707 lines
22 KiB
TypeScript
707 lines
22 KiB
TypeScript
/**
|
||
* 罗盘工具函数
|
||
* 所有函数都是纯函数,便于测试
|
||
*/
|
||
|
||
import type { PolarPoint, AnnularSectorParams, CentroidResult } from './types';
|
||
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 坐标
|
||
* 约定:角度 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(" ");
|
||
}
|
||
|
||
/**
|
||
* 生成文字路径的圆弧(用于 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 textRadialPosition 文字径向位置:'centroid'(形心)或 'middle'(中点,默认)
|
||
* @param textLength 文字字符数(可选,用于路径长度估算)
|
||
* @param fontSize 字体大小(可选,用于路径长度估算)
|
||
* @returns SVG 路径字符串(直线)
|
||
*/
|
||
export function generateVerticalTextPath(
|
||
rInner: number,
|
||
rOuter: number,
|
||
aStartDeg: number,
|
||
aEndDeg: number,
|
||
textRadialPosition: 'centroid' | 'middle' = 'middle',
|
||
textLength?: number,
|
||
fontSize?: number
|
||
): 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;
|
||
|
||
let resolvedTextLength = textLength;
|
||
let resolvedFontSize = fontSize;
|
||
const hasTextLength = typeof resolvedTextLength === 'number';
|
||
const hasFontSize = typeof resolvedFontSize === 'number';
|
||
|
||
if (!hasTextLength && !hasFontSize) {
|
||
const tempLength = 2;
|
||
const tempFontSize = calculateSectorFontSize(
|
||
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 =
|
||
effectiveTextLength * TEXT_LAYOUT_CONFIG.CHAR_SPACING_RATIO * effectiveFontSize;
|
||
|
||
// 确保路径不超出扇区边界(考虑径向 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 backgroundColor 颜色字符串,如 '#ffffff' 或 'hsl(180 70% 48%)'
|
||
* @returns 文字颜色(深色或浅色)
|
||
*/
|
||
export function getTextColorForBackground(backgroundColor: string): string {
|
||
const hex = parseHexColor(backgroundColor);
|
||
if (hex) {
|
||
const lum = relativeLuminance(hex.r, hex.g, hex.b);
|
||
return lum < 0.5 ? TEXT_ON_DARK : TEXT_ON_LIGHT;
|
||
}
|
||
|
||
const match = backgroundColor.match(/hsl\([^)]+\s+(\d+)%\)/i);
|
||
if (!match) return TEXT_ON_LIGHT;
|
||
|
||
const lightness = parseInt(match[1], 10);
|
||
return lightness < 50 ? TEXT_ON_DARK : TEXT_ON_LIGHT;
|
||
}
|
||
|
||
/**
|
||
* 计算扇区的合适字体大小
|
||
* 精确根据扇区尺寸和文字长度计算,确保文字不溢出
|
||
* @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 应显示的字符数
|
||
*/
|
||
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;
|
||
textRadialPosition: 'centroid' | 'middle';
|
||
fill?: string;
|
||
textColor?: string;
|
||
label?: string;
|
||
}): any {
|
||
const {
|
||
layerIndex,
|
||
pieIndex,
|
||
rInner,
|
||
rOuter,
|
||
aStart,
|
||
aEnd,
|
||
textRadialPosition,
|
||
fill,
|
||
textColor,
|
||
label,
|
||
} = 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;
|
||
|
||
const providedLabel = typeof label === 'string' && label.length > 0 ? label : undefined;
|
||
const providedLength = providedLabel ? providedLabel.length : undefined;
|
||
|
||
// 计算文字长度和字体大小
|
||
let textLength: number;
|
||
let sectorFontSize: number;
|
||
|
||
if (isVertical) {
|
||
if (typeof providedLength === 'number') {
|
||
textLength = providedLength;
|
||
sectorFontSize = calculateSectorFontSize(rInner, rOuter, aStart, aEnd, textLength, 3, 20, true);
|
||
} else {
|
||
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 {
|
||
if (typeof providedLength === 'number') {
|
||
textLength = providedLength;
|
||
} 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 finalLabel = providedLabel ?? '测'.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,
|
||
textLength,
|
||
sectorFontSize
|
||
)
|
||
: generateTextPath(rInner, rOuter, aStart, aEnd, effectiveTextRadialPosition);
|
||
|
||
// 生成颜色
|
||
const fillColor = fill ?? DEFAULT_SECTOR_FILL;
|
||
const baseTextColor = 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 ? TEXT_ON_LIGHT : baseTextColor;
|
||
|
||
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: finalLabel,
|
||
path: annularSectorPath(rInner, rOuter, aStart, aEnd),
|
||
innerFillPath,
|
||
innerFillColor,
|
||
textPath,
|
||
textPathId,
|
||
isVertical,
|
||
fontSize: sectorFontSize,
|
||
};
|
||
}
|