diff --git a/src/App.tsx b/src/App.tsx index 3b3ec0e..d07007d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,11 +6,13 @@ import { ModuleRegistry as AgGridModuleRegistry, type ColDef, type ColGroupDef, + type GridApi, + type GridReadyEvent, type ValueFormatterParams, } from 'ag-grid-community'; import type { AgCartesianChartOptions } from 'ag-charts-community'; import { ModuleRegistry } from 'ag-charts-community'; -import { Building2, Construction, LayoutGrid, Library, LocateFixed, MapPinned, SquareFunction, Waypoints } from 'lucide-react'; +import { Building2, Construction, LayoutGrid, Library, LocateFixed, MapPinned, PanelRightClose, PanelRightOpen, SquareFunction, Waypoints } from 'lucide-react'; import { AnnotationsModule, ContextMenuModule, @@ -140,6 +142,7 @@ const defaultTemplateFilterNode = { filterKey: 'templateLibrary', label: '默认模板', } as const; +const overallSummaryKey = 'summary'; // const mockGeoLocationPayload = { // checkStrictly: true, @@ -196,6 +199,7 @@ type ApiBuildingFunctionStat = { type ApiBuildingFunctionStatBatchItem = { key?: string; data?: ApiBuildingFunctionStat[]; + summary?: ApiBuildingFunctionStat | null; }; type ChartDatum = { groupName: string; @@ -237,6 +241,7 @@ type SelectedFilterNode = { type PivotGridRow = { year: string; name: string; + summary: boolean; lowValue: number | null; centerValue: number | null; highValue: number | null; @@ -717,6 +722,7 @@ function renderFilterTreeNodes( function App() { const workspaceRef = useRef(null); const chartFrameRef = useRef(null); + const pivotGridApiRef = useRef | null>(null); const treeInitialLoadStartedRef = useRef>({ geoLocation: false, facilityType: false, @@ -738,6 +744,8 @@ function App() { const [metricKey, setMetricKey] = useState('cost'); const [groupKey, setGroupKey] = useState('year'); const [chartViewKey, setChartViewKey] = useState('trend'); + const [workspaceFullscreen, setWorkspaceFullscreen] = useState(false); + const [rightPanelCollapsed, setRightPanelCollapsed] = useState(false); const [statisticMenuOpen, setStatisticMenuOpen] = useState(false); const [metricMenuOpen, setMetricMenuOpen] = useState(false); const [activeContentKey, setActiveContentKey] = useState('geoLocation'); @@ -764,6 +772,7 @@ function App() { }); const [selectedContentNodes, setSelectedContentNodes] = useState([]); const [chartDataBySelection, setChartDataBySelection] = useState>({}); + const [chartSummaryBySelection, setChartSummaryBySelection] = useState>({}); const [chartQueryVersion, setChartQueryVersion] = useState(0); const [loading, setLoading] = useState(false); const [loadError, setLoadError] = useState(null); @@ -861,6 +870,8 @@ function App() { const currentViewShortLabel = chartViewKey === 'pivot' ? '表' : '趋'; const pivotToggleActionLabel = chartViewKey === 'pivot' ? '切换到趋势图' : '切换到表格'; const pivotToggleTitle = `${chartViewKey === 'pivot' ? '当前表格' : '当前趋势图'},${pivotToggleActionLabel}`; + const fullscreenToggleLabel = workspaceFullscreen ? '退出全屏' : '全屏'; + const rightPanelToggleLabel = rightPanelCollapsed ? '展开选择区' : '收起选择区'; const activeContent = contentOptions.find((option) => option.key === activeContentKey) ?? contentOptions[0]; const activeTree = treeByContent[activeContentKey]; const activeFilter = filterOptions.find((option) => option.key === filterModalKey); @@ -912,6 +923,7 @@ function App() { return rows.map((datum) => ({ year: datum.groupName, name: node.label, + summary: false, lowValue: datum.thresholdLowValue, centerValue: datum.thresholdCenterValue, highValue: datum.thresholdHighValue, @@ -927,6 +939,32 @@ function App() { }), [chartDataBySelection, selectedContentNodes], ); + const pivotGridPinnedBottomRowData = useMemo( + () => { + const datum = chartSummaryBySelection[overallSummaryKey] + ?? selectedContentNodes + .map((node) => chartSummaryBySelection[getSelectionKey(node.contentKey, node.id)]) + .find(Boolean); + if (!datum) return []; + return [{ + year: '', + name: '统计', + summary: true, + lowValue: datum.thresholdLowValue, + centerValue: datum.thresholdCenterValue, + highValue: datum.thresholdHighValue, + maxValue: datum.maxValue, + minValue: datum.minValue, + avgValue: datum.avgValue, + medianValue: datum.medianValue, + standardDeviation: datum.standardDeviation, + interquartileRange: datum.interquartileRange, + coefficientOfVariation: datum.coefficientOfVariation, + dataCount: datum.dataCount, + }]; + }, + [chartSummaryBySelection, selectedContentNodes], + ); const pivotGridColumnDefs = useMemo<(ColDef | ColGroupDef)[]>( () => { const valueFormatter = ({ value }: ValueFormatterParams) => ( @@ -937,14 +975,15 @@ function App() { { field: 'year', headerName: '年度', - minWidth: 68, - width: 74, + minWidth: 58, + width: 64, }, { field: 'name', headerName: '名称', flex: 1, - minWidth: 108, + minWidth: 86, + tooltipField: 'name', }, { headerName: '基准阀值', @@ -953,21 +992,21 @@ function App() { field: 'lowValue', headerName: '低值', type: 'numericColumn', - minWidth: 78, + minWidth: 64, valueFormatter, }, { field: 'centerValue', headerName: '中心值', type: 'numericColumn', - minWidth: 78, + minWidth: 68, valueFormatter, }, { field: 'highValue', headerName: '高值', type: 'numericColumn', - minWidth: 78, + minWidth: 64, valueFormatter, }, ], @@ -979,49 +1018,49 @@ function App() { field: 'maxValue', headerName: '最大值', type: 'numericColumn', - minWidth: 78, + minWidth: 68, valueFormatter, }, { field: 'minValue', headerName: '最小值', type: 'numericColumn', - minWidth: 78, + minWidth: 68, valueFormatter, }, { field: 'avgValue', headerName: '平均值', type: 'numericColumn', - minWidth: 78, + minWidth: 68, valueFormatter, }, { field: 'medianValue', headerName: '中位数', type: 'numericColumn', - minWidth: 78, + minWidth: 68, valueFormatter, }, { field: 'standardDeviation', headerName: '标准差', type: 'numericColumn', - minWidth: 78, + minWidth: 68, valueFormatter, }, { field: 'interquartileRange', headerName: '四分位距', type: 'numericColumn', - minWidth: 88, + minWidth: 76, valueFormatter, }, { field: 'coefficientOfVariation', headerName: '变异系数', type: 'numericColumn', - minWidth: 88, + minWidth: 76, valueFormatter: ({ value }: ValueFormatterParams) => ( value == null ? '' : formatNumber(Number(value), 4) ), @@ -1032,6 +1071,17 @@ function App() { }, [requestMetricKey], ); + const fitPivotGridColumns = useCallback(() => { + window.requestAnimationFrame(() => { + pivotGridApiRef.current?.sizeColumnsToFit({ + defaultMinWidth: 58, + columnLimits: [ + { key: 'year', minWidth: 58, maxWidth: 72 }, + { key: 'name', minWidth: 86, maxWidth: 220 }, + ], + }); + }); + }, []); const selectedNodeKeys = useMemo( () => new Set(selectedContentNodes.map((node) => getSelectionKey(node.contentKey, node.id))), [selectedContentNodes], @@ -1306,6 +1356,10 @@ function App() { const { [selectionKey]: _removed, ...rest } = data; return rest; }); + setChartSummaryBySelection((data) => { + const { [selectionKey]: _removed, ...rest } = data; + return rest; + }); return current.filter((_, index) => index !== existingIndex); } @@ -1319,6 +1373,7 @@ function App() { setActiveContentKey(nextContentKey); setSelectedContentNodes([]); setChartDataBySelection({}); + setChartSummaryBySelection({}); setLoadError(null); setLoadingHint(''); setLoading(false); @@ -1546,6 +1601,7 @@ function App() { resetIndicatorTreeState(); } setChartDataBySelection({}); + setChartSummaryBySelection({}); setLoadError(null); if (selectedContentNodes.length > 0) { setLoadingHint('正在按筛选条件重新计算'); @@ -1578,6 +1634,7 @@ function App() { resetIndicatorTreeState(); } setChartDataBySelection({}); + setChartSummaryBySelection({}); setLoadError(null); if (selectedContentNodes.length > 0) { setLoadingHint('正在按筛选条件重新计算'); @@ -1589,6 +1646,7 @@ function App() { const updateMetricKey = (nextMetricKey: MetricKey) => { setMetricKey(nextMetricKey); setChartDataBySelection({}); + setChartSummaryBySelection({}); setLoadError(null); setLoadingHint('正在重新加载数据'); setLoading(true); @@ -1601,12 +1659,33 @@ function App() { setStatisticMenuOpen(false); }, []); + const toggleWorkspaceFullscreen = useCallback(() => { + const fullscreenTarget = workspaceRef.current; + if (!fullscreenTarget) return; + if (document.fullscreenElement === fullscreenTarget) { + void document.exitFullscreen(); + } else { + void fullscreenTarget.requestFullscreen(); + } + }, []); + const openGridFilterModal = (filterKey: 'templateLibrary' | 'indicatorTree') => { setMetricMenuOpen(false); setStatisticMenuOpen(false); openFilterModal(filterKey); }; + useEffect(() => { + const handleFullscreenChange = () => { + setWorkspaceFullscreen(document.fullscreenElement === workspaceRef.current); + }; + handleFullscreenChange(); + document.addEventListener('fullscreenchange', handleFullscreenChange); + return () => { + document.removeEventListener('fullscreenchange', handleFullscreenChange); + }; + }, []); + useEffect(() => { if (!contentTreeConfigs[activeContentKey]) return; if (treeByContent[activeContentKey].length > 0 || treeInitialLoadStartedRef.current[activeContentKey]) return; @@ -1645,10 +1724,6 @@ function App() { async function loadStats() { if (selectedContentNodes.length === 0) { setChartDataBySelection({}); - setLoading(false); - setLoadingHint(''); - setLoadError(null); - return; } setLoading(true); @@ -1656,7 +1731,7 @@ function App() { setLoadError(null); try { const hasAnyMissingNode = selectedContentNodes.some((node) => !chartDataBySelection[getSelectionKey(node.contentKey, node.id)]); - if (!hasAnyMissingNode) { + if (selectedContentNodes.length > 0 && !hasAnyMissingNode && chartSummaryBySelection[overallSummaryKey]) { setLoading(false); setLoadingHint(''); return; @@ -1687,10 +1762,19 @@ function App() { const results = (payload.data ?? []) .filter((item) => item.key) .map((item) => [item.key as string, (item.data ?? []).map(normalizeStat).slice(0, 36)] as const); + const summaries = (payload.data ?? []) + .filter((item) => item.key && item.summary) + .map((item) => [item.key as string, normalizeStat(item.summary as ApiBuildingFunctionStat)] as const); + const overallSummary = (payload.data ?? []).find((item) => item.summary)?.summary; setChartDataBySelection((current) => ({ ...current, ...Object.fromEntries(results), })); + setChartSummaryBySelection((current) => ({ + ...current, + ...Object.fromEntries(summaries), + ...(overallSummary ? { [overallSummaryKey]: normalizeStat(overallSummary) } : {}), + })); } catch (error) { if (controller.signal.aborted) return; setLoadError(error instanceof Error ? error.message : '接口请求失败'); @@ -1783,20 +1867,12 @@ function App() { } }; - const toggleFullscreen = () => { - if (document.fullscreenElement === fullscreenTarget) { - void document.exitFullscreen(); - } else { - void fullscreenTarget.requestFullscreen(); - } - }; - const handleKeyDown = (event: KeyboardEvent) => { if (event.key !== 'F11') return; event.preventDefault(); event.stopPropagation(); - toggleFullscreen(); + toggleWorkspaceFullscreen(); }; const handleFullscreenChange = () => { @@ -1818,7 +1894,7 @@ function App() { } else if (button.classList.contains('chart-pivot-button')) { togglePivotView(); } else { - toggleFullscreen(); + toggleWorkspaceFullscreen(); } }; @@ -1891,8 +1967,16 @@ function App() { selectedContentNodes.length, statisticMenuOpen, togglePivotView, + toggleWorkspaceFullscreen, ]); + useEffect(() => { + if (chartViewKey !== 'pivot') return; + + const timer = window.setTimeout(fitPivotGridColumns, 180); + return () => window.clearTimeout(timer); + }, [chartViewKey, fitPivotGridColumns, metricKey, pivotGridRowData.length, rightPanelCollapsed]); + const chartOptions = useMemo(() => { const visibleData = groupNames.map((groupName) => { const row: Record = { groupName }; @@ -2161,7 +2245,11 @@ function App() { ))} -
+
{indicatorSelectionLabel ?
{indicatorSelectionLabel}
: null}
{chartFilterOptions.map((option) => { @@ -2199,6 +2287,7 @@ function App() { planningForm: [], }); setChartDataBySelection({}); + setChartSummaryBySelection({}); setLoadError(null); if (selectedContentNodes.length > 0) { setLoadingHint('正在按筛选条件重新计算'); @@ -2264,9 +2353,23 @@ function App() { > {currentViewShortLabel} +