feat(sector-radar): add weighted scores and rank-change views
This commit is contained in:
@@ -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 "新一期数据不完整"
|
||||
|
||||
@@ -75,7 +75,7 @@ const sectorRadarRoute = createRoute({
|
||||
const rankChangeMetric = validatedSearchValue<RadarMetricKind>(
|
||||
search.rankChangeMetric,
|
||||
radarMetricKinds,
|
||||
"amount",
|
||||
"swing",
|
||||
)
|
||||
const side = validatedSearchValue<RadarRankSide>(
|
||||
search.side,
|
||||
|
||||
Reference in New Issue
Block a user