update at 2026-01-23 23:20:39

This commit is contained in:
douboer@gmail.com
2026-01-23 23:20:39 +08:00
parent dc45937623
commit d6312fcd16
24 changed files with 982 additions and 143 deletions

View File

@@ -2,6 +2,19 @@
<div class="luopan-wrap">
<!-- 工具栏 -->
<div class="toolbar">
<label class="toggle" v-if="configOptions.length">
<span>罗盘配置</span>
<select v-model="selectedConfigPath" :disabled="Boolean(props.config)">
<option
v-for="item in configOptions"
:key="item.path"
:value="item.path"
>
{{ item.name }}
</option>
</select>
</label>
<label class="toggle">
<input type="checkbox" v-model="showGuides" />
显示辅助线
@@ -298,10 +311,10 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import { useLuopan } from './composables/useLuopan';
import { DEFAULT_SIZE, DEFAULT_TEXT_RADIAL_POSITION, SECTOR_STROKE_WIDTH } from './constants';
import type { LuopanConfig, Sector, TextRadialPosition, TextUnit } from './types';
import type { LuopanConfigInput, Sector, TextRadialPosition, TextUnit } from './types';
import { annularSectorCentroid } from './utils';
/**
@@ -310,7 +323,7 @@ import { annularSectorCentroid } from './utils';
interface Props {
size?: number;
configPath?: string;
config?: LuopanConfig;
config?: LuopanConfigInput;
}
const props = withDefaults(defineProps<Props>(), {
@@ -338,13 +351,81 @@ const dragStartY = ref(0);
const dragStartPanX = ref(0);
const dragStartPanY = ref(0);
const resolveConfigPath = () => {
if (typeof window === 'undefined') return props.configPath;
interface ConfigListItem {
name: string;
path: string;
}
interface ConfigList {
default?: string;
items?: ConfigListItem[];
}
const normalizeConfigPath = (value: string) =>
value.startsWith('/') ? value : `/${value}`;
const resolveQueryConfig = () => {
if (typeof window === 'undefined') return undefined;
const param = new URLSearchParams(window.location.search).get('config');
return param ? `/${param}` : props.configPath;
return param ? normalizeConfigPath(param) : undefined;
};
const configInput = props.config ?? resolveConfigPath();
const resolveConfigPath = () => resolveQueryConfig() ?? props.configPath;
const configOptions = ref<ConfigListItem[]>([]);
const selectedConfigPath = ref(resolveConfigPath());
const updateUrlConfigParam = (path: string) => {
if (props.config) return;
if (typeof window === 'undefined') return;
const url = new URL(window.location.href);
url.searchParams.set('config', path.replace(/^\//, ''));
window.history.replaceState({}, '', url.toString());
};
const applySelectedConfig = (path: string) => {
const normalized = normalizeConfigPath(path);
selectedConfigPath.value = normalized;
};
const loadConfigList = async () => {
if (props.config) return;
try {
const response = await fetch('/luopan-configs.json', { cache: 'no-store' });
if (!response.ok) return;
const configList = (await response.json()) as ConfigList;
const items = Array.isArray(configList.items) ? configList.items : [];
const normalizedItems = items
.filter((item) => item && typeof item.path === 'string')
.map((item) => ({
name: item.name ?? item.path.replace(/^\//, ''),
path: normalizeConfigPath(item.path),
}));
configOptions.value = normalizedItems;
const queryPath = resolveQueryConfig();
const availablePaths = new Set(normalizedItems.map((item) => item.path));
if (queryPath && availablePaths.has(queryPath)) {
applySelectedConfig(queryPath);
return;
}
const defaultPath = configList.default
? normalizeConfigPath(configList.default)
: normalizedItems[0]?.path;
if (defaultPath) applySelectedConfig(defaultPath);
} catch (err) {
console.error('加载配置清单失败', err);
}
};
onMounted(loadConfigList);
watch(selectedConfigPath, (value, previous) => {
if (value === previous) return;
updateUrlConfigParam(value);
});
/**
* 使用罗盘逻辑
@@ -360,7 +441,10 @@ const {
toXY,
loading,
error,
} = useLuopan(configInput, textRadialPosition);
} = useLuopan(
computed(() => props.config ?? selectedConfigPath.value),
textRadialPosition
);
// 以实际外半径作为 `viewBox`,确保完整显示配置中的大半径罗盘
const viewBoxSize = computed(() => {

View File

@@ -6,7 +6,8 @@ export const applyPatternColoring = (
divisions: number,
color: string,
num: number,
interval: number
interval: number,
patternOffset: number = 1
): Map<number, string> => {
const colorMap = new Map<number, string>();
@@ -19,14 +20,18 @@ export const applyPatternColoring = (
return colorMap;
}
const cycleLength = num + interval;
if (cycleLength <= 0) return colorMap;
const offsetBase = Number.isFinite(patternOffset) ? Math.trunc(patternOffset) - 1 : 0;
const normalizedOffset = ((offsetBase % divisions) + divisions) % divisions;
// 规律填色:连续着色 `num` 个扇区,然后跳过 `interval` 个扇区。
let currentIndex = 0;
while (currentIndex < divisions) {
for (let i = 0; i < num && currentIndex < divisions; i++) {
colorMap.set(currentIndex, color);
currentIndex++;
for (let i = 0; i < divisions; i++) {
const relativeIndex = i - normalizedOffset;
const posInCycle = ((relativeIndex % cycleLength) + cycleLength) % cycleLength;
if (posInCycle < num) {
colorMap.set(i, color);
}
currentIndex += interval;
}
return colorMap;
@@ -57,7 +62,8 @@ export class ColorResolver {
const color = this.resolveColor(layer.colorRef);
if (typeof layer.num === 'number' && layer.num > 0) {
const interval = layer.interval ?? 0;
return applyPatternColoring(layer.divisions, color, layer.num, interval);
const patternOffset = layer.patternOffset ?? 1;
return applyPatternColoring(layer.divisions, color, layer.num, interval, patternOffset);
}
for (let i = 0; i < layer.divisions; i++) {

View File

@@ -2,14 +2,18 @@
* 罗盘业务逻辑组合函数
*/
import { computed, ref, readonly, watch, type Ref } from 'vue';
import { computed, ref, readonly, watch, type Ref, isRef } from 'vue';
import type {
CenterIconData,
DegreeRingData,
LayerConfig,
LuopanConfig,
LuopanConfigInput,
Sector,
SectorLayerConfig,
ThemeConfig,
ThemeItem,
ThemesConfig,
TextRadialPosition,
} from '../types';
import { polarToXY } from '../utils';
@@ -26,7 +30,7 @@ const isSectorLayer = (layer: LayerConfig): layer is SectorLayerConfig =>
const HEX_COLOR_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
const resolveThemeColor = (
theme: LuopanConfig['theme'],
theme: ThemeConfig,
value: string | undefined,
fallback: string
) => {
@@ -41,14 +45,84 @@ const findDegreeRingLayer = (layers: LayerConfig[]) =>
const findCenterIconLayer = (layers: LayerConfig[]) =>
layers.find((layer) => layer.type === 'centerIcon');
const THEMES_PATH = '/themes.json';
let cachedThemes: ThemesConfig | null = null;
const isObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
const normalizeThemeItem = (item: Record<string, unknown>): ThemeItem | null => {
const name = item.name;
const palettes = item.colorPalettes;
if (typeof name !== 'string' || !isObject(palettes)) return null;
const colorPalettes: Record<string, string> = {};
Object.entries(palettes).forEach(([key, value]) => {
if (typeof value === 'string') {
colorPalettes[key] = value;
}
});
if (Object.keys(colorPalettes).length === 0) return null;
return { name, colorPalettes };
};
const normalizeThemesConfig = (input: unknown): ThemesConfig => {
if (!isObject(input)) {
throw new Error('themes.json 必须为对象');
}
const rawItems = Array.isArray(input.items) ? input.items : [];
const items = rawItems
.filter((item): item is Record<string, unknown> => isObject(item))
.map((item) => normalizeThemeItem(item))
.filter((item): item is ThemeItem => Boolean(item));
if (items.length === 0) {
throw new Error('themes.json.items 不能为空');
}
return {
default: typeof input.default === 'string' ? input.default : undefined,
items,
};
};
const loadThemes = async (): Promise<ThemesConfig> => {
if (cachedThemes) return cachedThemes;
const response = await fetch(THEMES_PATH, { cache: 'no-store' });
if (!response.ok) {
throw new Error(`主题配置加载失败: ${response.status}`);
}
const raw = await response.json();
cachedThemes = normalizeThemesConfig(raw);
return cachedThemes;
};
const resolveTheme = async (configObj: LuopanConfigInput): Promise<ThemeConfig> => {
if (configObj.theme && !configObj.themeRef) {
return configObj.theme;
}
const themes = await loadThemes();
const themeName = configObj.themeRef ?? themes.default ?? themes.items[0].name;
const matched = themes.items.find((item) => item.name === themeName);
if (matched) {
return { name: matched.name, colorPalettes: matched.colorPalettes };
}
if (configObj.theme) {
return configObj.theme;
}
throw new Error(`未找到主题: ${themeName}`);
};
/**
* 罗盘逻辑 Hook
* @param configPathOrObject 配置文件路径或配置对象
* @param textRadialPositionRef 文字径向位置的响应式引用(可选)
* @returns 罗盘相关的计算属性和方法
*/
type LuopanConfigSource = string | LuopanConfigInput;
export function useLuopan(
configPathOrObject: string | LuopanConfig,
configPathOrObject: LuopanConfigSource | Ref<LuopanConfigSource>,
textRadialPositionRef?: Ref<TextRadialPosition>
) {
const config = ref<LuopanConfig | null>(null);
@@ -62,6 +136,10 @@ export function useLuopan(
() => textRadialPositionRef?.value ?? 'middle'
);
const configSource = isRef(configPathOrObject)
? configPathOrObject
: ref(configPathOrObject);
const buildSectors = (configObj: LuopanConfig) => {
const resolver = new ColorResolver(configObj.theme, configObj.background);
const builder = new SectorBuilder(resolver, {
@@ -79,27 +157,30 @@ export function useLuopan(
loading.value = true;
error.value = null;
let configObj: LuopanConfig;
if (typeof configPathOrObject === 'string') {
const jsonText = await fetch(configPathOrObject).then((res) => res.text());
let configObj: LuopanConfigInput;
const configInput = configSource.value;
if (typeof configInput === 'string') {
const jsonText = await fetch(configInput).then((res) => res.text());
configObj = parseConfig(jsonText);
} else {
configObj = configPathOrObject;
configObj = configInput;
}
const resolvedTheme = await resolveTheme(configObj);
const resolvedBackground = resolveThemeColor(
configObj.theme,
resolvedTheme,
configObj.background,
'#000000'
);
const resolvedStrokeColor = resolveThemeColor(
configObj.theme,
resolvedTheme,
configObj.strokeColor,
'#1f2937'
);
const resolvedConfig: LuopanConfig = {
...configObj,
theme: resolvedTheme,
background: resolvedBackground,
strokeColor: resolvedStrokeColor,
strokeWidth: typeof configObj.strokeWidth === 'number'
@@ -139,6 +220,10 @@ export function useLuopan(
}
});
watch(configSource, () => {
loadConfig();
});
loadConfig();
const sectorLayers = computed(() =>

View File

@@ -1,4 +1,4 @@
import type { LuopanConfig, ThemeConfig } from './types';
import type { LuopanConfigInput, ThemeConfig } from './types';
const isObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -53,7 +53,7 @@ const normalizeTheme = (theme: Record<string, unknown>): ThemeConfig => {
};
};
export const parseConfig = (jsonText: string): LuopanConfig => {
export const parseConfig = (jsonText: string): LuopanConfigInput => {
const cleanText = stripJsonComments(jsonText);
let parsed: unknown;
@@ -69,7 +69,9 @@ export const parseConfig = (jsonText: string): LuopanConfig => {
const config = parsed as Record<string, unknown>;
assertCondition(typeof config.name === 'string', 'name 为必填字符串');
assertCondition(typeof config.background === 'string', 'background 为必填字符串');
assertCondition(isObject(config.theme), 'theme 为必填对象');
if (config.theme !== undefined) {
assertCondition(isObject(config.theme), 'theme 必须为对象');
}
assertCondition(Array.isArray(config.layers), 'layers 为必填数组');
return {
@@ -81,7 +83,8 @@ export const parseConfig = (jsonText: string): LuopanConfig => {
strokeOpacity: typeof config.strokeOpacity === 'number' ? config.strokeOpacity : undefined,
insetDistance: typeof config.insetDistance === 'number' ? config.insetDistance : undefined,
outerRadius: typeof config.outerRadius === 'number' ? config.outerRadius : undefined,
theme: normalizeTheme(config.theme),
layers: config.layers as LuopanConfig['layers'],
themeRef: typeof config.themeRef === 'string' ? config.themeRef : undefined,
theme: isObject(config.theme) ? normalizeTheme(config.theme) : undefined,
layers: config.layers as LuopanConfigInput['layers'],
};
};

View File

@@ -6,7 +6,10 @@
export type {
Example,
LuopanConfig,
LuopanConfigInput,
ThemeConfig,
ThemeItem,
ThemesConfig,
CenterIconConfig,
DegreeRingConfig,
DegreeRingData,

View File

@@ -8,14 +8,15 @@ export function splitMultiTextUnits(
content: string,
aStart: number,
aEnd: number,
svgIconPath: string = 'src/assets/icons/'
svgIconPath: string = 'src/assets/icons/',
unitRatios?: number[]
): 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 ratios = normalizeRatios(unitRatios, parts.length) ?? getLayoutRatio(parts.length);
const totalAngle = aEnd - aStart;
const units: TextUnit[] = [];
const basePath = ensureTrailingSlash(svgIconPath);
@@ -39,3 +40,14 @@ export function splitMultiTextUnits(
return units;
}
const normalizeRatios = (
ratios: number[] | undefined,
count: number
): number[] | null => {
if (!ratios || ratios.length !== count) return null;
if (ratios.some((value) => typeof value !== 'number' || value <= 0)) return null;
const sum = ratios.reduce((acc, value) => acc + value, 0);
if (sum <= 0) return null;
return ratios.map((value) => value / sum);
};

View File

@@ -49,6 +49,13 @@ export class SectorBuilder {
for (let i = 0; i < layer.divisions; i++) {
const aStart = startAngle + i * angleStep;
const aEnd = aStart + angleStep;
// groupSplit=false 且扇区足够细时,对填充路径做轻微重叠,避免抗锯齿露底
const angleOverlapDeg =
layer.groupSplit === false && angleStep > 0 && angleStep <= 10
? Math.min(0.2, angleStep * 0.02)
: 0;
const fillAStart = aStart - angleOverlapDeg;
const fillAEnd = aEnd + angleOverlapDeg;
const sectorConfig = layer.sectors?.[i];
const rawContent = typeof sectorConfig?.content === 'string' ? sectorConfig.content.trim() : '';
const isMultiText = rawContent.includes('|');
@@ -65,8 +72,8 @@ export class SectorBuilder {
? annularSectorInsetPath(
layer.rInner,
layer.rOuter,
aStart,
aEnd,
fillAStart,
fillAEnd,
this.insetDistance
)
: undefined;
@@ -102,7 +109,8 @@ export class SectorBuilder {
layer.rInner,
layer.rOuter,
effectiveTextRadialPosition,
sectorKey
sectorKey,
layer.unitRatios
)
: undefined;
@@ -130,7 +138,7 @@ export class SectorBuilder {
fill: baseFillColor,
textColor,
label: isMultiText ? '' : rawContent,
path: annularSectorPath(layer.rInner, layer.rOuter, aStart, aEnd),
path: annularSectorPath(layer.rInner, layer.rOuter, fillAStart, fillAEnd),
innerFillPath: normalizedInnerFillPath,
innerFillColor,
textPath,
@@ -154,9 +162,10 @@ export class SectorBuilder {
rInner: number,
rOuter: number,
textRadialPosition: TextRadialPosition,
sectorKey: string
sectorKey: string,
unitRatios?: number[]
): TextUnit[] {
const units = splitMultiTextUnits(content, aStart, aEnd, this.svgIconPath);
const units = splitMultiTextUnits(content, aStart, aEnd, this.svgIconPath, unitRatios);
return units.map((unit, index) => {
const layout = this.computeTextLayout(
@@ -230,9 +239,16 @@ export class SectorBuilder {
if (!layer.num) return true;
const cycleLength = layer.num + (layer.interval ?? 0);
const posInCycle = sectorIndex % cycleLength;
if (cycleLength <= 0) return true;
const patternOffset = layer.patternOffset ?? 1;
const offsetBase = Number.isFinite(patternOffset) ? Math.trunc(patternOffset) - 1 : 0;
const normalizedOffset =
layer.divisions > 0 ? ((offsetBase % layer.divisions) + layer.divisions) % layer.divisions : 0;
const posInCycle =
((sectorIndex - normalizedOffset) % cycleLength + cycleLength) % cycleLength;
// 仅保留每个着色分组的末尾分割线
return posInCycle >= layer.num - 1;
// 仅保留分组边界:着色组起点 + 间隔组起点(若存在间隔)
if ((layer.interval ?? 0) === 0) return posInCycle === 0;
return posInCycle === 0 || posInCycle === layer.num;
}
}

View File

@@ -22,7 +22,7 @@ export interface Example {
/**
* JSON 配置根对象
*/
export interface LuopanConfig {
export interface LuopanConfigBase {
name: string;
description?: string;
background: string;
@@ -31,10 +31,17 @@ export interface LuopanConfig {
strokeOpacity?: number;
insetDistance?: number;
outerRadius?: number;
theme: ThemeConfig;
themeRef?: string;
theme?: ThemeConfig;
layers: LayerConfig[];
}
export interface LuopanConfig extends LuopanConfigBase {
theme: ThemeConfig;
}
export type LuopanConfigInput = LuopanConfigBase;
/**
* 主题配置
*/
@@ -43,6 +50,16 @@ export interface ThemeConfig {
colorPalettes: Record<string, string>;
}
export interface ThemeItem {
name: string;
colorPalettes: Record<string, string>;
}
export interface ThemesConfig {
default?: string;
items: ThemeItem[];
}
/**
* 中心图标配置
*/
@@ -90,8 +107,10 @@ export interface SectorLayerConfig {
startAngle?: number;
colorRef?: string;
innerFill?: 0 | 1;
unitRatios?: number[];
num?: number;
interval?: number;
patternOffset?: number;
groupSplit?: boolean;
sectors?: SectorConfig[];
}

View File

@@ -260,7 +260,7 @@ export function generateTextPath(
}
// 不调整半径,保持在中线位置
// 使用 dominant-baseline 属性控制文字垂直对齐
// 使用 `dominant-baseline` 属性控制文字垂直对齐
const adjustedRMid = rMid;
const a1 = normalizeDeg(aStartDeg);
@@ -275,8 +275,8 @@ export function generateTextPath(
const largeArc = delta > 180 ? 1 : 0;
// 保持路径完整,不在这里应用 padding
// padding 通过字体大小计算textPath 的 startOffset/text-anchor 实现
// 保持路径完整,不在这里应用内边距(`padding`
// 内边距(`padding`通过字体大小计算以及 `textPath``startOffset`/`text-anchor` 实现
if (needReverse) {
// 反向路径(从结束点到起始点),保持文字头朝外
@@ -384,14 +384,14 @@ export function generateVerticalTextPath(
const requiredPathLength =
effectiveTextLength * TEXT_LAYOUT_CONFIG.CHAR_SPACING_RATIO * effectiveFontSize;
// 确保路径不超出扇区边界(考虑径向 padding
// 确保路径不超出扇区边界(考虑径向内边距 `padding`
const maxPathLength = radialHeight * TEXT_LAYOUT_CONFIG.RADIAL_PADDING_RATIO;
const actualPathLength = Math.min(requiredPathLength, maxPathLength);
let finalStartR: number;
let finalEndR: number;
// 对于从圆心开始的扇区(rInner=0),形心会偏向外侧
// 对于从圆心开始的扇区`rInner`=0,形心会偏向外侧
// 需要特殊处理以防止溢出
if (rInner === 0) {
// 计算路径应该在哪里结束(从外圆向内)
@@ -409,7 +409,7 @@ export function generateVerticalTextPath(
finalEndR = rMid - halfPath;
}
} else {
// 普通扇区:以 rMid 为中心
// 普通扇区:以 `rMid` 为中心
const halfPathLength = actualPathLength / 2;
// 确保不超出边界
@@ -492,13 +492,13 @@ export function calculateSectorFontSize(
const maxByHeight = availableHeight / (textLength * TEXT_LAYOUT_CONFIG.CHAR_SPACING_RATIO);
// 约束2最内侧字符的弧长宽度这是最严格的宽度限制
// 最内侧字符的中心位置大约在 rInner + fontSize/2 处
// 最内侧字符的中心位置大约在 `rInner` + `fontSize`/2 处
// 保守估计:假设字体大小约为径向宽度的一半
const estimatedFontSize = radialWidth * 0.5;
const innerMostRadius = rInner + estimatedFontSize / 2;
const innerArcLength = (innerMostRadius * deltaDeg * Math.PI) / 180;
// 字符宽度约为 fontSize × 1.0(方块字)
// 字符宽度约为 `fontSize` × 1.0(方块字)
const availableArcLength = innerArcLength * TEXT_LAYOUT_CONFIG.TANGENT_PADDING_RATIO;
const maxByWidth = availableArcLength / 1.0; // 单个字符宽度
@@ -515,11 +515,11 @@ export function calculateSectorFontSize(
const maxByHeight = radialWidth * TEXT_LAYOUT_CONFIG.RADIAL_PADDING_RATIO;
// 2. 宽度约束:根据文字总宽度计算
// 中文字符宽度 = fontSize方块字
// 字符间距:约 0.1 * fontSize总占用约 1.1 * fontSize
// 中文字符宽度 = `fontSize`(方块字)
// 字符间距:约 0.1 * `fontSize`,总占用约 1.1 * `fontSize`
const availableArcLength = arcLength * TEXT_LAYOUT_CONFIG.TANGENT_PADDING_RATIO;
// 反推字体大小fontSize = 可用弧长 / (字符数 × 1.1)
// 反推字体大小:`fontSize` = 可用弧长 / (字符数 × 1.1)
const maxByWidth = availableArcLength / (textLength * 1.1);
// 3. 取宽度和高度约束中较小的那个(更严格的限制)
@@ -561,13 +561,13 @@ function calculateVerticalTextLength(
// 计算径向可用高度
const radialHeight = rOuter - rInner;
// 考虑上下padding可用高度约为总高度的配置比例
// 考虑上下内边距(`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] 范围内
// 限制在 [`MIN_CHARS`, `MAX_CHARS`] 范围内
const charCount = Math.max(MIN_CHARS, Math.min(MAX_CHARS, maxFittableChars));
return charCount;