first commit
This commit is contained in:
656
src/utils.ts
Normal file
656
src/utils.ts
Normal file
@@ -0,0 +1,656 @@
|
||||
/**
|
||||
* 罗盘工具函数
|
||||
* 所有函数都是纯函数,便于测试
|
||||
*/
|
||||
|
||||
import type { PolarPoint, AnnularSectorParams, CentroidResult } from './types';
|
||||
import { TEXT_LAYOUT_CONFIG } from './constants';
|
||||
|
||||
/**
|
||||
* 极坐标转 SVG 坐标
|
||||
* 约定:角度 aDeg:0°在北(上方),顺时针为正
|
||||
* 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) + 中心方向角 aMid(deg/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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user