Files
lupin-demo/detail-design.md
2026-01-28 16:48:40 +08:00

16 KiB
Raw Blame History

罗盘组件详细设计文档

1. 概述

本组件实现了一个可配置的圆盘罗盘可视化系统,支持将圆盘分为多个同心圆环和扇区,并在每个区域精确放置文字或图形。

2. 数学模型

2.1 坐标系统

  • 角度约定0° 位于正北(上方),顺时针方向为正
  • SVG 坐标系
    • x 轴:向右为正
    • y 轴:向下为正
    • 原点:画布中心

2.2 参数定义

角度参数

  • n总扇区数pie 数量)
  • angle[i]:第 i 个分度线的角度i ∈ [0...n]
  • wide(i) = angle[i] - angle[i-1]:第 i 块扇区的角度宽度

半径参数

  • m:总圆环数
  • radius(j):第 j 个圆环的半径j ∈ [0...m]
  • height(j) = radius(j) - radius(j-1):第 j 层圆环的高度

区域定义

  • layer(j):第 j 层,由圆环 j 和圆环 j-1 之间形成
  • pie(i):第 i 个扇区,由分度线 i-1 和分度线 i 之间形成
  • sector(j,i):第 j 层第 i 个扇区的区域

2.3 形心计算公式

对于圆环扇形区域 sector(j,i),其形心位置计算如下:

输入参数

  • rInner = radius(j-1):内半径
  • rOuter = radius(j):外半径
  • aStart = angle[i-1]:起始角度(度)
  • aEnd = angle[i]:结束角度(度)

计算步骤

  1. 角度归一化
a₁ = normalize(aStart)  // 转换到 [0, 360)
a₂ = normalize(aEnd)
Δ = a₂ - a₁  // 若 < 0则加 360
  1. 中心角度
aMid = a₁ + Δ/2  // 扇区中心角度
  1. 形心半径(面积加权质心)
radialFactor = (2/3) × (r₂³ - r₁³) / (r₂² - r₁²)
sinc(x) = sin(x) / x  // x ≠ 0 时
ρ = radialFactor × sinc(Δ/2)

其中:

  • r₁ = rInner
  • r₂ = rOuter
  • Δ 为弧度制
  1. 极坐标转笛卡尔坐标
cx = ρ × sin(aMid)
cy = -ρ × cos(aMid)  // 注意y 坐标取负以适应 SVG 坐标系

输出结果

  • (cx, cy):形心的笛卡尔坐标
  • ρ:形心的极坐标半径
  • aMid:形心的极坐标角度

2.4 特殊情况处理

  1. 纯扇形rInner = 0

    • 公式仍然适用,内圆退化为点
  2. 跨越 0° 的扇区

    • 例如aStart = 315°, aEnd = 45°
    • 通过归一化自动处理:Δ = 45 - 315 + 360 = 90°
  3. 退化情况

    • rInner ≥ rOuter返回 (0, 0)
    • Δ = 0返回 (0, 0)

3. 核心算法实现

3.1 极坐标转换

function polarToXY(aDeg: number, r: number): {x: number, y: number} {
  const a = (aDeg * Math.PI) / 180;
  return { 
    x: r * Math.sin(a), 
    y: -r * Math.cos(a)  // y 坐标取负
  };
}

3.2 角度归一化

function normalizeDeg(deg: number): number {
  const d = deg % 360;
  return d < 0 ? d + 360 : d;
}

3.3 形心计算

