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", -- 冷蓝(冷色调)
|
"冷": "#1976D2", -- 冷蓝(冷色调)
|
||||||
"强": "#D32F2F", -- 强烈红(高饱和度)
|
"强": "#D32F2F", -- 强烈红(高饱和度)
|
||||||
"\u8f6f": "#FFE0B2" -- 柔和杏(低饱和度)
|
"\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": [
|
"layers": [
|
||||||
|
-- ========================================
|
||||||
|
-- 中心图标层 (Center Icon Layer)
|
||||||
|
-- ========================================
|
||||||
|
{
|
||||||
|
"type": "centerIcon",
|
||||||
|
"centerIcon": {
|
||||||
|
"rIcon": 50, -- 图标半径,单位:像素
|
||||||
|
"opacity": 0.8, -- 图标透明度(0.0-1.0,0为完全透明,1为完全不透明)
|
||||||
|
"name": "centericon.svg" -- SVG图标文件名,路径固定为 /icons/ 目录
|
||||||
|
}
|
||||||
|
},
|
||||||
-- ========================================
|
-- ========================================
|
||||||
-- 阴阳 (2等分)
|
-- 阴阳 (2等分)
|
||||||
-- ========================================
|
-- ========================================
|
||||||
@@ -246,6 +231,26 @@
|
|||||||
"num": 3, -- 连续着色3个扇区,每个区域跨3度
|
"num": 3, -- 连续着色3个扇区,每个区域跨3度
|
||||||
"interval": 2, -- 着色后间隔1个扇区
|
"interval": 2, -- 着色后间隔1个扇区
|
||||||
"groupSplit": false -- 新增:隐藏同组扇区之间的分割线, false表示不显示group中间分割线,该参数不设置,默认显示。
|
"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
|
-- 示例: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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
154
refactor-plan.md
154
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 配置文件完全定义罗盘结构
|
1. **配置驱动**:从 JSON 配置文件完全定义罗盘结构
|
||||||
2. **复杂着色规则**:支持三级着色优先级(全局 → 层级规律填色 → 扇区独立)
|
2. **新增罗盘零改码**:新增罗盘只需在 `public/` 下增加 JSON 配置文件,无需修改代码
|
||||||
3. **多文本单元**:扇区内容支持 `|` 分隔的多个文本单元,角度智能分配
|
3. **复杂着色规则**:支持三级着色优先级(全局 → 层级规律填色 → 扇区独立)
|
||||||
4. **SVG 图标支持**:扇区内容可以是 SVG 文件
|
4. **多文本单元**:扇区内容支持 `|` 分隔的多个文本单元,角度智能分配
|
||||||
5. **中心图标**:支持可旋转的中心 SVG 图标
|
5. **SVG 图标支持**:扇区内容可以是 SVG 文件
|
||||||
6. **360度刻度环**:支持多种刻度模式的度数环
|
6. **中心图标**:作为 layer 类型,支持可旋转的中心 SVG 图标
|
||||||
7. **命名配色方案**:通过 theme.colorPalettes 定义可复用颜色
|
7. **360度刻度环**:作为 layer 类型,支持多种刻度模式的度数环
|
||||||
8. **规律填色机制**:通过 num + interval 实现周期性着色
|
8. **命名配色方案**:通过 theme.colorPalettes 定义可复用颜色
|
||||||
9. **同组分割线控制**:groupSplit 参数控制组内分割线显示
|
9. **规律填色机制**:通过 num + interval 实现周期性着色
|
||||||
|
10. **同组分割线控制**:groupSplit 参数控制组内分割线显示
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -95,18 +98,15 @@ JSON 文本 (带注释)
|
|||||||
[sectorBuilder] 遍历 layers
|
[sectorBuilder] 遍历 layers
|
||||||
↓
|
↓
|
||||||
对每个 layer:
|
对每个 layer:
|
||||||
|
- type=sectors:
|
||||||
1. 计算规律填色模式 (num, interval)
|
1. 计算规律填色模式 (num, interval)
|
||||||
2. 生成所有扇区
|
2. 生成所有扇区
|
||||||
3. 对每个扇区:
|
3. 对每个扇区:
|
||||||
- 应用颜色优先级
|
- 应用颜色优先级
|
||||||
- 解析多文本单元
|
- 解析多文本单元
|
||||||
- 计算 SVG 路径
|
- 计算 SVG 路径
|
||||||
↓
|
- type=degreeRing:生成刻度环数据
|
||||||
完整的 Sector 数组
|
- type=centerIcon:加载中心图标
|
||||||
↓
|
|
||||||
[degreeRing] 生成刻度环
|
|
||||||
↓
|
|
||||||
[centerIcon] 加载中心图标
|
|
||||||
↓
|
↓
|
||||||
最终渲染数据
|
最终渲染数据
|
||||||
```
|
```
|
||||||
@@ -147,8 +147,6 @@ function applyPatternColoring(
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
CG: 角度分配还需结合扇区高度,就能计算出字的大小和布局
|
|
||||||
|
|
||||||
**多文本单元角度分配:**
|
**多文本单元角度分配:**
|
||||||
```typescript
|
```typescript
|
||||||
function splitMultiTextUnits(
|
function splitMultiTextUnits(
|
||||||
@@ -192,9 +190,7 @@ export interface LuopanConfig {
|
|||||||
description?: string;
|
description?: string;
|
||||||
background: string; // 全局背景色
|
background: string; // 全局背景色
|
||||||
theme: ThemeConfig;
|
theme: ThemeConfig;
|
||||||
centerIcon?: CenterIconConfig;
|
layers: LayerConfig[]; // 扇区层 + 中心图标层 + 刻度环层
|
||||||
degreeRing?: DegreeRingConfig;
|
|
||||||
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;
|
divisions: number;
|
||||||
rInner: number;
|
rInner: number;
|
||||||
rOuter: number;
|
rOuter: number;
|
||||||
@@ -241,6 +244,18 @@ export interface LayerConfig {
|
|||||||
sectors?: SectorConfig[];
|
sectors?: SectorConfig[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 中心图标层配置 */
|
||||||
|
export interface CenterIconLayerConfig {
|
||||||
|
type: 'centerIcon';
|
||||||
|
centerIcon: CenterIconConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 刻度环层配置 */
|
||||||
|
export interface DegreeRingLayerConfig {
|
||||||
|
type: 'degreeRing';
|
||||||
|
degreeRing: DegreeRingConfig;
|
||||||
|
}
|
||||||
|
|
||||||
/** 扇区配置 */
|
/** 扇区配置 */
|
||||||
export interface SectorConfig {
|
export interface SectorConfig {
|
||||||
content?: string; // 支持 "|" 分隔
|
content?: string; // 支持 "|" 分隔
|
||||||
@@ -256,7 +271,6 @@ export interface TextUnit {
|
|||||||
isSvg: boolean; // 是否为 SVG 文件名
|
isSvg: boolean; // 是否为 SVG 文件名
|
||||||
}
|
}
|
||||||
|
|
||||||
CG: ???
|
|
||||||
/** 刻度线数据 */
|
/** 刻度线数据 */
|
||||||
export interface TickMark {
|
export interface TickMark {
|
||||||
angle: number;
|
angle: number;
|
||||||
@@ -267,13 +281,12 @@ export interface TickMark {
|
|||||||
label?: string; // 度数标签(仅主刻度)
|
label?: string; // 度数标签(仅主刻度)
|
||||||
}
|
}
|
||||||
|
|
||||||
CG:???
|
|
||||||
/** 扩展现有 Sector 接口 */
|
/** 扩展现有 Sector 接口 */
|
||||||
export interface Sector {
|
export interface Sector {
|
||||||
// ... 现有字段保持 ...
|
// ... 现有字段保持 ...
|
||||||
|
|
||||||
// 新增字段
|
// 新增字段
|
||||||
textUnits?: TextUnit[]; // 多文本单元 ???
|
textUnits?: TextUnit[]; // 多文本单元
|
||||||
groupSplitVisible?: boolean; // 是否显示与下一个扇区的分割线
|
groupSplitVisible?: boolean; // 是否显示与下一个扇区的分割线
|
||||||
isSvgContent?: boolean; // 内容是否为 SVG
|
isSvgContent?: boolean; // 内容是否为 SVG
|
||||||
svgPath?: string; // SVG 文件路径
|
svgPath?: string; // SVG 文件路径
|
||||||
@@ -284,7 +297,7 @@ export interface Sector {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// 输入:JSON 配置字符串
|
// 输入:JSON 配置字符串
|
||||||
const jsonText = await fetch('/demo.json.conf').then(r => r.text());
|
const jsonText = await fetch('/demo.json').then(r => r.text());
|
||||||
|
|
||||||
// 步骤1:解析配置
|
// 步骤1:解析配置
|
||||||
const config = parseConfig(jsonText);
|
const config = parseConfig(jsonText);
|
||||||
@@ -295,17 +308,21 @@ const colorResolver = new ColorResolver(config.theme, config.background);
|
|||||||
// 步骤3:构建扇区
|
// 步骤3:构建扇区
|
||||||
const sectorBuilder = new SectorBuilder(colorResolver);
|
const sectorBuilder = new SectorBuilder(colorResolver);
|
||||||
const sectors = config.layers.flatMap((layer, layerIndex) =>
|
const sectors = config.layers.flatMap((layer, layerIndex) =>
|
||||||
sectorBuilder.buildLayer(layer, layerIndex)
|
layer.type === 'centerIcon' || layer.type === 'degreeRing'
|
||||||
|
? []
|
||||||
|
: sectorBuilder.buildLayer(layer, layerIndex)
|
||||||
);
|
);
|
||||||
|
|
||||||
// 步骤4:生成刻度环
|
// 步骤4:生成刻度环(从 layers 中提取)
|
||||||
const degreeRingData = config.degreeRing
|
const degreeRingLayer = config.layers.find(layer => layer.type === 'degreeRing');
|
||||||
? buildDegreeRing(config.degreeRing)
|
const degreeRingData = degreeRingLayer
|
||||||
|
? buildDegreeRing(degreeRingLayer.degreeRing)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// 步骤5:加载中心图标
|
// 步骤5:加载中心图标(从 layers 中提取)
|
||||||
const centerIconData = config.centerIcon
|
const centerIconLayer = config.layers.find(layer => layer.type === 'centerIcon');
|
||||||
? await loadCenterIcon(config.centerIcon)
|
const centerIconData = centerIconLayer
|
||||||
|
? await loadCenterIcon(centerIconLayer.centerIcon)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// 输出:完整渲染数据
|
// 输出:完整渲染数据
|
||||||
@@ -332,10 +349,10 @@ const renderData = {
|
|||||||
- JSON 解析
|
- JSON 解析
|
||||||
- 基础验证(必填字段检查)
|
- 基础验证(必填字段检查)
|
||||||
3. ✅ 编写单元测试:`configParser.test.ts`
|
3. ✅ 编写单元测试:`configParser.test.ts`
|
||||||
4. ✅ 测试用例:解析 `demo.json.conf`
|
4. ✅ 测试用例:解析 `demo.json`
|
||||||
|
|
||||||
**验收标准:**
|
**验收标准:**
|
||||||
- 能成功解析 `demo.json.conf` 为 LuopanConfig 对象
|
- 能成功解析 `demo.json` 为 LuopanConfig 对象
|
||||||
- 所有必填字段验证通过
|
- 所有必填字段验证通过
|
||||||
- 测试覆盖率 > 80%
|
- 测试覆盖率 > 80%
|
||||||
|
|
||||||
@@ -417,34 +434,15 @@ export class ColorResolver {
|
|||||||
|
|
||||||
**核心代码:**
|
**核心代码:**
|
||||||
```typescript
|
```typescript
|
||||||
export function parseMultiText(
|
// 仅保留名称,具体实现参考前文 splitMultiTextUnits
|
||||||
|
export function splitMultiTextUnits(
|
||||||
content: string,
|
content: string,
|
||||||
aStart: number,
|
aStart: number,
|
||||||
aEnd: number,
|
aEnd: number,
|
||||||
svgIconPath: string = 'src/assets/icons/'
|
svgIconPath: string = 'src/assets/icons/'
|
||||||
): TextUnit[] {
|
): TextUnit[] {
|
||||||
const parts = content.split('|').map(s => s.trim());
|
// 参考前面代码
|
||||||
const ratios = getLayoutRatio(parts.length);
|
return [];
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -503,7 +501,7 @@ export class SectorBuilder {
|
|||||||
// 解析多文本单元
|
// 解析多文本单元
|
||||||
const content = sectorConfig?.content ?? '';
|
const content = sectorConfig?.content ?? '';
|
||||||
const textUnits = content.includes('|')
|
const textUnits = content.includes('|')
|
||||||
? parseMultiText(content, aStart, aEnd)
|
? splitMultiTextUnits(content, aStart, aEnd)
|
||||||
: [{ content, aStart, aEnd, isSvg: content.endsWith('.svg') }];
|
: [{ content, aStart, aEnd, isSvg: content.endsWith('.svg') }];
|
||||||
|
|
||||||
// 确定是否显示分割线
|
// 确定是否显示分割线
|
||||||
@@ -553,7 +551,7 @@ export class SectorBuilder {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**验收标准:**
|
**验收标准:**
|
||||||
- 完整解析 `demo.json.conf` 所有 layer
|
- 完整解析 `demo.json` 所有 layer
|
||||||
- 颜色优先级正确
|
- 颜色优先级正确
|
||||||
- groupSplit 逻辑正确
|
- groupSplit 逻辑正确
|
||||||
- 多文本单元正确拆分
|
- 多文本单元正确拆分
|
||||||
@@ -624,14 +622,17 @@ export function buildDegreeRing(config: DegreeRingConfig): {
|
|||||||
ticks.push({ angle, type, length, startR, endR });
|
ticks.push({ angle, type, length, startR, endR });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成度数标签
|
// 生成度数标签(使用 textPath 保持方向一致)
|
||||||
const labels: DegreeLabel[] = [];
|
const labels: DegreeLabel[] = [];
|
||||||
if (config.showDegree === 1) {
|
if (config.showDegree === 1) {
|
||||||
|
const r = (rInner + rOuter) / 2;
|
||||||
for (let angle = 0; angle < 360; angle += config.majorTick) {
|
for (let angle = 0; angle < 360; angle += config.majorTick) {
|
||||||
labels.push({
|
labels.push({
|
||||||
angle,
|
angle,
|
||||||
text: angle.toString(),
|
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)
|
- 刻度线长度正确(major > minor > micro)
|
||||||
- mode 模式正确(inner, outer, both)
|
- mode 模式正确(inner, outer, both)
|
||||||
- 度数标签位置准确
|
- 度数标签方向与扇区文字一致(自动翻转)
|
||||||
- 中心图标加载和旋转正常
|
- 中心图标加载和旋转正常
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -711,17 +712,21 @@ export function useLuopan(configPathOrObject: string | LuopanConfig) {
|
|||||||
// 构建扇区
|
// 构建扇区
|
||||||
const sectorBuilder = new SectorBuilder(colorResolver);
|
const sectorBuilder = new SectorBuilder(colorResolver);
|
||||||
sectors.value = configObj.layers.flatMap((layer, i) =>
|
sectors.value = configObj.layers.flatMap((layer, i) =>
|
||||||
sectorBuilder.buildLayer(layer, i)
|
layer.type === 'centerIcon' || layer.type === 'degreeRing'
|
||||||
|
? []
|
||||||
|
: sectorBuilder.buildLayer(layer, i)
|
||||||
);
|
);
|
||||||
|
|
||||||
// 构建刻度环
|
// 构建刻度环(从 layers 中提取)
|
||||||
if (configObj.degreeRing) {
|
const degreeRingLayer = configObj.layers.find(layer => layer.type === 'degreeRing');
|
||||||
degreeRing.value = buildDegreeRing(configObj.degreeRing);
|
if (degreeRingLayer) {
|
||||||
|
degreeRing.value = buildDegreeRing(degreeRingLayer.degreeRing);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载中心图标
|
// 加载中心图标(从 layers 中提取)
|
||||||
if (configObj.centerIcon) {
|
const centerIconLayer = configObj.layers.find(layer => layer.type === 'centerIcon');
|
||||||
centerIcon.value = await loadCenterIcon(configObj.centerIcon);
|
if (centerIconLayer) {
|
||||||
|
centerIcon.value = await loadCenterIcon(centerIconLayer.centerIcon);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -807,12 +812,12 @@ export function useLuopan(configPathOrObject: string | LuopanConfig) {
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const { config, sectors, degreeRing, centerIcon, loading, error } =
|
const { config, sectors, degreeRing, centerIcon, loading, error } =
|
||||||
useLuopan('/demo.json.conf');
|
useLuopan('/demo.json');
|
||||||
</script>
|
</script>
|
||||||
```
|
```
|
||||||
|
|
||||||
**验收标准:**
|
**验收标准:**
|
||||||
- 成功加载和渲染 `demo.json.conf`
|
- 成功加载和渲染 `demo.json`
|
||||||
- 所有 layer 正确显示
|
- 所有 layer 正确显示
|
||||||
- 多文本单元正确拆分显示
|
- 多文本单元正确拆分显示
|
||||||
- 刻度环和中心图标正常渲染
|
- 刻度环和中心图标正常渲染
|
||||||
@@ -844,8 +849,8 @@ const { config, sectors, degreeRing, centerIcon, loading, error } =
|
|||||||
**测试策略:**
|
**测试策略:**
|
||||||
```typescript
|
```typescript
|
||||||
describe('完整流程测试', () => {
|
describe('完整流程测试', () => {
|
||||||
it('应正确解析和渲染 demo.json.conf', async () => {
|
it('应正确解析和渲染 demo.json', async () => {
|
||||||
const config = await loadConfig('/demo.json.conf');
|
const config = await loadConfig('/demo.json');
|
||||||
const sectors = buildAllSectors(config);
|
const sectors = buildAllSectors(config);
|
||||||
|
|
||||||
// 验证扇区数量
|
// 验证扇区数量
|
||||||
@@ -927,6 +932,7 @@ describe('完整流程测试', () => {
|
|||||||
2. 每个 `TextUnit` 包含独立的 `aStart`, `aEnd`, `content`
|
2. 每个 `TextUnit` 包含独立的 `aStart`, `aEnd`, `content`
|
||||||
3. 渲染时为每个单元生成独立的 `<textPath>` 元素
|
3. 渲染时为每个单元生成独立的 `<textPath>` 元素
|
||||||
4. 使用 `getLayoutRatio()` 确保角度分配准确
|
4. 使用 `getLayoutRatio()` 确保角度分配准确
|
||||||
|
5. 字体大小与布局按单元独立计算:每个 `TextUnit` 用自身 `aStart/aEnd` + `content.length` 调用 `calculateSectorFontSize`,并基于相同角度范围生成 `generateTextPath` / `generateVerticalTextPath`
|
||||||
|
|
||||||
**代码示例:**
|
**代码示例:**
|
||||||
```typescript
|
```typescript
|
||||||
@@ -1091,7 +1097,7 @@ describe('ColorResolver', () => {
|
|||||||
```typescript
|
```typescript
|
||||||
describe('完整渲染流程', () => {
|
describe('完整渲染流程', () => {
|
||||||
it('应从 JSON 配置生成完整罗盘', async () => {
|
it('应从 JSON 配置生成完整罗盘', async () => {
|
||||||
const config = await loadConfig('/demo.json.conf');
|
const config = await loadConfig('/demo.json');
|
||||||
const { sectors, degreeRing, centerIcon } = buildLuopan(config);
|
const { sectors, degreeRing, centerIcon } = buildLuopan(config);
|
||||||
|
|
||||||
// 验证总扇区数
|
// 验证总扇区数
|
||||||
|
|||||||
336
src/Luopan.vue
336
src/Luopan.vue
@@ -2,15 +2,6 @@
|
|||||||
<div class="luopan-wrap">
|
<div class="luopan-wrap">
|
||||||
<!-- 工具栏 -->
|
<!-- 工具栏 -->
|
||||||
<div class="toolbar">
|
<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">
|
<label class="toggle">
|
||||||
<input type="checkbox" v-model="showGuides" />
|
<input type="checkbox" v-model="showGuides" />
|
||||||
显示辅助线
|
显示辅助线
|
||||||
@@ -31,8 +22,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="status">加载中...</div>
|
||||||
|
<div v-else-if="error" class="status">错误: {{ error.message }}</div>
|
||||||
|
|
||||||
<!-- SVG 画布容器 -->
|
<!-- SVG 画布容器 -->
|
||||||
<div
|
<div
|
||||||
|
v-else
|
||||||
class="svg-container"
|
class="svg-container"
|
||||||
@wheel.prevent="handleWheel"
|
@wheel.prevent="handleWheel"
|
||||||
@mousedown="handleMouseDown"
|
@mousedown="handleMouseDown"
|
||||||
@@ -43,7 +38,7 @@
|
|||||||
<svg
|
<svg
|
||||||
:width="size"
|
:width="size"
|
||||||
:height="size"
|
:height="size"
|
||||||
:viewBox="`${-size / 2} ${-size / 2} ${size} ${size}`"
|
:viewBox="`${viewBoxMin} ${viewBoxMin} ${viewBoxSize} ${viewBoxSize}`"
|
||||||
class="svg"
|
class="svg"
|
||||||
:style="{
|
:style="{
|
||||||
transform: `scale(${scale}) translate(${panX}px, ${panY}px)`,
|
transform: `scale(${scale}) translate(${panX}px, ${panY}px)`,
|
||||||
@@ -52,13 +47,14 @@
|
|||||||
>
|
>
|
||||||
<!-- 背景 -->
|
<!-- 背景 -->
|
||||||
<rect
|
<rect
|
||||||
:x="-size / 2"
|
:x="viewBoxMin"
|
||||||
:y="-size / 2"
|
:y="viewBoxMin"
|
||||||
:width="size"
|
:width="viewBoxSize"
|
||||||
:height="size"
|
:height="viewBoxSize"
|
||||||
fill="white"
|
:fill="config?.background || '#ffffff'"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<g v-memo="[sectors]">
|
||||||
<!-- 扇区 -->
|
<!-- 扇区 -->
|
||||||
<g>
|
<g>
|
||||||
<path
|
<path
|
||||||
@@ -67,7 +63,7 @@
|
|||||||
:d="s.path"
|
:d="s.path"
|
||||||
:fill="s.fill"
|
:fill="s.fill"
|
||||||
stroke="#1f2937"
|
stroke="#1f2937"
|
||||||
stroke-opacity="0.15"
|
:stroke-opacity="s.groupSplitVisible === false ? 0 : 0.15"
|
||||||
:stroke-width="SECTOR_STROKE_WIDTH"
|
:stroke-width="SECTOR_STROKE_WIDTH"
|
||||||
/>
|
/>
|
||||||
</g>
|
</g>
|
||||||
@@ -85,8 +81,108 @@
|
|||||||
</template>
|
</template>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
|
<!-- 定义文字路径 -->
|
||||||
|
<defs>
|
||||||
|
<path
|
||||||
|
v-for="s in sectors"
|
||||||
|
:key="s.textPathId"
|
||||||
|
:id="s.textPathId"
|
||||||
|
:d="s.textPath"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
<template v-for="s in sectors" :key="s.key + '-units'">
|
||||||
|
<path
|
||||||
|
v-for="unit in s.textUnits || []"
|
||||||
|
:key="unit.textPathId"
|
||||||
|
:id="unit.textPathId"
|
||||||
|
:d="unit.textPath"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- 文字标签(沿圆弧排列) -->
|
||||||
|
<g>
|
||||||
|
<template v-for="s in sectors" :key="s.key + '-text'">
|
||||||
|
<template v-if="s.textUnits">
|
||||||
|
<g v-for="unit in s.textUnits || []" :key="unit.textPathId">
|
||||||
|
<text
|
||||||
|
v-if="!unit.isSvg"
|
||||||
|
:font-size="unit.fontSize"
|
||||||
|
:fill="s.textColor"
|
||||||
|
:writing-mode="unit.isVertical ? 'tb' : undefined"
|
||||||
|
:glyph-orientation-vertical="unit.isVertical ? '0' : undefined"
|
||||||
|
:text-anchor="unit.isVertical ? 'middle' : undefined"
|
||||||
|
style="user-select: none"
|
||||||
|
>
|
||||||
|
<textPath
|
||||||
|
:href="'#' + unit.textPathId"
|
||||||
|
startOffset="50%"
|
||||||
|
text-anchor="middle"
|
||||||
|
dominant-baseline="central"
|
||||||
|
>
|
||||||
|
{{ unit.content }}
|
||||||
|
</textPath>
|
||||||
|
</text>
|
||||||
|
<image
|
||||||
|
v-else
|
||||||
|
:href="unit.svgPath"
|
||||||
|
:x="getUnitSvgBox(s, unit).x"
|
||||||
|
:y="getUnitSvgBox(s, unit).y"
|
||||||
|
:width="getUnitSvgBox(s, unit).size"
|
||||||
|
:height="getUnitSvgBox(s, unit).size"
|
||||||
|
:opacity="s.textColor ? 1 : 1"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<text
|
||||||
|
v-if="!s.isSvgContent"
|
||||||
|
:font-size="s.fontSize"
|
||||||
|
:fill="s.textColor"
|
||||||
|
:writing-mode="s.isVertical ? 'tb' : undefined"
|
||||||
|
:glyph-orientation-vertical="s.isVertical ? '0' : undefined"
|
||||||
|
:text-anchor="s.isVertical ? 'middle' : undefined"
|
||||||
|
style="user-select: none"
|
||||||
|
>
|
||||||
|
<textPath
|
||||||
|
:href="'#' + s.textPathId"
|
||||||
|
startOffset="50%"
|
||||||
|
text-anchor="middle"
|
||||||
|
dominant-baseline="central"
|
||||||
|
>
|
||||||
|
{{ s.label }}
|
||||||
|
</textPath>
|
||||||
|
</text>
|
||||||
|
<image
|
||||||
|
v-else
|
||||||
|
:href="s.svgPath"
|
||||||
|
:x="getSectorSvgBox(s).x"
|
||||||
|
:y="getSectorSvgBox(s).y"
|
||||||
|
:width="getSectorSvgBox(s).size"
|
||||||
|
:height="getSectorSvgBox(s).size"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- 形心点(仅辅助线开启时显示) -->
|
||||||
|
<g v-if="showGuides" v-memo="[sectors]">
|
||||||
|
<circle
|
||||||
|
v-for="s in sectors"
|
||||||
|
:key="s.key + '-center'"
|
||||||
|
:cx="s.cx"
|
||||||
|
:cy="s.cy"
|
||||||
|
r="2.2"
|
||||||
|
fill="#ef4444"
|
||||||
|
opacity="0.8"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
|
||||||
<!-- 辅助线:圆环、分度线 -->
|
<!-- 辅助线:圆环、分度线 -->
|
||||||
<g v-if="showGuides" stroke="#111827" stroke-opacity="0.18">
|
<g v-if="showGuides" v-memo="[rings, anglesDeg, outerMost]" stroke="#111827" stroke-opacity="0.18">
|
||||||
<!-- 圆环 -->
|
<!-- 圆环 -->
|
||||||
<circle
|
<circle
|
||||||
v-for="r in rings"
|
v-for="r in rings"
|
||||||
@@ -107,49 +203,69 @@
|
|||||||
/>
|
/>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- 定义文字路径 -->
|
<!-- 刻度环 -->
|
||||||
|
<g v-if="degreeRing" v-memo="[degreeRing]">
|
||||||
<defs>
|
<defs>
|
||||||
<path
|
<path
|
||||||
v-for="s in sectors"
|
v-for="label in degreeRing.labels || []"
|
||||||
:key="s.textPathId"
|
:key="label.textPathId"
|
||||||
:id="s.textPathId"
|
:id="label.textPathId"
|
||||||
:d="s.textPath"
|
:d="label.textPath"
|
||||||
fill="none"
|
fill="none"
|
||||||
/>
|
/>
|
||||||
</defs>
|
</defs>
|
||||||
|
<circle
|
||||||
<!-- 文字标签(沿圆弧排列) -->
|
:r="degreeRing.ring.rOuter"
|
||||||
<g>
|
fill="none"
|
||||||
|
:stroke="degreeRing.ring.color"
|
||||||
|
:stroke-opacity="degreeRing.ring.opacity"
|
||||||
|
:stroke-width="SECTOR_STROKE_WIDTH"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
:r="degreeRing.ring.rInner"
|
||||||
|
fill="none"
|
||||||
|
:stroke="degreeRing.ring.color"
|
||||||
|
:stroke-opacity="degreeRing.ring.opacity"
|
||||||
|
:stroke-width="SECTOR_STROKE_WIDTH"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
v-for="tick in degreeRing.ticks"
|
||||||
|
:key="'tick-' + tick.angle + '-' + tick.startR + '-' + tick.endR"
|
||||||
|
:x1="tick.x1"
|
||||||
|
:y1="tick.y1"
|
||||||
|
:x2="tick.x2"
|
||||||
|
:y2="tick.y2"
|
||||||
|
:stroke="degreeRing.tickColor"
|
||||||
|
:stroke-width="SECTOR_STROKE_WIDTH"
|
||||||
|
/>
|
||||||
<text
|
<text
|
||||||
v-for="s in sectors"
|
v-for="label in degreeRing.labels || []"
|
||||||
:key="s.key + '-label'"
|
:key="'degree-' + label.angle"
|
||||||
:font-size="s.fontSize"
|
:fill="degreeRing.tickColor"
|
||||||
:fill="s.textColor"
|
:font-size="label.fontSize"
|
||||||
:writing-mode="s.isVertical ? 'tb' : undefined"
|
|
||||||
:glyph-orientation-vertical="s.isVertical ? '0' : undefined"
|
|
||||||
:text-anchor="s.isVertical ? 'middle' : undefined"
|
|
||||||
style="user-select: none"
|
style="user-select: none"
|
||||||
>
|
>
|
||||||
<textPath
|
<textPath
|
||||||
:href="'#' + s.textPathId"
|
:href="'#' + label.textPathId"
|
||||||
startOffset="50%"
|
startOffset="50%"
|
||||||
text-anchor="middle"
|
text-anchor="middle"
|
||||||
dominant-baseline="central"
|
dominant-baseline="central"
|
||||||
>
|
>
|
||||||
{{ s.label }}
|
{{ label.text }}
|
||||||
</textPath>
|
</textPath>
|
||||||
</text>
|
</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
<!-- 可选:画一个小点看形心位置 -->
|
<!-- 中心图标 -->
|
||||||
<circle
|
<g v-if="centerIcon" v-memo="[centerIcon]">
|
||||||
v-if="showGuides"
|
<image
|
||||||
v-for="s in sectors"
|
:href="centerIcon.svgPath"
|
||||||
:key="s.key + '-center'"
|
:width="centerIcon.rIcon * 2"
|
||||||
:cx="s.cx"
|
:height="centerIcon.rIcon * 2"
|
||||||
:cy="s.cy"
|
:x="-centerIcon.rIcon"
|
||||||
r="2.2"
|
:y="-centerIcon.rIcon"
|
||||||
fill="#ef4444"
|
:opacity="centerIcon.opacity"
|
||||||
opacity="0.8"
|
:transform="`rotate(${centerIcon.rotation})`"
|
||||||
/>
|
/>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -166,90 +282,168 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useLuopan } from './composables/useLuopan';
|
import { useLuopan } from './composables/useLuopan';
|
||||||
import { EXAMPLES, DEFAULT_SIZE, DEFAULT_TEXT_RADIAL_POSITION, SECTOR_STROKE_WIDTH } from './constants';
|
import { DEFAULT_SIZE, DEFAULT_TEXT_RADIAL_POSITION, SECTOR_STROKE_WIDTH } from './constants';
|
||||||
import type { TextRadialPosition } from './types';
|
import type { LuopanConfig, Sector, TextRadialPosition, TextUnit } from './types';
|
||||||
|
import { annularSectorCentroid } from './utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Props
|
* Props
|
||||||
*/
|
*/
|
||||||
interface Props {
|
interface Props {
|
||||||
size?: number;
|
size?: number;
|
||||||
|
configPath?: string;
|
||||||
|
config?: LuopanConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
size: DEFAULT_SIZE,
|
size: DEFAULT_SIZE,
|
||||||
|
configPath: '/demo.json',
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 状态
|
* 状态
|
||||||
*/
|
*/
|
||||||
const showGuides = ref(true);
|
const showGuides = ref(true);
|
||||||
const exampleIndex = ref(0);
|
|
||||||
const examples = EXAMPLES;
|
|
||||||
const textRadialPosition = ref<TextRadialPosition>(DEFAULT_TEXT_RADIAL_POSITION);
|
const textRadialPosition = ref<TextRadialPosition>(DEFAULT_TEXT_RADIAL_POSITION);
|
||||||
|
|
||||||
// 缩放和平移状态
|
// 缩放和平移状态
|
||||||
const scale = ref(1);
|
const scale = ref(1);
|
||||||
const panX = ref(0);
|
const panX = ref(0);
|
||||||
const panY = 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 isDragging = ref(false);
|
||||||
const dragStartX = ref(0);
|
const dragStartX = ref(0);
|
||||||
const dragStartY = ref(0);
|
const dragStartY = ref(0);
|
||||||
const dragStartPanX = ref(0);
|
const dragStartPanX = ref(0);
|
||||||
const dragStartPanY = ref(0);
|
const dragStartPanY = ref(0);
|
||||||
|
|
||||||
/**
|
const resolveConfigPath = () => {
|
||||||
* 当前示例
|
if (typeof window === 'undefined') return props.configPath;
|
||||||
*/
|
const param = new URLSearchParams(window.location.search).get('config');
|
||||||
const currentExample = computed(() => examples[exampleIndex.value]);
|
return param ? `/${param}` : props.configPath;
|
||||||
|
};
|
||||||
|
|
||||||
|
const configInput = props.config ?? resolveConfigPath();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 使用罗盘逻辑
|
* 使用罗盘逻辑
|
||||||
*/
|
*/
|
||||||
const { anglesDeg, rings, outerMost, sectors, toXY } =
|
const {
|
||||||
useLuopan(currentExample, textRadialPosition);
|
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 = () => {
|
const zoomIn = () => {
|
||||||
if (scale.value < 5) {
|
if (scale.value < 5) {
|
||||||
scale.value = Math.min(5, scale.value + 0.2);
|
setScale(nextScale + 0.2);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const zoomOut = () => {
|
const zoomOut = () => {
|
||||||
if (scale.value > 0.5) {
|
if (scale.value > 0.5) {
|
||||||
scale.value = Math.max(0.5, scale.value - 0.2);
|
setScale(nextScale - 0.2);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetZoom = () => {
|
const resetZoom = () => {
|
||||||
scale.value = 1;
|
nextScale = 1;
|
||||||
panX.value = 0;
|
nextPanX = 0;
|
||||||
panY.value = 0;
|
nextPanY = 0;
|
||||||
|
scheduleTransform();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleWheel = (e: WheelEvent) => {
|
const handleWheel = (e: WheelEvent) => {
|
||||||
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
||||||
const newScale = Math.max(0.5, Math.min(5, scale.value + delta));
|
setScale(nextScale + delta);
|
||||||
scale.value = newScale;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseDown = (e: MouseEvent) => {
|
const handleMouseDown = (e: MouseEvent) => {
|
||||||
isDragging.value = true;
|
isDragging.value = true;
|
||||||
dragStartX.value = e.clientX;
|
dragStartX.value = e.clientX;
|
||||||
dragStartY.value = e.clientY;
|
dragStartY.value = e.clientY;
|
||||||
dragStartPanX.value = panX.value;
|
dragStartPanX.value = nextPanX;
|
||||||
dragStartPanY.value = panY.value;
|
dragStartPanY.value = nextPanY;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
if (isDragging.value) {
|
if (isDragging.value) {
|
||||||
const dx = (e.clientX - dragStartX.value) / scale.value;
|
const dx = (e.clientX - dragStartX.value) / nextScale;
|
||||||
const dy = (e.clientY - dragStartY.value) / scale.value;
|
const dy = (e.clientY - dragStartY.value) / nextScale;
|
||||||
panX.value = dragStartPanX.value + dx;
|
setPan(dragStartPanX.value + dx, dragStartPanY.value + dy);
|
||||||
panY.value = dragStartPanY.value + dy;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -326,6 +520,14 @@ button.active {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.svg {
|
.svg {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
transition: transform 0.1s ease-out;
|
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 { computed, ref, readonly, watch, type Ref } from 'vue';
|
||||||
import type { Example, Sector, TextRadialPosition } from '../types';
|
import type {
|
||||||
import {
|
CenterIconData,
|
||||||
polarToXY,
|
DegreeRingData,
|
||||||
generateSectorData,
|
LayerConfig,
|
||||||
} from '../utils';
|
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
|
* 罗盘逻辑 Hook
|
||||||
* @param exampleRef 当前示例的响应式引用
|
* @param configPathOrObject 配置文件路径或配置对象
|
||||||
* @param textRadialPositionRef 文字径向位置的响应式引用
|
* @param textRadialPositionRef 文字径向位置的响应式引用(可选)
|
||||||
* @returns 罗盘相关的计算属性和方法
|
* @returns 罗盘相关的计算属性和方法
|
||||||
*/
|
*/
|
||||||
export function useLuopan(
|
export function useLuopan(
|
||||||
exampleRef: Ref<Example>,
|
configPathOrObject: string | LuopanConfig,
|
||||||
textRadialPositionRef: Ref<TextRadialPosition>
|
textRadialPositionRef?: Ref<TextRadialPosition>
|
||||||
) {
|
) {
|
||||||
/**
|
const config = ref<LuopanConfig | null>(null);
|
||||||
* 角度分割点列表
|
const sectors = ref<Sector[]>([]);
|
||||||
*/
|
const degreeRing = ref<DegreeRingData | null>(null);
|
||||||
const anglesDeg = computed(() => exampleRef.value.angles);
|
const centerIcon = ref<CenterIconData | null>(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref<Error | null>(null);
|
||||||
|
|
||||||
/**
|
const textRadialPosition = computed(
|
||||||
* 圆环半径列表
|
() => textRadialPositionRef?.value ?? 'middle'
|
||||||
*/
|
);
|
||||||
const rings = computed(() => exampleRef.value.radii);
|
|
||||||
|
const buildSectors = (configObj: LuopanConfig) => {
|
||||||
|
const resolver = new ColorResolver(configObj.theme, configObj.background);
|
||||||
|
const builder = new SectorBuilder(resolver, {
|
||||||
|
textRadialPosition: textRadialPosition.value,
|
||||||
|
});
|
||||||
|
const sectorLayers = configObj.layers.filter(isSectorLayer);
|
||||||
|
return sectorLayers.flatMap((layer, index) => builder.buildLayer(layer, index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadConfig = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
let configObj: LuopanConfig;
|
||||||
|
if (typeof configPathOrObject === 'string') {
|
||||||
|
const jsonText = await fetch(configPathOrObject).then((res) => res.text());
|
||||||
|
configObj = parseConfig(jsonText);
|
||||||
|
} else {
|
||||||
|
configObj = configPathOrObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
config.value = configObj;
|
||||||
|
sectors.value = buildSectors(configObj);
|
||||||
|
|
||||||
|
const degreeRingLayer = findDegreeRingLayer(configObj.layers);
|
||||||
|
degreeRing.value = degreeRingLayer
|
||||||
|
? buildDegreeRing(degreeRingLayer.degreeRing)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const centerIconLayer = findCenterIconLayer(configObj.layers);
|
||||||
|
centerIcon.value = centerIconLayer
|
||||||
|
? await loadCenterIcon(centerIconLayer.centerIcon)
|
||||||
|
: null;
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err as Error;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 文字位置切换后仅重建扇区
|
||||||
|
watch(textRadialPosition, () => {
|
||||||
|
if (config.value) {
|
||||||
|
sectors.value = buildSectors(config.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
loadConfig();
|
||||||
|
|
||||||
|
const sectorLayers = computed(() =>
|
||||||
|
config.value ? config.value.layers.filter(isSectorLayer) : []
|
||||||
|
);
|
||||||
|
|
||||||
|
const rings = computed(() => sectorLayers.value.map((layer) => layer.rOuter));
|
||||||
|
|
||||||
|
const anglesDeg = computed(() => {
|
||||||
|
const firstLayer = sectorLayers.value[0];
|
||||||
|
if (!firstLayer || firstLayer.divisions <= 0) return [];
|
||||||
|
|
||||||
|
const step = 360 / firstLayer.divisions;
|
||||||
|
const start = firstLayer.startAngle ?? 0;
|
||||||
|
return Array.from({ length: firstLayer.divisions + 1 }, (_, i) => start + i * step);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* 最外层半径
|
|
||||||
*/
|
|
||||||
const outerMost = computed(() => {
|
const outerMost = computed(() => {
|
||||||
const radii = exampleRef.value.radii;
|
if (!config.value) return 0;
|
||||||
return radii[radii.length - 1];
|
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) {
|
||||||
const sectors = computed<Sector[]>(() => {
|
radii.push(degreeRingLayer.degreeRing.rOuter);
|
||||||
const res: Sector[] = [];
|
|
||||||
const A = exampleRef.value.angles;
|
|
||||||
const R = exampleRef.value.radii;
|
|
||||||
|
|
||||||
const layerCount = R.length;
|
|
||||||
const pieCount = A.length - 1;
|
|
||||||
|
|
||||||
for (let j = 0; j < layerCount; j++) {
|
|
||||||
const rInner = j === 0 ? 0 : R[j - 1];
|
|
||||||
const rOuter = R[j];
|
|
||||||
|
|
||||||
for (let i = 0; i < pieCount; i++) {
|
|
||||||
const aStart = A[i];
|
|
||||||
const aEnd = A[i + 1];
|
|
||||||
|
|
||||||
const sector = generateSectorData({
|
|
||||||
layerIndex: j,
|
|
||||||
pieIndex: i,
|
|
||||||
rInner,
|
|
||||||
rOuter,
|
|
||||||
aStart,
|
|
||||||
aEnd,
|
|
||||||
textRadialPosition: textRadialPositionRef.value,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.push(sector);
|
|
||||||
}
|
}
|
||||||
}
|
return radii.length > 0 ? Math.max(...radii) : 0;
|
||||||
return res;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* 极坐标转 XY(暴露给模板使用)
|
|
||||||
*/
|
|
||||||
const toXY = polarToXY;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
config: readonly(config),
|
||||||
|
sectors: readonly(sectors),
|
||||||
|
degreeRing: readonly(degreeRing),
|
||||||
|
centerIcon: readonly(centerIcon),
|
||||||
anglesDeg,
|
anglesDeg,
|
||||||
rings,
|
rings,
|
||||||
outerMost,
|
outerMost,
|
||||||
sectors,
|
toXY: polarToXY,
|
||||||
toXY,
|
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 {
|
export type {
|
||||||
Example,
|
Example,
|
||||||
|
LuopanConfig,
|
||||||
|
ThemeConfig,
|
||||||
|
CenterIconConfig,
|
||||||
|
DegreeRingConfig,
|
||||||
|
DegreeRingData,
|
||||||
|
DegreeLabel,
|
||||||
|
CenterIconData,
|
||||||
|
LayerConfig,
|
||||||
|
SectorConfig,
|
||||||
|
TextUnit,
|
||||||
|
TickMark,
|
||||||
Sector,
|
Sector,
|
||||||
PolarPoint,
|
PolarPoint,
|
||||||
AnnularSectorParams,
|
AnnularSectorParams,
|
||||||
@@ -32,3 +43,13 @@ export type { UseLuopanReturn } from './composables/useLuopan';
|
|||||||
|
|
||||||
// 常量导出
|
// 常量导出
|
||||||
export { EXAMPLES, DEFAULT_SIZE, SECTOR_INSET_DISTANCE } from './constants';
|
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[];
|
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;
|
fontSize: number;
|
||||||
/** 是否竖排文字 */
|
/** 是否竖排文字 */
|
||||||
isVertical: boolean;
|
isVertical: boolean;
|
||||||
|
/** 多文本单元 */
|
||||||
|
textUnits?: TextUnit[];
|
||||||
|
/** 是否显示与下一个扇区的分割线 */
|
||||||
|
groupSplitVisible?: boolean;
|
||||||
|
/** 内容是否为 SVG */
|
||||||
|
isSvgContent?: boolean;
|
||||||
|
/** SVG 文件路径 */
|
||||||
|
svgPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,544 +2,105 @@
|
|||||||
* Luopan 组件单元测试
|
* Luopan 组件单元测试
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { mount } from '@vue/test-utils';
|
import { mount } from '@vue/test-utils';
|
||||||
import Luopan from '../src/Luopan.vue';
|
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('Luopan 组件', () => {
|
||||||
describe('基本渲染', () => {
|
it('应该成功渲染', async () => {
|
||||||
it('应该成功渲染', () => {
|
const wrapper = mount(Luopan, { props: { config: baseConfig } });
|
||||||
const wrapper = mount(Luopan);
|
await flushPromises();
|
||||||
expect(wrapper.exists()).toBe(true);
|
expect(wrapper.exists()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应该渲染工具栏', () => {
|
it('应该渲染工具栏控件', async () => {
|
||||||
const wrapper = mount(Luopan);
|
const wrapper = mount(Luopan, { props: { config: baseConfig } });
|
||||||
const toolbar = wrapper.find('.toolbar');
|
await flushPromises();
|
||||||
expect(toolbar.exists()).toBe(true);
|
|
||||||
|
expect(wrapper.find('.toolbar').exists()).toBe(true);
|
||||||
|
expect(wrapper.find('input[type="checkbox"]').exists()).toBe(true);
|
||||||
|
expect(wrapper.find('select').exists()).toBe(true);
|
||||||
|
expect(wrapper.find('.zoom-controls').exists()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应该渲染 SVG 容器', () => {
|
it('应该渲染 SVG 容器', async () => {
|
||||||
const wrapper = mount(Luopan);
|
const wrapper = mount(Luopan, { props: { config: baseConfig } });
|
||||||
const svg = wrapper.find('svg');
|
await flushPromises();
|
||||||
expect(svg.exists()).toBe(true);
|
expect(wrapper.find('svg').exists()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应该使用默认尺寸', () => {
|
it('应该渲染扇区路径', async () => {
|
||||||
const wrapper = mount(Luopan);
|
const wrapper = mount(Luopan, { props: { config: baseConfig } });
|
||||||
const svg = wrapper.find('svg');
|
await flushPromises();
|
||||||
expect(svg.attributes('width')).toBeTruthy();
|
|
||||||
expect(svg.attributes('height')).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该使用传入的 size prop', () => {
|
const sectorPaths = wrapper.findAll('path[fill]').filter((path) =>
|
||||||
const customSize = 600;
|
path.attributes('d')?.includes('M ')
|
||||||
const wrapper = mount(Luopan, {
|
|
||||||
props: { size: customSize },
|
|
||||||
});
|
|
||||||
const svg = wrapper.find('svg');
|
|
||||||
expect(svg.attributes('width')).toBe(String(customSize));
|
|
||||||
expect(svg.attributes('height')).toBe(String(customSize));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该设置正确的 viewBox', () => {
|
|
||||||
const size = 520;
|
|
||||||
const wrapper = mount(Luopan, {
|
|
||||||
props: { size },
|
|
||||||
});
|
|
||||||
const svg = wrapper.find('svg');
|
|
||||||
const viewBox = svg.attributes('viewBox');
|
|
||||||
expect(viewBox).toBe(`${-size / 2} ${-size / 2} ${size} ${size}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('示例切换', () => {
|
|
||||||
it('应该渲染示例切换按钮', () => {
|
|
||||||
const wrapper = mount(Luopan);
|
|
||||||
const buttons = wrapper.findAll('.toolbar button:not(.zoom-controls button)');
|
|
||||||
expect(buttons.length).toBeGreaterThanOrEqual(EXAMPLES.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('第一个示例按钮应该默认激活', () => {
|
|
||||||
const wrapper = mount(Luopan);
|
|
||||||
const firstButton = wrapper.findAll('.toolbar button')[0];
|
|
||||||
expect(firstButton.classes()).toContain('active');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('点击示例按钮应该切换示例', async () => {
|
|
||||||
const wrapper = mount(Luopan);
|
|
||||||
const buttons = wrapper.findAll('.toolbar button');
|
|
||||||
|
|
||||||
// 假设至少有2个示例
|
|
||||||
if (buttons.length >= 2) {
|
|
||||||
const secondButton = buttons[1];
|
|
||||||
await secondButton.trigger('click');
|
|
||||||
|
|
||||||
expect(secondButton.classes()).toContain('active');
|
|
||||||
expect(buttons[0].classes()).not.toContain('active');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('辅助线显示', () => {
|
|
||||||
it('应该有辅助线切换开关', () => {
|
|
||||||
const wrapper = mount(Luopan);
|
|
||||||
const checkbox = wrapper.find('input[type="checkbox"]');
|
|
||||||
expect(checkbox.exists()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('辅助线应该默认显示', () => {
|
|
||||||
const wrapper = mount(Luopan);
|
|
||||||
const checkbox = wrapper.find('input[type="checkbox"]');
|
|
||||||
expect((checkbox.element as HTMLInputElement).checked).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('切换辅助线开关应该显示/隐藏辅助线', async () => {
|
|
||||||
const wrapper = mount(Luopan);
|
|
||||||
const checkbox = wrapper.find('input[type="checkbox"]');
|
|
||||||
|
|
||||||
// 默认显示辅助线
|
|
||||||
let guidesGroup = wrapper.findAll('g').find(g => g.attributes('stroke') === '#111827');
|
|
||||||
expect(guidesGroup).toBeDefined();
|
|
||||||
|
|
||||||
// 取消勾选
|
|
||||||
await checkbox.setValue(false);
|
|
||||||
guidesGroup = wrapper.findAll('g').find(g => g.attributes('stroke') === '#111827');
|
|
||||||
// 辅助线可能被隐藏或不渲染
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('文字位置模式', () => {
|
|
||||||
it('应该有文字位置选择器', () => {
|
|
||||||
const wrapper = mount(Luopan);
|
|
||||||
const select = wrapper.find('select');
|
|
||||||
expect(select.exists()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('选择器应该有两个选项', () => {
|
|
||||||
const wrapper = mount(Luopan);
|
|
||||||
const options = wrapper.findAll('option');
|
|
||||||
expect(options.length).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该能切换文字位置模式', async () => {
|
|
||||||
const wrapper = mount(Luopan);
|
|
||||||
const select = wrapper.find('select');
|
|
||||||
|
|
||||||
// 切换到形心模式
|
|
||||||
await select.setValue('centroid');
|
|
||||||
expect((select.element as HTMLSelectElement).value).toBe('centroid');
|
|
||||||
|
|
||||||
// 切换回中点模式
|
|
||||||
await select.setValue('middle');
|
|
||||||
expect((select.element as HTMLSelectElement).value).toBe('middle');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('缩放功能', () => {
|
|
||||||
it('应该渲染缩放控件', () => {
|
|
||||||
const wrapper = mount(Luopan);
|
|
||||||
const zoomControls = wrapper.find('.zoom-controls');
|
|
||||||
expect(zoomControls.exists()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该有放大、缩小和重置按钮', () => {
|
|
||||||
const wrapper = mount(Luopan);
|
|
||||||
const buttons = wrapper.findAll('.zoom-controls button');
|
|
||||||
expect(buttons.length).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该显示当前缩放级别', () => {
|
|
||||||
const wrapper = mount(Luopan);
|
|
||||||
const zoomLevel = wrapper.find('.zoom-level');
|
|
||||||
expect(zoomLevel.exists()).toBe(true);
|
|
||||||
expect(zoomLevel.text()).toContain('%');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('点击放大按钮应该增加缩放', async () => {
|
|
||||||
const wrapper = mount(Luopan);
|
|
||||||
const zoomInButton = wrapper.findAll('.zoom-controls button')[1];
|
|
||||||
const svg = wrapper.find('svg');
|
|
||||||
|
|
||||||
const initialTransform = svg.attributes('style');
|
|
||||||
|
|
||||||
await zoomInButton.trigger('click');
|
|
||||||
|
|
||||||
const newTransform = svg.attributes('style');
|
|
||||||
expect(newTransform).not.toBe(initialTransform);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('点击缩小按钮应该减少缩放', async () => {
|
|
||||||
const wrapper = mount(Luopan);
|
|
||||||
const zoomOutButton = wrapper.findAll('.zoom-controls button')[0];
|
|
||||||
const svg = wrapper.find('svg');
|
|
||||||
|
|
||||||
const initialTransform = svg.attributes('style');
|
|
||||||
|
|
||||||
await zoomOutButton.trigger('click');
|
|
||||||
|
|
||||||
const newTransform = svg.attributes('style');
|
|
||||||
expect(newTransform).not.toBe(initialTransform);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('点击重置按钮应该重置缩放和平移', async () => {
|
|
||||||
const wrapper = mount(Luopan);
|
|
||||||
const buttons = wrapper.findAll('.zoom-controls button');
|
|
||||||
const zoomInButton = buttons[1];
|
|
||||||
const resetButton = buttons[2];
|
|
||||||
const svg = wrapper.find('svg');
|
|
||||||
|
|
||||||
// 先放大
|
|
||||||
await zoomInButton.trigger('click');
|
|
||||||
|
|
||||||
// 然后重置
|
|
||||||
await resetButton.trigger('click');
|
|
||||||
|
|
||||||
const transform = svg.attributes('style');
|
|
||||||
expect(transform).toContain('scale(1)');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('缩放到达上限时应该禁用放大按钮', async () => {
|
|
||||||
const wrapper = mount(Luopan);
|
|
||||||
const zoomInButton = wrapper.findAll('.zoom-controls button')[1];
|
|
||||||
|
|
||||||
// 多次点击放大直到达到上限
|
|
||||||
for (let i = 0; i < 30; i++) {
|
|
||||||
await zoomInButton.trigger('click');
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(zoomInButton.attributes('disabled')).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('缩放到达下限时应该禁用缩小按钮', async () => {
|
|
||||||
const wrapper = mount(Luopan);
|
|
||||||
const zoomOutButton = wrapper.findAll('.zoom-controls button')[0];
|
|
||||||
|
|
||||||
// 多次点击缩小直到达到下限
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
await zoomOutButton.trigger('click');
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(zoomOutButton.attributes('disabled')).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('鼠标滚轮缩放', () => {
|
|
||||||
it('应该监听滚轮事件', () => {
|
|
||||||
const wrapper = mount(Luopan);
|
|
||||||
const container = wrapper.find('.svg-container');
|
|
||||||
expect(container.exists()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('向下滚动应该缩小', async () => {
|
|
||||||
const wrapper = mount(Luopan);
|
|
||||||
const container = wrapper.find('.svg-container');
|
|
||||||
const svg = wrapper.find('svg');
|
|
||||||
|
|
||||||
await container.trigger('wheel', { deltaY: 100 });
|
|
||||||
|
|
||||||
// 验证缩放变化
|
|
||||||
const transform = svg.attributes('style');
|
|
||||||
expect(transform).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('向上滚动应该放大', async () => {
|
|
||||||
const wrapper = mount(Luopan);
|
|
||||||
const container = wrapper.find('.svg-container');
|
|
||||||
const svg = wrapper.find('svg');
|
|
||||||
|
|
||||||
await container.trigger('wheel', { deltaY: -100 });
|
|
||||||
|
|
||||||
// 验证缩放变化
|
|
||||||
const transform = svg.attributes('style');
|
|
||||||
expect(transform).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('鼠标拖拽平移', () => {
|
|
||||||
it('鼠标按下时光标应该变为抓取状态', async () => {
|
|
||||||
const wrapper = mount(Luopan);
|
|
||||||
const container = wrapper.find('.svg-container');
|
|
||||||
const svg = wrapper.find('svg');
|
|
||||||
|
|
||||||
// 初始状态
|
|
||||||
expect(svg.attributes('style')).toContain('cursor: grab');
|
|
||||||
|
|
||||||
// 鼠标按下
|
|
||||||
await container.trigger('mousedown', { clientX: 100, clientY: 100 });
|
|
||||||
|
|
||||||
// 应该变为抓取中状态
|
|
||||||
expect(svg.attributes('style')).toContain('cursor: grabbing');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('鼠标释放时光标应该恢复', async () => {
|
|
||||||
const wrapper = mount(Luopan);
|
|
||||||
const container = wrapper.find('.svg-container');
|
|
||||||
const svg = wrapper.find('svg');
|
|
||||||
|
|
||||||
await container.trigger('mousedown', { clientX: 100, clientY: 100 });
|
|
||||||
await container.trigger('mouseup');
|
|
||||||
|
|
||||||
expect(svg.attributes('style')).toContain('cursor: grab');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('拖拽应该改变平移位置', async () => {
|
|
||||||
const wrapper = mount(Luopan);
|
|
||||||
const container = wrapper.find('.svg-container');
|
|
||||||
const svg = wrapper.find('svg');
|
|
||||||
|
|
||||||
const initialTransform = svg.attributes('style');
|
|
||||||
|
|
||||||
// 模拟拖拽
|
|
||||||
await container.trigger('mousedown', { clientX: 100, clientY: 100 });
|
|
||||||
await container.trigger('mousemove', { clientX: 150, clientY: 150 });
|
|
||||||
await container.trigger('mouseup');
|
|
||||||
|
|
||||||
const newTransform = svg.attributes('style');
|
|
||||||
expect(newTransform).not.toBe(initialTransform);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('鼠标离开时应该停止拖拽', async () => {
|
|
||||||
const wrapper = mount(Luopan);
|
|
||||||
const container = wrapper.find('.svg-container');
|
|
||||||
const svg = wrapper.find('svg');
|
|
||||||
|
|
||||||
await container.trigger('mousedown', { clientX: 100, clientY: 100 });
|
|
||||||
await container.trigger('mouseleave');
|
|
||||||
|
|
||||||
expect(svg.attributes('style')).toContain('cursor: grab');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('扇区渲染', () => {
|
|
||||||
it('应该渲染多个扇区路径', () => {
|
|
||||||
const wrapper = mount(Luopan);
|
|
||||||
const sectorPaths = wrapper.findAll('path[fill]').filter(path =>
|
|
||||||
!path.attributes('d')?.includes('none')
|
|
||||||
);
|
);
|
||||||
expect(sectorPaths.length).toBeGreaterThan(0);
|
expect(sectorPaths.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('扇区应该有填充颜色', () => {
|
it('应该渲染文字或 SVG 内容', async () => {
|
||||||
const wrapper = mount(Luopan);
|
const wrapper = mount(Luopan, { props: { config: baseConfig } });
|
||||||
const sectorPaths = wrapper.findAll('path[fill]');
|
await flushPromises();
|
||||||
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');
|
const textPaths = wrapper.findAll('textPath');
|
||||||
expect(textPaths.length).toBeGreaterThan(0);
|
expect(textPaths.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应该在 defs 中定义文字路径', () => {
|
it('应该渲染刻度环和中心图标', async () => {
|
||||||
const wrapper = mount(Luopan);
|
const wrapper = mount(Luopan, { props: { config: baseConfig } });
|
||||||
const defs = wrapper.find('defs');
|
await flushPromises();
|
||||||
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');
|
const lines = wrapper.findAll('line');
|
||||||
// 应该有一些径向线
|
|
||||||
expect(lines.length).toBeGreaterThan(0);
|
expect(lines.length).toBeGreaterThan(0);
|
||||||
});
|
|
||||||
|
|
||||||
it('显示辅助线时应该渲染形心点', async () => {
|
const images = wrapper.findAll('image');
|
||||||
const wrapper = mount(Luopan);
|
expect(images.length).toBeGreaterThan(0);
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
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,73 +5,77 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { useLuopan } from '../src/composables/useLuopan';
|
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', () => {
|
describe('useLuopan', () => {
|
||||||
const createMockExample = (): Example => ({
|
it('应该返回所有必需的属性和方法', async () => {
|
||||||
name: '测试示例',
|
|
||||||
angles: [0, 90, 180, 270, 360],
|
|
||||||
radii: [50, 100, 150],
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('基本功能', () => {
|
|
||||||
it('应该返回所有必需的属性和方法', () => {
|
|
||||||
const example = ref(createMockExample());
|
|
||||||
const textRadialPosition = ref<TextRadialPosition>('middle');
|
const textRadialPosition = ref<TextRadialPosition>('middle');
|
||||||
|
const result = useLuopan(createMockConfig(), textRadialPosition);
|
||||||
const result = useLuopan(example, textRadialPosition);
|
await result.reload();
|
||||||
|
|
||||||
expect(result).toHaveProperty('anglesDeg');
|
expect(result).toHaveProperty('anglesDeg');
|
||||||
expect(result).toHaveProperty('rings');
|
expect(result).toHaveProperty('rings');
|
||||||
expect(result).toHaveProperty('outerMost');
|
expect(result).toHaveProperty('outerMost');
|
||||||
expect(result).toHaveProperty('sectors');
|
expect(result).toHaveProperty('sectors');
|
||||||
|
expect(result).toHaveProperty('degreeRing');
|
||||||
|
expect(result).toHaveProperty('centerIcon');
|
||||||
expect(result).toHaveProperty('toXY');
|
expect(result).toHaveProperty('toXY');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('anglesDeg 应该返回角度数组', () => {
|
it('anglesDeg 应该按 divisions 生成角度数组', async () => {
|
||||||
const example = ref(createMockExample());
|
|
||||||
const textRadialPosition = ref<TextRadialPosition>('middle');
|
const textRadialPosition = ref<TextRadialPosition>('middle');
|
||||||
|
const { anglesDeg, reload } = useLuopan(createMockConfig(), textRadialPosition);
|
||||||
const { anglesDeg } = useLuopan(example, textRadialPosition);
|
await reload();
|
||||||
|
|
||||||
expect(anglesDeg.value).toEqual([0, 90, 180, 270, 360]);
|
expect(anglesDeg.value).toEqual([0, 90, 180, 270, 360]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rings 应该返回半径数组', () => {
|
it('rings 应该返回 rOuter 列表', async () => {
|
||||||
const example = ref(createMockExample());
|
|
||||||
const textRadialPosition = ref<TextRadialPosition>('middle');
|
const textRadialPosition = ref<TextRadialPosition>('middle');
|
||||||
|
const { rings, reload } = useLuopan(createMockConfig(), textRadialPosition);
|
||||||
|
await reload();
|
||||||
|
|
||||||
const { rings } = useLuopan(example, textRadialPosition);
|
expect(rings.value).toEqual([80]);
|
||||||
|
|
||||||
expect(rings.value).toEqual([50, 100, 150]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('outerMost 应该返回最外层半径', () => {
|
it('outerMost 应该返回最大半径', async () => {
|
||||||
const example = ref(createMockExample());
|
|
||||||
const textRadialPosition = ref<TextRadialPosition>('middle');
|
const textRadialPosition = ref<TextRadialPosition>('middle');
|
||||||
|
const { outerMost, reload } = useLuopan(createMockConfig(), textRadialPosition);
|
||||||
|
await reload();
|
||||||
|
|
||||||
const { outerMost } = useLuopan(example, textRadialPosition);
|
expect(outerMost.value).toBe(80);
|
||||||
|
|
||||||
expect(outerMost.value).toBe(150);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('扇区生成', () => {
|
it('应该生成正确数量的扇区', async () => {
|
||||||
it('应该生成正确数量的扇区', () => {
|
|
||||||
const example = ref(createMockExample());
|
|
||||||
const textRadialPosition = ref<TextRadialPosition>('middle');
|
const textRadialPosition = ref<TextRadialPosition>('middle');
|
||||||
|
const { sectors, reload } = useLuopan(createMockConfig(), textRadialPosition);
|
||||||
|
await reload();
|
||||||
|
|
||||||
const { sectors } = useLuopan(example, textRadialPosition);
|
expect(sectors.value.length).toBe(4);
|
||||||
|
|
||||||
// 4个角度分割 × 3层 = 12个扇区
|
|
||||||
expect(sectors.value.length).toBe(12);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('每个扇区应该有必需的属性', () => {
|
it('扇区应包含必要字段', async () => {
|
||||||
const example = ref(createMockExample());
|
|
||||||
const textRadialPosition = ref<TextRadialPosition>('middle');
|
const textRadialPosition = ref<TextRadialPosition>('middle');
|
||||||
|
const { sectors, reload } = useLuopan(createMockConfig(), textRadialPosition);
|
||||||
const { sectors } = useLuopan(example, textRadialPosition);
|
await reload();
|
||||||
|
|
||||||
sectors.value.forEach((sector) => {
|
sectors.value.forEach((sector) => {
|
||||||
expect(sector).toHaveProperty('key');
|
expect(sector).toHaveProperty('key');
|
||||||
@@ -81,346 +85,8 @@ describe('useLuopan', () => {
|
|||||||
expect(sector).toHaveProperty('rOuter');
|
expect(sector).toHaveProperty('rOuter');
|
||||||
expect(sector).toHaveProperty('aStart');
|
expect(sector).toHaveProperty('aStart');
|
||||||
expect(sector).toHaveProperty('aEnd');
|
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('path');
|
||||||
expect(sector).toHaveProperty('textPath');
|
expect(sector).toHaveProperty('textPath');
|
||||||
expect(sector).toHaveProperty('textPathId');
|
|
||||||
expect(sector).toHaveProperty('isVertical');
|
|
||||||
expect(sector).toHaveProperty('fontSize');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('扇区的 key 应该是唯一的', () => {
|
|
||||||
const example = ref(createMockExample());
|
|
||||||
const textRadialPosition = ref<TextRadialPosition>('middle');
|
|
||||||
|
|
||||||
const { sectors } = useLuopan(example, textRadialPosition);
|
|
||||||
|
|
||||||
const keys = sectors.value.map((s) => s.key);
|
|
||||||
const uniqueKeys = new Set(keys);
|
|
||||||
expect(uniqueKeys.size).toBe(keys.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('扇区应该有正确的层索引和扇区索引', () => {
|
|
||||||
const example = ref(createMockExample());
|
|
||||||
const textRadialPosition = ref<TextRadialPosition>('middle');
|
|
||||||
|
|
||||||
const { sectors } = useLuopan(example, textRadialPosition);
|
|
||||||
|
|
||||||
// 检查第一层的扇区
|
|
||||||
const layer0Sectors = sectors.value.filter((s) => s.layerIndex === 0);
|
|
||||||
expect(layer0Sectors.length).toBe(4);
|
|
||||||
expect(layer0Sectors.map((s) => s.pieIndex)).toEqual([0, 1, 2, 3]);
|
|
||||||
|
|
||||||
// 检查第二层的扇区
|
|
||||||
const layer1Sectors = sectors.value.filter((s) => s.layerIndex === 1);
|
|
||||||
expect(layer1Sectors.length).toBe(4);
|
|
||||||
expect(layer1Sectors.map((s) => s.pieIndex)).toEqual([0, 1, 2, 3]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('第一层扇区应该从圆心开始(rInner = 0)', () => {
|
|
||||||
const example = ref(createMockExample());
|
|
||||||
const textRadialPosition = ref<TextRadialPosition>('middle');
|
|
||||||
|
|
||||||
const { sectors } = useLuopan(example, textRadialPosition);
|
|
||||||
|
|
||||||
const layer0Sectors = sectors.value.filter((s) => s.layerIndex === 0);
|
|
||||||
layer0Sectors.forEach((sector) => {
|
|
||||||
expect(sector.rInner).toBe(0);
|
|
||||||
expect(sector.rOuter).toBe(50);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('扇区应该有正确的角度范围', () => {
|
|
||||||
const example = ref(createMockExample());
|
|
||||||
const textRadialPosition = ref<TextRadialPosition>('middle');
|
|
||||||
|
|
||||||
const { sectors } = useLuopan(example, textRadialPosition);
|
|
||||||
|
|
||||||
const sector0 = sectors.value.find((s) => s.layerIndex === 0 && s.pieIndex === 0);
|
|
||||||
expect(sector0?.aStart).toBe(0);
|
|
||||||
expect(sector0?.aEnd).toBe(90);
|
|
||||||
|
|
||||||
const sector1 = sectors.value.find((s) => s.layerIndex === 0 && s.pieIndex === 1);
|
|
||||||
expect(sector1?.aStart).toBe(90);
|
|
||||||
expect(sector1?.aEnd).toBe(180);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('扇区应该有有效的颜色', () => {
|
|
||||||
const example = ref(createMockExample());
|
|
||||||
const textRadialPosition = ref<TextRadialPosition>('middle');
|
|
||||||
|
|
||||||
const { sectors } = useLuopan(example, textRadialPosition);
|
|
||||||
|
|
||||||
sectors.value.forEach((sector) => {
|
|
||||||
expect(sector.fill).toMatch(/^#[0-9a-fA-F]{6}$/);
|
|
||||||
expect(sector.textColor).toMatch(/^#[0-9a-fA-F]{6}$/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('扇区应该有有效的字体大小', () => {
|
|
||||||
const example = ref(createMockExample());
|
|
||||||
const textRadialPosition = ref<TextRadialPosition>('middle');
|
|
||||||
|
|
||||||
const { sectors } = useLuopan(example, textRadialPosition);
|
|
||||||
|
|
||||||
sectors.value.forEach((sector) => {
|
|
||||||
expect(sector.fontSize).toBeGreaterThan(0);
|
|
||||||
expect(sector.fontSize).toBeLessThanOrEqual(30);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('扇区应该有非空的标签', () => {
|
|
||||||
const example = ref(createMockExample());
|
|
||||||
const textRadialPosition = ref<TextRadialPosition>('middle');
|
|
||||||
|
|
||||||
const { sectors } = useLuopan(example, textRadialPosition);
|
|
||||||
|
|
||||||
sectors.value.forEach((sector) => {
|
|
||||||
expect(sector.label).toBeTruthy();
|
|
||||||
expect(sector.label.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('扇区应该有有效的 SVG 路径', () => {
|
|
||||||
const example = ref(createMockExample());
|
|
||||||
const textRadialPosition = ref<TextRadialPosition>('middle');
|
|
||||||
|
|
||||||
const { sectors } = useLuopan(example, textRadialPosition);
|
|
||||||
|
|
||||||
sectors.value.forEach((sector) => {
|
|
||||||
expect(sector.path).toContain('M ');
|
|
||||||
expect(sector.path).toContain('Z');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('扇区应该有有效的文字路径', () => {
|
|
||||||
const example = ref(createMockExample());
|
|
||||||
const textRadialPosition = ref<TextRadialPosition>('middle');
|
|
||||||
|
|
||||||
const { sectors } = useLuopan(example, textRadialPosition);
|
|
||||||
|
|
||||||
sectors.value.forEach((sector) => {
|
|
||||||
expect(sector.textPath).toContain('M ');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('文字位置模式', () => {
|
|
||||||
it('应该在 middle 模式下使用中点位置', () => {
|
|
||||||
const example = ref(createMockExample());
|
|
||||||
const textRadialPosition = ref<TextRadialPosition>('middle');
|
|
||||||
|
|
||||||
const { sectors } = useLuopan(example, textRadialPosition);
|
|
||||||
|
|
||||||
// 第二层的中点应该在 (50 + 100) / 2 = 75 附近
|
|
||||||
const layer1Sector = sectors.value.find((s) => s.layerIndex === 1);
|
|
||||||
expect(layer1Sector).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该在 centroid 模式下使用形心位置', () => {
|
|
||||||
const example = ref(createMockExample());
|
|
||||||
const textRadialPosition = ref<TextRadialPosition>('centroid');
|
|
||||||
|
|
||||||
const { sectors } = useLuopan(example, textRadialPosition);
|
|
||||||
|
|
||||||
// 形心位置应该与中点位置不同
|
|
||||||
expect(sectors.value.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('最内层应该始终使用形心位置', () => {
|
|
||||||
const example = ref(createMockExample());
|
|
||||||
const textRadialPosition = ref<TextRadialPosition>('middle');
|
|
||||||
|
|
||||||
const { sectors } = useLuopan(example, textRadialPosition);
|
|
||||||
|
|
||||||
const innermostSectors = sectors.value.filter((s) => s.layerIndex === 0);
|
|
||||||
expect(innermostSectors.length).toBeGreaterThan(0);
|
|
||||||
// 最内层应该使用形心位置,即使设置为 middle
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('竖排文字判断', () => {
|
|
||||||
it('应该为窄扇区设置 isVertical', () => {
|
|
||||||
const example = ref({
|
|
||||||
name: '窄扇区示例',
|
|
||||||
angles: [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160, 170, 180, 190, 200, 210, 220, 230, 240, 250, 260, 270, 280, 290, 300, 310, 320, 330, 340, 350, 360],
|
|
||||||
radii: [50, 100, 150],
|
|
||||||
});
|
|
||||||
const textRadialPosition = ref<TextRadialPosition>('middle');
|
|
||||||
|
|
||||||
const { sectors } = useLuopan(example, textRadialPosition);
|
|
||||||
|
|
||||||
// 某些窄扇区应该被设置为竖排
|
|
||||||
const verticalSectors = sectors.value.filter((s) => s.isVertical);
|
|
||||||
expect(verticalSectors.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该为宽扇区不设置 isVertical', () => {
|
|
||||||
const example = ref({
|
|
||||||
name: '宽扇区示例',
|
|
||||||
angles: [0, 180, 360],
|
|
||||||
radii: [50, 60],
|
|
||||||
});
|
|
||||||
const textRadialPosition = ref<TextRadialPosition>('middle');
|
|
||||||
|
|
||||||
const { sectors } = useLuopan(example, textRadialPosition);
|
|
||||||
|
|
||||||
// 宽扇区应该都不是竖排
|
|
||||||
sectors.value.forEach((sector) => {
|
|
||||||
expect(sector.isVertical).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('内部填色', () => {
|
|
||||||
it('某些扇区应该有内部填色', () => {
|
|
||||||
const example = ref(createMockExample());
|
|
||||||
const textRadialPosition = ref<TextRadialPosition>('middle');
|
|
||||||
|
|
||||||
const { sectors } = useLuopan(example, textRadialPosition);
|
|
||||||
|
|
||||||
const filledSectors = sectors.value.filter(
|
|
||||||
(s) => s.innerFillPath && s.innerFillColor
|
|
||||||
);
|
|
||||||
expect(filledSectors.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('有内部填色的扇区应该使用白色底色和黑色文字', () => {
|
|
||||||
const example = ref(createMockExample());
|
|
||||||
const textRadialPosition = ref<TextRadialPosition>('middle');
|
|
||||||
|
|
||||||
const { sectors } = useLuopan(example, textRadialPosition);
|
|
||||||
|
|
||||||
const filledSectors = sectors.value.filter(
|
|
||||||
(s) => s.innerFillPath && s.innerFillColor
|
|
||||||
);
|
|
||||||
filledSectors.forEach((sector) => {
|
|
||||||
expect(sector.fill).toBe('#ffffff');
|
|
||||||
expect(sector.textColor).toBe('#111827');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('响应式更新', () => {
|
|
||||||
it('应该响应示例变化', () => {
|
|
||||||
const example = ref(createMockExample());
|
|
||||||
const textRadialPosition = ref<TextRadialPosition>('middle');
|
|
||||||
|
|
||||||
const { sectors } = useLuopan(example, textRadialPosition);
|
|
||||||
|
|
||||||
const initialCount = sectors.value.length;
|
|
||||||
|
|
||||||
// 更改示例
|
|
||||||
example.value = {
|
|
||||||
name: '新示例',
|
|
||||||
angles: [0, 120, 240, 360],
|
|
||||||
radii: [100, 200],
|
|
||||||
};
|
|
||||||
|
|
||||||
// 扇区数量应该变化
|
|
||||||
expect(sectors.value.length).not.toBe(initialCount);
|
|
||||||
expect(sectors.value.length).toBe(6); // 3个角度分割 × 2层
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该响应文字位置模式变化', () => {
|
|
||||||
const example = ref(createMockExample());
|
|
||||||
const textRadialPosition = ref<TextRadialPosition>('middle');
|
|
||||||
|
|
||||||
const { sectors } = useLuopan(example, textRadialPosition);
|
|
||||||
|
|
||||||
const middlePaths = sectors.value.map((s) => s.textPath);
|
|
||||||
|
|
||||||
// 更改文字位置模式
|
|
||||||
textRadialPosition.value = 'centroid';
|
|
||||||
|
|
||||||
const centroidPaths = sectors.value.map((s) => s.textPath);
|
|
||||||
|
|
||||||
// 非最内层的文字路径应该有所不同
|
|
||||||
const differentPaths = middlePaths.filter(
|
|
||||||
(path, index) => path !== centroidPaths[index]
|
|
||||||
);
|
|
||||||
expect(differentPaths.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('toXY', () => {
|
|
||||||
it('应该正确转换极坐标', () => {
|
|
||||||
const example = ref(createMockExample());
|
|
||||||
const textRadialPosition = ref<TextRadialPosition>('middle');
|
|
||||||
|
|
||||||
const { toXY } = useLuopan(example, textRadialPosition);
|
|
||||||
|
|
||||||
const point = toXY(0, 100);
|
|
||||||
expect(point.x).toBeCloseTo(0);
|
|
||||||
expect(point.y).toBeCloseTo(-100);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('边界情况', () => {
|
|
||||||
it('应该处理单层罗盘', () => {
|
|
||||||
const example = ref({
|
|
||||||
name: '单层',
|
|
||||||
angles: [0, 90, 180, 270, 360],
|
|
||||||
radii: [100],
|
|
||||||
});
|
|
||||||
const textRadialPosition = ref<TextRadialPosition>('middle');
|
|
||||||
|
|
||||||
const { sectors } = useLuopan(example, textRadialPosition);
|
|
||||||
|
|
||||||
expect(sectors.value.length).toBe(4);
|
|
||||||
sectors.value.forEach((sector) => {
|
|
||||||
expect(sector.layerIndex).toBe(0);
|
|
||||||
expect(sector.rInner).toBe(0);
|
|
||||||
expect(sector.rOuter).toBe(100);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该处理两个扇区的罗盘', () => {
|
|
||||||
const example = ref({
|
|
||||||
name: '两扇区',
|
|
||||||
angles: [0, 180, 360],
|
|
||||||
radii: [100],
|
|
||||||
});
|
|
||||||
const textRadialPosition = ref<TextRadialPosition>('middle');
|
|
||||||
|
|
||||||
const { sectors } = useLuopan(example, textRadialPosition);
|
|
||||||
|
|
||||||
expect(sectors.value.length).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该处理大量层数的罗盘', () => {
|
|
||||||
const example = ref({
|
|
||||||
name: '多层',
|
|
||||||
angles: [0, 90, 180, 270, 360],
|
|
||||||
radii: [20, 40, 60, 80, 100, 120, 140, 160, 180, 200],
|
|
||||||
});
|
|
||||||
const textRadialPosition = ref<TextRadialPosition>('middle');
|
|
||||||
|
|
||||||
const { sectors } = useLuopan(example, textRadialPosition);
|
|
||||||
|
|
||||||
expect(sectors.value.length).toBe(40); // 4个扇区 × 10层
|
|
||||||
const layerIndices = new Set(sectors.value.map((s) => s.layerIndex));
|
|
||||||
expect(layerIndices.size).toBe(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该处理大量扇区的罗盘', () => {
|
|
||||||
const example = ref({
|
|
||||||
name: '多扇区',
|
|
||||||
angles: Array.from({ length: 37 }, (_, i) => i * 10), // 36个扇区
|
|
||||||
radii: [100, 200],
|
|
||||||
});
|
|
||||||
const textRadialPosition = ref<TextRadialPosition>('middle');
|
|
||||||
|
|
||||||
const { sectors } = useLuopan(example, textRadialPosition);
|
|
||||||
|
|
||||||
expect(sectors.value.length).toBe(72); // 36个扇区 × 2层
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
10
todolist.md
10
todolist.md
@@ -189,15 +189,15 @@
|
|||||||
rinner
|
rinner
|
||||||
router
|
router
|
||||||
showDegree -- 是否显示度数,0不限时,1显示,如显示按10度间隔显示
|
showDegree -- 是否显示度数,0不限时,1显示,如显示按10度间隔显示
|
||||||
mode -- inner(表示刻度线在rinner的外部),outter(表示刻度线在routter的内部),both(两边都标注,度数(如有)居于中间)
|
mode -- inner(表示刻度线在rInner的外部),outer(表示刻度线在rOuter的内部),both(两边都标注,度数(如有)居于中间)
|
||||||
opacity -- 圆的透明度,目的是有时候只需要显示刻度,而不用显示圆圈
|
opacity -- 圆的透明度,目的是有时候只需要显示刻度,而不用显示圆圈
|
||||||
|
|
||||||
-- ========================================
|
-- ========================================
|
||||||
-- 360度刻度环配置 (360 Degree Scale Ring)
|
-- 360度刻度环配置 (360 Degree Scale Ring)
|
||||||
-- ========================================
|
-- ========================================
|
||||||
"degreeRing": {
|
"degreeRing": {
|
||||||
"rInner": 450, -- 刻度环内半径
|
"rInner": 350, -- 刻度环内半径
|
||||||
"rOuter": 500, -- 刻度环外半径
|
"rOuter": 380, -- 刻度环外半径
|
||||||
"showDegree": 1, -- 是否显示度数:0=不显示,1=显示(按 10° 间隔)
|
"showDegree": 1, -- 是否显示度数:0=不显示,1=显示(按 10° 间隔)
|
||||||
"mode": "both", -- 刻度线模式:"inner"(在rInner外侧)、"outer"(在rOuter内侧)、"both"(两侧都有,度数居中)
|
"mode": "both", -- 刻度线模式:"inner"(在rInner外侧)、"outer"(在rOuter内侧)、"both"(两侧都有,度数居中)
|
||||||
"opacity": 0.3, -- 圆环透明度(0.0-1.0,设置为0可以只显示刻度而不显示圆圈)
|
"opacity": 0.3, -- 圆环透明度(0.0-1.0,设置为0可以只显示刻度而不显示圆圈)
|
||||||
@@ -206,7 +206,7 @@
|
|||||||
"majorTick": 10, -- 主刻度间隔(度),如 10 表示每 10° 一个主刻度
|
"majorTick": 10, -- 主刻度间隔(度),如 10 表示每 10° 一个主刻度
|
||||||
"minorTick": 5, -- 次刻度间隔(度),如 2 表示每 2° 一个次刻度
|
"minorTick": 5, -- 次刻度间隔(度),如 2 表示每 2° 一个次刻度
|
||||||
"microTick": 1, -- 微刻度间隔(度),如 1 表示每 1° 一个微刻度
|
"microTick": 1, -- 微刻度间隔(度),如 1 表示每 1° 一个微刻度
|
||||||
"tickColor": "#ffffff",-- 刻度线颜色
|
"tickColor": "#000000",-- 刻度线颜色
|
||||||
"ringColor": "#ffffff" -- 圆环颜色
|
"ringColor": "#000000" -- 圆环颜色
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user