first commit
This commit is contained in:
340
src/Luopan.vue
Normal file
340
src/Luopan.vue
Normal 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>
|
||||
106
src/composables/useLuopan.ts
Normal file
106
src/composables/useLuopan.ts
Normal 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
128
src/constants.ts
Normal 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
36
src/index.ts
Normal 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
98
src/types.ts
Normal 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
656
src/utils.ts
Normal file
@@ -0,0 +1,656 @@
|
||||
/**
|
||||
* 罗盘工具函数
|
||||
* 所有函数都是纯函数,便于测试
|
||||
*/
|
||||
|
||||
import type { PolarPoint, AnnularSectorParams, CentroidResult } from './types';
|
||||
import { TEXT_LAYOUT_CONFIG } from './constants';
|
||||
|
||||
/**
|
||||
* 极坐标转 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(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算文字旋转角度
|
||||
* - 文字沿径向方向: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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user