function annularSectorCentroid(params: {
  rInner: number,
  rOuter: number,
  aStartDeg: number,
  aEndDeg: number
}): CentroidResult {
  const { rInner, rOuter, aStartDeg, aEndDeg } = params;

  // 1. 角度归一化
  const a1 = normalizeDeg(aStartDeg);
  const a2 = normalizeDeg(aEndDeg);
  let deltaDeg = a2 - a1;
  if (deltaDeg < 0) deltaDeg += 360;

  // 2. 中心角度
  const aMidDeg = normalizeDeg(a1 + deltaDeg / 2);
  const aMidRad = (aMidDeg * Math.PI) / 180;

  // 3. 边界检查
  if (rOuter <= rInner || deltaDeg === 0) {
    return { cx: 0, cy: 0, rho: 0, aMidDeg, aMidRad, deltaDeg };
  }

  // 4. 形心半径计算
  const delta = (deltaDeg * Math.PI) / 180;
  const radialFactor = (2 / 3) * 
    ((rOuter ** 3 - rInner ** 3) / (rOuter ** 2 - rInner ** 2));
  const half = delta / 2;
  const sinc = half === 0 ? 1 : Math.sin(half) / half;
  const rho = radialFactor * sinc;

  // 5. 坐标转换
  const p = polarToXY(aMidDeg, rho);
  return { cx: p.x, cy: p.y, rho, aMidDeg, aMidRad, deltaDeg };
}

4. 扇区路径生成

4.1 SVG 路径绘制

圆环扇形使用 SVG 的 <path> 元素绘制,包含以下步骤:

  1. 移动到外弧起点
  2. 绘制外弧arc to
  3. 连线到内弧终点
  4. 绘制内弧arc to
  5. 闭合路径
function annularSectorPath(
  rInner: number, 
  rOuter: number, 
  aStartDeg: number, 
  aEndDeg: number
): string {
  // 计算关键点
  const p1 = polarToXY(aStart, rOuter);  // 外弧起点
  const p2 = polarToXY(aEnd, rOuter);    // 外弧终点
  const p3 = polarToXY(aEnd, rInner);    // 内弧终点
  const p4 = polarToXY(aStart, rInner);  // 内弧起点

  // 确定 large-arc-flag
  const largeArc = delta > 180 ? 1 : 0;

  // 生成路径
  return [
    `M ${p1.x} ${p1.y}`,                                      // 起点
    `A ${rOuter} ${rOuter} 0 ${largeArc} 1 ${p2.x} ${p2.y}`, // 外弧
    `L ${p3.x} ${p3.y}`,                                      // 连线
    `A ${rInner} ${rInner} 0 ${largeArc} 0 ${p4.x} ${p4.y}`, // 内弧
    "Z"                                                        // 闭合
  ].join(" ");
}

4.2 特殊情况:纯扇形

rInner ≈ 0 时,内弧退化为圆心点:

if (rInner <= 0.000001) {
  return [
    `M ${p1.x} ${p1.y}`,
    `A ${rOuter} ${rOuter} 0 ${largeArc} 1 ${p2.x} ${p2.y}`,
    `L 0 0`,  // 连线到圆心
    "Z"
  ].join(" ");
}

5. 文字排列算法

5.1 文字路径生成

文字沿圆弧排列,使用 SVG 的 <textPath> 功能。路径使用形心半径:

function generateTextPath(
  rInner: number,
  rOuter: number,
  aStartDeg: number,
  aEndDeg: number,
  reverse: boolean
): string {
  // 使用形心半径作为文字路径
  const centroid = annularSectorCentroid({
    rInner, rOuter, aStartDeg, aEndDeg
  });
  const rMid = centroid.rho;  // 形心半径

  const p1 = polarToXY(aStart, rMid);
  const p2 = polarToXY(aEnd, rMid);
  const largeArc = delta > 180 ? 1 : 0;

  if (reverse) {
    // 反向路径(左半圆)
    return `M ${p2.x} ${p2.y} A ${rMid} ${rMid} 0 ${largeArc} 0 ${p1.x} ${p1.y}`;
  } else {
    // 正向路径(右半圆)
    return `M ${p1.x} ${p1.y} A ${rMid} ${rMid} 0 ${largeArc} 1 ${p2.x} ${p2.y}`;
  }
}

5.2 文字方向判断

为保持文字"头朝外",需要判断是否使用反向路径:

// 左半圆90° ~ 270°需要反向路径
const needReverse = aMidDeg > 90 && aMidDeg < 270;

