agchart/src/App.tsx
2026-05-08 15:23:43 +08:00

1061 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useMemo, useRef, useState } from 'react';
import { AgCharts } from 'ag-charts-react';
import type { AgCartesianChartOptions } from 'ag-charts-community';
import { ModuleRegistry } from 'ag-charts-community';
import {
AnnotationsModule,
ContextMenuModule,
CrosshairModule,
LicenseManager,
ZoomModule,
} from 'ag-charts-enterprise';
import { AG_CHARTS_LOCALE_ZH_CN } from 'ag-charts-locale';
LicenseManager.setLicenseKey('[v3][RELEASE][0102]_NDg2Njc4MzY3MDgzNw==16d78ca762fb5d2ff740aed081e2af7b');
ModuleRegistry.registerModules([AnnotationsModule, ContextMenuModule, ZoomModule, CrosshairModule]);
const API_BASE_URL = 'https://nest.zwgczx.com/api/v1';
const statisticOptions = [
{ key: 'minValue', label: '最低值', shortLabel: '低' },
{ key: 'maxValue', label: '最高值', shortLabel: '高' },
{ key: 'avgValue', label: '平均值', shortLabel: '均' },
{ key: 'medianValue', label: '中位数', shortLabel: '中' },
{ key: 'dataCount', label: '数据量', shortLabel: '量' },
] as const;
const metricOptions = [
{ key: 'cost', label: '造价(元)' },
{ key: 'buildingArea', label: '建筑面积指标(元/m²' },
{ key: 'builtArea', label: '建造面积指标(元/m²' },
{ key: 'usableArea', label: '使用面积指标(元/m²' },
] as const;
const contentOptions = [
{ key: 'geoLocation', label: '自然地理区位' },
{ key: 'facilityType', label: '设施类别' },
{ key: 'constructionStage', label: '建设阶段' },
{ key: 'planningForm', label: '规划形式' },
] as const;
const browserTreeDefaults = {
treetype: '256',
checkStrictly: 'true',
requestid: '-1',
workflowid: '181028',
wfid: '181028',
billid: '-1812',
isbill: '1',
f_weaver_belongto_userid: '267',
f_weaver_belongto_usertype: '0',
wf_isagent: '0',
wf_beagenter: '0',
wfTestStr: '',
viewtype: '1',
fromModule: 'workflow',
wfCreater: '267',
disabledConditionCache: 'true',
companyId: '1',
};
const contentTreeConfigs = {
geoLocation: {
endpoint: '/api/public/browser/data/256',
treeid: '94004',
fieldid: '305425',
defaultExpandedLevel: 1,
},
facilityType: {
endpoint: '/api/public/browser/data/256',
treeid: '94005',
fieldid: '305426',
defaultExpandedLevel: 3,
},
constructionStage: {
endpoint: '/api/public/browser/data/256',
treeid: '94007',
fieldid: '305428',
defaultExpandedLevel: 1,
},
planningForm: {
endpoint: '/api/public/browser/data/256',
treeid: '94006',
fieldid: '305427',
defaultExpandedLevel: 1,
},
} as const;
const chartLineColors = ['#0078a8', '#d14d72', '#1f8f4d', '#d96f23', '#6b5cc8', '#0d7680', '#9a6b12', '#b24b38'];
// const mockGeoLocationPayload = {
// checkStrictly: true,
// type: 3,
// datas: [
// {
// allVersionIds: '',
// canClick: false,
// checkStrictly: true,
// dsporder: 0,
// icon: '',
// id: '95005_22',
// isImgIcon: false,
// isParent: true,
// linkUrl: '/formmode/search/CustomSearchOpenTree.jsp?pid=95005_22',
// name: '中国',
// pid: '0_0',
// selected: false,
// title: '中国',
// type: '2',
// },
// ],
// iconSetting: {
// bgColor: '#96358a',
// icon: 'icon-coms-ModelingEngine',
// fontColor: '#fff',
// },
// };
type StatisticKey = (typeof statisticOptions)[number]['key'];
type MetricKey = (typeof metricOptions)[number]['key'];
type ContentKey = (typeof contentOptions)[number]['key'];
type GroupKey = 'year';
type ApiBuildingFunctionStat = {
group_key?: string | number | null;
group_name?: string | null;
min_value?: number | null;
max_value?: number | null;
avg_value?: number | null;
median_value?: number | null;
data_count?: number | null;
};
type ApiBuildingFunctionStatBatchItem = {
key?: string;
data?: ApiBuildingFunctionStat[];
};
type ChartDatum = {
groupName: string;
minValue: number | null;
maxValue: number | null;
avgValue: number | null;
medianValue: number | null;
dataCount: number | null;
};
type TreeNode = {
id: string;
label: string;
children: TreeNode[];
hasChildren: boolean;
canClick: boolean;
expanded: boolean;
loading: boolean;
loaded: boolean;
};
type SelectedContentNode = {
id: string;
contentKey: ContentKey;
label: string;
color: string;
};
function formatNumber(value: number, maximumFractionDigits: number) {
return value.toLocaleString('zh-CN', {
minimumFractionDigits: 0,
maximumFractionDigits,
});
}
function formatCostValue(value: number) {
const absValue = Math.abs(value);
if (absValue >= 10000) {
return `${formatNumber(value / 10000, 2)}万元`;
}
const fractionDigits = absValue > 0 && absValue < 1 ? 4 : 2;
return `${formatNumber(value, fractionDigits)}`;
}
function formatAreaMetricValue(value: number) {
const absValue = Math.abs(value);
const fractionDigits = absValue > 0 && absValue < 1 ? 4 : 2;
return `${formatNumber(value, fractionDigits)}元/m²`;
}
function formatChartValue(value: number, metricKey: MetricKey, statisticKey: StatisticKey) {
if (statisticKey === 'dataCount') {
return formatNumber(value, 0);
}
if (metricKey === 'cost') {
return formatCostValue(value);
}
return formatAreaMetricValue(value);
}
function normalizeStat(row: ApiBuildingFunctionStat): ChartDatum {
return {
groupName: row.group_name || String(row.group_key ?? '未命名'),
minValue: row.min_value ?? null,
maxValue: row.max_value ?? null,
avgValue: row.avg_value ?? null,
medianValue: row.median_value ?? null,
dataCount: row.data_count ?? null,
};
}
function buildQuery(params: Record<string, string | number | boolean | null | undefined>) {
const search = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value == null) return;
search.set(key, String(value));
});
search.set('currenttime', String(Date.now()));
search.set('__random__', String(Date.now()));
return search.toString();
}
function pickArray(payload: unknown): unknown[] {
if (Array.isArray(payload)) return payload;
if (!payload || typeof payload !== 'object') return [];
const source = payload as Record<string, unknown>;
const candidates = [
source.datas,
source.data,
source.data && typeof source.data === 'object' ? (source.data as Record<string, unknown>).datas : null,
source.data && typeof source.data === 'object' ? (source.data as Record<string, unknown>).list : null,
source.data && typeof source.data === 'object' ? (source.data as Record<string, unknown>).treeDatas : null,
source.result,
source.rows,
source.list,
source.treeDatas,
source.children,
];
for (const candidate of candidates) {
if (Array.isArray(candidate)) return candidate;
}
return [];
}
function readText(row: Record<string, unknown>, keys: string[]) {
for (const key of keys) {
const value = row[key];
if (value !== null && value !== undefined && String(value).trim()) {
return String(value);
}
}
return '';
}
function normalizeTreeRows(rows: unknown[]): TreeNode[] {
return rows
.filter((row): row is Record<string, unknown> => !!row && typeof row === 'object')
.map((row, index) => {
const children = normalizeTreeRows(pickArray(row.children ?? row.childs ?? row.subs));
const id = readText(row, ['id', 'key', 'value', 'nodeid', 'treeid', 'browserid']) || `node-${index}`;
const label = readText(row, ['name', 'label', 'title', 'text', 'showname', 'showName', 'browsername', 'displayName']) || id;
const hasChildren =
children.length > 0 ||
row.hasChild === true ||
row.haschild === true ||
row.isParent === true ||
row.isparent === true ||
row.children === true ||
row.child === true;
const canClick = row.canClick === true || row.canClick === 'true' || row.canclick === true || row.canclick === 'true';
return {
id,
label,
children,
hasChildren,
canClick,
expanded: children.length > 0,
loading: false,
loaded: children.length > 0,
};
});
}
function updateNode(nodes: TreeNode[], nodeId: string, updater: (node: TreeNode) => TreeNode): TreeNode[] {
return nodes.map((node) => {
if (node.id === nodeId) return updater(node);
return {
...node,
children: updateNode(node.children, nodeId, updater),
};
});
}
function getSelectionKey(contentKey: ContentKey, nodeId: string) {
return `${contentKey}:${nodeId}`;
}
function getSeriesValueKey(index: number) {
return `amount${index}`;
}
function renderTreeNodes(
nodes: TreeNode[],
contentKey: ContentKey,
selectedNodeKeys: Set<string>,
getNodeColor: (contentKey: ContentKey, nodeId: string) => string,
onToggle: (nodeId: string) => void,
onSelect: (node: TreeNode) => void,
depth = 0,
) {
return (
<ul className="content-tree-list" role={depth === 0 ? 'tree' : 'group'}>
{nodes.map((node) => {
const selected = selectedNodeKeys.has(getSelectionKey(contentKey, node.id));
const color = node.canClick ? getNodeColor(contentKey, node.id) : undefined;
return (
<li className="content-tree-node" role="treeitem" aria-expanded={node.hasChildren ? node.expanded : undefined} key={node.id}>
<div className="content-tree-row" style={{ paddingLeft: 8 + depth * 18 }}>
{node.hasChildren ? (
<button className="content-tree-caret" type="button" aria-label={node.expanded ? '收起' : '展开'} onClick={() => onToggle(node.id)}>
{node.expanded ? '▾' : '▸'}
</button>
) : (
<span className="content-tree-caret is-leaf" />
)}
<button
className="content-tree-select"
type="button"
aria-pressed={selected}
disabled={!node.canClick}
onClick={() => {
if (node.canClick) onSelect(node);
}}
title={node.label}
>
{node.canClick ? <span className="content-tree-series-mark" style={{ backgroundColor: color }} /> : null}
<span className="content-tree-label">{node.label}</span>
</button>
{node.loading ? <span className="content-tree-loading"></span> : null}
</div>
{node.expanded && node.children.length > 0
? renderTreeNodes(node.children, contentKey, selectedNodeKeys, getNodeColor, onToggle, onSelect, depth + 1)
: null}
</li>
);
})}
</ul>
);
}
function App() {
const workspaceRef = useRef<HTMLElement>(null);
const chartFrameRef = useRef<HTMLDivElement>(null);
const treeInitialLoadStartedRef = useRef<Record<ContentKey, boolean>>({
geoLocation: false,
facilityType: false,
constructionStage: false,
planningForm: false,
});
const [statisticKey, setStatisticKey] = useState<StatisticKey>('avgValue');
const [metricKey, setMetricKey] = useState<MetricKey>('cost');
const [groupKey, setGroupKey] = useState<GroupKey>('year');
const [statisticMenuOpen, setStatisticMenuOpen] = useState(false);
const [metricMenuOpen, setMetricMenuOpen] = useState(false);
const [activeContentKey, setActiveContentKey] = useState<ContentKey>('geoLocation');
const [treeByContent, setTreeByContent] = useState<Record<ContentKey, TreeNode[]>>({
geoLocation: [],
facilityType: [],
constructionStage: [],
planningForm: [],
});
const [treeLoadingByContent, setTreeLoadingByContent] = useState<Record<ContentKey, boolean>>({
geoLocation: false,
facilityType: false,
constructionStage: false,
planningForm: false,
});
const [treeErrorByContent, setTreeErrorByContent] = useState<Record<ContentKey, string | null>>({
geoLocation: null,
facilityType: null,
constructionStage: null,
planningForm: null,
});
const [selectedContentNodes, setSelectedContentNodes] = useState<SelectedContentNode[]>([]);
const [chartDataBySelection, setChartDataBySelection] = useState<Record<string, ChartDatum[]>>({});
const [chartQueryVersion, setChartQueryVersion] = useState(0);
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [loadingHint, setLoadingHint] = useState('');
const selectedStatistic = statisticOptions.find((option) => option.key === statisticKey) ?? statisticOptions[0];
const selectedMetric = metricOptions.find((option) => option.key === metricKey) ?? metricOptions[0];
const activeContent = contentOptions.find((option) => option.key === activeContentKey) ?? contentOptions[0];
const activeTree = treeByContent[activeContentKey];
const selectedNodeKeys = useMemo(
() => new Set(selectedContentNodes.map((node) => getSelectionKey(node.contentKey, node.id))),
[selectedContentNodes],
);
const getNodeColor = (contentKey: ContentKey, nodeId: string) => {
let hash = 0;
const key = getSelectionKey(contentKey, nodeId);
for (let index = 0; index < key.length; index += 1) {
hash = (hash * 31 + key.charCodeAt(index)) % chartLineColors.length;
}
return chartLineColors[hash];
};
const fetchContentTree = async (contentKey: ContentKey, nodeId?: string) => {
const config = contentTreeConfigs[contentKey];
if (!config) {
throw new Error('接口待接入');
}
const treeParams = {
...browserTreeDefaults,
treeid: config.treeid,
cube_treeid: config.treeid,
fieldid: config.fieldid,
};
const params = nodeId
? {
...treeParams,
type: '2',
id: nodeId,
isVirtual: '',
}
: {
...treeParams,
pageSize: '10',
current: '1',
min: '1',
max: '10',
};
const response = await fetch(`${config.endpoint}?${buildQuery(params)}`, {
credentials: 'include',
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return normalizeTreeRows(pickArray(await response.json()));
};
const loadContentTreeWithDefaultExpansion = async (contentKey: ContentKey) => {
const defaultExpandedLevel = contentTreeConfigs[contentKey]?.defaultExpandedLevel ?? 0;
const loadChildren = async (nodes: TreeNode[], level: number): Promise<TreeNode[]> => {
if (level > defaultExpandedLevel) return nodes;
return Promise.all(
nodes.map(async (node) => {
if (!node.hasChildren) return node;
const children = node.loaded ? node.children : await fetchContentTree(contentKey, node.id);
return {
...node,
children: await loadChildren(children, level + 1),
expanded: true,
loading: false,
loaded: true,
hasChildren: node.hasChildren || children.length > 0,
};
}),
);
};
return loadChildren(await fetchContentTree(contentKey), 1);
};
const toggleContentNode = (nodeId: string) => {
if (!contentTreeConfigs[activeContentKey]) return;
const target = activeTree.find((node) => node.id === nodeId);
const visit = (nodes: TreeNode[]): TreeNode | null => {
for (const node of nodes) {
if (node.id === nodeId) return node;
const matched = visit(node.children);
if (matched) return matched;
}
return null;
};
const node = target ?? visit(activeTree);
if (!node?.hasChildren) return;
setTreeByContent((current) => ({
...current,
[activeContentKey]: updateNode(current[activeContentKey], nodeId, (node) => ({
...node,
expanded: node.loaded ? !node.expanded : node.expanded,
loading: node.loaded ? node.loading : true,
})),
}));
if (node.loaded) return;
const currentContentKey = activeContentKey;
fetchContentTree(currentContentKey, nodeId)
.then((children) => {
setTreeByContent((current) => ({
...current,
[currentContentKey]: updateNode(current[currentContentKey], nodeId, (currentNode) => ({
...currentNode,
children,
expanded: true,
loading: false,
loaded: true,
hasChildren: currentNode.hasChildren || children.length > 0,
})),
}));
})
.catch((error) => {
setTreeByContent((current) => ({
...current,
[currentContentKey]: updateNode(current[currentContentKey], nodeId, (currentNode) => ({
...currentNode,
loading: false,
})),
}));
setTreeErrorByContent((current) => ({
...current,
[currentContentKey]: error instanceof Error ? error.message : '加载失败',
}));
});
};
const toggleSelectedContentNode = (node: TreeNode) => {
const contentKey = activeContentKey;
setSelectedContentNodes((current) => {
const selectionKey = getSelectionKey(contentKey, node.id);
const existingIndex = current.findIndex((item) => getSelectionKey(item.contentKey, item.id) === selectionKey);
if (existingIndex >= 0) {
setChartDataBySelection((data) => {
const { [selectionKey]: _removed, ...rest } = data;
return rest;
});
return current.filter((_, index) => index !== existingIndex);
}
const color = getNodeColor(contentKey, node.id);
return [...current, { id: node.id, contentKey, label: node.label, color }];
});
};
const updateMetricKey = (nextMetricKey: MetricKey) => {
setMetricKey(nextMetricKey);
setChartDataBySelection({});
setLoadError(null);
setLoadingHint('正在重新加载数据');
setLoading(true);
setChartQueryVersion((version) => version + 1);
};
useEffect(() => {
if (!contentTreeConfigs[activeContentKey]) return;
if (treeByContent[activeContentKey].length > 0 || treeInitialLoadStartedRef.current[activeContentKey]) return;
const currentContentKey = activeContentKey;
treeInitialLoadStartedRef.current[currentContentKey] = true;
setTreeLoadingByContent((current) => ({ ...current, [currentContentKey]: true }));
setTreeErrorByContent((current) => ({ ...current, [currentContentKey]: null }));
loadContentTreeWithDefaultExpansion(currentContentKey)
.then((nodes) => {
setTreeByContent((current) => ({ ...current, [currentContentKey]: nodes }));
})
.catch((error) => {
treeInitialLoadStartedRef.current[currentContentKey] = false;
setTreeErrorByContent((current) => ({
...current,
[currentContentKey]: error instanceof Error ? error.message : '加载失败',
}));
})
.finally(() => {
setTreeLoadingByContent((current) => ({ ...current, [currentContentKey]: false }));
});
}, [activeContentKey, treeByContent]);
useEffect(() => {
const controller = new AbortController();
async function loadStats() {
if (selectedContentNodes.length === 0) {
setChartDataBySelection({});
setLoading(false);
setLoadingHint('');
setLoadError(null);
return;
}
setLoading(true);
setLoadingHint('正在加载数据');
setLoadError(null);
try {
const hasAnyMissingNode = selectedContentNodes.some((node) => !chartDataBySelection[getSelectionKey(node.contentKey, node.id)]);
if (!hasAnyMissingNode) {
setLoading(false);
setLoadingHint('');
return;
}
const response = await fetch(`${API_BASE_URL}/zw/getBuildingFunctionCostStatsBatch`, {
method: 'POST',
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
groupBy: groupKey,
metric: metricKey,
nodes: selectedContentNodes.map((node) => ({
key: getSelectionKey(node.contentKey, node.id),
contentKey: node.contentKey,
nodeId: node.id,
})),
}),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = (await response.json()) as { data?: ApiBuildingFunctionStatBatchItem[] };
const results = (payload.data ?? [])
.filter((item) => item.key)
.map((item) => [item.key as string, (item.data ?? []).map(normalizeStat).slice(0, 36)] as const);
setChartDataBySelection((current) => ({
...current,
...Object.fromEntries(results),
}));
} catch (error) {
if (controller.signal.aborted) return;
setLoadError(error instanceof Error ? error.message : '接口请求失败');
} finally {
if (!controller.signal.aborted) {
setLoading(false);
setLoadingHint('');
}
}
}
void loadStats();
return () => {
controller.abort();
};
}, [chartQueryVersion, groupKey, metricKey, selectedContentNodes]);
useEffect(() => {
const frame = chartFrameRef.current;
const fullscreenTarget = workspaceRef.current;
if (!frame || !fullscreenTarget) return;
const getFullscreenButton = () => frame.querySelector<HTMLButtonElement>('.chart-fullscreen-button');
const getStatisticButton = () => frame.querySelector<HTMLElement>('.ag-charts-myButton-statistic')?.closest<HTMLButtonElement>('.ag-charts-toolbar__button');
const syncToolbarButtons = () => {
const button = getFullscreenButton();
if (button) {
let icon = button.querySelector<HTMLElement>('.ag-charts-myButton-fullScreen');
if (!icon) {
button.innerHTML = '<i class="anticon anticon-arrow-salt ag-charts-myButton-fullScreen ag-charts-diy-button"></i>';
icon = button.querySelector<HTMLElement>('.ag-charts-myButton-fullScreen');
}
const isFullscreen = document.fullscreenElement === fullscreenTarget;
button.classList.toggle('ag-charts-toolbar__button--active', isFullscreen);
icon?.classList.toggle('anticon-arrow-salt', !isFullscreen);
icon?.classList.toggle('anticon-shrink', isFullscreen);
}
const statisticButton = getStatisticButton();
if (statisticButton) {
statisticButton.classList.add('chart-statistic-button');
statisticButton.setAttribute('aria-expanded', String(statisticMenuOpen));
}
};
const toggleFullscreen = () => {
if (document.fullscreenElement === fullscreenTarget) {
void document.exitFullscreen();
} else {
void fullscreenTarget.requestFullscreen();
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'F11') return;
event.preventDefault();
event.stopPropagation();
toggleFullscreen();
};
const handleFullscreenChange = () => {
syncToolbarButtons();
};
const handleToolbarClick = (event: MouseEvent) => {
const target = event.target as Element | null;
const button = target?.closest<HTMLButtonElement>(
'.chart-fullscreen-button, .chart-statistic-button',
);
if (!button || !frame.contains(button)) return;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
if (button.classList.contains('chart-statistic-button')) {
setMetricMenuOpen(false);
setStatisticMenuOpen((open) => !open);
} else {
toggleFullscreen();
}
};
const handleToolbarKeyDown = (event: KeyboardEvent) => {
if (event.key !== ' ' && event.key !== 'Enter') return;
const target = event.target as Element | null;
const button = target?.closest<HTMLButtonElement>(
'.chart-fullscreen-button, .chart-statistic-button',
);
if (!button || !frame.contains(button)) return;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
if (button.classList.contains('chart-statistic-button')) {
setMetricMenuOpen(false);
setStatisticMenuOpen((open) => !open);
} else {
toggleFullscreen();
}
};
const suppressBrowserContextMenu = (event: MouseEvent) => {
event.preventDefault();
};
const observer = new MutationObserver(syncToolbarButtons);
document.addEventListener('keydown', handleKeyDown, true);
document.addEventListener('fullscreenchange', handleFullscreenChange);
document.addEventListener('contextmenu', suppressBrowserContextMenu);
frame.addEventListener('contextmenu', suppressBrowserContextMenu);
frame.addEventListener('click', handleToolbarClick, true);
frame.addEventListener('keydown', handleToolbarKeyDown, true);
observer.observe(frame, { childList: true, subtree: true });
syncToolbarButtons();
return () => {
document.removeEventListener('keydown', handleKeyDown, true);
document.removeEventListener('fullscreenchange', handleFullscreenChange);
document.removeEventListener('contextmenu', suppressBrowserContextMenu);
frame.removeEventListener('contextmenu', suppressBrowserContextMenu);
frame.removeEventListener('click', handleToolbarClick, true);
frame.removeEventListener('keydown', handleToolbarKeyDown, true);
observer.disconnect();
};
}, [statisticMenuOpen]);
const chartOptions = useMemo<AgCartesianChartOptions>(() => {
const isCount = statisticKey === 'dataCount';
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);
});
});
const visibleData = groupNames.map((groupName) => {
const row: Record<string, string | number | null> = { groupName };
selectedContentNodes.forEach((node, index) => {
const datum = chartDataBySelection[getSelectionKey(node.contentKey, node.id)]?.find((item) => item.groupName === groupName);
row[getSeriesValueKey(index)] = datum?.[statisticKey] ?? null;
});
return row;
});
return {
theme: {
palette: {
fills: ['#006f9b', '#ff7faa', '#00994d', '#ff8833', '#00a0dd'],
strokes: ['#003f58', '#934962', '#004a25', '#914d1d', '#006288'],
},
params: {
foregroundColor: '#262a33',
backgroundColor: '#fff1e5',
accentColor: '#0d7680',
fontFamily: '"Microsoft YaHei", "PingFang SC", "Segoe UI", Arial, sans-serif',
fontSize: 14,
tooltipBackgroundColor: '#fff7ef',
tooltipTextColor: '#262a33',
},
},
locale: {
localeText: AG_CHARTS_LOCALE_ZH_CN,
},
background: {
fill: 'transparent',
},
padding: {
top: 16,
right: 16,
bottom: 18,
left: 24,
},
data: visibleData,
zoom: {
enabled: true,
anchorPointX: 'pointer',
anchorPointY: 'pointer',
buttons: {
enabled: true,
visible: 'hover',
buttons: [
{ icon: 'zoom-out', value: 'zoom-out', section: 'zoom', tooltip: '缩小' },
{ icon: 'zoom-in', value: 'zoom-in', section: 'zoom', tooltip: '放大' },
{ icon: 'pan-left', value: 'pan-left', section: 'pan', tooltip: '左移' },
{ icon: 'pan-right', value: 'pan-right', section: 'pan', tooltip: '右移' },
{ icon: 'reset', value: 'reset', section: 'reset', tooltip: '重置' },
],
},
},
contextMenu: {
enabled: true,
items: ['defaults'],
},
overlays: {
noData: {
text: '请选择右侧分类项',
},
},
annotations: {
enabled: true,
toolbar: {
buttons: ([
{
value: 'note',
tooltip: '切换统计指标',
label: `<span class="ag-charts-myButton-statistic ag-charts-diy-button">${selectedStatistic.shortLabel}</span>`,
},
{
icon: 'trend-line-drawing',
value: 'line-menu',
tooltip: 'Line Tool',
},
{
icon: 'text-annotation',
value: 'text-menu',
tooltip: 'Text Tool',
},
{
icon: 'arrow-drawing',
value: 'shape-menu',
tooltip: 'Shape Tool',
},
{
icon: 'fibonacci-retracement-drawing',
value: 'fibonacci-menu',
tooltip: 'Fibonacci Tool',
},
{
icon: 'delete',
value: 'clear',
tooltip: 'Clear annotations',
},
] as unknown as NonNullable<NonNullable<AgCartesianChartOptions['annotations']>['toolbar']>['buttons']),
},
},
series: selectedContentNodes.map((node, index) => ({
type: 'line',
xKey: 'groupName',
yKey: getSeriesValueKey(index),
yName: `${node.label} ${selectedStatistic.label}`,
stroke: node.color,
strokeWidth: 2,
marker: {
enabled: true,
fill: node.color,
stroke: node.color,
size: 5,
},
interpolation: {
type: 'smooth',
},
tooltip: {
renderer: ({ datum, yKey, yName }) => ({
title: yName,
data: [
{ label: selectedMetric.label, value: formatChartValue(Number(datum[yKey]), metricKey, statisticKey) },
],
}),
},
})),
axes: {
x: {
type: 'category',
position: 'bottom',
line: {
enabled: true,
stroke: '#c8b9a7',
},
tick: {
enabled: false,
},
label: {
color: '#1f2933',
fontSize: 12,
},
crosshair: {
snap: false,
},
},
y: {
type: 'number',
position: 'left',
title: {
text: '',
},
label: {
color: '#1f2933',
fontSize: 12,
formatter: ({ value }) => formatChartValue(Number(value), metricKey, statisticKey),
},
line: {
enabled: false,
},
tick: {
enabled: false,
},
gridLine: {
enabled: true,
style: [
{
stroke: '#e5d9ca',
lineDash: [0],
},
],
},
crosshair: {
snap: false,
},
},
},
legend: {
enabled: selectedContentNodes.length > 1,
},
tooltip: {
enabled: true,
},
};
}, [chartDataBySelection, metricKey, selectedContentNodes, selectedMetric.label, selectedStatistic.label, statisticKey]);
return (
<main className="dashboard-shell">
<div className="watermark-layer" aria-hidden="true">
{Array.from({ length: 18 }).map((_, index) => (
<span key={index}></span>
))}
</div>
<section className="workspace" aria-label="年度费用模板" ref={workspaceRef}>
<section className="chart-area" aria-label="年度总费用图表">
<div className="chart-frame" ref={chartFrameRef}>
{statisticMenuOpen ? (
<div className="statistic-switcher-menu" role="menu" aria-label="切换统计指标">
{statisticOptions.map((option) => (
<button
className="statistic-switcher-menu-item"
type="button"
role="menuitem"
key={option.key}
aria-current={option.key === statisticKey}
onClick={() => {
setStatisticKey(option.key);
setStatisticMenuOpen(false);
}}
>
{option.label}
</button>
))}
</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>
{loading || loadError ? <div className="chart-status">{loading ? loadingHint || '加载中' : loadError}</div> : null}
<button className="chart-fullscreen-button ag-charts-toolbar__button" type="button" title="全屏(F11)">
<i className="anticon anticon-arrow-salt ag-charts-myButton-fullScreen ag-charts-diy-button" />
</button>
<AgCharts options={chartOptions} />
{loading ? (
<div className="chart-loading-mask" aria-live="polite" aria-busy="true">
<div className="chart-loading-panel">{loadingHint || '加载中'}</div>
</div>
) : null}
</div>
</section>
<aside className="right-panel" aria-label="选择内容">
<div className="content-tabs" role="tablist" aria-label="选择内容切换项">
{contentOptions.map((option) => (
<button
className="content-tab"
type="button"
role="tab"
key={option.key}
aria-selected={option.key === activeContentKey}
onClick={() => setActiveContentKey(option.key)}
>
{option.label}
</button>
))}
</div>
<div className="content-tree-panel">
<div className="content-tree-title">{activeContent.label}</div>
{treeLoadingByContent[activeContentKey] ? (
<div className="content-tree-empty"></div>
) : treeErrorByContent[activeContentKey] ? (
<div className="content-tree-empty">{treeErrorByContent[activeContentKey]}</div>
) : activeTree.length > 0 ? (
renderTreeNodes(activeTree, activeContentKey, selectedNodeKeys, getNodeColor, toggleContentNode, toggleSelectedContentNode)
) : (
<div className="content-tree-empty"></div>
)}
</div>
</aside>
</section>
</main>
);
}
export default App;