/* app-render.js - top-level rendering coordinator for the Bergamo FSR demo */ (function () { 'use strict'; var FSR = window.FSR = window.FSR || {}; var utils = FSR.utils; FSR.createRenderer = function (options) { var SF = options.SF; var routeTimeline = null; var mapRenderer = FSR.createMapRenderer(options); var routeListRenderer = FSR.createRouteListRenderer(options); return { buildAnalysisBody: buildAnalysisBody, destroy: destroy, renderAll: renderAll, renderApiGuide: renderApiGuide, }; function renderAll(plan, routeGeometry) { renderSummary(plan); mapRenderer.renderMap(plan, routeGeometry); routeListRenderer.renderRouteCards(plan, routeGeometry); renderTimeline(plan); renderTables(plan); } function renderSummary(plan) { var routes = plan.technician_routes || []; var visits = plan.service_visits || []; var assigned = utils.assignedVisitSet(routes); var assignedCount = Object.keys(assigned).length; var routeMetrics = routes.map(function (route) { return utils.routeStats(plan, route); }); var travelMinutes = routeMetrics.reduce(function (sum, stats) { return sum + stats.travelMinutes; }, 0); var serviceMinutes = routeMetrics.reduce(function (sum, stats) { return sum + stats.serviceMinutes; }, 0); var routeIssues = routeMetrics.reduce(function (sum, stats) { return sum + stats.unreachable + stats.missingSkills + stats.missingParts + stats.lateMinutes + stats.overtimeMinutes; }, 0); options.summaryContainer.innerHTML = ''; options.summaryContainer.appendChild(SF.createTable({ columns: ['Dataset', 'Visits', 'Assigned', 'Technicians', 'Travel', 'Service', 'Hard issues', 'Score'], rows: [[ options.getSelectedDemoId() || options.getDemoCatalog().defaultId || 'STANDARD', String(visits.length), String(assignedCount), String(routes.length), utils.formatDuration(travelMinutes), utils.formatDuration(serviceMinutes), String(routeIssues + Math.max(0, visits.length - assignedCount)), String(plan.score || 'unsolved'), ]], })); } function renderTimeline(plan) { var timelineConfig = buildTimelineConfig(plan); options.timelineContainer.innerHTML = ''; options.timelineContainer.appendChild(SF.el('div', { className: 'sf-section' }, SF.createTable({ columns: ['Route lanes', 'Window', 'Source'], rows: [[String((plan.technician_routes || []).length), '08:00-18:00', 'Latest SolverForge solution payload']], }))); if (!routeTimeline) routeTimeline = SF.rail.createTimeline(timelineConfig); else routeTimeline.setModel(timelineConfig.model); options.timelineContainer.appendChild(routeTimeline.el); } function buildTimelineConfig(plan) { return { label: 'Technician', labelWidth: 280, title: 'Bergamo Field Service Routes', subtitle: 'Ordered service visits per technician', model: { axis: buildDayAxis(), lanes: (plan.technician_routes || []).map(routeLane(plan)), }, }; } function routeLane(plan) { return function (route, routeIdx) { var items = utils.routeSchedule(plan, route).map(function (entry, entryIdx) { return { id: 'route-' + routeIdx + '-visit-' + entryIdx, startMinute: entry.start, endMinute: entry.end, label: entry.visit.customer || entry.visit.name || entry.visit.id, meta: utils.timeLabel(entry.start) + '-' + utils.timeLabel(entry.end), tone: utils.toneForRoute(routeIdx), }; }); var stats = utils.routeStats(plan, route); return { id: route.id || ('route-' + routeIdx), label: route.technician_name || route.id || ('Technician ' + (routeIdx + 1)), mode: 'detailed', badges: utils.routeBadges(stats), stats: [ { label: 'Stops', value: (route.visits || []).length }, { label: 'Travel', value: utils.formatDuration(stats.travelMinutes) }, { label: 'Service', value: utils.formatDuration(stats.serviceMinutes) }, ], items: items, }; }; } function buildDayAxis() { var ticks = []; for (var minute = options.dayStart; minute <= options.dayEnd; minute += 60) { ticks.push({ id: 'tick-' + minute, minute: minute, label: utils.timeLabel(minute) }); } return { startMinute: options.dayStart, endMinute: options.dayEnd, days: [{ id: 'bergamo-day', label: 'Service day', subLabel: '08:00-18:00', startMinute: options.dayStart, endMinute: options.dayEnd }], ticks: ticks, initialViewport: { startMinute: options.dayStart, endMinute: options.dayEnd }, }; } function renderTables(plan) { options.tablesContainer.innerHTML = ''; ['technician_routes', 'service_visits', 'locations'].forEach(function (key) { var rows = plan[key] || []; if (!rows.length) return; var columns = Object.keys(rows[0]).filter(function (column) { return column !== 'score'; }); var values = rows.map(function (row) { return columns.map(function (column) { var value = row[column]; if (value == null) return '-'; if (Array.isArray(value)) return value.join(', '); return String(value); }); }); var section = SF.el('div', { className: 'sf-section' }); section.appendChild(SF.el('h3', null, utils.title(key))); section.appendChild(SF.createTable({ columns: columns, rows: values })); options.tablesContainer.appendChild(section); }); } function renderApiGuide() { var catalog = options.getDemoCatalog(); var demoId = options.getSelectedDemoId() || catalog.defaultId; options.apiGuideContainer.innerHTML = ''; options.apiGuideContainer.appendChild(SF.createApiGuide({ endpoints: [ { method: 'GET', path: '/demo-data', description: 'Discover demo datasets', curl: utils.buildCurl('GET', '/demo-data') }, { method: 'GET', path: '/demo-data/' + (demoId || '{id}'), description: 'Fetch Bergamo seed data', curl: utils.buildCurl('GET', '/demo-data/' + (demoId || 'STANDARD')) }, { method: 'POST', path: '/jobs', description: 'Create a retained solve job', curl: utils.buildCurl('POST', '/jobs', true) }, { method: 'GET', path: '/jobs/{id}/events', description: 'Stream typed SolverForge lifecycle events', curl: utils.buildCurl('GET', '/jobs/{id}/events') }, { method: 'GET', path: '/jobs/{id}/snapshot', description: 'Fetch latest route snapshot', curl: utils.buildCurl('GET', '/jobs/{id}/snapshot') }, { method: 'GET', path: '/jobs/{id}/routes?snapshot_revision={n}', description: 'Fetch encoded route geometry for a retained snapshot', curl: utils.buildCurl('GET', '/jobs/{id}/routes?snapshot_revision={n}') }, { method: 'GET', path: '/jobs/{id}/analysis', description: 'Analyze the latest retained score', curl: utils.buildCurl('GET', '/jobs/{id}/analysis') }, ], })); } function buildAnalysisBody(analysis) { var container = SF.el('div', { className: 'fsr-analysis-body' }); if (!analysis || !analysis.constraints) { container.appendChild(SF.el('p', null, 'No analysis available.')); return container; } container.appendChild(SF.el('p', null, SF.el('strong', null, 'Score: '), String(analysis.score))); container.appendChild(SF.createTable({ columns: ['Constraint', 'Weight', 'Score', 'Matches'], rows: analysis.constraints.map(function (constraint) { return [ constraint.name, constraint.weight, constraint.score, String(constraint.matchCount || 0), ]; }), })); return container; } function destroy() { if (routeTimeline) routeTimeline.destroy(); } }; })();