225 lines
6.6 KiB
TypeScript
225 lines
6.6 KiB
TypeScript
import type { SectorLayerConfig, Sector, TextUnit, TextRadialPosition } from './types';
|
||
import { ColorResolver } from './colorResolver';
|
||
import { splitMultiTextUnits } from './multiTextParser';
|
||
import { SECTOR_INSET_DISTANCE, TEXT_LAYOUT_CONFIG } from './constants';
|
||
import {
|
||
annularSectorCentroid,
|
||
annularSectorInsetPath,
|
||
annularSectorPath,
|
||
calculateSectorFontSize,
|
||
generateTextPath,
|
||
generateVerticalTextPath,
|
||
getTextColorForBackground,
|
||
} from './utils';
|
||
|
||
const ensureTrailingSlash = (input: string): string =>
|
||
input.endsWith('/') ? input : `${input}/`;
|
||
|
||
interface SectorBuilderOptions {
|
||
textRadialPosition?: TextRadialPosition;
|
||
svgIconPath?: string;
|
||
insetDistance?: number;
|
||
}
|
||
|
||
export class SectorBuilder {
|
||
private colorResolver: ColorResolver;
|
||
private textRadialPosition: TextRadialPosition;
|
||
private svgIconPath: string;
|
||
private insetDistance: number;
|
||
|
||
constructor(colorResolver: ColorResolver, options: SectorBuilderOptions = {}) {
|
||
this.colorResolver = colorResolver;
|
||
this.textRadialPosition = options.textRadialPosition ?? 'middle';
|
||
this.svgIconPath = ensureTrailingSlash(options.svgIconPath ?? 'src/assets/icons/');
|
||
this.insetDistance =
|
||
typeof options.insetDistance === 'number'
|
||
? Math.max(0, options.insetDistance)
|
||
: SECTOR_INSET_DISTANCE;
|
||
}
|
||
|
||
buildLayer(layer: SectorLayerConfig, layerIndex: number): Sector[] {
|
||
if (!layer.divisions || layer.divisions <= 0) return [];
|
||
|
||
const sectors: Sector[] = [];
|
||
const angleStep = 360 / layer.divisions;
|
||
const startAngle = layer.startAngle ?? 0;
|
||
// 预计算层级规律填色映射,供扇区颜色合并使用
|
||
const layerColorMap = this.colorResolver.resolveLayerColors(layer);
|
||
|
||
for (let i = 0; i < layer.divisions; i++) {
|
||
const aStart = startAngle + i * angleStep;
|
||
const aEnd = aStart + angleStep;
|
||
const sectorConfig = layer.sectors?.[i];
|
||
const rawContent = typeof sectorConfig?.content === 'string' ? sectorConfig.content.trim() : '';
|
||
const isMultiText = rawContent.includes('|');
|
||
|
||
// 颜色优先级:sector > layer pattern > background
|
||
const fillColor = this.colorResolver.resolveSectorColor(layerColorMap, sectorConfig, i);
|
||
const textColor = getTextColorForBackground(fillColor);
|
||
const innerFill = (sectorConfig?.innerFill ?? layer.innerFill ?? 0) === 1;
|
||
const innerFillPath = innerFill
|
||
? annularSectorInsetPath(
|
||
layer.rInner,
|
||
layer.rOuter,
|
||
aStart,
|
||
aEnd,
|
||
this.insetDistance
|
||
)
|
||
: undefined;
|
||
|
||
const sectorKey = `L${layerIndex}-P${i}`;
|
||
const textPathId = `text-path-${sectorKey}`;
|
||
const effectiveTextRadialPosition =
|
||
layerIndex === 0 ? 'centroid' : this.textRadialPosition;
|
||
|
||
const { isVertical, fontSize, textPath } = this.computeTextLayout(
|
||
layer.rInner,
|
||
layer.rOuter,
|
||
aStart,
|
||
aEnd,
|
||
rawContent.replace(/\|/g, ''),
|
||
effectiveTextRadialPosition
|
||
);
|
||
|
||
const textUnits = isMultiText
|
||
? this.buildTextUnits(
|
||
rawContent,
|
||
aStart,
|
||
aEnd,
|
||
layer.rInner,
|
||
layer.rOuter,
|
||
effectiveTextRadialPosition,
|
||
sectorKey
|
||
)
|
||
: undefined;
|
||
|
||
const isSvgContent = !isMultiText && rawContent.toLowerCase().endsWith('.svg');
|
||
|
||
const centroid = annularSectorCentroid({
|
||
rInner: layer.rInner,
|
||
rOuter: layer.rOuter,
|
||
aStartDeg: aStart,
|
||
aEndDeg: aEnd,
|
||
});
|
||
|
||
sectors.push({
|
||
key: sectorKey,
|
||
layerIndex,
|
||
pieIndex: i,
|
||
rInner: layer.rInner,
|
||
rOuter: layer.rOuter,
|
||
aStart,
|
||
aEnd,
|
||
aMidDeg: centroid.aMidDeg,
|
||
aMidRad: centroid.aMidRad,
|
||
cx: centroid.cx,
|
||
cy: centroid.cy,
|
||
fill: fillColor,
|
||
textColor,
|
||
label: isMultiText ? '' : rawContent,
|
||
path: annularSectorPath(layer.rInner, layer.rOuter, aStart, aEnd),
|
||
innerFillPath,
|
||
innerFillColor: innerFill ? fillColor : undefined,
|
||
textPath,
|
||
textPathId,
|
||
isVertical,
|
||
fontSize,
|
||
textUnits,
|
||
groupSplitVisible: this.shouldShowGroupSplit(layer, i),
|
||
isSvgContent,
|
||
svgPath: isSvgContent ? `${this.svgIconPath}${rawContent}` : undefined,
|
||
});
|
||
}
|
||
|
||
return sectors;
|
||
}
|
||
|
||
private buildTextUnits(
|
||
content: string,
|
||
aStart: number,
|
||
aEnd: number,
|
||
rInner: number,
|
||
rOuter: number,
|
||
textRadialPosition: TextRadialPosition,
|
||
sectorKey: string
|
||
): TextUnit[] {
|
||
const units = splitMultiTextUnits(content, aStart, aEnd, this.svgIconPath);
|
||
|
||
return units.map((unit, index) => {
|
||
const layout = this.computeTextLayout(
|
||
rInner,
|
||
rOuter,
|
||
unit.aStart,
|
||
unit.aEnd,
|
||
unit.content,
|
||
textRadialPosition
|
||
);
|
||
|
||
return {
|
||
...unit,
|
||
textPathId: `${sectorKey}-unit-${index}`,
|
||
textPath: layout.textPath,
|
||
fontSize: layout.fontSize,
|
||
isVertical: layout.isVertical,
|
||
};
|
||
});
|
||
}
|
||
|
||
private computeTextLayout(
|
||
rInner: number,
|
||
rOuter: number,
|
||
aStart: number,
|
||
aEnd: number,
|
||
content: string,
|
||
textRadialPosition: TextRadialPosition
|
||
): { isVertical: boolean; fontSize: number; textPath: string } {
|
||
const radialHeight = rOuter - rInner;
|
||
const deltaDeg = aEnd - aStart;
|
||
const centroid = annularSectorCentroid({
|
||
rInner,
|
||
rOuter,
|
||
aStartDeg: aStart,
|
||
aEndDeg: aEnd,
|
||
});
|
||
const arcWidth = (centroid.rho * Math.abs(deltaDeg) * Math.PI) / 180;
|
||
|
||
// 角度过窄时更适合竖排
|
||
const isVertical = arcWidth < radialHeight;
|
||
const textLength = Math.max(1, content.length);
|
||
const fontSize = calculateSectorFontSize(
|
||
rInner,
|
||
rOuter,
|
||
aStart,
|
||
aEnd,
|
||
textLength,
|
||
TEXT_LAYOUT_CONFIG.FONT_SIZE.MIN,
|
||
TEXT_LAYOUT_CONFIG.FONT_SIZE.MAX,
|
||
isVertical
|
||
);
|
||
const textPath = isVertical
|
||
? generateVerticalTextPath(
|
||
rInner,
|
||
rOuter,
|
||
aStart,
|
||
aEnd,
|
||
textRadialPosition,
|
||
textLength,
|
||
fontSize
|
||
)
|
||
: generateTextPath(rInner, rOuter, aStart, aEnd, textRadialPosition);
|
||
|
||
return { isVertical, fontSize, textPath };
|
||
}
|
||
|
||
private shouldShowGroupSplit(layer: SectorLayerConfig, sectorIndex: number): boolean {
|
||
// groupSplit 关闭时,仅保留分组边界线
|
||
if (layer.groupSplit !== false) return true;
|
||
if (!layer.num) return true;
|
||
|
||
const cycleLength = layer.num + (layer.interval ?? 0);
|
||
const posInCycle = sectorIndex % cycleLength;
|
||
|
||
return posInCycle >= layer.num - 1;
|
||
}
|
||
}
|