first commit

This commit is contained in:
douboer
2026-01-21 13:22:26 +08:00
commit 24452838a1
28 changed files with 7901 additions and 0 deletions

340
src/Luopan.vue Normal file
View File

@@ -0,0 +1,340 @@
<template>
<div class="luopan-wrap">
<!-- 工具栏 -->
<div class="toolbar">
<button
v-for="(ex, idx) in examples"
:key="idx"
:class="{ active: idx === exampleIndex }"
@click="exampleIndex = idx"
>
示例 {{ idx + 1 }}{{ ex.name }}
</button>
<label class="toggle">
<input type="checkbox" v-model="showGuides" />
显示辅助线
</label>
<label class="toggle">
<select v-model="textRadialPosition">
<option value="middle">文字位置中点</option>
<option value="centroid">文字位置形心</option>
</select>
</label>
<div class="zoom-controls">
<button @click="zoomOut" :disabled="scale <= 0.5" title="缩小"></button>
<span class="zoom-level">{{ Math.round(scale * 100) }}%</span>
<button @click="zoomIn" :disabled="scale >= 5" title="放大">+</button>
<button @click="resetZoom" title="重置">重置</button>
</div>
</div>
<!-- SVG 画布容器 -->
<div
class="svg-container"
@wheel.prevent="handleWheel"
@mousedown="handleMouseDown"
@mousemove="handleMouseMove"
@mouseup="handleMouseUp"
@mouseleave="handleMouseUp"
>
<svg
:width="size"
:height="size"
:viewBox="`${-size / 2} ${-size / 2} ${size} ${size}`"
class="svg"
:style="{
transform: `scale(${scale}) translate(${panX}px, ${panY}px)`,
cursor: isDragging ? 'grabbing' : 'grab'
}"
>
<!-- 背景 -->
<rect
:x="-size / 2"
:y="-size / 2"
:width="size"
:height="size"
fill="white"
/>
<!-- 扇区 -->
<g>
<path
v-for="s in sectors"
:key="s.key"
:d="s.path"
:fill="s.fill"
stroke="#1f2937"
stroke-opacity="0.15"
:stroke-width="SECTOR_STROKE_WIDTH"
/>
</g>
<!-- 内部填色区域 -->
<g>
<template v-for="s in sectors" :key="s.key + '-inner'">
<path
v-if="s.innerFillPath"
:d="s.innerFillPath"
:fill="s.innerFillColor"
fill-opacity="0.6"
stroke="none"
/>
</template>
</g>
<!-- 辅助线圆环分度线 -->
<g v-if="showGuides" stroke="#111827" stroke-opacity="0.18">
<!-- 圆环 -->
<circle
v-for="r in rings"
:key="'ring-' + r"
:r="r"
fill="none"
:stroke-width="SECTOR_STROKE_WIDTH"
/>
<!-- 径向线 -->
<line
v-for="a in anglesDeg"
:key="'ang-' + a"
:x1="0"
:y1="0"
:x2="toXY(a, outerMost).x"
:y2="toXY(a, outerMost).y"
:stroke-width="SECTOR_STROKE_WIDTH"
/>
</g>
<!-- 定义文字路径 -->
<defs>
<path
v-for="s in sectors"
:key="s.textPathId"
:id="s.textPathId"
:d="s.textPath"
fill="none"
/>
</defs>
<!-- 文字标签沿圆弧排列 -->
<g>
<text
v-for="s in sectors"
:key="s.key + '-label'"
:font-size="s.fontSize"
:fill="s.textColor"
:writing-mode="s.isVertical ? 'tb' : undefined"
:glyph-orientation-vertical="s.isVertical ? '0' : undefined"
:text-anchor="s.isVertical ? 'middle' : undefined"
style="user-select: none"
>
<textPath
:href="'#' + s.textPathId"
startOffset="50%"
text-anchor="middle"
dominant-baseline="central"
>
{{ s.label }}
</textPath>
</text>
<!-- 可选画一个小点看形心位置 -->
<circle
v-if="showGuides"
v-for="s in sectors"
:key="s.key + '-center'"
:cx="s.cx"
:cy="s.cy"
r="2.2"
fill="#ef4444"
opacity="0.8"
/>
</g>
</svg>
</div>
<!-- 说明 -->
<div class="note">
<div>角度约定0°在北上方顺时针为正SVG 坐标 y 向下</div>
<div>文字方向沿圆弧排列带有弧度头朝外</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useLuopan } from './composables/useLuopan';
import { EXAMPLES, DEFAULT_SIZE, DEFAULT_TEXT_RADIAL_POSITION, SECTOR_STROKE_WIDTH } from './constants';
import type { TextRadialPosition } from './types';
/**
* Props
*/
interface Props {
size?: number;
}
const props = withDefaults(defineProps<Props>(), {
size: DEFAULT_SIZE,
});
/**
* 状态
*/
const showGuides = ref(true);
const exampleIndex = ref(0);
const examples = EXAMPLES;
const textRadialPosition = ref<TextRadialPosition>(DEFAULT_TEXT_RADIAL_POSITION);
// 缩放和平移状态
const scale = ref(1);
const panX = ref(0);
const panY = ref(0);
const isDragging = ref(false);
const dragStartX = ref(0);
const dragStartY = ref(0);
const dragStartPanX = ref(0);
const dragStartPanY = ref(0);
/**
* 当前示例
*/
const currentExample = computed(() => examples[exampleIndex.value]);
/**
* 使用罗盘逻辑
*/
const { anglesDeg, rings, outerMost, sectors, getLabelTransform, toXY } =
useLuopan(currentExample, textRadialPosition);
/**
* 缩放功能
*/
const zoomIn = () => {
if (scale.value < 5) {
scale.value = Math.min(5, scale.value + 0.2);
}
};
const zoomOut = () => {
if (scale.value > 0.5) {
scale.value = Math.max(0.5, scale.value - 0.2);
}
};
const resetZoom = () => {
scale.value = 1;
panX.value = 0;
panY.value = 0;
};
const handleWheel = (e: WheelEvent) => {
const delta = e.deltaY > 0 ? -0.1 : 0.1;
const newScale = Math.max(0.5, Math.min(5, scale.value + delta));
scale.value = newScale;
};
const handleMouseDown = (e: MouseEvent) => {
isDragging.value = true;
dragStartX.value = e.clientX;
dragStartY.value = e.clientY;
dragStartPanX.value = panX.value;
dragStartPanY.value = panY.value;
};
const handleMouseMove = (e: MouseEvent) => {
if (isDragging.value) {
const dx = (e.clientX - dragStartX.value) / scale.value;
const dy = (e.clientY - dragStartY.value) / scale.value;
panX.value = dragStartPanX.value + dx;
panY.value = dragStartPanY.value + dy;
}
};
const handleMouseUp = () => {
isDragging.value = false;
};
</script>
<style scoped>
.luopan-wrap {
display: grid;
gap: 12px;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto,
Helvetica, Arial;
}
.toolbar {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
button {
padding: 6px 10px;
border: 1px solid #d1d5db;
border-radius: 10px;
background: #fff;
cursor: pointer;
transition: all 0.2s;
}
button:hover {
background: #f9fafb;
}
button.active {
border-color: #111827;
background: #f3f4f6;
}
.toggle {
margin-left: 10px;
display: inline-flex;
gap: 8px;
align-items: center;
color: #374151;
font-size: 14px;
cursor: pointer;
}
.zoom-controls {
margin-left: auto;
display: flex;
gap: 8px;
align-items: center;
}
.zoom-level {
min-width: 50px;
text-align: center;
font-size: 13px;
color: #374151;
font-weight: 500;
}
.svg-container {
border: 1px solid #e5e7eb;
border-radius: 14px;
background: #f9fafb;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.svg {
background: #fff;
transition: transform 0.1s ease-out;
user-select: none;
}
.note {
color: #6b7280;
font-size: 13px;
line-height: 1.4;
}
</style>

View File

@@ -0,0 +1,106 @@
/**
* 罗盘业务逻辑组合函数
*/
import { computed, type Ref, type ComputedRef } from 'vue';
import type { Example, Sector, TextRadialPosition } from '../types';
import {
polarToXY,
calculateLabelRotation,
generateSectorData,
} from '../utils';
/**
* 罗盘逻辑 Hook
* @param exampleRef 当前示例的响应式引用
* @param textRadialPositionRef 文字径向位置的响应式引用
* @returns 罗盘相关的计算属性和方法
*/
export function useLuopan(
exampleRef: Ref<Example>,
textRadialPositionRef: Ref<TextRadialPosition>
) {
/**
* 角度分割点列表
*/
const anglesDeg = computed(() => exampleRef.value.angles);
/**
* 圆环半径列表
*/
const rings = computed(() => exampleRef.value.radii);
/**
* 最外层半径
*/
const outerMost = computed(() => {
const radii = exampleRef.value.radii;
return radii[radii.length - 1];
});
/**
* 生成所有扇区数据
*/
const sectors = computed<Sector[]>(() => {
const res: Sector[] = [];
const A = exampleRef.value.angles;
const R = exampleRef.value.radii;
const layerCount = R.length;
const pieCount = A.length - 1;
for (let j = 0; j < layerCount; j++) {
const rInner = j === 0 ? 0 : R[j - 1];
const rOuter = R[j];
for (let i = 0; i < pieCount; i++) {
const aStart = A[i];
const aEnd = A[i + 1];
const sector = generateSectorData({
layerIndex: j,
pieIndex: i,
rInner,
rOuter,
aStart,
aEnd,
layerCount,
pieCount,
textRadialPosition: textRadialPositionRef.value,
});
res.push(sector);
}
}
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暴露给模板使用
*/
const toXY = polarToXY;
return {
anglesDeg,
rings,
outerMost,
sectors,
getLabelTransform,
toXY,
};
}
/**
* 返回类型定义
*/
export type UseLuopanReturn = ReturnType<typeof useLuopan>;

128
src/constants.ts Normal file
View File

@@ -0,0 +1,128 @@
/**
* 常量和示例配置
*/
import type { Example, TextRadialPosition } from './types';
/**
* 默认 SVG 画布大小
*/
export const DEFAULT_SIZE = 520;
/**
* 默认文字径向位置
*/
export const DEFAULT_TEXT_RADIAL_POSITION: TextRadialPosition = 'middle';
/**
* 扇区填色内缩距离(像素)
*/
export const SECTOR_INSET_DISTANCE = 1;
/**
* 扇区边界线宽度(像素)
*/
export const SECTOR_STROKE_WIDTH = 0.3;
/**
* 文字布局配置常量
* 用于计算扇区内文字的字体大小和位置
*/
export const TEXT_LAYOUT_CONFIG = {
/** 字符间距系数:字符实际占用高度 = fontSize × 此系数(包含字符本身+行间距) */
CHAR_SPACING_RATIO: 1.2,
/** 径向留白比例在径向方向圆环宽度方向预留的空白比例0.8 表示文字占用 80%,留白 20% */
RADIAL_PADDING_RATIO: 0.9,
/** 切向留白比例在切向方向沿弧线方向预留的空白比例0.85 表示文字占用 85%,留白 15% */
TANGENT_PADDING_RATIO: 0.9,
/** 小角度扇区字体缩放配置:角度较小时弧线弯曲明显,需要缩小字体以防止视觉溢出 */
SMALL_ANGLE_SCALE: {
/** 极小角度阈值(度):小于此角度时应用极小缩放 */
TINY_THRESHOLD: 15,
/** 极小角度缩放比例:字体大小乘以此比例 */
TINY_SCALE: 0.7,
/** 小角度阈值(度):介于极小和此值之间时应用小角度缩放 */
SMALL_THRESHOLD: 30,
/** 小角度缩放比例:字体大小乘以此比例 */
SMALL_SCALE: 0.85,
},
/** 字体大小限制 */
FONT_SIZE: {
/** 最小字体大小(像素) */
MIN: 2,
/** 最大字体大小(像素) */
MAX: 20,
},
/** 竖排文字配置 */
VERTICAL_TEXT: {
/** 最少字符数 */
MIN_CHARS: 1,
/** 最多字符数 */
MAX_CHARS: 4,
},
/** 横排文字配置 */
HORIZONTAL_TEXT: {
/** 最少字符数 */
MIN_CHARS: 1,
/** 最多字符数 */
MAX_CHARS: 6,
},
} as const;
/**
* 预设示例数据
*/
export const EXAMPLES: Example[] = [
{
name: "12 等分 × 3 层圆环",
angles: Array.from({ length: 13 }, (_, i) => i * 30), // 0..360
radii: [60, 120, 180],
},
{
name: "不等分 × 4 层圆环",
angles: [0, 18, 55, 92, 140, 175, 225, 260, 305, 360],
radii: [50, 95, 140, 190],
},
{
name: "24 等分 × 2 层(更密)",
angles: Array.from({ length: 25 }, (_, i) => i * 15), // 0..360
radii: [100, 190],
},
{
name: "8 等分 × 5 层圆环",
angles: Array.from({ length: 9 }, (_, i) => i * 45), // 0..360
radii: [40, 75, 110, 145, 180],
},
{
name: "16 等分 × 4 层",
angles: Array.from({ length: 17 }, (_, i) => i * 22.5), // 0..360
radii: [50, 95, 140, 185],
},
{
name: "24 等分 × 6 层(密集)",
angles: Array.from({ length: 25 }, (_, i) => i * 15), // 0..360
radii: [30, 60, 90, 120, 150, 180],
},
{
name: "36 等分 × 3 层(精细)",
angles: Array.from({ length: 37 }, (_, i) => i * 10), // 0..360
radii: [70, 135, 200],
},
{
name: "6 等分 × 8 层(多层)",
angles: Array.from({ length: 7 }, (_, i) => i * 60), // 0..360
radii: [25, 48, 71, 94, 117, 140, 163, 186],
},
{
name: "24 等分 × 31 层",
angles: Array.from({ length: 25 }, (_, i) => i * 360/24), // 0..360
radii: Array.from({ length: 31 }, (_, i) => 15 + i * 6), // 10, 16, 22, ..., 190
},
];

36
src/index.ts Normal file
View File

@@ -0,0 +1,36 @@
/**
* 罗盘组件库入口文件
*/
// 类型导出
export type {
Example,
Sector,
PolarPoint,
AnnularSectorParams,
CentroidResult,
} from './types';
// 组件导出
export { default as Luopan } from './Luopan.vue';
// 工具函数导出
export {
polarToXY,
normalizeDeg,
annularSectorCentroid,
annularSectorPath,
annularSectorInsetPath,
calculateLabelRotation,
generateSectorColor,
generateTextPath,
generateVerticalTextPath,
getTextColorForBackground,
} from './utils';
// Composables 导出
export { useLuopan } from './composables/useLuopan';
export type { UseLuopanReturn } from './composables/useLuopan';
// 常量导出
export { EXAMPLES, DEFAULT_SIZE, SECTOR_INSET_DISTANCE } from './constants';

98
src/types.ts Normal file
View File

@@ -0,0 +1,98 @@
/**
* 罗盘类型定义
*/
/**
* 文字径向位置
*/
export type TextRadialPosition = 'centroid' | 'middle';
/**
* 示例配置
*/
export interface Example {
/** 示例名称 */
name: string;
/** 角度分割点北为0顺时针必须从0开始并以360结束 */
angles: number[];
/** 圆环半径列表(从中心到外:比如 [40, 80, 120] 表示3层第一层内半径=0 */
radii: number[];
}
/**
* 扇区配置
*/
export interface Sector {
/** 唯一标识 */
key: string;
/** 层索引 */
layerIndex: number;
/** 扇区索引 */
pieIndex: number;
/** 内半径 */
rInner: number;
/** 外半径 */
rOuter: number;
/** 起始角度(度) */
aStart: number;
/** 结束角度(度) */
aEnd: number;
/** 中间角度(度) */
aMidDeg: number;
/** 中间角度(弧度) */
aMidRad: number;
/** 形心 x 坐标 */
cx: number;
/** 形心 y 坐标 */
cy: number;
/** 填充颜色 */
fill: string;
/** 文字颜色 */
textColor: string;
/** 标签文本 */
label: string;
/** SVG 路径 */
path: string;
/** 内缩填色路径(可选)*/
innerFillPath?: string;
/** 填色颜色(可选)*/
innerFillColor?: string;
/** 文字路径 */
textPath: string;
/** 文字路径ID */
textPathId: string;
/** 字体大小(根据扇区尺寸动态计算) */
fontSize: number;
/** 是否竖排文字 */
isVertical: boolean;
}
/**
* 极坐标点
*/
export interface PolarPoint {
x: number;
y: number;
}
/**
* 圆环扇形参数
*/
export interface AnnularSectorParams {
rInner: number;
rOuter: number;
aStartDeg: number;
aEndDeg: number;
}
/**
* 圆环扇形形心结果
*/
export interface CentroidResult {
cx: number;
cy: number;
rho: number;
aMidDeg: number;
aMidRad: number;
deltaDeg: number;
}

656
src/utils.ts Normal file
View File

@@ -0,0 +1,656 @@
/**
* 罗盘工具函数
* 所有函数都是纯函数,便于测试
*/
import type { PolarPoint, AnnularSectorParams, CentroidResult } from './types';
import { TEXT_LAYOUT_CONFIG } from './constants';
/**
* 极坐标转 SVG 坐标
* 约定:角度 aDeg0°在北上方顺时针为正
* 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) + 中心方向角 aMiddeg/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,
};
}