# 罗盘组件详细设计文档 ## 1. 概述 本组件实现了一个可配置的圆盘罗盘可视化系统,支持将圆盘分为多个同心圆环和扇区,并在每个区域精确放置文字或图形。 ## 2. 数学模型 ### 2.1 坐标系统 - **角度约定**:0° 位于正北(上方),顺时针方向为正 - **SVG 坐标系**: - x 轴:向右为正 - y 轴:向下为正 - 原点:画布中心 ### 2.2 参数定义 #### 角度参数 - `n`:总扇区数(pie 数量) - `angle[i]`:第 i 个分度线的角度,i ∈ [0...n] - `wide(i) = angle[i] - angle[i-1]`:第 i 块扇区的角度宽度 #### 半径参数 - `m`:总圆环数 - `radius(j)`:第 j 个圆环的半径,j ∈ [0...m] - `height(j) = radius(j) - radius(j-1)`:第 j 层圆环的高度 #### 区域定义 - `layer(j)`:第 j 层,由圆环 j 和圆环 j-1 之间形成 - `pie(i)`:第 i 个扇区,由分度线 i-1 和分度线 i 之间形成 - `sector(j,i)`:第 j 层第 i 个扇区的区域 ### 2.3 形心计算公式 对于圆环扇形区域 `sector(j,i)`,其形心位置计算如下: #### 输入参数 - `rInner = radius(j-1)`:内半径 - `rOuter = radius(j)`:外半径 - `aStart = angle[i-1]`:起始角度(度) - `aEnd = angle[i]`:结束角度(度) #### 计算步骤 1. **角度归一化** ``` a₁ = normalize(aStart) // 转换到 [0, 360) a₂ = normalize(aEnd) Δ = a₂ - a₁ // 若 < 0,则加 360 ``` 2. **中心角度** ``` aMid = a₁ + Δ/2 // 扇区中心角度 ``` 3. **形心半径**(面积加权质心) ``` radialFactor = (2/3) × (r₂³ - r₁³) / (r₂² - r₁²) sinc(x) = sin(x) / x // x ≠ 0 时 ρ = radialFactor × sinc(Δ/2) ``` 其中: - r₁ = rInner - r₂ = rOuter - Δ 为弧度制 4. **极坐标转笛卡尔坐标** ``` cx = ρ × sin(aMid) cy = -ρ × cos(aMid) // 注意:y 坐标取负以适应 SVG 坐标系 ``` #### 输出结果 - `(cx, cy)`:形心的笛卡尔坐标 - `ρ`:形心的极坐标半径 - `aMid`:形心的极坐标角度 ### 2.4 特殊情况处理 1. **纯扇形**(rInner = 0) - 公式仍然适用,内圆退化为点 2. **跨越 0° 的扇区** - 例如:aStart = 315°, aEnd = 45° - 通过归一化自动处理:Δ = 45 - 315 + 360 = 90° 3. **退化情况** - rInner ≥ rOuter:返回 (0, 0) - Δ = 0:返回 (0, 0) ## 3. 核心算法实现 ### 3.1 极坐标转换 ```typescript function polarToXY(aDeg: number, r: number): {x: number, y: number} { const a = (aDeg * Math.PI) / 180; return { x: r * Math.sin(a), y: -r * Math.cos(a) // y 坐标取负 }; } ``` ### 3.2 角度归一化 ```typescript function normalizeDeg(deg: number): number { const d = deg % 360; return d < 0 ? d + 360 : d; } ``` ### 3.3 形心计算 ```typescript function annularSectorCentroid(params: { rInner: number, rOuter: number, aStartDeg: number, aEndDeg: number }): CentroidResult { const { rInner, rOuter, aStartDeg, aEndDeg } = params; // 1. 角度归一化 const a1 = normalizeDeg(aStartDeg); const a2 = normalizeDeg(aEndDeg); let deltaDeg = a2 - a1; if (deltaDeg < 0) deltaDeg += 360; // 2. 中心角度 const aMidDeg = normalizeDeg(a1 + deltaDeg / 2); const aMidRad = (aMidDeg * Math.PI) / 180; // 3. 边界检查 if (rOuter <= rInner || deltaDeg === 0) { return { cx: 0, cy: 0, rho: 0, aMidDeg, aMidRad, deltaDeg }; } // 4. 形心半径计算 const delta = (deltaDeg * Math.PI) / 180; const radialFactor = (2 / 3) * ((rOuter ** 3 - rInner ** 3) / (rOuter ** 2 - rInner ** 2)); const half = delta / 2; const sinc = half === 0 ? 1 : Math.sin(half) / half; const rho = radialFactor * sinc; // 5. 坐标转换 const p = polarToXY(aMidDeg, rho); return { cx: p.x, cy: p.y, rho, aMidDeg, aMidRad, deltaDeg }; } ``` ## 4. 扇区路径生成 ### 4.1 SVG 路径绘制 圆环扇形使用 SVG 的 `` 元素绘制,包含以下步骤: 1. 移动到外弧起点 2. 绘制外弧(arc to) 3. 连线到内弧终点 4. 绘制内弧(arc to) 5. 闭合路径 ```typescript function annularSectorPath( rInner: number, rOuter: number, aStartDeg: number, aEndDeg: number ): string { // 计算关键点 const p1 = polarToXY(aStart, rOuter); // 外弧起点 const p2 = polarToXY(aEnd, rOuter); // 外弧终点 const p3 = polarToXY(aEnd, rInner); // 内弧终点 const p4 = polarToXY(aStart, rInner); // 内弧起点 // 确定 large-arc-flag const largeArc = delta > 180 ? 1 : 0; // 生成路径 return [ `M ${p1.x} ${p1.y}`, // 起点 `A ${rOuter} ${rOuter} 0 ${largeArc} 1 ${p2.x} ${p2.y}`, // 外弧 `L ${p3.x} ${p3.y}`, // 连线 `A ${rInner} ${rInner} 0 ${largeArc} 0 ${p4.x} ${p4.y}`, // 内弧 "Z" // 闭合 ].join(" "); } ``` ### 4.2 特殊情况:纯扇形 当 `rInner ≈ 0` 时,内弧退化为圆心点: ```typescript if (rInner <= 0.000001) { return [ `M ${p1.x} ${p1.y}`, `A ${rOuter} ${rOuter} 0 ${largeArc} 1 ${p2.x} ${p2.y}`, `L 0 0`, // 连线到圆心 "Z" ].join(" "); } ``` ## 5. 文字排列算法 ### 5.1 文字路径生成 文字沿圆弧排列,使用 SVG 的 `` 功能。路径使用形心半径: ```typescript function generateTextPath( rInner: number, rOuter: number, aStartDeg: number, aEndDeg: number, reverse: boolean ): string { // 使用形心半径作为文字路径 const centroid = annularSectorCentroid({ rInner, rOuter, aStartDeg, aEndDeg }); const rMid = centroid.rho; // 形心半径 const p1 = polarToXY(aStart, rMid); const p2 = polarToXY(aEnd, rMid); const largeArc = delta > 180 ? 1 : 0; if (reverse) { // 反向路径(左半圆) return `M ${p2.x} ${p2.y} A ${rMid} ${rMid} 0 ${largeArc} 0 ${p1.x} ${p1.y}`; } else { // 正向路径(右半圆) return `M ${p1.x} ${p1.y} A ${rMid} ${rMid} 0 ${largeArc} 1 ${p2.x} ${p2.y}`; } } ``` ### 5.2 文字方向判断 为保持文字"头朝外",需要判断是否使用反向路径: ```typescript // 左半圆(90° ~ 270°)需要反向路径 const needReverse = aMidDeg > 90 && aMidDeg < 270; ``` **原理**: - 右半圆(0°~90°, 270°~360°):正向路径,文字自然朝外 - 左半圆(90°~270°):反向路径,避免文字倒置 ### 5.3 文字长度适配 根据层数自动调整文字长度: ```typescript // 第 j 层显示 (j+1) × 2 个字 const textLength = (layerIndex + 1) * 2; const label = '测'.repeat(textLength); ``` 示例: - 第1层:2个字 - 第2层:4个字 - 第3层:6个字 ## 6. 颜色生成策略 当前实现以主题色板为核心:`colorRef` 优先解析为主题色名,未命中时退回原值。 **扇区填色优先级**: 1. 扇区级 `colorRef` 2. 层级规律填色(`layer.colorRef` + `num/interval/patternOffset`) 3. 全局 `background` **刻度环颜色**: - `degreeRing.colorRef` 存在时,渲染环形背景填充(作为刻度环背景)。 - `tickColor` / `ringColor` 显式提供则优先使用;未提供时根据背景计算对比色。 - `opacity` 同时作用于环形背景和内外圆环描边。 ## 7. 组件架构 ### 7.1 模块划分 ``` src/ ├── types.ts # TypeScript 类型定义 ├── utils.ts # 纯函数工具集 ├── constants.ts # 配置常量 ├── composables/ │ └── useLuopan.ts # 组合函数(业务逻辑) ├── Luopan.vue # 主组件 ├── utils.test.ts # 单元测试 └── index.ts # 入口文件 ``` ### 7.2 数据流 ``` 配置数据 (Example) ↓ useLuopan (计算扇区数据) ↓ Sector[] (包含形心、路径、颜色等) ↓ Luopan.vue (渲染 SVG) ``` ### 7.3 关键类型定义 ```typescript // 配置 interface Example { name: string; angles: number[]; // 角度分割点 [0, 30, 60, ..., 360] radii: number[]; // 圆环半径 [60, 120, 180] } // 扇区数据 interface Sector { key: string; layerIndex: number; pieIndex: number; rInner: number; rOuter: number; aStart: number; aEnd: number; aMidDeg: number; aMidRad: number; cx: number; // 形心 x cy: number; // 形心 y fill: string; label: string; path: string; // 扇区路径 textPath: string; // 文字路径 textPathId: string; needReverse: boolean; } ``` ## 8. 使用示例 ### 8.1 基本使用 ```vue ``` ### 8.2 自定义配置 ```typescript import { useLuopan } from './src'; import { ref } from 'vue'; const customExample = ref({ name: '自定义罗盘', angles: [0, 45, 90, 135, 180, 225, 270, 315, 360], radii: [80, 160, 240] }); const { sectors } = useLuopan(customExample); ``` ### 8.3 访问工具函数 ```typescript import { polarToXY, annularSectorCentroid, generateSectorColor } from './src'; // 计算特定区域的形心 const centroid = annularSectorCentroid({ rInner: 50, rOuter: 100, aStartDeg: 0, aEndDeg: 30 }); console.log(`形心位置: (${centroid.cx}, ${centroid.cy})`); ``` ## 9. 测试 ### 9.1 单元测试覆盖 - ✅ 极坐标转换(4个方向) - ✅ 角度归一化(负数、大于360) - ✅ 形心计算(纯扇形、圆环、跨0°) - ✅ 路径生成(正常、退化情况) - ✅ 颜色生成(色相、亮度) ### 9.2 运行测试 ```bash npm run test # 运行测试 npm run test:ui # 测试 UI npm run test:coverage # 覆盖率报告 ``` ## 10. 性能优化 ### 10.1 计算优化 - 使用 Vue 的 `computed` 缓存计算结果 - 避免重复计算:形心、路径等一次生成 - 三角函数结果复用 ### 10.2 渲染优化 - 使用 `key` 属性优化列表渲染 - SVG 路径字符串预生成,避免模板内计算 - 辅助线可选显示,减少 DOM 节点 ## 11. 扩展性 ### 11.1 支持的扩展 - ✅ 自定义角度分割 - ✅ 自定义圆环半径 - ✅ 自定义颜色方案 - ✅ 自定义文字内容 - 🔲 图标/图片放置(预留 ``) - 🔲 交互事件(点击、悬停) - 🔲 动画效果 ### 11.2 API 设计原则 - 纯函数优先,便于测试 - 类型安全,完整的 TypeScript 支持 - 组合式 API,灵活复用 - 渐进式增强,保持简单使用 ## 12. 文字布局与自适应 ### 12.1 文字布局配置常量 所有文字布局相关的魔法数字已集中在 `TEXT_LAYOUT_CONFIG` 中管理: ```typescript export const TEXT_LAYOUT_CONFIG = { /** 字符间距系数:1.2 表示字符实际占用高度 = fontSize × 1.2 */ CHAR_SPACING_RATIO: 1.2, /** 径向留白比例:0.8 表示文字占用 80%,上下各留 10% */ RADIAL_PADDING_RATIO: 0.8, /** 切向留白比例:0.85 表示文字占用 85%,左右各留 7.5% */ TANGENT_PADDING_RATIO: 0.85, /** 小角度扇区字体缩放:防止弧线弯曲导致视觉溢出 */ SMALL_ANGLE_SCALE: { TINY_THRESHOLD: 15, // < 15° 应用 70% 缩放 TINY_SCALE: 0.7, SMALL_THRESHOLD: 30, // 15°-30° 应用 85% 缩放 SMALL_SCALE: 0.85, }, /** 字体大小限制 */ FONT_SIZE: { MIN: 3, // 最小 3px MAX: 24, // 最大 24px }, /** 竖排文字字符数范围 */ VERTICAL_TEXT: { MIN_CHARS: 1, MAX_CHARS: 4, }, } ``` ### 12.2 字体大小自适应算法 #### 横排文字(沿弧线) 字体大小同时受径向高度和弧长双重约束: ```typescript // 1. 径向高度约束 maxByHeight = radialHeight × RADIAL_PADDING_RATIO // 2. 弧长宽度约束 arcLength = rMid × deltaDeg × π/180 availableArcLength = arcLength × TANGENT_PADDING_RATIO maxByWidth = availableArcLength / (textLength × 1.1) // 3. 取较小值 calculatedSize = min(maxByHeight, maxByWidth) // 4. 小角度额外缩放 if (deltaDeg < 15°) calculatedSize × 0.7 else if (deltaDeg < 30°) calculatedSize × 0.85 ``` **关键点**: - `1.1` 系数包含字符宽度(1.0) + 字符间距(0.1) - 小角度缩放是因为弧线弯曲使文字在视觉上"更胖" #### 竖排文字(沿径向) 竖排文字沿径向排列,受不同约束: ```typescript // 1. 径向高度约束(主要) availableHeight = radialHeight × RADIAL_PADDING_RATIO maxByHeight = availableHeight / (textLength × CHAR_SPACING_RATIO) // 2. 最内侧字符的弧长约束(防止最里面的字溢出) estimatedFontSize = radialHeight × 0.5 innerMostRadius = rInner + estimatedFontSize / 2 innerArcLength = innerMostRadius × deltaDeg × π/180 availableArcLength = innerArcLength × TANGENT_PADDING_RATIO maxByWidth = availableArcLength // 单个字符宽度 // 3. 取较小值 calculatedSize = min(maxByHeight, maxByWidth) ``` **关键点**: - 最内侧字符位置的半径最小,弧长最短,是最严格的宽度限制 - 不需要小角度缩放(竖排不受弧线弯曲影响) ### 12.3 竖排文字路径生成 竖排文字路径是沿径向的直线,需要特殊处理不同场景: #### 普通圆环扇区(rInner > 0) ```typescript // 以形心或中点为中心,向两边延伸 rMid = centroid.rho // 或 (rInner + rOuter) / 2 requiredPathLength = textLength × CHAR_SPACING_RATIO × fontSize halfPath = requiredPathLength / 2 // 确保不超出边界 safeHalfPath = min( halfPath, radialHeight × RADIAL_PADDING_RATIO / 2, rMid - rInner, rOuter - rMid ) finalStartR = rMid + safeHalfPath // 外端 finalEndR = rMid - safeHalfPath // 内端 ``` #### 从圆心开始的扇区(rInner = 0) 对于最内层扇区,形心会偏向外侧,需要特殊处理: ```typescript if (rInner === 0) { // 检查以形心居中是否会溢出外圆 if (rMid + halfPath > rOuter) { // 会溢出:改为从外圆向内延伸 finalStartR = rOuter finalEndR = rOuter - actualPathLength } else { // 不会溢出:正常使用形心居中 finalStartR = rMid + halfPath finalEndR = rMid - halfPath } } ``` **原理**: - 最内层的形心通常在 r = 2/3 × rOuter 附近(对于纯扇形) - 如果以形心为中心,向外空间只有 1/3 × rOuter - 当文字较多时,会向外溢出 - 因此优先尝试形心居中,溢出时回退到从外圆向内 ### 12.4 文字字符数计算 #### 横排文字 基于扇区尺寸和层数综合决定: ```typescript estimatedFontSize = radialHeight × 0.6 charWidth = estimatedFontSize × 1.15 availableWidth = arcWidth × 0.8 maxChars = floor(availableWidth / charWidth) layerBasedLength = max(2, layerIndex + 1) // 外层字多一些 textLength = max(1, min(6, min(maxChars, layerBasedLength))) ``` #### 竖排文字 两步计算法,确保字符数与字体大小匹配: ```typescript // 步骤1:用中等字符数估算字体大小 tempFontSize = calculateSectorFontSize(rInner, rOuter, aStart, aEnd, 2, isVertical=true) // 步骤2:根据字体大小计算实际能容纳的字符数(1-4个) textLength = calculateVerticalTextLength(rInner, rOuter, tempFontSize, 1, 4) // 步骤3:用实际字符数重新精确计算字体大小 finalFontSize = calculateSectorFontSize(rInner, rOuter, aStart, aEnd, textLength, isVertical=true) ``` ### 12.5 横排文字路径生成 横排文字沿弧线排列,路径保持完整(不缩短),padding 通过字体大小计算实现: ```typescript // 路径从 aStart 到 aEnd 的完整弧线 rMid = centroid.rho // 或 (rInner + rOuter) / 2 p1 = polarToXY(aStart, rMid) p2 = polarToXY(aEnd, rMid) // 弧线路径 if (reverse) { path = `M ${p2.x} ${p2.y} A ${rMid} ${rMid} 0 ${largeArc} 0 ${p1.x} ${p1.y}` } else { path = `M ${p1.x} ${p1.y} A ${rMid} ${rMid} 0 ${largeArc} 1 ${p2.x} ${p2.y}` } ``` 文字通过 `` 的 `startOffset="50%"` 和 `text-anchor="middle"` 在路径上居中。 ### 12.6 调试与验证 如需调试特定扇区的计算结果,可临时添加日志: ```typescript // 在 useLuopan.ts 中 if (j === 0 && aStart >= 225 && aStart <= 260) { console.log(`扇区 [${aStart}-${aEnd}°, 层${j}]:`, { radialHeight, arcWidth, textLength, fontSize }); } ``` ## 13. 参考资料 ### 12.1 数学原理 - 圆环扇形形心公式:基于面积加权的质心计算 - SVG 坐标系统:W3C SVG 规范 - 极坐标系统:标准数学定义 ### 12.2 技术栈 - Vue 3.4+ - TypeScript 5.3+ - Vite 5.0+ - Vitest 1.2+ --- **版本**: 1.1.0 **最后更新**: 2026年1月20日 **主要变更**: - 新增文字布局配置常量集中管理 - 优化字体大小自适应算法 - 完善竖排文字特殊场景处理 - 修复最内层扇区文字溢出问题