update at 2026-01-21 21:48:08
This commit is contained in:
264
src/utils.ts
264
src/utils.ts
@@ -6,6 +6,43 @@
|
||||
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°在北(上方),顺时针为正
|
||||
@@ -195,21 +232,6 @@ export function annularSectorInsetPath(
|
||||
].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 内半径
|
||||
@@ -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 aEndDeg 结束角度(度)
|
||||
* @param textRadialPosition 文字径向位置:'centroid'(形心)或 'middle'(中点,默认)
|
||||
* @param textLength 文字字符数(可选,用于路径长度估算)
|
||||
* @param fontSize 字体大小(可选,用于路径长度估算)
|
||||
* @returns SVG 路径字符串(直线)
|
||||
*/
|
||||
export function generateVerticalTextPath(
|
||||
@@ -297,7 +308,9 @@ export function generateVerticalTextPath(
|
||||
rOuter: number,
|
||||
aStartDeg: number,
|
||||
aEndDeg: number,
|
||||
textRadialPosition: 'centroid' | 'middle' = 'middle'
|
||||
textRadialPosition: 'centroid' | 'middle' = 'middle',
|
||||
textLength?: number,
|
||||
fontSize?: number
|
||||
): string {
|
||||
// 计算中间角度
|
||||
const a1 = normalizeDeg(aStartDeg);
|
||||
@@ -317,22 +330,59 @@ export function generateVerticalTextPath(
|
||||
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);
|
||||
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 = textLength * TEXT_LAYOUT_CONFIG.CHAR_SPACING_RATIO * fontSize;
|
||||
const requiredPathLength =
|
||||
effectiveTextLength * TEXT_LAYOUT_CONFIG.CHAR_SPACING_RATIO * effectiveFontSize;
|
||||
|
||||
// 确保路径不超出扇区边界(考虑径向 padding)
|
||||
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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成扇区颜色
|
||||
* @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 文字颜色(深色或浅色)
|
||||
*/
|
||||
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';
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -530,7 +550,7 @@ export function calculateSectorFontSize(
|
||||
* @param fontSize 字体大小
|
||||
* @returns 应显示的字符数
|
||||
*/
|
||||
export function calculateVerticalTextLength(
|
||||
function calculateVerticalTextLength(
|
||||
rInner: number,
|
||||
rOuter: number,
|
||||
fontSize: number
|
||||
@@ -565,11 +585,23 @@ export function generateSectorData(params: {
|
||||
rOuter: number;
|
||||
aStart: number;
|
||||
aEnd: number;
|
||||
layerCount: number;
|
||||
pieCount: number;
|
||||
textRadialPosition: 'centroid' | 'middle';
|
||||
fill?: string;
|
||||
textColor?: string;
|
||||
label?: string;
|
||||
}): 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 c = annularSectorCentroid({ rInner, rOuter, aStartDeg: aStart, aEndDeg: aEnd });
|
||||
@@ -580,32 +612,42 @@ export function generateSectorData(params: {
|
||||
|
||||
// 判断是否需要竖排
|
||||
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) {
|
||||
// 竖排逻辑
|
||||
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);
|
||||
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 {
|
||||
// 横排逻辑
|
||||
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));
|
||||
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 label = '测'.repeat(textLength);
|
||||
const finalLabel = providedLabel ?? '测'.repeat(textLength);
|
||||
|
||||
const textPathId = `text-path-L${layerIndex}-P${pieIndex}`;
|
||||
|
||||
@@ -615,12 +657,20 @@ export function generateSectorData(params: {
|
||||
|
||||
// 生成文字路径(路径方向已经在函数内部自动处理)
|
||||
const textPath = isVertical
|
||||
? generateVerticalTextPath(rInner, rOuter, aStart, aEnd, effectiveTextRadialPosition)
|
||||
? generateVerticalTextPath(
|
||||
rInner,
|
||||
rOuter,
|
||||
aStart,
|
||||
aEnd,
|
||||
effectiveTextRadialPosition,
|
||||
textLength,
|
||||
sectorFontSize
|
||||
)
|
||||
: generateTextPath(rInner, rOuter, aStart, aEnd, effectiveTextRadialPosition);
|
||||
|
||||
// 生成颜色
|
||||
const fillColor = generateSectorColor(layerIndex, pieIndex, layerCount, pieCount);
|
||||
const textColor = getTextColorForBackground(fillColor);
|
||||
const fillColor = fill ?? DEFAULT_SECTOR_FILL;
|
||||
const baseTextColor = textColor ?? getTextColorForBackground(fillColor);
|
||||
|
||||
// 内部填色逻辑
|
||||
const shouldFill = (pieIndex + layerIndex) % 3 === 0;
|
||||
@@ -628,7 +678,7 @@ export function generateSectorData(params: {
|
||||
const innerFillColor = shouldFill ? fillColor : undefined;
|
||||
|
||||
const baseFillColor = shouldFill ? '#ffffff' : fillColor;
|
||||
const finalTextColor = shouldFill ? '#111827' : textColor;
|
||||
const finalTextColor = shouldFill ? TEXT_ON_LIGHT : baseTextColor;
|
||||
|
||||
return {
|
||||
key: `L${layerIndex}-P${pieIndex}`,
|
||||
@@ -644,7 +694,7 @@ export function generateSectorData(params: {
|
||||
cy: c.cy,
|
||||
fill: baseFillColor,
|
||||
textColor: finalTextColor,
|
||||
label,
|
||||
label: finalLabel,
|
||||
path: annularSectorPath(rInner, rOuter, aStart, aEnd),
|
||||
innerFillPath,
|
||||
innerFillColor,
|
||||
|
||||
Reference in New Issue
Block a user