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()