1181 lines
34 KiB
Markdown
1181 lines
34 KiB
Markdown
# 罗盘项目重构方案
|
||
|
||
---
|
||
|
||
## 一、项目现状分析
|
||
|
||
### 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 最大 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 关键算法
|
||
|
||
**规律填色算法:**
|
||
```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.vue(3-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`(特殊情况:全部着色)
|
||
|
||
---
|
||
|
||
### 难点 3:groupSplit 的判断逻辑
|
||
|
||
**问题:** 需要判断两个相邻扇区是否属于同一着色组,仅在组边界显示分割线。
|
||
|
||
**解决方案:**
|
||
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"
|
||
/>
|
||
```
|
||
|
||
---
|
||
|
||
### 难点 4:SVG 图标加载和缓存
|
||
|
||
**问题:** 多个扇区可能使用相同 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-2):4-5天
|
||
- 核心功能(阶段3-4):5-6天
|
||
- 高级功能(阶段5):2-3天
|
||
- 集成和优化(阶段6-7):5-6天
|
||
|
||
### 10.3 后续优化方向
|
||
|
||
1. **性能优化**:
|
||
- 虚拟滚动(大量扇区)
|
||
- Web Worker 解析配置
|
||
- Canvas 渲染模式(可选)
|
||
|
||
2. **功能扩展**:
|
||
- 配置编辑器(可视化配置生成)
|
||
- 动画效果(旋转、渐变)
|
||
- 导出功能(PNG, SVG)
|
||
- 多配置切换
|
||
|
||
3. **开发体验**:
|
||
- VS Code 插件(JSON 配置提示)
|
||
- 配置验证工具
|
||
- 在线预览工具
|
||
|
||
---
|
||
|
||
**本重构方案完全基于现有代码和需求文档制定,确保可行性和可执行性。建议按阶段逐步实施,每个阶段完成后进行验收,确保质量和进度。**
|