From 9d24ed5899d386fce7d8c2c369fe2e7ac8f63d5c Mon Sep 17 00:00:00 2001 From: Arjun Vijayanatha Kurup Date: Mon, 27 Apr 2020 22:48:11 +0530 Subject: [PATCH 01/18] Added missing d3-cloud type to devDependencies --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 9bb308d4cdcf1..2cdad4c19cb8b 100644 --- a/package.json +++ b/package.json @@ -320,6 +320,7 @@ "@types/classnames": "^2.2.9", "@types/color": "^3.0.0", "@types/d3": "^3.5.43", + "@types/d3-cloud": "^1.2.3", "@types/dedent": "^0.7.0", "@types/deep-freeze-strict": "^1.1.0", "@types/delete-empty": "^2.0.0", From d3c488c9fcffeb538d2bc063a09161c6a0d4f6ca Mon Sep 17 00:00:00 2001 From: Arjun Vijayanatha Kurup Date: Mon, 27 Apr 2020 22:50:11 +0530 Subject: [PATCH 02/18] Converted to TypeScript issue #63592 fixed --- .../public/components/feedback_message.tsx | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 src/plugins/vis_type_tagcloud/public/components/feedback_message.tsx diff --git a/src/plugins/vis_type_tagcloud/public/components/feedback_message.tsx b/src/plugins/vis_type_tagcloud/public/components/feedback_message.tsx new file mode 100644 index 0000000000000..dad403245216d --- /dev/null +++ b/src/plugins/vis_type_tagcloud/public/components/feedback_message.tsx @@ -0,0 +1,63 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. 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, { Component, Fragment } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiIconTip } from '@elastic/eui'; +import { FeedbackMessageComponentState } from '../types'; + +export class FeedbackMessage extends Component<{}, FeedbackMessageComponentState> { + constructor(props: {}) { + super(props); + this.state = { shouldShowTruncate: false, shouldShowIncomplete: false }; + } + + render() { + if (!this.state.shouldShowTruncate && !this.state.shouldShowIncomplete) { + return ''; + } + + return ( + + {this.state.shouldShowTruncate && ( +

+ +

