/* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ import React from 'react'; import cloudLayout, { Word } from 'd3-cloud'; import { PlainObject, createEncoderFactory, DeriveEncoding, Encoder, } from 'encodable'; import { SupersetThemeProps, withTheme, seedRandom, CategoricalColorScale, } from '@superset-ui/core'; export const ROTATION = { flat: () => 0, // this calculates a random rotation between -90 and 90 degrees. random: () => Math.floor(seedRandom() * 6 - 3) * 30, square: () => Math.floor(seedRandom() * 2) * 90, }; export type RotationType = keyof typeof ROTATION; export type WordCloudEncoding = DeriveEncoding; type WordCloudEncodingConfig = { color: ['Color', string]; fontFamily: ['Category', string]; fontSize: ['Numeric', number]; fontWeight: ['Category', string | number]; text: ['Text', string]; }; /** * These props should be stored when saving the chart. */ export interface WordCloudVisualProps { encoding?: Partial; rotation?: RotationType; } export interface WordCloudProps extends WordCloudVisualProps { data: PlainObject[]; height: number; width: number; sliceId: number; } export interface WordCloudState { words: Word[]; scaleFactor: number; } const defaultProps: Required = { encoding: {}, rotation: 'flat', }; type FullWordCloudProps = WordCloudProps & typeof defaultProps & SupersetThemeProps; const SCALE_FACTOR_STEP = 0.5; const MAX_SCALE_FACTOR = 3; // Percentage of top results that will always be displayed. // Needed to avoid clutter when shrinking a chart with many records. const TOP_RESULTS_PERCENTAGE = 0.1; class WordCloud extends React.PureComponent< FullWordCloudProps, WordCloudState > { static defaultProps = defaultProps; // Cannot name it isMounted because of conflict // with React's component function name isComponentMounted = false; wordCloudEncoderFactory = createEncoderFactory({ channelTypes: { color: 'Color', fontFamily: 'Category', fontSize: 'Numeric', fontWeight: 'Category', text: 'Text', }, defaultEncoding: { color: { value: 'black' }, fontFamily: { value: this.props.theme.typography.families.sansSerif }, fontSize: { value: 20 }, fontWeight: { value: 'bold' }, text: { value: '' }, }, }); createEncoder = this.wordCloudEncoderFactory.createSelector(); constructor(props: FullWordCloudProps) { super(props); this.state = { words: [], scaleFactor: 1, }; this.setWords = this.setWords.bind(this); } componentDidMount() { this.isComponentMounted = true; this.update(); } componentDidUpdate(prevProps: WordCloudProps) { const { data, encoding, width, height, rotation } = this.props; if ( prevProps.data !== data || prevProps.encoding !== encoding || prevProps.width !== width || prevProps.height !== height || prevProps.rotation !== rotation ) { this.update(); } } componentWillUnmount() { this.isComponentMounted = false; } setWords(words: Word[]) { if (this.isComponentMounted) { this.setState({ words }); } } update() { const { data, encoding } = this.props; const encoder = this.createEncoder(encoding); encoder.setDomainFromDataset(data); const sortedData = [...data].sort( (a, b) => encoder.channels.fontSize.encodeDatum(b, 0) - encoder.channels.fontSize.encodeDatum(a, 0), ); const topResultsCount = Math.max( sortedData.length * TOP_RESULTS_PERCENTAGE, 10, ); const topResults = sortedData.slice(0, topResultsCount); // Ensure top results are always included in the final word cloud by scaling chart down if needed this.generateCloud(encoder, 1, (words: Word[]) => topResults.every((d: PlainObject) => words.find( ({ text }) => encoder.channels.text.getValueFromDatum(d) === text, ), ), ); } generateCloud( encoder: Encoder, scaleFactor: number, isValid: (word: Word[]) => boolean, ) { const { data, width, height, rotation } = this.props; cloudLayout() .size([width * scaleFactor, height * scaleFactor]) // clone the data because cloudLayout mutates input .words(data.map(d => ({ ...d }))) .padding(5) .rotate(ROTATION[rotation] || ROTATION.flat) .text(d => encoder.channels.text.getValueFromDatum(d)) .font(d => encoder.channels.fontFamily.encodeDatum( d, this.props.theme.typography.families.sansSerif, ), ) .fontWeight(d => encoder.channels.fontWeight.encodeDatum(d, 'normal')) .fontSize(d => encoder.channels.fontSize.encodeDatum(d, 0)) .on('end', (words: Word[]) => { if (isValid(words) || scaleFactor > MAX_SCALE_FACTOR) { if (this.isComponentMounted) { this.setState({ words, scaleFactor }); } } else { this.generateCloud(encoder, scaleFactor + SCALE_FACTOR_STEP, isValid); } }) .start(); } render() { const { scaleFactor } = this.state; const { width, height, encoding, sliceId } = this.props; const { words } = this.state; const encoder = this.createEncoder(encoding); encoder.channels.color.setDomainFromDataset(words); const { getValueFromDatum } = encoder.channels.color; const colorFn = encoder.channels.color.scale as CategoricalColorScale; const viewBoxWidth = width * scaleFactor; const viewBoxHeight = height * scaleFactor; return ( {words.map(w => ( {w.text} ))} ); } } export default withTheme(WordCloud);