原理

  • 右半圆0°~90°, 270°~360°正向路径文字自然朝外
  • 左半圆90°~270°反向路径避免文字倒置

5.3 文字长度适配

根据层数自动调整文字长度:

// 第 j 层显示 (j+1) × 2 个字
const textLength = (layerIndex + 1) * 2;
const label = '测'.repeat(textLength);

示例:

  • 第1层2个字
  • 第2层4个字
  • 第3层6个字

6. 颜色生成策略

当前实现以主题色板为核心:colorRef 优先解析为主题色名,未命中时退回原值。

扇区填色优先级

  1. 扇区级 colorRef
  2. 层级规律填色(layer.colorRef + num/interval/patternOffset
  3. 全局 background

刻度环颜色

  • degreeRing.colorRef 存在时,渲染环形背景填充(作为刻度环背景)。
  • tickColor / ringColor 显式提供则优先使用;未提供时根据背景计算对比色。
  • opacity 同时作用于环形背景和内外圆环描边。

7. 组件架构

7.1 模块划分

src/
├── types.ts              # TypeScript 类型定义
├── utils.ts              # 纯函数工具集
├── constants.ts          # 配置常量
├── composables/
│   └── useLuopan.ts     # 组合函数(业务逻辑)
├── Luopan.vue           # 主组件
├── utils.test.ts        # 单元测试
└── index.ts             # 入口文件

7.2 数据流

配置数据 (Example)
    ↓
useLuopan (计算扇区数据)
    ↓
Sector[] (包含形心、路径、颜色等)
    ↓
Luopan.vue (渲染 SVG)

7.3 关键类型定义

// 配置
interface Example {
  name: string;
  angles: number[];    // 角度分割点 [0, 30, 60, ..., 360]
  radii: number[];     // 圆环半径 [60, 120, 180]
}

// 扇区数据
interface Sector {
  key: string;
  layerIndex: number;
  pieIndex: number;
  rInner: number;
  rOuter: number;
  aStart: number;
  aEnd: number;
  aMidDeg: number;
  aMidRad: number;
  cx: number;          // 形心 x
  cy: number;          // 形心 y
  fill: string;
  label: string;
  path: string;        // 扇区路径
  textPath: string;    // 文字路径
  textPathId: string;
  needReverse: boolean;
}

8. 使用示例

8.1 基本使用

<template>
  <Luopan :size="520" />
</template>

<script setup>
import { Luopan } from './src';
</script>

8.2 自定义配置

import { useLuopan } from './src';
import { ref } from 'vue';

const customExample = ref({
  name: '自定义罗盘',
  angles: [0, 45, 90, 135, 180, 225, 270, 315, 360],
  radii: [80, 160, 240]
});

const { sectors } = useLuopan(customExample);

8.3 访问工具函数

import { 
  polarToXY, 
  annularSectorCentroid,
  generateSectorColor 
} from './src';

// 计算特定区域的形心
const centroid = annularSectorCentroid({
  rInner: 50,
  rOuter: 100,
  aStartDeg: 0,
  aEndDeg: 30
});

console.log(`形心位置: (${centroid.cx}, ${centroid.cy})`);

9. 测试

9.1 单元测试覆盖

  • 极坐标转换4个方向
  • 角度归一化负数、大于360
  • 形心计算纯扇形、圆环、跨0°
  • 路径生成(正常、退化情况)
  • 颜色生成(色相、亮度)

9.2 运行测试

npm run test          # 运行测试
npm run test:ui       # 测试 UI
npm run test:coverage # 覆盖率报告

10. 性能优化

10.1 计算优化

  • 使用 Vue 的 computed 缓存计算结果
  • 避免重复计算:形心、路径等一次生成
  • 三角函数结果复用

10.2 渲染优化

  • 使用 key 属性优化列表渲染
  • SVG 路径字符串预生成,避免模板内计算
  • 辅助线可选显示,减少 DOM 节点

11. 扩展性

11.1 支持的扩展

  • 自定义角度分割
  • 自定义圆环半径
  • 自定义颜色方案
  • 自定义文字内容
  • 🔲 图标/图片放置(预留 <foreignObject>
  • 🔲 交互事件(点击、悬停)
  • 🔲 动画效果

11.2 API 设计原则

  • 纯函数优先,便于测试
  • 类型安全,完整的 TypeScript 支持
  • 组合式 API灵活复用
  • 渐进式增强,保持简单使用

12. 文字布局与自适应

12.1 文字布局配置常量

所有文字布局相关的魔法数字已集中在 TEXT_LAYOUT_CONFIG 中管理:

export const TEXT_LAYOUT_CONFIG = {
  /** 字符间距系数1.2 表示字符实际占用高度 = fontSize × 1.2 */
  CHAR_SPACING_RATIO: 1.2,
  
  /** 径向留白比例0.8 表示文字占用 80%,上下各留 10% */
  RADIAL_PADDING_RATIO: 0.8,
  
  /** 切向留白比例0.85 表示文字占用 85%,左右各留 7.5% */
  TANGENT_PADDING_RATIO: 0.85,
  
  /** 小角度扇区字体缩放:防止弧线弯曲导致视觉溢出 */
  SMALL_ANGLE_SCALE: {
    TINY_THRESHOLD: 15,   // < 15° 应用 70% 缩放
    TINY_SCALE: 0.7,
    SMALL_THRESHOLD: 30,  // 15°-30° 应用 85% 缩放
    SMALL_SCALE: 0.85,
  },
  
  /** 字体大小限制 */
  FONT_SIZE: {
    MIN: 3,   // 最小 3px
    MAX: 24,  // 最大 24px
  },
  
  /** 竖排文字字符数范围 */
  VERTICAL_TEXT: {
    MIN_CHARS: 1,
    MAX_CHARS: 4,
  },
}

12.2 字体大小自适应算法

横排文字(沿弧线)

字体大小同时受径向高度和弧长双重约束:

// 1. 径向高度约束
maxByHeight = radialHeight × RADIAL_PADDING_RATIO

// 2. 弧长宽度约束
arcLength = rMid × deltaDeg × π/180
availableArcLength = arcLength × TANGENT_PADDING_RATIO
maxByWidth = availableArcLength / (textLength × 1.1)

// 3. 取较小值
calculatedSize = min(maxByHeight, maxByWidth)

// 4. 小角度额外缩放
if (deltaDeg < 15°) calculatedSize × 0.7
else if (deltaDeg < 30°) calculatedSize × 0.85

关键点

  • 1.1 系数包含字符宽度(1.0) + 字符间距(0.1)
  • 小角度缩放是因为弧线弯曲使文字在视觉上"更胖"

竖排文字(沿径向)

竖排文字沿径向排列,受不同约束:

// 1. 径向高度约束(主要)
availableHeight = radialHeight × RADIAL_PADDING_RATIO
maxByHeight = availableHeight / (textLength × CHAR_SPACING_RATIO)

// 2. 最内侧字符的弧长约束(防止最里面的字溢出)
estimatedFontSize = radialHeight × 0.5
innerMostRadius = rInner + estimatedFontSize / 2
innerArcLength = innerMostRadius × deltaDeg × π/180
availableArcLength = innerArcLength × TANGENT_PADDING_RATIO
maxByWidth = availableArcLength  // 单个字符宽度

// 3. 取较小值
calculatedSize = min(maxByHeight, maxByWidth)

关键点

  • 最内侧字符位置的半径最小,弧长最短,是最严格的宽度限制
  • 不需要小角度缩放(竖排不受弧线弯曲影响)

12.3 竖排文字路径生成

竖排文字路径是沿径向的直线,需要特殊处理不同场景:

普通圆环扇区rInner > 0

// 以形心或中点为中心,向两边延伸
rMid = centroid.rho  // 或 (rInner + rOuter) / 2
requiredPathLength = textLength × CHAR_SPACING_RATIO × fontSize
halfPath = requiredPathLength / 2

// 确保不超出边界
safeHalfPath = min(
  halfPath,
  radialHeight × RADIAL_PADDING_RATIO / 2,
  rMid - rInner,
  rOuter - rMid
)

finalStartR = rMid + safeHalfPath  // 外端
finalEndR = rMid - safeHalfPath    // 内端

从圆心开始的扇区rInner = 0

对于最内层扇区,形心会偏向外侧,需要特殊处理:

if (rInner === 0) {
  // 检查以形心居中是否会溢出外圆
  if (rMid + halfPath > rOuter) {
    // 会溢出:改为从外圆向内延伸
    finalStartR = rOuter
    finalEndR = rOuter - actualPathLength
  } else {
    // 不会溢出:正常使用形心居中
    finalStartR = rMid + halfPath
    finalEndR = rMid - halfPath
  }
}

原理

  • 最内层的形心通常在 r = 2/3 × rOuter 附近(对于纯扇形)
  • 如果以形心为中心,向外空间只有 1/3 × rOuter
  • 当文字较多时,会向外溢出
  • 因此优先尝试形心居中,溢出时回退到从外圆向内

12.4 文字字符数计算

横排文字

基于扇区尺寸和层数综合决定:

estimatedFontSize = radialHeight × 0.6
charWidth = estimatedFontSize × 1.15
availableWidth = arcWidth × 0.8
maxChars = floor(availableWidth / charWidth)

layerBasedLength = max(2, layerIndex + 1)  // 外层字多一些
textLength = max(1, min(6, min(maxChars, layerBasedLength)))

竖排文字

两步计算法,确保字符数与字体大小匹配:

// 步骤1用中等字符数估算字体大小
tempFontSize = calculateSectorFontSize(rInner, rOuter, aStart, aEnd, 2, isVertical=true)

// 步骤2根据字体大小计算实际能容纳的字符数1-4个
textLength = calculateVerticalTextLength(rInner, rOuter, tempFontSize, 1, 4)

// 步骤3用实际字符数重新精确计算字体大小
finalFontSize = calculateSectorFontSize(rInner, rOuter, aStart, aEnd, textLength, isVertical=true)

12.5 横排文字路径生成

横排文字沿弧线排列路径保持完整不缩短padding 通过字体大小计算实现:

// 路径从 aStart 到 aEnd 的完整弧线
rMid = centroid.rho  // 或 (rInner + rOuter) / 2
p1 = polarToXY(aStart, rMid)
p2 = polarToXY(aEnd, rMid)

// 弧线路径
if (reverse) {
  path = `M ${p2.x} ${p2.y} A ${rMid} ${rMid} 0 ${largeArc} 0 ${p1.x} ${p1.y}`
} else {
  path = `M ${p1.x} ${p1.y} A ${rMid} ${rMid} 0 ${largeArc} 1 ${p2.x} ${p2.y}`
}

文字通过 <textPath>startOffset="50%"text-anchor="middle" 在路径上居中。

12.6 调试与验证

如需调试特定扇区的计算结果,可临时添加日志:

// 在 useLuopan.ts 中
if (j === 0 && aStart >= 225 && aStart <= 260) {
  console.log(`扇区 [${aStart}-${aEnd}°, 层${j}]:`, {
    radialHeight, arcWidth, textLength, fontSize
  });
}

13. 参考资料

12.1 数学原理

  • 圆环扇形形心公式:基于面积加权的质心计算
  • SVG 坐标系统W3C SVG 规范
  • 极坐标系统:标准数学定义

12.2 技术栈

  • Vue 3.4+
  • TypeScript 5.3+
  • Vite 5.0+
  • Vitest 1.2+

版本: 1.1.0
最后更新: 2026年1月20日
主要变更:

  • 新增文字布局配置常量集中管理
  • 优化字体大小自适应算法
  • 完善竖排文字特殊场景处理
  • 修复最内层扇区文字溢出问题