update at 2026-01-22 18:43:01

This commit is contained in:
douboer
2026-01-22 18:43:01 +08:00
parent c23c71eabf
commit a930a99a50
23 changed files with 2082 additions and 1186 deletions

View File

@@ -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
View 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
View 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;
}
}

View File

@@ -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
View 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
View 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,
};
}

View File

@@ -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
View 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
View 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;
}
}

View File

@@ -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;
}
/**