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

17
.gitignore vendored Normal file
View File

@@ -0,0 +1,17 @@
node_modules
.DS_Store
dist
*.log
/tmp
# Environment variables (contains sensitive data)
.env
.env.local
.env.*.local
# Account configuration (may contain phone number)
account.json
# Trace files (debugging artifacts, may contain sensitive data)
traces/
*.zip

33
App.vue Normal file
View File

@@ -0,0 +1,33 @@
<template>
<div id="app">
<h1>罗盘组件示例</h1>
<Luopan />
</div>
</template>
<script setup lang="ts">
import Luopan from './src/Luopan.vue';
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
#app {
padding: 20px;
max-width: 600px;
margin: 0 auto;
}
h1 {
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto,
Helvetica, Arial;
color: #111827;
margin-bottom: 20px;
font-size: 24px;
font-weight: 600;
}
</style>

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

169
docs/desc.md Normal file
View File

@@ -0,0 +1,169 @@
如何理解三合风水立正向与兼向的内容
怹烎地理 2020.03.13
前言
回顾中国传统风水文化的发展历程,隋唐之前,风水术相对单纯,并没有如今这般繁杂的理论。当时的风水实践更侧重于对自然环境的直接观察与利用,理论体系较为简洁明了。但自宋朝之后,各种风水理论如雨后春笋般逐渐涌现。到了明朝末期时代,风水派别更是如繁花般多样,各类理论堆积如山。
各家各派都以“八卦”为旗号,衍生出众多令人困惑的“理论”。争议较大的是,有人称宋朝之前就有许多托名杨公的书籍,倘若其作者手中真保存有杨公的散落竹文,也必然会按照自己的理论对其进行增删修改。尤其是明末清初的蒋大鸿先生,依据自己的理论,著书立说,对很多风水古籍进行了整理和改编,其创立的三元风水术流传至今,理论真伪,众说不一。就本人而言,其实“说不一”者,或许是 其知识面尚不够广,有待在“易理”上进一步深入,没有一双慧眼,恐怕实在难以悟透风水的精华理论。
本文根据师承之三合风水精华理论,就如何区别三合风水立“正向与兼向”的内容进行阐述,
本章内容导读:
1、风水立向规则因派别而异。
2、三合、三元风水的正向与兼向规则。
3、三合风水实际操作倾向及二十四山内涵。
4、二十四山五格吉凶含义剖析。
5、兼向理论依据 与八卦人伦关系。
6、基于八卦人伦关系的兼向判断。
7、对朝向可兼不可兼理论的小结。
一、风水立向规则因派别而异
立向的选择至关重要,它被认为对居住者或逝者的运势有着深远影响。而立向又存在正向与兼向的区别,这些具体规则并非统一不变,而是会因风水派别的不同而呈现出显著差异。不同的风水派别基于各自独特的理论体系和传承,在正向与兼向的判定标准、操作方法以及背后的原理阐释上,都有着各自的一套说法。
从科学理性的角度出发,建筑选址与立向,应当将地理环境作为根本依据,而理气则作为辅助手段。理气需与实际的地理形势紧密结合,地理形势也需要借助合理的理气来优化。然而,理气绝不能脱离实际,必须基于实实在在的真理,而不是牵强附会地编造一些自相矛盾的说法。像风水理论中那些一会儿说吉,一会儿又说凶的矛盾表述,不过是故意制造神秘,故弄玄虚罢了。
二、三合、三元风水的正向与兼向规则的内容
在三合风水的体系里,正向与兼向有着明确且独特的规则。
(一)正向规则
正向在三合风水的概念中指的是二十四山当中某山中心特定的3°范围。以常见的子山午向为例来详细说明午山在方位上所涵盖的总范围是15°即处于173.5°至188.5°之间。然而若要立为正午向实际所占据的仅仅是午字中心的3°范围也就是178.5°至181.5°。一旦立向超出了这个3°的范围便不再属于正向而是被界定为兼向。这种精确的范围划分是三合风水判断正向的关键依据为风水师在实际操作中确定正向提供了清晰的标准。
(二)兼向规则
当朝向出现靠近另一座山的情况,且具有兼用另一山之意时,就
构成了兼向。在具体判断兼向时,存在独特的观察方式。例如在立子向的情况下,如果是往左兼,就需要兼看人盘;要是往右兼,则要兼看天盘。具体来说,往左兼时,兼看人盘的“癸”字,这种朝向便被叫做子向兼癸;往右兼时,兼看天盘的“壬”字,相应地称为子向兼壬(可结合相关图示更直观地理解)。
之所以会有这样的兼向观察规则与罗盘的构造密切相关。罗盘分为地盘、人盘、天盘这三盘。其中地盘在整个罗盘体系中占据着主导地位犹如最高统帅起着决定性作用。而人盘和天盘则如同国务总理主要承担辅佐和校正地盘的功能。并且罗盘其他各层的理论分析都是以地盘数据作为基础展开的。所以立向自然是以地盘为主。但需要注意的是这三盘的方位数据并不一致。以地盘为基准人盘相对地盘错后7.5°天盘则提前7.5°。基于这样的差异就产生了“往左兼兼看人盘往右兼兼看天盘”的规则。虽然从常规角度看在15°范围内立向似乎不应算兼向但这正是三合风水特有的立向法则其中蕴含着深厚的风水原理和讲究。
(三)三元风水的正向与兼向规则
相较于三合风水三元风水对于正向与兼向有着不同的范围界定方式。在三元风水的理论里每山跨度设定为15°在这15° 的范围内又进一步细致划分。其中中心9°的范围被划定为正向在9°范围之外向左右两边各延伸1.5°的区域属于兼向在三元风水中也称作替向而从兼向再往两边各1.5度的区域,则被定义为出卦,也就是所谓的空亡。
备注:简述三元风水的正向与兼向规则的内容,其目的在于习者区分,在之前部分的章节中,已对两者作了主要区别介绍,此不再作更多阐述,此并无他意,将后续的内容中补全。
三、三合风水实际操作倾向及二十四山内涵的内容
在三合风水的实际操作中,存在一个显著的倾向,那就是十有八九会采用兼向。这一现象的背后,与二十四山的独特构成紧密相关。
二十四山是由十二地支、八干以及四维卦共同构成。我们生活在地球上,十二地支与我们的生活息息相关,其蕴含的气场强弱对我们有着直接的影响;而八干四维来自宇宙,相对而言,它们对我们的影响较为间接和微弱。
二十四山的每山均为15°为了更精确地分析和运用风水每山又被平均细分为5个小格每个小格恰好为3°如此一来刚好凑成每山15°的完整范围。这看似简单的五格划分实则暗藏玄机其中蕴含着极为复杂的吉凶含义。要想全面清晰地阐述这些含义需要分两大部分且每部分都要通过三步来详细说明。这种细分方式以及背后复杂的吉凶含义构成了三合风水独特的立向基础也解释了为何在实际操作中兼向应用更为广泛。每一小格所承载的吉凶信息都在风水师的考量范围内影响着最终的立向决策。
四、二十四山五格吉凶含义剖析的内容
(一)十二地支的五格
1、鬼甲空亡
在十二地支五格中,正对着地支中心的一格,具有独特的性质。这一格凝聚着地支极为浓烈的正气,其气场浓度之强,仿佛如同坚硬的龟甲,因此被赋予了“龟甲空亡”的称呼。这样强大且独特的气场,普通百姓之家难以承受。在实际应用中,只有诸如寺庙、政府大楼、公检法机关、监狱等特殊场所,因其需要彰显庄重威严的气质,与这种强大气场相契合,才适合采用这样的朝向。
这一格通常空白无字,但并非真的没有字,实则是省略不刻。原因在于对于普通民房而言,立向于此会被认为不吉利。实际上,这一格隐藏着特定的干支。对于阳支,会冠以“戊”;阴支则冠以“己”。以子山为例,子为阳,所以这一小格实际为戊子;又如丑山,丑为阴,此小格便是己丑。其实,“龟甲空亡”这一名称并不完全准确,因为它并非真正意义上的空亡,只是支气过浓而已,或许叫龟甲或者风洞更为贴切,这样的称呼能更准确地反映其本质特征。
2、旺和相
位于正中心两侧的两个小格,这里的地支气场浓度恰到好处,既不过强也不过弱,完美契合了儒道所倡导的中庸思想,故而被称作“旺相”。这两格所具备的气场条件,非常适宜民房立向。
为了明确这两格的属性和用途,它们分别刻有干支。对于阳支,会冠以“丙、庚”;阴支则冠以“丁、辛”。例如子山,由于子为阳,这两格就分别刻写丙子、庚子;若是丑山,丑为阴,两格分别刻写丁丑、辛丑。在三合风水的理论中,丙庚被视为旺,丁辛被看作相。民房立向于此,被认为十分吉利,这也正是众多老百姓建房开墓大多采用兼向的重要原因之一。至于为何“丙、庚、丁、辛”会被认为吉利,这与八卦阴阳爻相配的理论紧密相关,不过由于篇幅限制,在此暂不深入探讨这一复杂的理论内容。
3、阴差和阳错
在两侧小格之外,左右靠边的两个小格,已属地支的余气范围,气场十分微弱。而且,这两格紧邻其他卦山,其阴阳属性又与所在地支不完全一致,因此被命名为“阴阳差错”。由于这样的气场条件不太吉利,所以这两格同样没有刻字。
当遇到阳山时,会冠以天干“甲、壬”;遇到阴山,则冠以天干“乙、癸”。比如子山,对应甲子和壬子;丑山,对应乙丑和癸丑。在三合风水里,有一种说法是甲壬为孤、乙癸为虚。这里的孤代表鳏夫之意,虚指寡妇之意。鳏夫寡妇独居,呈现出孤阴孤阳的状态,在传统观念中,这种状态没有繁衍后代的功能,所以被视为不吉利。
(二)八干四维的五格
1、大空亡
在八干四维的五格中,正对着干维中心的一格,象征着来自宇宙深空的气息。由于这种气息对地球的影响相对较小,也就是说这一格几乎没有地气的存在,所以被称作“大空亡”。显然,这样的干维正向由于缺乏地气的支撑,被认为不吉利,因此这一格通常空白无字,当然这也是出于习惯而被省略的。
2、旺和相
位于正中心两侧的两个小格,它们靠近地支。由于天干本身不接地气,所以在立向时需要兼用地支的气场。基于此,这两个小格分别刻有干支,以表明其在立向中的作用和属性。
3、阴差和阳错
在两侧小格之外,左右靠边的两个小格,处于紧邻其他卦山的位置,这里属于阴阳转换的关键节点。在风水理论中,这样的位置气场不稳定,不适宜立向,所以同样空白无字。
至此二十四山每山的五格基本阐述完毕。每山5格总共24山通过简单计算24×5 = 120格所以被称为一百二十分金。所谓分金其本质就是对四面八方进行详细划分寓意着这种划分如同黄金般珍贵对风水的精准判断具有重要意义。而每山之下所分配的干支由于涉及面广泛且复杂在本文中未能详细叙述。
五、兼向理论依据与八卦人伦关系的内容
在三合风水关于兼向的理论构建中,八卦所对应的人伦关系起着至关重要的依据作用。
八卦分别对应着不同的人伦角色并且大致与特定年龄段相关联。其中乾对应老男、坤对应老母这两个卦象所代表的人物形象大约相当于80岁左右的长辈。震对应长男、巽对应长女其对应的年龄段大约在60岁上下。坎对应中男、离对应中女这两组大约代表40岁左右的人群。艮对应少男、兑对应少女他们所象征的人群大约在20岁左右。如此一来八卦对应人伦恰似一个四世同堂的家庭结构各有其位层次分明。
在风水理念里,十分注重辈分相同以及男女相配,这被视为正配,是一种理想且吉利的搭配方式。然而,罗盘上存在二十四山,而八卦仅有八个,为了使二十四山与八卦人伦相对应,三合风水形成了独特的对应办法。
申、子、辰、癸这四山,被纳入坎卦的范畴,它们统统属于中男,属性为阳。这意味着在风水的人伦对应体系中,这四座山在气场和象征意义上,与坎卦所代表的中男特质相呼应。
亥、卯、未、庚四山,则被纳入震卦范畴,都属于长男,同样属性为阳。这些山与震卦所象征的长男在风水意象上紧密相连。
寅、午、戌、壬四山,被归类到离卦范畴,统统属于中女,属性为阴。它们在风水理论中,与离卦所代表的中女建立了对应关系。
巳、酉、丑、丁四山,被纳入兑卦范畴,都属于少女,属性为阴。表明这些山在风水的人伦对应中有其特定的象征意义。
乾、甲二山,被归为乾卦范畴,统统属于老男,属性为阳。它们与乾卦所代表的老男特质相契合。
坤、乙二山,被纳入坤卦范畴,统统属于老母,属性为阴。这两座山在风水理念中与坤卦所对应的老母相对应。
艮、丙二山,被纳入艮卦范畴,统统属于少男,属性为阳。它们和艮卦所代表的少男存在着象征意义上的联系。
巽、辛二山,被纳入巽卦范畴,统统属于长女,属性为阴。这两座山与巽卦所对应的长女在风水象征上相互关联。
至于为何要如此规定二十四山与八卦人伦的对应关系,这与八卦纳甲这一复杂的理论相关。八卦纳甲涉及到诸多易学原理和文化内涵,由于其内容繁杂,在本文中暂不展开详细叙述。但这种对应关系为后续判断哪些向可兼、哪些不可兼奠定了重要的理论基础。
六、基于八卦人伦关系的兼向判断的内容
在三合风水里,依据八卦人伦关系来判断兼向的吉凶,有着一套详细的准则,详述如下:
(一)男女正配最吉祥
向字为主,山字为辅,男女正配最吉祥:在判断兼向吉凶时,向字占据主导地位,山字起辅助作用。当男女呈现正配时,寓意最为吉祥。例如午山子向兼丙壬,在此朝向中,子代表中男,壬代表中女,形成男女正配的格局,象征着生生不息,是大吉大利之象。这就如同一个家庭中,男女角色搭配得当,和谐美满,充满生机与活力。然而,若为午山子向兼丁癸,子为中男,癸同样为中男,两个男性在一起,就如同两个光棍汉,从风水象征意义来讲,自然没有繁衍功能,不具备吉祥的寓意。再如子山午向兼壬丙,午是中女,丙是少男,这种搭配就像小青年娶老妇女,不仅不一定能生育,而且午属阳,丙属阴,阴阳驳杂,不符合风水所追求的和谐与吉利,所以也是不吉之象。
(二)阴阳相同,男女偏配也吉祥
即使并非严格意义上的男女正配,但如果阴阳属性相同,在风水判断中也可能被视为吉祥。例如卯山酉向兼甲庚,酉为少女,庚为长男,虽不是传统意义上的正配,但酉和庚都属阴性,二者气场较为和谐,所以也被认为吉利。又如辛山乙向兼戌辰,乙为老女,辰为中男,虽然老女可能不再具备生育能力,但毕竟是一男一女的组合,且乙和辰都属阳,阴阳属性的一致性使得这种搭配在风水上也具有一定的吉利象征。
(三)阴阳相同,男女不配也不利
当男女角色不匹配且阴阳属性相同,往往预示着不利。像乙山辛向兼卯酉,辛为长女,酉为少女,两个女性住在一起,在风水意象中有寡妇之象。即便二者阴阳属性相同,也难以改变其不吉利的寓意。倘若乙山辛向兼辰戌,辛为长女,戌为中女,同样是两个女性共处,而且阴阳属性不同,犯了阴阳驳杂的忌讳,情况就更差了。这也导致在一些风水观点中,“辛”字只有立正向才较为合适。但前文提到天干不接地气,这又使得风水理论在实际应用中产生矛盾,让人左右为难,需要深入领悟其中真谛,才能运用自如。
(四)阴阳不同,男女相配也不利
有些情况下,尽管男女看似正配,但阴阳属性不同,也会被判定为不吉。比如子山午向兼壬丙,午是少女,丙是少男,表面上看是标准的男女正配,但午属阳,丙属阴,犯了阴阳驳杂的原则,故而不吉。这种观点看似与自然规律中阴阳结合才能生育的认知相悖,按照常理阴阳不同才能通婚生育,可这里却认为“阴阳驳杂”不吉,在一些人看来,这实在是一种有争议的说法,甚至可被视为一种谬论。
(五)立正向的时候,向字和山字都论
以子山午向为例,坐山“子”为中男,向方“午”为中女,坐向恰当,形成男女正配的格局,从风水角度有利于繁衍生息,呈现大吉大利之态。然而,前文提及正向会犯龟甲空亡,不适合普通民房立向,这无疑体现了风水术内部理论之间的矛盾之处。作者在此只是客观陈述这些观点,读者权且听听,至于在实际应用中如何抉择,由读者自行决定。
(六)立正向的时候,什么都不论
例如乙山辛向,坐山“乙”为老女,向方“辛”为中女,立正向在某些风水理论中被认为吉利。但从实际情况来看,建房在地上,埋葬在地下,都与地球紧密相连,完全以天干立向,不符合自然法则,这再次凸显了风水理论在实际应用中的困境与矛盾。
七、对朝向可兼不可兼理论的总结
综上所述,朝向可兼与不可兼的理论,诞生于北宋之后,其内容存在诸多牵强附会之处。这一理论体系内部矛盾重重,例如在立正向的问题上,既强调正向会犯龟甲空亡,不适合民房,又声称有些字必须立正向;在男女相配的观念里,一方面认可男女正配的吉祥意义,却又出现老男配中女这样看似矛盾的搭配情况;而且常常强调一阴一阳谓之道,可在实际判断兼向时,阴阳相兼又被判定为犯驳杂不吉。
鉴于这些理论的自相矛盾性,它并不适宜直接应用于实际的风水操作中。我们只能将其当作一种参考资料,或者用于风水文化的研究层面,从文化传承与发展的角度去审视它。例如,通过研究这一理论,我们可以了解到特定历史时期人们的思想观念、哲学认知以及对自然和生活的理解。
值得注意的是,三合风水中的生、旺、墓理论,实际上是对自然界万物旺衰过程的一种总结,与大自然的运行法则相契合。因此,在实际的风水操作中,结合实地环境,参照十二长生来放线立向,才是更为合理的做法。这样既能够遵循自然规律,又能在一定程度上避免陷入那些矛盾理论的困境,使风水实践更具科学性和合理性。
前言各家各派都以“八卦”为旗号,衍生出众多令人困惑的“理论”。争议较大的是,有人称宋朝之前就有许多托名杨公的书籍,倘若其作者手中真保存有杨公的散落竹文,也必然会按照自己的理论对其进行增删修改。尤其是明末清初的蒋大鸿先生,依据自己的理论,著书立说,对很多风水古籍进行了整理和改编,其创立的三元风水术流传至今,理论真伪,众说不一。就本人而言,其实“说不一”者,或许是 其知识面尚不够广,有待在“易理”上进一步深入,没有一双慧眼,恐怕实在难以悟透风水的精华理论。

