|
|
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 |
|
|
|
|
|
|
|
|
st.set_page_config( |
|
|
page_title="RASA ITS", |
|
|
page_icon="π½οΈ", |
|
|
layout="wide", |
|
|
initial_sidebar_state="expanded" |
|
|
) |
|
|
|
|
|
|
|
|
@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) |
|
|
|
|
|
|
|
|
if price_str.startswith(">"): |
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
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() |
|
|
|
|
|
|
|
|
for _, row in road_nodes.iterrows(): |
|
|
G.add_node(row['osmid'], x=row['x'], y=row['y'], type='road') |
|
|
|
|
|
|
|
|
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']) |
|
|
|
|
|
|
|
|
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') |
|
|
|
|
|
|
|
|
if road_coords: |
|
|
nearest_road = min(road_coords, |
|
|
key=lambda item: haversine_distance( |
|
|
building['latitude'], building['longitude'], |
|
|
item[1], item[2] |
|
|
))[0] |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
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""" |
|
|
|
|
|
m = create_base_map() |
|
|
|
|
|
if not path or len(path) < 2: |
|
|
return m |
|
|
|
|
|
|
|
|
start_node = path[0] |
|
|
end_node = path[-1] |
|
|
|
|
|
|
|
|
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']]) |
|
|
|
|
|
|
|
|
if len(path_coords) >= 2: |
|
|
folium.PolyLine( |
|
|
locations=path_coords, |
|
|
color='red', |
|
|
weight=4, |
|
|
opacity=0.8, |
|
|
popup='Rute Terpendek' |
|
|
).add_to(m) |
|
|
|
|
|
|
|
|
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'<b>Mulai:</b> {start_name}', |
|
|
tooltip='Lokasi Awal' |
|
|
).add_to(m) |
|
|
|
|
|
|
|
|
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'<b>Tujuan:</b> {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() |
|
|
|
|
|
|
|
|
if search_term: |
|
|
filtered = filtered[filtered['menu'].str.contains(search_term, case=False, na=False)] |
|
|
|
|
|
|
|
|
if selected_tags: |
|
|
tag_filter = filtered['tags'].str.contains('|'.join(selected_tags), case=False, na=False) |
|
|
filtered = filtered[tag_filter] |
|
|
|
|
|
|
|
|
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""" |
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
def main(): |
|
|
st.title("π½οΈ RASA ITS") |
|
|
st.markdown("Temukan pilihan makanan terbaik dan terdekat di sekitar ITS!") |
|
|
|
|
|
|
|
|
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'] |
|
|
|
|
|
|
|
|
graph = build_graph(road_nodes, road_edges, buildings) |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
st.sidebar.header("π― Kontrol Navigasi") |
|
|
|
|
|
|
|
|
st.sidebar.subheader("π Lokasi Anda") |
|
|
building_names = [""] + buildings['name'].tolist() |
|
|
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 |
|
|
|
|
|
|
|
|
st.sidebar.subheader("π Pencarian & Filter Menu") |
|
|
search_term = st.sidebar.text_input("Cari menu:", placeholder="contoh: ayam, nasi") |
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
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 |
|
|
) |
|
|
|
|
|
|
|
|
if st.sidebar.button("πΊοΈ Hapus Petunjuk Arah"): |
|
|
st.session_state.selected_path = None |
|
|
st.session_state.show_directions = False |
|
|
st.rerun() |
|
|
|
|
|
|
|
|
user_has_input = has_user_input( |
|
|
st.session_state.user_location, |
|
|
search_term, |
|
|
selected_tags, |
|
|
price_range, |
|
|
default_price_range |
|
|
) |
|
|
|
|
|
|
|
|
col1, col2 = st.columns([2, 1]) |
|
|
|
|
|
with col1: |
|
|
st.subheader("πΊοΈ Peta ITS") |
|
|
|
|
|
|
|
|
if st.session_state.selected_building_name: |
|
|
st.info(f"π Lokasi saat ini: {st.session_state.selected_building_name}") |
|
|
|
|
|
|
|
|
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() |
|
|
|
|
|
|
|
|
st_folium(display_map, width=700, height=500, key="main_map") |
|
|
|
|
|
with col2: |
|
|
st.subheader("π½οΈ Pilihan Menu") |
|
|
|
|
|
|
|
|
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: |
|
|
|
|
|
filtered_menus = filter_menus(menus, search_term, selected_tags, price_range) |
|
|
|
|
|
if not filtered_menus.empty: |
|
|
if st.session_state.user_location: |
|
|
|
|
|
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:") |
|
|
|
|
|
|
|
|
for i, menu_info in enumerate(menu_distances[:10]): |
|
|
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: |
|
|
|
|
|
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!") |
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__": |
|
|
main() |