/** * 罗盘工具函数 * 所有函数都是纯函数,便于测试 */ 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` 圆弧标记位 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, }; }