feat: implement CitySelectorDropdown for ScanTerminalDashboard with multi-filter search and regional categorization
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user