first commit

This commit is contained in:
douboer
2026-01-21 13:22:26 +08:00
commit 24452838a1
28 changed files with 7901 additions and 0 deletions

679
detail-design.md Normal file
View File

@@ -0,0 +1,679 @@
# 罗盘组件详细设计文档
## 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 的 `<path>` 元素绘制,包含以下步骤:
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 的 `<textPath>` 功能。路径使用形心半径:
```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. 颜色生成策略
使用 HSL 颜色空间实现层次分明的配色:
```typescript
function generateSectorColor(
layerIndex: number,
pieIndex: number,
totalPies: number = 24
): string {
const hue = (pieIndex * 360) / totalPies; // 色相均匀分布
const light = 78 - layerIndex * 10; // 亮度由内向外递减
return `hsl(${hue} 70% ${light}%)`;
}
```
**效果**
- 相邻扇区色相不同,易于区分
- 内层较亮,外层较暗,突出层次
## 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
<template>
<Luopan :size="520" />
</template>
<script setup>
import { Luopan } from './src';
</script>
```
### 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 支持的扩展
- ✅ 自定义角度分割
- ✅ 自定义圆环半径
- ✅ 自定义颜色方案
- ✅ 自定义文字内容
- 🔲 图标/图片放置(预留 `<foreignObject>`
- 🔲 交互事件(点击、悬停)
- 🔲 动画效果
### 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}`
}
```
文字通过 `<textPath>``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日
**主要变更**:
- 新增文字布局配置常量集中管理
- 优化字体大小自适应算法
- 完善竖排文字特殊场景处理
- 修复最内层扇区文字溢出问题