update at 2026-01-21 21:48:08

This commit is contained in:
douboer@gmail.com
2026-01-21 21:48:08 +08:00
parent 54f3fd93f2
commit 78d8801a07
8 changed files with 184 additions and 271 deletions

5
package-lock.json generated
View File

@@ -1027,6 +1027,7 @@
"integrity": "sha512-xa57bCPGuzEFqGjPs3vVLyqareG8DX0uMkr5U/v5vLv5/ZUrBrPL7gzxzTJedEyZxFMfsozwTIbbYfEQVo3kgg==", "integrity": "sha512-xa57bCPGuzEFqGjPs3vVLyqareG8DX0uMkr5U/v5vLv5/ZUrBrPL7gzxzTJedEyZxFMfsozwTIbbYfEQVo3kgg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vitest/utils": "1.6.1", "@vitest/utils": "1.6.1",
"fast-glob": "^3.3.2", "fast-glob": "^3.3.2",
@@ -2756,6 +2757,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -2784,6 +2786,7 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.21.3", "esbuild": "^0.21.3",
"postcss": "^8.4.43", "postcss": "^8.4.43",
@@ -2867,6 +2870,7 @@
"integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vitest/expect": "1.6.1", "@vitest/expect": "1.6.1",
"@vitest/runner": "1.6.1", "@vitest/runner": "1.6.1",
@@ -2932,6 +2936,7 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz",
"integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==", "integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.26", "@vue/compiler-dom": "3.5.26",
"@vue/compiler-sfc": "3.5.26", "@vue/compiler-sfc": "3.5.26",

View File

@@ -206,7 +206,7 @@ const currentExample = computed(() => examples[exampleIndex.value]);
/** /**
* 使用罗盘逻辑 * 使用罗盘逻辑
*/ */
const { anglesDeg, rings, outerMost, sectors, getLabelTransform, toXY } = const { anglesDeg, rings, outerMost, sectors, toXY } =
useLuopan(currentExample, textRadialPosition); useLuopan(currentExample, textRadialPosition);
/** /**

View File

@@ -6,7 +6,6 @@ import { computed, type Ref, type ComputedRef } from 'vue';
import type { Example, Sector, TextRadialPosition } from '../types'; import type { Example, Sector, TextRadialPosition } from '../types';
import { import {
polarToXY, polarToXY,
calculateLabelRotation,
generateSectorData, generateSectorData,
} from '../utils'; } from '../utils';
@@ -64,8 +63,6 @@ export function useLuopan(
rOuter, rOuter,
aStart, aStart,
aEnd, aEnd,
layerCount,
pieCount,
textRadialPosition: textRadialPositionRef.value, textRadialPosition: textRadialPositionRef.value,
}); });
@@ -75,16 +72,6 @@ export function useLuopan(
return res; return res;
}); });
/**
* 计算标签的变换属性
* @param s 扇区数据
* @returns SVG transform 字符串
*/
const getLabelTransform = (s: Sector): string => {
const rotDeg = calculateLabelRotation(s.aMidDeg);
return `translate(${s.cx} ${s.cy}) rotate(${rotDeg})`;
};
/** /**
* 极坐标转 XY暴露给模板使用 * 极坐标转 XY暴露给模板使用
*/ */
@@ -95,7 +82,6 @@ export function useLuopan(
rings, rings,
outerMost, outerMost,
sectors, sectors,
getLabelTransform,
toXY, toXY,
}; };
} }

View File

