update at 2026-01-22 18:43:01

This commit is contained in:
douboer
2026-01-22 18:43:01 +08:00
parent c23c71eabf
commit a930a99a50
23 changed files with 2082 additions and 1186 deletions

175
public/demo.json Normal file
View File

@@ -0,0 +1,175 @@
{
"name": "demo",
"description": "luopan demo config with named color palettes",
"background": "#fff000",
"outerRadius": 500,
"theme": {
"name": "五行配色主题",
"colorPalettes": {
"黑": "#000000",
"灰": "#757575",
"白": "#ffffff",
"木": "#43A047",
"火": "#E53935",
"土": "#8D6E63",
"金": "#78909C",
"水": "#0288D1",
"热": "#FF8F00",
"冷": "#1976D2",
"强": "#D32F2F",
"\u8f6f": "#FFE0B2"
}
},
"layers": [
{
"type": "centerIcon",
"centerIcon": {
"rIcon": 50,
"opacity": 0.8,
"name": "centericon.svg"
}
},
{
"divisions": 2,
"rInner": 90,
"rOuter": 120,
"startAngle": 0,
"sectors": [
{
"content": "阴",
"innerFill": 1
},
{
"content": "阳",
"colorRef": "White",
"innerFill": 0
}
]
},
{
"divisions": 8,
"rInner": 120,
"rOuter": 160,
"startAngle": 0,
"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 }
]
},
{
"divisions": 12,
"rInner": 160,
"rOuter": 200,
"startAngle": 0,
"colorRef": "土",
"innerFill": 1,
"num": 3,
"interval": 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 }
]
},
{
"divisions": 24,
"rInner": 210,
"rOuter": 240,
"startAngle": 0,
"sectors": [
{ "content": "甲乙|子|丙丁", "colorRef": "木", "innerFill": 1 },
{ "content": "丑" },
{ "content": "戊己|寅|庚辛", "colorRef": "土", "innerFill": 0 },
{ "content": "卯" },
{ "content": "壬癸|辰|甲乙", "innerFill": 1 },
{ "content": "巳" },
{ "content": "丙丁|午|戊己", "colorRef": "火", "innerFill": 0 },
{ "content": "未" },
{ "content": "庚辛|申|壬癸", "colorRef": "金", "innerFill": 1 },
{ "content": "酉" },
{ "content": "甲乙|戌|丙丁", "innerFill": 0 },
{ "content": "亥" },
{ "content": "戊己|子|庚辛" },
{ "content": "丑" },
{ "content": "壬癸|寅|甲乙" },
{ "content": "卯" },
{ "content": "丙丁|辰|戊己" },
{ "content": "巳" },
{ "content": "庚辛|午|壬癸" },
{ "content": "未" },
{ "content": "甲乙|申|丙丁" },
{ "content": "酉" },
{ "content": "戊己|戌|庚辛" },
{ "content": "亥" }
]
},
{
"divisions": 16,
"rInner": 240,
"rOuter": 270,
"startAngle": 0,
"sectors": [
{ "content": "甲乙|丙丁|戊己|庚辛", "colorRef": "木", "innerFill": 1 },
{ "content": "壬癸|甲乙|丙丁|戊己", "colorRef": "土", "innerFill": 0 },
{ "content": "庚辛|壬癸|甲乙|丙丁", "innerFill": 1 },
{ "content": "戊己|庚辛|壬癸|甲乙", "colorRef": "火", "innerFill": 0 },
{ "content": "丙丁|戊己|庚辛|壬癸", "colorRef": "金", "innerFill": 1 },
{ "content": "子丑|寅卯|辰巳|午未", "innerFill": 0 },
{ "content": "申酉|戌亥|子丑|寅卯", "colorRef": "木", "innerFill": 1 },
{ "content": "辰巳|午未|申酉|戌亥", "colorRef": "水", "innerFill": 0 },
{ "content": "甲乙|丙丁|戊己|庚辛", "innerFill": 1 },
{ "content": "壬癸|甲乙|丙丁|戊己", "colorRef": "土", "innerFill": 0 },
{ "content": "庚辛|壬癸|甲乙|丙丁", "colorRef": "木", "innerFill": 1 },
{ "content": "戊己|庚辛|壬癸|甲乙", "innerFill": 0 },
{ "content": "丙丁|戊己|庚辛|壬癸", "colorRef": "火", "innerFill": 1 },
{ "content": "子丑|寅卯|辰巳|午未", "colorRef": "金", "innerFill": 0 },
{ "content": "申酉|戌亥|子丑|寅卯", "innerFill": 1 },
{ "content": "辰巳|午未|申酉|戌亥", "colorRef": "水", "innerFill": 0 }
]
},
{
"divisions": 120,
"rInner": 300,
"rOuter": 310,
"startAngle": -4.5,
"innerFill": 0,
"colorRef": "火",
"num": 3,
"interval": 2,
"groupSplit": false
},
{
"type": "degreeRing",
"degreeRing": {
"rInner": 350,
"rOuter": 380,
"showDegree": 1,
"mode": "both",
"opacity": 1,
"tickLength": 6,
"tickLengthStep": 2,
"majorTick": 10,
"minorTick": 5,
"microTick": 1,
"tickColor": "#000000",
"ringColor": "#000000"
}
}
]
}

View File

@@ -48,33 +48,7 @@
"冷": "#1976D2", -- 冷蓝(冷色调)
"强": "#D32F2F", -- 强烈红(高饱和度)
"\u8f6f": "#FFE0B2" -- 柔和杏(低饱和度)
}
},
-- ========================================
-- 中心图标配置 (Center Icon Configuration)
-- ========================================
"centerIcon": {
"rIcon": 50, -- 图标半径,单位:像素
"opacity": 0.8, -- 图标透明度0.0-1.00为完全透明1为完全不透明
"name": "centericon.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" -- 圆环颜色
},
-- ========================================
@@ -82,6 +56,17 @@
-- ========================================
-- 从内向外定义每一层的配置
"layers": [
-- ========================================
-- 中心图标层 (Center Icon Layer)
-- ========================================
{
"type": "centerIcon",
"centerIcon": {
"rIcon": 50, -- 图标半径,单位:像素
"opacity": 0.8, -- 图标透明度0.0-1.00为完全透明1为完全不透明
"name": "centericon.svg" -- SVG图标文件名路径固定为 /icons/ 目录
}
},
-- ========================================
-- 阴阳 (2等分)
-- ========================================
@@ -246,6 +231,26 @@
"num": 3, -- 连续着色3个扇区每个区域跨3度
"interval": 2, -- 着色后间隔1个扇区
"groupSplit": false -- 新增:隐藏同组扇区之间的分割线, false表示不显示group中间分割线该参数不设置默认显示。
},
-- ========================================
-- 360度刻度环层 (360 Degree Scale Ring Layer)
-- ========================================
{
"type": "degreeRing",
"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" -- 圆环颜色
}
}
]
}
@@ -281,4 +286,3 @@
-- 示例interval=0表示该layer的所有扇区使用同样的colorRef
--
-- ========================================

174
public/demo2.json Normal file
View File

@@ -0,0 +1,174 @@
{
"name": "demo2",
"description": "luopan demo config with named color palettes",
"background": "#000000",
"outerRadius": 500,
"theme": {
"name": "五行配色主题",
"colorPalettes": {
"黑": "#000000",
"灰": "#757575",
"白": "#ffffff",
"木": "#43A047",
"火": "#E53935",
"土": "#8D6E63",
"金": "#78909C",
"水": "#0288D1",
"热": "#FF8F00",
"冷": "#1976D2",
"强": "#D32F2F",
"\u8f6f": "#FFE0B2"
}
},
"layers": [
{
"type": "centerIcon",
"centerIcon": {
"rIcon": 50,
"opacity": 0.8,
"name": "centericon.svg"
}
},
{
"divisions": 2,
"rInner": 60,
"rOuter": 90,
"startAngle": 0,
"sectors": [
{
"content": "阴",
"innerFill": 1
},
{
"content": "阳",
"colorRef": "White",
"innerFill": 0
}
]
},
{
"divisions": 8,
"rInner": 120,
"rOuter": 160,
"startAngle": 0,
"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 }
]
},
{
"divisions": 12,
"rInner": 160,
"rOuter": 200,
"startAngle": 0,
"colorRef": "土",
"innerFill": 1,
"num": 3,
"interval": 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 }
]
},
{
"divisions": 24,
"rInner": 210,
"rOuter": 240,
"startAngle": 0,
"sectors": [
{ "content": "甲乙|子|丙丁", "colorRef": "木", "innerFill": 1 },
{ "content": "丑" },
{ "content": "戊己|寅|庚辛", "colorRef": "土", "innerFill": 0 },
{ "content": "卯" },
{ "content": "壬癸|辰|甲乙", "innerFill": 1 },
{ "content": "巳" },
{ "content": "丙丁|午|戊己", "colorRef": "火", "innerFill": 0 },
{ "content": "未" },
{ "content": "庚辛|申|壬癸", "colorRef": "金", "innerFill": 1 },
{ "content": "酉" },
{ "content": "甲乙|戌|丙丁", "innerFill": 0 },
{ "content": "亥" },
{ "content": "戊己|子|庚辛" },
{ "content": "丑" },
{ "content": "壬癸|寅|甲乙" },
{ "content": "卯" },
{ "content": "丙丁|辰|戊己" },
{ "content": "巳" },
{ "content": "庚辛|午|壬癸" },
{ "content": "未" },
{ "content": "甲乙|申|丙丁" },
{ "content": "酉" },
{ "content": "戊己|戌|庚辛" },
{ "content": "亥" }
]
},
{
"divisions": 16,
"rInner": 240,
"rOuter": 270,
"startAngle": 0,
"sectors": [
{ "content": "甲乙|丙丁|戊己|庚辛", "colorRef": "木", "innerFill": 1 },
{ "content": "壬癸|甲乙|丙丁|戊己", "colorRef": "土", "innerFill": 0 },
{ "content": "庚辛|壬癸|甲乙|丙丁", "innerFill": 1 },
{ "content": "戊己|庚辛|壬癸|甲乙", "colorRef": "火", "innerFill": 0 },
{ "content": "丙丁|戊己|庚辛|壬癸", "colorRef": "金", "innerFill": 1 },
{ "content": "子丑|寅卯|辰巳|午未", "innerFill": 0 },
{ "content": "申酉|戌亥|子丑|寅卯", "colorRef": "木", "innerFill": 1 },
{ "content": "辰巳|午未|申酉|戌亥", "colorRef": "水", "innerFill": 0 },
{ "content": "甲乙|丙丁|戊己|庚辛", "innerFill": 1 },
{ "content": "壬癸|甲乙|丙丁|戊己", "colorRef": "土", "innerFill": 0 },
{ "content": "庚辛|壬癸|甲乙|丙丁", "colorRef": "木", "innerFill": 1 },
{ "content": "戊己|庚辛|壬癸|甲乙", "innerFill": 0 },
{ "content": "丙丁|戊己|庚辛|壬癸", "colorRef": "火", "innerFill": 1 },
{ "content": "子丑|寅卯|辰巳|午未", "colorRef": "金", "innerFill": 0 },
{ "content": "申酉|戌亥|子丑|寅卯", "innerFill": 1 },
{ "content": "辰巳|午未|申酉|戌亥", "colorRef": "水", "innerFill": 0 }
]
},
{
"divisions": 120,
"rInner": 300,
"rOuter": 310,
"startAngle": -4.5,
"innerFill": 0,
"colorRef": "火",
"num": 3,
"interval": 2,
"groupSplit": false
},
{
"type": "degreeRing",
"degreeRing": {
"rInner": 450,
"rOuter": 500,
"showDegree": 1,
"mode": "both",
"opacity": 0.3,
"tickLength": 6,
"majorTick": 10,
"minorTick": 5,
"microTick": 1,
"tickColor": "#ffffff",
"ringColor": "#ffffff"
}
}
]
}

