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);
|
setActiveSearchSlotIndex(null);
|
||||||
}}
|
}}
|
||||||
onClose={() => 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>
|
</div>
|
||||||
|
|||||||
@@ -113,6 +113,9 @@ const getCityCode = (city: string): string => {
|
|||||||
return CITY_IATA_MAP[normalized] || normalized.substring(0, 3).toUpperCase();
|
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({
|
export function CitySelectorDropdown({
|
||||||
isEn,
|
isEn,
|
||||||
rows,
|
rows,
|
||||||
@@ -124,6 +127,7 @@ export function CitySelectorDropdown({
|
|||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [activeTab, setActiveTab] = useState<string>("all");
|
const [activeTab, setActiveTab] = useState<string>("all");
|
||||||
|
const [viewportNudgeY, setViewportNudgeY] = useState(0);
|
||||||
|
|
||||||
// Auto-focus input on mount
|
// Auto-focus input on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -193,6 +197,35 @@ export function CitySelectorDropdown({
|
|||||||
});
|
});
|
||||||
}, [rows, searchQuery, activeTab]);
|
}, [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 getRegionLabel = (regionKey: string): string => {
|
||||||
const match = REGIONS.find((r) => r.key === regionKey);
|
const match = REGIONS.find((r) => r.key === regionKey);
|
||||||
if (!match) return regionKey;
|
if (!match) return regionKey;
|
||||||
@@ -203,9 +236,10 @@ export function CitySelectorDropdown({
|
|||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className={clsx(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
|
style={viewportNudgeY ? { marginTop: viewportNudgeY } : undefined}
|
||||||
onClick={(e) => e.stopPropagation()} // Prevent triggering slot clicks
|
onClick={(e) => e.stopPropagation()} // Prevent triggering slot clicks
|
||||||
>
|
>
|
||||||
{/* Search Input Area */}
|
{/* Search Input Area */}
|
||||||
@@ -243,7 +277,7 @@ export function CitySelectorDropdown({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Results List */}
|
{/* 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 ? (
|
{filteredRows.length === 0 ? (
|
||||||
<div className="p-4 text-center text-slate-400 font-medium">
|
<div className="p-4 text-center text-slate-400 font-medium">
|
||||||
{isEn ? "No matching cities" : "无匹配城市"}
|
{isEn ? "No matching cities" : "无匹配城市"}
|
||||||
|
|||||||
@@ -233,6 +233,13 @@ export function runTests() {
|
|||||||
chartCanvas.includes("canToggleRunwayDetails") && chartCanvas.includes("individualRunwaySeriesCount > 1"),
|
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",
|
"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("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(!chart.includes("build3DayChartData"), "temperature chart component must not render future prediction curves");
|
||||||
assert(
|
assert(
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ export function runTests() {
|
|||||||
path.join(projectRoot, "components", "dashboard", "scan-terminal", "TemperatureChartCanvas.tsx"),
|
path.join(projectRoot, "components", "dashboard", "scan-terminal", "TemperatureChartCanvas.tsx"),
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
|
const citySelectorSource = fs.readFileSync(
|
||||||
|
path.join(projectRoot, "components", "dashboard", "scan-terminal", "CitySelectorDropdown.tsx"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
dashboardSource.includes("MAX_TERMINAL_CHARTS = 9"),
|
dashboardSource.includes("MAX_TERMINAL_CHARTS = 9"),
|
||||||
@@ -54,6 +58,22 @@ export function runTests() {
|
|||||||
dashboardSource.includes("handleSelectCityForSlot(slotIndex, null);"),
|
dashboardSource.includes("handleSelectCityForSlot(slotIndex, null);"),
|
||||||
"stale saved chart slots must render the empty city picker instead of a row=null Temperature Chart",
|
"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(
|
assert(
|
||||||
chartSource.includes("setLiveTemp(null);") &&
|
chartSource.includes("setLiveTemp(null);") &&
|
||||||
chartSource.includes("lastAppliedPatchRevisionRef.current = 0;"),
|
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 _hourlyCache = new Map<string, { ts: number; data: HourlyForecast }>();
|
||||||
const _hourlyRequestCache = new Map<string, Promise<HourlyForecast>>();
|
const _hourlyRequestCache = new Map<string, Promise<HourlyForecast>>();
|
||||||
const MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS = 3;
|
const MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS = 3;
|
||||||
|
const HOURLY_DETAIL_REQUEST_TIMEOUT_MS = 12_000;
|
||||||
let _hourlyActiveDetailRequests = 0;
|
let _hourlyActiveDetailRequests = 0;
|
||||||
const _hourlyDetailRequestQueue: Array<() => void> = [];
|
const _hourlyDetailRequestQueue: Array<() => void> = [];
|
||||||
const RUNWAY_LINE_COLORS = ["#00897b", "#d97706", "#7c3aed", "#0891b2", "#ea580c", "#64748b"];
|
const RUNWAY_LINE_COLORS = ["#00897b", "#d97706", "#7c3aed", "#0891b2", "#ea580c", "#64748b"];
|
||||||
@@ -985,11 +986,9 @@ async function fetchHourlyForecastForCity(
|
|||||||
if (pending) return pending;
|
if (pending) return pending;
|
||||||
|
|
||||||
const request = runQueuedHourlyDetailRequest(() =>
|
const request = runQueuedHourlyDetailRequest(() =>
|
||||||
fetch(`/api/city/${encodeURIComponent(city)}/detail?depth=full&force_refresh=false&resolution=${resParam}`, {
|
fetchCityDetailWithTimeout(city, resParam)
|
||||||
headers: { Accept: "application/json" },
|
|
||||||
})
|
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
if (!res.ok) return null;
|
if (!res || !res.ok) return null;
|
||||||
return res.json() as Promise<CityDetail>;
|
return res.json() as Promise<CityDetail>;
|
||||||
})
|
})
|
||||||
.then((json) => {
|
.then((json) => {
|
||||||
@@ -1008,6 +1007,17 @@ async function fetchHourlyForecastForCity(
|
|||||||
return request;
|
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({
|
function shouldPollLiveChart({
|
||||||
city,
|
city,
|
||||||
compact,
|
compact,
|
||||||
@@ -2246,6 +2256,7 @@ function binObservationsToSlots(
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS,
|
MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS,
|
||||||
|
HOURLY_DETAIL_REQUEST_TIMEOUT_MS,
|
||||||
HOURLY_CACHE_TTL_MS,
|
HOURLY_CACHE_TTL_MS,
|
||||||
_hourlyCache,
|
_hourlyCache,
|
||||||
__resetHourlyDetailRequestQueueForTest,
|
__resetHourlyDetailRequestQueueForTest,
|
||||||
|
|||||||
Reference in New Issue
Block a user