update at 2026-01-23 23:20:39

This commit is contained in:
douboer@gmail.com
2026-01-23 23:20:39 +08:00
parent dc45937623
commit d6312fcd16
24 changed files with 982 additions and 143 deletions

1
.codex/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
tmp/

View File

@@ -6,23 +6,7 @@
"strokeOpacity": 1,
"strokeColor": "黑",
"insetDistance": 1,
"theme": {
"name": "五行配色主题",
"colorPalettes": {
"黑": "#000000",
"灰": "#757575",
"白": "#ffffff",
"木": "#43A047",
"火": "#E53935",
"土": "#8D6E63",
"金": "#D4AF37",
"水": "#0288D1",
"热": "#FF8F00",
"冷": "#1976D2",
"强": "#D32F2F",
"软": "#FFE0B2"
}
},
"themeRef": "五行强",
"layers": [
{
"type": "centerIcon",

View File

@@ -26,33 +26,9 @@
-- ========================================
-- 主题配置
-- ========================================
"theme": {
"name": "五行配色主题",
-- 文字颜色自动计算:根据背景色明暗度自动选择黑/白文字,确保高对比度
-- "textOnLight": "#1a1a1a", 已删除,由系统自动计算
-- "textOnDark": "#ffffff", 已删除,由系统自动计算
-- ========================================
-- 命名配色方案 (Named Color Palettes)
-- ========================================
-- 定义可复用的命名颜色,在 layers 中通过名称引用
-- 包含10种配色黑、灰、白、五行木火土金水、热、冷、强、软
"colorPalettes": {
"黑": "#000000", -- 纯黑
"灰": "#757575", -- 中灰
"白": "#ffffff", -- 纯白
"木": "#43A047", -- 生机绿(五行:木)
"火": "#E53935", -- 烈焰红(五行:火)
"土": "#8D6E63", -- 大地棕(五行:土)
"金": "#78909C", -- 金属灰(五行:金)
"水": "#0288D1", -- 水蓝(五行:水)
"热": "#FF8F00", -- 暖橙(暖色调)
"冷": "#1976D2", -- 冷蓝(冷色调)
"强": "#D32F2F", -- 强烈红(高饱和度)
"\u8f6f": "#FFE0B2" -- 柔和杏(低饱和度)
},
},
-- 主题统一放在 public/themes.json 中管理
-- 通过 themeRef 引用主题名称;不填则使用 themes.json.default
"themeRef": "五行弱",
-- ========================================
-- 层配置 (Layers Configuration)
@@ -129,7 +105,7 @@
"startAngle": 0, -- 第一个扇区的起始角度0度为正北方向顺时针
"colorRef": "土", -- 着色使用的颜色引用
"innerFill": 1, -- 着色区域的内缩设置
-- "start": 1, -- 从第1个扇区开始着色已废弃统一从第1个扇区开始
-- "patternOffset": 2, -- 第一个着色单元偏移1个扇区(可选
"num": 3, -- 连续着色3个扇区
"interval": 1, -- 着色后间隔1个扇区
"sectors": [
@@ -265,17 +241,18 @@
-- 示例:{ "content": "木", "colorRef": "木" }
--
-- 优先级2层级规律填色配置
-- 通过 colorRef + start + num + interval 实现规律着色
-- 通过 colorRef + patternOffset + num + interval 实现规律着色
-- 未着色的扇区使用全局 background 颜色
--
-- ========================================
-- 规律填色算法说明
-- ========================================
-- startAngle: 第一个扇区的起始角度0度为正北方向顺时针增加
-- patternOffset: 规律填色起始偏移扇区序号从1开始默认1未指定时与startAngle对齐
-- num: 连续着色扇区数量
-- interval: 着色后间隔的扇区数量
--
-- 算法:从第1个扇区开始,循环执行 "着色num个" → "跳过interval个"
-- 算法:从第 patternOffset 个扇区开始,循环执行 "着色num个" → "跳过interval个"
-- 特殊情况interval=0 表示无间隔,全部着色
--
-- 示例divisions=12, startAngle=0, num=3, interval=1

View File

@@ -1,25 +1,13 @@
{
"name": "demo2",
"description": "luopan demo config with named color palettes",
"description": "luopan demo2 config with named color palettes",
"background": "#000000",
"strokeWidth": 1,
"strokeOpacity": 1,
"strokeColor": "白",
"insetDistance": 1,
"outerRadius": 500,
"theme": {
"name": "五行配色主题",
"colorPalettes": {
"黑": "#000000",
"灰": "#757575",
"白": "#ffffff",
"木": "#43A047",
"火": "#E53935",
"土": "#8D6E63",
"金": "#78909C",
"水": "#0288D1",
"热": "#FF8F00",
"冷": "#1976D2",
"强": "#D32F2F",
"\u8f6f": "#FFE0B2"
}
},
"themeRef": "五行弱",
"layers": [
{
"type": "centerIcon",
@@ -38,11 +26,12 @@
"sectors": [
{
"content": "阴",
"colorRef": "黑",
"innerFill": 1
},
{
"content": "阳",
"colorRef": "White",
"colorRef": "",
"innerFill": 0
}
]
@@ -150,8 +139,8 @@
"startAngle": -4.5,
"innerFill": 0,
"colorRef": "火",
"num": 3,
"interval": 2,
"num": 4,
"interval": 1,
"groupSplit": false
},
{

160
public/demo3.json Normal file
View File

@@ -0,0 +1,160 @@
{
"name": "demo",
"description": "luopan demo config with named color palettes",
"background": "白",
"strokeWidth": 1,
"strokeOpacity": 1,
"strokeColor": "黑",
"insetDistance": 1,
"themeRef": "五行弱",
"layers": [
{
"type": "centerIcon",
"centerIcon": {
"rIcon": 50,
"opacity": 0.8,
"name": "centericon.svg"
}
},
{
"divisions": 2,
"rInner": 60,
"rOuter": 100,
"startAngle": 0,
"sectors": [
{
"content": "阴",
"colorRef": "黑"
},
{
"content": "阳",
"colorRef": "白"
}
]
},
{
"divisions": 8,
"rInner": 120,
"rOuter": 160,
"startAngle": 0,
"colorRef": "水",
"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": "土",
"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": 200,
"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": 250,
"rOuter": 270,
"startAngle": 0,
"sectors": [
{ "content": "甲乙|丙丁|戊己|庚辛", "colorRef": "木", "innerFill": 1 },
{ "content": "壬癸|甲乙|丙丁|戊己", "colorRef": "土", "innerFill": 0 },
{ "content": "庚辛|壬癸|甲乙|丙丁", "innerFill": 1 },
{ "content": "戊己|庚辛|壬癸|甲乙", "colorRef": "火", "innerFill": 0 },
{ "content": "丙丁|戊己|庚辛|壬癸", "colorRef": "金", "innerFill": 1 },
{ "content": "子丑|寅卯|辰巳|午未", "innerFill": 0 },
{ "content": "申酉|戌亥|子丑|寅卯", "colorRef": "木", "innerFill": 1 },
{ "content": "辰巳|午未|申酉|戌亥", "colorRef": "水", "innerFill": 0 },
{ "content": "甲乙|丙丁|戊己|庚辛", "innerFill": 1 },
{ "content": "壬癸|甲乙|丙丁|戊己", "colorRef": "土", "innerFill": 0 },
{ "content": "庚辛|壬癸|甲乙|丙丁", "colorRef": "木", "innerFill": 1 },
{ "content": "戊己|庚辛|壬癸|甲乙", "innerFill": 0 },
{ "content": "丙丁|戊己|庚辛|壬癸", "colorRef": "火", "innerFill": 1 },
{ "content": "子丑|寅卯|辰巳|午未", "colorRef": "金", "innerFill": 0 },
{ "content": "申酉|戌亥|子丑|寅卯", "innerFill": 1 },
{ "content": "辰巳|午未|申酉|戌亥", "colorRef": "水", "innerFill": 0 }
]
},
{
"divisions": 120,
"rInner": 300,
"rOuter": 310,
"startAngle": -4.5,
"innerFill": 0,
"colorRef": "火",
"num": 3,
"interval": 2,
"groupSplit": false
},
{
"type": "degreeRing",
"degreeRing": {
"rInner": 350,
"rOuter": 380,
"showDegree": 1,
"mode": "both",
"opacity": 1,
"tickLength": 6,
"tickLengthStep": 2,
"majorTick": 10,
"minorTick": 5,
"microTick": 1,
"tickColor": "#000000",
"ringColor": "#000000"
}
}
]
}

View File

@@ -0,0 +1,21 @@
{
"default": "demo.json",
"items": [
{
"name": "样式测试1",
"path": "/demo.json"
},
{
"name": "样式测试2",
"path": "/demo2.json"
},
{
"name": "样式测试3",
"path": "/demo3.json"
},
{
"name": "入门罗盘",
"path": "/rumen.json"
}
]
}

162
public/rumen.json Normal file
View File

@@ -0,0 +1,162 @@
{
"name": "rumen",
"description": "luopan rumen config",
"background": "金",
"strokeWidth": 1,
"strokeOpacity": 1,
"strokeColor": "黑",
"insetDistance": 1,
"themeRef": "五行弱",
"layers": [
{
"type": "centerIcon",
"centerIcon": {
"rIcon": 50,
"opacity": 0.8,
"name": "centericon.svg"
}
},
{
"divisions": 2,
"rInner": 60,
"rOuter": 100,
"startAngle": 0,
"sectors": [
{
"content": "阴",
"colorRef": "黑"
},
{
"content": "阳",
"colorRef": "白"
}
]
},
{
"divisions": 8,
"rInner": 120,
"rOuter": 160,
"startAngle": 0,
"sectors": [
{ "content": "乾", "colorRef": "火", "innerFill": 1 },
{ "content": "兑", "colorRef": "冷", "innerFill": 0 },
{ "content": "离", "colorRef": "火", "innerFill": 1 },
{ "content": "震", "colorRef": "木", "innerFill": 0 },
{ "content": "巽", "colorRef": "木", "innerFill": 1 },
{ "content": "坎", "colorRef": "冷", "innerFill": 0 },
{ "content": "艮", "colorRef": "强", "innerFill": 1 },
{ "content": "坤", "colorRef": "土", "innerFill": 0 }
]
},
{
"divisions": 12,
"rInner": 160,
"rOuter": 200,
"startAngle": 0,
"colorRef": "金",
"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": 200,
"rOuter": 240,
"startAngle": 0,
"colorRef": "金",
"num": 2,
"interval": 1,
"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": 250,
"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"
}
}
]
}

174
public/themes.json Normal file
View File

@@ -0,0 +1,174 @@
{
"default": "五行强",
"items": [
{
"name": "五行强",
"colorPalettes": {
"黑": "#000000",
"灰": "#757575",
"白": "#ffffff",
"木": "#43A047",
"火": "#E53935",
"土": "#8D6E63",
"金": "#D4AF37",
"水": "#0288D1",
"热": "#FF8F00",
"冷": "#1976D2",
"强": "#D32F2F",
"软": "#FFE0B2"
}
},
{
"name": "五行弱",
"colorPalettes": {
"黑": "#000000",
"灰": "#DADADA",
"白": "#ffffff",
"木": "#2A6B2D",
"火": "#C44846",
"土": "#A57563",
"金": "#E7D238",
"水": "#1E7BAC",
"热": "#FF8F00",
"冷": "#1976D2",
"强": "#FF0000",
"弱": "#EFD272"
}
},
{
"name": "晨曦",
"colorPalettes": {
"霞光": "#FFB997",
"暖金": "#FFC857",
"薄雾": "#E6EFF6",
"嫩绿": "#A8E6CE",
"朝蓝": "#7AC7E3",
"珊红": "#FF6B6B",
"砂色": "#D9B99B",
"云白": "#FFFFFF",
"影灰": "#7D7F87",
"墨蓝": "#2A3D66"
}
},
{
"name": "暮色",
"colorPalettes": {
"暮紫": "#6C5B7B",
"深蓝": "#283149",
"暮橙": "#FF914D",
"黯灰": "#5D5D66",
"暗玉": "#2E8B7C",
"烟蓝": "#4B6F86",
"月白": "#F2F6F8",
"铜黄": "#C08F35",
"绛红": "#A23E48",
"夜黑": "#0B0F1A"
}
},
{
"name": "海风",
"colorPalettes": {
"海蓝": "#0077B6",
"浅滩": "#EAF6F6",
"浪白": "#F7FCFF",
"蔚蓝": "#00B4D8",
"青绿": "#2EC4B6",
"碧玉": "#118AB2",
"珊粉": "#FF7A7A",
"深海": "#023E8A",
"泡沫": "#CFF7F3",
"夕珊": "#FFB4A2"
}
},
{
"name": "砂岩",
"colorPalettes": {
"砂黄": "#DDB892",
"土褐": "#A67C52",
"岩灰": "#8A8776",
"日黄": "#FFDA77",
"褐红": "#A65E3B",
"朽木": "#C7B299",
"石白": "#F5F3EE",
"暮砂": "#BFAE9D",
"土灰": "#9E8F80",
"暗褐": "#5C3A21"
}
},
{
"name": "森林",
"colorPalettes": {
"树绿": "#2F7A1F",
"苔绿": "#69995B",
"深绿": "#1B4636",
"叶黄": "#C8D44B",
"泥褐": "#7C5A3A",
"树皮": "#6A4C3B",
"莹绿": "#9FD89B",
"林影": "#254117",
"嫩叶": "#B7E4A6",
"藤蔓": "#3B6B35"
}
},
{
"name": "霓虹",
"colorPalettes": {
"电青": "#00F5D4",
"霓粉": "#FF6AC1",
"紫光": "#9D4EDD",
"亮黄": "#FFD60A",
"霓橙": "#FF7A00",
"洋红": "#FF007F",
"亮蓝": "#00A3FF",
"霓绿": "#41F582",
"黑底": "#0A0A0A",
"白晕": "#F8F8FF"
}
},
{
"name": "芙蓉",
"colorPalettes": {
"芙粉": "#FADCD9",
"荷绿": "#9BD7C6",
"莲白": "#FFF7F3",
"柔紫": "#E9D6FF",
"粉橙": "#FFD1BA",
"浅灰": "#E6E6E6",
"香黄": "#FFE9A8",
"薄荷": "#BCEBCB",
"桃红": "#FF94A6",
"雾蓝": "#DDECF7"
}
},
{
"name": "墨韵",
"colorPalettes": {
"墨黑": "#0B0B0B",
"浓灰": "#4A4A4A",
"烟白": "#F5F5F5",
"石墨": "#2E2E2E",
"青黛": "#1F6F8B",
"皓白": "#FFFFFF",
"墨蓝": "#14213D",
"暗灰": "#6B6B6B",
"笔褐": "#5B4B3A",
"玄青": "#0F4C5C"
}
},
{
"name": "灰阶",
"colorPalettes": {
"墨灰": "#0B0B0B",
"深灰": "#2F2F2F",
"石灰": "#595959",
"烟灰": "#808080",
"中灰": "#A0A0A0",
"银灰": "#C0C0C0",
"雾灰": "#D9D9D9",
"亮灰": "#EAEAEA",
"浅灰": "#F5F5F5",
"乳白": "#FFFFFF"
}
}
]
}

View File

@@ -17,15 +17,91 @@
**核心变化:**
1. **配置驱动**:从 JSON 配置文件完全定义罗盘结构
2. **新增罗盘零改码**:新增罗盘只需在 `public/` 下增加 JSON 配置文件,无需修改代码
3. **复杂着色规则**:支持三级着色优先级(全局 → 层级规律填色 → 扇区独立)
4. **多文本单元**:扇区内容支持 `|` 分隔的多个文本单元,角度智能分配
5. **SVG 图标支持**:扇区内容可以是 SVG 文件
6. **中心图标**作为 layer 类型,支持可旋转的中心 SVG 图标
7. **360度刻度环**:作为 layer 类型,支持多种刻度模式的度数环
8. **命名配色方案**:通过 theme.colorPalettes 定义可复用颜色
9. **规律填色机制**:通过 num + interval 实现周期性着色
10. **同组分割线控制**groupSplit 参数控制组内分割线显示
11. **外半径兜底**:默认使用 layers 最大 rOuterouterRadius 仅作为无层配置时的兜底
3. **配置下拉切换**:下拉菜单可选择 `public/` 下的配置(如 `demo.json``demo2.json`)切换罗盘
4. **复杂着色规则**:支持三级着色优先级(全局 → 层级规律填色 → 扇区独立)
5. **多文本单元**:扇区内容支持 `|` 分隔的多个文本单元,角度智能分配
6. **多文本比例可配**在每个 layer 中可配置多文本比例(用于 `|` 分隔内容的角度分配)
7. **SVG 图标支持**:扇区内容可以是 SVG 文件
8. **中心图标**:作为 layer 类型,支持可旋转的中心 SVG 图标
9. **360度刻度环**:作为 layer 类型,支持多种刻度模式的度数环
10. **命名配色方案**:通过 theme.colorPalettes 定义可复用颜色
11. **规律填色机制**:通过 num + interval 实现周期性着色
12. **同组分割线控制**groupSplit 参数控制组内分割线显示
13. **外半径兜底**:默认使用 layers 最大 rOuterouterRadius 仅作为无层配置时的兜底
### 1.3 配置清单与切换机制(新增)
**问题:** 运行时无法直接读取 `public/` 目录文件列表,因此下拉菜单需要“配置清单”或“构建期生成清单”。
**推荐方案(满足“新增罗盘只需增加 JSON 文件”):**
1. **清单文件:** 构建期生成 `public/luopan-configs.json`,包含可选配置列表与默认项。
2. **生成逻辑:** 扫描 `public/` 下的 `*.json`,排除 `*.json.conf``luopan-configs.json` 本身。
3. **示例格式:**
```json
{
"default": "demo.json",
"items": [
{ "name": "示例罗盘一", "path": "/demo.json" },
{ "name": "示例罗盘二", "path": "/demo2.json" }
]
}
```
4. **前端加载:** `Luopan.vue` 启动时拉取该清单,渲染下拉选项。
5. **切换逻辑:** 选择项变化时更新 `configPath` 并触发重新加载;同步更新 URL `?config=xxx.json` 便于分享。
6. **新增配置:** 仅需在 `public/` 放入新的 `*.json`,清单会在构建/开发启动时自动更新。
**备用方案(不启用生成脚本):** 手动维护 `public/luopan-configs.json`,仍然无需改代码,但需追加清单条目。
### 1.4 主题抽离与引用(新增)
**目标:** 罗盘配置通过 `themeRef` 引用统一的 `themes.json`,不再内嵌 theme。
**主题文件结构public/themes.json**
```json
{
"default": "五行",
"items": [
{
"name": "五行",
"colorPalettes": {
"黑": "#000000",
"灰": "#757575"
}
}
]
}
```
**罗盘 JSON 使用方式:**
```json
{
"name": "demo",
"background": "白",
"themeRef": "五行",
"layers": []
}
```
**解析规则:**
1. 如果配置包含 `themeRef`,优先使用对应主题。
2. 若未指定 `themeRef`,使用 `themes.json.default`。
3. 若 `themeRef` 或 `default` 不存在:抛出配置错误(或回退到空主题,需在实现中约定)。
**实现改动点:**
1. `types.ts`
- `LuopanConfig.theme` 变为可选
- 新增 `themeRef?: string`
2. `configParser.ts`
- 支持解析 `themeRef`
- 当 `theme` 缺失时暂不报错,由上层注入主题
3. `useLuopan.ts`
- 读取 `public/themes.json`
- 根据 `themeRef/default` 注入主题到配置
- 处理错误与回退
4. 示例与文档:
- `public/demo*.json` 替换为 `themeRef`
- 新增 `public/themes.json`
- 更新 `demo.json.conf` 说明
---
@@ -148,15 +224,17 @@ function applyPatternColoring(
}
```
**多文本单元角度分配:**
**多文本单元角度分配(支持 unitRatios**
```typescript
function splitMultiTextUnits(
content: string,
aStart: number,
aEnd: number
aEnd: number,
unitRatios?: number[]
): TextUnit[] {
const units = content.split('|');
const ratios = getLayoutRatio(units.length); // 从 constants.ts
const ratios = normalizeRatios(unitRatios, units.length)
?? getLayoutRatio(units.length); // 从 constants.ts
const totalAngle = aEnd - aStart;
const textUnits: TextUnit[] = [];
@@ -174,6 +252,27 @@ function splitMultiTextUnits(
return textUnits;
}
function normalizeRatios(
ratios: number[] | undefined,
count: number
): number[] | null {
if (!ratios || ratios.length !== count) return null;
if (ratios.some((value) => typeof value !== 'number' || value <= 0)) return null;
const sum = ratios.reduce((acc, value) => acc + value, 0);
if (sum <= 0) return null;
return ratios.map((value) => value / sum);
}
```
**示例layer 级别配置比例):**
```json
{
"divisions": 24,
"rInner": 200,
"rOuter": 240,
"unitRatios": [0.25, 0.5, 0.25]
}
```
---
@@ -243,6 +342,7 @@ export interface SectorLayerConfig {
startAngle?: number;
colorRef?: string;
innerFill?: 0 | 1;
unitRatios?: number[]; // 多文本单元比例(对应 content 用 "|" 分隔)
num?: number;
interval?: number;
groupSplit?: boolean;
@@ -429,6 +529,7 @@ export class ColorResolver {
- splitMultiTextUnits() 函数
- 角度分配逻辑
- SVG 文件检测
- 支持 layer.unitRatios 覆盖默认比例
2. ✅ 更新 `utils.ts`
- 适配多文本单元的路径生成
- 字体大小计算调整
@@ -674,7 +775,7 @@ export function buildDegreeRing(config: DegreeRingConfig): {
- 调用各个解析器和构建器
- 返回完整渲染数据
2. ✅ 更新 `Luopan.vue`
- 移除示例选择器
- 使用配置下拉替换示例选择器(可切换 `public/*.json`
- 添加配置加载界面
- 渲染多文本单元
- 渲染刻度环
@@ -1179,4 +1280,3 @@ describe('完整渲染流程', () => {
---
**本重构方案完全基于现有代码和需求文档制定,确保可行性和可执行性。建议按阶段逐步实施,每个阶段完成后进行验收,确保质量和进度。**

View File

@@ -2,6 +2,19 @@
<div class="luopan-wrap">
<!-- 工具栏 -->
<div class="toolbar">
<label class="toggle" v-if="configOptions.length">
<span>罗盘配置</span>
<select v-model="selectedConfigPath" :disabled="Boolean(props.config)">
<option
v-for="item in configOptions"
:key="item.path"
:value="item.path"
>
{{ item.name }}
</option>
</select>
</label>
<label class="toggle">
<input type="checkbox" v-model="showGuides" />
显示辅助线
@@ -298,10 +311,10 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import { useLuopan } from './composables/useLuopan';
import { DEFAULT_SIZE, DEFAULT_TEXT_RADIAL_POSITION, SECTOR_STROKE_WIDTH } from './constants';
import type { LuopanConfig, Sector, TextRadialPosition, TextUnit } from './types';
import type { LuopanConfigInput, Sector, TextRadialPosition, TextUnit } from './types';
import { annularSectorCentroid } from './utils';
/**
@@ -310,7 +323,7 @@ import { annularSectorCentroid } from './utils';
interface Props {
size?: number;
configPath?: string;
config?: LuopanConfig;
config?: LuopanConfigInput;
}
const props = withDefaults(defineProps<Props>(), {
@@ -338,13 +351,81 @@ const dragStartY = ref(0);
const dragStartPanX = ref(0);
const dragStartPanY = ref(0);
const resolveConfigPath = () => {
if (typeof window === 'undefined') return props.configPath;
interface ConfigListItem {
name: string;
path: string;
}
interface ConfigList {
default?: string;
items?: ConfigListItem[];
}
const normalizeConfigPath = (value: string) =>
value.startsWith('/') ? value : `/${value}`;
const resolveQueryConfig = () => {
if (typeof window === 'undefined') return undefined;
const param = new URLSearchParams(window.location.search).get('config');
return param ? `/${param}` : props.configPath;
return param ? normalizeConfigPath(param) : undefined;
};
const configInput = props.config ?? resolveConfigPath();
const resolveConfigPath = () => resolveQueryConfig() ?? props.configPath;
const configOptions = ref<ConfigListItem[]>([]);
const selectedConfigPath = ref(resolveConfigPath());
const updateUrlConfigParam = (path: string) => {
if (props.config) return;
if (typeof window === 'undefined') return;
const url = new URL(window.location.href);
url.searchParams.set('config', path.replace(/^\//, ''));
window.history.replaceState({}, '', url.toString());
};
const applySelectedConfig = (path: string) => {
const normalized = normalizeConfigPath(path);
selectedConfigPath.value = normalized;
};
const loadConfigList = async () => {
if (props.config) return;
try {
const response = await fetch('/luopan-configs.json', { cache: 'no-store' });
if (!response.ok) return;
const configList = (await response.json()) as ConfigList;
const items = Array.isArray(configList.items) ? configList.items : [];
const normalizedItems = items
.filter((item) => item && typeof item.path === 'string')
.map((item) => ({
name: item.name ?? item.path.replace(/^\//, ''),
path: normalizeConfigPath(item.path),
}));
configOptions.value = normalizedItems;
const queryPath = resolveQueryConfig();
const availablePaths = new Set(normalizedItems.map((item) => item.path));
if (queryPath && availablePaths.has(queryPath)) {
applySelectedConfig(queryPath);
return;
}
const defaultPath = configList.default
? normalizeConfigPath(configList.default)
: normalizedItems[0]?.path;
if (defaultPath) applySelectedConfig(defaultPath);
} catch (err) {
console.error('加载配置清单失败', err);
}
};
onMounted(loadConfigList);
watch(selectedConfigPath, (value, previous) => {
if (value === previous) return;
updateUrlConfigParam(value);
});
/**
* 使用罗盘逻辑
@@ -360,7 +441,10 @@ const {
toXY,
loading,
error,
} = useLuopan(configInput, textRadialPosition);
} = useLuopan(
computed(() => props.config ?? selectedConfigPath.value),
textRadialPosition
);
// 以实际外半径作为 `viewBox`,确保完整显示配置中的大半径罗盘
const viewBoxSize = computed(() => {

View File

@@ -6,7 +6,8 @@ export const applyPatternColoring = (
divisions: number,
color: string,
num: number,
interval: number
interval: number,
patternOffset: number = 1
): Map<number, string> => {
const colorMap = new Map<number, string>();
@@ -19,14 +20,18 @@ export const applyPatternColoring = (
return colorMap;
}
const cycleLength = num + interval;
if (cycleLength <= 0) return colorMap;
const offsetBase = Number.isFinite(patternOffset) ? Math.trunc(patternOffset) - 1 : 0;
const normalizedOffset = ((offsetBase % divisions) + divisions) % divisions;
// 规律填色:连续着色 `num` 个扇区,然后跳过 `interval` 个扇区。
let currentIndex = 0;
while (currentIndex < divisions) {
for (let i = 0; i < num && currentIndex < divisions; i++) {
colorMap.set(currentIndex, color);
currentIndex++;
for (let i = 0; i < divisions; i++) {
const relativeIndex = i - normalizedOffset;
const posInCycle = ((relativeIndex % cycleLength) + cycleLength) % cycleLength;
if (posInCycle < num) {
colorMap.set(i, color);
}
currentIndex += interval;
}
return colorMap;
@@ -57,7 +62,8 @@ export class ColorResolver {
const color = this.resolveColor(layer.colorRef);
if (typeof layer.num === 'number' && layer.num > 0) {
const interval = layer.interval ?? 0;
return applyPatternColoring(layer.divisions, color, layer.num, interval);
const patternOffset = layer.patternOffset ?? 1;
return applyPatternColoring(layer.divisions, color, layer.num, interval, patternOffset);
}
for (let i = 0; i < layer.divisions; i++) {

View File

@@ -2,14 +2,18 @@
* 罗盘业务逻辑组合函数
*/
import { computed, ref, readonly, watch, type Ref } from 'vue';
import { computed, ref, readonly, watch, type Ref, isRef } from 'vue';
import type {
CenterIconData,
DegreeRingData,
LayerConfig,
LuopanConfig,
LuopanConfigInput,
Sector,
SectorLayerConfig,
ThemeConfig,
ThemeItem,
ThemesConfig,
TextRadialPosition,
} from '../types';
import { polarToXY } from '../utils';
@@ -26,7 +30,7 @@ const isSectorLayer = (layer: LayerConfig): layer is SectorLayerConfig =>
const HEX_COLOR_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
const resolveThemeColor = (
theme: LuopanConfig['theme'],
theme: ThemeConfig,
value: string | undefined,
fallback: string
) => {
@@ -41,14 +45,84 @@ const findDegreeRingLayer = (layers: LayerConfig[]) =>
const findCenterIconLayer = (layers: LayerConfig[]) =>
layers.find((layer) => layer.type === 'centerIcon');
const THEMES_PATH = '/themes.json';
let cachedThemes: ThemesConfig | null = null;
const isObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
const normalizeThemeItem = (item: Record<string, unknown>): ThemeItem | null => {
const name = item.name;
const palettes = item.colorPalettes;
if (typeof name !== 'string' || !isObject(palettes)) return null;
const colorPalettes: Record<string, string> = {};
Object.entries(palettes).forEach(([key, value]) => {
if (typeof value === 'string') {
colorPalettes[key] = value;
}
});
if (Object.keys(colorPalettes).length === 0) return null;
return { name, colorPalettes };
};
const normalizeThemesConfig = (input: unknown): ThemesConfig => {
if (!isObject(input)) {
throw new Error('themes.json 必须为对象');
}
const rawItems = Array.isArray(input.items) ? input.items : [];
const items = rawItems
.filter((item): item is Record<string, unknown> => isObject(item))
.map((item) => normalizeThemeItem(item))
.filter((item): item is ThemeItem => Boolean(item));
if (items.length === 0) {
throw new Error('themes.json.items 不能为空');
}
return {
default: typeof input.default === 'string' ? input.default : undefined,
items,
};
};
const loadThemes = async (): Promise<ThemesConfig> => {
if (cachedThemes) return cachedThemes;
const response = await fetch(THEMES_PATH, { cache: 'no-store' });
if (!response.ok) {
throw new Error(`主题配置加载失败: ${response.status}`);
}
const raw = await response.json();
cachedThemes = normalizeThemesConfig(raw);
return cachedThemes;
};
const resolveTheme = async (configObj: LuopanConfigInput): Promise<ThemeConfig> => {
if (configObj.theme && !configObj.themeRef) {
return configObj.theme;
}
const themes = await loadThemes();
const themeName = configObj.themeRef ?? themes.default ?? themes.items[0].name;
const matched = themes.items.find((item) => item.name === themeName);
if (matched) {
return { name: matched.name, colorPalettes: matched.colorPalettes };
}
if (configObj.theme) {
return configObj.theme;
}
throw new Error(`未找到主题: ${themeName}`);
};
/**
* 罗盘逻辑 Hook
* @param configPathOrObject 配置文件路径或配置对象
* @param textRadialPositionRef 文字径向位置的响应式引用(可选)
* @returns 罗盘相关的计算属性和方法
*/
type LuopanConfigSource = string | LuopanConfigInput;
export function useLuopan(
configPathOrObject: string | LuopanConfig,
configPathOrObject: LuopanConfigSource | Ref<LuopanConfigSource>,
textRadialPositionRef?: Ref<TextRadialPosition>
) {
const config = ref<LuopanConfig | null>(null);
@@ -62,6 +136,10 @@ export function useLuopan(
() => textRadialPositionRef?.value ?? 'middle'
);
const configSource = isRef(configPathOrObject)
? configPathOrObject
: ref(configPathOrObject);
const buildSectors = (configObj: LuopanConfig) => {
const resolver = new ColorResolver(configObj.theme, configObj.background);
const builder = new SectorBuilder(resolver, {
@@ -79,27 +157,30 @@ export function useLuopan(
loading.value = true;
error.value = null;
let configObj: LuopanConfig;
if (typeof configPathOrObject === 'string') {
const jsonText = await fetch(configPathOrObject).then((res) => res.text());
let configObj: LuopanConfigInput;
const configInput = configSource.value;
if (typeof configInput === 'string') {
const jsonText = await fetch(configInput).then((res) => res.text());
configObj = parseConfig(jsonText);
} else {
configObj = configPathOrObject;
configObj = configInput;
}
const resolvedTheme = await resolveTheme(configObj);
const resolvedBackground = resolveThemeColor(
configObj.theme,
resolvedTheme,
configObj.background,
'#000000'
);
const resolvedStrokeColor = resolveThemeColor(
configObj.theme,
resolvedTheme,
configObj.strokeColor,
'#1f2937'
);
const resolvedConfig: LuopanConfig = {
...configObj,
theme: resolvedTheme,
background: resolvedBackground,
strokeColor: resolvedStrokeColor,
strokeWidth: typeof configObj.strokeWidth === 'number'
@@ -139,6 +220,10 @@ export function useLuopan(
}
});
watch(configSource, () => {
loadConfig();
});
loadConfig();
const sectorLayers = computed(() =>

View File

@@ -1,4 +1,4 @@
import type { LuopanConfig, ThemeConfig } from './types';
import type { LuopanConfigInput, ThemeConfig } from './types';
const isObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -53,7 +53,7 @@ const normalizeTheme = (theme: Record<string, unknown>): ThemeConfig => {
};
};
export const parseConfig = (jsonText: string): LuopanConfig => {
export const parseConfig = (jsonText: string): LuopanConfigInput => {
const cleanText = stripJsonComments(jsonText);
let parsed: unknown;
@@ -69,7 +69,9 @@ export const parseConfig = (jsonText: string): LuopanConfig => {
const config = parsed as Record<string, unknown>;
assertCondition(typeof config.name === 'string', 'name 为必填字符串');
assertCondition(typeof config.background === 'string', 'background 为必填字符串');
assertCondition(isObject(config.theme), 'theme 为必填对象');
if (config.theme !== undefined) {
assertCondition(isObject(config.theme), 'theme 必须为对象');
}
assertCondition(Array.isArray(config.layers), 'layers 为必填数组');
return {
@@ -81,7 +83,8 @@ export const parseConfig = (jsonText: string): LuopanConfig => {
strokeOpacity: typeof config.strokeOpacity === 'number' ? config.strokeOpacity : undefined,
insetDistance: typeof config.insetDistance === 'number' ? config.insetDistance : undefined,
outerRadius: typeof config.outerRadius === 'number' ? config.outerRadius : undefined,
theme: normalizeTheme(config.theme),
layers: config.layers as LuopanConfig['layers'],
themeRef: typeof config.themeRef === 'string' ? config.themeRef : undefined,
theme: isObject(config.theme) ? normalizeTheme(config.theme) : undefined,
layers: config.layers as LuopanConfigInput['layers'],
};
};

View File

@@ -6,7 +6,10 @@
export type {
Example,
LuopanConfig,
LuopanConfigInput,
ThemeConfig,
ThemeItem,
ThemesConfig,
CenterIconConfig,
DegreeRingConfig,
DegreeRingData,

View File

@@ -8,14 +8,15 @@ export function splitMultiTextUnits(
content: string,
aStart: number,
aEnd: number,
svgIconPath: string = 'src/assets/icons/'
svgIconPath: string = 'src/assets/icons/',
unitRatios?: number[]
): 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 ratios = normalizeRatios(unitRatios, parts.length) ?? getLayoutRatio(parts.length);
const totalAngle = aEnd - aStart;
const units: TextUnit[] = [];
const basePath = ensureTrailingSlash(svgIconPath);
@@ -39,3 +40,14 @@ export function splitMultiTextUnits(
return units;
}
const normalizeRatios = (
ratios: number[] | undefined,
count: number
): number[] | null => {
if (!ratios || ratios.length !== count) return null;
if (ratios.some((value) => typeof value !== 'number' || value <= 0)) return null;
const sum = ratios.reduce((acc, value) => acc + value, 0);
if (sum <= 0) return null;
return ratios.map((value) => value / sum);
};

View File

@@ -49,6 +49,13 @@ export class SectorBuilder {
for (let i = 0; i < layer.divisions; i++) {
const aStart = startAngle + i * angleStep;
const aEnd = aStart + angleStep;
// groupSplit=false 且扇区足够细时,对填充路径做轻微重叠,避免抗锯齿露底
const angleOverlapDeg =
layer.groupSplit === false && angleStep > 0 && angleStep <= 10
? Math.min(0.2, angleStep * 0.02)
: 0;
const fillAStart = aStart - angleOverlapDeg;
const fillAEnd = aEnd + angleOverlapDeg;
const sectorConfig = layer.sectors?.[i];
const rawContent = typeof sectorConfig?.content === 'string' ? sectorConfig.content.trim() : '';
const isMultiText = rawContent.includes('|');
@@ -65,8 +72,8 @@ export class SectorBuilder {
? annularSectorInsetPath(
layer.rInner,
layer.rOuter,
aStart,
aEnd,
fillAStart,
fillAEnd,
this.insetDistance
)
: undefined;
@@ -102,7 +109,8 @@ export class SectorBuilder {
layer.rInner,
layer.rOuter,
effectiveTextRadialPosition,
sectorKey
sectorKey,
layer.unitRatios
)
: undefined;
@@ -130,7 +138,7 @@ export class SectorBuilder {
fill: baseFillColor,
textColor,
label: isMultiText ? '' : rawContent,
path: annularSectorPath(layer.rInner, layer.rOuter, aStart, aEnd),
path: annularSectorPath(layer.rInner, layer.rOuter, fillAStart, fillAEnd),
innerFillPath: normalizedInnerFillPath,
innerFillColor,
textPath,
@@ -154,9 +162,10 @@ export class SectorBuilder {
rInner: number,
rOuter: number,
textRadialPosition: TextRadialPosition,
sectorKey: string
sectorKey: string,
unitRatios?: number[]
): TextUnit[] {
const units = splitMultiTextUnits(content, aStart, aEnd, this.svgIconPath);
const units = splitMultiTextUnits(content, aStart, aEnd, this.svgIconPath, unitRatios);
return units.map((unit, index) => {
const layout = this.computeTextLayout(
@@ -230,9 +239,16 @@ export class SectorBuilder {
if (!layer.num) return true;
const cycleLength = layer.num + (layer.interval ?? 0);
const posInCycle = sectorIndex % cycleLength;
if (cycleLength <= 0) return true;
const patternOffset = layer.patternOffset ?? 1;
const offsetBase = Number.isFinite(patternOffset) ? Math.trunc(patternOffset) - 1 : 0;
const normalizedOffset =
layer.divisions > 0 ? ((offsetBase % layer.divisions) + layer.divisions) % layer.divisions : 0;
const posInCycle =
((sectorIndex - normalizedOffset) % cycleLength + cycleLength) % cycleLength;
// 仅保留每个着色分组的末尾分割线
return posInCycle >= layer.num - 1;
// 仅保留分组边界:着色组起点 + 间隔组起点(若存在间隔)
if ((layer.interval ?? 0) === 0) return posInCycle === 0;
return posInCycle === 0 || posInCycle === layer.num;
}
}

View File

@@ -22,7 +22,7 @@ export interface Example {
/**
* JSON 配置根对象
*/
export interface LuopanConfig {
export interface LuopanConfigBase {
name: string;
description?: string;
background: string;
@@ -31,10 +31,17 @@ export interface LuopanConfig {
strokeOpacity?: number;
insetDistance?: number;
outerRadius?: number;
theme: ThemeConfig;
themeRef?: string;
theme?: ThemeConfig;
layers: LayerConfig[];
}
export interface LuopanConfig extends LuopanConfigBase {
theme: ThemeConfig;
}
export type LuopanConfigInput = LuopanConfigBase;
/**
* 主题配置
*/
@@ -43,6 +50,16 @@ export interface ThemeConfig {
colorPalettes: Record<string, string>;
}
export interface ThemeItem {
name: string;
colorPalettes: Record<string, string>;
}
export interface ThemesConfig {
default?: string;
items: ThemeItem[];
}
/**
* 中心图标配置
*/
@@ -90,8 +107,10 @@ export interface SectorLayerConfig {
startAngle?: number;
colorRef?: string;
innerFill?: 0 | 1;
unitRatios?: number[];
num?: number;
interval?: number;
patternOffset?: number;
groupSplit?: boolean;
sectors?: SectorConfig[];
}

View File

@@ -260,7 +260,7 @@ export function generateTextPath(
}
// 不调整半径,保持在中线位置
// 使用 dominant-baseline 属性控制文字垂直对齐
// 使用 `dominant-baseline` 属性控制文字垂直对齐
const adjustedRMid = rMid;
const a1 = normalizeDeg(aStartDeg);
@@ -275,8 +275,8 @@ export function generateTextPath(
const largeArc = delta > 180 ? 1 : 0;
// 保持路径完整,不在这里应用 padding
// padding 通过字体大小计算textPath 的 startOffset/text-anchor 实现
// 保持路径完整,不在这里应用内边距(`padding`
// 内边距(`padding`通过字体大小计算以及 `textPath``startOffset`/`text-anchor` 实现
if (needReverse) {
// 反向路径(从结束点到起始点),保持文字头朝外
@@ -384,14 +384,14 @@ export function generateVerticalTextPath(
const requiredPathLength =
effectiveTextLength * TEXT_LAYOUT_CONFIG.CHAR_SPACING_RATIO * effectiveFontSize;
// 确保路径不超出扇区边界(考虑径向 padding
// 确保路径不超出扇区边界(考虑径向内边距 `padding`
const maxPathLength = radialHeight * TEXT_LAYOUT_CONFIG.RADIAL_PADDING_RATIO;
const actualPathLength = Math.min(requiredPathLength, maxPathLength);
let finalStartR: number;
let finalEndR: number;
// 对于从圆心开始的扇区(rInner=0),形心会偏向外侧
// 对于从圆心开始的扇区`rInner`=0,形心会偏向外侧
// 需要特殊处理以防止溢出
if (rInner === 0) {
// 计算路径应该在哪里结束(从外圆向内)
@@ -409,7 +409,7 @@ export function generateVerticalTextPath(
finalEndR = rMid - halfPath;
}
} else {
// 普通扇区:以 rMid 为中心
// 普通扇区:以 `rMid` 为中心
const halfPathLength = actualPathLength / 2;
// 确保不超出边界
@@ -492,13 +492,13 @@ export function calculateSectorFontSize(
const maxByHeight = availableHeight / (textLength * TEXT_LAYOUT_CONFIG.CHAR_SPACING_RATIO);
// 约束2最内侧字符的弧长宽度这是最严格的宽度限制
// 最内侧字符的中心位置大约在 rInner + fontSize/2 处
// 最内侧字符的中心位置大约在 `rInner` + `fontSize`/2 处
// 保守估计:假设字体大小约为径向宽度的一半
const estimatedFontSize = radialWidth * 0.5;
const innerMostRadius = rInner + estimatedFontSize / 2;
const innerArcLength = (innerMostRadius * deltaDeg * Math.PI) / 180;
// 字符宽度约为 fontSize × 1.0(方块字)
// 字符宽度约为 `fontSize` × 1.0(方块字)
const availableArcLength = innerArcLength * TEXT_LAYOUT_CONFIG.TANGENT_PADDING_RATIO;
const maxByWidth = availableArcLength / 1.0; // 单个字符宽度
@@ -515,11 +515,11 @@ export function calculateSectorFontSize(
const maxByHeight = radialWidth * TEXT_LAYOUT_CONFIG.RADIAL_PADDING_RATIO;
// 2. 宽度约束:根据文字总宽度计算
// 中文字符宽度 = fontSize方块字
// 字符间距:约 0.1 * fontSize总占用约 1.1 * fontSize
// 中文字符宽度 = `fontSize`(方块字)
// 字符间距:约 0.1 * `fontSize`,总占用约 1.1 * `fontSize`
const availableArcLength = arcLength * TEXT_LAYOUT_CONFIG.TANGENT_PADDING_RATIO;
// 反推字体大小fontSize = 可用弧长 / (字符数 × 1.1)
// 反推字体大小:`fontSize` = 可用弧长 / (字符数 × 1.1)
const maxByWidth = availableArcLength / (textLength * 1.1);
// 3. 取宽度和高度约束中较小的那个(更严格的限制)
@@ -561,13 +561,13 @@ function calculateVerticalTextLength(
// 计算径向可用高度
const radialHeight = rOuter - rInner;
// 考虑上下padding可用高度约为总高度的配置比例
// 考虑上下内边距(`padding`,可用高度约为总高度的配置比例
const availableHeight = radialHeight * TEXT_LAYOUT_CONFIG.RADIAL_PADDING_RATIO;
// 计算可以容纳的字符数
const maxFittableChars = Math.floor(availableHeight / (fontSize * TEXT_LAYOUT_CONFIG.CHAR_SPACING_RATIO));
// 限制在 [MIN_CHARS, MAX_CHARS] 范围内
// 限制在 [`MIN_CHARS`, `MAX_CHARS`] 范围内
const charCount = Math.max(MIN_CHARS, Math.min(MAX_CHARS, maxFittableChars));
return charCount;

View File

@@ -27,6 +27,16 @@ describe('colorResolver', () => {
expect(map.has(5)).toBe(false);
});
it('applyPatternColoring 应支持规律填色偏移', () => {
const map = applyPatternColoring(6, '#111111', 2, 1, 2);
expect(map.has(0)).toBe(false);
expect(map.has(1)).toBe(true);
expect(map.has(2)).toBe(true);
expect(map.has(3)).toBe(false);
expect(map.has(4)).toBe(true);
expect(map.has(5)).toBe(true);
});
it('resolveLayerColors 在提供 colorRef 且无 num 时覆盖全部扇区', () => {
const resolver = new ColorResolver(theme, '#000000');
const layer: SectorLayerConfig = {

View File

@@ -22,13 +22,14 @@ describe('configParser', () => {
expect(config.name).toBe('demo');
expect(config.layers.length).toBeGreaterThan(0);
expect(config.theme.colorPalettes['木']).toBe('#43A047');
expect(config.themeRef).toBe('五行配色主题');
expect(config.theme).toBeUndefined();
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": [] }';
const raw = '{ "background": "#000", "layers": [] }';
expect(() => parseConfig(raw)).toThrow('name 为必填字符串');
});
});

View File

@@ -20,6 +20,16 @@ describe('multiTextParser', () => {
expect(units[1].isSvg).toBe(false);
});
it('应支持自定义 unitRatios', () => {
const units = splitMultiTextUnits('甲|乙|丙', 0, 100, 'src/assets/icons', [0.2, 0.3, 0.5]);
expect(units[0].aStart).toBe(0);
expect(units[0].aEnd).toBe(20);
expect(units[1].aStart).toBe(20);
expect(units[1].aEnd).toBe(50);
expect(units[2].aStart).toBe(50);
expect(units[2].aEnd).toBe(100);
});
it('空内容应返回空数组', () => {
expect(splitMultiTextUnits('', 0, 60)).toEqual([]);
});

View File

@@ -61,6 +61,25 @@ describe('sectorBuilder', () => {
expect(sectors[2].groupSplitVisible).toBe(true);
});
it('groupSplit=false 时应受 patternOffset 影响', () => {
const resolver = new ColorResolver(theme, '#000000');
const builder = new SectorBuilder(resolver);
const baseLayer: SectorLayerConfig = {
divisions: 4,
rInner: 0,
rOuter: 10,
num: 2,
interval: 1,
groupSplit: false,
};
const baseSectors = builder.buildLayer(baseLayer, 0);
const offsetSectors = builder.buildLayer({ ...baseLayer, patternOffset: 2 }, 0);
expect(baseSectors[1].groupSplitVisible).toBe(false);
expect(offsetSectors[2].groupSplitVisible).toBe(false);
});
it('应生成多文本单元并按角度分配', () => {
const resolver = new ColorResolver(theme, '#000000');
const builder = new SectorBuilder(resolver);

View File

@@ -135,7 +135,7 @@ describe('annularSectorPath', () => {
it('应该在大角度时设置 large-arc-flag', () => {
const path = annularSectorPath(50, 100, 0, 270);
// 大角度应包含 large-arc-flag = 1
// 大角度应包含 `large-arc-flag` = 1
expect(path).toBeTruthy();
});
});

View File

@@ -72,6 +72,7 @@
| innerFill | number | 否 | 内缩设置0=不内缩1=内缩1像素 | 1 |
| num | number | 否 | 规律填色连续着色的扇区数量与interval配合使用 | 3 |
| interval | number | 否 | 规律填色着色后间隔的扇区数量0表示无间隔 | 1 |
| patternOffset | number | 否 | 规律填色起始偏移扇区序号从1开始默认1 | 2 |
| groupSplit | boolean | 否 | 是否显示同组扇区间分割线false隐藏默认true | false |
| sectors | array | 否 | 扇区配置数组,可省略表示该层只有填色无内容 | 见下方sectors说明 |
@@ -100,7 +101,7 @@
参数:
startAngle表示第一个扇区的起始角度以度为单位0度为正北方向顺时针增加
innerfill仅对num/interval定义的着色扇区生效(目前layer中的innerFill对所有sector生效暂保持这个逻辑)
-- start表示着色起始扇区已废弃统一从第1个扇区开始
patternOffset表示规律填色起始偏移扇区序号从1开始默认1未指定时与startAngle对齐即从第一个扇区开始着色
num表示连接几个单元着色
interval表示中间间隔几个单元
比如num=3,interval=1,意思是从第1个扇区开始着色对1、2、3扇区着色colorref4扇区全局背景5、6、7着色colorref……
@@ -123,7 +124,7 @@
"startAngle": 0, -- 第一个扇区的起始角度0度为正北方向顺时针
"innerFill": 1, -- 着色区域的内缩设置
"colorRef": "土", -- 着色使用的颜色引用
-- "start": 1, -- 从第1个扇区开始着色已废弃统一从第1个扇区开始
-- "patternOffset": 2, -- 第一个着色单元偏移1个扇区(可选
"num": 3, -- 连续着色3个扇区
"interval": 1, -- 着色后间隔1个扇区
"sectors": [
@@ -201,6 +202,8 @@
}
}
注意json中如配置了centerIcon作为第一层。
### 360度刻度环配置参数
rinner
router