update at 2026-01-22 18:43:01
This commit is contained in:
175
public/demo.json
Normal file
175
public/demo.json
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -48,33 +48,7 @@
|
||||
"冷": "#1976D2", -- 冷蓝(冷色调)
|
||||
"强": "#D32F2F", -- 强烈红(高饱和度)
|
||||
"\u8f6f": "#FFE0B2" -- 柔和杏(低饱和度)
|
||||
}
|
||||
},
|
||||
|
||||
-- ========================================
|
||||
-- 中心图标配置 (Center Icon Configuration)
|
||||
-- ========================================
|
||||
"centerIcon": {
|
||||
"rIcon": 50, -- 图标半径,单位:像素
|
||||
"opacity": 0.8, -- 图标透明度(0.0-1.0,0为完全透明,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.0,0为完全透明,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
174
public/demo2.json
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
168
refactor-plan.md
168
refactor-plan.md
@@ -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:
|
||||
1. 计算规律填色模式 (num, interval)
|
||||
2. 生成所有扇区
|
||||
3. 对每个扇区:
|
||||
- 应用颜色优先级
|
||||
- 解析多文本单元
|
||||
- 计算 SVG 路径
|
||||
↓
|
||||
完整的 Sector 数组
|
||||
↓
|
||||
[degreeRing] 生成刻度环
|
||||
↓
|
||||
[centerIcon] 加载中心图标
|
||||
- type=sectors:
|
||||
1. 计算规律填色模式 (num, interval)
|
||||
2. 生成所有扇区
|
||||
3. 对每个扇区:
|
||||
- 应用颜色优先级
|
||||
- 解析多文本单元
|
||||
- 计算 SVG 路径
|
||||
- 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);
|
||||
@@ -294,18 +307,22 @@ 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)
|
||||
const sectors = config.layers.flatMap((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);
|
||||
|
||||
// 验证总扇区数
|
||||
|
||||
386
src/Luopan.vue
386
src/Luopan.vue
@@ -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
|
||||
<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,41 +47,142 @@
|
||||
>
|
||||
<!-- 背景 -->
|
||||
<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>
|
||||
<path
|
||||
<g v-memo="[sectors]">
|
||||
<!-- 扇区 -->
|
||||
<g>
|
||||
<path
|
||||
v-for="s in sectors"
|
||||
:key="s.key"
|
||||
:d="s.path"
|
||||
:fill="s.fill"
|
||||
stroke="#1f2937"
|
||||
:stroke-opacity="s.groupSplitVisible === false ? 0 : 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>
|
||||
|
||||
<!-- 定义文字路径 -->
|
||||
<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"
|
||||
:d="s.path"
|
||||
:fill="s.fill"
|
||||
stroke="#1f2937"
|
||||
stroke-opacity="0.15"
|
||||
:stroke-width="SECTOR_STROKE_WIDTH"
|
||||
:key="s.key + '-center'"
|
||||
:cx="s.cx"
|
||||
:cy="s.cy"
|
||||
r="2.2"
|
||||
fill="#ef4444"
|
||||
opacity="0.8"
|
||||
/>
|
||||
</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">
|
||||
<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>
|
||||
|
||||
<!-- 定义文字路径 -->
|
||||
<defs>
|
||||
<path
|
||||
v-for="s in sectors"
|
||||
:key="s.textPathId"
|
||||
:id="s.textPathId"
|
||||
:d="s.textPath"
|
||||
<!-- 刻度环 -->
|
||||
<g v-if="degreeRing" v-memo="[degreeRing]">
|
||||
<defs>
|
||||
<path
|
||||
v-for="label in degreeRing.labels || []"
|
||||
:key="label.textPathId"
|
||||
:id="label.textPathId"
|
||||
:d="label.textPath"
|
||||
fill="none"
|
||||
/>
|
||||
</defs>
|
||||
<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"
|
||||
/>
|
||||
</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"
|
||||
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
19
src/centerIcon.ts
Normal 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
71
src/colorResolver.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 outerMost = computed(() => {
|
||||
const radii = exampleRef.value.radii;
|
||||
return radii[radii.length - 1];
|
||||
});
|
||||
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 sectors = computed<Sector[]>(() => {
|
||||
const res: Sector[] = [];
|
||||
const A = exampleRef.value.angles;
|
||||
const R = exampleRef.value.radii;
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
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);
|
||||
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);
|
||||
}
|
||||
return res;
|
||||
});
|
||||
|
||||
/**
|
||||
* 极坐标转 XY(暴露给模板使用)
|
||||
*/
|
||||
const toXY = polarToXY;
|
||||
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(() => {
|
||||
if (!config.value) return 0;
|
||||
if (typeof config.value.outerRadius === 'number') return config.value.outerRadius;
|
||||
|
||||
const radii = sectorLayers.value.map((layer) => layer.rOuter);
|
||||
const degreeRingLayer = findDegreeRingLayer(config.value.layers);
|
||||
if (degreeRingLayer) {
|
||||
radii.push(degreeRingLayer.degreeRing.rOuter);
|
||||
}
|
||||
return radii.length > 0 ? Math.max(...radii) : 0;
|
||||
});
|
||||
|
||||
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
83
src/configParser.ts
Normal 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
137
src/degreeRing.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
21
src/index.ts
21
src/index.ts
@@ -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
41
src/multiTextParser.ts
Normal 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
218
src/sectorBuilder.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
169
src/types.ts
169
src/types.ts
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
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}`);
|
||||
});
|
||||
it('应该成功渲染', async () => {
|
||||
const wrapper = mount(Luopan, { props: { config: baseConfig } });
|
||||
await flushPromises();
|
||||
expect(wrapper.exists()).toBe(true);
|
||||
});
|
||||
|
||||
describe('示例切换', () => {
|
||||
it('应该渲染示例切换按钮', () => {
|
||||
const wrapper = mount(Luopan);
|
||||
const buttons = wrapper.findAll('.toolbar button:not(.zoom-controls button)');
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(EXAMPLES.length);
|
||||
});
|
||||
it('应该渲染工具栏控件', async () => {
|
||||
const wrapper = mount(Luopan, { props: { config: baseConfig } });
|
||||
await flushPromises();
|
||||
|
||||
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');
|
||||
}
|
||||
});
|
||||
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);
|
||||
});
|
||||
|
||||
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');
|
||||
// 辅助线可能被隐藏或不渲染
|
||||
});
|
||||
it('应该渲染 SVG 容器', async () => {
|
||||
const wrapper = mount(Luopan, { props: { config: baseConfig } });
|
||||
await flushPromises();
|
||||
expect(wrapper.find('svg').exists()).toBe(true);
|
||||
});
|
||||
|
||||
describe('文字位置模式', () => {
|
||||
it('应该有文字位置选择器', () => {
|
||||
const wrapper = mount(Luopan);
|
||||
const select = wrapper.find('select');
|
||||
expect(select.exists()).toBe(true);
|
||||
});
|
||||
it('应该渲染扇区路径', async () => {
|
||||
const wrapper = mount(Luopan, { props: { config: baseConfig } });
|
||||
await flushPromises();
|
||||
|
||||
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');
|
||||
});
|
||||
const sectorPaths = wrapper.findAll('path[fill]').filter((path) =>
|
||||
path.attributes('d')?.includes('M ')
|
||||
);
|
||||
expect(sectorPaths.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
describe('缩放功能', () => {
|
||||
it('应该渲染缩放控件', () => {
|
||||
const wrapper = mount(Luopan);
|
||||
const zoomControls = wrapper.find('.zoom-controls');
|
||||
expect(zoomControls.exists()).toBe(true);
|
||||
});
|
||||
it('应该渲染文字或 SVG 内容', async () => {
|
||||
const wrapper = mount(Luopan, { props: { config: baseConfig } });
|
||||
await flushPromises();
|
||||
|
||||
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();
|
||||
});
|
||||
const textPaths = wrapper.findAll('textPath');
|
||||
expect(textPaths.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
describe('鼠标滚轮缩放', () => {
|
||||
it('应该监听滚轮事件', () => {
|
||||
const wrapper = mount(Luopan);
|
||||
const container = wrapper.find('.svg-container');
|
||||
expect(container.exists()).toBe(true);
|
||||
});
|
||||
it('应该渲染刻度环和中心图标', async () => {
|
||||
const wrapper = mount(Luopan, { props: { config: baseConfig } });
|
||||
await flushPromises();
|
||||
|
||||
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();
|
||||
});
|
||||
const lines = wrapper.findAll('line');
|
||||
expect(lines.length).toBeGreaterThan(0);
|
||||
|
||||
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);
|
||||
});
|
||||
const images = wrapper.findAll('image');
|
||||
expect(images.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
25
tests/centerIcon.test.ts
Normal file
25
tests/centerIcon.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
53
tests/colorResolver.test.ts
Normal file
53
tests/colorResolver.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
34
tests/configParser.test.ts
Normal file
34
tests/configParser.test.ts
Normal 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
60
tests/degreeRing.test.ts
Normal 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 ');
|
||||
});
|
||||
});
|
||||
26
tests/multiTextParser.test.ts
Normal file
26
tests/multiTextParser.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
96
tests/sectorBuilder.test.ts
Normal file
96
tests/sectorBuilder.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -5,422 +5,88 @@
|
||||
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';
|
||||
|
||||
const createMockConfig = (): LuopanConfig => ({
|
||||
name: '测试配置',
|
||||
background: '#000000',
|
||||
theme: {
|
||||
colorPalettes: {
|
||||
A: '#111111',
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
divisions: 4,
|
||||
rInner: 0,
|
||||
rOuter: 80,
|
||||
sectors: [{ content: '甲' }, { content: '乙' }, { content: '丙' }, { content: '丁' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe('useLuopan', () => {
|
||||
const createMockExample = (): Example => ({
|
||||
name: '测试示例',
|
||||
angles: [0, 90, 180, 270, 360],
|
||||
radii: [50, 100, 150],
|
||||
it('应该返回所有必需的属性和方法', async () => {
|
||||
const textRadialPosition = ref<TextRadialPosition>('middle');
|
||||
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');
|
||||
});
|
||||
|
||||
describe('基本功能', () => {
|
||||
it('应该返回所有必需的属性和方法', () => {
|
||||
const example = ref(createMockExample());
|
||||
const textRadialPosition = ref<TextRadialPosition>('middle');
|
||||
|
||||
const result = useLuopan(example, textRadialPosition);
|
||||
it('anglesDeg 应该按 divisions 生成角度数组', async () => {
|
||||
const textRadialPosition = ref<TextRadialPosition>('middle');
|
||||
const { anglesDeg, reload } = useLuopan(createMockConfig(), textRadialPosition);
|
||||
await reload();
|
||||
|
||||
expect(result).toHaveProperty('anglesDeg');
|
||||
expect(result).toHaveProperty('rings');
|
||||
expect(result).toHaveProperty('outerMost');
|
||||
expect(result).toHaveProperty('sectors');
|
||||
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);
|
||||
});
|
||||
expect(anglesDeg.value).toEqual([0, 90, 180, 270, 360]);
|
||||
});
|
||||
|
||||
describe('扇区生成', () => {
|
||||
it('应该生成正确数量的扇区', () => {
|
||||
const example = ref(createMockExample());
|
||||
const textRadialPosition = ref<TextRadialPosition>('middle');
|
||||
|
||||
const { sectors } = useLuopan(example, textRadialPosition);
|
||||
it('rings 应该返回 rOuter 列表', async () => {
|
||||
const textRadialPosition = ref<TextRadialPosition>('middle');
|
||||
const { rings, reload } = useLuopan(createMockConfig(), textRadialPosition);
|
||||
await reload();
|
||||
|
||||
// 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('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 ');
|
||||
});
|
||||
});
|
||||
expect(rings.value).toEqual([80]);
|
||||
});
|
||||
|
||||
describe('文字位置模式', () => {
|
||||
it('应该在 middle 模式下使用中点位置', () => {
|
||||
const example = ref(createMockExample());
|
||||
const textRadialPosition = ref<TextRadialPosition>('middle');
|
||||
|
||||
const { sectors } = useLuopan(example, textRadialPosition);
|
||||
it('outerMost 应该返回最大半径', async () => {
|
||||
const textRadialPosition = ref<TextRadialPosition>('middle');
|
||||
const { outerMost, reload } = useLuopan(createMockConfig(), textRadialPosition);
|
||||
await reload();
|
||||
|
||||
// 第二层的中点应该在 (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
|
||||
});
|
||||
expect(outerMost.value).toBe(80);
|
||||
});
|
||||
|
||||
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);
|
||||
it('应该生成正确数量的扇区', async () => {
|
||||
const textRadialPosition = ref<TextRadialPosition>('middle');
|
||||
const { sectors, reload } = useLuopan(createMockConfig(), textRadialPosition);
|
||||
await reload();
|
||||
|
||||
// 某些窄扇区应该被设置为竖排
|
||||
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);
|
||||
});
|
||||
});
|
||||
expect(sectors.value.length).toBe(4);
|
||||
});
|
||||
|
||||
describe('内部填色', () => {
|
||||
it('某些扇区应该有内部填色', () => {
|
||||
const example = ref(createMockExample());
|
||||
const textRadialPosition = ref<TextRadialPosition>('middle');
|
||||
|
||||
const { sectors } = useLuopan(example, textRadialPosition);
|
||||
it('扇区应包含必要字段', async () => {
|
||||
const textRadialPosition = ref<TextRadialPosition>('middle');
|
||||
const { sectors, reload } = useLuopan(createMockConfig(), textRadialPosition);
|
||||
await reload();
|
||||
|
||||
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层
|
||||
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('path');
|
||||
expect(sector).toHaveProperty('textPath');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
10
todolist.md
10
todolist.md
@@ -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" -- 圆环颜色
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user