Spaces:
Sleeping
Sleeping
# app.py | |
# This application provides a user interface for HKUST students to browse, search, | |
# and find accommodations in different neighborhoods of Hong Kong. It features an interactive map | |
# visualization, listing cards with pricing information, traffic-based discounts, and smart search | |
# functionality to match user preferences with available properties. | |
# Key features: | |
# - Interactive map displaying BNB listings with location markers | |
# - Neighborhood-based filtering of available accommodations | |
# - Smart search system that highlights matching terms in descriptions and reviews | |
# - Traffic-based discount system promoting eco-friendly housing options | |
# - Detailed view of property reviews with highlighted search terms | |
# - Responsive pagination for browsing through large sets of listings | |
# - Loading animations and informative UI elements for better user experience | |
# The application uses Folium for map visualization, Streamlit for the web interface | |
# Author: Gordon Li (20317033) | |
# Company : HKUST Sustainability | |
# Date: March 2025 | |
import os | |
import re | |
import streamlit as st | |
import streamlit.components.v1 as components | |
from html import escape | |
from streamlit_folium import st_folium | |
import math | |
from visualiser.hkust_bnb_visualiser import HKUSTBNBVisualiser | |
from huggingface_hub import login | |
from constant.hkust_bnb_constant import ( | |
SIDEBAR_HEADER, | |
SIDEBAR_DIVIDER, | |
TRAFFIC_EXPLANATION, | |
SEARCH_EXPLANATION, | |
REVIEW_CARD_TEMPLATE, | |
LISTINGS_COUNT_INFO, | |
LISTING_CARD_TEMPLATE, | |
PRICE_DISPLAY_WITH_DISCOUNT, | |
PRICE_DISPLAY_NORMAL, | |
RELEVANCE_INFO_LISTING, | |
LOTTIE_HTML | |
) | |
# Loads CSS styles from a file and applies them to the Streamlit application. | |
# Parameters: | |
# css_file: Path to the CSS file to be loaded | |
def load_css(css_file): | |
with open(css_file) as f: | |
st.markdown(f'<style>{f.read()}</style>', unsafe_allow_html=True) | |
# Highlights search terms within text by wrapping them in a span with highlight class. | |
# Parameters: | |
# text: The original text to process | |
# search_query: The search terms to highlight within the text | |
# Returns: | |
# Text with highlighted search terms | |
def highlight_search_terms(text, search_query): | |
if not search_query: | |
return text | |
highlighted_text = text | |
search_terms = search_query.lower().split() | |
for term in search_terms: | |
if term.strip(): | |
pattern = f'(?i)\\b{term}\\b' | |
replacement = f'<span class="highlight">{term}</span>' | |
highlighted_text = re.sub(pattern, replacement, highlighted_text) | |
return highlighted_text | |
# Renders a loading animation using Lottie animation in HTML format. | |
def render_lottie_loading_animation(): | |
components.html(LOTTIE_HTML, height=750) | |
# Renders a dialog containing reviews for the currently selected listing. | |
# Displays reviewer name, review date, and comments with search terms highlighted. | |
def render_review_dialog(): | |
with st.container(): | |
col_title = st.columns([5, 1]) | |
with col_title[0]: | |
st.markdown(f"### Reviews for {st.session_state.current_review_listing_name}") | |
reviews = st.session_state.visualizer.get_listing_reviews(st.session_state.current_review_listing) | |
if reviews: | |
for review in reviews: | |
try: | |
review_date, reviewer_name, comments = review | |
highlighted_comments = highlight_search_terms( | |
str(comments), | |
st.session_state.search_query | |
) | |
st.markdown( | |
REVIEW_CARD_TEMPLATE.format( | |
reviewer_name=escape(str(reviewer_name)), | |
review_date=escape(str(review_date)), | |
highlighted_comments=highlighted_comments | |
), | |
unsafe_allow_html=True | |
) | |
except Exception as e: | |
st.error(f"Error displaying review: {str(e)}") | |
else: | |
st.info("No reviews available for this listing.") | |
# Initializes the session state with default values for various application parameters. | |
# Sets up the visualizer and loads required resources for the application. | |
def initialize_session_state(): | |
default_states = { | |
'center_lat': None, | |
'center_lng': None, | |
'selected_id': None, | |
'current_page': 1, | |
'previous_neighborhood': None, | |
'items_per_page': 3, | |
'search_query': "", | |
'tokenizer_loaded': False, | |
'show_review_dialog': False, | |
'current_review_listing': None, | |
'current_review_listing_name': None, | |
'show_traffic_explanation': False, | |
'show_search_explanation': False, | |
'listings_limit': 10, | |
'loading_complete': False, | |
} | |
for key, default_value in default_states.items(): | |
if key not in st.session_state: | |
st.session_state[key] = default_value | |
if 'visualizer' not in st.session_state: | |
st.session_state.loading_complete = False | |
st.session_state.visualizer = HKUSTBNBVisualiser() | |
st.session_state.tokenizer_loaded = True | |
st.session_state.loading_complete = True | |
# Main function that sets up the Streamlit application interface. | |
# Handles page configuration, sidebar setup, map rendering, listing display, | |
# pagination, and user interactions with the application elements. | |
def main(): | |
st.set_page_config( | |
layout="wide", | |
page_title="HKUST BNB+ | Platform for BNB Matching for HKUST PG Student", | |
initial_sidebar_state="expanded" | |
) | |
load_css('css/style.css') | |
if 'loading_complete' not in st.session_state or not st.session_state.loading_complete: | |
render_lottie_loading_animation() | |
initialize_session_state() | |
st.rerun() | |
visualizer = st.session_state.visualizer | |
if visualizer is None or not hasattr(visualizer, 'neighborhoods'): | |
st.error("Error initializing the application. Please refresh the page.") | |
return | |
if st.session_state.show_traffic_explanation: | |
with st.expander("📊 Traffic-Based Discount System", expanded=True): | |
st.markdown(TRAFFIC_EXPLANATION) | |
if st.button("Close", key="close_traffic_btn"): | |
st.session_state.show_traffic_explanation = False | |
st.rerun() | |
if st.session_state.show_search_explanation: | |
with st.expander("🔍 Smart Search System", expanded=True): | |
st.markdown(SEARCH_EXPLANATION) | |
if st.button("Close", key="close_search_btn"): | |
st.session_state.show_search_explanation = False | |
st.rerun() | |
with st.sidebar: | |
st.image("css/img.png", width=50) | |
st.markdown(SIDEBAR_HEADER, unsafe_allow_html=True) | |
search_query = st.text_input( | |
"🔍 Search listings", | |
value=st.session_state.search_query, | |
placeholder="Try: 'cozy , quiet '" | |
) | |
if search_query != st.session_state.search_query: | |
st.session_state.search_query = search_query | |
st.session_state.current_page = 1 | |
st.session_state.show_review_dialog = False | |
st.markdown(SIDEBAR_DIVIDER, unsafe_allow_html=True) | |
neighborhood = st.selectbox( | |
"Select Neighborhood", | |
options=visualizer.neighborhoods, | |
index=visualizer.neighborhoods.index("Kowloon City") if "Kowloon City" in visualizer.neighborhoods else 0 | |
) | |
listings_limit = st.selectbox( | |
"Number of listings to show", | |
options=[10, 20, 30, 40, 50], | |
index=0, | |
help="Select how many listings to display for this neighborhood" | |
) | |
if listings_limit != st.session_state.listings_limit: | |
st.session_state.listings_limit = listings_limit | |
st.session_state.current_page = 1 | |
st.session_state.show_review_dialog = False | |
st.markdown(SIDEBAR_DIVIDER, unsafe_allow_html=True) | |
st.markdown("### 💡 Help & Information") | |
col1, col2 = st.columns(2) | |
with col1: | |
if st.button("Green Discount", key="traffic_info_btn"): | |
st.session_state.show_traffic_explanation = True | |
st.rerun() | |
with col2: | |
if st.button("Smart Search", key="search_info_btn"): | |
st.session_state.show_search_explanation = True | |
st.rerun() | |
if st.button("Reset All", key="reset_btn"): | |
for key in ['center_lat', 'center_lng', 'selected_id', 'search_query', | |
'show_review_dialog', 'show_traffic_explanation', 'show_search_explanation']: | |
st.session_state[key] = None if key in ['center_lat', 'center_lng', | |
'selected_id'] else False if 'show_' in key else "" | |
st.session_state.current_page = 1 | |
st.session_state.listings_limit = 10 | |
st.rerun() | |
m, df = visualizer.create_map_and_data( | |
neighborhood, | |
True, | |
st.session_state.center_lat, | |
st.session_state.center_lng, | |
st.session_state.selected_id, | |
st.session_state.search_query, | |
st.session_state.current_page, | |
st.session_state.items_per_page, | |
st.session_state.listings_limit | |
) | |
if st.session_state.previous_neighborhood != neighborhood: | |
st.session_state.current_page = 1 | |
if not df.empty: | |
st.session_state.selected_id = df.iloc[0]['id'] | |
st.session_state.center_lat = df.iloc[0]['latitude'] | |
st.session_state.center_lng = df.iloc[0]['longitude'] | |
st.session_state.previous_neighborhood = neighborhood | |
st.session_state.show_review_dialog = False | |
st.rerun() | |
if m is None: | |
st.error("No data available for the selected neighborhood") | |
return | |
col1, col2 = st.columns([7, 3]) | |
with col1: | |
st.markdown('<div class="map-container">', unsafe_allow_html=True) | |
st_folium(m, width=None, height=700) | |
st.markdown('</div>', unsafe_allow_html=True) | |
with col2: | |
st.markdown( | |
LISTINGS_COUNT_INFO.format( | |
listings_limit=st.session_state.listings_limit, | |
neighborhood=neighborhood | |
), | |
unsafe_allow_html=True | |
) | |
total_items = len(df) | |
total_pages = math.ceil(total_items / st.session_state.items_per_page) | |
st.session_state.current_page = min(max(1, st.session_state.current_page), total_pages) | |
start_idx = (st.session_state.current_page - 1) * st.session_state.items_per_page | |
end_idx = min(start_idx + st.session_state.items_per_page, total_items) | |
st.markdown('<div class="scrollable-container">', unsafe_allow_html=True) | |
for idx in range(start_idx, end_idx): | |
row = df.iloc[idx] | |
background_color = "#E3F2FD" if st.session_state.selected_id == row['id'] else "white" | |
discounted_price = row['price'] | |
discount_tag = "" | |
listing_lat = row['latitude'] | |
listing_lng = row['longitude'] | |
nearest_spot, distance = visualizer.find_nearest_traffic_spot(listing_lat, listing_lng) | |
if nearest_spot: | |
discount_rate = nearest_spot.get_discount_rate() | |
if discount_rate > 0: | |
discounted_price = row['price'] * (1 - discount_rate) | |
discount_percentage = int(discount_rate * 100) | |
discount_tag = f"""<span class="discount-tag">-{discount_percentage}%</span>""" | |
if discount_tag: | |
price_display = PRICE_DISPLAY_WITH_DISCOUNT.format( | |
original_price=row['price'], | |
discounted_price=discounted_price, | |
discount_tag=discount_tag | |
) | |
else: | |
price_display = PRICE_DISPLAY_NORMAL.format(price=row['price']) | |
relevance_info = "" | |
if st.session_state.search_query and 'relevance_percentage' in row: | |
relevance_info = RELEVANCE_INFO_LISTING.format(relevance_percentage=row['relevance_percentage']) | |
st.markdown( | |
LISTING_CARD_TEMPLATE.format( | |
background_color=background_color, | |
listing_name=escape(str(row['name'])), | |
price_display=price_display, | |
room_type=escape(str(row['room_type'])), | |
review_count=row['number_of_reviews'], | |
relevance_info=relevance_info | |
), | |
unsafe_allow_html=True | |
) | |
col_details, col_reviews = st.columns(2) | |
with col_details: | |
if st.button("View Details", key=f"btn_{row['id']}"): | |
st.session_state.selected_id = row['id'] | |
st.session_state.center_lat = row['latitude'] | |
st.session_state.center_lng = row['longitude'] | |
st.rerun() | |
with col_reviews: | |
if st.button("View Reviews", key=f"review_btn_{row['id']}"): | |
st.session_state.show_review_dialog = True | |
st.session_state.current_review_listing = row['id'] | |
st.session_state.current_review_listing_name = row['name'] | |
st.session_state.scroll_to_review = True | |
st.rerun() | |
st.markdown('</div>', unsafe_allow_html=True) | |
# Pagination controls | |
col_prev, col_select, col_next = st.columns([1, 1, 1]) | |
with col_select: | |
page_options = list(range(1, total_pages + 1)) | |
new_page = st.selectbox( | |
"Go to page", | |
options=page_options, | |
index=st.session_state.current_page - 1, | |
key="page_selector", | |
label_visibility="collapsed" | |
) | |
if new_page != st.session_state.current_page: | |
st.session_state.current_page = new_page | |
new_start_idx = (new_page - 1) * st.session_state.items_per_page | |
if not df.empty and new_start_idx < len(df): | |
st.session_state.selected_id = df.iloc[new_start_idx]['id'] | |
st.session_state.center_lat = df.iloc[new_start_idx]['latitude'] | |
st.session_state.center_lng = df.iloc[new_start_idx]['longitude'] | |
st.session_state.show_review_dialog = False | |
st.rerun() | |
with col_prev: | |
if st.button("← Previous", disabled=st.session_state.current_page <= 1): | |
st.session_state.current_page -= 1 | |
new_start_idx = (st.session_state.current_page - 1) * st.session_state.items_per_page | |
if not df.empty: | |
st.session_state.selected_id = df.iloc[new_start_idx]['id'] | |
st.session_state.center_lat = df.iloc[new_start_idx]['latitude'] | |
st.session_state.center_lng = df.iloc[new_start_idx]['longitude'] | |
st.session_state.show_review_dialog = False | |
st.rerun() | |
with col_next: | |
if st.button("Next →", disabled=st.session_state.current_page >= total_pages): | |
st.session_state.current_page += 1 | |
new_start_idx = (st.session_state.current_page - 1) * st.session_state.items_per_page | |
if not df.empty: | |
st.session_state.selected_id = df.iloc[new_start_idx]['id'] | |
st.session_state.center_lat = df.iloc[new_start_idx]['latitude'] | |
st.session_state.center_lng = df.iloc[new_start_idx]['longitude'] | |
st.session_state.show_review_dialog = False | |
st.rerun() | |
if st.session_state.show_review_dialog: | |
render_review_dialog() | |
# Main entry point for the application. Authenticates with Hugging Face if a token is available, | |
# then calls the main function to start the application. | |
if __name__ == "__main__": | |
token = os.environ.get("HF_TOKEN") | |
if token: | |
login(token=token) | |
main() |