5240-frontend / app.py
Gordon Li
comment reformat
ae72b3f
# 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()