diff --git a/package-lock.json b/package-lock.json index 8969d848a..55a820308 100644 --- a/package-lock.json +++ b/package-lock.json @@ -84,6 +84,7 @@ "react-draggable": "^4.4.5", "react-fast-compare": "^3.2.0", "react-grid-layout": "^1.2.5", + "react-hexgrid": "^2.0.1", "react-highlight-words": "^0.17.0", "react-hooks-global-state": "^2.0.0", "react-i18next": "^14.0.0", @@ -8137,6 +8138,15 @@ "node": ">=8" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "optional": true, + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmmirror.com/bl/-/bl-4.1.0.tgz", @@ -10851,6 +10861,12 @@ "resolved": "https://registry.npmmirror.com/file-saver/-/file-saver-2.0.5.tgz", "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==" }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "optional": true + }, "node_modules/fill-range": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-4.0.0.tgz", @@ -16072,6 +16088,12 @@ "thenify-all": "^1.0.0" } }, + "node_modules/nan": { + "version": "2.24.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.24.0.tgz", + "integrity": "sha512-Vpf9qnVW1RaDkoNKFUvfxqAbtI8ncb8OJlqZ9wwpXzWPEsvsB1nvdUi6oYrHIkQ1Y/tMDnr1h4nczS0VB9Xykg==", + "optional": true + }, "node_modules/nano-css": { "version": "5.6.1", "resolved": "https://registry.npmmirror.com/nano-css/-/nano-css-5.6.1.tgz", @@ -17835,6 +17857,38 @@ "react-dom": ">= 16.3.0" } }, + "node_modules/react-hexgrid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/react-hexgrid/-/react-hexgrid-2.0.1.tgz", + "integrity": "sha512-5yBYUAhagw3SNeqgyzcRblxtQQHZFabCaNHufX1q6eXwaGHQx2wEaX+gNgBjB4RpC6VIMOBCjGE7AUK43k2rkg==", + "dependencies": { + "classnames": "^2.3.1" + }, + "optionalDependencies": { + "fsevents": "^1.2.13" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/react-hexgrid/node_modules/fsevents": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz", + "integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==", + "deprecated": "Upgrade to fsevents v2 to mitigate potential security issues", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "bindings": "^1.5.0", + "nan": "^2.12.1" + }, + "engines": { + "node": ">= 4.0" + } + }, "node_modules/react-highlight-words": { "version": "0.17.0", "resolved": "https://registry.npmmirror.com/react-highlight-words/-/react-highlight-words-0.17.0.tgz", @@ -29198,6 +29252,15 @@ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", "dev": true }, + "bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "optional": true, + "requires": { + "file-uri-to-path": "1.0.0" + } + }, "bl": { "version": "4.1.0", "resolved": "https://registry.npmmirror.com/bl/-/bl-4.1.0.tgz", @@ -31405,6 +31468,12 @@ "resolved": "https://registry.npmmirror.com/file-saver/-/file-saver-2.0.5.tgz", "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==" }, + "file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "optional": true + }, "fill-range": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-4.0.0.tgz", @@ -35985,6 +36054,12 @@ "thenify-all": "^1.0.0" } }, + "nan": { + "version": "2.24.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.24.0.tgz", + "integrity": "sha512-Vpf9qnVW1RaDkoNKFUvfxqAbtI8ncb8OJlqZ9wwpXzWPEsvsB1nvdUi6oYrHIkQ1Y/tMDnr1h4nczS0VB9Xykg==", + "optional": true + }, "nano-css": { "version": "5.6.1", "resolved": "https://registry.npmmirror.com/nano-css/-/nano-css-5.6.1.tgz", @@ -37287,6 +37362,27 @@ "resize-observer-polyfill": "^1.5.1" } }, + "react-hexgrid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/react-hexgrid/-/react-hexgrid-2.0.1.tgz", + "integrity": "sha512-5yBYUAhagw3SNeqgyzcRblxtQQHZFabCaNHufX1q6eXwaGHQx2wEaX+gNgBjB4RpC6VIMOBCjGE7AUK43k2rkg==", + "requires": { + "classnames": "^2.3.1", + "fsevents": "^1.2.13" + }, + "dependencies": { + "fsevents": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz", + "integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==", + "optional": true, + "requires": { + "bindings": "^1.5.0", + "nan": "^2.12.1" + } + } + } + }, "react-highlight-words": { "version": "0.17.0", "resolved": "https://registry.npmmirror.com/react-highlight-words/-/react-highlight-words-0.17.0.tgz", diff --git a/package.json b/package.json index da59fbd4f..d049f2e7a 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,7 @@ "react-draggable": "^4.4.5", "react-fast-compare": "^3.2.0", "react-grid-layout": "^1.2.5", + "react-hexgrid": "^2.0.1", "react-highlight-words": "^0.17.0", "react-hooks-global-state": "^2.0.0", "react-i18next": "^14.0.0", diff --git a/src/components/HoneycombChart/index.tsx b/src/components/HoneycombChart/index.tsx new file mode 100644 index 000000000..87872168c --- /dev/null +++ b/src/components/HoneycombChart/index.tsx @@ -0,0 +1,175 @@ +import React, { useState, useCallback } from 'react'; +import _ from 'lodash'; +import { HexGrid, Layout, Hexagon } from 'react-hexgrid'; + +import getRoundedHexagonPath from './utils/getRoundedHexagonPath'; + +interface Props { + data: Array<{ + q: number; + r: number; + s: number; + color: string; + title: string; + subtitle: string; + tooltip?: string; + }>; + hexSize: number; + viewBoxWidth: number; + viewBoxHeight: number; + minX: number; + minY: number; + options: { + width: number; + height: number; + spacing?: number; + enableRounded?: boolean; + }; +} + +export default function HoneycombChart(props: Props) { + const { data, hexSize, viewBoxWidth, viewBoxHeight, minX, minY, options } = props; + const { width, height, spacing = 1.02, enableRounded = true } = options; + const roundedPath = getRoundedHexagonPath(hexSize, enableRounded); + const padding = 2; + const viewBox = `${minX - padding} ${minY - padding} ${viewBoxWidth + padding * 2} ${viewBoxHeight + padding * 2}`; + + // 基于六边形尺寸计算文字容器区域(居中且不触碰边缘) + const hexWidth = Math.sqrt(3) * hexSize; + const hexHeight = 2 * hexSize; + const textBoxWidth = hexWidth * 0.84; + const textBoxHeight = hexHeight * 0.48; + + const [tooltipInfo, setTooltipInfo] = useState<{ visible: boolean; x: number; y: number; content: string }>({ + visible: false, + x: 0, + y: 0, + content: '', + }); + const [hoveredIndex, setHoveredIndex] = useState(null); + + const handleHexMouseEnter = useCallback((e: React.MouseEvent, hex: any, index: number) => { + setHoveredIndex(index); + setTooltipInfo({ + visible: true, + x: e.clientX, + y: e.clientY, + content: hex.tooltip || `${hex.title} - ${hex.subtitle}`, + }); + }, []); + + const handleHexMouseMove = useCallback((e: React.MouseEvent) => { + setTooltipInfo((prev) => ({ + ...prev, + x: e.clientX, + y: e.clientY, + })); + }, []); + + const handleHexMouseLeave = useCallback(() => { + setHoveredIndex(null); + setTooltipInfo((prev) => ({ ...prev, visible: false })); + }, []); + + return ( + <> +
+ + + {data.map((hex, i) => ( + + {(() => { + const isActive = hoveredIndex === i; + return ( + <> + handleHexMouseEnter(e, hex, i)} + onMouseMove={handleHexMouseMove} + onMouseLeave={handleHexMouseLeave} + /> + +
+
+ {hex.title} +
+
+ {hex.subtitle} +
+
+
+ + ); + })()} +
+ ))} +
+
+
+ + {/* 自定义提示框 */} + {tooltipInfo.visible && ( +
+ {tooltipInfo.content} +
+ )} + + ); +} diff --git a/src/components/HoneycombChart/utils/calculateHexCoordinates.ts b/src/components/HoneycombChart/utils/calculateHexCoordinates.ts new file mode 100644 index 000000000..bce476433 --- /dev/null +++ b/src/components/HoneycombChart/utils/calculateHexCoordinates.ts @@ -0,0 +1,130 @@ +/** + * 计算蜂窝图中每个六边形的立方体坐标和合适的尺寸 + * @param {number} count - 输入数据集数量 + * @param {number} spacing - 六边形之间的间距 + * @param {number} containerWidth - 容器宽度 + * @param {number} containerHeight - 容器高度 + * @returns {{ + * coordinates: Array<{q: number, r: number, s: number}>, + * hexSize: number, + * rows: number, + * cols: number, + * viewBoxWidth: number, + * viewBoxHeight: number, + * minX: number, + * minY: number + * }} 立方体坐标数组和六边形尺寸 + */ + +export default function calculateHexCoordinates( + count: number, + spacing = 1, + containerWidth: number, + containerHeight: number, +): { + coordinates: { q: number; r: number; s: number }[]; + hexSize: number; + rows: number; + cols: number; + viewBoxWidth: number; + viewBoxHeight: number; + minX: number; + minY: number; +} { + const n = count; + if (n === 0) + return { + coordinates: [], + hexSize: 10, + rows: 0, + cols: 0, + viewBoxWidth: 0, + viewBoxHeight: 0, + minX: 0, + minY: 0, + }; + + // 容器宽高比 + const aspectRatio = containerWidth / containerHeight; + + // 1. 初始化最佳布局参数 + let bestRows = 0; + let bestCols = 0; + let bestSize = 0; + let bestScore = -Infinity; + + // 2. 遍历所有可能的行数布局,找到最优布局 + for (let rows = 1; rows <= n; rows++) { + const cols = Math.ceil(n / rows); + + // 3. 根据行列数计算合适的六边形尺寸(pointy 方向) + // pointy 方向:宽度 = cols * sqrt(3) * hexSize,高度 = rows * 1.5 * hexSize + const widthPerHex = containerWidth / (cols * Math.sqrt(3) * spacing); + const heightPerHex = containerHeight / (rows * 1.5 * spacing); + const hexSize = Math.min(widthPerHex, heightPerHex); + + // 4. 计算布局的宽高比与容器宽高比的匹配度 + const layoutAspectRatio = (cols * Math.sqrt(3)) / (rows * 1.5); + const aspectRatioDiff = Math.abs(layoutAspectRatio - aspectRatio) / aspectRatio; + + // 优先选择宽高比匹配的布局,然后再选尺寸大的 + // 权重:宽高比匹配度权重大,尺寸权重小 + const score = -aspectRatioDiff + hexSize * 0.01; + + if (score > bestScore) { + bestScore = score; + bestSize = hexSize; + bestRows = rows; + bestCols = cols; + } + } + + // 5. 生成立方体坐标系 + const coordinates: { q: number; r: number; s: number }[] = []; + for (let i = 0; i < bestRows; i++) { + for (let j = 0; j < bestCols; j++) { + const index = i * bestCols + j; + if (index >= n) break; + + const q = j - Math.floor(i / 2); + const r = i; + const s = -q - r; + coordinates.push({ q, r, s }); + } + } + + // 6. 计算实际的六边形范围,用于正确显示 + // 根据 react-hexgrid 的布局,六边形的实际坐标会受到 spacing 的影响 + let minX = Infinity, + maxX = -Infinity; + let minY = Infinity, + maxY = -Infinity; + + coordinates.forEach(({ q, r }) => { + // react-hexgrid 在计算位置时会应用 spacing + const x = bestSize * Math.sqrt(3) * (q + r / 2) * spacing; + const y = bestSize * 1.5 * r * spacing; + const hw = (bestSize * Math.sqrt(3)) / 2; + const hh = bestSize; + + minX = Math.min(minX, x - hw); + maxX = Math.max(maxX, x + hw); + minY = Math.min(minY, y - hh); + maxY = Math.max(maxY, y + hh); + }); + + // 计算实际的宽高 + const actualWidth = maxX - minX; + const actualHeight = maxY - minY; + + return { + coordinates, + hexSize: bestSize, + rows: bestRows, + cols: bestCols, + viewBoxWidth: actualWidth, + viewBoxHeight: actualHeight, + minX, + minY, + }; +} diff --git a/src/components/HoneycombChart/utils/getRoundedHexagonPath.ts b/src/components/HoneycombChart/utils/getRoundedHexagonPath.ts new file mode 100644 index 000000000..d572cec9d --- /dev/null +++ b/src/components/HoneycombChart/utils/getRoundedHexagonPath.ts @@ -0,0 +1,61 @@ +/** + * 生成圆角六边形的 SVG 路径 + * @param {number} size - 六边形大小 + * @param {boolean} enableRounded - 是否启用圆角 + * @returns {string} SVG 路径字符串 + */ + +export default function getRoundedHexagonPath(size: number, enableRounded: boolean): string { + // pointy 方向六边形的6个顶点坐标 + const points = [ + { x: 0, y: -size }, // 顶部 + { x: (size * Math.sqrt(3)) / 2, y: -size / 2 }, // 右上 + { x: (size * Math.sqrt(3)) / 2, y: size / 2 }, // 右下 + { x: 0, y: size }, // 底部 + { x: (-size * Math.sqrt(3)) / 2, y: size / 2 }, // 左下 + { x: (-size * Math.sqrt(3)) / 2, y: -size / 2 }, // 左上 + ]; + + let path = ''; + + // 如果启用圆角,计算合适的圆角半径(边长的 1/10) + const r = enableRounded ? (Math.sqrt(3) * size) / 2 / 10 : 0; + + for (let i = 0; i < points.length; i++) { + const current = points[i]; + const next = points[(i + 1) % points.length]; + const prev = points[(i - 1 + points.length) % points.length]; + + // 计算从前一个点到当前点的向量 + const dx1 = current.x - prev.x; + const dy1 = current.y - prev.y; + const len1 = Math.sqrt(dx1 * dx1 + dy1 * dy1); + + // 计算从当前点到下一个点的向量 + const dx2 = next.x - current.x; + const dy2 = next.y - current.y; + const len2 = Math.sqrt(dx2 * dx2 + dy2 * dy2); + + // 计算圆角的起点和终点 + const startX = current.x - (dx1 / len1) * r; + const startY = current.y - (dy1 / len1) * r; + const endX = current.x + (dx2 / len2) * r; + const endY = current.y + (dy2 / len2) * r; + + if (i === 0) { + path += `M ${startX} ${startY}`; + } else { + path += ` L ${startX} ${startY}`; + } + + // 使用二次贝塞尔曲线绘制圆角 + if (enableRounded) { + path += ` Q ${current.x} ${current.y} ${endX} ${endY}`; + } else { + path += ` L ${endX} ${endY}`; + } + } + + path += ' Z'; + return path; +} diff --git a/src/pages/builtInComponents/Dashboards/index.tsx b/src/pages/builtInComponents/Dashboards/index.tsx index 2bd145ffc..d618a90e7 100644 --- a/src/pages/builtInComponents/Dashboards/index.tsx +++ b/src/pages/builtInComponents/Dashboards/index.tsx @@ -186,6 +186,11 @@ export default function index(props: Props) { ); }, }, + { + title: t('common:table.note'), + dataIndex: 'note', + key: 'note', + }, { title: t('common:table.update_by'), dataIndex: 'updated_by', diff --git a/src/pages/dashboard/Detail/Detail.tsx b/src/pages/dashboard/Detail/Detail.tsx index 376b1ea9c..75f3fb8a8 100644 --- a/src/pages/dashboard/Detail/Detail.tsx +++ b/src/pages/dashboard/Detail/Detail.tsx @@ -214,6 +214,7 @@ export default function DetailV2(props: IProps) { name: updateData.name, ident: updateData.ident, tags: updateData.tags, + note: updateData.note, configs, }); } else { @@ -594,6 +595,7 @@ export default function DetailV2(props: IProps) { name: dashboard.name, ident: dashboard.ident, tags: dashboard.tags, + note: dashboard.note, }); updateDashboardConfigs(dashboard.id, { configs: JSON.stringify(dashboard.configs), diff --git a/src/pages/dashboard/Detail/Title.tsx b/src/pages/dashboard/Detail/Title.tsx index 9223b6d42..16197e68b 100644 --- a/src/pages/dashboard/Detail/Title.tsx +++ b/src/pages/dashboard/Detail/Title.tsx @@ -242,6 +242,7 @@ export default function Title(props: IProps) { name: dashboard.name, ident: dashboard.ident, tags: dashboard.tags, + note: dashboard.note, }); updateDashboardConfigs(dashboard.id, { configs: JSON.stringify(dashboard.configs), @@ -338,6 +339,7 @@ export default function Title(props: IProps) { name: values.name, ident: values.ident, tags: _.join(values.tags, ' '), + note: values.note, configs: JSON.stringify(dashboardConfigs), }); } else { diff --git a/src/pages/dashboard/List/FormModal.tsx b/src/pages/dashboard/List/FormModal.tsx index b713bd3d8..f7cb08054 100644 --- a/src/pages/dashboard/List/FormModal.tsx +++ b/src/pages/dashboard/List/FormModal.tsx @@ -50,6 +50,7 @@ function index(props: Props & ModalWrapProps) { name: initialValues?.name, ident: initialValues?.ident, tags: initialValues?.tags ? _.split(initialValues.tags, ' ') : undefined, + note: initialValues?.note, graphTooltip: configs.graphTooltip, graphZoom: configs.graphZoom, }); @@ -77,6 +78,7 @@ function index(props: Props & ModalWrapProps) { name: values.name, ident: values.ident, tags: _.join(values.tags, ' '), + note: values.note, }); message.success(t('common:success.edit')); } else if (action === 'create' && busiId) { @@ -84,6 +86,7 @@ function index(props: Props & ModalWrapProps) { name: values.name, ident: values.ident, tags: _.join(values.tags, ' '), + note: values.note, configs: JSON.stringify({ var: [], panels: [], @@ -137,6 +140,9 @@ function index(props: Props & ModalWrapProps) { + + + diff --git a/src/pages/dashboard/List/ImportGrafanaURLFormModal.tsx b/src/pages/dashboard/List/ImportGrafanaURLFormModal.tsx index 3a98bfe7b..0a447e58c 100644 --- a/src/pages/dashboard/List/ImportGrafanaURLFormModal.tsx +++ b/src/pages/dashboard/List/ImportGrafanaURLFormModal.tsx @@ -63,6 +63,7 @@ function index(props: Props & ModalWrapProps) { name: values.name, ident: values.ident, tags: _.join(values.tags, ' '), + note: values.note, }); message.success(t('common:success.edit')); if (result) { @@ -89,6 +90,7 @@ function index(props: Props & ModalWrapProps) { name: initialValues?.name, ident: initialValues?.ident, tags: initialValues?.tags ? _.split(initialValues.tags, ' ') : undefined, + note: initialValues?.note, iframe_url: initialValues?.configs?.iframe_url, }} > @@ -118,6 +120,9 @@ function index(props: Props & ModalWrapProps) { -
+ + + +