1
This commit is contained in:
parent
d5cce5fd21
commit
2380b3680d
283
src/App.tsx
283
src/App.tsx
@ -12,7 +12,7 @@ import {
|
|||||||
} from 'ag-grid-community';
|
} from 'ag-grid-community';
|
||||||
import type { AgCartesianChartOptions } from 'ag-charts-community';
|
import type { AgCartesianChartOptions } from 'ag-charts-community';
|
||||||
import { ModuleRegistry } from 'ag-charts-community';
|
import { ModuleRegistry } from 'ag-charts-community';
|
||||||
import { Building2, Construction, LayoutGrid, Library, LocateFixed, MapPinned, PanelRightClose, PanelRightOpen, Waypoints } from 'lucide-react';
|
import { Building2, CalendarRange, Construction, LayoutGrid, Library, LocateFixed, MapPinned, PanelRightClose, PanelRightOpen, Ruler, Waypoints } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
AnnotationsModule,
|
AnnotationsModule,
|
||||||
ContextMenuModule,
|
ContextMenuModule,
|
||||||
@ -69,6 +69,8 @@ const filterOptions = [
|
|||||||
{ key: 'facilityType', label: '设施类别', icon: Building2 },
|
{ key: 'facilityType', label: '设施类别', icon: Building2 },
|
||||||
{ key: 'constructionStage', label: '建设阶段', icon: Construction },
|
{ key: 'constructionStage', label: '建设阶段', icon: Construction },
|
||||||
{ key: 'planningForm', label: '规划形式', icon: LayoutGrid },
|
{ key: 'planningForm', label: '规划形式', icon: LayoutGrid },
|
||||||
|
{ key: 'time', label: '时间', icon: CalendarRange },
|
||||||
|
{ key: 'area', label: '面积', icon: Ruler },
|
||||||
] as const;
|
] as const;
|
||||||
const chartFilterOptions = filterOptions.filter((option) => option.key !== 'templateLibrary' && option.key !== 'indicatorTree');
|
const chartFilterOptions = filterOptions.filter((option) => option.key !== 'templateLibrary' && option.key !== 'indicatorTree');
|
||||||
|
|
||||||
@ -126,6 +128,17 @@ const defaultTemplateFilterNode = {
|
|||||||
label: '默认模板',
|
label: '默认模板',
|
||||||
} as const;
|
} as const;
|
||||||
const overallSummaryKey = 'summary';
|
const overallSummaryKey = 'summary';
|
||||||
|
const pivotGridWanValueFields = new Set([
|
||||||
|
'lowValue',
|
||||||
|
'centerValue',
|
||||||
|
'highValue',
|
||||||
|
'maxValue',
|
||||||
|
'minValue',
|
||||||
|
'avgValue',
|
||||||
|
'medianValue',
|
||||||
|
'standardDeviation',
|
||||||
|
'interquartileRange',
|
||||||
|
]);
|
||||||
|
|
||||||
// const mockGeoLocationPayload = {
|
// const mockGeoLocationPayload = {
|
||||||
// checkStrictly: true,
|
// checkStrictly: true,
|
||||||
@ -159,6 +172,7 @@ type StatisticKey = (typeof statisticOptions)[number]['key'];
|
|||||||
type MetricKey = (typeof metricOptions)[number]['key'];
|
type MetricKey = (typeof metricOptions)[number]['key'];
|
||||||
type ContentKey = (typeof contentOptions)[number]['key'];
|
type ContentKey = (typeof contentOptions)[number]['key'];
|
||||||
type FilterKey = (typeof filterOptions)[number]['key'];
|
type FilterKey = (typeof filterOptions)[number]['key'];
|
||||||
|
type RangeFilterKey = Extract<FilterKey, 'time' | 'area'>;
|
||||||
type ChartViewKey = 'trend' | 'pivot';
|
type ChartViewKey = 'trend' | 'pivot';
|
||||||
type ApiBuildingFunctionStat = {
|
type ApiBuildingFunctionStat = {
|
||||||
group_key?: string | number | null;
|
group_key?: string | number | null;
|
||||||
@ -269,20 +283,31 @@ function formatChartValue(value: number, metricKey: MetricKey) {
|
|||||||
return formatAreaMetricValue(value);
|
return formatAreaMetricValue(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatGridWanValue(value: number) {
|
||||||
|
const wanValue = value / 10000;
|
||||||
|
const fractionDigits = Math.abs(wanValue) > 0 && Math.abs(wanValue) < 1 ? 4 : 2;
|
||||||
|
return formatNumber(wanValue, fractionDigits);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeStat(row: ApiBuildingFunctionStat): ChartDatum {
|
function normalizeStat(row: ApiBuildingFunctionStat): ChartDatum {
|
||||||
const avgValue = row.avg_value ?? null;
|
const avgValue = row.avg_value ?? null;
|
||||||
const medianValue = row.median_value ?? null;
|
const medianValue = row.median_value ?? null;
|
||||||
const fallbackThresholdLowValue = avgValue == null || medianValue == null ? null : Math.min(avgValue, medianValue);
|
const fallbackThresholdLowValue = avgValue == null || medianValue == null ? null : Math.min(avgValue, medianValue);
|
||||||
const fallbackThresholdHighValue = avgValue == null || medianValue == null ? null : Math.max(avgValue, medianValue);
|
const fallbackThresholdHighValue = avgValue == null || medianValue == null ? null : Math.max(avgValue, medianValue);
|
||||||
|
const thresholdLowValue = row.threshold_low_value ?? fallbackThresholdLowValue;
|
||||||
|
const thresholdHighValue = row.threshold_high_value ?? fallbackThresholdHighValue;
|
||||||
|
const thresholdCenterValue = thresholdLowValue == null || thresholdHighValue == null
|
||||||
|
? null
|
||||||
|
: (thresholdLowValue + thresholdHighValue) / 2;
|
||||||
return {
|
return {
|
||||||
groupName: row.group_name || String(row.group_key ?? '未命名'),
|
groupName: row.group_name || String(row.group_key ?? '未命名'),
|
||||||
minValue: row.min_value ?? null,
|
minValue: row.min_value ?? null,
|
||||||
maxValue: row.max_value ?? null,
|
maxValue: row.max_value ?? null,
|
||||||
avgValue,
|
avgValue,
|
||||||
medianValue,
|
medianValue,
|
||||||
thresholdLowValue: row.threshold_low_value ?? fallbackThresholdLowValue,
|
thresholdLowValue,
|
||||||
thresholdCenterValue: row.threshold_center_value ?? medianValue,
|
thresholdCenterValue,
|
||||||
thresholdHighValue: row.threshold_high_value ?? fallbackThresholdHighValue,
|
thresholdHighValue,
|
||||||
standardDeviation: row.stddev_value ?? row.standard_deviation ?? null,
|
standardDeviation: row.stddev_value ?? row.standard_deviation ?? null,
|
||||||
interquartileRange: row.iqr_value ?? row.quartile_range ?? null,
|
interquartileRange: row.iqr_value ?? row.quartile_range ?? null,
|
||||||
coefficientOfVariation: row.variation_coefficient ?? row.coefficient_of_variation ?? null,
|
coefficientOfVariation: row.variation_coefficient ?? row.coefficient_of_variation ?? null,
|
||||||
@ -558,6 +583,10 @@ function isIndicatorTreeFilterKey(filterKey: FilterKey): filterKey is 'indicator
|
|||||||
return filterKey === 'indicatorTree';
|
return filterKey === 'indicatorTree';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRangeFilterKey(filterKey: FilterKey): filterKey is RangeFilterKey {
|
||||||
|
return filterKey === 'time' || filterKey === 'area';
|
||||||
|
}
|
||||||
|
|
||||||
function isSingleSelectFilterKey(filterKey: FilterKey) {
|
function isSingleSelectFilterKey(filterKey: FilterKey) {
|
||||||
return isTemplateFilterKey(filterKey) || isIndicatorTreeFilterKey(filterKey);
|
return isTemplateFilterKey(filterKey) || isIndicatorTreeFilterKey(filterKey);
|
||||||
}
|
}
|
||||||
@ -723,6 +752,8 @@ function App() {
|
|||||||
facilityType: false,
|
facilityType: false,
|
||||||
constructionStage: false,
|
constructionStage: false,
|
||||||
planningForm: false,
|
planningForm: false,
|
||||||
|
time: false,
|
||||||
|
area: false,
|
||||||
});
|
});
|
||||||
const [statisticKey, setStatisticKey] = useState<StatisticKey>('avgValue');
|
const [statisticKey, setStatisticKey] = useState<StatisticKey>('avgValue');
|
||||||
const [metricKey, setMetricKey] = useState<MetricKey>('cost');
|
const [metricKey, setMetricKey] = useState<MetricKey>('cost');
|
||||||
@ -766,6 +797,8 @@ function App() {
|
|||||||
facilityType: [],
|
facilityType: [],
|
||||||
constructionStage: [],
|
constructionStage: [],
|
||||||
planningForm: [],
|
planningForm: [],
|
||||||
|
time: [],
|
||||||
|
area: [],
|
||||||
});
|
});
|
||||||
const [filterTreeLoadingByKey, setFilterTreeLoadingByKey] = useState<Record<FilterKey, boolean>>({
|
const [filterTreeLoadingByKey, setFilterTreeLoadingByKey] = useState<Record<FilterKey, boolean>>({
|
||||||
templateLibrary: false,
|
templateLibrary: false,
|
||||||
@ -775,6 +808,8 @@ function App() {
|
|||||||
facilityType: false,
|
facilityType: false,
|
||||||
constructionStage: false,
|
constructionStage: false,
|
||||||
planningForm: false,
|
planningForm: false,
|
||||||
|
time: false,
|
||||||
|
area: false,
|
||||||
});
|
});
|
||||||
const [filterTreeErrorByKey, setFilterTreeErrorByKey] = useState<Record<FilterKey, string | null>>({
|
const [filterTreeErrorByKey, setFilterTreeErrorByKey] = useState<Record<FilterKey, string | null>>({
|
||||||
templateLibrary: null,
|
templateLibrary: null,
|
||||||
@ -784,6 +819,8 @@ function App() {
|
|||||||
facilityType: null,
|
facilityType: null,
|
||||||
constructionStage: null,
|
constructionStage: null,
|
||||||
planningForm: null,
|
planningForm: null,
|
||||||
|
time: null,
|
||||||
|
area: null,
|
||||||
});
|
});
|
||||||
const [filterSearchTreeByKey, setFilterSearchTreeByKey] = useState<Record<FilterKey, TreeNode[]>>({
|
const [filterSearchTreeByKey, setFilterSearchTreeByKey] = useState<Record<FilterKey, TreeNode[]>>({
|
||||||
templateLibrary: [],
|
templateLibrary: [],
|
||||||
@ -793,6 +830,8 @@ function App() {
|
|||||||
facilityType: [],
|
facilityType: [],
|
||||||
constructionStage: [],
|
constructionStage: [],
|
||||||
planningForm: [],
|
planningForm: [],
|
||||||
|
time: [],
|
||||||
|
area: [],
|
||||||
});
|
});
|
||||||
const [filterSearchLoadingByKey, setFilterSearchLoadingByKey] = useState<Record<FilterKey, boolean>>({
|
const [filterSearchLoadingByKey, setFilterSearchLoadingByKey] = useState<Record<FilterKey, boolean>>({
|
||||||
templateLibrary: false,
|
templateLibrary: false,
|
||||||
@ -802,6 +841,8 @@ function App() {
|
|||||||
facilityType: false,
|
facilityType: false,
|
||||||
constructionStage: false,
|
constructionStage: false,
|
||||||
planningForm: false,
|
planningForm: false,
|
||||||
|
time: false,
|
||||||
|
area: false,
|
||||||
});
|
});
|
||||||
const [filterSearchErrorByKey, setFilterSearchErrorByKey] = useState<Record<FilterKey, string | null>>({
|
const [filterSearchErrorByKey, setFilterSearchErrorByKey] = useState<Record<FilterKey, string | null>>({
|
||||||
templateLibrary: null,
|
templateLibrary: null,
|
||||||
@ -811,6 +852,8 @@ function App() {
|
|||||||
facilityType: null,
|
facilityType: null,
|
||||||
constructionStage: null,
|
constructionStage: null,
|
||||||
planningForm: null,
|
planningForm: null,
|
||||||
|
time: null,
|
||||||
|
area: null,
|
||||||
});
|
});
|
||||||
const [appliedFilters, setAppliedFilters] = useState<Record<FilterKey, SelectedFilterNode[]>>({
|
const [appliedFilters, setAppliedFilters] = useState<Record<FilterKey, SelectedFilterNode[]>>({
|
||||||
templateLibrary: getDefaultTemplateFilterNodes(),
|
templateLibrary: getDefaultTemplateFilterNodes(),
|
||||||
@ -820,9 +863,12 @@ function App() {
|
|||||||
facilityType: [],
|
facilityType: [],
|
||||||
constructionStage: [],
|
constructionStage: [],
|
||||||
planningForm: [],
|
planningForm: [],
|
||||||
|
time: [],
|
||||||
|
area: [],
|
||||||
});
|
});
|
||||||
const [filterModalKey, setFilterModalKey] = useState<FilterKey | null>(null);
|
const [filterModalKey, setFilterModalKey] = useState<FilterKey | null>(null);
|
||||||
const [draftFilterNodes, setDraftFilterNodes] = useState<SelectedFilterNode[]>([]);
|
const [draftFilterNodes, setDraftFilterNodes] = useState<SelectedFilterNode[]>([]);
|
||||||
|
const [draftRangeValues, setDraftRangeValues] = useState({ min: '', max: '' });
|
||||||
const [filterSearchValue, setFilterSearchValue] = useState('');
|
const [filterSearchValue, setFilterSearchValue] = useState('');
|
||||||
const filterSearchComposingRef = useRef(false);
|
const filterSearchComposingRef = useRef(false);
|
||||||
const filterSearchTimerRef = useRef<number | null>(null);
|
const filterSearchTimerRef = useRef<number | null>(null);
|
||||||
@ -834,6 +880,8 @@ function App() {
|
|||||||
facilityType: 0,
|
facilityType: 0,
|
||||||
constructionStage: 0,
|
constructionStage: 0,
|
||||||
planningForm: 0,
|
planningForm: 0,
|
||||||
|
time: 0,
|
||||||
|
area: 0,
|
||||||
});
|
});
|
||||||
const lastFilterSearchRef = useRef('');
|
const lastFilterSearchRef = useRef('');
|
||||||
|
|
||||||
@ -857,6 +905,26 @@ function App() {
|
|||||||
const activeFilterTreeError = filterModalKey
|
const activeFilterTreeError = filterModalKey
|
||||||
? trimmedFilterSearchValue ? filterSearchErrorByKey[filterModalKey] : filterTreeErrorByKey[filterModalKey]
|
? trimmedFilterSearchValue ? filterSearchErrorByKey[filterModalKey] : filterTreeErrorByKey[filterModalKey]
|
||||||
: null;
|
: null;
|
||||||
|
const draftRangeMin = draftRangeValues.min === '' ? null : Number(draftRangeValues.min);
|
||||||
|
const draftRangeMax = draftRangeValues.max === '' ? null : Number(draftRangeValues.max);
|
||||||
|
const draftRangeError = filterModalKey && isRangeFilterKey(filterModalKey)
|
||||||
|
? (draftRangeMin != null && !Number.isFinite(draftRangeMin))
|
||||||
|
|| (draftRangeMax != null && !Number.isFinite(draftRangeMax))
|
||||||
|
? '请输入有效数字'
|
||||||
|
: draftRangeMin != null && draftRangeMax != null && draftRangeMin > draftRangeMax
|
||||||
|
? '起始值不能大于截止值'
|
||||||
|
: filterModalKey === 'time' && (
|
||||||
|
(draftRangeMin != null && (!Number.isInteger(draftRangeMin) || draftRangeMin < 1900 || draftRangeMin > 2100))
|
||||||
|
|| (draftRangeMax != null && (!Number.isInteger(draftRangeMax) || draftRangeMax < 1900 || draftRangeMax > 2100))
|
||||||
|
)
|
||||||
|
? '年份请输入1900至2100之间的整数'
|
||||||
|
: filterModalKey === 'area' && (
|
||||||
|
(draftRangeMin != null && draftRangeMin < 0)
|
||||||
|
|| (draftRangeMax != null && draftRangeMax < 0)
|
||||||
|
)
|
||||||
|
? '面积不能小于0'
|
||||||
|
: ''
|
||||||
|
: '';
|
||||||
const selectedTemplateId = appliedFilters.templateLibrary[0]?.id || defaultTemplateFilterNode.id;
|
const selectedTemplateId = appliedFilters.templateLibrary[0]?.id || defaultTemplateFilterNode.id;
|
||||||
const defaultIndicatorTreeNodes = useMemo(
|
const defaultIndicatorTreeNodes = useMemo(
|
||||||
() => getDefaultIndicatorTreeFilterNodes(filterTreeByKey.indicatorTree),
|
() => getDefaultIndicatorTreeFilterNodes(filterTreeByKey.indicatorTree),
|
||||||
@ -874,11 +942,31 @@ function App() {
|
|||||||
const selectedValueKey = metricKey === 'dataCount' ? 'dataCount' : statisticKey;
|
const selectedValueKey = metricKey === 'dataCount' ? 'dataCount' : statisticKey;
|
||||||
const requestMetricKey = metricKey === 'dataCount' ? 'cost' : metricKey;
|
const requestMetricKey = metricKey === 'dataCount' ? 'cost' : metricKey;
|
||||||
const seriesValueLabel = metricKey === 'dataCount' ? selectedMetric.label : selectedStatistic.label;
|
const seriesValueLabel = metricKey === 'dataCount' ? selectedMetric.label : selectedStatistic.label;
|
||||||
|
const appliedTimeRange = useMemo(() => {
|
||||||
|
const [minText = '', maxText = ''] = (appliedFilters.time[0]?.id ?? ':').split(':');
|
||||||
|
return {
|
||||||
|
min: minText === '' ? null : Number(minText),
|
||||||
|
max: maxText === '' ? null : Number(maxText),
|
||||||
|
};
|
||||||
|
}, [appliedFilters.time]);
|
||||||
|
const hasTimeFilter = appliedFilters.time.length > 0;
|
||||||
|
const visibleChartDataBySelection = useMemo(() => {
|
||||||
|
if (!hasTimeFilter) return chartDataBySelection;
|
||||||
|
return Object.fromEntries(Object.entries(chartDataBySelection).map(([key, rows]) => [
|
||||||
|
key,
|
||||||
|
rows.filter((datum) => {
|
||||||
|
const year = Number(datum.groupName);
|
||||||
|
return Number.isFinite(year)
|
||||||
|
&& (appliedTimeRange.min == null || year >= appliedTimeRange.min)
|
||||||
|
&& (appliedTimeRange.max == null || year <= appliedTimeRange.max);
|
||||||
|
}),
|
||||||
|
]));
|
||||||
|
}, [appliedTimeRange.max, appliedTimeRange.min, chartDataBySelection, hasTimeFilter]);
|
||||||
const groupNames = useMemo(() => {
|
const groupNames = useMemo(() => {
|
||||||
const names: string[] = [];
|
const names: string[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
selectedContentNodes.forEach((node) => {
|
selectedContentNodes.forEach((node) => {
|
||||||
const rows = chartDataBySelection[getSelectionKey(node.contentKey, node.id)] ?? [];
|
const rows = visibleChartDataBySelection[getSelectionKey(node.contentKey, node.id)] ?? [];
|
||||||
rows.forEach((datum) => {
|
rows.forEach((datum) => {
|
||||||
if (seen.has(datum.groupName)) return;
|
if (seen.has(datum.groupName)) return;
|
||||||
seen.add(datum.groupName);
|
seen.add(datum.groupName);
|
||||||
@ -886,10 +974,10 @@ function App() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
return names.sort(compareGroupNames);
|
return names.sort(compareGroupNames);
|
||||||
}, [chartDataBySelection, selectedContentNodes]);
|
}, [selectedContentNodes, visibleChartDataBySelection]);
|
||||||
const pivotGridRowData = useMemo<PivotGridRow[]>(
|
const pivotGridRowData = useMemo<PivotGridRow[]>(
|
||||||
() => selectedContentNodes.flatMap((node) => {
|
() => selectedContentNodes.flatMap((node) => {
|
||||||
const rows = chartDataBySelection[getSelectionKey(node.contentKey, node.id)] ?? [];
|
const rows = visibleChartDataBySelection[getSelectionKey(node.contentKey, node.id)] ?? [];
|
||||||
return rows.map((datum) => ({
|
return rows.map((datum) => ({
|
||||||
year: datum.groupName,
|
year: datum.groupName,
|
||||||
name: node.label,
|
name: node.label,
|
||||||
@ -907,10 +995,11 @@ function App() {
|
|||||||
dataCount: datum.dataCount,
|
dataCount: datum.dataCount,
|
||||||
}));
|
}));
|
||||||
}),
|
}),
|
||||||
[chartDataBySelection, selectedContentNodes],
|
[selectedContentNodes, visibleChartDataBySelection],
|
||||||
);
|
);
|
||||||
const pivotGridPinnedBottomRowData = useMemo<PivotGridRow[]>(
|
const pivotGridPinnedBottomRowData = useMemo<PivotGridRow[]>(
|
||||||
() => {
|
() => {
|
||||||
|
if (hasTimeFilter) return [];
|
||||||
const datum = chartSummaryBySelection[overallSummaryKey]
|
const datum = chartSummaryBySelection[overallSummaryKey]
|
||||||
?? selectedContentNodes
|
?? selectedContentNodes
|
||||||
.map((node) => chartSummaryBySelection[getSelectionKey(node.contentKey, node.id)])
|
.map((node) => chartSummaryBySelection[getSelectionKey(node.contentKey, node.id)])
|
||||||
@ -933,13 +1022,14 @@ function App() {
|
|||||||
dataCount: datum.dataCount,
|
dataCount: datum.dataCount,
|
||||||
}];
|
}];
|
||||||
},
|
},
|
||||||
[chartSummaryBySelection, selectedContentNodes],
|
[chartSummaryBySelection, hasTimeFilter, selectedContentNodes],
|
||||||
);
|
);
|
||||||
const pivotGridSampleCount = pivotGridPinnedBottomRowData[0]?.dataCount ?? 0;
|
|
||||||
const pivotGridColumnDefs = useMemo<(ColDef<PivotGridRow> | ColGroupDef<PivotGridRow>)[]>(
|
const pivotGridColumnDefs = useMemo<(ColDef<PivotGridRow> | ColGroupDef<PivotGridRow>)[]>(
|
||||||
() => {
|
() => {
|
||||||
|
const valueUnit = requestMetricKey === 'cost' ? '万元' : '万元/m²';
|
||||||
|
const valueColumnMinWidth = requestMetricKey === 'cost' ? 112 : 142;
|
||||||
const valueFormatter = ({ value }: ValueFormatterParams<PivotGridRow, number | null>) => (
|
const valueFormatter = ({ value }: ValueFormatterParams<PivotGridRow, number | null>) => (
|
||||||
value == null ? '' : formatChartValue(Number(value), requestMetricKey)
|
value == null ? '' : formatGridWanValue(Number(value))
|
||||||
);
|
);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@ -961,75 +1051,75 @@ function App() {
|
|||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
field: 'lowValue',
|
field: 'lowValue',
|
||||||
headerName: '低值',
|
headerName: `低值(${valueUnit})`,
|
||||||
type: 'numericColumn',
|
type: 'numericColumn',
|
||||||
minWidth: 64,
|
minWidth: valueColumnMinWidth,
|
||||||
valueFormatter,
|
valueFormatter,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'centerValue',
|
field: 'centerValue',
|
||||||
headerName: '中心值',
|
headerName: `中心值(${valueUnit})`,
|
||||||
type: 'numericColumn',
|
type: 'numericColumn',
|
||||||
minWidth: 68,
|
minWidth: valueColumnMinWidth,
|
||||||
valueFormatter,
|
valueFormatter,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'highValue',
|
field: 'highValue',
|
||||||
headerName: '高值',
|
headerName: `高值(${valueUnit})`,
|
||||||
type: 'numericColumn',
|
type: 'numericColumn',
|
||||||
minWidth: 64,
|
minWidth: valueColumnMinWidth,
|
||||||
valueFormatter,
|
valueFormatter,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headerName: `样本统计值(${formatNumber(pivotGridSampleCount, 0)})`,
|
headerName: '样本统计值',
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
field: 'maxValue',
|
field: 'maxValue',
|
||||||
headerName: '最大值',
|
headerName: `最大值(${valueUnit})`,
|
||||||
type: 'numericColumn',
|
type: 'numericColumn',
|
||||||
minWidth: 68,
|
minWidth: valueColumnMinWidth,
|
||||||
valueFormatter,
|
valueFormatter,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'minValue',
|
field: 'minValue',
|
||||||
headerName: '最小值',
|
headerName: `最小值(${valueUnit})`,
|
||||||
type: 'numericColumn',
|
type: 'numericColumn',
|
||||||
minWidth: 68,
|
minWidth: valueColumnMinWidth,
|
||||||
valueFormatter,
|
valueFormatter,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'avgValue',
|
field: 'avgValue',
|
||||||
headerName: '平均值',
|
headerName: `平均值(${valueUnit})`,
|
||||||
type: 'numericColumn',
|
type: 'numericColumn',
|
||||||
minWidth: 68,
|
minWidth: valueColumnMinWidth,
|
||||||
valueFormatter,
|
valueFormatter,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'medianValue',
|
field: 'medianValue',
|
||||||
headerName: '中位数',
|
headerName: `中位数(${valueUnit})`,
|
||||||
type: 'numericColumn',
|
type: 'numericColumn',
|
||||||
minWidth: 68,
|
minWidth: valueColumnMinWidth,
|
||||||
valueFormatter,
|
valueFormatter,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'standardDeviation',
|
field: 'standardDeviation',
|
||||||
headerName: '标准差',
|
headerName: `标准差(${valueUnit})`,
|
||||||
type: 'numericColumn',
|
type: 'numericColumn',
|
||||||
minWidth: 68,
|
minWidth: valueColumnMinWidth,
|
||||||
valueFormatter,
|
valueFormatter,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'interquartileRange',
|
field: 'interquartileRange',
|
||||||
headerName: '四分位距',
|
headerName: `四分位距(${valueUnit})`,
|
||||||
type: 'numericColumn',
|
type: 'numericColumn',
|
||||||
minWidth: 76,
|
minWidth: valueColumnMinWidth,
|
||||||
valueFormatter,
|
valueFormatter,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'coefficientOfVariation',
|
field: 'coefficientOfVariation',
|
||||||
headerName: '变异系数',
|
headerName: '变异系数(无量纲)',
|
||||||
type: 'numericColumn',
|
type: 'numericColumn',
|
||||||
minWidth: 76,
|
minWidth: 76,
|
||||||
valueFormatter: ({ value }: ValueFormatterParams<PivotGridRow, number | null>) => (
|
valueFormatter: ({ value }: ValueFormatterParams<PivotGridRow, number | null>) => (
|
||||||
@ -1038,9 +1128,18 @@ function App() {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
field: 'dataCount',
|
||||||
|
headerName: '样本数量(个)',
|
||||||
|
type: 'numericColumn',
|
||||||
|
minWidth: 104,
|
||||||
|
valueFormatter: ({ value }: ValueFormatterParams<PivotGridRow, number | null>) => (
|
||||||
|
value == null ? '' : formatNumber(Number(value), 0)
|
||||||
|
),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
[pivotGridSampleCount, requestMetricKey],
|
[requestMetricKey],
|
||||||
);
|
);
|
||||||
const fitPivotGridColumns = useCallback(() => {
|
const fitPivotGridColumns = useCallback(() => {
|
||||||
window.requestAnimationFrame(() => {
|
window.requestAnimationFrame(() => {
|
||||||
@ -1054,7 +1153,15 @@ function App() {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
const exportPivotGrid = useCallback(() => {
|
const exportPivotGrid = useCallback(() => {
|
||||||
pivotGridApiRef.current?.exportDataAsCsv({ fileName: '组团统计表.csv' });
|
pivotGridApiRef.current?.exportDataAsCsv({
|
||||||
|
fileName: '组团统计表.csv',
|
||||||
|
processCellCallback: ({ column, value }) => {
|
||||||
|
if (value == null) return '';
|
||||||
|
return pivotGridWanValueFields.has(column.getColId())
|
||||||
|
? String(Number(value) / 10000)
|
||||||
|
: String(value);
|
||||||
|
},
|
||||||
|
});
|
||||||
setGridContextMenuPosition(null);
|
setGridContextMenuPosition(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -1086,12 +1193,22 @@ function App() {
|
|||||||
);
|
);
|
||||||
const appliedFilterPayload = useMemo(
|
const appliedFilterPayload = useMemo(
|
||||||
() => filterOptions
|
() => filterOptions
|
||||||
|
.filter((option) => option.key !== 'time')
|
||||||
.map((option) => ({
|
.map((option) => ({
|
||||||
key: option.key,
|
key: option.key,
|
||||||
nodes: appliedFilters[option.key].map((node) => ({ nodeId: node.id })),
|
nodes: appliedFilters[option.key].map((node) => ({ nodeId: node.id })),
|
||||||
}))
|
}))
|
||||||
.filter((filter) => filter.nodes.length > 0),
|
.filter((filter) => filter.nodes.length > 0),
|
||||||
[appliedFilters],
|
[
|
||||||
|
appliedFilters.area,
|
||||||
|
appliedFilters.constructionStage,
|
||||||
|
appliedFilters.facilityType,
|
||||||
|
appliedFilters.geoLocation,
|
||||||
|
appliedFilters.indicatorTree,
|
||||||
|
appliedFilters.planningForm,
|
||||||
|
appliedFilters.region,
|
||||||
|
appliedFilters.templateLibrary,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const getNodeColor = (contentKey: ContentKey, nodeId: string) => {
|
const getNodeColor = (contentKey: ContentKey, nodeId: string) => {
|
||||||
@ -1376,6 +1493,7 @@ function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ensureFilterTreeLoaded = (filterKey: FilterKey) => {
|
const ensureFilterTreeLoaded = (filterKey: FilterKey) => {
|
||||||
|
if (isRangeFilterKey(filterKey)) return;
|
||||||
if (filterTreeByKey[filterKey].length > 0 || filterTreeInitialLoadStartedRef.current[filterKey]) return;
|
if (filterTreeByKey[filterKey].length > 0 || filterTreeInitialLoadStartedRef.current[filterKey]) return;
|
||||||
|
|
||||||
filterTreeInitialLoadStartedRef.current[filterKey] = true;
|
filterTreeInitialLoadStartedRef.current[filterKey] = true;
|
||||||
@ -1399,6 +1517,10 @@ function App() {
|
|||||||
|
|
||||||
const openFilterModal = (filterKey: FilterKey) => {
|
const openFilterModal = (filterKey: FilterKey) => {
|
||||||
setFilterModalKey(filterKey);
|
setFilterModalKey(filterKey);
|
||||||
|
if (isRangeFilterKey(filterKey)) {
|
||||||
|
const [min = '', max = ''] = (appliedFilters[filterKey][0]?.id ?? ':').split(':');
|
||||||
|
setDraftRangeValues({ min, max });
|
||||||
|
}
|
||||||
if (isIndicatorTreeFilterKey(filterKey) && appliedFilters[filterKey].length === 0 && defaultIndicatorTreeNodes.length > 0) {
|
if (isIndicatorTreeFilterKey(filterKey) && appliedFilters[filterKey].length === 0 && defaultIndicatorTreeNodes.length > 0) {
|
||||||
setDraftFilterNodes(defaultIndicatorTreeNodes);
|
setDraftFilterNodes(defaultIndicatorTreeNodes);
|
||||||
} else {
|
} else {
|
||||||
@ -1410,7 +1532,9 @@ function App() {
|
|||||||
window.clearTimeout(filterSearchTimerRef.current);
|
window.clearTimeout(filterSearchTimerRef.current);
|
||||||
filterSearchTimerRef.current = null;
|
filterSearchTimerRef.current = null;
|
||||||
}
|
}
|
||||||
|
if (!isRangeFilterKey(filterKey)) {
|
||||||
ensureFilterTreeLoaded(filterKey);
|
ensureFilterTreeLoaded(filterKey);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -1428,6 +1552,7 @@ function App() {
|
|||||||
lastFilterSearchRef.current = '';
|
lastFilterSearchRef.current = '';
|
||||||
setFilterModalKey(null);
|
setFilterModalKey(null);
|
||||||
setDraftFilterNodes([]);
|
setDraftFilterNodes([]);
|
||||||
|
setDraftRangeValues({ min: '', max: '' });
|
||||||
setFilterSearchValue('');
|
setFilterSearchValue('');
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1563,6 +1688,40 @@ function App() {
|
|||||||
|
|
||||||
const applyFilterModal = () => {
|
const applyFilterModal = () => {
|
||||||
if (!filterModalKey) return;
|
if (!filterModalKey) return;
|
||||||
|
if (isRangeFilterKey(filterModalKey)) {
|
||||||
|
if (draftRangeError) return;
|
||||||
|
const min = draftRangeValues.min.trim();
|
||||||
|
const max = draftRangeValues.max.trim();
|
||||||
|
const unit = filterModalKey === 'time' ? '年' : 'm²';
|
||||||
|
const label = min && max
|
||||||
|
? `${min} - ${max}${unit}`
|
||||||
|
: min
|
||||||
|
? `${min}${unit}及以上`
|
||||||
|
: `${max}${unit}及以下`;
|
||||||
|
const nextRangeNodes: SelectedFilterNode[] = min || max ? [{
|
||||||
|
id: `${min}:${max}`,
|
||||||
|
filterKey: filterModalKey,
|
||||||
|
label,
|
||||||
|
}] : [];
|
||||||
|
setAppliedFilters((current) => ({
|
||||||
|
...current,
|
||||||
|
[filterModalKey]: nextRangeNodes,
|
||||||
|
}));
|
||||||
|
if (filterModalKey === 'time') {
|
||||||
|
closeFilterModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setChartDataBySelection({});
|
||||||
|
setChartSummaryBySelection({});
|
||||||
|
setLoadError(null);
|
||||||
|
if (selectedContentNodes.length > 0) {
|
||||||
|
setLoadingHint('正在按筛选条件重新计算');
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
|
setChartQueryVersion((version) => version + 1);
|
||||||
|
closeFilterModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
let nextDraftNodes = draftFilterNodes;
|
let nextDraftNodes = draftFilterNodes;
|
||||||
if (isTemplateFilterKey(filterModalKey) && nextDraftNodes.length === 0) {
|
if (isTemplateFilterKey(filterModalKey) && nextDraftNodes.length === 0) {
|
||||||
nextDraftNodes = getDefaultTemplateFilterNodes();
|
nextDraftNodes = getDefaultTemplateFilterNodes();
|
||||||
@ -1619,6 +1778,9 @@ function App() {
|
|||||||
}
|
}
|
||||||
return nextFilters;
|
return nextFilters;
|
||||||
});
|
});
|
||||||
|
if (filterKey === 'time') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isTemplateFilterKey(filterKey)) {
|
if (isTemplateFilterKey(filterKey)) {
|
||||||
resetIndicatorTreeState();
|
resetIndicatorTreeState();
|
||||||
}
|
}
|
||||||
@ -1970,7 +2132,7 @@ function App() {
|
|||||||
const trendData = groupNames.map((groupName) => {
|
const trendData = groupNames.map((groupName) => {
|
||||||
const row: Record<string, string | number | null> = { groupName };
|
const row: Record<string, string | number | null> = { groupName };
|
||||||
selectedContentNodes.forEach((node) => {
|
selectedContentNodes.forEach((node) => {
|
||||||
const datum = chartDataBySelection[getSelectionKey(node.contentKey, node.id)]?.find((item) => item.groupName === groupName);
|
const datum = visibleChartDataBySelection[getSelectionKey(node.contentKey, node.id)]?.find((item) => item.groupName === groupName);
|
||||||
row[getSeriesValueKey(node.contentKey, node.id)] = datum?.[selectedValueKey] ?? null;
|
row[getSeriesValueKey(node.contentKey, node.id)] = datum?.[selectedValueKey] ?? null;
|
||||||
});
|
});
|
||||||
return row;
|
return row;
|
||||||
@ -2175,7 +2337,6 @@ function App() {
|
|||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
activeFilterCount,
|
activeFilterCount,
|
||||||
chartDataBySelection,
|
|
||||||
chartEmptyText,
|
chartEmptyText,
|
||||||
groupNames,
|
groupNames,
|
||||||
metricKey,
|
metricKey,
|
||||||
@ -2187,6 +2348,7 @@ function App() {
|
|||||||
seriesValueLabel,
|
seriesValueLabel,
|
||||||
selectedValueKey,
|
selectedValueKey,
|
||||||
statisticKey,
|
statisticKey,
|
||||||
|
visibleChartDataBySelection,
|
||||||
]);
|
]);
|
||||||
const renderMetricSwitcher = (variant: 'chart' | 'grid') => (
|
const renderMetricSwitcher = (variant: 'chart' | 'grid') => (
|
||||||
<div className={`metric-switcher metric-switcher--${variant}`}>
|
<div className={`metric-switcher metric-switcher--${variant}`}>
|
||||||
@ -2277,6 +2439,8 @@ function App() {
|
|||||||
facilityType: [],
|
facilityType: [],
|
||||||
constructionStage: [],
|
constructionStage: [],
|
||||||
planningForm: [],
|
planningForm: [],
|
||||||
|
time: [],
|
||||||
|
area: [],
|
||||||
});
|
});
|
||||||
setChartDataBySelection({});
|
setChartDataBySelection({});
|
||||||
setChartSummaryBySelection({});
|
setChartSummaryBySelection({});
|
||||||
@ -2461,7 +2625,7 @@ function App() {
|
|||||||
{filterModalKey && activeFilter ? (
|
{filterModalKey && activeFilter ? (
|
||||||
<div className="filter-modal-backdrop" role="presentation" onMouseDown={closeFilterModal}>
|
<div className="filter-modal-backdrop" role="presentation" onMouseDown={closeFilterModal}>
|
||||||
<section
|
<section
|
||||||
className="filter-modal"
|
className={`filter-modal${isRangeFilterKey(filterModalKey) ? ' filter-modal--range' : ''}`}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={`${activeFilter.label}筛选`}
|
aria-label={`${activeFilter.label}筛选`}
|
||||||
@ -2471,6 +2635,37 @@ function App() {
|
|||||||
<h2>{activeFilter.label}</h2>
|
<h2>{activeFilter.label}</h2>
|
||||||
<button className="filter-modal-close" type="button" aria-label="关闭" onClick={closeFilterModal}>×</button>
|
<button className="filter-modal-close" type="button" aria-label="关闭" onClick={closeFilterModal}>×</button>
|
||||||
</header>
|
</header>
|
||||||
|
{isRangeFilterKey(filterModalKey) ? (
|
||||||
|
<div className="filter-range-fields">
|
||||||
|
<label>
|
||||||
|
<span>{filterModalKey === 'time' ? '起始年份' : '最小面积(m²)'}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={filterModalKey === 'time' ? 1900 : 0}
|
||||||
|
max={filterModalKey === 'time' ? 2100 : undefined}
|
||||||
|
step={filterModalKey === 'time' ? 1 : 'any'}
|
||||||
|
value={draftRangeValues.min}
|
||||||
|
placeholder="不限"
|
||||||
|
onChange={(event) => setDraftRangeValues((current) => ({ ...current, min: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<span className="filter-range-separator">至</span>
|
||||||
|
<label>
|
||||||
|
<span>{filterModalKey === 'time' ? '截止年份' : '最大面积(m²)'}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={filterModalKey === 'time' ? 1900 : 0}
|
||||||
|
max={filterModalKey === 'time' ? 2100 : undefined}
|
||||||
|
step={filterModalKey === 'time' ? 1 : 'any'}
|
||||||
|
value={draftRangeValues.max}
|
||||||
|
placeholder="不限"
|
||||||
|
onChange={(event) => setDraftRangeValues((current) => ({ ...current, max: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="filter-range-error" role="status">{draftRangeError}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div className="filter-modal-search">
|
<div className="filter-modal-search">
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
@ -2510,24 +2705,32 @@ function App() {
|
|||||||
<div className="content-tree-empty">{trimmedFilterSearchValue ? '无匹配结果' : '暂无数据'}</div>
|
<div className="content-tree-empty">{trimmedFilterSearchValue ? '无匹配结果' : '暂无数据'}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<footer className="filter-modal-actions">
|
<footer className="filter-modal-actions">
|
||||||
<button
|
<button
|
||||||
className="filter-modal-clear"
|
className="filter-modal-clear"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setDraftFilterNodes(
|
onClick={() => {
|
||||||
|
if (isRangeFilterKey(filterModalKey)) {
|
||||||
|
setDraftRangeValues({ min: '', max: '' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDraftFilterNodes(
|
||||||
isTemplateFilterKey(filterModalKey)
|
isTemplateFilterKey(filterModalKey)
|
||||||
? getDefaultTemplateFilterNodes()
|
? getDefaultTemplateFilterNodes()
|
||||||
: isIndicatorTreeFilterKey(filterModalKey)
|
: isIndicatorTreeFilterKey(filterModalKey)
|
||||||
? defaultIndicatorTreeNodes
|
? defaultIndicatorTreeNodes
|
||||||
: [],
|
: [],
|
||||||
)}
|
);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
清空当前
|
清空当前
|
||||||
</button>
|
</button>
|
||||||
<button className="filter-modal-cancel" type="button" onClick={closeFilterModal}>
|
<button className="filter-modal-cancel" type="button" onClick={closeFilterModal}>
|
||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
<button className="filter-modal-confirm" type="button" onClick={applyFilterModal}>
|
<button className="filter-modal-confirm" type="button" disabled={Boolean(draftRangeError)} onClick={applyFilterModal}>
|
||||||
确认
|
确认
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@ -171,10 +171,11 @@ button {
|
|||||||
|
|
||||||
.chart-filter-bar {
|
.chart-filter-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1041,6 +1042,57 @@ button {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-modal--range {
|
||||||
|
grid-template-rows: auto auto auto;
|
||||||
|
height: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-range-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||||
|
align-items: end;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 22px 18px 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-range-fields label {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
color: #5d554d;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-range-fields input {
|
||||||
|
width: 100%;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid rgba(90, 82, 72, 0.2);
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #262a33;
|
||||||
|
background: #fffdfa;
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-range-fields input:focus {
|
||||||
|
border-color: rgba(0, 120, 168, 0.46);
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 120, 168, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-range-separator {
|
||||||
|
padding-bottom: 8px;
|
||||||
|
color: #776e65;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-range-error {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
min-height: 18px;
|
||||||
|
color: #a13f2c;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.filter-modal-header {
|
.filter-modal-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -1249,3 +1301,8 @@ button {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-modal-confirm:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user