// ==UserScript== // @author robertclemo // @id route-planner@robertclemo // @name Ops Route Planner // @category Tweaks // @version 0.1.1 // @namespace https://github.com/robertclemo/route-planner-iitc // @description Draw a shape with Draw Tools, then compute an optimized round-trip driving route through every portal inside it. // @updateURL https://raw.githubusercontent.com/IITC-CE/Community-plugins/master/dist/robertclemo/route-planner.meta.js // @downloadURL https://raw.githubusercontent.com/IITC-CE/Community-plugins/master/dist/robertclemo/route-planner.user.js // @homepageURL https://github.com/robertclemo/route-planner-iitc // @depends draw-tools@breunigs // @issueTracker https://github.com/robertclemo/route-planner-iitc/issues // @include https://intel.ingress.com/* // @match https://intel.ingress.com/* // @grant none // ==/UserScript== function wrapper(plugin_info) { if (typeof window.plugin !== 'function') window.plugin = function () {}; // ensure plugin framework is there if (typeof window.plugin.routePlanner !== 'undefined') return; var self = {}; window.plugin.routePlanner = self; self.routeLayer = null; // L.LayerGroup holding the polyline + numbered markers self.lastRoute = null; // array of portal objects in visiting order // ---------- geometry helpers ---------- // haversine distance in km function dist(a, b) { var R = 6371; var dLat = (b.lat - a.lat) * Math.PI / 180; var dLng = (b.lng - a.lng) * Math.PI / 180; var lat1 = a.lat * Math.PI / 180; var lat2 = b.lat * Math.PI / 180; var h = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.sin(dLng / 2) * Math.sin(dLng / 2) * Math.cos(lat1) * Math.cos(lat2); return 2 * R * Math.asin(Math.sqrt(h)); } // ray-casting point-in-polygon; pts is array of {lat,lng} function pointInPolygon(pt, pts) { var inside = false; for (var i = 0, j = pts.length - 1; i < pts.length; j = i++) { var xi = pts[i].lng, yi = pts[i].lat; var xj = pts[j].lng, yj = pts[j].lat; var intersect = ((yi > pt.lat) !== (yj > pt.lat)) && (pt.lng < (xj - xi) * (pt.lat - yi) / (yj - yi) + xi); if (intersect) inside = !inside; } return inside; } function flattenLatLngs(ll) { // Leaflet polygons can return nested arrays (rings / multi-polygons) var out = []; (function walk(arr) { if (!arr.length) return; if (typeof arr[0].lat === 'number') { arr.forEach(function (p) { out.push({ lat: p.lat, lng: p.lng }); }); } else { arr.forEach(walk); } })(ll); return out; } // ---------- portal selection from Draw Tools shapes ---------- function getSelectedPortals() { if (!window.plugin.drawTools || !window.plugin.drawTools.drawnItems) { alert('Draw Tools plugin is required. Enable it, draw a polygon or circle around the op area, then run this again.'); return null; } var shapes = []; window.plugin.drawTools.drawnItems.eachLayer(function (layer) { if (layer instanceof L.Circle) { shapes.push({ type: 'circle', center: layer.getLatLng(), radius: layer.getRadius() }); } else if (layer.getLatLngs) { shapes.push({ type: 'polygon', points: flattenLatLngs(layer.getLatLngs()) }); } }); if (shapes.length === 0) { alert('No drawn shapes found. Use Draw Tools to draw a polygon or circle around the portals you want to route, then run this again.'); return null; } var selected = []; var seen = {}; for (var guid in window.portals) { var p = window.portals[guid]; var ll = p.getLatLng(); var pt = { lat: ll.lat, lng: ll.lng }; var hit = shapes.some(function (s) { if (s.type === 'circle') { return dist(pt, { lat: s.center.lat, lng: s.center.lng }) * 1000 <= s.radius; } return pointInPolygon(pt, s.points); }); if (hit && !seen[guid]) { seen[guid] = true; var title = (p.options && p.options.data && p.options.data.title) || 'Unknown portal'; selected.push({ guid: guid, lat: ll.lat, lng: ll.lng, title: title }); } } return selected; } // ---------- TSP heuristic: nearest neighbor + 2-opt, round trip ---------- function buildDistanceMatrix(pts) { var n = pts.length; var m = []; for (var i = 0; i < n; i++) { m.push([]); for (var j = 0; j < n; j++) { m[i][j] = (i === j) ? 0 : dist(pts[i], pts[j]); } } return m; } function nearestNeighborTour(m) { var n = m.length; var visited = new Array(n).fill(false); var tour = [0]; visited[0] = true; for (var k = 1; k < n; k++) { var last = tour[tour.length - 1]; var best = -1, bestD = Infinity; for (var j = 0; j < n; j++) { if (!visited[j] && m[last][j] < bestD) { bestD = m[last][j]; best = j; } } tour.push(best); visited[best] = true; } return tour; } function tourLength(tour, m) { var total = 0; for (var i = 0; i < tour.length; i++) { total += m[tour[i]][tour[(i + 1) % tour.length]]; } return total; } function twoOpt(tour, m, timeBudgetMs) { var n = tour.length; var improved = true; var start = Date.now(); while (improved) { improved = false; for (var i = 0; i < n - 1; i++) { for (var k = i + 1; k < n; k++) { if (Date.now() - start > timeBudgetMs) return tour; var a = tour[i === 0 ? n - 1 : i - 1], b = tour[i]; var c = tour[k], d = tour[(k + 1) % n]; if (a === c || b === d) continue; var before = m[a][b] + m[c][d]; var after = m[a][c] + m[b][d]; if (after + 1e-9 < before) { var seg = tour.slice(i, k + 1).reverse(); for (var s = 0; s < seg.length; s++) tour[i + s] = seg[s]; improved = true; } } } } return tour; } function solveRoute(pts) { var m = buildDistanceMatrix(pts); var tour = nearestNeighborTour(m); tour = twoOpt(tour, m, 4000); // 4s budget, fine for 50-150 portals return { order: tour.map(function (i) { return pts[i]; }), km: tourLength(tour, m) }; } // ---------- rendering ---------- function clearRoute() { if (self.routeLayer) { window.map.removeLayer(self.routeLayer); self.routeLayer = null; } } function drawRoute(order) { clearRoute(); self.routeLayer = L.layerGroup(); var latlngs = order.map(function (p) { return [p.lat, p.lng]; }); latlngs.push(latlngs[0]); // close the loop L.polyline(latlngs, { color: '#ff6600', weight: 3, opacity: 0.85, dashArray: '6,6' }) .addTo(self.routeLayer); order.forEach(function (p, idx) { L.marker([p.lat, p.lng], { icon: L.divIcon({ className: 'route-planner-stop', html: '
' + order.length + ' portals — approx round-trip driving order distance: ' + result.km.toFixed(1) + ' km (straight-line estimate, not actual road distance).
' + '' + linksHtml + '
' + (links.length > 1 ? 'Google Maps limits waypoints per link, so the route is split into ' + links.length + ' legs. Open each in order.
' : '') + '