619 lines
26 KiB
JavaScript
619 lines
26 KiB
JavaScript
// Budapest Sights Data
|
|
const sightsData = [
|
|
{ id: 1, name: "Hungarian Parliament Building", category: "Historic Landmark", description: "One of Europe's most stunning government buildings, located right on the Danube. Features neo-Gothic architecture with over 40 kg of gold, 691 rooms, and 96m height commemorating Hungary's founding year 896.", latitude: 47.5076, longitude: 19.0458, admission: "From €30, guided tours available in English", hours: "Various tour times, advance booking recommended", visit_duration: "60-90 minutes" },
|
|
{ id: 2, name: "Fisherman's Bastion", category: "Historic Landmark", description: "Fairytale-like neo-Romanesque terrace with seven towers representing the seven Magyar tribes. Offers iconic views of Parliament and the Danube. Lower terrace free 24/7, upper ramparts require ticket.", latitude: 47.5023, longitude: 19.0350, admission: "Lower terrace free, upper ramparts 1,500 HUF (€4)", hours: "Lower 24/7, Upper 9:00-21:00 (summer), 9:00-19:00 (rest of year)", visit_duration: "45-60 minutes" },
|
|
{ id: 3, name: "Buda Castle", category: "Historic Landmark", description: "UNESCO World Heritage Site, once home to Hungarian kings. Now houses the Hungarian National Gallery and Budapest History Museum. Beautiful courtyards and terraces with stunning views of Pest.", latitude: 47.4967, longitude: 19.0397, admission: "Grounds free, museums require separate tickets", hours: "Grounds 24/7, museums vary", visit_duration: "90-120 minutes" },
|
|
{ id: 4, name: "Matthias Church", category: "Religious Site", description: "Stunning Gothic church with colorful Zsolnay tiled roof, originally built in 1015. Hosted royal coronations and has excellent acoustics for classical concerts.", latitude: 47.5019, longitude: 19.0348, admission: "3,100 HUF (€8) adults, bell tower extra 3,700 HUF", hours: "Mon-Sat 9:00-17:45, Sun 13:00-17:45", visit_duration: "45-60 minutes" },
|
|
{ id: 5, name: "Chain Bridge (Széchenyi Lánchíd)", category: "Historic Landmark", description: "Budapest's oldest permanent bridge (1849), first to connect Buda and Pest. Iconic symbol with stone lions, suspension chains, beautiful when illuminated at night.", latitude: 47.4983, longitude: 19.0436, admission: "Free", hours: "24/7", visit_duration: "20-30 minutes" },
|
|
{ id: 6, name: "St. Stephen's Basilica", category: "Religious Site", description: "Neoclassical church named after Hungary's first king, whose mummified right hand is preserved here. 96m height (same as Parliament). Panoramic dome with 360° views.", latitude: 47.5009, longitude: 19.0534, admission: "6,000 HUF (€15) including dome, 20% discount with Budapest Card", hours: "Mon-Sat 9:00-17:45, Sun 13:00-17:45", visit_duration: "60-90 minutes" },
|
|
{ id: 7, name: "Heroes' Square", category: "Monument", description: "Iconic square featuring the Millennium Monument with Archangel Gabriel atop central column, surrounded by statues of Hungary's national leaders. Flanked by Museum of Fine Arts and Műcsarnok.", latitude: 47.5146, longitude: 19.0777, admission: "Free", hours: "24/7", visit_duration: "30-45 minutes" },
|
|
{ id: 8, name: "Széchenyi Thermal Baths", category: "Thermal Bath", description: "Europe's largest medicinal bath complex with 18 pools (3 outdoor). Neo-baroque architecture, waters from 1,246m deep well. Famous for chess players in outdoor pools, even in winter.", latitude: 47.5196, longitude: 19.0815, admission: "From 8,600 HUF (€22) weekdays, 9,200 HUF (€23) weekends", hours: "6:00-22:00 daily", visit_duration: "2-4 hours" },
|
|
{ id: 9, name: "Gellért Thermal Baths", category: "Thermal Bath", description: "Stunning Art Nouveau baths (1912-1918) at foot of Gellért Hill. Famous for blue Zsolnay tiles, marble columns, stained-glass ceilings. 10 pools with wave pool in summer.", latitude: 47.4826, longitude: 19.0523, admission: "From 17,700 HUF (€45)", hours: "9:00-19:00 daily", visit_duration: "2-3 hours" },
|
|
{ id: 10, name: "Gellért Hill & Citadel", category: "Viewpoint", description: "235m high hill with panoramic city views. Features Citadel fortress (1851) and Liberty Statue. Easy hike with stunning vistas, especially at sunset.", latitude: 47.4867, longitude: 19.0463, admission: "Free", hours: "24/7", visit_duration: "60-90 minutes" },
|
|
{ id: 11, name: "Great Market Hall (Central Market)", category: "Market", description: "Largest and oldest indoor market (1897) with neo-Gothic design and Zsolnay tile roof. Three floors with fresh produce, local foods, souvenirs, and traditional Hungarian street food on upper level.", latitude: 47.4870, longitude: 19.0583, admission: "Free entry", hours: "Mon 6:00-17:00, Tue-Fri 6:00-18:00, Sat 6:00-15:00, Sun closed", visit_duration: "60-90 minutes" },
|
|
{ id: 12, name: "Shoes on the Danube Bank", category: "Memorial", description: "Poignant Holocaust memorial with 60 pairs of iron shoes honoring 3,500 Jews shot into the Danube by Arrow Cross militia (1944-45). Located 300m south of Parliament.", latitude: 47.5050, longitude: 19.0451, admission: "Free", hours: "24/7", visit_duration: "15-30 minutes" },
|
|
{ id: 13, name: "Dohány Street Synagogue", category: "Religious Site", description: "Europe's largest synagogue (1859) in Moorish Revival style, seats 3,000. Complex includes Heroes' Temple, Jewish Museum, memorial garden. Located at former ghetto border.", latitude: 47.4959, longitude: 19.0618, admission: "Tickets required, guided tours available", hours: "Weekdays only, closed Saturdays and Jewish holidays", visit_duration: "60-90 minutes" },
|
|
{ id: 14, name: "Hungarian State Opera House", category: "Cultural Venue", description: "Neo-Renaissance masterpiece (1884) by Miklós Ybl on Andrássy Avenue. Interior decorated with 7kg of gold. Offers ballet and opera performances, guided tours available.", latitude: 47.5027, longitude: 19.0586, admission: "Tours and performances vary", hours: "Tour times vary, performances evenings", visit_duration: "45-60 minutes (tour) or 2-3 hours (performance)" },
|
|
{ id: 15, name: "Szimpla Kert", category: "Ruin Bar", description: "Original and most famous ruin bar (2002) in abandoned building. Eclectic decor with vintage items, multiple rooms, outdoor courtyard. Sunday farmers market. Free concerts.", latitude: 47.4968, longitude: 19.0631, admission: "Free entry, pay for drinks", hours: "Daily 12:00-late, Sunday market 9:00-14:00", visit_duration: "90-120 minutes" },
|
|
{ id: 16, name: "Vajdahunyad Castle", category: "Historic Landmark", description: "Eclectic castle in City Park blending Romanesque, Gothic, Renaissance, and Baroque styles. Originally built for 1896 Millennium Exhibition, rebuilt in stone due to popularity.", latitude: 47.5148, longitude: 19.0826, admission: "Grounds free, museum inside requires ticket", hours: "Grounds 24/7, museum varies", visit_duration: "45-60 minutes" },
|
|
{ id: 17, name: "Andrássy Avenue", category: "Street/District", description: "Budapest's most elegant boulevard, UNESCO World Heritage Site. Lined with neo-Renaissance townhouses, luxury shops, cafes. Connects city center to Heroes' Square.", latitude: 47.5035, longitude: 19.0640, admission: "Free", hours: "24/7", visit_duration: "60-90 minutes (walking)" },
|
|
{ id: 18, name: "Elizabeth Lookout Tower", category: "Viewpoint", description: "Fairytale stone tower on János Hill offering 360° panoramic views of Budapest and surrounding hills. On clear days, can spot Mátra mountains.", latitude: 47.5180, longitude: 18.9570, admission: "Small fee", hours: "Varies by season", visit_duration: "45-60 minutes" },
|
|
{ id: 19, name: "Váci Street", category: "Shopping Street", description: "Famous pedestrian shopping street running through city center. Filled with shops, restaurants, cafes. Connects to Great Market Hall at southern end.", latitude: 47.4950, longitude: 19.0525, admission: "Free", hours: "24/7 (shops vary)", visit_duration: "60-90 minutes" },
|
|
{ id: 20, name: "City Park (Városliget)", category: "Park", description: "Large public park with lake, Vajdahunyad Castle, Széchenyi Baths, Heroes' Square. Popular for walking, boating in summer, ice skating in winter.", latitude: 47.5157, longitude: 19.0833, admission: "Free", hours: "24/7", visit_duration: "90-120 minutes" }
|
|
];
|
|
|
|
// App state
|
|
let map;
|
|
let markers = [];
|
|
let selectedSights = new Set();
|
|
let hotelMarker = null;
|
|
let routePolyline = null;
|
|
let hotelLocation = null;
|
|
let isPlacingHotel = false;
|
|
|
|
// Constants
|
|
const WALKING_SPEED_KMH = 5.0;
|
|
const WALKING_SPEED_MPH = 3.1;
|
|
const STREET_FACTOR = 1.3;
|
|
const BUDAPEST_CENTER = [47.4979, 19.0402];
|
|
|
|
// Category colors for markers
|
|
const categoryColors = {
|
|
"Historic Landmark": "#dc2626",
|
|
"Religious Site": "#fbbf24",
|
|
"Thermal Bath": "#1e40af",
|
|
"Viewpoint": "#059669",
|
|
"Cultural Venue": "#7c3aed",
|
|
"Market": "#ea580c",
|
|
"Memorial": "#6b7280",
|
|
"Ruin Bar": "#0891b2",
|
|
"Street/District": "#0891b2",
|
|
"Shopping Street": "#0891b2",
|
|
"Park": "#059669",
|
|
"Monument": "#6b7280"
|
|
};
|
|
|
|
// Category icons
|
|
const categoryIcons = {
|
|
"Historic Landmark": "🏛️",
|
|
"Religious Site": "⛪",
|
|
"Thermal Bath": "🛁",
|
|
"Viewpoint": "🏔️",
|
|
"Cultural Venue": "🎭",
|
|
"Market": "🏪",
|
|
"Memorial": "🕊️",
|
|
"Ruin Bar": "🍺",
|
|
"Street/District": "🛣️",
|
|
"Shopping Street": "🛍️",
|
|
"Park": "🌳",
|
|
"Monument": "🗿"
|
|
};
|
|
|
|
// Initialize the application
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
initializeMap();
|
|
renderSightsList();
|
|
setupEventListeners();
|
|
showInstructions();
|
|
});
|
|
|
|
// Initialize Leaflet map
|
|
function initializeMap() {
|
|
map = L.map('map').setView(BUDAPEST_CENTER, 13);
|
|
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© OpenStreetMap contributors',
|
|
maxZoom: 18
|
|
}).addTo(map);
|
|
|
|
// Add all sight markers
|
|
sightsData.forEach((sight, index) => {
|
|
addSightMarker(sight, index + 1);
|
|
});
|
|
|
|
// Handle map clicks for hotel placement
|
|
map.on('click', handleMapClick);
|
|
}
|
|
|
|
// Add sight marker to map
|
|
function addSightMarker(sight, number) {
|
|
const category = sight.category;
|
|
const color = categoryColors[category] || categoryColors["other"];
|
|
const icon = categoryIcons[category] || "📍";
|
|
|
|
const customIcon = L.divIcon({
|
|
html: `<div class="custom-marker" style="background-color: ${color}; color: white; border-radius: 50%; width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: 12px; border: 2px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.2);">${number}</div>`,
|
|
className: 'custom-div-icon',
|
|
iconSize: [30, 30],
|
|
iconAnchor: [15, 15],
|
|
popupAnchor: [0, -15]
|
|
});
|
|
|
|
const marker = L.marker([sight.latitude, sight.longitude], { icon: customIcon })
|
|
.bindPopup(createPopupContent(sight))
|
|
.addTo(map);
|
|
|
|
marker.sightData = sight;
|
|
markers.push(marker);
|
|
}
|
|
|
|
// Create popup content for markers
|
|
function createPopupContent(sight) {
|
|
const icon = categoryIcons[sight.category] || "📍";
|
|
return `
|
|
<div class="popup-content">
|
|
<h4>${icon} ${sight.name}</h4>
|
|
<p><strong>Category:</strong> ${sight.category}</p>
|
|
<p><strong>Admission:</strong> ${sight.admission}</p>
|
|
<p><strong>Hours:</strong> ${sight.hours}</p>
|
|
<p><strong>Duration:</strong> ${sight.visit_duration}</p>
|
|
<button onclick="openSightModal(${sight.id})" class="btn btn--primary btn--sm">More Details</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// Handle map clicks for hotel placement
|
|
function handleMapClick(e) {
|
|
if (isPlacingHotel) {
|
|
placeHotel(e.latlng);
|
|
isPlacingHotel = false;
|
|
document.getElementById('placeHotelBtn').textContent = '📍 Place My Hotel';
|
|
document.body.style.cursor = 'default';
|
|
}
|
|
}
|
|
|
|
// Place hotel marker
|
|
function placeHotel(latlng) {
|
|
// Remove existing hotel marker
|
|
if (hotelMarker) {
|
|
map.removeLayer(hotelMarker);
|
|
}
|
|
|
|
const hotelIcon = L.divIcon({
|
|
html: `<div class="custom-marker" style="background-color: #ec4899; color: white; border-radius: 50%; width: 35px; height: 35px; display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: 16px; border: 2px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.2);">🏨</div>`,
|
|
className: 'custom-div-icon',
|
|
iconSize: [35, 35],
|
|
iconAnchor: [17.5, 17.5],
|
|
popupAnchor: [0, -17.5]
|
|
});
|
|
|
|
hotelMarker = L.marker(latlng, { icon: hotelIcon })
|
|
.bindPopup('<div><h4>🏨 Your Hotel</h4><p>Starting point for your route</p></div>')
|
|
.addTo(map);
|
|
|
|
hotelLocation = latlng;
|
|
|
|
// Show hotel info
|
|
document.getElementById('hotelInfo').classList.remove('hidden');
|
|
|
|
// Recalculate route if sights are selected
|
|
if (selectedSights.size > 0) {
|
|
updateRouteDisplay();
|
|
}
|
|
}
|
|
|
|
// Remove hotel marker
|
|
function removeHotel() {
|
|
if (hotelMarker) {
|
|
map.removeLayer(hotelMarker);
|
|
hotelMarker = null;
|
|
hotelLocation = null;
|
|
document.getElementById('hotelInfo').classList.add('hidden');
|
|
updateRouteDisplay();
|
|
}
|
|
}
|
|
|
|
// Render sights list in sidebar
|
|
function renderSightsList(filter = 'all', searchTerm = '') {
|
|
const sightsList = document.getElementById('sightsList');
|
|
sightsList.innerHTML = '';
|
|
|
|
const filteredSights = sightsData.filter(sight => {
|
|
const matchesFilter = filter === 'all' ||
|
|
sight.category === filter ||
|
|
(filter === 'other' && !Object.keys(categoryColors).includes(sight.category));
|
|
const matchesSearch = sight.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
sight.description.toLowerCase().includes(searchTerm.toLowerCase());
|
|
return matchesFilter && matchesSearch;
|
|
});
|
|
|
|
filteredSights.forEach(sight => {
|
|
const sightElement = document.createElement('div');
|
|
sightElement.className = 'sight-item';
|
|
sightElement.innerHTML = createSightItemHTML(sight);
|
|
sightElement.addEventListener('click', (e) => {
|
|
if (e.target.type !== 'checkbox') {
|
|
openSightModal(sight.id);
|
|
}
|
|
});
|
|
sightsList.appendChild(sightElement);
|
|
});
|
|
}
|
|
|
|
// Create sight item HTML
|
|
function createSightItemHTML(sight) {
|
|
const icon = categoryIcons[sight.category] || "📍";
|
|
const isSelected = selectedSights.has(sight.id);
|
|
|
|
return `
|
|
<div class="sight-item-header">
|
|
<div>
|
|
<div class="sight-item-title">${icon} ${sight.name}</div>
|
|
<div class="sight-category">${sight.category}</div>
|
|
</div>
|
|
<input type="checkbox" class="sight-checkbox"
|
|
${isSelected ? 'checked' : ''}
|
|
onchange="toggleSightSelection(${sight.id})">
|
|
</div>
|
|
<div class="sight-description">${sight.description.substring(0, 120)}...</div>
|
|
<div class="sight-details">
|
|
<strong>Duration:</strong> ${sight.visit_duration} •
|
|
<strong>Admission:</strong> ${sight.admission}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// Toggle sight selection for route planning
|
|
function toggleSightSelection(sightId) {
|
|
if (selectedSights.has(sightId)) {
|
|
selectedSights.delete(sightId);
|
|
} else {
|
|
selectedSights.add(sightId);
|
|
}
|
|
|
|
updateRouteDisplay();
|
|
renderSightsList(getCurrentFilter(), getCurrentSearch());
|
|
}
|
|
|
|
// Update route display
|
|
function updateRouteDisplay() {
|
|
const routeStats = document.getElementById('routeStats');
|
|
const selectedSightsContainer = document.getElementById('selectedSights');
|
|
const downloadBtn = document.getElementById('downloadRouteBtn');
|
|
|
|
if (selectedSights.size === 0) {
|
|
routeStats.classList.add('hidden');
|
|
selectedSightsContainer.innerHTML = '';
|
|
downloadBtn.classList.add('hidden');
|
|
|
|
// Remove route polyline
|
|
if (routePolyline) {
|
|
map.removeLayer(routePolyline);
|
|
routePolyline = null;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Show stats
|
|
routeStats.classList.remove('hidden');
|
|
downloadBtn.classList.remove('hidden');
|
|
|
|
// Get selected sight objects
|
|
const selectedSightObjects = Array.from(selectedSights).map(id =>
|
|
sightsData.find(sight => sight.id === id)
|
|
);
|
|
|
|
// Calculate route
|
|
const route = calculateOptimalRoute(selectedSightObjects);
|
|
const { totalDistance, totalTime } = calculateRouteStats(route);
|
|
|
|
// Update stats display
|
|
document.getElementById('totalDistance').textContent =
|
|
`${totalDistance.toFixed(1)} km (${(totalDistance * 0.621371).toFixed(1)} mi)`;
|
|
document.getElementById('totalTime').textContent =
|
|
`${Math.round(totalTime)} minutes`;
|
|
document.getElementById('selectedCount').textContent =
|
|
`${selectedSights.size} sight${selectedSights.size !== 1 ? 's' : ''}`;
|
|
|
|
// Render selected sights with distances
|
|
renderSelectedSights(route);
|
|
|
|
// Draw route on map
|
|
drawRouteOnMap(route);
|
|
}
|
|
|
|
// Calculate optimal route
|
|
function calculateOptimalRoute(sights) {
|
|
if (sights.length === 0) return [];
|
|
if (sights.length === 1) return sights;
|
|
|
|
let startPoint = hotelLocation ?
|
|
{ latitude: hotelLocation.lat, longitude: hotelLocation.lng } :
|
|
sights[0];
|
|
|
|
const route = [];
|
|
const remaining = [...sights];
|
|
let current = startPoint;
|
|
|
|
// If we have a hotel, start from there
|
|
if (hotelLocation) {
|
|
route.push({ isHotel: true, ...current });
|
|
}
|
|
|
|
// Greedy algorithm: always go to nearest unvisited sight
|
|
while (remaining.length > 0) {
|
|
let nearestIndex = 0;
|
|
let minDistance = calculateDistance(
|
|
current.latitude, current.longitude,
|
|
remaining[0].latitude, remaining[0].longitude
|
|
);
|
|
|
|
for (let i = 1; i < remaining.length; i++) {
|
|
const distance = calculateDistance(
|
|
current.latitude, current.longitude,
|
|
remaining[i].latitude, remaining[i].longitude
|
|
);
|
|
|
|
if (distance < minDistance) {
|
|
minDistance = distance;
|
|
nearestIndex = i;
|
|
}
|
|
}
|
|
|
|
const nextSight = remaining.splice(nearestIndex, 1)[0];
|
|
route.push(nextSight);
|
|
current = nextSight;
|
|
}
|
|
|
|
return route;
|
|
}
|
|
|
|
// Calculate route statistics
|
|
function calculateRouteStats(route) {
|
|
if (route.length <= 1) return { totalDistance: 0, totalTime: 0 };
|
|
|
|
let totalDistance = 0;
|
|
|
|
for (let i = 0; i < route.length - 1; i++) {
|
|
const current = route[i];
|
|
const next = route[i + 1];
|
|
|
|
const distance = calculateDistance(
|
|
current.latitude, current.longitude,
|
|
next.latitude, next.longitude
|
|
) * STREET_FACTOR;
|
|
|
|
totalDistance += distance;
|
|
}
|
|
|
|
const totalTime = (totalDistance / WALKING_SPEED_KMH) * 60;
|
|
|
|
return { totalDistance, totalTime };
|
|
}
|
|
|
|
// Calculate distance between two points using Haversine formula
|
|
function calculateDistance(lat1, lon1, lat2, lon2) {
|
|
const R = 6371; // Earth's radius in km
|
|
const dLat = (lat2 - lat1) * Math.PI / 180;
|
|
const dLon = (lon2 - lon1) * Math.PI / 180;
|
|
const a =
|
|
Math.sin(dLat/2) * Math.sin(dLat/2) +
|
|
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
|
Math.sin(dLon/2) * Math.sin(dLon/2);
|
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
|
|
return R * c;
|
|
}
|
|
|
|
// Render selected sights in route order
|
|
function renderSelectedSights(route) {
|
|
const container = document.getElementById('selectedSights');
|
|
container.innerHTML = '';
|
|
|
|
const sightsOnly = route.filter(item => !item.isHotel);
|
|
|
|
sightsOnly.forEach((sight, index) => {
|
|
const nextSight = sightsOnly[index + 1];
|
|
const prevSight = index === 0 ?
|
|
(hotelLocation ? { latitude: hotelLocation.lat, longitude: hotelLocation.lng } : sight) :
|
|
sightsOnly[index - 1];
|
|
|
|
const distance = calculateDistance(
|
|
prevSight.latitude, prevSight.longitude,
|
|
sight.latitude, sight.longitude
|
|
) * STREET_FACTOR;
|
|
|
|
const element = document.createElement('div');
|
|
element.className = 'selected-sight';
|
|
element.innerHTML = `
|
|
<div class="sight-order">${index + 1}</div>
|
|
<div class="selected-sight-info">
|
|
<div class="selected-sight-name">${sight.name}</div>
|
|
<div class="selected-sight-distance">
|
|
${index === 0 && hotelLocation ? 'From hotel: ' : 'From previous: '}
|
|
${distance.toFixed(1)} km (${(distance * 0.621371).toFixed(1)} mi)
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
element.addEventListener('click', () => {
|
|
map.setView([sight.latitude, sight.longitude], 16);
|
|
});
|
|
|
|
container.appendChild(element);
|
|
});
|
|
}
|
|
|
|
// Draw route on map
|
|
function drawRouteOnMap(route) {
|
|
// Remove existing route
|
|
if (routePolyline) {
|
|
map.removeLayer(routePolyline);
|
|
}
|
|
|
|
if (route.length < 2) return;
|
|
|
|
const routeCoords = route.map(point => [point.latitude, point.longitude]);
|
|
|
|
routePolyline = L.polyline(routeCoords, {
|
|
color: '#2563eb',
|
|
weight: 3,
|
|
opacity: 0.8,
|
|
dashArray: '10, 5'
|
|
}).addTo(map);
|
|
|
|
// Fit map to show all points
|
|
const group = new L.featureGroup(markers.concat(routePolyline));
|
|
if (hotelMarker) {
|
|
group.addLayer(hotelMarker);
|
|
}
|
|
map.fitBounds(group.getBounds().pad(0.1));
|
|
}
|
|
|
|
// Open sight detail modal
|
|
function openSightModal(sightId) {
|
|
const sight = sightsData.find(s => s.id === sightId);
|
|
if (!sight) return;
|
|
|
|
const modal = document.getElementById('sightModal');
|
|
const title = document.getElementById('modalTitle');
|
|
const content = document.getElementById('modalContent');
|
|
const checkbox = document.getElementById('modalAddToRoute');
|
|
|
|
const icon = categoryIcons[sight.category] || "📍";
|
|
|
|
title.textContent = `${icon} ${sight.name}`;
|
|
checkbox.checked = selectedSights.has(sight.id);
|
|
checkbox.onchange = () => toggleSightSelection(sight.id);
|
|
|
|
content.innerHTML = `
|
|
<div style="margin-bottom: 16px;">
|
|
<strong>Category:</strong> ${sight.category}
|
|
</div>
|
|
<div style="margin-bottom: 16px;">
|
|
<p>${sight.description}</p>
|
|
</div>
|
|
<div style="margin-bottom: 12px;">
|
|
<strong>💰 Admission:</strong> ${sight.admission}
|
|
</div>
|
|
<div style="margin-bottom: 12px;">
|
|
<strong>🕒 Hours:</strong> ${sight.hours}
|
|
</div>
|
|
<div style="margin-bottom: 12px;">
|
|
<strong>⏱️ Recommended Duration:</strong> ${sight.visit_duration}
|
|
</div>
|
|
`;
|
|
|
|
modal.classList.remove('hidden');
|
|
|
|
// Set up zoom button
|
|
document.getElementById('zoomToSight').onclick = () => {
|
|
map.setView([sight.latitude, sight.longitude], 16);
|
|
modal.classList.add('hidden');
|
|
};
|
|
}
|
|
|
|
// Setup event listeners
|
|
function setupEventListeners() {
|
|
// Search
|
|
document.getElementById('searchInput').addEventListener('input', (e) => {
|
|
renderSightsList(getCurrentFilter(), e.target.value);
|
|
});
|
|
|
|
// Filter buttons
|
|
document.querySelectorAll('.filter-btn').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
|
e.target.classList.add('active');
|
|
renderSightsList(e.target.dataset.category, getCurrentSearch());
|
|
});
|
|
});
|
|
|
|
// Hotel placement
|
|
document.getElementById('placeHotelBtn').addEventListener('click', () => {
|
|
isPlacingHotel = true;
|
|
document.getElementById('placeHotelBtn').textContent = '📍 Click on map to place hotel';
|
|
document.body.style.cursor = 'crosshair';
|
|
});
|
|
|
|
document.getElementById('removeHotelBtn').addEventListener('click', removeHotel);
|
|
|
|
// Route controls
|
|
document.getElementById('optimizeRouteBtn').addEventListener('click', updateRouteDisplay);
|
|
document.getElementById('clearRouteBtn').addEventListener('click', clearRoute);
|
|
document.getElementById('downloadRouteBtn').addEventListener('click', downloadRoute);
|
|
|
|
// Modal controls
|
|
document.getElementById('closeModal').addEventListener('click', () => {
|
|
document.getElementById('sightModal').classList.add('hidden');
|
|
});
|
|
|
|
document.getElementById('closeInstructions').addEventListener('click', () => {
|
|
document.getElementById('mapInstructions').classList.add('hidden');
|
|
});
|
|
|
|
// Click outside modal to close
|
|
document.getElementById('sightModal').addEventListener('click', (e) => {
|
|
if (e.target.id === 'sightModal') {
|
|
document.getElementById('sightModal').classList.add('hidden');
|
|
}
|
|
});
|
|
}
|
|
|
|
// Clear route
|
|
function clearRoute() {
|
|
selectedSights.clear();
|
|
updateRouteDisplay();
|
|
renderSightsList(getCurrentFilter(), getCurrentSearch());
|
|
}
|
|
|
|
// Download route as text file
|
|
function downloadRoute() {
|
|
if (selectedSights.size === 0) return;
|
|
|
|
const selectedSightObjects = Array.from(selectedSights).map(id =>
|
|
sightsData.find(sight => sight.id === id)
|
|
);
|
|
|
|
const route = calculateOptimalRoute(selectedSightObjects);
|
|
const { totalDistance, totalTime } = calculateRouteStats(route);
|
|
|
|
let content = "BUDAPEST TRAVEL ROUTE\n";
|
|
content += "Generated by Budapest Travel Guide\n";
|
|
content += "=================================\n\n";
|
|
|
|
if (hotelLocation) {
|
|
content += "Starting Point: Your Hotel\n";
|
|
content += `Location: ${hotelLocation.lat.toFixed(6)}, ${hotelLocation.lng.toFixed(6)}\n\n`;
|
|
}
|
|
|
|
content += `Total Distance: ${totalDistance.toFixed(1)} km (${(totalDistance * 0.621371).toFixed(1)} mi)\n`;
|
|
content += `Estimated Walking Time: ${Math.round(totalTime)} minutes\n`;
|
|
content += `Number of Sights: ${selectedSights.size}\n\n`;
|
|
|
|
const sightsOnly = route.filter(item => !item.isHotel);
|
|
|
|
sightsOnly.forEach((sight, index) => {
|
|
content += `${index + 1}. ${sight.name}\n`;
|
|
content += ` Category: ${sight.category}\n`;
|
|
content += ` Duration: ${sight.visit_duration}\n`;
|
|
content += ` Admission: ${sight.admission}\n`;
|
|
content += ` Hours: ${sight.hours}\n`;
|
|
content += ` Location: ${sight.latitude.toFixed(6)}, ${sight.longitude.toFixed(6)}\n`;
|
|
|
|
if (index < sightsOnly.length - 1) {
|
|
const nextSight = sightsOnly[index + 1];
|
|
const distance = calculateDistance(
|
|
sight.latitude, sight.longitude,
|
|
nextSight.latitude, nextSight.longitude
|
|
) * STREET_FACTOR;
|
|
content += ` → Next: ${distance.toFixed(1)} km (${Math.round((distance / WALKING_SPEED_KMH) * 60)} min walk)\n`;
|
|
}
|
|
content += "\n";
|
|
});
|
|
|
|
content += "\nTips:\n";
|
|
content += "• Budapest city center is very walkable\n";
|
|
content += "• Buda side has hills - budget extra time\n";
|
|
content += "• Public transport available for longer distances\n";
|
|
content += "• Distances are estimated walking routes\n";
|
|
|
|
const blob = new Blob([content], { type: 'text/plain' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'budapest-route.txt';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
// Helper functions
|
|
function getCurrentFilter() {
|
|
return document.querySelector('.filter-btn.active')?.dataset.category || 'all';
|
|
}
|
|
|
|
function getCurrentSearch() {
|
|
return document.getElementById('searchInput').value;
|
|
}
|
|
|
|
function showInstructions() {
|
|
// Hide instructions after 5 seconds
|
|
setTimeout(() => {
|
|
document.getElementById('mapInstructions').classList.add('hidden');
|
|
}, 5000);
|
|
}
|
|
|
|
// Make functions available globally for HTML onclick handlers
|
|
window.toggleSightSelection = toggleSightSelection;
|
|
window.openSightModal = openSightModal; |