// ==UserScript== // @author Avataar120 // @id fanfields3@Avataar120 // @name Fan Fields 3 // @category Layer // @version 3.4.0.20260927 // @description Fork of Heistergand's Fan Fields 2 (thanks Heistergand for the original work!). Plans the largest tidy set of nested fields, and adds: walking optimization (less backtracking between portals, Destroy stops placed where they add the least walking), automatic best anchor/direction search that reuses your faction's existing links, Blockers handling in the Task List, plan locking, Pick anchor on the map, a Task List that follows your progress and can Reroute the steps left from where you stand, key counts read from a screen recording of your keys in Ingress (Keys plugin), and route export to Google Maps / Portal Route. Enable from the layer chooser. // @downloadURL https://raw.githubusercontent.com/IITC-CE/Community-plugins/master/dist/Avataar120/fanfields3.user.js // @updateURL https://raw.githubusercontent.com/IITC-CE/Community-plugins/master/dist/Avataar120/fanfields3.meta.js // @icon https://raw.githubusercontent.com/Avataar120/fanfields3/master/fanfields3-32.png // @icon64 https://raw.githubusercontent.com/Avataar120/fanfields3/master/fanfields3-64.png // @supportURL https://github.com/Avataar120/fanfields3/issues // @namespace https://github.com/Avataar120/fanfields3 // @issueTracker https://github.com/Avataar120/fanfields3/issues // @homepageURL https://github.com/Avataar120/fanfields3/ // @depends draw-tools@breunigs // @recommends bookmarks@ZasoGD|draw-tools-plus@zaso|liveInventory@DanielOnDiordna|keys@xelio // @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-09-27-224858'; plugin_info.pluginId = 'fanfields'; /* global L, $, dialog, map, portals, links, plugin -- eslint*/ /* exported setup, changelog -- eslint */ var arcname = (window.PLAYER && window.PLAYER.team === 'ENLIGHTENED') ? 'Arc' : '***'; var changelog = [{ version: '3.4.0', changes: [ 'NEW: "Keys video" button in the Task List (Keys plugin only): record your phone screen while scrolling through your keys in Ingress, pick the recording (or screenshots), and the key counts of the plan\'s portals are read from it and written into the Keys plugin once you have checked them. The text is read on your device (the recognition library is downloaded once from a CDN); nothing is sent anywhere.', 'NEW: In the Task List, the Keys cell turns red when you hold fewer keys for a portal than the plan needs, on the printed Task List too.', 'NEW: A "Keys video" button (a key with a small camera) on the map, next to the other Fan Fields 3 buttons, opens the same window directly.', ], },{ version: '3.3.1', changes: [ 'FIX: On mobile, tapping a portal name in the Task List now selects that portal the same way a tap on the map does: tapping its name in the bottom bar opens its own details, instead of those of the last portal opened by hand, or just the menu.', 'FIX: The plan no longer locks itself while IITC is still loading the map: it waits until all portals and links are loaded and the plan is calculated from them.', ], },{ version: '3.3.0', changes: [ 'NEW: The Task List opens with the links of the first portal still to do already unfolded.', 'NEW: When the portal whose links are unfolded in the Task List becomes finished (Action "Nothing"), its links fold away and the next portal still to do unfolds instead.', 'FIX: A finished portal (Action "Nothing") now stays finished in the Task List and for Reroute, even when its data is briefly missing, its links are hidden at the current zoom or key counts refresh. It only goes back when the plan changes, when the portal is destroyed, flipped or drops under 8 resonators, or when one of its links disappears because the portal at the other end was lost.', ], },{ version: '3.2.1', changes: [ 'FIX: A finished portal (Action "Nothing") now always shows pale yellow and struck through in the Task List, like every other finished portal, even when it was green (moved earlier by Less walking) or carried the red cross of a blocking link it frees. The printed Task List follows the same rule.', ], },{ version: '3.2.0', changes: [ 'NEW: Reroute button next to Refresh in the Task List: reorders the steps still to do so that, from your current position (GPS, else IITC\'s own location, else the map center), you walk as little as possible. Steps already done stay at the top, each portal is still captured and its keys gathered before anyone links to it (unless it is already yours with enough keys), and no field is lost. Links and fields stay the same, the map numbers, Destroy stops, Google Maps and Portal Route follow the new order, and it works while the plan is locked too. The new order holds until the plan changes or Reset link orders is used.', 'FIX: A field closed by a different link than planned, when the walk order was changed (Less walking), is now counted and drawn instead of being left out.', ], },{ version: '3.1.3', changes: [ 'IMPROVE: The Task List now opens scrolled to the first step still to do, with the step just before it kept in view, instead of always starting at the top.', ], },{ version: '3.1.2', changes: [ 'IMPROVE: New, more modern plugin icon: a white hand fan on a violet-to-pink tile with a big 3 in its center.', ], },{ version: '3.1.1', changes: [ 'IMPROVE: The plugin description in the IITC plugin list now thanks Heistergand, the author of the original Fan Fields 2 this plugin is a fork of, and lists the main features added since: walking optimization, automatic best anchor search, Blockers handling, plan locking, Pick anchor and route export.', ], },{ version: '3.1.0', changes: [ 'NEW: Blockers option (on by default): links that cross your plan and that Respect Intel does not avoid are drawn as red dotted lines, and the Task List gets Destroy stops telling you which portals to neutralize so those links are gone before the links they block are thrown. Each stop is placed where it adds the least walking, and one portal that frees several links is preferred over several separate ones when it costs less walking.', 'NEW: A portal the plan captures anyway is marked with a red cross and "(frees N)" when capturing it frees a blocking link in time.', 'NEW: Max detour option (100 m, 200 m, 500 m by default, 1 km or no limit) limits the extra walking one Destroy stop may add. Links that cannot be freed within it are listed under the Task List.', 'NEW: Destroy stops also appear in the Google Maps route, the Portal Route import and the printed Task List.', 'NEW: The plan now locks itself as soon as a new plan is completely calculated, so it stops moving while you pan, zoom or the map data refreshes. Changing a menu option, the drawn polygon or a map layer recalculates it and locks it again; clicking Lock/Unlock yourself keeps your choice until the next new plan.', 'FIX: Menu options such as Clockwise, Respect Intel, SBUL or Optim now recalculate the plan even while it is locked, instead of changing the button without changing the plan.', ], },{ version: '3.0.0', changes: [ 'NEW: The plugin is now Fan Fields 3, with its own GitHub repository (fanfields3) and new download/update address. Scripts installed from the old address no longer update: reinstall from https://github.com/Avataar120/fanfields3/raw/master/iitc_plugin_fanfields3.user.js.', 'IMPROVE: The automatic anchor/direction search now tries first the portals already touched by the most links thrown in-game for your faction, and stops as soon as every existing link is reused (or after 8 seconds on very large selections).', 'IMPROVE: When several anchors reuse as many existing links, the search now prefers the one giving more fields, then fewer keys on the busiest portal.', 'IMPROVE: The search now runs in the background once the portals have finished loading, so drawing a polygon no longer freezes the map: the plan shows right away and the anchor may update about a second later. Choosing an anchor, a direction or a manual order yourself cancels it.', 'IMPROVE: The search is skipped when none of your faction\'s links exist between the selected portals (it is retried for one minute, in case they are still loading).', 'IMPROVE: Checking whether a link already exists in-game is now instant, which speeds up the Task List, the faded links on the map and the anchor search.', 'FIX: The Clockwise/Counterclockwise button now shows the right direction after the automatic search changes it.', ], },{ version: '2.8.16', changes: [ 'NEW: Task List now shows grid lines between portals and between columns, and centers all its text, for an easier read.', 'NEW: Added a Lock/Unlock padlock icon to the map\'s own topleft shortcuts (green open, red closed), next to the Task List/Shift/Pick anchor buttons, so the plan can be frozen without opening the sidebar menu.', ], },{ version: '2.8.15', changes: [ 'FIX: The Statistics window no longer stays frozen on stale numbers — it now updates live every time the plan recalculates, same as the Task List already did.', ], },{ version: '2.8.14', changes: [ 'NEW: Added a "Pick anchor" button (in the sidebar and next to the map\'s own Shift left/right buttons) to make any portal the anchor just by clicking it on the map — including one in the middle of the selection, not only the outer edge portals reachable with Shift left/right.', 'IMPROVE: The automatic anchor/direction search that runs right after drawing or editing a polygon now considers every portal inside it, not only the outer edge ones, when picking whichever reuses the most links already thrown in-game for your faction.', ], },{ version: '2.8.13', changes: [ 'NEW: On mobile, tapping a portal name in the Task List now centers the map on that portal, highlights it and closes the list. Desktop is unchanged: the map is centered, the portal details open and the list stays open.', 'NEW: Task List\'s Fields cell now fades and strikes through, like the Links cell, once no outgoing link is left to throw from that portal.', 'FIX: Task List\'s Action column no longer stays on "Keys" for a portal once the keys you hold (Keys or LiveInventory plugin) cover what it needs — it now shows "Nothing".', 'FIX: On mobile, "Navigate with Google Maps" now only sends the next 20 stops still to do instead of the whole route, since a longer route could crash the Google Maps app.', ], },{ version: '2.8.12', changes: [ 'NEW: Task List\'s Keys column now includes a quick-set button next to the count (Keys plugin only): mark a portal as having enough keys with one tap, or reset that portal back to 0 once marked.', ], },{ version: '2.8.11', changes: [ 'FIX: Respect Intel now only blocks crossing the selected factions\' links, even when your own faction is included — it no longer changes anything else about how already-existing links are handled or displayed.', 'FIX: Task List\'s Links column now shows how many outgoing links are still left to throw from a portal, instead of its total outgoing link count.', 'NEW: Already-thrown links now show as a faded brownish-red on the map itself, not just in the Task List — only links still left to throw stay bright red. Toggle via the same "Grey out done links" button.', 'FIX: Task List link details no longer look bold for a still-to-throw link — lighter, slightly smaller text than before.', 'NEW: Respect Intel now defaults to your own faction (ENL or RES) instead of NONE.', 'FIX: Task List\'s Refresh/shift/OK buttons were unreachable on mobile once the list had enough portals to grow taller than the screen — the list now caps its height to the visible screen and scrolls its own content instead.', 'NEW: After a new (or edited) polygon replaces the previous one, the plan now automatically searches for whichever starting portal and direction reuses the most links already thrown in-game for your faction, instead of keeping an arbitrary orientation. Skipped while Locked or when a manual portal order is active.', 'FIX: Task List\'s "Navigate with Google Maps" route no longer includes a portal with nothing left to do there.', ], },{ version: '2.8.10', changes: [ 'NEW: Added a Task List button to the map\'s top-left corner, next to the anchor rotation buttons, so the list can be opened directly from there.', 'NEW: Added the anchor rotation buttons to the Task List window itself, next to the OK button, so the start portal can be changed without closing the list.', 'NEW: Added a Refresh button next to those anchor rotation buttons in the Task List, to force an IITC map data refresh without closing the list.', 'FIX: An open Task List no longer stays stuck showing an already-thrown link as still outstanding — its own 10-second refresh now also re-reads the current in-game data itself, instead of relying only on IITC data events that don\'t always reach the plugin (seen on mobile).', ], },{ version: '2.8.9', changes: [ 'NEW: Task List\'s Action column now shows Capture, Link, Keys or Nothing for each portal, based on what actually still needs doing there (not owned or under 8 resonators, outgoing links left, incoming links or keys still needed, or fully done).', 'NEW: The Links and Keys columns now fade and strike through on their own once that count is settled, even before the whole portal is done.', 'NEW: A small camera icon marks Volatile Scout Controlled portals next to their name in the Task List.', 'FIX: Portal ownership was never correctly detected, so the Task List never recognized a portal as already captured.', 'FIX: Tooltips no longer pop up on tap on mobile in the Task List.', ], },{ version: '2.8.8', changes: [ 'NEW: Coming back to IITC after it was in the background (app switch, screen lock) now triggers a data refresh right away, instead of waiting for IITC\'s own refresh timer or needing to pan/zoom the map manually.', 'NEW: An open Task List now also refreshes itself every 10 seconds on its own, so available key counts and in-game link/portal completion stay current even between plan recalculations.', ], },{ version: '2.8.7', changes: [ 'FIX: Clicking a portal in the Task List threw an error on desktop (window.map.flyTo is not a function).', ], },{ version: '2.8.6', changes: [ 'NEW: "Link order" menu button lets you choose how links are oriented, without changing the fanfield plan itself: keep the algorithm\'s own choice, optimize for fewer keys on any single portal, or optimize for less backtracking while walking the plan.', ], },{ version: '2.8.5', changes: [ 'NEW: Task List strikes through a link line already made in-game for your faction (grey), and once all of a portal\'s links exist, fades and strikes through that whole portal line too (yellow). Toggle via the "Grey out done links" button.', 'NEW: Task List no longer counts a key toward a portal\'s "Keys needed" once the link it was meant for already exists in-game.', 'NEW: Task List now refreshes itself live as the background plan changes (new links appearing in-game, fan field rotation, anchor changes, ...) — no need to close and reopen it.', ], },{ version: '2.8.4', changes: [ 'NEW: add button to flip a link.', ], },{ version: '2.8.3', changes: [ 'FIX: formatDistance is not defined on desktop IITC-CE builds.', ], },{ version: '2.8.2', changes: [ 'FIX: Respect Intel integrates already existing own-faction links into planned fields.', ], },{ version: '2.8.1', changes: [ 'NEW: Add support for 3rd party plugin "Portal Route".', ], },{ version: '2.8.0', changes: [ 'NEW: Add user warning when trying to link impossible lengths from underneath a field.', 'IMPROVE print design', 'FIX: adjust label position to avoid overlapping with portal names', 'FIX: minor code cleanup.', ], },{ version: '2.7.8', changes: [ 'NEW: Respect Intel now supports filtering which factions\' links are treated as blockers (Issue #104).', ], }, { 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; // Compat: window.formatDistance has been moved to IITC.utils.formatDistance // in the recent builds of IITC-CE (desktop). We handle both cases : thisplugin.formatDistance = function (distance) { if (window.IITC && window.IITC.utils && typeof window.IITC.utils.formatDistance === 'function') { return window.IITC.utils.formatDistance(distance); } if (typeof window.formatDistance === 'function') { return window.formatDistance(distance); } // Fallback minimal si aucune des deux n'existe return distance < 1000 ? Math.round(distance) + ' m' : (distance / 1000).toFixed(2) + ' km'; }; // const values // zoom level used for projecting points between latLng and pixel coordinates. may affect precision of triangulation thisplugin.PROJECT_ZOOM = 16; // Debug: when true, updateLayer() logs the plan's portals (with coordinates) and the drawn // polygon(s) to the console on every recalculation. Toggle from the console: // window.plugin.fanfields.debugLogPlan = false; thisplugin.debugLogPlan = true; // Most stops (origin included) handed to Google Maps on mobile: a longer route can make the // Google Maps app misbehave or crash when opened from the "Navigate with Google Maps" link. thisplugin.GOOGLE_MAPS_MAX_STOPS_MOBILE = 20; thisplugin.LABEL_WIDTH = 100; thisplugin.LABEL_HEIGHT = 49; thisplugin.LABEL_PADDING_TOP = 27; // 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; // Pinned anchor (guid), applied by updateLayer() on every recalculation: extends // thisplugin.perimeterpoints with it if needed and re-derives thisplugin.startingpointIndex // from it, so an anchor OFF the hull sticks around across recalculations exactly like a hull // one. null means "no pin" — the algorithm's own hull-based choice (or the marker, if any) // applies as usual. Set two ways: // - thisplugin.setAnchorByGuid (the "Pick anchor" button): an explicit user choice — // thisplugin.forcedAnchorIsManual is set alongside it, so the auto-orientation search // below never silently overrides it on a later polygon edit. // - the auto-orientation search itself, for its own best pick when that pick isn't a hull // portal — forcedAnchorIsManual stays false, so a genuinely new polygon can freely // re-search and replace it. // Cleared by cycling (previousStartingPoint/nextStartingPoint), since stepping away from a // pinned anchor means the user no longer wants it pinned, manual or not. thisplugin.forcedAnchorGUID = null; thisplugin.forcedAnchorIsManual = false; // Whether the next portal click on the map should set that portal as the anchor (see the // Pick anchor sidebar button and the portalSelected hook in setup()). thisplugin.isPickingAnchor = false; 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; // Manual per-link direction overrides (Task List "flip" button, and the "Fewer keys" // optimizer below). // Keyed by undirected link key (getUndirectedLinkKey) -> true. // Applies to mesh links between fan points as well as a portal's own anchor (fan/star) link. thisplugin.manualLinkFlips = {}; // "Less walking" (DISTANCE mode): portals it relocated earlier in the walk, right next to // whichever portal already in the walk is closest to them (see computeDistanceOrderFlips). // Keyed by guid -> true. Used only to highlight them in the Task List (green), as a reminder // that this portal must already be captured, with enough of its own keys gathered, by the // time the walk reaches that earlier spot — it's no longer visited in its "natural" position. thisplugin.relocatedForLessWalkingGuids = {}; // "Less walking" (DISTANCE mode): the walk/display order it relocated portals into (see // computeDistanceOrderReordering). This is a PURE DISPLAY order — never fed back into the // core algorithm, which always builds its links/fields from thisplugin.sortedFanpoints // (and thisplugin.manualOrderGuids, the Manage Portal Order feature's own, unrelated, // BUILD-order override). The core algorithm only considers, for each portal, the portals // that precede it in sortedFanpoints as possible link partners — reordering that array would // silently change which links/fields exist, which is exactly what this feature must never // do. thisplugin.getDisplayOrder() resolves this (or falls back to sortedFanpoints) for // everything that only cares about the walking sequence: the Task List, the on-map position // numbers, the "Path" preview, Google Maps navigation, Portal Route stops, and bookmarks // order. null when no portal is currently relocated. thisplugin.displayOrderGuids = null; // Task List "Reroute" button: the walk order it computed from the player's position (see // thisplugin.rerouteFromPlayerPosition) — steps already done first, then the remaining ones in // the order that walks the least. Like displayOrderGuids it's a PURE DISPLAY order, and it takes // precedence over it. It only holds for the exact plan it was computed on // (routeOrderPlanKey, see getPlanShapeKey): as soon as the plan changes (anchor, options, // polygon, link directions, ...) it's dropped for good and the normal walk order comes back. // routeOrderInfo keeps what the Task List reports about it: { source, before, after }. thisplugin.routeOrderGuids = null; thisplugin.routeOrderPlanKey = null; thisplugin.routeOrderInfo = null; // Link order optimization (menu button "Link order"). This never touches the algorithm // itself (which links exist, which fields form) — it only pre-fills / edits // thisplugin.manualLinkFlips, the very same map the Task List's per-link ↔ button edits by // hand, so the result is always just a starting point the user can keep tweaking manually. // ALGO: pure algorithm, no automatic overrides. // KEYS: greedy rebalancing of mesh link direction to lower the maximum keys needed at any // single portal. // DISTANCE: when a portal's own OUTGOING count (as the base algorithm computed it — not its // total degree) is exactly 2 — its anchor link plus one mesh link — its mesh link // flips (mesh partner -> portal) if that partner is just as close, or closer, to // whatever comes right after this portal in the walk: i.e. this portal wasn't // really "on the way". Rather than also flipping its anchor link, the portal is // instead relocated in the WALK/DISPLAY order only (thisplugin.displayOrderGuids — // see above), to wherever in the whole walk adds the least extra distance // (cheapest insertion), while never landing after a portal that throws a link at // it — see computeDistanceOrderReordering for the details. // ALGO ("Algorithm", no automatic override) still exists internally as the target of a full // "Reset link orders" (Task List), but the menu button no longer cycles through it — it // toggles only between KEYS and DISTANCE, which default to DISTANCE ("Less walking"). thisplugin.linkOrderModeENUM = { ALGO: 0, KEYS: 1, DISTANCE: 2 }; thisplugin.linkOrderMode = thisplugin.linkOrderModeENUM.DISTANCE; // Set whenever something invalidates the active optimization (anchor/order/geometry change) // so the next updateLayer() run recomputes it. Never set for a single manual flip via the // Task List ↔ button — that's meant to stick until the user re-optimizes on purpose. // Starts true so the default DISTANCE mode computes as soon as the very first plan exists. thisplugin._linkOrderRecomputePending = true; // Set whenever the portal set itself just changed (a new/edited polygon replaced the // previous one — see the lastPlanSignature check in updateLayer()), so the next // updateLayer() run schedules a search for whichever anchor/direction reuses the most links // already thrown in-game for our own faction, instead of keeping whatever anchor/direction // happened to be selected before. Never set for anything else (order changes, zoom, link // flips, ...) — this search tries every portal in the polygon(s) as anchor (hull or not) in // both directions, so it's deliberately reserved for an actual new polygon rather than every // recalculation. thisplugin._orientationSearchPending = false; // While no own-faction link joins two portals of the plan, the search has nothing to reuse and // is skipped, then retried on the next recalculations — only until this timestamp (set when the // portal set changes), so it covers links still loading in, not links thrown later while playing. thisplugin.ORIENTATION_SEARCH_RETRY_MS = 60000; thisplugin._orientationSearchRetryUntil = 0; // Longest the search may keep trying candidates (it tries the most promising ones first, and // stops early once a candidate reuses every existing link). thisplugin.ORIENTATION_SEARCH_BUDGET_MS = 8000; // How long the portal set must stay unchanged before the search starts, and how long it works // at a stretch before handing control back to the browser. thisplugin.ORIENTATION_SEARCH_START_DELAY_MS = 1000; thisplugin.ORIENTATION_SEARCH_SLICE_MS = 30; // Identifies the scheduled/running search: bumped by cancelOrientationSearch(), which makes any // older search stop at its next slice and discard its result. thisplugin._orientationSearchToken = 0; thisplugin._orientationSearchTimer = null; // The walk/display order: thisplugin.sortedFanpoints reordered per thisplugin.displayOrderGuids // (the "Less walking" relocation — see above), or thisplugin.sortedFanpoints itself unchanged // if there's no active relocation, or if displayOrderGuids no longer matches the current plan // (stale guid set, wrong length, or a non-anchor first entry). Use this — never // thisplugin.sortedFanpoints directly — for anything that only cares about the sequence a // player actually walks: Task List rows/positions, on-map position numbers, the "Path" // preview, Google Maps navigation, Portal Route stops, bookmark order. The core algorithm // itself, and anything about which links/fields exist, must keep using thisplugin.sortedFanpoints. // A "Reroute" order (routeOrderGuids) computed on this very plan wins over both. thisplugin.getDisplayOrder = function () { var sorted = thisplugin.sortedFanpoints || []; var routeOrder = thisplugin.getRouteOrder(); if (routeOrder) return routeOrder; var guids = thisplugin.displayOrderGuids; if (!guids || guids.length !== sorted.length) return sorted; var byGuid = {}; sorted.forEach(function (fp) { byGuid[fp.guid] = fp; }); var reordered = []; for (var i = 0; i < guids.length; i++) { var fp = byGuid[guids[i]]; if (!fp) return sorted; // stale guid set (plan changed since) — ignore it reordered.push(fp); } if (reordered[0].guid !== thisplugin.startingpointGUID) return sorted; // anchor must stay first return reordered; }; // Identifies the plan's shape: its portals in build order, each with the portals it throws to. // Two plans with the same key have exactly the same links in the same directions. thisplugin.getPlanShapeKey = function () { return (thisplugin.sortedFanpoints || []).map(function (fp) { return fp.guid + '>' + (fp.outgoing || []).map(function (target) { return target.guid; }).join(','); }).join('|'); }; // The "Reroute" walk order as fanpoints, or null when there is none or the plan it was computed // on has changed since (in which case it's dropped for good). thisplugin.getRouteOrder = function () { var guids = thisplugin.routeOrderGuids; if (!guids) return null; var sorted = thisplugin.sortedFanpoints || []; var byGuid = {}; sorted.forEach(function (fp) { byGuid[fp.guid] = fp; }); var order = guids.map(function (guid) { return byGuid[guid]; }); var stillValid = guids.length === sorted.length && order.every(function (fp) { return !!fp; }) && thisplugin.getPlanShapeKey() === thisplugin.routeOrderPlanKey; if (!stillValid) { thisplugin.clearRouteOrder(); return null; } return order; }; thisplugin.clearRouteOrder = function () { thisplugin.routeOrderGuids = null; thisplugin.routeOrderPlanKey = null; thisplugin.routeOrderInfo = 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('Fanfields3: removed BOOKMARKS portal (' + bkmrkData.id_bookmark + ' situated in ' + bkmrkData.id_folder + ' folder)'); } }); let type = "folder"; let label = 'Fanfields3'; // 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('Fanfields3: 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('Fanfields3: added BOOKMARKS portal ' + bookmark_ID); } // loop again: ordered(!) to add them as bookmarks — the walk order, relocations included thisplugin.getDisplayOrder().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) { // Stepping through the hull by hand means giving up whichever anchor was pinned — // manually or by the auto-orientation search — otherwise the pin would just fight the // cycle buttons on the very next recalculation. thisplugin.cancelOrientationSearch(); thisplugin.forcedAnchorGUID = null; thisplugin.forcedAnchorIsManual = false; thisplugin.startingpointIndex = i; thisplugin.startingpointGUID = thisplugin.perimeterpoints[thisplugin.startingpointIndex][0]; thisplugin.startingpoint = this.fanpoints[thisplugin.startingpointGUID]; // Reset manual order and link flips because the start/anchor changed (ghi#23) thisplugin.manualOrderGuids = null; thisplugin.manualLinkFlips = {}; thisplugin.relocatedForLessWalkingGuids = {}; thisplugin.displayOrderGuids = null; thisplugin.requestLinkOrderRecompute(); 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); }; // Manually force any portal currently in the plan to become the anchor — hull or not. // Unlike a DrawTools marker, this doesn't require a pixel-exact snap: it directly pins // thisplugin.forcedAnchorGUID, which updateLayer() applies on every recalculation (see the // "forcedAnchorGUID" block there). Returns false without doing anything if the portal isn't // part of the current plan (outside the drawn polygon(s), or excluded by Bookmarks-only). thisplugin.setAnchorByGuid = function (guid) { if (!guid || !thisplugin.fanpoints || !(guid in thisplugin.fanpoints)) return false; thisplugin.cancelOrientationSearch(); thisplugin.forcedAnchorGUID = guid; thisplugin.forcedAnchorIsManual = true; // Reset manual order and link flips because the start/anchor changed (ghi#23), same as // cycling via updateStartingPoint. thisplugin.manualOrderGuids = null; thisplugin.manualLinkFlips = {}; thisplugin.relocatedForLessWalkingGuids = {}; thisplugin.displayOrderGuids = null; thisplugin.requestLinkOrderRecompute(); thisplugin.updateLayer(); return true; }; // "Pick anchor" sidebar button: toggles whether the next portal click on the map sets that // portal as the anchor (see the portalSelected hook in setup()). thisplugin.toggleAnchorPicking = function () { thisplugin.isPickingAnchor = !thisplugin.isPickingAnchor; thisplugin.updateAnchorPickingButton(); }; thisplugin.updateAnchorPickingButton = function () { $('#plugin_fanfields3_pickanchor_btn, #fanfieldPickAnchorButton') .toggleClass('plugin_fanfields3_active', thisplugin.isPickingAnchor); }; 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 you cannot or do not want to destroy, use Respect Intel. ' + 'Choose which factions\' links are treated as blockers (NONE / ALL / ENL / RES / ENL & MAC / RES & MAC / MAC). ' + 'The plan avoids crossing those currently visible intel links — nothing else changes, even when the selected mode includes your own faction.

