// ==UserScript== // @author Heistergand // @id fanfields@heistergand // @name Fan Fields 2 // @category Layer // @version 2.7.7.20260201 // @description Calculate how to link the portals to create the largest tidy set of nested fields. Enable from the layer chooser. // @downloadURL https://raw.githubusercontent.com/IITC-CE/Community-plugins/master/dist/heistergand/fanfields.user.js // @updateURL https://raw.githubusercontent.com/IITC-CE/Community-plugins/master/dist/heistergand/fanfields.meta.js // @icon https://raw.githubusercontent.com/Heistergand/fanfields2/master/fanfields2-32.png // @icon64 https://raw.githubusercontent.com/Heistergand/fanfields2/master/fanfields2-64.png // @supportURL https://github.com/Heistergand/fanfields2/issues // @namespace https://github.com/Heistergand/fanfields2 // @issueTracker https://github.com/Heistergand/fanfields2/issues // @homepageURL https://github.com/Heistergand/fanfields2/ // @depends draw-tools@breunigs // @recommends bookmarks@ZasoGD|draw-tools-plus@zaso|liveInventory@DanielOnDiordna|keys@xelio // @preview https://raw.githubusercontent.com/Heistergand/fanfields2/master/FanFields2.png // @match https://intel.ingress.com/* // @include https://intel.ingress.com/* // @grant none // ==/UserScript== function wrapper(plugin_info) { // ensure plugin framework is there, even if iitc is not yet loaded if (typeof window.plugin !== 'function') window.plugin = function () {}; plugin_info.buildName = 'main'; plugin_info.dateTimeVersion = '2026-02-01-142842'; plugin_info.pluginId = 'fanfields'; /* global L, $, dialog, map, portals, links, plugin, formatDistance -- eslint*/ /* exported setup, changelog -- eslint */ var arcname = (window.PLAYER && window.PLAYER.team === 'ENLIGHTENED') ? 'Arc' : '***'; var changelog = [{ version: '2.7.7', changes: [ 'IMPROVE portal sequence editor usage for mobile', 'UPDATE help dialog content', 'IMPROVE task list design' ], }, { version: '2.7.6', changes: [ 'FIX: Some minor code cleanup', 'FIX: Minor cosmetics', ], }, { version: '2.7.5', changes: [ 'NEW: Print your task list.', 'NEW: Show fields in task list.', 'FIX: Click on portal in task list now flies to the portal.', 'FIX: Uniform dialog titles', ], }, { version: '2.7.4', changes: [ 'FIX: Respect Intel not working anymore.', 'FIX: Dialog width on mobile too small.', ], }, { version: '2.7.3', changes: [ 'FIX: Tooltip must be a glyph sequence.', 'FIX: Double-Click on leaflet buttons isn\'t zooming the map anymore.', ], }, { version: '2.7.2', changes: [ 'FIX: Code cleanup and refactoring.', ], }, { version: '2.7.1', changes: [ 'FIX: The linking algorithm from version 2.6.6 was not perfect.', ], }, { version: '2.7.0', changes: [ 'NEW: Added portal sequence editor to customise the visit order.', 'NEW: Added straight-line route preview along the portal sequence.', ], }, { version: '2.6.6', changes: [ 'NEW: New linking algorythm.', ], }, { version: '2.6.5', changes: [ 'FIX: Fixed last fix.', ], }, { version: '2.6.4', changes: [ 'FIX: Fixed compatibility with Inventory Overview plugin.', ], }, { version: '2.6.3', changes: [ 'FIX: Fixed some minor issues like spelling mistakes.', ], }, { version: '2.6.2', changes: [ 'NEW: Task list now contains a single navigation link for each portal.', ], }, { version: '2.6.1', changes: [ 'FIX: Counts of outgoing links and sbul are now correct when respecting intel and using outbounding mode.', ], }, { version: '2.6.0', changes: [ 'NEW: Add control buttons for better ux on mobile.', ], }, { version: '2.5.6', changes: [ 'NEW: Implementing link details in show-as-list dialog.', ], }, { version: '2.5.5', changes: [ 'FIX: Plugin did not work on IITC-Mobile.', ], }, { version: '2.5.4', changes: [ 'NEW: Option to only use bookmarked portals within the Fanfields (Toggle-Button)', ], }, { version: '2.5.3', changes: [ 'NEW: Saving to Bookmarks now creates a folder in the Bookmarks list.', ], }, { version: '2.5.2', changes: [ 'FIX: Prefer LiveInventory Plugin over Keys Plugin (hotfix)', ], }, { version: '2.5.1', changes: [ 'FIX: Prefer LiveInventory Plugin over Keys Plugin', ], }, { version: '2.5.0', changes: [ 'NEW: Integrate key counts from LiveInventory plugin.', ], }, { version: '2.4.1', changes: [ 'FIX: "Show as List" without having the Keys Plugin did not show any Keys.', ], }, { version: '2.4.0', changes: [ 'NEW: Integrate functionality with Key Plugin.', 'NEW: Replace fieldset box design with a separated sidebar box.', ], }, { version: '2.3.2', changes: [ 'NEW: Introducing code for upcoming multiple fanfields by Drawtools Colors', 'FIX: some code refactorings', 'FIX: SBUL defaults to 2 now, assuming most fields are done solo.', 'FIX: If a marker is not actually snapped onto a portal it does not act as fan point anymore.', 'FIX: When adding a marker, it\'s now selected as start portal.', ], }, { version: '2.3.1', changes: [ 'FIX: Portals were difficult to select underneath the fanfileds plan.', ], }, { version: '2.3.0', changes: [ 'NEW: Added ' + arcname + ' support.', ], }, { version: '2.2.9', changes: [ 'FIX: Link direction indicator did not work anymore.', 'NEW: Link direction indicator is now optional.', 'NEW: New plugin icon showing a hand fan.', ], }, { version: '2.2.8', changes: [ 'FIX: minor changes', ], }, { version: '2.2.7', changes: [ 'FIX: Menu Buttons in Mobile version are now actually buttons.', ], }, { version: '2.2.6', changes: [ 'NEW: Google Maps Portal Routing', ], }, { version: '2.2.5', changes: [ 'NEW: Set how many SBUL you plan to use.', 'FIX: Anchor shift button design changed', ], }, { version: '2.2.4', changes: [ 'FIX: Width of dialog boxes did extend screen size', 'FIX: Fixed what should have been fixed in 2.2.4', ], }, { version: '2.2.3', changes: [ 'FIX: Made Bookmark Plugin optional', 'NEW: Anchor shifting ("Cycle Start") is now bidirectional.', 'FIX: Some minor fixes and code formatting.', ], }, { version: '2.2.2', changes: [ 'NEW: Added favicon.ico to script header.', ], }, { version: '2.2.1', changes: [ 'FIX: Merged from Jormund fork (2.1.7): Fixed L.LatLng extension', ], }, { version: '2.2.0', changes: [ 'FIX: Reintroducing the marker function which was removed in 2.1.7 so that a Drawtools Marker can be used to force a portal inside (or outside) the hull to be the anchor.', ], }, { version: '2.1.10', changes: [ 'FIX: minor fixes', ], }, { version: '2.1.9', changes: [ 'FIX: Fixed blank in header for compatibility with IITC-CE Button.', 'FIX: Fix for missing constants in leaflet verion 1.6.0.', ], }, { version: '2.1.8', changes: [ 'NEW: Added starting portal advance button to select among the list of perimeter portals.', ], }, { version: '2.1.7', changes: [ 'DEL: Removed marker and random selection of starting point portal.', 'NEW: Replaced with use of first outer hull portal. This ensures maximum fields will be generated.', ], }, { version: '2.1.5', changes: [ 'FIX: Minor syntax issue affecting potentially more strict runtimes', ], }, { version: '2.1.4', changes: [ 'FIX: Make the clockwise button change its label to "Counterclockwise" when toggled', ], }, { version: '2.1.3', changes: [ 'FIX: added id tags to menu button elements, ...just because.', ], }, { version: '2.1.2', changes: [ 'FIX: Minor issues', ], }, { version: '2.1.1', changes: [ 'FIX: changed List export format to display as a table', ], }, { version: '2.1.0', changes: [ 'NEW: Added save to DrawTools functionality', 'NEW: Added fanfield statistics', 'FIX: Changed some menu texts', 'VER: Increased Minor Version due to DrawTools Milestone', ], }, { version: '2.0.9', changes: [ 'NEW: Added the number of outgoing links to the simple list export', ], }, { version: '2.0.8', changes: [ 'NEW: Toggle the direction of the star-links (Inbound/Outbound) and calculate number of SBUL', 'FIX: Despite crosslinks, respecting the current intel did not handle done links', ], }, { version: '2.0.7', changes: [ 'FIX: Sorting of the portals was not accurate for far distance anchors when the angle was too equal.', 'NEW: Added option to respect current intel and not crossing lines.', ], }, { version: '2.0.6', changes: [ 'FIX: Plan messed up on multiple polygons.', ], }, { version: '2.0.5', changes: [ 'FIX: fan links abandoned when Marker was outside the polygon', 'BUG: Issue found where plan messes up when using more than one polygon (fixed in 2.0.6)', ], }, { version: '2.0.4', changes: [ 'NEW: Added Lock/Unlock button to freeze the plan and prevent recalculation on any events.', 'NEW: Added a simple text export (in a dialog box)', 'FIX: Several changes to the algorithm', 'BUG: Issue found where links are closing fields on top of portals that are successors in the list once you got around the startportal', ], }, { version: '2.0.3', changes: [ 'FIX: Counterclockwise did not work properly', 'NEW: Save as Bookmarks', ], }, { version: '2.0.2', changes: [ 'NEW: Added Menu', 'NEW: Added counterclockwise option', 'FIX: Minor Bugfixes', ], }, { version: '2.0.1', changes: [ 'NEW: Count keys to farm', 'NEW: Count total fields', 'NEW: Added labels to portals', 'FIX: Links were drawn in random order', 'FIX: Only fields to the center portal were drawn', ], }, ]; // PLUGIN START //////////////////////////////////////////////////////// // use own namespace for plugin /* jshint shadow:true */ window.plugin.fanfields = function () {}; var thisplugin = window.plugin.fanfields; // const values // zoom level used for projecting points between latLng and pixel coordinates. may affect precision of triangulation thisplugin.PROJECT_ZOOM = 16; thisplugin.LABEL_WIDTH = 100; thisplugin.LABEL_HEIGHT = 49; // constants no longer present in leaflet 1.6.0 thisplugin.DEG_TO_RAD = Math.PI / 180; thisplugin.RAD_TO_DEG = 180 / Math.PI; thisplugin.labelLayers = {}; thisplugin.startingpoint = undefined; thisplugin.availableSBUL = 2; thisplugin.locations = []; thisplugin.fanpoints = []; thisplugin.sortedFanpoints = []; thisplugin.perimeterpoints = []; thisplugin.startingpointIndex = 0; thisplugin.links = []; thisplugin.linksLayerGroup = null; thisplugin.fieldsLayerGroup = null; thisplugin.numbersLayerGroup = null; // ghi#23 thisplugin.orderPathLayerGroup = null; thisplugin.showOrderPath = false; thisplugin.manualOrderGuids = null; thisplugin.lastPlanSignature = null; thisplugin.saveBookmarks = function () { // loop thru portals and UN-Select them for bkmrks var bkmrkData, list; thisplugin.sortedFanpoints.forEach(function (point, index) { bkmrkData = window.plugin.bookmarks.findByGuid(point.guid); if (bkmrkData) { list = window.plugin.bookmarks.bkmrksObj.portals; delete list[bkmrkData.id_folder].bkmrk[bkmrkData.id_bookmark]; $('.bkmrk#' + bkmrkData.id_bookmark + '') .remove(); window.plugin.bookmarks.saveStorage(); window.plugin.bookmarks.updateStarPortal(); window.runHooks('pluginBkmrksEdit', { "target": "portal", "action": "remove", "folder": bkmrkData.id_folder, "id": bkmrkData.id_bookmark, "guid": point.guid }); console.log('Fanfields2: removed BOOKMARKS portal (' + bkmrkData.id_bookmark + ' situated in ' + bkmrkData.id_folder + ' folder)'); } }); let type = "folder"; let label = 'Fanfields2'; // Add new folder in the localStorage let folder_ID = window.plugin.bookmarks.generateID(); window.plugin.bookmarks.bkmrksObj.portals[folder_ID] = { "label": label, "state": 1, "bkmrk": {} }; window.plugin.bookmarks.saveStorage(); window.plugin.bookmarks.refreshBkmrks(); window.runHooks('pluginBkmrksEdit', { "target": type, "action": "add", "id": folder_ID }); console.log('Fanfields2: added BOOKMARKS ' + type + ' ' + folder_ID); thisplugin.addPortalBookmark = function (guid, latlng, label, folder_ID) { var bookmark_ID = window.plugin.bookmarks.generateID(); // Add bookmark in the localStorage window.plugin.bookmarks.bkmrksObj.portals[folder_ID].bkmrk[bookmark_ID] = { "guid": guid, "latlng": latlng, "label": label }; window.plugin.bookmarks.saveStorage(); window.plugin.bookmarks.refreshBkmrks(); window.runHooks('pluginBkmrksEdit', { "target": "portal", "action": "add", "id": bookmark_ID, "guid": guid }); console.log('Fanfields2: added BOOKMARKS portal ' + bookmark_ID); } // loop again: ordered(!) to add them as bookmarks thisplugin.sortedFanpoints.forEach(function (point, index) { if (point.guid) { var p = window.portals[point.guid]; var ll = p.getLatLng(); //plugin.bookmarks.addPortalBookmark(point.guid, ll.lat+','+ll.lng, p.options.data.title); thisplugin.addPortalBookmark(point.guid, ll.lat + ',' + ll.lng, p.options.data.title, folder_ID) } }); }; thisplugin.updateStartingPoint = function (i) { thisplugin.startingpointIndex = i; thisplugin.startingpointGUID = thisplugin.perimeterpoints[thisplugin.startingpointIndex][0]; thisplugin.startingpoint = this.fanpoints[thisplugin.startingpointGUID]; // Reset manual order because the start/anchor changed (ghi#23) thisplugin.manualOrderGuids = null; thisplugin.updateLayer(); } // cycle to next starting point on the convex hull list of portals thisplugin.nextStartingPoint = function () { // *** startingpoint handling is duplicated in updateLayer(). var i = thisplugin.startingpointIndex + 1; if (i >= thisplugin.perimeterpoints.length) { i = 0; } thisplugin.updateStartingPoint(i); }; thisplugin.previousStartingPoint = function () { var i = thisplugin.startingpointIndex - 1; if (i < 0) { i = thisplugin.perimeterpoints.length - 1; } thisplugin.updateStartingPoint(i); }; thisplugin.helpDialogWidth = 650; thisplugin.help = function () { let width = thisplugin.helpDialogWidth; thisplugin.MaxDialogWidth = thisplugin.getMaxDialogWidth(); if (thisplugin.MaxDialogWidth < thisplugin.helpDialogWidth) { width = thisplugin.MaxDialogWidth; } dialog({ html: '