66
docs/kaixi28.md Normal file
View File

@@ -0,0 +1,66 @@
## 开禧二十八宿与地盘正针
1、二十八星宿名称
二十八星宿是中国古代天文学家为观测日、月、五星运行而划分的二十八个星区,是我国本土天文学创作,用来说明日、月、五星运行所到的位置。二十八宿是古代中国将黄道和天赤道附近的天区划分为二十八个区域。
二十八星宿具体如下:
东方青龙七宿:角、亢、氐(dī)、房、心、尾、箕(jī)。
北方玄武七宿:斗(dǒu)、牛、女、虚、危、室、壁。
西方白虎七宿:奎、娄(lóu)、胃、昴(mǎo)、毕、觜(zī)、参(shēn)。
南方朱雀七宿:井、鬼、柳、星、张、翼、轸(zhěn)。
二十八宿的名称记载,最早出现于先秦文献《吕氏春秋》(约完成于公元前239年)中,先秦的《逸周书》,西汉时期的《礼记》、《淮南子》)和《史记》也有记载。考古发现1978年发掘的战国中期湖北曾侯乙墓(公元前433年),出土的漆箱盖上出现了完整的二十八宿名。
2、二十八星宿与动物和五行的对应关系
东方七宿:角木狡、亢金龙、氐土貉、房日兔、心月狐、尾火虎、箕水豹。
北方七宿:斗木蟹、牛金牛、女土蝠、虚日鼠、危月燕、室火猪、壁水狳。
西方七宿:奎木狼、娄金狗、胃土雉、昴日鸡、毕月乌、觜火猴、参水猿。
南方七宿:井木犴、鬼金羊、柳土獐、星日马、张月鹿、翼火蛇、轸水蚓。
其中的日、月宿均属火。
五行的排布顺序是有规律的,永远是:木、金、土、火、火、火、水。
3、二十八宿宿度数值
罗盘上的二十八宿后面有“半、太、少”的字样,半即是二分之一,少即是少于一半,太即是一半多。
3、线度吉凶符号解释
开禧二十八星宿之线度吉凶一层标示有红点、空白、y、x、人和等字样用以表示二十八宿度之线度吉凶。
(1)、红点,表示吉度,为吉利线度。很多地理师只用红点。
(2)、空白表示平,普通线度。
(3)、y表示差错线度是地盘十二支的正中一线。
(4)、x表示关煞二十八宿宿与宿之间为关两宿相克即为煞。
(5)人表示空亡线。其中压二十四山的交界线,为小空亡。八宫的交界线,为大空亡。
(6)、丶表示凶度,为凶险的线度。
综合盘开禧二十八宿三层
4、二十八星宿之线度吉凶
(1)、 角宿、十二度太:一、二度为吉,三度平,四五度犯关煞,六度吉,七度犯差错,八度凶,九度合北斗高起星吉,十、十一度吉,十二度及太度凶。
(2)、 亢宿、九度太:一度凶,二度犯小空亡,三度吉,四五度犯关煞,六度平,七度凶,八度吉,九度犯大空亡。
(3)、 氏宿、十六度少:一二度犯关煞,三度觉骑官星吉,四度水星吉,五度合将军星吉,六七度犯关煞,八度合贵星吉,九十十一度平,十二度吉气吉,十三度凶,十四十五度犯差错,十六防度吉。
(4)、 房宿、五度太:一二度吉,三四度犯关煞,五合阴德监司吉,太度犯小空亡。
(5)、 心宿、六度:一度吉,二度合太子侍从吉,三四度犯关煞,五度罗网,六度犯白虎腾蛇凶。
(6)、 尾宿、十八度:一二度犯大空亡,三度凶,四度吉,五度合紫微星吉,六七度平,八度吉,九度合帝座吉,十十一度犯关煞,十二十三度平,十四度凶,十五度平,十六十七度犯关煞,十八度犯天贫星凶。
(7)、 箕宿、九度半:一二度吉,三度凶,四度吉,五六度犯关煞,七度犯小空亡,八度平,九度犯恶死凶,半度平。
(8)、 斗宿、二十二度:一度合土气高甲星吉, 二度吉,三四度平,五度犯大空亡,六度合贵子星吉,七八度犯关煞,九十度吉,十一度合相龙星吉,十二度吉,十三度犯小空亡,十四度合国家桂石星吉,十五度合师旅吉,十六度凶,十七十八度吉,十九合大贵星吉,二十度合师错,二十一二十二度俱平。
(9)、 牛宿、七度:一度凶,二度合女帝星吉,三四犯关煞,五度犯小空亡,六度凶,七度吉。
(10)、 女宿、十一度:一度吉,二三度犯关煞,四度凶,五度吉,六度犯大空亡,七度合贵星吉,八度合黄度吉,九度合天才星吉,十十一度凶。
(11)、 虚宿、九度少:一度凶,二三度犯关煞,四度合富贵星吉,五度合过于用星吉,六度合大贵星吉,七度平,八九度犯关煞。
(12)、 危宿、十六度:一度凶,二三度吉,四五度犯关煞,六度合金库星吉,七度凶,八度犯小空亡,九度十度凶,十一度合公吏星吉,十二度平,十三度凶,十四度犯黑道,十五度十六度犯关煞。
(13)、 室宿、十八度:一二度吉,三度合人道吉,四度五度犯关煞,六度吉,七度犯小空亡,八度犯腾蛇,九度合天皇帝星吉,十十一度犯关煞,十二度合斧头星吉,十三度合斧柄星吉,十四度平,十五度犯差错空亡,十六十七度吉,十八度凶。
(14)、 壁宿、九度太:一度合五艮巾帽星吉,二度合上司得从星吉,三四度平,五六度犯关煞,七度吉,八度合外屏侍从星吉,九度犯差错。
(15)、 奎宿、十八度:一度右,二度犯大空亡,三度合大贵星吉,四五度凶,六度合天府星吉,七度合黄道福寿星吉,八度合天仓星吉,九度吉,十度犯小空亡,十一至十三度平,十四犯差错,十五度吉,十六度平,十七度犯差错,十八度吉。
(16)、 娄宿、十二度太:一度合天仓星吉,二三度犯关煞,四度合天文将军星吉,五度平,六度吉,七度犯小空亡,八度吉,九度十度平,十一度犯差错,十二度犯黑道,太度合朝土执政吉。
(17)、 胃宿、十五度少:一度吉,二度犯大空亡,三度四度犯关煞,五度吉,六度合财库星吉,七度平,八度合太阴吉,九度合天河太旺星吉,十度犯小空亡,十一度合天府总领星吉,十二度吉,十三度平,十四十五度凶。
(18)、 昴宿、十一度:一度平,二度犯差错,三度吉,四度合黄道吉,五六度犯关煞,七八度吉,九度犯小空亡,十度合黄道星吉,十一度大空亡。
(19)、 毕宿、十六度半:一度合八座星吉,二度平,三度合天壬诸郎星吉,四度平,五度犯大空亡,六七度犯关煞,八度合文章高贵星吉,九度犯小空亡,十度犯咸池淋浴数,十一度合天子耳日星吉,十二十三犯关煞,十四犯小空亡,十五度凶,十六度平,半度吉。
(20)、 嘴宿、半度 半度吉。
(21)、 参宿、九度半:一度平,二三度犯关煞,四度犯差错,五度平,六度合国子星吉,七八度犯关煞,九度合紫微星,半度吉。
(22)、 井宿、三十度少:一度平,二度犯空亡,三度吉,四五度凶,六度合生气吉,七八九度平,十十一度犯关煞,十二度凶,十三度吉,十四度合郎宿星吉,十五度凶,十六度合生气吉,十七度凶,十八度犯小空亡,十九度二十度吉,二十一度二十二度犯关煞,二十三度平,二十四度合富贵吉,二十五度犯差错,二十六度合紫微星吉,二十七度二十八度犯关煞,二十九度犯聋哑破家星,三十并少度平。
(23)、 鬼宿、二度关半:一度犯债负星凶,二度凶,半度犯小空亡。
(24)、 柳宿、十三度半:一度合文章侍郎星吉,二三度犯关煞,四度合执政诸候星吉,五六七度平,八度九度犯关煞,十度至十三度半俱吉,
(25)、 星宿、六度太:一度平,二度犯小空亡,三度吉,四度合文昌高寿星吉,五六度并太度俱平。
(26)、 张宿、十八度太:一度合宰相星吉,二度吉,三度犯南针错凶,四度吉,五度平,六七度犯关煞,八九度吉,十度犯小空亡,十一度合北斗枢星吉,十二度合权府星吉,十三度吉,十四度凶,十五十六度俱平,十七至太度犯关煞。
(27)、 翌宿、二十度少:一度合长寿星吉,二度合大权星吉,三度平,四度吉,五六度犯关煞,七度吉,八度犯小空亡,九度平,十度合坐气吉,十一度合黄道吉,十二度十三度合印堂星吉,十四度凶,十五度犯小空亡,十六度吉,十七度十八度犯关煞,十九度合惟星吉,二十度合诸候星吉。
(28)、 轸宿、十八度太:一二度合诸候星吉,三度犯小空亡,四度合八座吉,五度平,六度合三公吉,七度至九度平,十十一度犯关煞,十二度合太极左府星吉,十三度凶,十四度平,十五度合外盾虎楼星吉,十六十七度犯关煞,十八度犯小空亡。

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>罗盘组件示例</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/main.ts"></script>
</body>
</html>

4
main.ts Normal file
View File

@@ -0,0 +1,4 @@
import { createApp } from 'vue';
import App from './App.vue';
createApp(App).mount('#app');

3177
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "luopan-demo2",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview",
"test": "vitest"
},
"dependencies": {
"vue": "^3.4.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"@vitest/ui": "^1.2.0",
"@vue/test-utils": "^2.4.6",
"happy-dom": "^20.3.4",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vitest": "^1.2.0",
"vue-tsc": "^1.8.0"
}
}

193
public/complex-luopan.json Normal file
View File

@@ -0,0 +1,193 @@
{
"name": "传统风水罗盘(完整版)",
"description": "24层复杂罗盘配置包含天干地支、八卦、二十四山、六十甲子等",
"theme": "chinese",
"outerRadius": 500,
"layers": [
{
"divisions": 2,
"rInner": 0,
"rOuter": 30,
"sectors": [
{ "content": "阴", "fillColor": "#000000", "textColor": "#FFFFFF" },
{ "content": "阳", "fillColor": "#FFFFFF", "textColor": "#000000" }
]
},
{
"divisions": 4,
"rInner": 30,
"rOuter": 60,
"sectors": [
{ "content": "少阳", "fillColor": "#FFD700" },
{ "content": "太阳", "fillColor": "#FF0000", "textColor": "#FFFFFF" },
{ "content": "少阴", "fillColor": "#32CD32" },
{ "content": "太阴", "fillColor": "#0000FF", "textColor": "#FFFFFF" }
]
},
{
"divisions": 8,
"rInner": 60,
"rOuter": 100,
"sectors": [
{ "content": "乾☰", "fillColor": "#DC143C", "textColor": "#FFFFFF" },
{ "content": "兑☱", "fillColor": "#FFD700", "textColor": "#000000" },
{ "content": "离☲", "fillColor": "#FF6347", "textColor": "#FFFFFF" },
{ "content": "震☳", "fillColor": "#32CD32", "textColor": "#000000" },
{ "content": "巽☴", "fillColor": "#00A86B", "textColor": "#FFFFFF" },
{ "content": "坎☵", "fillColor": "#4169E1", "textColor": "#FFFFFF" },
{ "content": "艮☶", "fillColor": "#CD853F", "textColor": "#000000" },
{ "content": "坤☷", "fillColor": "#8B4513", "textColor": "#FFFFFF" }
]
},
{
"divisions": 12,
"rInner": 100,
"rOuter": 130,
"sectors": [
{ "content": "子", "fillColor": "#4169E1", "textColor": "#FFFFFF" },
{ "content": "丑", "fillColor": "#8B4513", "textColor": "#FFFFFF" },
{ "content": "寅", "fillColor": "#228B22", "textColor": "#FFFFFF" },
{ "content": "卯", "fillColor": "#00A86B", "textColor": "#FFFFFF" },
{ "content": "辰", "fillColor": "#8B7355", "textColor": "#FFFFFF" },
{ "content": "巳", "fillColor": "#FF4500", "textColor": "#FFFFFF" },
{ "content": "午", "fillColor": "#DC143C", "textColor": "#FFFFFF" },
{ "content": "未", "fillColor": "#8B4513", "textColor": "#FFFFFF" },
{ "content": "申", "fillColor": "#FFD700", "textColor": "#000000" },
{ "content": "酉", "fillColor": "#FFA500", "textColor": "#000000" },
{ "content": "戌", "fillColor": "#8B7355", "textColor": "#FFFFFF" },
{ "content": "亥", "fillColor": "#000080", "textColor": "#FFFFFF" }
]
},
{
"divisions": 24,
"rInner": 130,
"rOuter": 170,
"sectors": [
{ "content": "壬" }, { "content": "子" }, { "content": "癸" },
{ "content": "丑" }, { "content": "艮" }, { "content": "寅" },
{ "content": "甲" }, { "content": "卯" }, { "content": "乙" },
{ "content": "辰" }, { "content": "巽" }, { "content": "巳" },
{ "content": "丙" }, { "content": "午" }, { "content": "丁" },
{ "content": "未" }, { "content": "坤" }, { "content": "申" },
{ "content": "庚" }, { "content": "酉" }, { "content": "辛" },
{ "content": "戌" }, { "content": "乾" }, { "content": "亥" }
],
"colorMode": "alternating",
"alternatingColors": ["#FFF9C4", "#FFCCBC"]
},
{
"divisions": 60,
"rInner": 170,
"rOuter": 210,
"sectors": [
{ "content": "甲子" }, { "content": "乙丑" }, { "content": "丙寅" }, { "content": "丁卯" }, { "content": "戊辰" },
{ "content": "己巳" }, { "content": "庚午" }, { "content": "辛未" }, { "content": "壬申" }, { "content": "癸酉" },
{ "content": "甲戌" }, { "content": "乙亥" }, { "content": "丙子" }, { "content": "丁丑" }, { "content": "戊寅" },
{ "content": "己卯" }, { "content": "庚辰" }, { "content": "辛巳" }, { "content": "壬午" }, { "content": "癸未" },
{ "content": "甲申" }, { "content": "乙酉" }, { "content": "丙戌" }, { "content": "丁亥" }, { "content": "戊子" },
{ "content": "己丑" }, { "content": "庚寅" }, { "content": "辛卯" }, { "content": "壬辰" }, { "content": "癸巳" },
{ "content": "甲午" }, { "content": "乙未" }, { "content": "丙申" }, { "content": "丁酉" }, { "content": "戊戌" },
{ "content": "己亥" }, { "content": "庚子" }, { "content": "辛丑" }, { "content": "壬寅" }, { "content": "癸卯" },
{ "content": "甲辰" }, { "content": "乙巳" }, { "content": "丙午" }, { "content": "丁未" }, { "content": "戊申" },
{ "content": "己酉" }, { "content": "庚戌" }, { "content": "辛亥" }, { "content": "壬子" }, { "content": "癸丑" },
{ "content": "甲寅" }, { "content": "乙卯" }, { "content": "丙辰" }, { "content": "丁巳" }, { "content": "戊午" },
{ "content": "己未" }, { "content": "庚申" }, { "content": "辛酉" }, { "content": "壬戌" }, { "content": "癸亥" }
],
"colorMode": "alternating",
"alternatingColors": ["#FFE082", "#FFCC80", "#FFAB91"]
},
{
"divisions": 72,
"rInner": 210,
"rOuter": 250,
"sectors": [
{ "content": "1" }, { "content": "2" }, { "content": "3" }, { "content": "4" }, { "content": "5" },
{ "content": "6" }, { "content": "7" }, { "content": "8" }, { "content": "9" }, { "content": "10" },
{ "content": "11" }, { "content": "12" }, { "content": "13" }, { "content": "14" }, { "content": "15" },
{ "content": "16" }, { "content": "17" }, { "content": "18" }, { "content": "19" }, { "content": "20" },
{ "content": "21" }, { "content": "22" }, { "content": "23" }, { "content": "24" }, { "content": "25" },
{ "content": "26" }, { "content": "27" }, { "content": "28" }, { "content": "29" }, { "content": "30" },
{ "content": "31" }, { "content": "32" }, { "content": "33" }, { "content": "34" }, { "content": "35" },
{ "content": "36" }, { "content": "37" }, { "content": "38" }, { "content": "39" }, { "content": "40" },
{ "content": "41" }, { "content": "42" }, { "content": "43" }, { "content": "44" }, { "content": "45" },
{ "content": "46" }, { "content": "47" }, { "content": "48" }, { "content": "49" }, { "content": "50" },
{ "content": "51" }, { "content": "52" }, { "content": "53" }, { "content": "54" }, { "content": "55" },
{ "content": "56" }, { "content": "57" }, { "content": "58" }, { "content": "59" }, { "content": "60" },
{ "content": "61" }, { "content": "62" }, { "content": "63" }, { "content": "64" }, { "content": "65" },
{ "content": "66" }, { "content": "67" }, { "content": "68" }, { "content": "69" }, { "content": "70" },
{ "content": "71" }, { "content": "72" }
],
"colorMode": "solid",
"defaultFillColor": "#FFF59D"
},
{
"divisions": 120,
"rInner": 250,
"rOuter": 290,
"colorMode": "alternating",
"alternatingColors": ["#FFEB3B", "#FFC107"]
},
{
"divisions": 120,
"rInner": 290,
"rOuter": 320,
"colorMode": "solid",
"defaultFillColor": "#FFFDE7"
},
{
"divisions": 72,
"rInner": 320,
"rOuter": 350,
"sectors": [
{ "content": "0" }, { "content": "5" }, { "content": "10" }, { "content": "15" },
{ "content": "20" }, { "content": "25" }, { "content": "30" }, { "content": "35" },
{ "content": "40" }, { "content": "45" }, { "content": "50" }, { "content": "55" },
{ "content": "60" }, { "content": "65" }, { "content": "70" }, { "content": "75" },
{ "content": "80" }, { "content": "85" }, { "content": "90" }, { "content": "95" },
{ "content": "100" }, { "content": "105" }, { "content": "110" }, { "content": "115" },
{ "content": "120" }, { "content": "125" }, { "content": "130" }, { "content": "135" },
{ "content": "140" }, { "content": "145" }, { "content": "150" }, { "content": "155" },
{ "content": "160" }, { "content": "165" }, { "content": "170" }, { "content": "175" },
{ "content": "180" }, { "content": "185" }, { "content": "190" }, { "content": "195" },
{ "content": "200" }, { "content": "205" }, { "content": "210" }, { "content": "215" },
{ "content": "220" }, { "content": "225" }, { "content": "230" }, { "content": "235" },
{ "content": "240" }, { "content": "245" }, { "content": "250" }, { "content": "255" },
{ "content": "260" }, { "content": "265" }, { "content": "270" }, { "content": "275" },
{ "content": "280" }, { "content": "285" }, { "content": "290" }, { "content": "295" },
{ "content": "300" }, { "content": "305" }, { "content": "310" }, { "content": "315" },
{ "content": "320" }, { "content": "325" }, { "content": "330" }, { "content": "335" },
{ "content": "340" }, { "content": "345" }, { "content": "350" }, { "content": "355" }
],
"colorMode": "solid",
"defaultFillColor": "#FFFDE7"
},
{
"divisions": 72,
"rInner": 350,
"rOuter": 380,
"colorMode": "alternating",
"alternatingColors": ["#FFD54F", "#FFF176"]
},
{
"divisions": 72,
"rInner": 380,
"rOuter": 420,
"colorMode": "solid",
"defaultFillColor": "#FFEB3B"
},
{
"divisions": 72,
"rInner": 420,
"rOuter": 460,
"colorMode": "alternating",
"alternatingColors": ["#FFD54F", "#FFCA28", "#FFC107"]
},
{
"divisions": 72,
"rInner": 460,
"rOuter": 500,
"colorMode": "solid",
"defaultFillColor": "#FFD54F"
}
]
}

