34 KiB
34 KiB
罗盘项目重构方案
一、项目现状分析
1.1 当前架构特点
- 示例驱动:目前使用 EXAMPLES 数组硬编码示例数据(角度数组 + 半径数组)
- 简单数据结构:通过角度分割点和半径列表简单定义层次和扇区
- 内置逻辑:颜色、文字、填充等逻辑写死在代码中
- 测试导向:现有实现主要为测试工具函数正确性而设计
1.2 目标需求分析(基于 todolist.md 和 demo.json)
说明:public/*.json 为实际加载配置,.json.conf 仅作解释说明,不参与加载。
核心变化:
- 配置驱动:从 JSON 配置文件完全定义罗盘结构
- 新增罗盘零改码:新增罗盘只需在
public/下增加 JSON 配置文件,无需修改代码 - 复杂着色规则:支持三级着色优先级(全局 → 层级规律填色 → 扇区独立)
- 多文本单元:扇区内容支持
|分隔的多个文本单元,角度智能分配 - SVG 图标支持:扇区内容可以是 SVG 文件
- 中心图标:作为 layer 类型,支持可旋转的中心 SVG 图标
- 360度刻度环:作为 layer 类型,支持多种刻度模式的度数环
- 命名配色方案:通过 theme.colorPalettes 定义可复用颜色
- 规律填色机制:通过 num + interval 实现周期性着色
- 同组分割线控制:groupSplit 参数控制组内分割线显示
- 外半径兜底:默认使用 layers 最大 rOuter,outerRadius 仅作为无层配置时的兜底
二、整体架构设计
2.1 分层架构
┌─────────────────────────────────────────────────┐
│ UI 层(Luopan.vue) │
│ - SVG 渲染 │
│ - 交互控制(缩放、平移) │
└─────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────┐
│ Composable 层(useLuopan.ts) │
│ - 配置解析协调 │
│ - 响应式数据管理 │
└─────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────┐
│ 配置解析层(新增) │
│ - configParser.ts:JSON → 内部数据结构 │
│ - colorResolver.ts:颜色解析引擎 │
│ - sectorBuilder.ts:扇区数据生成器 │
│ - multiTextParser.ts:多文本单元解析器 │
└─────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────┐
│ 工具函数层(utils.ts - 保持不变) │
│ - 几何计算(已完善) │
│ - 路径生成(已完善) │
│ - 文字布局计算(已完善) │
└─────────────────────────────────────────────────┘
2.2 核心模块划分
| 模块名 | 文件 | 职责 |
|---|---|---|
| 类型定义 | types.ts |
扩展类型定义,支持 JSON 配置结构 |
| JSON 解析 | configParser.ts |
解析 JSON 配置,去注释,验证结构 |
| 颜色解析器 | colorResolver.ts |
处理三级着色优先级、规律填色 |
| 扇区构建器 | sectorBuilder.ts |
根据配置生成完整扇区数据 |
| 多文本解析 | multiTextParser.ts |
处理 ` |
| 中心图标 | centerIcon.ts |
处理中心 SVG 图标加载和渲染 |
| 刻度环 | degreeRing.ts |
生成 360度刻度环数据 |
| 组合逻辑 | useLuopan.ts |
重构为配置驱动模式 |
三、JSON 配置解析流程
3.1 解析流程图
JSON 文本 (带注释)
↓
[configParser] 去除 -- 注释
↓
标准 JSON 对象
↓
[configParser] 结构验证
↓
验证通过的 LuopanConfig
↓
[colorResolver] 解析颜色主题
↓
颜色映射表 (ColorPalette)
↓
[sectorBuilder] 遍历 layers
↓
对每个 layer:
- type=sectors:
1. 计算规律填色模式 (num, interval)
2. 生成所有扇区
3. 对每个扇区:
- 应用颜色优先级
- 解析多文本单元
- 计算 SVG 路径
- type=degreeRing:生成刻度环数据
- type=centerIcon:加载中心图标
↓
最终渲染数据
3.2 关键算法
规律填色算法:
function applyPatternColoring(
divisions: number,
colorRef: string,
num: number,
interval: number
): Map<number, string> {
const colorMap = new Map<number, string>();
if (interval === 0) {
// 全部着色
for (let i = 0; i < divisions; i++) {
colorMap.set(i, colorRef);
}
return colorMap;
}
// 周期性着色:num个着色 + interval个空白
let currentIndex = 0;
while (currentIndex < divisions) {
// 着色 num 个
for (let i = 0; i < num && currentIndex < divisions; i++) {
colorMap.set(currentIndex, colorRef);
currentIndex++;
}
// 跳过 interval 个
currentIndex += interval;
}
return colorMap;
}
多文本单元角度分配:
function splitMultiTextUnits(
content: string,
aStart: number,
aEnd: number
): TextUnit[] {
const units = content.split('|');
const ratios = getLayoutRatio(units.length); // 从 constants.ts
const totalAngle = aEnd - aStart;
const textUnits: TextUnit[] = [];
let accumulatedAngle = aStart;
for (let i = 0; i < units.length; i++) {
const unitAngle = totalAngle * ratios[i];
textUnits.push({
content: units[i],
aStart: accumulatedAngle,
aEnd: accumulatedAngle + unitAngle
});
accumulatedAngle += unitAngle;
}
return textUnits;
}
四、数据流设计
4.1 配置数据结构(新增类型)
// src/types.ts 扩展
/** JSON 配置根对象 */
export interface LuopanConfig {
name: string;
description?: string;
background: string; // 全局背景色(支持主题色)
strokeWidth?: number; // 扇区边界线宽度
strokeColor?: string; // 扇区边界线颜色(支持主题色)
strokeOpacity?: number; // 扇区边界线透明度
outerRadius?: number; // 可选兜底外半径,默认使用 layers 最大 rOuter
theme: ThemeConfig;
layers: LayerConfig[]; // 扇区层 + 中心图标层 + 刻度环层
}
/** 主题配置 */
export interface ThemeConfig {
name?: string;
colorPalettes: Record<string, string>; // 命名颜色映射
}
/** 中心图标配置 */
export interface CenterIconConfig {
rIcon: number;
opacity: number;
name: string; // SVG 文件名
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; // 是否为 SVG 文件名
}
/** 刻度线数据 */
export interface TickMark {
angle: number;
type: 'major' | 'minor' | 'micro';
length: number;
startR: number;
endR: number;
label?: string; // 度数标签(仅主刻度)
}
/** 扩展现有 Sector 接口 */
export interface Sector {
// ... 现有字段保持 ...
// 新增字段
textUnits?: TextUnit[]; // 多文本单元
groupSplitVisible?: boolean; // 是否显示与下一个扇区的分割线
isSvgContent?: boolean; // 内容是否为 SVG
svgPath?: string; // SVG 文件路径
}
4.2 数据流转示例
// 输入:JSON 配置字符串
const jsonText = await fetch('/demo.json').then(r => r.text());
// 步骤1:解析配置
const config = parseConfig(jsonText);
// 步骤2:解析颜色主题
const colorResolver = new ColorResolver(config.theme, config.background);
// 步骤3:构建扇区
const sectorBuilder = new SectorBuilder(colorResolver);
const sectors = config.layers.flatMap((layer, layerIndex) =>
layer.type === 'centerIcon' || layer.type === 'degreeRing'
? []
: sectorBuilder.buildLayer(layer, layerIndex)
);
// 步骤4:生成刻度环(从 layers 中提取)
const degreeRingLayer = config.layers.find(layer => layer.type === 'degreeRing');
const degreeRingData = degreeRingLayer
? buildDegreeRing(degreeRingLayer.degreeRing)
: null;
// 步骤5:加载中心图标(从 layers 中提取)
const centerIconLayer = config.layers.find(layer => layer.type === 'centerIcon');
const centerIconData = centerIconLayer
? await loadCenterIcon(centerIconLayer.centerIcon)
: null;
// 输出:完整渲染数据
const renderData = {
sectors,
degreeRing: degreeRingData,
centerIcon: centerIconData,
background: config.background
};
五、实施步骤(分阶段)
阶段 1:类型定义和配置解析(2-3天)
目标: 建立配置解析基础
任务:
- ✅ 扩展
types.ts,新增所有配置相关类型 - ✅ 实现
configParser.ts:- 去除
--注释 - JSON 解析
- 基础验证(必填字段检查)
- 去除
- ✅ 编写单元测试:
configParser.test.ts - ✅ 测试用例:解析
demo.json
验收标准:
- 能成功解析
demo.json为 LuopanConfig 对象 - 所有必填字段验证通过
- 测试覆盖率 > 80%
阶段 2:颜色解析引擎(2天)
目标: 实现三级着色优先级
任务:
- ✅ 实现
colorResolver.ts:- ColorResolver 类
- resolveColor(ref: string) 方法
- 规律填色算法
- 颜色优先级逻辑
- ✅ 编写单元测试:
colorResolver.test.ts - ✅ 测试用例覆盖:
- 全局背景色
- 层级 colorRef
- 扇区 colorRef
- 规律填色(num + interval)
核心代码:
export class ColorResolver {
private colorPalettes: Record<string, string>;
private globalBackground: string;
constructor(theme: ThemeConfig, background: string) {
this.colorPalettes = theme.colorPalettes;
this.globalBackground = background;
}
resolveColor(colorRef?: string): string {
if (!colorRef) return this.globalBackground;
return this.colorPalettes[colorRef] ?? colorRef;
}
resolveLayerColors(layer: LayerConfig): Map<number, string> {
const colorMap = new Map<number, string>();
if (!layer.colorRef || !layer.num) {
return colorMap; // 无规律填色
}
return applyPatternColoring(
layer.divisions,
this.resolveColor(layer.colorRef),
layer.num,
layer.interval ?? 0
);
}
}
验收标准:
- 着色优先级正确
- 规律填色算法准确
- 边界情况处理(interval=0, num > divisions)
阶段 3:多文本单元解析(2天)
目标: 支持 | 分隔文本的角度分配
任务:
- ✅ 实现
multiTextParser.ts:- splitMultiTextUnits() 函数
- 角度分配逻辑
- SVG 文件检测
- ✅ 更新
utils.ts:- 适配多文本单元的路径生成
- 字体大小计算调整
- ✅ 编写单元测试:
multiTextParser.test.ts - ✅ 测试用例:
- 1-5 个单元的角度分配
- LAYOUT_RATIO_PRESETS 验证
核心代码:
// 仅保留名称,具体实现参考前文 splitMultiTextUnits
export function splitMultiTextUnits(
content: string,
aStart: number,
aEnd: number,
svgIconPath: string = 'src/assets/icons/'
): TextUnit[] {
// 参考前面代码
return [];
}
验收标准:
- 角度分配与 LAYOUT_RATIO_PRESETS 一致
- 支持 SVG 文件名识别
- 边界情况(单个文本、空字符串)
阶段 4:扇区构建器(3-4天)
目标: 整合所有逻辑,生成完整扇区数据
任务:
- ✅ 实现
sectorBuilder.ts:- SectorBuilder 类
- buildLayer() 方法
- buildSector() 方法
- 整合颜色、多文本、innerFill、groupSplit
- ✅ 更新
Sector接口(types.ts) - ✅ 编写单元测试:
sectorBuilder.test.ts - ✅ 集成测试:完整 layer 构建
核心代码:
export class SectorBuilder {
constructor(private colorResolver: ColorResolver) {}
buildLayer(layer: LayerConfig, layerIndex: number): Sector[] {
const sectors: Sector[] = [];
const layerColorMap = this.colorResolver.resolveLayerColors(layer);
const angleStep = 360 / layer.divisions;
const startAngle = layer.startAngle ?? 0;
for (let i = 0; i < layer.divisions; i++) {
const aStart = startAngle + i * angleStep;
const aEnd = aStart + angleStep;
// 确定颜色(优先级:sector > layer pattern > global)
const sectorConfig = layer.sectors?.[i];
let fillColor: string;
if (sectorConfig?.colorRef) {
fillColor = this.colorResolver.resolveColor(sectorConfig.colorRef);
} else if (layerColorMap.has(i)) {
fillColor = layerColorMap.get(i)!;
} else {
fillColor = this.colorResolver.resolveColor();
}
// 确定 innerFill
const innerFill = sectorConfig?.innerFill ?? layer.innerFill ?? 0;
// 解析多文本单元
const content = sectorConfig?.content ?? '';
const textUnits = content.includes('|')
? splitMultiTextUnits(content, aStart, aEnd)
: [{ content, aStart, aEnd, isSvg: content.endsWith('.svg') }];
// 确定是否显示分割线
const groupSplitVisible = this.shouldShowGroupSplit(
layer,
i,
layerColorMap
);
// 生成完整扇区
const sector = this.buildSector({
layerIndex,
pieIndex: i,
rInner: layer.rInner,
rOuter: layer.rOuter,
aStart,
aEnd,
fillColor,
innerFill,
textUnits,
groupSplitVisible,
});
sectors.push(sector);
}
return sectors;
}
private shouldShowGroupSplit(
layer: LayerConfig,
sectorIndex: number,
layerColorMap: Map<number, string>
): boolean {
if (layer.groupSplit !== false) return true; // 默认显示
// 如果 groupSplit=false,检查是否为组内分割线
if (!layer.num) return true;
const cycleLength = layer.num + (layer.interval ?? 0);
const posInCycle = sectorIndex % cycleLength;
// 组内(0 到 num-1)不显示分割线
return posInCycle >= layer.num - 1;
}
}
验收标准:
- 完整解析
demo.json所有 layer - 颜色优先级正确
- groupSplit 逻辑正确
- 多文本单元正确拆分
阶段 5:刻度环和中心图标(2-3天)
目标: 实现刻度环和中心图标功能
任务:
- ✅ 实现
degreeRing.ts:- buildDegreeRing() 函数
- 刻度线生成(major, minor, micro)
- 度数标签生成
- mode 处理(inner, outer, both)
- ✅ 实现
centerIcon.ts:- loadCenterIcon() 函数
- SVG 加载和解析
- 旋转处理
- ✅ 更新 Luopan.vue:
- 渲染刻度环
- 渲染中心图标
- ✅ 编写单元测试
核心代码(刻度环):
export function buildDegreeRing(config: DegreeRingConfig): {
ticks: TickMark[];
ring: { rInner: number; rOuter: number; color: string; opacity: number };
labels?: DegreeLabel[];
} {
const ticks: TickMark[] = [];
const { rInner, rOuter, mode, tickLength, tickLengthStep = 0 } = config;
// 生成刻度线
for (let angle = 0; angle < 360; angle++) {
let type: 'major' | 'minor' | 'micro';
let length: number;
if (angle % config.majorTick === 0) {
type = 'major';
length = tickLength;
} else if (angle % config.minorTick === 0) {
type = 'minor';
length = tickLength - tickLengthStep;
} else if (angle % config.microTick === 0) {
type = 'micro';
length = tickLength - 2 * tickLengthStep;
} else {
continue;
}
// 根据 mode 确定刻度线位置
let startR: number, endR: number;
if (mode === 'inner') {
startR = rInner;
endR = rInner + length;
} else if (mode === 'outer') {
startR = rOuter - length;
endR = rOuter;
} else { // both
startR = rInner;
endR = rInner + length;
// 还需要添加外侧刻度
}
ticks.push({ angle, type, length, startR, endR });
}
// 生成度数标签(使用 textPath 保持方向一致)
const labels: DegreeLabel[] = [];
if (config.showDegree === 1) {
const r = (rInner + rOuter) / 2;
for (let angle = 0; angle < 360; angle += config.majorTick) {
labels.push({
angle,
text: angle.toString(),
r,
textPathId: `degree-label-${angle}`,
textPath: generateTextPath(r, r, angle - 4, angle + 4)
});
}
}
return {
ticks,
ring: {
rInner,
rOuter,
color: config.ringColor,
opacity: config.opacity
},
labels: config.showDegree ? labels : undefined
};
}
验收标准:
- 刻度线长度正确(major > minor > micro)
- mode 模式正确(inner, outer, both)
- 度数标签方向与扇区文字一致(自动翻转)
- 中心图标加载和旋转正常
阶段 6:重构 useLuopan 和 Luopan.vue(3-4天)
目标: 适配新的配置驱动模式
任务:
- ✅ 重构
useLuopan.ts:- 接收 JSON 配置路径或对象
- 异步加载和解析配置
- 调用各个解析器和构建器
- 返回完整渲染数据
- ✅ 更新
Luopan.vue:- 移除示例选择器
- 添加配置加载界面
- 渲染多文本单元
- 渲染刻度环
- 渲染中心图标
- 处理 groupSplit(条件渲染分割线)
- ✅ 更新
App.vue:- 传入配置文件路径
- ✅ E2E 测试
核心代码(useLuopan 重构):
export function useLuopan(configPathOrObject: string | LuopanConfig) {
const config = ref<LuopanConfig | null>(null);
const sectors = ref<Sector[]>([]);
const degreeRing = ref<DegreeRingData | null>(null);
const centerIcon = ref<CenterIconData | null>(null);
const loading = ref(true);
const error = ref<Error | null>(null);
const loadConfig = async () => {
try {
loading.value = true;
// 加载配置
let configObj: LuopanConfig;
if (typeof configPathOrObject === 'string') {
const jsonText = await fetch(configPathOrObject).then(r => r.text());
configObj = parseConfig(jsonText);
} else {
configObj = configPathOrObject;
}
config.value = configObj;
// 解析颜色
const colorResolver = new ColorResolver(
configObj.theme,
configObj.background
);
// 构建扇区
const sectorBuilder = new SectorBuilder(colorResolver);
sectors.value = configObj.layers.flatMap((layer, i) =>
layer.type === 'centerIcon' || layer.type === 'degreeRing'
? []
: sectorBuilder.buildLayer(layer, i)
);
// 构建刻度环(从 layers 中提取)
const degreeRingLayer = configObj.layers.find(layer => layer.type === 'degreeRing');
if (degreeRingLayer) {
degreeRing.value = buildDegreeRing(degreeRingLayer.degreeRing);
}
// 加载中心图标(从 layers 中提取)
const centerIconLayer = configObj.layers.find(layer => layer.type === 'centerIcon');
if (centerIconLayer) {
centerIcon.value = await loadCenterIcon(centerIconLayer.centerIcon);
}
} catch (e) {
error.value = e as Error;
} finally {
loading.value = false;
}
};
// 自动加载
loadConfig();
return {
config: readonly(config),
sectors: readonly(sectors),
degreeRing: readonly(degreeRing),
centerIcon: readonly(centerIcon),
loading: readonly(loading),
error: readonly(error),
reload: loadConfig
};
}
Luopan.vue 关键更新:
<template>
<div class="luopan-wrap">
<div v-if="loading">加载中...</div>
<div v-else-if="error">错误: {{ error.message }}</div>
<div v-else class="svg-container">
<svg :width="size" :height="size" :viewBox="viewBox">
<!-- 背景 -->
<rect :fill="config.background" />
<!-- 扇区 -->
<g v-for="sector in sectors" :key="sector.key">
<!-- 主扇区路径 -->
<path :d="sector.path" :fill="sector.fill" />
<!-- 内缩填色 -->
<path v-if="sector.innerFillPath" :d="sector.innerFillPath" />
<!-- 分割线(条件渲染) -->
<path
v-if="sector.groupSplitVisible"
:d="sector.strokePath"
stroke="#000"
/>
<!-- 多文本单元 -->
<g v-if="sector.textUnits">
<text v-for="(unit, i) in sector.textUnits" :key="i">
<!-- SVG 图标或文字 -->
<textPath v-if="!unit.isSvg" :href="unit.textPathId">
{{ unit.content }}
</textPath>
<image v-else :href="unit.svgPath" />
</text>
</g>
</g>
<!-- 刻度环 -->
<g v-if="degreeRing">
<circle :r="degreeRing.ring.rOuter" :fill="none" />
<line v-for="tick in degreeRing.ticks" :key="tick.angle" />
<text v-for="label in degreeRing.labels" :key="label.angle">
{{ label.text }}
</text>
</g>
<!-- 中心图标 -->
<g v-if="centerIcon">
<image
:href="centerIcon.svgPath"
:transform="`rotate(${centerIcon.rotation})`"
/>
</g>
</svg>
</div>
</div>
</template>
<script setup lang="ts">
const { config, sectors, degreeRing, centerIcon, loading, error } =
useLuopan('/demo.json');
</script>
验收标准:
- 成功加载和渲染
demo.json - 所有 layer 正确显示
- 多文本单元正确拆分显示
- 刻度环和中心图标正常渲染
- groupSplit 效果正确
阶段 7:测试和优化(2-3天)
目标: 确保质量和性能
任务:
- ✅ 单元测试补全(目标覆盖率 > 85%)
- ✅ 集成测试:
- 完整配置文件解析
- 边界情况测试
- ✅ 性能优化:
- 大量扇区渲染优化(>1000个)
- 配置解析缓存
- ✅ 错误处理:
- 配置格式错误提示
- SVG 加载失败处理
- 颜色引用不存在处理
- ✅ 文档更新:
- README.md
- API 文档
- 配置示例
测试策略:
describe('完整流程测试', () => {
it('应正确解析和渲染 demo.json', async () => {
const config = await loadConfig('/demo.json');
const sectors = buildAllSectors(config);
// 验证扇区数量
const expectedCount = config.layers.reduce(
(sum, layer) => sum + layer.divisions, 0
);
expect(sectors).toHaveLength(expectedCount);
// 验证颜色优先级
const layer3Sector0 = sectors.find(
s => s.layerIndex === 2 && s.pieIndex === 0
);
expect(layer3Sector0?.fill).toBe('#0288D1'); // 水
// 验证多文本单元
const multiTextSector = sectors.find(
s => s.textUnits && s.textUnits.length > 1
);
expect(multiTextSector?.textUnits).toHaveLength(3);
});
it('应处理配置错误', async () => {
const invalidJson = '{ "name": "test", "missing": "layers" }';
expect(() => parseConfig(invalidJson)).toThrow('缺少必填字段');
});
});
验收标准:
- 单元测试覆盖率 > 85%
- 所有边界情况测试通过
- 大量扇区(120等分 × 5层)渲染流畅
- 错误信息清晰友好
六、需要新增/修改的文件清单
新增文件(共 8 个)
| 文件路径 | 功能 | 优先级 |
|---|---|---|
src/configParser.ts |
JSON 配置解析和验证 | P0 |
src/colorResolver.ts |
颜色解析引擎 | P0 |
src/sectorBuilder.ts |
扇区数据构建器 | P0 |
src/multiTextParser.ts |
多文本单元解析 | P1 |
src/degreeRing.ts |
刻度环生成器 | P1 |
src/centerIcon.ts |
中心图标处理 | P2 |
tests/configParser.test.ts |
配置解析测试 | P0 |
tests/sectorBuilder.test.ts |
扇区构建测试 | P1 |
修改文件(共 5 个)
| 文件路径 | 修改内容 | 影响范围 |
|---|---|---|
src/types.ts |
新增配置相关类型定义 | 扩展,不影响现有代码 |
src/constants.ts |
保持不变,已有 LAYOUT_RATIO_PRESETS | 无 |
src/utils.ts |
保持不变,工具函数完善 | 无 |
src/composables/useLuopan.ts |
重构为配置驱动模式 | 完全重写 |
src/Luopan.vue |
适配新数据结构和渲染逻辑 | 大幅修改 |
保持不变(共 2 个)
| 文件路径 | 原因 |
|---|---|
src/utils.ts |
几何计算和路径生成函数已完善,无需修改 |
src/constants.ts |
LAYOUT_RATIO_PRESETS 已定义,符合需求 |
七、技术难点和解决方案
难点 1:多文本单元的路径生成
问题: 一个扇区内有多个文本单元,每个单元需要独立的 textPath,且角度分配要精确。
解决方案:
- 在
sectorBuilder中为每个文本单元生成独立的TextUnit对象 - 每个
TextUnit包含独立的aStart,aEnd,content - 渲染时为每个单元生成独立的
<textPath>元素 - 使用
getLayoutRatio()确保角度分配准确 - 字体大小与布局按单元独立计算:每个
TextUnit用自身aStart/aEnd+content.length调用calculateSectorFontSize,并基于相同角度范围生成generateTextPath/generateVerticalTextPath
代码示例:
// 为每个文本单元生成独立路径
textUnits.forEach((unit, index) => {
unit.textPathId = `${sector.key}-unit-${index}`;
unit.textPath = generateTextPath(
sector.rInner,
sector.rOuter,
unit.aStart,
unit.aEnd
);
});
难点 2:规律填色算法的边界情况
问题: num + interval 组合可能产生边界情况(如 num > divisions)。
解决方案:
- 算法中加入边界检查:
for (let i = 0; i < num && currentIndex < divisions; i++) { colorMap.set(currentIndex, colorRef); currentIndex++; } - 单元测试覆盖所有边界情况:
num = divisions, interval = 0(全部着色)num = 1, interval = 1(隔一个着色)num > divisions(截断处理)interval = 0(特殊情况:全部着色)
难点 3:groupSplit 的判断逻辑
问题: 需要判断两个相邻扇区是否属于同一着色组,仅在组边界显示分割线。
解决方案:
- 在
SectorBuilder中计算每个扇区在周期中的位置:const cycleLength = num + interval; const posInCycle = sectorIndex % cycleLength; const isGroupBoundary = posInCycle === num - 1; - 为每个扇区添加
groupSplitVisible属性 - 渲染时条件渲染分割线:
<path v-if="sector.groupSplitVisible" :d="sector.boundaryPath" />
难点 4:SVG 图标加载和缓存
问题: 多个扇区可能使用相同 SVG 图标,需要避免重复加载。
解决方案:
- 实现 SVG 缓存机制:
const svgCache = new Map<string, string>(); async function loadSvg(filename: string): Promise<string> { if (svgCache.has(filename)) { return svgCache.get(filename)!; } const path = `/src/assets/icons/${filename}`; const content = await fetch(path).then(r => r.text()); svgCache.set(filename, content); return content; } - 使用
<defs>定义 SVG,<use>引用:<defs> <g v-for="svg in uniqueSvgs" :key="svg.name" :id="svg.id"> <path :d="svg.path" /> </g> </defs> <use v-for="sector in svgSectors" :href="`#${sector.svgId}`" />
难点 5:刻度环的刻度线长度分级
问题: major、minor、micro 三种刻度线长度需要根据 tickLengthStep 递减。
解决方案:
- 统一计算逻辑:
const lengths = { major: config.tickLength, minor: config.tickLength - (config.tickLengthStep ?? 0), micro: config.tickLength - 2 * (config.tickLengthStep ?? 0) }; - 确保最小长度不为负:
Object.keys(lengths).forEach(key => { lengths[key] = Math.max(1, lengths[key]); });
八、测试策略
8.1 单元测试
| 模块 | 测试文件 | 测试重点 |
|---|---|---|
| 配置解析 | configParser.test.ts |
注释去除、JSON 解析、验证逻辑 |
| 颜色解析 | colorResolver.test.ts |
优先级、规律填色、边界情况 |
| 多文本解析 | multiTextParser.test.ts |
角度分配、SVG 识别 |
| 扇区构建 | sectorBuilder.test.ts |
完整扇区生成、groupSplit |
| 刻度环 | degreeRing.test.ts |
刻度线生成、度数标签 |
示例测试用例:
describe('ColorResolver', () => {
it('应正确解析颜色引用', () => {
const resolver = new ColorResolver(
{ colorPalettes: { '木': '#43A047' } },
'#000000'
);
expect(resolver.resolveColor('木')).toBe('#43A047');
});
it('应应用规律填色', () => {
const layer: LayerConfig = {
divisions: 12,
colorRef: '土',
num: 3,
interval: 1,
// ...
};
const colorMap = resolver.resolveLayerColors(layer);
// 扇区 0, 1, 2 着色
expect(colorMap.has(0)).toBe(true);
expect(colorMap.has(1)).toBe(true);
expect(colorMap.has(2)).toBe(true);
// 扇区 3 空白
expect(colorMap.has(3)).toBe(false);
// 扇区 4, 5, 6 着色
expect(colorMap.has(4)).toBe(true);
});
});
8.2 集成测试
describe('完整渲染流程', () => {
it('应从 JSON 配置生成完整罗盘', async () => {
const config = await loadConfig('/demo.json');
const { sectors, degreeRing, centerIcon } = buildLuopan(config);
// 验证总扇区数
expect(sectors.length).toBeGreaterThan(0);
// 验证刻度环
expect(degreeRing?.ticks.length).toBe(360); // 每度一个微刻度
// 验证中心图标
expect(centerIcon?.svgPath).toContain('centericon.svg');
});
});
8.3 E2E 测试(可选)
使用 Playwright 或 Cypress 测试完整用户流程:
- 加载页面
- 验证罗盘正确渲染
- 测试缩放和平移
- 验证多文本单元显示
- 验证刻度环显示
九、实施风险和缓解措施
| 风险 | 影响 | 概率 | 缓解措施 |
|---|---|---|---|
| 配置文件格式复杂,解析困难 | 高 | 中 | 分阶段实现,先支持基础字段,再扩展 |
| 多文本单元渲染性能问题 | 中 | 低 | 使用虚拟化技术,仅渲染可见扇区 |
| SVG 图标加载失败 | 中 | 中 | 实现错误处理和占位符显示 |
| 规律填色算法边界情况 | 高 | 中 | 充分的单元测试覆盖 |
| 现有代码兼容性 | 低 | 低 | utils.ts 保持不变,新旧模式共存 |
十、总结和后续规划
10.1 重构价值
- 配置驱动:从硬编码到 JSON 配置,大幅提升灵活性
- 功能完整:支持复杂着色、多文本、SVG 图标、刻度环
- 可维护性:清晰的模块划分和职责分离
- 可扩展性:易于添加新功能(如动画、交互)
10.2 预期工期
- 总计:16-20 个工作日
- 基础架构(阶段1-2):4-5天
- 核心功能(阶段3-4):5-6天
- 高级功能(阶段5):2-3天
- 集成和优化(阶段6-7):5-6天
10.3 后续优化方向
-
性能优化:
- 虚拟滚动(大量扇区)
- Web Worker 解析配置
- Canvas 渲染模式(可选)
-
功能扩展:
- 配置编辑器(可视化配置生成)
- 动画效果(旋转、渐变)
- 导出功能(PNG, SVG)
- 多配置切换
-
开发体验:
- VS Code 插件(JSON 配置提示)
- 配置验证工具
- 在线预览工具
本重构方案完全基于现有代码和需求文档制定,确保可行性和可执行性。建议按阶段逐步实施,每个阶段完成后进行验收,确保质量和进度。