feat(sector-radar): add weighted scores and rank-change views

This commit is contained in:
yuxuanhui
2026-09-21 23:57:55 +08:00
parent 669e89d3c3
commit b9981aa48d
45 changed files with 2468 additions and 320 deletions
+1
View File
@@ -3,3 +3,4 @@ dist
node_modules
pnpm-lock.yaml
.pnpm-store
.playwright-cli
@@ -8,6 +8,7 @@ import {
getSectorRadarHistory,
getSectorRadarDetail,
parseRadarDetailResponse,
parseRadarRankingsResponse,
getSectorRadarDates,
getSectorRadarRankings,
getStockSectorMembership,
@@ -133,6 +134,72 @@ describe("sector radar API adapters", () => {
requestJson.mockReset()
})
it("preserves score precision, signed changes, zero and unknown historical values", () => {
const response = parseRadarRankingsResponse({
...rankingPayload,
comparison_trade_date: "2026-08-21",
rows: [
{
...rankingPayload.rows[0],
weighted_score: "712.345678901234",
rank_change_values: { amount: 3, ratio: 0, swing: null },
},
],
})
expect(response.comparison_trade_date).toBe("2026-08-21")
expect(response.rows[0]?.weighted_score).toBe(712.345678901234)
expect(response.rows[0]?.rank_change_values).toEqual({
amount: 3,
ratio: 0,
swing: null,
})
const legacy = parseRadarRankingsResponse(rankingPayload)
expect(legacy.rows[0]?.weighted_score).toBeNull()
expect(legacy.rows[0]?.rank_change_values).toEqual({
amount: null,
ratio: null,
swing: null,
})
expect(legacy.comparison_trade_date).toBeNull()
const detail = parseRadarDetailResponse({
...detailPayload,
summary: {
...detailPayload.summary,
ratio: { ...historyMetric, weighted_score: "712.345" },
},
})
expect(detail.summary.ratio.weighted_score).toBe(712.345)
})
it.each(["NaN", "Infinity", "0x10", "", true])(
"rejects malformed weighted scores %s",
(weighted_score) => {
expect(() =>
parseRadarRankingsResponse({
...rankingPayload,
rows: [{ ...rankingPayload.rows[0], weighted_score }],
}),
).toThrow("weighted_score")
},
)
it("rejects fractional changes and invalid comparison dates", () => {
expect(() =>
parseRadarRankingsResponse({
...rankingPayload,
rows: [
{ ...rankingPayload.rows[0], rank_change_values: { amount: 0.5 } },
],
}),
).toThrow("rank_change_values.amount")
expect(() =>
parseRadarRankingsResponse({
...rankingPayload,
comparison_trade_date: "yesterday",
}),
).toThrow("comparison_trade_date")
})
it("reads persisted sector resources and preserves nullable independent metrics", async () => {
const signal = new AbortController().signal
const query = {
@@ -121,6 +121,10 @@ export function parseRadarRankingsResponse(
5,
"rankings.rank_change_days",
),
comparison_trade_date: readNullableDate(
record.comparison_trade_date ?? null,
"rankings.comparison_trade_date",
),
side: readEnum(record.side, radarRankSides, "rankings.side"),
search: readNullableString(record.search, "rankings.search"),
publication: readNullablePublication(
@@ -305,6 +309,14 @@ function readRankingRow(value: unknown, index: number): RadarRankingRow {
record.metric_value,
`${path}.metric_value`,
),
weighted_score: readNullableFiniteNumber(
record.weighted_score ?? null,
`${path}.weighted_score`,
),
rank_change_values: readRankChangeValues(
record.rank_change_values,
`${path}.rank_change_values`,
),
pct_change: readNullableFiniteNumber(
record.pct_change ?? null,
`${path}.pct_change`,
@@ -377,6 +389,30 @@ function readRankingRow(value: unknown, index: number): RadarRankingRow {
}
}
function readRankChangeValues(
value: unknown,
path: string,
): RadarRankingRow["rank_change_values"] {
const record = value == null ? {} : readRecord(value, path)
return {
amount: readNullableInteger(
record.amount ?? null,
Number.MIN_SAFE_INTEGER,
`${path}.amount`,
),
ratio: readNullableInteger(
record.ratio ?? null,
Number.MIN_SAFE_INTEGER,
`${path}.ratio`,
),
swing: readNullableInteger(
record.swing ?? null,
Number.MIN_SAFE_INTEGER,
`${path}.swing`,
),
}
}
function readRecord(value: unknown, path: string): JsonRecord {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw contractError(path, "must be an object")
@@ -627,6 +663,10 @@ function readHistoryMetric(value: unknown, path: string): RadarHistoryMetric {
record.metric_value,
`${path}.metric_value`,
),
weighted_score: readNullableFiniteNumber(
record.weighted_score ?? null,
`${path}.weighted_score`,
),
missing: readBoolean(record.missing, `${path}.missing`),
in_top: readBoolean(record.in_top, `${path}.in_top`),
in_bottom: readBoolean(record.in_bottom, `${path}.in_bottom`),
@@ -68,6 +68,7 @@ export interface RadarRankingRow {
implementation_kind: "independent"
unit: RadarMetricUnit
metric_value: number | null
weighted_score: number | null
quality: RadarMetricQuality
member_count: number
valid_sample_count: number
@@ -77,6 +78,7 @@ export interface RadarRankingRow {
rank_percentile: number | null
rank_change_days: number
rank_change: number | null
rank_change_values: Record<RadarMetricKind, number | null>
pct_change: number | null
daily_net_amount_yuan: number | null
daily_ratio: number | null
@@ -91,6 +93,7 @@ export interface RadarRankingsResponse {
view: RadarView
rank_change_metric: RadarMetricKind
rank_change_days: number
comparison_trade_date: string | null
side: RadarRankSide
search: string | null
publication: RadarPublication | null
@@ -141,6 +144,7 @@ export interface RadarHistoryMetric {
rank_percentile: number | null
pool_size: number
metric_value: number | null
weighted_score: number | null
missing: boolean
in_top: boolean
in_bottom: boolean
@@ -33,6 +33,7 @@ const metric = {
rank_percentile: 90,
pool_size: 20,
metric_value: 0.02,
weighted_score: null,
missing: false,
in_top: true,
in_bottom: false,
@@ -111,6 +112,7 @@ const row: RadarRankingRow = {
implementation_kind: "independent",
unit: "ratio",
metric_value: 0.02,
weighted_score: null,
quality: "available",
member_count: 1,
valid_sample_count: 1,
@@ -120,6 +122,7 @@ const row: RadarRankingRow = {
rank_percentile: 90,
rank_change_days: 1,
rank_change: null,
rank_change_values: { amount: null, ratio: null, swing: null },
pct_change: -2,
daily_net_amount_yuan: 2e8,
daily_ratio: 0.02,
@@ -0,0 +1,107 @@
import type {
RadarMetricKind,
RadarRankingRow,
RadarView,
} from "../api/sector-radar.types"
export const radarMetricLabels: Record<RadarMetricKind, string> = {
swing: "波段流入率",
ratio: "单日流入率",
amount: "单日净额",
}
export type RadarSortKey =
| "pct_change"
| "weighted_score"
| "daily_net_amount_yuan"
| "daily_ratio"
| "on_list_count"
| "metric_value"
| "change_amount"
| "change_ratio"
| "change_swing"
export type RadarSort = {
key: RadarSortKey
direction: "ascending" | "descending"
}
export type RadarColumn = { label: string; key?: RadarSortKey }
export function rankChangeAuxiliaryMetrics(metric: RadarMetricKind) {
return (["swing", "ratio", "amount"] as const).filter(
(candidate) => candidate !== metric,
)
}
/** Keep the two table halves in the same field order, mirrored at the center. */
export function radarColumns(
view: RadarView,
metric: RadarMetricKind,
side: "top" | "bottom",
): RadarColumn[] {
const columns: RadarColumn[] = [
{ label: "板块/排名" },
{ label: "涨跌幅", key: "pct_change" },
]
if (view === "rank_change") {
columns.push(
...rankChangeAuxiliaryMetrics(metric).map((kind): RadarColumn => ({
label: radarMetricLabels[kind],
key: `change_${kind}`,
})),
{
label: side === "top" ? "上升" : "下降",
key: `change_${metric}`,
},
)
} else if (view === "amount") {
columns.push(
{ label: "单日流入率", key: "daily_ratio" },
{ label: "净额", key: "metric_value" },
)
} else {
columns.push(
{ label: "加权评分", key: "weighted_score" },
{ label: "净额", key: "daily_net_amount_yuan" },
{ label: "在榜", key: "on_list_count" },
{
label: view === "swing" ? (side === "top" ? "流入" : "流出") : "流入率",
key: "metric_value",
},
)
}
return side === "top" ? columns : columns.reverse()
}
function sortValue(row: RadarRankingRow, key: RadarSortKey) {
switch (key) {
case "change_amount":
return row.rank_change_values.amount
case "change_ratio":
return row.rank_change_values.ratio
case "change_swing":
return row.rank_change_values.swing
default:
return row[key]
}
}
/** Reorder an entire side's candidate pool; unknown values remain last in both directions. */
export function sortRadarRows(rows: RadarRankingRow[], sort: RadarSort | null) {
if (!sort) return rows
return [...rows].sort((a, b) => {
const left = sortValue(a, sort.key)
const right = sortValue(b, sort.key)
if (left == null && right != null) return 1
if (right == null && left != null) return -1
if (left != null && right != null && left !== right) {
return (left - right) * (sort.direction === "ascending" ? 1 : -1)
}
return (
(a.rank_position ?? Number.MAX_SAFE_INTEGER) -
(b.rank_position ?? Number.MAX_SAFE_INTEGER) ||
a.sector_code.localeCompare(b.sector_code)
)
})
}
@@ -71,6 +71,7 @@ const rankingsResponse: RadarRankingsResponse = {
view: "amount",
rank_change_metric: "amount",
rank_change_days: 1,
comparison_trade_date: "2026-08-27",
side: "all",
search: null,
publication: successPublication,
@@ -96,6 +97,8 @@ const rankingsResponse: RadarRankingsResponse = {
implementation_kind: "independent",
unit: "CNY_100M",
metric_value: 12.5,
weighted_score: 712.345,
rank_change_values: { amount: 3, ratio: 0, swing: -2 },
quality: "available",
member_count: 20,
valid_sample_count: 19,
@@ -121,6 +124,8 @@ const rankingsResponse: RadarRankingsResponse = {
implementation_kind: "independent",
unit: "CNY_100M",
metric_value: null,
weighted_score: null,
rank_change_values: { amount: null, ratio: null, swing: null },
quality: "available_limited_sample",
member_count: 4,
valid_sample_count: 3,
@@ -272,22 +277,28 @@ describe("SectorRadarPage", () => {
expect(
within(screen.getAllByRole("row")[1]!)
.getAllByRole("columnheader")
.map((cell) => cell.textContent),
.map((cell) => cell.textContent?.replace(/[↕↑↓]/g, "")),
).toEqual([
"板块/排名",
"涨跌幅",
"加权评分",
"净额",
"在榜",
"流入",
"流出",
"在榜",
"净额",
"加权评分",
"涨跌幅",
"板块/排名",
])
expect(screen.getAllByText("-1.25%")).toHaveLength(2)
expect(screen.getAllByText("+12.5 亿元")).toHaveLength(2)
}
expect(screen.getAllByText("712.3")).toHaveLength(2)
expect(
screen.getAllByRole("columnheader", { name: "加权评分" }),
).toHaveLength(2)
expect(screen.getAllByText("-1%")).toHaveLength(4)
const trigger = screen.getAllByRole("button", {
name: "机器人展开在榜历史",
@@ -537,7 +548,7 @@ describe("SectorRadarPage", () => {
})
})
it("loads the next page once when repeated scroll events reach the bottom", () => {
it("loads each side once when repeated scroll events reach the bottom", () => {
fetchNextPage.mockReturnValue(new Promise(() => undefined))
useSectorRadarRankings.mockReturnValue(
rankingQueryResult({ hasNextPage: true }),
@@ -555,7 +566,7 @@ describe("SectorRadarPage", () => {
fireEvent.scroll(scrollRegion)
fireEvent.scroll(scrollRegion)
expect(fetchNextPage).toHaveBeenCalledOnce()
expect(fetchNextPage).toHaveBeenCalledTimes(2)
})
it("announces that the next page is loading without hiding loaded rows", () => {
@@ -676,10 +687,143 @@ describe("SectorRadarPage", () => {
render(<SectorRadarPage />)
expect(
screen.getByRole("columnheader", { name: "排名上升" }),
screen.getByRole("columnheader", { name: "上升" }),
).toBeInTheDocument()
expect(screen.getAllByText("+3 名")[0]).toBeInTheDocument()
expect(screen.getAllByText("暂无可比历史")[0]).toBeInTheDocument()
expect(screen.getAllByTitle("暂无可比历史")[0]).toHaveTextContent("—")
})
it.each(
(["swing", "ratio", "amount"] as const).flatMap((metric) =>
[1, 2, 3, 4, 5].map((days) => ({ metric, days })),
),
)(
"restores the $metric / $days-session view and changes URL filters",
async ({ metric, days }) => {
routeSearch = {
...routeSearch,
view: "rank_change",
rankChangeMetric: metric,
rankChangeDays: days,
}
const labels = {
swing: "波段流入率",
ratio: "单日流入率",
amount: "单日净额",
}
useSectorRadarRankings.mockReturnValue(
rankingQueryResult({
data: {
pageParams: [1],
pages: [
{
...rankingsResponse,
view: "rank_change",
rank_change_metric: metric,
rank_change_days: days,
},
],
},
}),
)
render(<SectorRadarPage />)
expect(
screen.getByRole("columnheader", { name: `${labels[metric]}排名变化` }),
).toBeInTheDocument()
expect(screen.getByText("排名飙升榜 TOP 10%")).toBeInTheDocument()
expect(screen.getByText("BOTTOM 10% 排名暴跌榜")).toBeInTheDocument()
expect(
screen.getByRole("combobox", { name: "统计天数" }),
).toHaveTextContent(`近 ${days} 天`)
expect(useSectorRadarRankings).toHaveBeenCalledWith(
expect.objectContaining({
rankChangeMetric: metric,
rankChangeDays: days,
}),
)
const tabs = within(screen.getByRole("tablist", { name: "排序基准" }))
expect(
tabs.getByRole("tab", {
name: { swing: "波段率", ratio: "单日率", amount: "单日额" }[metric],
}),
).toHaveAttribute("aria-selected", "true")
fireEvent.click(tabs.getByRole("tab", { name: "单日率" }))
expect(navigate.mock.calls.at(-1)?.[0].search(routeSearch)).toMatchObject(
{ rankChangeMetric: "ratio", page: 1 },
)
fireEvent.click(screen.getByRole("combobox", { name: "统计天数" }))
const option = await screen.findByRole("option", { name: "近 5 天" })
fireEvent.pointerDown(option, { pointerType: "mouse" })
fireEvent.click(option)
expect(navigate.mock.calls.at(-1)?.[0].search(routeSearch)).toMatchObject(
{ rankChangeDays: 5, page: 1 },
)
expect(screen.getAllByRole("columnheader")).toHaveLength(13)
},
)
it("loads all side candidates before sorting, keeps missing scores last, and leaves the other side intact", () => {
routeSearch = { ...routeSearch, view: "ratio" }
const first = { ...rankingsResponse, view: "ratio" as const, total: 3 }
const last = {
...first,
page: 2,
rows: [
{
...first.rows[0]!,
sector_code: "BK03",
sector_name: "后页高分",
weighted_score: 900,
},
],
}
const nextTop = vi.fn().mockResolvedValue(undefined)
const nextBottom = vi.fn().mockResolvedValue(undefined)
let complete = false
useSectorRadarRankings.mockImplementation((query: { side: string }) =>
rankingQueryResult({
data: {
pageParams: complete ? [1, 2] : [1],
pages:
query.side === "top"
? complete
? [first, last]
: [first]
: [{ ...bottomRankingsResponse, view: "ratio" }],
},
hasNextPage: query.side === "top" && !complete,
fetchNextPage: query.side === "top" ? nextTop : nextBottom,
}),
)
const { container, rerender } = render(<SectorRadarPage />)
const order = () =>
Array.from(
container.querySelectorAll("tbody tr"),
(row) => row.firstElementChild?.textContent,
)
fireEvent.click(screen.getByRole("button", { name: "左榜按加权评分排序" }))
expect(nextTop).toHaveBeenCalledOnce()
expect(nextBottom).not.toHaveBeenCalled()
expect(order()[0]).toContain("机器人")
complete = true
rerender(<SectorRadarPage />)
expect(order()).toEqual([
expect.stringContaining("后页高分"),
expect.stringContaining("机器人"),
expect.stringContaining("低空经济"),
])
expect(
container.querySelector("tbody tr")?.lastElementChild,
).toHaveTextContent("新能源")
const score = screen.getByRole("button", { name: "左榜按加权评分排序" })
expect(score.closest("th")).toHaveAttribute("aria-sort", "descending")
fireEvent.click(score)
expect(order()).toEqual([
expect.stringContaining("机器人"),
expect.stringContaining("后页高分"),
expect.stringContaining("低空经济"),
])
expect(score.closest("th")).toHaveAttribute("aria-sort", "ascending")
})
it("warns when a partial attempt has not replaced last-good", () => {
@@ -1,5 +1,5 @@
import { AlertTriangle, Database, RefreshCw } from "lucide-react"
import { useRef, useState } from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import { useNavigate, useSearch } from "@tanstack/react-router"
import { PageLayout } from "@/app/layout/page-layout"
@@ -39,23 +39,32 @@ import { RadarDetailDialog } from "../components/radar-detail-dialog"
import { RadarHistory } from "../components/radar-history"
import { formatRadarValue, radarValueTone } from "../components/radar-format"
import {
radarColumns,
radarMetricLabels,
rankChangeAuxiliaryMetrics,
sortRadarRows,
type RadarColumn,
type RadarSort,
} from "../components/radar-table-sort"
const sectorTypeOptions = [
{ label: "概念", value: "concept" },
{ label: "行业", value: "industry" },
] as const
const viewOptions = [
{ label: "波段资金率", value: "swing" },
{ label: "波段流入率", value: "swing" },
{ label: "单日流入率", value: "ratio" },
{ label: "单日净额", value: "amount" },
{ label: "排名变化", value: "rank_change" },
] as const
const metricOptions = [
{ label: "主力净额", value: "amount" },
{ label: "单日流入率", value: "ratio" },
{ label: "波段资金率", value: "swing" },
{ label: "波段率", value: "swing" },
{ label: "单日率", value: "ratio" },
{ label: "单日额", value: "amount" },
] as const
const rankChangeDayOptions = [1, 2, 3, 4, 5].map((value) => ({
label: `${value} 日变化`,
label: value === 1 ? "近 1 天(相比昨日)" : `近 ${value} 天`,
value: String(value),
}))
@@ -149,6 +158,7 @@ export function SectorRadarPage() {
/>
) : null}
<RadarTable
key={`${publication.publication_id}:${response.sector_type}:${response.view}:${response.rank_change_metric}:${response.rank_change_days}`}
bottom={bottomRankings}
response={response}
top={topRankings}
@@ -201,37 +211,38 @@ function RadarFilters({
/>
<TabList
ariaLabel="指标视角"
className="lg:col-span-4"
className="lg:col-span-6"
options={viewOptions}
value={search.view}
onChange={(value) => onChange({ page: 1, view: value as RadarView })}
/>
<RadarSectorSearch
key={search.tradeDate ?? "latest"}
tradeDate={search.tradeDate}
/>
{search.view === "rank_change" ? (
<>
<FilterSelect
className="lg:col-span-2"
label="变化指标"
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border pt-3 lg:col-span-12">
<TabList
ariaLabel="排序基准"
className="min-w-64"
inline
options={metricOptions}
value={search.rankChangeMetric}
onValueChange={(value) =>
onChange={(value) =>
onChange({ page: 1, rankChangeMetric: value as RadarMetricKind })
}
/>
<FilterSelect
className="lg:col-span-2"
label="对比区间"
label="统计天数"
inline
options={rankChangeDayOptions}
value={String(search.rankChangeDays)}
onValueChange={(value) =>
onChange({ page: 1, rankChangeDays: Number(value) })
}
/>
</>
</div>
) : null}
<RadarSectorSearch
key={search.tradeDate ?? "latest"}
tradeDate={search.tradeDate}
/>
</div>
)
}
@@ -239,30 +250,36 @@ function RadarFilters({
function TabList({
ariaLabel,
className,
inline = false,
onChange,
options,
value,
}: {
ariaLabel: string
className?: string
inline?: boolean
onChange: (value: string) => void
options: readonly { label: string; value: string }[]
value: string
}) {
return (
<div
className={`grid gap-1 text-xs font-medium text-muted-foreground ${className ?? ""}`}
className={`${inline ? "flex items-center gap-3" : "grid gap-1"} text-xs font-medium text-muted-foreground ${className ?? ""}`}
>
<span>{ariaLabel}</span>
<div
aria-label={ariaLabel}
className="flex rounded-md border border-input bg-background p-0.5"
className={
inline
? "flex gap-2"
: "flex rounded-md border border-input bg-background p-0.5"
}
role="tablist"
>
{options.map((option) => (
<button
aria-selected={value === option.value}
className={`min-h-9 flex-1 rounded-sm px-2 text-xs transition-colors ${value === option.value ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground"}`}
className={`min-h-9 flex-1 px-2 text-xs transition-colors ${inline ? `border-b-2 ${value === option.value ? "border-foreground text-foreground" : "border-transparent hover:text-foreground"}` : `rounded-sm ${value === option.value ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground"}`}`}
key={option.value}
onClick={() => onChange(option.value)}
role="tab"
@@ -278,12 +295,14 @@ function TabList({
function FilterSelect({
className,
inline = false,
label,
onValueChange,
options,
value,
}: {
className?: string
inline?: boolean
label: string
onValueChange: (value: string) => void
options: readonly { label: string; value: string }[]
@@ -291,7 +310,7 @@ function FilterSelect({
}) {
return (
<label
className={`grid gap-1 text-xs font-medium text-muted-foreground ${className ?? ""}`}
className={`${inline ? "flex items-center gap-3 whitespace-nowrap" : "grid gap-1"} text-xs font-medium text-muted-foreground ${className ?? ""}`}
>
{label}
<Select
@@ -354,28 +373,36 @@ function RadarTable({
bottom: RankingQueryResult
response: RadarRankingsResponse
}) {
const topRows = top.data?.pages.flatMap((page) => page.rows) ?? []
const bottomRows = bottom.data?.pages.flatMap((page) => page.rows) ?? []
const [topSort, setTopSort] = useState<RadarSort | null>(null)
const [bottomSort, setBottomSort] = useState<RadarSort | null>(null)
const loadTop = useCompleteRankingPool(top, Boolean(topSort))
const loadBottom = useCompleteRankingPool(bottom, Boolean(bottomSort))
// A secondary sort is meaningful only after every candidate on that side is loaded.
const topRows = sortRadarRows(
top.data?.pages.flatMap((page) => page.rows) ?? [],
top.hasNextPage ? null : topSort,
)
const bottomRows = sortRadarRows(
bottom.data?.pages.flatMap((page) => page.rows) ?? [],
bottom.hasNextPage ? null : bottomSort,
)
const rowCount = Math.max(topRows.length, bottomRows.length)
const rankChangeView = response.view === "rank_change"
const metricView = !rankChangeView
const columns =
response.view === "ratio" || response.view === "swing" ? 10 : 8
const loadMoreRequestPending = useRef({ top: false, bottom: false })
function requestLoadMore(query: RankingQueryResult) {
const side = query === top ? "top" : "bottom"
if (
loadMoreRequestPending.current[side] ||
query.isFetchingNextPage ||
!query.hasNextPage ||
query.isFetchNextPageError
)
return
loadMoreRequestPending.current[side] = true
void query.fetchNextPage().finally(() => {
loadMoreRequestPending.current[side] = false
})
}
const topColumns = radarColumns(
response.view,
response.rank_change_metric,
"top",
)
const bottomColumns = radarColumns(
response.view,
response.rank_change_metric,
"bottom",
)
const columns = topColumns.length + bottomColumns.length
const changeScale = Math.max(
1,
...[...topRows, ...bottomRows].map((row) => Math.abs(row.rank_change ?? 0)),
)
return (
<section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-card">
<div
@@ -384,8 +411,8 @@ function RadarTable({
onScroll={(event) => {
const c = event.currentTarget
if (c.scrollHeight - c.scrollTop - c.clientHeight <= 160) {
requestLoadMore(top)
requestLoadMore(bottom)
loadTop()
loadBottom()
}
}}
role="region"
@@ -395,127 +422,81 @@ function RadarTable({
<caption className="sr-only">
{response.sector_type === "concept" ? "概念" : "行业"}板块资金双榜
</caption>
<colgroup>
{Array.from({ length: columns }, (_, i) => (
<col
key={i}
className={i === 3 || i === 4 ? "w-[120px]" : "w-[100px]"}
/>
))}
</colgroup>
<thead className="sticky top-0 z-10 bg-muted/95 text-xs text-muted-foreground backdrop-blur">
<tr>
<th
className="px-3 py-1 text-left font-medium"
className="px-3 py-3 text-left font-semibold text-foreground"
colSpan={(columns - 2) / 2}
scope="colgroup"
>
资金进攻榜 TOP 10%
{rankChangeView ? (
<span
aria-hidden="true"
className="mr-2 inline-block h-4 w-1 align-middle bg-red-500"
/>
) : null}
{rankChangeView ? "排名飙升榜 TOP 10%" : "资金进攻榜 TOP 10%"}
</th>
<th
className="border-x border-border px-3 py-1 text-center font-medium"
className="border-x border-border px-3 py-3 text-center font-medium whitespace-nowrap text-foreground"
colSpan={2}
scope="colgroup"
>
{rankChangeView ? "排名变化" : response.definition.label}
{rankChangeView
? `${radarMetricLabels[response.rank_change_metric]}排名变化`
: response.definition.label}
</th>
<th
className="px-3 py-1 text-right font-medium"
className="px-3 py-3 text-right font-semibold text-foreground"
colSpan={(columns - 2) / 2}
scope="colgroup"
>
BOTTOM 10% 资金撤离榜
{rankChangeView
? "BOTTOM 10% 排名暴跌榜"
: "BOTTOM 10% 资金撤离榜"}
{rankChangeView ? (
<span
aria-hidden="true"
className="ml-2 inline-block h-4 w-1 align-middle bg-emerald-500"
/>
) : null}
</th>
</tr>
{metricView ? (
<tr>
{(response.view !== "amount"
? [
"板块/排名",
"涨跌幅",
"净额",
"在榜",
response.view === "swing" ? "流入" : "流入率",
response.view === "swing" ? "流出" : "流入率",
"在榜",
"净额",
"涨跌幅",
"板块/排名",
]
: [
"板块/排名",
"涨跌幅",
"单日流入率",
"净额",
"净额",
"单日流入率",
"涨跌幅",
"板块/排名",
]
).map((label, index) => (
<th
key={index}
scope="col"
className="px-3 py-1 text-right font-medium"
>
{label}
</th>
))}
</tr>
) : (
<>
<tr>
<th className="px-3 py-1 text-left font-medium" scope="col">
板块/排名
</th>
<th className="px-3 py-1 text-right font-medium" scope="col">
排名百分位
</th>
<th className="px-3 py-1 text-right font-medium" scope="col">
样本
</th>
<th
className="border-l border-border px-3 py-1 text-right font-medium"
scope="col"
>
{rankChangeView ? "排名上升" : "流入"}
</th>
<th
className="border-r border-border px-3 py-1 text-left font-medium"
scope="col"
>
{rankChangeView ? "排名下降" : "流出"}
</th>
<th className="px-3 py-1 text-right font-medium" scope="col">
样本
</th>
<th className="px-3 py-1 text-right font-medium" scope="col">
排名百分位
</th>
<th className="px-3 py-1 text-right font-medium" scope="col">
板块/排名
</th>
</tr>
</>
)}
<tr>
<RadarColumnHeaders
columns={topColumns}
side="top"
sort={topSort}
onSort={setTopSort}
/>
<RadarColumnHeaders
columns={bottomColumns}
side="bottom"
sort={bottomSort}
onSort={setBottomSort}
/>
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{Array.from({ length: rowCount }, (_, i) => (
<RadarMirrorRow
key={`${response.publication?.publication_id}:${response.view}:${topRows[i]?.sector_code}:${bottomRows[i]?.sector_code}`}
view={response.view}
rankChangeView={rankChangeView}
rankChangeMetric={response.rank_change_metric}
comparisonDate={response.comparison_trade_date}
changeScale={changeScale}
top={topRows[i]}
bottom={bottomRows[i]}
/>
))}
{rowCount === 0 ? (
<tr>
<td
className="px-3 py-8 text-center text-muted-foreground"
colSpan={columns}
>
没有符合当前筛选条件的板块。
<td className="p-0 text-muted-foreground" colSpan={columns}>
<div className="sticky left-0 w-fit max-w-[calc(100vw-3rem)] px-3 py-8">
{rankChangeView
? "暂无可比历史,当前窗口没有可计算排名变化的板块。"
: "没有符合当前筛选条件的板块。"}
</div>
</td>
</tr>
) : null}
@@ -527,7 +508,9 @@ function RadarTable({
className="border-t border-border/60 px-3 py-2 text-center text-xs text-muted-foreground"
role="status"
>
正在加载更多板块…
{(topSort && top.hasNextPage) || (bottomSort && bottom.hasNextPage)
? "正在补齐榜单以完成排序…"
: "正在加载更多板块…"}
</div>
) : null}
{top.isFetchNextPageError || bottom.isFetchNextPageError ? (
@@ -546,7 +529,6 @@ function RadarTable({
{top.isFetchNextPageError ? (
<Button
onClick={() => {
loadMoreRequestPending.current.top = false
void top.fetchNextPage()
}}
size="sm"
@@ -558,7 +540,6 @@ function RadarTable({
{bottom.isFetchNextPageError ? (
<Button
onClick={() => {
loadMoreRequestPending.current.bottom = false
void bottom.fetchNextPage()
}}
size="sm"
@@ -574,16 +555,94 @@ function RadarTable({
)
}
/** Load the bounded candidate pool before applying a client-side column sort. */
function useCompleteRankingPool(query: RankingQueryResult, active: boolean) {
const { fetchNextPage, hasNextPage, isFetching, isFetchNextPageError } = query
const pageCount = query.data?.pages.length ?? 0
const requestedPageCount = useRef<number | null>(null)
const loadMore = useCallback(() => {
if (
!hasNextPage ||
isFetching ||
isFetchNextPageError ||
requestedPageCount.current === pageCount
)
return
requestedPageCount.current = pageCount
void fetchNextPage()
}, [fetchNextPage, hasNextPage, isFetching, isFetchNextPageError, pageCount])
useEffect(() => {
if (active) loadMore()
}, [active, loadMore])
return loadMore
}
function RadarColumnHeaders({
columns,
side,
sort,
onSort,
}: {
columns: RadarColumn[]
side: "top" | "bottom"
sort: RadarSort | null
onSort: (sort: RadarSort) => void
}) {
return columns.map((column) => {
const key = column.key
const direction = key && sort?.key === key ? sort.direction : undefined
return (
<th
key={key ?? "name"}
scope="col"
aria-label={column.label}
aria-sort={direction}
className={`px-2 py-2 whitespace-nowrap font-medium ${key || side === "bottom" ? "text-right" : "text-left"}`}
>
{key ? (
<button
type="button"
className="inline-flex min-h-7 items-center gap-1 hover:text-foreground"
aria-label={`${side === "top" ? "左榜" : "右榜"}按${column.label}排序`}
onClick={() =>
onSort({
key,
direction:
direction === "descending" ? "ascending" : "descending",
})
}
>
{column.label}
<span aria-hidden="true" className="text-muted-foreground">
{direction === "ascending"
? "↑"
: direction === "descending"
? "↓"
: "↕"}
</span>
</button>
) : (
column.label
)}
</th>
)
})
}
function RadarMirrorRow({
top,
bottom,
rankChangeView,
rankChangeMetric,
comparisonDate,
changeScale,
view,
}: {
view: RadarView
top?: RadarRankingRow
bottom?: RadarRankingRow
rankChangeView: boolean
rankChangeMetric: RadarMetricKind
comparisonDate: string | null
changeScale: number
}) {
const [expanded, setExpanded] = useState<"top" | "bottom" | null>(null)
if (view !== "rank_change")
@@ -610,7 +669,7 @@ function RadarMirrorRow({
{expanded && (expanded === "top" ? top : bottom) ? (
<tr>
<td
colSpan={view !== "amount" ? 10 : 8}
colSpan={view !== "amount" ? 12 : 8}
className="bg-muted/20 p-3"
>
<RadarHistory
@@ -629,14 +688,20 @@ function RadarMirrorRow({
)
return (
<tr className="hover:bg-muted/40">
<RankCells row={top} align="left" />
<MetricCell direction="in" rankChangeView={rankChangeView} row={top} />
<MetricCell
direction="out"
rankChangeView={rankChangeView}
row={bottom}
<RankChangeCells
row={top}
side="top"
metric={rankChangeMetric}
comparisonDate={comparisonDate}
changeScale={changeScale}
/>
<RankChangeCells
row={bottom}
side="bottom"
metric={rankChangeMetric}
comparisonDate={comparisonDate}
changeScale={changeScale}
/>
<RankCells row={bottom} align="right" />
</tr>
)
}
@@ -657,7 +722,7 @@ function MetricCells({
if (!row)
return (
<td
colSpan={view !== "amount" ? 5 : 4}
colSpan={view !== "amount" ? 6 : 4}
className="p-3 text-center text-muted-foreground"
>
—
@@ -673,6 +738,17 @@ function MetricCells({
>
{formatRadarValue(row.pct_change, "percent")}
</td>,
...(view !== "amount"
? [
<td
key="score"
className="px-3 py-2 text-right font-medium tabular-nums"
title="资金率排名与成交额加权评分"
>
{row.weighted_score == null ? "—" : row.weighted_score.toFixed(1)}
</td>,
]
: []),
<td
key="extra"
className={`px-3 py-2 text-right tabular-nums ${radarValueTone(view !== "amount" ? row.daily_net_amount_yuan : row.daily_ratio)}`}
@@ -718,77 +794,71 @@ function SectorRank({ row }: { row: RadarRankingRow }) {
)
}
function RankCells({
function RankChangeCells({
row,
align,
side,
metric,
comparisonDate,
changeScale,
}: {
row?: RadarRankingRow
align: "left" | "right"
side: "top" | "bottom"
metric: RadarMetricKind
comparisonDate: string | null
changeScale: number
}) {
const rankClass = align === "right" ? "text-right" : "text-left"
const rankCell = row ? (
<td className={`px-3 py-2 whitespace-nowrap ${rankClass}`}>
<SectorRank row={row} />
</td>
) : (
<td className="px-3 py-1.5 text-muted-foreground">—</td>
)
const percentileCell = row ? (
<td className="px-3 py-1.5 text-right tabular-nums">
{row.rank_percentile === null
? "—"
: `${formatNumber(row.rank_percentile, 2)}%`}
</td>
) : (
<td />
)
const sampleCell = row ? (
<td className="px-3 py-1.5 text-right tabular-nums">
{row.valid_sample_count}/{row.member_count}
</td>
) : (
<td />
)
if (align === "right") {
if (!row)
return (
<>
{sampleCell}
{percentileCell}
{rankCell}
</>
<td colSpan={5} className="p-3 text-center text-muted-foreground">
—
</td>
)
}
return (
<>
{rankCell}
{percentileCell}
{sampleCell}
</>
)
}
function MetricCell({
row,
rankChangeView,
direction,
}: {
row?: RadarRankingRow
rankChangeView: boolean
direction: "in" | "out"
}) {
const value = row
? rankChangeView
? formatRankChange(row.rank_change)
: formatRadarValue(row.metric_value, row.unit)
: "—"
const borderClass = direction === "in" ? "border-l" : "border-r"
const toneClass = `${radarValueTone(rankChangeView ? row?.rank_change : row?.metric_value)} bg-muted/30`
return (
const fields = [
<td
className={`${borderClass} border-border px-3 py-1.5 ${direction === "in" ? "text-right" : "text-left"} font-medium tabular-nums ${toneClass}`}
key="name"
className={`px-3 py-2 whitespace-nowrap ${side === "bottom" ? "text-right" : ""}`}
>
{value}
</td>
)
<SectorRank row={row} />
</td>,
<td
key="pct"
className={`px-3 py-2 text-right tabular-nums ${radarValueTone(row.pct_change)}`}
>
{formatRadarValue(row.pct_change, "percent")}
</td>,
...rankChangeAuxiliaryMetrics(metric).map((kind) => (
<td
key={kind}
className={`px-3 py-2 text-right whitespace-nowrap tabular-nums ${radarValueTone(row.rank_change_values[kind])}`}
title={
row.rank_change_values[kind] == null
? "暂无可比历史"
: `${radarMetricLabels[kind]}相比 ${comparisonDate ?? "历史交易日"}`
}
>
{formatRankChange(row.rank_change_values[kind])}
</td>
)),
<td
key="change"
className={`relative min-w-24 border-x border-border px-3 py-2 font-medium whitespace-nowrap tabular-nums ${side === "top" ? "text-right" : "text-left"} ${radarValueTone(row.rank_change)}`}
title={
row.rank_change == null
? "暂无可比历史"
: `相比 ${comparisonDate ?? "历史交易日"}`
}
>
<span
aria-hidden="true"
className={`absolute inset-y-1 bg-current opacity-10 ${side === "top" ? "right-0" : "left-0"}`}
style={{
width: `${(100 * Math.abs(row.rank_change ?? 0)) / changeScale}%`,
}}
/>
<span className="relative">{formatRankChange(row.rank_change)}</span>
</td>,
]
return <>{side === "top" ? fields : fields.reverse()}</>
}
function RadarLoading() {
@@ -868,14 +938,11 @@ function RadarNoData() {
)
}
function formatRankChange(value: number | null) {
if (value === null) return "暂无可比历史"
if (value === null) return "—"
if (value > 0) return `+${value} 名`
if (value < 0) return `${value} 名`
return "0 名"
}
function formatNumber(value: number, maximumFractionDigits: number) {
return value.toLocaleString("zh-CN", { maximumFractionDigits })
}
function attemptStatusLabel(status: RadarPublication["status"]) {
if (status === "running") return "新一期仍在构建"
if (status === "partial") return "新一期数据不完整"
+1 -1
View File
@@ -75,7 +75,7 @@ const sectorRadarRoute = createRoute({
const rankChangeMetric = validatedSearchValue<RadarMetricKind>(
search.rankChangeMetric,
radarMetricKinds,
"amount",
"swing",
)
const side = validatedSearchValue<RadarRankSide>(
search.side,