import { ElementTypeEnum } from '../../constants'; import type { ComponentType, JSXElement } from '../../jsx'; import { getElementBounds, Group, Text } from '../../jsx'; import { ItemsGroup } from '../components'; import { FlexLayout } from '../layouts'; import { getColorPrimary, getPaletteColor } from '../utils'; import { registerStructure } from './registry'; import type { BaseStructureProps } from './types'; interface WordCandidate { label: string; value?: number; color: string; fontSize: number; width: number; height: number; } interface PlacedWord extends WordCandidate { angle: number; centerX: number; centerY: number; box: { x: number; y: number; width: number; height: number }; } const DEFAULT_ROTATE_ANGLES = [0, 30, -30, 60, -60]; const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5)); function getRotatedSize(width: number, height: number, angle: number) { const rad = (Math.PI / 180) * angle; const cos = Math.cos(rad); const sin = Math.sin(rad); return { width: Math.abs(width * cos) + Math.abs(height * sin), height: Math.abs(width * sin) + Math.abs(height * cos), }; } function hasCollision( x: number, y: number, width: number, height: number, placed: PlacedWord[], padding: number, ) { const left = x - padding; const right = x + width + padding; const top = y - padding; const bottom = y + height + padding; return placed.some((word) => { const wLeft = word.box.x - padding; const wRight = word.box.x + word.box.width + padding; const wTop = word.box.y - padding; const wBottom = word.box.y + word.box.height + padding; return !( right <= wLeft || left >= wRight || bottom <= wTop || top >= wBottom ); }); } function placeWords( words: WordCandidate[], enableRotate: boolean, padding: number, spiralStep: number, radiusStep: number, ): PlacedWord[] { const placed: PlacedWord[] = []; const rotationAngles = enableRotate ? DEFAULT_ROTATE_ANGLES : [0]; const maxAttempts = Math.max(1600, words.length * 28); words.forEach((word, wordIndex) => { const sizeBias = Math.max(word.width, word.height); const angleOffset = wordIndex * GOLDEN_ANGLE; let extraRadius = 0; let placedWord: PlacedWord | null = null; for (let attempt = 0; attempt < maxAttempts && !placedWord; attempt++) { if (attempt === Math.floor(maxAttempts * 0.6)) { // Gradually expand the search radius for dense layouts extraRadius = sizeBias; } const theta = angleOffset + attempt * spiralStep; const radius = radiusStep * Math.sqrt(attempt + 1) + extraRadius + sizeBias * 0.25; const centerX = radius * Math.cos(theta); const centerY = radius * Math.sin(theta); for (const angle of rotationAngles) { const rotated = getRotatedSize(word.width, word.height, angle); const x = centerX - rotated.width / 2; const y = centerY - rotated.height / 2; if ( !hasCollision(x, y, rotated.width, rotated.height, placed, padding) ) { placedWord = { ...word, angle, centerX, centerY, box: { x, y, width: rotated.width, height: rotated.height }, }; break; } } } if (!placedWord) { const fallbackAngle = rotationAngles[wordIndex % rotationAngles.length]; const farRadius = radiusStep * Math.sqrt(maxAttempts + 1) + sizeBias; const theta = angleOffset; const centerX = farRadius * Math.cos(theta); const centerY = farRadius * Math.sin(theta); const rotated = getRotatedSize(word.width, word.height, fallbackAngle); placedWord = { ...word, angle: fallbackAngle, centerX, centerY, box: { x: centerX - rotated.width / 2, y: centerY - rotated.height / 2, width: rotated.width, height: rotated.height, }, }; } placed.push(placedWord); }); return placed; } export interface ChartWordCloudProps extends BaseStructureProps { minFontSize?: number; maxFontSize?: number; enableRotate?: boolean; padding?: number; spiralStep?: number; radiusStep?: number; } export const ChartWordCloud: ComponentType = (props) => { const { data, options, minFontSize = 16, maxFontSize = 48, enableRotate = true, padding = 6, spiralStep = 0.45, radiusStep = 10, } = props; const { items = [] } = data; const validItems = items .map((datum, index) => ({ datum, index })) .filter(({ datum }) => datum.label); if (validItems.length === 0) { return ( ); } const values = validItems .map(({ datum }) => datum.value) .filter((v): v is number => typeof v === 'number'); const hasValues = values.length > 0; const minValue = hasValues ? Math.min(...values) : 0; const maxValue = hasValues ? Math.max(...values) : 0; const sameValue = hasValues && minValue === maxValue; const uniformSize = (minFontSize + maxFontSize) / 2; const mapFontSize = (value?: number) => { if (!hasValues || sameValue) return uniformSize; if (value == null) return minFontSize; const ratio = (value - minValue) / (maxValue - minValue || 1); return minFontSize + ratio * (maxFontSize - minFontSize); }; const words: WordCandidate[] = validItems .map(({ datum, index }) => { const fontSize = mapFontSize(datum.value); const measured = getElementBounds( {datum.label} , ); const color = getPaletteColor(options, [index]) || getColorPrimary(options) || '#333333'; return { label: datum.label as string, value: datum.value, color, fontSize, width: measured.width * 1.05, height: measured.height, }; }) .sort((a, b) => b.fontSize - a.fontSize); const placedWords = placeWords( words, enableRotate, padding, spiralStep, radiusStep, ); const minX = Math.min(...placedWords.map((w) => w.box.x)); const minY = Math.min(...placedWords.map((w) => w.box.y)); const maxX = Math.max(...placedWords.map((w) => w.box.x + w.box.width)); const maxY = Math.max(...placedWords.map((w) => w.box.y + w.box.height)); const offsetX = -minX + padding; const offsetY = -minY + padding; const containerWidth = maxX - minX + padding * 2; const containerHeight = maxY - minY + padding * 2; const wordElements: JSXElement[] = placedWords.map((word, index) => { const translateX = word.centerX - word.width / 2 + offsetX; const translateY = word.centerY - word.height / 2 + offsetY; const rotationOriginX = word.width / 2; const rotationOriginY = word.height / 2; const transform = `translate(${translateX}, ${translateY}) rotate(${word.angle}, ${rotationOriginX}, ${rotationOriginY})`; return ( {word.label} ); }); return ( {wordElements} ); }; registerStructure('chart-wordcloud', { component: ChartWordCloud, composites: [], });