Files
lupin-demo/refactor-plan.md
2026-01-22 20:01:26 +08:00

1181 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 罗盘项目重构方案
---
## 一、项目现状分析
### 1.1 当前架构特点
- **示例驱动**:目前使用 EXAMPLES 数组硬编码示例数据(角度数组 + 半径数组)
- **简单数据结构**:通过角度分割点和半径列表简单定义层次和扇区
- **内置逻辑**:颜色、文字、填充等逻辑写死在代码中
- **测试导向**:现有实现主要为测试工具函数正确性而设计
### 1.2 目标需求分析(基于 todolist.md 和 demo.json
说明:`public/*.json` 为实际加载配置,`.json.conf` 仅作解释说明,不参与加载。
**核心变化:**
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 仅作为无层配置时的兜底
---
## 二、整体架构设计
### 2.1 分层架构
```
┌─────────────────────────────────────────────────┐
│ UI 层Luopan.vue
│ - SVG 渲染 │
│ - 交互控制(缩放、平移) │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ Composable 层useLuopan.ts
│ - 配置解析协调 │
│ - 响应式数据管理 │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ 配置解析层(新增) │
│ - configParser.tsJSON → 内部数据结构 │
│ - 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 关键算法
**规律填色算法:**
```typescript
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;
}
```
**多文本单元角度分配:**
```typescript
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 配置数据结构(新增类型)
```typescript
// 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 数据流转示例
```typescript
// 输入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天
**目标:** 建立配置解析基础
**任务:**
1. ✅ 扩展 `types.ts`,新增所有配置相关类型
2. ✅ 实现 `configParser.ts`
- 去除 `--` 注释
- JSON 解析
- 基础验证(必填字段检查)
3. ✅ 编写单元测试:`configParser.test.ts`
4. ✅ 测试用例:解析 `demo.json`
**验收标准:**
- 能成功解析 `demo.json` 为 LuopanConfig 对象
- 所有必填字段验证通过
- 测试覆盖率 > 80%
---
### 阶段 2颜色解析引擎2天
**目标:** 实现三级着色优先级
**任务:**
1. ✅ 实现 `colorResolver.ts`
- ColorResolver 类
- resolveColor(ref: string) 方法
- 规律填色算法
- 颜色优先级逻辑
2. ✅ 编写单元测试:`colorResolver.test.ts`
3. ✅ 测试用例覆盖:
- 全局背景色
- 层级 colorRef
- 扇区 colorRef
- 规律填色num + interval
**核心代码:**
```typescript
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天
**目标:** 支持 `|` 分隔文本的角度分配
**任务:**
1. ✅ 实现 `multiTextParser.ts`
- splitMultiTextUnits() 函数
- 角度分配逻辑
- SVG 文件检测
2. ✅ 更新 `utils.ts`
- 适配多文本单元的路径生成
- 字体大小计算调整
3. ✅ 编写单元测试:`multiTextParser.test.ts`
4. ✅ 测试用例:
- 1-5 个单元的角度分配
- LAYOUT_RATIO_PRESETS 验证
**核心代码:**
```typescript
// 仅保留名称,具体实现参考前文 splitMultiTextUnits
export function splitMultiTextUnits(
content: string,
aStart: number,
aEnd: number,
svgIconPath: string = 'src/assets/icons/'
): TextUnit[] {
// 参考前面代码
return [];
}
```
**验收标准:**
- 角度分配与 LAYOUT_RATIO_PRESETS 一致
- 支持 SVG 文件名识别
- 边界情况(单个文本、空字符串)
---
### 阶段 4扇区构建器3-4天
**目标:** 整合所有逻辑,生成完整扇区数据
**任务:**
1. ✅ 实现 `sectorBuilder.ts`
- SectorBuilder 类
- buildLayer() 方法
- buildSector() 方法
- 整合颜色、多文本、innerFill、groupSplit
2. ✅ 更新 `Sector` 接口types.ts
3. ✅ 编写单元测试:`sectorBuilder.test.ts`
4. ✅ 集成测试:完整 layer 构建
**核心代码:**
```typescript
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天
**目标:** 实现刻度环和中心图标功能
**任务:**
1. ✅ 实现 `degreeRing.ts`
- buildDegreeRing() 函数
- 刻度线生成major, minor, micro
- 度数标签生成
- mode 处理inner, outer, both
2. ✅ 实现 `centerIcon.ts`
- loadCenterIcon() 函数
- SVG 加载和解析
- 旋转处理
3. ✅ 更新 Luopan.vue
- 渲染刻度环
- 渲染中心图标
4. ✅ 编写单元测试
**核心代码(刻度环):**
```typescript
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.vue3-4天
**目标:** 适配新的配置驱动模式
**任务:**
1. ✅ 重构 `useLuopan.ts`
- 接收 JSON 配置路径或对象
- 异步加载和解析配置
- 调用各个解析器和构建器
- 返回完整渲染数据
2. ✅ 更新 `Luopan.vue`
- 移除示例选择器
- 添加配置加载界面
- 渲染多文本单元
- 渲染刻度环
- 渲染中心图标
- 处理 groupSplit条件渲染分割线
3. ✅ 更新 `App.vue`
- 传入配置文件路径
4. ✅ E2E 测试
**核心代码useLuopan 重构):**
```typescript
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 关键更新:**
```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天
**目标:** 确保质量和性能
**任务:**
1. ✅ 单元测试补全(目标覆盖率 > 85%
2. ✅ 集成测试:
- 完整配置文件解析
- 边界情况测试
3. ✅ 性能优化:
- 大量扇区渲染优化(>1000个
- 配置解析缓存
4. ✅ 错误处理:
- 配置格式错误提示
- SVG 加载失败处理
- 颜色引用不存在处理
5. ✅ 文档更新:
- README.md
- API 文档
- 配置示例
**测试策略:**
```typescript
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且角度分配要精确。
**解决方案:**
1.`sectorBuilder` 中为每个文本单元生成独立的 `TextUnit` 对象
2. 每个 `TextUnit` 包含独立的 `aStart`, `aEnd`, `content`
3. 渲染时为每个单元生成独立的 `<textPath>` 元素
4. 使用 `getLayoutRatio()` 确保角度分配准确
5. 字体大小与布局按单元独立计算:每个 `TextUnit` 用自身 `aStart/aEnd` + `content.length` 调用 `calculateSectorFontSize`,并基于相同角度范围生成 `generateTextPath` / `generateVerticalTextPath`
**代码示例:**
```typescript
// 为每个文本单元生成独立路径
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
**解决方案:**
1. 算法中加入边界检查:
```typescript
for (let i = 0; i < num && currentIndex < divisions; i++) {
colorMap.set(currentIndex, colorRef);
currentIndex++;
}
```
2. 单元测试覆盖所有边界情况:
- `num = divisions, interval = 0`(全部着色)
- `num = 1, interval = 1`(隔一个着色)
- `num > divisions`(截断处理)
- `interval = 0`(特殊情况:全部着色)
---
### 难点 3groupSplit 的判断逻辑
**问题:** 需要判断两个相邻扇区是否属于同一着色组,仅在组边界显示分割线。
**解决方案:**
1. 在 `SectorBuilder` 中计算每个扇区在周期中的位置:
```typescript
const cycleLength = num + interval;
const posInCycle = sectorIndex % cycleLength;
const isGroupBoundary = posInCycle === num - 1;
```
2. 为每个扇区添加 `groupSplitVisible` 属性
3. 渲染时条件渲染分割线:
```vue
<path
v-if="sector.groupSplitVisible"
:d="sector.boundaryPath"
/>
```
---
### 难点 4SVG 图标加载和缓存
**问题:** 多个扇区可能使用相同 SVG 图标,需要避免重复加载。
**解决方案:**
1. 实现 SVG 缓存机制:
```typescript
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;
}
```
2. 使用 `<defs>` 定义 SVG`<use>` 引用:
```vue
<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` 递减。
**解决方案:**
1. 统一计算逻辑:
```typescript
const lengths = {
major: config.tickLength,
minor: config.tickLength - (config.tickLengthStep ?? 0),
micro: config.tickLength - 2 * (config.tickLengthStep ?? 0)
};
```
2. 确保最小长度不为负:
```typescript
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` | 刻度线生成、度数标签 |
**示例测试用例:**
```typescript
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 集成测试
```typescript
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 测试完整用户流程:
1. 加载页面
2. 验证罗盘正确渲染
3. 测试缩放和平移
4. 验证多文本单元显示
5. 验证刻度环显示
---
## 九、实施风险和缓解措施
| 风险 | 影响 | 概率 | 缓解措施 |
|-----|------|------|---------|
| 配置文件格式复杂,解析困难 | 高 | 中 | 分阶段实现,先支持基础字段,再扩展 |
| 多文本单元渲染性能问题 | 中 | 低 | 使用虚拟化技术,仅渲染可见扇区 |
| SVG 图标加载失败 | 中 | 中 | 实现错误处理和占位符显示 |
| 规律填色算法边界情况 | 高 | 中 | 充分的单元测试覆盖 |
| 现有代码兼容性 | 低 | 低 | `utils.ts` 保持不变,新旧模式共存 |
---
## 十、总结和后续规划
### 10.1 重构价值
1. **配置驱动**:从硬编码到 JSON 配置,大幅提升灵活性
2. **功能完整**支持复杂着色、多文本、SVG 图标、刻度环
3. **可维护性**:清晰的模块划分和职责分离
4. **可扩展性**:易于添加新功能(如动画、交互)
### 10.2 预期工期
- **总计16-20 个工作日**
- 基础架构阶段1-24-5天
- 核心功能阶段3-45-6天
- 高级功能阶段52-3天
- 集成和优化阶段6-75-6天
### 10.3 后续优化方向
1. **性能优化**
- 虚拟滚动(大量扇区)
- Web Worker 解析配置
- Canvas 渲染模式(可选)
2. **功能扩展**
- 配置编辑器(可视化配置生成)
- 动画效果(旋转、渐变)
- 导出功能PNG, SVG
- 多配置切换
3. **开发体验**
- VS Code 插件JSON 配置提示)
- 配置验证工具
- 在线预览工具
---
**本重构方案完全基于现有代码和需求文档制定,确保可行性和可执行性。建议按阶段逐步实施,每个阶段完成后进行验收,确保质量和进度。**