324
public/demo.json.conf Normal file
View File

@@ -0,0 +1,324 @@
-- ========================================
-- 罗盘配置文件 (Demo Configuration)
-- ========================================
-- 该文件不是正式JSON支持使用 -- 添加注释
--
-- 功能说明:
-- 1. 支持命名配色方案 (Named Color Palettes)
-- 2. 支持规律填色机制 (Pattern-based Coloring)
-- 3. 文字颜色自动计算 (Auto Text Color)
-- 4. 扇区内缩效果控制 (Inner Fill Control)
-- ========================================
{
"name": "demo",
"description": "luopan demo config with named color palettes",
-- ========================================
-- 全局配置
-- ========================================
"background": "#000000", -- 全局背景色,未着色扇区使用此颜色
"outerRadius": 500, -- 罗盘外半径,单位:像素
-- ========================================
-- 主题配置
-- ========================================
"theme": {
"name": "五行配色主题",
-- 文字颜色自动计算:根据背景色明暗度自动选择黑/白文字,确保高对比度
-- "textOnLight": "#1a1a1a", 已删除,由系统自动计算
-- "textOnDark": "#ffffff", 已删除,由系统自动计算
-- ========================================
-- 命名配色方案 (Named Color Palettes)
-- ========================================
-- 定义可复用的命名颜色,在 layers 中通过名称引用
-- 包含10种配色黑、灰、白、五行木火土金水、热、冷、强、软
"colorPalettes": {
"黑": "#000000", -- 纯黑
"灰": "#757575", -- 中灰
"白": "#ffffff", -- 纯白
"木": "#43A047", -- 生机绿(五行:木)
"火": "#E53935", -- 烈焰红(五行:火)
"土": "#8D6E63", -- 大地棕(五行:土)
"金": "#78909C", -- 金属灰(五行:金)
"水": "#0288D1", -- 水蓝(五行:水)
"热": "#FF8F00", -- 暖橙(暖色调)
"冷": "#1976D2", -- 冷蓝(冷色调)
"强": "#D32F2F", -- 强烈红(高饱和度)
"\u8f6f": "#FFE0B2" -- 柔和杏(低饱和度)
}
},
-- ========================================
-- 中心图标配置 (Center Icon Configuration)
-- ========================================
"centerIcon": {
"rIcon": 50, -- 图标半径,单位:像素
"opacity": 0.8, -- 图标透明度0.0-1.00为完全透明1为完全不透明
"name": "taiji.svg" -- SVG图标文件名路径固定为 /icons/ 目录
},
-- ========================================
-- 360度刻度环配置 (360 Degree Scale Ring)
-- ========================================
"degreeRing": {
"rInner": 450, -- 刻度环内半径
"rOuter": 500, -- 刻度环外半径
"showDegree": 1, -- 是否显示度数0=不显示1=显示(按 10° 间隔)
"mode": "both", -- 刻度线模式:"inner"在rInner外侧、"outer"在rOuter内侧、"both"(两侧都有,度数居中)
"opacity": 0.3, -- 圆环透明度0.0-1.0设置为0可以只显示刻度而不显示圆圈
"tickLength": 6, -- 刻度线长度,单位:像素, minorTick比majorTick短1px microTick比minorTick短1px
"majorTick": 10, -- 主刻度间隔(度),如 10 表示每 10° 一个主刻度
"minorTick": 5, -- 次刻度间隔(度),如 2 表示每 2° 一个次刻度
"microTick": 1, -- 微刻度间隔(度),如 1 表示每 1° 一个微刻度
"tickColor": "#ffffff",-- 刻度线颜色
"ringColor": "#ffffff" -- 圆环颜色
},
-- ========================================
-- 层配置 (Layers Configuration)
-- ========================================
-- 从内向外定义每一层的配置
"layers": [
-- ========================================
-- 第1层阴阳 (2等分)
-- ========================================
-- 演示:扇区级别 colorRef 优先级最高
{
"divisions": 2, -- 2等分
"rInner": 60, -- 内半径
"rOuter": 90, -- 外半径
-- "colorPalette": "Black", -- 默认配色(已废弃,已注释)
"sectors": [
{
"content": "阴", -- 扇区文字内容
"innerFill": 1 -- 1=显示内缩效果0=不显示
},
{
"content": "阳",
"colorRef": "White", -- 扇区级别指定颜色(优先级最高)
"innerFill": 0
}
]
},
-- ========================================
-- 第2层四象 (4等分)
-- ========================================
-- 演示:旧的 colorMode 方式(将被移除)
{
"divisions": 4,
"rInner": 90,
"rOuter": 120,
-- "colorPalette": "Fire", -- 旧方式:使用 colorPalette已废弃已注释
-- "colorMode": "alternating", -- 旧方式:交替色模式(已废弃,已注释)
"sectors": [
{ "content": "少阳", "innerFill": 1 },
{ "content": "太阳", "innerFill": 0 },
{ "content": "少阴", "innerFill": 1 },
{ "content": "太阴", "innerFill": 0 }
]
},
-- ========================================
-- 第3层八卦 (8等分)
-- ========================================
-- 演示:旧的 colorMode 方式(将被移除)
{
"divisions": 8,
"rInner": 120,
"rOuter": 160,
-- "colorPalette": "Wood", -- 旧方式:使用 colorPalette
-- "colorMode": "gradient", -- 旧方式:渐变模式(将被移除)
"sectors": [
{ "content": "乾", "innerFill": 1 },
{ "content": "兑", "innerFill": 0 },
{ "content": "离", "innerFill": 1 },
{ "content": "震", "innerFill": 0 },
{ "content": "巽", "innerFill": 1 },
{ "content": "坎", "innerFill": 0 },
{ "content": "艮", "innerFill": 1 },
{ "content": "坤", "innerFill": 0 }
]
},
-- ========================================
-- 第4层地支 (12等分)
-- ========================================
-- 演示:规律填色 - 3个着色1个间隔
-- 着色规律start=2, num=3, interval=1
-- 效果扇区2-4着色5空白6-8着色9空白10-12着色1空白
{
"divisions": 12,
"rInner": 160,
"rOuter": 200,
"colorRef": "土", -- 着色使用的颜色引用
"innerFill": 1, -- 着色区域的内缩设置
"start": 2, -- 从第2个扇区开始着色1-based索引
"num": 3, -- 连续着色3个扇区
"interval": 1, -- 着色后间隔1个扇区
"sectors": [
{ "content": "子", "colorRef": "水", "innerFill": 1 }, -- 高优先级着色:水
{ "content": "丑" },
{ "content": "寅", "colorRef": "木", "innerFill": 0 }, -- 高优先级着色:木
{ "content": "卯", "colorRef": "木", "innerFill": 1 }, -- 高优先级着色:木
{ "content": "辰" },
{ "content": "巳", "colorRef": "火", "innerFill": 1 }, -- 高优先级着色:火
{ "content": "午", "colorRef": "火", "innerFill": 0 }, -- 高优先级着色:火
{ "content": "未", "innerFill": 1 },
{ "content": "申", "colorRef": "金", "innerFill": 0 }, -- 高优先级着色:金
{ "content": "酉", "colorRef": "金", "innerFill": 1 }, -- 高优先级着色:金
{ "content": "戌" },
{ "content": "亥", "innerFill": 0 }
]
},
-- ========================================
-- 第5层二十四山 (24等分)
-- ========================================
-- 演示:规律填色 - 2个着色2个间隔
-- 着色规律start=1, num=2, interval=2
-- 效果扇区1-2着色3-4空白5-6着色7-8空白……
{
"divisions": 24,
"rInner": 200,
"rOuter": 250,
"colorRef": "水", -- 使用"水"配色
"innerFill": 0, -- 不显示内缩
"start": 1, -- 从第1个扇区开始
"num": 2, -- 连续着色2个扇区
"interval": 2, -- 着色后间隔2个扇区
"sectors": [
{ "content": "壬" }, { "content": "子" }, { "content": "癸" },
{ "content": "丑" }, { "content": "艮" }, { "content": "寅" },
{ "content": "甲" }, { "content": "卯" }, { "content": "乙" },
{ "content": "辰" }, { "content": "巽" }, { "content": "巳" },
{ "content": "丙" }, { "content": "午" }, { "content": "丁" },
{ "content": "未" }, { "content": "坤" }, { "content": "申" },
{ "content": "庚" }, { "content": "酉" }, { "content": "辛" },
{ "content": "戌" }, { "content": "乾" }, { "content": "亥" }
]
},
-- ========================================
-- 第6层五行 (5等分)
-- ========================================
-- 演示:每个扇区单独指定 colorRef优先级最高
{
"divisions": 5,
"rInner": 250,
"rOuter": 300,
"sectors": [
{ "content": "木", "colorRef": "Wood", "innerFill": 1 }, -- 单独指定木色
{ "content": "火", "colorRef": "Fire", "innerFill": 1 }, -- 单独指定火色
{ "content": "土", "colorRef": "Earth", "innerFill": 0 }, -- 单独指定土色
{ "content": "金", "colorRef": "Metal", "innerFill": 1 }, -- 单独指定金色
{ "content": "水", "colorRef": "Water", "innerFill": 1 } -- 单独指定水色
]
},
-- ========================================
-- 第7层8等分示例
-- ========================================
-- 演示:规律填色 - 交替着色1个着色1个间隔
-- 着色规律start=1, num=1, interval=1
-- 效果:奇数扇区着色,偶数扇区空白
{
"divisions": 8,
"rInner": 300,
"rOuter": 360,
"colorRef": "热", -- 使用"热"配色(暖橙色)
"innerFill": 1, -- 显示内缩
"start": 1, -- 从第1个扇区开始
"num": 1, -- 连续着色1个扇区
"interval": 1, -- 着色后间隔1个扇区形成交替效果
"sectors": [
{ "content": "1" }, { "content": "2" }, { "content": "3" },
{ "content": "4" }, { "content": "5" }, { "content": "6" },
{ "content": "7" }, { "content": "8" }
]
},
-- ========================================
-- 第8层16等分示例
-- ========================================
-- 演示:规律填色 - 4个着色2个间隔从第3个开始
-- 着色规律start=3, num=4, interval=2
-- 效果扇区3-6着色7-8空白9-12着色13-14空白15-18着色……
{
"divisions": 16,
"rInner": 360,
"rOuter": 430,
"colorRef": "冷", -- 使用"冷"配色(冷蓝色)
"innerFill": 0, -- 不显示内缩
"start": 3, -- 从第3个扇区开始
"num": 4, -- 连续着色4个扇区
"interval": 2 -- 着色后间隔2个扇区
},
-- ========================================
-- 第9层8等分示例
-- ========================================
-- 演示:规律填色 - 连续着色(无间隔)
-- 着色规律start=1, num=2, interval=0
-- 效果:全部扇区都着色(因为 interval=0会连续循环
{
"divisions": 8,
"rInner": 430,
"rOuter": 480,
"colorRef": "强", -- 使用"强"配色(强烈红)
"innerFill": 1, -- 显示内缩
"start": 1, -- 从第1个扇区开始
"num": 2, -- 连续着色2个扇区
"interval": 0 -- 0=无间隔,全部着色
},
-- ========================================
-- 第10层12等分示例最外层
-- ========================================
-- 演示:规律填色 - 1个着色2个间隔
-- 着色规律start=1, num=1, interval=2
-- 效果扇区1着色2-3空白4着色5-6空白7着色……
{
"divisions": 12,
"rInner": 480,
"rOuter": 500,
"colorRef": "软", -- 使用"软"配色(柔和杏)
"innerFill": 0, -- 不显示内缩
"start": 1, -- 从第1个扇区开始
"num": 1, -- 连续着色1个扇区
"interval": 2 -- 着色后间隔2个扇区
}
]
}
-- ========================================
-- 扇区背景色着色优先级总结
-- ========================================
-- 优先级1最高sectors 中单独指定 colorRef
-- 示例:{ "content": "木", "colorRef": "木" }
--
-- 优先级2层级规律填色配置
-- 通过 colorRef + start + num + interval 实现规律着色
-- 未着色的扇区使用全局 background 颜色
--
-- ========================================
-- 规律填色算法说明
-- ========================================
-- start: 起始扇区索引1-based从1开始计数
-- num: 连续着色扇区数量
-- interval: 着色后间隔的扇区数量
--
-- 算法:循环执行 "着色num个" → "跳过interval个"
-- 特殊情况interval=0 表示无间隔,全部着色
--
-- 示例divisions=12, start=2, num=3, interval=1
-- 扇区1: 空白start之前
-- 扇区2-4: 着色num=3
-- 扇区5: 空白interval=1
-- 扇区6-8: 着色num=3
-- 扇区9: 空白interval=1
-- 扇区10-12: 着色num=3
-- ========================================

22
runit.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/bin/bash
git pull origin main
# 自动添加所有修改
git add .
# 如果没有提交信息,默认用时间戳
msg="update at $(date '+%Y-%m-%d %H:%M:%S')"
# 支持自定义提交信息:./run.sh "your message"
if [ $# -gt 0 ]; then
msg="$*"
fi
# 提交
git commit -m "$msg"
# 推送到远程 main 分支
git push origin main

340
src/Luopan.vue Normal file
View File

@@ -0,0 +1,340 @@
<template>
<div class="luopan-wrap">
<!-- 工具栏 -->
<div class="toolbar">
<button
v-for="(ex, idx) in examples"
:key="idx"
:class="{ active: idx === exampleIndex }"
@click="exampleIndex = idx"
>
示例 {{ idx + 1 }}{{ ex.name }}
</button>
<label class="toggle">
<input type="checkbox" v-model="showGuides" />
显示辅助线
</label>
<label class="toggle">
<select v-model="textRadialPosition">
<option value="middle">文字位置中点</option>
<option value="centroid">文字位置形心</option>
</select>
</label>
<div class="zoom-controls">
<button @click="zoomOut" :disabled="scale <= 0.5" title="缩小"></button>
<span class="zoom-level">{{ Math.round(scale * 100) }}%</span>
<button @click="zoomIn" :disabled="scale >= 5" title="放大">+</button>
<button @click="resetZoom" title="重置">重置</button>
</div>
</div>
<!-- SVG 画布容器 -->
<div
class="svg-container"
@wheel.prevent="handleWheel"
@mousedown="handleMouseDown"
@mousemove="handleMouseMove"
@mouseup="handleMouseUp"
@mouseleave="handleMouseUp"
>
<svg
:width="size"
:height="size"
:viewBox="`${-size / 2} ${-size / 2} ${size} ${size}`"
class="svg"
:style="{
transform: `scale(${scale}) translate(${panX}px, ${panY}px)`,
cursor: isDragging ? 'grabbing' : 'grab'
}"
>
<!-- 背景 -->
<rect
:x="-size / 2"
:y="-size / 2"
:width="size"
:height="size"
fill="white"
/>
<!-- 扇区 -->
<g>
<path
v-for="s in sectors"
:key="s.key"
:d="s.path"
:fill="s.fill"
stroke="#1f2937"
stroke-opacity="0.15"
:stroke-width="SECTOR_STROKE_WIDTH"
/>
</g>
<!-- 内部填色区域 -->
<g>
<template v-for="s in sectors" :key="s.key + '-inner'">
<path
v-if="s.innerFillPath"
:d="s.innerFillPath"
:fill="s.innerFillColor"
fill-opacity="0.6"
stroke="none"
/>
</template>
</g>
<!-- 辅助线圆环分度线 -->
<g v-if="showGuides" stroke="#111827" stroke-opacity="0.18">
<!-- 圆环 -->
<circle
v-for="r in rings"
:key="'ring-' + r"
:r="r"
fill="none"
:stroke-width="SECTOR_STROKE_WIDTH"
/>
<!-- 径向线 -->
<line
v-for="a in anglesDeg"
:key="'ang-' + a"
:x1="0"
:y1="0"
:x2="toXY(a, outerMost).x"
:y2="toXY(a, outerMost).y"
:stroke-width="SECTOR_STROKE_WIDTH"
/>
</g>
<!-- 定义文字路径 -->
<defs>
<path
v-for="s in sectors"
:key="s.textPathId"
:id="s.textPathId"
:d="s.textPath"
fill="none"
/>
</defs>
<!-- 文字标签沿圆弧排列 -->
<g>
<text
v-for="s in sectors"
:key="s.key + '-label'"
:font-size="s.fontSize"
:fill="s.textColor"
:writing-mode="s.isVertical ? 'tb' : undefined"
:glyph-orientation-vertical="s.isVertical ? '0' : undefined"
:text-anchor="s.isVertical ? 'middle' : undefined"
style="user-select: none"
>
<textPath
:href="'#' + s.textPathId"
startOffset="50%"
text-anchor="middle"
dominant-baseline="central"
>
{{ s.label }}
</textPath>
</text>
<!-- 可选画一个小点看形心位置 -->
<circle
v-if="showGuides"
v-for="s in sectors"
:key="s.key + '-center'"
:cx="s.cx"
:cy="s.cy"
r="2.2"
fill="#ef4444"
opacity="0.8"
/>
</g>
</svg>
</div>
<!-- 说明 -->
<div class="note">
<div>角度约定0°在北上方顺时针为正SVG 坐标 y 向下</div>
<div>文字方向沿圆弧排列带有弧度头朝外</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useLuopan } from './composables/useLuopan';
import { EXAMPLES, DEFAULT_SIZE, DEFAULT_TEXT_RADIAL_POSITION, SECTOR_STROKE_WIDTH } from './constants';
import type { TextRadialPosition } from './types';
/**
* Props
*/
interface Props {
size?: number;
}
const props = withDefaults(defineProps<Props>(), {
size: DEFAULT_SIZE,
});
/**
* 状态
*/
const showGuides = ref(true);
const exampleIndex = ref(0);
const examples = EXAMPLES;
const textRadialPosition = ref<TextRadialPosition>(DEFAULT_TEXT_RADIAL_POSITION);
// 缩放和平移状态
const scale = ref(1);
const panX = ref(0);
const panY = ref(0);
const isDragging = ref(false);
const dragStartX = ref(0);
const dragStartY = ref(0);
const dragStartPanX = ref(0);
const dragStartPanY = ref(0);
/**
* 当前示例
*/
const currentExample = computed(() => examples[exampleIndex.value]);
/**
* 使用罗盘逻辑
*/
const { anglesDeg, rings, outerMost, sectors, getLabelTransform, toXY } =
useLuopan(currentExample, textRadialPosition);
/**
* 缩放功能
*/
const zoomIn = () => {
if (scale.value < 5) {
scale.value = Math.min(5, scale.value + 0.2);
}
};
const zoomOut = () => {
if (scale.value > 0.5) {
scale.value = Math.max(0.5, scale.value - 0.2);
}
};
const resetZoom = () => {
scale.value = 1;
panX.value = 0;
panY.value = 0;
};
const handleWheel = (e: WheelEvent) => {
const delta = e.deltaY > 0 ? -0.1 : 0.1;
const newScale = Math.max(0.5, Math.min(5, scale.value + delta));
scale.value = newScale;
};
const handleMouseDown = (e: MouseEvent) => {
isDragging.value = true;
dragStartX.value = e.clientX;
dragStartY.value = e.clientY;
dragStartPanX.value = panX.value;
dragStartPanY.value = panY.value;
};
const handleMouseMove = (e: MouseEvent) => {
if (isDragging.value) {
const dx = (e.clientX - dragStartX.value) / scale.value;
const dy = (e.clientY - dragStartY.value) / scale.value;
panX.value = dragStartPanX.value + dx;
panY.value = dragStartPanY.value + dy;
}
};
const handleMouseUp = () => {
isDragging.value = false;
};
</script>
<style scoped>
.luopan-wrap {
display: grid;
gap: 12px;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto,
Helvetica, Arial;
}
.toolbar {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
button {
padding: 6px 10px;
border: 1px solid #d1d5db;
border-radius: 10px;
background: #fff;
cursor: pointer;
transition: all 0.2s;
}
button:hover {
background: #f9fafb;
}
button.active {
border-color: #111827;
background: #f3f4f6;
}
.toggle {
margin-left: 10px;
display: inline-flex;
gap: 8px;
align-items: center;
color: #374151;
font-size: 14px;
cursor: pointer;
}
.zoom-controls {
margin-left: auto;
display: flex;
gap: 8px;
align-items: center;
}
.zoom-level {
min-width: 50px;
text-align: center;
font-size: 13px;
color: #374151;
font-weight: 500;
}
.svg-container {
border: 1px solid #e5e7eb;
border-radius: 14px;
background: #f9fafb;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.svg {
background: #fff;
transition: transform 0.1s ease-out;
user-select: none;
}
.note {
color: #6b7280;
font-size: 13px;
line-height: 1.4;
}
</style>

View File

@@ -0,0 +1,106 @@
/**
* 罗盘业务逻辑组合函数
*/
import { computed, type Ref, type ComputedRef } from 'vue';
import type { Example, Sector, TextRadialPosition } from '../types';
import {
polarToXY,
calculateLabelRotation,
generateSectorData,
} from '../utils';
/**
* 罗盘逻辑 Hook
* @param exampleRef 当前示例的响应式引用
* @param textRadialPositionRef 文字径向位置的响应式引用
* @returns 罗盘相关的计算属性和方法
*/
export function useLuopan(
exampleRef: Ref<Example>,
textRadialPositionRef: Ref<TextRadialPosition>
) {
/**
* 角度分割点列表
*/
const anglesDeg = computed(() => exampleRef.value.angles);
/**
* 圆环半径列表
*/
const rings = computed(() => exampleRef.value.radii);
/**
* 最外层半径
*/
const outerMost = computed(() => {
const radii = exampleRef.value.radii;
return radii[radii.length - 1];
});
/**
* 生成所有扇区数据
*/
const sectors = computed<Sector[]>(() => {
const res: Sector[] = [];
const A = exampleRef.value.angles;
const R = exampleRef.value.radii;
const layerCount = R.length;
const pieCount = A.length - 1;
for (let j = 0; j < layerCount; j++) {
const rInner = j === 0 ? 0 : R[j - 1];
const rOuter = R[j];
for (let i = 0; i < pieCount; i++) {
const aStart = A[i];
const aEnd = A[i + 1];
const sector = generateSectorData({
layerIndex: j,
pieIndex: i,
rInner,
rOuter,
aStart,
aEnd,
layerCount,
pieCount,
textRadialPosition: textRadialPositionRef.value,
});
res.push(sector);
}
}
return res;
});
/**
* 计算标签的变换属性
* @param s 扇区数据
* @returns SVG transform 字符串
*/
const getLabelTransform = (s: Sector): string => {
const rotDeg = calculateLabelRotation(s.aMidDeg);
return `translate(${s.cx} ${s.cy}) rotate(${rotDeg})`;
};
/**
* 极坐标转 XY暴露给模板使用
*/
const toXY = polarToXY;
return {
anglesDeg,
rings,
outerMost,
sectors,
getLabelTransform,
toXY,
};
}
/**
* 返回类型定义
*/
export type UseLuopanReturn = ReturnType<typeof useLuopan>;

128
src/constants.ts Normal file
View File

@@ -0,0 +1,128 @@
/**
* 常量和示例配置
*/
import type { Example, TextRadialPosition } from './types';
/**
* 默认 SVG 画布大小
*/
export const DEFAULT_SIZE = 520;
/**
* 默认文字径向位置
*/
export const DEFAULT_TEXT_RADIAL_POSITION: TextRadialPosition = 'middle';
/**
* 扇区填色内缩距离(像素)
*/
export const SECTOR_INSET_DISTANCE = 1;
/**
* 扇区边界线宽度(像素)
*/
export const SECTOR_STROKE_WIDTH = 0.3;
/**
* 文字布局配置常量
* 用于计算扇区内文字的字体大小和位置
*/
export const TEXT_LAYOUT_CONFIG = {
/** 字符间距系数:字符实际占用高度 = fontSize × 此系数(包含字符本身+行间距) */
CHAR_SPACING_RATIO: 1.2,
/** 径向留白比例在径向方向圆环宽度方向预留的空白比例0.8 表示文字占用 80%,留白 20% */
RADIAL_PADDING_RATIO: 0.9,
/** 切向留白比例在切向方向沿弧线方向预留的空白比例0.85 表示文字占用 85%,留白 15% */
TANGENT_PADDING_RATIO: 0.9,
/** 小角度扇区字体缩放配置:角度较小时弧线弯曲明显,需要缩小字体以防止视觉溢出 */
SMALL_ANGLE_SCALE: {
/** 极小角度阈值(度):小于此角度时应用极小缩放 */
TINY_THRESHOLD: 15,
/** 极小角度缩放比例:字体大小乘以此比例 */
TINY_SCALE: 0.7,
/** 小角度阈值(度):介于极小和此值之间时应用小角度缩放 */
SMALL_THRESHOLD: 30,
/** 小角度缩放比例:字体大小乘以此比例 */
SMALL_SCALE: 0.85,
},
/** 字体大小限制 */
FONT_SIZE: {
/** 最小字体大小(像素) */
MIN: 2,
/** 最大字体大小(像素) */
MAX: 20,
},
/** 竖排文字配置 */
VERTICAL_TEXT: {
/** 最少字符数 */
MIN_CHARS: 1,
/** 最多字符数 */
MAX_CHARS: 4,
},
/** 横排文字配置 */
HORIZONTAL_TEXT: {
/** 最少字符数 */
MIN_CHARS: 1,
/** 最多字符数 */
MAX_CHARS: 6,
},
} as const;
/**
* 预设示例数据
*/
export const EXAMPLES: Example[] = [
{
name: "12 等分 × 3 层圆环",
angles: Array.from({ length: 13 }, (_, i) => i * 30), // 0..360
radii: [60, 120, 180],
},
{
name: "不等分 × 4 层圆环",
angles: [0, 18, 55, 92, 140, 175, 225, 260, 305, 360],
radii: [50, 95, 140, 190],
},
{
name: "24 等分 × 2 层(更密)",
angles: Array.from({ length: 25 }, (_, i) => i * 15), // 0..360
radii: [100, 190],
},
{
name: "8 等分 × 5 层圆环",
angles: Array.from({ length: 9 }, (_, i) => i * 45), // 0..360
radii: [40, 75, 110, 145, 180],
},
{
name: "16 等分 × 4 层",
angles: Array.from({ length: 17 }, (_, i) => i * 22.5), // 0..360
radii: [50, 95, 140, 185],
},
{
name: "24 等分 × 6 层(密集)",
angles: Array.from({ length: 25 }, (_, i) => i * 15), // 0..360
radii: [30, 60, 90, 120, 150, 180],
},
{
name: "36 等分 × 3 层(精细)",
angles: Array.from({ length: 37 }, (_, i) => i * 10), // 0..360
radii: [70, 135, 200],
},
{
name: "6 等分 × 8 层(多层)",
angles: Array.from({ length: 7 }, (_, i) => i * 60), // 0..360
radii: [25, 48, 71, 94, 117, 140, 163, 186],
},
{
name: "24 等分 × 31 层",
angles: Array.from({ length: 25 }, (_, i) => i * 360/24), // 0..360
radii: Array.from({ length: 31 }, (_, i) => 15 + i * 6), // 10, 16, 22, ..., 190
},
];

36
src/index.ts Normal file
View File

@@ -0,0 +1,36 @@
/**
* 罗盘组件库入口文件
*/
// 类型导出
export type {
Example,
Sector,
PolarPoint,
AnnularSectorParams,
CentroidResult,
} from './types';
// 组件导出
export { default as Luopan } from './Luopan.vue';
// 工具函数导出
export {
polarToXY,
normalizeDeg,
annularSectorCentroid,
annularSectorPath,
annularSectorInsetPath,
calculateLabelRotation,
generateSectorColor,
generateTextPath,
generateVerticalTextPath,
getTextColorForBackground,
} from './utils';
// Composables 导出
export { useLuopan } from './composables/useLuopan';
export type { UseLuopanReturn } from './composables/useLuopan';
// 常量导出
export { EXAMPLES, DEFAULT_SIZE, SECTOR_INSET_DISTANCE } from './constants';

98
src/types.ts Normal file
View File

@@ -0,0 +1,98 @@
/**
* 罗盘类型定义
*/
/**
* 文字径向位置
*/
export type TextRadialPosition = 'centroid' | 'middle';
/**
* 示例配置
*/
export interface Example {
/** 示例名称 */
name: string;
/** 角度分割点北为0顺时针必须从0开始并以360结束 */
angles: number[];
/** 圆环半径列表(从中心到外:比如 [40, 80, 120] 表示3层第一层内半径=0 */
radii: number[];
}
/**
* 扇区配置
*/
export interface Sector {
/** 唯一标识 */
key: string;
/** 层索引 */
layerIndex: number;
/** 扇区索引 */
pieIndex: number;
/** 内半径 */
rInner: number;
/** 外半径 */
rOuter: number;
/** 起始角度(度) */
aStart: number;
/** 结束角度(度) */
aEnd: number;
/** 中间角度(度) */
aMidDeg: number;
/** 中间角度(弧度) */
aMidRad: number;
/** 形心 x 坐标 */
cx: number;
/** 形心 y 坐标 */
cy: number;
/** 填充颜色 */
fill: string;
/** 文字颜色 */
textColor: string;
/** 标签文本 */
label: string;
/** SVG 路径 */
path: string;
/** 内缩填色路径(可选)*/
innerFillPath?: string;
/** 填色颜色(可选)*/
innerFillColor?: string;
/** 文字路径 */
textPath: string;
/** 文字路径ID */
textPathId: string;
/** 字体大小(根据扇区尺寸动态计算) */
fontSize: number;
/** 是否竖排文字 */
isVertical: boolean;
}
/**
* 极坐标点
*/
export interface PolarPoint {
x: number;
y: number;
}
/**
* 圆环扇形参数
*/
export interface AnnularSectorParams {
rInner: number;
rOuter: number;
aStartDeg: number;
aEndDeg: number;
}
/**
* 圆环扇形形心结果
*/
export interface CentroidResult {
cx: number;
cy: number;
rho: number;
aMidDeg: number;
aMidRad: number;
deltaDeg: number;
}

656
src/utils.ts Normal file
View File

@@ -0,0 +1,656 @@
/**
* 罗盘工具函数
* 所有函数都是纯函数,便于测试
*/
import type { PolarPoint, AnnularSectorParams, CentroidResult } from './types';
import { TEXT_LAYOUT_CONFIG } from './constants';
/**
* 极坐标转 SVG 坐标
* 约定:角度 aDeg0°在北上方顺时针为正
* SVG坐标x右正y下正
* @param aDeg 角度(度)
* @param r 半径
* @returns SVG 坐标点
*/
export function polarToXY(aDeg: number, r: number): PolarPoint {
const a = (aDeg * Math.PI) / 180;
return {
x: r * Math.sin(a),
y: -r * Math.cos(a)
};
}
/**
* 角度归一化到 [0, 360) 范围
* @param deg 角度
* @returns 归一化后的角度
*/
export function normalizeDeg(deg: number): number {
const d = deg % 360;
return d < 0 ? d + 360 : d;
}
/**
* 圆环扇形形心(用于放文字/图标)
* 输入rInner, rOuter, aStartDeg, aEndDeg
* 输出:形心坐标 (cx, cy) + 中心方向角 aMiddeg/rad
* @param params 扇形参数
* @returns 形心结果
*/
export function annularSectorCentroid(params: AnnularSectorParams): CentroidResult {
const { rInner, rOuter, aStartDeg, aEndDeg } = params;
const a1 = normalizeDeg(aStartDeg);
const a2 = normalizeDeg(aEndDeg);
let deltaDeg = a2 - a1;
if (deltaDeg < 0) deltaDeg += 360;
const aMidDeg = normalizeDeg(a1 + deltaDeg / 2);
const aMidRad = (aMidDeg * Math.PI) / 180;
if (rOuter <= rInner || deltaDeg === 0) {
return { cx: 0, cy: 0, rho: 0, aMidDeg, aMidRad, deltaDeg };
}
// rho = (2/3) * (r2^3 - r1^3)/(r2^2 - r1^2) * sinc(delta/2)
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;
const p = polarToXY(aMidDeg, rho);
return { cx: p.x, cy: p.y, rho, aMidDeg, aMidRad, deltaDeg };
}
/**
* 生成圆环扇形路径SVG path
* @param rInner 内半径
* @param rOuter 外半径
* @param aStartDeg 起始角度(度)
* @param aEndDeg 结束角度(度)
* @returns SVG path 字符串
*/
export function annularSectorPath(
rInner: number,
rOuter: number,
aStartDeg: number,
aEndDeg: number
): string {
const a1 = normalizeDeg(aStartDeg);
const a2 = normalizeDeg(aEndDeg);
let delta = a2 - a1;
if (delta < 0) delta += 360;
// SVG arc flags
const largeArc = delta > 180 ? 1 : 0;
const sweepOuter = 1;
const sweepInner = 0;
const p1 = polarToXY(a1, rOuter);
const p2 = polarToXY(a2, rOuter);
const p3 = polarToXY(a2, rInner);
const p4 = polarToXY(a1, rInner);
// 如果 rInner=0内弧退化为点
if (rInner <= 0.000001) {
return [
`M ${p1.x} ${p1.y}`,
`A ${rOuter} ${rOuter} 0 ${largeArc} ${sweepOuter} ${p2.x} ${p2.y}`,
`L 0 0`,
"Z",
].join(" ");
}
return [
`M ${p1.x} ${p1.y}`,
`A ${rOuter} ${rOuter} 0 ${largeArc} ${sweepOuter} ${p2.x} ${p2.y}`,
`L ${p3.x} ${p3.y}`,
`A ${rInner} ${rInner} 0 ${largeArc} ${sweepInner} ${p4.x} ${p4.y}`,
"Z",
].join(" ");
}
/**
* 生成内缩的圆环扇形路径(用于填色区域)
* 实现所有边界的等距离内缩
* @param rInner 内半径
* @param rOuter 外半径
* @param aStartDeg 起始角度(度)
* @param aEndDeg 结束角度(度)
* @param inset 内缩距离(像素)
* @returns SVG path 字符串
*/
export function annularSectorInsetPath(
rInner: number,
rOuter: number,
aStartDeg: number,
aEndDeg: number,
inset: number = 2
): string {
const a1 = normalizeDeg(aStartDeg);
const a2 = normalizeDeg(aEndDeg);
let delta = a2 - a1;
if (delta < 0) delta += 360;
// 半径方向内缩
const rInnerInset = rInner + inset;
const rOuterInset = rOuter - inset;
// 如果内缩后内外半径重叠,返回空路径
if (rInnerInset >= rOuterInset) return '';
// 角度方向内缩:在外圆上内缩 inset 距离
// 弧长 = 半径 × 角度(弧度),所以角度偏移 = 弧长 / 半径
const angleInsetRadOuter = inset / rOuterInset;
const angleInsetDegOuter = (angleInsetRadOuter * 180) / Math.PI;
// 角度方向内缩:在内圆上内缩 inset 距离
let angleInsetDegInner = 0;
if (rInnerInset > 0.1) {
// 正常的圆环或从圆心开始但内缩后有半径的扇区
const angleInsetRadInner = inset / rInnerInset;
angleInsetDegInner = (angleInsetRadInner * 180) / Math.PI;
} else {
// 内缩后半径接近0使用外圆的角度偏移
angleInsetDegInner = angleInsetDegOuter;
}
// 计算新的角度范围(使用外圆的角度偏移作为主要参考)
const aStartInset = a1 + angleInsetDegOuter;
const aEndInset = a2 - angleInsetDegOuter;
// 如果角度内缩后重叠,返回空路径
let deltaInset = aEndInset - aStartInset;
if (deltaInset < 0) deltaInset += 360;
if (deltaInset <= 0) return '';
// 生成内缩路径
const largeArc = deltaInset > 180 ? 1 : 0;
// 外圆:使用外圆的角度偏移
const p1Outer = polarToXY(aStartInset, rOuterInset);
const p2Outer = polarToXY(aEndInset, rOuterInset);
// 内圆:计算内圆的实际角度
const aStartInner = a1 + angleInsetDegInner;
const aEndInner = a2 - angleInsetDegInner;
const p1Inner = polarToXY(aStartInner, rInnerInset);
const p2Inner = polarToXY(aEndInner, rInnerInset);
// 统一使用相同的路径结构,无论是否从圆心开始
return [
`M ${p1Outer.x} ${p1Outer.y}`,
`A ${rOuterInset} ${rOuterInset} 0 ${largeArc} 1 ${p2Outer.x} ${p2Outer.y}`,
`L ${p2Inner.x} ${p2Inner.y}`,
`A ${rInnerInset} ${rInnerInset} 0 ${largeArc} 0 ${p1Inner.x} ${p1Inner.y}`,
"Z",
].join(" ");
}
/**
* 计算文字旋转角度
* - 文字沿径向方向rot = aMid头朝外、脚朝圆心
* - 为避免倒着读:当角度在 (180°, 360°) 之间时翻转 180°
* @param aMidDeg 中间角度(度)
* @returns 旋转角度
*/
export function calculateLabelRotation(aMidDeg: number): number {
let rotDeg = aMidDeg;
if (aMidDeg > 180 && aMidDeg < 360) {
rotDeg += 180;
}
return rotDeg;
}
/**
* 生成文字路径的圆弧(用于 textPath
* @param rInner 内半径
* @param rOuter 外半径
* @param aStartDeg 起始角度(度)
* @param aEndDeg 结束角度(度)
* @param textRadialPosition 文字径向位置:'centroid'(形心)或 'middle'(中点,默认)
* @returns SVG path 字符串
*/
export function generateTextPath(
rInner: number,
rOuter: number,
aStartDeg: number,
aEndDeg: number,
textRadialPosition: 'centroid' | 'middle' = 'middle'
): string {
// 根据配置选择径向位置
let rMid: number;
if (textRadialPosition === 'centroid') {
// 计算形心半径
const centroid = annularSectorCentroid({ rInner, rOuter, aStartDeg, aEndDeg });
rMid = centroid.rho;
} else {
// 使用几何中点
rMid = (rInner + rOuter) / 2;
}
// 不调整半径,保持在中线位置
// 使用 dominant-baseline 属性来控制文字的垂直对齐
const adjustedRMid = rMid;
const a1 = normalizeDeg(aStartDeg);
const a2 = normalizeDeg(aEndDeg);
let delta = a2 - a1;
if (delta < 0) delta += 360;
// 计算中间角度,自动判断是否需要反向
const aMidDeg = normalizeDeg(a1 + delta / 2);
const needReverse = aMidDeg > 90 && aMidDeg < 270;
const largeArc = delta > 180 ? 1 : 0;
// 保持路径完整,不在这里应用 padding
// padding 通过字体大小计算和 textPath 的 startOffset/text-anchor 来实现
if (needReverse) {
// 反向路径(从结束点到起始点),保持文字头朝外
const p1 = polarToXY(a2, adjustedRMid);
const p2 = polarToXY(a1, adjustedRMid);
return `M ${p1.x} ${p1.y} A ${adjustedRMid} ${adjustedRMid} 0 ${largeArc} 0 ${p2.x} ${p2.y}`;
} else {
// 正向路径(从起始点到结束点)
const p1 = polarToXY(a1, adjustedRMid);
const p2 = polarToXY(a2, adjustedRMid);
return `M ${p1.x} ${p1.y} A ${adjustedRMid} ${adjustedRMid} 0 ${largeArc} 1 ${p2.x} ${p2.y}`;
}
}
/**
* 生成竖排文字路径(径向方向)
* 用于宽度小于高度的扇区
* @param rInner 内半径
* @param rOuter 外半径
* @param aStartDeg 起始角度(度)
* @param aEndDeg 结束角度(度)
* @param aMidDeg 中心角度
* @param textRadialPosition 文字径向位置:'centroid'(形心)或 'middle'(中点,默认)
* @param fontSize 字体大小
* @param textLength 文字字符数用于计算路径长度默认4
* @returns SVG path 字符串(径向直线)
*/
/**
* 生成竖排文字路径(径向方向)
* 用于宽度小于高度的扇区
* @param rInner 内半径
* @param rOuter 外半径
* @param aStartDeg 起始角度(度)
* @param aEndDeg 结束角度(度)
* @param textRadialPosition 文字径向位置:'centroid'(形心)或 'middle'(中点,默认)
* @returns SVG 路径字符串(直线)
*/
export function generateVerticalTextPath(
rInner: number,
rOuter: number,
aStartDeg: number,
aEndDeg: number,
textRadialPosition: 'centroid' | 'middle' = 'middle'
): string {
// 计算中间角度
const a1 = normalizeDeg(aStartDeg);
const a2 = normalizeDeg(aEndDeg);
let delta = a2 - a1;
if (delta < 0) delta += 360;
const aMidDeg = normalizeDeg(a1 + delta / 2);
// 根据配置选择径向位置
let rMid: number;
if (textRadialPosition === 'centroid') {
// 计算形心半径
const centroid = annularSectorCentroid({ rInner, rOuter, aStartDeg, aEndDeg });
rMid = centroid.rho;
} else {
// 使用几何中点
rMid = (rInner + rOuter) / 2;
}
// 计算径向高度和字体大小
const radialHeight = rOuter - rInner;
// 计算字体大小(竖排)
const tempLength = 2; // 先假设2个字
const tempFontSize = calculateSectorFontSize(rInner, rOuter, aStartDeg, aEndDeg, tempLength, 3, 20, true);
// 根据字体大小决定实际字符数
const textLength = calculateVerticalTextLength(rInner, rOuter, tempFontSize);
// 用实际字符数重新计算字体大小
const fontSize = calculateSectorFontSize(rInner, rOuter, aStartDeg, aEndDeg, textLength, 3, 20, true);
// 竖排文字路径:根据扇区特点选择合适的起始位置
// 计算实际需要的路径长度:字符数 × 字符间距系数 × 字体大小
const requiredPathLength = textLength * TEXT_LAYOUT_CONFIG.CHAR_SPACING_RATIO * fontSize;
// 确保路径不超出扇区边界(考虑径向 padding
const maxPathLength = radialHeight * TEXT_LAYOUT_CONFIG.RADIAL_PADDING_RATIO;
const actualPathLength = Math.min(requiredPathLength, maxPathLength);
let finalStartR: number;
let finalEndR: number;
// 对于从圆心开始的扇区(rInner=0),形心会偏向外侧
// 需要特殊处理以防止溢出
if (rInner === 0) {
// 计算路径应该在哪里结束(从外圆向内)
// 使用形心位置作为参考,但确保路径从外圆附近开始
const halfPath = actualPathLength / 2;
// 如果使用形心居中,检查是否会溢出
if (rMid + halfPath > rOuter) {
// 会溢出:改为从外圆向内延伸
finalStartR = rOuter;
finalEndR = rOuter - actualPathLength;
} else {
// 不会溢出:使用形心居中
finalStartR = rMid + halfPath;
finalEndR = rMid - halfPath;
}
} else {
// 普通扇区:以 rMid 为中心
const halfPathLength = actualPathLength / 2;
// 确保不超出边界
const safeHalfPath = Math.min(
halfPathLength,
rMid - rInner,
rOuter - rMid
);
finalStartR = rMid + safeHalfPath;
finalEndR = rMid - safeHalfPath;
}
const p1 = polarToXY(aMidDeg, finalStartR);
const p2 = polarToXY(aMidDeg, finalEndR);
return `M ${p1.x} ${p1.y} L ${p2.x} ${p2.y}`;
}
/**
* 生成扇区颜色
* @param layerIndex 层索引
* @param pieIndex 扇区索引
* @param totalLayers 总层数
* @param totalPies 总扇区数可选默认24
* @returns HSL 颜色字符串
*/
export function generateSectorColor(
layerIndex: number,
pieIndex: number,
totalLayers: number = 10,
totalPies: number = 24
): string {
const hue = (pieIndex * 360) / totalPies;
// 根据总层数动态调整亮度范围
// 最浅85%最深25%
// 使用线性插值,让颜色分布更均匀
const maxLight = 85;
const minLight = 25;
const lightRange = maxLight - minLight;
// 计算当前层的亮度比例0到1
const ratio = totalLayers > 1 ? layerIndex / (totalLayers - 1) : 0;
// 从浅到深:最内层最浅,最外层最深
const light = maxLight - (lightRange * ratio);
return `hsl(${hue} 70% ${Math.round(light)}%)`;
}
/**
* 根据背景颜色亮度计算文字颜色
* 确保文字与背景有足够的对比度
* @param backgroundColor HSL 颜色字符串,如 'hsl(180 70% 48%)'
* @returns 文字颜色(深色或浅色)
*/
export function getTextColorForBackground(backgroundColor: string): string {
// 从 HSL 字符串中提取亮度值
const match = backgroundColor.match(/hsl\([^)]+\s+(\d+)%\)/);
if (!match) return '#111827'; // 默认深色
const lightness = parseInt(match[1]);
// 亮度阈值50%
// 亮度低于50%使用白色文字,否则使用深色文字
return lightness < 50 ? '#ffffff' : '#111827';
}
/**
* 计算扇区的合适字体大小
* 精确根据扇区尺寸和文字长度计算,确保文字不溢出
* @param rInner 内半径
* @param rOuter 外半径
* @param aStartDeg 起始角度(度)
* @param aEndDeg 结束角度(度)
* @param textLength 文字长度(字符数)
* @param minFontSize 最小字体大小(可选,默认 6
* @param maxFontSize 最大字体大小(可选,默认 28
* @param isVertical 是否竖排文字(可选,默认 false
* @returns 计算后的字体大小
*/
export function calculateSectorFontSize(
rInner: number,
rOuter: number,
aStartDeg: number,
aEndDeg: number,
textLength: number = 1,
minFontSize: number = TEXT_LAYOUT_CONFIG.FONT_SIZE.MIN,
maxFontSize: number = TEXT_LAYOUT_CONFIG.FONT_SIZE.MAX,
isVertical: boolean = false
): number {
if (textLength === 0) return minFontSize;
// 计算径向宽度(圆环宽度)
const radialWidth = rOuter - rInner;
// 计算角度跨度
const a1 = normalizeDeg(aStartDeg);
const a2 = normalizeDeg(aEndDeg);
let deltaDeg = a2 - a1;
if (deltaDeg < 0) deltaDeg += 360;
let calculatedSize: number;
if (isVertical) {
// 竖排文字:沿径向排列,从外圆向内圆
// 约束1径向高度所有字符的总高度
const availableHeight = radialWidth * TEXT_LAYOUT_CONFIG.RADIAL_PADDING_RATIO;
const maxByHeight = availableHeight / (textLength * TEXT_LAYOUT_CONFIG.CHAR_SPACING_RATIO);
// 约束2最内侧字符的弧长宽度这是最严格的宽度限制
// 最内侧字符的中心位置大约在 rInner + fontSize/2 处
// 保守估计:假设字体大小约为径向宽度的一半
const estimatedFontSize = radialWidth * 0.5;
const innerMostRadius = rInner + estimatedFontSize / 2;
const innerArcLength = (innerMostRadius * deltaDeg * Math.PI) / 180;
// 字符宽度约为 fontSize × 1.0(方块字)
const availableArcLength = innerArcLength * TEXT_LAYOUT_CONFIG.TANGENT_PADDING_RATIO;
const maxByWidth = availableArcLength / 1.0; // 单个字符宽度
// 取两个约束中的较小值
calculatedSize = Math.min(maxByHeight, maxByWidth);
} else {
// 横排文字:同时受径向宽度和弧长限制
// 计算形心半径处的弧长
const centroid = annularSectorCentroid({ rInner, rOuter, aStartDeg, aEndDeg });
const rMid = centroid.rho;
const arcLength = (rMid * deltaDeg * Math.PI) / 180;
// 1. 高度约束:字体高度不能超过径向宽度
const maxByHeight = radialWidth * TEXT_LAYOUT_CONFIG.RADIAL_PADDING_RATIO;
// 2. 宽度约束:根据文字总宽度计算
// 中文字符宽度 = fontSize方块字
// 字符间距:约 0.1 * fontSize总占用约 1.1 * fontSize
const availableArcLength = arcLength * TEXT_LAYOUT_CONFIG.TANGENT_PADDING_RATIO;
// 反推字体大小fontSize = 可用弧长 / (字符数 × 1.1)
const maxByWidth = availableArcLength / (textLength * 1.1);
// 3. 取宽度和高度约束中较小的那个(更严格的限制)
calculatedSize = Math.min(maxByHeight, maxByWidth);
}
// 对于横排小角度的扇区,需要额外限制(竖排不需要此限制)
if (!isVertical) {
// 当角度很小时,弧线弯曲明显,文字更容易溢出
const { TINY_THRESHOLD, TINY_SCALE, SMALL_THRESHOLD, SMALL_SCALE } = TEXT_LAYOUT_CONFIG.SMALL_ANGLE_SCALE;
if (deltaDeg < TINY_THRESHOLD) {
calculatedSize *= TINY_SCALE;
} else if (deltaDeg < SMALL_THRESHOLD) {
calculatedSize *= SMALL_SCALE;
}
}
// 限制在合理范围内
const finalSize = Math.max(minFontSize, Math.min(maxFontSize, calculatedSize));
return Math.round(finalSize * 10) / 10; // 保留一位小数
}
/**
* 根据扇区径向高度决定竖排文字应显示的字符数
* @param rInner 内半径
* @param rOuter 外半径
* @param fontSize 字体大小
* @returns 应显示的字符数
*/
export function calculateVerticalTextLength(
rInner: number,
rOuter: number,
fontSize: number
): number {
// 从配置中读取字符数范围
const { MIN_CHARS, MAX_CHARS } = TEXT_LAYOUT_CONFIG.VERTICAL_TEXT;
// 计算径向可用高度
const radialHeight = rOuter - rInner;
// 考虑上下padding可用高度约为总高度的配置比例
const availableHeight = radialHeight * TEXT_LAYOUT_CONFIG.RADIAL_PADDING_RATIO;
// 计算可以容纳的字符数
const maxFittableChars = Math.floor(availableHeight / (fontSize * TEXT_LAYOUT_CONFIG.CHAR_SPACING_RATIO));
// 限制在 [MIN_CHARS, MAX_CHARS] 范围内
const charCount = Math.max(MIN_CHARS, Math.min(MAX_CHARS, maxFittableChars));
return charCount;
}
/**
* 生成单个扇区的完整数据
* @param params 扇区参数
* @returns 扇区数据
*/
export function generateSectorData(params: {
layerIndex: number;
pieIndex: number;
rInner: number;
rOuter: number;
aStart: number;
aEnd: number;
layerCount: number;
pieCount: number;
textRadialPosition: 'centroid' | 'middle';
}): any {
const { layerIndex, pieIndex, rInner, rOuter, aStart, aEnd, layerCount, pieCount, textRadialPosition } = params;
const deltaDeg = aEnd - aStart;
const c = annularSectorCentroid({ rInner, rOuter, aStartDeg: aStart, aEndDeg: aEnd });
// 计算扇区尺寸
const radialHeight = rOuter - rInner;
const arcWidth = (c.rho * deltaDeg * Math.PI) / 180;
// 判断是否需要竖排
const isVertical = arcWidth < radialHeight;
// 计算文字长度和字体大小
let textLength: number;
let sectorFontSize: number;
if (isVertical) {
// 竖排逻辑
const tempLength = 2;
const tempFontSize = calculateSectorFontSize(rInner, rOuter, aStart, aEnd, tempLength, 3, 20, true);
textLength = calculateVerticalTextLength(rInner, rOuter, tempFontSize);
sectorFontSize = calculateSectorFontSize(rInner, rOuter, aStart, aEnd, textLength, 3, 20, true);
} else {
// 横排逻辑
const { RADIAL_PADDING_RATIO, CHAR_SPACING_RATIO, TANGENT_PADDING_RATIO } = TEXT_LAYOUT_CONFIG;
const { MIN_CHARS, MAX_CHARS } = TEXT_LAYOUT_CONFIG.HORIZONTAL_TEXT;
const estimatedFontSize = radialHeight * RADIAL_PADDING_RATIO;
const charWidth = estimatedFontSize * CHAR_SPACING_RATIO;
const availableWidth = arcWidth * TANGENT_PADDING_RATIO;
const maxChars = Math.floor(availableWidth / charWidth);
textLength = Math.max(MIN_CHARS, Math.min(MAX_CHARS, maxChars));
sectorFontSize = calculateSectorFontSize(rInner, rOuter, aStart, aEnd, textLength);
}
const label = '测'.repeat(textLength);
const textPathId = `text-path-L${layerIndex}-P${pieIndex}`;
// 最内层使用形心位置
const isInnermostLayer = layerIndex === 0;
const effectiveTextRadialPosition = isInnermostLayer ? 'centroid' : textRadialPosition;
// 生成文字路径(路径方向已经在函数内部自动处理)
const textPath = isVertical
? generateVerticalTextPath(rInner, rOuter, aStart, aEnd, effectiveTextRadialPosition)
: generateTextPath(rInner, rOuter, aStart, aEnd, effectiveTextRadialPosition);
// 生成颜色
const fillColor = generateSectorColor(layerIndex, pieIndex, layerCount, pieCount);
const textColor = getTextColorForBackground(fillColor);
// 内部填色逻辑
const shouldFill = (pieIndex + layerIndex) % 3 === 0;
const innerFillPath = shouldFill ? annularSectorInsetPath(rInner, rOuter, aStart, aEnd, 1) : undefined;
const innerFillColor = shouldFill ? fillColor : undefined;
const baseFillColor = shouldFill ? '#ffffff' : fillColor;
const finalTextColor = shouldFill ? '#111827' : textColor;
return {
key: `L${layerIndex}-P${pieIndex}`,
layerIndex,
pieIndex,
rInner,
rOuter,
aStart,
aEnd,
aMidDeg: c.aMidDeg,
aMidRad: c.aMidRad,
cx: c.cx,
cy: c.cy,
fill: baseFillColor,
textColor: finalTextColor,
label,
path: annularSectorPath(rInner, rOuter, aStart, aEnd),
innerFillPath,
innerFillColor,
textPath,
textPathId,
isVertical,
fontSize: sectorFontSize,
};
}

