279 lines
8.4 KiB
TypeScript
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>;
|