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