Logo Voyage

MediaWiki:Gadget-TripPlanner.js Voyage Tips and guide

You can check the original Wikivoyage article Here
/**
 * Wikivoyage Trip Planner Gadget
 * ==============================
 * A client-side tool for planning trips using Wikivoyage listings.
 * Allows users to drag listings from articles into a floating "cart", organize them 
 * into days, view them on a map, and calculate routes.
 *
 * CORE FEATURES:
 * ------------------
 * 1. UI: Floating, minimizable widget with "Backlog" and "Daily" bins. 
 *        Supports drag-and-drop reordering and moving items between bins.
 * 2. Data Source: Draggable from any .vcard/listing on Wikivoyage pages.
 *                 Parses data-lat, data-lon, and wikidata attributes.
 * 3. 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.
 * 4. Mapping: Lazy-loaded Leaflet map (via 'ext.kartographer').
 *             Interactive markers (colored by day) with popup image loading from Wikidata.
 * 5. 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.
 * 6. 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.
 *     - load()/save(): Handles LocalStorage vs API logic.
 *
 * - UI Generation:
 *     - injectStyles(): CSS definitions (Widget, Overlays, Map).
 *     - drawUI(): Renders the main widget container.
 *     - renderBins(): Renders the list of items (Backlog + 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'.
 * - Kartographer: 'ext.kartographer.box for Leaflet (L) access.
 * - jQuery ($).
 *
 * COMPATIBILITY NOTE:
 * -------------------
 * Code avoids ES6 Spread Operator (...) to ensure compatibility with 
 * older MediaWiki JS environments / Gadget compilation. 
 * Uses Object.assign() and Array.concat() instead.
 */
 
 (function (mw, $) {
    'use strict';

    if (mw.config.get('wgAction') !== 'view') return;

    const TripPlanner = {
        storageKey: 'wv-trip-planner',
        optionKey: 'userjs-wv-trip-planner', 
        
        saveTimer: null,
        map: null,
        markerLayer: null,
        data: { activeTripIndex: 0, minimized: false, mapVisible: 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.data.mapVisible = false;
            this.cachedRoutes = {}
            
            this.injectStyles();
            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') {
			            this.data = msg.data.data;

			            if (!this.data.closed && !this.data.minimized) {
			                this.drawUI();
			            } else if (this.data.minimized) {
			                this.drawUI(); // draw the icon TODO: probably unnecessarry
			            }
			        }
			    };
			}
        },

        load: function () {
            const defaults = { activeTripIndex: 0, minimized: false, mapVisible: 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, mapVisible: 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 => t.backlog.length > 0 || 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; 
            }
        },

        save: function () {
        	const dataToSave = $.extend(true, {}, this.data);
            delete dataToSave.mapVisible; 

            if (mw.user.isAnon()) {
                // Anon: Save directly to LocalStorage
                localStorage.setItem(this.storageKey, JSON.stringify(dataToSave));
                if (this.syncChannel)
                	this.syncChannel.postMessage({ type: 'sync', data: dataToSave });
            } else {
                // Logged In: Save to API (Debounced to avoid flooding server on every drag event)
                clearTimeout(this.saveTimer);
                this.saveTimer = setTimeout(() => {
                    this.saveDataToApi(dataToSave);
                    if (this.syncChannel)
            			 this.syncChannel.postMessage({ type: 'sync', data: dataToSave });
                }, 2000); 
            }
        },

        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 () { return this.data.trips[this.data.activeTripIndex] || 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%; }
                
                /* Map Close Button (Desktop Default) */
                #tp-map-hide { position: absolute; right: 10px; top: 10px; z-index: 2001; cursor: pointer; background: #fff; padding: 5px 10px; border: 1px solid #333; font-weight: bold; }

                .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-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; }
                }
            `);
        },

		drawUI: function () {
            // 1. Handle "Closed" state (Launcher in user menu)
            if (this.data.closed) {
            	this.data.mapVisible = false;
            	if (this.map) { this.map.remove(); this.map = null; }
                $('#tp-widget, #tp-map-container, #tp-minimized-icon').remove();
                this.drawLauncher();
                return;
            } else {
                $('.tp-launcher-icon, #tp-launcher-li').remove();
                $('#tp-launcher').remove();
            }

            // 2. Handle "Minimized" state (Floating Icon)
            if (this.data.minimized) {
            	if (this.map) { this.map.remove(); this.map = null; }
                $('#tp-widget, #tp-map-container').remove();

                $('body').append(`<div id="tp-minimized-icon" title="Expand Trip Planner">🎒</div>`);
                return;
            }

            // 3. Handle "Open" state (Full Widget)
            // Only remove the widget and icon. LEAVE the map container if it exists.
            $('#tp-widget, #tp-minimized-icon').remove();

            const trip = this.getActiveTrip();

            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 $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-settings-overlay">
                            <b style="margin-bottom:10px; display:block">Trip Settings</b>
                            
                            <div class="tp-setting-block">
                                <label>Trip Name</label>
                                <input type="text" id="tp-rename-input" value="${safeName}">
                            </div>

                            <div class="tp-setting-block" style="display:flex; gap:10px">
                                <div style="flex:1">
                                    <label>Start Date</label>
                                    <input type="date" id="tp-start-date" value="${trip.startDate}">
                                </div>
                                <div style="flex:1">
                                    <label>Days</label>
                                    <input type="number" id="tp-length" value="${trip.length}" min="1">
                                </div>
                            </div>

                            <div class="tp-divider"></div>

                            <button id="tp-del-trip" class="mw-ui-button mw-ui-destructive mw-ui-quiet" style="width:100%; margin-bottom:10px">🗑️ Delete This Trip</button>
                            <button id="tp-close-settings" class="mw-ui-button mw-ui-progressive" style="width:100%">Done</button>
                        </div>

                        <!-- Reusable Menu Overlay (Move/Save) -->
                        <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>
                        
                        <!-- Edit Form -->
                        <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 id="tp-route-overlay">
                            <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">🚶 Walking</option>
	                                    <option value="driving-car">🚗 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>

                            <!-- 3. Optimizer -->
                            <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 class="tp-settings">
                            <div class="tp-settings-row">
                                <select id="tp-select-trip" style="flex-grow:1"></select>
                                <button id="tp-new-trip" class="mw-ui-button" title="New Trip">+</button>
                                <button id="tp-open-settings" class="mw-ui-button" title="Settings">⚙️</button>
                            </div>
                        </div>

                        <div class="tp-body">
                            <div class="tp-section-title">📦 Backlog
                            <div>
                            	<span class="tp-btn-edit" id="tp-add-article" title="Add current article as destination">+ Article</span>
                            	<span class="tp-btn-edit" id="tp-add-custom">+ POI</span>
                            	<span class="tp-btn-move" data-move-type="backlog">Move</span>
                        	</div>
                        	</div>
                            <div class="tp-bin tp-backlog" data-type="backlog"></div>
                            <div id="tp-days-container"></div>
                        </div>

                        <div class="tp-controls">
                            <button id="tp-btn-map" class="mw-ui-button mw-ui-progressive">🗺️ Map</button>
                            <button id="tp-btn-route" class="mw-ui-button">🚗 Route</button>
                            <button id="tp-btn-export" class="mw-ui-button">💾 Save...</button>
                            <button id="tp-import" class="mw-ui-button">📂 Load</button>
                        </div>
                    </div>
                    <input type="file" id="tp-file-input" style="display:none" accept=".json">
                </div>
            `);

            $('body').append($widget);
            
            // Ensure Map Container Exists (Create only if missing)
            if ($('#tp-map-container').length === 0) {
                $('body').append(`
                    <div id="tp-map-container">
                        <div id="tp-map-hide" style="position:absolute; right:10px; top:10px; z-index:2001; cursor:pointer; background:#fff; padding:5px; border:1px solid #333">Close [x]</div>
                        <div id="tp-map-canvas"></div>
                    </div>
                `);
                
                // If the map was supposed to be visible but we just created the container,
                // update visibility logic.
                $('#tp-map-container').toggle(this.data.mapVisible);
            }

            this.renderTripOptions();
            this.renderBins();
            
            //  Explicitly sync mobile class and refresh map size if visible
            $('body').toggleClass('tp-mobile-map-open', this.data.mapVisible);

            if (this.data.mapVisible) {
            	this.updateMapMarkers();
            	if (this.map) {
                    setTimeout(() => { this.map.invalidateSize(); }, 350);
                }
            }
            this.updatePageIndicators();
        },

		drawLauncher: function () {
        	createLauncherIcon(() => {
                this.data.closed = false;
                this.saveAndRefresh();
            });
	    },

        renderTripOptions: function () {
            const $select = $('#tp-select-trip').empty();
            this.data.trips.forEach((t, i) => $select.append($('<option>', { value: i, text: t.name, selected: i === this.data.activeTripIndex })));
        },

        renderBins: function () {
            const trip = this.getActiveTrip();
            const $backlog = $('.tp-backlog').empty();
            trip.backlog.forEach((item, i) => $backlog.append(this.createItemHTML(item, 'backlog', i)));

            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]}">
                        Day ${dIdx + 1} (${dayDate.toLocaleDateString(undefined, {month:'short',day:'numeric'})}) 
                        <span class="tp-btn-move" data-move-type="day" data-day-idx="${dIdx}">Move</span>
                    </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 (this.data.mapVisible)
            	this.updateMapMarkers();
            this.updatePageIndicators();
        },

		updatePageIndicators: function () {
            const trip = this.getActiveTrip();
	            const allItems = [...trip.backlog, ...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(parseFloat(lat) + "," + parseFloat(lon)));

                if (isPresent) $l.addClass('tp-in-trip');
                else $l.removeClass('tp-in-trip');
            });
        },

        createItemHTML: function (item, type, idx, dayIdx = null) {
        	const safeTitle = mw.html.escape(item.title);
			const safeNote = item.note ? mw.html.escape(item.note) : '';

            const url = item.sourcePage ? mw.util.getUrl(item.sourcePage) + (item.wikidata ? '#' + item.wikidata : '') : '#';
            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-day="${dayIdx}">
                    ${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" 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.backlog, ...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 (Backlog + all Days) using concat/reduce
                    var allItems = trip.backlog.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 : 'backlog' 
                                }
                            });
                        }
                    });

                    // 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;

			$(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
            });

            // 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'); // 'backlog' or 'day'
                // sourceDayIdx is the numeric index if moving a day, or -1 if backlog
                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, targetDayIdx) => {
                    let items;
                    // Cut
                    if (moveType === 'backlog') {
                        items = currentTrip.backlog.splice(0);
                    } else {
                        items = currentTrip.days[sourceDayIdx].items.splice(0);
                    }
                    // Paste
                    if (targetType === 'backlog') {
                        targetTrip.backlog.push(...items);
                    } else {
                        targetTrip.days[targetDayIdx].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. Backlog Option
                    // Show if: It's another trip OR (It's current trip AND we aren't already in backlog)
                    if (!isCurrent || moveType !== 'backlog') {
                        ensureHeader();
                        menuOptions.push({
                            label: `📦 Backlog`,
                            action: () => performMove(trip, 'backlog')
                        });
                    }

                    // 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 === 'backlog') ? "Move Backlog 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', function() {
                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) => {
                    self.getActiveTrip().backlog.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, .item-edit-trigger', function() {
                const isNew = $(this).attr('id') === 'tp-add-custom';
                const p = isNew ? null : $(this).closest('.tp-item');
                editingRef = isNew ?
                	{ type: 'backlog', idx: -1 } :
                	{ type: p.data('type'), idx: p.data('idx'), day: p.data('day') };
                const item = isNew ?
                	{ title: "", lat: "", lon: "", note: "", sourcePage: "" } :
                	(editingRef.type === 'backlog' ?
                		self.getActiveTrip().backlog[editingRef.idx] :
                		self.getActiveTrip().days[editingRef.day].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', '#f-save', () => {
                const item = { 
                	title: $('#f-title').val(),
                	lat: parseFloat($('#f-lat').val()),
                	lon: parseFloat($('#f-lon').val()),
                	note: $('#f-note').val(),
                	wikidata: $('#f-wikidata').val(),
                	sourcePage: $('#f-source').val().trim()
    			};

                if (editingRef.idx === -1)
                	self.getActiveTrip().backlog.push(item);
                else if (editingRef.type === 'backlog')
                	self.getActiveTrip().backlog[editingRef.idx] = item;
                else
                	self.getActiveTrip().days[editingRef.day].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) => {
            	self.data.activeTripIndex = parseInt($(e.target).val());
            	self.saveAndRefresh();
        	});
            $(document).on('click', '#tp-new-trip', () => {
            	const n = prompt("Trip Name?");
            	if (n) {
            		self.createNewTrip(n);
            		self.data.activeTripIndex = self.data.trips.length-1;
            		self.saveAndRefresh();
        		}
            });
            
            // Delete Trip Logic (Restored)
            $(document).on('click', '#tp-del-trip', () => {
                if (self.data.trips.length > 1 && confirm("Are you sure you want to delete this trip permanently?")) {
                    self.data.trips.splice(self.data.activeTripIndex, 1);
                    self.data.activeTripIndex = 0;
                    self.saveAndRefresh();
                } else if (self.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)
                	trip.days.splice(n).forEach(d => trip.backlog.push(...d.items));
                else
                	while (trip.days.length < n)
                		trip.days.push({items:[]});
                trip.length = n;
                self.saveAndRefresh();
            });

            $(document).on('click', '.tp-remove', function() {
                const p = $(this).closest('.tp-item');
                const trip = self.getActiveTrip();
                if (p.data('type') === 'backlog')
                	trip.backlog.splice(p.data('idx'), 1);
            	else
            		trip.days[p.data('day')].items.splice(p.data('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('dragover', '.tp-bin', (e) => e.preventDefault());
            $(document).on('drop', '.tp-bin', function(e) {
                e.preventDefault(); 
                $('.tp-route-stats').remove();
                if (self.routeLayer) self.routeLayer.clearLayers();
                self.cachedRoutes = {};
                
                const trip = self.getActiveTrip();
                const transfer = e.originalEvent.dataTransfer;
                const isBacklog = $(this).data('type') === 'backlog';
                const targetList = isBacklog ? trip.backlog : trip.days[$(this).data('day-idx')].items;

				const jsonData = transfer.getData('application/json');
                if (jsonData) {
	                const item = JSON.parse(jsonData);
	                
	                // 1. HANDLE REMOVAL FROM SOURCE (If it's an internal move)
	                if (item._srcType) {
	                    // Remove from old location
	                    if (item._srcType === 'backlog') {
	                        trip.backlog.splice(item._srcIdx, 1);
	                    } else {
	                        trip.days[item._srcDay].items.splice(item._srcIdx, 1);
	                    }
	                    
	                    // Clean up metadata before adding to new spot
	                    delete item._srcType;
	                    delete item._srcIdx;
	                    delete item._srcDay;
	                }
	                
	                // 2. HANDLE INSERTION (Reordering support)
	                const $targetItem = $(e.target).closest('.tp-item');
	                
	                // Adjust index: If we removed an item from the SAME list and the old index 
	                // was lower than the new index, all indices shifted down by 1.
	                // However, since we define the drop target by the *visual* element 
	                // (which hasn't moved yet), we usually don't need complex math if we splice immediately.
	                
	                if ($targetItem.length && $targetItem.data('idx') !== undefined) {
	                    targetList.splice($targetItem.data('idx'), 0, item);
	                } else {
	                    targetList.push(item);
	                }
	                
	                self.saveAndRefresh();
	                return;
                }
                
                // .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 = '';
                    
                    const href = $html.attr('href');
                    const match = href.match(/.*\/wiki\/(.+)#(Q\d+)/);
                    const match2 = href.match(/.*\/wiki\/(.+)/);
                    if (match) {
                    	pageName = decodeURIComponent(match[1]);
                    } else if (match2) {
                    	// let's use whole wiki url as page, we likely won't have wikidata there (Paris#Coffee_Xyz)
                    	pageName = decodeURIComponent(match2[1]);
                    } else {
                    	// maybe some external url, let's use it as is for now; TODO: handling in the rest of the code
                    	pageName = href;
                    }

                    if ($m.length && ($m.attr('marker-lat') || $m.attr('marker-wikidata'))) {
                        targetList.push({
                            title: $m.attr('marker-name') || $m.text().trim(),
                            lat: parseFloat($m.attr('marker-lat')),
                            lon: parseFloat($m.attr('marker-long')),
                            wikidata: $m.attr('marker-wikidata'),
                            sourcePage: pageName,
                            note: ""
                        });
                        self.saveAndRefresh();
                        return;
                    }
                }
               
            	// Article[/Subarticle]#Qid
            	const rawText = transfer.getData('text/plain') || transfer.getData('URIList');
                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.saveAndRefresh();
                        }).fail(() => mw.notify("Failed to parse dropped link.", { type: 'error' }));
                        return;
                    }
                }
            });
            
             $(document).on('dragstart', '.tp-item', function(e) {
                const p = $(this);
                const trip = self.getActiveTrip();
                
                // Get the item object WITHOUT removing it yet
                let item;
                if (p.data('type') === 'backlog') {
                    item = trip.backlog[p.data('idx')];
                } else {
                    item = trip.days[p.data('day')].items[p.data('idx')];
                }

                // Add source metadata so the drop handler can remove it later
                // We clone the item to avoid modifying the active reference directly in the drag payload
                const payload = Object.assign({}, item, {
                    _srcType: p.data('type'),
                    _srcIdx: p.data('idx'),
                    _srcDay: p.data('day')
                });

                e.originalEvent.dataTransfer.setData('application/json', JSON.stringify(payload));
            });

            // Map & Import
            $(document).on('click', '#tp-btn-map, #tp-map-hide', () => self.toggleMap());
            $(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) {
                        // Import 'All' Backup
                        // 1. Define defaults
                        const defaults = { 
                            activeTripIndex: 0, 
                            minimized: false, 
                            mapVisible: false,
                            trips: [],
                            // Preserve existing API key if the import doesn't have one
                            orsKey: self.data.orsKey || '' 
                        };

                        // 2. Merge: Defaults -> Import Data
                        // This ensures imp.trips replaces the current trips, 
                        // but missing settings fall back to defaults or preserved values.
                        self.data = $.extend({}, defaults, imp);
                    } 
                    else if (imp.days) {
                        // Import Single Trip
                        self.data.trips.push(imp);
                    }
                    self.saveAndRefresh();
                };
                reader.readAsText(e.target.files[0]);
            });
            
            $(document).on('click', '#tp-btn-route', () => {
                $('#tp-ors-key').val(self.data.orsKey || '');
                $('#tp-route-overlay').css('display', 'flex');
            });
            
            $(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.");
                
                // Save settings
                self.data.orsKey = key;
                self.save();

                $('#tp-route-overlay').hide();
                self.calculateRoutes(profile); // Pass profile to function
            });
            
             $(document).on('click', '.tp-section-title', function(e) {
                // Avoid conflict if clicking the "Move" button inside the title
                if ($(e.target).hasClass('tp-btn-move')) return;

                const $moveBtn = $(this).find('.tp-btn-move');
                const type = $moveBtn.data('move-type'); // 'day' or 'backlog'
                
                if (type === 'backlog') {
                    self.highlightDay('backlog');
                } else {
                    const idx = $moveBtn.data('day-idx');
                    if (idx !== undefined) self.highlightDay(parseInt(idx));
                }
            });
        },

        toggleMap: function (force) {
        	const renderMap = () => {
	            const shouldShow = force !== undefined ? force : !this.data.mapVisible;
	            this.data.mapVisible = shouldShow;

                $('body').toggleClass('tp-mobile-map-open', shouldShow);
	
	            $('#tp-map-container').toggle(this.data.mapVisible);
	            if (shouldShow) {
	                if (!this.map) {
	                    this.map = L.map('tp-map-canvas');
	                    L.tileLayer('https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png').addTo(this.map);
	                    
	                    this.map.on('click', () => this.highlightDay(null));

	                    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]);
                            });
                        }
	                }
	                setTimeout(() => {
	                	this.map.invalidateSize();
	                	this.updateMapMarkers(true);
	                }, 500);
	            }
        	};
        	
            renderMap();
        },

        updateMapMarkers: function (shouldFitBounds) {
            if (!this.markerLayer || !window.L) return;
            this.markerLayer.clearLayers();
            const trip = this.getActiveTrip();
            const bounds = [];

            const add = (item, color, label, dayIndexForHighlight) => {
                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.tpDayIdx = (dayIndexForHighlight !== null) ? dayIndexForHighlight : 'backlog';

                m.on('click', (e) => {
                    // Prevent map background click from firing
                    L.DomEvent.stopPropagation(e);
                    this.highlightDay(m.tpDayIdx);
                });

                const safeTitle = mw.html.escape(item.title);
                const safeNote = item.note ? mw.html.escape(item.note) : '';
                // 2. Build Link URL
                const url = item.sourcePage ? mw.util.getUrl(item.sourcePage) + (item.wikidata ? '#' + item.wikidata : '') : '#';

                // 3. Prepare Popup HTML (with placeholder for image)
                let popupHtml = `<div style="text-align:center">
                                    <b style="font-size:1.1em"><a href="${url}" target="_blank">${safeTitle}</a></b>
                                    ${safeNote ? `<br><span style="font-size:0.9em; color:#555">${safeNote}</span>` : ''}
                                 </div>`;
                
                if (item.wikidata) {
                    popupHtml += `<div id="tp-img-${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;">
                                    <span style="color:#999; font-size:0.8em">Loading Image...</span>
                                  </div>`;
                }

                m.bindPopup(popupHtml);

                // 4. Fetch Image on Click (Lazy Load)
                if (item.wikidata) {
                    m.on('popupopen', function () {
                        const $container = $(`#tp-img-${item.wikidata}`);
                        // Only fetch if we haven't already replaced the content
                        if ($container.find('img').length === 0 && $container.text().includes('Loading')) {
                            
                            // Query Wikidata API for PageImages
                            $.ajax({
                                url: 'https://www.wikidata.org/w/api.php',
                                data: {
                                    action: 'query',
                                    titles: item.wikidata,
                                    prop: 'pageimages',
                                    pithumbsize: 250,
                                    format: 'json',
                                    origin: '*'
                                },
                                dataType: 'json'
                            }).done(function (data) {
                                const pages = data.query.pages;
                                const pageId = Object.keys(pages)[0];
                                if (pages[pageId].thumbnail) {
                                    const src = pages[pageId].thumbnail.source;
                                    $container.html(`<img src="${src}" style="max-width:100%; border-radius:4px; display:block">`);
                                } else {
                                    $container.html(`<span style="color:#ccc; font-size:0.8em">(No Image)</span>`).css('min-height', '20px');
                                }
                            });
                        }
                    });
                }

                m.addTo(this.markerLayer);
                bounds.push([item.lat, item.lon]);
            };

            trip.backlog.forEach(i => add(i, "#777", "B", null));
            // 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);
                });
            });
            
            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);
                    });
                }
            });
            
            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,
    			backlog: [], 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: parseFloat(lat),
                        	lon: parseFloat(lon),
                        	wikidata: wdId,
                        	sourcePage: pageName
                    	};
                        e.originalEvent.dataTransfer.setData('application/json', JSON.stringify(data));
                    });
                    
                    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: parseFloat(lat),
                                lon: parseFloat(lon),
                                wikidata: wdId,
                                sourcePage: pageName,
                                note: ""
                            };
                            
                            thiss.getActiveTrip().backlog.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);
                        }
                    }
                }
            });
        },
        
	    calculateRoutes: function (profile) {
	        const trip = this.getActiveTrip();
            
            // UI Cleanup
            $('.tp-route-stats').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]);
                
                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 })
                }).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-item[data-day="${startItem.day}"][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);
                });
            });

            Promise.all(promises).then(() => {
                if (this.routeLayer && window.L && !this.data.mapVisible) {
                    this.toggleMap(true);
                }
            });
        },
        
        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];
            
            // 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],
                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],
                // 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);
                alert("Optimization failed. Check API Key or constraints.");
            });
        },
        
        highlightDay: function (targetIdx) {
            if (!this.map) return;
            
            const setOpacity = (layer) => {
                // Determine if this layer belongs to the target
                // targetIdx can be an integer (0, 1...) or 'backlog'
                // If targetIdx is null, we reset (show all)
                const isMatch = (targetIdx === null) || (layer.tpDayIdx === targetIdx);
                
                // Opacity: Match=1, Others=0.2
                const op = isMatch ? 1 : 0.2;
                const fillOp = isMatch ? 0.9 : 0.2;

                if (layer.setOpacity) { 
                    // Markers (L.marker)
                    layer.setOpacity(op); 
                    // Force z-index to bring highlighted to front
                    if (isMatch) layer.setZIndexOffset(1000); else layer.setZIndexOffset(0);
                } else if (layer.setStyle) { 
                    // Polylines/GeoJSON
                    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);
            
            // LIST SCROLLING & HIGHLIGHTING
            
            // 1. Reset any previous highlights in the list
            $('.tp-section-title').css({ 'background': '', 'transition': '' });

            // If clicking the map background (targetIdx === null), we stop here
            if (targetIdx === null) return;

            // 2. Identify the target DOM element in the sidebar
            let $targetHeader;

            if (targetIdx === 'backlog') {
                // The header immediately preceding the backlog bin
                $targetHeader = $('.tp-backlog').prev('.tp-section-title');
            } else {
                // The header immediately preceding the specific day bin
                // targetIdx is 0, 1, 2... matches data-day-idx
                $targetHeader = $(`.tp-day-bin[data-day-idx="${targetIdx}"]`).prev('.tp-section-title');
            }

            // 3. Scroll and Highlight
            if ($targetHeader.length && $('.tp-body').length) {
                const $container = $('.tp-body');

                // A. Scroll the container
                // Calculation: Element's position relative to document MINUS Container's position relative to document PLUS Current Scroll
                const scrollTop = $targetHeader.offset().top - $container.offset().top + $container.scrollTop();
                
                $container.stop().animate({
                    scrollTop: scrollTop - 10 // -10 for a little padding at the top
                }, 300);

                // B. Visual Flash Effect
                $targetHeader.css({
                    'background': '#fff9c4', // Light yellow highlight
                    'transition': 'background 0.5s'
                });

                // Remove highlight after 1.5 seconds
                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'));
	        }
        }
    }

	// lightweight bootstrap (no load of data/init until first click)
	 $(function () {
        // Create the initial "Lazy" icon
        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'
            ]).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
                TripPlanner.init();
            });
        });
    });
}(mediaWiki, jQuery));


Discover



Powered by GetYourGuide