/** * 罗盘业务逻辑组合函数 */ 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 => typeof value === 'object' && value !== null && !Array.isArray(value); const normalizeThemeItem = (item: Record): ThemeItem | null => { const name = item.name; const palettes = item.colorPalettes; if (typeof name !== 'string' || !isObject(palettes)) return null; const colorPalettes: Record = {}; 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 => 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 => { 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 => { 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, textRadialPositionRef?: Ref ) { const config = ref(null); const sectors = ref([]); const degreeRing = ref(null); const centerIcon = ref(null); const loading = ref(false); const error = ref(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;