+ )} + {this.state.shouldShowIncomplete && ( +

+ +

+ )} + + } + /> + ); + } +} From dd3e13653c285a9fc7f1cfb33b7efbe7d78cd0d1 Mon Sep 17 00:00:00 2001 From: Arjun Vijayanatha Kurup Date: Mon, 27 Apr 2020 22:51:07 +0530 Subject: [PATCH 03/18] Converted label.js to TypeScript issue #63592 fixed --- .../public/components/label.tsx | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 src/plugins/vis_type_tagcloud/public/components/label.tsx diff --git a/src/plugins/vis_type_tagcloud/public/components/label.tsx b/src/plugins/vis_type_tagcloud/public/components/label.tsx new file mode 100644 index 0000000000000..0015dee2ca6c4 --- /dev/null +++ b/src/plugins/vis_type_tagcloud/public/components/label.tsx @@ -0,0 +1,39 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. 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, { Component } from 'react'; +import { LabelComponentState } from '../types'; + +export class Label extends Component<{}, LabelComponentState> { + constructor(props: {}) { + super(props); + this.state = { label: '', shouldShowLabel: true }; + } + + render() { + return ( +
+ {this.state.label} +
+ ); + } +} From d493f37f4797880d679cf17f15a79e645295c67b Mon Sep 17 00:00:00 2001 From: Arjun Vijayanatha Kurup Date: Mon, 27 Apr 2020 22:51:50 +0530 Subject: [PATCH 04/18] Converted tag_cloud.js to TypeScript issue #63592 fixed --- .../public/components/tag_cloud.tsx | 457 ++++++++++++++++++ 1 file changed, 457 insertions(+) create mode 100644 src/plugins/vis_type_tagcloud/public/components/tag_cloud.tsx diff --git a/src/plugins/vis_type_tagcloud/public/components/tag_cloud.tsx b/src/plugins/vis_type_tagcloud/public/components/tag_cloud.tsx new file mode 100644 index 0000000000000..397a949f7129e --- /dev/null +++ b/src/plugins/vis_type_tagcloud/public/components/tag_cloud.tsx @@ -0,0 +1,457 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. 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 d3 from 'd3'; +import d3TagCloud from 'd3-cloud'; +import { EventEmitter } from 'events'; + +const ORIENTATIONS = { + single: () => 0, + 'right angled': (tag: any) => { + return hashWithinRange(tag.text, 2) * 90; + }, + multiple: (tag: any) => { + return hashWithinRange(tag.text, 12) * 15 - 90; // fan out 12 * 15 degrees over top-right and bottom-right quadrant (=-90 deg offset) + }, +}; +const D3_SCALING_FUNCTIONS = { + linear: () => d3.scale.linear(), + log: () => d3.scale.log(), + 'square root': () => d3.scale.sqrt(), +}; + +export class TagCloud extends EventEmitter { + _element: HTMLElement; + _d3SvgContainer: any; + _svgGroup: any; + _size: [number, number]; + + _fontFamily: string; + _fontStyle: string; + _fontWeight: string; + _spiral: string; + _timeInterval: number; + _padding: number; + + _orientation: any; + _minFontSize: number; + _maxFontSize: number; + _textScale: string; + _optionsAsString: any; + + _words: any; + + _colorScale: any; + _setTimeoutId: any; + _pendingJob: any; + _layoutIsUpdating: any; + _allInViewBox: boolean; + _DOMisUpdating: boolean; + + _cloudWidth: number; + _cloudHeight: number; + + _completedJob: any; + tag: any; + + STATUS = { COMPLETE: 0, INCOMPLETE: 1 }; + + constructor(domNode: HTMLElement, colorScale: any) { + super(); + + // DOM + this._element = domNode; + this._d3SvgContainer = d3.select(this._element).append('svg'); + this._svgGroup = this._d3SvgContainer.append('g'); + this._size = [1, 1]; + this.resize(); + + // SETTING (non-configurable) + this._fontFamily = 'Open Sans, sans-serif'; + this._fontStyle = 'normal'; + this._fontWeight = 'normal'; + this._spiral = 'archimedean'; // layout shape + this._timeInterval = 1000; // time allowed for layout algorithm + this._padding = 5; + + // OPTIONS + this._orientation = 'single'; + this._minFontSize = 10; + this._maxFontSize = 36; + this._textScale = 'linear'; + this._optionsAsString = null; + + // DATA + this._words = null; + + // UTIL + this._colorScale = colorScale; + this._setTimeoutId = null; + this._pendingJob = null; + this._layoutIsUpdating = null; + this._allInViewBox = false; + this._DOMisUpdating = false; + + this._cloudWidth = 0; + this._cloudHeight = 0; + + this._completedJob = null; + + this.STATUS.COMPLETE = 0; + this.STATUS.INCOMPLETE = 0; + } + + setOptions(options: any) { + if (JSON.stringify(options) === this._optionsAsString) { + return; + } + this._optionsAsString = JSON.stringify(options); + this._orientation = options.orientation; + this._minFontSize = Math.min(options.minFontSize, options.maxFontSize); + this._maxFontSize = Math.max(options.minFontSize, options.maxFontSize); + this._textScale = options.scale; + this._invalidate(false); + } + + resize() { + const newWidth = this._element.offsetWidth; + const newHeight = this._element.offsetHeight; + + if (newWidth === this._size[0] && newHeight === this._size[1]) { + return; + } + + const wasInside = this._size[0] >= this._cloudWidth && this._size[1] >= this._cloudHeight; + const willBeInside = this._cloudWidth <= newWidth && this._cloudHeight <= newHeight; + this._size[0] = newWidth; + this._size[1] = newHeight; + if (wasInside && willBeInside && this._allInViewBox) { + this._invalidate(true); + } else { + this._invalidate(false); + } + } + + setData(data: string) { + this._words = data; + this._invalidate(false); + } + + destroy() { + clearTimeout(this._setTimeoutId); + this._element.innerHTML = ''; + } + + getStatus() { + return this._allInViewBox ? this.STATUS.COMPLETE : this.STATUS.INCOMPLETE; + } + + _updateContainerSize() { + this._d3SvgContainer.attr('width', this._size[0]); + this._d3SvgContainer.attr('height', this._size[1]); + this._svgGroup.attr('width', this._size[0]); + this._svgGroup.attr('height', this._size[1]); + } + + _isJobRunning() { + return this._setTimeoutId || this._layoutIsUpdating || this._DOMisUpdating; + } + + async _processPendingJob() { + if (!this._pendingJob) { + return; + } + + if (this._isJobRunning()) { + return; + } + + this._completedJob = null; + const job: any = await this._pickPendingJob(); + if (job.words.length) { + if (job.refreshLayout) { + await this._updateLayout(job); + } + await this._updateDOM(job); + const cloudBBox = this._svgGroup[0][0].getBBox(); + this._cloudWidth = cloudBBox.width; + this._cloudHeight = cloudBBox.height; + this._allInViewBox = + cloudBBox.x >= 0 && + cloudBBox.y >= 0 && + cloudBBox.x + cloudBBox.width <= this._element.offsetWidth && + cloudBBox.y + cloudBBox.height <= this._element.offsetHeight; + } else { + this._emptyDOM(job); + } + + if (this._pendingJob) { + this._processPendingJob(); // pick up next job + } else { + this._completedJob = job; + this.emit('renderComplete'); + } + } + + async _pickPendingJob() { + return await new Promise(resolve => { + this._setTimeoutId = setTimeout(async () => { + const job = this._pendingJob; + this._pendingJob = null; + this._setTimeoutId = null; + resolve(job); + }, 0); + }); + } + + _emptyDOM() { + this._svgGroup.selectAll('text').remove(); + this._cloudWidth = 0; + this._cloudHeight = 0; + this._allInViewBox = true; + this._DOMisUpdating = false; + } + + async _updateDOM(job: any) { + const canSkipDomUpdate = this._pendingJob || this._setTimeoutId; + if (canSkipDomUpdate) { + this._DOMisUpdating = false; + return; + } + + this._DOMisUpdating = true; + const affineTransform = positionWord.bind( + null, + this._element.offsetWidth / 2, + this._element.offsetHeight / 2 + ); + const svgTextNodes = this._svgGroup.selectAll('text'); + const stage = svgTextNodes.data(job.words, getText); + + await new Promise(resolve => { + const enterSelection = stage.enter(); + const enteringTags = enterSelection.append('text'); + enteringTags.style('font-size', getSizeInPixels); + enteringTags.style('font-style', this._fontStyle); + enteringTags.style('font-weight', () => this._fontWeight); + enteringTags.style('font-family', () => this._fontFamily); + enteringTags.style('fill', this.getFill.bind(this)); + enteringTags.attr('text-anchor', () => 'middle'); + enteringTags.attr('transform', affineTransform); + enteringTags.attr('data-test-subj', getDisplayText); + enteringTags.text(getDisplayText); + + const self = this; + enteringTags.on({ + click(event: MouseEvent) { + self.emit('select', event); + }, + mouseover() { + d3.select(this).style('cursor', 'pointer'); + }, + mouseout() { + d3.select(this).style('cursor', 'default'); + }, + }); + + const movingTags = stage.transition(); + movingTags.duration(600); + movingTags.style('font-size', getSizeInPixels); + movingTags.style('font-style', this._fontStyle); + movingTags.style('font-weight', () => this._fontWeight); + movingTags.style('font-family', () => this._fontFamily); + movingTags.attr('transform', affineTransform); + + const exitingTags = stage.exit(); + const exitTransition = exitingTags.transition(); + exitTransition.duration(200); + exitingTags.style('fill-opacity', 1e-6); + exitingTags.attr('font-size', 1); + exitingTags.remove(); + + let exits = 0; + let moves = 0; + const resolveWhenDone = () => { + if (exits === 0 && moves === 0) { + this._DOMisUpdating = false; + resolve(true); + } + }; + exitTransition.each(() => exits++); + exitTransition.each('end', () => { + exits--; + resolveWhenDone(); + }); + movingTags.each(() => moves++); + movingTags.each('end', () => { + moves--; + resolveWhenDone(); + }); + }); + } + + _makeTextSizeMapper() { + const mapSizeToFontSize = D3_SCALING_FUNCTIONS[this._textScale](); + const range = + this._words.length === 1 + ? [this._maxFontSize, this._maxFontSize] + : [this._minFontSize, this._maxFontSize]; + mapSizeToFontSize.range(range); + if (this._words) { + mapSizeToFontSize.domain(d3.extent(this._words, getValue)); + } + return mapSizeToFontSize; + } + + _makeNewJob() { + return { + refreshLayout: true, + size: this._size.slice(), + words: this._words, + }; + } + + _makeJobPreservingLayout() { + return { + refreshLayout: false, + size: this._size.slice(), + words: this._completedJob.words.map((tag: any) => { + return { + x: tag.x, + y: tag.y, + rotate: tag.rotate, + size: tag.size, + rawText: tag.rawText || tag.text, + displayText: tag.displayText, + meta: tag.meta, + }; + }), + }; + } + + _invalidate(keepLayout: any) { + if (!this._words) { + return; + } + + this._updateContainerSize(); + + const canReuseLayout = keepLayout && !this._isJobRunning() && this._completedJob; + this._pendingJob = canReuseLayout ? this._makeJobPreservingLayout() : this._makeNewJob(); + this._processPendingJob(); + } + + async _updateLayout(job: any) { + if (job.size[0] <= 0 || job.size[1] <= 0) { + // If either width or height isn't above 0 we don't relayout anything, + // since the d3-cloud will be stuck in an infinite loop otherwise. + return; + } + + const mapSizeToFontSize = this._makeTextSizeMapper(); + const tagCloudLayoutGenerator = d3TagCloud(); + tagCloudLayoutGenerator.size(job.size); + tagCloudLayoutGenerator.padding(this._padding); + tagCloudLayoutGenerator.rotate(ORIENTATIONS[this._orientation]); + tagCloudLayoutGenerator.font(this._fontFamily); + tagCloudLayoutGenerator.fontStyle(this._fontStyle); + tagCloudLayoutGenerator.fontWeight(this._fontWeight); + tagCloudLayoutGenerator.fontSize(tag => mapSizeToFontSize(tag.value)); + tagCloudLayoutGenerator.random(seed); + tagCloudLayoutGenerator.spiral(this._spiral); + tagCloudLayoutGenerator.words(job.words); + tagCloudLayoutGenerator.text(getDisplayText); + tagCloudLayoutGenerator.timeInterval(this._timeInterval); + + this._layoutIsUpdating = true; + await new Promise(resolve => { + tagCloudLayoutGenerator.on('end', () => { + this._layoutIsUpdating = false; + resolve(true); + }); + tagCloudLayoutGenerator.start(); + }); + } + + /** + * Returns debug info. For debugging only. + * @return {*} + */ + getDebugInfo() { + const debug = {}; + debug.positions = this._completedJob + ? this._completedJob.words.map((tag: any) => { + return { + displayText: tag.displayText, + rawText: tag.rawText || tag.text, + x: tag.x, + y: tag.y, + rotate: tag.rotate, + }; + }) + : []; + debug.size = { + width: this._size[0], + height: this._size[1], + }; + return debug; + } + + getFill(tag: any) { + return this._colorScale(tag.text); + } +} + +function seed() { + return 0.5; // constant seed (not random) to ensure constant layouts for identical data +} + +function getText(word: any) { + return word.rawText; +} + +function getDisplayText(word: any) { + return word.displayText; +} + +function positionWord(xTranslate: any, yTranslate: any, word: any) { + if (isNaN(word.x) || isNaN(word.y) || isNaN(word.rotate)) { + // move off-screen + return `translate(${xTranslate * 3}, ${yTranslate * 3})rotate(0)`; + } + + return `translate(${word.x + xTranslate}, ${word.y + yTranslate})rotate(${word.rotate})`; +} + +function getValue(tag: any) { + return tag.value; +} + +function getSizeInPixels(tag: any) { + return `${tag.size}px`; +} + +function hashWithinRange(str: string, max: any) { + str = JSON.stringify(str); + let hash = 0; + for (const ch of str) { + hash = (hash * 31 + ch.charCodeAt(0)) % max; + } + return Math.abs(hash) % max; +} From b422ebcf4dcc736ca94196f5716c3c78a8bdf8ea Mon Sep 17 00:00:00 2001 From: Arjun Vijayanatha Kurup Date: Mon, 27 Apr 2020 22:56:48 +0530 Subject: [PATCH 05/18] Added types supporting TagCloud; issue #63592 fixed --- .../vis_type_tagcloud/public/types.tsx | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 src/plugins/vis_type_tagcloud/public/types.tsx diff --git a/src/plugins/vis_type_tagcloud/public/types.tsx b/src/plugins/vis_type_tagcloud/public/types.tsx new file mode 100644 index 0000000000000..250df88d70c42 --- /dev/null +++ b/src/plugins/vis_type_tagcloud/public/types.tsx @@ -0,0 +1,36 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. 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. + */ + +export interface TagCloudVisParams { + scale: 'linear' | 'log' | 'square root'; + orientation: 'single' | 'right angled' | 'multiple'; + minFontSize: number; + maxFontSize: number; + showLabel: boolean; +} + +export interface FeedbackMessageComponentState { + shouldShowTruncate: boolean; + shouldShowIncomplete: boolean; +} + +export interface LabelComponentState { + label: string; + shouldShowLabel: boolean; +} From 3fee65e8eeb060f99b6bf98229eabfc74ef68263 Mon Sep 17 00:00:00 2001 From: Arjun Vijayanatha Kurup Date: Mon, 27 Apr 2020 22:57:12 +0530 Subject: [PATCH 06/18] Converted tag_cloud_visualization to TypeScript issue #63592 fixed --- .../components/tag_cloud_visualization.tsx | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 src/plugins/vis_type_tagcloud/public/components/tag_cloud_visualization.tsx diff --git a/src/plugins/vis_type_tagcloud/public/components/tag_cloud_visualization.tsx b/src/plugins/vis_type_tagcloud/public/components/tag_cloud_visualization.tsx new file mode 100644 index 0000000000000..304bd641fd658 --- /dev/null +++ b/src/plugins/vis_type_tagcloud/public/components/tag_cloud_visualization.tsx @@ -0,0 +1,167 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. 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 * as Rx from 'rxjs'; +import { take } from 'rxjs/operators'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { I18nProvider } from '@kbn/i18n/react'; +import d3 from 'd3'; +import { getFormatService } from '../services'; +import { Label } from './label'; +import { TagCloud } from './tag_cloud'; +import { FeedbackMessage } from './feedback_message'; + +const MAX_TAG_COUNT = 200; + +export function createTagCloudVisualization({ colors }: { colors: any }) { + const colorScale = d3.scale.ordinal().range(colors.seedColors); + return class TagCloudVisualization<> { + _containerNode: any; + _vis: any; + _truncated: boolean; + _tagCloud: TagCloud; + _visParams: any; + _renderComplete$: any; + _feedbackNode: any; + _feedbackMessage: any; + _labelNode: HTMLElement; + _label: any; + + constructor(node: any, vis: any) { + this._containerNode = node; + + const cloudRelativeContainer = document.createElement('div'); + cloudRelativeContainer.classList.add('tgcVis'); + cloudRelativeContainer.setAttribute('style', 'position: relative'); + const cloudContainer = document.createElement('div'); + cloudContainer.classList.add('tgcVis'); + cloudContainer.setAttribute('data-test-subj', 'tagCloudVisualization'); + this._containerNode.classList.add('visChart--vertical'); + cloudRelativeContainer.appendChild(cloudContainer); + this._containerNode.appendChild(cloudRelativeContainer); + + this._vis = vis; + this._truncated = false; + this._tagCloud = new TagCloud(cloudContainer, colorScale); + this._tagCloud.on('select', event => { + if (!this._visParams.bucket) { + return; + } + this._vis.API.events.filter({ + table: event.meta.data, + column: 0, + row: event.meta.rowIndex, + }); + }); + this._renderComplete$ = Rx.fromEvent(this._tagCloud, 'renderComplete'); + + this._feedbackNode = document.createElement('div'); + this._containerNode.appendChild(this._feedbackNode); + this._feedbackMessage = React.createRef(); + render( + + + , + this._feedbackNode + ); + + this._labelNode = document.createElement('div'); + this._containerNode.appendChild(this._labelNode); + this._label = React.createRef(); + render(