import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AgGridReact } from 'ag-grid-react'; import { AgCharts } from 'ag-charts-react'; import { AllCommunityModule as AgGridAllCommunityModule, ModuleRegistry as AgGridModuleRegistry, type ColDef, type ColGroupDef, 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, Waypoints } from 'lucide-react'; import { AnnotationsModule, ContextMenuModule, CrosshairModule, LicenseManager, ZoomModule, } from 'ag-charts-enterprise'; import { AG_CHARTS_LOCALE_ZH_CN } from 'ag-charts-locale'; LicenseManager.setLicenseKey('[v3][RELEASE][0102]_NDg2Njc4MzY3MDgzNw==16d78ca762fb5d2ff740aed081e2af7b'); ModuleRegistry.registerModules([AnnotationsModule, ContextMenuModule, ZoomModule, CrosshairModule]); AgGridModuleRegistry.registerModules([AgGridAllCommunityModule]); const API_BASE_URL = 'https://nest.zwgczx.com/api/v1'; // const API_BASE_URL = 'http://127.0.0.1:9089/api/v1'; const statisticOptions = [ { key: 'minValue', label: '最低值', shortLabel: '低' }, { key: 'maxValue', label: '最高值', shortLabel: '高' }, { key: 'avgValue', label: '平均值', shortLabel: '均' }, { key: 'medianValue', label: '中位数', shortLabel: '中' }, ] as const; const metricOptions = [ { key: 'cost', label: '造价(元)' }, { key: 'buildingArea', label: '建筑面积指标(元/m²)' }, { key: 'builtArea', label: '建造面积指标(元/m²)' }, { key: 'usableArea', label: '使用面积指标(元/m²)' }, { key: 'dataCount', label: '数据量' }, ] as const; const metricShortLabels: Record = { cost: '价', buildingArea: '建', builtArea: '造', usableArea: '用', dataCount: '数', }; const contentOptions = [ { key: 'geoLocation', label: '自然地理区位' }, { key: 'facilityType', label: '设施类别' }, { key: 'constructionStage', label: '建设阶段' }, { key: 'planningForm', label: '规划形式' }, ] as const; const filterOptions = [ { key: 'templateLibrary', label: '模板库', icon: Library }, { key: 'indicatorTree', label: '指标树形', icon: Waypoints }, { key: 'region', label: '省市区', icon: MapPinned }, { key: 'geoLocation', label: '自然地理区位', icon: LocateFixed }, { key: 'facilityType', label: '设施类别', icon: Building2 }, { key: 'constructionStage', label: '建设阶段', icon: Construction }, { key: 'planningForm', label: '规划形式', icon: LayoutGrid }, ] as const; const chartFilterOptions = filterOptions.filter((option) => option.key !== 'templateLibrary' && option.key !== 'indicatorTree'); const browserTreeDefaults = { treetype: '256', checkStrictly: 'true', requestid: '-1', workflowid: '181028', wfid: '181028', billid: '-1812', isbill: '1', f_weaver_belongto_userid: '267', f_weaver_belongto_usertype: '0', wf_isagent: '0', wf_beagenter: '0', wfTestStr: '', viewtype: '1', fromModule: 'workflow', wfCreater: '267', disabledConditionCache: 'true', companyId: '1', }; const contentTreeConfigs = { geoLocation: { endpoint: '/api/public/browser/data/256', treeid: '94004', fieldid: '305425', defaultExpandedLevel: 1, }, facilityType: { endpoint: '/api/public/browser/data/256', treeid: '94005', fieldid: '305426', defaultExpandedLevel: 3, }, constructionStage: { endpoint: '/api/public/browser/data/256', treeid: '94007', fieldid: '305428', defaultExpandedLevel: 1, }, planningForm: { endpoint: '/api/public/browser/data/256', treeid: '94006', fieldid: '305427', defaultExpandedLevel: 1, }, } as const; const chartLineColors = ['#0078a8', '#d14d72', '#1f8f4d', '#d96f23', '#6b5cc8', '#0d7680', '#9a6b12', '#b24b38']; const defaultTemplateFilterNode = { id: '3', filterKey: 'templateLibrary', label: '默认模板', } as const; const overallSummaryKey = 'summary'; // const mockGeoLocationPayload = { // checkStrictly: true, // type: 3, // datas: [ // { // allVersionIds: '', // canClick: false, // checkStrictly: true, // dsporder: 0, // icon: '', // id: '95005_22', // isImgIcon: false, // isParent: true, // linkUrl: '/formmode/search/CustomSearchOpenTree.jsp?pid=95005_22', // name: '中国', // pid: '0_0', // selected: false, // title: '中国', // type: '2', // }, // ], // iconSetting: { // bgColor: '#96358a', // icon: 'icon-coms-ModelingEngine', // fontColor: '#fff', // }, // }; type StatisticKey = (typeof statisticOptions)[number]['key']; type MetricKey = (typeof metricOptions)[number]['key']; type ContentKey = (typeof contentOptions)[number]['key']; type FilterKey = (typeof filterOptions)[number]['key']; type ChartViewKey = 'trend' | 'pivot'; type ApiBuildingFunctionStat = { group_key?: string | number | null; group_name?: string | null; min_value?: number | null; max_value?: number | null; avg_value?: number | null; median_value?: number | null; threshold_low_value?: number | null; threshold_center_value?: number | null; threshold_high_value?: number | null; stddev_value?: number | null; standard_deviation?: number | null; iqr_value?: number | null; quartile_range?: number | null; variation_coefficient?: number | null; coefficient_of_variation?: number | null; data_count?: number | null; }; type ApiBuildingFunctionStatBatchItem = { key?: string; data?: ApiBuildingFunctionStat[]; summary?: ApiBuildingFunctionStat | null; }; type ChartDatum = { groupName: string; minValue: number | null; maxValue: number | null; avgValue: number | null; medianValue: number | null; thresholdLowValue: number | null; thresholdCenterValue: number | null; thresholdHighValue: number | null; standardDeviation: number | null; interquartileRange: number | null; coefficientOfVariation: number | null; dataCount: number | null; }; type TreeNode = { id: string; label: string; children: TreeNode[]; hasChildren: boolean; canClick: boolean; expanded: boolean; loading: boolean; loaded: boolean; hasData?: boolean; dataStatus?: 'normal' | 'empty'; }; type SelectedContentNode = { id: string; contentKey: ContentKey; label: string; color: string; }; type SelectedFilterNode = { id: string; filterKey: FilterKey; label: string; }; type PivotGridRow = { year: string; name: string; summary: boolean; lowValue: number | null; centerValue: number | null; highValue: number | null; maxValue: number | null; minValue: number | null; avgValue: number | null; medianValue: number | null; standardDeviation: number | null; interquartileRange: number | null; coefficientOfVariation: number | null; dataCount: number | null; }; function formatNumber(value: number, maximumFractionDigits: number) { return value.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits, }); } function formatCostValue(value: number) { const absValue = Math.abs(value); if (absValue >= 10000) { return `${formatNumber(value / 10000, 2)}万元`; } const fractionDigits = absValue > 0 && absValue < 1 ? 4 : 2; return `${formatNumber(value, fractionDigits)}元`; } function formatAreaMetricValue(value: number) { const absValue = Math.abs(value); const fractionDigits = absValue > 0 && absValue < 1 ? 4 : 2; return `${formatNumber(value, fractionDigits)}元/m²`; } function formatChartValue(value: number, metricKey: MetricKey) { if (metricKey === 'dataCount') { return formatNumber(value, 0); } if (metricKey === 'cost') { return formatCostValue(value); } return formatAreaMetricValue(value); } function normalizeStat(row: ApiBuildingFunctionStat): ChartDatum { const avgValue = row.avg_value ?? null; const medianValue = row.median_value ?? null; const fallbackThresholdLowValue = avgValue == null || medianValue == null ? null : Math.min(avgValue, medianValue); const fallbackThresholdHighValue = avgValue == null || medianValue == null ? null : Math.max(avgValue, medianValue); return { groupName: row.group_name || String(row.group_key ?? '未命名'), minValue: row.min_value ?? null, maxValue: row.max_value ?? null, avgValue, medianValue, thresholdLowValue: row.threshold_low_value ?? fallbackThresholdLowValue, thresholdCenterValue: row.threshold_center_value ?? medianValue, thresholdHighValue: row.threshold_high_value ?? fallbackThresholdHighValue, standardDeviation: row.stddev_value ?? row.standard_deviation ?? null, interquartileRange: row.iqr_value ?? row.quartile_range ?? null, coefficientOfVariation: row.variation_coefficient ?? row.coefficient_of_variation ?? null, dataCount: row.data_count ?? null, }; } function buildQuery(params: Record) { const search = new URLSearchParams(); Object.entries(params).forEach(([key, value]) => { if (value == null) return; search.set(key, String(value)); }); search.set('currenttime', String(Date.now())); search.set('__random__', String(Date.now())); return search.toString(); } function pickArray(payload: unknown): unknown[] { if (Array.isArray(payload)) return payload; if (!payload || typeof payload !== 'object') return []; const source = payload as Record; const candidates = [ source.datas, source.data, source.data && typeof source.data === 'object' ? (source.data as Record).datas : null, source.data && typeof source.data === 'object' ? (source.data as Record).list : null, source.data && typeof source.data === 'object' ? (source.data as Record).treeDatas : null, source.result, source.rows, source.list, source.treeDatas, source.children, ]; for (const candidate of candidates) { if (Array.isArray(candidate)) return candidate; } return []; } function readText(row: Record, keys: string[]) { for (const key of keys) { const value = row[key]; if (value !== null && value !== undefined && String(value).trim()) { return String(value); } } return ''; } function readIndicatorDataStatus(row: Record) { const rawStatus = readText(row, ['dataStatus', 'datastatus']).toLowerCase(); const rawHasData = row.hasData ?? row.hasdata; const hasDataField = rawStatus === 'normal' || rawStatus === 'empty' || rawHasData !== undefined; const hasData = !hasDataField || rawStatus === 'normal' || rawHasData === true || rawHasData === 1 || rawHasData === '1' || rawHasData === 'true'; return { hasData, dataStatus: rawStatus === 'empty' || !hasData ? 'empty' as const : 'normal' as const, }; } function normalizeTreeRows(rows: unknown[]): TreeNode[] { return rows .filter((row): row is Record => !!row && typeof row === 'object') .map((row, index) => { const children = normalizeTreeRows(pickArray(row.children ?? row.childs ?? row.subs)); const id = readText(row, ['id', 'key', 'value', 'nodeid', 'treeid', 'browserid']) || `node-${index}`; const label = readText(row, ['name', 'label', 'title', 'text', 'showname', 'showName', 'browsername', 'displayName']) || id; const hasChildren = children.length > 0 || row.hasChild === true || row.haschild === true || row.isParent === true || row.isparent === true || row.children === true || row.child === true; const canClick = row.canClick === true || row.canClick === 'true' || row.canclick === true || row.canclick === 'true'; return { id, label, children, hasChildren, canClick, expanded: children.length > 0, loading: false, loaded: children.length > 0, }; }); } function normalizeFlatTemplateLibraryRows(rows: unknown[]): TreeNode[] { return rows .filter((row): row is Record => !!row && typeof row === 'object') .map((row, index) => { const id = readText(row, ['id', 'mainid', 'mbid']) || `node-${index}`; const label = readText(row, ['mbmc', 'label', 'name', 'title', 'text']) || id; return createFilterTreeNode(id, label); }); } function normalizeFlatIndicatorRows(rows: unknown[]): TreeNode[] { const sourceRows = rows.filter((row): row is Record => !!row && typeof row === 'object'); const rowsById = new Map>(); const childrenByParent = new Map(); sourceRows.forEach((row, index) => { const id = readText(row, ['zbid', 'zjzbk', 'id']) || `node-${index}`; if (!id) return; rowsById.set(id, row); const parentId = readText(row, ['sj', 'parentId', 'parentid', 'pid', 'pId']) || ''; const children = childrenByParent.get(parentId) || []; children.push(id); childrenByParent.set(parentId, children); }); const buildNode = (id: string): TreeNode => { const row = rowsById.get(id) || {}; const children = (childrenByParent.get(id) || []) .filter((childId) => rowsById.has(childId)) .map(buildNode); const label = readText(row, ['label', 'name', 'title', 'text', 'zbbh', 'mbmc']) || id; const dataStatus = readIndicatorDataStatus(row); return { id, label, children, hasChildren: children.length > 0, canClick: true, expanded: children.length > 0, loading: false, loaded: true, hasData: dataStatus.hasData, dataStatus: dataStatus.dataStatus, }; }; return Array.from(rowsById.keys()) .filter((id) => { const row = rowsById.get(id); if (!row) return false; const parentId = readText(row, ['sj', 'parentId', 'parentid', 'pid', 'pId']) || ''; return !parentId || parentId === '0' || parentId === '0_0' || !rowsById.has(parentId); }) .map(buildNode); } const regionFieldKeys = { provinceId: ['provinceId', 'province_id', 'provinceid', 'sfid', 'sf_id', 'shengId', 'sheng_id', 'sheng'], provinceName: ['provinceName', 'province_name', 'province', 'sfmc', 'sf', 'shengName', 'sheng_name', 'shengmc'], cityId: ['cityId', 'city_id', 'cityid', 'sid', 's_id', 'shiId', 'shi_id', 'shi'], cityName: ['cityName', 'city_name', 'city', 'smc', 's', 'shiName', 'shi_name', 'shimc'], districtId: ['districtId', 'district_id', 'districtid', 'countyId', 'county_id', 'qid', 'q_id', 'xid', 'x_id', 'id'], districtName: ['districtName', 'district_name', 'district', 'countyName', 'county_name', 'county', 'qmc', 'q', 'xmc', 'x', 'name', 'shortname'], }; function createFilterTreeNode(id: string, label: string, children: TreeNode[] = [], expanded = false): TreeNode { return { id, label, children, hasChildren: children.length > 0, canClick: true, expanded, loading: false, loaded: true, }; } function normalizeFlatRegionRows(rows: unknown[]): TreeNode[] { const sourceRows = rows.filter((row): row is Record => !!row && typeof row === 'object'); const hasRegionShape = sourceRows.some((row) => readText(row, regionFieldKeys.provinceName) && readText(row, regionFieldKeys.cityName) && readText(row, regionFieldKeys.districtName), ); if (!hasRegionShape) return []; const provinceMap = new Map; }>; }>(); sourceRows.forEach((row) => { const provinceLabel = readText(row, regionFieldKeys.provinceName); const cityLabel = readText(row, regionFieldKeys.cityName); const districtLabel = readText(row, regionFieldKeys.districtName); if (!provinceLabel || !cityLabel || !districtLabel) return; const provinceId = `region:province:${readText(row, regionFieldKeys.provinceId) || provinceLabel}`; const cityId = `region:city:${provinceLabel}:${readText(row, regionFieldKeys.cityId) || cityLabel}`; const districtId = `region:district:${readText(row, regionFieldKeys.districtId) || `${provinceLabel}:${cityLabel}:${districtLabel}`}`; const province = provinceMap.get(provinceId) ?? { id: provinceId, label: provinceLabel, cityMap: new Map }>(), }; const city = province.cityMap.get(cityId) ?? { id: cityId, label: cityLabel, districts: new Map(), }; city.districts.set(districtId, createFilterTreeNode(districtId, districtLabel)); province.cityMap.set(cityId, city); provinceMap.set(provinceId, province); }); return Array.from(provinceMap.values()).map((province) => { const cityNodes = Array.from(province.cityMap.values()).map((city) => createFilterTreeNode(city.id, city.label, Array.from(city.districts.values()), false), ); return createFilterTreeNode(province.id, province.label, cityNodes, true); }); } function updateNode(nodes: TreeNode[], nodeId: string, updater: (node: TreeNode) => TreeNode): TreeNode[] { return nodes.map((node) => { if (node.id === nodeId) return updater(node); return { ...node, children: updateNode(node.children, nodeId, updater), }; }); } function getSelectionKey(contentKey: ContentKey, nodeId: string) { return `${contentKey}:${nodeId}`; } function getFilterSelectionKey(filterKey: FilterKey, nodeId: string) { return `${filterKey}:${nodeId}`; } function getSeriesValueKey(contentKey: ContentKey, nodeId: string) { return `amount_${getSelectionKey(contentKey, nodeId).replace(/[^a-zA-Z0-9_]/g, '_')}`; } function compareGroupNames(a: string, b: string) { const numberA = Number(a); const numberB = Number(b); if (Number.isFinite(numberA) && Number.isFinite(numberB)) { return numberA - numberB; } return a.localeCompare(b, 'zh-CN', { numeric: true }); } function isContentFilterKey(filterKey: FilterKey): filterKey is ContentKey { return Object.prototype.hasOwnProperty.call(contentTreeConfigs, filterKey); } function isTemplateFilterKey(filterKey: FilterKey): filterKey is 'templateLibrary' { return filterKey === 'templateLibrary'; } function isIndicatorTreeFilterKey(filterKey: FilterKey): filterKey is 'indicatorTree' { return filterKey === 'indicatorTree'; } function isSingleSelectFilterKey(filterKey: FilterKey) { return isTemplateFilterKey(filterKey) || isIndicatorTreeFilterKey(filterKey); } function getDefaultTemplateFilterNodes(): SelectedFilterNode[] { return [{ ...defaultTemplateFilterNode }]; } function isDefaultTemplateSelection(nodes: SelectedFilterNode[]) { return nodes.length === 1 && nodes[0]?.id === defaultTemplateFilterNode.id; } function isSameFilterSelection(a: SelectedFilterNode[], b: SelectedFilterNode[]) { return a.length === b.length && a.every((node, index) => node.id === b[index]?.id); } function getDefaultIndicatorTreeFilterNodes(nodes: TreeNode[]): SelectedFilterNode[] { const defaultNode = nodes[0]; if (!defaultNode) return []; return [{ id: defaultNode.id, filterKey: 'indicatorTree', label: defaultNode.label, }]; } function isDefaultIndicatorTreeSelection(nodes: SelectedFilterNode[], treeNodes: TreeNode[]) { const defaultNode = treeNodes[0]; return Boolean(defaultNode) && nodes.length === 1 && nodes[0]?.id === defaultNode?.id; } function filterTreeNodesByKeyword(nodes: TreeNode[], keyword: string): TreeNode[] { const normalizedKeyword = keyword.trim().toLowerCase(); if (!normalizedKeyword) return nodes; const expandDescendants = (targetNodes: TreeNode[]): TreeNode[] => targetNodes.map((node) => ({ ...node, children: expandDescendants(node.children), expanded: node.hasChildren || node.children.length > 0 ? true : node.expanded, })); return nodes.flatMap((node) => { const matched = node.label.toLowerCase().includes(normalizedKeyword); const children = filterTreeNodesByKeyword(node.children, keyword); if (!matched && children.length === 0) return []; return [{ ...node, children: matched ? expandDescendants(node.children) : children, expanded: true, }]; }); } function renderTreeNodes( nodes: TreeNode[], contentKey: ContentKey, selectedNodeKeys: Set, getNodeColor: (contentKey: ContentKey, nodeId: string) => string, onToggle: (nodeId: string) => void, onSelect: (node: TreeNode) => void, depth = 0, ) { return (
    {nodes.map((node) => { const selected = selectedNodeKeys.has(getSelectionKey(contentKey, node.id)); const color = getNodeColor(contentKey, node.id); return (
  • {node.hasChildren ? ( ) : ( )} {node.loading ? 加载中 : null}
    {node.expanded && node.children.length > 0 ? renderTreeNodes(node.children, contentKey, selectedNodeKeys, getNodeColor, onToggle, onSelect, depth + 1) : null}
  • ); })}
); } function renderFilterTreeNodes( nodes: TreeNode[], filterKey: FilterKey, selectedNodeKeys: Set, onToggle: (nodeId: string) => void, onSelect: (node: TreeNode) => void, depth = 0, ) { return (
    {nodes.map((node) => { const selected = selectedNodeKeys.has(getFilterSelectionKey(filterKey, node.id)); const showNoData = filterKey === 'indicatorTree' && node.dataStatus === 'empty'; return (
  • {node.hasChildren ? ( ) : ( )} {node.loading ? 加载中 : null}
    {node.expanded && node.children.length > 0 ? renderFilterTreeNodes(node.children, filterKey, selectedNodeKeys, onToggle, onSelect, depth + 1) : null}
  • ); })}
); } function App() { const workspaceRef = useRef(null); const chartFrameRef = useRef(null); const treeInitialLoadStartedRef = useRef>({ geoLocation: false, facilityType: false, constructionStage: false, planningForm: false, }); const filterTreeInitialLoadStartedRef = useRef>({ templateLibrary: false, indicatorTree: false, region: false, geoLocation: false, facilityType: false, constructionStage: false, planningForm: false, }); const [statisticKey, setStatisticKey] = useState('avgValue'); const [metricKey, setMetricKey] = useState('cost'); const [chartViewKey, setChartViewKey] = useState('trend'); const [workspaceFullscreen, setWorkspaceFullscreen] = useState(false); const [statisticMenuOpen, setStatisticMenuOpen] = useState(false); const [metricMenuOpen, setMetricMenuOpen] = useState(false); const [activeContentKey, setActiveContentKey] = useState('geoLocation'); const [treeByContent, setTreeByContent] = useState>({ geoLocation: [], facilityType: [], constructionStage: [], planningForm: [], }); const [treeLoadingByContent, setTreeLoadingByContent] = useState>({ geoLocation: false, facilityType: false, constructionStage: false, planningForm: false, }); const [treeErrorByContent, setTreeErrorByContent] = useState>({ geoLocation: null, facilityType: null, constructionStage: null, planningForm: null, }); 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); const [loadingHint, setLoadingHint] = useState(''); const [filterTreeByKey, setFilterTreeByKey] = useState>({ templateLibrary: [], indicatorTree: [], region: [], geoLocation: [], facilityType: [], constructionStage: [], planningForm: [], }); const [filterTreeLoadingByKey, setFilterTreeLoadingByKey] = useState>({ templateLibrary: false, indicatorTree: false, region: false, geoLocation: false, facilityType: false, constructionStage: false, planningForm: false, }); const [filterTreeErrorByKey, setFilterTreeErrorByKey] = useState>({ templateLibrary: null, indicatorTree: null, region: null, geoLocation: null, facilityType: null, constructionStage: null, planningForm: null, }); const [filterSearchTreeByKey, setFilterSearchTreeByKey] = useState>({ templateLibrary: [], indicatorTree: [], region: [], geoLocation: [], facilityType: [], constructionStage: [], planningForm: [], }); const [filterSearchLoadingByKey, setFilterSearchLoadingByKey] = useState>({ templateLibrary: false, indicatorTree: false, region: false, geoLocation: false, facilityType: false, constructionStage: false, planningForm: false, }); const [filterSearchErrorByKey, setFilterSearchErrorByKey] = useState>({ templateLibrary: null, indicatorTree: null, region: null, geoLocation: null, facilityType: null, constructionStage: null, planningForm: null, }); const [appliedFilters, setAppliedFilters] = useState>({ templateLibrary: getDefaultTemplateFilterNodes(), indicatorTree: [], region: [], geoLocation: [], facilityType: [], constructionStage: [], planningForm: [], }); const [filterModalKey, setFilterModalKey] = useState(null); const [draftFilterNodes, setDraftFilterNodes] = useState([]); const [filterSearchValue, setFilterSearchValue] = useState(''); const filterSearchComposingRef = useRef(false); const filterSearchTimerRef = useRef(null); const filterSearchRequestSeqRef = useRef>({ templateLibrary: 0, indicatorTree: 0, region: 0, geoLocation: 0, facilityType: 0, constructionStage: 0, planningForm: 0, }); const lastFilterSearchRef = useRef(''); const selectedStatistic = statisticOptions.find((option) => option.key === statisticKey) ?? statisticOptions[0]; const selectedMetric = metricOptions.find((option) => option.key === metricKey) ?? metricOptions[0]; const selectedMetricShortLabel = metricShortLabels[metricKey]; const currentViewShortLabel = chartViewKey === 'pivot' ? '表' : '趋'; const pivotToggleActionLabel = chartViewKey === 'pivot' ? '切换到趋势图' : '切换到表格'; const pivotToggleTitle = `${chartViewKey === 'pivot' ? '当前表格' : '当前趋势图'},${pivotToggleActionLabel}`; const fullscreenToggleLabel = workspaceFullscreen ? '退出全屏' : '全屏'; const activeContent = contentOptions.find((option) => option.key === activeContentKey) ?? contentOptions[0]; const activeTree = treeByContent[activeContentKey]; const activeFilter = filterOptions.find((option) => option.key === filterModalKey); const activeFilterTree = filterModalKey ? filterTreeByKey[filterModalKey] : []; const trimmedFilterSearchValue = filterSearchValue.trim(); const activeFilterDisplayTree = filterModalKey && trimmedFilterSearchValue ? filterSearchTreeByKey[filterModalKey] : activeFilterTree; const activeFilterTreeLoading = filterModalKey ? trimmedFilterSearchValue ? filterSearchLoadingByKey[filterModalKey] : filterTreeLoadingByKey[filterModalKey] : false; const activeFilterTreeError = filterModalKey ? trimmedFilterSearchValue ? filterSearchErrorByKey[filterModalKey] : filterTreeErrorByKey[filterModalKey] : null; const selectedTemplateId = appliedFilters.templateLibrary[0]?.id || defaultTemplateFilterNode.id; const defaultIndicatorTreeNodes = useMemo( () => getDefaultIndicatorTreeFilterNodes(filterTreeByKey.indicatorTree), [filterTreeByKey.indicatorTree], ); const indicatorSelectionLabel = appliedFilters.indicatorTree[0]?.label ?? defaultIndicatorTreeNodes[0]?.label ?? ''; const activeFilterCount = Object.entries(appliedFilters).reduce((total, [key, nodes]) => ( key === 'templateLibrary' && isDefaultTemplateSelection(nodes) ? total : total + nodes.length ), 0); const chartEmptyText = selectedContentNodes.length === 0 ? '请选择右侧分类项' : activeFilterCount > 0 ? '当前筛选无数据' : '所选分类暂无数据'; const selectedValueKey = metricKey === 'dataCount' ? 'dataCount' : statisticKey; const requestMetricKey = metricKey === 'dataCount' ? 'cost' : metricKey; const seriesValueLabel = metricKey === 'dataCount' ? selectedMetric.label : selectedStatistic.label; const groupNames = useMemo(() => { const names: string[] = []; const seen = new Set(); selectedContentNodes.forEach((node) => { const rows = chartDataBySelection[getSelectionKey(node.contentKey, node.id)] ?? []; rows.forEach((datum) => { if (seen.has(datum.groupName)) return; seen.add(datum.groupName); names.push(datum.groupName); }); }); return names.sort(compareGroupNames); }, [chartDataBySelection, selectedContentNodes]); const pivotGridRowData = useMemo( () => selectedContentNodes.flatMap((node) => { const rows = chartDataBySelection[getSelectionKey(node.contentKey, node.id)] ?? []; return rows.map((datum) => ({ year: datum.groupName, name: node.label, summary: false, 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, })); }), [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) => ( value == null ? '' : formatChartValue(Number(value), requestMetricKey) ); return [ { field: 'year', headerName: '年度', minWidth: 68, width: 74, }, { field: 'name', headerName: '名称', flex: 1, minWidth: 108, }, { headerName: '基准阀值', children: [ { field: 'lowValue', headerName: '低值', type: 'numericColumn', minWidth: 78, valueFormatter, }, { field: 'centerValue', headerName: '中心值', type: 'numericColumn', minWidth: 78, valueFormatter, }, { field: 'highValue', headerName: '高值', type: 'numericColumn', minWidth: 78, valueFormatter, }, ], }, { headerName: '样本统计值(括号显示样本数量)', children: [ { field: 'maxValue', headerName: '最大值', type: 'numericColumn', minWidth: 78, valueFormatter, }, { field: 'minValue', headerName: '最小值', type: 'numericColumn', minWidth: 78, valueFormatter, }, { field: 'avgValue', headerName: '平均值', type: 'numericColumn', minWidth: 78, valueFormatter, }, { field: 'medianValue', headerName: '中位数', type: 'numericColumn', minWidth: 78, valueFormatter, }, { field: 'standardDeviation', headerName: '标准差', type: 'numericColumn', minWidth: 78, valueFormatter, }, { field: 'interquartileRange', headerName: '四分位距', type: 'numericColumn', minWidth: 88, valueFormatter, }, { field: 'coefficientOfVariation', headerName: '变异系数', type: 'numericColumn', minWidth: 88, valueFormatter: ({ value }: ValueFormatterParams) => ( value == null ? '' : formatNumber(Number(value), 4) ), }, ], }, ]; }, [requestMetricKey], ); const selectedNodeKeys = useMemo( () => new Set(selectedContentNodes.map((node) => getSelectionKey(node.contentKey, node.id))), [selectedContentNodes], ); const draftFilterNodeKeys = useMemo( () => new Set(draftFilterNodes.map((node) => getFilterSelectionKey(node.filterKey, node.id))), [draftFilterNodes], ); const appliedFilterPayload = useMemo( () => filterOptions .map((option) => ({ key: option.key, nodes: appliedFilters[option.key].map((node) => ({ nodeId: node.id })), })) .filter((filter) => filter.nodes.length > 0), [appliedFilters], ); const getNodeColor = (contentKey: ContentKey, nodeId: string) => { let hash = 0; const key = getSelectionKey(contentKey, nodeId); for (let index = 0; index < key.length; index += 1) { hash = (hash * 31 + key.charCodeAt(index)) % chartLineColors.length; } return chartLineColors[hash]; }; const fetchContentTree = async (contentKey: ContentKey, nodeId?: string, signal?: AbortSignal) => { const config = contentTreeConfigs[contentKey]; if (!config) { throw new Error('接口待接入'); } const treeParams = { ...browserTreeDefaults, treeid: config.treeid, cube_treeid: config.treeid, fieldid: config.fieldid, }; const params = nodeId ? { ...treeParams, type: '2', id: nodeId, isVirtual: '', } : { ...treeParams, pageSize: '10', current: '1', min: '1', max: '10', }; const response = await fetch(`${config.endpoint}?${buildQuery(params)}`, { credentials: 'include', signal, headers: { 'X-Requested-With': 'XMLHttpRequest', }, }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const rows = pickArray(await response.json()); return normalizeTreeRows(rows); }; const normalizeBackendTree = (nodes: TreeNode[]): TreeNode[] => nodes.map((node) => ({ ...node, hasChildren: node.children.length > 0 || node.hasChildren, expanded: node.children.length > 0, loaded: true, children: normalizeBackendTree(node.children), })); const getTreeNodePrefix = (nodes: TreeNode[]) => { const stack = [...nodes]; while (stack.length) { const node = stack.shift(); if (!node) { continue; } const matched = node.id.match(/^(.*_)\d+$/); if (matched) { return matched[1]; } stack.push(...node.children); } return ''; }; const fetchRegionFilterTree = async (signal?: AbortSignal) => { const response = await fetch(`${API_BASE_URL}/zw/getBuildingFunctionCostFilterTree?${buildQuery({ key: 'region' })}`, { signal, headers: { 'X-Requested-With': 'XMLHttpRequest', }, }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const rows = pickArray(await response.json()); const flatRegionTree = normalizeFlatRegionRows(rows); return flatRegionTree.length > 0 ? flatRegionTree : normalizeBackendTree(normalizeTreeRows(rows)); }; const fetchTemplateLibraryTree = async (signal?: AbortSignal) => { const response = await fetch(`${API_BASE_URL}/zw/getBuildingFunctionCostFilterTree?${buildQuery({ key: 'templateLibrary' })}`, { signal, headers: { 'X-Requested-With': 'XMLHttpRequest', }, }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const rows = pickArray(await response.json()); const templateRows = normalizeFlatTemplateLibraryRows(rows); return templateRows.length > 0 ? templateRows : normalizeBackendTree(normalizeTreeRows(rows)); }; const fetchIndicatorTree = async (signal?: AbortSignal) => { const response = await fetch(`${API_BASE_URL}/zw/getBuildingFunctionCostFilterTree?${buildQuery({ key: 'indicatorTree', templateId: selectedTemplateId })}`, { signal, headers: { 'X-Requested-With': 'XMLHttpRequest', }, }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const rows = pickArray(await response.json()); const indicatorRows = normalizeFlatIndicatorRows(rows); return indicatorRows.length > 0 ? indicatorRows : normalizeBackendTree(normalizeTreeRows(rows)); }; const fetchBackendFilterTreeSearch = async (filterKey: FilterKey, keyword: string, signal?: AbortSignal) => { const response = await fetch(`${API_BASE_URL}/zw/getBuildingFunctionCostFilterTreeSearch?${buildQuery({ key: filterKey, keyword, nodePrefix: isContentFilterKey(filterKey) ? getTreeNodePrefix(filterTreeByKey[filterKey]) : '', })}`, { signal, headers: { 'X-Requested-With': 'XMLHttpRequest', }, }); if (!response.ok) { const errorText = await response.text().catch(() => ''); throw new Error(`搜索接口请求失败:HTTP ${response.status}${errorText ? ` ${errorText.slice(0, 160)}` : ''}`); } return normalizeBackendTree(normalizeTreeRows(pickArray(await response.json()))); }; const loadContentTreeWithDefaultExpansion = async (contentKey: ContentKey) => { const defaultExpandedLevel = contentTreeConfigs[contentKey]?.defaultExpandedLevel ?? 0; const loadChildren = async (nodes: TreeNode[], level: number): Promise => { if (level > defaultExpandedLevel) return nodes; return Promise.all( nodes.map(async (node) => { if (!node.hasChildren) return node; const children = node.loaded ? node.children : await fetchContentTree(contentKey, node.id); return { ...node, children: await loadChildren(children, level + 1), expanded: true, loading: false, loaded: true, hasChildren: node.hasChildren || children.length > 0, }; }), ); }; return loadChildren(await fetchContentTree(contentKey), 1); }; const loadFilterTree = async (filterKey: FilterKey, keyword?: string, signal?: AbortSignal) => { if (filterKey === 'region') { const nodes = await fetchRegionFilterTree(signal); return keyword?.trim() ? filterTreeNodesByKeyword(nodes, keyword) : nodes; } if (isTemplateFilterKey(filterKey)) { const nodes = await fetchTemplateLibraryTree(signal); return keyword?.trim() ? filterTreeNodesByKeyword(nodes, keyword) : nodes; } if (filterKey === 'indicatorTree') { const nodes = await fetchIndicatorTree(signal); return keyword?.trim() ? filterTreeNodesByKeyword(nodes, keyword) : nodes; } if (keyword?.trim() && isContentFilterKey(filterKey)) { return fetchBackendFilterTreeSearch(filterKey, keyword, signal); } if (isContentFilterKey(filterKey)) { return loadContentTreeWithDefaultExpansion(filterKey); } return []; }; const toggleContentNode = (nodeId: string) => { if (!contentTreeConfigs[activeContentKey]) return; const target = activeTree.find((node) => node.id === nodeId); const visit = (nodes: TreeNode[]): TreeNode | null => { for (const node of nodes) { if (node.id === nodeId) return node; const matched = visit(node.children); if (matched) return matched; } return null; }; const node = target ?? visit(activeTree); if (!node?.hasChildren) return; setTreeByContent((current) => ({ ...current, [activeContentKey]: updateNode(current[activeContentKey], nodeId, (node) => ({ ...node, expanded: node.loaded ? !node.expanded : node.expanded, loading: node.loaded ? node.loading : true, })), })); if (node.loaded) return; const currentContentKey = activeContentKey; fetchContentTree(currentContentKey, nodeId) .then((children) => { setTreeByContent((current) => ({ ...current, [currentContentKey]: updateNode(current[currentContentKey], nodeId, (currentNode) => ({ ...currentNode, children, expanded: true, loading: false, loaded: true, hasChildren: currentNode.hasChildren || children.length > 0, })), })); }) .catch((error) => { setTreeByContent((current) => ({ ...current, [currentContentKey]: updateNode(current[currentContentKey], nodeId, (currentNode) => ({ ...currentNode, loading: false, })), })); setTreeErrorByContent((current) => ({ ...current, [currentContentKey]: error instanceof Error ? error.message : '加载失败', })); }); }; const toggleSelectedContentNode = (node: TreeNode) => { const contentKey = activeContentKey; setSelectedContentNodes((current) => { const selectionKey = getSelectionKey(contentKey, node.id); const existingIndex = current.findIndex((item) => getSelectionKey(item.contentKey, item.id) === selectionKey); if (existingIndex >= 0) { setChartDataBySelection((data) => { const { [selectionKey]: _removed, ...rest } = data; return rest; }); setChartSummaryBySelection((data) => { const { [selectionKey]: _removed, ...rest } = data; return rest; }); return current.filter((_, index) => index !== existingIndex); } const color = getNodeColor(contentKey, node.id); return [...current, { id: node.id, contentKey, label: node.label, color }]; }); }; const handleActiveContentKeyChange = (nextContentKey: ContentKey) => { if (nextContentKey === activeContentKey) return; setActiveContentKey(nextContentKey); setSelectedContentNodes([]); setChartDataBySelection({}); setChartSummaryBySelection({}); setLoadError(null); setLoadingHint(''); setLoading(false); }; const resetIndicatorTreeState = () => { filterTreeInitialLoadStartedRef.current.indicatorTree = false; filterSearchRequestSeqRef.current.indicatorTree += 1; setFilterTreeByKey((current) => ({ ...current, indicatorTree: [] })); setFilterTreeLoadingByKey((current) => ({ ...current, indicatorTree: false })); setFilterTreeErrorByKey((current) => ({ ...current, indicatorTree: null })); setFilterSearchTreeByKey((current) => ({ ...current, indicatorTree: [] })); setFilterSearchLoadingByKey((current) => ({ ...current, indicatorTree: false })); setFilterSearchErrorByKey((current) => ({ ...current, indicatorTree: null })); }; const ensureFilterTreeLoaded = (filterKey: FilterKey) => { if (filterTreeByKey[filterKey].length > 0 || filterTreeInitialLoadStartedRef.current[filterKey]) return; filterTreeInitialLoadStartedRef.current[filterKey] = true; setFilterTreeLoadingByKey((current) => ({ ...current, [filterKey]: true })); setFilterTreeErrorByKey((current) => ({ ...current, [filterKey]: null })); loadFilterTree(filterKey) .then((nodes) => { setFilterTreeByKey((current) => ({ ...current, [filterKey]: nodes })); }) .catch((error) => { filterTreeInitialLoadStartedRef.current[filterKey] = false; setFilterTreeErrorByKey((current) => ({ ...current, [filterKey]: error instanceof Error ? error.message : '加载失败', })); }) .finally(() => { setFilterTreeLoadingByKey((current) => ({ ...current, [filterKey]: false })); }); }; const openFilterModal = (filterKey: FilterKey) => { setFilterModalKey(filterKey); if (isIndicatorTreeFilterKey(filterKey) && appliedFilters[filterKey].length === 0 && defaultIndicatorTreeNodes.length > 0) { setDraftFilterNodes(defaultIndicatorTreeNodes); } else { setDraftFilterNodes(appliedFilters[filterKey]); } setFilterSearchValue(''); lastFilterSearchRef.current = ''; if (filterSearchTimerRef.current != null) { window.clearTimeout(filterSearchTimerRef.current); filterSearchTimerRef.current = null; } ensureFilterTreeLoaded(filterKey); }; useEffect(() => { ensureFilterTreeLoaded('indicatorTree'); }, [selectedTemplateId]); const closeFilterModal = () => { if (filterModalKey) { filterSearchRequestSeqRef.current[filterModalKey] += 1; } if (filterSearchTimerRef.current != null) { window.clearTimeout(filterSearchTimerRef.current); filterSearchTimerRef.current = null; } lastFilterSearchRef.current = ''; setFilterModalKey(null); setDraftFilterNodes([]); setFilterSearchValue(''); }; const scheduleFilterSearch = (filterKey: FilterKey, rawKeyword: string) => { if (filterSearchTimerRef.current != null) { window.clearTimeout(filterSearchTimerRef.current); filterSearchTimerRef.current = null; } const keyword = rawKeyword.trim(); const searchKey = `${filterKey}:${keyword}`; if (keyword && lastFilterSearchRef.current === searchKey) { return; } lastFilterSearchRef.current = searchKey; filterSearchRequestSeqRef.current[filterKey] += 1; const requestSeq = filterSearchRequestSeqRef.current[filterKey]; if (!keyword) { setFilterSearchTreeByKey((current) => ({ ...current, [filterKey]: [] })); setFilterSearchErrorByKey((current) => ({ ...current, [filterKey]: null })); setFilterSearchLoadingByKey((current) => ({ ...current, [filterKey]: false })); return; } if (isTemplateFilterKey(filterKey) || filterKey === 'indicatorTree') { filterSearchTimerRef.current = window.setTimeout(() => { setFilterSearchLoadingByKey((current) => ({ ...current, [filterKey]: true })); setFilterSearchErrorByKey((current) => ({ ...current, [filterKey]: null })); setFilterSearchTreeByKey((current) => ({ ...current, [filterKey]: filterTreeNodesByKeyword(filterTreeByKey[filterKey], keyword), })); setFilterSearchLoadingByKey((current) => ({ ...current, [filterKey]: false })); }, 300); return; } filterSearchTimerRef.current = window.setTimeout(() => { setFilterSearchLoadingByKey((current) => ({ ...current, [filterKey]: true })); setFilterSearchErrorByKey((current) => ({ ...current, [filterKey]: null })); void loadFilterTree(filterKey, keyword) .then((nodes) => { if (filterSearchRequestSeqRef.current[filterKey] !== requestSeq) return; setFilterSearchTreeByKey((current) => ({ ...current, [filterKey]: nodes })); }) .catch((error) => { if (filterSearchRequestSeqRef.current[filterKey] !== requestSeq) return; setFilterSearchErrorByKey((current) => ({ ...current, [filterKey]: error instanceof Error ? error.message : '加载失败', })); }) .finally(() => { if (filterSearchRequestSeqRef.current[filterKey] !== requestSeq) return; setFilterSearchLoadingByKey((current) => ({ ...current, [filterKey]: false })); }); }, 500); }; const toggleFilterTreeNode = (nodeId: string) => { if (!filterModalKey) return; const visit = (nodes: TreeNode[]): TreeNode | null => { for (const node of nodes) { if (node.id === nodeId) return node; const matched = visit(node.children); if (matched) return matched; } return null; }; const node = visit(filterTreeByKey[filterModalKey]); if (!node?.hasChildren) return; setFilterTreeByKey((current) => ({ ...current, [filterModalKey]: updateNode(current[filterModalKey], nodeId, (currentNode) => ({ ...currentNode, expanded: currentNode.loaded ? !currentNode.expanded : currentNode.expanded, loading: currentNode.loaded ? currentNode.loading : true, })), })); if (node.loaded || !isContentFilterKey(filterModalKey)) return; const currentFilterKey = filterModalKey; fetchContentTree(currentFilterKey, nodeId) .then((children) => { setFilterTreeByKey((current) => ({ ...current, [currentFilterKey]: updateNode(current[currentFilterKey], nodeId, (currentNode) => ({ ...currentNode, children, expanded: true, loading: false, loaded: true, hasChildren: currentNode.hasChildren || children.length > 0, })), })); }) .catch((error) => { setFilterTreeByKey((current) => ({ ...current, [currentFilterKey]: updateNode(current[currentFilterKey], nodeId, (currentNode) => ({ ...currentNode, loading: false, })), })); setFilterTreeErrorByKey((current) => ({ ...current, [currentFilterKey]: error instanceof Error ? error.message : '加载失败', })); }); }; const toggleDraftFilterNode = (node: TreeNode) => { if (!filterModalKey) return; const currentFilterKey = filterModalKey; setDraftFilterNodes((current) => { const selectionKey = getFilterSelectionKey(currentFilterKey, node.id); const exists = current.some((item) => getFilterSelectionKey(item.filterKey, item.id) === selectionKey); if (isSingleSelectFilterKey(currentFilterKey)) { if (exists) { return current; } return [{ id: node.id, filterKey: currentFilterKey, label: node.label }]; } if (exists) { return current.filter((item) => getFilterSelectionKey(item.filterKey, item.id) !== selectionKey); } return [...current, { id: node.id, filterKey: currentFilterKey, label: node.label }]; }); }; const applyFilterModal = () => { if (!filterModalKey) return; let nextDraftNodes = draftFilterNodes; if (isTemplateFilterKey(filterModalKey) && nextDraftNodes.length === 0) { nextDraftNodes = getDefaultTemplateFilterNodes(); } if (isIndicatorTreeFilterKey(filterModalKey)) { if (nextDraftNodes.length === 0) { nextDraftNodes = defaultIndicatorTreeNodes; } if (isDefaultIndicatorTreeSelection(nextDraftNodes, filterTreeByKey.indicatorTree)) { nextDraftNodes = []; } } const shouldReloadIndicatorTree = isTemplateFilterKey(filterModalKey) && !isSameFilterSelection(appliedFilters.templateLibrary, nextDraftNodes); setAppliedFilters((current) => { const nextFilters = { ...current, [filterModalKey]: nextDraftNodes, }; if (shouldReloadIndicatorTree) { nextFilters.indicatorTree = []; } return nextFilters; }); if (shouldReloadIndicatorTree) { resetIndicatorTreeState(); } setChartDataBySelection({}); setChartSummaryBySelection({}); setLoadError(null); if (selectedContentNodes.length > 0) { setLoadingHint('正在按筛选条件重新计算'); setLoading(true); } setChartQueryVersion((version) => version + 1); closeFilterModal(); }; const clearFilter = (filterKey: FilterKey) => { const nextNodes = isTemplateFilterKey(filterKey) ? getDefaultTemplateFilterNodes() : isIndicatorTreeFilterKey(filterKey) ? defaultIndicatorTreeNodes : []; if (isSameFilterSelection(appliedFilters[filterKey], nextNodes)) { return; } setAppliedFilters((current) => { const nextFilters = { ...current, [filterKey]: nextNodes, }; if (isTemplateFilterKey(filterKey)) { nextFilters.indicatorTree = []; } return nextFilters; }); if (isTemplateFilterKey(filterKey)) { resetIndicatorTreeState(); } setChartDataBySelection({}); setChartSummaryBySelection({}); setLoadError(null); if (selectedContentNodes.length > 0) { setLoadingHint('正在按筛选条件重新计算'); setLoading(true); } setChartQueryVersion((version) => version + 1); }; const updateMetricKey = (nextMetricKey: MetricKey) => { setMetricKey(nextMetricKey); setChartDataBySelection({}); setChartSummaryBySelection({}); setLoadError(null); setLoadingHint('正在重新加载数据'); setLoading(true); setChartQueryVersion((version) => version + 1); }; const togglePivotView = useCallback(() => { setChartViewKey((current) => (current === 'trend' ? 'pivot' : 'trend')); setMetricMenuOpen(false); 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; const currentContentKey = activeContentKey; treeInitialLoadStartedRef.current[currentContentKey] = true; setTreeLoadingByContent((current) => ({ ...current, [currentContentKey]: true })); setTreeErrorByContent((current) => ({ ...current, [currentContentKey]: null })); loadContentTreeWithDefaultExpansion(currentContentKey) .then((nodes) => { setTreeByContent((current) => ({ ...current, [currentContentKey]: nodes })); }) .catch((error) => { treeInitialLoadStartedRef.current[currentContentKey] = false; setTreeErrorByContent((current) => ({ ...current, [currentContentKey]: error instanceof Error ? error.message : '加载失败', })); }) .finally(() => { setTreeLoadingByContent((current) => ({ ...current, [currentContentKey]: false })); }); }, [activeContentKey, treeByContent]); useEffect(() => { if (filterModalKey !== 'indicatorTree') return; if (draftFilterNodes.length > 0) return; if (defaultIndicatorTreeNodes.length === 0) return; setDraftFilterNodes(defaultIndicatorTreeNodes); }, [defaultIndicatorTreeNodes, draftFilterNodes.length, filterModalKey]); useEffect(() => { const controller = new AbortController(); async function loadStats() { if (selectedContentNodes.length === 0) { setChartDataBySelection({}); } setLoading(true); setLoadingHint('正在加载数据'); setLoadError(null); try { const hasAnyMissingNode = selectedContentNodes.some((node) => !chartDataBySelection[getSelectionKey(node.contentKey, node.id)]); if (selectedContentNodes.length > 0 && !hasAnyMissingNode && chartSummaryBySelection[overallSummaryKey]) { setLoading(false); setLoadingHint(''); return; } const response = await fetch(`${API_BASE_URL}/zw/getBuildingFunctionCostStatsBatch`, { method: 'POST', signal: controller.signal, headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ groupBy: 'year', metric: requestMetricKey, templateId: selectedTemplateId, filters: appliedFilterPayload, nodes: selectedContentNodes.map((node) => ({ key: getSelectionKey(node.contentKey, node.id), contentKey: node.contentKey, nodeId: node.id, })), }), }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const payload = (await response.json()) as { data?: ApiBuildingFunctionStatBatchItem[] }; 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 : '接口请求失败'); } finally { if (!controller.signal.aborted) { setLoading(false); setLoadingHint(''); } } } void loadStats(); return () => { controller.abort(); }; }, [appliedFilterPayload, chartQueryVersion, metricKey, requestMetricKey, selectedContentNodes, selectedTemplateId]); useEffect(() => { const frame = chartFrameRef.current; const fullscreenTarget = workspaceRef.current; if (!frame || !fullscreenTarget) return; const getTemplateButton = () => frame.querySelector('.ag-charts-myButton-template')?.closest('.ag-charts-toolbar__button'); const getIndicatorButton = () => frame.querySelector('.ag-charts-myButton-indicator')?.closest('.ag-charts-toolbar__button'); const getFullscreenButton = () => frame.querySelector('.ag-charts-myButton-fullScreen')?.closest('.ag-charts-toolbar__button'); const getStatisticButton = () => frame.querySelector('.ag-charts-myButton-statistic')?.closest('.ag-charts-toolbar__button'); const getPivotButton = () => frame.querySelector('.ag-charts-myButton-pivot')?.closest('.ag-charts-toolbar__button'); const setButtonAttribute = (button: HTMLButtonElement, name: string, value: string) => { if (button.getAttribute(name) !== value) { button.setAttribute(name, value); } }; const enableCustomToolbarButton = (button: HTMLButtonElement) => { if (button.disabled) { button.disabled = false; } if (button.hasAttribute('disabled')) { button.removeAttribute('disabled'); } setButtonAttribute(button, 'aria-disabled', 'false'); }; const syncToolbarButtons = () => { const templateButton = getTemplateButton(); if (templateButton) { templateButton.classList.add('chart-template-button'); enableCustomToolbarButton(templateButton); setButtonAttribute(templateButton, 'aria-expanded', String(filterModalKey === 'templateLibrary')); } const indicatorButton = getIndicatorButton(); if (indicatorButton) { indicatorButton.classList.add('chart-indicator-button'); enableCustomToolbarButton(indicatorButton); setButtonAttribute(indicatorButton, 'aria-expanded', String(filterModalKey === 'indicatorTree')); setButtonAttribute(indicatorButton, 'title', '指标树形'); } const button = getFullscreenButton(); if (button) { let icon = button.querySelector('.ag-charts-myButton-fullScreen'); if (!icon) { button.innerHTML = ''; icon = button.querySelector('.ag-charts-myButton-fullScreen'); } const isFullscreen = document.fullscreenElement === fullscreenTarget; button.classList.add('chart-fullscreen-button'); button.classList.toggle('ag-charts-toolbar__button--active', isFullscreen); enableCustomToolbarButton(button); setButtonAttribute(button, 'aria-pressed', String(isFullscreen)); icon?.classList.toggle('anticon-arrow-salt', !isFullscreen); icon?.classList.toggle('anticon-shrink', isFullscreen); } const statisticButton = getStatisticButton(); if (statisticButton) { statisticButton.classList.add('chart-statistic-button'); enableCustomToolbarButton(statisticButton); setButtonAttribute(statisticButton, 'aria-expanded', String(statisticMenuOpen)); } const pivotButton = getPivotButton(); if (pivotButton) { pivotButton.classList.add('chart-pivot-button'); pivotButton.classList.toggle('ag-charts-toolbar__button--active', chartViewKey === 'pivot'); enableCustomToolbarButton(pivotButton); setButtonAttribute(pivotButton, 'aria-pressed', String(chartViewKey === 'pivot')); setButtonAttribute(pivotButton, 'aria-label', pivotToggleActionLabel); setButtonAttribute(pivotButton, 'title', pivotToggleTitle); } }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key !== 'F11') return; event.preventDefault(); event.stopPropagation(); toggleWorkspaceFullscreen(); }; const handleFullscreenChange = () => { syncToolbarButtons(); }; const handleToolbarAction = (button: HTMLButtonElement) => { if (button.classList.contains('chart-template-button')) { setMetricMenuOpen(false); setStatisticMenuOpen(false); openFilterModal('templateLibrary'); } else if (button.classList.contains('chart-indicator-button')) { setMetricMenuOpen(false); setStatisticMenuOpen(false); openFilterModal('indicatorTree'); } else if (button.classList.contains('chart-statistic-button')) { setMetricMenuOpen(false); setStatisticMenuOpen((open) => !open); } else if (button.classList.contains('chart-pivot-button')) { togglePivotView(); } else { toggleWorkspaceFullscreen(); } }; const handleToolbarClick = (event: MouseEvent) => { const target = event.target as Element | null; const button = target?.closest( '.chart-template-button, .chart-indicator-button, .chart-fullscreen-button, .chart-statistic-button, .chart-pivot-button', ); if (!button || !frame.contains(button)) return; event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); handleToolbarAction(button); }; const handleToolbarKeyDown = (event: KeyboardEvent) => { if (event.key !== ' ' && event.key !== 'Enter') return; const target = event.target as Element | null; const button = target?.closest( '.chart-template-button, .chart-indicator-button, .chart-fullscreen-button, .chart-statistic-button, .chart-pivot-button', ); if (!button || !frame.contains(button)) return; event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); handleToolbarAction(button); }; const suppressBrowserContextMenu = (event: MouseEvent) => { event.preventDefault(); }; const observer = new MutationObserver(syncToolbarButtons); const initialSyncTimers = [ window.setTimeout(syncToolbarButtons, 0), window.setTimeout(syncToolbarButtons, 100), window.setTimeout(syncToolbarButtons, 500), window.setTimeout(syncToolbarButtons, 1000), ]; document.addEventListener('keydown', handleKeyDown, true); document.addEventListener('fullscreenchange', handleFullscreenChange); document.addEventListener('contextmenu', suppressBrowserContextMenu); frame.addEventListener('contextmenu', suppressBrowserContextMenu); frame.addEventListener('click', handleToolbarClick, true); frame.addEventListener('keydown', handleToolbarKeyDown, true); observer.observe(frame, { childList: true, subtree: true }); syncToolbarButtons(); return () => { initialSyncTimers.forEach((timer) => window.clearTimeout(timer)); document.removeEventListener('keydown', handleKeyDown, true); document.removeEventListener('fullscreenchange', handleFullscreenChange); document.removeEventListener('contextmenu', suppressBrowserContextMenu); frame.removeEventListener('contextmenu', suppressBrowserContextMenu); frame.removeEventListener('click', handleToolbarClick, true); frame.removeEventListener('keydown', handleToolbarKeyDown, true); observer.disconnect(); }; }, [ activeContentKey, appliedFilters, filterModalKey, filterTreeByKey, chartViewKey, pivotToggleActionLabel, pivotToggleTitle, selectedContentNodes.length, statisticMenuOpen, togglePivotView, toggleWorkspaceFullscreen, ]); const chartOptions = useMemo(() => { const trendData = groupNames.map((groupName) => { const row: Record = { groupName }; selectedContentNodes.forEach((node) => { const datum = chartDataBySelection[getSelectionKey(node.contentKey, node.id)]?.find((item) => item.groupName === groupName); row[getSeriesValueKey(node.contentKey, node.id)] = datum?.[selectedValueKey] ?? null; }); return row; }); const series = selectedContentNodes.map((node) => ({ type: 'line' as const, xKey: 'groupName', yKey: getSeriesValueKey(node.contentKey, node.id), yName: `${node.label} ${seriesValueLabel}`, stroke: node.color, strokeWidth: 2, connectMissingData: true, marker: { enabled: true, fill: node.color, stroke: node.color, size: 5, }, interpolation: { type: 'smooth' as const, }, tooltip: { renderer: ({ datum, yKey, yName }: { datum: Record; yKey: string; yName?: string }) => ({ title: yName ?? '', data: [ { label: selectedMetric.label, value: formatChartValue(Number(datum[yKey]), metricKey) }, ], }), }, })); return { theme: { palette: { fills: ['#006f9b', '#ff7faa', '#00994d', '#ff8833', '#00a0dd'], strokes: ['#003f58', '#934962', '#004a25', '#914d1d', '#006288'], }, params: { foregroundColor: '#262a33', backgroundColor: '#fff1e5', accentColor: '#0d7680', fontFamily: '"Microsoft YaHei", "PingFang SC", "Segoe UI", Arial, sans-serif', fontSize: 14, tooltipBackgroundColor: '#fff7ef', tooltipTextColor: '#262a33', }, }, locale: { localeText: AG_CHARTS_LOCALE_ZH_CN, }, background: { fill: 'transparent', }, padding: { top: 16, right: 16, bottom: 18, left: 24, }, data: trendData, zoom: { enabled: true, anchorPointX: 'pointer', anchorPointY: 'pointer', buttons: { enabled: true, visible: 'hover', buttons: [ { icon: 'zoom-out', value: 'zoom-out', section: 'zoom', tooltip: '缩小' }, { icon: 'zoom-in', value: 'zoom-in', section: 'zoom', tooltip: '放大' }, { icon: 'pan-left', value: 'pan-left', section: 'pan', tooltip: '左移' }, { icon: 'pan-right', value: 'pan-right', section: 'pan', tooltip: '右移' }, { icon: 'reset', value: 'reset', section: 'reset', tooltip: '重置' }, ], }, }, contextMenu: { enabled: true, items: ['defaults'], }, overlays: { noData: { text: chartEmptyText, }, }, annotations: { enabled: true, toolbar: { buttons: ([ { value: 'callout', tooltip: '模板库', label: '', }, { value: 'text', tooltip: '指标树形', label: '', }, { value: 'note', tooltip: '切换统计指标', label: `${selectedStatistic.shortLabel}`, }, { icon: 'trend-line-drawing', value: 'line-menu', tooltip: 'Line Tool', }, { icon: 'text-annotation', value: 'text-menu', tooltip: 'Text Tool', }, { icon: 'arrow-drawing', value: 'shape-menu', tooltip: 'Shape Tool', }, { icon: 'fibonacci-retracement-drawing', value: 'fibonacci-menu', tooltip: 'Fibonacci Tool', }, { value: 'comment', tooltip: '全屏(F11)', label: '', }, { icon: 'delete', value: 'clear', tooltip: 'Clear annotations', }, { value: 'comment', tooltip: pivotToggleActionLabel, label: `${currentViewShortLabel}`, }, ] as unknown as NonNullable['toolbar']>['buttons']), }, }, series, axes: { x: { type: 'category', position: 'bottom', line: { enabled: true, stroke: '#c8b9a7', }, tick: { enabled: false, }, label: { color: '#1f2933', fontSize: 12, }, crosshair: { snap: false, }, }, y: { type: 'number', position: 'left', title: { text: '', }, label: { color: '#1f2933', fontSize: 12, formatter: ({ value }) => formatChartValue(Number(value), metricKey), }, line: { enabled: false, }, tick: { enabled: false, }, gridLine: { enabled: true, style: [ { stroke: '#e5d9ca', lineDash: [0], }, ], }, crosshair: { snap: false, }, }, }, legend: { enabled: selectedContentNodes.length > 1, }, tooltip: { enabled: true, mode: 'shared', pagination: true, }, }; }, [ activeFilterCount, chartDataBySelection, chartEmptyText, groupNames, metricKey, currentViewShortLabel, pivotToggleActionLabel, requestMetricKey, selectedContentNodes, selectedMetric.label, seriesValueLabel, selectedValueKey, statisticKey, ]); const renderMetricSwitcher = (variant: 'chart' | 'grid') => (
{metricMenuOpen ? (
{metricOptions.map((option) => ( ))}
) : null}
); return (
{indicatorSelectionLabel ?
{indicatorSelectionLabel}
: null}
{chartFilterOptions.map((option) => { const count = appliedFilters[option.key].length; const FilterIcon = option.icon; return ( ); })} {activeFilterCount > 0 ? ( ) : null}
{statisticMenuOpen ? (
{statisticOptions.map((option) => ( ))}
) : null} {chartViewKey === 'trend' ? renderMetricSwitcher('chart') : null} {loading || loadError ?
{loading ? loadingHint || '加载中' : loadError}
: null} {chartViewKey === 'pivot' ? (