feat: implement CitySelectorDropdown for ScanTerminalDashboard with multi-filter search and regional categorization

This commit is contained in:
2569718930@qq.com
2026-05-30 16:39:23 +08:00
parent a03b8095ee
commit 5470da6b3a
5 changed files with 79 additions and 7 deletions
@@ -860,7 +860,7 @@ function PolyWeatherTerminal({
setActiveSearchSlotIndex(null);
}}
onClose={() => setActiveSearchSlotIndex(null)}
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50 w-[380px] bg-white border border-slate-200 rounded shadow-lg p-2"
className="absolute left-1/2 top-12 z-50 w-[380px] -translate-x-1/2 bg-white border border-slate-200 rounded shadow-lg p-2"
/>
)}
</div>
@@ -113,6 +113,9 @@ const getCityCode = (city: string): string => {
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,
@@ -124,6 +127,7 @@ export function CitySelectorDropdown({
const inputRef = useRef<HTMLInputElement>(null);
const [searchQuery, setSearchQuery] = useState("");
const [activeTab, setActiveTab] = useState<string>("all");
const [viewportNudgeY, setViewportNudgeY] = useState(0);
// Auto-focus input on mount
useEffect(() => {
@@ -193,6 +197,35 @@ export function CitySelectorDropdown({
});
}, [rows, searchQuery, 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;
@@ -203,9 +236,10 @@ export function CitySelectorDropdown({
<div
ref={containerRef}
className={clsx(
"flex flex-col bg-white border border-slate-300 rounded shadow-2xl overflow-hidden text-xs text-[#202833] animate-in fade-in-50 zoom-in-95 duration-100",
"flex max-h-[calc(100vh-72px)] min-h-0 flex-col bg-white border border-slate-300 rounded shadow-2xl overflow-hidden text-xs text-[#202833] animate-in fade-in-50 zoom-in-95 duration-100",
className
)}
style={viewportNudgeY ? { marginTop: viewportNudgeY } : undefined}
onClick={(e) => e.stopPropagation()} // Prevent triggering slot clicks
>
{/* Search Input Area */}
@@ -243,7 +277,7 @@ export function CitySelectorDropdown({
</div>
{/* Results List */}
<div className="flex-1 overflow-y-auto max-h-[380px] divide-y divide-slate-100">
<div className="min-h-0 flex-1 overflow-y-auto max-h-[380px] divide-y divide-slate-100">
{filteredRows.length === 0 ? (
<div className="p-4 text-center text-slate-400 font-medium">
{isEn ? "No matching cities" : "无匹配城市"}
@@ -233,6 +233,13 @@ export function runTests() {
chartCanvas.includes("canToggleRunwayDetails") && chartCanvas.includes("individualRunwaySeriesCount > 1"),
"single-runway charts must not show the runway-detail toggle because aggregate and individual views are visually redundant",
);
assert(
chartLogic.includes("HOURLY_DETAIL_REQUEST_TIMEOUT_MS = 12_000") &&
chartLogic.includes("fetchCityDetailWithTimeout") &&
chartLogic.includes("signal: controller.signal") &&
chartLogic.includes("controller.abort()"),
"city detail chart fetches must have a frontend timeout so panels cannot stay on 加载图表 forever",
);
assert(!chart.includes("3D"), "temperature chart UI must not expose a 3D/future-forecast mode");
assert(!chart.includes("build3DayChartData"), "temperature chart component must not render future prediction curves");
assert(
@@ -23,6 +23,10 @@ export function runTests() {
path.join(projectRoot, "components", "dashboard", "scan-terminal", "TemperatureChartCanvas.tsx"),
"utf8",
);
const citySelectorSource = fs.readFileSync(
path.join(projectRoot, "components", "dashboard", "scan-terminal", "CitySelectorDropdown.tsx"),
"utf8",
);
assert(
dashboardSource.includes("MAX_TERMINAL_CHARTS = 9"),
@@ -54,6 +58,22 @@ export function runTests() {
dashboardSource.includes("handleSelectCityForSlot(slotIndex, null);"),
"stale saved chart slots must render the empty city picker instead of a row=null Temperature Chart",
);
assert(
dashboardSource.includes("absolute left-1/2 top-12 z-50") &&
!dashboardSource.includes("top-1/2 -translate-x-1/2 -translate-y-1/2"),
"empty top-row slots must open the city selector downward so the search input is not clipped by the viewport header",
);
assert(
citySelectorSource.includes("max-h-[calc(100vh-72px)]") &&
citySelectorSource.includes("min-h-0 flex-1 overflow-y-auto"),
"city selector dropdown must cap its viewport height and keep the result list scrollable",
);
assert(
citySelectorSource.includes("MIN_DROPDOWN_TOP_PX") &&
citySelectorSource.includes("viewportNudgeY") &&
citySelectorSource.includes("getBoundingClientRect()"),
"city selector dropdown must nudge itself inside the viewport when opened from top-row chart cards",
);
assert(
chartSource.includes("setLiveTemp(null);") &&
chartSource.includes("lastAppliedPatchRevisionRef.current = 0;"),
@@ -356,6 +356,7 @@ const HOURLY_CACHE_TTL_MS = DASHBOARD_REFRESH_POLICY_MS.metar;
const _hourlyCache = new Map<string, { ts: number; data: HourlyForecast }>();
const _hourlyRequestCache = new Map<string, Promise<HourlyForecast>>();
const MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS = 3;
const HOURLY_DETAIL_REQUEST_TIMEOUT_MS = 12_000;
let _hourlyActiveDetailRequests = 0;
const _hourlyDetailRequestQueue: Array<() => void> = [];
const RUNWAY_LINE_COLORS = ["#00897b", "#d97706", "#7c3aed", "#0891b2", "#ea580c", "#64748b"];
@@ -985,11 +986,9 @@ async function fetchHourlyForecastForCity(
if (pending) return pending;
const request = runQueuedHourlyDetailRequest(() =>
fetch(`/api/city/${encodeURIComponent(city)}/detail?depth=full&force_refresh=false&resolution=${resParam}`, {
headers: { Accept: "application/json" },
})
fetchCityDetailWithTimeout(city, resParam)
.then(async (res) => {
if (!res.ok) return null;
if (!res || !res.ok) return null;
return res.json() as Promise<CityDetail>;
})
.then((json) => {
@@ -1008,6 +1007,17 @@ async function fetchHourlyForecastForCity(
return request;
}
function fetchCityDetailWithTimeout(city: string, resolution: string) {
const controller = new AbortController();
const timeoutId = globalThis.setTimeout(() => controller.abort(), HOURLY_DETAIL_REQUEST_TIMEOUT_MS);
return fetch(`/api/city/${encodeURIComponent(city)}/detail?depth=full&force_refresh=false&resolution=${resolution}`, {
headers: { Accept: "application/json" },
signal: controller.signal,
})
.catch(() => null)
.finally(() => globalThis.clearTimeout(timeoutId));
}
function shouldPollLiveChart({
city,
compact,
@@ -2246,6 +2256,7 @@ function binObservationsToSlots(
export {
MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS,
HOURLY_DETAIL_REQUEST_TIMEOUT_MS,
HOURLY_CACHE_TTL_MS,
_hourlyCache,
__resetHourlyDetailRequestQueueForTest,