Select portals
' + 'Using Drawtools, draw one or more polygons around the portals you want to work with. ' + 'Polygons can overlap each other or be completely separated. All portals within the polygons ' + 'count toward your planned fanfield. ' + 'Optional: toggle 🔖 Bookmarks only to restrict the selection to your bookmarked portals.

' + '

Show the plan
' + 'From the layer selector, enable the Fanfields layers (Links / Fields / Numbers). ' + 'The fanfield is calculated and shown as red links/fields on the intel. ' + 'Link directions can be indicated with dashed stubs at the origin portal — toggle Show link dir as needed.

' + '

Choose the anchor (start portal)
' + 'By default, the script selects an anchor portal from the convex hull of all selected portals. ' + 'Use the Cycle Start buttons to step through hull portals (previous/next). ' + 'To force an inside portal as anchor (totally legitimate), place a Drawtools marker snapped onto that portal, ' + 'then cycle until it becomes the anchor.

' + '

Build mode: inbounding / outbounding
' + 'A fanfield can be done inbounding by farming many keys at the anchor and linking to it from all other portals. ' + 'It can also be done outbounding by star-linking from the anchor until the maximum number of outgoing links is reached. ' + 'In outbounding mode you can set how many SBUL you plan to use (0–4) to calculate the outgoing link capacity.