545
tests/Luopan.test.ts Normal file
View File

@@ -0,0 +1,545 @@
/**
* Luopan 组件单元测试
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import Luopan from '../src/Luopan.vue';
import { EXAMPLES } from '../src/constants';
describe('Luopan 组件', () => {
describe('基本渲染', () => {
it('应该成功渲染', () => {
const wrapper = mount(Luopan);
expect(wrapper.exists()).toBe(true);
});
it('应该渲染工具栏', () => {
const wrapper = mount(Luopan);
const toolbar = wrapper.find('.toolbar');
expect(toolbar.exists()).toBe(true);
});
it('应该渲染 SVG 容器', () => {
const wrapper = mount(Luopan);
const svg = wrapper.find('svg');
expect(svg.exists()).toBe(true);
});
it('应该使用默认尺寸', () => {
const wrapper = mount(Luopan);
const svg = wrapper.find('svg');
expect(svg.attributes('width')).toBeTruthy();
expect(svg.attributes('height')).toBeTruthy();
});
it('应该使用传入的 size prop', () => {
const customSize = 600;
const wrapper = mount(Luopan, {
props: { size: customSize },
});
const svg = wrapper.find('svg');
expect(svg.attributes('width')).toBe(String(customSize));
expect(svg.attributes('height')).toBe(String(customSize));
});
it('应该设置正确的 viewBox', () => {
const size = 520;
const wrapper = mount(Luopan, {
props: { size },
});
const svg = wrapper.find('svg');
const viewBox = svg.attributes('viewBox');
expect(viewBox).toBe(`${-size / 2} ${-size / 2} ${size} ${size}`);
});
});
describe('示例切换', () => {
it('应该渲染示例切换按钮', () => {
const wrapper = mount(Luopan);
const buttons = wrapper.findAll('.toolbar button:not(.zoom-controls button)');
expect(buttons.length).toBeGreaterThanOrEqual(EXAMPLES.length);
});
it('第一个示例按钮应该默认激活', () => {
const wrapper = mount(Luopan);
const firstButton = wrapper.findAll('.toolbar button')[0];
expect(firstButton.classes()).toContain('active');
});
it('点击示例按钮应该切换示例', async () => {
const wrapper = mount(Luopan);
const buttons = wrapper.findAll('.toolbar button');
// 假设至少有2个示例
if (buttons.length >= 2) {
const secondButton = buttons[1];
await secondButton.trigger('click');
expect(secondButton.classes()).toContain('active');
expect(buttons[0].classes()).not.toContain('active');
}
});
});
describe('辅助线显示', () => {
it('应该有辅助线切换开关', () => {
const wrapper = mount(Luopan);
const checkbox = wrapper.find('input[type="checkbox"]');
expect(checkbox.exists()).toBe(true);
});
it('辅助线应该默认显示', () => {
const wrapper = mount(Luopan);
const checkbox = wrapper.find('input[type="checkbox"]');
expect((checkbox.element as HTMLInputElement).checked).toBe(true);
});
it('切换辅助线开关应该显示/隐藏辅助线', async () => {
const wrapper = mount(Luopan);
const checkbox = wrapper.find('input[type="checkbox"]');
// 默认显示辅助线
let guidesGroup = wrapper.findAll('g').find(g => g.attributes('stroke') === '#111827');
expect(guidesGroup).toBeDefined();
// 取消勾选
await checkbox.setValue(false);
guidesGroup = wrapper.findAll('g').find(g => g.attributes('stroke') === '#111827');
// 辅助线可能被隐藏或不渲染
});
});
describe('文字位置模式', () => {
it('应该有文字位置选择器', () => {
const wrapper = mount(Luopan);
const select = wrapper.find('select');
expect(select.exists()).toBe(true);
});
it('选择器应该有两个选项', () => {
const wrapper = mount(Luopan);
const options = wrapper.findAll('option');
expect(options.length).toBe(2);
});
it('应该能切换文字位置模式', async () => {
const wrapper = mount(Luopan);
const select = wrapper.find('select');
// 切换到形心模式
await select.setValue('centroid');
expect((select.element as HTMLSelectElement).value).toBe('centroid');
// 切换回中点模式
await select.setValue('middle');
expect((select.element as HTMLSelectElement).value).toBe('middle');
});
});
describe('缩放功能', () => {
it('应该渲染缩放控件', () => {
const wrapper = mount(Luopan);
const zoomControls = wrapper.find('.zoom-controls');
expect(zoomControls.exists()).toBe(true);
});
it('应该有放大、缩小和重置按钮', () => {
const wrapper = mount(Luopan);
const buttons = wrapper.findAll('.zoom-controls button');
expect(buttons.length).toBe(3);
});
it('应该显示当前缩放级别', () => {
const wrapper = mount(Luopan);
const zoomLevel = wrapper.find('.zoom-level');
expect(zoomLevel.exists()).toBe(true);
expect(zoomLevel.text()).toContain('%');
});
it('点击放大按钮应该增加缩放', async () => {
const wrapper = mount(Luopan);
const zoomInButton = wrapper.findAll('.zoom-controls button')[1];
const svg = wrapper.find('svg');
const initialTransform = svg.attributes('style');
await zoomInButton.trigger('click');
const newTransform = svg.attributes('style');
expect(newTransform).not.toBe(initialTransform);
});
it('点击缩小按钮应该减少缩放', async () => {
const wrapper = mount(Luopan);
const zoomOutButton = wrapper.findAll('.zoom-controls button')[0];
const svg = wrapper.find('svg');
const initialTransform = svg.attributes('style');
await zoomOutButton.trigger('click');
const newTransform = svg.attributes('style');
expect(newTransform).not.toBe(initialTransform);
});
it('点击重置按钮应该重置缩放和平移', async () => {
const wrapper = mount(Luopan);
const buttons = wrapper.findAll('.zoom-controls button');
const zoomInButton = buttons[1];
const resetButton = buttons[2];
const svg = wrapper.find('svg');
// 先放大
await zoomInButton.trigger('click');
// 然后重置
await resetButton.trigger('click');
const transform = svg.attributes('style');
expect(transform).toContain('scale(1)');
});
it('缩放到达上限时应该禁用放大按钮', async () => {
const wrapper = mount(Luopan);
const zoomInButton = wrapper.findAll('.zoom-controls button')[1];
// 多次点击放大直到达到上限
for (let i = 0; i < 30; i++) {
await zoomInButton.trigger('click');
}
expect(zoomInButton.attributes('disabled')).toBeDefined();
});
it('缩放到达下限时应该禁用缩小按钮', async () => {
const wrapper = mount(Luopan);
const zoomOutButton = wrapper.findAll('.zoom-controls button')[0];
// 多次点击缩小直到达到下限
for (let i = 0; i < 10; i++) {
await zoomOutButton.trigger('click');
}
expect(zoomOutButton.attributes('disabled')).toBeDefined();
});
});
describe('鼠标滚轮缩放', () => {
it('应该监听滚轮事件', () => {
const wrapper = mount(Luopan);
const container = wrapper.find('.svg-container');
expect(container.exists()).toBe(true);
});
it('向下滚动应该缩小', async () => {
const wrapper = mount(Luopan);
const container = wrapper.find('.svg-container');
const svg = wrapper.find('svg');
await container.trigger('wheel', { deltaY: 100 });
// 验证缩放变化
const transform = svg.attributes('style');
expect(transform).toBeTruthy();
});
it('向上滚动应该放大', async () => {
const wrapper = mount(Luopan);
const container = wrapper.find('.svg-container');
const svg = wrapper.find('svg');
await container.trigger('wheel', { deltaY: -100 });
// 验证缩放变化
const transform = svg.attributes('style');
expect(transform).toBeTruthy();
});
});
describe('鼠标拖拽平移', () => {
it('鼠标按下时光标应该变为抓取状态', async () => {
const wrapper = mount(Luopan);
const container = wrapper.find('.svg-container');
const svg = wrapper.find('svg');
// 初始状态
expect(svg.attributes('style')).toContain('cursor: grab');
// 鼠标按下
await container.trigger('mousedown', { clientX: 100, clientY: 100 });
// 应该变为抓取中状态
expect(svg.attributes('style')).toContain('cursor: grabbing');
});
it('鼠标释放时光标应该恢复', async () => {
const wrapper = mount(Luopan);
const container = wrapper.find('.svg-container');
const svg = wrapper.find('svg');
await container.trigger('mousedown', { clientX: 100, clientY: 100 });
await container.trigger('mouseup');
expect(svg.attributes('style')).toContain('cursor: grab');
});
it('拖拽应该改变平移位置', async () => {
const wrapper = mount(Luopan);
const container = wrapper.find('.svg-container');
const svg = wrapper.find('svg');
const initialTransform = svg.attributes('style');
// 模拟拖拽
await container.trigger('mousedown', { clientX: 100, clientY: 100 });
await container.trigger('mousemove', { clientX: 150, clientY: 150 });
await container.trigger('mouseup');
const newTransform = svg.attributes('style');
expect(newTransform).not.toBe(initialTransform);
});
it('鼠标离开时应该停止拖拽', async () => {
const wrapper = mount(Luopan);
const container = wrapper.find('.svg-container');
const svg = wrapper.find('svg');
await container.trigger('mousedown', { clientX: 100, clientY: 100 });
await container.trigger('mouseleave');
expect(svg.attributes('style')).toContain('cursor: grab');
});
});
describe('扇区渲染', () => {
it('应该渲染多个扇区路径', () => {
const wrapper = mount(Luopan);
const sectorPaths = wrapper.findAll('path[fill]').filter(path =>
!path.attributes('d')?.includes('none')
);
expect(sectorPaths.length).toBeGreaterThan(0);
});
it('扇区应该有填充颜色', () => {
const wrapper = mount(Luopan);
const sectorPaths = wrapper.findAll('path[fill]');
sectorPaths.forEach((path) => {
const fill = path.attributes('fill');
expect(fill).toBeTruthy();
});
});
it('扇区应该有边框', () => {
const wrapper = mount(Luopan);
const sectorPaths = wrapper.findAll('path[stroke]');
expect(sectorPaths.length).toBeGreaterThan(0);
});
it('某些扇区应该有内部填色', () => {
const wrapper = mount(Luopan);
// 查找内部填色路径fill-opacity="0.6"
const innerFillPaths = wrapper.findAll('path[fill-opacity]');
expect(innerFillPaths.length).toBeGreaterThanOrEqual(0);
});
});
describe('文字渲染', () => {
it('应该渲染文字标签', () => {
const wrapper = mount(Luopan);
const textElements = wrapper.findAll('text');
expect(textElements.length).toBeGreaterThan(0);
});
it('文字应该使用 textPath', () => {
const wrapper = mount(Luopan);
const textPaths = wrapper.findAll('textPath');
expect(textPaths.length).toBeGreaterThan(0);
});
it('应该在 defs 中定义文字路径', () => {
const wrapper = mount(Luopan);
const defs = wrapper.find('defs');
expect(defs.exists()).toBe(true);
const pathsInDefs = defs.findAll('path');
expect(pathsInDefs.length).toBeGreaterThan(0);
});
it('每个 textPath 应该引用对应的路径 id', () => {
const wrapper = mount(Luopan);
const textPaths = wrapper.findAll('textPath');
textPaths.forEach((textPath) => {
const href = textPath.attributes('href');
expect(href).toBeTruthy();
expect(href?.startsWith('#')).toBe(true);
});
});
it('文字应该有字体大小', () => {
const wrapper = mount(Luopan);
const textElements = wrapper.findAll('text');
textElements.forEach((text) => {
const fontSize = text.attributes('font-size');
expect(fontSize).toBeTruthy();
expect(parseFloat(fontSize!)).toBeGreaterThan(0);
});
});
it('文字应该有填充颜色', () => {
const wrapper = mount(Luopan);
const textElements = wrapper.findAll('text');
textElements.forEach((text) => {
const fill = text.attributes('fill');
expect(fill).toBeTruthy();
});
});
it('文字内容应该非空', () => {
const wrapper = mount(Luopan);
const textPaths = wrapper.findAll('textPath');
textPaths.forEach((textPath) => {
expect(textPath.text().length).toBeGreaterThan(0);
});
});
});
describe('辅助线渲染', () => {
it('显示辅助线时应该渲染圆环', async () => {
const wrapper = mount(Luopan);
const checkbox = wrapper.find('input[type="checkbox"]');
await checkbox.setValue(true);
const circles = wrapper.findAll('circle[fill="none"]');
expect(circles.length).toBeGreaterThan(0);
});
it('显示辅助线时应该渲染径向线', async () => {
const wrapper = mount(Luopan);
const checkbox = wrapper.find('input[type="checkbox"]');
await checkbox.setValue(true);
const lines = wrapper.findAll('line');
// 应该有一些径向线
expect(lines.length).toBeGreaterThan(0);
});
it('显示辅助线时应该渲染形心点', async () => {
const wrapper = mount(Luopan);
const checkbox = wrapper.find('input[type="checkbox"]');
await checkbox.setValue(true);
const centroidCircles = wrapper.findAll('circle[fill="#ef4444"]');
expect(centroidCircles.length).toBeGreaterThan(0);
});
});
describe('说明文本', () => {
it('应该显示说明区域', () => {
const wrapper = mount(Luopan);
const note = wrapper.find('.note');
expect(note.exists()).toBe(true);
});
it('说明应该包含角度约定', () => {
const wrapper = mount(Luopan);
const note = wrapper.find('.note');
expect(note.text()).toContain('角度');
});
it('说明应该包含文字方向', () => {
const wrapper = mount(Luopan);
const note = wrapper.find('.note');
expect(note.text()).toContain('文字');
});
});
describe('背景渲染', () => {
it('应该渲染白色背景矩形', () => {
const wrapper = mount(Luopan);
const bgRect = wrapper.find('rect[fill="white"]');
expect(bgRect.exists()).toBe(true);
});
it('背景应该覆盖整个 SVG 区域', () => {
const size = 520;
const wrapper = mount(Luopan, {
props: { size },
});
const bgRect = wrapper.find('rect[fill="white"]');
expect(bgRect.attributes('width')).toBe(String(size));
expect(bgRect.attributes('height')).toBe(String(size));
});
});
describe('组件样式', () => {
it('主容器应该使用 grid 布局', () => {
const wrapper = mount(Luopan);
const wrap = wrapper.find('.luopan-wrap');
expect(wrap.exists()).toBe(true);
});
it('工具栏按钮应该有样式', () => {
const wrapper = mount(Luopan);
const buttons = wrapper.findAll('button');
expect(buttons.length).toBeGreaterThan(0);
});
it('SVG 容器应该存在', () => {
const wrapper = mount(Luopan);
const container = wrapper.find('.svg-container');
expect(container.exists()).toBe(true);
});
});
describe('边界情况', () => {
it('应该处理极小的尺寸', () => {
const wrapper = mount(Luopan, {
props: { size: 100 },
});
expect(wrapper.exists()).toBe(true);
});
it('应该处理极大的尺寸', () => {
const wrapper = mount(Luopan, {
props: { size: 2000 },
});
expect(wrapper.exists()).toBe(true);
});
it('应该在没有示例时不崩溃', () => {
// 这个测试需要 mock EXAMPLES
const wrapper = mount(Luopan);
expect(wrapper.exists()).toBe(true);
});
});
describe('性能', () => {
it('应该在合理时间内渲染', () => {
const startTime = performance.now();
const wrapper = mount(Luopan);
const endTime = performance.now();
expect(endTime - startTime).toBeLessThan(1000); // 应在1秒内完成
});
it('切换示例应该不会造成内存泄漏', async () => {
const wrapper = mount(Luopan);
const buttons = wrapper.findAll('.toolbar button');
if (buttons.length >= 2) {
// 多次切换示例
for (let i = 0; i < 5; i++) {
await buttons[i % buttons.length].trigger('click');
}
}
expect(wrapper.exists()).toBe(true);
});
});
});

117
tests/README.md Normal file
View File

@@ -0,0 +1,117 @@
# 测试文档
本目录包含罗盘项目的所有测试用例。
## 测试文件结构
```
tests/
├── README.md # 本文档
├── constants.test.ts # 常量和配置测试
├── utils.test.ts # 工具函数单元测试
├── useLuopan.test.ts # 组合函数业务逻辑测试
└── Luopan.test.ts # Vue 组件测试
```
## 测试覆盖范围
### 1. constants.test.ts (22 个测试)
测试项目的常量配置和示例数据:
- 默认尺寸、文字位置等配置项的有效性
- 示例数据的完整性和正确性
- 角度数组和半径数组的有效性验证
- 不同类型示例的存在性检查
### 2. utils.test.ts (48 个测试)
测试所有工具函数的核心逻辑:
- **polarToXY**: 极坐标转换
- **normalizeDeg**: 角度归一化
- **annularSectorCentroid**: 扇形形心计算
- **annularSectorPath**: SVG 路径生成
- **annularSectorInsetPath**: 内缩路径生成
- **calculateLabelRotation**: 文字旋转角度
- **generateSectorColor**: 扇区颜色生成
- **generateTextPath**: 文字路径生成
- **generateVerticalTextPath**: 竖排文字路径
- **getTextColorForBackground**: 文字颜色适配
- **calculateSectorFontSize**: 字体大小计算
### 3. useLuopan.test.ts (32 个测试)
测试罗盘业务逻辑组合函数:
- 基本功能和返回值验证
- 扇区生成逻辑
- 文字位置模式middle/centroid
- 文字方向判断
- 竖排文字判断
- 内部填色逻辑
- 响应式更新
- 边界情况处理
### 4. Luopan.test.ts (57 个测试)
测试 Vue 组件的完整功能:
- 组件基本渲染
- 示例切换功能
- 辅助线显示/隐藏
- 文字位置模式选择
- 缩放功能(放大、缩小、重置)
- 鼠标滚轮缩放
- 鼠标拖拽平移
- 扇区渲染
- 文字渲染
- 辅助线渲染
- 说明文本
- 背景渲染
- 样式验证
- 边界情况
- 性能测试
## 运行测试
### 运行所有测试
```bash
npm test
```
### 运行单个测试文件
```bash
npm test tests/utils.test.ts
```
### 运行测试并监视文件变化
```bash
npm test -- --watch
```
### 生成测试覆盖率报告
```bash
npm test -- --coverage
```
## 测试统计
- **总测试文件**: 4 个
- **总测试用例**: 159 个
- **测试通过率**: 100%
- **测试运行时间**: ~1.8 秒
## 测试编写规范
1. **描述清晰**: 每个测试用例的描述应该清楚说明测试的内容
2. **独立性**: 测试用例之间应该相互独立,不依赖执行顺序
3. **覆盖全面**: 包括正常情况、边界情况和异常情况
4. **断言准确**: 使用合适的断言方法,确保测试的准确性
5. **可维护性**: 测试代码应该易于理解和维护
## 技术栈
- **测试框架**: Vitest
- **Vue 测试工具**: @vue/test-utils
- **测试环境**: happy-dom
## 贡献指南
添加新功能时,请确保:
1. 为新功能编写对应的测试用例
2. 确保所有现有测试通过
3. 测试覆盖率不低于当前水平
4. 测试用例描述清晰,易于理解

165
tests/constants.test.ts Normal file
View File

@@ -0,0 +1,165 @@
/**
* 常量和配置单元测试
*/
import { describe, it, expect } from 'vitest';
import {
DEFAULT_SIZE,
DEFAULT_TEXT_RADIAL_POSITION,
SECTOR_INSET_DISTANCE,
SECTOR_STROKE_WIDTH,
EXAMPLES,
} from '../src/constants';
describe('DEFAULT_SIZE', () => {
it('应该是一个正数', () => {
expect(DEFAULT_SIZE).toBeGreaterThan(0);
});
it('应该是一个合理的画布尺寸', () => {
expect(DEFAULT_SIZE).toBeGreaterThanOrEqual(200);
expect(DEFAULT_SIZE).toBeLessThanOrEqual(2000);
});
});
describe('DEFAULT_TEXT_RADIAL_POSITION', () => {
it('应该是有效的文字位置值', () => {
expect(['middle', 'centroid']).toContain(DEFAULT_TEXT_RADIAL_POSITION);
});
});
describe('SECTOR_INSET_DISTANCE', () => {
it('应该是一个非负数', () => {
expect(SECTOR_INSET_DISTANCE).toBeGreaterThanOrEqual(0);
});
it('应该是一个合理的内缩距离', () => {
expect(SECTOR_INSET_DISTANCE).toBeLessThan(10);
});
});
describe('SECTOR_STROKE_WIDTH', () => {
it('应该是一个正数', () => {
expect(SECTOR_STROKE_WIDTH).toBeGreaterThan(0);
});
it('应该是一个合理的线宽', () => {
expect(SECTOR_STROKE_WIDTH).toBeLessThan(5);
});
});
describe('EXAMPLES', () => {
it('应该包含至少一个示例', () => {
expect(EXAMPLES.length).toBeGreaterThan(0);
});
it('每个示例应该有名称', () => {
EXAMPLES.forEach((example) => {
expect(example.name).toBeTruthy();
expect(typeof example.name).toBe('string');
});
});
it('每个示例应该有有效的角度数组', () => {
EXAMPLES.forEach((example) => {
expect(Array.isArray(example.angles)).toBe(true);
expect(example.angles.length).toBeGreaterThanOrEqual(2);
});
});
it('每个示例的角度应该从 0 开始', () => {
EXAMPLES.forEach((example) => {
expect(example.angles[0]).toBe(0);
});
});
it('每个示例的角度应该以 360 结束', () => {
EXAMPLES.forEach((example) => {
expect(example.angles[example.angles.length - 1]).toBe(360);
});
});
it('每个示例的角度应该是递增的', () => {
EXAMPLES.forEach((example) => {
for (let i = 1; i < example.angles.length; i++) {
expect(example.angles[i]).toBeGreaterThan(example.angles[i - 1]);
}
});
});
it('每个示例应该有有效的半径数组', () => {
EXAMPLES.forEach((example) => {
expect(Array.isArray(example.radii)).toBe(true);
expect(example.radii.length).toBeGreaterThanOrEqual(1);
});
});
it('每个示例的半径应该是正数', () => {
EXAMPLES.forEach((example) => {
example.radii.forEach((radius) => {
expect(radius).toBeGreaterThan(0);
});
});
});
it('每个示例的半径应该是递增的', () => {
EXAMPLES.forEach((example) => {
for (let i = 1; i < example.radii.length; i++) {
expect(example.radii[i]).toBeGreaterThan(example.radii[i - 1]);
}
});
});
it('应该包含不同类型的示例(等分和不等分)', () => {
const hasEqualDivision = EXAMPLES.some((example) => {
const angles = example.angles;
if (angles.length < 3) return false;
const step = angles[1] - angles[0];
return angles.every((angle, i) => i === 0 || Math.abs(angle - angles[i - 1] - step) < 0.01);
});
const hasUnequalDivision = EXAMPLES.some((example) => {
const angles = example.angles;
if (angles.length < 3) return false;
const firstStep = angles[1] - angles[0];
return angles.some((angle, i) => i > 1 && Math.abs(angle - angles[i - 1] - firstStep) > 0.01);
});
expect(hasEqualDivision).toBe(true);
expect(hasUnequalDivision).toBe(true);
});
it('应该包含不同层数的示例', () => {
const layerCounts = EXAMPLES.map((example) => example.radii.length);
const uniqueLayerCounts = new Set(layerCounts);
expect(uniqueLayerCounts.size).toBeGreaterThan(1);
});
it('应该包含密集和稀疏的扇区分割', () => {
const sectorCounts = EXAMPLES.map((example) => example.angles.length - 1);
const minSectors = Math.min(...sectorCounts);
const maxSectors = Math.max(...sectorCounts);
expect(maxSectors).toBeGreaterThan(minSectors * 2);
});
it('所有示例的半径都不应超过合理范围', () => {
EXAMPLES.forEach((example) => {
const maxRadius = Math.max(...example.radii);
expect(maxRadius).toBeLessThan(500); // 假设最大半径不超过500
});
});
it('应该有12等分3层的标准示例', () => {
const example = EXAMPLES.find((ex) => ex.name.includes('12') && ex.name.includes('3'));
expect(example).toBeDefined();
if (example) {
expect(example.angles.length).toBe(13); // 0 到 36013个点
expect(example.radii.length).toBe(3);
}
});
it('应该有包含大量层数的示例', () => {
const maxLayers = Math.max(...EXAMPLES.map((ex) => ex.radii.length));
expect(maxLayers).toBeGreaterThanOrEqual(6);
});
});

