'1'
This commit is contained in:
1 parent
58abe588aa
commit
cfb6afcdce
7 files changed
+634
-168
No files matched your search
+458
-159
@@ -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<MetricKey, string> = {
|
||||
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<StatisticKey>('avgValue');
|
||||
const [metricKey, setMetricKey] = useState<MetricKey>('cost');
|
||||
const [groupKey, setGroupKey] = useState<GroupKey>('year');
|
||||
const [chartViewKey, setChartViewKey] = useState<ChartViewKey>('trend');
|
||||
const [statisticMenuOpen, setStatisticMenuOpen] = useState(false);
|
||||
const [metricMenuOpen, setMetricMenuOpen] = useState(false);
|
||||
const [activeContentKey, setActiveContentKey] = useState<ContentKey>('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<string>();
|
||||
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<PivotGridRow[]>(
|
||||
() => 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<PivotGridRow> | ColGroupDef<PivotGridRow>)[]>(
|
||||
() => {
|
||||
const valueFormatter = ({ value }: ValueFormatterParams<PivotGridRow, number | null>) => (
|
||||
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<PivotGridRow, number | null>) => (
|
||||
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<HTMLElement>('.ag-charts-myButton-indicator')?.closest<HTMLButtonElement>('.ag-charts-toolbar__button');
|
||||
const getFullscreenButton = () => frame.querySelector<HTMLElement>('.ag-charts-myButton-fullScreen')?.closest<HTMLButtonElement>('.ag-charts-toolbar__button');
|
||||
const getStatisticButton = () => frame.querySelector<HTMLElement>('.ag-charts-myButton-statistic')?.closest<HTMLButtonElement>('.ag-charts-toolbar__button');
|
||||
const getPivotButton = () => frame.querySelector<HTMLElement>('.ag-charts-myButton-pivot')?.closest<HTMLButtonElement>('.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<HTMLButtonElement>(
|
||||
'.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<HTMLButtonElement>(
|
||||
'.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<HTMLButtonElement>(
|
||||
'.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<AgCartesianChartOptions>(() => {
|
||||
const groupNames: string[] = [];
|
||||
const groupNameSeen = new Set<string>();
|
||||
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<string, string | number | null> = { groupName };
|
||||
selectedContentNodes.forEach((node) => {
|
||||
@@ -1738,7 +1969,7 @@ function App() {
|
||||
{
|
||||
value: 'text',
|
||||
tooltip: '指标树形',
|
||||
label: '<span class="ag-charts-myButton-indicator ag-charts-diy-button">树</span>',
|
||||
label: '<span class="ag-charts-myButton-indicator ag-charts-diy-button">指</span>',
|
||||
},
|
||||
{
|
||||
value: 'note',
|
||||
@@ -1775,6 +2006,11 @@ function App() {
|
||||
value: 'clear',
|
||||
tooltip: 'Clear annotations',
|
||||
},
|
||||
{
|
||||
value: 'comment',
|
||||
tooltip: pivotToggleActionLabel,
|
||||
label: `<span class="ag-charts-myButton-pivot ag-charts-diy-button">${currentViewShortLabel}</span>`,
|
||||
},
|
||||
] as unknown as NonNullable<NonNullable<AgCartesianChartOptions['annotations']>['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') => (
|
||||
<div className={`metric-switcher metric-switcher--${variant}`}>
|
||||
<button
|
||||
className={`metric-switcher-button metric-switcher-button--${variant}`}
|
||||
type="button"
|
||||
title={`切换纵坐标指标:${selectedMetric.label}`}
|
||||
aria-expanded={metricMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
aria-label={`纵坐标:${selectedMetric.label}`}
|
||||
onClick={() => {
|
||||
setStatisticMenuOpen(false);
|
||||
setMetricMenuOpen((open) => !open);
|
||||
}}
|
||||
>
|
||||
{variant === 'grid' ? selectedMetricShortLabel : selectedMetric.label}
|
||||
</button>
|
||||
{metricMenuOpen ? (
|
||||
<div className="metric-switcher-menu" role="menu" aria-label="切换纵坐标指标">
|
||||
{metricOptions.map((option) => (
|
||||
<button
|
||||
className="metric-switcher-menu-item"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
key={option.key}
|
||||
aria-current={option.key === metricKey}
|
||||
onClick={() => {
|
||||
updateMetricKey(option.key);
|
||||
setMetricMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="dashboard-shell">
|
||||
@@ -1874,6 +2162,7 @@ function App() {
|
||||
</div>
|
||||
|
||||
<section className="workspace" aria-label="年度费用模板" ref={workspaceRef}>
|
||||
{indicatorSelectionLabel ? <div className="chart-indicator-selection-label" title={indicatorSelectionLabel}>{indicatorSelectionLabel}</div> : null}
|
||||
<div className="chart-filter-bar chart-filter-bar--workspace" aria-label="筛选条件">
|
||||
{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() {
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="metric-switcher">
|
||||
<button
|
||||
className="metric-switcher-button"
|
||||
type="button"
|
||||
title="切换纵坐标指标"
|
||||
aria-expanded={metricMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
aria-label={`纵坐标:${selectedMetric.label}`}
|
||||
onClick={() => {
|
||||
setStatisticMenuOpen(false);
|
||||
setMetricMenuOpen((open) => !open);
|
||||
}}
|
||||
>
|
||||
{selectedMetric.label}
|
||||
</button>
|
||||
{metricMenuOpen ? (
|
||||
<div className="metric-switcher-menu" role="menu" aria-label="切换纵坐标指标">
|
||||
{metricOptions.map((option) => (
|
||||
<button
|
||||
className="metric-switcher-menu-item"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
key={option.key}
|
||||
aria-current={option.key === metricKey}
|
||||
onClick={() => {
|
||||
updateMetricKey(option.key);
|
||||
setMetricMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{chartViewKey === 'trend' ? renderMetricSwitcher('chart') : null}
|
||||
{loading || loadError ? <div className="chart-status">{loading ? loadingHint || '加载中' : loadError}</div> : null}
|
||||
<AgCharts options={chartOptions} />
|
||||
{chartViewKey === 'pivot' ? (
|
||||
<div className="chart-pivot-grid-panel ag-theme-quartz">
|
||||
<button
|
||||
className="chart-grid-tool-button chart-grid-tool-button--template"
|
||||
type="button"
|
||||
title="模板库"
|
||||
aria-label="模板库"
|
||||
onClick={() => openGridFilterModal('templateLibrary')}
|
||||
>
|
||||
库
|
||||
</button>
|
||||
<button
|
||||
className="chart-grid-tool-button chart-grid-tool-button--indicator"
|
||||
type="button"
|
||||
title="指标树形"
|
||||
aria-label="指标树形"
|
||||
onClick={() => openGridFilterModal('indicatorTree')}
|
||||
>
|
||||
指
|
||||
</button>
|
||||
<button
|
||||
className="chart-grid-tool-button chart-grid-tool-button--trend"
|
||||
type="button"
|
||||
title={pivotToggleTitle}
|
||||
aria-label={pivotToggleActionLabel}
|
||||
onClick={togglePivotView}
|
||||
>
|
||||
{currentViewShortLabel}
|
||||
</button>
|
||||
{renderMetricSwitcher('grid')}
|
||||
<AgGridReact<PivotGridRow>
|
||||
rowData={pivotGridRowData}
|
||||
columnDefs={pivotGridColumnDefs}
|
||||
containerStyle={{ width: '100%', height: '100%' }}
|
||||
theme="legacy"
|
||||
defaultColDef={{
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
filter: true,
|
||||
}}
|
||||
suppressCellFocus
|
||||
overlayNoRowsTemplate={selectedContentNodes.length === 0 ? '请选择右侧分类项' : '暂无透视数据'}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{loading ? (
|
||||
<div className="chart-loading-mask" aria-live="polite" aria-busy="true">
|
||||
<div className="chart-loading-panel">{loadingHint || '加载中'}</div>
|
||||
@@ -2017,83 +2316,83 @@ function App() {
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
{filterModalKey && activeFilter ? (
|
||||
<div className="filter-modal-backdrop" role="presentation" onMouseDown={closeFilterModal}>
|
||||
<section
|
||||
className="filter-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`${activeFilter.label}筛选`}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header className="filter-modal-header">
|
||||
<h2>{activeFilter.label}</h2>
|
||||
<button className="filter-modal-close" type="button" aria-label="关闭" onClick={closeFilterModal}>×</button>
|
||||
</header>
|
||||
<div className="filter-modal-search">
|
||||
<input
|
||||
type="search"
|
||||
value={filterSearchValue}
|
||||
placeholder="搜索"
|
||||
onChange={(event) => {
|
||||
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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="filter-modal-selected">
|
||||
{draftFilterNodes.length > 0 ? `已选 ${draftFilterNodes.length} 项` : '未选择'}
|
||||
</div>
|
||||
<div className="filter-modal-tree">
|
||||
{activeFilterTreeLoading ? (
|
||||
<div className="content-tree-empty">加载中</div>
|
||||
) : activeFilterTreeError ? (
|
||||
<div className="content-tree-empty">{activeFilterTreeError}</div>
|
||||
) : activeFilterDisplayTree.length > 0 ? (
|
||||
renderFilterTreeNodes(activeFilterDisplayTree, filterModalKey, draftFilterNodeKeys, toggleFilterTreeNode, toggleDraftFilterNode)
|
||||
) : (
|
||||
<div className="content-tree-empty">{trimmedFilterSearchValue ? '无匹配结果' : '暂无数据'}</div>
|
||||
)}
|
||||
</div>
|
||||
<footer className="filter-modal-actions">
|
||||
<button
|
||||
className="filter-modal-clear"
|
||||
type="button"
|
||||
onClick={() => setDraftFilterNodes(
|
||||
isTemplateFilterKey(filterModalKey)
|
||||
? getDefaultTemplateFilterNodes()
|
||||
: isIndicatorTreeFilterKey(filterModalKey)
|
||||
? defaultIndicatorTreeNodes
|
||||
: [],
|
||||
{filterModalKey && activeFilter ? (
|
||||
<div className="filter-modal-backdrop" role="presentation" onMouseDown={closeFilterModal}>
|
||||
<section
|
||||
className="filter-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`${activeFilter.label}筛选`}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header className="filter-modal-header">
|
||||
<h2>{activeFilter.label}</h2>
|
||||
<button className="filter-modal-close" type="button" aria-label="关闭" onClick={closeFilterModal}>×</button>
|
||||
</header>
|
||||
<div className="filter-modal-search">
|
||||
<input
|
||||
type="search"
|
||||
value={filterSearchValue}
|
||||
placeholder="搜索"
|
||||
onChange={(event) => {
|
||||
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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="filter-modal-selected">
|
||||
{draftFilterNodes.length > 0 ? `已选 ${draftFilterNodes.length} 项` : '未选择'}
|
||||
</div>
|
||||
<div className="filter-modal-tree">
|
||||
{activeFilterTreeLoading ? (
|
||||
<div className="content-tree-empty">加载中</div>
|
||||
) : activeFilterTreeError ? (
|
||||
<div className="content-tree-empty">{activeFilterTreeError}</div>
|
||||
) : activeFilterDisplayTree.length > 0 ? (
|
||||
renderFilterTreeNodes(activeFilterDisplayTree, filterModalKey, draftFilterNodeKeys, toggleFilterTreeNode, toggleDraftFilterNode)
|
||||
) : (
|
||||
<div className="content-tree-empty">{trimmedFilterSearchValue ? '无匹配结果' : '暂无数据'}</div>
|
||||
)}
|
||||
>
|
||||
清空当前
|
||||
</button>
|
||||
<button className="filter-modal-cancel" type="button" onClick={closeFilterModal}>
|
||||
取消
|
||||
</button>
|
||||
<button className="filter-modal-confirm" type="button" onClick={applyFilterModal}>
|
||||
确认
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<footer className="filter-modal-actions">
|
||||
<button
|
||||
className="filter-modal-clear"
|
||||
type="button"
|
||||
onClick={() => setDraftFilterNodes(
|
||||
isTemplateFilterKey(filterModalKey)
|
||||
? getDefaultTemplateFilterNodes()
|
||||
: isIndicatorTreeFilterKey(filterModalKey)
|
||||
? defaultIndicatorTreeNodes
|
||||
: [],
|
||||
)}
|
||||
>
|
||||
清空当前
|
||||
</button>
|
||||
<button className="filter-modal-cancel" type="button" onClick={closeFilterModal}>
|
||||
取消
|
||||
</button>
|
||||
<button className="filter-modal-confirm" type="button" onClick={applyFilterModal}>
|
||||
确认
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
+18
-5
@@ -8,16 +8,29 @@ ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__zbChartRoot?: ReturnType<typeof createRoot>;
|
||||
__chartReactRoots?: WeakMap<Element, ReturnType<typeof createRoot>>;
|
||||
}
|
||||
}
|
||||
|
||||
const chartContainerId = 'sbChart';
|
||||
|
||||
const getRootRegistry = () => {
|
||||
window.__chartReactRoots ??= new WeakMap<Element, ReturnType<typeof createRoot>>();
|
||||
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(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
@@ -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);
|
||||
|
||||
+126
-2
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user