first commit

This commit is contained in:
douboer
2026-01-21 13:22:26 +08:00
commit 24452838a1
28 changed files with 7901 additions and 0 deletions

656
src/utils.ts Normal file
View File

@@ -0,0 +1,656 @@
/**
* 罗盘工具函数
* 所有函数都是纯函数,便于测试
*/
import type { PolarPoint, AnnularSectorParams, CentroidResult } from './types';
import { TEXT_LAYOUT_CONFIG } from './constants';
/**
* 极坐标转 SVG 坐标
* 约定:角度 aDeg0°在北上方顺时针为正
* SVG坐标x右正y下正
* @param aDeg 角度(度)
* @param r 半径
* @returns SVG 坐标点
*/
export function polarToXY(aDeg: number, r: number): PolarPoint {
const a = (aDeg * Math.PI) / 180;
return {
x: r * Math.sin(a),
y: -r * Math.cos(a)
};
}
/**
* 角度归一化到 [0, 360) 范围
* @param deg 角度
* @returns 归一化后的角度
*/
export function normalizeDeg(deg: number): number {
const d = deg % 360;
return d < 0 ? d + 360 : d;
}
/**
* 圆环扇形形心(用于放文字/图标)
* 输入rInner, rOuter, aStartDeg, aEndDeg
* 输出:形心坐标 (cx, cy) + 中心方向角 aMiddeg/rad
* @param params 扇形参数
* @returns 形心结果
*/
export function annularSectorCentroid(params: AnnularSectorParams): CentroidResult {
const { rInner, rOuter, aStartDeg, aEndDeg } = params;
const a1 = normalizeDeg(aStartDeg);
const a2 = normalizeDeg(aEndDeg);
let deltaDeg = a2 - a1;
if (deltaDeg < 0) deltaDeg += 360;
const aMidDeg = normalizeDeg(a1 + deltaDeg / 2);
const aMidRad = (aMidDeg * Math.PI) / 180;
if (rOuter <= rInner || deltaDeg === 0) {
return { cx: 0, cy: 0, rho: 0, aMidDeg, aMidRad, deltaDeg };
}
// rho = (2/3) * (r2^3 - r1^3)/(r2^2 - r1^2) * sinc(delta/2)
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;
const p = polarToXY(aMidDeg, rho);
return { cx: p.x, cy: p.y, rho, aMidDeg, aMidRad, deltaDeg };
}
/**
* 生成圆环扇形路径SVG path
* @param rInner 内半径
* @param rOuter 外半径
* @param aStartDeg 起始角度(度)
* @param aEndDeg 结束角度(度)
* @returns SVG path 字符串
*/
export function annularSectorPath(
rInner: number,
rOuter: number,
aStartDeg: number,
aEndDeg: number
): string {
const a1 = normalizeDeg(aStartDeg);
const a2 = normalizeDeg(aEndDeg);
let delta = a2 - a1;
if (delta < 0) delta += 360;
// SVG arc flags
const largeArc = delta > 180 ? 1 : 0;
const sweepOuter = 1;
const sweepInner = 0;
const p1 = polarToXY(a1, rOuter);
const p2 = polarToXY(a2, rOuter);
const p3 = polarToXY(a2, rInner);
const p4 = polarToXY(a1, rInner);
// 如果 rInner=0内弧退化为点
if (rInner <= 0.000001) {
return [
`M ${p1.x} ${p1.y}`,
`A ${rOuter} ${rOuter} 0 ${largeArc} ${sweepOuter} ${p2.x} ${p2.y}`,
`L 0 0`,
"Z",
].join(" ");
}
return [
`M ${p1.x} ${p1.y}`,
`A ${rOuter} ${rOuter} 0 ${largeArc} ${sweepOuter} ${p2.x} ${p2.y}`,
`L ${p3.x} ${p3.y}`,
`A ${rInner} ${rInner} 0 ${largeArc} ${sweepInner} ${p4.x} ${p4.y}`,
"Z",
].join(" ");
}
/**
* 生成内缩的圆环扇形路径(用于填色区域)
* 实现所有边界的等距离内缩
* @param rInner 内半径
* @param rOuter 外半径
* @param aStartDeg 起始角度(度)
* @param aEndDeg 结束角度(度)
* @param inset 内缩距离(像素)
* @returns SVG path 字符串
*/
export function annularSectorInsetPath(
rInner: number,
rOuter: number,
aStartDeg: number,
aEndDeg: number,
inset: number = 2
): string {
const a1 = normalizeDeg(aStartDeg);
const a2 = normalizeDeg(aEndDeg);
let delta = a2 - a1;
if (delta < 0) delta += 360;
// 半径方向内缩
const rInnerInset = rInner + inset;
const rOuterInset = rOuter - inset;
// 如果内缩后内外半径重叠,返回空路径
if (rInnerInset >= rOuterInset) return '';
// 角度方向内缩:在外圆上内缩 inset 距离
// 弧长 = 半径 × 角度(弧度),所以角度偏移 = 弧长 / 半径
const angleInsetRadOuter = inset / rOuterInset;
const angleInsetDegOuter = (angleInsetRadOuter * 180) / Math.PI;
// 角度方向内缩:在内圆上内缩 inset 距离
let angleInsetDegInner = 0;
if (rInnerInset > 0.1) {
// 正常的圆环或从圆心开始但内缩后有半径的扇区
const angleInsetRadInner = inset / rInnerInset;
angleInsetDegInner = (angleInsetRadInner * 180) / Math.PI;
} else {
// 内缩后半径接近0使用外圆的角度偏移
angleInsetDegInner = angleInsetDegOuter;
}
// 计算新的角度范围(使用外圆的角度偏移作为主要参考)
const aStartInset = a1 + angleInsetDegOuter;
const aEndInset = a2 - angleInsetDegOuter;
// 如果角度内缩后重叠,返回空路径
let deltaInset = aEndInset - aStartInset;
if (deltaInset < 0) deltaInset += 360;
if (deltaInset <= 0) return '';
// 生成内缩路径
const largeArc = deltaInset > 180 ? 1 : 0;
// 外圆:使用外圆的角度偏移
const p1Outer = polarToXY(aStartInset, rOuterInset);
const p2Outer = polarToXY(aEndInset, rOuterInset);
// 内圆:计算内圆的实际角度
const aStartInner = a1 + angleInsetDegInner;
const aEndInner = a2 - angleInsetDegInner;
const p1Inner = polarToXY(aStartInner, rInnerInset);
const p2Inner = polarToXY(aEndInner, rInnerInset);
// 统一使用相同的路径结构,无论是否从圆心开始
return [
`M ${p1Outer.x} ${p1Outer.y}`,
`A ${rOuterInset} ${rOuterInset} 0 ${largeArc} 1 ${p2Outer.x} ${p2Outer.y}`,
`L ${p2Inner.x} ${p2Inner.y}`,
`A ${rInnerInset} ${rInnerInset} 0 ${largeArc} 0 ${p1Inner.x} ${p1Inner.y}`,
"Z",
].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
* @param rInner 内半径
* @param rOuter 外半径
* @param aStartDeg 起始角度(度)
* @param aEndDeg 结束角度(度)
* @param textRadialPosition 文字径向位置:'centroid'(形心)或 'middle'(中点,默认)
* @returns SVG path 字符串
*/
export function generateTextPath(
rInner: number,
rOuter: number,
aStartDeg: number,
aEndDeg: number,
textRadialPosition: 'centroid' | 'middle' = 'middle'
): string {
// 根据配置选择径向位置
let rMid: number;
if (textRadialPosition === 'centroid') {
// 计算形心半径
const centroid = annularSectorCentroid({ rInner, rOuter, aStartDeg, aEndDeg });
rMid = centroid.rho;
} else {
// 使用几何中点
rMid = (rInner + rOuter) / 2;
}
// 不调整半径,保持在中线位置
// 使用 dominant-baseline 属性来控制文字的垂直对齐
const adjustedRMid = rMid;
const a1 = normalizeDeg(aStartDeg);
const a2 = normalizeDeg(aEndDeg);
let delta = a2 - a1;
if (delta < 0) delta += 360;
// 计算中间角度,自动判断是否需要反向
const aMidDeg = normalizeDeg(a1 + delta / 2);
const needReverse = aMidDeg > 90 && aMidDeg < 270;
const largeArc = delta > 180 ? 1 : 0;
// 保持路径完整,不在这里应用 padding
// padding 通过字体大小计算和 textPath 的 startOffset/text-anchor 来实现
if (needReverse) {
// 反向路径(从结束点到起始点),保持文字头朝外
const p1 = polarToXY(a2, adjustedRMid);
const p2 = polarToXY(a1, adjustedRMid);
return `M ${p1.x} ${p1.y} A ${adjustedRMid} ${adjustedRMid} 0 ${largeArc} 0 ${p2.x} ${p2.y}`;
} else {
// 正向路径(从起始点到结束点)
const p1 = polarToXY(a1, adjustedRMid);
const p2 = polarToXY(a2, adjustedRMid);
return `M ${p1.x} ${p1.y} A ${adjustedRMid} ${adjustedRMid} 0 ${largeArc} 1 ${p2.x} ${p2.y}`;
}
}
/**
* 生成竖排文字路径(径向方向)
* 用于宽度小于高度的扇区
* @param rInner 内半径
* @param rOuter 外半径
* @param aStartDeg 起始角度(度)
* @param aEndDeg 结束角度(度)
* @param aMidDeg 中心角度
* @param textRadialPosition 文字径向位置:'centroid'(形心)或 'middle'(中点,默认)
* @param fontSize 字体大小
* @param textLength 文字字符数用于计算路径长度默认4
* @returns SVG path 字符串(径向直线)
*/
/**
* 生成竖排文字路径(径向方向)
* 用于宽度小于高度的扇区
* @param rInner 内半径
* @param rOuter 外半径
* @param aStartDeg 起始角度(度)
* @param aEndDeg 结束角度(度)
* @param textRadialPosition 文字径向位置:'centroid'(形心)或 'middle'(中点,默认)
* @returns SVG 路径字符串(直线)
*/
export function generateVerticalTextPath(
rInner: number,
rOuter: number,
aStartDeg: number,
aEndDeg: number,
textRadialPosition: 'centroid' | 'middle' = 'middle'
): string {
// 计算中间角度
const a1 = normalizeDeg(aStartDeg);
const a2 = normalizeDeg(aEndDeg);
let delta = a2 - a1;
if (delta < 0) delta += 360;
const aMidDeg = normalizeDeg(a1 + delta / 2);
// 根据配置选择径向位置
let rMid: number;
if (textRadialPosition === 'centroid') {
// 计算形心半径
const centroid = annularSectorCentroid({ rInner, rOuter, aStartDeg, aEndDeg });
rMid = centroid.rho;
} else {
// 使用几何中点
rMid = (rInner + rOuter) / 2;
}
// 计算径向高度和字体大小
const radialHeight = rOuter - rInner;
// 计算字体大小(竖排)
const tempLength = 2; // 先假设2个字
const tempFontSize = calculateSectorFontSize(rInner, rOuter, aStartDeg, aEndDeg, tempLength, 3, 20, true);
// 根据字体大小决定实际字符数
const textLength = calculateVerticalTextLength(rInner, rOuter, tempFontSize);
// 用实际字符数重新计算字体大小
const fontSize = calculateSectorFontSize(rInner, rOuter, aStartDeg, aEndDeg, textLength, 3, 20, true);
// 竖排文字路径:根据扇区特点选择合适的起始位置
// 计算实际需要的路径长度:字符数 × 字符间距系数 × 字体大小
const requiredPathLength = textLength * TEXT_LAYOUT_CONFIG.CHAR_SPACING_RATIO * fontSize;
// 确保路径不超出扇区边界(考虑径向 padding
const maxPathLength = radialHeight * TEXT_LAYOUT_CONFIG.RADIAL_PADDING_RATIO;
const actualPathLength = Math.min(requiredPathLength, maxPathLength);
let finalStartR: number;
let finalEndR: number;
// 对于从圆心开始的扇区(rInner=0),形心会偏向外侧
// 需要特殊处理以防止溢出
if (rInner === 0) {
// 计算路径应该在哪里结束(从外圆向内)
// 使用形心位置作为参考,但确保路径从外圆附近开始
const halfPath = actualPathLength / 2;
// 如果使用形心居中,检查是否会溢出
if (rMid + halfPath > rOuter) {
// 会溢出:改为从外圆向内延伸
finalStartR = rOuter;
finalEndR = rOuter - actualPathLength;
} else {
// 不会溢出:使用形心居中
finalStartR = rMid + halfPath;
finalEndR = rMid - halfPath;
}
} else {
// 普通扇区:以 rMid 为中心
const halfPathLength = actualPathLength / 2;
// 确保不超出边界
const safeHalfPath = Math.min(
halfPathLength,
rMid - rInner,
rOuter - rMid
);
finalStartR = rMid + safeHalfPath;
finalEndR = rMid - safeHalfPath;
}
const p1 = polarToXY(aMidDeg, finalStartR);
const p2 = polarToXY(aMidDeg, finalEndR);
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%)'
* @returns 文字颜色(深色或浅色)
*/
export function getTextColorForBackground(backgroundColor: string): string {
// 从 HSL 字符串中提取亮度值
const match = backgroundColor.match(/hsl\([^)]+\s+(\d+)%\)/);
if (!match) return '#111827'; // 默认深色
const lightness = parseInt(match[1]);
// 亮度阈值50%
// 亮度低于50%使用白色文字,否则使用深色文字
return lightness < 50 ? '#ffffff' : '#111827';
}
/**
* 计算扇区的合适字体大小
* 精确根据扇区尺寸和文字长度计算,确保文字不溢出
* @param rInner 内半径
* @param rOuter 外半径
* @param aStartDeg 起始角度(度)
* @param aEndDeg 结束角度(度)
* @param textLength 文字长度(字符数)
* @param minFontSize 最小字体大小(可选,默认 6
* @param maxFontSize 最大字体大小(可选,默认 28
* @param isVertical 是否竖排文字(可选,默认 false
* @returns 计算后的字体大小
*/
export function calculateSectorFontSize(
rInner: number,
rOuter: number,
aStartDeg: number,
aEndDeg: number,
textLength: number = 1,
minFontSize: number = TEXT_LAYOUT_CONFIG.FONT_SIZE.MIN,
maxFontSize: number = TEXT_LAYOUT_CONFIG.FONT_SIZE.MAX,
isVertical: boolean = false
): number {
if (textLength === 0) return minFontSize;
// 计算径向宽度(圆环宽度)
const radialWidth = rOuter - rInner;
// 计算角度跨度
const a1 = normalizeDeg(aStartDeg);
const a2 = normalizeDeg(aEndDeg);
let deltaDeg = a2 - a1;
if (deltaDeg < 0) deltaDeg += 360;
let calculatedSize: number;
if (isVertical) {
// 竖排文字:沿径向排列,从外圆向内圆
// 约束1径向高度所有字符的总高度
const availableHeight = radialWidth * TEXT_LAYOUT_CONFIG.RADIAL_PADDING_RATIO;
const maxByHeight = availableHeight / (textLength * TEXT_LAYOUT_CONFIG.CHAR_SPACING_RATIO);
// 约束2最内侧字符的弧长宽度这是最严格的宽度限制
// 最内侧字符的中心位置大约在 rInner + fontSize/2 处
// 保守估计:假设字体大小约为径向宽度的一半
const estimatedFontSize = radialWidth * 0.5;
const innerMostRadius = rInner + estimatedFontSize / 2;
const innerArcLength = (innerMostRadius * deltaDeg * Math.PI) / 180;
// 字符宽度约为 fontSize × 1.0(方块字)
const availableArcLength = innerArcLength * TEXT_LAYOUT_CONFIG.TANGENT_PADDING_RATIO;
const maxByWidth = availableArcLength / 1.0; // 单个字符宽度
// 取两个约束中的较小值
calculatedSize = Math.min(maxByHeight, maxByWidth);
} else {
// 横排文字:同时受径向宽度和弧长限制
// 计算形心半径处的弧长
const centroid = annularSectorCentroid({ rInner, rOuter, aStartDeg, aEndDeg });
const rMid = centroid.rho;
const arcLength = (rMid * deltaDeg * Math.PI) / 180;
// 1. 高度约束:字体高度不能超过径向宽度
const maxByHeight = radialWidth * TEXT_LAYOUT_CONFIG.RADIAL_PADDING_RATIO;
// 2. 宽度约束:根据文字总宽度计算
// 中文字符宽度 = fontSize方块字
// 字符间距:约 0.1 * fontSize总占用约 1.1 * fontSize
const availableArcLength = arcLength * TEXT_LAYOUT_CONFIG.TANGENT_PADDING_RATIO;
// 反推字体大小fontSize = 可用弧长 / (字符数 × 1.1)
const maxByWidth = availableArcLength / (textLength * 1.1);
// 3. 取宽度和高度约束中较小的那个(更严格的限制)
calculatedSize = Math.min(maxByHeight, maxByWidth);
}
// 对于横排小角度的扇区,需要额外限制(竖排不需要此限制)
if (!isVertical) {
// 当角度很小时,弧线弯曲明显,文字更容易溢出
const { TINY_THRESHOLD, TINY_SCALE, SMALL_THRESHOLD, SMALL_SCALE } = TEXT_LAYOUT_CONFIG.SMALL_ANGLE_SCALE;
if (deltaDeg < TINY_THRESHOLD) {
calculatedSize *= TINY_SCALE;
} else if (deltaDeg < SMALL_THRESHOLD) {
calculatedSize *= SMALL_SCALE;
}
}
// 限制在合理范围内
const finalSize = Math.max(minFontSize, Math.min(maxFontSize, calculatedSize));
return Math.round(finalSize * 10) / 10; // 保留一位小数
}
/**
* 根据扇区径向高度决定竖排文字应显示的字符数
* @param rInner 内半径
* @param rOuter 外半径
* @param fontSize 字体大小
* @returns 应显示的字符数
*/
export function calculateVerticalTextLength(
rInner: number,
rOuter: number,
fontSize: number
): number {
// 从配置中读取字符数范围
const { MIN_CHARS, MAX_CHARS } = TEXT_LAYOUT_CONFIG.VERTICAL_TEXT;
// 计算径向可用高度
const radialHeight = rOuter - rInner;
// 考虑上下padding可用高度约为总高度的配置比例
const availableHeight = radialHeight * TEXT_LAYOUT_CONFIG.RADIAL_PADDING_RATIO;
// 计算可以容纳的字符数
const maxFittableChars = Math.floor(availableHeight / (fontSize * TEXT_LAYOUT_CONFIG.CHAR_SPACING_RATIO));
// 限制在 [MIN_CHARS, MAX_CHARS] 范围内
const charCount = Math.max(MIN_CHARS, Math.min(MAX_CHARS, maxFittableChars));
return charCount;
}
/**
* 生成单个扇区的完整数据
* @param params 扇区参数
* @returns 扇区数据
*/
export function generateSectorData(params: {
layerIndex: number;
pieIndex: number;
rInner: number;
rOuter: number;
aStart: number;
aEnd: number;
layerCount: number;
pieCount: number;
textRadialPosition: 'centroid' | 'middle';
}): any {
const { layerIndex, pieIndex, rInner, rOuter, aStart, aEnd, layerCount, pieCount, textRadialPosition } = params;
const deltaDeg = aEnd - aStart;
const c = annularSectorCentroid({ rInner, rOuter, aStartDeg: aStart, aEndDeg: aEnd });
// 计算扇区尺寸
const radialHeight = rOuter - rInner;
const arcWidth = (c.rho * deltaDeg * Math.PI) / 180;
// 判断是否需要竖排
const isVertical = arcWidth < radialHeight;
// 计算文字长度和字体大小
let textLength: number;
let sectorFontSize: number;
if (isVertical) {
// 竖排逻辑
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 {
// 横排逻辑
const { RADIAL_PADDING_RATIO, CHAR_SPACING_RATIO, TANGENT_PADDING_RATIO } = TEXT_LAYOUT_CONFIG;
const { MIN_CHARS, MAX_CHARS } = TEXT_LAYOUT_CONFIG.HORIZONTAL_TEXT;
const estimatedFontSize = radialHeight * RADIAL_PADDING_RATIO;
const charWidth = estimatedFontSize * CHAR_SPACING_RATIO;
const availableWidth = arcWidth * TANGENT_PADDING_RATIO;
const maxChars = Math.floor(availableWidth / charWidth);
textLength = Math.max(MIN_CHARS, Math.min(MAX_CHARS, maxChars));
sectorFontSize = calculateSectorFontSize(rInner, rOuter, aStart, aEnd, textLength);
}
const label = '测'.repeat(textLength);
const textPathId = `text-path-L${layerIndex}-P${pieIndex}`;
// 最内层使用形心位置
const isInnermostLayer = layerIndex === 0;
const effectiveTextRadialPosition = isInnermostLayer ? 'centroid' : textRadialPosition;
// 生成文字路径(路径方向已经在函数内部自动处理)
const textPath = isVertical
? generateVerticalTextPath(rInner, rOuter, aStart, aEnd, effectiveTextRadialPosition)
: generateTextPath(rInner, rOuter, aStart, aEnd, effectiveTextRadialPosition);
// 生成颜色
const fillColor = generateSectorColor(layerIndex, pieIndex, layerCount, pieCount);
const textColor = getTextColorForBackground(fillColor);
// 内部填色逻辑
const shouldFill = (pieIndex + layerIndex) % 3 === 0;
const innerFillPath = shouldFill ? annularSectorInsetPath(rInner, rOuter, aStart, aEnd, 1) : undefined;
const innerFillColor = shouldFill ? fillColor : undefined;
const baseFillColor = shouldFill ? '#ffffff' : fillColor;
const finalTextColor = shouldFill ? '#111827' : textColor;
return {
key: `L${layerIndex}-P${pieIndex}`,
layerIndex,
pieIndex,
rInner,
rOuter,
aStart,
aEnd,
aMidDeg: c.aMidDeg,
aMidRad: c.aMidRad,
cx: c.cx,
cy: c.cy,
fill: baseFillColor,
textColor: finalTextColor,
label,
path: annularSectorPath(rInner, rOuter, aStart, aEnd),
innerFillPath,
innerFillColor,
textPath,
textPathId,
isVertical,
fontSize: sectorFontSize,
};
}