"use client"; import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; import clsx from "clsx"; import type { ScanOpportunityRow } from "@/lib/dashboard-types"; import { REGIONS, getCityRegion } from "./continent-grouping"; import { rowName } from "./utils"; interface CitySelectorDropdownProps { isEn: boolean; rows: ScanOpportunityRow[]; onSelectCity: (city: string) => void; onClose: () => void; className?: string; } // Map each city to its airport code (IATA) to match the financial symbol style of Koyfin const CITY_IATA_MAP: Record = { beijing: "PEK", shanghai: "SHA", shenzhen: "SZX", guangzhou: "CAN", chengdu: "CTU", chongqing: "CKG", wuhan: "WUH", taipei: "TPE", "hong kong": "HKG", tokyo: "HND", seoul: "ICN", singapore: "SIN", "kuala lumpur": "KUL", manila: "MNL", jakarta: "CGK", karachi: "KHI", lucknow: "LKO", london: "LHR", paris: "CDG", munich: "MUC", milan: "MXP", madrid: "MAD", amsterdam: "AMS", warsaw: "WAW", helsinki: "HEL", "cape town": "CPT", jeddah: "JED", toronto: "YYZ", "new york": "LGA", "los angeles": "LAX", "san francisco": "SFO", denver: "DEN", austin: "AUS", houston: "HOU", dallas: "DAL", miami: "MIA", atlanta: "ATL", seattle: "SEA", "mexico city": "MEX", "panama city": "PAC", "buenos aires": "EZE", "sao paulo": "GRU", wellington: "WLG", }; // Map each city to its ICAO code to prevent referencing non-existent field row.icao in TypeScript const CITY_ICAO_MAP: Record = { beijing: "ZBAA", shanghai: "ZSPD", shenzhen: "LFS", guangzhou: "ZGGG", chengdu: "ZUUU", chongqing: "ZUCK", wuhan: "ZHHH", taipei: "RCSS", "hong kong": "VHHH", tokyo: "RJTT", seoul: "RKSI", singapore: "WSSS", "kuala lumpur": "WMKK", manila: "RPLL", jakarta: "WIHH", karachi: "OPKC", lucknow: "VILK", london: "EGLC", paris: "LFPB", munich: "EDDM", milan: "LIMC", madrid: "LEMD", amsterdam: "EHAM", warsaw: "EPWA", helsinki: "EFHK", "cape town": "FACT", jeddah: "OEJN", toronto: "CYYZ", "new york": "KLGA", "los angeles": "KLAX", "san francisco": "KSFO", denver: "KBKF", austin: "KAUS", houston: "KHOU", dallas: "KDAL", miami: "KMIA", atlanta: "KATL", seattle: "KSEA", "mexico city": "MMMX", "panama city": "MPMG", "buenos aires": "SAEZ", "sao paulo": "SBGR", wellington: "NZWN", }; const getCityCode = (city: string): string => { const normalized = String(city || "").toLowerCase().trim(); return CITY_IATA_MAP[normalized] || normalized.substring(0, 3).toUpperCase(); }; const MIN_DROPDOWN_TOP_PX = 56; const DROPDOWN_VIEWPORT_PADDING_PX = 12; export function CitySelectorDropdown({ isEn, rows, onSelectCity, onClose, className, }: CitySelectorDropdownProps) { const containerRef = useRef(null); const inputRef = useRef(null); const [searchQuery, setSearchQuery] = useState(""); const deferredSearchQuery = useDeferredValue(searchQuery); const [activeTab, setActiveTab] = useState("all"); const [viewportNudgeY, setViewportNudgeY] = useState(0); // Auto-focus input on mount useEffect(() => { inputRef.current?.focus(); }, []); // Handle click outside and Escape key useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(event.target as Node)) { onClose(); } }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { onClose(); } }; document.addEventListener("mousedown", handleClickOutside); document.addEventListener("keydown", handleKeyDown); return () => { document.removeEventListener("mousedown", handleClickOutside); document.removeEventListener("keydown", handleKeyDown); }; }, [onClose]); // Tab definitions matching the Koyfin pill row style const tabs = useMemo(() => { return [ { key: "all", labelEn: "ALL", labelZh: "全部" }, ...REGIONS.map((r) => ({ key: r.key, labelEn: r.labelEn.replace("Asia", "").replace("America", "").trim().toUpperCase() || r.labelEn, labelZh: r.labelZh, })), ]; }, []); // Filter rows const filteredRows = useMemo(() => { const q = deferredSearchQuery.toLowerCase().trim(); return rows.filter((row) => { // 1. Region filter if (activeTab !== "all") { const region = getCityRegion(row); if (region !== activeTab) return false; } // 2. Query filter if (!q) return true; const key = String(row.city || "").toLowerCase().trim(); const code = getCityCode(row.city || ""); const icao = CITY_ICAO_MAP[key] || ""; const haystack = [ row.city, row.city_display_name, row.display_name, row.airport, icao, code, ] .filter(Boolean) .map((s) => s!.toLowerCase()); return haystack.some((s) => s.includes(q)); }); }, [rows, deferredSearchQuery, activeTab]); useEffect(() => { let frame = 0; const updatePosition = () => { cancelAnimationFrame(frame); frame = requestAnimationFrame(() => { const el = containerRef.current; if (!el) return; const rect = el.getBoundingClientRect(); let next = 0; if (rect.top < MIN_DROPDOWN_TOP_PX) { next = MIN_DROPDOWN_TOP_PX - rect.top; } else if (rect.bottom > window.innerHeight - DROPDOWN_VIEWPORT_PADDING_PX) { next = Math.max( MIN_DROPDOWN_TOP_PX - rect.top, window.innerHeight - DROPDOWN_VIEWPORT_PADDING_PX - rect.bottom, ); } setViewportNudgeY((prev) => (Math.abs(prev - next) < 1 ? prev : next)); }); }; updatePosition(); window.addEventListener("resize", updatePosition); return () => { cancelAnimationFrame(frame); window.removeEventListener("resize", updatePosition); }; }, [filteredRows.length]); const getRegionLabel = (regionKey: string): string => { const match = REGIONS.find((r) => r.key === regionKey); if (!match) return regionKey; return isEn ? match.labelEn : match.labelZh; }; return (
e.stopPropagation()} // Prevent triggering slot clicks > {/* Search Input Area */}
setSearchQuery(e.target.value)} placeholder="Search..." className="w-full px-2.5 py-1.5 border border-[#3b82f6] rounded bg-white text-xs outline-none ring-2 ring-blue-500/10 focus:border-blue-500" />
{/* Koyfin-style Category Pills Bar */}
{tabs.map((tab) => { const isActive = activeTab === tab.key; return ( ); })}
{/* Results List */}
{filteredRows.length === 0 ? (
{isEn ? "No matching cities" : "无匹配城市"}
) : ( filteredRows.map((row) => { const cityName = rowName(row); const obsTemp = row.current_temp ?? row.current_max_so_far; const debPrediction = row.deb_prediction; const symbol = row.temp_symbol || "°C"; const regionKey = getCityRegion(row) || "unknown"; const regionLabel = getRegionLabel(regionKey); const cityKey = String(row.city || "").toLowerCase().trim(); return ( ); }) )}
); }