update at 2026-01-21 21:48:08

This commit is contained in:
douboer@gmail.com
2026-01-21 21:48:08 +08:00
parent 54f3fd93f2
commit 78d8801a07
8 changed files with 184 additions and 271 deletions

View File

@@ -206,7 +206,7 @@ const currentExample = computed(() => examples[exampleIndex.value]);
/**
* 使用罗盘逻辑
*/
const { anglesDeg, rings, outerMost, sectors, getLabelTransform, toXY } =
const { anglesDeg, rings, outerMost, sectors, toXY } =
useLuopan(currentExample, textRadialPosition);
/**

View File

@@ -6,7 +6,6 @@ import { computed, type Ref, type ComputedRef } from 'vue';
import type { Example, Sector, TextRadialPosition } from '../types';
import {
polarToXY,
calculateLabelRotation,
generateSectorData,
} from '../utils';
@@ -64,8 +63,6 @@ export function useLuopan(
rOuter,
aStart,
aEnd,
layerCount,
pieCount,
textRadialPosition: textRadialPositionRef.value,
});
@@ -75,16 +72,6 @@ export function useLuopan(
return res;
});
/**
* 计算标签的变换属性
* @param s 扇区数据
* @returns SVG transform 字符串
*/
const getLabelTransform = (s: Sector): string => {
const rotDeg = calculateLabelRotation(s.aMidDeg);
return `translate(${s.cx} ${s.cy}) rotate(${rotDeg})`;
};
/**
* 极坐标转 XY暴露给模板使用
*/
@@ -95,7 +82,6 @@ export function useLuopan(
rings,
outerMost,
sectors,
getLabelTransform,
toXY,
};
}

View File

@@ -21,8 +21,6 @@ export {
annularSectorCentroid,
annularSectorPath,
annularSectorInsetPath,
calculateLabelRotation,
generateSectorColor,
generateTextPath,
generateVerticalTextPath,
getTextColorForBackground,

View File

@@ -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 坐标
* 约定:角度 aDeg0°在北上方顺时针为正
@@ -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,