// ==UserScript== // @author Tarsi210 // @id herringbone-planner@Tarsi210 // @name Herringbone Planner // @category Layer // @version 0.4.4 // @description Finds and draws an optimal visible-portal spine for herringbone multilayer planning, with CSV export. // @downloadURL https://raw.githubusercontent.com/IITC-CE/Community-plugins/master/dist/Tarsi210/herringbone-planner.user.js // @updateURL https://raw.githubusercontent.com/IITC-CE/Community-plugins/master/dist/Tarsi210/herringbone-planner.meta.js // @homepageURL https://github.com/Tarsi210/iitc-herringbone-planner // @issueTracker https://github.com/Tarsi210/iitc-herringbone-planner/issues // @include http://intel.ingress.com/* // @include https://intel.ingress.com/* // @include http://intel-x.ingress.com/* // @include https://intel-x.ingress.com/* // @include http://www.ingress.com/intel* // @include https://www.ingress.com/intel* // @match http://intel.ingress.com/* // @match https://intel.ingress.com/* // @match http://intel-x.ingress.com/* // @match https://intel-x.ingress.com/* // @match http://www.ingress.com/intel* // @match https://www.ingress.com/intel* // @grant unsafeWindow // ==/UserScript== function wrapper(plugin_info, w) { 'use strict'; w = w || window; var $ = w.$; var doc = w.document || document; var plugin = {}; var ns = 'herringboneSpinePlanner'; if (!w.plugin) w.plugin = {}; w.plugin[ns] = plugin; var STORAGE_KEY = 'plugin-herringbone-spine-planner-settings'; var DEFAULT_SETTINGS = { maxDeviationMeters: 35, maxSegmentAngleDegrees: 25, minPortals: 4, maxSpinePortals: 0, maxVisiblePortals: 180, maxExecutionSeconds: 30, autoRefresh: true, showCorridor: true, showRejectedPortals: true, showAnchorLinks: true, anchorA: null, anchorB: null }; plugin.settings = loadSettings(); plugin.lastResult = null; plugin.layerGroup = null; plugin.refreshTimer = null; plugin.computeTimer = null; plugin.refreshSerial = 0; plugin.control = null; function setup() { try { plugin.isSetup = true; w.console.log('[Herringbone Planner] setup starting'); if (!$) throw new Error('jQuery is not available in IITC page context yet.'); injectCss(); plugin.layerGroup = new w.L.LayerGroup(); plugin.layerGroup.addTo(w.map); if (w.layerChooser && w.layerChooser.addOverlay) { w.layerChooser.addOverlay(plugin.layerGroup, 'Herringbone Planner'); } addControls(); addToolboxLink(); bindMapEvents(); addPortalDetailsButtons(); plugin.refresh(); w.console.log('[Herringbone Planner] setup complete'); } catch (err) { w.console.error('[Herringbone Planner] setup failed', err); w.setTimeout(function () { w.alert('Herringbone Planner failed during setup. Open the browser console for details: ' + err.message); }, 100); } } plugin.refresh = function () { if (!w.map || !w.portals) return; var serial = ++plugin.refreshSerial; var portals = getVisiblePortals(); if (portals.length > plugin.settings.maxVisiblePortals) { plugin.lastResult = null; drawResult(null); updateStatus(null, portals.length, true); return; } if (plugin.computeTimer) w.clearTimeout(plugin.computeTimer); updateProcessingStatus(portals.length); setDialogStatus('Processing herringbone plan...'); plugin.computeTimer = w.setTimeout(function () { plugin.computeTimer = null; if (serial !== plugin.refreshSerial) return; var startedAt = Date.now(); var deadlineMs = startedAt + plugin.settings.maxExecutionSeconds * 1000; var search = findBestSpine(portals, plugin.settings, deadlineMs); var result = search.result; if (result) { result.timedOut = search.timedOut; result.elapsedMs = Date.now() - startedAt; } rememberKeyCount(result); plugin.lastResult = result; drawResult(result); updateStatus(result, portals.length, false, search.timedOut); setDialogStatus(formatResultSummary(result, search.timedOut)); updateDialogAnchorLabels(); }, 25); }; plugin.scheduleRefresh = function () { if (!plugin.settings.autoRefresh) return; if (plugin.refreshTimer) w.clearTimeout(plugin.refreshTimer); plugin.refreshTimer = w.setTimeout(function () { plugin.refreshTimer = null; plugin.refresh(); }, 350); }; plugin.openDialog = function () { var html = [ '
', '', '', '', '', '', '', '', '', '
Corridor is the allowed max-deviation band around the selected start/end center line.
', '', '', '
', '
Anchor A: ' + escapeHtml(formatAnchorLabel(plugin.settings.anchorA, 'A')) + '
', '
Anchor B: ' + escapeHtml(formatAnchorLabel(plugin.settings.anchorB, 'B')) + '
', '
', '
', '', '', '', '
', '
' + formatResultSummary(plugin.lastResult) + '
', '
', '', '', '
', '
' ].join(''); w.dialog({ html: html, title: 'Herringbone Planner', id: 'herringbone-spine-planner-dialog', width: 380 }); $('#hbsp-save-refresh').on('click', function () { plugin.settings.maxDeviationMeters = clampNumber($('#hbsp-max-deviation').val(), 1, 10000, DEFAULT_SETTINGS.maxDeviationMeters); plugin.settings.maxSegmentAngleDegrees = clampNumber($('#hbsp-max-angle').val(), 1, 89, DEFAULT_SETTINGS.maxSegmentAngleDegrees); plugin.settings.minPortals = clampNumber($('#hbsp-min-portals').val(), 2, 10000, DEFAULT_SETTINGS.minPortals); plugin.settings.maxSpinePortals = clampNumber($('#hbsp-max-spine-portals').val(), 0, 10000, DEFAULT_SETTINGS.maxSpinePortals); plugin.settings.maxVisiblePortals = clampNumber($('#hbsp-max-visible').val(), 10, 1000, DEFAULT_SETTINGS.maxVisiblePortals); plugin.settings.maxExecutionSeconds = clampNumber($('#hbsp-max-execution').val(), 1, 120, DEFAULT_SETTINGS.maxExecutionSeconds); plugin.settings.autoRefresh = $('#hbsp-auto-refresh').prop('checked'); plugin.settings.showCorridor = $('#hbsp-show-corridor').prop('checked'); plugin.settings.showRejectedPortals = $('#hbsp-show-rejected').prop('checked'); plugin.settings.showAnchorLinks = $('#hbsp-show-anchor-links').prop('checked'); saveSettings(); plugin.refresh(); setDialogStatus('Processing herringbone plan...'); }); $('#hbsp-export-csv').on('click', function () { plugin.exportCsv(); }); $('#hbsp-set-anchor-a').on('click', function () { setAnchorFromSelected('A'); plugin.openDialog(); }); $('#hbsp-set-anchor-b').on('click', function () { setAnchorFromSelected('B'); plugin.openDialog(); }); $('#hbsp-clear-anchors').on('click', function () { plugin.settings.anchorA = null; plugin.settings.anchorB = null; saveSettings(); plugin.refresh(); plugin.openDialog(); }); }; plugin.exportCsv = function () { if (!plugin.lastResult || !plugin.lastResult.sequence || !plugin.lastResult.sequence.length) { w.alert('No herringbone plan found to export.'); return; } var lines = []; lines.push([ 'row_type', 'throw_order', 'name', 'lat', 'lng', 'keys_required', 'portal_link', 'notes', 'distance_from_previous_m' ].join(',')); var result = plugin.lastResult; var sequence = getOperationalSpineOrder(result); if (result.anchors) { var spineCount = result.sequence.length; lines.push(formatCsvRow([ 'anchor_a', '', result.anchors.a.name, result.anchors.a.lat.toFixed(7), result.anchors.a.lng.toFixed(7), spineCount + 1, portalLink(result.anchors.a), 'Needs one key for each spine portal, plus one extra because Anchor B throws the baseline link to Anchor A.', '' ])); lines.push(formatCsvRow([ 'anchor_b', '', result.anchors.b.name, result.anchors.b.lat.toFixed(7), result.anchors.b.lng.toFixed(7), spineCount, portalLink(result.anchors.b), 'Needs one key for each spine portal.', '' ])); lines.push(formatCsvRow([ 'baseline', 1, result.anchors.b.name + ' -> ' + result.anchors.a.name, '', '', '', '', '', 'Throw from Anchor B to Anchor A first.', '' ])); } sequence.forEach(function (portal, idx) { lines.push([ csvCell('spine'), result.anchors ? idx + 2 : idx + 1, csvCell(portal.name), portal.lat.toFixed(7), portal.lng.toFixed(7), '', csvCell(portalLink(portal)), csvCell(result.anchors ? 'From this portal, link to Anchor A and Anchor B.' : 'Spine portal.'), idx === 0 ? '' : distanceFromPreviousInOrder(sequence, idx).toFixed(1) ].join(',')); }); var csv = lines.join('\n'); var filename = makeCsvFilename(result); downloadText(filename, csv, 'text/csv'); }; function getOperationalSpineOrder(result) { var sequence = result.sequence.slice(); if (!result.anchors || sequence.length < 2) return sequence; var anchorMidpoint = { latLng: w.L.latLng( (result.anchors.a.lat + result.anchors.b.lat) / 2, (result.anchors.a.lng + result.anchors.b.lng) / 2 ) }; var firstDistance = distanceMeters(anchorMidpoint, sequence[0]); var lastDistance = distanceMeters(anchorMidpoint, sequence[sequence.length - 1]); if (lastDistance < firstDistance) sequence.reverse(); return sequence; } function distanceFromPreviousInOrder(sequence, idx) { if (idx <= 0) return 0; return distanceMeters(sequence[idx - 1], sequence[idx]); } function totalWalkDistance(result) { if (!result || !result.sequence || result.sequence.length < 2) return 0; var sequence = getOperationalSpineOrder(result); var total = 0; for (var i = 1; i < sequence.length; i++) { total += distanceFromPreviousInOrder(sequence, i); } return total; } function formatCsvRow(values) { return values.map(csvCell).join(','); } function portalLink(portal) { return 'https://intel.ingress.com/intel?pll=' + portal.lat.toFixed(6) + ',' + portal.lng.toFixed(6); } function makeCsvFilename(result) { var timestamp = new Date().toISOString().replace(/[:.]/g, '-'); if (result.anchors) { return 'Herringbone-' + sanitizeFilenamePart(result.anchors.a.name) + '-' + sanitizeFilenamePart(result.anchors.b.name) + '-' + timestamp + '.csv'; } return 'Herringbone-' + timestamp + '.csv'; } function sanitizeFilenamePart(value) { var text = String(value || 'Anchor').replace(/[^a-z0-9]+/gi, ''); return text || 'Anchor'; } function bindMapEvents() { w.map.on('moveend zoomend overlayadd overlayremove', plugin.scheduleRefresh); w.addHook('mapDataRefreshEnd', plugin.scheduleRefresh); w.addHook('mapDataEntityInject', plugin.scheduleRefresh); w.addHook('portalSelected', addPortalDetailsButtons); w.addHook('portalDetailsUpdated', addPortalDetailsButtons); } function addToolboxLink() { if (!$('#toolbox').length) return; $('') .text('Herringbone Planner') .attr('title', 'Open Herringbone Planner') .on('click', plugin.openDialog) .appendTo('#toolbox'); } function addPortalDetailsButtons() { w.setTimeout(function () { var details = $('#portaldetails'); if (!details.length || details.find('#hbsp-portal-buttons').length) return; var box = $('
'); $('