@@ -21,8 +21,6 @@ export {
annularSectorCentroid, annularSectorCentroid,
annularSectorPath, annularSectorPath,
annularSectorInsetPath, annularSectorInsetPath,
calculateLabelRotation,
generateSectorColor,
generateTextPath, generateTextPath,
generateVerticalTextPath, generateVerticalTextPath,
getTextColorForBackground, getTextColorForBackground,

View File

@@ -6,6 +6,43 @@
import type { PolarPoint, AnnularSectorParams, CentroidResult } from './types'; import type { PolarPoint, AnnularSectorParams, CentroidResult } from './types';
import { TEXT_LAYOUT_CONFIG } from './constants'; import { TEXT_LAYOUT_CONFIG } from './constants';
const DEFAULT_SECTOR_FILL = '#e5e7eb';
const TEXT_ON_LIGHT = '#111827';
const TEXT_ON_DARK = '#ffffff';
const HEX_COLOR_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
const toLinearChannel = (channel: number): number => {
const c = channel / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
};
const relativeLuminance = (r: number, g: number, b: number): number => {
const rl = toLinearChannel(r);
const gl = toLinearChannel(g);
const bl = toLinearChannel(b);
return 0.2126 * rl + 0.7152 * gl + 0.0722 * bl;
};
const parseHexColor = (input: string): { r: number; g: number; b: number } | null => {
const match = input.trim().match(HEX_COLOR_RE);
if (!match) return null;
const hex = match[1];
if (hex.length === 3) {
return {
r: parseInt(hex[0] + hex[0], 16),
g: parseInt(hex[1] + hex[1], 16),
b: parseInt(hex[2] + hex[2], 16),
};
}
return {
r: parseInt(hex.slice(0, 2), 16),
g: parseInt(hex.slice(2, 4), 16),
b: parseInt(hex.slice(4, 6), 16),
};
};
/** /**
* 极坐标转 SVG 坐标 * 极坐标转 SVG 坐标
* 约定:角度 aDeg0°在北上方顺时针为正 * 约定:角度 aDeg0°在北上方顺时针为正
@@ -195,21 +232,6 @@ export function annularSectorInsetPath(
].join(" "); ].join(" ");
} }
/**
* 计算文字旋转角度
* - 文字沿径向方向rot = aMid头朝外、脚朝圆心
* - 为避免倒着读:当角度在 (180°, 360°) 之间时翻转 180°
* @param aMidDeg 中间角度(度)
* @returns 旋转角度
*/
export function calculateLabelRotation(aMidDeg: number): number {
let rotDeg = aMidDeg;
if (aMidDeg > 180 && aMidDeg < 360) {
rotDeg += 180;
}
return rotDeg;
}
/** /**
* 生成文字路径的圆弧(用于 textPath * 生成文字路径的圆弧(用于 textPath
* @param rInner 内半径 * @param rInner 内半径
@@ -269,19 +291,6 @@ export function generateTextPath(
} }
} }
/**
* 生成竖排文字路径(径向方向)
* 用于宽度小于高度的扇区
* @param rInner 内半径
* @param rOuter 外半径
* @param aStartDeg 起始角度(度)
* @param aEndDeg 结束角度(度)
* @param aMidDeg 中心角度
* @param textRadialPosition 文字径向位置:'centroid'(形心)或 'middle'(中点,默认)
* @param fontSize 字体大小
* @param textLength 文字字符数用于计算路径长度默认4
* @returns SVG path 字符串(径向直线)
*/
/** /**
* 生成竖排文字路径(径向方向) * 生成竖排文字路径(径向方向)
* 用于宽度小于高度的扇区 * 用于宽度小于高度的扇区
@@ -290,6 +299,8 @@ export function generateTextPath(
* @param aStartDeg 起始角度(度) * @param aStartDeg 起始角度(度)
* @param aEndDeg 结束角度(度) * @param aEndDeg 结束角度(度)
* @param textRadialPosition 文字径向位置:'centroid'(形心)或 'middle'(中点,默认) * @param textRadialPosition 文字径向位置:'centroid'(形心)或 'middle'(中点,默认)
* @param textLength 文字字符数(可选,用于路径长度估算)
* @param fontSize 字体大小(可选,用于路径长度估算)
* @returns SVG 路径字符串(直线) * @returns SVG 路径字符串(直线)
*/ */
export function generateVerticalTextPath( export function generateVerticalTextPath(
@@ -297,7 +308,9 @@ export function generateVerticalTextPath(
rOuter: number, rOuter: number,
aStartDeg: number, aStartDeg: number,
aEndDeg: number, aEndDeg: number,
textRadialPosition: 'centroid' | 'middle' = 'middle' textRadialPosition: 'centroid' | 'middle' = 'middle',
textLength?: number,
fontSize?: number
): string { ): string {
// 计算中间角度 // 计算中间角度
const a1 = normalizeDeg(aStartDeg); const a1 = normalizeDeg(aStartDeg);
@@ -317,22 +330,59 @@ export function generateVerticalTextPath(
rMid = (rInner + rOuter) / 2; rMid = (rInner + rOuter) / 2;
} }
// 计算径向高度和字体大小 // 计算径向高度
const radialHeight = rOuter - rInner; const radialHeight = rOuter - rInner;
// 计算字体大小(竖排) let resolvedTextLength = textLength;
const tempLength = 2; // 先假设2个字 let resolvedFontSize = fontSize;
const tempFontSize = calculateSectorFontSize(rInner, rOuter, aStartDeg, aEndDeg, tempLength, 3, 20, true); const hasTextLength = typeof resolvedTextLength === 'number';
const hasFontSize = typeof resolvedFontSize === 'number';
// 根据字体大小决定实际字符数
const textLength = calculateVerticalTextLength(rInner, rOuter, tempFontSize); if (!hasTextLength && !hasFontSize) {
const tempLength = 2;
// 用实际字符数重新计算字体大小 const tempFontSize = calculateSectorFontSize(
const fontSize = calculateSectorFontSize(rInner, rOuter, aStartDeg, aEndDeg, textLength, 3, 20, true); rInner,
rOuter,
aStartDeg,
aEndDeg,
tempLength,
3,
20,
true
);
resolvedTextLength = calculateVerticalTextLength(rInner, rOuter, tempFontSize);
resolvedFontSize = calculateSectorFontSize(
rInner,
rOuter,
aStartDeg,
aEndDeg,
resolvedTextLength,
3,
20,
true
);
} else if (hasTextLength && !hasFontSize) {
resolvedFontSize = calculateSectorFontSize(
rInner,
rOuter,
aStartDeg,
aEndDeg,
resolvedTextLength,
3,
20,
true
);
} else if (!hasTextLength && hasFontSize) {
resolvedTextLength = calculateVerticalTextLength(rInner, rOuter, resolvedFontSize);
}
const effectiveTextLength = resolvedTextLength ?? 0;
const effectiveFontSize = resolvedFontSize ?? TEXT_LAYOUT_CONFIG.FONT_SIZE.MIN;
// 竖排文字路径:根据扇区特点选择合适的起始位置 // 竖排文字路径:根据扇区特点选择合适的起始位置
// 计算实际需要的路径长度:字符数 × 字符间距系数 × 字体大小 // 计算实际需要的路径长度:字符数 × 字符间距系数 × 字体大小
const requiredPathLength = textLength * TEXT_LAYOUT_CONFIG.CHAR_SPACING_RATIO * fontSize; const requiredPathLength =
effectiveTextLength * TEXT_LAYOUT_CONFIG.CHAR_SPACING_RATIO * effectiveFontSize;
// 确保路径不超出扇区边界(考虑径向 padding // 确保路径不超出扇区边界(考虑径向 padding
const maxPathLength = radialHeight * TEXT_LAYOUT_CONFIG.RADIAL_PADDING_RATIO; const maxPathLength = radialHeight * TEXT_LAYOUT_CONFIG.RADIAL_PADDING_RATIO;
@@ -379,54 +429,24 @@ export function generateVerticalTextPath(
return `M ${p1.x} ${p1.y} L ${p2.x} ${p2.y}`; return `M ${p1.x} ${p1.y} L ${p2.x} ${p2.y}`;
} }
/**
* 生成扇区颜色
* @param layerIndex 层索引
* @param pieIndex 扇区索引
* @param totalLayers 总层数
* @param totalPies 总扇区数可选默认24
* @returns HSL 颜色字符串
*/
export function generateSectorColor(
layerIndex: number,
pieIndex: number,
totalLayers: number = 10,
totalPies: number = 24
): string {
const hue = (pieIndex * 360) / totalPies;
// 根据总层数动态调整亮度范围
// 最浅85%最深25%
// 使用线性插值,让颜色分布更均匀
const maxLight = 85;
const minLight = 25;
const lightRange = maxLight - minLight;
// 计算当前层的亮度比例0到1
const ratio = totalLayers > 1 ? layerIndex / (totalLayers - 1) : 0;
// 从浅到深:最内层最浅,最外层最深
const light = maxLight - (lightRange * ratio);
return `hsl(${hue} 70% ${Math.round(light)}%)`;
}
/** /**
* 根据背景颜色亮度计算文字颜色 * 根据背景颜色亮度计算文字颜色
* 确保文字与背景有足够的对比度 * 确保文字与背景有足够的对比度
* @param backgroundColor HSL 颜色字符串,如 'hsl(180 70% 48%)' * @param backgroundColor 颜色字符串,如 '#ffffff' 或 'hsl(180 70% 48%)'
* @returns 文字颜色(深色或浅色) * @returns 文字颜色(深色或浅色)
*/ */
export function getTextColorForBackground(backgroundColor: string): string { export function getTextColorForBackground(backgroundColor: string): string {
// 从 HSL 字符串中提取亮度值 const hex = parseHexColor(backgroundColor);
const match = backgroundColor.match(/hsl\([^)]+\s+(\d+)%\)/); if (hex) {
if (!match) return '#111827'; // 默认深色 const lum = relativeLuminance(hex.r, hex.g, hex.b);
return lum < 0.5 ? TEXT_ON_DARK : TEXT_ON_LIGHT;
const lightness = parseInt(match[1]); }
// 亮度阈值50% const match = backgroundColor.match(/hsl\([^)]+\s+(\d+)%\)/i);
// 亮度低于50%使用白色文字,否则使用深色文字 if (!match) return TEXT_ON_LIGHT;
return lightness < 50 ? '#ffffff' : '#111827';
const lightness = parseInt(match[1], 10);
return lightness < 50 ? TEXT_ON_DARK : TEXT_ON_LIGHT;
} }
/** /**
@@ -530,7 +550,7 @@ export function calculateSectorFontSize(
* @param fontSize 字体大小 * @param fontSize 字体大小
* @returns 应显示的字符数 * @returns 应显示的字符数
*/ */
export function calculateVerticalTextLength( function calculateVerticalTextLength(
rInner: number, rInner: number,
rOuter: number, rOuter: number,
fontSize: number fontSize: number
@@ -565,11 +585,23 @@ export function generateSectorData(params: {
rOuter: number; rOuter: number;
aStart: number; aStart: number;
aEnd: number; aEnd: number;
layerCount: number;
pieCount: number;
textRadialPosition: 'centroid' | 'middle'; textRadialPosition: 'centroid' | 'middle';
fill?: string;
textColor?: string;
label?: string;
}): any { }): any {
const { layerIndex, pieIndex, rInner, rOuter, aStart, aEnd, layerCount, pieCount, textRadialPosition } = params; const {
layerIndex,
pieIndex,
rInner,
rOuter,
aStart,
aEnd,
textRadialPosition,
fill,
textColor,
label,
} = params;
const deltaDeg = aEnd - aStart; const deltaDeg = aEnd - aStart;
const c = annularSectorCentroid({ rInner, rOuter, aStartDeg: aStart, aEndDeg: aEnd }); const c = annularSectorCentroid({ rInner, rOuter, aStartDeg: aStart, aEndDeg: aEnd });
@@ -580,32 +612,42 @@ export function generateSectorData(params: {
// 判断是否需要竖排 // 判断是否需要竖排
const isVertical = arcWidth < radialHeight; const isVertical = arcWidth < radialHeight;
const providedLabel = typeof label === 'string' && label.length > 0 ? label : undefined;
const providedLength = providedLabel ? providedLabel.length : undefined;
// 计算文字长度和字体大小 // 计算文字长度和字体大小
let textLength: number; let textLength: number;
let sectorFontSize: number; let sectorFontSize: number;
if (isVertical) { if (isVertical) {
// 竖排逻辑 if (typeof providedLength === 'number') {
const tempLength = 2; textLength = providedLength;
const tempFontSize = calculateSectorFontSize(rInner, rOuter, aStart, aEnd, tempLength, 3, 20, true); sectorFontSize = calculateSectorFontSize(rInner, rOuter, aStart, aEnd, textLength, 3, 20, true);
textLength = calculateVerticalTextLength(rInner, rOuter, tempFontSize); } else {
sectorFontSize = calculateSectorFontSize(rInner, rOuter, aStart, aEnd, textLength, 3, 20, true); const tempLength = 2;
const tempFontSize = calculateSectorFontSize(rInner, rOuter, aStart, aEnd, tempLength, 3, 20, true);
textLength = calculateVerticalTextLength(rInner, rOuter, tempFontSize);
sectorFontSize = calculateSectorFontSize(rInner, rOuter, aStart, aEnd, textLength, 3, 20, true);
}
} else { } else {
// 横排逻辑 if (typeof providedLength === 'number') {
const { RADIAL_PADDING_RATIO, CHAR_SPACING_RATIO, TANGENT_PADDING_RATIO } = TEXT_LAYOUT_CONFIG; textLength = providedLength;
const { MIN_CHARS, MAX_CHARS } = TEXT_LAYOUT_CONFIG.HORIZONTAL_TEXT; } else {
const { RADIAL_PADDING_RATIO, CHAR_SPACING_RATIO, TANGENT_PADDING_RATIO } = TEXT_LAYOUT_CONFIG;
const estimatedFontSize = radialHeight * RADIAL_PADDING_RATIO; const { MIN_CHARS, MAX_CHARS } = TEXT_LAYOUT_CONFIG.HORIZONTAL_TEXT;
const charWidth = estimatedFontSize * CHAR_SPACING_RATIO;
const availableWidth = arcWidth * TANGENT_PADDING_RATIO; const estimatedFontSize = radialHeight * RADIAL_PADDING_RATIO;
const maxChars = Math.floor(availableWidth / charWidth); const charWidth = estimatedFontSize * CHAR_SPACING_RATIO;
const availableWidth = arcWidth * TANGENT_PADDING_RATIO;
textLength = Math.max(MIN_CHARS, Math.min(MAX_CHARS, maxChars)); const maxChars = Math.floor(availableWidth / charWidth);
textLength = Math.max(MIN_CHARS, Math.min(MAX_CHARS, maxChars));
}
sectorFontSize = calculateSectorFontSize(rInner, rOuter, aStart, aEnd, textLength); sectorFontSize = calculateSectorFontSize(rInner, rOuter, aStart, aEnd, textLength);
} }
const label = '测'.repeat(textLength); const finalLabel = providedLabel ?? '测'.repeat(textLength);
const textPathId = `text-path-L${layerIndex}-P${pieIndex}`; const textPathId = `text-path-L${layerIndex}-P${pieIndex}`;
@@ -615,12 +657,20 @@ export function generateSectorData(params: {
// 生成文字路径(路径方向已经在函数内部自动处理) // 生成文字路径(路径方向已经在函数内部自动处理)
const textPath = isVertical const textPath = isVertical
? generateVerticalTextPath(rInner, rOuter, aStart, aEnd, effectiveTextRadialPosition) ? generateVerticalTextPath(
rInner,
rOuter,
aStart,
aEnd,
effectiveTextRadialPosition,
textLength,
sectorFontSize
)
: generateTextPath(rInner, rOuter, aStart, aEnd, effectiveTextRadialPosition); : generateTextPath(rInner, rOuter, aStart, aEnd, effectiveTextRadialPosition);
// 生成颜色 // 生成颜色
const fillColor = generateSectorColor(layerIndex, pieIndex, layerCount, pieCount); const fillColor = fill ?? DEFAULT_SECTOR_FILL;
const textColor = getTextColorForBackground(fillColor); const baseTextColor = textColor ?? getTextColorForBackground(fillColor);
// 内部填色逻辑 // 内部填色逻辑
const shouldFill = (pieIndex + layerIndex) % 3 === 0; const shouldFill = (pieIndex + layerIndex) % 3 === 0;
@@ -628,7 +678,7 @@ export function generateSectorData(params: {
const innerFillColor = shouldFill ? fillColor : undefined; const innerFillColor = shouldFill ? fillColor : undefined;
const baseFillColor = shouldFill ? '#ffffff' : fillColor; const baseFillColor = shouldFill ? '#ffffff' : fillColor;
const finalTextColor = shouldFill ? '#111827' : textColor; const finalTextColor = shouldFill ? TEXT_ON_LIGHT : baseTextColor;
return { return {
key: `L${layerIndex}-P${pieIndex}`, key: `L${layerIndex}-P${pieIndex}`,
@@ -644,7 +694,7 @@ export function generateSectorData(params: {
cy: c.cy, cy: c.cy,
fill: baseFillColor, fill: baseFillColor,
textColor: finalTextColor, textColor: finalTextColor,
label, label: finalLabel,
path: annularSectorPath(rInner, rOuter, aStart, aEnd), path: annularSectorPath(rInner, rOuter, aStart, aEnd),
innerFillPath, innerFillPath,
innerFillColor, innerFillColor,

View File

@@ -22,26 +22,23 @@ tests/
- 角度数组和半径数组的有效性验证 - 角度数组和半径数组的有效性验证
- 不同类型示例的存在性检查 - 不同类型示例的存在性检查
### 2. utils.test.ts (48 个测试) ### 2. utils.test.ts (39 个测试)
测试所有工具函数的核心逻辑: 测试所有工具函数的核心逻辑:
- **polarToXY**: 极坐标转换 - **polarToXY**: 极坐标转换
- **normalizeDeg**: 角度归一化 - **normalizeDeg**: 角度归一化
- **annularSectorCentroid**: 扇形形心计算 - **annularSectorCentroid**: 扇形形心计算
- **annularSectorPath**: SVG 路径生成 - **annularSectorPath**: SVG 路径生成
- **annularSectorInsetPath**: 内缩路径生成 - **annularSectorInsetPath**: 内缩路径生成
- **calculateLabelRotation**: 文字旋转角度
- **generateSectorColor**: 扇区颜色生成
- **generateTextPath**: 文字路径生成 - **generateTextPath**: 文字路径生成
- **generateVerticalTextPath**: 竖排文字路径 - **generateVerticalTextPath**: 竖排文字路径
- **getTextColorForBackground**: 文字颜色适配 - **getTextColorForBackground**: 文字颜色适配
- **calculateSectorFontSize**: 字体大小计算 - **calculateSectorFontSize**: 字体大小计算
### 3. useLuopan.test.ts (32 个测试) ### 3. useLuopan.test.ts (29 个测试)
测试罗盘业务逻辑组合函数: 测试罗盘业务逻辑组合函数:
- 基本功能和返回值验证 - 基本功能和返回值验证
- 扇区生成逻辑 - 扇区生成逻辑
- 文字位置模式middle/centroid - 文字位置模式middle/centroid
- 文字方向判断
- 竖排文字判断 - 竖排文字判断
- 内部填色逻辑 - 内部填色逻辑
- 响应式更新 - 响应式更新
@@ -90,7 +87,7 @@ npm test -- --coverage
## 测试统计 ## 测试统计
- **总测试文件**: 4 个 - **总测试文件**: 4 个
- **总测试用例**: 159 - **总测试用例**: 147
- **测试通过率**: 100% - **测试通过率**: 100%
- **测试运行时间**: ~1.8 秒 - **测试运行时间**: ~1.8 秒

View File

@@ -25,7 +25,6 @@ describe('useLuopan', () => {
expect(result).toHaveProperty('rings'); expect(result).toHaveProperty('rings');
expect(result).toHaveProperty('outerMost'); expect(result).toHaveProperty('outerMost');
expect(result).toHaveProperty('sectors'); expect(result).toHaveProperty('sectors');
expect(result).toHaveProperty('getLabelTransform');
expect(result).toHaveProperty('toXY'); expect(result).toHaveProperty('toXY');
}); });
@@ -92,7 +91,6 @@ describe('useLuopan', () => {
expect(sector).toHaveProperty('path'); expect(sector).toHaveProperty('path');
expect(sector).toHaveProperty('textPath'); expect(sector).toHaveProperty('textPath');
expect(sector).toHaveProperty('textPathId'); expect(sector).toHaveProperty('textPathId');
expect(sector).toHaveProperty('needReverse');
expect(sector).toHaveProperty('isVertical'); expect(sector).toHaveProperty('isVertical');
expect(sector).toHaveProperty('fontSize'); expect(sector).toHaveProperty('fontSize');
}); });
@@ -161,8 +159,8 @@ describe('useLuopan', () => {
const { sectors } = useLuopan(example, textRadialPosition); const { sectors } = useLuopan(example, textRadialPosition);
sectors.value.forEach((sector) => { sectors.value.forEach((sector) => {
expect(sector.fill).toMatch(/^(hsl\(.*\)|#[0-9a-fA-F]{6}|#ffffff)$/); expect(sector.fill).toMatch(/^#[0-9a-fA-F]{6}$/);
expect(sector.textColor).toMatch(/^(#[0-9a-fA-F]{6}|#ffffff)$/); expect(sector.textColor).toMatch(/^#[0-9a-fA-F]{6}$/);
}); });
}); });
@@ -248,38 +246,6 @@ describe('useLuopan', () => {
}); });
}); });
describe('文字方向', () => {
it('应该为左半圆的扇区设置 needReverse', () => {
const example = ref(createMockExample());
const textRadialPosition = ref<TextRadialPosition>('middle');
const { sectors } = useLuopan(example, textRadialPosition);
// 中间角度在 (90, 270) 范围内的扇区应该需要反向
const leftSectors = sectors.value.filter(
(s) => s.aMidDeg > 90 && s.aMidDeg < 270
);
leftSectors.forEach((sector) => {
expect(sector.needReverse).toBe(true);
});
});
it('应该为右半圆的扇区不设置 needReverse', () => {
const example = ref(createMockExample());
const textRadialPosition = ref<TextRadialPosition>('middle');
const { sectors } = useLuopan(example, textRadialPosition);
// 中间角度在 [0, 90] 或 [270, 360] 范围内的扇区不需要反向
const rightSectors = sectors.value.filter(
(s) => s.aMidDeg <= 90 || s.aMidDeg >= 270
);
rightSectors.forEach((sector) => {
expect(sector.needReverse).toBe(false);
});
});
});
describe('竖排文字判断', () => { describe('竖排文字判断', () => {
it('应该为窄扇区设置 isVertical', () => { it('应该为窄扇区设置 isVertical', () => {
const example = ref({ const example = ref({
@@ -384,21 +350,6 @@ describe('useLuopan', () => {
}); });
}); });
describe('getLabelTransform', () => {
it('应该返回有效的 SVG transform 字符串', () => {
const example = ref(createMockExample());
const textRadialPosition = ref<TextRadialPosition>('middle');
const { sectors, getLabelTransform } = useLuopan(example, textRadialPosition);
const sector = sectors.value[0];
const transform = getLabelTransform(sector);
expect(transform).toContain('translate');
expect(transform).toContain('rotate');
});
});
describe('toXY', () => { describe('toXY', () => {
it('应该正确转换极坐标', () => { it('应该正确转换极坐标', () => {
const example = ref(createMockExample()); const example = ref(createMockExample());

View File

@@ -10,8 +10,6 @@ import {
annularSectorCentroid, annularSectorCentroid,
annularSectorPath, annularSectorPath,
annularSectorInsetPath, annularSectorInsetPath,
calculateLabelRotation,
generateSectorColor,
generateTextPath, generateTextPath,
generateVerticalTextPath, generateVerticalTextPath,
getTextColorForBackground, getTextColorForBackground,
@@ -142,73 +140,6 @@ describe('annularSectorPath', () => {
}); });
}); });
describe('calculateLabelRotation', () => {
it('应该在上半圆不进行翻转', () => {
expect(calculateLabelRotation(0)).toBe(0);
expect(calculateLabelRotation(45)).toBe(45);
expect(calculateLabelRotation(90)).toBe(90);
expect(calculateLabelRotation(180)).toBe(180);
});
it('应该在下半圆翻转180°避免倒字', () => {
expect(calculateLabelRotation(181)).toBe(361); // 181 + 180
expect(calculateLabelRotation(270)).toBe(450); // 270 + 180
expect(calculateLabelRotation(359)).toBe(539); // 359 + 180
});
it('应该在边界值正确处理', () => {
expect(calculateLabelRotation(180)).toBe(180); // 等于180不翻转
expect(calculateLabelRotation(360)).toBe(360); // 等于360不翻转
});
});
describe('generateSectorColor', () => {
it('应该生成有效的 HSL 颜色', () => {
const color = generateSectorColor(0, 0);
expect(color).toMatch(/^hsl\(\d+(\.\d+)? \d+% \d+%\)$/);
});
it('应该根据层索引改变亮度', () => {
const color1 = generateSectorColor(0, 0);
const color2 = generateSectorColor(1, 0);
const color3 = generateSectorColor(2, 0);
// 提取亮度值
const light1 = parseInt(color1.match(/(\d+)%\)$/)?.[1] || '0');
const light2 = parseInt(color2.match(/(\d+)%\)$/)?.[1] || '0');
const light3 = parseInt(color3.match(/(\d+)%\)$/)?.[1] || '0');
expect(light1).toBeGreaterThan(light2);
expect(light2).toBeGreaterThan(light3);
});
it('应该根据扇区索引改变色相', () => {
const color1 = generateSectorColor(0, 0);
const color2 = generateSectorColor(0, 6);
// 提取色相值
const hue1 = parseInt(color1.match(/^hsl\((\d+(\.\d+)?)/)?.[1] || '0');
const hue2 = parseInt(color2.match(/^hsl\((\d+(\.\d+)?)/)?.[1] || '0');
expect(hue1).not.toBe(hue2);
});
it('应该为单层生成颜色', () => {
const color = generateSectorColor(0, 0, 1, 24);
expect(color).toMatch(/^hsl\(\d+(\.\d+)? \d+% \d+%\)$/);
});
it('应该处理大量层数', () => {
const color1 = generateSectorColor(0, 0, 31, 24);
const color2 = generateSectorColor(30, 0, 31, 24);
const light1 = parseInt(color1.match(/(\d+)%\)$/)?.[1] || '0');
const light2 = parseInt(color2.match(/(\d+)%\)$/)?.[1] || '0');
expect(light1).toBeGreaterThan(light2);
});
});
describe('annularSectorInsetPath', () => { describe('annularSectorInsetPath', () => {
it('应该生成内缩路径', () => { it('应该生成内缩路径', () => {
const path = annularSectorInsetPath(50, 100, 0, 90, 2); const path = annularSectorInsetPath(50, 100, 0, 90, 2);
@@ -236,33 +167,33 @@ describe('annularSectorInsetPath', () => {
describe('generateTextPath', () => { describe('generateTextPath', () => {
it('应该生成正向文字路径', () => { it('应该生成正向文字路径', () => {
const path = generateTextPath(50, 100, 0, 90, false, 'middle', 12); const path = generateTextPath(50, 100, 0, 90, 'middle');
expect(path).toContain('M '); expect(path).toContain('M ');
expect(path).toContain('A '); expect(path).toContain('A ');
expect(path).toContain('0 1'); // 正向扫描 expect(path).toContain('0 1'); // 正向扫描
}); });
it('应该生成反向文字路径', () => { it('应该生成反向文字路径', () => {
const path = generateTextPath(50, 100, 0, 90, true, 'middle', 12); const path = generateTextPath(50, 100, 120, 240, 'middle');
expect(path).toContain('M '); expect(path).toContain('M ');
expect(path).toContain('A '); expect(path).toContain('A ');
expect(path).toContain('0 0'); // 反向扫描 expect(path).toContain('0 0'); // 反向扫描
}); });
it('应该在 centroid 模式下使用形心半径', () => { it('应该在 centroid 模式下使用形心半径', () => {
const pathCentroid = generateTextPath(50, 100, 0, 90, false, 'centroid', 12); const pathCentroid = generateTextPath(50, 100, 0, 90, 'centroid');
const pathMiddle = generateTextPath(50, 100, 0, 90, false, 'middle', 12); const pathMiddle = generateTextPath(50, 100, 0, 90, 'middle');
expect(pathCentroid).not.toBe(pathMiddle); expect(pathCentroid).not.toBe(pathMiddle);
}); });
it('应该处理跨越360°的扇区', () => { it('应该处理跨越360°的扇区', () => {
const path = generateTextPath(50, 100, 315, 45, false, 'middle', 12); const path = generateTextPath(50, 100, 315, 45, 'middle');
expect(path).toContain('M '); expect(path).toContain('M ');
expect(path).toContain('A '); expect(path).toContain('A ');
}); });
it('应该处理大角度扇区', () => { it('应该处理大角度扇区', () => {
const path = generateTextPath(50, 100, 0, 270, false, 'middle', 12); const path = generateTextPath(50, 100, 0, 270, 'middle');
expect(path).toContain('M '); expect(path).toContain('M ');
expect(path).toContain('A '); expect(path).toContain('A ');
}); });
@@ -270,25 +201,25 @@ describe('generateTextPath', () => {
describe('generateVerticalTextPath', () => { describe('generateVerticalTextPath', () => {
it('应该生成竖排文字路径', () => { it('应该生成竖排文字路径', () => {
const path = generateVerticalTextPath(50, 100, 0, 30, 15, 'middle', 12); const path = generateVerticalTextPath(50, 100, 0, 30, 'middle');
expect(path).toContain('M '); expect(path).toContain('M ');
expect(path).toContain('L '); expect(path).toContain('L ');
}); });
it('应该在 centroid 模式下使用形心', () => { it('应该在 centroid 模式下使用形心', () => {
const pathCentroid = generateVerticalTextPath(50, 100, 0, 30, 15, 'centroid', 12); const pathCentroid = generateVerticalTextPath(50, 100, 0, 30, 'centroid');
const pathMiddle = generateVerticalTextPath(50, 100, 0, 30, 15, 'middle', 12); const pathMiddle = generateVerticalTextPath(50, 100, 0, 30, 'middle');
expect(pathCentroid).not.toBe(pathMiddle); expect(pathCentroid).not.toBe(pathMiddle);
}); });
it('应该处理不同的角度', () => { it('应该处理不同的角度', () => {
const path1 = generateVerticalTextPath(50, 100, 0, 30, 15, 'middle', 12); const path1 = generateVerticalTextPath(50, 100, 0, 30, 'middle');
const path2 = generateVerticalTextPath(50, 100, 0, 30, 180, 'middle', 12); const path2 = generateVerticalTextPath(50, 100, 150, 180, 'middle');
expect(path1).not.toBe(path2); expect(path1).not.toBe(path2);
}); });
it('应该处理窄扇区', () => { it('应该处理窄扇区', () => {
const path = generateVerticalTextPath(80, 100, 0, 10, 5, 'middle', 12); const path = generateVerticalTextPath(80, 100, 0, 10, 'middle');
expect(path).toContain('M '); expect(path).toContain('M ');
expect(path).toContain('L '); expect(path).toContain('L ');
}); });
@@ -296,22 +227,17 @@ describe('generateVerticalTextPath', () => {
describe('getTextColorForBackground', () => { describe('getTextColorForBackground', () => {
it('应该为深色背景返回白色', () => { it('应该为深色背景返回白色', () => {
const color = getTextColorForBackground('hsl(180 70% 25%)'); const color = getTextColorForBackground('#111111');
expect(color).toBe('#ffffff'); expect(color).toBe('#ffffff');
}); });
it('应该为浅色背景返回深色', () => { it('应该为浅色背景返回深色', () => {
const color = getTextColorForBackground('hsl(180 70% 85%)'); const color = getTextColorForBackground('#f9fafb');
expect(color).toBe('#111827'); expect(color).toBe('#111827');
}); });
it('应该处理边界值50%', () => { it('应该支持 HSL 输入', () => {
const color = getTextColorForBackground('hsl(180 70% 50%)'); const color = getTextColorForBackground('hsl(180 70% 25%)');
expect(color).toBe('#111827');
});
it('应该处理边界值49%', () => {
const color = getTextColorForBackground('hsl(180 70% 49%)');
expect(color).toBe('#ffffff'); expect(color).toBe('#ffffff');
}); });