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