Files
lupin-demo/src/composables/useLuopan.ts
2026-01-23 23:20:39 +08:00

279 lines
8.4 KiB
TypeScript

/**
* 罗盘业务逻辑组合函数
*/
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';
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 HEX_COLOR_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
const resolveThemeColor = (
theme: ThemeConfig,
value: string | undefined,
fallback: string
) => {
if (!value) return fallback;
if (HEX_COLOR_RE.test(value)) return value;
return theme.colorPalettes[value] ?? fallback;
};
const findDegreeRingLayer = (layers: LayerConfig[]) =>
layers.find((layer) => layer.type === 'degreeRing');
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: LuopanConfigSource | Ref<LuopanConfigSource>,
textRadialPositionRef?: Ref<TextRadialPosition>
) {
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 textRadialPosition = computed(
() => 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, {
textRadialPosition: textRadialPosition.value,
insetDistance: configObj.insetDistance,
});
// 层索引与配置顺序一致(`centerIcon`/`degreeRing` 仍占位)。
return configObj.layers.flatMap((layer, index) =>
isSectorLayer(layer) ? builder.buildLayer(layer, index) : []
);
};
const loadConfig = async () => {
try {
loading.value = true;
error.value = null;
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 = configInput;
}
const resolvedTheme = await resolveTheme(configObj);
const resolvedBackground = resolveThemeColor(
resolvedTheme,
configObj.background,
'#000000'
);
const resolvedStrokeColor = resolveThemeColor(
resolvedTheme,
configObj.strokeColor,
'#1f2937'
);
const resolvedConfig: LuopanConfig = {
...configObj,
theme: resolvedTheme,
background: resolvedBackground,
strokeColor: resolvedStrokeColor,
strokeWidth: typeof configObj.strokeWidth === 'number'
? configObj.strokeWidth
: undefined,
strokeOpacity: typeof configObj.strokeOpacity === 'number'
? configObj.strokeOpacity
: undefined,
insetDistance: typeof configObj.insetDistance === 'number'
? configObj.insetDistance
: undefined,
};
config.value = resolvedConfig;
sectors.value = buildSectors(resolvedConfig);
const degreeRingLayer = findDegreeRingLayer(resolvedConfig.layers);
degreeRing.value = degreeRingLayer
? buildDegreeRing(degreeRingLayer.degreeRing)
: null;
const centerIconLayer = findCenterIconLayer(resolvedConfig.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);
}
});
watch(configSource, () => {
loadConfig();
});
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;
// 取扇区层与刻度环中的最大半径。
const radii = sectorLayers.value.map((layer) => layer.rOuter);
const degreeRingLayer = findDegreeRingLayer(config.value.layers);
if (degreeRingLayer) {
radii.push(degreeRingLayer.degreeRing.rOuter);
}
const maxRadius = radii.length > 0 ? Math.max(...radii) : 0;
if (maxRadius > 0) return maxRadius;
return typeof config.value.outerRadius === 'number' ? config.value.outerRadius : 0;
});
return {
config: readonly(config),
sectors: readonly(sectors),
degreeRing: readonly(degreeRing),
centerIcon: readonly(centerIcon),
anglesDeg,
rings,
outerMost,
toXY: polarToXY,
loading: readonly(loading),
error: readonly(error),
reload: loadConfig,
};
}
/**
* 返回类型定义
*/
export type UseLuopanReturn = ReturnType<typeof useLuopan>;