import streamlit as st import pandas as pd import numpy as np import networkx as nx import folium from streamlit_folium import st_folium import requests from io import StringIO import math from scipy.optimize import linear_sum_assignment from geopy.distance import geodesic import plotly.express as px import plotly.graph_objects as go # Konfigurasi halaman Streamlit st.set_page_config( page_title="RASA ITS", page_icon="🍽️", layout="wide", initial_sidebar_state="expanded" ) # Fungsi untuk memuat data CSV dengan caching @st.cache_data def load_csv_from_local(file_path): """Memuat data CSV dari file lokal dengan caching untuk performa""" try: return pd.read_csv(file_path) except Exception as e: st.error(f"Error memuat data dari {file_path}: {e}") return pd.DataFrame() @st.cache_data def load_all_data(): """Memuat semua dataset yang diperlukan dari file lokal""" file_paths = { 'buildings': 'data/building.csv', 'road_nodes': 'data/road_nodes.csv', 'road_edges': 'data/road_edges.csv', 'menus': 'data/menu_with_tags.csv' } data = {} for key, path in file_paths.items(): data[key] = load_csv_from_local(path) return data def format_price(price_str): """Memformat string harga dengan format Indonesia (IDR)""" if pd.isna(price_str): return "Harga tidak tersedia" price_str = str(price_str) # Cek apakah harga dimulai dengan ">" (harga minimum) if price_str.startswith(">"): # Hapus ">" dan konversi ke angka price_num = price_str[1:] try: price_value = float(price_num) return f"mulai dari IDR {price_value:,.0f}".replace(",", ".") except ValueError: return f"mulai dari IDR {price_num}" else: try: price_value = float(price_str) return f"IDR {price_value:,.0f}".replace(",", ".") except ValueError: return f"IDR {price_str}" def extract_numeric_price(price_str): """Ekstrak nilai numerik dari string harga untuk perhitungan""" if pd.isna(price_str): return np.nan price_str = str(price_str) # Hapus ">" jika ada if price_str.startswith(">"): price_str = price_str[1:] try: return float(price_str) except ValueError: return np.nan @st.cache_data def build_graph(road_nodes, road_edges, buildings): """Membangun graf terarah dari jaringan jalan dan bangunan untuk navigasi""" G = nx.DiGraph() # Tambahkan node jalan for _, row in road_nodes.iterrows(): G.add_node(row['osmid'], x=row['x'], y=row['y'], type='road') # Tambahkan edge jalan dengan bobot jarak for _, row in road_edges.iterrows(): if pd.notna(row['length']) and row['u'] in G.nodes and row['v'] in G.nodes: G.add_edge(row['u'], row['v'], length=row['length']) # Tambahkan node bangunan dan hubungkan ke node jalan terdekat road_coords = [(n, data['y'], data['x']) for n, data in G.nodes(data=True) if data.get('type') == 'road'] for idx, building in buildings.iterrows(): building_id = f"building_{idx}" G.add_node(building_id, x=building['longitude'], y=building['latitude'], name=building['name'], type='building') # Cari node jalan terdekat if road_coords: nearest_road = min(road_coords, key=lambda item: haversine_distance( building['latitude'], building['longitude'], item[1], item[2] ))[0] # Hitung jarak dan tambahkan edge dua arah dist = haversine_distance( building['latitude'], building['longitude'], G.nodes[nearest_road]['y'], G.nodes[nearest_road]['x'] ) G.add_edge(building_id, nearest_road, length=dist) G.add_edge(nearest_road, building_id, length=dist) return G def haversine_distance(lat1, lon1, lat2, lon2): """Hitung jarak haversine antara dua titik dalam meter""" lat1, lon1, lat2, lon2 = map(math.radians, [lat1, lon1, lat2, lon2]) dlat = lat2 - lat1 dlon = lon2 - lon1 a = math.sin(dlat/2)**2 + math.cos(lat1)*math.cos(lat2)*math.sin(dlon/2)**2 c = 2 * math.asin(math.sqrt(a)) return 6371000 * c # Radius bumi dalam meter def create_base_map(center_lat=-7.2820, center_lon=112.7950): """Buat peta dasar folium yang berpusat di ITS ITS""" m = folium.Map( location=[center_lat, center_lon], zoom_start=15.5, tiles='OpenStreetMap' ) return m def create_map_with_directions(graph, path, buildings): """Buat peta dengan marker awal dan tujuan plus rute navigasi""" # Buat peta dasar yang berpusat di ITS m = create_base_map() if not path or len(path) < 2: return m # Ambil node awal dan akhir start_node = path[0] end_node = path[-1] # Buat koordinat jalur path_coords = [] for node in path: if node in graph.nodes: node_data = graph.nodes[node] path_coords.append([node_data['y'], node_data['x']]) # Tambahkan garis rute if len(path_coords) >= 2: folium.PolyLine( locations=path_coords, color='red', weight=4, opacity=0.8, popup='Rute Terpendek' ).add_to(m) # Tambahkan marker awal (lokasi pengguna) if start_node in graph.nodes: start_data = graph.nodes[start_node] start_name = start_data.get('name', 'Lokasi Awal') folium.Marker( location=[start_data['y'], start_data['x']], icon=folium.Icon(color='green', icon='play'), popup=f'Mulai: {start_name}', tooltip='Lokasi Awal' ).add_to(m) # Tambahkan marker tujuan if end_node in graph.nodes: end_data = graph.nodes[end_node] end_name = end_data.get('name', 'Tujuan') folium.Marker( location=[end_data['y'], end_data['x']], icon=folium.Icon(color='red', icon='stop'), popup=f'Tujuan: {end_name}', tooltip='Tujuan' ).add_to(m) return m def find_shortest_path(graph, start_node, end_node): """Cari jalur terpendek menggunakan algoritma Dijkstra""" try: path = nx.dijkstra_path(graph, start_node, end_node, weight='length') distance = nx.dijkstra_path_length(graph, start_node, end_node, weight='length') return path, distance except (nx.NetworkXNoPath, nx.NodeNotFound): return None, float('inf') def filter_menus(menus, search_term="", selected_tags=None, price_range=(0, 100000)): """Filter menu berdasarkan kata kunci, tag, dan rentang harga""" filtered = menus.copy() # Filter berdasarkan kata kunci pencarian if search_term: filtered = filtered[filtered['menu'].str.contains(search_term, case=False, na=False)] # Filter berdasarkan tag yang dipilih if selected_tags: tag_filter = filtered['tags'].str.contains('|'.join(selected_tags), case=False, na=False) filtered = filtered[tag_filter] # Konversi harga ke numerik dan filter berdasarkan rentang harga filtered['price_numeric'] = filtered['price'].apply(extract_numeric_price) filtered = filtered.dropna(subset=['price_numeric']) filtered = filtered[ (filtered['price_numeric'] >= price_range[0]) & (filtered['price_numeric'] <= price_range[1]) ] return filtered def calculate_menu_distances(filtered_menus, buildings, graph, user_location): """Hitung jarak dari lokasi pengguna ke lokasi menu yang difilter""" # Jalankan Dijkstra sekali saja untuk seluruh node all_distances = nx.single_source_dijkstra_path_length(graph, user_location, weight='length') menu_distances = [] for _, menu in filtered_menus.iterrows(): building_match = buildings[buildings['name'].str.contains(menu['location'], case=False, na=False)] if not building_match.empty: building_idx = building_match.index[0] building_node = f"building_{building_idx}" dist = all_distances.get(building_node, float('inf')) if dist != float('inf'): menu_distances.append({ 'menu': menu['menu'], 'location': menu['location'], 'price': menu['price'], 'price_numeric': menu['price_numeric'], 'category': menu['category'], 'tags': menu['tags'], 'distance': dist, 'building_node': building_node, 'building_idx': building_idx }) return sorted(menu_distances, key=lambda x: x['distance']) def has_user_input(user_location, search_term, selected_tags, price_range, default_price_range): """Cek apakah pengguna sudah memberikan input untuk pencarian menu""" has_location = user_location is not None has_search = search_term.strip() != "" has_tags = selected_tags and len(selected_tags) > 0 has_custom_price = price_range != default_price_range return has_location or has_search or has_tags or has_custom_price # Fungsi utama aplikasi def main(): st.title("🍽️ RASA ITS") st.markdown("Temukan pilihan makanan terbaik dan terdekat di sekitar ITS!") # Muat data dengan loading spinner with st.spinner("Memuat data ITS..."): data = load_all_data() if any(df.empty for df in data.values()): st.error("Gagal memuat data yang diperlukan.") return buildings = data['buildings'] road_nodes = data['road_nodes'] road_edges = data['road_edges'] menus = data['menus'] # Bangun graf untuk navigasi graph = build_graph(road_nodes, road_edges, buildings) # Inisialisasi session state untuk menyimpan status aplikasi if 'selected_path' not in st.session_state: st.session_state.selected_path = None if 'user_location' not in st.session_state: st.session_state.user_location = None if 'selected_building_name' not in st.session_state: st.session_state.selected_building_name = None if 'show_directions' not in st.session_state: st.session_state.show_directions = False # Kontrol sidebar st.sidebar.header("🎯 Kontrol Navigasi") # Pemilihan lokasi pengguna - HANYA DROPDOWN st.sidebar.subheader("πŸ“ Lokasi Anda") building_names = [""] + buildings['name'].tolist() # Tambah opsi kosong selected_building = st.sidebar.selectbox("Pilih lokasi Anda:", building_names) if selected_building: building_idx = buildings[buildings['name'] == selected_building].index[0] st.session_state.user_location = f"building_{building_idx}" st.session_state.selected_building_name = selected_building else: st.session_state.user_location = None st.session_state.selected_building_name = None # Pencarian dan filter menu st.sidebar.subheader("πŸ” Pencarian & Filter Menu") search_term = st.sidebar.text_input("Cari menu:", placeholder="contoh: ayam, nasi") # Ambil tag unik untuk filtering all_tags = set() for tags_str in menus['tags'].dropna(): if isinstance(tags_str, str): all_tags.update(tag.strip() for tag in tags_str.split(',')) all_tags = sorted(list(all_tags)) selected_tags = st.sidebar.multiselect("Filter berdasarkan tag:", all_tags) # Slider rentang harga - gunakan nilai numerik untuk filtering menus['price_numeric'] = menus['price'].apply(extract_numeric_price) menus_with_price = menus.dropna(subset=['price_numeric']) min_price = int(menus_with_price['price_numeric'].min()) if not menus_with_price['price_numeric'].empty else 0 max_price = int(menus_with_price['price_numeric'].max()) if not menus_with_price['price_numeric'].empty else 100000 default_price_range = (min_price, max_price) price_range = st.sidebar.slider( "Rentang harga (IDR):", min_value=min_price, max_value=max_price, value=default_price_range, step=1000 ) # Tombol hapus petunjuk arah if st.sidebar.button("πŸ—ΊοΈ Hapus Petunjuk Arah"): st.session_state.selected_path = None st.session_state.show_directions = False st.rerun() # Cek apakah pengguna sudah memberikan input user_has_input = has_user_input( st.session_state.user_location, search_term, selected_tags, price_range, default_price_range ) # Area konten utama dengan 2 kolom col1, col2 = st.columns([2, 1]) with col1: st.subheader("πŸ—ΊοΈ Peta ITS") # Tampilkan lokasi saat ini if st.session_state.selected_building_name: st.info(f"πŸ“ Lokasi saat ini: {st.session_state.selected_building_name}") # Buat dan tampilkan peta if st.session_state.show_directions and st.session_state.selected_path: display_map = create_map_with_directions(graph, st.session_state.selected_path, buildings) else: display_map = create_base_map() # Tampilkan peta tanpa handling klik st_folium(display_map, width=700, height=500, key="main_map") with col2: st.subheader("🍽️ Pilihan Menu") # Hanya tampilkan opsi menu jika pengguna sudah memberikan input if not user_has_input: st.info("πŸ‘‹ **Selamat datang!** Untuk melihat pilihan menu, silakan:") st.markdown(""" - πŸ“ **Pilih lokasi Anda** dari dropdown - πŸ” **Cari makanan tertentu** (contoh: "ayam", "nasi") - 🏷️ **Pilih tag makanan** (contoh: "pedas", "ayam") - πŸ’° **Sesuaikan rentang harga** jika diperlukan """) st.markdown("---") st.markdown("πŸ—ΊοΈ **Tips:** Anda dapat menjelajahi peta ITS di sebelah kiri!") else: # Filter menu berdasarkan kriteria filtered_menus = filter_menus(menus, search_term, selected_tags, price_range) if not filtered_menus.empty: if st.session_state.user_location: # Hitung jarak dan urutkan menu_distances = calculate_menu_distances( filtered_menus, buildings, graph, st.session_state.user_location ) if menu_distances: st.write(f"Ditemukan {len(menu_distances)} pilihan menu:") # Tampilkan opsi menu dengan petunjuk arah for i, menu_info in enumerate(menu_distances[:10]): # Tampilkan 10 teratas with st.expander( f"🍽️ {menu_info['menu']} - {format_price(menu_info['price'])} " f"({menu_info['distance']:.0f}m)" ): st.write(f"**Lokasi:** {menu_info['location']}") st.write(f"**Kategori:** {menu_info['category']}") st.write(f"**Tag:** {menu_info['tags']}") st.write(f"**Jarak:** {menu_info['distance']:.0f} meter") if st.button(f"🧭 Tampilkan Petunjuk Arah", key=f"dir_{i}"): path, distance = find_shortest_path( graph, st.session_state.user_location, menu_info['building_node'] ) if path: st.session_state.selected_path = path st.session_state.show_directions = True st.success(f"Rute ditemukan! Jarak: {distance:.0f} meter") st.rerun() else: st.error("Tidak ada rute yang ditemukan ke lokasi ini.") else: st.info("Tidak ada lokasi menu yang dapat dijangkau.") else: # Tampilkan menu yang difilter tanpa perhitungan jarak st.write(f"Ditemukan {len(filtered_menus)} pilihan menu:") st.info("πŸ’‘ Pilih lokasi Anda untuk melihat jarak dan mendapatkan petunjuk arah!") for i, (_, menu) in enumerate(filtered_menus.head(10).iterrows()): with st.expander(f"🍽️ {menu['menu']} - {format_price(menu['price'])}"): st.write(f"**Lokasi:** {menu['location']}") st.write(f"**Kategori:** {menu['category']}") st.write(f"**Tag:** {menu['tags']}") st.info("πŸ“ Pilih lokasi Anda untuk melihat jarak dan mendapatkan petunjuk arah") else: st.info("Tidak ada menu yang ditemukan sesuai kriteria Anda. Coba sesuaikan filter!") # Jalankan aplikasi jika file dieksekusi langsung if __name__ == "__main__": main()