update at 2026-01-22 18:43:01
This commit is contained in:
386
src/Luopan.vue
386
src/Luopan.vue
@@ -2,15 +2,6 @@
|
||||
<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" />
|
||||
显示辅助线
|
||||
@@ -31,8 +22,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="status">加载中...</div>
|
||||
<div v-else-if="error" class="status">错误: {{ error.message }}</div>
|
||||
|
||||
<!-- SVG 画布容器 -->
|
||||
<div
|
||||
<div
|
||||
v-else
|
||||
class="svg-container"
|
||||
@wheel.prevent="handleWheel"
|
||||
@mousedown="handleMouseDown"
|
||||
@@ -43,7 +38,7 @@
|
||||
<svg
|
||||
:width="size"
|
||||
:height="size"
|
||||
:viewBox="`${-size / 2} ${-size / 2} ${size} ${size}`"
|
||||
:viewBox="`${viewBoxMin} ${viewBoxMin} ${viewBoxSize} ${viewBoxSize}`"
|
||||
class="svg"
|
||||
:style="{
|
||||
transform: `scale(${scale}) translate(${panX}px, ${panY}px)`,
|
||||
@@ -52,41 +47,142 @@
|
||||
>
|
||||
<!-- 背景 -->
|
||||
<rect
|
||||
:x="-size / 2"
|
||||
:y="-size / 2"
|
||||
:width="size"
|
||||
:height="size"
|
||||
fill="white"
|
||||
:x="viewBoxMin"
|
||||
:y="viewBoxMin"
|
||||
:width="viewBoxSize"
|
||||
:height="viewBoxSize"
|
||||
:fill="config?.background || '#ffffff'"
|
||||
/>
|
||||
|
||||
<!-- 扇区 -->
|
||||
<g>
|
||||
<path
|
||||
<g v-memo="[sectors]">
|
||||
<!-- 扇区 -->
|
||||
<g>
|
||||
<path
|
||||
v-for="s in sectors"
|
||||
:key="s.key"
|
||||
:d="s.path"
|
||||
:fill="s.fill"
|
||||
stroke="#1f2937"
|
||||
:stroke-opacity="s.groupSplitVisible === false ? 0 : 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>
|
||||
|
||||
<!-- 定义文字路径 -->
|
||||
<defs>
|
||||
<path
|
||||
v-for="s in sectors"
|
||||
:key="s.textPathId"
|
||||
:id="s.textPathId"
|
||||
:d="s.textPath"
|
||||
fill="none"
|
||||
/>
|
||||
<template v-for="s in sectors" :key="s.key + '-units'">
|
||||
<path
|
||||
v-for="unit in s.textUnits || []"
|
||||
:key="unit.textPathId"
|
||||
:id="unit.textPathId"
|
||||
:d="unit.textPath"
|
||||
fill="none"
|
||||
/>
|
||||
</template>
|
||||
</defs>
|
||||
|
||||
<!-- 文字标签(沿圆弧排列) -->
|
||||
<g>
|
||||
<template v-for="s in sectors" :key="s.key + '-text'">
|
||||
<template v-if="s.textUnits">
|
||||
<g v-for="unit in s.textUnits || []" :key="unit.textPathId">
|
||||
<text
|
||||
v-if="!unit.isSvg"
|
||||
:font-size="unit.fontSize"
|
||||
:fill="s.textColor"
|
||||
:writing-mode="unit.isVertical ? 'tb' : undefined"
|
||||
:glyph-orientation-vertical="unit.isVertical ? '0' : undefined"
|
||||
:text-anchor="unit.isVertical ? 'middle' : undefined"
|
||||
style="user-select: none"
|
||||
>
|
||||
<textPath
|
||||
:href="'#' + unit.textPathId"
|
||||
startOffset="50%"
|
||||
text-anchor="middle"
|
||||
dominant-baseline="central"
|
||||
>
|
||||
{{ unit.content }}
|
||||
</textPath>
|
||||
</text>
|
||||
<image
|
||||
v-else
|
||||
:href="unit.svgPath"
|
||||
:x="getUnitSvgBox(s, unit).x"
|
||||
:y="getUnitSvgBox(s, unit).y"
|
||||
:width="getUnitSvgBox(s, unit).size"
|
||||
:height="getUnitSvgBox(s, unit).size"
|
||||
:opacity="s.textColor ? 1 : 1"
|
||||
/>
|
||||
</g>
|
||||
</template>
|
||||
<template v-else>
|
||||
<text
|
||||
v-if="!s.isSvgContent"
|
||||
: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>
|
||||
<image
|
||||
v-else
|
||||
:href="s.svgPath"
|
||||
:x="getSectorSvgBox(s).x"
|
||||
:y="getSectorSvgBox(s).y"
|
||||
:width="getSectorSvgBox(s).size"
|
||||
:height="getSectorSvgBox(s).size"
|
||||
/>
|
||||
</template>
|
||||
|
||||
</template>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- 形心点(仅辅助线开启时显示) -->
|
||||
<g v-if="showGuides" v-memo="[sectors]">
|
||||
<circle
|
||||
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"
|
||||
:key="s.key + '-center'"
|
||||
:cx="s.cx"
|
||||
:cy="s.cy"
|
||||
r="2.2"
|
||||
fill="#ef4444"
|
||||
opacity="0.8"
|
||||
/>
|
||||
</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">
|
||||
<g v-if="showGuides" v-memo="[rings, anglesDeg, outerMost]" stroke="#111827" stroke-opacity="0.18">
|
||||
<!-- 圆环 -->
|
||||
<circle
|
||||
v-for="r in rings"
|
||||
@@ -107,49 +203,69 @@
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- 定义文字路径 -->
|
||||
<defs>
|
||||
<path
|
||||
v-for="s in sectors"
|
||||
:key="s.textPathId"
|
||||
:id="s.textPathId"
|
||||
:d="s.textPath"
|
||||
<!-- 刻度环 -->
|
||||
<g v-if="degreeRing" v-memo="[degreeRing]">
|
||||
<defs>
|
||||
<path
|
||||
v-for="label in degreeRing.labels || []"
|
||||
:key="label.textPathId"
|
||||
:id="label.textPathId"
|
||||
:d="label.textPath"
|
||||
fill="none"
|
||||
/>
|
||||
</defs>
|
||||
<circle
|
||||
:r="degreeRing.ring.rOuter"
|
||||
fill="none"
|
||||
:stroke="degreeRing.ring.color"
|
||||
:stroke-opacity="degreeRing.ring.opacity"
|
||||
:stroke-width="SECTOR_STROKE_WIDTH"
|
||||
/>
|
||||
<circle
|
||||
:r="degreeRing.ring.rInner"
|
||||
fill="none"
|
||||
:stroke="degreeRing.ring.color"
|
||||
:stroke-opacity="degreeRing.ring.opacity"
|
||||
:stroke-width="SECTOR_STROKE_WIDTH"
|
||||
/>
|
||||
<line
|
||||
v-for="tick in degreeRing.ticks"
|
||||
:key="'tick-' + tick.angle + '-' + tick.startR + '-' + tick.endR"
|
||||
:x1="tick.x1"
|
||||
:y1="tick.y1"
|
||||
:x2="tick.x2"
|
||||
:y2="tick.y2"
|
||||
:stroke="degreeRing.tickColor"
|
||||
:stroke-width="SECTOR_STROKE_WIDTH"
|
||||
/>
|
||||
</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"
|
||||
v-for="label in degreeRing.labels || []"
|
||||
:key="'degree-' + label.angle"
|
||||
:fill="degreeRing.tickColor"
|
||||
:font-size="label.fontSize"
|
||||
style="user-select: none"
|
||||
>
|
||||
<textPath
|
||||
:href="'#' + s.textPathId"
|
||||
:href="'#' + label.textPathId"
|
||||
startOffset="50%"
|
||||
text-anchor="middle"
|
||||
dominant-baseline="central"
|
||||
>
|
||||
{{ s.label }}
|
||||
{{ label.text }}
|
||||
</textPath>
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- 可选:画一个小点看形心位置 -->
|
||||
<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 v-if="centerIcon" v-memo="[centerIcon]">
|
||||
<image
|
||||
:href="centerIcon.svgPath"
|
||||
:width="centerIcon.rIcon * 2"
|
||||
:height="centerIcon.rIcon * 2"
|
||||
:x="-centerIcon.rIcon"
|
||||
:y="-centerIcon.rIcon"
|
||||
:opacity="centerIcon.opacity"
|
||||
:transform="`rotate(${centerIcon.rotation})`"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
@@ -166,90 +282,168 @@
|
||||
<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';
|
||||
import { DEFAULT_SIZE, DEFAULT_TEXT_RADIAL_POSITION, SECTOR_STROKE_WIDTH } from './constants';
|
||||
import type { LuopanConfig, Sector, TextRadialPosition, TextUnit } from './types';
|
||||
import { annularSectorCentroid } from './utils';
|
||||
|
||||
/**
|
||||
* Props
|
||||
*/
|
||||
interface Props {
|
||||
size?: number;
|
||||
configPath?: string;
|
||||
config?: LuopanConfig;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: DEFAULT_SIZE,
|
||||
configPath: '/demo.json',
|
||||
});
|
||||
|
||||
/**
|
||||
* 状态
|
||||
*/
|
||||
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);
|
||||
let nextScale = scale.value;
|
||||
let nextPanX = panX.value;
|
||||
let nextPanY = panY.value;
|
||||
let rafId: number | null = null;
|
||||
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 resolveConfigPath = () => {
|
||||
if (typeof window === 'undefined') return props.configPath;
|
||||
const param = new URLSearchParams(window.location.search).get('config');
|
||||
return param ? `/${param}` : props.configPath;
|
||||
};
|
||||
|
||||
const configInput = props.config ?? resolveConfigPath();
|
||||
|
||||
/**
|
||||
* 使用罗盘逻辑
|
||||
*/
|
||||
const { anglesDeg, rings, outerMost, sectors, toXY } =
|
||||
useLuopan(currentExample, textRadialPosition);
|
||||
const {
|
||||
config,
|
||||
sectors,
|
||||
degreeRing,
|
||||
centerIcon,
|
||||
anglesDeg,
|
||||
rings,
|
||||
outerMost,
|
||||
toXY,
|
||||
loading,
|
||||
error,
|
||||
} = useLuopan(configInput, textRadialPosition);
|
||||
|
||||
// viewBox 以实际外半径为准,确保完整显示配置中的大半径罗盘
|
||||
const viewBoxSize = computed(() => {
|
||||
const radius = outerMost.value > 0 ? outerMost.value : props.size / 2;
|
||||
return radius * 2;
|
||||
});
|
||||
|
||||
const viewBoxMin = computed(() => -viewBoxSize.value / 2);
|
||||
|
||||
// 使用 rAF 合并缩放/拖拽更新,减少渲染频率
|
||||
const scheduleTransform = () => {
|
||||
if (rafId !== null) return;
|
||||
const requestFrame =
|
||||
typeof requestAnimationFrame === 'function'
|
||||
? requestAnimationFrame
|
||||
: (cb: FrameRequestCallback) => setTimeout(() => cb(Date.now()), 16);
|
||||
|
||||
rafId = requestFrame(() => {
|
||||
scale.value = nextScale;
|
||||
panX.value = nextPanX;
|
||||
panY.value = nextPanY;
|
||||
rafId = null;
|
||||
});
|
||||
};
|
||||
|
||||
const setScale = (value: number) => {
|
||||
nextScale = Math.max(0.5, Math.min(5, value));
|
||||
scheduleTransform();
|
||||
};
|
||||
|
||||
const setPan = (x: number, y: number) => {
|
||||
nextPanX = x;
|
||||
nextPanY = y;
|
||||
scheduleTransform();
|
||||
};
|
||||
|
||||
const getUnitSvgBox = (sector: Sector, unit: TextUnit) => {
|
||||
// SVG 图标与文字共享布局规则,使用单元角度范围计算形心位置
|
||||
const centroid = annularSectorCentroid({
|
||||
rInner: sector.rInner,
|
||||
rOuter: sector.rOuter,
|
||||
aStartDeg: unit.aStart,
|
||||
aEndDeg: unit.aEnd,
|
||||
});
|
||||
const size = unit.fontSize ?? sector.fontSize;
|
||||
return {
|
||||
x: centroid.cx - size / 2,
|
||||
y: centroid.cy - size / 2,
|
||||
size,
|
||||
};
|
||||
};
|
||||
|
||||
const getSectorSvgBox = (sector: Sector) => {
|
||||
const size = sector.fontSize;
|
||||
return {
|
||||
x: sector.cx - size / 2,
|
||||
y: sector.cy - size / 2,
|
||||
size,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 缩放功能
|
||||
*/
|
||||
const zoomIn = () => {
|
||||
if (scale.value < 5) {
|
||||
scale.value = Math.min(5, scale.value + 0.2);
|
||||
setScale(nextScale + 0.2);
|
||||
}
|
||||
};
|
||||
|
||||
const zoomOut = () => {
|
||||
if (scale.value > 0.5) {
|
||||
scale.value = Math.max(0.5, scale.value - 0.2);
|
||||
setScale(nextScale - 0.2);
|
||||
}
|
||||
};
|
||||
|
||||
const resetZoom = () => {
|
||||
scale.value = 1;
|
||||
panX.value = 0;
|
||||
panY.value = 0;
|
||||
nextScale = 1;
|
||||
nextPanX = 0;
|
||||
nextPanY = 0;
|
||||
scheduleTransform();
|
||||
};
|
||||
|
||||
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;
|
||||
setScale(nextScale + delta);
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
isDragging.value = true;
|
||||
dragStartX.value = e.clientX;
|
||||
dragStartY.value = e.clientY;
|
||||
dragStartPanX.value = panX.value;
|
||||
dragStartPanY.value = panY.value;
|
||||
dragStartPanX.value = nextPanX;
|
||||
dragStartPanY.value = nextPanY;
|
||||
};
|
||||
|
||||
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 dx = (e.clientX - dragStartX.value) / nextScale;
|
||||
const dy = (e.clientY - dragStartY.value) / nextScale;
|
||||
setPan(dragStartPanX.value + dx, dragStartPanY.value + dy);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -326,6 +520,14 @@ button.active {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.svg {
|
||||
background: #fff;
|
||||
transition: transform 0.1s ease-out;
|
||||
|
||||
19
src/centerIcon.ts
Normal file
19
src/centerIcon.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { CenterIconConfig, CenterIconData } from './types';
|
||||
|
||||
const ensureTrailingSlash = (input: string): string =>
|
||||
input.endsWith('/') ? input : `${input}/`;
|
||||
|
||||
export async function loadCenterIcon(
|
||||
config: CenterIconConfig,
|
||||
svgIconPath: string = 'src/assets/icons/'
|
||||
): Promise<CenterIconData> {
|
||||
const basePath = ensureTrailingSlash(svgIconPath);
|
||||
|
||||
// 保持异步签名,便于后续替换为实际加载/解析逻辑
|
||||
return {
|
||||
rIcon: config.rIcon,
|
||||
opacity: config.opacity,
|
||||
svgPath: `${basePath}${config.name}`,
|
||||
rotation: config.rotation ?? 0,
|
||||
};
|
||||
}
|
||||
71
src/colorResolver.ts
Normal file
71
src/colorResolver.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { ThemeConfig, SectorLayerConfig, SectorConfig } from './types';
|
||||
|
||||
const HEX_COLOR_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
||||
|
||||
export const applyPatternColoring = (
|
||||
divisions: number,
|
||||
color: string,
|
||||
num: number,
|
||||
interval: number
|
||||
): Map<number, string> => {
|
||||
const colorMap = new Map<number, string>();
|
||||
|
||||
if (divisions <= 0 || num <= 0) return colorMap;
|
||||
|
||||
if (interval === 0) {
|
||||
for (let i = 0; i < divisions; i++) {
|
||||
colorMap.set(i, color);
|
||||
}
|
||||
return colorMap;
|
||||
}
|
||||
|
||||
let currentIndex = 0;
|
||||
while (currentIndex < divisions) {
|
||||
for (let i = 0; i < num && currentIndex < divisions; i++) {
|
||||
colorMap.set(currentIndex, color);
|
||||
currentIndex++;
|
||||
}
|
||||
currentIndex += interval;
|
||||
}
|
||||
|
||||
return colorMap;
|
||||
};
|
||||
|
||||
export class ColorResolver {
|
||||
private theme: ThemeConfig;
|
||||
private background: string;
|
||||
|
||||
constructor(theme: ThemeConfig, background: string) {
|
||||
this.theme = theme;
|
||||
this.background = background;
|
||||
}
|
||||
|
||||
resolveColor(ref?: string): string {
|
||||
if (!ref) return this.background;
|
||||
if (HEX_COLOR_RE.test(ref)) return ref;
|
||||
return this.theme.colorPalettes[ref] ?? this.background;
|
||||
}
|
||||
|
||||
resolveLayerColors(layer: SectorLayerConfig): Map<number, string> {
|
||||
const colorMap = new Map<number, string>();
|
||||
|
||||
if (!layer.colorRef || !layer.num) {
|
||||
return colorMap;
|
||||
}
|
||||
|
||||
const interval = layer.interval ?? 0;
|
||||
const color = this.resolveColor(layer.colorRef);
|
||||
return applyPatternColoring(layer.divisions, color, layer.num, interval);
|
||||
}
|
||||
|
||||
resolveSectorColor(
|
||||
layerColorMap: Map<number, string>,
|
||||
sector: SectorConfig | undefined,
|
||||
sectorIndex: number
|
||||
): string {
|
||||
if (sector?.colorRef) {
|
||||
return this.resolveColor(sector.colorRef);
|
||||
}
|
||||
return layerColorMap.get(sectorIndex) ?? this.background;
|
||||
}
|
||||
}
|
||||
@@ -2,87 +2,142 @@
|
||||
* 罗盘业务逻辑组合函数
|
||||
*/
|
||||
|
||||
import { computed, type Ref, type ComputedRef } from 'vue';
|
||||
import type { Example, Sector, TextRadialPosition } from '../types';
|
||||
import {
|
||||
polarToXY,
|
||||
generateSectorData,
|
||||
} from '../utils';
|
||||
import { computed, ref, readonly, watch, type Ref } from 'vue';
|
||||
import type {
|
||||
CenterIconData,
|
||||
DegreeRingData,
|
||||
LayerConfig,
|
||||
LuopanConfig,
|
||||
Sector,
|
||||
SectorLayerConfig,
|
||||
TextRadialPosition,
|
||||
} from '../types';
|
||||
import { polarToXY } from '../utils';
|
||||
import { parseConfig } from '../configParser';
|
||||
import { ColorResolver } from '../colorResolver';
|
||||
import { SectorBuilder } from '../sectorBuilder';
|
||||
import { buildDegreeRing } from '../degreeRing';
|
||||
import { loadCenterIcon } from '../centerIcon';
|
||||
|
||||
const isSectorLayer = (layer: LayerConfig): layer is SectorLayerConfig =>
|
||||
layer.type !== 'centerIcon' && layer.type !== 'degreeRing';
|
||||
|
||||
const findDegreeRingLayer = (layers: LayerConfig[]) =>
|
||||
layers.find((layer) => layer.type === 'degreeRing');
|
||||
|
||||
const findCenterIconLayer = (layers: LayerConfig[]) =>
|
||||
layers.find((layer) => layer.type === 'centerIcon');
|
||||
|
||||
/**
|
||||
* 罗盘逻辑 Hook
|
||||
* @param exampleRef 当前示例的响应式引用
|
||||
* @param textRadialPositionRef 文字径向位置的响应式引用
|
||||
* @param configPathOrObject 配置文件路径或配置对象
|
||||
* @param textRadialPositionRef 文字径向位置的响应式引用(可选)
|
||||
* @returns 罗盘相关的计算属性和方法
|
||||
*/
|
||||
export function useLuopan(
|
||||
exampleRef: Ref<Example>,
|
||||
textRadialPositionRef: Ref<TextRadialPosition>
|
||||
configPathOrObject: string | LuopanConfig,
|
||||
textRadialPositionRef?: Ref<TextRadialPosition>
|
||||
) {
|
||||
/**
|
||||
* 角度分割点列表
|
||||
*/
|
||||
const anglesDeg = computed(() => exampleRef.value.angles);
|
||||
const config = ref<LuopanConfig | null>(null);
|
||||
const sectors = ref<Sector[]>([]);
|
||||
const degreeRing = ref<DegreeRingData | null>(null);
|
||||
const centerIcon = ref<CenterIconData | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref<Error | null>(null);
|
||||
|
||||
/**
|
||||
* 圆环半径列表
|
||||
*/
|
||||
const rings = computed(() => exampleRef.value.radii);
|
||||
const textRadialPosition = computed(
|
||||
() => textRadialPositionRef?.value ?? 'middle'
|
||||
);
|
||||
|
||||
/**
|
||||
* 最外层半径
|
||||
*/
|
||||
const outerMost = computed(() => {
|
||||
const radii = exampleRef.value.radii;
|
||||
return radii[radii.length - 1];
|
||||
});
|
||||
const buildSectors = (configObj: LuopanConfig) => {
|
||||
const resolver = new ColorResolver(configObj.theme, configObj.background);
|
||||
const builder = new SectorBuilder(resolver, {
|
||||
textRadialPosition: textRadialPosition.value,
|
||||
});
|
||||
const sectorLayers = configObj.layers.filter(isSectorLayer);
|
||||
return sectorLayers.flatMap((layer, index) => builder.buildLayer(layer, index));
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成所有扇区数据
|
||||
*/
|
||||
const sectors = computed<Sector[]>(() => {
|
||||
const res: Sector[] = [];
|
||||
const A = exampleRef.value.angles;
|
||||
const R = exampleRef.value.radii;
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
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,
|
||||
textRadialPosition: textRadialPositionRef.value,
|
||||
});
|
||||
|
||||
res.push(sector);
|
||||
let configObj: LuopanConfig;
|
||||
if (typeof configPathOrObject === 'string') {
|
||||
const jsonText = await fetch(configPathOrObject).then((res) => res.text());
|
||||
configObj = parseConfig(jsonText);
|
||||
} else {
|
||||
configObj = configPathOrObject;
|
||||
}
|
||||
|
||||
config.value = configObj;
|
||||
sectors.value = buildSectors(configObj);
|
||||
|
||||
const degreeRingLayer = findDegreeRingLayer(configObj.layers);
|
||||
degreeRing.value = degreeRingLayer
|
||||
? buildDegreeRing(degreeRingLayer.degreeRing)
|
||||
: null;
|
||||
|
||||
const centerIconLayer = findCenterIconLayer(configObj.layers);
|
||||
centerIcon.value = centerIconLayer
|
||||
? await loadCenterIcon(centerIconLayer.centerIcon)
|
||||
: null;
|
||||
} catch (err) {
|
||||
error.value = err as Error;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 文字位置切换后仅重建扇区
|
||||
watch(textRadialPosition, () => {
|
||||
if (config.value) {
|
||||
sectors.value = buildSectors(config.value);
|
||||
}
|
||||
return res;
|
||||
});
|
||||
|
||||
/**
|
||||
* 极坐标转 XY(暴露给模板使用)
|
||||
*/
|
||||
const toXY = polarToXY;
|
||||
loadConfig();
|
||||
|
||||
const sectorLayers = computed(() =>
|
||||
config.value ? config.value.layers.filter(isSectorLayer) : []
|
||||
);
|
||||
|
||||
const rings = computed(() => sectorLayers.value.map((layer) => layer.rOuter));
|
||||
|
||||
const anglesDeg = computed(() => {
|
||||
const firstLayer = sectorLayers.value[0];
|
||||
if (!firstLayer || firstLayer.divisions <= 0) return [];
|
||||
|
||||
const step = 360 / firstLayer.divisions;
|
||||
const start = firstLayer.startAngle ?? 0;
|
||||
return Array.from({ length: firstLayer.divisions + 1 }, (_, i) => start + i * step);
|
||||
});
|
||||
|
||||
const outerMost = computed(() => {
|
||||
if (!config.value) return 0;
|
||||
if (typeof config.value.outerRadius === 'number') return config.value.outerRadius;
|
||||
|
||||
const radii = sectorLayers.value.map((layer) => layer.rOuter);
|
||||
const degreeRingLayer = findDegreeRingLayer(config.value.layers);
|
||||
if (degreeRingLayer) {
|
||||
radii.push(degreeRingLayer.degreeRing.rOuter);
|
||||
}
|
||||
return radii.length > 0 ? Math.max(...radii) : 0;
|
||||
});
|
||||
|
||||
return {
|
||||
config: readonly(config),
|
||||
sectors: readonly(sectors),
|
||||
degreeRing: readonly(degreeRing),
|
||||
centerIcon: readonly(centerIcon),
|
||||
anglesDeg,
|
||||
rings,
|
||||
outerMost,
|
||||
sectors,
|
||||
toXY,
|
||||
toXY: polarToXY,
|
||||
loading: readonly(loading),
|
||||
error: readonly(error),
|
||||
reload: loadConfig,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
83
src/configParser.ts
Normal file
83
src/configParser.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { LuopanConfig, ThemeConfig } from './types';
|
||||
|
||||
const isObject = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
|
||||
const assertCondition = (condition: boolean, message: string): void => {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
};
|
||||
|
||||
export const stripJsonComments = (input: string): string => {
|
||||
let output = '';
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const char = input[i];
|
||||
const next = input[i + 1];
|
||||
|
||||
if (!inString && char === '-' && next === '-') {
|
||||
while (i < input.length && input[i] !== '\n') {
|
||||
i++;
|
||||
}
|
||||
if (i < input.length) {
|
||||
output += '\n';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' && !escaped) {
|
||||
inString = !inString;
|
||||
}
|
||||
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (char === '\\' && inString) {
|
||||
escaped = true;
|
||||
}
|
||||
|
||||
output += char;
|
||||
}
|
||||
|
||||
return output;
|
||||
};
|
||||
|
||||
const normalizeTheme = (theme: Record<string, unknown>): ThemeConfig => {
|
||||
const palettes = theme.colorPalettes;
|
||||
assertCondition(isObject(palettes), 'theme.colorPalettes 必须为对象');
|
||||
return {
|
||||
name: typeof theme.name === 'string' ? theme.name : undefined,
|
||||
colorPalettes: palettes,
|
||||
};
|
||||
};
|
||||
|
||||
export const parseConfig = (jsonText: string): LuopanConfig => {
|
||||
const cleanText = stripJsonComments(jsonText);
|
||||
let parsed: unknown;
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(cleanText);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '未知错误';
|
||||
throw new Error(`配置解析失败: ${message}`);
|
||||
}
|
||||
|
||||
assertCondition(isObject(parsed), '配置必须为对象');
|
||||
|
||||
const config = parsed as Record<string, unknown>;
|
||||
assertCondition(typeof config.name === 'string', 'name 为必填字符串');
|
||||
assertCondition(typeof config.background === 'string', 'background 为必填字符串');
|
||||
assertCondition(isObject(config.theme), 'theme 为必填对象');
|
||||
assertCondition(Array.isArray(config.layers), 'layers 为必填数组');
|
||||
|
||||
return {
|
||||
name: config.name,
|
||||
description: typeof config.description === 'string' ? config.description : undefined,
|
||||
background: config.background,
|
||||
outerRadius: typeof config.outerRadius === 'number' ? config.outerRadius : undefined,
|
||||
theme: normalizeTheme(config.theme),
|
||||
layers: config.layers as LuopanConfig['layers'],
|
||||
};
|
||||
};
|
||||
137
src/degreeRing.ts
Normal file
137
src/degreeRing.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import type { DegreeRingConfig, TickMark, DegreeLabel, DegreeRingData } from './types';
|
||||
import { generateTextPath, polarToXY } from './utils';
|
||||
|
||||
// 根据刻度级别计算长度,后续由 clamp 处理最小值
|
||||
const resolveTickLength = (config: DegreeRingConfig, type: TickMark['type']): number => {
|
||||
const step = config.tickLengthStep ?? 0;
|
||||
if (type === 'major') return config.tickLength;
|
||||
if (type === 'minor') return config.tickLength - step;
|
||||
return config.tickLength - 2 * step;
|
||||
};
|
||||
|
||||
const clampTickLength = (value: number): number => (value < 1 ? 1 : value);
|
||||
|
||||
export function buildDegreeRing(config: DegreeRingConfig): DegreeRingData {
|
||||
const ticks: TickMark[] = [];
|
||||
const { rInner, rOuter, mode } = config;
|
||||
const labelFontSize = 8;
|
||||
|
||||
const majorTick = Math.max(1, config.majorTick);
|
||||
const minorTick = Math.max(1, config.minorTick);
|
||||
const microTick = Math.max(1, config.microTick);
|
||||
|
||||
for (let angle = 0; angle < 360; angle++) {
|
||||
let type: TickMark['type'] | null = null;
|
||||
|
||||
if (angle % majorTick === 0) {
|
||||
type = 'major';
|
||||
} else if (angle % minorTick === 0) {
|
||||
type = 'minor';
|
||||
} else if (angle % microTick === 0) {
|
||||
type = 'micro';
|
||||
}
|
||||
|
||||
if (!type) continue;
|
||||
|
||||
const length = clampTickLength(resolveTickLength(config, type));
|
||||
|
||||
// 预计算坐标,避免渲染时重复三角计算
|
||||
if (mode === 'inner') {
|
||||
const start = polarToXY(angle, rInner);
|
||||
const end = polarToXY(angle, rInner + length);
|
||||
ticks.push({
|
||||
angle,
|
||||
type,
|
||||
length,
|
||||
startR: rInner,
|
||||
endR: rInner + length,
|
||||
x1: start.x,
|
||||
y1: start.y,
|
||||
x2: end.x,
|
||||
y2: end.y,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mode === 'outer') {
|
||||
const start = polarToXY(angle, rOuter - length);
|
||||
const end = polarToXY(angle, rOuter);
|
||||
ticks.push({
|
||||
angle,
|
||||
type,
|
||||
length,
|
||||
startR: rOuter - length,
|
||||
endR: rOuter,
|
||||
x1: start.x,
|
||||
y1: start.y,
|
||||
x2: end.x,
|
||||
y2: end.y,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// both: 同角度生成内外两条刻度线
|
||||
const innerStart = polarToXY(angle, rInner);
|
||||
const innerEnd = polarToXY(angle, rInner + length);
|
||||
ticks.push({
|
||||
angle,
|
||||
type,
|
||||
length,
|
||||
startR: rInner,
|
||||
endR: rInner + length,
|
||||
x1: innerStart.x,
|
||||
y1: innerStart.y,
|
||||
x2: innerEnd.x,
|
||||
y2: innerEnd.y,
|
||||
});
|
||||
const outerStart = polarToXY(angle, rOuter - length);
|
||||
const outerEnd = polarToXY(angle, rOuter);
|
||||
ticks.push({
|
||||
angle,
|
||||
type,
|
||||
length,
|
||||
startR: rOuter - length,
|
||||
endR: rOuter,
|
||||
x1: outerStart.x,
|
||||
y1: outerStart.y,
|
||||
x2: outerEnd.x,
|
||||
y2: outerEnd.y,
|
||||
});
|
||||
}
|
||||
|
||||
const labels: DegreeLabel[] = [];
|
||||
if (config.showDegree === 1) {
|
||||
for (let angle = 0; angle < 360; angle += majorTick) {
|
||||
const r = (rInner + rOuter) / 2;
|
||||
const text = angle.toString();
|
||||
const estimatedArcLength = text.length * labelFontSize * 1.1;
|
||||
const estimatedSpan = r > 0 ? (estimatedArcLength / r) * (180 / Math.PI) : 0;
|
||||
const maxSpan = Math.max(majorTick * 0.9, 4);
|
||||
const span = Math.min(Math.max(estimatedSpan, 4), maxSpan);
|
||||
const aStart = angle - span / 2;
|
||||
const aEnd = angle + span / 2;
|
||||
|
||||
// 使用 textPath 保持度数方向与扇区文字一致
|
||||
labels.push({
|
||||
angle,
|
||||
text,
|
||||
r,
|
||||
fontSize: labelFontSize,
|
||||
textPathId: `degree-label-${angle}`,
|
||||
textPath: generateTextPath(r, r, aStart, aEnd, 'middle'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ticks,
|
||||
tickColor: config.tickColor,
|
||||
ring: {
|
||||
rInner,
|
||||
rOuter,
|
||||
color: config.ringColor,
|
||||
opacity: config.opacity,
|
||||
},
|
||||
labels: labels.length > 0 ? labels : undefined,
|
||||
};
|
||||
}
|
||||
21
src/index.ts
21
src/index.ts
@@ -5,6 +5,17 @@
|
||||
// 类型导出
|
||||
export type {
|
||||
Example,
|
||||
LuopanConfig,
|
||||
ThemeConfig,
|
||||
CenterIconConfig,
|
||||
DegreeRingConfig,
|
||||
DegreeRingData,
|
||||
DegreeLabel,
|
||||
CenterIconData,
|
||||
LayerConfig,
|
||||
SectorConfig,
|
||||
TextUnit,
|
||||
TickMark,
|
||||
Sector,
|
||||
PolarPoint,
|
||||
AnnularSectorParams,
|
||||
@@ -32,3 +43,13 @@ export type { UseLuopanReturn } from './composables/useLuopan';
|
||||
|
||||
// 常量导出
|
||||
export { EXAMPLES, DEFAULT_SIZE, SECTOR_INSET_DISTANCE } from './constants';
|
||||
|
||||
// 配置解析导出
|
||||
export { parseConfig, stripJsonComments } from './configParser';
|
||||
|
||||
// 解析工具导出
|
||||
export { ColorResolver, applyPatternColoring } from './colorResolver';
|
||||
export { splitMultiTextUnits } from './multiTextParser';
|
||||
export { SectorBuilder } from './sectorBuilder';
|
||||
export { buildDegreeRing } from './degreeRing';
|
||||
export { loadCenterIcon } from './centerIcon';
|
||||
|
||||
41
src/multiTextParser.ts
Normal file
41
src/multiTextParser.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { TextUnit } from './types';
|
||||
import { getLayoutRatio } from './constants';
|
||||
|
||||
const ensureTrailingSlash = (input: string): string =>
|
||||
input.endsWith('/') ? input : `${input}/`;
|
||||
|
||||
export function splitMultiTextUnits(
|
||||
content: string,
|
||||
aStart: number,
|
||||
aEnd: number,
|
||||
svgIconPath: string = 'src/assets/icons/'
|
||||
): TextUnit[] {
|
||||
const parts = content.split('|').map((part) => part.trim());
|
||||
if (parts.length === 0 || (parts.length === 1 && parts[0] === '')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const ratios = getLayoutRatio(parts.length);
|
||||
const totalAngle = aEnd - aStart;
|
||||
const units: TextUnit[] = [];
|
||||
const basePath = ensureTrailingSlash(svgIconPath);
|
||||
|
||||
let currentAngle = aStart;
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const unitAngle = totalAngle * ratios[i];
|
||||
const contentPart = parts[i];
|
||||
const isSvg = contentPart.toLowerCase().endsWith('.svg');
|
||||
|
||||
units.push({
|
||||
content: contentPart,
|
||||
aStart: currentAngle,
|
||||
aEnd: currentAngle + unitAngle,
|
||||
isSvg,
|
||||
svgPath: isSvg ? `${basePath}${contentPart}` : undefined,
|
||||
});
|
||||
|
||||
currentAngle += unitAngle;
|
||||
}
|
||||
|
||||
return units;
|
||||
}
|
||||
218
src/sectorBuilder.ts
Normal file
218
src/sectorBuilder.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import type { SectorLayerConfig, Sector, TextUnit, TextRadialPosition } from './types';
|
||||
import { ColorResolver } from './colorResolver';
|
||||
import { splitMultiTextUnits } from './multiTextParser';
|
||||
import { SECTOR_INSET_DISTANCE, TEXT_LAYOUT_CONFIG } from './constants';
|
||||
import {
|
||||
annularSectorCentroid,
|
||||
annularSectorInsetPath,
|
||||
annularSectorPath,
|
||||
calculateSectorFontSize,
|
||||
generateTextPath,
|
||||
generateVerticalTextPath,
|
||||
getTextColorForBackground,
|
||||
} from './utils';
|
||||
|
||||
const ensureTrailingSlash = (input: string): string =>
|
||||
input.endsWith('/') ? input : `${input}/`;
|
||||
|
||||
interface SectorBuilderOptions {
|
||||
textRadialPosition?: TextRadialPosition;
|
||||
svgIconPath?: string;
|
||||
}
|
||||
|
||||
export class SectorBuilder {
|
||||
private colorResolver: ColorResolver;
|
||||
private textRadialPosition: TextRadialPosition;
|
||||
private svgIconPath: string;
|
||||
|
||||
constructor(colorResolver: ColorResolver, options: SectorBuilderOptions = {}) {
|
||||
this.colorResolver = colorResolver;
|
||||
this.textRadialPosition = options.textRadialPosition ?? 'middle';
|
||||
this.svgIconPath = ensureTrailingSlash(options.svgIconPath ?? 'src/assets/icons/');
|
||||
}
|
||||
|
||||
buildLayer(layer: SectorLayerConfig, layerIndex: number): Sector[] {
|
||||
if (!layer.divisions || layer.divisions <= 0) return [];
|
||||
|
||||
const sectors: Sector[] = [];
|
||||
const angleStep = 360 / layer.divisions;
|
||||
const startAngle = layer.startAngle ?? 0;
|
||||
// 预计算层级规律填色映射,供扇区颜色合并使用
|
||||
const layerColorMap = this.colorResolver.resolveLayerColors(layer);
|
||||
|
||||
for (let i = 0; i < layer.divisions; i++) {
|
||||
const aStart = startAngle + i * angleStep;
|
||||
const aEnd = aStart + angleStep;
|
||||
const sectorConfig = layer.sectors?.[i];
|
||||
const rawContent = typeof sectorConfig?.content === 'string' ? sectorConfig.content.trim() : '';
|
||||
const isMultiText = rawContent.includes('|');
|
||||
|
||||
// 颜色优先级:sector > layer pattern > background
|
||||
const fillColor = this.colorResolver.resolveSectorColor(layerColorMap, sectorConfig, i);
|
||||
const textColor = getTextColorForBackground(fillColor);
|
||||
const innerFill = (sectorConfig?.innerFill ?? layer.innerFill ?? 0) === 1;
|
||||
const innerFillPath = innerFill
|
||||
? annularSectorInsetPath(
|
||||
layer.rInner,
|
||||
layer.rOuter,
|
||||
aStart,
|
||||
aEnd,
|
||||
SECTOR_INSET_DISTANCE
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const sectorKey = `L${layerIndex}-P${i}`;
|
||||
const textPathId = `text-path-${sectorKey}`;
|
||||
const effectiveTextRadialPosition =
|
||||
layerIndex === 0 ? 'centroid' : this.textRadialPosition;
|
||||
|
||||
const { isVertical, fontSize, textPath } = this.computeTextLayout(
|
||||
layer.rInner,
|
||||
layer.rOuter,
|
||||
aStart,
|
||||
aEnd,
|
||||
rawContent.replace(/\|/g, ''),
|
||||
effectiveTextRadialPosition
|
||||
);
|
||||
|
||||
const textUnits = isMultiText
|
||||
? this.buildTextUnits(
|
||||
rawContent,
|
||||
aStart,
|
||||
aEnd,
|
||||
layer.rInner,
|
||||
layer.rOuter,
|
||||
effectiveTextRadialPosition,
|
||||
sectorKey
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const isSvgContent = !isMultiText && rawContent.toLowerCase().endsWith('.svg');
|
||||
|
||||
const centroid = annularSectorCentroid({
|
||||
rInner: layer.rInner,
|
||||
rOuter: layer.rOuter,
|
||||
aStartDeg: aStart,
|
||||
aEndDeg: aEnd,
|
||||
});
|
||||
|
||||
sectors.push({
|
||||
key: sectorKey,
|
||||
layerIndex,
|
||||
pieIndex: i,
|
||||
rInner: layer.rInner,
|
||||
rOuter: layer.rOuter,
|
||||
aStart,
|
||||
aEnd,
|
||||
aMidDeg: centroid.aMidDeg,
|
||||
aMidRad: centroid.aMidRad,
|
||||
cx: centroid.cx,
|
||||
cy: centroid.cy,
|
||||
fill: fillColor,
|
||||
textColor,
|
||||
label: isMultiText ? '' : rawContent,
|
||||
path: annularSectorPath(layer.rInner, layer.rOuter, aStart, aEnd),
|
||||
innerFillPath,
|
||||
innerFillColor: innerFill ? fillColor : undefined,
|
||||
textPath,
|
||||
textPathId,
|
||||
isVertical,
|
||||
fontSize,
|
||||
textUnits,
|
||||
groupSplitVisible: this.shouldShowGroupSplit(layer, i),
|
||||
isSvgContent,
|
||||
svgPath: isSvgContent ? `${this.svgIconPath}${rawContent}` : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return sectors;
|
||||
}
|
||||
|
||||
private buildTextUnits(
|
||||
content: string,
|
||||
aStart: number,
|
||||
aEnd: number,
|
||||
rInner: number,
|
||||
rOuter: number,
|
||||
textRadialPosition: TextRadialPosition,
|
||||
sectorKey: string
|
||||
): TextUnit[] {
|
||||
const units = splitMultiTextUnits(content, aStart, aEnd, this.svgIconPath);
|
||||
|
||||
return units.map((unit, index) => {
|
||||
const layout = this.computeTextLayout(
|
||||
rInner,
|
||||
rOuter,
|
||||
unit.aStart,
|
||||
unit.aEnd,
|
||||
unit.content,
|
||||
textRadialPosition
|
||||
);
|
||||
|
||||
return {
|
||||
...unit,
|
||||
textPathId: `${sectorKey}-unit-${index}`,
|
||||
textPath: layout.textPath,
|
||||
fontSize: layout.fontSize,
|
||||
isVertical: layout.isVertical,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private computeTextLayout(
|
||||
rInner: number,
|
||||
rOuter: number,
|
||||
aStart: number,
|
||||
aEnd: number,
|
||||
content: string,
|
||||
textRadialPosition: TextRadialPosition
|
||||
): { isVertical: boolean; fontSize: number; textPath: string } {
|
||||
const radialHeight = rOuter - rInner;
|
||||
const deltaDeg = aEnd - aStart;
|
||||
const centroid = annularSectorCentroid({
|
||||
rInner,
|
||||
rOuter,
|
||||
aStartDeg: aStart,
|
||||
aEndDeg: aEnd,
|
||||
});
|
||||
const arcWidth = (centroid.rho * Math.abs(deltaDeg) * Math.PI) / 180;
|
||||
|
||||
// 角度过窄时更适合竖排
|
||||
const isVertical = arcWidth < radialHeight;
|
||||
const textLength = Math.max(1, content.length);
|
||||
const fontSize = calculateSectorFontSize(
|
||||
rInner,
|
||||
rOuter,
|
||||
aStart,
|
||||
aEnd,
|
||||
textLength,
|
||||
TEXT_LAYOUT_CONFIG.FONT_SIZE.MIN,
|
||||
TEXT_LAYOUT_CONFIG.FONT_SIZE.MAX,
|
||||
isVertical
|
||||
);
|
||||
const textPath = isVertical
|
||||
? generateVerticalTextPath(
|
||||
rInner,
|
||||
rOuter,
|
||||
aStart,
|
||||
aEnd,
|
||||
textRadialPosition,
|
||||
textLength,
|
||||
fontSize
|
||||
)
|
||||
: generateTextPath(rInner, rOuter, aStart, aEnd, textRadialPosition);
|
||||
|
||||
return { isVertical, fontSize, textPath };
|
||||
}
|
||||
|
||||
private shouldShowGroupSplit(layer: SectorLayerConfig, sectorIndex: number): boolean {
|
||||
// groupSplit 关闭时,仅保留分组边界线
|
||||
if (layer.groupSplit !== false) return true;
|
||||
if (!layer.num) return true;
|
||||
|
||||
const cycleLength = layer.num + (layer.interval ?? 0);
|
||||
const posInCycle = sectorIndex % cycleLength;
|
||||
|
||||
return posInCycle >= layer.num - 1;
|
||||
}
|
||||
}
|
||||
169
src/types.ts
169
src/types.ts
@@ -19,6 +19,167 @@ export interface Example {
|
||||
radii: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 配置根对象
|
||||
*/
|
||||
export interface LuopanConfig {
|
||||
name: string;
|
||||
description?: string;
|
||||
background: string;
|
||||
outerRadius?: number;
|
||||
theme: ThemeConfig;
|
||||
layers: LayerConfig[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 主题配置
|
||||
*/
|
||||
export interface ThemeConfig {
|
||||
name?: string;
|
||||
colorPalettes: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 中心图标配置
|
||||
*/
|
||||
export interface CenterIconConfig {
|
||||
rIcon: number;
|
||||
opacity: number;
|
||||
name: string;
|
||||
rotation?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 刻度环配置
|
||||
*/
|
||||
export interface DegreeRingConfig {
|
||||
rInner: number;
|
||||
rOuter: number;
|
||||
showDegree: 0 | 1;
|
||||
mode: 'inner' | 'outer' | 'both';
|
||||
opacity: number;
|
||||
tickLength: number;
|
||||
tickLengthStep?: number;
|
||||
majorTick: number;
|
||||
minorTick: number;
|
||||
microTick: number;
|
||||
tickColor: string;
|
||||
ringColor: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 层配置(扇区层/中心图标层/刻度环层)
|
||||
*/
|
||||
export type LayerConfig =
|
||||
| SectorLayerConfig
|
||||
| CenterIconLayerConfig
|
||||
| DegreeRingLayerConfig;
|
||||
|
||||
/**
|
||||
* 普通扇区层配置
|
||||
*/
|
||||
export interface SectorLayerConfig {
|
||||
type?: 'sectors';
|
||||
divisions: number;
|
||||
rInner: number;
|
||||
rOuter: number;
|
||||
startAngle?: number;
|
||||
colorRef?: string;
|
||||
innerFill?: 0 | 1;
|
||||
num?: number;
|
||||
interval?: number;
|
||||
groupSplit?: boolean;
|
||||
sectors?: SectorConfig[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 中心图标层配置
|
||||
*/
|
||||
export interface CenterIconLayerConfig {
|
||||
type: 'centerIcon';
|
||||
centerIcon: CenterIconConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 刻度环层配置
|
||||
*/
|
||||
export interface DegreeRingLayerConfig {
|
||||
type: 'degreeRing';
|
||||
degreeRing: DegreeRingConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 扇区配置
|
||||
*/
|
||||
export interface SectorConfig {
|
||||
content?: string;
|
||||
colorRef?: string;
|
||||
innerFill?: 0 | 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文本单元
|
||||
*/
|
||||
export interface TextUnit {
|
||||
content: string;
|
||||
aStart: number;
|
||||
aEnd: number;
|
||||
isSvg: boolean;
|
||||
textPathId?: string;
|
||||
textPath?: string;
|
||||
svgPath?: string;
|
||||
fontSize?: number;
|
||||
isVertical?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 刻度线数据
|
||||
*/
|
||||
export interface TickMark {
|
||||
angle: number;
|
||||
type: 'major' | 'minor' | 'micro';
|
||||
length: number;
|
||||
startR: number;
|
||||
endR: number;
|
||||
label?: string;
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 刻度标签数据
|
||||
*/
|
||||
export interface DegreeLabel {
|
||||
angle: number;
|
||||
text: string;
|
||||
r: number;
|
||||
fontSize: number;
|
||||
textPathId: string;
|
||||
textPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 刻度环渲染数据
|
||||
*/
|
||||
export interface DegreeRingData {
|
||||
ticks: TickMark[];
|
||||
tickColor: string;
|
||||
ring: { rInner: number; rOuter: number; color: string; opacity: number };
|
||||
labels?: DegreeLabel[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 中心图标渲染数据
|
||||
*/
|
||||
export interface CenterIconData {
|
||||
rIcon: number;
|
||||
opacity: number;
|
||||
svgPath: string;
|
||||
rotation: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 扇区配置
|
||||
*/
|
||||
@@ -65,6 +226,14 @@ export interface Sector {
|
||||
fontSize: number;
|
||||
/** 是否竖排文字 */
|
||||
isVertical: boolean;
|
||||
/** 多文本单元 */
|
||||
textUnits?: TextUnit[];
|
||||
/** 是否显示与下一个扇区的分割线 */
|
||||
groupSplitVisible?: boolean;
|
||||
/** 内容是否为 SVG */
|
||||
isSvgContent?: boolean;
|
||||
/** SVG 文件路径 */
|
||||
svgPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user