' + '

Avoid blockers
' + 'If you need to plan around links/fields you cannot or do not want to destroy, use Respect Intel. ' + 'When enabled, the plan avoids crosslinks with currently visible intel links/fields.

' + '

Order & route planning
' + 'Switch between Clockwise and Counterclockwise order to find an easier route or squeeze out extra fields. ' + 'For fine control, open Manage Portal Order and drag & drop portals to customise your visit order. ' + 'Use Path to preview a straight-line route along the current portal sequence.

' + '

Freeze recalculation
' + 'Use 🔒 Locked to prevent the script from recalculating while you zoom into details or work with large areas. ' + 'Switch back to 🔓 Unlocked to refresh after changes.

' + '

Task list & exports
' + 'Open Task List to get a step-by-step plan including per-portal key requirements, outgoing link counts, and (optional) link details. ' + 'If you use a Keys/LiveInventory plugin, the task list can also show your available key counts. ' + 'The task list includes a navigation link for Google Maps and a print-friendly view. ' + 'You can also export the plan to Drawtools/Bookmarks to share or continue working with it.

' + '
' + '

Found a bug? Post your issues at GitHub:
' + 'https://github.com/Heistergand/fanfields2/issues

', id: 'plugin_fanfields2_alert_help', title: 'Fan Fields 2 - Help', width: width, closeOnEscape: true }); }; thisplugin.showStatistics = function () { var text = ""; if (this.sortedFanpoints.length > 3) { text = "" + "" + "" + "" + "" + //"" + "
FanPortals:" + (thisplugin.n - 1) + "
CenterKeys:" + thisplugin.centerKeys + "
Total links / keys:" + thisplugin.donelinks.length.toString() + "
Fields:" + thisplugin.triangles.length.toString() + "
Build AP (links and fields):" + (thisplugin.donelinks.length * 313 + thisplugin.triangles.length * 1250) .toString() + "
Destroy AP (links and fields):" + (thisplugin.sortedFanpoints.length*187 + thisplugin.triangles.length*750).toString() + "
"; var width = 400; thisplugin.MaxDialogWidth = thisplugin.getMaxDialogWidth(); if (thisplugin.MaxDialogWidth < width) { width = thisplugin.MaxDialogWidth; } dialog({ html: text, id: 'plugin_fanfields2_alert_statistics', title: 'Fan Fields 2 - Statistics', width: width, closeOnEscape: true }); } } thisplugin.exportDrawtools = function () { var alatlng, blatlng, layer; $.each(thisplugin.sortedFanpoints, function (index, portal) { $.each(portal.outgoing, function (targetIndex, targetPortal) { alatlng = map.unproject(portal.point, thisplugin.PROJECT_ZOOM); blatlng = map.unproject(targetPortal.point, thisplugin.PROJECT_ZOOM); layer = L.geodesicPolyline([alatlng, blatlng], window.plugin.drawTools.lineOptions); window.plugin.drawTools.drawnItems.addLayer(layer); window.plugin.drawTools.save(); }); }); } thisplugin.exportArcs = function () { if (window.PLAYER.team === 'RESISTANCE') { // sorry return; }; var alatlng, blatlng, layer; $.each(thisplugin.sortedFanpoints, function (index, portal) { $.each(portal.outgoing, function (targetIndex, targetPortal) { window.selectedPortal = portal.guid; window.plugin.arcs.draw(); window.selectedPortal = targetPortal.guid; window.plugin.arcs.draw(); }); }); window.plugin.arcs.list(); } thisplugin.exportTasks = function () { //todo... } thisplugin.flyToPortal = function (latlng, guid) { window.map.flyTo(latlng, map.getZoom()); if (window.portals[guid]) window.renderPortalDetails(guid); else window.urlPortal = guid; } // Show as list thisplugin.exportText = function () { var text = ""; let fieldSymbol = "▲"; text += ""; text += ""; text += ""; text += ""; text += ""; text += ""; text += ""; var gmnav = 'http://maps.google.com/maps/dir/'; thisplugin.sortedFanpoints.forEach(function (portal, index) { var p, lat, lng; var latlng = map.unproject(portal.point, thisplugin.PROJECT_ZOOM); lat = Math.round(latlng.lat * 10000000) / 10000000 lng = Math.round(latlng.lng * 10000000) / 10000000 gmnav += `${lat},${lng}/`; p = portal.portal; // window.portals[portal.guid]; let rawTitle = "unknown title"; if (p !== undefined && p.options && p.options.data && p.options.data.title) { rawTitle = p.options.data.title; } let title = window.escapeHtmlSpecialChars(rawTitle); let uriTitle = encodeURIComponent(rawTitle); let availableKeysText = ''; let availableKeys = 0; if (window.plugin.keys || window.plugin.LiveInventory) { if (window.plugin.LiveInventory) { if (window.plugin.LiveInventory.keyGuidCount) { availableKeys = window.plugin.LiveInventory.keyGuidCount[portal.guid] || 0; } else if (window.plugin.LiveInventory.keyCount) { availableKeys = window.plugin.LiveInventory.keyCount.find(obj => obj.portalCoupler.portalGuid === portal.guid) ?.count || 0; } } else { availableKeys = window.plugin.keys.keys[portal.guid] || 0; } // Beware of bugs in the above code; I have only proved it correct, not tried it! (Donald Knuth) let keyColorAttribute = ''; if (availableKeys >= portal.incoming.length) { keyColorAttribute = 'plugin_fanfields2_enoughKeys'; } else { keyColorAttribute = 'plugin_fanfields2_notEnoughKeys'; }; availableKeysText = keyColorAttribute + '>' + availableKeys + '/'; } else { availableKeysText = '>'; }; // Row start text += ''; // List Item Index (Pos.) text += ''; // Action text += ''; // Portal Name text += ''; // Keys text += ''; let fieldsCreatedAtThisPortal = 0 if (portal.outgoing.length > 0) { portal.outgoing.forEach(function (outPortal, outIndex) { let meta = portal.outgoingMeta?.[outPortal.guid]; fieldsCreatedAtThisPortal += meta?.creatingFieldsWith?.length ?? 0; }); } // Fields (here: empty cell) text += ''; // Row End text += ''; text += '\n'; if (portal.outgoing.length > 0) { // DetailBlock Start text += ''; portal.outgoing.forEach(function (outPortal, outIndex) { let distance = thisplugin.distanceTo(portal.point, outPortal.point); // Row start let linkDetailText = ''; // List Item Index (Pos.) linkDetailText += ''; // Action linkDetailText += ''; let outPortalTitle = 'unknown title'; if (outPortal.portal !== undefined) { outPortalTitle = outPortal.portal.options.data.title; } // Portal Name (Target) linkDetailText += ''; // Keys (here: empty cell) linkDetailText += ''; // Link (Distance) linkDetailText += ''; // Fields let meta = portal.outgoingMeta?.[outPortal.guid]; let fieldsCreatedByThisLink = meta?.creatingFieldsWith?.length ?? 0; linkDetailText += ''; // Row End linkDetailText += '\n'; text += linkDetailText; }); text += '\n'; } // end if portal.outgoing.length > 0 }); text += '
Pos.ActionPortal NameKeysLinksFields
' + (index) + ''; text += ' '; text += ' '; text += ''; const gmapsHref = `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}&query_destination_id=(${uriTitle})`; // Two links are rendered: // - UI link: uses onclick to interact with IITC (flyToPortal) // - Print link: real href for PDF / printing // Visibility is controlled via CSS (@media print or print window styles) text += ` ${title}`; text += ` ${title}`; text += ''; // Links text += '' + portal.outgoing.length + '' + fieldSymbol.repeat(fieldsCreatedAtThisPortal); + '
'; if (window.plugin.keys || window.plugin.LiveInventory) { text += '
Adjust available keys using your keys plugin.
'; }; text += '
'; gmnav += '&nav=1'; text += '
' + ' ' + '
'; text += 'Navigate with Google Maps'; thisplugin.exportDialogWidth = 500; var width = thisplugin.exportDialogWidth; thisplugin.MaxDialogWidth = thisplugin.getMaxDialogWidth(); if (thisplugin.MaxDialogWidth < thisplugin.exportDialogWidth) { width = thisplugin.MaxDialogWidth; } const toggleFunction = function () { $('[plugin_fanfields2_exportText_toggle="toggle"]') .each(function () { const $toggle = $(this); const $label = $toggle.prev('.plugin_fanfields2_exportText_Label'); const $details = $toggle.parents() .next('.plugin_fanfields2_exportText_LinkDetails'); if ($details.length) { $label.addClass('has-children'); } else { $toggle.remove(); // Remove the checkbox if there are no child elements $label.css('cursor', 'default'); // Reset the cursor back to default } }); $('[plugin_fanfields2_exportText_toggle="toggle"]') .change(function () { const isChecked = $(this) .is(':checked'); $(this) .parents() .next('.plugin_fanfields2_exportText_LinkDetails') .toggle(); $(this) .prev('.plugin_fanfields2_exportText_Label') .attr('aria-expanded', isChecked); }); }; dialog({ html: text, id: 'plugin_fanfields2_alert_textExport', title: 'Fan Fields 2 - Task List', width: width, closeOnEscape: true }); toggleFunction(); $('#plugin_fanfields2_export_pdf_btn') .off('click') .on('click', function () { thisplugin.exportTaskListToPDF(); }); }; thisplugin.exportTaskListToPDF = function () { const id = 'plugin_fanfields2_alert_textExport'; // Resolve the actual dialog content element. // IITC/jQuery-UI may wrap the original element inside a dialog container. let $dlg = $('#dialog-' + id + ' .ui-dialog-content'); if (!$dlg.length) $dlg = $('#dialog-' + id); if (!$dlg.length) $dlg = $('#' + id); if (!$dlg.length) return; // Ensure all link detail rows are expanded before exporting $dlg.find('[plugin_fanfields2_exportText_toggle="toggle"]') .each(function () { const $toggle = $(this); const $label = $toggle.prev('.plugin_fanfields2_exportText_Label'); const $details = $toggle.parents() .next('.plugin_fanfields2_exportText_LinkDetails'); if ($details.length) { $toggle.prop('checked', true); $details.show(); $label.attr('aria-expanded', true); } }); const htmlInner = $dlg.html(); // open new window for printing const w = window.open('', '_blank'); if (!w) return; const css = ` @page { margin: 12mm; } body { font-family: Arial, sans-serif; font-size: 10pt; color: #000; } h1 { font-size: 14pt; margin: 0 0 10px 0; } table { width: 100%; border-collapse: collapse; } th, td { border: 1px solid #666; padding: 4px 6px; vertical-align: top; } thead th { background: #eee; } .plugin_fanfields2_exportText_ui { display: none !important; } .plugin_fanfields2_exportText_print { display: inline !important; color: #000; text-decoration: none; } tbody.plugin_fanfields2_exportText_Portal td { font-weight: bold !important; } tbody.plugin_fanfields2_exportText_LinkDetails td { font-weight: normal !important; } button, input[type="checkbox"] { display: none !important; } .plugin_fanfields2_exportText_Label::before { display: none !important; } .plugin_fanfields2_exportText_LinkDetails { display: table-row-group !important; } `; w.document.open(); w.document.write(` Fan Fields 2 – Tasks