View File

@@ -10,18 +10,21 @@
- **内置逻辑**:颜色、文字、填充等逻辑写死在代码中
- **测试导向**:现有实现主要为测试工具函数正确性而设计
### 1.2 目标需求分析(基于 todolist.md 和 demo.json.conf
### 1.2 目标需求分析(基于 todolist.md 和 demo.json
说明:`public/*.json` 为实际加载配置,`.json.conf` 仅作解释说明,不参与加载。
**核心变化:**
1. **配置驱动**:从 JSON 配置文件完全定义罗盘结构
2. **复杂着色规则**:支持三级着色优先级(全局 → 层级规律填色 → 扇区独立)
3. **多文本单元**:扇区内容支持 `|` 分隔的多个文本单元,角度智能分配
4. **SVG 图标支持**:扇区内容可以是 SVG 文件
5. **中心图标**:支持可旋转的中心 SVG 图标
6. **360度刻度环**:支持多种刻度模式的度数环
7. **命名配色方案**:通过 theme.colorPalettes 定义可复用颜色
8. **规律填色机制**:通过 num + interval 实现周期性着
9. **同组分割线控制**groupSplit 参数控制组内分割线显示
2. **新增罗盘零改码**:新增罗盘只需在 `public/` 下增加 JSON 配置文件,无需修改代码
3. **复杂着色规则**:支持三级着色优先级(全局 → 层级规律填色 → 扇区独立)
4. **多文本单元**:扇区内容支持 `|` 分隔的多个文本单元,角度智能分配
5. **SVG 图标支持**:扇区内容可以是 SVG 文件
6. **中心图标**:作为 layer 类型,支持可旋转的中心 SVG 图标
7. **360度刻度环**:作为 layer 类型,支持多种刻度模式的度数环
8. **命名配色方案**:通过 theme.colorPalettes 定义可复用颜
9. **规律填色机制**:通过 num + interval 实现周期性着色
10. **同组分割线控制**groupSplit 参数控制组内分割线显示
---
@@ -95,18 +98,15 @@ JSON 文本 (带注释)
[sectorBuilder] 遍历 layers
对每个 layer:
- type=sectors
1. 计算规律填色模式 (num, interval)
2. 生成所有扇区
3. 对每个扇区:
- 应用颜色优先级
- 解析多文本单元
- 计算 SVG 路径
完整的 Sector 数组
[degreeRing] 生成刻度环
[centerIcon] 加载中心图标
- type=degreeRing生成刻度环数据
- type=centerIcon加载中心图标
最终渲染数据
```
@@ -147,8 +147,6 @@ function applyPatternColoring(
}
```
CG: 角度分配还需结合扇区高度,就能计算出字的大小和布局
**多文本单元角度分配:**
```typescript
function splitMultiTextUnits(
@@ -192,9 +190,7 @@ export interface LuopanConfig {
description?: string;
background: string; // 全局背景色
theme: ThemeConfig;
centerIcon?: CenterIconConfig;
degreeRing?: DegreeRingConfig;
layers: LayerConfig[];
layers: LayerConfig[]; // 扇区层 + 中心图标层 + 刻度环层
}
/** 主题配置 */
@@ -228,7 +224,14 @@ export interface DegreeRingConfig {
}
/** 层配置 */
export interface LayerConfig {
export type LayerConfig =
| SectorLayerConfig
| CenterIconLayerConfig
| DegreeRingLayerConfig;
/** 普通扇区层配置 */
export interface SectorLayerConfig {
type?: 'sectors';
divisions: number;
rInner: number;
rOuter: number;
@@ -241,6 +244,18 @@ export interface LayerConfig {
sectors?: SectorConfig[];
}
/** 中心图标层配置 */
export interface CenterIconLayerConfig {
type: 'centerIcon';
centerIcon: CenterIconConfig;
}
/** 刻度环层配置 */
export interface DegreeRingLayerConfig {
type: 'degreeRing';
degreeRing: DegreeRingConfig;
}
/** 扇区配置 */
export interface SectorConfig {
content?: string; // 支持 "|" 分隔
@@ -256,7 +271,6 @@ export interface TextUnit {
isSvg: boolean; // 是否为 SVG 文件名
}
CG
/** 刻度线数据 */
export interface TickMark {
angle: number;
@@ -267,13 +281,12 @@ export interface TickMark {
label?: string; // 度数标签(仅主刻度)
}
CG
/** 扩展现有 Sector 接口 */
export interface Sector {
// ... 现有字段保持 ...
// 新增字段
textUnits?: TextUnit[]; // 多文本单元
textUnits?: TextUnit[]; // 多文本单元
groupSplitVisible?: boolean; // 是否显示与下一个扇区的分割线
isSvgContent?: boolean; // 内容是否为 SVG
svgPath?: string; // SVG 文件路径
@@ -284,7 +297,7 @@ export interface Sector {
```typescript
// 输入JSON 配置字符串
const jsonText = await fetch('/demo.json.conf').then(r => r.text());
const jsonText = await fetch('/demo.json').then(r => r.text());
// 步骤1解析配置
const config = parseConfig(jsonText);
@@ -295,17 +308,21 @@ const colorResolver = new ColorResolver(config.theme, config.background);
// 步骤3构建扇区
const sectorBuilder = new SectorBuilder(colorResolver);
const sectors = config.layers.flatMap((layer, layerIndex) =>
sectorBuilder.buildLayer(layer, layerIndex)
layer.type === 'centerIcon' || layer.type === 'degreeRing'
? []
: sectorBuilder.buildLayer(layer, layerIndex)
);
// 步骤4生成刻度环
const degreeRingData = config.degreeRing
? buildDegreeRing(config.degreeRing)
// 步骤4生成刻度环(从 layers 中提取)
const degreeRingLayer = config.layers.find(layer => layer.type === 'degreeRing');
const degreeRingData = degreeRingLayer
? buildDegreeRing(degreeRingLayer.degreeRing)
: null;
// 步骤5加载中心图标
const centerIconData = config.centerIcon
? await loadCenterIcon(config.centerIcon)
// 步骤5加载中心图标(从 layers 中提取)
const centerIconLayer = config.layers.find(layer => layer.type === 'centerIcon');
const centerIconData = centerIconLayer
? await loadCenterIcon(centerIconLayer.centerIcon)
: null;
// 输出:完整渲染数据
@@ -332,10 +349,10 @@ const renderData = {
- JSON 解析
- 基础验证(必填字段检查)
3. ✅ 编写单元测试:`configParser.test.ts`
4. ✅ 测试用例:解析 `demo.json.conf`
4. ✅ 测试用例:解析 `demo.json`
**验收标准:**
- 能成功解析 `demo.json.conf` 为 LuopanConfig 对象
- 能成功解析 `demo.json` 为 LuopanConfig 对象
- 所有必填字段验证通过
- 测试覆盖率 > 80%
@@ -417,34 +434,15 @@ export class ColorResolver {
**核心代码:**
```typescript
export function parseMultiText(
// 仅保留名称,具体实现参考前文 splitMultiTextUnits
export function splitMultiTextUnits(
content: string,
aStart: number,
aEnd: number,
svgIconPath: string = 'src/assets/icons/'
): TextUnit[] {
const parts = content.split('|').map(s => s.trim());
const ratios = getLayoutRatio(parts.length);
const totalAngle = aEnd - aStart;
const units: TextUnit[] = [];
let currentAngle = aStart;
for (let i = 0; i < parts.length; i++) {
const unitAngle = totalAngle * ratios[i];
const isSvg = parts[i].endsWith('.svg');
units.push({
content: parts[i],
aStart: currentAngle,
aEnd: currentAngle + unitAngle,
isSvg,
});
currentAngle += unitAngle;
}
return units;
// 参考前面代码
return [];
}
```
@@ -503,7 +501,7 @@ export class SectorBuilder {
// 解析多文本单元
const content = sectorConfig?.content ?? '';
const textUnits = content.includes('|')
? parseMultiText(content, aStart, aEnd)
? splitMultiTextUnits(content, aStart, aEnd)
: [{ content, aStart, aEnd, isSvg: content.endsWith('.svg') }];
// 确定是否显示分割线
@@ -553,7 +551,7 @@ export class SectorBuilder {
```
**验收标准:**
- 完整解析 `demo.json.conf` 所有 layer
- 完整解析 `demo.json` 所有 layer
- 颜色优先级正确
- groupSplit 逻辑正确
- 多文本单元正确拆分
@@ -624,14 +622,17 @@ export function buildDegreeRing(config: DegreeRingConfig): {
ticks.push({ angle, type, length, startR, endR });
}
// 生成度数标签
// 生成度数标签(使用 textPath 保持方向一致)
const labels: DegreeLabel[] = [];
if (config.showDegree === 1) {
const r = (rInner + rOuter) / 2;
for (let angle = 0; angle < 360; angle += config.majorTick) {
labels.push({
angle,
text: angle.toString(),
r: (rInner + rOuter) / 2
r,
textPathId: `degree-label-${angle}`,
textPath: generateTextPath(r, r, angle - 4, angle + 4)
});
}
}
@@ -652,7 +653,7 @@ export function buildDegreeRing(config: DegreeRingConfig): {
**验收标准:**
- 刻度线长度正确major > minor > micro
- mode 模式正确inner, outer, both
- 度数标签位置准确
- 度数标签方向与扇区文字一致(自动翻转)
- 中心图标加载和旋转正常
---
@@ -711,17 +712,21 @@ export function useLuopan(configPathOrObject: string | LuopanConfig) {
// 构建扇区
const sectorBuilder = new SectorBuilder(colorResolver);
sectors.value = configObj.layers.flatMap((layer, i) =>
sectorBuilder.buildLayer(layer, i)
layer.type === 'centerIcon' || layer.type === 'degreeRing'
? []
: sectorBuilder.buildLayer(layer, i)
);
// 构建刻度环
if (configObj.degreeRing) {
degreeRing.value = buildDegreeRing(configObj.degreeRing);
// 构建刻度环(从 layers 中提取)
const degreeRingLayer = configObj.layers.find(layer => layer.type === 'degreeRing');
if (degreeRingLayer) {
degreeRing.value = buildDegreeRing(degreeRingLayer.degreeRing);
}
// 加载中心图标
if (configObj.centerIcon) {
centerIcon.value = await loadCenterIcon(configObj.centerIcon);
// 加载中心图标(从 layers 中提取)
const centerIconLayer = configObj.layers.find(layer => layer.type === 'centerIcon');
if (centerIconLayer) {
centerIcon.value = await loadCenterIcon(centerIconLayer.centerIcon);
}
} catch (e) {
@@ -807,12 +812,12 @@ export function useLuopan(configPathOrObject: string | LuopanConfig) {
<script setup lang="ts">
const { config, sectors, degreeRing, centerIcon, loading, error } =
useLuopan('/demo.json.conf');
useLuopan('/demo.json');
</script>
```
**验收标准:**
- 成功加载和渲染 `demo.json.conf`
- 成功加载和渲染 `demo.json`
- 所有 layer 正确显示
- 多文本单元正确拆分显示
- 刻度环和中心图标正常渲染
@@ -844,8 +849,8 @@ const { config, sectors, degreeRing, centerIcon, loading, error } =
**测试策略:**
```typescript
describe('完整流程测试', () => {
it('应正确解析和渲染 demo.json.conf', async () => {
const config = await loadConfig('/demo.json.conf');
it('应正确解析和渲染 demo.json', async () => {
const config = await loadConfig('/demo.json');
const sectors = buildAllSectors(config);
// 验证扇区数量
@@ -927,6 +932,7 @@ describe('完整流程测试', () => {
2. 每个 `TextUnit` 包含独立的 `aStart`, `aEnd`, `content`
3. 渲染时为每个单元生成独立的 `<textPath>` 元素
4. 使用 `getLayoutRatio()` 确保角度分配准确
5. 字体大小与布局按单元独立计算:每个 `TextUnit` 用自身 `aStart/aEnd` + `content.length` 调用 `calculateSectorFontSize`,并基于相同角度范围生成 `generateTextPath` / `generateVerticalTextPath`
**代码示例:**
```typescript
@@ -1091,7 +1097,7 @@ describe('ColorResolver', () => {
```typescript
describe('完整渲染流程', () => {
it('应从 JSON 配置生成完整罗盘', async () => {
const config = await loadConfig('/demo.json.conf');
const config = await loadConfig('/demo.json');
const { sectors, degreeRing, centerIcon } = buildLuopan(config);
// 验证总扇区数

View File

@@ -2,15 +2,6 @@
<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" />
显示辅助线
@@ -31,8 +22,12 @@
</div>
</div>
<div v-if="loading" class="status">加载中...</div>
<div v-else-if="error" class="status">错误: {{ error.message }}</div>
<!-- SVG 画布容器 -->
<div
v-else
class="svg-container"
@wheel.prevent="handleWheel"
@mousedown="handleMouseDown"
@@ -43,7 +38,7 @@
<svg
:width="size"
:height="size"
:viewBox="`${-size / 2} ${-size / 2} ${size} ${size}`"
:viewBox="`${viewBoxMin} ${viewBoxMin} ${viewBoxSize} ${viewBoxSize}`"
class="svg"
:style="{
transform: `scale(${scale}) translate(${panX}px, ${panY}px)`,
@@ -52,13 +47,14 @@
>
<!-- 背景 -->
<rect
:x="-size / 2"
:y="-size / 2"
:width="size"
:height="size"
fill="white"
:x="viewBoxMin"
:y="viewBoxMin"
:width="viewBoxSize"
:height="viewBoxSize"
:fill="config?.background || '#ffffff'"
/>
<g v-memo="[sectors]">
<!-- 扇区 -->
<g>
<path
@@ -67,7 +63,7 @@
:d="s.path"
:fill="s.fill"
stroke="#1f2937"
stroke-opacity="0.15"
:stroke-opacity="s.groupSplitVisible === false ? 0 : 0.15"
:stroke-width="SECTOR_STROKE_WIDTH"
/>
</g>
@@ -85,8 +81,108 @@
</template>
</g>
<!-- 定义文字路径 -->
<defs>
<path
v-for="s in sectors"
:key="s.textPathId"
:id="s.textPathId"
:d="s.textPath"
fill="none"
/>
<template v-for="s in sectors" :key="s.key + '-units'">
<path
v-for="unit in s.textUnits || []"
:key="unit.textPathId"
:id="unit.textPathId"
:d="unit.textPath"
fill="none"
/>
</template>
</defs>
<!-- 文字标签沿圆弧排列 -->
<g>
<template v-for="s in sectors" :key="s.key + '-text'">
<template v-if="s.textUnits">
<g v-for="unit in s.textUnits || []" :key="unit.textPathId">
<text
v-if="!unit.isSvg"
:font-size="unit.fontSize"
:fill="s.textColor"
:writing-mode="unit.isVertical ? 'tb' : undefined"
:glyph-orientation-vertical="unit.isVertical ? '0' : undefined"
:text-anchor="unit.isVertical ? 'middle' : undefined"
style="user-select: none"
>
<textPath
:href="'#' + unit.textPathId"
startOffset="50%"
text-anchor="middle"
dominant-baseline="central"
>
{{ unit.content }}
</textPath>
</text>
<image
v-else
:href="unit.svgPath"
:x="getUnitSvgBox(s, unit).x"
:y="getUnitSvgBox(s, unit).y"
:width="getUnitSvgBox(s, unit).size"
:height="getUnitSvgBox(s, unit).size"
:opacity="s.textColor ? 1 : 1"
/>
</g>
</template>
<template v-else>
<text
v-if="!s.isSvgContent"
: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>
<image
v-else
:href="s.svgPath"
:x="getSectorSvgBox(s).x"
:y="getSectorSvgBox(s).y"
:width="getSectorSvgBox(s).size"
:height="getSectorSvgBox(s).size"
/>
</template>
</template>
</g>
</g>
<!-- 形心点仅辅助线开启时显示 -->
<g v-if="showGuides" v-memo="[sectors]">
<circle
v-for="s in sectors"
:key="s.key + '-center'"
:cx="s.cx"
:cy="s.cy"
r="2.2"
fill="#ef4444"
opacity="0.8"
/>
</g>
<!-- 辅助线圆环分度线 -->
<g v-if="showGuides" stroke="#111827" stroke-opacity="0.18">
<g v-if="showGuides" v-memo="[rings, anglesDeg, outerMost]" stroke="#111827" stroke-opacity="0.18">
<!-- 圆环 -->
<circle
v-for="r in rings"
@@ -107,49 +203,69 @@
/>
</g>
<!-- 定义文字路径 -->
<!-- 刻度环 -->
<g v-if="degreeRing" v-memo="[degreeRing]">
<defs>
<path
v-for="s in sectors"
:key="s.textPathId"
:id="s.textPathId"
:d="s.textPath"
v-for="label in degreeRing.labels || []"
:key="label.textPathId"
:id="label.textPathId"
:d="label.textPath"
fill="none"
/>
</defs>
<!-- 文字标签沿圆弧排列 -->
<g>
<circle
:r="degreeRing.ring.rOuter"
fill="none"
:stroke="degreeRing.ring.color"
:stroke-opacity="degreeRing.ring.opacity"
:stroke-width="SECTOR_STROKE_WIDTH"
/>
<circle
:r="degreeRing.ring.rInner"
fill="none"
:stroke="degreeRing.ring.color"
:stroke-opacity="degreeRing.ring.opacity"
:stroke-width="SECTOR_STROKE_WIDTH"
/>
<line
v-for="tick in degreeRing.ticks"
:key="'tick-' + tick.angle + '-' + tick.startR + '-' + tick.endR"
:x1="tick.x1"
:y1="tick.y1"
:x2="tick.x2"
:y2="tick.y2"
:stroke="degreeRing.tickColor"
:stroke-width="SECTOR_STROKE_WIDTH"
/>
<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"
v-for="label in degreeRing.labels || []"
:key="'degree-' + label.angle"
:fill="degreeRing.tickColor"
:font-size="label.fontSize"
style="user-select: none"
>
<textPath
:href="'#' + s.textPathId"
:href="'#' + label.textPathId"
startOffset="50%"
text-anchor="middle"
dominant-baseline="central"
>
{{ s.label }}
{{ label.text }}
</textPath>
</text>
</g>
<!-- 可选画一个小点看形心位置 -->
<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 v-if="centerIcon" v-memo="[centerIcon]">
<image
:href="centerIcon.svgPath"
:width="centerIcon.rIcon * 2"
:height="centerIcon.rIcon * 2"
:x="-centerIcon.rIcon"
:y="-centerIcon.rIcon"
:opacity="centerIcon.opacity"
:transform="`rotate(${centerIcon.rotation})`"
/>
</g>
</svg>
@@ -166,90 +282,168 @@
<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';
import { DEFAULT_SIZE, DEFAULT_TEXT_RADIAL_POSITION, SECTOR_STROKE_WIDTH } from './constants';
import type { LuopanConfig, Sector, TextRadialPosition, TextUnit } from './types';
import { annularSectorCentroid } from './utils';
/**
* Props
*/
interface Props {
size?: number;
configPath?: string;
config?: LuopanConfig;
}
const props = withDefaults(defineProps<Props>(), {
size: DEFAULT_SIZE,
configPath: '/demo.json',
});
/**
* 状态
*/
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);
let nextScale = scale.value;
let nextPanX = panX.value;
let nextPanY = panY.value;
let rafId: number | null = null;
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 resolveConfigPath = () => {
if (typeof window === 'undefined') return props.configPath;
const param = new URLSearchParams(window.location.search).get('config');
return param ? `/${param}` : props.configPath;
};
const configInput = props.config ?? resolveConfigPath();
/**
* 使用罗盘逻辑
*/
const { anglesDeg, rings, outerMost, sectors, toXY } =
useLuopan(currentExample, textRadialPosition);
const {
config,
sectors,
degreeRing,
centerIcon,
anglesDeg,
rings,
outerMost,
toXY,
loading,
error,
} = useLuopan(configInput, textRadialPosition);
// viewBox 以实际外半径为准,确保完整显示配置中的大半径罗盘
const viewBoxSize = computed(() => {
const radius = outerMost.value > 0 ? outerMost.value : props.size / 2;
return radius * 2;
});
const viewBoxMin = computed(() => -viewBoxSize.value / 2);
// 使用 rAF 合并缩放/拖拽更新,减少渲染频率
const scheduleTransform = () => {
if (rafId !== null) return;
const requestFrame =
typeof requestAnimationFrame === 'function'
? requestAnimationFrame
: (cb: FrameRequestCallback) => setTimeout(() => cb(Date.now()), 16);
rafId = requestFrame(() => {
scale.value = nextScale;
panX.value = nextPanX;
panY.value = nextPanY;
rafId = null;
});
};
const setScale = (value: number) => {
nextScale = Math.max(0.5, Math.min(5, value));
scheduleTransform();
};
const setPan = (x: number, y: number) => {
nextPanX = x;
nextPanY = y;
scheduleTransform();
};
const getUnitSvgBox = (sector: Sector, unit: TextUnit) => {
// SVG 图标与文字共享布局规则,使用单元角度范围计算形心位置
const centroid = annularSectorCentroid({
rInner: sector.rInner,
rOuter: sector.rOuter,
aStartDeg: unit.aStart,
aEndDeg: unit.aEnd,
});
const size = unit.fontSize ?? sector.fontSize;
return {
x: centroid.cx - size / 2,
y: centroid.cy - size / 2,
size,
};
};
const getSectorSvgBox = (sector: Sector) => {
const size = sector.fontSize;
return {
x: sector.cx - size / 2,
y: sector.cy - size / 2,
size,
};
};
/**
* 缩放功能
*/
const zoomIn = () => {
if (scale.value < 5) {
scale.value = Math.min(5, scale.value + 0.2);
setScale(nextScale + 0.2);
}
};
const zoomOut = () => {
if (scale.value > 0.5) {
scale.value = Math.max(0.5, scale.value - 0.2);
setScale(nextScale - 0.2);
}
};
const resetZoom = () => {
scale.value = 1;
panX.value = 0;
panY.value = 0;
nextScale = 1;
nextPanX = 0;
nextPanY = 0;
scheduleTransform();
};
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;
setScale(nextScale + delta);
};
const handleMouseDown = (e: MouseEvent) => {
isDragging.value = true;
dragStartX.value = e.clientX;
dragStartY.value = e.clientY;
dragStartPanX.value = panX.value;
dragStartPanY.value = panY.value;
dragStartPanX.value = nextPanX;
dragStartPanY.value = nextPanY;
};
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 dx = (e.clientX - dragStartX.value) / nextScale;
const dy = (e.clientY - dragStartY.value) / nextScale;
setPan(dragStartPanX.value + dx, dragStartPanY.value + dy);
}
};
@@ -326,6 +520,14 @@ button.active {
justify-content: center;
}
.status {
padding: 16px;
border-radius: 12px;
background: #f3f4f6;
color: #374151;
font-size: 14px;
}
.svg {
background: #fff;
transition: transform 0.1s ease-out;

19
src/centerIcon.ts Normal file
View File

@@ -0,0 +1,19 @@
import type { CenterIconConfig, CenterIconData } from './types';
const ensureTrailingSlash = (input: string): string =>
input.endsWith('/') ? input : `${input}/`;
export async function loadCenterIcon(
config: CenterIconConfig,
svgIconPath: string = 'src/assets/icons/'
): Promise<CenterIconData> {
const basePath = ensureTrailingSlash(svgIconPath);
// 保持异步签名,便于后续替换为实际加载/解析逻辑
return {
rIcon: config.rIcon,
opacity: config.opacity,
svgPath: `${basePath}${config.name}`,
rotation: config.rotation ?? 0,
};
}

71
src/colorResolver.ts Normal file
View File

@@ -0,0 +1,71 @@
import type { ThemeConfig, SectorLayerConfig, SectorConfig } from './types';
const HEX_COLOR_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
export const applyPatternColoring = (
divisions: number,
color: string,
num: number,
interval: number
): Map<number, string> => {
const colorMap = new Map<number, string>();
if (divisions <= 0 || num <= 0) return colorMap;
if (interval === 0) {
for (let i = 0; i < divisions; i++) {
colorMap.set(i, color);
}
return colorMap;
}
let currentIndex = 0;
while (currentIndex < divisions) {
for (let i = 0; i < num && currentIndex < divisions; i++) {
colorMap.set(currentIndex, color);
currentIndex++;
}
currentIndex += interval;
}
return colorMap;
};
export class ColorResolver {
private theme: ThemeConfig;
private background: string;
constructor(theme: ThemeConfig, background: string) {
this.theme = theme;
this.background = background;
}
resolveColor(ref?: string): string {
if (!ref) return this.background;
if (HEX_COLOR_RE.test(ref)) return ref;
return this.theme.colorPalettes[ref] ?? this.background;
}
resolveLayerColors(layer: SectorLayerConfig): Map<number, string> {
const colorMap = new Map<number, string>();
if (!layer.colorRef || !layer.num) {
return colorMap;
}
const interval = layer.interval ?? 0;
const color = this.resolveColor(layer.colorRef);
return applyPatternColoring(layer.divisions, color, layer.num, interval);
}
resolveSectorColor(
layerColorMap: Map<number, string>,
sector: SectorConfig | undefined,
sectorIndex: number
): string {
if (sector?.colorRef) {
return this.resolveColor(sector.colorRef);
}
return layerColorMap.get(sectorIndex) ?? this.background;
}
}

View File

@@ -2,87 +2,142 @@
* 罗盘业务逻辑组合函数
*/
import { computed, type Ref, type ComputedRef } from 'vue';
import type { Example, Sector, TextRadialPosition } from '../types';
import {
polarToXY,
generateSectorData,
} from '../utils';
import { computed, ref, readonly, watch, type Ref } from 'vue';
import type {
CenterIconData,
DegreeRingData,
LayerConfig,
LuopanConfig,
Sector,
SectorLayerConfig,
TextRadialPosition,
} from '../types';
import { polarToXY } from '../utils';
import { parseConfig } from '../configParser';
import { ColorResolver } from '../colorResolver';
import { SectorBuilder } from '../sectorBuilder';
import { buildDegreeRing } from '../degreeRing';
import { loadCenterIcon } from '../centerIcon';
const isSectorLayer = (layer: LayerConfig): layer is SectorLayerConfig =>
layer.type !== 'centerIcon' && layer.type !== 'degreeRing';
const findDegreeRingLayer = (layers: LayerConfig[]) =>
layers.find((layer) => layer.type === 'degreeRing');
const findCenterIconLayer = (layers: LayerConfig[]) =>
layers.find((layer) => layer.type === 'centerIcon');
/**
* 罗盘逻辑 Hook
* @param exampleRef 当前示例的响应式引用
* @param textRadialPositionRef 文字径向位置的响应式引用
* @param configPathOrObject 配置文件路径或配置对象
* @param textRadialPositionRef 文字径向位置的响应式引用(可选)
* @returns 罗盘相关的计算属性和方法
*/
export function useLuopan(
exampleRef: Ref<Example>,
textRadialPositionRef: Ref<TextRadialPosition>
configPathOrObject: string | LuopanConfig,
textRadialPositionRef?: Ref<TextRadialPosition>
) {
/**
* 角度分割点列表
*/
const anglesDeg = computed(() => exampleRef.value.angles);
const config = ref<LuopanConfig | null>(null);
const sectors = ref<Sector[]>([]);
const degreeRing = ref<DegreeRingData | null>(null);
const centerIcon = ref<CenterIconData | null>(null);
const loading = ref(false);
const error = ref<Error | null>(null);
/**
* 圆环半径列表
*/
const rings = computed(() => exampleRef.value.radii);
const textRadialPosition = computed(
() => textRadialPositionRef?.value ?? 'middle'
);
const buildSectors = (configObj: LuopanConfig) => {
const resolver = new ColorResolver(configObj.theme, configObj.background);
const builder = new SectorBuilder(resolver, {
textRadialPosition: textRadialPosition.value,
});
const sectorLayers = configObj.layers.filter(isSectorLayer);
return sectorLayers.flatMap((layer, index) => builder.buildLayer(layer, index));
};
const loadConfig = async () => {
try {
loading.value = true;
error.value = null;
let configObj: LuopanConfig;
if (typeof configPathOrObject === 'string') {
const jsonText = await fetch(configPathOrObject).then((res) => res.text());
configObj = parseConfig(jsonText);
} else {
configObj = configPathOrObject;
}
config.value = configObj;
sectors.value = buildSectors(configObj);
const degreeRingLayer = findDegreeRingLayer(configObj.layers);
degreeRing.value = degreeRingLayer
? buildDegreeRing(degreeRingLayer.degreeRing)
: null;
const centerIconLayer = findCenterIconLayer(configObj.layers);
centerIcon.value = centerIconLayer
? await loadCenterIcon(centerIconLayer.centerIcon)
: null;
} catch (err) {
error.value = err as Error;
} finally {
loading.value = false;
}
};
// 文字位置切换后仅重建扇区
watch(textRadialPosition, () => {
if (config.value) {
sectors.value = buildSectors(config.value);
}
});
loadConfig();
const sectorLayers = computed(() =>
config.value ? config.value.layers.filter(isSectorLayer) : []
);
const rings = computed(() => sectorLayers.value.map((layer) => layer.rOuter));
const anglesDeg = computed(() => {
const firstLayer = sectorLayers.value[0];
if (!firstLayer || firstLayer.divisions <= 0) return [];
const step = 360 / firstLayer.divisions;
const start = firstLayer.startAngle ?? 0;
return Array.from({ length: firstLayer.divisions + 1 }, (_, i) => start + i * step);
});
/**
* 最外层半径
*/
const outerMost = computed(() => {
const radii = exampleRef.value.radii;
return radii[radii.length - 1];
});
if (!config.value) return 0;
if (typeof config.value.outerRadius === 'number') return config.value.outerRadius;
/**
* 生成所有扇区数据
*/
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,
textRadialPosition: textRadialPositionRef.value,
});
res.push(sector);
const radii = sectorLayers.value.map((layer) => layer.rOuter);
const degreeRingLayer = findDegreeRingLayer(config.value.layers);
if (degreeRingLayer) {
radii.push(degreeRingLayer.degreeRing.rOuter);
}
}
return res;
return radii.length > 0 ? Math.max(...radii) : 0;
});
/**
* 极坐标转 XY暴露给模板使用
*/
const toXY = polarToXY;
return {
config: readonly(config),
sectors: readonly(sectors),
degreeRing: readonly(degreeRing),
centerIcon: readonly(centerIcon),
anglesDeg,
rings,
outerMost,
sectors,
toXY,
toXY: polarToXY,
loading: readonly(loading),
error: readonly(error),
reload: loadConfig,
};
}

83
src/configParser.ts Normal file
View File

@@ -0,0 +1,83 @@
import type { LuopanConfig, ThemeConfig } from './types';
const isObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
const assertCondition = (condition: boolean, message: string): void => {
if (!condition) {
throw new Error(message);
}
};
export const stripJsonComments = (input: string): string => {
let output = '';
let inString = false;
let escaped = false;
for (let i = 0; i < input.length; i++) {
const char = input[i];
const next = input[i + 1];
if (!inString && char === '-' && next === '-') {
while (i < input.length && input[i] !== '\n') {
i++;
}
if (i < input.length) {
output += '\n';
}
continue;
}
if (char === '"' && !escaped) {
inString = !inString;
}
if (escaped) {
escaped = false;
} else if (char === '\\' && inString) {
escaped = true;
}
output += char;
}
return output;
};
const normalizeTheme = (theme: Record<string, unknown>): ThemeConfig => {
const palettes = theme.colorPalettes;
assertCondition(isObject(palettes), 'theme.colorPalettes 必须为对象');
return {
name: typeof theme.name === 'string' ? theme.name : undefined,
colorPalettes: palettes,
};
};
export const parseConfig = (jsonText: string): LuopanConfig => {
const cleanText = stripJsonComments(jsonText);
let parsed: unknown;
try {
parsed = JSON.parse(cleanText);
} catch (error) {
const message = error instanceof Error ? error.message : '未知错误';
throw new Error(`配置解析失败: ${message}`);
}
assertCondition(isObject(parsed), '配置必须为对象');
const config = parsed as Record<string, unknown>;
assertCondition(typeof config.name === 'string', 'name 为必填字符串');
assertCondition(typeof config.background === 'string', 'background 为必填字符串');
assertCondition(isObject(config.theme), 'theme 为必填对象');
assertCondition(Array.isArray(config.layers), 'layers 为必填数组');
return {
name: config.name,
description: typeof config.description === 'string' ? config.description : undefined,
background: config.background,
outerRadius: typeof config.outerRadius === 'number' ? config.outerRadius : undefined,
theme: normalizeTheme(config.theme),
layers: config.layers as LuopanConfig['layers'],
};
};

137
src/degreeRing.ts Normal file
View File

@@ -0,0 +1,137 @@
import type { DegreeRingConfig, TickMark, DegreeLabel, DegreeRingData } from './types';
import { generateTextPath, polarToXY } from './utils';
// 根据刻度级别计算长度,后续由 clamp 处理最小值
const resolveTickLength = (config: DegreeRingConfig, type: TickMark['type']): number => {
const step = config.tickLengthStep ?? 0;
if (type === 'major') return config.tickLength;
if (type === 'minor') return config.tickLength - step;
return config.tickLength - 2 * step;
};
const clampTickLength = (value: number): number => (value < 1 ? 1 : value);
export function buildDegreeRing(config: DegreeRingConfig): DegreeRingData {
const ticks: TickMark[] = [];
const { rInner, rOuter, mode } = config;
const labelFontSize = 8;
const majorTick = Math.max(1, config.majorTick);
const minorTick = Math.max(1, config.minorTick);
const microTick = Math.max(1, config.microTick);
for (let angle = 0; angle < 360; angle++) {
let type: TickMark['type'] | null = null;
if (angle % majorTick === 0) {
type = 'major';
} else if (angle % minorTick === 0) {
type = 'minor';
} else if (angle % microTick === 0) {
type = 'micro';
}
if (!type) continue;
const length = clampTickLength(resolveTickLength(config, type));
// 预计算坐标,避免渲染时重复三角计算
if (mode === 'inner') {
const start = polarToXY(angle, rInner);
const end = polarToXY(angle, rInner + length);
ticks.push({
angle,
type,
length,
startR: rInner,
endR: rInner + length,
x1: start.x,
y1: start.y,
x2: end.x,
y2: end.y,
});
continue;
}
if (mode === 'outer') {
const start = polarToXY(angle, rOuter - length);
const end = polarToXY(angle, rOuter);
ticks.push({
angle,
type,
length,
startR: rOuter - length,
endR: rOuter,
x1: start.x,
y1: start.y,
x2: end.x,
y2: end.y,
});
continue;
}
// both: 同角度生成内外两条刻度线
const innerStart = polarToXY(angle, rInner);
const innerEnd = polarToXY(angle, rInner + length);
ticks.push({
angle,
type,
length,
startR: rInner,
endR: rInner + length,
x1: innerStart.x,
y1: innerStart.y,
x2: innerEnd.x,
y2: innerEnd.y,
});
const outerStart = polarToXY(angle, rOuter - length);
const outerEnd = polarToXY(angle, rOuter);
ticks.push({
angle,
type,
length,
startR: rOuter - length,
endR: rOuter,
x1: outerStart.x,
y1: outerStart.y,
x2: outerEnd.x,
y2: outerEnd.y,
});
}
const labels: DegreeLabel[] = [];
if (config.showDegree === 1) {
for (let angle = 0; angle < 360; angle += majorTick) {
const r = (rInner + rOuter) / 2;
const text = angle.toString();
const estimatedArcLength = text.length * labelFontSize * 1.1;
const estimatedSpan = r > 0 ? (estimatedArcLength / r) * (180 / Math.PI) : 0;
const maxSpan = Math.max(majorTick * 0.9, 4);
const span = Math.min(Math.max(estimatedSpan, 4), maxSpan);
const aStart = angle - span / 2;
const aEnd = angle + span / 2;
// 使用 textPath 保持度数方向与扇区文字一致
labels.push({
angle,
text,
r,
fontSize: labelFontSize,
textPathId: `degree-label-${angle}`,
textPath: generateTextPath(r, r, aStart, aEnd, 'middle'),
});
}
}
return {
ticks,
tickColor: config.tickColor,
ring: {
rInner,
rOuter,
color: config.ringColor,
opacity: config.opacity,
},
labels: labels.length > 0 ? labels : undefined,
};
}

View File

@@ -5,6 +5,17 @@
// 类型导出
export type {
Example,
LuopanConfig,
ThemeConfig,
CenterIconConfig,
DegreeRingConfig,
DegreeRingData,
DegreeLabel,
CenterIconData,
LayerConfig,
SectorConfig,
TextUnit,
TickMark,
Sector,
PolarPoint,
AnnularSectorParams,
@@ -32,3 +43,13 @@ export type { UseLuopanReturn } from './composables/useLuopan';
// 常量导出
export { EXAMPLES, DEFAULT_SIZE, SECTOR_INSET_DISTANCE } from './constants';
// 配置解析导出
export { parseConfig, stripJsonComments } from './configParser';
// 解析工具导出
export { ColorResolver, applyPatternColoring } from './colorResolver';
export { splitMultiTextUnits } from './multiTextParser';
export { SectorBuilder } from './sectorBuilder';
export { buildDegreeRing } from './degreeRing';
export { loadCenterIcon } from './centerIcon';

41
src/multiTextParser.ts Normal file
View File

@@ -0,0 +1,41 @@
import type { TextUnit } from './types';
import { getLayoutRatio } from './constants';
const ensureTrailingSlash = (input: string): string =>
input.endsWith('/') ? input : `${input}/`;
export function splitMultiTextUnits(
content: string,
aStart: number,
aEnd: number,
svgIconPath: string = 'src/assets/icons/'
): TextUnit[] {
const parts = content.split('|').map((part) => part.trim());
if (parts.length === 0 || (parts.length === 1 && parts[0] === '')) {
return [];
}
const ratios = getLayoutRatio(parts.length);
const totalAngle = aEnd - aStart;
const units: TextUnit[] = [];
const basePath = ensureTrailingSlash(svgIconPath);
let currentAngle = aStart;
for (let i = 0; i < parts.length; i++) {
const unitAngle = totalAngle * ratios[i];
const contentPart = parts[i];
const isSvg = contentPart.toLowerCase().endsWith('.svg');
units.push({
content: contentPart,
aStart: currentAngle,
aEnd: currentAngle + unitAngle,
isSvg,
svgPath: isSvg ? `${basePath}${contentPart}` : undefined,
});
currentAngle += unitAngle;
}
return units;
}

218
src/sectorBuilder.ts Normal file
View File

@@ -0,0 +1,218 @@
import type { SectorLayerConfig, Sector, TextUnit, TextRadialPosition } from './types';
import { ColorResolver } from './colorResolver';
import { splitMultiTextUnits } from './multiTextParser';
import { SECTOR_INSET_DISTANCE, TEXT_LAYOUT_CONFIG } from './constants';
import {
annularSectorCentroid,
annularSectorInsetPath,
annularSectorPath,
calculateSectorFontSize,
generateTextPath,
generateVerticalTextPath,
getTextColorForBackground,
} from './utils';
const ensureTrailingSlash = (input: string): string =>
input.endsWith('/') ? input : `${input}/`;
interface SectorBuilderOptions {
textRadialPosition?: TextRadialPosition;
svgIconPath?: string;
}
export class SectorBuilder {
private colorResolver: ColorResolver;
private textRadialPosition: TextRadialPosition;
private svgIconPath: string;
constructor(colorResolver: ColorResolver, options: SectorBuilderOptions = {}) {
this.colorResolver = colorResolver;
this.textRadialPosition = options.textRadialPosition ?? 'middle';
this.svgIconPath = ensureTrailingSlash(options.svgIconPath ?? 'src/assets/icons/');
}
buildLayer(layer: SectorLayerConfig, layerIndex: number): Sector[] {
if (!layer.divisions || layer.divisions <= 0) return [];
const sectors: Sector[] = [];
const angleStep = 360 / layer.divisions;
const startAngle = layer.startAngle ?? 0;
// 预计算层级规律填色映射,供扇区颜色合并使用
const layerColorMap = this.colorResolver.resolveLayerColors(layer);
for (let i = 0; i < layer.divisions; i++) {
const aStart = startAngle + i * angleStep;
const aEnd = aStart + angleStep;
const sectorConfig = layer.sectors?.[i];
const rawContent = typeof sectorConfig?.content === 'string' ? sectorConfig.content.trim() : '';
const isMultiText = rawContent.includes('|');
// 颜色优先级sector > layer pattern > background
const fillColor = this.colorResolver.resolveSectorColor(layerColorMap, sectorConfig, i);
const textColor = getTextColorForBackground(fillColor);
const innerFill = (sectorConfig?.innerFill ?? layer.innerFill ?? 0) === 1;
const innerFillPath = innerFill
? annularSectorInsetPath(
layer.rInner,
layer.rOuter,
aStart,
aEnd,
SECTOR_INSET_DISTANCE
)
: undefined;
const sectorKey = `L${layerIndex}-P${i}`;
const textPathId = `text-path-${sectorKey}`;
const effectiveTextRadialPosition =
layerIndex === 0 ? 'centroid' : this.textRadialPosition;
const { isVertical, fontSize, textPath } = this.computeTextLayout(
layer.rInner,
layer.rOuter,
aStart,
aEnd,
rawContent.replace(/\|/g, ''),
effectiveTextRadialPosition
);
const textUnits = isMultiText
? this.buildTextUnits(
rawContent,
aStart,
aEnd,
layer.rInner,
layer.rOuter,
effectiveTextRadialPosition,
sectorKey
)
: undefined;
const isSvgContent = !isMultiText && rawContent.toLowerCase().endsWith('.svg');
const centroid = annularSectorCentroid({
rInner: layer.rInner,
rOuter: layer.rOuter,
aStartDeg: aStart,
aEndDeg: aEnd,
});
sectors.push({
key: sectorKey,
layerIndex,
pieIndex: i,
rInner: layer.rInner,
rOuter: layer.rOuter,
aStart,
aEnd,
aMidDeg: centroid.aMidDeg,
aMidRad: centroid.aMidRad,
cx: centroid.cx,
cy: centroid.cy,
fill: fillColor,
textColor,
label: isMultiText ? '' : rawContent,
path: annularSectorPath(layer.rInner, layer.rOuter, aStart, aEnd),
innerFillPath,
innerFillColor: innerFill ? fillColor : undefined,
textPath,
textPathId,
isVertical,
fontSize,
textUnits,
groupSplitVisible: this.shouldShowGroupSplit(layer, i),
isSvgContent,
svgPath: isSvgContent ? `${this.svgIconPath}${rawContent}` : undefined,
});
}
return sectors;
}
private buildTextUnits(
content: string,
aStart: number,
aEnd: number,
rInner: number,
rOuter: number,
textRadialPosition: TextRadialPosition,
sectorKey: string
): TextUnit[] {
const units = splitMultiTextUnits(content, aStart, aEnd, this.svgIconPath);
return units.map((unit, index) => {
const layout = this.computeTextLayout(
rInner,
rOuter,
unit.aStart,
unit.aEnd,
unit.content,
textRadialPosition
);
return {
...unit,
textPathId: `${sectorKey}-unit-${index}`,
textPath: layout.textPath,
fontSize: layout.fontSize,
isVertical: layout.isVertical,
};
});
}
private computeTextLayout(
rInner: number,
rOuter: number,
aStart: number,
aEnd: number,
content: string,
textRadialPosition: TextRadialPosition
): { isVertical: boolean; fontSize: number; textPath: string } {
const radialHeight = rOuter - rInner;
const deltaDeg = aEnd - aStart;
const centroid = annularSectorCentroid({
rInner,
rOuter,
aStartDeg: aStart,
aEndDeg: aEnd,
});
const arcWidth = (centroid.rho * Math.abs(deltaDeg) * Math.PI) / 180;
// 角度过窄时更适合竖排
const isVertical = arcWidth < radialHeight;
const textLength = Math.max(1, content.length);
const fontSize = calculateSectorFontSize(
rInner,
rOuter,
aStart,
aEnd,
textLength,
TEXT_LAYOUT_CONFIG.FONT_SIZE.MIN,
TEXT_LAYOUT_CONFIG.FONT_SIZE.MAX,
isVertical
);
const textPath = isVertical
? generateVerticalTextPath(
rInner,
rOuter,
aStart,
aEnd,
textRadialPosition,
textLength,
fontSize
)
: generateTextPath(rInner, rOuter, aStart, aEnd, textRadialPosition);
return { isVertical, fontSize, textPath };
}
private shouldShowGroupSplit(layer: SectorLayerConfig, sectorIndex: number): boolean {
// groupSplit 关闭时,仅保留分组边界线
if (layer.groupSplit !== false) return true;
if (!layer.num) return true;
const cycleLength = layer.num + (layer.interval ?? 0);
const posInCycle = sectorIndex % cycleLength;
return posInCycle >= layer.num - 1;
}
}

View File

@@ -19,6 +19,167 @@ export interface Example {
radii: number[];
}
/**
* JSON 配置根对象
*/
export interface LuopanConfig {
name: string;
description?: string;
background: string;
outerRadius?: number;
theme: ThemeConfig;
layers: LayerConfig[];
}
/**
* 主题配置
*/
export interface ThemeConfig {
name?: string;
colorPalettes: Record<string, string>;
}
/**
* 中心图标配置
*/
export interface CenterIconConfig {
rIcon: number;
opacity: number;
name: string;
rotation?: number;
}
/**
* 刻度环配置
*/
export interface DegreeRingConfig {
rInner: number;
rOuter: number;
showDegree: 0 | 1;
mode: 'inner' | 'outer' | 'both';
opacity: number;
tickLength: number;
tickLengthStep?: number;
majorTick: number;
minorTick: number;
microTick: number;
tickColor: string;
ringColor: string;
}
/**
* 层配置(扇区层/中心图标层/刻度环层)
*/
export type LayerConfig =
| SectorLayerConfig
| CenterIconLayerConfig
| DegreeRingLayerConfig;
/**
* 普通扇区层配置
*/
export interface SectorLayerConfig {
type?: 'sectors';
divisions: number;
rInner: number;
rOuter: number;
startAngle?: number;
colorRef?: string;
innerFill?: 0 | 1;
num?: number;
interval?: number;
groupSplit?: boolean;
sectors?: SectorConfig[];
}
/**
* 中心图标层配置
*/
export interface CenterIconLayerConfig {
type: 'centerIcon';
centerIcon: CenterIconConfig;
}
/**
* 刻度环层配置
*/
export interface DegreeRingLayerConfig {
type: 'degreeRing';
degreeRing: DegreeRingConfig;
}
/**
* 扇区配置
*/
export interface SectorConfig {
content?: string;
colorRef?: string;
innerFill?: 0 | 1;
}
/**
* 文本单元
*/
export interface TextUnit {
content: string;
aStart: number;
aEnd: number;
isSvg: boolean;
textPathId?: string;
textPath?: string;
svgPath?: string;
fontSize?: number;
isVertical?: boolean;
}
/**
* 刻度线数据
*/
export interface TickMark {
angle: number;
type: 'major' | 'minor' | 'micro';
length: number;
startR: number;
endR: number;
label?: string;
x1: number;
y1: number;
x2: number;
y2: number;
}
/**
* 刻度标签数据
*/
export interface DegreeLabel {
angle: number;
text: string;
r: number;
fontSize: number;
textPathId: string;
textPath: string;
}
/**
* 刻度环渲染数据
*/
export interface DegreeRingData {
ticks: TickMark[];
tickColor: string;
ring: { rInner: number; rOuter: number; color: string; opacity: number };
labels?: DegreeLabel[];
}
/**
* 中心图标渲染数据
*/
export interface CenterIconData {
rIcon: number;
opacity: number;
svgPath: string;
rotation: number;
}
/**
* 扇区配置
*/
@@ -65,6 +226,14 @@ export interface Sector {
fontSize: number;
/** 是否竖排文字 */
isVertical: boolean;
/** 多文本单元 */
textUnits?: TextUnit[];
/** 是否显示与下一个扇区的分割线 */
groupSplitVisible?: boolean;
/** 内容是否为 SVG */
isSvgContent?: boolean;
/** SVG 文件路径 */
svgPath?: string;
}
/**

View File

@@ -2,544 +2,105 @@
* Luopan 组件单元测试
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import Luopan from '../src/Luopan.vue';
import { EXAMPLES } from '../src/constants';
import type { LuopanConfig } from '../src/types';
const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0));
const baseConfig: LuopanConfig = {
name: '测试配置',
background: '#000000',
theme: {
colorPalettes: {
A: '#111111',
},
},
layers: [
{
divisions: 4,
rInner: 0,
rOuter: 80,
sectors: [{ content: '甲' }, { content: '乙' }, { content: '丙' }, { content: '丁' }],
},
{
type: 'degreeRing',
degreeRing: {
rInner: 90,
rOuter: 100,
showDegree: 1,
mode: 'both',
opacity: 0.3,
tickLength: 6,
tickLengthStep: 1,
majorTick: 10,
minorTick: 5,
microTick: 1,
tickColor: '#ffffff',
ringColor: '#ffffff',
},
},
{
type: 'centerIcon',
centerIcon: {
rIcon: 10,
opacity: 1,
name: 'centericon.svg',
},
},
],
};
describe('Luopan 组件', () => {
describe('基本渲染', () => {
it('应该成功渲染', () => {
const wrapper = mount(Luopan);
it('应该成功渲染', async () => {
const wrapper = mount(Luopan, { props: { config: baseConfig } });
await flushPromises();
expect(wrapper.exists()).toBe(true);
});
it('应该渲染工具栏', () => {
const wrapper = mount(Luopan);
const toolbar = wrapper.find('.toolbar');
expect(toolbar.exists()).toBe(true);
it('应该渲染工具栏控件', async () => {
const wrapper = mount(Luopan, { props: { config: baseConfig } });
await flushPromises();
expect(wrapper.find('.toolbar').exists()).toBe(true);
expect(wrapper.find('input[type="checkbox"]').exists()).toBe(true);
expect(wrapper.find('select').exists()).toBe(true);
expect(wrapper.find('.zoom-controls').exists()).toBe(true);
});
it('应该渲染 SVG 容器', () => {
const wrapper = mount(Luopan);
const svg = wrapper.find('svg');
expect(svg.exists()).toBe(true);
it('应该渲染 SVG 容器', async () => {
const wrapper = mount(Luopan, { props: { config: baseConfig } });
await flushPromises();
expect(wrapper.find('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('应该渲染扇区路径', async () => {
const wrapper = mount(Luopan, { props: { config: baseConfig } });
await flushPromises();
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')
const sectorPaths = wrapper.findAll('path[fill]').filter((path) =>
path.attributes('d')?.includes('M ')
);
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('应该渲染文字或 SVG 内容', async () => {
const wrapper = mount(Luopan, { props: { config: baseConfig } });
await flushPromises();
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);
it('应该渲染刻度环和中心图标', async () => {
const wrapper = mount(Luopan, { props: { config: baseConfig } });
await flushPromises();
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);
});
const images = wrapper.findAll('image');
expect(images.length).toBeGreaterThan(0);
});
});

25
tests/centerIcon.test.ts Normal file
View File

@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest';
import { loadCenterIcon } from '../src/centerIcon';
describe('centerIcon', () => {
it('应生成带默认旋转的中心图标数据', async () => {
const data = await loadCenterIcon({
rIcon: 50,
opacity: 0.8,
name: 'centericon.svg',
});
expect(data.rotation).toBe(0);
expect(data.svgPath).toBe('src/assets/icons/centericon.svg');
});
it('应支持自定义图标路径', async () => {
const data = await loadCenterIcon(
{ rIcon: 20, opacity: 1, name: 'icon.svg', rotation: 30 },
'/icons'
);
expect(data.svgPath).toBe('/icons/icon.svg');
expect(data.rotation).toBe(30);
});
});

View File

@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest';
import { ColorResolver, applyPatternColoring } from '../src/colorResolver';
import type { ThemeConfig, SectorLayerConfig, SectorConfig } from '../src/types';
describe('colorResolver', () => {
const theme: ThemeConfig = {
colorPalettes: {
: '#43A047',
: '#E53935',
},
};
it('resolveColor 应解析命名色并支持十六进制', () => {
const resolver = new ColorResolver(theme, '#000000');
expect(resolver.resolveColor('木')).toBe('#43A047');
expect(resolver.resolveColor('#ffffff')).toBe('#ffffff');
expect(resolver.resolveColor('不存在')).toBe('#000000');
});
it('applyPatternColoring 应按 num + interval 生成映射', () => {
const map = applyPatternColoring(6, '#111111', 2, 1);
expect(map.has(0)).toBe(true);
expect(map.has(1)).toBe(true);
expect(map.has(2)).toBe(false);
expect(map.has(3)).toBe(true);
expect(map.has(4)).toBe(true);
expect(map.has(5)).toBe(false);
});
it('resolveLayerColors 仅在提供 colorRef 与 num 时生效', () => {
const resolver = new ColorResolver(theme, '#000000');
const layer: SectorLayerConfig = {
divisions: 4,
rInner: 0,
rOuter: 10,
colorRef: '火',
num: 2,
interval: 1,
};
const map = resolver.resolveLayerColors(layer);
expect(map.size).toBe(3);
});
it('resolveSectorColor 应遵循 sector > layer > background 优先级', () => {
const resolver = new ColorResolver(theme, '#000000');
const layerMap = new Map<number, string>([[1, '#E53935']]);
const sector: SectorConfig = { content: 'x', colorRef: '木' };
expect(resolver.resolveSectorColor(layerMap, sector, 1)).toBe('#43A047');
expect(resolver.resolveSectorColor(layerMap, undefined, 1)).toBe('#E53935');
expect(resolver.resolveSectorColor(layerMap, undefined, 2)).toBe('#000000');
});
});

View File

@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseConfig, stripJsonComments } from '../src/configParser';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
describe('configParser', () => {
it('应移除 -- 行注释并保持 JSON 可解析', () => {
const raw = '{\n -- 注释\n "name": "demo" -- 行尾注释\n}';
const stripped = stripJsonComments(raw);
expect(stripped).not.toContain('-- 注释');
expect(() => JSON.parse(stripped)).not.toThrow();
});
it('应能解析 demo.json', () => {
const demoPath = resolve(__dirname, '..', 'public', 'demo.json');
const text = readFileSync(demoPath, 'utf8');
const config = parseConfig(text);
expect(config.name).toBe('demo');
expect(config.layers.length).toBeGreaterThan(0);
expect(config.theme.colorPalettes['木']).toBe('#43A047');
expect(config.layers.some((layer) => layer.type === 'centerIcon')).toBe(true);
expect(config.layers.some((layer) => layer.type === 'degreeRing')).toBe(true);
});
it('缺少必填字段时应抛错', () => {
const raw = '{ "background": "#000", "theme": { "colorPalettes": {} }, "layers": [] }';
expect(() => parseConfig(raw)).toThrow('name 为必填字符串');
});
});

60
tests/degreeRing.test.ts Normal file
View File

@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest';
import { buildDegreeRing } from '../src/degreeRing';
import type { DegreeRingConfig } from '../src/types';
describe('degreeRing', () => {
const baseConfig: DegreeRingConfig = {
rInner: 90,
rOuter: 100,
showDegree: 1,
mode: 'both',
opacity: 0.3,
tickLength: 6,
tickLengthStep: 1,
majorTick: 10,
minorTick: 5,
microTick: 1,
tickColor: '#ffffff',
ringColor: '#ffffff',
};
it('应生成主/次/微刻度并支持 both 模式', () => {
const data = buildDegreeRing(baseConfig);
const angle0 = data.ticks.filter((tick) => tick.angle === 0);
expect(angle0).toHaveLength(2);
expect(angle0[0].length).toBe(6);
expect(angle0[1].length).toBe(6);
});
it('应按 majorTick 生成度数标签', () => {
const data = buildDegreeRing(baseConfig);
expect(data.labels?.length).toBe(36);
expect(data.labels?.[0].text).toBe('0');
expect(data.labels?.[1].text).toBe('10');
});
it('inner 模式应从 rInner 向外绘制', () => {
const data = buildDegreeRing({ ...baseConfig, mode: 'inner' });
const tick = data.ticks[0];
expect(tick.startR).toBe(baseConfig.rInner);
expect(tick.endR).toBe(baseConfig.rInner + tick.length);
});
it('tickLengthStep 应影响次/微刻度长度', () => {
const data = buildDegreeRing({ ...baseConfig, tickLength: 6, tickLengthStep: 2 });
const major = data.ticks.find((tick) => tick.angle === 0);
const minor = data.ticks.find((tick) => tick.angle === 5 && tick.type === 'minor');
const micro = data.ticks.find((tick) => tick.angle === 1 && tick.type === 'micro');
expect(major?.length).toBe(6);
expect(minor?.length).toBe(4);
expect(micro?.length).toBe(2);
});
it('度数标签应生成 textPath', () => {
const data = buildDegreeRing(baseConfig);
const label = data.labels?.[0];
expect(label?.textPath).toContain('M ');
});
});

View File

@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest';
import { splitMultiTextUnits } from '../src/multiTextParser';
describe('multiTextParser', () => {
it('应按布局比例分配角度', () => {
const units = splitMultiTextUnits('甲乙|子|丙丁', 0, 120);
expect(units).toHaveLength(3);
expect(units[0].aStart).toBe(0);
expect(units[0].aEnd).toBe(30);
expect(units[1].aStart).toBe(30);
expect(units[1].aEnd).toBe(90);
expect(units[2].aStart).toBe(90);
expect(units[2].aEnd).toBe(120);
});
it('应识别 SVG 文件并拼接路径', () => {
const units = splitMultiTextUnits('a.svg|b', 0, 60, 'src/assets/icons');
expect(units[0].isSvg).toBe(true);
expect(units[0].svgPath).toBe('src/assets/icons/a.svg');
expect(units[1].isSvg).toBe(false);
});
it('空内容应返回空数组', () => {
expect(splitMultiTextUnits('', 0, 60)).toEqual([]);
});
});

View File

@@ -0,0 +1,96 @@
import { describe, it, expect } from 'vitest';
import { ColorResolver } from '../src/colorResolver';
import { SectorBuilder } from '../src/sectorBuilder';
import type { SectorLayerConfig, ThemeConfig } from '../src/types';
describe('sectorBuilder', () => {
const theme: ThemeConfig = {
colorPalettes: {
A: '#111111',
B: '#222222',
},
};
it('应生成正确数量的扇区', () => {
const resolver = new ColorResolver(theme, '#000000');
const builder = new SectorBuilder(resolver);
const layer: SectorLayerConfig = {
divisions: 4,
rInner: 0,
rOuter: 10,
};
const sectors = builder.buildLayer(layer, 0);
expect(sectors).toHaveLength(4);
});
it('应遵循 sector > layer > background 的颜色优先级', () => {
const resolver = new ColorResolver(theme, '#000000');
const builder = new SectorBuilder(resolver);
const layer: SectorLayerConfig = {
divisions: 4,
rInner: 0,
rOuter: 10,
colorRef: 'A',
num: 2,
interval: 1,
sectors: [{ content: 'x', colorRef: 'B' }],
};
const sectors = builder.buildLayer(layer, 0);
expect(sectors[0].fill).toBe('#222222');
expect(sectors[1].fill).toBe('#111111');
expect(sectors[2].fill).toBe('#000000');
});
it('groupSplit=false 时应隐藏组内分割线', () => {
const resolver = new ColorResolver(theme, '#000000');
const builder = new SectorBuilder(resolver);
const layer: SectorLayerConfig = {
divisions: 4,
rInner: 0,
rOuter: 10,
num: 2,
interval: 1,
groupSplit: false,
};
const sectors = builder.buildLayer(layer, 0);
expect(sectors[0].groupSplitVisible).toBe(false);
expect(sectors[1].groupSplitVisible).toBe(true);
expect(sectors[2].groupSplitVisible).toBe(true);
});
it('应生成多文本单元并按角度分配', () => {
const resolver = new ColorResolver(theme, '#000000');
const builder = new SectorBuilder(resolver);
const layer: SectorLayerConfig = {
divisions: 4,
rInner: 0,
rOuter: 10,
sectors: [{ content: '甲乙|子|丙丁' }],
};
const sector = builder.buildLayer(layer, 0)[0];
expect(sector.textUnits).toHaveLength(3);
expect(sector.textUnits?.[0].aStart).toBeCloseTo(0);
expect(sector.textUnits?.[0].aEnd).toBeCloseTo(22.5);
expect(sector.textUnits?.[1].aStart).toBeCloseTo(22.5);
expect(sector.textUnits?.[1].aEnd).toBeCloseTo(67.5);
});
it('应识别单文本 SVG 内容并生成路径', () => {
const resolver = new ColorResolver(theme, '#000000');
const builder = new SectorBuilder(resolver);
const layer: SectorLayerConfig = {
divisions: 1,
rInner: 0,
rOuter: 10,
sectors: [{ content: 'icon.svg' }],
};
const sector = builder.buildLayer(layer, 0)[0];
expect(sector.isSvgContent).toBe(true);
expect(sector.svgPath).toBe('src/assets/icons/icon.svg');
});
});

View File

@@ -5,73 +5,77 @@
import { describe, it, expect } from 'vitest';
import { ref } from 'vue';
import { useLuopan } from '../src/composables/useLuopan';
import type { Example, TextRadialPosition } from '../src/types';
import type { LuopanConfig, TextRadialPosition } from '../src/types';
describe('useLuopan', () => {
const createMockExample = (): Example => ({
name: '测试示例',
angles: [0, 90, 180, 270, 360],
radii: [50, 100, 150],
const createMockConfig = (): LuopanConfig => ({
name: '测试配置',
background: '#000000',
theme: {
colorPalettes: {
A: '#111111',
},
},
layers: [
{
divisions: 4,
rInner: 0,
rOuter: 80,
sectors: [{ content: '甲' }, { content: '乙' }, { content: '丙' }, { content: '丁' }],
},
],
});
describe('基本功能', () => {
it('应该返回所有必需的属性和方法', () => {
const example = ref(createMockExample());
describe('useLuopan', () => {
it('应该返回所有必需的属性和方法', async () => {
const textRadialPosition = ref<TextRadialPosition>('middle');
const result = useLuopan(example, textRadialPosition);
const result = useLuopan(createMockConfig(), textRadialPosition);
await result.reload();
expect(result).toHaveProperty('anglesDeg');
expect(result).toHaveProperty('rings');
expect(result).toHaveProperty('outerMost');
expect(result).toHaveProperty('sectors');
expect(result).toHaveProperty('degreeRing');
expect(result).toHaveProperty('centerIcon');
expect(result).toHaveProperty('toXY');
});
it('anglesDeg 应该返回角度数组', () => {
const example = ref(createMockExample());
it('anglesDeg 应该按 divisions 生成角度数组', async () => {
const textRadialPosition = ref<TextRadialPosition>('middle');
const { anglesDeg } = useLuopan(example, textRadialPosition);
const { anglesDeg, reload } = useLuopan(createMockConfig(), textRadialPosition);
await reload();
expect(anglesDeg.value).toEqual([0, 90, 180, 270, 360]);
});
it('rings 应该返回半径数组', () => {
const example = ref(createMockExample());
it('rings 应该返回 rOuter 列表', async () => {
const textRadialPosition = ref<TextRadialPosition>('middle');
const { rings, reload } = useLuopan(createMockConfig(), textRadialPosition);
await reload();
const { rings } = useLuopan(example, textRadialPosition);
expect(rings.value).toEqual([50, 100, 150]);
expect(rings.value).toEqual([80]);
});
it('outerMost 应该返回最外层半径', () => {
const example = ref(createMockExample());
it('outerMost 应该返回最半径', async () => {
const textRadialPosition = ref<TextRadialPosition>('middle');
const { outerMost, reload } = useLuopan(createMockConfig(), textRadialPosition);
await reload();
const { outerMost } = useLuopan(example, textRadialPosition);
expect(outerMost.value).toBe(150);
});
expect(outerMost.value).toBe(80);
});
describe('扇区生成', () => {
it('应该生成正确数量的扇区', () => {
const example = ref(createMockExample());
it('应该生成正确数量的扇区', async () => {
const textRadialPosition = ref<TextRadialPosition>('middle');
const { sectors, reload } = useLuopan(createMockConfig(), textRadialPosition);
await reload();
const { sectors } = useLuopan(example, textRadialPosition);
// 4个角度分割 × 3层 = 12个扇区
expect(sectors.value.length).toBe(12);
expect(sectors.value.length).toBe(4);
});
it('每个扇区应该有必需的属性', () => {
const example = ref(createMockExample());
it('扇区应包含必要字段', async () => {
const textRadialPosition = ref<TextRadialPosition>('middle');
const { sectors } = useLuopan(example, textRadialPosition);
const { sectors, reload } = useLuopan(createMockConfig(), textRadialPosition);
await reload();
sectors.value.forEach((sector) => {
expect(sector).toHaveProperty('key');
@@ -81,346 +85,8 @@ describe('useLuopan', () => {
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('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(/^#[0-9a-fA-F]{6}$/);
expect(sector.textColor).toMatch(/^#[0-9a-fA-F]{6}$/);
});
});
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('应该为窄扇区设置 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('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层
});
});
});

View File

@@ -189,15 +189,15 @@
rinner
router
showDegree -- 是否显示度数0不限时1显示如显示按10度间隔显示
mode -- inner表示刻度线在rinner的外部outter表示刻度线在routter的内部both两边都标注度数如有居于中间
mode -- inner表示刻度线在rInner的外部outer表示刻度线在rOuter的内部both两边都标注度数如有居于中间
opacity -- 圆的透明度,目的是有时候只需要显示刻度,而不用显示圆圈
-- ========================================
-- 360度刻度环配置 (360 Degree Scale Ring)
-- ========================================
"degreeRing": {
"rInner": 450, -- 刻度环内半径
"rOuter": 500, -- 刻度环外半径
"rInner": 350, -- 刻度环内半径
"rOuter": 380, -- 刻度环外半径
"showDegree": 1, -- 是否显示度数0=不显示1=显示(按 10° 间隔)
"mode": "both", -- 刻度线模式:"inner"在rInner外侧、"outer"在rOuter内侧、"both"(两侧都有,度数居中)
"opacity": 0.3, -- 圆环透明度0.0-1.0设置为0可以只显示刻度而不显示圆圈
@@ -206,7 +206,7 @@
"majorTick": 10, -- 主刻度间隔(度),如 10 表示每 10° 一个主刻度
"minorTick": 5, -- 次刻度间隔(度),如 2 表示每 2° 一个次刻度
"microTick": 1, -- 微刻度间隔(度),如 1 表示每 1° 一个微刻度
"tickColor": "#ffffff",-- 刻度线颜色
"ringColor": "#ffffff" -- 圆环颜色
"tickColor": "#000000",-- 刻度线颜色
"ringColor": "#000000" -- 圆环颜色
}