MediaWiki:Gadget-TripPlanner.js

 /**  * Wikivoyage Trip Planner Gadget  * ==============================  * A client-side tool for planning trips using Wikivoyage listings.  * Allows users to drag listings from articles into custom buckets or days,  * organize them, view them on a map, and calculate/optimize routes.  *  * CORE FEATURES:  * ------------------  * 1. UI Modes: Dual-mode application:  *    - Mini Widget Mode: A floating, minimizable widget suitable for reading articles.  *    - Full View Mode: A split-screen full-page interface with a persistent map,  *      routing, and bucket controls.  * 2. Data Source & Drag-and-Drop:  *    - Drag-and-drop POIs between day/bucket bins.  *    - Parses data-lat, data-lon, and wikidata attributes from vcards.  *    - Allows wikidata and ListingBrowser as sources too  * 3. Multi-Tab Synchronization:  *    - Uses BroadcastChannel to sync trip plans in real-time across all open tabs.  *    - Protects local tab UI states (minimized/expanded) during synchronization events.  * 4. Persistence: Dual-mode storage:  *    - Anonymous: Uses browser LocalStorage.  *    - Logged-in: Uses MediaWiki User Options API ('userjs-wv-trip-planner-*').  *    - Auto-merge: Merges local data into account data upon login.  * 5. Mapping: Interactive markers (colored by day) with popup image loading from Wikidata.  * 6. Routing: Integrated with OpenRouteService (ORS) API.  *             - Calculates travel time/distance between POIs.  *             - Optimizes day itinerary (TSP) using ORS Optimization API.  *             - Draws colored route polylines on the map.  * 7. I/O: Import/Export support for JSON, GPX, WikiText ({{listing}}), and GeoJSON.  *  * EXTERNAL SERVICES:  * ------------------  * 1. OpenRouteService (api.openrouteservice.org): Used for routing and optimization.  *    Requires a user-provided API Key (stored in settings).  * 2. Wikidata API: Used to fetch thumbnail images for map popups.  * 3. Wikimedia Maps: Tile server for the Leaflet map.  *  * CODE STRUCTURE (Singleton 'TripPlanner'):  * -----------------------------------------  * - Config:  *     - storageKey/optionKey: Persistence identifiers.  *     - colors: Palette for different days.  *  * - Lifecycle:  *     - init(): Entry point after the launcher icon is clicked or auto-run in Full Mode.  *     - load()/save(): Handles LocalStorage vs API serialization logic.  *  * - UI Generation:  *     - injectStyles(): CSS definitions (Widget, Overlays, Map, Split-Screen).  *     - drawUI(): Renders the main widget container and sets up layout modes.  *     - renderBins(): Renders the list of items (Custom Buckets + Days).  *     - toggleMap(): Lazy-loads Leaflet libraries and renders map container.  *  * - Logic & Handlers:  *     - setupListeners(): Central event delegation (Clicks, Drag&Drop, Changes).  *     - calculateRoutes(): Orchestrates ORS API calls for routing.  *     - optimizeDay(): Orchestrates ORS API calls for ordering.  *     - exportData(): Generates file blobs for download.  *  * - Map Helpers:  *     - updateMapMarkers(): Draws L.marker/L.divIcon based on current data.  *     - highlightDay(): Manages opacity/visibility of specific day layers.  *  * DEPENDENCIES:  * -------------  * - MediaWiki Modules: 'mediawiki.util', 'mediawiki.api', 'mediawiki.user', 'mediawiki.notification'.  * - OOJS UI: 'oojs-ui-core', 'oojs-ui-widgets', 'oojs-ui-windows'.  * - Kartographer: 'ext.kartographer.box' for Leaflet (L) access.  * - jQuery ($).  */  (function (mw, $) {     'use strict';      if (mw.config.get('wgAction') !== 'view') return;      let BucketEditDialog;      function initOOUI() {         if (BucketEditDialog) return;          BucketEditDialog = function (config) {             BucketEditDialog.super.call(this, config);         };         OO.inheritClass(BucketEditDialog, OO.ui.ProcessDialog);          BucketEditDialog.static.name = 'bucketEditDialog';         BucketEditDialog.static.title = 'Edit Bucket';         BucketEditDialog.static.actions = [             { action: 'save', label: 'Save', flags: ['primary', 'progressive'] },             { label: 'Cancel', flags: 'safe' }         ];          BucketEditDialog.prototype.initialize = function () {             BucketEditDialog.super.prototype.initialize.call(this);             this.content = new OO.ui.PanelLayout({ padded: true, expanded: false });              this.nameInput = new OO.ui.TextInputWidget({                 placeholder: 'Enter bucket name (e.g. food, sleep)'             });             this.nameField = new OO.ui.FieldLayout(this.nameInput, {                 label: 'Bucket Name',                 align: 'top'             });              this.customIconInput = new OO.ui.TextInputWidget({                 maxLength: 2             });              const presetIcons = ['🍴', '🏨', '📷', '🛍️', '📦', '🗺️', '🚶', '📍', '☕', '🍺', '⛺', '🏛️', '🎡', '🛒', '💳', '🌲'];             const buttonItems = presetIcons.map(function (icon) {                 return new OO.ui.ButtonOptionWidget({                     data: icon,                     label: icon,                     title: icon                 });             });              this.presetSelect = new OO.ui.ButtonSelectWidget({                 items: buttonItems             });              const thiss = this;             this.presetSelect.on('select', function (item) {                 if (item) {                     thiss.customIconInput.setValue(item.getData());                 }             });              this.customIconInput.on('change', function (value) {                 if (presetIcons.indexOf(value) === -1) {                     thiss.presetSelect.selectItem(null);                 } else {                     const opt = thiss.presetSelect.findItemFromData(value);                     if (opt) {                         thiss.presetSelect.selectItem(opt);                     }                 }             });              this.nameInput.on('change', function (value) {                 thiss.actions.setAbilities({ save: !!value.trim() });             });              this.presetField = new OO.ui.FieldLayout(this.presetSelect, {                 label: 'Choose a Predefined Icon',                 align: 'top'             });              this.customIconField = new OO.ui.FieldLayout(this.customIconInput, {                 label: 'Icon (emoji or character)',                 align: 'top'             });              this.fieldset = new OO.ui.FieldsetLayout();             this.fieldset.addItems([this.nameField, this.presetField, this.customIconField]);             this.content.$element.append(this.fieldset.$element);             this.$body.append(this.content.$element);         };          BucketEditDialog.prototype.getSetupProcess = function (data) {             data = data || {};             return BucketEditDialog.super.prototype.getSetupProcess.call(this, data)                 .next(function () {                     this.title.setLabel(data.title || 'Edit Bucket');                     this.nameInput.setValue(data.name || '');                     this.customIconInput.setValue(data.icon || '🗂️');                     this.actions.setAbilities({ save: !!(data.name || '').trim() });                      const val = data.icon || '🗂️';                     const presetIcons = ['🍴', '🏨', '📷', '🛍️', '📦', '🗺️', '🚶', '📍', '☕', '🍺', '⛺', '🏛️', '🎡', '🛒', '💳', '🌲'];                     if (presetIcons.indexOf(val) !== -1) {                         const opt = this.presetSelect.findItemFromData(val);                         if (opt) {                             this.presetSelect.selectItem(opt);                         }                     } else {                         this.presetSelect.selectItem(null);                     }                      this.onSave = data.onSave;                 }, this);         };          BucketEditDialog.prototype.getActionProcess = function (action) {             if (action === 'save') {                 const name = this.nameInput.getValue().trim();                 const icon = this.customIconInput.getValue().trim();                  if (!name) {                     return new OO.ui.Process(function () {                         alert('Please enter a name for the bucket.');                     });                 }                  const thiss = this;                 return new OO.ui.Process(function () {                     if (thiss.onSave) {                         thiss.onSave(name, icon || '🗂️');                     }                     thiss.close({ action: action });                 });             }             return BucketEditDialog.super.prototype.getActionProcess.call(this, action);         };     }      const TripPlanner = {         storageKey: 'wv-trip-planner',         optionKey: 'userjs-wv-trip-planner',          saveTimer: null,         syncTimer: null,         map: null,         markerLayer: null,         localTripIndex: null, // Isolated tab-local trip selection         data: { activeTripIndex: 0, minimized: false, trips: [] },         colors: ['#d33', '#36c', '#2a7b39', '#f60', '#72309d', '#e2127a', '#00af89'],          init: function () {             if ($('.vcard').length === 0 && $('.listing').length > 0) {                 console.warn("TripPlanner: vcard class not found, listings might be un-draggable.");             }              this.load();             if (this.data.trips.length === 0) this.createNewTrip("My First Trip");              this.data.closed = false;             this.data.minimized = false;             this.cachedRoutes = {}  			// used for sync of tabs 			this.tabId = Math.random().toString(36).substring(2, 11);             this.activeOtherTabs = new Set();             this.isRouting = false;             this.lastRoutingTime = 0;             this.autoRouteTimer = null;              this.injectStyles();             this.initMap(); // Ensure map container and Leaflet exist             this.setupListeners();             this.makeListingsDraggable();              this.drawUI();              this.syncChannel = window.BroadcastChannel ? new BroadcastChannel('wv-trip-planner-sync') : null;             if (this.syncChannel) {                 this.syncChannel.onmessage = (msg) => {                     if (msg.data.type === 'sync') {                         // Preserve local UI state flags                         const wasClosed = this.data.closed;                         const wasMinimized = this.data.minimized;                          this.data = msg.data.data;                          this.data.closed = wasClosed;                         this.data.minimized = wasMinimized;                          const isFullMode = $('#trip-planner-app').length > 0;                         if (isFullMode) {                             this.renderTripOptions();                             this.renderBins();                             this.updateMapMarkers();                             // Sync topbar inputs without full redraw                             const trip = this.getActiveTrip();                             $('#tp-rename-input').val(trip.name);                             $('#tp-start-date').val(trip.startDate);                             $('#tp-length').val(trip.length);                         } else {                             this.drawUI();                         }                     } else if (msg.data.type === 'hello') {                         this.activeOtherTabs.add(msg.data.tabId);                         this.syncChannel.postMessage({ type: 'alive', tabId: this.tabId, to: msg.data.tabId });                     } else if (msg.data.type === 'alive') {                         if (msg.data.to === this.tabId) {                             this.activeOtherTabs.add(msg.data.tabId);                         }                     } else if (msg.data.type === 'bye') {                         this.activeOtherTabs.delete(msg.data.tabId);                     }                 };  				// Announce presence to other tabs                 this.syncChannel.postMessage({ type: 'hello', tabId: this.tabId });                  // Notify other tabs when closing                 $(window).on('beforeunload', () => {                     if (this.syncChannel) {                         this.syncChannel.postMessage({ type: 'bye', tabId: this.tabId });                     }                 });             }         },          load: function () {             const defaults = { activeTripIndex: 0, minimized: false, trips: [], orsKey: '' };              // 1. Read Local Storage (Used for Anon, or for merging after login)             const localStr = localStorage.getItem(this.storageKey);             let localData = null;             if (localStr) {                 try {                     localData = $.extend({}, defaults, JSON.parse(localStr));                 } catch (e) {                     console.error("Local load error", e);                 }             }              if (mw.user.isAnon()) {                 // --- CASE 1: Anonymous User -> Use Local Storage ---                 if (localData) this.data = localData;             } else {                 // --- CASE 2: Logged In -> Use Account Options ---                 const remoteStr = mw.user.options.get(this.optionKey);                 let remoteData = {                     activeTripIndex: 0, minimized: false, trips: []                 };                  if (remoteStr) {                     try {                         $.extend(remoteData, JSON.parse(remoteStr));                     } catch (e) { console.error("Remote load error", e); }                 }                  // --- MERGE LOGIC: If local data exists, merge it into account and clear local ---                 if (localData && localData.trips && localData.trips.length > 0) {                     // Avoid merging empty default trips if possible                     const validLocalTrips = localData.trips.filter(t => {                         const hasItemsInBuckets = t.buckets ? t.buckets.some(b => b.items.length > 0) : (t.backlog && t.backlog.length > 0);                         return hasItemsInBuckets || t.days.some(d => d.items.length > 0) || t.name !== "My First Trip";                     });                      if (validLocalTrips.length > 0) {                         remoteData.trips = remoteData.trips.concat(validLocalTrips);                         // Trigger immediate save to server to persist the merge                         this.saveDataToApi(remoteData);                     }                     // Clear local storage so we don't merge again next reload                     localStorage.removeItem(this.storageKey);                 }                  this.data = remoteData;                 if (!this.data.closed)                     this.data.closed = false;             }             this.migrateData();         },          migrateData: function () {             if (!this.data || !this.data.trips) return;             this.data.trips.forEach(trip => {                 if (!trip.buckets) {                     trip.buckets = [                         { id: "backlog", name: "Backlog", icon: "📦", items: trip.backlog || [] }                     ];                 }                 delete trip.backlog;                 // Ensure every bucket has an id, name, icon, items                 trip.buckets.forEach(b => {                     if (!b.id) b.id = 'bucket_' + Math.random().toString(36).substr(2, 9);                     if (!b.icon) b.icon = '📦';                     if (!b.items) b.items = [];                 });             });         },          save: function () {             const dataToSave = $.extend(true, {}, this.data);              // cleanup non-persistent flags             delete dataToSave.closed;             delete dataToSave.minimized;             if (dataToSave.trips) {                 dataToSave.trips.forEach(trip => {                     delete trip.backlog;                 });             }              // 1. Sync other tabs (100ms delay)             this.broadcastSync(dataToSave);              if (mw.user.isAnon()) {                 // Anon: Save directly to LocalStorage                 localStorage.setItem(this.storageKey, JSON.stringify(dataToSave));             } else {                 // Logged In: Save to API (Debounced 2s)                 clearTimeout(this.saveTimer);                 this.saveTimer = setTimeout(() => {                     this.saveDataToApi(dataToSave);                 }, 2000);             }         },          broadcastSync: function (data) {             if (!this.syncChannel) return;             clearTimeout(this.syncTimer);             this.syncTimer = setTimeout(() => {                 this.syncChannel.postMessage({ type: 'sync', data: data });             }, 100);         },          saveDataToApi: function (dataToSave) {             new mw.Api().saveOption(this.optionKey, JSON.stringify(dataToSave))                 .fail(function (code, data) { console.error("TripPlanner Sync Error", code, data); });         },          getActiveTrip: function () {             const idx = (this.localTripIndex !== null) ? this.localTripIndex : this.data.activeTripIndex;             return this.data.trips[idx] || this.data.trips[0];         },          injectStyles: function () {             mw.util.addCSS(`                 /* --- DESKTOP / DEFAULT STYLES --- */                 #tp-widget {                      position: fixed; bottom: 10px; right: 10px;                      width: 360px;                      max-width: 95vw;                     background: var(--background-color-base, #fff);                      color: var(--color-base, #202122);                     border: 1px solid #a2a9b1; border-radius: 8px;                      z-index: 2000; box-shadow: 0 4px 15px rgba(0,0,0,0.3);                      font-family: sans-serif; display: flex; flex-direction: column;                     box-sizing: border-box;                  }                                  #tp-widget * { box-sizing: border-box; }                   .tp-header { background: #36c; color: white; padding: 10px; border-radius: 8px 8px 0 0; cursor: pointer; display: flex; justify-content: space-between; align-items: center; }                 .tp-content { position: relative; display: flex; flex-direction: column; height: 100%; }                 .tp-settings {                 	padding: 8px;                 	border-bottom: 1px solid var(--border-color-subtle, #eee);                      background: var(--background-color-interactive-subtle, #f8f9fa);                     font-size: 0.85em;                 }                 .tp-settings-row { display: flex; gap: 5px; margin-bottom: 5px; align-items: center; }                 .tp-body { padding: 10px; max-height: 400px; overflow-y: auto; background: var(--background-color-base, #fff); flex-grow: 1; }                 .tp-section-title { font-size: 0.75em; font-weight: bold; color: var(--color-subtle, #72777d); text-transform: uppercase; margin: 12px 0 5px; display: flex; justify-content: space-between; }                 .tp-bin { border: 1px solid #c8ccd1; border-radius: 4px; padding: 8px; margin-bottom: 10px; background: var(--background-color-interactive-subtle, #fdfdfd); min-height: 35px; }                 .tp-bin.drag-over { background: var(--background-color-progressive-subtle, #eaf3ff); border: 2px solid #36c; }                 .tp-item {                 	 background: var(--background-color-base, #fff);                 	 color: var(--color-base, #202122);                 	 border: 1px solid #eaecf0; padding: 6px; margin: 4px 0; font-size: 0.85em; display: flex; justify-content: space-between; cursor: grab; align-items: center; box-shadow: 0 1px 2px rgba(0,0,0,0.05);             	 }             	 .tp-item:hover { border-color: var(--border-color-progressive, #36c); }                                  .tp-controls {                 	display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 4px; padding: 8px; border-top: 1px solid #eee;                  	background: var(--background-color-base, #fff);                 }                                  /* Overlays */                 #tp-menu-overlay, #tp-edit-form, #tp-settings-overlay, #tp-route-overlay {                      position: absolute; top: 0; left: 0; right: 0; bottom: 0;                      background: var(--background-color-base, #fff);                      color: var(--color-base, #202122);                     z-index: 110;                      display: none; flex-direction: column; padding: 15px; border-radius: 0 0 8px 8px;                  }                                  .tp-menu-btn {                 	display: block; width: 100%; text-align: left; padding: 8px; border: 1px solid #ccc;                 	background: var(--background-color-base, #fff);                      color: var(--color-base, #202122); margin-bottom: 5px; cursor: pointer; border-radius: 4px; font-size: 0.85em;                 }                 .tp-menu-btn:hover { background: var(--background-color-progressive-subtle, #f0f4ff); border-color: #36c; color: #36c; }                 .tp-form-row { margin-bottom: 8px; display: flex; flex-direction: column; font-size: 0.8em; }                 .tp-form-row input, .tp-form-row textarea {                 	padding: 4px;              		border: 1px solid var(--border-color-subtle, #ccc);                      background: var(--background-color-base, #fff);                     color: var(--color-base, #202122);                     border-radius: 3px;                 }                  /* Map & Helpers */                 #tp-map-container { position: fixed; top: 60px; left: 60px; right: 420px; bottom: 60px; background: var(--background-color-base, white); border: 2px solid #36c; border-radius: 8px; z-index: 1999; display: none; box-shadow: 0 0 20px rgba(0,0,0,0.4); }                 #tp-map-canvas { width: 100%; height: 100%; }                  .tp-btn-move, .tp-btn-edit { cursor: pointer; color: var(--color-progressive, #36c); font-size: 10px; border: 1px solid #36c; padding: 1px 4px; border-radius: 3px; }                 .tp-remove { color: var(--color-destructive, #d33); cursor: pointer; font-weight: bold; padding: 0 5px; }                 .listing-draggable-proxy { cursor: move !important; }                 .tp-in-trip::before { content: "✅ "; }                 .tp-route-stats {                 	font-size: 10px;                 	color: var(--color-subtle, #555);                      background: var(--background-color-neutral-subtle, #f0f0f0);                     text-align: center; padding: 2px; margin: 2px 10px; border-radius: 4px; border: 1px dashed #ccc;                 }                 .tp-item-error { border: 2px solid #d33 !important; background: #fff1f0 !important; }                 .tp-error-msg { font-size: 0.8em; color: #d33; font-style: italic; margin-top: 2px; }                                  .tp-setting-block { margin-bottom: 12px; }                 .tp-setting-block label { display: block; font-weight: bold; font-size: 0.8em; color: var(--color-subtle, #555); margin-bottom: 3px; }                 .tp-setting-block input { width: 100%; padding: 5px; border: 1px solid #ccc; border-radius: 4px; }                 .tp-divider { border-bottom: 1px solid var(--border-color-subtle, #eee); margin: 10px 0; }                  #tp-minimized-icon {                     position: fixed; bottom: 20px; right: 20px;                     width: 48px; height: 48px;                     background: #36c; color: white;                     border-radius: 50%;                     text-align: center; line-height: 48px; font-size: 24px;                     box-shadow: 0 4px 12px rgba(0,0,0,0.4);                     cursor: pointer; z-index: 2000;                     transition: transform 0.1s;                     user-select: none;                 }                 #tp-minimized-icon:hover { transform: scale(1.1); background: #2a5ab0; }                                  #tp-widget .mw-ui-button { font-size: 0.8em; font-weight: bold; padding: 4px 8px; min-height: 24px; line-height: 1.4; width: 100%; }                 #tp-new-trip, #tp-open-settings { padding: 3px 8px; min-width: auto; width: auto !important; }                  /* MOBILE / TOUCHSCREEN OPTIMIZATIONS */                 @media (max-width: 1200px) and (pointer: coarse) {                     /*#tp-widget {                         width: 100% !important;                         left: 0 !important; right: 0 !important;                         bottom: 0 !important;                         border-radius: 8px 8px 0 0 !important;                         max-height: 80vh;                          transition: transform 0.3s;                         z-index: 2000;                         box-shadow: 0 -2px 10px rgba(0,0,0,0.2) !important;                     }                     #tp-body {                     	max-height: 15em;                     } */                                          /* 2. FULLSCREEN MAP MODE */                                          /* Hide widget when map is open to give full screen to map */                     /* body.tp-mobile-map-open #tp-widget {                         display: none !important;                     } */                                          /* Map covers 100% of viewport */                     body.tp-mobile-map-open #tp-map-container {                         display: block !important;                         position: fixed !important;                         top: 0 !important; left: 0 !important;                         width: 100% !important; height: 100% !important;                         border: none !important;                         border-radius: 0 !important;                         z-index: 2001 !important; /* Above everything */                     }                      /* Large, Touch-Friendly Close Button */                     body.tp-mobile-map-open #tp-map-hide {                          display: block !important;                          top: 15px !important;                         right: 15px !important;                         background: white !important;                         color: #333 !important;                         padding: 10px 20px !important; /* Larger touch target */                         border-radius: 4px !important;                         box-shadow: 0 2px 8px rgba(0,0,0,0.3) !important;                         font-weight: bold !important;                         font-size: 16px !important;                         border: 1px solid #ccc !important;                     }                                          /* 3. Mobile "Tap to Add" Button */                     .tp-mobile-add {                         display: inline-block;                         padding: 6px 12px;                         background: #eaf3ff;                         color: #36c;                         border: 1px solid #36c;                         border-radius: 16px;                         margin: 4px 0 4px 8px;                         font-weight: bold;                         cursor: pointer;                         font-size: 0.9em;                         white-space: nowrap;                     }                     .tp-mobile-add:active { background: #36c; color: white; }                 }                  /* --- FULL MODE / SPLIT SCREEN STYLES --- */                 .tp-full-app {                     display: flex; flex-direction: column;                      height: 80vh; min-height: 600px;                     border: 1px solid #a2a9b1; border-radius: 8px; overflow: hidden;                     background: var(--background-color-base, #fff); color: var(--color-base, #202122);                     font-family: sans-serif;                 }                 .tp-full-app * { box-sizing: border-box; }                                  .tp-full-topbar {                     display: flex; gap: 10px; align-items: center; padding: 10px 15px;                     background: #f8f9fa; border-bottom: 1px solid #eee; flex-wrap: wrap;                 }                 .tp-full-topbar .tp-setting-block { margin-bottom: 0; display: flex; align-items: center; gap: 5px; }                 .tp-full-topbar .tp-setting-block label { margin-bottom: 0; }                                  .tp-full-split {                     display: flex; flex-grow: 1; overflow: hidden; position: relative;                 }                 .tp-full-sidebar {                     flex-shrink: 0; max-width: 360px;                     overflow-y: auto; padding: 10px;                     border-right: 1px solid #eee;                     background: var(--background-color-base, #fff);                 }                 @media (max-width: 720px) {                     .tp-full-app {                         height: auto;                         min-height: auto;                     }                     .tp-full-split {                         flex-direction: column;                         overflow: visible;                     }                     .tp-full-sidebar {                          max-width: 100%;                         height: 350px;                          border-right: none;                         border-bottom: 1px solid #eee;                      }                     .tp-full-main {                         height: 80vh;                         min-height: 500px;                         flex-grow: 1;                         position: relative;                     }                 }                 .tp-full-main {                     flex-grow: 1; position: relative;                 }                 .tp-full-main #tp-map-container {                     /* Override fixed positioning when inside full mode */                     position: absolute !important; top: 0 !important; left: 0 !important;                      right: 0 !important; bottom: 0 !important;                     width: 100% !important; height: 100% !important;                     border: none !important; border-radius: 0 !important; box-shadow: none !important;                     z-index: 10 !important; display: block !important;                 }                 .tp-full-main #tp-map-hide {                     display: none !important; /* Hide map close button in full mode */                 }             `);         },          drawUI: function () {             const isFullMode = $('#trip-planner-app').length > 0;              // 1. Handle "Closed" state (Launcher in user menu)             if (this.data.closed && !isFullMode) {                 $('#tp-widget, #tp-minimized-icon').remove();                 $('#tp-map-container').hide().detach().appendTo('body');                 this.drawLauncher();                 return;             } else {                 $('.tp-launcher-icon, #tp-launcher-li').remove();                 $('#tp-launcher').remove();             }              // 2. Handle "Minimized" state (Floating Icon)             if (this.data.minimized && !isFullMode) {                 $('#tp-widget').remove();                 $('#tp-map-container').hide().detach().appendTo('body');                  $('body').append(`<div id="tp-minimized-icon" title="Expand Trip Planner">🎒</div>`);                 return;             }              // 3. Handle "Open" state             $('#tp-widget, #tp-minimized-icon').remove();              // Detach map container before resetting app             const $mapContainer = $('#tp-map-container').detach();              if (isFullMode) {                 $('#trip-planner-app').empty();             }             const trip = this.getActiveTrip();              let routeButtons = `<button id="tp-btn-route" class="mw-ui-button">🚗 Route</button>`;             if (this.routesActive) {                 routeButtons += `<button id="tp-btn-stop-route" class="mw-ui-button mw-ui-destructive" style="margin-left:5px">🛑 Stop routing</button>`;             }              if (this.lastActiveTrip && this.lastActiveTrip !== trip) {                 if (this.routeLayer) {                     this.routeLayer.clearLayers();                 }                 this.cachedRoutes = {};                 $('.tp-route-stats').remove(); 				this.routesActive = false; // Reset routes active on trip switch             }             this.lastActiveTrip = trip;              let dayOptions = "";             trip.days.forEach((d, i) => {                 dayOptions += `<option value="${i}">Day ${i + 1}</option>`;             });              const safeName = mw.html.escape(trip.name);             const safeKey = mw.html.escape(this.data.orsKey || '');             const helpUrl = mw.util.getUrl('Wikivoyage:Trip_Planner');             const fullModeUrl = mw.util.getUrl('Trip Planner');              let $widget;              if (isFullMode) {                 $widget = $(`                     <div class="tp-full-app">                         <div class="tp-full-topbar">                             <div class="tp-setting-block" style="margin-right:15px">                                 <label>Trip:</label>                                 <select id="tp-select-trip" style="padding:4px; max-width:150px; border:1px solid #ccc; border-radius:4px;"></select>                                 <button id="tp-new-trip" class="mw-ui-button" title="New Trip" style="padding:3px 8px; min-width:auto">+</button>                             </div>                                                          <div class="tp-setting-block" style="flex-grow:1; flex-basis: 250px">                                 <label>Name:</label>                                 <input type="text" id="tp-rename-input" value="${safeName}">                             </div>                              <div class="tp-setting-block">                                 <label>Start:</label>                                 <input type="date" id="tp-start-date" value="${trip.startDate}" style="width:130px">                             </div>                                                          <div class="tp-setting-block" style="margin-right:15px">                                 <label>Days:</label>                                 <input type="number" id="tp-length" value="${trip.length}" min="1" style="width:60px">                             </div>                              <div>                             ${routeButtons}                             <button id="tp-btn-export" class="mw-ui-button">💾 Save...</button>                             <button id="tp-import" class="mw-ui-button">📂 Load</button>                             <button id="tp-del-trip" class="mw-ui-button mw-ui-destructive mw-ui-quiet" title="Delete Trip" style="padding:4px 8px; min-width:auto; margin-left:auto;">🗑️</button>                             </div>                         </div>                         <div class="tp-full-split">                             <div class="tp-full-sidebar tp-body" style="max-height:none">                                 <div class="tp-section-title" style="border-bottom: 1px solid #ccc; padding-bottom: 4px; margin-bottom: 10px;">                                     <span>🗂️ Buckets</span>                                     <div>                                         <span class="tp-btn-edit" id="tp-add-bucket" title="Add custom bucket">+ Bucket</span>                                     </div>                                 </div>                                 <div id="tp-buckets-container"></div>                                 <div id="tp-days-container"></div>                             </div>                             <div class="tp-full-main" id="tp-map-container-wrapper">                             </div>                         </div>                          <div id="tp-menu-overlay" style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: var(--background-color-base, #fff); z-index: 110; display: none; flex-direction: column; padding: 15px;">                             <b id="tp-menu-title">Menu</b>                             <div id="tp-menu-content" style="margin-top:10px; flex-grow:1; overflow-y:auto"></div>                             <button class="mw-ui-button mw-ui-quiet" id="tp-menu-close">Cancel</button>                         </div>                                                  <div id="tp-edit-form" style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: var(--background-color-base, #fff); z-index: 110; display: none; flex-direction: column; padding: 15px;">                             <b id="tp-form-title">Edit Entry</b>                             <div class="tp-form-row"><label>Title</label><input type="text" id="f-title"></div>                             <div class="tp-form-row"><label>Lat</label><input type="text" id="f-lat"></div>                             <div class="tp-form-row"><label>Lon</label><input type="text" id="f-lon"></div>                             <div class="tp-form-row"> 							    <label>Wikidata</label> 							    <div style="display:flex; gap:5px"> 							        <input type="text" id="f-wikidata" style="flex-grow:1" placeholder="e.g. Q42"> 							        <button id="f-fetch-wd" class="mw-ui-button" style="min-width:auto; padding:0 8px" title="Fetch Title & Coordinates">⬆️</button> 							    </div> 							</div>                             <div class="tp-form-row"><label>Note</label><textarea id="f-note"></textarea></div>                             <div class="tp-form-row"><label>Source Page</label><input type="text" id="f-source" placeholder="e.g. Rome"></div>                             <div style="display:flex; gap:5px">                                 <button class="mw-ui-button mw-ui-progressive" id="f-save">Save</button>                                 <button class="mw-ui-button" id="f-cancel">Cancel</button>                             </div>                         </div>                          <div id="tp-route-overlay" style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: var(--background-color-base, #fff); z-index: 110; display: none; flex-direction: column; padding: 15px;">                             <b style="margin-bottom:10px; display:block">Route Calculator</b>                                                          <div class="tp-setting-block">                                 <label>ORS API Key <a href="https://openrouteservice.org/dev/#/signup" target="_blank" style="font-weight:normal; font-size:0.9em">(Get Free Key)</a></label>                                 <input type="text" id="tp-ors-key" placeholder="Paste key here..." value="${safeKey}">                             </div>                              <div class="tp-setting-block">                                 <label>Travel Mode</label>                                 <div style="display:flex; gap:5px"> 	                                 <select id="tp-route-profile" style="flex: 1; padding:5px; border:1px solid #ccc; border-radius:4px"> 	                                    <option value="foot-walking" ${(trip.profile || 'foot-walking') === 'foot-walking' ? 'selected' : ''}>🚶 Walking</option> 	                                    <option value="driving-car" ${(trip.profile || 'foot-walking') === 'driving-car' ? 'selected' : ''}>🚗 Driving (Car)</option> 	                                </select> 	                                <button id="tp-run-route" class="mw-ui-button mw-ui-progressive" style="flex:1">Calculate Routes</button>                                 </div>                             </div>                              <div class="tp-divider"></div>                              <div class="tp-setting-block">                                 <label>Optimize Day Order</label>                                 <div style="display:flex; gap:5px">                                     <select id="tp-opt-day" style="flex:1; padding:5px; border:1px solid #ccc; border-radius:4px">                                         ${dayOptions}                                     </select>                                     <button id="tp-run-opt" class="mw-ui-button" style="flex:1">Optimize</button>                                 </div>                                 <div style="font-size:0.75em; color:#666; margin-top:2px">Keeps 1st item fixed, reorders the rest.</div>                             </div>                              <button id="tp-close-route" class="mw-ui-button" style="margin-top:auto; width:100%">Close</button>                         </div>                     </div>                 `);                  $('#trip-planner-app').append($widget);                  // Move map container into its Full Mode wrapper                 $mapContainer.appendTo($widget.find('#tp-map-container-wrapper')).show();                  // Refresh map layout immediately as it might have been moved/resized                 if (this.map) {                     setTimeout(() => {                         this.map.invalidateSize();                         const fit = !this.dontFitMapNextTime;                         this.updateMapMarkers(fit);                         this.dontFitMapNextTime = false;                     }, 200); // slightly longer timeout to ensure DOM catch-up                 }              } else {                 $widget = $(`                     <div id="tp-widget">                         <div class="tp-header">                             <span id="tp-toggle" style="flex-grow:1">🎒 Trip Planner</span>                             <span>                                 <a href="${helpUrl}" target="_blank" style="color:white; text-decoration:none; font-weight:bold; cursor:pointer;" title="Help / Documentation">?</a>                                 <span id="tp-toggle-btn" style="cursor:pointer; margin-left:12px; font-weight:bold" title="Minimize to Icon">-</span>                                 <span id="tp-close-btn" style="cursor:pointer; margin-left:12px; font-weight:bold" title="Close">✕</span>                             </span>                         </div>                          <div class="tp-content" style="display: block; position:relative">                             <div id="tp-menu-overlay">                                 <b id="tp-menu-title">Menu</b>                                 <div id="tp-menu-content" style="margin-top:10px; flex-grow:1; overflow-y:auto"></div>                                 <button class="mw-ui-button mw-ui-quiet" id="tp-menu-close">Cancel</button>                             </div>                                                          <div id="tp-edit-form">                                 <b id="tp-form-title">Edit Entry</b>                                 <div class="tp-form-row"><label>Title</label><input type="text" id="f-title"></div>                                 <div class="tp-form-row"><label>Lat</label><input type="text" id="f-lat"></div>                                 <div class="tp-form-row"><label>Lon</label><input type="text" id="f-lon"></div>                                 <div class="tp-form-row">                                     <label>Wikidata</label>                                     <div style="display:flex; gap:5px">                                         <input type="text" id="f-wikidata" style="flex-grow:1" placeholder="e.g. Q42">                                         <button id="f-fetch-wd" class="mw-ui-button" style="min-width:auto; padding:0 8px" title="Fetch Title & Coordinates">⬆️</button>                                     </div>                                 </div>                                 <div class="tp-form-row"><label>Note</label><textarea id="f-note"></textarea></div>                                 <div class="tp-form-row"><label>Source Page</label><input type="text" id="f-source" placeholder="e.g. Rome"></div>                                 <div style="display:flex; gap:5px">                                     <button class="mw-ui-button mw-ui-progressive" id="f-save">Save</button>                                     <button class="mw-ui-button" id="f-cancel">Cancel</button>                                 </div>                             </div>                                                          <div class="tp-settings">                                 <div class="tp-settings-row">                                     <select id="tp-select-trip" style="flex-grow:1"></select>                                 </div>                             </div>                              <div class="tp-body">                                 <div id="tp-buckets-container"></div>                                 <div id="tp-days-container"></div>                             </div>                              <div class="tp-controls" style="grid-template-columns: 1fr;">                                 <a href="${fullModeUrl}" class="mw-ui-button mw-ui-progressive" style="text-align:center; text-decoration:none; display:block">🗺️ Open Full Trip Planner</a>                             </div>                         </div>                         <input type="file" id="tp-file-input" style="display:none" accept=".json">                     </div>                 `);                  $('body').append($widget);                  // Keep map detached/hidden in Widget Mode                 $mapContainer.hide().appendTo('body');             }              if ($('#tp-file-input').length === 0) {                 $('body').append('<input type="file" id="tp-file-input" style="display:none" accept=".json">');             }              this.renderTripOptions();             this.renderBins();              if (!isFullMode) {                 $('body').removeClass('tp-mobile-map-open');             }              if (isFullMode && this.map) {                 this.updateMapMarkers();             }             this.updatePageIndicators();             this.updateWidgetButtonsVisibility();              if (isFullMode && this.map && this.routesActive && this.data.orsKey) {                 const profile = trip.profile || 'foot-walking';                 setTimeout(() => {                     this.triggerAutoRoute();                 }, 500); 				this.updateRouteButtonState();             }         },          updateWidgetButtonsVisibility: function () {             const isFullMode = $('#trip-planner-app').length > 0;             if (!isFullMode) {                 // Hide day action buttons that are injected constantly in renderBins                 $('#tp-widget .tp-btn-move').hide();                 $('#tp-widget .tp-edit-bucket').hide();                 $('#tp-widget .tp-remove-bucket').hide();                 $('#tp-widget .tp-add-custom-btn').hide();             }         },          drawLauncher: function () {             createLauncherIcon(() => {                 this.data.closed = false;                 this.saveAndRefresh();             });         },          renderTripOptions: function () {             const $select = $('#tp-select-trip').empty();             const currentIdx = (this.localTripIndex !== null) ? this.localTripIndex : this.data.activeTripIndex;             this.data.trips.forEach((t, i) => $select.append($('<option>', { value: i, text: t.name, selected: i === currentIdx })));         },          renderBins: function () {             const isFullMode = $('#trip-planner-app').length > 0;             const trip = this.getActiveTrip();              // Render all buckets             const $bucketsContainer = $('#tp-buckets-container').empty();             trip.buckets.forEach(b => {                 const isBacklog = b.id === 'backlog';                 const $bucketBlock = $(`                     <div class="tp-section-title tp-bucket-header" data-bucket-id="${b.id}">                         <span class="tp-bucket-title-text" style="cursor: pointer;" title="Click to highlight on map, double-click to edit/rename">${b.icon} ${b.name}</span>                         <div>                             ${isFullMode ? `                                 <span class="tp-btn-edit tp-add-custom-btn" data-bucket-id="${b.id}">+ POI</span>                                 <span class="tp-btn-move" data-move-type="bucket" data-bucket-id="${b.id}">Move</span>                                 <span class="tp-btn-edit tp-edit-bucket" data-bucket-id="${b.id}" title="Edit/Rename Bucket">✏️</span>                                 ${!isBacklog ? `<span class="tp-remove tp-remove-bucket" data-bucket-id="${b.id}" title="Delete Bucket" style="font-weight:bold; margin-left:5px">×</span>` : ''}                             ` : `                                 <span class="tp-btn-edit tp-add-article-btn" data-bucket-id="${b.id}" title="Add current article as destination">+ Article</span>                             `}                         </div>                     </div>                     <div class="tp-bin tp-bucket-bin" data-type="bucket" data-bucket-id="${b.id}"></div>                 `);                 b.items.forEach((item, idx) => {                     $bucketBlock.filter('.tp-bucket-bin').append(this.createItemHTML(item, 'bucket', idx, b.id));                 });                 $bucketsContainer.append($bucketBlock);             });              // Render days             const $daysContainer = $('#tp-days-container').empty();             trip.days.forEach((day, dIdx) => {                 const dayDate = new Date(trip.startDate);                 dayDate.setDate(dayDate.getDate() + dIdx);                 const $dayBlock = $(`                     <div class="tp-section-title" style="color:${this.colors[dIdx % this.colors.length]}">                          <span>Day ${dIdx + 1} (${dayDate.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })})</span>                         <div>                             ${isFullMode ? `                                 <span class="tp-btn-edit tp-add-custom-btn" data-day-idx="${dIdx}">+ POI</span>                                 <span class="tp-btn-move" data-move-type="day" data-day-idx="${dIdx}">Move</span>                             ` : ''}                         </div>                     </div>                     <div class="tp-bin tp-day-bin" data-type="day" data-day-idx="${dIdx}"></div>                 `);                 day.items.forEach((item, iIdx) => {                     $dayBlock.filter('.tp-day-bin').append(this.createItemHTML(item, 'day', iIdx, dIdx));                 });                 $daysContainer.append($dayBlock);             });              if (isFullMode)                 this.updateMapMarkers();             this.updatePageIndicators();             this.updateWidgetButtonsVisibility();         },          updatePageIndicators: function () {             const thiss = this;             const trip = this.getActiveTrip();             const allItems = [...trip.buckets.flatMap(b => b.items), ...trip.days.flatMap(d => d.items)];              // Create Sets for fast lookup (Wikidata ID or Lat,Lon string)             const wdSet = new Set(allItems.map(i => i.wikidata).filter(Boolean));             const locSet = new Set(allItems.map(i => i.lat + "," + i.lon));              $('.vcard').each(function () {                 const $l = $(this);                 // Extract same data as draggable logic                 const lat = $l.attr('data-lat') || $l.find('[data-lat]').attr('data-lat');                 const lon = $l.attr('data-lon') || $l.find('[data-lon]').attr('data-lon');                 const wdId = $l.find('[id]').filter(function () { return /^Q\d+$/.test(this.id); }).attr('id');                  // Check if this listing is in our data                 const isPresent = (wdId && wdSet.has(wdId)) ||                     (lat && lon && locSet.has(thiss.parseCoordinate(lat) + "," + thiss.parseCoordinate(lon)));                  if (isPresent) $l.addClass('tp-in-trip');                 else $l.removeClass('tp-in-trip');             });         },          getItemUrl: function (item) {             if (!item.sourcePage) return '#';             const src = item.sourcePage.trim();             const baseUrl = /^(https?:)?\/\//i.test(src) ? src : mw.util.getUrl(src);             return baseUrl + (item.wikidata ? '#' + item.wikidata : '');         },          createItemHTML: function (item, type, idx, binIdOrIdx) {             const safeTitle = mw.html.escape(item.title);             const safeNote = item.note ? mw.html.escape(item.note) : ''; 			const isNoCoords = !item.lat || !item.lon;             const titleStyle = isNoCoords ? 'font-style:italic;' : '';              const url = this.getItemUrl(item);             const orderHtml = (type === 'day')                 ? `<span style="color:#555; margin-right:6px; min-width:15px;">${idx + 1}.</span>`                 : '';              return $(`                 <div class="tp-item" draggable="true" data-type="${type}" data-idx="${idx}" data-bin-id="${binIdOrIdx}">                     ${orderHtml}                     <div style="display:flex; flex-direction:column; overflow:hidden; flex-grow:1; margin-right:5px">                         <span style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-weight:bold; ${titleStyle}" title="${safeTitle}">${safeTitle}</span>                         ${safeNote ? `<span style="font-size:0.75em; color:#666; overflow:hidden; text-overflow:ellipsis; white-space:nowrap">${safeNote}</span>` : ''}                     </div>                     <span style="display:flex; align-items:center; flex-shrink:0">                         <span class="tp-btn-edit item-edit-trigger" style="border:none; padding:0 3px; font-size:1.1em">✏️</span>                         ${item.sourcePage ? `<a href="${url}" target="_blank" style="text-decoration:none; margin:0 3px">🔗</a>` : ''}                         <span class="tp-remove">×</span>                     </span>                 </div>             `);         },          showMenu: function (title, options) {             $('#tp-menu-title').text(title);             const $cont = $('#tp-menu-content').empty();              options.forEach(opt => {                 if (opt.header) {                     $cont.append(`                         <div style="                             font-weight: bold;                              color: #555;                              margin: 12px 0 4px 0;                              border-bottom: 1px solid #eee;                              padding-bottom: 2px;                             font-size: 0.9em;                         ">${mw.html.escape(opt.header)}</div>                     `);                     return;                 }                  // Standard Buttons                 const $btn = $(`<button class="tp-menu-btn">${opt.label}</button>`);                 if (opt.disabled) {                     $btn.prop('disabled', true).css({ opacity: 0.6, cursor: 'not-allowed', background: '#f8f8f8', color: '#888' });                     if (opt.helpText) {                         $btn.append(`<div style="font-size:0.8em; font-style:italic; margin-top:2px">${opt.helpText}</div>`);                     }                 } else {                     $btn.on('click', () => { opt.action(); $('#tp-menu-overlay').hide(); });                 }                 $cont.append($btn);             });             $('#tp-menu-overlay').css('display', 'flex');         },          exportData: function (format) {             const trip = this.getActiveTrip();             let content = "", mime = "text/plain", ext = "txt";              switch (format) {                 case 'trip':                     content = JSON.stringify(trip, null, 2);                     mime = "application/json";                     ext = "json";                     break;                 case 'all':                     const cleanData = {                         trips: this.data.trips                     };                     content = JSON.stringify(cleanData, null, 2);                     mime = "application/json";                     ext = "json";                     break;                 case 'gpx':                     content = `<?xml version="1.0" encoding="UTF-8"?><gpx version="1.1" creator="WVPlanner">`;                     [...trip.buckets.flatMap(b => b.items), ...trip.days.flatMap(d => d.items)].forEach(i => { if (i.lat) content += `<wpt lat="${i.lat}" lon="${i.lon}"><name>${i.title}</name></wpt>`; });                     content += `</gpx>`;                     mime = "application/gpx+xml";                     ext = "gpx";                     break;                 case 'wiki':                     trip.days.forEach((d, idx) => {                         content += `\n=== Day ${idx + 1} ===\n`;                         d.items.forEach(i => content += `* {{listing|name=${i.title}|lat=${i.lat}|long=${i.lon}|wikidata=${i.wikidata || ''}|content=${i.note || ''}}}\n`);                     });                     break;                 case 'geojson-full':                     var fc = { type: "FeatureCollection", features: [] };                      // 1. Flatten all items (Buckets + all Days)                     var allItems = trip.buckets.flatMap(b => b.items).concat(trip.days.reduce(function (acc, d) {                         return acc.concat(d.items);                     }, []));                      // 2. Add Points (Listings)                     allItems.forEach(function (i) {                         if (i.lat && i.lon) {                             fc.features.push({                                 type: "Feature",                                 geometry: { type: "Point", coordinates: [i.lon, i.lat] },                                 properties: {                                     name: i.title,                                     note: i.note || "",                                     wikidata: i.wikidata,                                     day: i.day !== undefined ? i.day + 1 : 'bucket'                                 }                             });                         }                     });                      // 3. Add Lines (Routes) from Leaflet Layer using concat                     if (this.routeLayer && window.L) {                         var routes = this.routeLayer.toGeoJSON();                         if (routes && routes.features) {                             fc.features = fc.features.concat(routes.features);                         }                     }                      content = JSON.stringify(fc, null, 2);                     mime = "application/geo+json";                     ext = "geojson";                     break;             }             const blob = new Blob([content], { type: mime });             const a = document.createElement('a'); a.href = URL.createObjectURL(blob);             a.download = `${trip.name.replace(/\s/g, '_')}.${ext}`; a.click();         },          setupListeners: function () {             const self = this;             const thiss = this;              $(document).on('click', '#tp-minimized-icon', function () {                 self.data.minimized = false;                 self.saveAndRefresh();             });              // Settings Overlay Toggles             $(document).on('click', '#tp-open-settings', () => $('#tp-settings-overlay').css('display', 'flex'));             $(document).on('click', '#tp-close-settings', () => {                 $('#tp-settings-overlay').hide();                 self.saveAndRefresh(); // Ensure changes (like rename) reflect in the main UI immediately             });              $(document).on('change', '#tp-route-profile', function () {                 const trip = self.getActiveTrip();                 trip.profile = $(this).val();                 self.save();             });              // NEW: Rename Logic             $(document).on('change', '#tp-rename-input', function () {                 self.getActiveTrip().name = $(this).val();                 self.save(); // Just save, don't redraw whole UI yet (wait for Close)             });              // Move Trip Logic             $(document).on('click', '.tp-btn-move', function () {                 const moveType = $(this).data('move-type'); // 'bucket' or 'day'                 const bucketId = $(this).data('bucket-id'); // if bucket                 const sourceDayIdx = $(this).data('day-idx') !== undefined ? parseInt($(this).data('day-idx')) : -1;                  const currentTripIdx = self.data.activeTripIndex;                 const currentTrip = self.getActiveTrip();                  let menuOptions = [];                  // Helper to perform the move                 const performMove = (targetTrip, targetType, targetBinIdOrIdx) => {                     let items;                     // Cut                     if (moveType === 'bucket') {                         const bucket = currentTrip.buckets.find(b => b.id === bucketId);                         items = bucket ? bucket.items.splice(0) : [];                     } else {                         items = currentTrip.days[sourceDayIdx].items.splice(0);                     }                     // Paste                     if (targetType === 'bucket') {                         const targetBucket = targetTrip.buckets.find(b => b.id === targetBinIdOrIdx);                         if (targetBucket) targetBucket.items.push(...items);                     } else {                         targetTrip.days[targetBinIdOrIdx].items.push(...items);                     }                     self.saveAndRefresh();                 };                  // Helper to add options for a specific trip                 const addTripOptions = (trip, isCurrent) => {                     let addedHeader = false;                      const ensureHeader = () => {                         if (!addedHeader) {                             menuOptions.push({ header: isCurrent ? "Current Trip" : trip.name });                             addedHeader = true;                         }                     };                      // 1. Bucket Options                     trip.buckets.forEach(b => {                         // Show if: It's another trip OR (It's current trip AND we aren't already in this specific bucket)                         if (!isCurrent || moveType !== 'bucket' || b.id !== bucketId) {                             ensureHeader();                             menuOptions.push({                                 label: `${b.icon} ${b.name}`,                                 action: () => performMove(trip, 'bucket', b.id)                             });                         }                     });                      // 2. Day Options                     trip.days.forEach((day, dIdx) => {                         // Show if: It's another trip OR (It's current trip AND we aren't in this specific day)                         if (!isCurrent || (moveType !== 'day' || dIdx !== sourceDayIdx)) {                             ensureHeader();                             menuOptions.push({                                 label: `➡ Day ${dIdx + 1}`,                                 action: () => performMove(trip, 'day', dIdx)                             });                         }                     });                 };                  // A. Process Current Trip First                 addTripOptions(currentTrip, true);                  // B. Process Other Trips                 self.data.trips.forEach((trip, tIdx) => {                     if (tIdx === currentTripIdx) return;                     addTripOptions(trip, false);                 });                  if (menuOptions.length === 0) {                     alert("No destinations available.");                 } else {                     const title = (moveType === 'bucket') ? `Move Bucket Items to:` : `Move Day ${sourceDayIdx + 1} Items to:`;                     self.showMenu(title, menuOptions);                 }             });              // Save Menu Logic             $(document).on('click', '#tp-btn-export', () => {                 const hasRoutes = self.routeLayer && window.L && self.routeLayer.getLayers().length > 0;                 self.showMenu("Export Format:", [                     { label: "Current Trip (JSON)", action: () => self.exportData('trip') },                     { label: "All Data Backup (JSON)", action: () => self.exportData('all') },                     { label: "GPS File (GPX)", action: () => self.exportData('gpx') },                     { label: "Wikivoyage Text (WikiText)", action: () => self.exportData('wiki') },                     {                         label: "Trip + Routes (GeoJSON)",                         action: () => self.exportData('geojson-full'),                         disabled: !hasRoutes,                         helpText: !hasRoutes ? "(Calculate routes first to enable)" : ""                     }                 ]);             });              $(document).on('click', '#tp-menu-close', () => $('#tp-menu-overlay').hide());              // Add / Edit Forms             $(document).on('click', '#tp-add-article, .tp-add-article-btn', function () {                 const bucketId = $(this).data('bucket-id') || 'backlog';                 const pageName = mw.config.get('wgPageName');                 const wdId = mw.config.get('wgWikibaseItemId');                 if (!wdId) return alert("This page has no Wikidata ID.");                  self.fetchWikidata(wdId, pageName, (item) => {                     const trip = self.getActiveTrip();                     const bucket = trip.buckets.find(b => b.id === bucketId) || trip.buckets[0];                     if (bucket) {                         bucket.items.push(item);                         self.saveAndRefresh();                         mw.notify(`Added article: ${item.title}`);                     }                 }).fail(() => alert("Failed to fetch article data."));             });              $(document).on('click', '#f-fetch-wd', function () {                 const id = $('#f-wikidata').val().trim().toUpperCase();                  if (!/^Q\d+$/.test(id)) {                     return;                 }                  const $btn = $(this);                 const originalText = $btn.text();                 $btn.text('⏳').prop('disabled', true);                  self.fetchWikidata(id, null, (item) => {                     $('#f-title').val(item.title);                     $('#f-lat').val(item.lat);                     $('#f-lon').val(item.lon);                 })                     .fail(err => alert(err.message || "Fetch failed."))                     .always(function () {                         $btn.text(originalText).prop('disabled', false);                     });             });              let editingRef = null;             $(document).on('click', '#tp-add-custom, .tp-add-custom-btn, .item-edit-trigger', function () {                 const isNew = $(this).attr('id') === 'tp-add-custom' || $(this).hasClass('tp-add-custom-btn');                  if (isNew) {                     const bucketId = $(this).data('bucket-id') || 'backlog';                     const dayIdx = $(this).data('day-idx');                     if (dayIdx !== undefined) {                         editingRef = { type: 'day', idx: -1, binId: dayIdx };                     } else {                         editingRef = { type: 'bucket', idx: -1, binId: bucketId };                     }                 } else {                     const p = $(this).closest('.tp-item');                     editingRef = { type: p.data('type'), idx: p.data('idx'), binId: p.data('bin-id') };                 }                  const item = isNew ?                     { title: "", lat: "", lon: "", note: "", sourcePage: "" } :                     (editingRef.type === 'bucket' ?                         self.getActiveTrip().buckets.find(b => b.id === editingRef.binId).items[editingRef.idx] :                         self.getActiveTrip().days[parseInt(editingRef.binId)].items[editingRef.idx]                     );                 $('#f-title').val(item.title);                 $('#f-lat').val(item.lat);                 $('#f-lon').val(item.lon);                 $('#f-wikidata').val(item.wikidata);                 $('#f-note').val(item.note || "");                 $('#f-source').val(item.sourcePage || "");                 $('#tp-edit-form').css('display', 'flex');             });              $(document).on('click', '#tp-add-bucket', () => {                 initOOUI();                 if (!TripPlanner.windowManager) {                     TripPlanner.windowManager = new OO.ui.WindowManager();                     $('body').append(TripPlanner.windowManager.$element);                      TripPlanner.bucketEditDialog = new BucketEditDialog({                         size: 'medium'                     });                     TripPlanner.windowManager.addWindows([TripPlanner.bucketEditDialog]);                 }                  TripPlanner.windowManager.openWindow(TripPlanner.bucketEditDialog, {                     title: 'Add Custom Bucket',                     name: '',                     icon: '🗂️',                     onSave: function (name, icon) {                         const trip = self.getActiveTrip();                         const id = 'bucket_' + Math.random().toString(36).substr(2, 9);                         trip.buckets.push({                             id: id,                             name: name,                             icon: icon,                             items: []                         });                         self.saveAndRefresh();                     }                 });             });              $(document).on('click', '.tp-edit-bucket', function (e) {                 e.stopPropagation();                 const bucketId = $(this).data('bucket-id');                 const trip = self.getActiveTrip();                 const bucket = trip.buckets.find(b => b.id === bucketId);                 if (!bucket) return;                  initOOUI();                 if (!TripPlanner.windowManager) {                     TripPlanner.windowManager = new OO.ui.WindowManager();                     $('body').append(TripPlanner.windowManager.$element);                      TripPlanner.bucketEditDialog = new BucketEditDialog({                         size: 'medium'                     });                     TripPlanner.windowManager.addWindows([TripPlanner.bucketEditDialog]);                 }                  TripPlanner.windowManager.openWindow(TripPlanner.bucketEditDialog, {                     title: 'Edit Bucket: ' + bucket.name,                     name: bucket.name,                     icon: bucket.icon,                     onSave: function (name, icon) {                         bucket.name = name;                         bucket.icon = icon;                         self.saveAndRefresh();                     }                 });             });              $(document).on('click', '.tp-remove-bucket', function (e) {                 e.stopPropagation();                 const bucketId = $(this).data('bucket-id');                 if (bucketId === 'backlog') {                     alert("The default backlog bucket cannot be deleted.");                     return;                 }                 const trip = self.getActiveTrip();                 const bucketIdx = trip.buckets.findIndex(b => b.id === bucketId);                 if (bucketIdx === -1) return;                  const bucket = trip.buckets[bucketIdx];                 if (bucket.items.length > 0) {                     if (confirm(`Bucket "${bucket.name}" contains ${bucket.items.length} item(s). Move them to Backlog first?`)) {                         const backlog = trip.buckets.find(b => b.id === 'backlog');                         if (backlog) backlog.items.push(...bucket.items);                     } else if (!confirm("Are you sure you want to delete this bucket and ALL its items permanently?")) {                         return;                     }                 }                  trip.buckets.splice(bucketIdx, 1);                 self.saveAndRefresh();             });              $(document).on('click', '#f-save', () => {                 const item = {                     title: $('#f-title').val(),                     lat: self.parseCoordinate($('#f-lat').val()),                     lon: self.parseCoordinate($('#f-lon').val()),                     note: $('#f-note').val(),                     wikidata: $('#f-wikidata').val(),                     sourcePage: $('#f-source').val().trim()                 };                  if (editingRef.idx === -1) {                     const list = (editingRef.type === 'bucket')                         ? self.getActiveTrip().buckets.find(b => b.id === editingRef.binId).items                         : self.getActiveTrip().days[parseInt(editingRef.binId)].items;                      if (editingRef.insertIdx !== undefined && editingRef.insertIdx !== -1) {                         list.splice(editingRef.insertIdx, 0, item);                     } else {                         list.push(item);                     }                 } else if (editingRef.type === 'bucket') {                     self.getActiveTrip().buckets.find(b => b.id === editingRef.binId).items[editingRef.idx] = item;                 } else {                     self.getActiveTrip().days[parseInt(editingRef.binId)].items[editingRef.idx] = item;                 }                 $('#tp-edit-form').hide(); self.saveAndRefresh();             });              $(document).on('click', '#f-cancel', () => $('#tp-edit-form').hide());              // Widget Basics             $(document).on('click', '#tp-close-btn', function (e) {                 e.stopPropagation(); // Prevent triggering the header toggle                 self.data.closed = true;                 self.saveAndRefresh();             });             $(document).on('click', '#tp-toggle-btn', function (e) {                 e.stopPropagation();                 self.data.minimized = true;                 self.saveAndRefresh();             });             $(document).on('change', '#tp-select-trip', (e) => {                 const newIdx = parseInt($(e.target).val());                 const isFullMode = $('#trip-planner-app').length > 0;                  if (isFullMode) { 					// only permanently change the trip if no other tabs; TODO: consider some even better strategy                     const otherTabsOpen = this.activeOtherTabs && this.activeOtherTabs.size > 0;                     if (otherTabsOpen) {                         this.localTripIndex = newIdx;                         this.drawUI();                     } else {                         this.data.activeTripIndex = newIdx;                         this.localTripIndex = newIdx;                         this.saveAndRefresh();                     }                 } else {                     this.data.activeTripIndex = newIdx;                     this.saveAndRefresh();                 }             });             $(document).on('click', '#tp-new-trip', () => {                 const n = prompt("Trip Name?");                 if (n) {                     self.createNewTrip(n);                     const newIdx = self.data.trips.length - 1;                     const isFullMode = $('#trip-planner-app').length > 0;                      if (isFullMode) {                         this.localTripIndex = newIdx;                     } else {                         this.data.activeTripIndex = newIdx;                     }                     self.saveAndRefresh();                 }             });              // Delete Trip Logic             $(document).on('click', '#tp-del-trip', () => {                 const currentIdx = (this.localTripIndex !== null) ? this.localTripIndex : this.data.activeTripIndex;                 if (this.data.trips.length > 1 && confirm("Are you sure you want to delete this trip permanently?")) {                     this.data.trips.splice(currentIdx, 1);                     this.data.activeTripIndex = 0;                     this.localTripIndex = 0;                     self.migrateData();                     this.saveAndRefresh();                 } else if (this.data.trips.length <= 1) {                     alert("You cannot delete the only remaining trip.");                 }             });              $(document).on('change', '#tp-length', (e) => {                 const trip = self.getActiveTrip();                 const n = Math.max(1, parseInt($(e.target).val()));                 if (n < trip.days.length) {                     const backlog = trip.buckets.find(b => b.id === 'backlog') || trip.buckets[0];                     trip.days.splice(n).forEach(d => backlog.items.push(...d.items));                 } else {                     while (trip.days.length < n)                         trip.days.push({ items: [] });                 }                 trip.length = n;                 self.saveAndRefresh();             });              $(document).on('change', '#tp-start-date', function (e) {                 self.getActiveTrip().startDate = $(this).val();                 self.saveAndRefresh();             });              $(document).on('click', '.tp-remove', function () {                 const p = $(this).closest('.tp-item');                 const trip = self.getActiveTrip();                 const binType = p.data('type');                 const binId = p.data('bin-id');                 const idx = p.data('idx');                  if (binType === 'bucket') {                     const bucket = trip.buckets.find(b => b.id === binId);                     if (bucket) bucket.items.splice(idx, 1);                 } else {                     trip.days[parseInt(binId)].items.splice(idx, 1);                 }                 self.saveAndRefresh();             });              $(document).on('click', '#tp-run-opt', () => {                 const dayIdx = parseInt($('#tp-opt-day').val());                 const profile = $('#tp-route-profile').val(); // Get selected profile                  self.optimizeDay(dayIdx, profile);                 $('#tp-route-overlay').hide();             });              // D&D             $(document).on('dragenter', '.tp-bin', function (e) { $(this).addClass('drag-over'); });             $(document).on('dragleave', '.tp-bin', function (e) {                 const relTarget = e.originalEvent && e.originalEvent.relatedTarget;                 if (!relTarget || !this.contains(relTarget)) {                     $(this).removeClass('drag-over');                 }             });             $(document).on('dragend drop', function () { $('.tp-bin').removeClass('drag-over'); });             $(document).on('dragover', '.tp-bin', (e) => e.preventDefault());             $(document).on('drop', '.tp-bin', function (e) {                 e.preventDefault();                 e.stopPropagation();                 $(this).removeClass('drag-over');                 const self = thiss;                 $('.tp-route-stats').remove();                 if (self.routeLayer) self.routeLayer.clearLayers();                 self.cachedRoutes = {};                  const trip = self.getActiveTrip();                 const transfer = e.originalEvent.dataTransfer;                  const binType = $(this).data('type'); // 'bucket' or 'day'                 const binId = $(this).data('bucket-id'); // for buckets                 const dayIdx = $(this).data('day-idx'); // for days                  const targetList = (binType === 'bucket')                     ? trip.buckets.find(b => b.id === binId).items                     : trip.days[parseInt(dayIdx)].items;  				let jsonData = transfer.getData('application/json') || transfer.getData('text/plain');                 if (jsonData) {                     try {                         const item = JSON.parse(jsonData);                         if (!item.title) throw "not a POI";                          // 1. HANDLE REMOVAL FROM SOURCE                         if (item._srcType !== undefined && !e.ctrlKey && !e.shiftKey) {                             const srcTrip = self.data.trips[item._srcTripIdx];                             if (srcTrip) {                                 if (item._srcType === 'bucket') {                                     const srcBucket = srcTrip.buckets.find(b => b.id === item._srcBinId);                                     if (srcBucket) srcBucket.items.splice(item._srcIdx, 1);                                 } else {                                     srcTrip.days[parseInt(item._srcBinId)].items.splice(item._srcIdx, 1);                                 }                             }                              // Clean up metadata                             delete item._srcType;                             delete item._srcIdx;                             delete item._srcBinId;                             delete item._srcTripIdx;                         } else if (item._srcType !== undefined) {                             // Duplication mode                             delete item._srcType;                             delete item._srcIdx;                             delete item._srcBinId;                             delete item._srcTripIdx;                         }                          // 2. HANDLE INSERTION                         const $targetItem = $(e.target).closest('.tp-item');                         if ($targetItem.length && $targetItem.data('idx') !== undefined) {                             targetList.splice($targetItem.data('idx'), 0, item);                         } else {                             targetList.push(item);                         }  						self.dontFitMapNextTime = true;                         self.saveAndRefresh();                         return;                     } catch (e) { /* not JSON listing, continue */ }                 }                  // .marker from ListingBrowser                 const htmlData = transfer.getData('text/html');                 if (htmlData) {                     const $html = $(htmlData);                     const $m = $html.hasClass('marker') ? $html : $html.find('.marker').first();                     var pageName = '';                      if ($m.attr('marker-source-page') !== undefined) {                         pageName = $m.attr('marker-source-page');                     } else {                         const href = $html.attr('href') || $m.attr('href');                         if (href && href !== '#') {                             const match = href.match(/.*\/wiki\/(.+)#(Q\d+)/);                             const match2 = href.match(/.*\/wiki\/(.+)/);                             if (match) {                                 pageName = decodeURIComponent(match[1]);                             } else if (match2) {                                 pageName = decodeURIComponent(match2[1]);                             } else {                                 pageName = href;                             }                         }                     } 	                     if ($m.length && ($m.attr('marker-lat') || $m.attr('marker-wikidata'))) {                         const srcType = $m.attr('marker-src-type');                         const srcBinId = $m.attr('marker-src-bin-id');                         const srcIdx = parseInt($m.attr('marker-src-idx'));                         const srcTripIdx = parseInt($m.attr('marker-src-trip-idx'));                          if (srcType && !isNaN(srcIdx) && !e.ctrlKey && !e.shiftKey) {                             const srcTrip = self.data.trips[srcTripIdx];                             if (srcTrip) {                                 if (srcType === 'bucket') {                                     const srcBucket = srcTrip.buckets.find(b => b.id === srcBinId);                                     if (srcBucket) srcBucket.items.splice(srcIdx, 1);                                 } else {                                     const day = srcTrip.days[parseInt(srcBinId)];                                     if (day) day.items.splice(srcIdx, 1);                                 }                             }                         }                          const note = $m.attr('marker-note') !== undefined ? $m.attr('marker-note') : "";                          const newItem = {                             title: $m.attr('marker-name') || $m.text().trim(),                             lat: self.parseCoordinate($m.attr('marker-lat')),                             lon: self.parseCoordinate($m.attr('marker-long')),                             wikidata: $m.attr('marker-wikidata'),                             sourcePage: pageName,                             note: note                         };                          const $targetItem = $(e.target).closest('.tp-item');                         if ($targetItem.length && $targetItem.data('idx') !== undefined) {                             targetList.splice($targetItem.data('idx'), 0, newItem);                         } else {                             targetList.push(newItem);                         }                          self.dontFitMapNextTime = true;                         self.saveAndRefresh();                         return; 					}                 }                  // Wikidata URL Drop                 const rawText = transfer.getData('text/plain') || transfer.getData('URIList');                 const wdUrlMatch = rawText ? rawText.match(/wikidata\.org\/.*?(Q\d+)/i) : null;                 if (wdUrlMatch) {                     const wdId = wdUrlMatch[1];                     const $bin = $(this);                     const binType = $bin.data('type');                     const binId = $bin.data('bucket-id');                     const dayIdx = $bin.data('day-idx');                      const $targetItem = $(e.target).closest('.tp-item');                     const insertIdx = ($targetItem.length && $targetItem.data('idx') !== undefined) ? parseInt($targetItem.data('idx')) : -1;                      mw.notify(`Fetching Wikidata item ${wdId}...`);                      self.fetchWikidata(wdId, null, (item) => {                         $('#f-title').val(item.title);                         $('#f-lat').val(item.lat);                         $('#f-lon').val(item.lon);                         $('#f-wikidata').val(item.wikidata);                         $('#f-note').val("");                         $('#f-source').val("");                          editingRef = {                             type: binType,                             idx: -1,                             binId: binType === 'bucket' ? binId : parseInt(dayIdx),                             insertIdx: insertIdx                         };                          $('#tp-edit-form').css('display', 'flex');                     }).fail(() => {                         mw.notify(`Failed to fetch Wikidata item ${wdId}`, { type: 'error' });                     });                     return;                 }                  // Article[/Subarticle]#Qid                 if (rawText && rawText.includes('#Q')) {                     const match = rawText.match(/.*\/wiki\/(.+)#(Q\d+)/);                     if (match) {                         const pageName = decodeURIComponent(match[1]);                         const wdId = match[2];                          self.fetchWikidata(wdId, pageName, (item) => {                             targetList.push(item); 							self.dontFitMapNextTime = true;                             self.saveAndRefresh();                         }).fail(() => mw.notify("Failed to parse dropped link.", { type: 'error' }));                         return;                     }                 }  				mw.notify("To drag and drop listings, the Trip Planner widget must be opened first on the source page.", { type: 'warn' });             });              $(document).on('dragstart', '.tp-item', function (e) {                 const p = $(this);                 const trip = self.getActiveTrip();                  let item;                 const binType = p.data('type'); // 'bucket' or 'day'                 const binId = p.data('bin-id'); // bucket.id or dayIdx                  if (binType === 'bucket') {                     const bucket = trip.buckets.find(b => b.id === binId);                     item = bucket ? bucket.items[p.data('idx')] : null;                 } else {                     item = trip.days[parseInt(binId)].items[p.data('idx')];                 }                  if (!item) return;                  // Add source metadata                 const payload = Object.assign({}, item, {                     _srcTripIdx: (self.localTripIndex !== null) ? self.localTripIndex : self.data.activeTripIndex,                     _srcType: binType,                     _srcIdx: p.data('idx'),                     _srcBinId: binId                 });                  e.originalEvent.dataTransfer.setData('application/json', JSON.stringify(payload));             });              $(document).on('click', '.tp-item', function (e) {                 // Ignore clicks on action buttons/links                 if ($(e.target).closest('.tp-btn-edit, .tp-remove, a').length) {                     return;                 }                 const $item = $(this);                 const isFullMode = $('#trip-planner-app').length > 0;                 if (!isFullMode) return;                  const type = $item.data('type');                 const idx = parseInt($item.data('idx'));                 const binId = $item.data('bin-id');                  self.zoomToMapItem(type, idx, binId);             });              // Map & Import             $(document).on('click', '#tp-import', () => $('#tp-file-input').click());             $(document).on('change', '#tp-file-input', function (e) {                 const reader = new FileReader();                 reader.onload = (ev) => {                     const imp = JSON.parse(ev.target.result);                     if (imp.trips) {                         const defaults = {                             activeTripIndex: 0,                             minimized: false,                             trips: [],                             orsKey: self.data.orsKey || ''                         };                         self.data = $.extend({}, defaults, imp);                     }                     else if (imp.days) {                         self.data.trips.push(imp);                     }                     self.migrateData();                     self.saveAndRefresh();                 };                 reader.readAsText(e.target.files[0]);             });              $(document).on('click', '#tp-btn-route', () => {                 if (self.isRouting) return;                 $('#tp-ors-key').val(self.data.orsKey || '');                 $('#tp-route-overlay').css('display', 'flex');             });              $(document).on('click', '#tp-btn-stop-route', () => {                 self.routesActive = false;                 if (self.routeLayer) self.routeLayer.clearLayers();                 self.cachedRoutes = {};                 $('.tp-route-stats').remove();                 self.saveAndRefresh();             });              $(document).on('click', '#tp-close-route', () => $('#tp-route-overlay').hide());             $(document).on('click', '#tp-run-route', () => {                 const key = $('#tp-ors-key').val().trim();                 const profile = $('#tp-route-profile').val();                  if (!key) return alert("API Key is required.");                  self.data.orsKey = key;                 const trip = self.getActiveTrip();                 trip.profile = profile;                 self.routesActive = true;                 self.save();                  $('#tp-route-overlay').hide();                 self.calculateRoutes(profile);             });              $(document).on('click', '.tp-section-title', function (e) {                 if ($(e.target).hasClass('tp-btn-move') || $(e.target).hasClass('tp-btn-edit') || $(e.target).hasClass('tp-remove')) return;                  const $moveBtn = $(this).find('.tp-btn-move');                 const type = $moveBtn.data('move-type');                  if (type === 'bucket') {                     const bucketId = $moveBtn.data('bucket-id');                     self.highlightDay(bucketId, 'bucket');                 } else if (type === 'day') {                     const idx = $moveBtn.data('day-idx');                     if (idx !== undefined) self.highlightDay(parseInt(idx), 'day');                 }             });         },          initMap: function () {             if ($('#tp-map-container').length === 0) {                 // Initial placement on body, hidden                 $('body').append(`                     <div id="tp-map-container" style="display:none">                         <div id="tp-map-canvas"></div>                     </div>                 `);             }              if (!this.map) {                 const kartoBox = mw.loader.require('ext.kartographer.box');                  const mapInstance = kartoBox.map({                     container: $('#tp-map-canvas')[0],                     center: [20, 0],                     zoom: 2                 });                  this.map = mapInstance;                  // Fire the hook to let MediaWiki:Kartographer.js register tiles and overlays on our instance                 mw.hook('wikipage.maps').fire(mapInstance);                  this.map.on('click', () => this.highlightDay(null));                  this.map.on('contextmenu', (e) => {                     const lat = e.latlng.lat;                     const lon = e.latlng.lng;                     const popupContent = `                         <div style="text-align:center; padding:5px; font-family:sans-serif;">                             <b style="font-size:12px;">Map Point</b><br>                             <span style="font-size:11px; color:#555;">${lat.toFixed(5)}, ${lon.toFixed(5)}</span><br>                             <a class="marker" draggable="true"                                 marker-name="Map Point"                                 marker-lat="${lat}"                                 marker-long="${lon}"                                 marker-source-page=""                                href="/wiki/Special:Map"                                 onclick="event.preventDefault();"                                 style="display:inline-block; margin-top:8px; padding:5px 10px; background:#36c; color:white; border-radius:4px; text-decoration:none; font-size:11px; font-weight:bold; cursor:grab; user-select:none;">                                 🎒 Drag to Itinerary                             </a>                             <div style="margin-top:8px;">                                 <a href="/wiki/User:Andree.sk/ListingBrowser?lat=${lat}&long=${lon}"                                     target="_blank"                                     style="font-size:11px; color:#36c; text-decoration:none; font-weight:bold;">                                     🔍 Search listings around                                 </a>                            </div>                         </div>                     `;                     L.popup()                         .setLatLng(e.latlng)                         .setContent(popupContent)                         .openOn(this.map);                 });                  this.routeLayer = L.featureGroup().addTo(this.map);                 this.markerLayer = L.featureGroup().addTo(this.map);                  if (this.cachedRoutes) {                     Object.keys(this.cachedRoutes).forEach(dIdx => {                         this.drawRouteLayer(parseInt(dIdx), this.cachedRoutes[dIdx]);                     });                 }             }         },          // toggleMap removed as per request - map is now persistent in full mode.          /**          * Lazy-loads an image from Wikidata based on QID          */         loadWikidataImage: function ($container) {             const qid = $container.data('qid');              // Don't fetch if already loaded             if ($container.find('img').length || $container.data('loading')) return;             $container.data('loading', true);              const wdApiUrl = `https://www.wikidata.org/w/api.php?action=wbgetclaims&entity=${qid}&property=P18&format=json&origin=*`;              $.getJSON(wdApiUrl).done(function (data) {                 try {                     // Dig into Wikidata response for the filename                     const claims = data.claims.P18;                     if (claims && claims[0].mainsnak.datavalue.value) {                         const filename = claims[0].mainsnak.datavalue.value;                          // Thumb URL (200px) and Full URL (Original)                         const baseUrl = `https://commons.wikimedia.org/wiki/Special:FilePath/${encodeURIComponent(filename)}`;                         const thumbUrl = `${baseUrl}?width=200`;                          const $img = $('<img>').attr('src', thumbUrl).css({                             maxWidth: '100%', maxHeight: '100%', display: 'none', cursor: 'zoom-in'                         });                          // Wrap in a link to the full-size image                         const $link = $('<a>')                             .attr({ href: baseUrl, target: '_blank', title: 'View full size' })                             .append($img);                          $img.on('load', function () {                             $container.empty().append($link);                             $img.fadeIn();                         });                     } else {                         $container.html('<small style="color:#999">No image available</small>');                     }                 } catch (e) {                     $container.hide();                 }             }).fail(function () {                 $container.hide();             });         },          /**          * Parses a coordinate string into decimal degrees.          * Supports decimal format and Degrees, Minutes, Seconds (DMS) format.          * @param {string|number} input          * @returns {number} Decimal degrees or NaN          */         parseCoordinate: function (input) {             if (typeof input === 'number') return input;             if (typeof input !== 'string') return NaN;              let str = input.trim();             if (!str) return NaN;              // 1. Try simple decimal (with optional hemisphere)             // Matches: 48.8566, -48.8566, 48.8566 N, S 48.8566             const decimalMatch = str.match(/^([NSEW])?\s*(-?\d+(?:\.\d+)?)\s*([NSEW])?$/i);             if (decimalMatch) {                 let val = parseFloat(decimalMatch[2]);                 const h = (decimalMatch[1] || decimalMatch[3] || '').toUpperCase();                 if (h === 'S' || h === 'W') val = -Math.abs(val);                 if (h === 'N' || h === 'E') val = Math.abs(val);                 return val;             }              // 2. Try DMS format             // Matches: 48° 51' 24" N, 48°51′24″S, 48 51 24 N, N 48 51 24, etc.             const dmsMatch = str.match(/^([NSEW])?\s*(\d{1,3})[°\s]+(\d{1,2})['′\s]+(\d{1,2}(?:\.\d+)?)["″\s]+([NSEW])?$/i);             if (dmsMatch) {                 const deg = parseFloat(dmsMatch[2]);                 const min = parseFloat(dmsMatch[3]);                 const sec = parseFloat(dmsMatch[4]);                 const h = (dmsMatch[1] || dmsMatch[5] || '').toUpperCase();                  let val = deg + min / 60 + sec / 3600;                 if (h === 'S' || h === 'W') val = -val;                 return val;             }              // 3. Try DM format (no seconds)             const dmMatch = str.match(/^([NSEW])?\s*(\d{1,3})[°\s]+(\d{1,2}(?:\.\d+)?)['′\s]+([NSEW])?$/i);             if (dmMatch) {                 const deg = parseFloat(dmMatch[2]);                 const min = parseFloat(dmMatch[3]);                 const h = (dmMatch[1] || dmMatch[4] || '').toUpperCase();                  let val = deg + min / 60;                 if (h === 'S' || h === 'W') val = -val;                 return val;             }              // 4. Try D [symbols] H             const dSymMatch = str.match(/^([NSEW])?\s*(\d{1,3}(?:\.\d+)?)[°\s]+([NSEW])?$/i);             if (dSymMatch) {                 const deg = parseFloat(dSymMatch[2]);                 const h = (dSymMatch[1] || dSymMatch[3] || '').toUpperCase();                  let val = deg;                 if (h === 'S' || h === 'W') val = -val;                 return val;             }              return parseFloat(str);         },          updateMapMarkers: function (shouldFitBounds) {             if (!this.markerLayer || !window.L) return;             this.markerLayer.clearLayers();             const trip = this.getActiveTrip();             const bounds = [];             const thiss = this;              const add = (item, color, label, binIdOrIdx, itemIdx, type) => {                 if (!item.lat || !item.lon) return;                  const icon = L.divIcon({                     className: '', // Clear default class to avoid side-effects                     html: `<div style="                         background: ${color};                          color: white;                          width: 24px; height: 24px;                          border: 2px solid white;                          border-radius: 50%;                          text-align: center;                          line-height: 20px;                          font-family: sans-serif;                          font-weight: bold;                          font-size: 11px;                          box-shadow: 0 1px 3px rgba(0,0,0,0.4);                         box-sizing: border-box;                     ">${label}</div>`,                     iconSize: [24, 24],                     iconAnchor: [12, 12], // Center the icon                     popupAnchor: [0, -12]                 });                  // 1. Create Marker                 const m = L.marker([item.lat, item.lon], { icon: icon });                  m.tpBinId = binIdOrIdx;                 m.tpIdx = itemIdx;                 m.tpType = type;                  m.on('click', (e) => {                     // Prevent map background click from firing                     L.DomEvent.stopPropagation(e);                     this.highlightDay(binIdOrIdx, type);                     this.highlightListItem(type, itemIdx, binIdOrIdx);                      // Enable dragging once selected                     if (m.dragging) {                         m.dragging.enable();                     }                 });  				m.on('dragend', () => {                     const latlng = m.getLatLng();                     const trip = thiss.getActiveTrip();                     let targetItem = null;                      if (type === 'bucket') {                         const bucket = trip.buckets.find(b => b.id === binIdOrIdx);                         if (bucket) {                             targetItem = bucket.items[itemIdx];                         }                     } else {                         const day = trip.days[binIdOrIdx];                         if (day) {                             targetItem = day.items[itemIdx];                         }                     }                      if (targetItem) {                         targetItem.lat = latlng.lat;                         targetItem.lon = latlng.lng;  						// TODO: probably this process is repeated somewhere, unify                         if (thiss.routeLayer) thiss.routeLayer.clearLayers();                         thiss.cachedRoutes = {};                         $('.tp-route-stats').remove();  						thiss.dontFitMapNextTime = true;                         thiss.saveAndRefresh();                     }  					if (m.dragging) {                         m.dragging.disable();                     }                 });                  const safeTitle = mw.html.escape(item.title);                 const safeNote = item.note ? mw.html.escape(item.note) : '';                 // 2. Build Link URL                 const url = this.getItemUrl(item);                  // 3. Prepare Popup HTML (with placeholder for image)                 let popupHtml = `<div style="text-align:center">                                     <b style="font-size:1.1em">                                         <a class="marker" draggable="true" href="${url}" target="_blank"                                             marker-name="${safeTitle}"                                             marker-lat="${item.lat || ''}"                                             marker-long="${item.lon || ''}"                                             marker-wikidata="${item.wikidata || ''}"                                            marker-source-page="${mw.html.escape(item.sourcePage || '')}"                                            marker-note="${safeNote}"                                            marker-src-type="${type}"                                            marker-src-bin-id="${binIdOrIdx}"                                            marker-src-idx="${itemIdx}"                                            marker-src-trip-idx="${(thiss.localTripIndex !== null) ? thiss.localTripIndex : thiss.data.activeTripIndex}"                                            onclick="if(this.getAttribute('href')==='#') event.preventDefault();">${safeTitle}</a>                                     </b>                                     ${safeNote ? `<br><span style="font-size:0.9em; color:#555">${safeNote}</span>` : ''}                                  </div>`;                  if (item.wikidata) {                     popupHtml += `                             <div class="wd-img" data-qid="${item.wikidata}" style="min-width:200px; min-height:100px; background:#f0f0f0; margin-top:8px; display:flex; align-items:center; justify-content:center; border-radius:4px;">                                 <small>Loading Image...</small>                             </div>`                 }                  m.bindPopup(popupHtml);                  // 4. Fetch Image on Click (Lazy Load)                 if (item.wikidata) {                     m.on('popupopen', function (e) {                         thiss.loadWikidataImage($(e.popup.getElement()).find('.wd-img'));                     });                 }                  m.addTo(this.markerLayer);                 bounds.push([item.lat, item.lon]);             };              trip.buckets.forEach((b) => {                 b.items.forEach((item, idx) => {                     const label = b.icon || "B";                     add(item, "#777", label, b.id, idx, 'bucket');                 });             });             // Days: Label is the day number (index + 1)             trip.days.forEach((d, dayIdx) => {                 d.items.forEach((item, itemIdx) => {                     add(item, this.colors[dayIdx % this.colors.length], itemIdx + 1, dayIdx, itemIdx, 'day');                 });             });              if (shouldFitBounds && bounds.length) {                 this.map.fitBounds(bounds, { padding: [50, 50] });             }         },          drawRouteLayer: function (dIdx, geojson) {             if (!this.routeLayer || !window.L) return;              const color = this.colors[dIdx % this.colors.length];              const routeGroup = L.geoJSON(geojson, {                 style: { color: color, weight: 5, opacity: 0.7 },                 onEachFeature: (feature, layer) => {                     layer.on('click', (e) => {                         L.DomEvent.stopPropagation(e);                         this.highlightDay(dIdx);                     }); 					layer.on('contextmenu', (e) => {                         L.DomEvent.stopPropagation(e);                     });                 }             });              routeGroup.tpBinId = dIdx;             routeGroup.tpType = 'day';             routeGroup.tpDayIdx = dIdx;             routeGroup.addTo(this.routeLayer);         },          saveAndRefresh: function () {             // Capture scroll position             const $body = $('.tp-body');             const scrollTop = $body.length ? $body.scrollTop() : 0;              this.save();             this.drawUI();              // Restore scroll position             $('.tp-body').scrollTop(scrollTop);         },          createNewTrip: function (n) {             this.data.trips.push({                 name: n,                 startDate: new Date().toISOString().split('T')[0],                 length: 3, 				profile: 'foot-walking',                 buckets: [                     { id: 'backlog', name: 'Backlog', icon: '📦', items: [] }                 ],                 days: [{ items: [] }, { items: [] }, { items: [] }]             });             this.save();         },          makeListingsDraggable: function () {             const thiss = this;             const pageName = mw.config.get('wgPageName');             const isTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0;              //$('.vcard, .listing-metadata, .mw-kartographer-maplink').each(function () {             $('.vcard').each(function () {                 //console.log($(this));                 const $l = $(this);                 const lat = $l.attr('data-lat') || $l.find('[data-lat]').attr('data-lat');                 const lon = $l.attr('data-lon') || $l.find('[data-lon]').attr('data-lon');                 if (lat && lon) {                     const wdId = $l.find('[id]').filter(function () {                         return /^Q\d+$/.test(this.id);                     }).attr('id') || "";                     const title = $l.find('.fn, .listing-name').first().text().trim() || $l.text().trim();                      $l.attr('draggable', 'true').addClass('listing-draggable-proxy');                     $l.on('dragstart', (e) => {                         const data = {                             title: title,                             lat: thiss.parseCoordinate(lat),                             lon: thiss.parseCoordinate(lon),                             wikidata: wdId,                             sourcePage: pageName                         };                         const json = JSON.stringify(data);                         e.originalEvent.dataTransfer.setData('application/json', json);                         e.originalEvent.dataTransfer.setData('text/plain', json);                     });                      if (isTouch && $l.find('.tp-mobile-add').length === 0) {                         const $btn = $('<span class="tp-mobile-add">+ Trip</span>');                          $btn.on('click', (e) => {                             e.preventDefault();                             e.stopPropagation(); // Stop Wikivoyage from opening the map/listing details                              const item = {                                 title: title,                                 lat: thiss.parseCoordinate(lat),                                 lon: thiss.parseCoordinate(lon),                                 wikidata: wdId,                                 sourcePage: pageName,                                 note: ""                             };                              const trip = thiss.getActiveTrip();                             const backlogBucket = trip.buckets.find(b => b.id === 'backlog') || trip.buckets[0];                             backlogBucket.items.push(item);                             thiss.saveAndRefresh();                             mw.notify(`Added "${title}" to Trip Backlog`);                         });                          // Insert button next to the Listing Name                         const $nameTarget = $l.find('.listing-name, .fn').first();                         if ($nameTarget.length) {                             $nameTarget.after($btn);                         } else {                             $l.prepend($btn);                         }                     }                 }             });         },          updateRouteButtonState: function () {             const $btn = $('#tp-btn-route');             if (!$btn.length) return;             const now = Date.now();             const elapsed = now - (this.lastRoutingTime || 0);             if (this.isRouting) {                 $btn.prop('disabled', true).text('⏳ Routing...');             } else if (elapsed < 15000) {                 const remaining = Math.ceil((15000 - elapsed) / 1000);                 $btn.prop('disabled', true).text(`⏳ Route (in ${remaining}s)`);                 setTimeout(() => this.updateRouteButtonState(), 1000);             } else {                 $btn.prop('disabled', false).text('🚗 Route');             }         },          triggerAutoRoute: function () {             if (!this.routesActive || this.isRouting) return;             const now = Date.now();             const cooldown = 15000;             const elapsed = now - (this.lastRoutingTime || 0);              clearTimeout(this.autoRouteTimer);              if (elapsed >= cooldown) {                 const trip = this.getActiveTrip();                 const profile = trip.profile || 'foot-walking';                 this.calculateRoutes(profile);             } else {                 this.autoRouteTimer = setTimeout(() => {                     const trip = this.getActiveTrip();                     const profile = trip.profile || 'foot-walking';                     this.calculateRoutes(profile);                 }, cooldown - elapsed);             }         },  		calculateRoutes: function (profile) {             if (this.isRouting) return;             this.isRouting = true;             this.updateRouteButtonState();  			const trip = this.getActiveTrip();              // UI Cleanup             $('.tp-route-stats').remove();             $('.tp-item').removeClass('tp-item-error').find('.tp-error-msg').remove();             if (this.routeLayer) this.routeLayer.clearLayers();             this.cachedRoutes = {}; // Clear old cache              mw.notify("Calculating continuous routes...");              // Track the end of the previous leg to link days together             let previousEndItem = null;              const promises = trip.days.map((day, dIdx) => {                 // 1. Identify valid items in current day                 // We preserve original indices for DOM injection later                 const currentDayItems = day.items                     .map((item, idx) => Object.assign({}, item, { day: dIdx, idx: idx }))                     .filter(i => i.lat && i.lon);                  // 2. Construct the routing batch for this day                 // Start with the last point of the previous day (if exists) to form the bridge                 let routePoints = [];                 if (previousEndItem) {                     routePoints.push(previousEndItem);                 }                 routePoints = routePoints.concat(currentDayItems);                  // Update the tracker: The last item of this day becomes the start for the next non-empty day                 if (currentDayItems.length > 0) {                     previousEndItem = currentDayItems[currentDayItems.length - 1];                 }                  // 3. Check if we have enough points to form a line                 if (routePoints.length < 2) {                     return Promise.resolve();                 }                  // 4. API Request                 const coords = routePoints.map(i => [i.lon, i.lat]); 				const snappingRadiuses = routePoints.map(function() { return 3000; }); // TODO: make global                  return $.ajax({                     method: "POST",                     url: `https://api.openrouteservice.org/v2/directions/${profile}/geojson`,                     contentType: "application/json",                     headers: { "Authorization": this.data.orsKey },                     data: JSON.stringify({ 						coordinates: coords, 						radiuses: snappingRadiuses 					})                 }).then((resp) => {                     this.cachedRoutes[dIdx] = resp;                     // Draw Line (Colored by the Current Day)                     if (this.routeLayer && window.L) {                         this.drawRouteLayer(dIdx, resp);                     }                      // Inject Stats                     const segments = resp.features[0].properties.segments;                     segments.forEach((seg, sIdx) => {                         // Map the segment back to our batch items                         const startItem = routePoints[sIdx];                         const endItem = routePoints[sIdx + 1];                          const dist = seg.distance > 1000 ? (seg.distance / 1000).toFixed(1) + " km" : Math.round(seg.distance) + " m";                         const time = seg.duration > 3600                             ? Math.floor(seg.duration / 3600) + "h " + Math.round((seg.duration % 3600) / 60) + "m"                             : Math.round(seg.duration / 60) + " min";                          const html = `<div class="tp-route-stats">⬇️ ${dist} / ${time} ⬇️</div>`;                          if (startItem.day === endItem.day) {                             // Intra-day travel: Inject between items                             $(`.tp-day-bin[data-day-idx="${startItem.day}"] .tp-item[data-idx="${startItem.idx}"]`).after(html);                         } else {                             // Inter-day travel: Inject at the bottom of the previous day's bin                             // This visually represents "Travel after finishing Day X, before Day Y"                             $(`.tp-bin[data-day-idx="${startItem.day}"]`).append(html);                         }                     });                 }).catch(err => {                     console.error(`Routing Error (Day ${dIdx + 1}):`, err);                     let msg = "Routing failed.";                     if (err.responseText) {                         try {                             const errorData = JSON.parse(err.responseText);                             if (errorData.error && errorData.error.message) {                                 msg = errorData.error.message;                                 // Try to extract coordinate index from message like "...coordinate 3: ..."                                 const match = msg.match(/coordinate (\d+):/i);                                 if (match) {                                     const coordIdx = parseInt(match[1]);                                     const item = routePoints[coordIdx];                                     if (item) {                                         const $itemEl = $(`.tp-day-bin[data-day-idx="${item.day}"] .tp-item[data-idx="${item.idx}"]`);                                         $itemEl.addClass('tp-item-error');                                         if ($itemEl.find('.tp-error-msg').length === 0) {                                             $itemEl.find('div:first').append(`<div class="tp-error-msg">⚠️ Point not routable</div>`);                                         }                                         msg = `Point "${item.title}" is not routable. Try moving it or removing it.`;                                     }                                 }                             }                         } catch (e) { console.error("Error parsing response", e); }                     }                     mw.notify(msg, { type: 'error' });                 });             });              Promise.all(promises).then(() => {                 // Route calculation complete                 this.isRouting = false;                 this.lastRoutingTime = Date.now();                 this.updateRouteButtonState();             }).catch(() => {                 this.isRouting = false;                 this.lastRoutingTime = Date.now();                 this.updateRouteButtonState();              });         },          optimizeDay: function (dayIdx, profile) {             const day = this.getActiveTrip().days[dayIdx];             const items = day.items;              if (items.length < 3) return mw.notify("Need at least 3 items to optimize.");             if (!this.data.orsKey) return alert("API Key missing.");              // 1. Prepare Payload             // Fixed Start: The first item in the list             const startItem = items[0]; 			const snappingRadiuses = 3000; // TODO: make global              // Jobs: All subsequent items             const jobs = items.slice(1).map((item, index) => ({                 id: index + 1, // Use original index as ID (offset by 1 because we sliced)                 location: [item.lon, item.lat], 				radius: snappingRadiuses,                 service: 300 // Assume 5 min stop per POI (optional, helps calc ETA)             }));              // Vehicle: Starts at item[0]             const vehicles = [{                 id: 1,                 profile: profile,                 start: [startItem.lon, startItem.lat], 				radius: snappingRadiuses,                 // For a round-trip, add: end: [startItem.lon, startItem.lat]             }];              mw.notify("Optimizing schedule...");              $.ajax({                 method: "POST",                 url: "https://api.openrouteservice.org/optimization",                 contentType: "application/json",                 headers: { "Authorization": this.data.orsKey },                 data: JSON.stringify({ jobs: jobs, vehicles: vehicles })             }).done((resp) => {                 const steps = resp.routes[0].steps;                  // 2. Reconstruct Order                 // Start with the fixed first item                 const newOrder = [startItem];                  // Append items in the order returned by API                 steps.forEach(step => {                     if (step.type === 'job') {                         // step.id corresponds to the index in the original 'items' array                         newOrder.push(items[step.id]);                     }                 });                  // Check if all items were visited (API might skip unreachable ones)                 if (newOrder.length !== items.length) {                     alert("Warning: Some locations were unreachable and removed from the optimized list.");                 }                  // 3. Save                 day.items = newOrder;                 this.saveAndRefresh();                 mw.notify("Optimization Complete!");              }).fail((err) => {                 console.error(err);                 let msg = "Optimization failed. Check API Key or constraints.";                 if (err.responseText) {                     try {                         const errorData = JSON.parse(err.responseText);                         if (errorData.error && errorData.error.message) {                             msg = errorData.error.message;                             // Similar coordinate matching for optimization errors                             const match = msg.match(/coordinate (\d+):/i);                             if (match) {                                 const coordIdx = parseInt(match[1]);                                 // In optimizeDay, coordinte 0 is startItem, others are jobs                                 const item = (coordIdx === 0) ? startItem : items[coordIdx];                                 if (item) {                                     $(`.tp-day-bin[data-day-idx="${dayIdx}"] .tp-item[data-idx="${(coordIdx === 0) ? 0 : coordIdx}"]`).addClass('tp-item-error');                                     msg = `Point "${item.title}" is not routable.`;                                 }                             }                         }                     } catch (e) { }                 }                 alert(msg);             });         },          zoomToMapItem: function (type, idx, binIdOrIdx) {             if (!this.map || !this.markerLayer) return;              let targetMarker = null;             this.markerLayer.eachLayer((layer) => {                 if (layer.tpType === type && layer.tpBinId === binIdOrIdx && layer.tpIdx === idx) {                     targetMarker = layer;                 }             });              if (targetMarker) {                 const currentZoom = this.map.getZoom();                 const targetZoom = Math.max(15, currentZoom);                 this.map.setView(targetMarker.getLatLng(), targetZoom);                 targetMarker.openPopup();             }         },          highlightListItem: function (type, idx, binIdOrIdx) {             let $targetItem;             if (type === 'bucket') {                 $targetItem = $(`.tp-bucket-bin[data-bucket-id="${binIdOrIdx}"] .tp-item[data-idx="${idx}"]`);             } else {                 $targetItem = $(`.tp-day-bin[data-day-idx="${binIdOrIdx}"] .tp-item[data-idx="${idx}"]`);             }              if ($targetItem.length && $('.tp-body').length) {                 const $container = $('.tp-body');                  const scrollTop = $targetItem.offset().top - $container.offset().top + $container.scrollTop();                  $container.stop().animate({                     scrollTop: scrollTop - 30                 }, 300);                  $targetItem.css({                     'transition': 'background 0.3s, border-color 0.3s',                     'background': '#fff9c4',                     'border-color': '#fbc02d'                 });                  setTimeout(() => {                     $targetItem.css({                         'transition': 'background 0.8s, border-color 0.8s',                         'background': '',                         'border-color': ''                     });                     setTimeout(() => {                         $targetItem.css('transition', '');                     }, 800);                 }, 1500);             }         },          highlightDay: function (targetIdx, type = 'day') {             if (!this.map) return;              const setOpacity = (layer) => {                 let isMatch;                 if (targetIdx === null) {                     isMatch = true;                 } else if (type === 'bucket') {                     isMatch = (layer.tpBinId === targetIdx && layer.tpType === 'bucket');                 } else {                     isMatch = (layer.tpBinId === targetIdx && layer.tpType === 'day');                 }                  const op = isMatch ? 1 : 0.2;                 const fillOp = isMatch ? 0.9 : 0.2;                  if (layer.setOpacity) {                     layer.setOpacity(op);                     if (isMatch) layer.setZIndexOffset(1000); else layer.setZIndexOffset(0);                 } else if (layer.setStyle) {                     layer.setStyle({ opacity: op, fillOpacity: fillOp });                     if (isMatch && layer.bringToFront) layer.bringToFront();                 }             };              if (this.markerLayer) this.markerLayer.eachLayer(setOpacity);             if (this.routeLayer) this.routeLayer.eachLayer(setOpacity);              $('.tp-section-title').css({ 'background': '', 'transition': '' });              if (targetIdx === null) return;              let $targetHeader;              if (type === 'bucket') {                 $targetHeader = $(`.tp-bucket-bin[data-bucket-id="${targetIdx}"]`).prev('.tp-section-title');             } else {                 $targetHeader = $(`.tp-day-bin[data-day-idx="${targetIdx}"]`).prev('.tp-section-title');             }              if ($targetHeader.length && $('.tp-body').length) {                 const $container = $('.tp-body');                  const scrollTop = $targetHeader.offset().top - $container.offset().top + $container.scrollTop();                  $container.stop().animate({                     scrollTop: scrollTop - 10                 }, 300);                  $targetHeader.css({                     'background': '#fff9c4',                     'transition': 'background 0.5s'                 });                  setTimeout(() => {                     $targetHeader.css('background', '');                 }, 1500);             }         },          fetchWikidata: function (wdId, sourcePage, onDone) {             const lang = mw.config.get('wgContentLanguage') || 'en';              return $.ajax({                 url: 'https://www.wikidata.org/w/api.php',                 data: {                     action: 'wbgetentities',                     ids: wdId,                     props: 'labels|claims',                     languages: lang,                     format: 'json',                     origin: '*'                 },                 dataType: 'json'             }).then((data) => {                 const entity = data.entities ? data.entities[wdId] : null;                 if (!entity || entity.missing) throw new Error("Wikidata item not found.");                  // 1. Extract Title (Localized label -> First label -> Fallback)                 let title = sourcePage ? sourcePage.replace(/_/g, ' ') : wdId;                 if (entity.labels && entity.labels[lang]) {                     title = entity.labels[lang].value;                 } else {                     const keys = Object.keys(entity.labels || {});                     if (keys.length > 0) title = entity.labels[keys[0]].value;                 }                  // 2. Extract Coordinates (P625)                 let lat = 0, lon = 0;                 const p625 = entity.claims && entity.claims.P625;                 if (p625 && p625[0].mainsnak.datavalue) {                     lat = p625[0].mainsnak.datavalue.value.latitude;                     lon = p625[0].mainsnak.datavalue.value.longitude;                 }                  onDone({ title, lat, lon, wikidata: wdId, sourcePage: sourcePage || "", note: "" });             });         },     };      function createLauncherIcon(onClickHandler) {         // 1. Prevent Duplicates         if ($('.tp-launcher-icon').length) return;          // 2. Create Icon Element factory         const iconFactory = () => $('<a>')             .addClass('tp-launcher-icon')             .attr('href', '#')             .attr('title', 'Open Trip Planner')             .text('🎒')             .css({                 'cursor': 'pointer',                 'font-size': '1.2em', 'text-decoration': 'none',                 'filter': 'grayscale(0.8) contrast(1) brightness(1.4)'             })             .on('click', function (e) {                 e.preventDefault();                 onClickHandler(e, $(this));             });          // 4. Insert into DOM (Mobile vs Desktop)         if (mw.config.get('skin') === 'minerva') {             const $icon = iconFactory().css({ 'font-size': '1.5em', 'margin': '0 12px', 'display': 'flex', 'align-items': 'center' });             if ($('#pt-notifications-alert').length == 0) {                 // Mobile                 $("#page-actions-overflow").before($("<li id=\"tp-launcher-li\" class=\"page-actions-menu__list-item\"></li>").append($icon))             } else {                 $('#pt-notifications-alert').parent().before($icon);             }         } else {             $('#pt-notifications-alert').before(iconFactory());             const $sticky = $('.vector-sticky-header-icons');             if ($sticky.length) {                 $sticky.prepend(iconFactory().css('margin-right', '10px'));             }         }     }  	function fetchLatestOptionsAndInit() { 		if (mw.user.isAnon()) { 			TripPlanner.init(); 		} else { 			new mw.Api().get({ 				action: 'query', 				meta: 'userinfo', 				uiprop: 'options' 			}).then((res) => { 				try { 					const opts = res.query.userinfo.options; 					const val = opts[TripPlanner.optionKey]; 					if (val) { 						mw.user.options.set(TripPlanner.optionKey, val); 					} 				} catch (e) { 					console.error("Failed to fetch fresh options", e); 				} 				TripPlanner.init(); 			}).catch(() => { 				TripPlanner.init(); 			}); 		} 	}      // lightweight bootstrap     $(function () {         const isFullMode = $('#trip-planner-app').length > 0;          if (isFullMode) {             // Auto-load in full mode             mw.loader.using([                 'mediawiki.util', 'mediawiki.api',                 'mediawiki.user', 'mediawiki.notification',                 'ext.kartographer.box', 'ext.kartographer.wv', 'oojs-ui-core', 'oojs-ui-widgets', 'oojs-ui-windows'             ]).then(() => {                 fetchLatestOptionsAndInit();             });         } else {             // Create the initial "Lazy" icon for widget mode             createLauncherIcon(function (e, $btn) {                 // Visual feedback                 $btn.text('⏳').css('cursor', 'wait');                  // Load dependencies on demand                 mw.loader.using([                     'mediawiki.util', 'mediawiki.api',                     'mediawiki.user', 'mediawiki.notification',                     'ext.kartographer.box', 'ext.kartographer.wv', 'oojs-ui-core', 'oojs-ui-widgets', 'oojs-ui-windows'                 ]).then(() => {                     // Remove the bootstrap icon/wrapper                     // (TripPlanner.drawUI will handle creating the actual widget UI)                     $('#tp-launcher-li').remove();                     $('.tp-launcher-icon').remove();                      // Initialize App                     fetchLatestOptionsAndInit();                 });             });         }     }); }(mediaWiki, jQuery));