Fan Fields 2 – Tasks

${htmlInner} `); w.document.close(); w.focus(); setTimeout(() => w.print(), 250); }; // ghi#23 start (3) // Manage-Order-Dialog thisplugin.showManageOrderDialog = function () { var that = thisplugin; let manageOrderDialogTitle = 'Fan Fields 2 - Manage Portal Order'; var isMobile = L && L.Browser && L.Browser.mobile; if (!that.sortedFanpoints || that.sortedFanpoints.length === 0) { var widthEmpty = 350; thisplugin.MaxDialogWidth = thisplugin.getMaxDialogWidth(); if (that.MaxDialogWidth < widthEmpty) widthEmpty = that.MaxDialogWidth; dialog({ html: '

No Fanfield plan calculated yet.
Draw a polygon and let Fanfields calculate first.

', id: 'plugin_fanfields2_order_dialog_empty', title: manageOrderDialogTitle, width: widthEmpty, closeOnEscape: true }); return; } function buildTableHTML() { var html = ''; html += ''; html += ''; // html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; that.sortedFanpoints.forEach(function (fp, idx) { var p = fp.portal; var title = (p && p.options && p.options.data && p.options.data.title) ? p.options.data.title : 'unknown title'; var keys = fp.incoming ? fp.incoming.length : 0; var out = fp.outgoing ? fp.outgoing.length : 0; var isAnchor = (fp.guid === that.startingpointGUID); var trClass = isAnchor ? 'plugin_fanfields2_order_anchor' : 'plugin_fanfields2_order_row'; // Grip/Move column: handle for normal rows, arrows for mobile, anchor icon for the pinned row. var moveCell = ''; if (isAnchor) { moveCell = ''; } else { if (isMobile) { moveCell = ''; } else { moveCell = ''; } } html += ''; html += moveCell; html += ''; html += ''; html += ''; html += ''; html += ''; }); html += '
#PortalKeysLinks out
' + '' + '' + '
' + idx + '' + title + (isAnchor ? ' (anchor)' : '') + '' + keys + '' + out + '
'; html += '
'; if (isMobile) { html += 'Use ▲/▼ to change visit order. First row (anchor) is fixed.
'; } else { html += 'Drag & drop rows to change visit order. First row (anchor) is fixed.
'; } html += 'Click Apply to use this order for the fanfield calculation.'; html += '
'; html += '
'; html += ' '; html += ' '; html += ' '; html += '
'; return html; } var width = 450; thisplugin.MaxDialogWidth = thisplugin.getMaxDialogWidth(); if (that.MaxDialogWidth < width) width = that.MaxDialogWidth; dialog({ html: '
' + buildTableHTML() + '
', id: 'plugin_fanfields2_order_dialog', title: manageOrderDialogTitle, width: width, closeOnEscape: true }); function initDragAndButtons() { var $tbody = $('#plugin_fanfields2_order_table tbody'); function renumberRows() { // Update the "#" column to match the current DOM order. $tbody.find('tr') .each(function (i) { $(this) .find('td.plugin_fanfields2_order_idx') .text(i); }); updateMoveButtons(); } function pinAnchorRow() { // Keep anchor row at top (and prevent it from being displaced). var $anchor = $tbody.find('tr.plugin_fanfields2_order_anchor'); if ($anchor.length && $tbody.children() .first()[0] !== $anchor[0]) { $tbody.prepend($anchor); } } function updateMoveButtons() { if (!isMobile) return; var $rows = $tbody.find('tr.plugin_fanfields2_order_row'); $rows.find('.plugin_fanfields2_order_move') .prop('disabled', false); $rows.first() .find('.plugin_fanfields2_order_move_up') .prop('disabled', true); $rows.last() .find('.plugin_fanfields2_order_move_down') .prop('disabled', true); } function moveRow($row, direction) { if (!$row.length || !$row.hasClass('plugin_fanfields2_order_row')) return; var $anchor = $tbody.find('> tr.plugin_fanfields2_order_anchor'); if (direction === 'up') { var $prev = $row.prev('tr'); if ($prev.length === 0 || $prev.is($anchor)) return; $row.insertBefore($prev); } else { var $next = $row.next('tr'); if ($next.length === 0) return; $row.insertAfter($next); } pinAnchorRow(); renumberRows(); } // Destroy old sortable if the dialog is rebuilt. if ($tbody.data('ui-sortable')) $tbody.sortable('destroy'); pinAnchorRow(); renumberRows(); $tbody.sortable({ // Only non-anchor rows are draggable. items: '> tr.plugin_fanfields2_order_row', handle: '.plugin_fanfields2_order_handle', axis: 'y', helper: 'clone', forcePlaceholderSize: true, placeholder: 'plugin_fanfields2_order_sort_placeholder', start: function (e, ui) { if (that.showOrderPath) { that.setOrderPathActive(false); $('#plugin_fanfields2_order_path') .text('Path'); } // Keep column widths stable while dragging. ui.helper.children() .each(function (i) { $(this) .width(ui.item.children() .eq(i) .width()); }); // Make placeholder span the full row width. var colCount = ui.item.children('td,th') .length; ui.placeholder .addClass('plugin_fanfields2_order_sort_placeholder') .html(' '); // Prevent placeholder from going above the anchor row. var $anchor = $tbody.find('> tr.plugin_fanfields2_order_anchor'); if ($anchor.length && ui.placeholder.index() === 0) { ui.placeholder.insertAfter($anchor); } }, change: function (e, ui) { // Prevent dropping above the anchor row. var $anchor = $tbody.find('> tr.plugin_fanfields2_order_anchor'); if ($anchor.length && ui.placeholder.index() === 0) { ui.placeholder.insertAfter($anchor); } }, update: function () { pinAnchorRow(); renumberRows(); } }); if (isMobile) { $tbody .off('click.plugin_fanfields2_order_move') .on('click.plugin_fanfields2_order_move', '.plugin_fanfields2_order_move', function () { var $row = $(this) .closest('tr'); if ($(this) .hasClass('plugin_fanfields2_order_move_up')) { moveRow($row, 'up'); } else { moveRow($row, 'down'); } }); } // Rebind Reset and Apply buttons $('#plugin_fanfields2_order_reset') .off('click') .on('click', function () { that.manualOrderGuids = null; that.updateLayer(); $('#plugin_fanfields2_order_dialog_inner') .html(buildTableHTML()); initDragAndButtons(); if (that.showOrderPath) { that.updateOrderPath(); } }); $('#plugin_fanfields2_order_apply') .off('click') .on('click', function () { var guids = []; $('#plugin_fanfields2_order_table tbody tr') .each(function () { guids.push($(this) .data('guid')); }); // The first entry must remain the anchor. if (guids[0] !== that.startingpointGUID) { that.manualOrderGuids = null; } else { that.manualOrderGuids = guids; } that.delayedUpdateLayer(0.2); $('#plugin_fanfields2_order_dialog') .dialog('close'); }); $('#plugin_fanfields2_order_path') .off('click') .on('click', function () { var newState = !that.showOrderPath; that.setOrderPathActive(newState); $(this) .text(newState ? 'Hide path' : 'Path'); }); $('#plugin_fanfields2_order_path') .text(that.showOrderPath ? 'Hide path' : 'Path'); } initDragAndButtons(); }; // ghi#23 end (3) thisplugin.respectCurrentLinks = false; thisplugin.toggleRespectCurrentLinks = function () { thisplugin.respectCurrentLinks = !thisplugin.respectCurrentLinks; if (thisplugin.respectCurrentLinks) { $('#plugin_fanfields2_respectbtn') .html('Respect Intel: ON'); } else { $('#plugin_fanfields2_respectbtn') .html('Respect Intel: OFF'); } thisplugin.delayedUpdateLayer(0.2); }; thisplugin.indicateLinkDirection = true; thisplugin.toggleLinkDirIndicator = function () { thisplugin.indicateLinkDirection = !thisplugin.indicateLinkDirection; if (thisplugin.indicateLinkDirection) { $('#plugin_fanfields2_direction_indicator_btn') .html('Show link dir: ON'); } else { $('#plugin_fanfields2_direction_indicator_btn') .html('Show link dir: OFF'); } thisplugin.delayedUpdateLayer(0.2); }; thisplugin.is_locked = false; thisplugin.lock = function () { thisplugin.is_locked = !thisplugin.is_locked; if (thisplugin.is_locked) { $('#plugin_fanfields2_lockbtn') .html('🔒 Locked'); // 🔒 } else { $('#plugin_fanfields2_lockbtn') .html('🔓 Unlocked'); // 🔓 } }; thisplugin.use_bookmarks_only = false; thisplugin.useBookmarksOnly = function () { thisplugin.use_bookmarks_only = !thisplugin.use_bookmarks_only; if (thisplugin.use_bookmarks_only) { $('#plugin_fanfields2_bookarks_only_btn') .html( '🔖 Bookmarks only' ); } else { $('#plugin_fanfields2_bookarks_only_btn') .html( '🔖 All Portals' ); } thisplugin.delayedUpdateLayer(0.2); }; thisplugin.is_clockwise = true; thisplugin.toggleclockwise = function () { thisplugin.is_clockwise = !thisplugin.is_clockwise; var clockwiseSymbol = "", clockwiseWord = ""; if (thisplugin.is_clockwise) { clockwiseSymbol = "↻" clockwiseWord = "Clockwise"; } else { clockwiseSymbol = "↺" clockwiseWord = "Counterclockwise"; } // Reset the order – new geometry, new base ordering (ghi#23) thisplugin.manualOrderGuids = null; $('#plugin_fanfields2_clckwsbtn') .html(clockwiseWord + ' ' + clockwiseSymbol + ''); thisplugin.delayedUpdateLayer(0.2); }; thisplugin.starDirENUM = { CENTRALIZING: -1, RADIATING: 1 }; thisplugin.stardirection = thisplugin.starDirENUM.CENTRALIZING; thisplugin.toggleStarDirection = function () { thisplugin.stardirection *= -1; var html = "Outbounding"; if (thisplugin.stardirection === thisplugin.starDirENUM.CENTRALIZING) { html = "Inbounding"; $('#plugin_fanfields2_availablesbul') .hide(); } else { $('#plugin_fanfields2_availablesbul') .show(); } $('#plugin_fanfields2_stardirbtn') .html(html); thisplugin.delayedUpdateLayer(0.2); }; thisplugin.increaseSBUL = function () { if (thisplugin.availableSBUL < 4) { thisplugin.availableSBUL++; $('#plugin_fanfields2_availablesbul_count') .html('' + (thisplugin.availableSBUL) + ''); thisplugin.delayedUpdateLayer(0.2); } } thisplugin.decreaseSBUL = function () { if (thisplugin.availableSBUL > 0) { thisplugin.availableSBUL--; $('#plugin_fanfields2_availablesbul_count') .html('' + (thisplugin.availableSBUL) + ''); thisplugin.delayedUpdateLayer(0.2); } } thisplugin.setupCSS = function () { // Collect CSS in one place and inject/update a single