| """Roam Service — Streamlit App with dark theme and big fonts.""" |
|
|
| from pathlib import Path |
| from dotenv import load_dotenv |
| load_dotenv(dotenv_path=Path(__file__).resolve().parent.parent / ".env", override=True) |
|
|
| import streamlit as st |
| import json |
| import logging |
| import folium |
| from streamlit_folium import st_folium |
|
|
| from styles.dark_theme import apply_dark_theme, EMOJI_MAP |
| from services.recommender import get_recommendations_cached, translate_items_cached |
|
|
| |
| CITY_SUGGESTIONS = [ |
| "Amsterdam", "Athens", "Bali", |
| "Bangkok", "Barcelona", "Beijing", "Berlin", |
| "Budapest", "Buenos Aires", |
| "Cairo", "Cape Town", "Chicago", |
| "Copenhagen", "Delhi", |
| "Dubai", "Dublin", "Edinburgh", |
| "Florence", "Hanoi", "Hong Kong", |
| "Honolulu", "Istanbul", |
| "Kuala Lumpur", "Kyoto", |
| "Las Vegas", "Lisbon", "London", |
| "Los Angeles", "Madrid", "Marrakech", |
| "Melbourne", "Mexico City", "Miami", |
| "Milan", "Montreal", "Moscow", |
| "Mumbai", "New York", "Osaka", |
| "Oslo", "Paris", "Prague", |
| "Reykjavik", "Rio de Janeiro", "Rome", |
| "San Francisco", "Santiago", "Seoul", |
| "Shanghai", "Singapore", "Stockholm", |
| "Sydney", "Taipei", "Tel Aviv", |
| "Tokyo", "Toronto", "Vancouver", |
| "Venice", "Vienna", "Warsaw", |
| "Washington", |
| ] |
|
|
|
|
| |
| st.set_page_config( |
| page_title="Roamify", |
| page_icon="✈️", |
| layout="wide", |
| initial_sidebar_state="collapsed", |
| ) |
|
|
| |
| apply_dark_theme() |
|
|
| |
| st.title("✈️ Roamify") |
| st.markdown( |
| '<div style="font-size:15px; color:#888; margin-top:-10px; margin-bottom:18px;">Designed by Joe, powered by Hermes Agent · 2026</div>', |
| unsafe_allow_html=True, |
| ) |
|
|
| |
| CATEGORIES = [ |
| ("Landmark", "🗼"), |
| ("Culture", "🏛️"), |
| ("Nature", "🌿"), |
| ("Gems", "💎"), |
| ("Photo", "📸"), |
| ("Food", "🍽️"), |
| ("Shopping", "🛍️"), |
| ] |
| CATEGORY_LABELS = [f"{emoji} {name}" for name, emoji in CATEGORIES] |
|
|
| LANG_OPTIONS = { |
| "None (English only)": None, |
| "繁體中文 (Traditional Chinese)": "Traditional Chinese", |
| "简体中文 (Simplified Chinese)": "Simplified Chinese", |
| "日本語 (Japanese)": "Japanese", |
| "한국어 (Korean)": "Korean", |
| "Français (French)": "French", |
| "Español (Spanish)": "Spanish", |
| "Deutsch (German)": "German", |
| } |
|
|
| |
| |
| if "cat_idx" not in st.session_state: |
| st.session_state.cat_idx = 0 |
|
|
| st.markdown('<div id="search-controls"></div>', unsafe_allow_html=True) |
|
|
| col_city, col_cat, col_num, col_lang, col_search, col_refresh = st.columns([13, 50, 8, 15, 7, 7], gap="small") |
|
|
| with col_city: |
| city = st.selectbox("City", CITY_SUGGESTIONS, index=CITY_SUGGESTIONS.index("Tokyo")) |
|
|
| with col_cat: |
| selected_category = st.radio( |
| label="Category", |
| options=range(len(CATEGORIES)), |
| format_func=lambda i: CATEGORY_LABELS[i], |
| horizontal=True, |
| key="cat_idx", |
| ) |
|
|
| with col_num: |
| num_attractions = st.selectbox("Picks", [3, 6, 9, 12, 15], index=1) |
|
|
| with col_lang: |
| selected_lang = st.selectbox("Translation", list(LANG_OPTIONS.keys()), index=0) |
| second_language = LANG_OPTIONS[selected_lang] |
|
|
| with col_search: |
| search = st.button("🔍", use_container_width=True, key="search_btn") |
|
|
| with col_refresh: |
| refresh = st.button("🔄", use_container_width=True, key="refresh_btn") |
|
|
| |
| if "refresh_mode" not in st.session_state: |
| st.session_state.refresh_mode = False |
|
|
| if refresh: |
| st.session_state.refresh_mode = True |
|
|
| |
| if search or refresh: |
| |
| categories = {name: (i == selected_category) for i, (name, _) in enumerate(CATEGORIES)} |
| if not city.strip(): |
| st.error("Please enter a city!") |
| else: |
| st.session_state["do_search"] = True |
| st.session_state["search_params"] = { |
| "city": city.strip(), |
| "num_attractions": num_attractions, |
| "second_language": second_language, |
| "categories": categories, |
| } |
|
|
|
|
| def _short_name(text: str, max_len: int = 22) -> str: |
| """Truncate name to fit one line in the card summary.""" |
| if len(text) <= max_len: |
| return text |
| return text[:max_len].rstrip() + "…" |
|
|
|
|
| def _render_cards(items: list[dict], translated: bool = False) -> None: |
| """Render items as a 3-column grid of expandable cards with uniform heights per row.""" |
| COLS = 3 |
|
|
| |
| rows_data = [] |
| for row_start in range(0, len(items), COLS): |
| rows_data.append(items[row_start:row_start + COLS]) |
|
|
| |
| CHARS_PER_LINE = 30 |
|
|
| for row_idx, row_items in enumerate(rows_data): |
| |
| descs = [] |
| for item in row_items: |
| d = item.get("description_local" if translated and item.get("description_local") else "description", "") |
| descs.append(d) |
|
|
| max_desc_lines = max((len(d) + CHARS_PER_LINE - 1) // CHARS_PER_LINE for d in descs) if descs else 1 |
|
|
| |
| cols = st.columns(COLS, gap="small") |
| for col_idx, item in enumerate(row_items): |
| i = row_idx * COLS + col_idx + 1 |
| name = item.get("name", "Unknown") |
| description = item.get("description", "") |
| name_local = item.get("name_local", "") |
| description_local = item.get("description_local", "") |
|
|
| label = f"**{i}. {_short_name(name)}**" |
| if translated and name_local: |
| label += f" **— {_short_name(name_local)}**" |
|
|
| |
| actual_desc = description_local if translated and description_local else description |
| desc_lines = (len(actual_desc) + CHARS_PER_LINE - 1) // CHARS_PER_LINE |
| desc_padding = "<br>" * (max_desc_lines - desc_lines) |
|
|
| with cols[col_idx]: |
| expand_by_default = (len(items) <= 6) or (i <= 3) |
| |
| st.markdown(f'<div class="card-pin" data-card-idx="{i}" style="display:none;"></div>', unsafe_allow_html=True) |
| with st.expander(label, expanded=expand_by_default): |
| image_url = item.get("image_url", "") |
| if image_url: |
| st.markdown( |
| f'<div style="width:100%;aspect-ratio:16/9;overflow:hidden;' |
| f'border-radius:8px;background:#1c2333;margin-bottom:12px;">' |
| f'<img src="{image_url}" style="width:100%;height:100%;' |
| f'object-fit:cover;object-position:center;display:block;" ' |
| f'loading="lazy" alt="{name}" class="card-img"/>' |
| f'</div>', |
| unsafe_allow_html=True, |
| ) |
| else: |
| st.markdown( |
| '<div style="display:flex;align-items:center;justify-content:center;' |
| 'width:100%;aspect-ratio:16/9;background:#111;border-radius:8px;font-size:48px;' |
| 'margin-bottom:12px;">' |
| '🏛️</div>', |
| unsafe_allow_html=True, |
| ) |
| |
| st.markdown(f'<div class="card-desc">{actual_desc}{desc_padding}</div>', unsafe_allow_html=True) |
|
|
|
|
| def _build_map(items: list[dict]) -> folium.Map: |
| """Build a folium map with true spider legs: overlapping numbered circles |
| fan out radially from their cluster centroid with straight leader lines |
| connecting back to small dots at the true locations.""" |
|
|
| valid_coords = [ |
| (float(item["latitude"]), float(item["longitude"])) |
| for item in items |
| if item.get("latitude") is not None and item.get("longitude") is not None |
| and str(item.get("latitude", "")).strip() != "" |
| and str(item.get("longitude", "")).strip() != "" |
| ] |
| if valid_coords: |
| center_lat = sum(c[0] for c in valid_coords) / len(valid_coords) |
| center_lon = sum(c[1] for c in valid_coords) / len(valid_coords) |
| else: |
| center_lat, center_lon = 48.8566, 2.3522 |
|
|
| m = folium.Map( |
| location=[center_lat, center_lon], |
| tiles="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", |
| attr="© <a href='https://carto.com/'>CARTO</a>", |
| name="CartoDB dark", |
| zoom_control=False, |
| ) |
|
|
| |
| m.get_root().html.add_child(folium.Element( |
| '<style>.leaflet-control-attribution{display:none!important}</style>' |
| )) |
|
|
| marker_coords = [] |
|
|
| for i, item in enumerate(items, 1): |
| try: |
| lat = float(item.get("latitude", 0)) |
| lon = float(item.get("longitude", 0)) |
| except (ValueError, TypeError): |
| continue |
| if lat == 0 and lon == 0: |
| continue |
|
|
| name = item.get("name", "Unknown") |
| name_local = item.get("name_local", "") |
| tip = item.get("tip_local", "") or item.get("tip", "") |
|
|
| |
| lines = [f"<div style='color:#2a9fd6; font-size:16px; font-weight:bold'>{i}. {name}</div>"] |
| if name_local: |
| lines.append(f"<div style='color:#aaa; font-size:13px'>{name_local}</div>") |
| if tip: |
| lines.append(f"<div style='font-size:15px; margin-top:6px'>💡 {tip}</div>") |
| popup_html = "".join(lines) |
|
|
| marker_coords.append([lat, lon]) |
|
|
| |
| folium.CircleMarker( |
| location=[lat, lon], |
| radius=4, |
| color="#2a9fd6", |
| fill=True, |
| fill_color="#2a9fd6", |
| fill_opacity=0.9, |
| weight=1, |
| ).add_to(m) |
|
|
| |
| folium.Marker( |
| location=[lat, lon], |
| popup=folium.Popup(popup_html, max_width=260, offset=(0, -25)), |
| icon=folium.DivIcon( |
| html=( |
| f'<div class="spider-marker" data-idx="{i}" data-lat="{lat}" data-lng="{lon}" style="' |
| f'display:flex;align-items:center;justify-content:center;' |
| f'width:36px;height:36px;border-radius:50%;' |
| f'background:#2a9fd6;color:#fff;font-size:18px;font-weight:700;' |
| f'box-shadow:0 2px 6px rgba(0,0,0,0.5);' |
| f'cursor:pointer;">' |
| f'{i}</div>' |
| ), |
| icon_size=(36, 36), |
| icon_anchor=(18, 18), |
| ), |
| ).add_to(m) |
|
|
| |
| if marker_coords: |
| m.fit_bounds(marker_coords, padding=(30, 30)) |
|
|
| |
| spider_js = """<script> |
| (function(){ |
| var MIN_DIST=48, LEG_LENGTH=44, svgEl=null; |
| function findMap(){for(var k in window){try{if(window[k] instanceof L.Map)return window[k]}catch(e){}}return null} |
| function ensureSvg(m){ |
| if(svgEl)return svgEl; |
| var c=m.getContainer(); |
| svgEl=document.createElementNS('http://www.w3.org/2000/svg','svg'); |
| svgEl.style.cssText='position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:450;'; |
| c.appendChild(svgEl);return svgEl; |
| } |
| function run(){ |
| var map=findMap();if(!map)return; |
| var svg=ensureSvg(map); |
| var els=document.querySelectorAll('.spider-marker'); |
| if(!els.length)return; |
| var pts=[]; |
| els.forEach(function(el){ |
| var lat=parseFloat(el.getAttribute('data-lat')),lng=parseFloat(el.getAttribute('data-lng')); |
| var cp=map.latLngToContainerPoint([lat,lng]); |
| pts.push({el:el,x:cp.x,y:cp.y,ox:cp.x,oy:cp.y,idx:parseInt(el.getAttribute('data-idx'))}); |
| }); |
| |
| // Reset all positions |
| pts.forEach(function(p){p.x=p.ox;p.y=p.oy;p.el.style.transform=''}); |
| |
| // Find clusters (groups of markers within MIN_DIST of each other) |
| var clusters=[], assigned={}; |
| for(var i=0;i<pts.length;i++){ |
| if(assigned[i])continue; |
| var cluster=[i]; assigned[i]=true; |
| for(var j=i+1;j<pts.length;j++){ |
| if(assigned[j])continue; |
| for(var k=0;k<cluster.length;k++){ |
| var ci=cluster[k]; |
| var dx=pts[j].x-pts[ci].x, dy=pts[j].y-pts[ci].y; |
| if(Math.sqrt(dx*dx+dy*dy)<MIN_DIST){cluster.push(j);assigned[j]=true;break;} |
| } |
| } |
| if(cluster.length>1)clusters.push(cluster); |
| } |
| |
| // Clear old lines |
| svg.querySelectorAll('line').forEach(function(l){l.remove()}); |
| |
| // For each cluster: compute centroid, fan out radially |
| clusters.forEach(function(cidxs){ |
| var cx=0,cy=0; |
| cidxs.forEach(function(i){cx+=pts[i].ox;cy+=pts[i].oy;}); |
| cx/=cidxs.length;cy/=cidxs.length; |
| |
| var n=cidxs.length; |
| var startAngle=0; |
| |
| cidxs.forEach(function(i,k){ |
| var angle=startAngle+(k*2*Math.PI/n); |
| var tx=cx+Math.cos(angle)*LEG_LENGTH; |
| var ty=cy+Math.sin(angle)*LEG_LENGTH; |
| |
| var ox=tx-pts[i].ox, oy=ty-pts[i].oy; |
| pts[i].x=tx;pts[i].y=ty; |
| pts[i].el.style.transform='translate('+ox+'px,'+oy+'px)'; |
| |
| var line=document.createElementNS('http://www.w3.org/2000/svg','line'); |
| line.setAttribute('x1',pts[i].ox);line.setAttribute('y1',pts[i].oy); |
| line.setAttribute('x2',tx);line.setAttribute('y2',ty); |
| line.setAttribute('stroke','#2a9fd6'); |
| line.setAttribute('stroke-width','1.5'); |
| line.setAttribute('stroke-opacity','0.7'); |
| svg.appendChild(line); |
| }); |
| }); |
| } |
| function init(){ |
| var m=findMap();if(!m){setTimeout(init,200);return} |
| m.on('moveend',run);m.on('zoomend',run);setTimeout(run,300); |
| } |
| if(document.readyState==='complete')init();else window.addEventListener('load',init); |
| })(); |
| </script>""" |
| m.get_root().html.add_child(folium.Element(spider_js)) |
|
|
| return m |
|
|
|
|
| |
| if st.session_state.get("do_search"): |
| params = st.session_state["search_params"] |
| sec_lang = params.get("second_language") |
| temperature = 0.7 if st.session_state.pop("refresh_mode", False) else 0 |
|
|
| try: |
| with st.spinner(f"Finding recommendations in {params['city']}..."): |
| attractions = get_recommendations_cached( |
| city=params["city"], |
| num_attractions=params["num_attractions"], |
| categories=params.get("categories"), |
| temperature=temperature, |
| ) |
|
|
| if attractions is None: |
| st.error("Failed to get recommendations. The AI response couldn't be parsed. Please try again.") |
| st.stop() |
|
|
| |
| st.session_state["last_attractions"] = attractions |
|
|
| if sec_lang: |
| with st.spinner(f"Translating into {sec_lang}..."): |
| |
| full_attractions = get_recommendations_cached( |
| city=params["city"], |
| num_attractions=19, |
| categories=params.get("categories"), |
| temperature=temperature, |
| ) |
| if full_attractions: |
| translated = translate_items_cached( |
| items=full_attractions, |
| second_language=sec_lang, |
| city=params["city"], |
| categories=params.get("categories"), |
| ) |
| num = len(attractions) |
| attractions = translated[:num] |
| st.session_state["last_attractions"] = attractions |
|
|
| except RuntimeError as e: |
| st.error(f"⚠️ {e}") |
| st.stop() |
| except Exception as e: |
| st.error(f"Something went wrong: {e}") |
| st.stop() |
|
|
| |
| left_col, right_col = st.columns([1, 1]) |
|
|
| with left_col: |
| st.subheader(f"{EMOJI_MAP['attractions']} Recommendations") |
| with st.container(height=800, border=False): |
| _render_cards(attractions, translated=bool(sec_lang)) |
|
|
| with right_col: |
| st.subheader("🗺️ Map") |
| st.markdown('<div style="margin-bottom:10px;"></div>', unsafe_allow_html=True) |
| m = _build_map(attractions) |
| st_folium(m, width="100%", height=800, returned_objects=[]) |
|
|
| elif st.session_state.get("last_attractions"): |
| |
| attractions = st.session_state["last_attractions"] |
| left_col, right_col = st.columns([1, 1]) |
|
|
| with left_col: |
| st.subheader(f"{EMOJI_MAP['attractions']} Recommendations") |
| with st.container(height=800, border=False): |
| _render_cards(attractions, translated=False) |
|
|
| with right_col: |
| st.subheader("🗺️ Map") |
| st.markdown('<div style="margin-bottom:10px;"></div>', unsafe_allow_html=True) |
| m = _build_map(attractions) |
| st_folium(m, width="100%", height=800, returned_objects=[]) |
|
|
| else: |
| |
| import re |
| hero_html = """<div class="hero-section" style="display:flex;flex-direction:column;align-items:center;justify-content:center; |
| min-height:600px;padding:80px 20px 40px;text-align:center;"> |
| <div class="hero-icon" style="font-size:120px;margin-bottom:16px;line-height:1;">🧳</div> |
| <div class="hero-title" style="font-size:42px;font-weight:700;color:#dee2e6;margin-bottom:8px;"> |
| Where to next? |
| </div> |
| <div class="hero-subtitle" style="font-size:24px;color:#888;max-width:600px;margin-bottom:32px;line-height:1.6;"> |
| Choose a city, tell us what you love, and get tailored recommendations. |
| </div> |
| <div style="display:flex;gap:24px;flex-wrap:wrap;justify-content:center;margin-bottom:40px;"> |
| <div class="hero-card" style="background:#1c2333;border-radius:14px;padding:24px 28px;width:190px;border:1px solid #2a2f3a;"> |
| <div style="font-size:48px;margin-bottom:10px;">🗼</div> |
| <div style="font-weight:600;color:#dee2e6;font-size:20px;">Landmarks</div> |
| <div style="font-size:16px;color:#666;margin-top:6px;">Colosseum, Taj Mahal, Big Ben</div> |
| </div> |
| <div class="hero-card" style="background:#1c2333;border-radius:14px;padding:24px 28px;width:190px;border:1px solid #2a2f3a;"> |
| <div style="font-size:48px;margin-bottom:10px;">🏛️</div> |
| <div style="font-weight:600;color:#dee2e6;font-size:20px;">Culture</div> |
| <div style="font-size:16px;color:#666;margin-top:6px;">Louvre, British Museum, Uffizi</div> |
| </div> |
| <div class="hero-card" style="background:#1c2333;border-radius:14px;padding:24px 28px;width:190px;border:1px solid #2a2f3a;"> |
| <div style="font-size:48px;margin-bottom:10px;">🍽️</div> |
| <div style="font-weight:600;color:#dee2e6;font-size:20px;">Food</div> |
| <div style="font-size:16px;color:#666;margin-top:6px;">Pizza, Ramen, In-N-Out Burgers</div> |
| </div> |
| <div class="hero-card" style="background:#1c2333;border-radius:14px;padding:24px 28px;width:190px;border:1px solid #2a2f3a;"> |
| <div style="font-size:48px;margin-bottom:10px;">🛍️</div> |
| <div style="font-weight:600;color:#dee2e6;font-size:20px;">Shopping</div> |
| <div style="font-size:16px;color:#666;margin-top:6px;">Harrods, Grand Bazaar, Ginza</div> |
| </div> |
| </div> |
| </div>""" |
| st.markdown(re.sub(r"\n\s+", "\n", hero_html), unsafe_allow_html=True) |