13a51bc7f5
1. 在 web/package.json 中添加 recharts 的必需依赖:react-redux 和 reselect 2. 在 lightning-distribution/page.tsx 中导入 recharts 组件 3. 将"峰值超越概率(P 曲线)"卡片改为折线图 + 可收起表格的展示方式: - 折线图为主视图,展示电流阈值(kA)与超越概率的关系 - Y 轴在 50% 处添加水平参考线标注中值 - 支持 tooltip 悬浮查看精确数值(阈值、概率、次数) - 表格放在 details 元素中,默认收起,用户可展开查看精确数值 4. 图表使用项目已有的 recharts 库,与 lightning-currents 页面保持一致 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
796 lines
35 KiB
TypeScript
796 lines
35 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
Alert,
|
|
Button,
|
|
Descriptions,
|
|
Empty,
|
|
Form,
|
|
Input,
|
|
InputNumber,
|
|
Select,
|
|
Space,
|
|
Table,
|
|
Tag,
|
|
Typography,
|
|
} from "antd";
|
|
import type { ColumnsType } from "antd/es/table";
|
|
import { useCallback, useMemo, useRef, useState, type CSSProperties } from "react";
|
|
import {
|
|
LineChart,
|
|
Line,
|
|
XAxis,
|
|
YAxis,
|
|
CartesianGrid,
|
|
Tooltip,
|
|
ReferenceLine,
|
|
ResponsiveContainer,
|
|
} from "recharts";
|
|
|
|
import { useAuth } from "@/components/auth-provider";
|
|
import { AdminPageLoading } from "@/components/admin-page-loading";
|
|
import { LightningDistributionMap } from "@/components/lightning-distribution-map";
|
|
import { Card } from "@/components/ui-antd";
|
|
import { useToastFeedback } from "@/hooks/use-toast-feedback";
|
|
import { useTopicSubscription } from "@/hooks/use-topic-subscription";
|
|
import { readApiError } from "@/lib/api";
|
|
import type {
|
|
LightningDistributionImportResponse,
|
|
LightningDistributionReportResponse,
|
|
LightningDistributionStatsResponse,
|
|
LightningPolarity,
|
|
LightningSyntheticCompareResponse,
|
|
LightningTowerBufferStatsResponse,
|
|
} from "@/types/auth";
|
|
|
|
type DistributionFilterValues = {
|
|
min_lat: number | null;
|
|
max_lat: number | null;
|
|
min_lon: number | null;
|
|
max_lon: number | null;
|
|
grid_size_km: number;
|
|
years: number | null;
|
|
thresholds: string;
|
|
};
|
|
|
|
type TowerBufferFormValues = {
|
|
tower_id: string;
|
|
longitude: number | null;
|
|
latitude: number | null;
|
|
radius_km: number;
|
|
design_current_ka: number;
|
|
};
|
|
|
|
const INITIAL_DISTRIBUTION_FILTERS: DistributionFilterValues = {
|
|
min_lat: null,
|
|
max_lat: null,
|
|
min_lon: null,
|
|
max_lon: null,
|
|
grid_size_km: 1,
|
|
years: null,
|
|
thresholds: "",
|
|
};
|
|
|
|
const INITIAL_TOWER_BUFFER_VALUES: TowerBufferFormValues = {
|
|
tower_id: "",
|
|
longitude: null,
|
|
latitude: null,
|
|
radius_km: 3,
|
|
design_current_ka: 100,
|
|
};
|
|
|
|
const POLARITY_OPTIONS = [
|
|
{ value: "all", label: "全部极性" },
|
|
{ value: "positive", label: "正极性" },
|
|
{ value: "negative", label: "负极性" },
|
|
{ value: "mixed", label: "混合" },
|
|
{ value: "unknown", label: "未知" },
|
|
] as const;
|
|
|
|
const SYNTHETIC_OPTIONS = [
|
|
{ value: "all", label: "全部来源" },
|
|
{ value: "false", label: "实测" },
|
|
{ value: "true", label: "合成" },
|
|
] as const;
|
|
|
|
function formatNullable(value: number | string | null | undefined): string {
|
|
if (value === null || value === undefined || value === "") {
|
|
return "-";
|
|
}
|
|
return String(value);
|
|
}
|
|
|
|
function formatNumber(value: number | null | undefined, digits = 3): string {
|
|
if (value === null || value === undefined || Number.isNaN(value)) {
|
|
return "-";
|
|
}
|
|
return value.toFixed(digits);
|
|
}
|
|
|
|
function formatPolarity(polarity: LightningPolarity): string {
|
|
if (polarity === "positive") return "正极性";
|
|
if (polarity === "negative") return "负极性";
|
|
if (polarity === "mixed") return "混合";
|
|
return "未知";
|
|
}
|
|
|
|
function terrainQualityTagColor(level: string | null | undefined): string {
|
|
if (level === "HIGH") return "green";
|
|
if (level === "MEDIUM") return "orange";
|
|
if (level === "LOW") return "red";
|
|
return "default";
|
|
}
|
|
|
|
function heatCellStyle(count: number, maxCount: number): CSSProperties {
|
|
if (maxCount <= 0 || count <= 0) {
|
|
return {};
|
|
}
|
|
const ratio = Math.min(1, Math.max(0, count / maxCount));
|
|
const alpha = Math.max(0.08, ratio * 0.46);
|
|
return {
|
|
background: `rgba(239, 68, 68, ${alpha.toFixed(3)})`,
|
|
};
|
|
}
|
|
|
|
export default function AdminLightningDistributionPage() {
|
|
const { user, initializing, hasPermission, fetchWithAuth } = useAuth();
|
|
const queryClient = useQueryClient();
|
|
const distributionUploadInputRef = useRef<HTMLInputElement | null>(null);
|
|
const [distributionForm] = Form.useForm<DistributionFilterValues>();
|
|
const [towerBufferForm] = Form.useForm<TowerBufferFormValues>();
|
|
const [keyword, setKeyword] = useState("");
|
|
const [regionFilter, setRegionFilter] = useState("");
|
|
const [polarityFilter, setPolarityFilter] = useState<(typeof POLARITY_OPTIONS)[number]["value"]>("all");
|
|
const [syntheticFilter, setSyntheticFilter] = useState<(typeof SYNTHETIC_OPTIONS)[number]["value"]>("all");
|
|
const [distributionFilters, setDistributionFilters] = useState<DistributionFilterValues>(INITIAL_DISTRIBUTION_FILTERS);
|
|
const [towerBufferValues, setTowerBufferValues] = useState<TowerBufferFormValues>(INITIAL_TOWER_BUFFER_VALUES);
|
|
const [reportPeriod, setReportPeriod] = useState<"week" | "month">("week");
|
|
const [error, setError] = useState("");
|
|
const [success, setSuccess] = useState("");
|
|
|
|
const canRead = hasPermission("lightning.read") || hasPermission("lightning.manage");
|
|
const canManage = hasPermission("lightning.manage");
|
|
|
|
const distributionStatsPath = useMemo(() => {
|
|
const params = new URLSearchParams();
|
|
if (distributionFilters.min_lat !== null) params.set("min_lat", String(distributionFilters.min_lat));
|
|
if (distributionFilters.max_lat !== null) params.set("max_lat", String(distributionFilters.max_lat));
|
|
if (distributionFilters.min_lon !== null) params.set("min_lon", String(distributionFilters.min_lon));
|
|
if (distributionFilters.max_lon !== null) params.set("max_lon", String(distributionFilters.max_lon));
|
|
if (regionFilter.trim()) params.set("region_id", regionFilter.trim());
|
|
if (keyword.trim()) params.set("location_tag", keyword.trim());
|
|
if (syntheticFilter !== "all") params.set("is_synthetic", syntheticFilter);
|
|
if (polarityFilter !== "all") params.set("polarity", polarityFilter);
|
|
if (distributionFilters.years !== null) params.set("years", String(distributionFilters.years));
|
|
params.set("grid_size_km", String(distributionFilters.grid_size_km));
|
|
params.set("grid_limit", "1000");
|
|
params.set("scatter_limit", "2000");
|
|
if (distributionFilters.thresholds.trim()) params.set("thresholds", distributionFilters.thresholds.trim());
|
|
return `/api/v1/lightning-currents/stats/distribution?${params.toString()}`;
|
|
}, [distributionFilters, keyword, polarityFilter, regionFilter, syntheticFilter]);
|
|
|
|
const towerBufferPath = useMemo(() => {
|
|
const params = new URLSearchParams();
|
|
if (towerBufferValues.tower_id.trim()) {
|
|
params.set("tower_id", towerBufferValues.tower_id.trim());
|
|
} else {
|
|
if (towerBufferValues.longitude !== null) params.set("longitude", String(towerBufferValues.longitude));
|
|
if (towerBufferValues.latitude !== null) params.set("latitude", String(towerBufferValues.latitude));
|
|
}
|
|
params.set("radius_km", String(towerBufferValues.radius_km));
|
|
params.set("design_current_ka", String(towerBufferValues.design_current_ka));
|
|
if (regionFilter.trim()) params.set("region_id", regionFilter.trim());
|
|
if (syntheticFilter !== "all") params.set("is_synthetic", syntheticFilter);
|
|
params.set("include_events_limit", "100");
|
|
return `/api/v1/lightning-currents/stats/tower-buffer?${params.toString()}`;
|
|
}, [towerBufferValues, regionFilter, syntheticFilter]);
|
|
|
|
const syntheticComparePath = useMemo(() => {
|
|
const params = new URLSearchParams();
|
|
if (distributionFilters.min_lat !== null) params.set("min_lat", String(distributionFilters.min_lat));
|
|
if (distributionFilters.max_lat !== null) params.set("max_lat", String(distributionFilters.max_lat));
|
|
if (distributionFilters.min_lon !== null) params.set("min_lon", String(distributionFilters.min_lon));
|
|
if (distributionFilters.max_lon !== null) params.set("max_lon", String(distributionFilters.max_lon));
|
|
if (regionFilter.trim()) params.set("region_id", regionFilter.trim());
|
|
if (keyword.trim()) params.set("location_tag", keyword.trim());
|
|
params.set("grid_size_km", String(distributionFilters.grid_size_km));
|
|
if (distributionFilters.years !== null) params.set("years", String(distributionFilters.years));
|
|
return `/api/v1/lightning-currents/stats/compare-synthetic?${params.toString()}`;
|
|
}, [distributionFilters, keyword, regionFilter]);
|
|
|
|
const reportPath = useMemo(() => {
|
|
const params = new URLSearchParams();
|
|
params.set("period", reportPeriod);
|
|
if (distributionFilters.min_lat !== null) params.set("min_lat", String(distributionFilters.min_lat));
|
|
if (distributionFilters.max_lat !== null) params.set("max_lat", String(distributionFilters.max_lat));
|
|
if (distributionFilters.min_lon !== null) params.set("min_lon", String(distributionFilters.min_lon));
|
|
if (distributionFilters.max_lon !== null) params.set("max_lon", String(distributionFilters.max_lon));
|
|
if (regionFilter.trim()) params.set("region_id", regionFilter.trim());
|
|
if (keyword.trim()) params.set("location_tag", keyword.trim());
|
|
if (syntheticFilter !== "all") params.set("is_synthetic", syntheticFilter);
|
|
return `/api/v1/lightning-currents/reports/distribution?${params.toString()}`;
|
|
}, [distributionFilters, keyword, regionFilter, reportPeriod, syntheticFilter]);
|
|
|
|
const distributionStatsQuery = useQuery({
|
|
queryKey: [distributionStatsPath],
|
|
enabled: !!user && canRead,
|
|
queryFn: async () => {
|
|
const response = await fetchWithAuth(distributionStatsPath);
|
|
if (!response.ok) {
|
|
throw new Error(await readApiError(response));
|
|
}
|
|
return (await response.json()) as LightningDistributionStatsResponse;
|
|
},
|
|
});
|
|
|
|
const towerBufferReady = useMemo(() => {
|
|
if (towerBufferValues.tower_id.trim()) return true;
|
|
return towerBufferValues.longitude !== null && towerBufferValues.latitude !== null;
|
|
}, [towerBufferValues]);
|
|
|
|
const towerBufferQuery = useQuery({
|
|
queryKey: [towerBufferPath],
|
|
enabled: !!user && canRead && towerBufferReady,
|
|
queryFn: async () => {
|
|
const response = await fetchWithAuth(towerBufferPath);
|
|
if (!response.ok) {
|
|
throw new Error(await readApiError(response));
|
|
}
|
|
return (await response.json()) as LightningTowerBufferStatsResponse;
|
|
},
|
|
});
|
|
|
|
const syntheticCompareQuery = useQuery({
|
|
queryKey: [syntheticComparePath],
|
|
enabled: !!user && canRead,
|
|
queryFn: async () => {
|
|
const response = await fetchWithAuth(syntheticComparePath);
|
|
if (!response.ok) {
|
|
throw new Error(await readApiError(response));
|
|
}
|
|
return (await response.json()) as LightningSyntheticCompareResponse;
|
|
},
|
|
});
|
|
|
|
const reportQuery = useQuery({
|
|
queryKey: [reportPath],
|
|
enabled: !!user && canRead,
|
|
queryFn: async () => {
|
|
const response = await fetchWithAuth(reportPath);
|
|
if (!response.ok) {
|
|
throw new Error(await readApiError(response));
|
|
}
|
|
return (await response.json()) as LightningDistributionReportResponse;
|
|
},
|
|
});
|
|
|
|
const distributionError = distributionStatsQuery.error instanceof Error ? distributionStatsQuery.error.message : "";
|
|
const towerBufferError = towerBufferQuery.error instanceof Error ? towerBufferQuery.error.message : "";
|
|
const compareError = syntheticCompareQuery.error instanceof Error ? syntheticCompareQuery.error.message : "";
|
|
const reportError = reportQuery.error instanceof Error ? reportQuery.error.message : "";
|
|
|
|
useToastFeedback({
|
|
errorMessage: error || distributionError || towerBufferError || compareError || reportError,
|
|
successMessage: success,
|
|
clearError: () => setError(""),
|
|
clearSuccess: () => setSuccess(""),
|
|
});
|
|
|
|
const refreshAll = useCallback(async () => {
|
|
await queryClient.invalidateQueries({
|
|
predicate: (query) =>
|
|
Array.isArray(query.queryKey)
|
|
&& typeof query.queryKey[0] === "string"
|
|
&& query.queryKey[0].startsWith("/api/v1/lightning-currents"),
|
|
});
|
|
}, [queryClient]);
|
|
|
|
useTopicSubscription(
|
|
"admin.lightning-currents",
|
|
useCallback(() => {
|
|
void refreshAll();
|
|
}, [refreshAll]),
|
|
);
|
|
|
|
const distributionStats = distributionStatsQuery.data;
|
|
const distributionGridCells = useMemo(() => distributionStats?.grid_cells ?? [], [distributionStats?.grid_cells]);
|
|
const distributionScatterPoints = distributionStats?.scatter_points ?? [];
|
|
const distributionPCurve = distributionStats?.p_curve ?? [];
|
|
const towerBufferStats = towerBufferQuery.data;
|
|
const towerTerrainMetrics = towerBufferStats?.terrain_metrics ?? null;
|
|
const syntheticCompare = syntheticCompareQuery.data;
|
|
const distributionReport = reportQuery.data;
|
|
|
|
const distributionImportMutation = useMutation({
|
|
mutationFn: async (file: File) => {
|
|
if (!canManage) {
|
|
throw new Error("缺少 lightning.manage 权限");
|
|
}
|
|
const formData = new FormData();
|
|
formData.append("file", file);
|
|
if (regionFilter.trim()) formData.append("region_id", regionFilter.trim());
|
|
if (keyword.trim()) formData.append("location_tag", keyword.trim());
|
|
if (syntheticFilter !== "all") formData.append("is_synthetic", syntheticFilter);
|
|
const response = await fetchWithAuth("/api/v1/lightning-currents/import-distribution", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(await readApiError(response));
|
|
}
|
|
return (await response.json()) as LightningDistributionImportResponse;
|
|
},
|
|
onSuccess: async (payload) => {
|
|
setError("");
|
|
setSuccess(
|
|
payload.warning_count > 0
|
|
? `分布导入完成:成功 ${payload.imported_count} 条,跳过 ${payload.skipped_count} 条,告警 ${payload.warning_count} 条`
|
|
: `分布导入完成:成功 ${payload.imported_count} 条`,
|
|
);
|
|
await refreshAll();
|
|
},
|
|
onError: (candidate) => {
|
|
setSuccess("");
|
|
setError(candidate instanceof Error ? candidate.message : "分布导入失败");
|
|
},
|
|
});
|
|
|
|
const maxGridCount = useMemo(
|
|
() => distributionGridCells.reduce((max, item) => Math.max(max, item.strike_count), 0),
|
|
[distributionGridCells],
|
|
);
|
|
|
|
const distributionGridColumns = useMemo<ColumnsType<LightningDistributionStatsResponse["grid_cells"][number]>>(
|
|
() => [
|
|
{ title: "网格", key: "grid", width: 120, render: (_: unknown, row) => `${row.grid_x},${row.grid_y}` },
|
|
{
|
|
title: "雷击次数",
|
|
dataIndex: "strike_count",
|
|
width: 120,
|
|
render: (value: number) => (
|
|
<div style={heatCellStyle(value, maxGridCount)} className="rounded px-2 py-1 text-center font-semibold">
|
|
{value}
|
|
</div>
|
|
),
|
|
},
|
|
{ title: "Ng(次/km²·年)", dataIndex: "ng_per_km2_year", width: 140, render: (value: number) => formatNumber(value, 3) },
|
|
{ title: "Imax(kA)", dataIndex: "i_max_ka", width: 120, render: (value: number | null) => formatNumber(value, 2) },
|
|
{ title: "Iavg(kA)", dataIndex: "i_avg_ka", width: 120, render: (value: number | null) => formatNumber(value, 2) },
|
|
{
|
|
title: "正极占比",
|
|
dataIndex: "positive_ratio",
|
|
width: 120,
|
|
render: (value: number) => `${(value * 100).toFixed(2)}%`,
|
|
},
|
|
{
|
|
title: "中心坐标",
|
|
key: "center",
|
|
width: 220,
|
|
render: (_: unknown, row) => `${row.center_lat.toFixed(5)}, ${row.center_lon.toFixed(5)}`,
|
|
},
|
|
],
|
|
[maxGridCount],
|
|
);
|
|
|
|
const towerBufferEventColumns = useMemo<ColumnsType<LightningTowerBufferStatsResponse["events"][number]>>(
|
|
() => [
|
|
{ title: "事件", dataIndex: "event_id", width: 180, render: (value: string) => <Typography.Text code>{value}</Typography.Text> },
|
|
{ title: "距离(km)", dataIndex: "distance_km", width: 100, render: (value: number) => formatNumber(value, 3) },
|
|
{ title: "电流(kA)", dataIndex: "current_ka", width: 120, render: (value: number | null) => formatNumber(value, 2) },
|
|
{ title: "绝对值(kA)", dataIndex: "abs_current_ka", width: 120, render: (value: number | null) => formatNumber(value, 2) },
|
|
{ title: "极性", dataIndex: "polarity", width: 90, render: (value: LightningPolarity) => formatPolarity(value) },
|
|
{
|
|
title: "坐标",
|
|
key: "coord",
|
|
width: 220,
|
|
render: (_: unknown, row) => `${formatNullable(row.latitude)}, ${formatNullable(row.longitude)}`,
|
|
},
|
|
],
|
|
[],
|
|
);
|
|
|
|
if (initializing || distributionStatsQuery.isLoading) {
|
|
return (
|
|
<AdminPageLoading
|
|
tip="加载地闪密度数据中..."
|
|
minHeightClassName="min-h-[280px]"
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (!user) {
|
|
return (
|
|
<Card>
|
|
<Space direction="vertical" size={12}>
|
|
<Typography.Text type="secondary">
|
|
请先登录后再访问地闪密度统计页面。
|
|
</Typography.Text>
|
|
<Button>
|
|
<Link href="/">返回首页</Link>
|
|
</Button>
|
|
</Space>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
if (!canRead) {
|
|
return (
|
|
<Card>
|
|
<Space direction="vertical" size={12}>
|
|
<Typography.Text type="secondary">你没有访问该页面的权限(需要 `lightning.read`)。</Typography.Text>
|
|
<Button>
|
|
<Link href="/">返回首页</Link>
|
|
</Button>
|
|
</Space>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Space direction="vertical" size={16} className="w-full">
|
|
<Card title="雷电分布统计(空间维度)">
|
|
<Space direction="vertical" size={12} className="w-full">
|
|
<Typography.Text type="secondary">
|
|
基于经纬度与电流幅值计算网格化地闪密度(Ng)、极值/平均值与极性占比,并以热力网格 + 散点图展示雷电空间分布。
|
|
</Typography.Text>
|
|
|
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
|
<Input
|
|
value={keyword}
|
|
allowClear
|
|
onChange={(event) => setKeyword(event.target.value)}
|
|
placeholder="按地点/标签筛选"
|
|
/>
|
|
<Input
|
|
value={regionFilter}
|
|
allowClear
|
|
onChange={(event) => setRegionFilter(event.target.value)}
|
|
placeholder="按 Region ID 筛选"
|
|
/>
|
|
<Select value={polarityFilter} options={[...POLARITY_OPTIONS]} onChange={(value) => setPolarityFilter(value)} />
|
|
<Select value={syntheticFilter} options={[...SYNTHETIC_OPTIONS]} onChange={(value) => setSyntheticFilter(value)} />
|
|
</div>
|
|
|
|
<Form<DistributionFilterValues>
|
|
form={distributionForm}
|
|
layout="vertical"
|
|
initialValues={INITIAL_DISTRIBUTION_FILTERS}
|
|
onFinish={(values) => {
|
|
setDistributionFilters(values);
|
|
}}
|
|
>
|
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
|
<Form.Item name="min_lat" label="最小纬度">
|
|
<InputNumber className="w-full" />
|
|
</Form.Item>
|
|
<Form.Item name="max_lat" label="最大纬度">
|
|
<InputNumber className="w-full" />
|
|
</Form.Item>
|
|
<Form.Item name="min_lon" label="最小经度">
|
|
<InputNumber className="w-full" />
|
|
</Form.Item>
|
|
<Form.Item name="max_lon" label="最大经度">
|
|
<InputNumber className="w-full" />
|
|
</Form.Item>
|
|
<Form.Item name="grid_size_km" label="网格尺寸(km)" rules={[{ required: true, message: "请输入网格尺寸" }]}>
|
|
<InputNumber className="w-full" min={0.1} max={100} precision={2} />
|
|
</Form.Item>
|
|
<Form.Item name="years" label="统计年限(可选)">
|
|
<InputNumber className="w-full" min={0.01} precision={2} />
|
|
</Form.Item>
|
|
<Form.Item name="thresholds" label="P 曲线阈值(kA)">
|
|
<Input placeholder="例如 10,20,50,100" />
|
|
</Form.Item>
|
|
</div>
|
|
<Space wrap>
|
|
<Button type="primary" htmlType="submit" loading={distributionStatsQuery.isFetching}>
|
|
更新分布统计
|
|
</Button>
|
|
<Button
|
|
onClick={() => {
|
|
distributionForm.setFieldsValue(INITIAL_DISTRIBUTION_FILTERS);
|
|
setDistributionFilters(INITIAL_DISTRIBUTION_FILTERS);
|
|
}}
|
|
>
|
|
重置范围
|
|
</Button>
|
|
{canManage && (
|
|
<>
|
|
<Button onClick={() => distributionUploadInputRef.current?.click()} loading={distributionImportMutation.isPending}>
|
|
导入雷电分布文件
|
|
</Button>
|
|
<input
|
|
ref={distributionUploadInputRef}
|
|
type="file"
|
|
accept=".txt,.csv,text/plain,text/csv"
|
|
className="hidden"
|
|
onChange={(event) => {
|
|
const file = event.target.files?.[0];
|
|
if (file) {
|
|
distributionImportMutation.mutate(file);
|
|
}
|
|
event.target.value = "";
|
|
}}
|
|
/>
|
|
</>
|
|
)}
|
|
</Space>
|
|
</Form>
|
|
|
|
<Descriptions bordered size="small" column={4}>
|
|
<Descriptions.Item label="记录总数">{distributionStats?.summary.total_records ?? 0}</Descriptions.Item>
|
|
<Descriptions.Item label="统计面积(km²)">{formatNumber(distributionStats?.summary.area_km2, 3)}</Descriptions.Item>
|
|
<Descriptions.Item label="统计年限">{formatNumber(distributionStats?.summary.data_years, 3)}</Descriptions.Item>
|
|
<Descriptions.Item label="整体 Ng">{formatNumber(distributionStats?.summary.overall_ng_per_km2_year, 4)}</Descriptions.Item>
|
|
<Descriptions.Item label="Imax(kA)">{formatNumber(distributionStats?.summary.max_abs_current_ka, 2)}</Descriptions.Item>
|
|
<Descriptions.Item label="Iavg(kA)">{formatNumber(distributionStats?.summary.avg_abs_current_ka, 2)}</Descriptions.Item>
|
|
<Descriptions.Item label="正极占比">
|
|
{distributionStats ? `${(distributionStats.polarity.positive_ratio * 100).toFixed(2)}%` : "-"}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="负极占比">
|
|
{distributionStats ? `${(distributionStats.polarity.negative_ratio * 100).toFixed(2)}%` : "-"}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="实测条数">{distributionStats?.sources.measured_count ?? 0}</Descriptions.Item>
|
|
<Descriptions.Item label="合成条数">{distributionStats?.sources.synthetic_count ?? 0}</Descriptions.Item>
|
|
<Descriptions.Item label="网格数">{distributionGridCells.length}</Descriptions.Item>
|
|
<Descriptions.Item label="散点数">{distributionScatterPoints.length}</Descriptions.Item>
|
|
</Descriptions>
|
|
|
|
<LightningDistributionMap
|
|
points={distributionScatterPoints}
|
|
grids={distributionGridCells}
|
|
loading={distributionStatsQuery.isFetching}
|
|
/>
|
|
|
|
<Table
|
|
rowKey={(row) => `${row.grid_x}-${row.grid_y}`}
|
|
size="small"
|
|
columns={distributionGridColumns}
|
|
dataSource={distributionGridCells}
|
|
loading={distributionStatsQuery.isFetching}
|
|
pagination={{ pageSize: 20, showSizeChanger: false }}
|
|
scroll={{ x: 1100 }}
|
|
/>
|
|
</Space>
|
|
</Card>
|
|
|
|
<Card title="资产关联分析(杆塔缓冲区)">
|
|
<Space direction="vertical" size={12} className="w-full">
|
|
<Typography.Text type="secondary">
|
|
输入杆塔编号或中心坐标,分析 2-5km 缓冲区内雷击强度,输出 Ng、超设计阈值次数与风险等级。
|
|
</Typography.Text>
|
|
<Form<TowerBufferFormValues>
|
|
form={towerBufferForm}
|
|
layout="vertical"
|
|
initialValues={INITIAL_TOWER_BUFFER_VALUES}
|
|
onFinish={(values) => {
|
|
setTowerBufferValues(values);
|
|
}}
|
|
>
|
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
|
|
<Form.Item name="tower_id" label="杆塔 ID(可选)">
|
|
<Input placeholder="优先使用杆塔ID定位" />
|
|
</Form.Item>
|
|
<Form.Item name="longitude" label="中心经度">
|
|
<InputNumber className="w-full" />
|
|
</Form.Item>
|
|
<Form.Item name="latitude" label="中心纬度">
|
|
<InputNumber className="w-full" />
|
|
</Form.Item>
|
|
<Form.Item name="radius_km" label="缓冲半径(km)" rules={[{ required: true, message: "请输入半径" }]}>
|
|
<InputNumber className="w-full" min={0.2} max={20} precision={2} />
|
|
</Form.Item>
|
|
<Form.Item name="design_current_ka" label="设计耐雷阈值(kA)" rules={[{ required: true, message: "请输入阈值" }]}>
|
|
<InputNumber className="w-full" min={1} max={1000} precision={1} />
|
|
</Form.Item>
|
|
</div>
|
|
<Button type="primary" htmlType="submit" loading={towerBufferQuery.isFetching}>
|
|
执行缓冲区分析
|
|
</Button>
|
|
</Form>
|
|
|
|
{!towerBufferReady ? (
|
|
<Alert type="info" showIcon message="请先输入杆塔 ID,或输入经纬度后再执行分析。" />
|
|
) : !towerBufferStats ? (
|
|
<Empty description="暂无缓冲区分析结果" />
|
|
) : (
|
|
<Space direction="vertical" size={12} className="w-full">
|
|
<Descriptions bordered size="small" column={4}>
|
|
<Descriptions.Item label="杆塔">{towerBufferStats.tower_no || towerBufferStats.tower_id || "自定义坐标"}</Descriptions.Item>
|
|
<Descriptions.Item label="半径(km)">{formatNumber(towerBufferStats.radius_km, 2)}</Descriptions.Item>
|
|
<Descriptions.Item label="雷击次数">{towerBufferStats.strike_count}</Descriptions.Item>
|
|
<Descriptions.Item label="超阈值次数">{towerBufferStats.exceed_design_count}</Descriptions.Item>
|
|
<Descriptions.Item label="Ng">{formatNumber(towerBufferStats.ng_per_km2_year, 4)}</Descriptions.Item>
|
|
<Descriptions.Item label="Imax(kA)">{formatNumber(towerBufferStats.max_abs_current_ka, 2)}</Descriptions.Item>
|
|
<Descriptions.Item label="Iavg(kA)">{formatNumber(towerBufferStats.avg_abs_current_ka, 2)}</Descriptions.Item>
|
|
<Descriptions.Item label="正极占比">{(towerBufferStats.positive_ratio * 100).toFixed(2)}%</Descriptions.Item>
|
|
<Descriptions.Item label="地面倾角(°)">{formatNumber(towerTerrainMetrics?.slope_deg, 2)}</Descriptions.Item>
|
|
<Descriptions.Item label="坡向(°)">{formatNumber(towerTerrainMetrics?.aspect_deg, 1)}</Descriptions.Item>
|
|
<Descriptions.Item label="地形暴露指数">{formatNumber(towerTerrainMetrics?.terrain_exposure_index, 3)}</Descriptions.Item>
|
|
<Descriptions.Item label="DEM分辨率(m)">{formatNumber(towerTerrainMetrics?.dem_resolution_m, 2)}</Descriptions.Item>
|
|
<Descriptions.Item label="纵坡/横坡(°)" span={2}>
|
|
{`${formatNumber(towerTerrainMetrics?.slope_along_line_deg, 2)} / ${formatNumber(towerTerrainMetrics?.slope_cross_line_deg, 2)}`}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="地形质量">
|
|
{towerTerrainMetrics?.quality_level
|
|
? <Tag color={terrainQualityTagColor(towerTerrainMetrics.quality_level)}>{towerTerrainMetrics.quality_level}</Tag>
|
|
: "-"}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="质量评分">{formatNumber(towerTerrainMetrics?.quality_score, 1)}</Descriptions.Item>
|
|
<Descriptions.Item label="DEM来源">{formatNullable(towerTerrainMetrics?.dem_source)}</Descriptions.Item>
|
|
<Descriptions.Item label="风险等级">
|
|
<Tag color={towerBufferStats.risk_level === "HIGH" ? "red" : towerBufferStats.risk_level === "MEDIUM" ? "orange" : "green"}>
|
|
{towerBufferStats.risk_level}
|
|
</Tag>
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="建议" span={3}>
|
|
{towerBufferStats.recommended_action}
|
|
</Descriptions.Item>
|
|
</Descriptions>
|
|
|
|
<Table
|
|
rowKey={(row) => row.id}
|
|
size="small"
|
|
columns={towerBufferEventColumns}
|
|
dataSource={towerBufferStats.events}
|
|
pagination={{ pageSize: 10, showSizeChanger: false }}
|
|
scroll={{ x: 980 }}
|
|
/>
|
|
</Space>
|
|
)}
|
|
</Space>
|
|
</Card>
|
|
|
|
<Card title="实测/合成分布对比与自动报表">
|
|
<Space direction="vertical" size={12} className="w-full">
|
|
{!syntheticCompare ? (
|
|
<Empty description="暂无实测/合成对比数据" />
|
|
) : (
|
|
<Descriptions bordered size="small" column={4}>
|
|
<Descriptions.Item label="网格尺寸(km)">{formatNumber(syntheticCompare.grid_size_km, 2)}</Descriptions.Item>
|
|
<Descriptions.Item label="统计年限">{formatNumber(syntheticCompare.data_years, 2)}</Descriptions.Item>
|
|
<Descriptions.Item label="网格相似度">
|
|
{syntheticCompare.grid_cosine_similarity === null ? "-" : `${(syntheticCompare.grid_cosine_similarity * 100).toFixed(2)}%`}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="说明">{syntheticCompare.note || "-"}</Descriptions.Item>
|
|
<Descriptions.Item label="实测数量">{syntheticCompare.measured.count}</Descriptions.Item>
|
|
<Descriptions.Item label="实测 Iavg(kA)">{formatNumber(syntheticCompare.measured.avg_abs_current_ka, 2)}</Descriptions.Item>
|
|
<Descriptions.Item label="合成数量">{syntheticCompare.synthetic.count}</Descriptions.Item>
|
|
<Descriptions.Item label="合成 Iavg(kA)">{formatNumber(syntheticCompare.synthetic.avg_abs_current_ka, 2)}</Descriptions.Item>
|
|
</Descriptions>
|
|
)}
|
|
|
|
<Space align="center">
|
|
<Typography.Text>自动报表周期:</Typography.Text>
|
|
<Select
|
|
value={reportPeriod}
|
|
style={{ width: 160 }}
|
|
options={[
|
|
{ label: "周报(近7天)", value: "week" },
|
|
{ label: "月报(近30天)", value: "month" },
|
|
]}
|
|
onChange={(value: "week" | "month") => setReportPeriod(value)}
|
|
/>
|
|
<Button onClick={() => void reportQuery.refetch()} loading={reportQuery.isFetching}>
|
|
刷新报表
|
|
</Button>
|
|
</Space>
|
|
|
|
{!distributionReport ? (
|
|
<Empty description="暂无报表数据" />
|
|
) : (
|
|
<Descriptions bordered size="small" column={4}>
|
|
<Descriptions.Item label="周期">{distributionReport.period === "week" ? "近7天" : "近30天"}</Descriptions.Item>
|
|
<Descriptions.Item label="雷击次数">{distributionReport.strike_count}</Descriptions.Item>
|
|
<Descriptions.Item label="平均电流(kA)">{formatNumber(distributionReport.avg_abs_current_ka, 2)}</Descriptions.Item>
|
|
<Descriptions.Item label="最大电流(kA)">{formatNumber(distributionReport.max_abs_current_ka, 2)}</Descriptions.Item>
|
|
<Descriptions.Item label="正极占比">{(distributionReport.positive_ratio * 100).toFixed(2)}%</Descriptions.Item>
|
|
<Descriptions.Item label="Ng">{formatNumber(distributionReport.ng_per_km2_year, 4)}</Descriptions.Item>
|
|
<Descriptions.Item label="最严重事件" span={2}>
|
|
{distributionReport.most_severe_event ? distributionReport.most_severe_event.event_id : "-"}
|
|
</Descriptions.Item>
|
|
</Descriptions>
|
|
)}
|
|
</Space>
|
|
</Card>
|
|
|
|
<Card title="峰值超越概率(P 曲线)">
|
|
{distributionPCurve.length === 0 ? (
|
|
<Empty description="暂无统计数据" />
|
|
) : (
|
|
<Space direction="vertical" size={16} className="w-full">
|
|
<div className="w-full" style={{ height: 400 }}>
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<LineChart
|
|
data={distributionPCurve}
|
|
margin={{ top: 10, right: 30, left: 10, bottom: 30 }}
|
|
>
|
|
<CartesianGrid strokeDasharray="3 3" />
|
|
<XAxis
|
|
dataKey="threshold_ka"
|
|
type="number"
|
|
domain={['dataMin', 'dataMax']}
|
|
label={{ value: '电流阈值 (kA)', position: 'insideBottom', offset: -10 }}
|
|
tickFormatter={(value) => `${value}`}
|
|
/>
|
|
<YAxis
|
|
domain={[0, 1]}
|
|
label={{ value: '超越概率', angle: -90, position: 'insideLeft' }}
|
|
tickFormatter={(value) => `${(value * 100).toFixed(0)}%`}
|
|
/>
|
|
<Tooltip
|
|
content={({ active, payload }) => {
|
|
if (active && payload && payload.length) {
|
|
const data = payload[0].payload;
|
|
return (
|
|
<div className="rounded-md border bg-white p-2 shadow-md">
|
|
<p className="text-sm">
|
|
<strong>阈值:</strong> {formatNumber(data.threshold_ka, 2)} kA
|
|
</p>
|
|
<p className="text-sm">
|
|
<strong>超越概率:</strong> {(data.exceedance_probability * 100).toFixed(2)}%
|
|
</p>
|
|
<p className="text-sm">
|
|
<strong>超越次数:</strong> {data.exceedance_count}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
return null;
|
|
}}
|
|
/>
|
|
<ReferenceLine
|
|
y={0.5}
|
|
stroke="#666"
|
|
strokeDasharray="5 5"
|
|
label={{
|
|
value: '50% (中值)',
|
|
position: 'right',
|
|
fill: '#666',
|
|
}}
|
|
/>
|
|
<Line
|
|
type="monotone"
|
|
dataKey="exceedance_probability"
|
|
stroke="#1890ff"
|
|
strokeWidth={2}
|
|
dot={{ r: 4 }}
|
|
activeDot={{ r: 6 }}
|
|
/>
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
|
|
<details className="w-full">
|
|
<summary className="cursor-pointer select-none text-sm font-medium text-gray-700">
|
|
查看精确数值表格
|
|
</summary>
|
|
<div className="mt-3">
|
|
<Table
|
|
rowKey={(row) => `${row.threshold_ka}`}
|
|
pagination={false}
|
|
dataSource={distributionPCurve}
|
|
columns={[
|
|
{ title: "阈值(kA)", dataIndex: "threshold_ka", width: 140, render: (value: number) => formatNumber(value, 2) },
|
|
{
|
|
title: "超越概率",
|
|
dataIndex: "exceedance_probability",
|
|
width: 140,
|
|
render: (value: number) => `${(value * 100).toFixed(2)}%`,
|
|
},
|
|
{ title: "超越次数", dataIndex: "exceedance_count", width: 140 },
|
|
]}
|
|
size="small"
|
|
/>
|
|
</div>
|
|
</details>
|
|
</Space>
|
|
)}
|
|
</Card>
|
|
</Space>
|
|
);
|
|
}
|
|
|