1
This commit is contained in:
parent
92f2584232
commit
3a82078bd2
374
src/App.tsx
374
src/App.tsx
@ -12,7 +12,7 @@ import {
|
||||
} from 'ag-grid-community';
|
||||
import type { AgCartesianChartOptions } from 'ag-charts-community';
|
||||
import { ModuleRegistry } from 'ag-charts-community';
|
||||
import { Building2, Construction, LayoutGrid, Library, LocateFixed, MapPinned, PanelRightClose, PanelRightOpen, SquareFunction, Waypoints } from 'lucide-react';
|
||||
import { Building2, CalendarRange, Construction, LayoutGrid, Library, LocateFixed, MapPinned, PanelRightClose, PanelRightOpen, Ruler, SquareFunction, Waypoints } from 'lucide-react';
|
||||
import {
|
||||
AnnotationsModule,
|
||||
ContextMenuModule,
|
||||
@ -77,6 +77,8 @@ const filterOptions = [
|
||||
{ key: 'buildingFunction', label: '建筑功能', icon: SquareFunction },
|
||||
{ key: 'constructionStage', label: '建设阶段', icon: Construction },
|
||||
{ key: 'planningForm', label: '规划形式', icon: LayoutGrid },
|
||||
{ key: 'time', label: '时间', icon: CalendarRange },
|
||||
{ key: 'area', label: '面积', icon: Ruler },
|
||||
] as const;
|
||||
const chartFilterOptions = filterOptions.filter((option) => option.key !== 'templateLibrary' && option.key !== 'indicatorTree');
|
||||
|
||||
@ -145,6 +147,17 @@ const defaultTemplateFilterNode = {
|
||||
label: '默认模板',
|
||||
} as const;
|
||||
const overallSummaryKey = 'summary';
|
||||
const pivotGridWanValueFields = new Set([
|
||||
'lowValue',
|
||||
'centerValue',
|
||||
'highValue',
|
||||
'maxValue',
|
||||
'minValue',
|
||||
'avgValue',
|
||||
'medianValue',
|
||||
'standardDeviation',
|
||||
'interquartileRange',
|
||||
]);
|
||||
|
||||
// const mockGeoLocationPayload = {
|
||||
// checkStrictly: true,
|
||||
@ -178,6 +191,7 @@ type StatisticKey = (typeof statisticOptions)[number]['key'];
|
||||
type MetricKey = (typeof metricOptions)[number]['key'];
|
||||
type ContentKey = (typeof contentOptions)[number]['key'];
|
||||
type FilterKey = (typeof filterOptions)[number]['key'];
|
||||
type RangeFilterKey = Extract<FilterKey, 'time' | 'area'>;
|
||||
type GroupKey = 'year';
|
||||
type ChartViewKey = 'trend' | 'pivot';
|
||||
type ApiBuildingFunctionStat = {
|
||||
@ -289,20 +303,31 @@ function formatChartValue(value: number, metricKey: MetricKey) {
|
||||
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 {
|
||||
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);
|
||||
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 {
|
||||
groupName: row.group_name || String(row.group_key ?? '未命名'),
|
||||
minValue: row.min_value ?? null,
|
||||
maxValue: row.max_value ?? null,
|
||||
avgValue,
|
||||
medianValue,
|
||||
thresholdLowValue: row.threshold_low_value ?? fallbackThresholdLowValue,
|
||||
thresholdCenterValue: row.threshold_center_value ?? medianValue,
|
||||
thresholdHighValue: row.threshold_high_value ?? fallbackThresholdHighValue,
|
||||
thresholdLowValue,
|
||||
thresholdCenterValue,
|
||||
thresholdHighValue,
|
||||
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,
|
||||
@ -578,6 +603,10 @@ function isIndicatorTreeFilterKey(filterKey: FilterKey): filterKey is 'indicator
|
||||
return filterKey === 'indicatorTree';
|
||||
}
|
||||
|
||||
function isRangeFilterKey(filterKey: FilterKey): filterKey is RangeFilterKey {
|
||||
return filterKey === 'time' || filterKey === 'area';
|
||||
}
|
||||
|
||||
function isSingleSelectFilterKey(filterKey: FilterKey) {
|
||||
return isTemplateFilterKey(filterKey) || isIndicatorTreeFilterKey(filterKey);
|
||||
}
|
||||
@ -741,6 +770,8 @@ function App() {
|
||||
buildingFunction: false,
|
||||
constructionStage: false,
|
||||
planningForm: false,
|
||||
time: false,
|
||||
area: false,
|
||||
});
|
||||
const [statisticKey, setStatisticKey] = useState<StatisticKey>('avgValue');
|
||||
const [metricKey, setMetricKey] = useState<MetricKey>('cost');
|
||||
@ -789,6 +820,8 @@ function App() {
|
||||
buildingFunction: [],
|
||||
constructionStage: [],
|
||||
planningForm: [],
|
||||
time: [],
|
||||
area: [],
|
||||
});
|
||||
const [filterTreeLoadingByKey, setFilterTreeLoadingByKey] = useState<Record<FilterKey, boolean>>({
|
||||
templateLibrary: false,
|
||||
@ -799,6 +832,8 @@ function App() {
|
||||
buildingFunction: false,
|
||||
constructionStage: false,
|
||||
planningForm: false,
|
||||
time: false,
|
||||
area: false,
|
||||
});
|
||||
const [filterTreeErrorByKey, setFilterTreeErrorByKey] = useState<Record<FilterKey, string | null>>({
|
||||
templateLibrary: null,
|
||||
@ -809,6 +844,8 @@ function App() {
|
||||
buildingFunction: null,
|
||||
constructionStage: null,
|
||||
planningForm: null,
|
||||
time: null,
|
||||
area: null,
|
||||
});
|
||||
const [filterSearchTreeByKey, setFilterSearchTreeByKey] = useState<Record<FilterKey, TreeNode[]>>({
|
||||
templateLibrary: [],
|
||||
@ -819,6 +856,8 @@ function App() {
|
||||
buildingFunction: [],
|
||||
constructionStage: [],
|
||||
planningForm: [],
|
||||
time: [],
|
||||
area: [],
|
||||
});
|
||||
const [filterSearchLoadingByKey, setFilterSearchLoadingByKey] = useState<Record<FilterKey, boolean>>({
|
||||
templateLibrary: false,
|
||||
@ -829,6 +868,8 @@ function App() {
|
||||
buildingFunction: false,
|
||||
constructionStage: false,
|
||||
planningForm: false,
|
||||
time: false,
|
||||
area: false,
|
||||
});
|
||||
const [filterSearchErrorByKey, setFilterSearchErrorByKey] = useState<Record<FilterKey, string | null>>({
|
||||
templateLibrary: null,
|
||||
@ -839,6 +880,8 @@ function App() {
|
||||
buildingFunction: null,
|
||||
constructionStage: null,
|
||||
planningForm: null,
|
||||
time: null,
|
||||
area: null,
|
||||
});
|
||||
const [appliedFilters, setAppliedFilters] = useState<Record<FilterKey, SelectedFilterNode[]>>({
|
||||
templateLibrary: getDefaultTemplateFilterNodes(),
|
||||
@ -849,9 +892,12 @@ function App() {
|
||||
buildingFunction: [],
|
||||
constructionStage: [],
|
||||
planningForm: [],
|
||||
time: [],
|
||||
area: [],
|
||||
});
|
||||
const [filterModalKey, setFilterModalKey] = useState<FilterKey | null>(null);
|
||||
const [draftFilterNodes, setDraftFilterNodes] = useState<SelectedFilterNode[]>([]);
|
||||
const [draftRangeValues, setDraftRangeValues] = useState({ min: '', max: '' });
|
||||
const [filterSearchValue, setFilterSearchValue] = useState('');
|
||||
const filterSearchComposingRef = useRef(false);
|
||||
const filterSearchTimerRef = useRef<number | null>(null);
|
||||
@ -864,6 +910,8 @@ function App() {
|
||||
buildingFunction: 0,
|
||||
constructionStage: 0,
|
||||
planningForm: 0,
|
||||
time: 0,
|
||||
area: 0,
|
||||
});
|
||||
const lastFilterSearchRef = useRef('');
|
||||
|
||||
@ -887,6 +935,26 @@ function App() {
|
||||
const activeFilterTreeError = filterModalKey
|
||||
? trimmedFilterSearchValue ? filterSearchErrorByKey[filterModalKey] : filterTreeErrorByKey[filterModalKey]
|
||||
: 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 defaultIndicatorTreeNodes = useMemo(
|
||||
() => getDefaultIndicatorTreeFilterNodes(filterTreeByKey.indicatorTree),
|
||||
@ -907,11 +975,31 @@ function App() {
|
||||
const selectedValueKey = metricKey === 'dataCount' ? 'dataCount' : statisticKey;
|
||||
const requestMetricKey = metricKey === 'dataCount' ? 'cost' : metricKey;
|
||||
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 names: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
selectedContentNodes.forEach((node) => {
|
||||
const rows = chartDataBySelection[getSelectionKey(node.contentKey, node.id)] ?? [];
|
||||
const rows = visibleChartDataBySelection[getSelectionKey(node.contentKey, node.id)] ?? [];
|
||||
rows.forEach((datum) => {
|
||||
if (seen.has(datum.groupName)) return;
|
||||
seen.add(datum.groupName);
|
||||
@ -919,10 +1007,10 @@ function App() {
|
||||
});
|
||||
});
|
||||
return names.sort(compareGroupNames);
|
||||
}, [chartDataBySelection, selectedContentNodes]);
|
||||
}, [selectedContentNodes, visibleChartDataBySelection]);
|
||||
const pivotGridRowData = useMemo<PivotGridRow[]>(
|
||||
() => selectedContentNodes.flatMap((node) => {
|
||||
const rows = chartDataBySelection[getSelectionKey(node.contentKey, node.id)] ?? [];
|
||||
const rows = visibleChartDataBySelection[getSelectionKey(node.contentKey, node.id)] ?? [];
|
||||
return rows.map((datum) => ({
|
||||
year: datum.groupName,
|
||||
name: node.label,
|
||||
@ -940,10 +1028,11 @@ function App() {
|
||||
dataCount: datum.dataCount,
|
||||
}));
|
||||
}),
|
||||
[chartDataBySelection, selectedContentNodes],
|
||||
[selectedContentNodes, visibleChartDataBySelection],
|
||||
);
|
||||
const pivotGridPinnedBottomRowData = useMemo<PivotGridRow[]>(
|
||||
() => {
|
||||
if (hasTimeFilter) return [];
|
||||
const datum = chartSummaryBySelection[overallSummaryKey]
|
||||
?? selectedContentNodes
|
||||
.map((node) => chartSummaryBySelection[getSelectionKey(node.contentKey, node.id)])
|
||||
@ -966,13 +1055,14 @@ function App() {
|
||||
dataCount: datum.dataCount,
|
||||
}];
|
||||
},
|
||||
[chartSummaryBySelection, selectedContentNodes],
|
||||
[chartSummaryBySelection, hasTimeFilter, selectedContentNodes],
|
||||
);
|
||||
const pivotGridSampleCount = pivotGridPinnedBottomRowData[0]?.dataCount ?? 0;
|
||||
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>) => (
|
||||
value == null ? '' : formatChartValue(Number(value), requestMetricKey)
|
||||
value == null ? '' : formatGridWanValue(Number(value))
|
||||
);
|
||||
|
||||
return [
|
||||
@ -994,75 +1084,75 @@ function App() {
|
||||
children: [
|
||||
{
|
||||
field: 'lowValue',
|
||||
headerName: '低值',
|
||||
headerName: `低值(${valueUnit})`,
|
||||
type: 'numericColumn',
|
||||
minWidth: 64,
|
||||
minWidth: valueColumnMinWidth,
|
||||
valueFormatter,
|
||||
},
|
||||
{
|
||||
field: 'centerValue',
|
||||
headerName: '中心值',
|
||||
headerName: `中心值(${valueUnit})`,
|
||||
type: 'numericColumn',
|
||||
minWidth: 68,
|
||||
minWidth: valueColumnMinWidth,
|
||||
valueFormatter,
|
||||
},
|
||||
{
|
||||
field: 'highValue',
|
||||
headerName: '高值',
|
||||
headerName: `高值(${valueUnit})`,
|
||||
type: 'numericColumn',
|
||||
minWidth: 64,
|
||||
minWidth: valueColumnMinWidth,
|
||||
valueFormatter,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
headerName: `样本统计值(${formatNumber(pivotGridSampleCount, 0)})`,
|
||||
headerName: '样本统计值',
|
||||
children: [
|
||||
{
|
||||
field: 'maxValue',
|
||||
headerName: '最大值',
|
||||
headerName: `最大值(${valueUnit})`,
|
||||
type: 'numericColumn',
|
||||
minWidth: 68,
|
||||
minWidth: valueColumnMinWidth,
|
||||
valueFormatter,
|
||||
},
|
||||
{
|
||||
field: 'minValue',
|
||||
headerName: '最小值',
|
||||
headerName: `最小值(${valueUnit})`,
|
||||
type: 'numericColumn',
|
||||
minWidth: 68,
|
||||
minWidth: valueColumnMinWidth,
|
||||
valueFormatter,
|
||||
},
|
||||
{
|
||||
field: 'avgValue',
|
||||
headerName: '平均值',
|
||||
headerName: `平均值(${valueUnit})`,
|
||||
type: 'numericColumn',
|
||||
minWidth: 68,
|
||||
minWidth: valueColumnMinWidth,
|
||||
valueFormatter,
|
||||
},
|
||||
{
|
||||
field: 'medianValue',
|
||||
headerName: '中位数',
|
||||
headerName: `中位数(${valueUnit})`,
|
||||
type: 'numericColumn',
|
||||
minWidth: 68,
|
||||
minWidth: valueColumnMinWidth,
|
||||
valueFormatter,
|
||||
},
|
||||
{
|
||||
field: 'standardDeviation',
|
||||
headerName: '标准差',
|
||||
headerName: `标准差(${valueUnit})`,
|
||||
type: 'numericColumn',
|
||||
minWidth: 68,
|
||||
minWidth: valueColumnMinWidth,
|
||||
valueFormatter,
|
||||
},
|
||||
{
|
||||
field: 'interquartileRange',
|
||||
headerName: '四分位距',
|
||||
headerName: `四分位距(${valueUnit})`,
|
||||
type: 'numericColumn',
|
||||
minWidth: 76,
|
||||
minWidth: valueColumnMinWidth,
|
||||
valueFormatter,
|
||||
},
|
||||
{
|
||||
field: 'coefficientOfVariation',
|
||||
headerName: '变异系数',
|
||||
headerName: '变异系数(无量纲)',
|
||||
type: 'numericColumn',
|
||||
minWidth: 76,
|
||||
valueFormatter: ({ value }: ValueFormatterParams<PivotGridRow, number | null>) => (
|
||||
@ -1071,9 +1161,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(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
@ -1087,7 +1186,15 @@ function App() {
|
||||
});
|
||||
}, []);
|
||||
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);
|
||||
}, []);
|
||||
|
||||
@ -1127,12 +1234,23 @@ function App() {
|
||||
}, [defaultIndicatorTreeNodes]);
|
||||
const appliedFilterPayload = useMemo(
|
||||
() => filterOptions
|
||||
.filter((option) => option.key !== 'time')
|
||||
.map((option) => ({
|
||||
key: option.key,
|
||||
nodes: appliedFilters[option.key].map((node) => ({ nodeId: node.id })),
|
||||
}))
|
||||
.filter((filter) => filter.nodes.length > 0),
|
||||
[appliedFilters],
|
||||
[
|
||||
appliedFilters.area,
|
||||
appliedFilters.buildingFunction,
|
||||
appliedFilters.constructionStage,
|
||||
appliedFilters.facilityType,
|
||||
appliedFilters.geoLocation,
|
||||
appliedFilters.indicatorTree,
|
||||
appliedFilters.planningForm,
|
||||
appliedFilters.region,
|
||||
appliedFilters.templateLibrary,
|
||||
],
|
||||
);
|
||||
|
||||
const getNodeColor = (contentKey: ContentKey, nodeId: string) => {
|
||||
@ -1418,6 +1536,7 @@ function App() {
|
||||
};
|
||||
|
||||
const ensureFilterTreeLoaded = (filterKey: FilterKey) => {
|
||||
if (isRangeFilterKey(filterKey)) return;
|
||||
if (filterTreeByKey[filterKey].length > 0 || filterTreeInitialLoadStartedRef.current[filterKey]) return;
|
||||
|
||||
filterTreeInitialLoadStartedRef.current[filterKey] = true;
|
||||
@ -1445,6 +1564,10 @@ function App() {
|
||||
|
||||
const openFilterModal = (filterKey: 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) {
|
||||
setDraftFilterNodes(defaultIndicatorTreeNodes);
|
||||
} else {
|
||||
@ -1456,7 +1579,9 @@ function App() {
|
||||
window.clearTimeout(filterSearchTimerRef.current);
|
||||
filterSearchTimerRef.current = null;
|
||||
}
|
||||
ensureFilterTreeLoaded(filterKey);
|
||||
if (!isRangeFilterKey(filterKey)) {
|
||||
ensureFilterTreeLoaded(filterKey);
|
||||
}
|
||||
};
|
||||
|
||||
const closeFilterModal = () => {
|
||||
@ -1470,6 +1595,7 @@ function App() {
|
||||
lastFilterSearchRef.current = '';
|
||||
setFilterModalKey(null);
|
||||
setDraftFilterNodes([]);
|
||||
setDraftRangeValues({ min: '', max: '' });
|
||||
setFilterSearchValue('');
|
||||
};
|
||||
|
||||
@ -1605,6 +1731,40 @@ function App() {
|
||||
|
||||
const applyFilterModal = () => {
|
||||
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;
|
||||
if (isTemplateFilterKey(filterModalKey) && nextDraftNodes.length === 0) {
|
||||
nextDraftNodes = getDefaultTemplateFilterNodes();
|
||||
@ -1657,6 +1817,9 @@ function App() {
|
||||
}
|
||||
return nextFilters;
|
||||
});
|
||||
if (filterKey === 'time') {
|
||||
return;
|
||||
}
|
||||
if (isTemplateFilterKey(filterKey)) {
|
||||
resetIndicatorTreeState();
|
||||
}
|
||||
@ -2007,7 +2170,7 @@ function App() {
|
||||
const visibleData = groupNames.map((groupName) => {
|
||||
const row: Record<string, string | number | null> = { groupName };
|
||||
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;
|
||||
});
|
||||
return row;
|
||||
@ -2211,7 +2374,6 @@ function App() {
|
||||
};
|
||||
}, [
|
||||
activeFilterCount,
|
||||
chartDataBySelection,
|
||||
chartEmptyText,
|
||||
groupNames,
|
||||
metricKey,
|
||||
@ -2223,6 +2385,7 @@ function App() {
|
||||
seriesValueLabel,
|
||||
selectedValueKey,
|
||||
statisticKey,
|
||||
visibleChartDataBySelection,
|
||||
]);
|
||||
|
||||
const renderMetricSwitcher = (variant: 'chart' | 'grid') => (
|
||||
@ -2311,6 +2474,8 @@ function App() {
|
||||
buildingFunction: [],
|
||||
constructionStage: [],
|
||||
planningForm: [],
|
||||
time: [],
|
||||
area: [],
|
||||
});
|
||||
setChartDataBySelection({});
|
||||
setChartSummaryBySelection({});
|
||||
@ -2495,7 +2660,7 @@ function App() {
|
||||
{filterModalKey && activeFilter ? (
|
||||
<div className="filter-modal-backdrop" role="presentation" onMouseDown={closeFilterModal}>
|
||||
<section
|
||||
className="filter-modal"
|
||||
className={`filter-modal${isRangeFilterKey(filterModalKey) ? ' filter-modal--range' : ''}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`${activeFilter.label}筛选`}
|
||||
@ -2505,63 +2670,102 @@ function App() {
|
||||
<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>
|
||||
{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">
|
||||
<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
|
||||
: [],
|
||||
)}
|
||||
onClick={() => {
|
||||
if (isRangeFilterKey(filterModalKey)) {
|
||||
setDraftRangeValues({ min: '', max: '' });
|
||||
return;
|
||||
}
|
||||
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 className="filter-modal-confirm" type="button" disabled={Boolean(draftRangeError)} onClick={applyFilterModal}>
|
||||
确认
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
@ -171,10 +171,11 @@ button {
|
||||
|
||||
.chart-filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@ -412,7 +413,7 @@ button {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@ -1041,6 +1042,57 @@ button {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -1249,3 +1301,8 @@ button {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-modal-confirm:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user