# 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'', 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'{term}' 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('
', unsafe_allow_html=True) st_folium(m, width=None, height=700) st.markdown('
', 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('
', 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"""-{discount_percentage}%""" 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('
', 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()