Files
lupin-demo/detail-design.md
2026-01-21 13:22:26 +08:00

680 lines
16 KiB
Markdown
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.

# 罗盘组件详细设计文档
## 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日
**主要变更**:
- 新增文字布局配置常量集中管理
- 优化字体大小自适应算法
- 完善竖排文字特殊场景处理
- 修复最内层扇区文字溢出问题