475
tests/useLuopan.test.ts Normal file
View File

@@ -0,0 +1,475 @@
/**
* useLuopan 组合函数单元测试
*/
import { describe, it, expect } from 'vitest';
import { ref } from 'vue';
import { useLuopan } from '../src/composables/useLuopan';
import type { Example, TextRadialPosition } from '../src/types';
describe('useLuopan', () => {
const createMockExample = (): Example => ({
name: '测试示例',
angles: [0, 90, 180, 270, 360],
radii: [50, 100, 150],
});
describe('基本功能', () => {
it('应该返回所有必需的属性和方法', () => {
const example = ref(createMockExample());
const textRadialPosition = ref<TextRadialPosition>('middle');
const result = useLuopan(example, textRadialPosition);
expect(result).toHaveProperty('anglesDeg');
expect(result).toHaveProperty('rings');
expect(result).toHaveProperty('outerMost');
expect(result).toHaveProperty('sectors');
expect(result).toHaveProperty('getLabelTransform');
expect(result).toHaveProperty('toXY');
});
it('anglesDeg 应该返回角度数组', () => {
const example = ref(createMockExample());
const textRadialPosition = ref<TextRadialPosition>('middle');
const { anglesDeg } = useLuopan(example, textRadialPosition);
expect(anglesDeg.value).toEqual([0, 90, 180, 270, 360]);
});
it('rings 应该返回半径数组', () => {
const example = ref(createMockExample());
const textRadialPosition = ref<TextRadialPosition>('middle');
const { rings } = useLuopan(example, textRadialPosition);
expect(rings.value).toEqual([50, 100, 150]);
});
it('outerMost 应该返回最外层半径', () => {
const example = ref(createMockExample());
const textRadialPosition = ref<TextRadialPosition>('middle');
const { outerMost } = useLuopan(example, textRadialPosition);
expect(outerMost.value).toBe(150);
});
});
describe('扇区生成', () => {
it('应该生成正确数量的扇区', () => {
const example = ref(createMockExample());
const textRadialPosition = ref<TextRadialPosition>('middle');
const { sectors } = useLuopan(example, textRadialPosition);
// 4个角度分割 × 3层 = 12个扇区
expect(sectors.value.length).toBe(12);
});
it('每个扇区应该有必需的属性', () => {
const example = ref(createMockExample());
const textRadialPosition = ref<TextRadialPosition>('middle');
const { sectors } = useLuopan(example, textRadialPosition);
sectors.value.forEach((sector) => {
expect(sector).toHaveProperty('key');
expect(sector).toHaveProperty('layerIndex');
expect(sector).toHaveProperty('pieIndex');
expect(sector).toHaveProperty('rInner');
expect(sector).toHaveProperty('rOuter');
expect(sector).toHaveProperty('aStart');
expect(sector).toHaveProperty('aEnd');
expect(sector).toHaveProperty('aMidDeg');
expect(sector).toHaveProperty('aMidRad');
expect(sector).toHaveProperty('cx');
expect(sector).toHaveProperty('cy');
expect(sector).toHaveProperty('fill');
expect(sector).toHaveProperty('textColor');
expect(sector).toHaveProperty('label');
expect(sector).toHaveProperty('path');
expect(sector).toHaveProperty('textPath');
expect(sector).toHaveProperty('textPathId');
expect(sector).toHaveProperty('needReverse');
expect(sector).toHaveProperty('isVertical');
expect(sector).toHaveProperty('fontSize');
});
});
it('扇区的 key 应该是唯一的', () => {
const example = ref(createMockExample());
const textRadialPosition = ref<TextRadialPosition>('middle');
const { sectors } = useLuopan(example, textRadialPosition);
const keys = sectors.value.map((s) => s.key);
const uniqueKeys = new Set(keys);
expect(uniqueKeys.size).toBe(keys.length);
});
it('扇区应该有正确的层索引和扇区索引', () => {
const example = ref(createMockExample());
const textRadialPosition = ref<TextRadialPosition>('middle');
const { sectors } = useLuopan(example, textRadialPosition);
// 检查第一层的扇区
const layer0Sectors = sectors.value.filter((s) => s.layerIndex === 0);
expect(layer0Sectors.length).toBe(4);
expect(layer0Sectors.map((s) => s.pieIndex)).toEqual([0, 1, 2, 3]);
// 检查第二层的扇区
const layer1Sectors = sectors.value.filter((s) => s.layerIndex === 1);
expect(layer1Sectors.length).toBe(4);
expect(layer1Sectors.map((s) => s.pieIndex)).toEqual([0, 1, 2, 3]);
});
it('第一层扇区应该从圆心开始rInner = 0', () => {
const example = ref(createMockExample());
const textRadialPosition = ref<TextRadialPosition>('middle');
const { sectors } = useLuopan(example, textRadialPosition);
const layer0Sectors = sectors.value.filter((s) => s.layerIndex === 0);
layer0Sectors.forEach((sector) => {
expect(sector.rInner).toBe(0);
expect(sector.rOuter).toBe(50);
});
});
it('扇区应该有正确的角度范围', () => {
const example = ref(createMockExample());
const textRadialPosition = ref<TextRadialPosition>('middle');
const { sectors } = useLuopan(example, textRadialPosition);
const sector0 = sectors.value.find((s) => s.layerIndex === 0 && s.pieIndex === 0);
expect(sector0?.aStart).toBe(0);
expect(sector0?.aEnd).toBe(90);
const sector1 = sectors.value.find((s) => s.layerIndex === 0 && s.pieIndex === 1);
expect(sector1?.aStart).toBe(90);
expect(sector1?.aEnd).toBe(180);
});
it('扇区应该有有效的颜色', () => {
const example = ref(createMockExample());
const textRadialPosition = ref<TextRadialPosition>('middle');
const { sectors } = useLuopan(example, textRadialPosition);
sectors.value.forEach((sector) => {
expect(sector.fill).toMatch(/^(hsl\(.*\)|#[0-9a-fA-F]{6}|#ffffff)$/);
expect(sector.textColor).toMatch(/^(#[0-9a-fA-F]{6}|#ffffff)$/);
});
});
it('扇区应该有有效的字体大小', () => {
const example = ref(createMockExample());
const textRadialPosition = ref<TextRadialPosition>('middle');
const { sectors } = useLuopan(example, textRadialPosition);
sectors.value.forEach((sector) => {
expect(sector.fontSize).toBeGreaterThan(0);
expect(sector.fontSize).toBeLessThanOrEqual(30);
});
});
it('扇区应该有非空的标签', () => {
const example = ref(createMockExample());
const textRadialPosition = ref<TextRadialPosition>('middle');
const { sectors } = useLuopan(example, textRadialPosition);
sectors.value.forEach((sector) => {
expect(sector.label).toBeTruthy();
expect(sector.label.length).toBeGreaterThan(0);
});
});
it('扇区应该有有效的 SVG 路径', () => {
const example = ref(createMockExample());
const textRadialPosition = ref<TextRadialPosition>('middle');
const { sectors } = useLuopan(example, textRadialPosition);
sectors.value.forEach((sector) => {
expect(sector.path).toContain('M ');
expect(sector.path).toContain('Z');
});
});
it('扇区应该有有效的文字路径', () => {
const example = ref(createMockExample());
const textRadialPosition = ref<TextRadialPosition>('middle');
const { sectors } = useLuopan(example, textRadialPosition);
sectors.value.forEach((sector) => {
expect(sector.textPath).toContain('M ');
});
});
});
describe('文字位置模式', () => {
it('应该在 middle 模式下使用中点位置', () => {
const example = ref(createMockExample());
const textRadialPosition = ref<TextRadialPosition>('middle');
const { sectors } = useLuopan(example, textRadialPosition);
// 第二层的中点应该在 (50 + 100) / 2 = 75 附近
const layer1Sector = sectors.value.find((s) => s.layerIndex === 1);
expect(layer1Sector).toBeDefined();
});
it('应该在 centroid 模式下使用形心位置', () => {
const example = ref(createMockExample());
const textRadialPosition = ref<TextRadialPosition>('centroid');
const { sectors } = useLuopan(example, textRadialPosition);
// 形心位置应该与中点位置不同
expect(sectors.value.length).toBeGreaterThan(0);
});
it('最内层应该始终使用形心位置', () => {
const example = ref(createMockExample());
const textRadialPosition = ref<TextRadialPosition>('middle');
const { sectors } = useLuopan(example, textRadialPosition);
const innermostSectors = sectors.value.filter((s) => s.layerIndex === 0);
expect(innermostSectors.length).toBeGreaterThan(0);
// 最内层应该使用形心位置,即使设置为 middle
});
});
describe('文字方向', () => {
it('应该为左半圆的扇区设置 needReverse', () => {
const example = ref(createMockExample());
const textRadialPosition = ref<TextRadialPosition>('middle');
const { sectors } = useLuopan(example, textRadialPosition);
// 中间角度在 (90, 270) 范围内的扇区应该需要反向
const leftSectors = sectors.value.filter(
(s) => s.aMidDeg > 90 && s.aMidDeg < 270
);
leftSectors.forEach((sector) => {
expect(sector.needReverse).toBe(true);
});
});
it('应该为右半圆的扇区不设置 needReverse', () => {
const example = ref(createMockExample());
const textRadialPosition = ref<TextRadialPosition>('middle');
const { sectors } = useLuopan(example, textRadialPosition);
// 中间角度在 [0, 90] 或 [270, 360] 范围内的扇区不需要反向
const rightSectors = sectors.value.filter(
(s) => s.aMidDeg <= 90 || s.aMidDeg >= 270
);
rightSectors.forEach((sector) => {
expect(sector.needReverse).toBe(false);
});
});
});
describe('竖排文字判断', () => {
it('应该为窄扇区设置 isVertical', () => {
const example = ref({
name: '窄扇区示例',
angles: [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160, 170, 180, 190, 200, 210, 220, 230, 240, 250, 260, 270, 280, 290, 300, 310, 320, 330, 340, 350, 360],
radii: [50, 100, 150],
});
const textRadialPosition = ref<TextRadialPosition>('middle');
const { sectors } = useLuopan(example, textRadialPosition);
// 某些窄扇区应该被设置为竖排
const verticalSectors = sectors.value.filter((s) => s.isVertical);
expect(verticalSectors.length).toBeGreaterThan(0);
});
it('应该为宽扇区不设置 isVertical', () => {
const example = ref({
name: '宽扇区示例',
angles: [0, 180, 360],
radii: [50, 60],
});
const textRadialPosition = ref<TextRadialPosition>('middle');
const { sectors } = useLuopan(example, textRadialPosition);
// 宽扇区应该都不是竖排
sectors.value.forEach((sector) => {
expect(sector.isVertical).toBe(false);
});
});
});
describe('内部填色', () => {
it('某些扇区应该有内部填色', () => {
const example = ref(createMockExample());
const textRadialPosition = ref<TextRadialPosition>('middle');
const { sectors } = useLuopan(example, textRadialPosition);
const filledSectors = sectors.value.filter(
(s) => s.innerFillPath && s.innerFillColor
);
expect(filledSectors.length).toBeGreaterThan(0);
});
it('有内部填色的扇区应该使用白色底色和黑色文字', () => {
const example = ref(createMockExample());
const textRadialPosition = ref<TextRadialPosition>('middle');
const { sectors } = useLuopan(example, textRadialPosition);
const filledSectors = sectors.value.filter(
(s) => s.innerFillPath && s.innerFillColor
);
filledSectors.forEach((sector) => {
expect(sector.fill).toBe('#ffffff');
expect(sector.textColor).toBe('#111827');
});
});
});
describe('响应式更新', () => {
it('应该响应示例变化', () => {
const example = ref(createMockExample());
const textRadialPosition = ref<TextRadialPosition>('middle');
const { sectors } = useLuopan(example, textRadialPosition);
const initialCount = sectors.value.length;
// 更改示例
example.value = {
name: '新示例',
angles: [0, 120, 240, 360],
radii: [100, 200],
};
// 扇区数量应该变化
expect(sectors.value.length).not.toBe(initialCount);
expect(sectors.value.length).toBe(6); // 3个角度分割 × 2层
});
it('应该响应文字位置模式变化', () => {
const example = ref(createMockExample());
const textRadialPosition = ref<TextRadialPosition>('middle');
const { sectors } = useLuopan(example, textRadialPosition);
const middlePaths = sectors.value.map((s) => s.textPath);
// 更改文字位置模式
textRadialPosition.value = 'centroid';
const centroidPaths = sectors.value.map((s) => s.textPath);
// 非最内层的文字路径应该有所不同
const differentPaths = middlePaths.filter(
(path, index) => path !== centroidPaths[index]
);
expect(differentPaths.length).toBeGreaterThan(0);
});
});
describe('getLabelTransform', () => {
it('应该返回有效的 SVG transform 字符串', () => {
const example = ref(createMockExample());
const textRadialPosition = ref<TextRadialPosition>('middle');
const { sectors, getLabelTransform } = useLuopan(example, textRadialPosition);
const sector = sectors.value[0];
const transform = getLabelTransform(sector);
expect(transform).toContain('translate');
expect(transform).toContain('rotate');
});
});
describe('toXY', () => {
it('应该正确转换极坐标', () => {
const example = ref(createMockExample());
const textRadialPosition = ref<TextRadialPosition>('middle');
const { toXY } = useLuopan(example, textRadialPosition);
const point = toXY(0, 100);
expect(point.x).toBeCloseTo(0);
expect(point.y).toBeCloseTo(-100);
});
});
describe('边界情况', () => {
it('应该处理单层罗盘', () => {
const example = ref({
name: '单层',
angles: [0, 90, 180, 270, 360],
radii: [100],
});
const textRadialPosition = ref<TextRadialPosition>('middle');
const { sectors } = useLuopan(example, textRadialPosition);
expect(sectors.value.length).toBe(4);
sectors.value.forEach((sector) => {
expect(sector.layerIndex).toBe(0);
expect(sector.rInner).toBe(0);
expect(sector.rOuter).toBe(100);
});
});
it('应该处理两个扇区的罗盘', () => {
const example = ref({
name: '两扇区',
angles: [0, 180, 360],
radii: [100],
});
const textRadialPosition = ref<TextRadialPosition>('middle');
const { sectors } = useLuopan(example, textRadialPosition);
expect(sectors.value.length).toBe(2);
});
it('应该处理大量层数的罗盘', () => {
const example = ref({
name: '多层',
angles: [0, 90, 180, 270, 360],
radii: [20, 40, 60, 80, 100, 120, 140, 160, 180, 200],
});
const textRadialPosition = ref<TextRadialPosition>('middle');
const { sectors } = useLuopan(example, textRadialPosition);
expect(sectors.value.length).toBe(40); // 4个扇区 × 10层
const layerIndices = new Set(sectors.value.map((s) => s.layerIndex));
expect(layerIndices.size).toBe(10);
});
it('应该处理大量扇区的罗盘', () => {
const example = ref({
name: '多扇区',
angles: Array.from({ length: 37 }, (_, i) => i * 10), // 36个扇区
radii: [100, 200],
});
const textRadialPosition = ref<TextRadialPosition>('middle');
const { sectors } = useLuopan(example, textRadialPosition);
expect(sectors.value.length).toBe(72); // 36个扇区 × 2层
});
});
});

369
tests/utils.test.ts Normal file
View File

@@ -0,0 +1,369 @@
/**
* 工具函数单元测试
* 使用 Vitest 进行测试
*/
import { describe, it, expect } from 'vitest';
import {
polarToXY,
normalizeDeg,
annularSectorCentroid,
annularSectorPath,
annularSectorInsetPath,
calculateLabelRotation,
generateSectorColor,
generateTextPath,
generateVerticalTextPath,
getTextColorForBackground,
calculateSectorFontSize,
} from '../src/utils';
describe('polarToXY', () => {
it('应该正确转换 0° 角度(北方)', () => {
const result = polarToXY(0, 100);
expect(result.x).toBeCloseTo(0);
expect(result.y).toBeCloseTo(-100);
});
it('应该正确转换 90° 角度(东方)', () => {
const result = polarToXY(90, 100);
expect(result.x).toBeCloseTo(100);
expect(result.y).toBeCloseTo(0);
});
it('应该正确转换 180° 角度(南方)', () => {
const result = polarToXY(180, 100);
expect(result.x).toBeCloseTo(0);
expect(result.y).toBeCloseTo(100);
});
it('应该正确转换 270° 角度(西方)', () => {
const result = polarToXY(270, 100);
expect(result.x).toBeCloseTo(-100);
expect(result.y).toBeCloseTo(0);
});
});
describe('normalizeDeg', () => {
it('应该保持 0-360 范围内的角度不变', () => {
expect(normalizeDeg(45)).toBe(45);
expect(normalizeDeg(180)).toBe(180);
expect(normalizeDeg(359)).toBe(359);
});
it('应该将负角度转换为正角度', () => {
expect(normalizeDeg(-45)).toBe(315);
expect(normalizeDeg(-180)).toBe(180);
expect(normalizeDeg(-90)).toBe(270);
});
it('应该将大于 360 的角度归一化', () => {
expect(normalizeDeg(405)).toBe(45);
expect(normalizeDeg(720)).toBe(0);
expect(normalizeDeg(370)).toBe(10);
});
});
describe('annularSectorCentroid', () => {
it('应该为简单扇形计算正确的形心', () => {
const result = annularSectorCentroid({
rInner: 0,
rOuter: 100,
aStartDeg: 0,
aEndDeg: 90,
});
expect(result.aMidDeg).toBe(45);
expect(result.deltaDeg).toBe(90);
expect(result.rho).toBeGreaterThan(0);
});
it('应该为圆环扇形计算正确的形心', () => {
const result = annularSectorCentroid({
rInner: 50,
rOuter: 100,
aStartDeg: 0,
aEndDeg: 90,
});
expect(result.aMidDeg).toBe(45);
expect(result.deltaDeg).toBe(90);
expect(result.rho).toBeGreaterThan(50);
expect(result.rho).toBeLessThan(100);
});
it('应该处理跨越 0° 的扇形', () => {
const result = annularSectorCentroid({
rInner: 0,
rOuter: 100,
aStartDeg: 315,
aEndDeg: 45,
});
expect(result.aMidDeg).toBe(0);
expect(result.deltaDeg).toBe(90);
});
it('应该返回零形心当内外半径相等时', () => {
const result = annularSectorCentroid({
rInner: 100,
rOuter: 100,
aStartDeg: 0,
aEndDeg: 90,
});
expect(result.cx).toBe(0);
expect(result.cy).toBe(0);
expect(result.rho).toBe(0);
});
});
describe('annularSectorPath', () => {
it('应该生成有效的 SVG 路径', () => {
const path = annularSectorPath(50, 100, 0, 90);
expect(path).toContain('M ');
expect(path).toContain('A ');
expect(path).toContain('L ');
expect(path).toContain('Z');
});
it('应该为纯扇形(内半径为 0生成简化路径', () => {
const path = annularSectorPath(0, 100, 0, 90);
expect(path).toContain('M ');
expect(path).toContain('A ');
expect(path).toContain('L 0 0');
expect(path).toContain('Z');
});
it('应该在大角度时设置 large-arc-flag', () => {
const path = annularSectorPath(50, 100, 0, 270);
// 大角度应包含 large-arc-flag = 1
expect(path).toBeTruthy();
});
});
describe('calculateLabelRotation', () => {
it('应该在上半圆不进行翻转', () => {
expect(calculateLabelRotation(0)).toBe(0);
expect(calculateLabelRotation(45)).toBe(45);
expect(calculateLabelRotation(90)).toBe(90);
expect(calculateLabelRotation(180)).toBe(180);
});
it('应该在下半圆翻转180°避免倒字', () => {
expect(calculateLabelRotation(181)).toBe(361); // 181 + 180
expect(calculateLabelRotation(270)).toBe(450); // 270 + 180
expect(calculateLabelRotation(359)).toBe(539); // 359 + 180
});
it('应该在边界值正确处理', () => {
expect(calculateLabelRotation(180)).toBe(180); // 等于180不翻转
expect(calculateLabelRotation(360)).toBe(360); // 等于360不翻转
});
});
describe('generateSectorColor', () => {
it('应该生成有效的 HSL 颜色', () => {
const color = generateSectorColor(0, 0);
expect(color).toMatch(/^hsl\(\d+(\.\d+)? \d+% \d+%\)$/);
});
it('应该根据层索引改变亮度', () => {
const color1 = generateSectorColor(0, 0);
const color2 = generateSectorColor(1, 0);
const color3 = generateSectorColor(2, 0);
// 提取亮度值
const light1 = parseInt(color1.match(/(\d+)%\)$/)?.[1] || '0');
const light2 = parseInt(color2.match(/(\d+)%\)$/)?.[1] || '0');
const light3 = parseInt(color3.match(/(\d+)%\)$/)?.[1] || '0');
expect(light1).toBeGreaterThan(light2);
expect(light2).toBeGreaterThan(light3);
});
it('应该根据扇区索引改变色相', () => {
const color1 = generateSectorColor(0, 0);
const color2 = generateSectorColor(0, 6);
// 提取色相值
const hue1 = parseInt(color1.match(/^hsl\((\d+(\.\d+)?)/)?.[1] || '0');
const hue2 = parseInt(color2.match(/^hsl\((\d+(\.\d+)?)/)?.[1] || '0');
expect(hue1).not.toBe(hue2);
});
it('应该为单层生成颜色', () => {
const color = generateSectorColor(0, 0, 1, 24);
expect(color).toMatch(/^hsl\(\d+(\.\d+)? \d+% \d+%\)$/);
});
it('应该处理大量层数', () => {
const color1 = generateSectorColor(0, 0, 31, 24);
const color2 = generateSectorColor(30, 0, 31, 24);
const light1 = parseInt(color1.match(/(\d+)%\)$/)?.[1] || '0');
const light2 = parseInt(color2.match(/(\d+)%\)$/)?.[1] || '0');
expect(light1).toBeGreaterThan(light2);
});
});
describe('annularSectorInsetPath', () => {
it('应该生成内缩路径', () => {
const path = annularSectorInsetPath(50, 100, 0, 90, 2);
expect(path).toContain('M ');
expect(path).toContain('A ');
expect(path).toContain('Z');
});
it('应该在内缩过大时返回空路径', () => {
const path = annularSectorInsetPath(50, 60, 0, 90, 20);
expect(path).toBe('');
});
it('应该处理小角度扇区', () => {
const path = annularSectorInsetPath(50, 100, 0, 5, 2);
expect(path).toBeTruthy();
});
it('应该处理从圆心开始的扇区', () => {
const path = annularSectorInsetPath(0, 100, 0, 90, 2);
expect(path).toContain('M ');
expect(path).toContain('A ');
});
});
describe('generateTextPath', () => {
it('应该生成正向文字路径', () => {
const path = generateTextPath(50, 100, 0, 90, false, 'middle', 12);
expect(path).toContain('M ');
expect(path).toContain('A ');
expect(path).toContain('0 1'); // 正向扫描
});
it('应该生成反向文字路径', () => {
const path = generateTextPath(50, 100, 0, 90, true, 'middle', 12);
expect(path).toContain('M ');
expect(path).toContain('A ');
expect(path).toContain('0 0'); // 反向扫描
});
it('应该在 centroid 模式下使用形心半径', () => {
const pathCentroid = generateTextPath(50, 100, 0, 90, false, 'centroid', 12);
const pathMiddle = generateTextPath(50, 100, 0, 90, false, 'middle', 12);
expect(pathCentroid).not.toBe(pathMiddle);
});
it('应该处理跨越360°的扇区', () => {
const path = generateTextPath(50, 100, 315, 45, false, 'middle', 12);
expect(path).toContain('M ');
expect(path).toContain('A ');
});
it('应该处理大角度扇区', () => {
const path = generateTextPath(50, 100, 0, 270, false, 'middle', 12);
expect(path).toContain('M ');
expect(path).toContain('A ');
});
});
describe('generateVerticalTextPath', () => {
it('应该生成竖排文字路径', () => {
const path = generateVerticalTextPath(50, 100, 0, 30, 15, 'middle', 12);
expect(path).toContain('M ');
expect(path).toContain('L ');
});
it('应该在 centroid 模式下使用形心', () => {
const pathCentroid = generateVerticalTextPath(50, 100, 0, 30, 15, 'centroid', 12);
const pathMiddle = generateVerticalTextPath(50, 100, 0, 30, 15, 'middle', 12);
expect(pathCentroid).not.toBe(pathMiddle);
});
it('应该处理不同的角度', () => {
const path1 = generateVerticalTextPath(50, 100, 0, 30, 15, 'middle', 12);
const path2 = generateVerticalTextPath(50, 100, 0, 30, 180, 'middle', 12);
expect(path1).not.toBe(path2);
});
it('应该处理窄扇区', () => {
const path = generateVerticalTextPath(80, 100, 0, 10, 5, 'middle', 12);
expect(path).toContain('M ');
expect(path).toContain('L ');
});
});
describe('getTextColorForBackground', () => {
it('应该为深色背景返回白色', () => {
const color = getTextColorForBackground('hsl(180 70% 25%)');
expect(color).toBe('#ffffff');
});
it('应该为浅色背景返回深色', () => {
const color = getTextColorForBackground('hsl(180 70% 85%)');
expect(color).toBe('#111827');
});
it('应该处理边界值50%', () => {
const color = getTextColorForBackground('hsl(180 70% 50%)');
expect(color).toBe('#111827');
});
it('应该处理边界值49%', () => {
const color = getTextColorForBackground('hsl(180 70% 49%)');
expect(color).toBe('#ffffff');
});
it('应该处理无效输入', () => {
const color = getTextColorForBackground('invalid');
expect(color).toBe('#111827');
});
});
describe('calculateSectorFontSize', () => {
it('应该为标准扇区计算字体大小', () => {
const fontSize = calculateSectorFontSize(50, 100, 0, 90, 4, 3, 24);
expect(fontSize).toBeGreaterThan(3);
expect(fontSize).toBeLessThanOrEqual(24);
});
it('应该为窄扇区返回较小的字体', () => {
const narrowSize = calculateSectorFontSize(90, 100, 0, 10, 4, 3, 24);
const wideSize = calculateSectorFontSize(50, 100, 0, 90, 4, 3, 24);
expect(narrowSize).toBeLessThan(wideSize);
});
it('应该根据文字长度调整', () => {
const shortText = calculateSectorFontSize(50, 100, 0, 90, 2, 3, 24);
const longText = calculateSectorFontSize(50, 100, 0, 90, 8, 3, 24);
expect(shortText).toBeGreaterThan(longText);
});
it('应该为小角度扇区应用额外限制', () => {
const size1 = calculateSectorFontSize(50, 100, 0, 10, 2, 3, 24);
const size2 = calculateSectorFontSize(50, 100, 0, 90, 2, 3, 24);
expect(size1).toBeLessThan(size2);
});
it('应该尊重最小字体大小限制', () => {
const fontSize = calculateSectorFontSize(95, 100, 0, 5, 10, 6, 24);
expect(fontSize).toBeGreaterThanOrEqual(6);
});
it('应该尊重最大字体大小限制', () => {
const fontSize = calculateSectorFontSize(0, 200, 0, 180, 1, 3, 20);
expect(fontSize).toBeLessThanOrEqual(20);
});
it('应该处理零文字长度', () => {
const fontSize = calculateSectorFontSize(50, 100, 0, 90, 0, 3, 24);
expect(fontSize).toBe(3);
});
it('应该处理从圆心开始的扇区', () => {
const fontSize = calculateSectorFontSize(0, 100, 0, 90, 4, 3, 24);
expect(fontSize).toBeGreaterThan(3);
expect(fontSize).toBeLessThanOrEqual(24);
});
});

84
todolist.md Normal file
View File

@@ -0,0 +1,84 @@
## Json文件配置
1. 扇区背景色着色原则:
最高优先级在layer中指定colorRef
第二优先级colorRef规律填色也就是说如果同一个sector中指定了colorRef该sector也指定了layer级别的colorRef以前者为准innerFill使用相同规则。
参数:
innerfill对着色区域生效
start表示着色起始扇区
num表示连接几个单元着色
interval表示中间间隔几个单元
比如start=2,num=3,interval=1,意思是从第二个扇区开始着色对2、3、4扇区着色colorref5扇区全局背景6、7、8着色colorref……
-- ========================================
-- 第4层地支 (12等分)
-- ========================================
-- 演示:规律填色 - 3个着色1个间隔
-- 着色规律start=2, num=3, interval=1
-- 效果扇区2-4着色5空白6-8着色9空白10-12着色1空白
”layers“:
{
{
"divisions": 12,
"rInner": 160,
"rOuter": 200,
"innerFill": 1, -- 着色区域的内缩设置
"colorRef": "土", -- 着色使用的颜色引用
"start": 2, -- 从第2个扇区开始着色1-based索引
"num": 3, -- 连续着色3个扇区
"interval": 1, -- 着色后间隔1个扇区
"sectors": [
{ "content": "子", "colorRef": "水", "innerFill": 1 }, -- 高优先级着色:水
{ "content": "丑" },
{ "content": "寅", "colorRef": "木", "innerFill": 0 }, -- 高优先级着色:木
{ "content": "卯", "colorRef": "木", "innerFill": 1 }, -- 高优先级着色:木
{ "content": "辰" },
{ "content": "巳", "colorRef": "火", "innerFill": 1 }, -- 高优先级着色:火
{ "content": "午", "colorRef": "火", "innerFill": 0 }, -- 高优先级着色:火
{ "content": "未", "innerFill": 1 },
{ "content": "申", "colorRef": "金", "innerFill": 0 }, -- 高优先级着色:金
{ "content": "酉", "colorRef": "金", "innerFill": 1 }, -- 高优先级着色:金
{ "content": "戌" },
{ "content": "亥", "innerFill": 0 }
]
}
}
2. 中心icon配置参数
rIcon -- 半径
opacity -- 圆的透明度
name -- icon文件名svg路径固定
-- ========================================
-- 中心图标配置 (Center Icon Configuration)
-- ========================================
"centerIcon": {
"rIcon": 50, -- 图标半径,单位:像素
"opacity": 0.8, -- 图标透明度0.0-1.00为完全透明1为完全不透明
"name": "taiji.svg" -- SVG图标文件名路径固定为 /icons/ 目录
}
3. 360度刻度环配置参数
rinner
router
showDegree -- 是否显示度数0不限时1显示如显示按10度间隔显示
mode -- inner表示刻度线在rinner的外部outter表示刻度线在routter的内部both两边都标注度数如有居于中间
opacity -- 圆的透明度,目的是有时候只需要显示刻度,而不用显示圆圈
-- ========================================
-- 360度刻度环配置 (360 Degree Scale Ring)
-- ========================================
"degreeRing": {
"rInner": 450, -- 刻度环内半径
"rOuter": 500, -- 刻度环外半径
"showDegree": 1, -- 是否显示度数0=不显示1=显示(按 10° 间隔)
"mode": "both", -- 刻度线模式:"inner"在rInner外侧、"outer"在rOuter内侧、"both"(两侧都有,度数居中)
"opacity": 0.3, -- 圆环透明度0.0-1.0设置为0可以只显示刻度而不显示圆圈
"tickLength": 6, -- 刻度线长度,单位:像素, minorTick比majorTick短1px microTick比minorTick短1px
"majorTick": 10, -- 主刻度间隔(度),如 10 表示每 10° 一个主刻度
"minorTick": 5, -- 次刻度间隔(度),如 2 表示每 2° 一个次刻度
"microTick": 1, -- 微刻度间隔(度),如 1 表示每 1° 一个微刻度
"tickColor": "#ffffff",-- 刻度线颜色
"ringColor": "#ffffff" -- 圆环颜色
}

25
tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "*.ts", "*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
tsconfig.node.json Normal file
View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

6
vite.config.ts Normal file
View File

@@ -0,0 +1,6 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
});

21
vitest.config.ts Normal file
View File

@@ -0,0 +1,21 @@
import { defineConfig } from 'vitest/config';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
test: {
environment: 'happy-dom',
globals: true,
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'dist/',
'**/*.test.ts',
'**/*.config.ts',
'**/types.ts',
],
},
},
});