Files
PolyWeather/frontend/components/dashboard/scan-terminal/continent-grouping.ts

239 lines
7.5 KiB
TypeScript

import type { ScanOpportunityRow } from "@/lib/dashboard-types";
export const REGIONS = [
{ key: "east_asia", labelEn: "East Asia", labelZh: "东亚", sort: 1 },
{ key: "southeast_asia", labelEn: "Southeast Asia", labelZh: "东南亚", sort: 2 },
{ key: "central_asia", labelEn: "Central / South Asia", labelZh: "中亚 / 南亚", sort: 3 },
{ key: "west_asia", labelEn: "West Asia / Middle East", labelZh: "西亚 / 中东", sort: 4 },
{ key: "europe_africa", labelEn: "Europe / Africa", labelZh: "欧洲 / 非洲", sort: 5 },
{ key: "south_america", labelEn: "Latin America", labelZh: "拉美", sort: 6 },
{ key: "north_america", labelEn: "North America", labelZh: "北美", sort: 7 },
] as const;
export type RegionKey = (typeof REGIONS)[number]["key"];
const REGION_KEYS = new Set<string>(REGIONS.map((r) => r.key));
const CITY_REGION: Record<string, RegionKey> = {
beijing: "east_asia",
busan: "east_asia",
chengdu: "east_asia",
chongqing: "east_asia",
guangzhou: "east_asia",
"hong kong": "east_asia",
qingdao: "east_asia",
seoul: "east_asia",
shanghai: "east_asia",
shenzhen: "east_asia",
taipei: "east_asia",
tokyo: "east_asia",
wuhan: "east_asia",
jakarta: "southeast_asia",
"kuala lumpur": "southeast_asia",
manila: "southeast_asia",
singapore: "southeast_asia",
karachi: "central_asia",
lucknow: "central_asia",
ankara: "west_asia",
istanbul: "west_asia",
jeddah: "west_asia",
"tel aviv": "west_asia",
amsterdam: "europe_africa",
"cape town": "europe_africa",
helsinki: "europe_africa",
london: "europe_africa",
madrid: "europe_africa",
milan: "europe_africa",
moscow: "europe_africa",
munich: "europe_africa",
paris: "europe_africa",
warsaw: "europe_africa",
"buenos aires": "south_america",
"sao paulo": "south_america",
"mexico city": "north_america",
atlanta: "north_america",
austin: "north_america",
chicago: "north_america",
dallas: "north_america",
denver: "north_america",
houston: "north_america",
"los angeles": "north_america",
miami: "north_america",
"new york": "north_america",
"panama city": "south_america",
"san francisco": "north_america",
seattle: "north_america",
toronto: "north_america",
wellington: "east_asia",
};
function normalizeCity(value?: string | null) {
return String(value || "").trim().toLowerCase()
.replace(/[_-]+/g, " ").replace(/\s+/g, " ");
}
export function getCityRegion(row: ScanOpportunityRow): RegionKey | null {
const key = normalizeCity(row.city || row.city_display_name || row.display_name);
return CITY_REGION[key] || null;
}
/** Default region on first load — East Asia. */
export function getDefaultRegion(): RegionKey {
return "east_asia";
}
export interface ContinentGroup {
key: RegionKey | "active_signals";
labelEn: string;
labelZh: string;
sort: number;
rows: ScanOpportunityRow[];
activeCount: number;
watchCount: number;
hotCity: string | null;
localTimeRange: string | null;
}
export function isActiveSignal(row: ScanOpportunityRow): boolean {
const decision = String(row.ai_decision || row.v4_metar_decision || "").toLowerCase();
if (decision.includes("approve")) return true;
if (row.tradable && row.active) return true;
return false;
}
export function isWatchSignal(row: ScanOpportunityRow): boolean {
const decision = String(row.ai_decision || row.v4_metar_decision || row.signal_status || "").toLowerCase();
if (decision.includes("watch")) return true;
if (decision.includes("monitor")) return true;
if (!row.tradable && row.active) return true;
return false;
}
export function isDeadSignal(row: ScanOpportunityRow): boolean {
if (row.closed) return true;
const decision = String(row.ai_decision || row.v4_metar_decision || "").toLowerCase();
if (decision.includes("veto")) return true;
return false;
}
export function getSignalState(row: ScanOpportunityRow): "active" | "watch" | "closed" | "data" {
if (isDeadSignal(row)) return "closed";
if (isActiveSignal(row)) return "active";
if (isWatchSignal(row)) return "watch";
return "data";
}
export function getSignalLabel(state: ReturnType<typeof getSignalState>, isEn: boolean): string {
switch (state) {
case "active": return isEn ? "◆ Active" : "◆ 活跃";
case "watch": return isEn ? "● Watch" : "● 观察";
case "closed": return isEn ? "○ Closed" : "○ 关闭";
case "data": return isEn ? "! Data" : "! 数据";
}
}
export type GapColor = "green" | "orange" | "slate" | "gray" | "red";
export function getGapColor(row: ScanOpportunityRow): GapColor {
const gap = Number(row.signed_gap ?? row.gap_to_target);
if (!Number.isFinite(gap)) return "gray";
if (gap >= 2) return "green";
if (gap >= 0) return "orange";
if (gap < -5) return "gray";
return "slate";
}
export const GAP_COLOR_MAP: Record<GapColor, string> = {
green: "text-emerald-600",
orange: "text-amber-600",
slate: "text-slate-500",
gray: "text-slate-400",
red: "text-red-500",
};
export function formatPrice(_midpoint?: number | null, _ask?: number | null, _bid?: number | null): string {
return "--";
}
export function formatSpreadLiquidity(_spread?: number | null, _liquidity?: number | null): string {
return "--";
}
export function buildContinentGroups(rows: ScanOpportunityRow[], isEn: boolean): ContinentGroup[] {
const regionMap = new Map<string, ScanOpportunityRow[]>();
for (const row of rows) {
const region = getCityRegion(row) || "unknown";
if (!regionMap.has(region)) regionMap.set(region, []);
regionMap.get(region)!.push(row);
}
const groups: ContinentGroup[] = [];
// Active Signals virtual group
const activeRows = rows.filter((r) => isActiveSignal(r));
if (activeRows.length > 0) {
const hotRow = activeRows.reduce((best, r) =>
Number(r.edge_percent || 0) > Number(best.edge_percent || 0) ? r : best
);
groups.push({
key: "active_signals",
labelEn: "Active Signals",
labelZh: "活跃信号",
sort: 0,
rows: activeRows,
activeCount: activeRows.filter((r) => isActiveSignal(r)).length,
watchCount: activeRows.filter((r) => isWatchSignal(r)).length,
hotCity: hotRow?.city_display_name || hotRow?.city || null,
localTimeRange: null,
});
}
for (const region of REGIONS) {
const regionRows = regionMap.get(region.key) || [];
if (regionRows.length === 0) continue;
const activeCount = regionRows.filter((r) => isActiveSignal(r)).length;
const watchCount = regionRows.filter((r) => isWatchSignal(r)).length;
const sorted = [...regionRows].sort((a, b) =>
Number(b.final_score || 0) - Number(a.final_score || 0)
);
const hotCity = sorted[0]?.city_display_name || sorted[0]?.city || null;
const times = regionRows
.map((r) => String(r.local_time || "").trim())
.filter(Boolean)
.sort();
const ltRange = times.length >= 2
? `${times[0]}-${times[times.length - 1]}`
: times[0] || null;
groups.push({
key: region.key,
labelEn: region.labelEn,
labelZh: region.labelZh,
sort: region.sort,
rows: regionRows,
activeCount,
watchCount,
hotCity,
localTimeRange: ltRange,
});
}
groups.sort((a, b) => a.sort - b.sort);
return groups;
}
export function getDefaultExpanded(groups: ContinentGroup[]): Set<string> {
const expanded = new Set<string>();
for (const g of groups) {
if (g.key === "active_signals") {
expanded.add(g.key);
} else if (g.activeCount > 0 || g.watchCount > 0) {
expanded.add(g.key);
}
}
return expanded;
}