Spaces:
Sleeping
Sleeping
Gordon Li
commited on
Commit
·
117ae8e
1
Parent(s):
b656af9
review
Browse files- AirbnbMapVisualiser.py +115 -114
- TrafficSpot.py +57 -0
- app.py +75 -18
- style.css +184 -1
AirbnbMapVisualiser.py
CHANGED
@@ -4,6 +4,7 @@ import folium
|
|
4 |
from html import escape
|
5 |
from transformers import AutoTokenizer
|
6 |
import numpy as np
|
|
|
7 |
|
8 |
|
9 |
class AirbnbMapVisualiser:
|
@@ -13,25 +14,103 @@ class AirbnbMapVisualiser:
|
|
13 |
'password': '7033',
|
14 |
'dsn': 'imz409.ust.hk:1521/imz409'
|
15 |
}
|
16 |
-
# Initialize the tokenizer
|
17 |
self.tokenizer = AutoTokenizer.from_pretrained('bert-base-uncased')
|
|
|
|
|
18 |
try:
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
print("No listings found in database")
|
25 |
except Exception as e:
|
26 |
print(f"Initialization error: {str(e)}")
|
27 |
-
self.all_listings = []
|
28 |
self.neighborhoods = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
29 |
|
30 |
def preprocess_text(self, text):
|
31 |
if not isinstance(text, str):
|
32 |
return ""
|
33 |
-
|
34 |
-
# Tokenize and decode back to get normalized text
|
35 |
tokens = self.tokenizer.tokenize(text)
|
36 |
cleaned_text = " ".join(tokens)
|
37 |
return cleaned_text
|
@@ -43,18 +122,14 @@ class AirbnbMapVisualiser:
|
|
43 |
def compute_token_based_similarity(self, text1, text2):
|
44 |
tokens1 = set(self.tokenizer.tokenize(text1.lower()))
|
45 |
tokens2 = set(self.tokenizer.tokenize(text2.lower()))
|
46 |
-
|
47 |
intersection = len(tokens1.intersection(tokens2))
|
48 |
-
|
49 |
normalization_factor = min(len(tokens1), len(tokens2))
|
50 |
|
51 |
if normalization_factor == 0:
|
52 |
return 0
|
53 |
|
54 |
base_similarity = intersection / normalization_factor
|
55 |
-
|
56 |
boosted_score = 1 / (1 + np.exp(-6 * (base_similarity - 0.5)))
|
57 |
-
|
58 |
return boosted_score
|
59 |
|
60 |
def compute_search_scores(self, df, search_query):
|
@@ -68,21 +143,17 @@ class AirbnbMapVisualiser:
|
|
68 |
|
69 |
token_sim = self.compute_token_based_similarity(processed_query, listing_desc)
|
70 |
|
71 |
-
# Enhanced exact match bonus
|
72 |
exact_match_bonus = 0
|
73 |
partial_match_bonus = 0
|
74 |
|
75 |
for term in query_terms:
|
76 |
-
# Exact match bonus
|
77 |
if term in desc_terms:
|
78 |
-
exact_match_bonus += 0.2
|
79 |
|
80 |
-
# Partial match bonus
|
81 |
for desc_term in desc_terms:
|
82 |
if term in desc_term or desc_term in term:
|
83 |
-
partial_match_bonus += 0.1
|
84 |
|
85 |
-
# Calculate category match bonus
|
86 |
category_keywords = {
|
87 |
'location': ['near', 'close', 'located', 'area', 'district'],
|
88 |
'amenities': ['wifi', 'kitchen', 'bathroom', 'furnished'],
|
@@ -94,18 +165,16 @@ class AirbnbMapVisualiser:
|
|
94 |
for category, keywords in category_keywords.items():
|
95 |
if any(keyword in processed_query.lower() for keyword in keywords):
|
96 |
if any(keyword in listing_desc.lower() for keyword in keywords):
|
97 |
-
category_bonus += 0.15
|
98 |
|
99 |
-
# Combine all scores with adjusted weights
|
100 |
final_score = min(1.0, (
|
101 |
-
token_sim * 0.4 +
|
102 |
-
exact_match_bonus * 0.3 +
|
103 |
-
partial_match_bonus * 0.2 +
|
104 |
-
category_bonus * 0.1
|
105 |
))
|
106 |
|
107 |
-
|
108 |
-
boosted_score = np.power(final_score, 0.7) # This will boost higher scores more
|
109 |
scores.append(boosted_score)
|
110 |
|
111 |
return np.array(scores)
|
@@ -114,33 +183,26 @@ class AirbnbMapVisualiser:
|
|
114 |
if not search_query:
|
115 |
return df
|
116 |
|
117 |
-
# Get similarity scores
|
118 |
scores = self.compute_search_scores(df, search_query)
|
119 |
-
|
120 |
-
# Add scores to dataframe
|
121 |
df['relevance_score'] = scores
|
122 |
-
|
123 |
-
# Convert to percentage (0-100) with adjusted scaling
|
124 |
df['relevance_percentage'] = df['relevance_score'] * 100
|
125 |
|
126 |
-
# Adjust threshold levels for relevance descriptions
|
127 |
def get_relevance_description(score):
|
128 |
-
if score >= 70:
|
129 |
return "Perfect match"
|
130 |
-
elif score >= 55:
|
131 |
return "Very relevant"
|
132 |
-
elif score >= 40:
|
133 |
return "Relevant"
|
134 |
-
elif score >= 25:
|
135 |
return "Somewhat relevant"
|
136 |
-
elif score >= 15:
|
137 |
return "Slightly relevant"
|
138 |
else:
|
139 |
return "Less relevant"
|
140 |
|
141 |
df['relevance_features'] = df['relevance_percentage'].apply(get_relevance_description)
|
142 |
|
143 |
-
# Enhanced matching features description
|
144 |
def get_matching_features(row):
|
145 |
query_tokens = set(self.tokenizer.tokenize(search_query.lower()))
|
146 |
desc_tokens = set(self.tokenizer.tokenize(self.create_listing_description(row).lower()))
|
@@ -149,7 +211,6 @@ class AirbnbMapVisualiser:
|
|
149 |
if not matching_tokens:
|
150 |
return "No direct matches"
|
151 |
|
152 |
-
# Group similar matches
|
153 |
grouped_matches = []
|
154 |
processed_tokens = set()
|
155 |
|
@@ -166,89 +227,48 @@ class AirbnbMapVisualiser:
|
|
166 |
return ", ".join(grouped_matches)
|
167 |
|
168 |
df['matching_features'] = df.apply(get_matching_features, axis=1)
|
169 |
-
|
170 |
-
# Sort by relevance score (descending)
|
171 |
return df.sort_values('relevance_score', ascending=False)
|
172 |
|
173 |
-
|
174 |
-
def get_all_listings(self):
|
175 |
-
try:
|
176 |
-
with oracledb.connect(**self.connection_params) as conn:
|
177 |
-
cursor = conn.cursor()
|
178 |
-
cursor.execute("""
|
179 |
-
SELECT ID, NAME, HOST_NAME, NEIGHBOURHOOD,
|
180 |
-
LATITUDE, LONGITUDE, ROOM_TYPE, PRICE,
|
181 |
-
NUMBER_OF_REVIEWS, REVIEWS_PER_MONTH,
|
182 |
-
MINIMUM_NIGHTS, AVAILABILITY_365
|
183 |
-
FROM airbnb_master_data
|
184 |
-
WHERE LATITUDE IS NOT NULL
|
185 |
-
AND LONGITUDE IS NOT NULL
|
186 |
-
""")
|
187 |
-
return cursor.fetchall()
|
188 |
-
except Exception as e:
|
189 |
-
print(f"Database error: {str(e)}")
|
190 |
-
return []
|
191 |
-
|
192 |
-
def get_traffic_spots(self):
|
193 |
-
try:
|
194 |
-
with oracledb.connect(**self.connection_params) as conn:
|
195 |
-
cursor = conn.cursor()
|
196 |
-
cursor.execute("""
|
197 |
-
SELECT KEY, LATITUDE, LONGITUDE
|
198 |
-
FROM TD_TRAFFIC_CAMERA_LOCATION
|
199 |
-
WHERE LATITUDE IS NOT NULL
|
200 |
-
AND LONGITUDE IS NOT NULL
|
201 |
-
""")
|
202 |
-
return cursor.fetchall()
|
203 |
-
except Exception as e:
|
204 |
-
print(f"Database error: {str(e)}")
|
205 |
-
return []
|
206 |
-
|
207 |
def create_map_and_data(self, neighborhood="Sha Tin", show_traffic=True, center_lat=None, center_lng=None,
|
208 |
selected_id=None, search_query=None):
|
209 |
-
|
|
|
|
|
210 |
return None, None
|
211 |
|
212 |
-
df = pd.DataFrame(
|
213 |
'id', 'name', 'host_name', 'neighbourhood',
|
214 |
'latitude', 'longitude', 'room_type', 'price',
|
215 |
'number_of_reviews', 'reviews_per_month',
|
216 |
'minimum_nights', 'availability_365'
|
217 |
])
|
218 |
|
219 |
-
# Filter by neighborhood
|
220 |
-
df = df[df['neighbourhood'] == neighborhood].copy()
|
221 |
-
|
222 |
-
# Convert numeric columns
|
223 |
numeric_cols = ['latitude', 'longitude', 'price', 'number_of_reviews',
|
224 |
'minimum_nights', 'availability_365', 'reviews_per_month']
|
225 |
for col in numeric_cols:
|
226 |
df[col] = pd.to_numeric(df[col], errors='coerce')
|
227 |
|
228 |
-
# Apply search-based sorting if query exists
|
229 |
if search_query:
|
230 |
df = self.sort_by_relevance(df, search_query)
|
231 |
|
232 |
if df.empty:
|
233 |
return None, None
|
234 |
|
235 |
-
# Calculate map center
|
236 |
if center_lat is None or center_lng is None:
|
237 |
center_lat = df['latitude'].mean()
|
238 |
center_lng = df['longitude'].mean()
|
239 |
|
240 |
-
# Create map
|
241 |
m = folium.Map(
|
242 |
location=[center_lat, center_lng],
|
243 |
zoom_start=16 if (center_lat is not None and center_lng is not None) else 14,
|
244 |
tiles='OpenStreetMap'
|
245 |
)
|
246 |
|
247 |
-
# Add markers for listings
|
248 |
for idx, row in df.iterrows():
|
249 |
marker_id = f"marker_{row['id']}"
|
|
|
|
|
250 |
|
251 |
-
# Add relevance information to popup if search was performed
|
252 |
relevance_info = ""
|
253 |
if search_query and 'relevance_percentage' in row:
|
254 |
relevance_info = f"""
|
@@ -267,6 +287,11 @@ class AirbnbMapVisualiser:
|
|
267 |
<p style='margin: 5px 0;'><strong>Price:</strong> ${row['price']:.0f}</p>
|
268 |
<p style='margin: 5px 0;'><strong>Reviews:</strong> {row['number_of_reviews']:.0f}</p>
|
269 |
{relevance_info}
|
|
|
|
|
|
|
|
|
|
|
270 |
</div>
|
271 |
"""
|
272 |
|
@@ -282,31 +307,7 @@ class AirbnbMapVisualiser:
|
|
282 |
|
283 |
if selected_id is not None and row['id'] == selected_id:
|
284 |
marker._name = marker_id
|
285 |
-
m.get_root().html.add_child(folium.Element(f"""
|
286 |
-
<script>
|
287 |
-
document.addEventListener('DOMContentLoaded', function() {{
|
288 |
-
setTimeout(function() {{
|
289 |
-
document.querySelector('#{marker_id}').click();
|
290 |
-
}}, 1000);
|
291 |
-
}});
|
292 |
-
</script>
|
293 |
-
"""))
|
294 |
-
|
295 |
-
# Add traffic cameras if requested
|
296 |
-
if show_traffic:
|
297 |
-
traffic_spots = self.get_traffic_spots()
|
298 |
-
for spot in traffic_spots:
|
299 |
-
if spot[1] and spot[2]:
|
300 |
-
popup_content = f"""
|
301 |
-
<div style='min-width: 150px; padding: 10px;'>
|
302 |
-
<p style='margin: 5px 0;'><strong>Traffic Camera ID:</strong> {escape(str(spot[0]))}</p>
|
303 |
-
</div>
|
304 |
-
"""
|
305 |
-
|
306 |
-
folium.Marker(
|
307 |
-
location=[float(spot[1]), float(spot[2])],
|
308 |
-
popup=popup_content,
|
309 |
-
icon=folium.Icon(color='blue', icon='camera'),
|
310 |
-
).add_to(m)
|
311 |
|
|
|
|
|
312 |
return m, df
|
|
|
4 |
from html import escape
|
5 |
from transformers import AutoTokenizer
|
6 |
import numpy as np
|
7 |
+
from TrafficSpot import TrafficSpotManager
|
8 |
|
9 |
|
10 |
class AirbnbMapVisualiser:
|
|
|
14 |
'password': '7033',
|
15 |
'dsn': 'imz409.ust.hk:1521/imz409'
|
16 |
}
|
|
|
17 |
self.tokenizer = AutoTokenizer.from_pretrained('bert-base-uncased')
|
18 |
+
self.traffic_manager = TrafficSpotManager(self.connection_params)
|
19 |
+
|
20 |
try:
|
21 |
+
# Get all neighborhoods first (this is a small query)
|
22 |
+
self.neighborhoods = self.get_all_neighborhoods()
|
23 |
+
# Initialize with Sha Tin listings
|
24 |
+
self.cached_listings = {}
|
25 |
+
self.cached_listings["Sha Tin"] = self.get_neighborhood_listings("Sha Tin")
|
|
|
26 |
except Exception as e:
|
27 |
print(f"Initialization error: {str(e)}")
|
|
|
28 |
self.neighborhoods = []
|
29 |
+
self.cached_listings = {}
|
30 |
+
|
31 |
+
def get_all_neighborhoods(self):
|
32 |
+
try:
|
33 |
+
with oracledb.connect(**self.connection_params) as conn:
|
34 |
+
cursor = conn.cursor()
|
35 |
+
cursor.execute("""
|
36 |
+
SELECT DISTINCT NEIGHBOURHOOD
|
37 |
+
FROM airbnb_master_data
|
38 |
+
WHERE NEIGHBOURHOOD IS NOT NULL
|
39 |
+
ORDER BY NEIGHBOURHOOD
|
40 |
+
""")
|
41 |
+
neighborhoods = [row[0] for row in cursor.fetchall()]
|
42 |
+
return neighborhoods
|
43 |
+
except Exception as e:
|
44 |
+
print(f"Database error getting neighborhoods: {str(e)}")
|
45 |
+
return []
|
46 |
+
|
47 |
+
def get_neighborhood_listings(self, neighborhood):
|
48 |
+
# Check if listings are already cached
|
49 |
+
if neighborhood in self.cached_listings:
|
50 |
+
return self.cached_listings[neighborhood]
|
51 |
+
|
52 |
+
try:
|
53 |
+
with oracledb.connect(**self.connection_params) as conn:
|
54 |
+
cursor = conn.cursor()
|
55 |
+
cursor.execute("""
|
56 |
+
SELECT m.ID, m.NAME, m.HOST_NAME, m.NEIGHBOURHOOD,
|
57 |
+
m.LATITUDE, m.LONGITUDE, m.ROOM_TYPE, m.PRICE,
|
58 |
+
COUNT(r.LISTING_ID) as NUMBER_OF_REVIEWS, m.REVIEWS_PER_MONTH,
|
59 |
+
m.MINIMUM_NIGHTS, m.AVAILABILITY_365
|
60 |
+
FROM airbnb_master_data m
|
61 |
+
LEFT JOIN airbnb_reviews_data r ON m.ID = r.LISTING_ID
|
62 |
+
WHERE m.LATITUDE IS NOT NULL AND m.LONGITUDE IS NOT NULL AND NEIGHBOURHOOD = :neighborhood
|
63 |
+
GROUP BY m.ID, m.NAME, m.HOST_NAME, m.NEIGHBOURHOOD,
|
64 |
+
m.LATITUDE, m.LONGITUDE, m.ROOM_TYPE, m.PRICE,
|
65 |
+
m.REVIEWS_PER_MONTH, m.MINIMUM_NIGHTS, m.AVAILABILITY_365
|
66 |
+
|
67 |
+
""", neighborhood=neighborhood)
|
68 |
+
listings = cursor.fetchall()
|
69 |
+
# Cache the results
|
70 |
+
self.cached_listings[neighborhood] = listings
|
71 |
+
return listings
|
72 |
+
except Exception as e:
|
73 |
+
print(f"Database error: {str(e)}")
|
74 |
+
return []
|
75 |
+
|
76 |
+
def get_listing_reviews(self, listing_id):
|
77 |
+
try:
|
78 |
+
with oracledb.connect(**self.connection_params) as conn:
|
79 |
+
cursor = conn.cursor()
|
80 |
+
cursor.execute("""
|
81 |
+
SELECT REVIEW_DATE, REVIEWER_NAME,
|
82 |
+
CASE
|
83 |
+
WHEN LENGTH(COMMENTS) > 200
|
84 |
+
THEN SUBSTR(COMMENTS, 1, 200) || '...'
|
85 |
+
ELSE COMMENTS
|
86 |
+
END as COMMENTS
|
87 |
+
FROM AIRBNB_REVIEWS_DATA
|
88 |
+
WHERE LISTING_ID = :listing_id
|
89 |
+
ORDER BY REVIEW_DATE DESC
|
90 |
+
""", listing_id=str(listing_id))
|
91 |
+
reviews = cursor.fetchall()
|
92 |
+
|
93 |
+
formatted_reviews = []
|
94 |
+
for review in reviews:
|
95 |
+
review_date, reviewer_name, comments = review
|
96 |
+
formatted_review = (
|
97 |
+
str(review_date) if review_date else '',
|
98 |
+
str(reviewer_name) if reviewer_name else '',
|
99 |
+
str(comments) if comments else ''
|
100 |
+
)
|
101 |
+
formatted_reviews.append(formatted_review)
|
102 |
+
|
103 |
+
return formatted_reviews
|
104 |
+
except oracledb.DatabaseError as e:
|
105 |
+
print(f"Database error fetching reviews: {str(e)}")
|
106 |
+
return []
|
107 |
+
except Exception as e:
|
108 |
+
print(f"Error fetching reviews: {str(e)}")
|
109 |
+
return []
|
110 |
|
111 |
def preprocess_text(self, text):
|
112 |
if not isinstance(text, str):
|
113 |
return ""
|
|
|
|
|
114 |
tokens = self.tokenizer.tokenize(text)
|
115 |
cleaned_text = " ".join(tokens)
|
116 |
return cleaned_text
|
|
|
122 |
def compute_token_based_similarity(self, text1, text2):
|
123 |
tokens1 = set(self.tokenizer.tokenize(text1.lower()))
|
124 |
tokens2 = set(self.tokenizer.tokenize(text2.lower()))
|
|
|
125 |
intersection = len(tokens1.intersection(tokens2))
|
|
|
126 |
normalization_factor = min(len(tokens1), len(tokens2))
|
127 |
|
128 |
if normalization_factor == 0:
|
129 |
return 0
|
130 |
|
131 |
base_similarity = intersection / normalization_factor
|
|
|
132 |
boosted_score = 1 / (1 + np.exp(-6 * (base_similarity - 0.5)))
|
|
|
133 |
return boosted_score
|
134 |
|
135 |
def compute_search_scores(self, df, search_query):
|
|
|
143 |
|
144 |
token_sim = self.compute_token_based_similarity(processed_query, listing_desc)
|
145 |
|
|
|
146 |
exact_match_bonus = 0
|
147 |
partial_match_bonus = 0
|
148 |
|
149 |
for term in query_terms:
|
|
|
150 |
if term in desc_terms:
|
151 |
+
exact_match_bonus += 0.2
|
152 |
|
|
|
153 |
for desc_term in desc_terms:
|
154 |
if term in desc_term or desc_term in term:
|
155 |
+
partial_match_bonus += 0.1
|
156 |
|
|
|
157 |
category_keywords = {
|
158 |
'location': ['near', 'close', 'located', 'area', 'district'],
|
159 |
'amenities': ['wifi', 'kitchen', 'bathroom', 'furnished'],
|
|
|
165 |
for category, keywords in category_keywords.items():
|
166 |
if any(keyword in processed_query.lower() for keyword in keywords):
|
167 |
if any(keyword in listing_desc.lower() for keyword in keywords):
|
168 |
+
category_bonus += 0.15
|
169 |
|
|
|
170 |
final_score = min(1.0, (
|
171 |
+
token_sim * 0.4 +
|
172 |
+
exact_match_bonus * 0.3 +
|
173 |
+
partial_match_bonus * 0.2 +
|
174 |
+
category_bonus * 0.1
|
175 |
))
|
176 |
|
177 |
+
boosted_score = np.power(final_score, 0.7)
|
|
|
178 |
scores.append(boosted_score)
|
179 |
|
180 |
return np.array(scores)
|
|
|
183 |
if not search_query:
|
184 |
return df
|
185 |
|
|
|
186 |
scores = self.compute_search_scores(df, search_query)
|
|
|
|
|
187 |
df['relevance_score'] = scores
|
|
|
|
|
188 |
df['relevance_percentage'] = df['relevance_score'] * 100
|
189 |
|
|
|
190 |
def get_relevance_description(score):
|
191 |
+
if score >= 70:
|
192 |
return "Perfect match"
|
193 |
+
elif score >= 55:
|
194 |
return "Very relevant"
|
195 |
+
elif score >= 40:
|
196 |
return "Relevant"
|
197 |
+
elif score >= 25:
|
198 |
return "Somewhat relevant"
|
199 |
+
elif score >= 15:
|
200 |
return "Slightly relevant"
|
201 |
else:
|
202 |
return "Less relevant"
|
203 |
|
204 |
df['relevance_features'] = df['relevance_percentage'].apply(get_relevance_description)
|
205 |
|
|
|
206 |
def get_matching_features(row):
|
207 |
query_tokens = set(self.tokenizer.tokenize(search_query.lower()))
|
208 |
desc_tokens = set(self.tokenizer.tokenize(self.create_listing_description(row).lower()))
|
|
|
211 |
if not matching_tokens:
|
212 |
return "No direct matches"
|
213 |
|
|
|
214 |
grouped_matches = []
|
215 |
processed_tokens = set()
|
216 |
|
|
|
227 |
return ", ".join(grouped_matches)
|
228 |
|
229 |
df['matching_features'] = df.apply(get_matching_features, axis=1)
|
|
|
|
|
230 |
return df.sort_values('relevance_score', ascending=False)
|
231 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
232 |
def create_map_and_data(self, neighborhood="Sha Tin", show_traffic=True, center_lat=None, center_lng=None,
|
233 |
selected_id=None, search_query=None):
|
234 |
+
listings = self.get_neighborhood_listings(neighborhood)
|
235 |
+
|
236 |
+
if not listings:
|
237 |
return None, None
|
238 |
|
239 |
+
df = pd.DataFrame(listings, columns=[
|
240 |
'id', 'name', 'host_name', 'neighbourhood',
|
241 |
'latitude', 'longitude', 'room_type', 'price',
|
242 |
'number_of_reviews', 'reviews_per_month',
|
243 |
'minimum_nights', 'availability_365'
|
244 |
])
|
245 |
|
|
|
|
|
|
|
|
|
246 |
numeric_cols = ['latitude', 'longitude', 'price', 'number_of_reviews',
|
247 |
'minimum_nights', 'availability_365', 'reviews_per_month']
|
248 |
for col in numeric_cols:
|
249 |
df[col] = pd.to_numeric(df[col], errors='coerce')
|
250 |
|
|
|
251 |
if search_query:
|
252 |
df = self.sort_by_relevance(df, search_query)
|
253 |
|
254 |
if df.empty:
|
255 |
return None, None
|
256 |
|
|
|
257 |
if center_lat is None or center_lng is None:
|
258 |
center_lat = df['latitude'].mean()
|
259 |
center_lng = df['longitude'].mean()
|
260 |
|
|
|
261 |
m = folium.Map(
|
262 |
location=[center_lat, center_lng],
|
263 |
zoom_start=16 if (center_lat is not None and center_lng is not None) else 14,
|
264 |
tiles='OpenStreetMap'
|
265 |
)
|
266 |
|
|
|
267 |
for idx, row in df.iterrows():
|
268 |
marker_id = f"marker_{row['id']}"
|
269 |
+
reviews = self.get_listing_reviews(row['id'])
|
270 |
+
review_button_key = f"review_btn_{row['id']}"
|
271 |
|
|
|
272 |
relevance_info = ""
|
273 |
if search_query and 'relevance_percentage' in row:
|
274 |
relevance_info = f"""
|
|
|
287 |
<p style='margin: 5px 0;'><strong>Price:</strong> ${row['price']:.0f}</p>
|
288 |
<p style='margin: 5px 0;'><strong>Reviews:</strong> {row['number_of_reviews']:.0f}</p>
|
289 |
{relevance_info}
|
290 |
+
<button onclick="streamlit_click('{review_button_key}')"
|
291 |
+
style="background-color: #4CAF50; color: white; padding: 8px 15px; border: none;
|
292 |
+
border-radius: 4px; cursor: pointer; margin-top: 10px;">
|
293 |
+
View Reviews ({len(reviews)})
|
294 |
+
</button>
|
295 |
</div>
|
296 |
"""
|
297 |
|
|
|
307 |
|
308 |
if selected_id is not None and row['id'] == selected_id:
|
309 |
marker._name = marker_id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
310 |
|
311 |
+
if show_traffic:
|
312 |
+
self.traffic_manager.add_spots_to_map(m)
|
313 |
return m, df
|
TrafficSpot.py
ADDED
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import oracledb
|
2 |
+
from html import escape
|
3 |
+
import folium
|
4 |
+
|
5 |
+
class TrafficSpot:
|
6 |
+
def __init__(self, key, latitude, longitude):
|
7 |
+
self.key = key
|
8 |
+
self.latitude = float(latitude) if latitude is not None else None
|
9 |
+
self.longitude = float(longitude) if longitude is not None else None
|
10 |
+
|
11 |
+
def is_valid(self):
|
12 |
+
return self.latitude is not None and self.longitude is not None
|
13 |
+
|
14 |
+
def create_popup_content(self):
|
15 |
+
return f"""
|
16 |
+
<div style='min-width: 150px; padding: 10px;'>
|
17 |
+
<p style='margin: 5px 0;'><strong>Traffic Camera ID:</strong> {escape(str(self.key))}</p>
|
18 |
+
</div>
|
19 |
+
"""
|
20 |
+
|
21 |
+
def add_to_map(self, folium_map):
|
22 |
+
if self.is_valid():
|
23 |
+
folium.Marker(
|
24 |
+
location=[self.latitude, self.longitude],
|
25 |
+
popup=self.create_popup_content(),
|
26 |
+
icon=folium.Icon(color='blue', icon='camera'),
|
27 |
+
).add_to(folium_map)
|
28 |
+
|
29 |
+
|
30 |
+
class TrafficSpotManager:
|
31 |
+
def __init__(self, connection_params):
|
32 |
+
self.connection_params = connection_params
|
33 |
+
self.traffic_spots = []
|
34 |
+
self.load_traffic_spots()
|
35 |
+
|
36 |
+
def load_traffic_spots(self):
|
37 |
+
try:
|
38 |
+
with oracledb.connect(**self.connection_params) as conn:
|
39 |
+
cursor = conn.cursor()
|
40 |
+
cursor.execute("""
|
41 |
+
SELECT KEY, LATITUDE, LONGITUDE
|
42 |
+
FROM TD_TRAFFIC_CAMERA_LOCATION
|
43 |
+
WHERE LATITUDE IS NOT NULL
|
44 |
+
AND LONGITUDE IS NOT NULL
|
45 |
+
""")
|
46 |
+
spots = cursor.fetchall()
|
47 |
+
self.traffic_spots = [
|
48 |
+
TrafficSpot(spot[0], spot[1], spot[2])
|
49 |
+
for spot in spots
|
50 |
+
]
|
51 |
+
except Exception as e:
|
52 |
+
print(f"Database error loading traffic spots: {str(e)}")
|
53 |
+
self.traffic_spots = []
|
54 |
+
|
55 |
+
def add_spots_to_map(self, folium_map):
|
56 |
+
for spot in self.traffic_spots:
|
57 |
+
spot.add_to_map(folium_map)
|
app.py
CHANGED
@@ -4,14 +4,40 @@ from streamlit_folium import st_folium, folium_static
|
|
4 |
import math
|
5 |
from AirbnbMapVisualiser import AirbnbMapVisualiser
|
6 |
|
|
|
7 |
def load_css(css_file):
|
8 |
with open(css_file) as f:
|
9 |
st.markdown(f'<style>{f.read()}</style>', unsafe_allow_html=True)
|
10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
11 |
def main():
|
12 |
st.set_page_config(
|
13 |
layout="wide",
|
14 |
-
page_title="
|
15 |
initial_sidebar_state="expanded"
|
16 |
)
|
17 |
load_css('style.css')
|
@@ -33,19 +59,31 @@ def main():
|
|
33 |
st.session_state.search_query = ""
|
34 |
if 'tokenizer_loaded' not in st.session_state:
|
35 |
st.session_state.tokenizer_loaded = False
|
|
|
|
|
|
|
|
|
|
|
|
|
36 |
|
37 |
# Initialize visualizer with loading message for tokenizer
|
38 |
-
if not st.session_state
|
39 |
with st.spinner('Loading BERT tokenizer...'):
|
40 |
-
visualizer = AirbnbMapVisualiser()
|
41 |
st.session_state.tokenizer_loaded = True
|
42 |
-
|
43 |
-
|
|
|
|
|
|
|
|
|
|
|
44 |
|
45 |
with st.sidebar:
|
46 |
-
st.markdown(
|
|
|
|
|
47 |
|
48 |
-
# Enhanced search bar with placeholder
|
49 |
search_query = st.text_input(
|
50 |
"🔍 Search listings",
|
51 |
value=st.session_state.search_query,
|
@@ -55,9 +93,9 @@ def main():
|
|
55 |
if search_query != st.session_state.search_query:
|
56 |
st.session_state.search_query = search_query
|
57 |
st.session_state.current_page = 1
|
|
|
58 |
|
59 |
-
st.markdown('<hr style="margin: 20px 0; border: none; border-top: 1px solid #e0e0e0;">',
|
60 |
-
unsafe_allow_html=True)
|
61 |
|
62 |
neighborhood = st.selectbox(
|
63 |
"Select Neighborhood",
|
@@ -72,6 +110,7 @@ def main():
|
|
72 |
st.session_state.selected_id = None
|
73 |
st.session_state.current_page = 1
|
74 |
st.session_state.search_query = ""
|
|
|
75 |
st.rerun()
|
76 |
|
77 |
# Create map and get data
|
@@ -92,6 +131,7 @@ def main():
|
|
92 |
st.session_state.center_lat = df.iloc[0]['latitude']
|
93 |
st.session_state.center_lng = df.iloc[0]['longitude']
|
94 |
st.session_state.previous_neighborhood = neighborhood
|
|
|
95 |
st.rerun()
|
96 |
|
97 |
if m is None:
|
@@ -114,16 +154,13 @@ def main():
|
|
114 |
|
115 |
st.markdown('<div class="scrollable-container">', unsafe_allow_html=True)
|
116 |
|
117 |
-
# Enhanced listing display with matching features
|
118 |
for idx in range(start_idx, end_idx):
|
119 |
row = df.iloc[idx]
|
120 |
background_color = "#E3F2FD" if st.session_state.selected_id == row['id'] else "white"
|
121 |
|
122 |
-
# Enhanced relevance info with matching features
|
123 |
relevance_info = ""
|
124 |
if st.session_state.search_query and 'relevance_percentage' in row:
|
125 |
-
relevance_info = f"""<p class="listing-info"> 🎯 Relevance: {row['relevance_percentage']:.0f}% </p>
|
126 |
-
"""
|
127 |
if 'matching_features' in row:
|
128 |
matching_features = row['matching_features']
|
129 |
if matching_features and matching_features != "No direct matches":
|
@@ -138,11 +175,23 @@ def main():
|
|
138 |
{relevance_info}</div>
|
139 |
""", unsafe_allow_html=True)
|
140 |
|
141 |
-
|
142 |
-
|
143 |
-
|
144 |
-
st.
|
145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
146 |
|
147 |
st.markdown('</div>', unsafe_allow_html=True)
|
148 |
|
@@ -166,6 +215,7 @@ def main():
|
|
166 |
st.session_state.selected_id = df.iloc[new_start_idx]['id']
|
167 |
st.session_state.center_lat = df.iloc[new_start_idx]['latitude']
|
168 |
st.session_state.center_lng = df.iloc[new_start_idx]['longitude']
|
|
|
169 |
st.rerun()
|
170 |
|
171 |
with col_prev:
|
@@ -176,6 +226,7 @@ def main():
|
|
176 |
st.session_state.selected_id = df.iloc[new_start_idx]['id']
|
177 |
st.session_state.center_lat = df.iloc[new_start_idx]['latitude']
|
178 |
st.session_state.center_lng = df.iloc[new_start_idx]['longitude']
|
|
|
179 |
st.rerun()
|
180 |
|
181 |
with col_next:
|
@@ -186,7 +237,13 @@ def main():
|
|
186 |
st.session_state.selected_id = df.iloc[new_start_idx]['id']
|
187 |
st.session_state.center_lat = df.iloc[new_start_idx]['latitude']
|
188 |
st.session_state.center_lng = df.iloc[new_start_idx]['longitude']
|
|
|
189 |
st.rerun()
|
190 |
|
|
|
|
|
|
|
|
|
|
|
191 |
if __name__ == "__main__":
|
192 |
main()
|
|
|
4 |
import math
|
5 |
from AirbnbMapVisualiser import AirbnbMapVisualiser
|
6 |
|
7 |
+
|
8 |
def load_css(css_file):
|
9 |
with open(css_file) as f:
|
10 |
st.markdown(f'<style>{f.read()}</style>', unsafe_allow_html=True)
|
11 |
|
12 |
+
|
13 |
+
def render_review_dialog():
|
14 |
+
# Display reviews
|
15 |
+
with st.container():
|
16 |
+
col_title = st.columns([5, 1])
|
17 |
+
with col_title[0]:
|
18 |
+
st.markdown(f"### Reviews for {st.session_state.current_review_listing_name}")
|
19 |
+
reviews = st.session_state.visualizer.get_listing_reviews(st.session_state.current_review_listing)
|
20 |
+
if reviews:
|
21 |
+
for review in reviews:
|
22 |
+
try:
|
23 |
+
review_date, reviewer_name, comments = review
|
24 |
+
st.markdown(f"""
|
25 |
+
<div class="review-card">
|
26 |
+
<div class="review-header">
|
27 |
+
{escape(str(reviewer_name))} - {escape(str(review_date))}
|
28 |
+
</div>
|
29 |
+
<div class="review-content">
|
30 |
+
{escape(str(comments))}
|
31 |
+
</div>
|
32 |
+
</div>""", unsafe_allow_html=True)
|
33 |
+
except Exception as e:
|
34 |
+
st.error(f"Error displaying review: {str(e)}")
|
35 |
+
else:
|
36 |
+
st.info("No reviews available for this listing.")
|
37 |
def main():
|
38 |
st.set_page_config(
|
39 |
layout="wide",
|
40 |
+
page_title="HKUST AirBNB+",
|
41 |
initial_sidebar_state="expanded"
|
42 |
)
|
43 |
load_css('style.css')
|
|
|
59 |
st.session_state.search_query = ""
|
60 |
if 'tokenizer_loaded' not in st.session_state:
|
61 |
st.session_state.tokenizer_loaded = False
|
62 |
+
if 'show_review_dialog' not in st.session_state:
|
63 |
+
st.session_state.show_review_dialog = False
|
64 |
+
if 'current_review_listing' not in st.session_state:
|
65 |
+
st.session_state.current_review_listing = None
|
66 |
+
if 'current_review_listing_name' not in st.session_state:
|
67 |
+
st.session_state.current_review_listing_name = None
|
68 |
|
69 |
# Initialize visualizer with loading message for tokenizer
|
70 |
+
if 'visualizer' not in st.session_state:
|
71 |
with st.spinner('Loading BERT tokenizer...'):
|
72 |
+
st.session_state.visualizer = AirbnbMapVisualiser()
|
73 |
st.session_state.tokenizer_loaded = True
|
74 |
+
|
75 |
+
visualizer = st.session_state.visualizer
|
76 |
+
|
77 |
+
# Check if visualizer is properly initialized
|
78 |
+
if visualizer is None or not hasattr(visualizer, 'neighborhoods'):
|
79 |
+
st.error("Error initializing the application. Please refresh the page.")
|
80 |
+
return
|
81 |
|
82 |
with st.sidebar:
|
83 |
+
st.markdown(
|
84 |
+
'<p class="sidebar-header">HKUST AirBNB+ (External Hall Scheme for Non Local PG Students) <BR/></p>',
|
85 |
+
unsafe_allow_html=True)
|
86 |
|
|
|
87 |
search_query = st.text_input(
|
88 |
"🔍 Search listings",
|
89 |
value=st.session_state.search_query,
|
|
|
93 |
if search_query != st.session_state.search_query:
|
94 |
st.session_state.search_query = search_query
|
95 |
st.session_state.current_page = 1
|
96 |
+
st.session_state.show_review_dialog = False
|
97 |
|
98 |
+
st.markdown('<hr style="margin: 20px 0; border: none; border-top: 1px solid #e0e0e0;">', unsafe_allow_html=True)
|
|
|
99 |
|
100 |
neighborhood = st.selectbox(
|
101 |
"Select Neighborhood",
|
|
|
110 |
st.session_state.selected_id = None
|
111 |
st.session_state.current_page = 1
|
112 |
st.session_state.search_query = ""
|
113 |
+
st.session_state.show_review_dialog = False
|
114 |
st.rerun()
|
115 |
|
116 |
# Create map and get data
|
|
|
131 |
st.session_state.center_lat = df.iloc[0]['latitude']
|
132 |
st.session_state.center_lng = df.iloc[0]['longitude']
|
133 |
st.session_state.previous_neighborhood = neighborhood
|
134 |
+
st.session_state.show_review_dialog = False
|
135 |
st.rerun()
|
136 |
|
137 |
if m is None:
|
|
|
154 |
|
155 |
st.markdown('<div class="scrollable-container">', unsafe_allow_html=True)
|
156 |
|
|
|
157 |
for idx in range(start_idx, end_idx):
|
158 |
row = df.iloc[idx]
|
159 |
background_color = "#E3F2FD" if st.session_state.selected_id == row['id'] else "white"
|
160 |
|
|
|
161 |
relevance_info = ""
|
162 |
if st.session_state.search_query and 'relevance_percentage' in row:
|
163 |
+
relevance_info = f"""<p class="listing-info"> 🎯 Relevance: {row['relevance_percentage']:.0f}% </p>"""
|
|
|
164 |
if 'matching_features' in row:
|
165 |
matching_features = row['matching_features']
|
166 |
if matching_features and matching_features != "No direct matches":
|
|
|
175 |
{relevance_info}</div>
|
176 |
""", unsafe_allow_html=True)
|
177 |
|
178 |
+
col_details, col_reviews = st.columns(2)
|
179 |
+
|
180 |
+
with col_details:
|
181 |
+
if st.button("View Details", key=f"btn_{row['id']}"):
|
182 |
+
st.session_state.selected_id = row['id']
|
183 |
+
st.session_state.center_lat = row['latitude']
|
184 |
+
st.session_state.center_lng = row['longitude']
|
185 |
+
st.rerun()
|
186 |
+
|
187 |
+
with col_reviews:
|
188 |
+
if st.button("View Reviews", key=f"review_btn_{row['id']}"):
|
189 |
+
st.session_state.show_review_dialog = True
|
190 |
+
st.session_state.current_review_listing = row['id']
|
191 |
+
st.session_state.current_review_listing_name = row['name']
|
192 |
+
st.session_state.scroll_to_review = True
|
193 |
+
st.rerun()
|
194 |
+
|
195 |
|
196 |
st.markdown('</div>', unsafe_allow_html=True)
|
197 |
|
|
|
215 |
st.session_state.selected_id = df.iloc[new_start_idx]['id']
|
216 |
st.session_state.center_lat = df.iloc[new_start_idx]['latitude']
|
217 |
st.session_state.center_lng = df.iloc[new_start_idx]['longitude']
|
218 |
+
st.session_state.show_review_dialog = False
|
219 |
st.rerun()
|
220 |
|
221 |
with col_prev:
|
|
|
226 |
st.session_state.selected_id = df.iloc[new_start_idx]['id']
|
227 |
st.session_state.center_lat = df.iloc[new_start_idx]['latitude']
|
228 |
st.session_state.center_lng = df.iloc[new_start_idx]['longitude']
|
229 |
+
st.session_state.show_review_dialog = False
|
230 |
st.rerun()
|
231 |
|
232 |
with col_next:
|
|
|
237 |
st.session_state.selected_id = df.iloc[new_start_idx]['id']
|
238 |
st.session_state.center_lat = df.iloc[new_start_idx]['latitude']
|
239 |
st.session_state.center_lng = df.iloc[new_start_idx]['longitude']
|
240 |
+
st.session_state.show_review_dialog = False
|
241 |
st.rerun()
|
242 |
|
243 |
+
# Show review dialog if active
|
244 |
+
if st.session_state.show_review_dialog:
|
245 |
+
render_review_dialog()
|
246 |
+
|
247 |
+
|
248 |
if __name__ == "__main__":
|
249 |
main()
|
style.css
CHANGED
@@ -227,4 +227,187 @@
|
|
227 |
margin: 16px 0;
|
228 |
border-radius: 4px;
|
229 |
color: #C62828;
|
230 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
227 |
margin: 16px 0;
|
228 |
border-radius: 4px;
|
229 |
color: #C62828;
|
230 |
+
}
|
231 |
+
|
232 |
+
.review-card {
|
233 |
+
background: white;
|
234 |
+
border: 1px solid #e0e0e0;
|
235 |
+
border-radius: 8px;
|
236 |
+
padding: 16px;
|
237 |
+
margin-bottom: 16px;
|
238 |
+
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
239 |
+
}
|
240 |
+
|
241 |
+
.review-header {
|
242 |
+
display: flex;
|
243 |
+
justify-content: space-between;
|
244 |
+
align-items: center;
|
245 |
+
margin-bottom: 12px;
|
246 |
+
}
|
247 |
+
|
248 |
+
.reviewer-name {
|
249 |
+
font-weight: bold;
|
250 |
+
color: #1a73e8;
|
251 |
+
}
|
252 |
+
|
253 |
+
.review-date {
|
254 |
+
color: #666;
|
255 |
+
font-size: 0.9em;
|
256 |
+
}
|
257 |
+
|
258 |
+
.review-content {
|
259 |
+
line-height: 1.6;
|
260 |
+
color: #333;
|
261 |
+
}
|
262 |
+
|
263 |
+
.relevance-indicator {
|
264 |
+
font-size: 0.9em;
|
265 |
+
font-weight: 500;
|
266 |
+
padding: 4px 8px;
|
267 |
+
border-radius: 4px;
|
268 |
+
background: rgba(0,0,0,0.05);
|
269 |
+
display: inline-block;
|
270 |
+
}
|
271 |
+
|
272 |
+
.review-search-container {
|
273 |
+
margin-bottom: 20px;
|
274 |
+
}
|
275 |
+
|
276 |
+
.review-filters {
|
277 |
+
display: flex;
|
278 |
+
gap: 16px;
|
279 |
+
margin-bottom: 16px;
|
280 |
+
}
|
281 |
+
|
282 |
+
.scrollable-reviews {
|
283 |
+
max-height: 600px;
|
284 |
+
overflow-y: auto;
|
285 |
+
padding-right: 16px;
|
286 |
+
}
|
287 |
+
|
288 |
+
.listing-card {
|
289 |
+
padding: 15px;
|
290 |
+
margin-bottom: 15px;
|
291 |
+
border-radius: 8px;
|
292 |
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
293 |
+
}
|
294 |
+
|
295 |
+
.listing-title {
|
296 |
+
margin: 0 0 10px 0;
|
297 |
+
font-size: 1.1em;
|
298 |
+
font-weight: bold;
|
299 |
+
}
|
300 |
+
|
301 |
+
.listing-info {
|
302 |
+
margin: 5px 0;
|
303 |
+
font-size: 0.9em;
|
304 |
+
}
|
305 |
+
|
306 |
+
.sidebar-header {
|
307 |
+
font-size: 1.2em;
|
308 |
+
font-weight: bold;
|
309 |
+
margin-bottom: 20px;
|
310 |
+
}
|
311 |
+
|
312 |
+
.map-container {
|
313 |
+
border-radius: 10px;
|
314 |
+
overflow: hidden;
|
315 |
+
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
316 |
+
}
|
317 |
+
|
318 |
+
.scrollable-container {
|
319 |
+
max-height: calc(100vh - 100px);
|
320 |
+
overflow-y: auto;
|
321 |
+
padding-right: 10px;
|
322 |
+
}
|
323 |
+
|
324 |
+
.listing-card {
|
325 |
+
padding: 15px;
|
326 |
+
margin-bottom: 15px;
|
327 |
+
border-radius: 8px;
|
328 |
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
329 |
+
transition: background-color 0.3s ease;
|
330 |
+
}
|
331 |
+
|
332 |
+
.listing-title {
|
333 |
+
margin: 0 0 10px 0;
|
334 |
+
font-size: 1.1em;
|
335 |
+
font-weight: bold;
|
336 |
+
color: #2c3e50;
|
337 |
+
}
|
338 |
+
|
339 |
+
.listing-info {
|
340 |
+
margin: 5px 0;
|
341 |
+
font-size: 0.9em;
|
342 |
+
color: #34495e;
|
343 |
+
}
|
344 |
+
|
345 |
+
/* Dialog styling */
|
346 |
+
.dialog-content {
|
347 |
+
background-color: white;
|
348 |
+
padding: 20px;
|
349 |
+
border-radius: 10px;
|
350 |
+
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
|
351 |
+
margin: 20px 0;
|
352 |
+
max-height: 80vh;
|
353 |
+
overflow-y: auto;
|
354 |
+
}
|
355 |
+
|
356 |
+
.review-card {
|
357 |
+
background-color: #f8f9fa;
|
358 |
+
border-left: 4px solid #4CAF50;
|
359 |
+
padding: 15px;
|
360 |
+
margin: 10px 0;
|
361 |
+
border-radius: 4px;
|
362 |
+
}
|
363 |
+
|
364 |
+
.review-header {
|
365 |
+
font-weight: bold;
|
366 |
+
color: #2c3e50;
|
367 |
+
margin-bottom: 8px;
|
368 |
+
}
|
369 |
+
|
370 |
+
.review-content {
|
371 |
+
color: #34495e;
|
372 |
+
line-height: 1.6;
|
373 |
+
font-size: 0.95em;
|
374 |
+
}
|
375 |
+
|
376 |
+
/* Button styling */
|
377 |
+
.stButton button {
|
378 |
+
width: 100%;
|
379 |
+
border-radius: 4px;
|
380 |
+
transition: all 0.3s ease;
|
381 |
+
}
|
382 |
+
|
383 |
+
.stButton button:hover {
|
384 |
+
transform: translateY(-1px);
|
385 |
+
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
386 |
+
}
|
387 |
+
|
388 |
+
/* Pagination styling */
|
389 |
+
.pagination {
|
390 |
+
display: flex;
|
391 |
+
justify-content: center;
|
392 |
+
align-items: center;
|
393 |
+
margin-top: 20px;
|
394 |
+
}
|
395 |
+
|
396 |
+
/* Custom scrollbar */
|
397 |
+
::-webkit-scrollbar {
|
398 |
+
width: 8px;
|
399 |
+
}
|
400 |
+
|
401 |
+
::-webkit-scrollbar-track {
|
402 |
+
background: #f1f1f1;
|
403 |
+
border-radius: 4px;
|
404 |
+
}
|
405 |
+
|
406 |
+
::-webkit-scrollbar-thumb {
|
407 |
+
background: #888;
|
408 |
+
border-radius: 4px;
|
409 |
+
}
|
410 |
+
|
411 |
+
::-webkit-scrollbar-thumb:hover {
|
412 |
+
background: #555;
|
413 |
+
}
|