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