import { useEffect, useMemo, useRef, useState } from 'react'; import { AgCharts } from 'ag-charts-react'; import type { AgCartesianChartOptions } from 'ag-charts-community'; import { ModuleRegistry } from 'ag-charts-community'; import { Building2, Construction, LayoutGrid, LocateFixed, MapPinned } 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]); 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 contentOptions = [ { key: 'geoLocation', label: '自然地理区位' }, { key: 'facilityType', label: '设施类别' }, { key: 'constructionStage', label: '建设阶段' }, { key: 'planningForm', label: '规划形式' }, ] as const; const filterOptions = [ { 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 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 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 GroupKey = 'year'; 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; data_count?: number | null; }; type ApiBuildingFunctionStatBatchItem = { key?: string; data?: ApiBuildingFunctionStat[]; }; type ChartDatum = { groupName: string; minValue: number | null; maxValue: number | null; avgValue: number | null; medianValue: number | null; dataCount: number | null; }; type TreeNode = { id: string; label: string; children: TreeNode[]; hasChildren: boolean; canClick: boolean; expanded: boolean; loading: boolean; loaded: boolean; }; type SelectedContentNode = { id: string; contentKey: ContentKey; label: string; color: string; }; type SelectedFilterNode = { id: string; filterKey: FilterKey; label: string; }; 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 { return { groupName: row.group_name || String(row.group_key ?? '未命名'), minValue: row.min_value ?? null, maxValue: row.max_value ?? null, avgValue: row.avg_value ?? null, medianValue: row.median_value ?? 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 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, }; }); } 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(index: number) { return `amount${index}`; } function isContentFilterKey(filterKey: FilterKey): filterKey is ContentKey { return filterKey !== 'region'; } 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)); 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>({ region: false, geoLocation: false, facilityType: false, constructionStage: false, planningForm: false, }); const [statisticKey, setStatisticKey] = useState('avgValue'); const [metricKey, setMetricKey] = useState('cost'); const [groupKey, setGroupKey] = useState('year'); 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 [chartQueryVersion, setChartQueryVersion] = useState(0); const [loading, setLoading] = useState(false); const [loadError, setLoadError] = useState(null); const [loadingHint, setLoadingHint] = useState(''); const [filterTreeByKey, setFilterTreeByKey] = useState>({ region: [], geoLocation: [], facilityType: [], constructionStage: [], planningForm: [], }); const [filterTreeLoadingByKey, setFilterTreeLoadingByKey] = useState>({ region: false, geoLocation: false, facilityType: false, constructionStage: false, planningForm: false, }); const [filterTreeErrorByKey, setFilterTreeErrorByKey] = useState>({ region: null, geoLocation: null, facilityType: null, constructionStage: null, planningForm: null, }); const [filterSearchTreeByKey, setFilterSearchTreeByKey] = useState>({ region: [], geoLocation: [], facilityType: [], constructionStage: [], planningForm: [], }); const [filterSearchLoadingByKey, setFilterSearchLoadingByKey] = useState>({ region: false, geoLocation: false, facilityType: false, constructionStage: false, planningForm: false, }); const [filterSearchErrorByKey, setFilterSearchErrorByKey] = useState>({ region: null, geoLocation: null, facilityType: null, constructionStage: null, planningForm: null, }); const [appliedFilters, setAppliedFilters] = useState>({ 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>({ 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 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 activeFilterCount = Object.values(appliedFilters).reduce((total, nodes) => 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 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 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 (keyword?.trim()) { return fetchBackendFilterTreeSearch(filterKey, keyword, signal); } return loadContentTreeWithDefaultExpansion(filterKey); }; 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; }); 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({}); setLoadError(null); setLoadingHint(''); setLoading(false); }; 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); setDraftFilterNodes(appliedFilters[filterKey]); setFilterSearchValue(''); lastFilterSearchRef.current = ''; if (filterSearchTimerRef.current != null) { window.clearTimeout(filterSearchTimerRef.current); filterSearchTimerRef.current = null; } ensureFilterTreeLoaded(filterKey); }; 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; } 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 (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; setAppliedFilters((current) => ({ ...current, [filterModalKey]: draftFilterNodes, })); setChartDataBySelection({}); setLoadError(null); if (selectedContentNodes.length > 0) { setLoadingHint('正在按筛选条件重新计算'); setLoading(true); } setChartQueryVersion((version) => version + 1); closeFilterModal(); }; const clearFilter = (filterKey: FilterKey) => { if (appliedFilters[filterKey].length === 0) return; setAppliedFilters((current) => ({ ...current, [filterKey]: [], })); setChartDataBySelection({}); setLoadError(null); if (selectedContentNodes.length > 0) { setLoadingHint('正在按筛选条件重新计算'); setLoading(true); } setChartQueryVersion((version) => version + 1); }; const updateMetricKey = (nextMetricKey: MetricKey) => { setMetricKey(nextMetricKey); setChartDataBySelection({}); setLoadError(null); setLoadingHint('正在重新加载数据'); setLoading(true); setChartQueryVersion((version) => version + 1); }; 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(() => { const controller = new AbortController(); async function loadStats() { if (selectedContentNodes.length === 0) { setChartDataBySelection({}); setLoading(false); setLoadingHint(''); setLoadError(null); return; } setLoading(true); setLoadingHint('正在加载数据'); setLoadError(null); try { const hasAnyMissingNode = selectedContentNodes.some((node) => !chartDataBySelection[getSelectionKey(node.contentKey, node.id)]); if (!hasAnyMissingNode) { 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: groupKey, metric: requestMetricKey, 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); setChartDataBySelection((current) => ({ ...current, ...Object.fromEntries(results), })); } 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, groupKey, metricKey, requestMetricKey, selectedContentNodes]); useEffect(() => { const frame = chartFrameRef.current; const fullscreenTarget = workspaceRef.current; if (!frame || !fullscreenTarget) return; const getFullscreenButton = () => frame.querySelector('.chart-fullscreen-button'); const getStatisticButton = () => frame.querySelector('.ag-charts-myButton-statistic')?.closest('.ag-charts-toolbar__button'); const syncToolbarButtons = () => { 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.toggle('ag-charts-toolbar__button--active', 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'); statisticButton.setAttribute('aria-expanded', String(statisticMenuOpen)); } }; 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(); }; const handleFullscreenChange = () => { syncToolbarButtons(); }; const handleToolbarClick = (event: MouseEvent) => { const target = event.target as Element | null; const button = target?.closest( '.chart-fullscreen-button, .chart-statistic-button', ); if (!button || !frame.contains(button)) return; event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); if (button.classList.contains('chart-statistic-button')) { setMetricMenuOpen(false); setStatisticMenuOpen((open) => !open); } else { toggleFullscreen(); } }; const handleToolbarKeyDown = (event: KeyboardEvent) => { if (event.key !== ' ' && event.key !== 'Enter') return; const target = event.target as Element | null; const button = target?.closest( '.chart-fullscreen-button, .chart-statistic-button', ); if (!button || !frame.contains(button)) return; event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); if (button.classList.contains('chart-statistic-button')) { setMetricMenuOpen(false); setStatisticMenuOpen((open) => !open); } else { toggleFullscreen(); } }; const suppressBrowserContextMenu = (event: MouseEvent) => { event.preventDefault(); }; const observer = new MutationObserver(syncToolbarButtons); 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 () => { 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(); }; }, [statisticMenuOpen]); const chartOptions = useMemo(() => { const groupNames: string[] = []; const groupNameSeen = new Set(); selectedContentNodes.forEach((node) => { const rows = chartDataBySelection[getSelectionKey(node.contentKey, node.id)] ?? []; rows.forEach((datum) => { if (groupNameSeen.has(datum.groupName)) return; groupNameSeen.add(datum.groupName); groupNames.push(datum.groupName); }); }); const visibleData = groupNames.map((groupName) => { const row: Record = { groupName }; selectedContentNodes.forEach((node, index) => { const datum = chartDataBySelection[getSelectionKey(node.contentKey, node.id)]?.find((item) => item.groupName === groupName); row[getSeriesValueKey(index)] = datum?.[selectedValueKey] ?? null; }); return row; }); 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: visibleData, 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: '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', }, { icon: 'delete', value: 'clear', tooltip: 'Clear annotations', }, ] as unknown as NonNullable['toolbar']>['buttons']), }, }, series: selectedContentNodes.map((node, index) => ({ type: 'line', xKey: 'groupName', yKey: getSeriesValueKey(index), yName: `${node.label} ${seriesValueLabel}`, stroke: node.color, strokeWidth: 2, marker: { enabled: true, fill: node.color, stroke: node.color, size: 5, }, interpolation: { type: 'smooth', }, tooltip: { renderer: ({ datum, yKey, yName }) => ({ title: yName, data: [ { label: selectedMetric.label, value: formatChartValue(Number(datum[yKey]), metricKey) }, ], }), }, })), 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, metricKey, requestMetricKey, selectedContentNodes, selectedMetric.label, seriesValueLabel, selectedValueKey, statisticKey]); return (
{filterOptions.map((option) => { const count = appliedFilters[option.key].length; const FilterIcon = option.icon; return ( ); })} {activeFilterCount > 0 ? ( ) : null}
{statisticMenuOpen ? (
{statisticOptions.map((option) => ( ))}
) : null}
{metricMenuOpen ? (
{metricOptions.map((option) => ( ))}
) : null}
{loading || loadError ?
{loading ? loadingHint || '加载中' : loadError}
: null} {loading ? (
{loadingHint || '加载中'}
) : null}
{filterModalKey && activeFilter ? (
event.stopPropagation()} >

{activeFilter.label}

{ const nextValue = event.target.value; setFilterSearchValue(nextValue); if (!filterSearchComposingRef.current && filterModalKey) { scheduleFilterSearch(filterModalKey, nextValue); } }} onCompositionStart={() => { filterSearchComposingRef.current = true; }} onCompositionEnd={(event) => { filterSearchComposingRef.current = false; const nextValue = event.currentTarget.value; setFilterSearchValue(nextValue); if (filterModalKey) { scheduleFilterSearch(filterModalKey, nextValue); } }} />
{draftFilterNodes.length > 0 ? `已选 ${draftFilterNodes.length} 项` : '未选择'}
{activeFilterTreeLoading ? (
加载中
) : activeFilterTreeError ? (
{activeFilterTreeError}
) : activeFilterDisplayTree.length > 0 ? ( renderFilterTreeNodes(activeFilterDisplayTree, filterModalKey, draftFilterNodeKeys, toggleFilterTreeNode, toggleDraftFilterNode) ) : (
{trimmedFilterSearchValue ? '无匹配结果' : '暂无数据'}
)}
) : null}
); } export default App;