diff --git a/bun.lock b/bun.lock index 77b5072..cb28c1f 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,8 @@ "ag-charts-community": "^13.2.1", "ag-charts-enterprise": "13.2.1", "ag-charts-react": "^13.2.1", + "ag-grid-community": "^35.3.0", + "ag-grid-react": "^35.3.0", "lucide-react": "^1.14.0", "react": "^19.1.0", "react-dom": "^19.1.0", @@ -202,6 +204,10 @@ "ag-charts-types": ["ag-charts-types@13.2.1", "", {}, "sha512-r7veb3QqJtIKlXmeUsLR4/oDPwmHxFI2tmbZra/203mdaz3uwQUrrgYNg628nrK+7L2YxXnwGc6L05tWjLLjNQ=="], + "ag-grid-community": ["ag-grid-community@35.3.0", "", { "dependencies": { "ag-charts-types": "13.3.0" } }, "sha512-c9WQWB88J965IjBC/GPUX30aAZix10o6oYT86DWipcxgLZTIQlLSilJJEr1bno/245rPEAIMjhoU1gp9VIfURg=="], + + "ag-grid-react": ["ag-grid-react@35.3.0", "", { "dependencies": { "ag-grid-community": "35.3.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-3c6YEFGQGNZxEi1PdK0b+WhKkKRJ7KxuYzsG4UmISyax5/J7N93f8B1TZK1pq+AgzPhdk/++vjZe3KhFdF3tog=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.27", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA=="], "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], @@ -232,6 +238,8 @@ "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "lucide-react": ["lucide-react@1.14.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA=="], @@ -242,16 +250,22 @@ "node-releases": ["node-releases@2.0.38", "", {}, "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], "postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="], + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + "react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="], "react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="], + "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], "rollup": ["rollup@4.60.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.3", "@rollup/rollup-android-arm64": "4.60.3", "@rollup/rollup-darwin-arm64": "4.60.3", "@rollup/rollup-darwin-x64": "4.60.3", "@rollup/rollup-freebsd-arm64": "4.60.3", "@rollup/rollup-freebsd-x64": "4.60.3", "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", "@rollup/rollup-linux-arm-musleabihf": "4.60.3", "@rollup/rollup-linux-arm64-gnu": "4.60.3", "@rollup/rollup-linux-arm64-musl": "4.60.3", "@rollup/rollup-linux-loong64-gnu": "4.60.3", "@rollup/rollup-linux-loong64-musl": "4.60.3", "@rollup/rollup-linux-ppc64-gnu": "4.60.3", "@rollup/rollup-linux-ppc64-musl": "4.60.3", "@rollup/rollup-linux-riscv64-gnu": "4.60.3", "@rollup/rollup-linux-riscv64-musl": "4.60.3", "@rollup/rollup-linux-s390x-gnu": "4.60.3", "@rollup/rollup-linux-x64-gnu": "4.60.3", "@rollup/rollup-linux-x64-musl": "4.60.3", "@rollup/rollup-openbsd-x64": "4.60.3", "@rollup/rollup-openharmony-arm64": "4.60.3", "@rollup/rollup-win32-arm64-msvc": "4.60.3", "@rollup/rollup-win32-ia32-msvc": "4.60.3", "@rollup/rollup-win32-x64-gnu": "4.60.3", "@rollup/rollup-win32-x64-msvc": "4.60.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A=="], @@ -271,5 +285,7 @@ "vite": ["vite@7.3.2", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "ag-grid-community/ag-charts-types": ["ag-charts-types@13.3.0", "", {}, "sha512-UMoAn908LC4ZIJSNfUckSBEFa79Mi1vFRA8qIRx+NusEuuFgXDioCZx4MxM7O3rDXlxTWH9DvQmcDjh7vyd89w=="], } } diff --git a/index.html b/index.html index 4ed0f49..69daf8b 100644 --- a/index.html +++ b/index.html @@ -6,7 +6,7 @@ AG Chart Service -
+
diff --git a/package.json b/package.json index a1a331f..047d7a0 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "ag-charts-community": "^13.2.1", "ag-charts-enterprise": "13.2.1", "ag-charts-react": "^13.2.1", + "ag-grid-community": "^35.3.0", + "ag-grid-react": "^35.3.0", "lucide-react": "^1.14.0", "react": "^19.1.0", "react-dom": "^19.1.0" diff --git a/src/App.tsx b/src/App.tsx index 210d9da..3b3ec0e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,13 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +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, SquareFunction, Waypoints } from 'lucide-react'; @@ -14,6 +22,7 @@ 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'; @@ -39,6 +48,14 @@ const metricOptions = [ { key: 'dataCount', label: '数据量' }, ] as const; +const metricShortLabels: Record = { + cost: '价', + buildingArea: '建', + builtArea: '造', + usableArea: '用', + dataCount: '数', +}; + const contentOptions = [ { key: 'geoLocation', label: '自然地理区位' }, { key: 'facilityType', label: '设施类别' }, @@ -157,6 +174,7 @@ type MetricKey = (typeof metricOptions)[number]['key']; type ContentKey = (typeof contentOptions)[number]['key']; type FilterKey = (typeof filterOptions)[number]['key']; type GroupKey = 'year'; +type ChartViewKey = 'trend' | 'pivot'; type ApiBuildingFunctionStat = { group_key?: string | number | null; group_name?: string | null; @@ -164,6 +182,15 @@ type ApiBuildingFunctionStat = { 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 = { @@ -176,6 +203,12 @@ type ChartDatum = { 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 = { @@ -201,6 +234,21 @@ type SelectedFilterNode = { filterKey: FilterKey; label: string; }; +type PivotGridRow = { + year: string; + name: string; + 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', { @@ -235,12 +283,22 @@ function formatChartValue(value: number, metricKey: MetricKey) { } 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: row.avg_value ?? null, - medianValue: row.median_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, }; } @@ -679,6 +737,7 @@ function App() { const [statisticKey, setStatisticKey] = useState('avgValue'); const [metricKey, setMetricKey] = useState('cost'); const [groupKey, setGroupKey] = useState('year'); + const [chartViewKey, setChartViewKey] = useState('trend'); const [statisticMenuOpen, setStatisticMenuOpen] = useState(false); const [metricMenuOpen, setMetricMenuOpen] = useState(false); const [activeContentKey, setActiveContentKey] = useState('geoLocation'); @@ -798,6 +857,10 @@ function App() { 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 activeContent = contentOptions.find((option) => option.key === activeContentKey) ?? contentOptions[0]; const activeTree = treeByContent[activeContentKey]; const activeFilter = filterOptions.find((option) => option.key === filterModalKey); @@ -815,8 +878,12 @@ function App() { () => 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 + (key === 'templateLibrary' && isDefaultTemplateSelection(nodes)) + || (key === 'indicatorTree' && isDefaultIndicatorTreeSelection(nodes, filterTreeByKey.indicatorTree)) + ? total + : total + nodes.length ), 0); const chartEmptyText = selectedContentNodes.length === 0 ? '请选择右侧分类项' @@ -826,6 +893,145 @@ function App() { 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, + 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 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], @@ -834,6 +1040,14 @@ function App() { () => new Set(draftFilterNodes.map((node) => getFilterSelectionKey(node.filterKey, node.id))), [draftFilterNodes], ); + + useEffect(() => { + if (defaultIndicatorTreeNodes.length === 0) return; + setAppliedFilters((current) => { + if (current.indicatorTree.length > 0) return current; + return { ...current, indicatorTree: defaultIndicatorTreeNodes }; + }); + }, [defaultIndicatorTreeNodes]); const appliedFilterPayload = useMemo( () => filterOptions .map((option) => ({ @@ -1143,6 +1357,10 @@ function App() { }); }; + useEffect(() => { + ensureFilterTreeLoaded('indicatorTree'); + }, [selectedTemplateId]); + const openFilterModal = (filterKey: FilterKey) => { setFilterModalKey(filterKey); if (isIndicatorTreeFilterKey(filterKey) && appliedFilters[filterKey].length === 0 && defaultIndicatorTreeNodes.length > 0) { @@ -1313,9 +1531,6 @@ function App() { if (nextDraftNodes.length === 0) { nextDraftNodes = defaultIndicatorTreeNodes; } - if (isDefaultIndicatorTreeSelection(nextDraftNodes, filterTreeByKey.indicatorTree)) { - nextDraftNodes = []; - } } setAppliedFilters((current) => { const nextFilters = { @@ -1380,6 +1595,18 @@ function App() { setChartQueryVersion((version) => version + 1); }; + const togglePivotView = useCallback(() => { + setChartViewKey((current) => (current === 'trend' ? 'pivot' : 'trend')); + setMetricMenuOpen(false); + setStatisticMenuOpen(false); + }, []); + + const openGridFilterModal = (filterKey: 'templateLibrary' | 'indicatorTree') => { + setMetricMenuOpen(false); + setStatisticMenuOpen(false); + openFilterModal(filterKey); + }; + useEffect(() => { if (!contentTreeConfigs[activeContentKey]) return; if (treeByContent[activeContentKey].length > 0 || treeInitialLoadStartedRef.current[activeContentKey]) return; @@ -1491,6 +1718,7 @@ function App() { 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); @@ -1543,6 +1771,16 @@ function App() { 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 toggleFullscreen = () => { @@ -1565,16 +1803,7 @@ function App() { syncToolbarButtons(); }; - 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', - ); - if (!button || !frame.contains(button)) return; - - event.preventDefault(); - event.stopPropagation(); - event.stopImmediatePropagation(); + const handleToolbarAction = (button: HTMLButtonElement) => { if (button.classList.contains('chart-template-button')) { setMetricMenuOpen(false); setStatisticMenuOpen(false); @@ -1586,37 +1815,39 @@ function App() { } else if (button.classList.contains('chart-statistic-button')) { setMetricMenuOpen(false); setStatisticMenuOpen((open) => !open); + } else if (button.classList.contains('chart-pivot-button')) { + togglePivotView(); } else { toggleFullscreen(); } }; + 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-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(); - 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 { - toggleFullscreen(); - } + handleToolbarAction(button); }; const suppressBrowserContextMenu = (event: MouseEvent) => { @@ -1649,20 +1880,20 @@ function App() { frame.removeEventListener('keydown', handleToolbarKeyDown, true); observer.disconnect(); }; - }, [activeContentKey, appliedFilters, filterModalKey, filterTreeByKey, selectedContentNodes.length, statisticMenuOpen]); + }, [ + activeContentKey, + appliedFilters, + filterModalKey, + filterTreeByKey, + chartViewKey, + pivotToggleActionLabel, + pivotToggleTitle, + selectedContentNodes.length, + statisticMenuOpen, + togglePivotView, + ]); 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); - }); - }); - groupNames.sort(compareGroupNames); const visibleData = groupNames.map((groupName) => { const row: Record = { groupName }; selectedContentNodes.forEach((node) => { @@ -1738,7 +1969,7 @@ function App() { { value: 'text', tooltip: '指标树形', - label: '', + label: '', }, { value: 'note', @@ -1775,6 +2006,11 @@ function App() { value: 'clear', tooltip: 'Clear annotations', }, + { + value: 'comment', + tooltip: pivotToggleActionLabel, + label: `${currentViewShortLabel}`, + }, ] as unknown as NonNullable['toolbar']>['buttons']), }, }, @@ -1863,7 +2099,59 @@ function App() { pagination: true, }, }; - }, [activeFilterCount, chartDataBySelection, chartEmptyText, metricKey, requestMetricKey, selectedContentNodes, selectedMetric.label, seriesValueLabel, selectedValueKey, statisticKey]); + }, [ + 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 (
@@ -1874,6 +2162,7 @@ function App() {
+ {indicatorSelectionLabel ?
{indicatorSelectionLabel}
: null}
{chartFilterOptions.map((option) => { const count = appliedFilters[option.key].length; @@ -1899,10 +2188,9 @@ function App() { type="button" title="清空全部筛选" onClick={() => { - resetIndicatorTreeState(); setAppliedFilters({ templateLibrary: getDefaultTemplateFilterNodes(), - indicatorTree: [], + indicatorTree: defaultIndicatorTreeNodes, region: [], geoLocation: [], facilityType: [], @@ -1944,43 +2232,54 @@ function App() { ))}
) : null} -
- - {metricMenuOpen ? ( -
- {metricOptions.map((option) => ( - - ))} -
- ) : null} -
+ {chartViewKey === 'trend' ? renderMetricSwitcher('chart') : null} {loading || loadError ?
{loading ? loadingHint || '加载中' : loadError}
: null} + {chartViewKey === 'pivot' ? ( +
+ + + + {renderMetricSwitcher('grid')} + + rowData={pivotGridRowData} + columnDefs={pivotGridColumnDefs} + containerStyle={{ width: '100%', height: '100%' }} + theme="legacy" + defaultColDef={{ + sortable: true, + resizable: true, + filter: true, + }} + suppressCellFocus + overlayNoRowsTemplate={selectedContentNodes.length === 0 ? '请选择右侧分类项' : '暂无透视数据'} + /> +
+ ) : null} {loading ? (
{loadingHint || '加载中'}
@@ -2017,83 +2316,83 @@ function App() { )}
-
- {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 ? '无匹配结果' : '暂无数据'}
- )} -
-
- + +
+ { + 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} + +
+ + + +
+ + + ) : null} +
); } diff --git a/src/main.tsx b/src/main.tsx index 9810412..2eaa144 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -8,16 +8,29 @@ ModuleRegistry.registerModules([AllCommunityModule]); declare global { interface Window { - __zbChartRoot?: ReturnType; + __chartReactRoots?: WeakMap>; } } +const chartContainerId = 'sbChart'; + +const getRootRegistry = () => { + window.__chartReactRoots ??= new WeakMap>(); + return window.__chartReactRoots; +}; + const mount = () => { - const container = document.getElementById('zbChart'); + const container = document.getElementById(chartContainerId); if (!container) return false; - window.__zbChartRoot ??= createRoot(container); - window.__zbChartRoot.render( + const roots = getRootRegistry(); + let root = roots.get(container); + if (!root) { + root = createRoot(container); + roots.set(container, root); + } + + root.render( , @@ -32,7 +45,7 @@ if (!mount()) { if (mount() || retryCount >= 40) { window.clearInterval(timer); if (retryCount >= 40) { - console.warn('zbChart container was not found.'); + console.warn(`${chartContainerId} container was not found.`); } } }, 50); diff --git a/src/styles.css b/src/styles.css index 114aec7..a5b6789 100644 --- a/src/styles.css +++ b/src/styles.css @@ -362,15 +362,19 @@ button { .chart-frame .chart-template-button, .chart-frame .chart-indicator-button, .chart-frame .chart-statistic-button, +.chart-frame .chart-pivot-button, .chart-frame .chart-fullscreen-button { color: #46413b; cursor: pointer !important; opacity: 1 !important; + overflow: visible !important; pointer-events: auto !important; } .chart-frame .ag-charts-myButton-template, -.chart-frame .ag-charts-myButton-indicator { +.chart-frame .ag-charts-myButton-indicator, +.chart-frame .ag-charts-myButton-pivot { + position: relative; display: block; min-width: 16px; height: 16px; @@ -380,9 +384,36 @@ button { text-align: center; } +.chart-indicator-selection-label { + position: absolute; + left: 28px; + top: 6px; + z-index: 3; + max-width: min(560px, calc(100vw - 56px)); + padding: 0; + border: 0; + border-radius: 0; + color: #0078a8; + background: transparent; + box-shadow: none; + font-size: 15px; + font-weight: 600; + line-height: 20px; + overflow: hidden; + pointer-events: none; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workspace:fullscreen .chart-indicator-selection-label { + left: 28px; + top: 6px; +} + .chart-frame .chart-template-button[aria-expanded="true"], .chart-frame .chart-indicator-button[aria-expanded="true"], .chart-frame .chart-statistic-button[aria-expanded="true"], +.chart-frame .chart-pivot-button.ag-charts-toolbar__button--active, .chart-frame .chart-fullscreen-button.ag-charts-toolbar__button--active { color: #0078a8; border-color: rgba(0, 120, 168, 0.36); @@ -390,11 +421,87 @@ button { box-shadow: 0 1px 5px rgba(69, 54, 36, 0.12); } +.chart-pivot-grid-panel { + position: absolute; + inset: 16px 0 0; + z-index: 10; + min-width: 0; + min-height: 0; + padding: 12px 16px 16px 74px; + border: 1px solid rgba(90, 82, 72, 0.22); + border-radius: 3px; + background: rgba(255, 249, 241, 0.96); + box-shadow: 0 6px 18px rgba(69, 54, 36, 0.08); +} + +.chart-pivot-grid-panel .ag-root-wrapper { + border-color: rgba(90, 82, 72, 0.18); + border-radius: 3px; +} + +.chart-grid-tool-button { + position: absolute; + left: 24px; + z-index: 12; + width: 26px; + height: 26px; + min-width: 26px; + min-height: 26px; + padding: 0; + border: 1px solid rgba(90, 82, 72, 0.22); + border-radius: 3px; + color: #46413b; + background: rgba(255, 249, 241, 0.72); + font-size: 14px; + font-weight: 600; + line-height: 24px; + text-align: center; + cursor: pointer; +} + +.chart-grid-tool-button--template { + top: 12px; +} + +.chart-grid-tool-button--indicator { + top: 46px; +} + +.chart-grid-tool-button--trend { + top: 80px; +} + +.chart-grid-tool-button:hover { + color: #0078a8; + border-color: rgba(0, 120, 168, 0.36); + background: rgba(255, 252, 248, 0.94); + box-shadow: 0 1px 5px rgba(69, 54, 36, 0.12); +} + +.chart-pivot-grid-panel.ag-theme-quartz { + --ag-active-color: #0078a8; + --ag-background-color: #fffaf4; + --ag-border-color: rgba(90, 82, 72, 0.18); + --ag-font-family: "Microsoft YaHei", "PingFang SC", "Segoe UI", Arial, sans-serif; + --ag-font-size: 12px; + --ag-foreground-color: #262a33; + --ag-header-background-color: #f6eadc; + --ag-row-hover-color: rgba(0, 120, 168, 0.08); +} + .metric-switcher { position: absolute; + z-index: 12; +} + +.metric-switcher--chart { left: 74px; top: 272px; - z-index: 12; +} + +.metric-switcher--grid { + left: 24px; + top: 114px; } .metric-switcher-button { @@ -420,6 +527,23 @@ button { text-orientation: upright; } +.metric-switcher-button--grid { + width: 26px; + height: 26px; + min-width: 26px; + min-height: 26px; + padding: 0; + border-color: rgba(90, 82, 72, 0.22); + color: #46413b; + background: rgba(255, 249, 241, 0.72); + font-size: 14px; + font-weight: 600; + letter-spacing: 0; + line-height: 24px; + writing-mode: horizontal-tb; + text-orientation: mixed; +} + .metric-switcher-button:hover, .metric-switcher-button[aria-expanded="true"] { color: #0078a8; diff --git a/vite.config.ts b/vite.config.ts index 5c59447..5bd8cc1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,7 +2,19 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; export default defineConfig({ - plugins: [react()], + plugins: [ + react(), + { + name: 'weaver-script-scope-wrapper', + renderChunk(code, chunk) { + if (!chunk.isEntry) return null; + return { + code: `;(() => {\n${code}\n})();\n`, + map: null, + }; + }, + }, + ], server: { port: 5173, },