' + '

Blockers
' + 'Every visible link that crosses a link of the plan still to be thrown, and whose faction Respect Intel does not avoid, is a blocker (with Respect Intel on NONE, that is every crossing link; your own faction\'s links count too when it is not selected). ' + 'With Blockers on (the default), blockers are drawn as red dotted lines and the Task List gets Destroy rows: portals to neutralize so that the blockers are gone before the link they block is thrown. ' + 'A portal that frees several links at once is preferred over several separate portals whenever it costs less walking, and each row is slotted into the walk where it adds the least detour, never later than the first link it unblocks. ' + 'An enemy portal the plan captures anyway is marked with a cross when its capture frees a link in time; when the walk reaches it too late, it gets a Destroy row earlier on, and is captured later on the walk as usual. ' + 'Max detour (100 m, 200 m, 500 m, 1 km or no limit) caps the extra walk of a single Destroy stop; blockers that cannot be freed within it are listed under the Task List. ' + 'Turning Blockers off only removes these rows. A link of your own faction can only be broken with a Jarvis/ADA flip, or by changing Respect Intel.

' + '

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.

' + '

Optim (link order)
' + 'The Optim button reorients some links (never the algorithm itself — which links exist, which fields form, stays the same): each click cycles between its two modes, shown in the button label. ' + 'Fewer keys tries to lower the highest key count on any single portal. ' + 'Less walking flips a 2-link portal\'s mesh link when it isn\'t really on the way to the next stop, and relocates that portal earlier in the visit order, right where it best fits between two portals already walked back-to-back — such relocated portals are highlighted green in the Task List as a reminder to capture them (and gather enough of their own keys) early. ' + 'Switching mode always restarts the calculation clean, from the untouched algorithm — either mode is only a starting point, and you can still flip individual links, or reorder portals, afterwards as usual. ' + 'To drop every automatic and manual override at once and go back to the plain algorithm, use the Task List\'s Reset link orders button.

' + '

Freeze recalculation
' + 'The plan locks itself as soon as a new plan is completely calculated (once IITC has finished loading the map, and including the automatic anchor search), so it no longer moves while you pan, zoom or the map data refreshes. ' + 'Changing something about the plan itself — a menu option, the drawn polygon, a map layer — recalculates it and locks it again. ' + 'Use 🔒 Locked to prevent the script from recalculating the plan while you zoom into details or work with large areas. ' + 'The Task List keeps reflecting portal captures and links thrown in-game while locked — only the plan itself (link/field order) stays frozen. ' + 'Switch back to 🔓 Unlocked to let the plan itself refresh again.

' + '

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. ' + 'With the Keys plugin, its Keys video button fills in your key counts from a screen recording of your keys in Ingress. ' + 'The task list includes a navigation link for Google Maps and a print-friendly view. ' + 'Its Reroute button reorders the steps still to do, starting from your current position (GPS, else IITC\'s own location, else the map center), so you walk as little as possible — while still capturing each portal, and getting its keys, before anyone links to it, and without losing a field. ' + 'The links and fields stay the same, it works while the plan is locked too, and the new order holds until the plan itself changes (or Reset link orders). ' + '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/Avataar120/fanfields3/issues

', id: 'plugin_fanfields3_alert_help', title: 'Fan Fields 3 - Help', width: width, closeOnEscape: true }); }; // Statistics dialog: build the HTML for the current plan. Used both to open the dialog and // to refresh it live (see thisplugin.refreshStatisticsIfOpen) as the background plan changes. thisplugin.buildStatisticsHTML = function () { if (!thisplugin.sortedFanpoints || thisplugin.sortedFanpoints.length <= 3) { return '

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

'; } var totalLinks = thisplugin.donelinks.length; var validLinks = (thisplugin.validLinkCount !== undefined) ? thisplugin.validLinkCount : totalLinks; var totalFields = thisplugin.triangles.length; var validFields = (thisplugin.validTriangleCount !== undefined) ? thisplugin.validTriangleCount : totalFields; var linksText = (validLinks !== totalLinks) ? (validLinks + ' / ' + totalLinks) : validLinks.toString(); var fieldsText = (validFields !== totalFields) ? (validFields + ' / ' + totalFields) : validFields.toString(); var warn = ''; if (validLinks !== totalLinks || validFields !== totalFields) { warn = '⚠ Under-field invalid links excluded from counts'; } return '' + '' + '' + '' + '' + warn + '
FanPortals:' + (thisplugin.n - 1) + '
CenterKeys:' + thisplugin.centerKeys + '
Total links / keys:' + linksText + '
Fields:' + fieldsText + '
Build AP (links and fields):' + (validLinks * 313 + validFields * 1250).toString() + '
'; }; // Whether the Statistics dialog is currently open and visible. thisplugin.isStatisticsDialogOpen = function () { return $('#plugin_fanfields3_statistics_inner').is(':visible'); }; // Rebuild the Statistics dialog's content in place. Used to auto-refresh live as the // background plan changes (new links appearing in-game, fan field rotation, etc.), mirroring // thisplugin.refreshTaskListDialog for the Task List. thisplugin.refreshStatisticsDialog = function () { $('#plugin_fanfields3_statistics_inner').html(thisplugin.buildStatisticsHTML()); }; // Called after every plan recalculation (see updateLayer) so an open Statistics dialog // reflects the latest counts without the user having to close and reopen it. thisplugin.refreshStatisticsIfOpen = function () { if (thisplugin.isStatisticsDialogOpen()) { thisplugin.refreshStatisticsDialog(); } }; thisplugin.showStatistics = function () { if (!thisplugin.sortedFanpoints || thisplugin.sortedFanpoints.length <= 3) return; var width = 400; thisplugin.MaxDialogWidth = thisplugin.getMaxDialogWidth(); if (thisplugin.MaxDialogWidth < width) { width = thisplugin.MaxDialogWidth; } var isMobile = L && L.Browser && L.Browser.mobile; // Mobile: the Stats button lives in IITC's own sidebar/info pane, which covers the whole // screen there — switch back to the map pane first so the dialog opened below shows over // the map, not over the (now pointless) sidebar. if (isMobile && typeof window.show === 'function') { window.show('map'); } // draggable is already IITC's own default for a non-modal window.dialog() (see // core/code/dialog.js) — not something this plugin needs to (or can usefully) turn on. dialog({ html: '
' + thisplugin.buildStatisticsHTML() + '
', id: 'plugin_fanfields3_alert_statistics', title: 'Fan Fields 3 - Statistics', width: width, closeOnEscape: true }); // Mobile: pin to the bottom of the screen instead of jQuery UI's default vertical // centering, so the dialog doesn't sit over the middle of the map where the portals are. // IITC's window.dialog() prefixes the id we pass with "dialog-" for the actual jQuery UI // element (see addTaskListShiftButtons) — '#plugin_fanfields3_alert_statistics' alone // matches nothing. if (isMobile) { $('#dialog-plugin_fanfields3_alert_statistics') .dialog('option', 'position', { my: 'bottom', at: 'bottom-15', of: window }); } } thisplugin.exportDrawtools = function () { var alatlng, blatlng, layer; $.each(thisplugin.sortedFanpoints, function (index, portal) { $.each(portal.outgoing, function (targetIndex, targetPortal) { var meta = (portal.outgoingMeta && portal.outgoingMeta[targetPortal.guid]) ? portal.outgoingMeta[targetPortal.guid] : null; if (meta && meta.invalidUnderField) return; 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) { var meta = (portal.outgoingMeta && portal.outgoingMeta[targetPortal.guid]) ? portal.outgoingMeta[targetPortal.guid] : null; if (meta && meta.invalidUnderField) return; 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) { // Compat: window.map.flyTo is not available on all IITC builds (desktop). Fall back to // a plain setView, which every build supports. if (typeof window.map.flyTo === 'function') { window.map.flyTo(latlng, map.getZoom()); } else { window.map.setView(latlng, map.getZoom()); } // Mobile: the Task List covers most of the screen, so center the map on the portal, select // it the same way a map tap does (name in the bottom bar, details ready behind it) and close // the list. Desktop keeps the list open and shows the portal details. if (L && L.Browser && L.Browser.mobile) { if (window.portals[guid]) window.renderPortalDetails(guid); else window.urlPortal = guid; $('#plugin_fanfields3_exportText_inner') .closest('.ui-dialog-content') .dialog('close'); return; } if (window.portals[guid]) window.renderPortalDetails(guid); else window.urlPortal = guid; } thisplugin.isCompatiblePortalRoutePlugin = function () { var routePlugin = window.plugin && window.plugin.portalRoute; if (!routePlugin) return false; if (typeof routePlugin.replaceStops === 'function') return true; return (typeof routePlugin.clearStops === 'function' && typeof routePlugin.addStop === 'function'); }; thisplugin.getPortalRoutePlugin = function () { return thisplugin.isCompatiblePortalRoutePlugin() ? window.plugin.portalRoute : null; }; thisplugin.getPortalRouteStops = function () { var blockerPlan = thisplugin.computeBlockerPlan(); var stops = []; function addStop(guid, point) { var latlng = map.unproject(point, thisplugin.PROJECT_ZOOM); stops.push({ guid: guid || null, title: thisplugin.getPortalTitleByGuid(guid), lat: latlng.lat, lng: latlng.lng }); } thisplugin.getDisplayOrder().forEach(function (portal, index) { blockerPlan.stops.forEach(function (stop) { if (stop.slot === index) addStop(stop.guid, stop.point); }); addStop(portal.guid, portal.point); }); return stops; }; thisplugin.routeWithPortalRoute = function () { var routePlugin = thisplugin.getPortalRoutePlugin(); if (!routePlugin) { var anyRoutePlugin = thisplugin.getAnyPortalRoutePlugin(); dialog({ html: anyRoutePlugin ? '

Portal Route is loaded, but does not expose a compatible route import function.

' : '

Portal Route is not loaded.

', id: anyRoutePlugin ? 'plugin_fanfields3_alert_portal_route_incompatible' : 'plugin_fanfields3_alert_portal_route_missing', title: 'Fan Fields 3 - Portal Route', width: anyRoutePlugin ? 400 : 350, closeOnEscape: true }); return; } var stops = thisplugin.getPortalRouteStops(); if (!stops.length) return; if (typeof routePlugin.replaceStops === 'function') { routePlugin.replaceStops(stops, { openPanel: true, clearRoute: true }); return; } if (typeof routePlugin.clearStops === 'function' && typeof routePlugin.addStop === 'function') { routePlugin.clearStops(); stops.forEach(function (stop) { routePlugin.addStop(stop); }); if (routePlugin.state) routePlugin.state.panelOpen = true; if (typeof routePlugin.savePanelOpen === 'function') routePlugin.savePanelOpen(); if (typeof routePlugin.renderPanel === 'function') routePlugin.renderPanel(); if (typeof routePlugin.showMessage === 'function') { routePlugin.showMessage('Imported ' + stops.length + ' Fan Fields stops.'); } return; } dialog({ html: '

Portal Route is loaded, but does not expose a compatible route import function.

', id: 'plugin_fanfields3_alert_portal_route_incompatible', title: 'Fan Fields 3 - Portal Route', width: 400, closeOnEscape: true }); }; thisplugin.getPortalTitleByGuid = function (guid) { var marker = guid ? window.portals[guid] : undefined; return (marker && marker.options && marker.options.data && marker.options.data.title) || 'unknown title'; }; // "Portal A - Portal B (RES)" for a blocking link. thisplugin.describeBlockerLink = function (blocker) { var teamLabel = ''; if (blocker.team === window.TEAM_ENL) teamLabel = 'ENL'; else if (blocker.team === window.TEAM_RES) teamLabel = 'RES'; else if (blocker.team === window.TEAM_MAC) teamLabel = 'MAC'; return window.escapeHtmlSpecialChars(thisplugin.getPortalTitleByGuid(blocker.guidA)) + ' ↔ ' + window.escapeHtmlSpecialChars(thisplugin.getPortalTitleByGuid(blocker.guidB)) + (teamLabel ? ' (' + teamLabel + ')' : ''); }; // Task List: the row (and its expandable list of freed links) for one extra Destroy // stop of the blocker plan. Also adds the stop to the Google Maps route. thisplugin.buildBlockerStopHTML = function (stop, gmStops) { var latlng = map.unproject(stop.point, thisplugin.PROJECT_ZOOM); var lat = Math.round(latlng.lat * 10000000) / 10000000; var lng = Math.round(latlng.lng * 10000000) / 10000000; var rawTitle = thisplugin.getPortalTitleByGuid(stop.guid); var title = window.escapeHtmlSpecialChars(rawTitle); var guid = stop.guid || ''; var toggleId = 'plugin_fanfields3_exportText_blk_' + String(stop.guid || stop.key).replace(/[^\w-]/g, '_'); var action = 'Destroy'; var ownTeam = thisplugin.getOwnFactionTeam(); var needsJarvis = stop.blockers.some(function (blocker) { return blocker.team === ownTeam; }); gmStops.push(lat + ',' + lng); var rowTitle = 'Free ' + stop.blockers.length + ' blocking link(s) before the links they block are thrown here. Adds about ' + thisplugin.formatDistance(stop.detour) + ' of walking.' + (needsJarvis ? ' Includes a link of your own faction: it needs a Jarvis/ADA flip.' : ''); var text = ''; text += '✖'; text += ''; text += ' '; text += ' '; text += ''; text += ''; text += ''; text += ' ' + title + ''; text += ' ' + title + ''; text += '
(frees ' + stop.blockers.length + ')'; text += ''; text += ''; text += '\n'; text += ''; stop.blockers.forEach(function (blocker) { text += 'Frees' + thisplugin.describeBlockerLink(blocker) + (blocker.team === ownTeam ? ' – own faction, needs a Jarvis' : '') + 'blocks ' + blocker.blocked.length + '\n'; }); text += '\n'; return text; }; // Keys still needed at a plan portal: one per incoming link, except those already made in-game // (when "Grey out done links" is on) — that key was already spent to make the link. thisplugin.getKeysStillNeeded = function (portal) { var alreadyLinkedIncomingCount = 0; if (thisplugin.greyOutExistingLinks && portal.incoming && portal.incoming.length > 0) { portal.incoming.forEach(function (srcPortal) { var srcMeta = srcPortal.outgoingMeta && srcPortal.outgoingMeta[portal.guid]; var isInvalid = srcMeta && srcMeta.invalidUnderField; if (!isInvalid && thisplugin.isLinkInGame(srcPortal.guid, portal.guid)) { alreadyLinkedIncomingCount++; } }); } var total = (portal.incomingValidCount !== undefined) ? portal.incomingValidCount : (portal.incoming || []).length; return total - alreadyLinkedIncomingCount; }; // Keys held for a portal, per the LiveInventory plugin, or else the Keys plugin; 0 without either. thisplugin.getAvailableKeys = function (guid) { if (window.plugin.LiveInventory) { if (window.plugin.LiveInventory.keyGuidCount) { return window.plugin.LiveInventory.keyGuidCount[guid] || 0; } if (window.plugin.LiveInventory.keyCount) { return window.plugin.LiveInventory.keyCount.find(obj => obj.portalCoupler.portalGuid === guid) ?.count || 0; } return 0; } if (window.plugin.keys) return window.plugin.keys.keys[guid] || 0; return 0; }; // Finished portals (Action "Nothing"), keyed by guid -> { partners: guids of the plan portals // it was seen linked to in-game }. A finished portal stays finished — missing data (portal not // loaded, links hidden at this zoom, key counts refreshing) doesn't bring it back — until: // - the plan changes (donePortalsPlanKey, see getPlanShapeKey); // - the portal itself is known to belong to another team (destroyed or flipped), or to have // fewer than 8 resonators; // - one of its in-game links is gone and the portal at its other end is known to belong to // another team. thisplugin.donePortalGuids = {}; thisplugin.donePortalsPlanKey = null; thisplugin.syncDonePortals = function () { var key = thisplugin.getPlanShapeKey(); if (key !== thisplugin.donePortalsPlanKey) { thisplugin.donePortalGuids = {}; thisplugin.donePortalsPlanKey = key; } }; // Whether the portal's loaded data says it's not ours; false while its data isn't known. thisplugin.isPortalKnownLost = function (guid) { var ownTeam = thisplugin.getOwnFactionTeam(); var team = thisplugin.getPortalTeam(window.portals[guid]); return ownTeam !== undefined && team !== undefined && team !== ownTeam; }; // Whether the portal's loaded data says it has fewer than 8 resonators; false while unknown. thisplugin.isPortalKnownDamaged = function (guid) { var marker = window.portals[guid]; var resCount = marker && marker.options && marker.options.data ? marker.options.data.resCount : undefined; return resCount !== undefined && resCount < 8; }; // Whether fp counts as finished: isNothingNow is what the live data says right now. Only a // live "Nothing" marks the portal finished; see donePortalGuids for what unmarks it. thisplugin.isPortalDone = function (fp, isNothingNow) { var entry = thisplugin.donePortalGuids[fp.guid]; if (entry) { var lost = thisplugin.isPortalKnownLost(fp.guid) || thisplugin.isPortalKnownDamaged(fp.guid) || Object.keys(entry.partners).some(function (partnerGuid) { return !thisplugin.isLinkInGame(fp.guid, partnerGuid) && thisplugin.isPortalKnownLost(partnerGuid); }); if (lost) { delete thisplugin.donePortalGuids[fp.guid]; entry = null; } } if (!entry && !isNothingNow) return false; entry = entry || (thisplugin.donePortalGuids[fp.guid] = { partners: {} }); (fp.outgoing || []).concat(fp.incoming || []).forEach(function (partner) { if (thisplugin.isLinkInGame(fp.guid, partner.guid)) entry.partners[partner.guid] = true; }); return true; }; // Task List: build the HTML for the current plan. Used both to open the dialog and to // refresh it live (see thisplugin.refreshTaskListIfOpen) as the background plan changes. thisplugin.buildTaskListHTML = function () { var text = ''; let fieldSymbol = '▲'; text += ''; text += ''; text += ''; text += ''; if (window.plugin.keys || window.plugin.LiveInventory) { text += ''; } else { text += ''; } text += ''; text += ''; text += ''; // Stops (lat,lng) for the Google Maps route, in walk order; the URL itself is built once // the whole list is known, since on mobile it may have to be cut short. var gmStops = []; // The walk order (relocations from "Less walking" included) — never sortedFanpoints // directly, which is the algorithm's own BUILD order and must stay untouched by display. var displayOrder = thisplugin.getDisplayOrder(); // Blockers: extra Destroy rows slotted into the walk (see computeBlockerPlan). var blockerPlan = thisplugin.computeBlockerPlan(); thisplugin.syncDonePortals(); displayOrder.forEach(function (portal, index) { blockerPlan.stops.forEach(function (stop) { if (stop.slot === index) text += thisplugin.buildBlockerStopHTML(stop, gmStops); }); 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 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); // Volatile Scout Controlled portal (ornament 'sc5_p'): scanning it awards 3 scout // control points instead of 1 — worth flagging next to the name as a reminder to scan it. let ornaments = (p !== undefined && p.options && p.options.data && p.options.data.ornaments) ? p.options.data.ornaments : []; let isVolatileScoutPortal = ornaments.indexOf('sc5_p') !== -1; // Is this portal already ours? If not (neutral, enemy faction, or Machina), it has // to be captured before anything else here matters. A portal not yet loaded from // the server (p undefined, or no team data) is treated the same as "not ours". Even // when it's already ours, fewer than 8 resonators means it's not fully secured yet, // so it still needs (re)capturing. var ownTeam = thisplugin.getOwnFactionTeam(); var portalTeam = thisplugin.getPortalTeam(p); var portalResCount = (p !== undefined && p.options && p.options.data) ? p.options.data.resCount : undefined; var portalOwnedByUs = ownTeam !== undefined && portalTeam === ownTeam; var portalFullyResonated = portalResCount !== undefined && portalResCount >= 8; var needsCapture = !portalOwnedByUs || !portalFullyResonated; // Outgoing links still to throw from here (already-made in-game links don't count, // when "Grey out done links" is on). Drives both the Action and the Links cell fade. var alreadyDoneOutgoingCount = 0; if (thisplugin.greyOutExistingLinks && portal.outgoing.length > 0) { portal.outgoing.forEach(function (outPortal) { var outMeta = portal.outgoingMeta && portal.outgoingMeta[outPortal.guid]; var isInvalid = outMeta && outMeta.invalidUnderField; if (!isInvalid && thisplugin.isLinkInGame(portal.guid, outPortal.guid)) { alreadyDoneOutgoingCount++; } }); } var totalOutgoingCount = (portal.outgoingValidCount !== undefined) ? portal.outgoingValidCount : portal.outgoing.length; var remainingOutgoingCount = totalOutgoingCount - alreadyDoneOutgoingCount; var keysNeeded = thisplugin.getKeysStillNeeded(portal); let availableKeys = 0; let hasKeysPluginData = !!(window.plugin.keys || window.plugin.LiveInventory); let hasEnoughKeys = false; let keyColorAttribute = ''; if (hasKeysPluginData) { availableKeys = thisplugin.getAvailableKeys(portal.guid); hasEnoughKeys = availableKeys >= keysNeeded; keyColorAttribute = hasEnoughKeys ? 'plugin_fanfields3_enoughKeys' : 'plugin_fanfields3_notEnoughKeys'; }; // Keys plugin quick-set button: only for the actual "keys" plugin (its count can be // written to), never for LiveInventory (a read-only reflection of the real inventory). // Green check = click to mark "enough keys" (raises the keys plugin count to at least // keysNeeded). Red cross = already marked enough; click to reset that portal back to 0. // Its own state always reflects the keys plugin's own count for this portal, even when // LiveInventory is also installed and takes priority for the Keys column's own number. var keysSetButtonHtml = ''; if (window.plugin.keys && keysNeeded > 0) { var ownKeysPluginCount = window.plugin.keys.keys[portal.guid] || 0; var hasEnoughKeysPluginOwn = ownKeysPluginCount >= keysNeeded; keysSetButtonHtml = ' '; } // Action for this portal: the next thing standing in the way of finishing it here, // checked in priority order. "Nothing" means it's fully wrapped up. // With a keys plugin, the keys already held settle it; without one, any key still // needed counts as outstanding since there's no way to know what's in the inventory. var needsKeys = hasKeysPluginData ? !hasEnoughKeys : keysNeeded > 0; var action = needsCapture ? 'Capture' : (remainingOutgoingCount > 0 ? 'Link' : (needsKeys ? 'Keys' : 'Nothing')); if (thisplugin.isPortalDone(portal, action === 'Nothing')) action = 'Nothing'; // Google Maps route: skip a portal with nothing left to do here — no point stopping // there again, and it only lengthens the route for everyone else on it. if (action !== 'Nothing') { gmStops.push(`${lat},${lng}`); } // A cell whose own number is already settled fades and strikes through on its own, // independently of what the portal's overall Action says. var linksCellDone = remainingOutgoingCount === 0; var keysCellDone = keysNeeded === 0 || (hasKeysPluginData && hasEnoughKeys); // "Less walking" relocated this portal earlier in the walk (see computeDistanceOrderFlips): // flag it so it's captured, with enough of its own keys gathered, ahead of schedule. Not // while a "Reroute" order is shown: that order already places every portal early enough. let isRelocatedForLessWalking = !thisplugin.routeOrderInfo && !!(thisplugin.relocatedForLessWalkingGuids && thisplugin.relocatedForLessWalkingGuids[portal.guid]); // Both classes can apply at once (a portal with nothing left to do that was also // relocated): "done" wins in the stylesheet, so a finished portal always shows faded // yellow and struck through, like every other finished portal. var portalRowClasses = []; if (action === 'Nothing') portalRowClasses.push('plugin_fanfields3_portal_done'); if (isRelocatedForLessWalking) portalRowClasses.push('plugin_fanfields3_portal_relocated'); var portalRowClass = portalRowClasses.join(' '); // Row start text += ''; // List Item Index (Pos.) text += ''; // Action text += ''; // Spacer column (aligns with the flip-button column on link detail rows) text += ''; // Portal Name text += ''; // Keys text += '' + (hasKeysPluginData ? availableKeys + '/' : '') + keysNeeded + keysSetButtonHtml + ''; // Links: how many outgoing links are still left to throw from here, not the portal's // total outgoing count — mirrors the Keys column, which already shows keys still needed // rather than the total incoming count. var linksTitle = (remainingOutgoingCount !== totalOutgoingCount) ? ' title="' + remainingOutgoingCount + ' still to throw, out of ' + totalOutgoingCount + ' total"' : ''; text += '' + remainingOutgoingCount + ''; let fieldsCreatedAtThisPortal = 0 if (portal.outgoing.length > 0) { portal.outgoing.forEach(function (outPortal, outIndex) { let meta = portal.outgoingMeta?.[outPortal.guid]; fieldsCreatedAtThisPortal += (meta && meta.fieldsCreatedValid !== undefined) ? meta.fieldsCreatedValid : (meta && meta.creatingFieldsWith ? meta.creatingFieldsWith.length : 0); }); } // Fields: created by this portal's outgoing links, so once none are left to throw the // fields are settled too — fade and strike them through like the Links 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); let meta = portal.outgoingMeta?.[outPortal.guid]; // Link already exists in-game (own faction)? Fade & strike through the whole line. let linkDone = thisplugin.greyOutExistingLinks && thisplugin.isLinkInGame(portal.guid, outPortal.guid); // Row start let linkDetailText = ''; // List Item Index (Pos.) — the target's WALK position, not its build-order index. linkDetailText += ''; // Action linkDetailText += ''; // ghi#23 (link flip): swap this link's direction, in its own compact column. Excluded only // for a link already done in-game (nothing left to flip) — anchor (fan/star) links are flippable too. 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 fieldsCreatedByThisLink = (meta && meta.fieldsCreatedValid !== undefined) ? meta.fieldsCreatedValid : (meta && meta.creatingFieldsWith ? meta.creatingFieldsWith.length : 0); linkDetailText += ''; // Row End linkDetailText += '\n'; text += linkDetailText; }); text += '\n'; } // end if portal.outgoing.length > 0 }); text += '
Pos.ActionPortal Name' + 'Keys (owned/needed)' + 'Keys' + 'KeysLinksFields
' + (index) + ''; var onRouteBlockers = blockerPlan.onRoute[portal.guid]; var onRouteTag = onRouteBlockers ? ' ✖' : ''; text += ' '; text += ' '; text += ''; const gmapsHref = `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}&query_destination_id=(${uriTitle})`; if (isVolatileScoutPortal) { text += '📷 '; } // 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}`; if (onRouteBlockers) { text += '
(frees ' + onRouteBlockers.length + ')'; } text += '
' + fieldSymbol.repeat(fieldsCreatedAtThisPortal) + '
'; if (window.plugin.keys || window.plugin.LiveInventory) { text += '
Adjust available keys using your keys plugin.
'; }; if (blockerPlan.stops.length || blockerPlan.unresolved.length) { text += '
'; if (blockerPlan.stops.length) { text += '✖ ' + blockerPlan.blockers.length + ' blocking link(s), ' + blockerPlan.stops.length + ' extra stop(s), about +' + thisplugin.formatDistance(blockerPlan.extraDistance) + ' of walking.'; } if (blockerPlan.unresolved.length) { text += '
⚠ ' + blockerPlan.unresolved.length + ' blocking link(s) left alone (every way to free them needs more than the maximum detour): ' + blockerPlan.unresolved.map(function (blocker) { return thisplugin.describeBlockerLink(blocker); }).join('; ') + '
'; } text += '
'; } var routeInfo = thisplugin.routeOrderInfo; if (routeInfo) { text += '
🚶 Steps still to do reordered from your position (' + routeInfo.source + '): about ' + thisplugin.formatDistance(routeInfo.after) + ' of walking' + (routeInfo.after < routeInfo.before - 1 ? ' instead of ' + thisplugin.formatDistance(routeInfo.before) + '.' : ' — the current order was already the shortest found.') + '
'; } text += '
'; // On mobile, only the next stops still to do are sent (see GOOGLE_MAPS_MAX_STOPS_MOBILE); // as portals get done they drop out of gmStops, so reopening the link moves the route on. var gmStopsTotal = gmStops.length; var gmStopsUsed = (L && L.Browser && L.Browser.mobile) ? gmStops.slice(0, thisplugin.GOOGLE_MAPS_MAX_STOPS_MOBILE) : gmStops; var gmnav = 'http://maps.google.com/maps/dir/' + gmStopsUsed.map(function (s) { return s + '/'; }).join('') + '&nav=1'; var gmnavLabel = 'Navigate with Google Maps' + (gmStopsUsed.length < gmStopsTotal ? ' (next ' + gmStopsUsed.length + ' of ' + gmStopsTotal + ' stops)' : ''); let flipCount = Object.keys(thisplugin.manualLinkFlips || {}).length; text += '
' + ' ' + ' ' + '
'; text += '
'; if (thisplugin.isCompatiblePortalRoutePlugin()) { text += 'Route with Portal Route
'; } text += '' + gmnavLabel + ''; text += '
'; return text; }; // end buildTaskListHTML // ghi#23 (link flip): remember which portals' link-detail lists are expanded, so a // refresh (flip/reset, or a live background update) doesn't visually collapse the // dialog back to its default state. thisplugin.getTaskListExpandedGuids = function () { var guids = []; $('#plugin_fanfields3_exportText_inner [plugin_fanfields3_exportText_toggle="toggle"]:checked') .each(function () { var guid = $(this).attr('data-guid'); if (guid) guids.push(guid); }); return guids; }; thisplugin.restoreTaskListExpandedGuids = function (guids) { var $inner = $('#plugin_fanfields3_exportText_inner'); guids.forEach(function (guid) { var $toggle = $inner.find('[plugin_fanfields3_exportText_toggle="toggle"][data-guid="' + guid + '"]'); if (!$toggle.length) return; $toggle.prop('checked', true); $toggle.parents() .next('.plugin_fanfields3_exportText_LinkDetails') .show(); $toggle.prev('.plugin_fanfields3_exportText_Label') .attr('aria-expanded', true); }); }; // The Task List toggle for a given guid, and whether its portal row is finished (Action "Nothing"). thisplugin.findTaskListToggle = function (guid) { return $('#plugin_fanfields3_exportText_inner [plugin_fanfields3_exportText_toggle="toggle"][data-guid="' + guid + '"]'); }; thisplugin.isTaskListToggleDone = function ($toggle) { return $toggle.closest('tr').hasClass('plugin_fanfields3_portal_done'); }; // Rebuild the Task List dialog's content in place, preserving the expanded/collapsed // per-portal link lists. Used after a flip/reset, and to auto-refresh live as the // background plan changes (new links appearing in-game, fan field rotation, etc.). // An expanded portal that has just become finished is collapsed, and the next unfinished // row below it with link details is expanded instead, so the list follows the walk. thisplugin.refreshTaskListDialog = function () { var expandedGuids = thisplugin.getTaskListExpandedGuids(); var doneBefore = {}; expandedGuids.forEach(function (guid) { doneBefore[guid] = thisplugin.isTaskListToggleDone(thisplugin.findTaskListToggle(guid)); }); $('#plugin_fanfields3_exportText_inner').html(thisplugin.buildTaskListHTML()); thisplugin.wireTaskListHandlers(); var guidsToExpand = []; expandedGuids.forEach(function (guid) { var $toggle = thisplugin.findTaskListToggle(guid); if (doneBefore[guid] || !$toggle.length || !thisplugin.isTaskListToggleDone($toggle)) { if (guidsToExpand.indexOf(guid) === -1) guidsToExpand.push(guid); return; } $toggle.closest('tbody').nextAll('tbody.plugin_fanfields3_exportText_Portal').each(function () { var $nextToggle = $(this).find('[plugin_fanfields3_exportText_toggle="toggle"]'); if (!$nextToggle.length || thisplugin.isTaskListToggleDone($nextToggle)) return true; var nextGuid = $nextToggle.attr('data-guid'); if (guidsToExpand.indexOf(nextGuid) === -1) guidsToExpand.push(nextGuid); return false; }); }); thisplugin.restoreTaskListExpandedGuids(guidsToExpand); }; // Whether the Task List dialog is currently open and visible. thisplugin.isTaskListDialogOpen = function () { return $('#plugin_fanfields3_exportText_inner').is(':visible'); }; // Called after every plan recalculation (see updateLayer) so an open Task List reflects // background changes — new links appearing in-game, fan field rotation, etc. — without // the user having to close and reopen it. thisplugin.refreshTaskListIfOpen = function () { if (thisplugin.isTaskListDialogOpen()) { thisplugin.refreshTaskListDialog(); } }; thisplugin.wireTaskListHandlers = function () { var $inner = $('#plugin_fanfields3_exportText_inner'); // On mobile, tapping any text carrying a title attribute (warning icons, the camera // icon, buttons, ...) pops up a native tooltip instead of just registering the tap — // strip them there so touch stays a plain tap. Desktop keeps its hover tooltips. if (L && L.Browser && L.Browser.mobile) { $inner.find('[title]').removeAttr('title'); } $inner.find('[plugin_fanfields3_exportText_toggle="toggle"]') .each(function () { const $toggle = $(this); const $label = $toggle.prev('.plugin_fanfields3_exportText_Label'); const $details = $toggle.parents() .next('.plugin_fanfields3_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 } }); $inner.find('[plugin_fanfields3_exportText_toggle="toggle"]') .change(function () { const isChecked = $(this) .is(':checked'); $(this) .parents() .next('.plugin_fanfields3_exportText_LinkDetails') .toggle(); $(this) .prev('.plugin_fanfields3_exportText_Label') .attr('aria-expanded', isChecked); }); // ghi#23 (link flip): reverse a link's direction, recompute the plan, and refresh this dialog in place. $inner .off('click.plugin_fanfields3_link_flip') .on('click.plugin_fanfields3_link_flip', '.plugin_fanfields3_link_flip_btn', function (ev) { ev.preventDefault(); var guidA = $(this).attr('data-guid-a'); var guidB = $(this).attr('data-guid-b'); thisplugin.toggleLinkFlip(guidA, guidB); thisplugin.refreshTaskListDialog(); }); $('#plugin_fanfields3_reset_link_flips_btn') .off('click') .on('click', function () { thisplugin.resetLinkFlips(); thisplugin.refreshTaskListDialog(); }); // Keys plugin quick-set button: mark "enough keys" (raises the keys plugin count to at // least what this portal needs) or, once marked, reset it back to 0. $inner .off('click.plugin_fanfields3_keys_set') .on('click.plugin_fanfields3_keys_set', '.plugin_fanfields3_keys_setbtn', function (ev) { ev.preventDefault(); var guid = $(this).attr('data-guid'); var needed = parseInt($(this).attr('data-needed'), 10) || 0; thisplugin.toggleKeysPluginCount(guid, needed); thisplugin.refreshTaskListDialog(); }); $('#plugin_fanfields3_export_pdf_btn') .off('click') .on('click', function () { thisplugin.exportTaskListToPDF(); }); if (thisplugin.isCompatiblePortalRoutePlugin()) { $('#plugin_fanfields3_portal_route_link') .off('click') .on('click', function (ev) { ev.preventDefault(); thisplugin.routeWithPortalRoute(); }); } }; // Show as list thisplugin.exportText = function () { thisplugin.exportDialogWidth = 500; var width = thisplugin.exportDialogWidth; thisplugin.MaxDialogWidth = thisplugin.getMaxDialogWidth(); if (thisplugin.MaxDialogWidth < thisplugin.exportDialogWidth) { width = thisplugin.MaxDialogWidth; } dialog({ html: '
' + thisplugin.buildTaskListHTML() + '
', id: 'plugin_fanfields3_alert_textExport', title: 'Fan Fields 3 - Task List', width: width, closeOnEscape: true }); // Pinned to the top of the screen rather than jQuery UI's default vertical centering: as // the dialog's height gets capped (see getMaxDialogHeight), centering would just push it // further down instead of shrinking it upward, defeating the point of the cap on a short // mobile screen. IITC's window.dialog() prefixes the id we pass with "dialog-" for the // actual jQuery UI element (see addTaskListShiftButtons below, which already accounts for // this) — '#plugin_fanfields3_alert_textExport' alone matches nothing. $('#dialog-plugin_fanfields3_alert_textExport') .dialog('option', 'position', { my: 'top', at: 'top+10', of: window }); thisplugin.wireTaskListHandlers(); thisplugin.addTaskListShiftButtons(); thisplugin.expandTaskListFirstPending(); thisplugin.scrollTaskListToFirstPending(); }; // Expand the link details of the first row with something still left to do (any row not // marked done, Destroy stops included) that has link details to show. thisplugin.expandTaskListFirstPending = function () { $('#plugin_fanfields3_exportText_inner tbody.plugin_fanfields3_exportText_Portal').each(function () { var $toggle = $(this).find('[plugin_fanfields3_exportText_toggle="toggle"]'); if (!$toggle.length || thisplugin.isTaskListToggleDone($toggle)) return true; thisplugin.restoreTaskListExpandedGuids([$toggle.attr('data-guid')]); return false; }); }; // Scroll the open Task List so its first row with something still left to do (any row not // marked done, Destroy stops included) sits at the top of the visible area, with the // previous row kept just above it for context. Stays at the top when that's the very first // row, or when every row is already done. thisplugin.scrollTaskListToFirstPending = function () { var $content = $('#dialog-plugin_fanfields3_alert_textExport'); if (!$content.length) return; var $rows = $content.find('.plugin_fanfields3_exportText_Portal > tr'); var pendingIndex = -1; $rows.each(function (i) { if (!$(this).hasClass('plugin_fanfields3_portal_done')) { pendingIndex = i; return false; } }); if (pendingIndex <= 0) return; var $target = $rows.eq(pendingIndex - 1); $content.scrollTop($content.scrollTop() + $target.offset().top - $content.offset().top); }; // Task List dialog: add the same anchor shift (rotation) controls as the map's own // topleft control, plus a Refresh button to force an IITC map data refresh, to the left // of the dialog's OK button, so these don't require leaving the Task List open. Only // needs wiring once per dialog open — unlike the table itself, the button pane isn't // touched by refreshTaskListDialog(). thisplugin.addTaskListShiftButtons = function () { var id = 'plugin_fanfields3_alert_textExport'; var $dlg = $('#dialog-' + id + ' .ui-dialog-content'); if (!$dlg.length) $dlg = $('#dialog-' + id); if (!$dlg.length) $dlg = $('#' + id); if (!$dlg.length) return; var $ui = $dlg.closest('.ui-dialog'); var $buttonpane = ($ui.length ? $ui : $dlg).find('.ui-dialog-buttonpane'); if (!$buttonpane.length) return; // On a tall Task List (many portals), the dialog can grow taller than the visible // viewport, pushing this very button row (and the OK button) below the visible screen — // unreachable, since jQuery UI's dialog has no built-in max height. Cap the dialog to the // viewport and let only its content area scroll, so the title bar and this button row // always stay in view. Reapplied every time this is called (each dialog open), since the // viewport can differ between opens (e.g. after rotating the screen). if ($ui.length) { $ui.css({ 'max-height': thisplugin.getMaxDialogHeight() + 'px', 'display': 'flex', 'flex-direction': 'column' }); $ui.find('.ui-dialog-content') .css({ 'flex': '1 1 auto', 'overflow-y': 'auto' }); $buttonpane.css('flex', '0 0 auto'); } // Already added (e.g. dialog reused rather than recreated)? if ($buttonpane.find('#plugin_fanfields3_tasklist_shift_left').length) return; var buttonsHtml = '' + '' + '' + ''; if (window.plugin.keys) { buttonsHtml += ''; } var $buttonset = $buttonpane.find('.ui-dialog-buttonset'); if ($buttonset.length) { $buttonset.prepend(buttonsHtml); } else { $buttonpane.prepend(buttonsHtml); } $buttonpane.find('#plugin_fanfields3_tasklist_shift_left') .off('click') .on('click', function () { thisplugin.previousStartingPoint(); }); $buttonpane.find('#plugin_fanfields3_tasklist_shift_right') .off('click') .on('click', function () { thisplugin.nextStartingPoint(); }); $buttonpane.find('#plugin_fanfields3_tasklist_refresh') .off('click') .on('click', function () { thisplugin.forceMapDataRefresh(); }); $buttonpane.find('#plugin_fanfields3_tasklist_reroute') .off('click') .on('click', function () { var $btn = $(this); if ($btn.prop('disabled')) return; $btn.prop('disabled', true).text('Locating…'); thisplugin.rerouteFromPlayerPosition(function () { $btn.prop('disabled', false).text('Reroute'); }); }); $buttonpane.find('#plugin_fanfields3_tasklist_keysvideo') .off('click') .on('click', function () { thisplugin.openKeysVideoDialog(); }); }; // --------------------------------------------------------------------- // Task List "Reroute" button: reorders the steps still to do so that, starting from where the // player stands, they walk as little as possible. Only the walk/display order changes // (thisplugin.routeOrderGuids) — never which links exist, their direction or which fields // form. The order must stay playable: // - a portal that still has to be captured, or whose keys are still missing, is visited // before any portal that throws a link at it. A portal that's already ours with enough of // its keys held (per a Keys/LiveInventory plugin) can receive links before its own visit; // - it may not make more links impossible to throw from under a field, nor lose a field, // compared with the current order (see thisplugin.simulateWalk). // --------------------------------------------------------------------- thisplugin.ROUTE_SEARCH_BUDGET_MS = 1500; thisplugin.ROUTE_GEOLOCATION_TIMEOUT_MS = 10000; // Calls back with the player's position { latlng, source }: the device's own location first, // else the last position known to IITC's user-location plugin, else the center of the map. thisplugin.getPlayerPosition = function (callback) { var answered = false; function answer(position) { if (answered) return; answered = true; callback(position); } function fallback() { var user = window.plugin.userLocation && window.plugin.userLocation.user; var ll = user && user.latlng; if (ll && (ll.lat || ll.lng)) { answer({ latlng: L.latLng(ll.lat, ll.lng), source: 'IITC location' }); } else { answer({ latlng: map.getCenter(), source: 'map center, as your location was not available' }); } } if (!navigator.geolocation) { fallback(); return; } // A permission prompt left unanswered never calls back at all. setTimeout(fallback, thisplugin.ROUTE_GEOLOCATION_TIMEOUT_MS + 2000); navigator.geolocation.getCurrentPosition(function (pos) { answer({ latlng: L.latLng(pos.coords.latitude, pos.coords.longitude), source: 'GPS' }); }, fallback, { enableHighAccuracy: true, timeout: thisplugin.ROUTE_GEOLOCATION_TIMEOUT_MS, maximumAge: 30000 }); }; // Where each portal of the walk stands, from the live game state: // - pending: something is still left to do there (capture, links to throw, keys to get); // - ready: it can already receive links (ours, with enough of its keys held); // - targets: the portals it still has links to throw at. // Links that can't be thrown from under a field are left out, as in the Task List. thisplugin.getRouteStepStates = function (walk) { var ownTeam = thisplugin.getOwnFactionTeam(); var hasKeysPlugin = !!(window.plugin.keys || window.plugin.LiveInventory); function isThrowable(srcFp, dstGuid) { var meta = srcFp.outgoingMeta && srcFp.outgoingMeta[dstGuid]; return !(meta && meta.invalidUnderField) && !thisplugin.isLinkInGame(srcFp.guid, dstGuid); } thisplugin.syncDonePortals(); var states = {}; walk.forEach(function (fp) { var marker = window.portals[fp.guid] || fp.portal; var data = marker && marker.options && marker.options.data; var owned = ownTeam !== undefined && thisplugin.getPortalTeam(marker) === ownTeam; var needsCapture = !owned || !(data && data.resCount >= 8); var targets = (fp.outgoing || []).filter(function (target) { return isThrowable(fp, target.guid); }); var keysNeeded = (fp.incoming || []).filter(function (src) { return isThrowable(src, fp.guid); }).length; var enoughKeys = hasKeysPlugin ? thisplugin.getAvailableKeys(fp.guid) >= keysNeeded : keysNeeded === 0; states[fp.guid] = { pending: needsCapture || targets.length > 0 || !enoughKeys ? !thisplugin.isPortalDone(fp, false) : false, ready: owned && enoughKeys, targets: targets }; }); return states; }; // Computes the "Reroute" walk order from startLatLng. Returns null when no step is left to do, // else { order: guids (steps done first, in their current order, then the remaining ones), // before, after: meters walked through the remaining steps in the current / new order }. thisplugin.computeRouteOrder = function (startLatLng) { var walk = thisplugin.getDisplayOrder(); var states = thisplugin.getRouteStepStates(walk); var done = walk.filter(function (fp) { return !states[fp.guid].pending; }); var pending = walk.filter(function (fp) { return states[fp.guid].pending; }); var m = pending.length; if (m === 0) return null; var indexByGuid = {}; pending.forEach(function (fp, i) { indexByGuid[fp.guid] = i; }); // Pairs [before, after]: a portal not ready yet comes before whoever throws a link at it. var precedences = []; pending.forEach(function (fp, i) { states[fp.guid].targets.forEach(function (target) { var ti = indexByGuid[target.guid]; if (ti !== undefined && !states[target.guid].ready) precedences.push([ti, i]); }); }); // Walking distances between pending portals, the start being index m. var latLngs = pending.map(function (fp) { return map.unproject(fp.point, thisplugin.PROJECT_ZOOM); }); latLngs.push(startLatLng); var dist = latLngs.map(function (a) { return latLngs.map(function (b) { return a.distanceTo(b); }); }); function lengthOf(seq) { var total = dist[m][seq[0]]; for (var i = 1; i < seq.length; i++) total += dist[seq[i - 1]][seq[i]]; return total; } function violationsOf(seq) { var pos = []; seq.forEach(function (idx, p) { pos[idx] = p; }); return precedences.filter(function (pair) { return pos[pair[0]] > pos[pair[1]]; }).length; } function evaluate(seq) { var sim = thisplugin.simulateWalk(done.concat(seq.map(function (idx) { return pending[idx]; }))); return { seq: seq, length: lengthOf(seq), violations: violationsOf(seq), invalid: Object.keys(sim.invalid).length, fields: sim.triangles.length }; } // Playability first (steps in a possible order, links throwable, fields kept), then walking. function isBetter(a, b) { if (a.violations !== b.violations) return a.violations < b.violations; if (a.invalid !== b.invalid) return a.invalid < b.invalid; if (a.fields !== b.fields) return a.fields > b.fields; return a.length < b.length - 1; } var deadline = Date.now() + thisplugin.ROUTE_SEARCH_BUDGET_MS; // Local search: move a run of 1 to 3 steps elsewhere, or walk a stretch backwards, keeping // any change that makes the order better, until none does (or time is up). function improve(best) { function tryCandidate(seq) { if (Date.now() >= deadline) return false; var violations = violationsOf(seq); if (violations > best.violations) return false; if (violations === best.violations && lengthOf(seq) >= best.length - 1) return false; var candidate = evaluate(seq); if (!isBetter(candidate, best)) return false; best = candidate; return true; } var improved = true; while (improved && Date.now() < deadline) { improved = false; for (var len = 1; len <= 3 && !improved; len++) { for (var i = 0; i + len <= m && !improved; i++) { var run = best.seq.slice(i, i + len); var rest = best.seq.slice(0, i).concat(best.seq.slice(i + len)); for (var j = 0; j <= rest.length && !improved; j++) { if (j === i) continue; improved = tryCandidate(rest.slice(0, j).concat(run, rest.slice(j))); } } } for (var a = 0; a < m - 1 && !improved; a++) { for (var b = a + 1; b < m && !improved; b++) { improved = tryCandidate(best.seq.slice(0, a) .concat(best.seq.slice(a, b + 1).reverse(), best.seq.slice(b + 1))); } } } return best; } // Nearest next step whose prerequisites are already visited (the earliest remaining step of // the current order when none is, which only a loop in the prerequisites can cause). function nearestNeighbour() { var seq = []; var placed = []; var current = m; while (seq.length < m) { var choice = -1; for (var i = 0; i < m; i++) { if (placed[i]) continue; var blocked = precedences.some(function (pair) { return pair[1] === i && !placed[pair[0]]; }); if (!blocked && (choice === -1 || dist[current][i] < dist[current][choice])) choice = i; } if (choice === -1) { for (choice = 0; placed[choice]; choice++); } seq.push(choice); placed[choice] = true; current = choice; } return seq; } var currentOrder = pending.map(function (fp, i) { return i; }); var fromCurrent = evaluate(currentOrder); var best = improve(fromCurrent); var fromNearest = improve(evaluate(nearestNeighbour())); if (isBetter(fromNearest, best)) best = fromNearest; return { order: done.map(function (fp) { return fp.guid; }) .concat(best.seq.map(function (idx) { return pending[idx].guid; })), before: fromCurrent.length, after: best.length }; }; // "Reroute" button: locates the player, reorders the steps still to do from there, and shows // the new order everywhere the walk order is used, without recalculating the plan itself — so // it works the same whether the plan is Locked or not. Calls onDone once finished. thisplugin.rerouteFromPlayerPosition = function (onDone) { thisplugin.getPlayerPosition(function (position) { thisplugin.clearRouteOrder(); thisplugin.validateUnderFieldLinks(); var result = thisplugin.computeRouteOrder(position.latlng); if (!result) { dialog({ html: '

Every step of the plan is already done: nothing left to reorder.

', id: 'plugin_fanfields3_alert_reroute', title: 'Fan Fields 3 - Reroute' }); thisplugin.redrawWalkOrder(); onDone(); return; } thisplugin.routeOrderGuids = result.order; thisplugin.routeOrderPlanKey = thisplugin.getPlanShapeKey(); thisplugin.routeOrderInfo = { source: position.source, before: result.before, after: result.after }; thisplugin.redrawWalkOrder(); thisplugin.scrollTaskListToFirstPending(); onDone(); }); }; thisplugin.exportTaskListToPDF = function () { const id = 'plugin_fanfields3_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_fanfields3_exportText_toggle="toggle"]') .each(function () { const $toggle = $(this); const $label = $toggle.prev('.plugin_fanfields3_exportText_Label'); const $details = $toggle.parents() .next('.plugin_fanfields3_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_fanfields3_exportText_ui { display: none !important; } .plugin_fanfields3_exportText_print { display: inline !important; color: #000; text-decoration: none; } tbody.plugin_fanfields3_exportText_Portal td { font-weight: bold !important; } tbody.plugin_fanfields3_exportText_LinkDetails td { font-weight: normal !important; } button, input[type="checkbox"] { display: none !important; } .plugin_fanfields3_exportText_Label::before { display: none !important; } .plugin_fanfields3_exportText_LinkDetails { display: table-row-group !important; } /* Keys alignment */ td[plugin_fanfields3_enoughKeys], td[plugin_fanfields3_notEnoughKeys] { text-align: center !important; } tr td[plugin_fanfields3_notEnoughKeys] { color: #C62828 !important; font-weight: bold; } td[plugin_fanfields3_enoughKeys], div[plugin_fanfields3_enoughKeys] { color: #828284; } tr.plugin_fanfields3_portal_done, tr.plugin_fanfields3_portal_done td, tr.plugin_fanfields3_portal_done a, tr.plugin_fanfields3_portal_done span, tr.plugin_fanfields3_link_done, tr.plugin_fanfields3_link_done td, tr.plugin_fanfields3_link_done a, tr.plugin_fanfields3_link_done span, td.plugin_fanfields3_cell_done { color: #828284 !important; text-decoration: line-through !important; } tr.plugin_fanfields3_portal_relocated, tr.plugin_fanfields3_portal_relocated td, tr.plugin_fanfields3_portal_relocated a, tr.plugin_fanfields3_portal_relocated span { color: #2E7D32 !important; } tr.plugin_fanfields3_blocker_row, tr.plugin_fanfields3_blocker_row td, tr.plugin_fanfields3_blocker_row a, tr.plugin_fanfields3_blocker_row span { color: #C62828 !important; } tr td span.plugin_fanfields3_blocker_tag { color: #C62828 !important; text-decoration: none !important; } tr.plugin_fanfields3_portal_done, tr.plugin_fanfields3_portal_done td, tr.plugin_fanfields3_portal_done td a, tr.plugin_fanfields3_portal_done td span { color: #828284 !important; text-decoration: line-through !important; } `; w.document.open(); w.document.write(` Fan Fields 3 – Tasks

Fan Fields 3 – 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 3 - 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_fanfields3_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.incomingValidCount !== undefined) ? fp.incomingValidCount : (fp.incoming ? fp.incoming.length : 0); var out = (fp.outgoingValidCount !== undefined) ? fp.outgoingValidCount : (fp.outgoing ? fp.outgoing.length : 0); var isAnchor = (fp.guid === that.startingpointGUID); var trClass = isAnchor ? 'plugin_fanfields3_order_anchor' : 'plugin_fanfields3_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_fanfields3_order_dialog', title: manageOrderDialogTitle, width: width, closeOnEscape: true }); function initDragAndButtons() { var $tbody = $('#plugin_fanfields3_order_table tbody'); function renumberRows() { // Update the "#" column to match the current DOM order. $tbody.find('tr') .each(function (i) { $(this) .find('td.plugin_fanfields3_order_idx') .text(i); }); updateMoveButtons(); } function pinAnchorRow() { // Keep anchor row at top (and prevent it from being displaced). var $anchor = $tbody.find('tr.plugin_fanfields3_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_fanfields3_order_row'); $rows.find('.plugin_fanfields3_order_move') .prop('disabled', false); $rows.first() .find('.plugin_fanfields3_order_move_up') .prop('disabled', true); $rows.last() .find('.plugin_fanfields3_order_move_down') .prop('disabled', true); } function moveRow($row, direction) { if (!$row.length || !$row.hasClass('plugin_fanfields3_order_row')) return; var $anchor = $tbody.find('> tr.plugin_fanfields3_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_fanfields3_order_row', handle: '.plugin_fanfields3_order_handle', axis: 'y', helper: 'clone', forcePlaceholderSize: true, placeholder: 'plugin_fanfields3_order_sort_placeholder', start: function (e, ui) { if (that.showOrderPath) { that.setOrderPathActive(false); $('#plugin_fanfields3_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_fanfields3_order_sort_placeholder') .html(' '); // Prevent placeholder from going above the anchor row. var $anchor = $tbody.find('> tr.plugin_fanfields3_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_fanfields3_order_anchor'); if ($anchor.length && ui.placeholder.index() === 0) { ui.placeholder.insertAfter($anchor); } }, update: function () { pinAnchorRow(); renumberRows(); } }); if (isMobile) { $tbody .off('click.plugin_fanfields3_order_move') .on('click.plugin_fanfields3_order_move', '.plugin_fanfields3_order_move', function () { var $row = $(this) .closest('tr'); if ($(this) .hasClass('plugin_fanfields3_order_move_up')) { moveRow($row, 'up'); } else { moveRow($row, 'down'); } }); } // Rebind Reset and Apply buttons $('#plugin_fanfields3_order_reset') .off('click') .on('click', function () { that.manualOrderGuids = null; that.requestLinkOrderRecompute(); that.updateLayer(); $('#plugin_fanfields3_order_dialog_inner') .html(buildTableHTML()); initDragAndButtons(); if (that.showOrderPath) { that.updateOrderPath(); } }); $('#plugin_fanfields3_order_apply') .off('click') .on('click', function () { var guids = []; $('#plugin_fanfields3_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.showUnderFieldWarningOnce = true; } that.requestLinkOrderRecompute(); that.delayedUpdateLayer(0.2, true); $('#plugin_fanfields3_order_dialog') .dialog('close'); }); $('#plugin_fanfields3_order_path') .off('click') .on('click', function () { var newState = !that.showOrderPath; that.setOrderPathActive(newState); $(this) .text(newState ? 'Hide path' : 'Path'); }); $('#plugin_fanfields3_order_path') .text(that.showOrderPath ? 'Hide path' : 'Path'); } initDragAndButtons(); }; // ghi#23 end (3) thisplugin.respectIntelLinksModeENUM = { NONE: 0, ALL: 1, ENL: 2, RES: 3, ENL_AND_MAC: 4, RES_AND_MAC: 5, MAC: 6 }; // Issue #104: Filter which visible intel links should block the plan. // NONE: ignore all intel links // ALL: treat all visible links as blockers (RES+ENL+MAC) // ENL/RES/MAC: only that faction blocks // ENL_AND_MAC / RES_AND_MAC: block those teams // Placeholder default, overwritten in setup() once window.PLAYER is reliably available: // the real default is the player's own faction (ENL or RES), not NONE. thisplugin.respectIntelLinksMode = thisplugin.respectIntelLinksModeENUM.NONE; thisplugin.isRespectingIntel = function () { return thisplugin.respectIntelLinksMode !== thisplugin.respectIntelLinksModeENUM.NONE; }; thisplugin.getRespectIntelTeams = function () { switch (thisplugin.respectIntelLinksMode) { case thisplugin.respectIntelLinksModeENUM.ALL: return [window.TEAM_RES, window.TEAM_ENL, window.TEAM_MAC]; case thisplugin.respectIntelLinksModeENUM.ENL: return [window.TEAM_ENL]; case thisplugin.respectIntelLinksModeENUM.RES: return [window.TEAM_RES]; case thisplugin.respectIntelLinksModeENUM.MAC: return [window.TEAM_MAC]; case thisplugin.respectIntelLinksModeENUM.ENL_AND_MAC: return [window.TEAM_ENL, window.TEAM_MAC]; case thisplugin.respectIntelLinksModeENUM.RES_AND_MAC: return [window.TEAM_RES, window.TEAM_MAC]; case thisplugin.respectIntelLinksModeENUM.NONE: default: return []; } }; thisplugin.getOwnFactionTeam = function () { if (!window.PLAYER) return undefined; if (window.PLAYER.team === 'ENLIGHTENED' || window.PLAYER.team === window.TEAM_ENL) { return window.TEAM_ENL; } if (window.PLAYER.team === 'RESISTANCE' || window.PLAYER.team === window.TEAM_RES) { return window.TEAM_RES; } return undefined; }; // A portal marker's own team (p.options.data.team) is the raw string IITC got from the // server ('RESISTANCE'/'E'/...), NOT the numeric TEAM_NONE/TEAM_RES/TEAM_ENL/TEAM_MAC // constant used everywhere else (including getOwnFactionTeam() above and link.team) — // comparing it directly against those constants always fails. This resolves it the same // way IITC itself does internally (IITC.utils.getTeamId, aliased as window.teamStringToId // for plugins), with a manual fallback for older/other builds lacking both. thisplugin.getPortalTeam = function (p) { if (p === undefined || !p.options || !p.options.data || p.options.data.team === undefined) return undefined; var teamRaw = p.options.data.team; if (typeof teamRaw === 'number') return teamRaw; if (typeof window.teamStringToId === 'function') return window.teamStringToId(teamRaw); if (window.IITC && window.IITC.utils && typeof window.IITC.utils.getTeamId === 'function') { return window.IITC.utils.getTeamId(teamRaw); } if (window.TEAM_CODENAMES) { var codenameIdx = window.TEAM_CODENAMES.indexOf(teamRaw); if (codenameIdx !== -1) return codenameIdx; } if (window.TEAM_CODES) { var codeIdx = window.TEAM_CODES.indexOf(teamRaw); if (codeIdx !== -1) return codeIdx; } return window.TEAM_NONE; }; thisplugin.getRespectIntelLabel = function () { switch (thisplugin.respectIntelLinksMode) { case thisplugin.respectIntelLinksModeENUM.ALL: return 'ALL'; case thisplugin.respectIntelLinksModeENUM.ENL: return 'ENL'; case thisplugin.respectIntelLinksModeENUM.RES: return 'RES'; case thisplugin.respectIntelLinksModeENUM.MAC: return 'MAC'; case thisplugin.respectIntelLinksModeENUM.ENL_AND_MAC: return 'E&M'; case thisplugin.respectIntelLinksModeENUM.RES_AND_MAC: return 'R&M'; case thisplugin.respectIntelLinksModeENUM.NONE: default: return 'NONE'; } }; thisplugin.updateRespectIntelButton = function () { $('#plugin_fanfields3_respectbtn') .html('Respect Intel: ' + thisplugin.getRespectIntelLabel()); }; // Backwards-compatible name: now cycles through the available modes. thisplugin.toggleRespectCurrentLinks = function () { thisplugin.respectIntelLinksMode++; if (thisplugin.respectIntelLinksMode > thisplugin.respectIntelLinksModeENUM.MAC) { thisplugin.respectIntelLinksMode = thisplugin.respectIntelLinksModeENUM.NONE; } thisplugin.updateRespectIntelButton(); thisplugin.delayedUpdateLayer(0.2, true); }; thisplugin.indicateLinkDirection = true; thisplugin.toggleLinkDirIndicator = function () { thisplugin.indicateLinkDirection = !thisplugin.indicateLinkDirection; if (thisplugin.indicateLinkDirection) { $('#plugin_fanfields3_direction_indicator_btn') .html('Show link dir: ON'); } else { $('#plugin_fanfields3_direction_indicator_btn') .html('Show link dir: OFF'); } thisplugin.delayedUpdateLayer(0.2, true); }; // Grey out / strike through links (and, once all of a portal's links exist, the portal // name too) that already exist in-game for the player's own faction — in the Task List, // and as a faded brownish-red on the map itself (thisplugin.updateLayer()'s own drawing // loop) instead of bright red. Requested as a toggleable plugin option. thisplugin.greyOutExistingLinks = true; thisplugin.toggleGreyOutExistingLinks = function () { thisplugin.greyOutExistingLinks = !thisplugin.greyOutExistingLinks; thisplugin.updateGreyOutExistingLinksButton(); thisplugin.refreshTaskListIfOpen(); thisplugin.updateLayer(); }; thisplugin.updateGreyOutExistingLinksButton = function () { $('#plugin_fanfields3_greyout_existing_btn') .html('Grey out done links: ' + (thisplugin.greyOutExistingLinks ? 'ON' : 'OFF')); }; // Blockers: a link, from a faction that Respect Intel does not avoid, crossing a link of the // plan that is still to be thrown. The plan itself is left alone; the Task List instead gets // extra "Destroy"/"Capture" rows, placed where they cost the least walking, that free those // links in time. thisplugin.manageBlockers = true; thisplugin.toggleManageBlockers = function () { thisplugin.manageBlockers = !thisplugin.manageBlockers; thisplugin.updateManageBlockersButton(); thisplugin.refreshTaskListIfOpen(); thisplugin.updateLayer(); }; thisplugin.updateManageBlockersButton = function () { $('#plugin_fanfields3_blockers_btn') .html('Blockers: ' + (thisplugin.manageBlockers ? 'ON' : 'OFF')); }; // Longest extra walk (meters) one Destroy stop may add to the route; 0 = no limit. thisplugin.BLOCKER_DETOUR_LIMITS_M = [100, 200, 500, 1000, 0]; thisplugin.blockerMaxDetourM = 500; thisplugin.getBlockerDetourLabel = function () { var limit = thisplugin.blockerMaxDetourM; if (!limit) return 'No limit'; return (limit >= 1000) ? (limit / 1000) + 'km' : limit + 'm'; }; thisplugin.updateBlockerDetourButton = function () { $('#plugin_fanfields3_blocker_detour_btn') .html('Max detour: ' + thisplugin.getBlockerDetourLabel()); }; thisplugin.cycleBlockerMaxDetour = function () { var limits = thisplugin.BLOCKER_DETOUR_LIMITS_M; var next = (limits.indexOf(thisplugin.blockerMaxDetourM) + 1) % limits.length; thisplugin.blockerMaxDetourM = limits[next]; thisplugin.updateBlockerDetourButton(); thisplugin.refreshTaskListIfOpen(); thisplugin.updateLayer(); }; // The portal guid at one end of an IITC link ('oGuid' = origin, 'dGuid' = destination), or // undefined when IITC did not give it. thisplugin.getLinkEndpointGuid = function (link, field) { var data = link && link.options && link.options.data; return (data && data[field]) || undefined; }; // Works out the blockers of the current plan and how to get rid of them. // // A blocker must be gone before the first link it blocks is thrown, i.e. before the walk // reaches that link's origin portal (its "deadline", a position in the walk). A link falls as // soon as either of its two portals is neutralized, so each blocker has two candidate portals: // - an enemy portal that the plan captures anyway, if the walk reaches it before the deadline, // frees its links for nothing; // - any other candidate is inserted into the walk as an extra stop, at the spot that adds the // least walking, no later than the deadline of the blockers it frees. A plan portal reached // only after the deadline gets such an early stop too (and is captured later on the walk). // Stops are chosen greedily by extra walking per blocker freed (one stop freeing several links // beats several stops on the same walking), then stops that became redundant are dropped. // The walk itself is never reordered. // // Returns { blockers, stops, onRoute, unresolved, extraDistance }: // - blockers: every blocking link { a, b, team, guidA, guidB, deadline, blocked } // - stops: extra Destroy rows in walk order { guid, point, slot, detour, // blockers } — slot = index of the walk portal it goes right before // - onRoute: plan portal guid -> blockers freed by the capture the plan already does there // - unresolved: blockers no stop could free within the maximum detour thisplugin.computeBlockerPlan = function () { var plan = { blockers: [], stops: [], onRoute: {}, unresolved: [], extraDistance: 0 }; if (!thisplugin.manageBlockers) return plan; var walk = thisplugin.getDisplayOrder(); if (!walk || walk.length < 2) return plan; var respectedTeams = thisplugin.getRespectIntelTeams(); var ownTeam = thisplugin.getOwnFactionTeam(); var maxDetour = thisplugin.blockerMaxDetourM; var pointKey = thisplugin.pointKey; // Links of the plan still to throw, each with its origin's position in the walk. var planned = []; walk.forEach(function (fp, k) { (fp.outgoing || []).forEach(function (target) { if (thisplugin.isLinkInGame(fp.guid, target.guid)) return; planned.push({ a: fp.point, b: target.point, originIdx: k, minX: Math.min(fp.point.x, target.point.x), maxX: Math.max(fp.point.x, target.point.x), minY: Math.min(fp.point.y, target.point.y), maxY: Math.max(fp.point.y, target.point.y) }); }); }); if (!planned.length) return plan; var guidByPointKey = {}; for (var locGuid in thisplugin.locations) guidByPointKey[pointKey(thisplugin.locations[locGuid])] = locGuid; var blockers = []; for (var linkGuid in thisplugin.intelLinks) { var intel = thisplugin.intelLinks[linkGuid]; if (respectedTeams.indexOf(intel.team) !== -1) continue; var minX = Math.min(intel.a.x, intel.b.x), maxX = Math.max(intel.a.x, intel.b.x); var minY = Math.min(intel.a.y, intel.b.y), maxY = Math.max(intel.a.y, intel.b.y); var deadline = Infinity; var blocked = []; for (var pi = 0; pi < planned.length; pi++) { var pl = planned[pi]; if (pl.maxX < minX || pl.minX > maxX || pl.maxY < minY || pl.minY > maxY) continue; if (thisplugin.intersects(pl, intel)) { blocked.push(pl); if (pl.originIdx < deadline) deadline = pl.originIdx; } } if (!blocked.length) continue; blockers.push({ a: intel.a, b: intel.b, team: intel.team, guidA: intel.guidA || guidByPointKey[pointKey(intel.a)], guidB: intel.guidB || guidByPointKey[pointKey(intel.b)], deadline: deadline, blocked: blocked }); } plan.blockers = blockers; if (!blockers.length) return plan; // Candidate portals: both ends of every blocker. var walkIdxByKey = {}; walk.forEach(function (fp, k) { walkIdxByKey[pointKey(fp.point)] = k; }); var cands = {}; blockers.forEach(function (blocker, bi) { [[blocker.a, blocker.guidA], [blocker.b, blocker.guidB]].forEach(function (end) { var key = pointKey(end[0]); var cand = cands[key]; if (!cand) { var guid = end[1] || guidByPointKey[key]; var marker = guid ? window.portals[guid] : undefined; var team = thisplugin.getPortalTeam(marker); var walkIdx = walkIdxByKey[key]; cand = cands[key] = { key: key, point: end[0], guid: guid, walkIdx: walkIdx, // A plan portal that isn't ours is captured on the walk anyway, which frees its links. capturedOnWalk: walkIdx !== undefined && (ownTeam === undefined || team !== ownTeam), blockerIdx: [] }; } cand.blockerIdx.push(bi); }); }); // Meters between two projected points, with the lat/lng of each point cached. var latLngCache = {}; function dist(p, q) { var kp = pointKey(p), kq = pointKey(q); var lp = latLngCache[kp] || (latLngCache[kp] = map.unproject(p, thisplugin.PROJECT_ZOOM)); var lq = latLngCache[kq] || (latLngCache[kq] = map.unproject(q, thisplugin.PROJECT_ZOOM)); return lp.distanceTo(lq); } var uncovered = {}; for (var ui = 0; ui < blockers.length; ui++) uncovered[ui] = true; // The plan's own captures come first: an enemy plan portal reached before the deadline. for (var ck in cands) { var free = cands[ck]; if (!free.capturedOnWalk) continue; free.blockerIdx.forEach(function (bi) { if (!uncovered[bi] || free.walkIdx >= blockers[bi].deadline) return; delete uncovered[bi]; var onRouteKey = free.guid || free.key; (plan.onRoute[onRouteKey] = plan.onRoute[onRouteKey] || []).push(blockers[bi]); }); } // The walk with the extra stops so far; items are { point, orig } (a walk portal, orig = // its index) or { point, stop }. var route = walk.map(function (fp, k) { return { point: fp.point, orig: k }; }); // Cheapest place to insert the candidate with at most `bound` walk portals before it. function bestInsertion(cand, bound) { var best = null; var walkBefore = 0; for (var j = 0; j <= route.length; j++) { if (walkBefore > bound) break; var prev = j > 0 ? route[j - 1].point : null; var next = j < route.length ? route[j].point : null; var cost; if (prev && next) cost = dist(prev, cand.point) + dist(cand.point, next) - dist(prev, next); else if (next) cost = dist(cand.point, next); else cost = prev ? dist(prev, cand.point) : 0; if (!best || cost < best.cost - 1e-6) best = { index: j, cost: cost, slot: walkBefore }; if (j < route.length && route[j].orig !== undefined) walkBefore++; } return best; } var stops = []; while (Object.keys(uncovered).length) { var pick = null; for (var key in cands) { var cand = cands[key]; var mine = cand.blockerIdx.filter(function (bi) { return uncovered[bi]; }); if (!mine.length) continue; var bounds = {}; mine.forEach(function (bi) { bounds[blockers[bi].deadline] = true; }); Object.keys(bounds).forEach(function (bound) { var ins = bestInsertion(cand, Number(bound)); if (!ins || (maxDetour && ins.cost > maxDetour)) return; var frees = mine.filter(function (bi) { return blockers[bi].deadline >= ins.slot; }); var ratio = ins.cost / frees.length; if (!pick || ratio < pick.ratio - 1e-6 || (Math.abs(ratio - pick.ratio) <= 1e-6 && (frees.length > pick.frees.length || (frees.length === pick.frees.length && ins.cost < pick.ins.cost)))) { pick = { cand: cand, ins: ins, frees: frees, ratio: ratio }; } }); } if (!pick) break; var stop = { cand: pick.cand, point: pick.cand.point, slot: pick.ins.slot, blockerIdx: pick.frees.slice() }; route.splice(pick.ins.index, 0, { point: stop.point, stop: stop }); stops.push(stop); pick.frees.forEach(function (bi) { delete uncovered[bi]; }); } // Drop stops another stop (or a plan capture) makes redundant. function covers(other, bi) { return other.cand.blockerIdx.indexOf(bi) !== -1 && blockers[bi].deadline >= other.slot; } function isFreedElsewhere(stop, bi) { return stops.some(function (other) { return other !== stop && covers(other, bi); }); } for (var si = stops.length - 1; si >= 0; si--) { var candidateStop = stops[si]; var needed = candidateStop.blockerIdx.some(function (bi) { return !isFreedElsewhere(candidateStop, bi); }); if (needed) continue; stops.splice(si, 1); route = route.filter(function (item) { return item.stop !== candidateStop; }); } // Final stops in walk order, with the extra walking each really adds. var slotsWalked = 0; route.forEach(function (item, j) { if (item.orig !== undefined) { slotsWalked++; return; } var stop = item.stop; var prev = j > 0 ? route[j - 1].point : null; var next = j < route.length - 1 ? route[j + 1].point : null; var detour; if (prev && next) detour = dist(prev, stop.point) + dist(stop.point, next) - dist(prev, next); else if (next) detour = dist(stop.point, next); else detour = prev ? dist(prev, stop.point) : 0; var cand = stop.cand; // Every blocker of this portal still in the way when the walk gets here falls with it. var stopBlockers = cand.blockerIdx .filter(function (bi) { return blockers[bi].deadline >= slotsWalked; }) .map(function (bi) { return blockers[bi]; }); plan.stops.push({ guid: cand.guid, key: cand.key, point: cand.point, slot: slotsWalked, detour: detour, blockers: stopBlockers }); }); blockers.forEach(function (blocker, bi) { if (uncovered[bi]) plan.unresolved.push(blocker); }); function routeLength(items) { var total = 0; for (var j = 1; j < items.length; j++) total += dist(items[j - 1].point, items[j].point); return total; } plan.extraDistance = routeLength(route) - routeLength(route.filter(function (item) { return item.orig !== undefined; })); return plan; }; thisplugin.is_locked = false; // Set when a plan is being (re)calculated from scratch, so the plan locks itself as soon as // that calculation is complete (see lockIfPlanComplete). Cleared by clicking Lock/Unlock: the // agent's own choice then stands until the next new plan. thisplugin._lockWhenPlanComplete = false; thisplugin.lock = function () { thisplugin.is_locked = !thisplugin.is_locked; thisplugin._lockWhenPlanComplete = false; thisplugin.updateLockButton(); }; // Whether IITC is still loading portals and links for the map (between its mapDataRefreshStart // and mapDataRefreshEnd hooks). True until its first load is over. thisplugin._mapDataLoading = true; // Locks the plan once a new plan is complete: IITC done loading the map data, drawn from that // data with no recalculation still waiting, link order optimized, and the automatic // anchor/direction search either done or not going to happen (a search still scheduled, // running, or waiting to retry while links load in means the plan may still change). When IITC // finishes loading, the mapDataRefreshEnd hook recalculates the plan, which checks again. thisplugin.lockIfPlanComplete = function () { if (!thisplugin._lockWhenPlanComplete) return; if (thisplugin._mapDataLoading || thisplugin.timer !== undefined) return; if (thisplugin._orientationSearchPending || thisplugin._orientationSearchTimer !== null) return; thisplugin._lockWhenPlanComplete = false; thisplugin.is_locked = true; thisplugin.updateLockButton(); }; // Keeps the sidebar Lock/Unlock button and the map's own topleft padlock control (see // addFfButtons) in sync with thisplugin.is_locked — whichever of the two was just clicked. thisplugin.updateLockButton = function () { if (thisplugin.is_locked) { $('#plugin_fanfields3_lockbtn') .html('🔒 Locked'); // 🔒 } else { $('#plugin_fanfields3_lockbtn') .html('🔓 Unlocked'); // 🔓 } $('#fanfieldLockButton') .html(thisplugin.is_locked ? lockIconClosed : lockIconOpen) .toggleClass('plugin_fanfields3_lock_locked', thisplugin.is_locked) .attr('title', thisplugin.is_locked ? 'Locked: click to let the plan recalculate again' : 'Unlocked: click to freeze the plan and stop it recalculating'); }; thisplugin.use_bookmarks_only = false; thisplugin.useBookmarksOnly = function () { thisplugin.use_bookmarks_only = !thisplugin.use_bookmarks_only; if (thisplugin.use_bookmarks_only) { $('#plugin_fanfields3_bookarks_only_btn') .html( '🔖 Bookmarks only' ); } else { $('#plugin_fanfields3_bookarks_only_btn') .html( '🔖 All Portals' ); } thisplugin.delayedUpdateLayer(0.2, true); }; thisplugin.is_clockwise = true; thisplugin.updateClockwiseButton = function () { var clockwiseSymbol = "", clockwiseWord = ""; if (thisplugin.is_clockwise) { clockwiseSymbol = "↻" clockwiseWord = "Clockwise"; } else { clockwiseSymbol = "↺" clockwiseWord = "Counterclockwise"; } $('#plugin_fanfields3_clckwsbtn') .html(clockwiseWord + ' ' + clockwiseSymbol + ''); }; thisplugin.toggleclockwise = function () { thisplugin.cancelOrientationSearch(); thisplugin.is_clockwise = !thisplugin.is_clockwise; // Reset the order and link flips – new geometry, new base ordering (ghi#23) thisplugin.manualOrderGuids = null; thisplugin.manualLinkFlips = {}; thisplugin.relocatedForLessWalkingGuids = {}; thisplugin.displayOrderGuids = null; thisplugin.requestLinkOrderRecompute(); thisplugin.updateClockwiseButton(); thisplugin.delayedUpdateLayer(0.2, true); }; 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_fanfields3_availablesbul') .hide(); } else { $('#plugin_fanfields3_availablesbul') .show(); } $('#plugin_fanfields3_stardirbtn') .html(html); thisplugin.delayedUpdateLayer(0.2, true); }; thisplugin.increaseSBUL = function () { if (thisplugin.availableSBUL < 4) { thisplugin.availableSBUL++; $('#plugin_fanfields3_availablesbul_count') .html('' + (thisplugin.availableSBUL) + ''); thisplugin.delayedUpdateLayer(0.2, true); } } thisplugin.decreaseSBUL = function () { if (thisplugin.availableSBUL > 0) { thisplugin.availableSBUL--; $('#plugin_fanfields3_availablesbul_count') .html('' + (thisplugin.availableSBUL) + ''); thisplugin.delayedUpdateLayer(0.2, true); } } thisplugin.setupCSS = function () { // Collect CSS in one place and inject/update a single