update at 2026-01-23 23:20:39
This commit is contained in:
100
src/Luopan.vue
100
src/Luopan.vue
@@ -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(() => {
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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(() =>
|
||||
|
||||
@@ -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'],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
export type {
|
||||
Example,
|
||||
LuopanConfig,
|
||||
LuopanConfigInput,
|
||||
ThemeConfig,
|
||||
ThemeItem,
|
||||
ThemesConfig,
|
||||
CenterIconConfig,
|
||||
DegreeRingConfig,
|
||||
DegreeRingData,
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
23
src/types.ts
23
src/types.ts
@@ -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[];
|
||||
}
|
||||
|
||||
26
src/utils.ts
26
src/utils.ts
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user