// ==UserScript== // @author MikeDiehn // @id portal-route@MikeDiehn // @name Portal Route // @category Navigate // @version 0.2.0-dev // @namespace https://github.com/mdiehn/iitc-plugin-portal-route // @updateURL https://raw.githubusercontent.com/IITC-CE/Community-plugins/master/dist/MikeDiehn/portal-route.meta.js // @downloadURL https://raw.githubusercontent.com/IITC-CE/Community-plugins/master/dist/MikeDiehn/portal-route.user.js // @description Route planning through selected portals with segment drive times, stop-time accounting, and Google Maps export. // @include https://intel.ingress.com/* // @include http://intel.ingress.com/* // @match https://intel.ingress.com/* // @match http://intel.ingress.com/* // @grant none // ==/UserScript== function wrapper(plugin_info) { if (typeof window.plugin !== 'function') window.plugin = function() {}; window.plugin.portalRoute = window.plugin.portalRoute || {}; var pr = window.plugin.portalRoute; pr.CSS = ` .portal-route-mini-control { margin-top: 10px; } .portal-route-mini-control a { text-align: center; font-size: 12px; font-weight: bold; } .portal-route-dialog-content { width: 100%; max-width: 100%; overflow-x: visible; font-size: 11px; line-height: 1.25; } .portal-route-dialog-content button, .portal-route-dialog-content input { font-size: 11px; } .portal-route-mini-control .portal-route-mini-remove { color: #c00000; } .portal-route-dialog-content * { box-sizing: border-box; } .portal-route-body p { margin: 0 0 6px; } .portal-route-summary { margin-top: 4px; } .portal-route-setting { display: flex; align-items: center; gap: 5px; margin: 8px 0 8px; } .portal-route-setting input { width: 4.5em; } .portal-route-checkbox-setting { align-items: center; } .portal-route-checkbox-setting input { width: auto; } .portal-route-empty { margin: 8px 0 10px; } .portal-route-waypoints-list { display: block; width: 100%; max-width: 100%; margin: 6px 0 8px; overflow: visible; } .portal-route-waypoint-row { display: grid; grid-template-columns: 20px minmax(0, 1fr) max-content 42px 22px 22px 22px; gap: 2px; align-items: center; width: 100%; max-width: 100%; min-width: 0; overflow: visible; } .portal-route-waypoint-row + .portal-route-waypoint-row { margin-top: 2px; } .portal-route-waypoint-num, .portal-route-waypoint-name-cell, .portal-route-leg-cell, .portal-route-wait-cell, .portal-route-row-action { min-width: 0; border: 0 !important; outline: 0 !important; background: transparent !important; } .portal-route-waypoint-num { width: 20px; text-align: center; } .portal-route-waypoint-name-cell { overflow: hidden; } .portal-route-leg-cell { min-width: max-content; padding-right: 14px; text-align: right; white-space: nowrap; overflow: visible; } .portal-route-wait-cell { width: 42px; text-align: center; } .portal-route-row-action { width: 22px; text-align: center; overflow: visible; } .portal-route-waypoint-name { display: block; width: 100%; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; padding: 0 !important; margin: 0 !important; border: 0 !important; outline: 0 !important; box-shadow: none !important; background: transparent !important; color: inherit !important; text-align: left; font-weight: bold; cursor: pointer; appearance: none; -webkit-appearance: none; } .portal-route-waypoint-name:hover, .portal-route-waypoint-name:focus, .portal-route-waypoint-name:active { border: 0 !important; outline: 0 !important; box-shadow: none !important; background: transparent !important; color: inherit !important; } .portal-route-wait-input { width: 42px; padding: 1px 2px; } .portal-route-row-button { width: 22px !important; min-width: 22px !important; max-width: 22px !important; height: 20px; min-height: 20px; padding: 0 !important; border: 0 !important; background: transparent !important; color: inherit !important; text-align: center; line-height: 20px; font-size: 14px !important; font-weight: bold !important; } .portal-route-row-button:disabled { opacity: 0.35; } .portal-route-remove-stop-button { color: #ff8080 !important; } .portal-route-stop-num, .portal-route-stop-label span { display: inline-flex; align-items: center; justify-content: center; width: 16px; min-width: 16px; height: 16px; min-height: 16px; padding: 0; border-radius: 50%; background: #ffd800; color: #111; font-weight: bold; font-size: 10px; line-height: 16px; } button.portal-route-stop-num, button.portal-route-waypoint-badge { width: 16px !important; min-width: 16px !important; height: 16px !important; min-height: 16px !important; padding: 0 !important; border: 0 !important; border-radius: 50% !important; background: #ffd800 !important; color: #111 !important; cursor: pointer; line-height: 16px !important; } .portal-route-leg { display: block; width: max-content; overflow: visible; text-overflow: clip; color: inherit; opacity: 1; font: inherit; font-weight: bold; } .portal-route-leg-stale, .portal-route-leg-empty { opacity: 0.45; } .portal-route-stale { margin-top: 4px; opacity: 0.85; font-size: 10px; font-style: italic; } .portal-route-actions { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; } .portal-route-footer-actions { justify-content: flex-end; border-top: 1px solid rgba(255, 255, 255, 0.25); margin-top: 10px; padding-top: 7px; } .portal-route-bottom-summary { margin-top: 8px; opacity: 0.9; } .portal-route-version { margin-top: 6px; opacity: 0.7; font-size: 10px; text-align: right; } .portal-route-totals { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 8px; } .portal-route-totals div { padding: 5px; background: rgba(0, 0, 0, 0.18); border: 1px solid rgba(255, 255, 255, 0.18); } .portal-route-totals span, .portal-route-totals strong { display: block; } .portal-route-message { display: none; margin-top: 8px; padding: 7px; border: 1px solid #ffd800; background: rgba(0, 0, 0, 0.22); } .portal-route-message-visible { display: block; } .portal-route-busy { opacity: 0.82; } .portal-route-stop-tooltip, .portal-route-stop-tooltip * { pointer-events: none; } .portal-route-stop-label { border: 0; background: transparent; } .portal-route-stop-label span { box-shadow: 0 1px 3px rgba(0, 0, 0, 0.65); } .portal-route-segment-time-label { border: 0; background: transparent; pointer-events: none; } .portal-route-segment-time-label span { display: inline-block; padding: 2px 5px; border-radius: 10px; background: rgba(0, 0, 0, 0.72); color: #fff; font-size: 10px; font-weight: bold; line-height: 1.2; white-space: nowrap; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.65); } .portal-route-stop-tooltip { font-size: 11px; } .portal-route-portal-action { margin-top: 8px; } .ui-dialog.portal-route-dialog { max-width: calc(100vw - 20px) !important; } .ui-dialog.portal-route-dialog .ui-dialog-content { box-sizing: border-box !important; overflow-x: visible !important; } .portal-route-waypoints-list, .portal-route-waypoint-row, .portal-route-waypoint-row > div, .portal-route-waypoint-name-cell, .portal-route-waypoint-name-cell * { border-color: transparent !important; } .portal-route-waypoint-name, button.portal-route-waypoint-name, .ui-dialog .portal-route-waypoint-name, .ui-dialog button.portal-route-waypoint-name { border: none !important; border-width: 0 !important; outline: none !important; box-shadow: none !important; background: transparent !important; background-image: none !important; } @media (max-width: 640px) { .ui-dialog.portal-route-dialog { position: fixed !important; left: 8px !important; right: 8px !important; top: 50% !important; bottom: auto !important; width: auto !important; max-width: calc(100vw - 16px) !important; max-height: calc(100dvh - 24px) !important; transform: translateY(-50%) !important; } .ui-dialog.portal-route-dialog .ui-dialog-content { width: auto !important; max-height: calc(100dvh - 90px) !important; overflow-y: auto !important; overflow-x: visible !important; padding-left: 8px !important; padding-right: 8px !important; padding-bottom: 8px !important; } .portal-route-waypoint-row { grid-template-columns: 18px minmax(0, 1fr) max-content 38px 20px 20px 20px; gap: 1px; } .portal-route-waypoint-num { width: 18px; } .portal-route-leg-cell { padding-right: 9px; } .portal-route-wait-cell { width: 38px; } .portal-route-wait-input { width: 38px; } .portal-route-row-action { width: 20px; } .portal-route-row-button { width: 20px !important; min-width: 20px !important; max-width: 20px !important; } } `; pr.ID = 'portal-route'; pr.NAME = 'Portal Route'; pr.VERSION = '0.2.0-dev'; pr.SHOW_VERSION_IN_PANEL = true; pr.DOM_IDS = { css: 'iitc-plugin-portal-route-css', dialog: 'iitc-plugin-portal-route-dialog', dialogContent: 'iitc-plugin-portal-route-dialog-content', miniControl: 'iitc-plugin-portal-route-mini-control', toolboxLink: 'iitc-plugin-portal-route-toolbox-link' }; pr.STORAGE_KEYS = { stops: 'iitc-portal-route-stops', settings: 'iitc-portal-route-settings', panelOpen: 'iitc-portal-route-panel-open', panelPosition: 'iitc-portal-route-panel-position', route: 'iitc-portal-route-route', routeDirty: 'iitc-portal-route-route-dirty' }; pr.DEFAULT_SETTINGS = { defaultStopMinutes: 5, includeReturnToStart: false, showSegmentTimesOnMap: false }; pr.state = { stops: [], route: null, routeDirty: false, settings: Object.assign({}, pr.DEFAULT_SETTINGS), layers: { labels: null, routeLine: null, segmentLabels: null }, panelOpen: false, panelView: 'main', miniControl: null }; pr.getEffectiveStopMinutes = function(stop) { if (stop && typeof stop.stopMinutes === 'number' && !Number.isNaN(stop.stopMinutes)) { return stop.stopMinutes; } return pr.state.settings.defaultStopMinutes; }; pr.loadState = function() { try { var rawSettings = localStorage.getItem(pr.STORAGE_KEYS.settings); if (rawSettings) { pr.state.settings = Object.assign({}, pr.DEFAULT_SETTINGS, JSON.parse(rawSettings)); } var rawStops = localStorage.getItem(pr.STORAGE_KEYS.stops); if (rawStops) { var stops = JSON.parse(rawStops); if (Array.isArray(stops)) pr.state.stops = stops; } var rawPanelOpen = localStorage.getItem(pr.STORAGE_KEYS.panelOpen); if (rawPanelOpen !== null) pr.state.panelOpen = rawPanelOpen === 'true'; var rawRoute = localStorage.getItem(pr.STORAGE_KEYS.route); if (rawRoute) { var route = JSON.parse(rawRoute); if (route && Array.isArray(route.legs)) pr.state.route = route; } var rawRouteDirty = localStorage.getItem(pr.STORAGE_KEYS.routeDirty); if (rawRouteDirty !== null) pr.state.routeDirty = rawRouteDirty === 'true'; } catch (e) { console.warn('Portal Route: failed to load saved state', e); } }; pr.saveSettings = function() { localStorage.setItem(pr.STORAGE_KEYS.settings, JSON.stringify(pr.state.settings)); }; pr.saveStops = function() { localStorage.setItem(pr.STORAGE_KEYS.stops, JSON.stringify(pr.state.stops)); }; pr.savePanelOpen = function() { localStorage.setItem(pr.STORAGE_KEYS.panelOpen, String(pr.state.panelOpen)); }; pr.saveRoute = function() { if (pr.state.route) { localStorage.setItem(pr.STORAGE_KEYS.route, JSON.stringify(pr.state.route)); } else { localStorage.removeItem(pr.STORAGE_KEYS.route); } localStorage.setItem(pr.STORAGE_KEYS.routeDirty, String(!!pr.state.routeDirty)); }; pr.clearSavedRoute = function() { localStorage.removeItem(pr.STORAGE_KEYS.route); localStorage.removeItem(pr.STORAGE_KEYS.routeDirty); }; pr.formatDuration = function(seconds) { seconds = Math.max(0, Math.round(seconds || 0)); var minutes = Math.round(seconds / 60); var hours = Math.floor(minutes / 60); var mins = minutes % 60; if (hours > 0 && mins > 0) return hours + ' hr ' + mins + ' min'; if (hours > 0) return hours + ' hr'; return minutes + ' min'; }; pr.formatDistance = function(meters) { meters = Math.max(0, Number(meters || 0)); var miles = meters / 1609.344; if (miles >= 10) return miles.toFixed(0) + ' mi'; return miles.toFixed(1) + ' mi'; }; pr.portalToStop = function(guid) { var portal = guid && window.portals && window.portals[guid]; if (!portal || !portal.getLatLng) return null; var latlng = portal.getLatLng(); var data = portal.options && portal.options.data ? portal.options.data : {}; return { guid: guid, title: data.title || data.name || guid, lat: latlng.lat, lng: latlng.lng }; }; pr.addSelectedPortal = function() { var guid = window.selectedPortal; var stop = pr.portalToStop(guid); if (!stop) { pr.showMessage('No selected portal found.'); return; } pr.addStop(stop); }; pr.injectPortalDetailsAction = function() { var container = document.querySelector('#portaldetails .linkdetails') || document.querySelector('#portaldetails'); if (!container || container.querySelector('.portal-route-add-link')) return; var link = document.createElement('a'); link.href = '#'; link.className = 'portal-route-add-link'; link.textContent = 'Add to Portal Route'; link.addEventListener('click', function(ev) { ev.preventDefault(); pr.addSelectedPortal(); }); var wrapper = document.createElement('div'); wrapper.className = 'portal-route-portal-action'; wrapper.appendChild(link); container.appendChild(wrapper); }; pr.markRouteStale = function(options) { options = options || {}; var hadRouteState = !!pr.state.route || !!pr.state.routeDirty; pr.state.routeDirty = hadRouteState; if (options.clearRoute) { pr.state.route = null; pr.clearRouteLine(); } else if (pr.state.route && pr.state.route.legs) { pr.state.route.totals = pr.calculateTotals(pr.state.route.legs); } pr.saveRoute(); }; pr.markRouteCurrent = function() { pr.state.routeDirty = false; pr.saveRoute(); }; pr.addStop = function(stop) { if (!stop || typeof stop.lat !== 'number' || typeof stop.lng !== 'number') return; if (stop.guid && pr.state.stops.some(function(existing) { return existing.guid === stop.guid; })) { pr.showMessage('Already in route: ' + stop.title); return; } pr.state.stops.push({ guid: stop.guid || null, title: stop.title || 'Unnamed portal', lat: stop.lat, lng: stop.lng, stopMinutes: null }); pr.markRouteStale({ clearRoute: true }); pr.saveStops(); pr.redrawLabels(); pr.renderPanel(); }; pr.removeStop = function(index) { if (index < 0 || index >= pr.state.stops.length) return; pr.state.stops.splice(index, 1); pr.markRouteStale({ clearRoute: true }); pr.saveStops(); pr.redrawLabels(); pr.renderPanel(); }; pr.clearStops = function() { pr.state.stops = []; pr.state.route = null; pr.state.routeDirty = false; pr.saveStops(); pr.saveRoute(); pr.clearRouteLine(); pr.redrawLabels(); pr.renderPanel(); }; pr.moveStop = function(fromIndex, toIndex) { if (fromIndex < 0 || fromIndex >= pr.state.stops.length) return; if (toIndex < 0 || toIndex >= pr.state.stops.length) return; if (fromIndex === toIndex) return; var item = pr.state.stops.splice(fromIndex, 1)[0]; pr.state.stops.splice(toIndex, 0, item); pr.markRouteStale({ clearRoute: true }); pr.saveStops(); pr.redrawLabels(); pr.renderPanel(); }; pr.setStopMinutes = function(index, minutes) { if (index < 0 || index >= pr.state.stops.length) return; if (typeof minutes !== 'number' || !isFinite(minutes) || minutes < 0) return; pr.state.stops[index].stopMinutes = Math.round(minutes); pr.markRouteStale(); pr.saveStops(); pr.renderPanel(); }; pr.parseDurationMinutes = function(text) { var match = String(text == null ? '' : text).trim().toLowerCase().match(/^(\d+(?:\.\d+)?)\s*([mhd]?)$/); if (!match) return null; var value = Number(match[1]); var unit = match[2] || 'm'; if (!isFinite(value) || value < 0) return null; if (unit === 'm') return Math.round(value); if (unit === 'h') return Math.round(value * 60); if (unit === 'd') return Math.round(value * 24 * 60); return null; }; pr.formatDurationInput = function(minutes) { minutes = Math.max(0, Math.round(Number(minutes || 0))); if (minutes && minutes % 1440 === 0) return (minutes / 1440) + 'd'; if (minutes && minutes % 60 === 0) return (minutes / 60) + 'h'; return minutes + 'm'; }; pr.selectStopPortal = function(index, center) { var stop = pr.state.stops[index]; if (!stop || !stop.guid) return; var portal = window.portals && window.portals[stop.guid]; if (center && portal && portal.getLatLng && window.map) { window.map.setView(portal.getLatLng(), window.map.getZoom()); } if (typeof window.renderPortalDetails === 'function') { window.renderPortalDetails(stop.guid); } else { window.selectedPortal = stop.guid; } pr.renderMiniControl(); }; pr.calculateTotals = function(legs) { var driveSeconds = 0; var distanceMeters = 0; legs.forEach(function(leg) { driveSeconds += leg.durationSeconds || 0; distanceMeters += leg.distanceMeters || 0; }); var stopSeconds = pr.state.stops.reduce(function(sum, stop) { return sum + pr.getEffectiveStopMinutes(stop) * 60; }, 0); return { driveSeconds: driveSeconds, stopSeconds: stopSeconds, tripSeconds: driveSeconds + stopSeconds, distanceMeters: distanceMeters }; }; pr.getGoogleLatLng = function(stop) { return new google.maps.LatLng(stop.lat, stop.lng); }; pr.calculateRoute = function() { if (pr.state.stops.length < 2) { pr.showMessage('Add at least two portals to calculate a route.'); return; } if (!window.google || !google.maps || !google.maps.DirectionsService) { pr.showMessage('Google Maps DirectionsService is not available in this IITC session.'); return; } var stops = pr.state.stops; var origin = stops[0]; var destination = stops[stops.length - 1]; var waypoints = stops.slice(1, -1).map(function(stop) { return { location: pr.getGoogleLatLng(stop), stopover: true }; }); var service = new google.maps.DirectionsService(); var request = { origin: pr.getGoogleLatLng(origin), destination: pr.getGoogleLatLng(destination), waypoints: waypoints, optimizeWaypoints: false, travelMode: google.maps.TravelMode.DRIVING }; pr.setBusy(true); service.route(request, function(result, status) { pr.setBusy(false); if (status !== google.maps.DirectionsStatus.OK) { pr.showMessage('Route calculation failed: ' + status); return; } var route = result.routes && result.routes[0]; if (!route) { pr.showMessage('Route calculation returned no route.'); return; } var legs = route.legs.map(function(leg, index) { var fromStop = stops[index]; var toStop = stops[index + 1]; var legPath = []; if (leg.steps) { leg.steps.forEach(function(step) { if (step.path) { step.path.forEach(function(point) { legPath.push({ lat: point.lat(), lng: point.lng() }); }); } }); } return { fromIndex: index, toIndex: index + 1, fromLabel: fromStop ? fromStop.title : 'Stop ' + (index + 1), toLabel: toStop ? toStop.title : 'Stop ' + (index + 2), distanceMeters: leg.distance ? leg.distance.value : 0, durationSeconds: leg.duration ? leg.duration.value : 0, distanceText: leg.distance ? leg.distance.text : '', durationText: leg.duration ? leg.duration.text : '', path: legPath }; }); var path = []; if (route.overview_path) { path = route.overview_path.map(function(point) { return L.latLng(point.lat(), point.lng()); }); } pr.state.route = { legs: legs, totals: pr.calculateTotals(legs), path: path.map(function(point) { return { lat: point.lat, lng: point.lng }; }) }; pr.markRouteCurrent(); pr.drawRoutePath(path); pr.renderPanel(); }); }; pr.routeOverlayTarget = function() { if (pr.layerGroup) return pr.layerGroup; return window.map; }; pr.ensureLayers = function() { var target = pr.routeOverlayTarget(); if (!pr.state.layers.labels) { pr.state.layers.labels = L.layerGroup().addTo(target); } if (!pr.state.layers.segmentLabels) { pr.state.layers.segmentLabels = L.layerGroup().addTo(target); } }; pr.clearLabels = function() { if (pr.state.layers.labels) { pr.state.layers.labels.clearLayers(); } }; pr.clearSegmentTimeLabels = function() { if (pr.state.layers.segmentLabels) { pr.state.layers.segmentLabels.clearLayers(); } }; pr.clearRouteLine = function() { if (pr.state.layers.routeLine) { var owner = pr.routeOverlayTarget(); if (owner && owner.hasLayer && owner.hasLayer(pr.state.layers.routeLine)) { owner.removeLayer(pr.state.layers.routeLine); } else if (window.map && window.map.hasLayer && window.map.hasLayer(pr.state.layers.routeLine)) { window.map.removeLayer(pr.state.layers.routeLine); } pr.state.layers.routeLine = null; } pr.clearSegmentTimeLabels(); }; pr.redrawLabels = function() { if (!window.map || !window.L) return; pr.ensureLayers(); pr.clearLabels(); pr.state.stops.forEach(function(stop, index) { var icon = L.divIcon({ className: 'portal-route-stop-label', html: '' + (index + 1) + '', iconSize: [18, 18], iconAnchor: [0, 24] }); var marker = L.marker([stop.lat, stop.lng], { icon: icon, interactive: true, keyboard: false, bubblingMouseEvents: false }); marker.bindTooltip((index + 1) + '. ' + stop.title, { direction: 'right', offset: [16, -10], opacity: 0.9, interactive: false, className: 'portal-route-stop-tooltip' }); marker.addTo(pr.state.layers.labels); }); }; pr.toLatLng = function(point) { if (!point) return null; if (point.lat && typeof point.lat === 'function' && point.lng && typeof point.lng === 'function') { return L.latLng(point.lat(), point.lng()); } if (typeof point.lat === 'number' && typeof point.lng === 'number') { return L.latLng(point.lat, point.lng); } return null; }; pr.getPathMidpoint = function(path) { if (!path || path.length === 0) return null; var points = path.map(pr.toLatLng).filter(Boolean); if (points.length === 0) return null; if (points.length === 1) return points[0]; var total = 0; for (var i = 1; i < points.length; i++) { total += points[i - 1].distanceTo(points[i]); } if (!total) return points[Math.floor(points.length / 2)]; var halfway = total / 2; var walked = 0; for (var j = 1; j < points.length; j++) { var from = points[j - 1]; var to = points[j]; var segment = from.distanceTo(to); if (walked + segment >= halfway) { var ratio = segment ? (halfway - walked) / segment : 0; return L.latLng( from.lat + (to.lat - from.lat) * ratio, from.lng + (to.lng - from.lng) * ratio ); } walked += segment; } return points[Math.floor(points.length / 2)]; }; pr.getLegLabelLatLng = function(leg) { var midpoint = pr.getPathMidpoint(leg && leg.path); if (midpoint) return midpoint; var fromStop = pr.state.stops[leg.fromIndex]; var toStop = pr.state.stops[leg.toIndex]; if (!fromStop || !toStop) return null; return L.latLng( (fromStop.lat + toStop.lat) / 2, (fromStop.lng + toStop.lng) / 2 ); }; pr.redrawSegmentTimeLabels = function() { if (!window.map || !window.L) return; pr.ensureLayers(); pr.clearSegmentTimeLabels(); if (!pr.state.settings.showSegmentTimesOnMap) return; if (!pr.state.route || !Array.isArray(pr.state.route.legs)) return; pr.state.route.legs.forEach(function(leg) { var latLng = pr.getLegLabelLatLng(leg); if (!latLng) return; var text = leg.durationText || pr.formatDuration(leg.durationSeconds); var icon = L.divIcon({ className: 'portal-route-segment-time-label', html: '' + pr.escapeHtml(text) + '', iconSize: null, iconAnchor: [16, 8] }); L.marker(latLng, { icon: icon, interactive: false, keyboard: false, bubblingMouseEvents: false }).addTo(pr.state.layers.segmentLabels); }); }; pr.drawRoutePath = function(path, options) { options = options || {}; pr.clearRouteLine(); if (!path || path.length < 2) return; pr.state.layers.routeLine = L.polyline(path, { color: '#ff7f00', weight: 5, opacity: 0.8, interactive: false, bubblingMouseEvents: false }).addTo(pr.routeOverlayTarget()); pr.redrawSegmentTimeLabels(); if (options.fitBounds === false) return; try { window.map.fitBounds(pr.state.layers.routeLine.getBounds(), { padding: [30, 30] }); } catch (e) { console.warn('Portal Route: unable to fit route bounds', e); } }; pr.redrawRouteLine = function() { if (!window.map || !window.L) return; if (!pr.state.route || !Array.isArray(pr.state.route.path)) return; var path = pr.state.route.path.map(function(point) { return L.latLng(point.lat, point.lng); }); pr.drawRoutePath(path, { fitBounds: false }); }; pr.escapeHtml = function(value) { return String(value == null ? '' : value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }; pr.renderEmptyHelp = function() { return '
There are no waypoints defined.
Select a portal and add it from the Portal Route control.
| # | Portal | Wait | Next leg |
|---|