diff --git a/client/app/directives/resize-event.js b/client/app/directives/resize-event.js index a0c4d5bed0..396cf55b1a 100644 --- a/client/app/directives/resize-event.js +++ b/client/app/directives/resize-event.js @@ -1,48 +1,13 @@ -import { findIndex } from 'lodash'; - -const items = new Map(); - -function checkItems() { - items.forEach((item, node) => { - const bounds = node.getBoundingClientRect(); - // convert to int (because these numbers needed for comparisons), but preserve 1 decimal point - const width = Math.round(bounds.width * 10); - const height = Math.round(bounds.height * 10); - - if ( - (item.width !== width) || - (item.height !== height) - ) { - item.width = width; - item.height = height; - item.callback(node); - } - }); - - setTimeout(checkItems, 100); -} - -checkItems(); // ensure it was called only once! +import resizeObserver from '@/services/resizeObserver'; function resizeEvent() { return { restrict: 'A', link($scope, $element, attrs) { - const node = $element[0]; - if (!items.has(node)) { - items.set(node, { - callback: () => { - $scope.$evalAsync(attrs.resizeEvent); - }, - }); - - $scope.$on('$destroy', () => { - const index = findIndex(items, item => item.node === node); - if (index >= 0) { - items.splice(index, 1); // remove item - } - }); - } + const unwatch = resizeObserver($element[0], () => { + $scope.$evalAsync(attrs.resizeEvent); + }); + $scope.$on('$destroy', unwatch); }, }; } diff --git a/client/app/lib/hooks/useQueryResult.js b/client/app/lib/hooks/useQueryResult.js index 6a7179b930..3918dd2fb9 100644 --- a/client/app/lib/hooks/useQueryResult.js +++ b/client/app/lib/hooks/useQueryResult.js @@ -2,9 +2,9 @@ import { useState, useEffect } from 'react'; function getQueryResultData(queryResult) { return { - columns: queryResult ? queryResult.getColumns() : [], - rows: queryResult ? queryResult.getData() : [], - filters: queryResult ? queryResult.getFilters() : [], + columns: (queryResult && queryResult.getColumns()) || [], + rows: (queryResult && queryResult.getData()) || [], + filters: (queryResult && queryResult.getFilters()) || [], }; } diff --git a/client/app/pages/dashboards/dashboard.less b/client/app/pages/dashboards/dashboard.less index 905bccfe4f..21f2cf2d60 100644 --- a/client/app/pages/dashboards/dashboard.less +++ b/client/app/pages/dashboards/dashboard.less @@ -83,6 +83,7 @@ .sunburst-visualization-container, .sankey-visualization-container, .map-visualization-container, + .word-cloud-visualization-container, .plotly-chart-container { position: absolute; left: 0; diff --git a/client/app/services/resizeObserver.js b/client/app/services/resizeObserver.js new file mode 100644 index 0000000000..65c1c8f890 --- /dev/null +++ b/client/app/services/resizeObserver.js @@ -0,0 +1,50 @@ +/* global ResizeObserver */ + +function observeNative(node, callback) { + if ((typeof ResizeObserver === 'function') && node) { + const observer = new ResizeObserver(() => callback()); // eslint-disable-line compat/compat + observer.observe(node); + return () => observer.disconnect(); + } + return null; +} + +const items = new Map(); + +function checkItems() { + if (items.size > 0) { + items.forEach((item, node) => { + const bounds = node.getBoundingClientRect(); + // convert to int (because these numbers needed for comparisons), but preserve 1 decimal point + const width = Math.round(bounds.width * 10); + const height = Math.round(bounds.height * 10); + + if ( + (item.width !== width) || + (item.height !== height) + ) { + item.width = width; + item.height = height; + item.callback(node); + } + }); + + setTimeout(checkItems, 100); + } +} + +function observeFallback(node, callback) { + if (node && !items.has(node)) { + const shouldTrigger = items.size === 0; + items.set(node, { callback }); + if (shouldTrigger) { + checkItems(); + } + return () => items.delete(node); + } + return null; +} + +export default function observe(node, callback) { + return observeNative(node, callback) || observeFallback(node, callback) || (() => {}); +} diff --git a/client/app/visualizations/EditVisualizationDialog.jsx b/client/app/visualizations/EditVisualizationDialog.jsx index 10c370a03e..db2a94384c 100644 --- a/client/app/visualizations/EditVisualizationDialog.jsx +++ b/client/app/visualizations/EditVisualizationDialog.jsx @@ -1,4 +1,4 @@ -import { extend, map, findIndex, isEqual } from 'lodash'; +import { extend, map, sortBy, findIndex, isEqual } from 'lodash'; import React, { useState, useMemo } from 'react'; import PropTypes from 'prop-types'; import Modal from 'antd/lib/modal'; @@ -158,7 +158,7 @@ function EditVisualizationDialog({ dialog, visualization, query, queryResult }) onChange={onTypeChanged} > {map( - registeredVisualizations, + sortBy(registeredVisualizations, ['type']), vis => {vis.name}, )} diff --git a/client/app/visualizations/word-cloud/Editor.jsx b/client/app/visualizations/word-cloud/Editor.jsx index 3499ca603a..37cee6ab84 100644 --- a/client/app/visualizations/word-cloud/Editor.jsx +++ b/client/app/visualizations/word-cloud/Editor.jsx @@ -1,30 +1,101 @@ -import { map } from 'lodash'; +import { map, merge } from 'lodash'; import React from 'react'; import Select from 'antd/lib/select'; +import InputNumber from 'antd/lib/input-number'; +import * as Grid from 'antd/lib/grid'; import { EditorPropTypes } from '@/visualizations'; -const { Option } = Select; - export default function Editor({ options, data, onOptionsChange }) { - const onColumnChanged = (column) => { - const newOptions = { ...options, column }; - onOptionsChange(newOptions); + const optionsChanged = (newOptions) => { + onOptionsChange(merge({}, options, newOptions)); }; return ( -
- - -
+ +
+ + +
+
+ + +
+
+ + + + optionsChanged({ wordLengthLimit: { min: value > 0 ? value : null } })} + /> + + + optionsChanged({ wordLengthLimit: { max: value > 0 ? value : null } })} + /> + + +
+
+ + + + optionsChanged({ wordCountLimit: { min: value > 0 ? value : null } })} + /> + + + optionsChanged({ wordCountLimit: { max: value > 0 ? value : null } })} + /> + + +
+
); } diff --git a/client/app/visualizations/word-cloud/Renderer.jsx b/client/app/visualizations/word-cloud/Renderer.jsx new file mode 100644 index 0000000000..cbf6a50cdd --- /dev/null +++ b/client/app/visualizations/word-cloud/Renderer.jsx @@ -0,0 +1,172 @@ +import d3 from 'd3'; +import cloud from 'd3-cloud'; +import { each, filter, map, min, max, sortBy, toString } from 'lodash'; +import React, { useMemo, useState, useEffect } from 'react'; +import resizeObserver from '@/services/resizeObserver'; +import { RendererPropTypes } from '@/visualizations'; + +import './renderer.less'; + +function computeWordFrequencies(rows, column) { + const result = {}; + + each(rows, (row) => { + const wordsList = toString(row[column]).split(/\s/g); + each(wordsList, (d) => { + result[d] = (result[d] || 0) + 1; + }); + }); + + return result; +} + +function getWordsWithFrequencies(rows, wordColumn, frequencyColumn) { + const result = {}; + + each(rows, (row) => { + const count = parseFloat(row[frequencyColumn]); + if (Number.isFinite(count) && (count > 0)) { + const word = toString(row[wordColumn]); + result[word] = count; + } + }); + + return result; +} + +function applyLimitsToWords(words, { wordLength, wordCount }) { + wordLength.min = Number.isFinite(wordLength.min) ? wordLength.min : null; + wordLength.max = Number.isFinite(wordLength.max) ? wordLength.max : null; + + wordCount.min = Number.isFinite(wordCount.min) ? wordCount.min : null; + wordCount.max = Number.isFinite(wordCount.max) ? wordCount.max : null; + + return filter(words, ({ text, count }) => { + const wordLengthFits = ( + (!wordLength.min || (text.length >= wordLength.min)) && + (!wordLength.max || (text.length <= wordLength.max)) + ); + const wordCountFits = ( + (!wordCount.min || (count >= wordCount.min)) && + (!wordCount.max || (count <= wordCount.max)) + ); + return wordLengthFits && wordCountFits; + }); +} + +function prepareWords(rows, options) { + let result = []; + + if (options.column) { + if (options.frequenciesColumn) { + result = getWordsWithFrequencies(rows, options.column, options.frequenciesColumn); + } else { + result = computeWordFrequencies(rows, options.column); + } + result = sortBy( + map(result, (count, text) => ({ text, count })), + [({ count }) => -count, ({ text }) => -text.length], // "count" desc, length("text") desc + ); + } + + // Add additional attributes + const counts = map(result, item => item.count); + const wordSize = d3.scale.linear() + .domain([min(counts), max(counts)]) + .range([10, 100]); // min/max word size + const color = d3.scale.category20(); + + each(result, (item, index) => { + item.size = wordSize(item.count); + item.color = color(index); + item.angle = index % 2 * 90; // make it stable between renderings + }); + + return applyLimitsToWords(result, { + wordLength: options.wordLengthLimit, + wordCount: options.wordCountLimit, + }); +} + +function scaleElement(node, container) { + node.style.transform = null; + const { width: nodeWidth, height: nodeHeight } = node.getBoundingClientRect(); + const { width: containerWidth, height: containerHeight } = container.getBoundingClientRect(); + + const scaleX = containerWidth / nodeWidth; + const scaleY = containerHeight / nodeHeight; + + node.style.transform = `scale(${Math.min(scaleX, scaleY)})`; +} + +function createLayout() { + const fontFamily = window.getComputedStyle(document.body).fontFamily; + + return cloud() + // make the area large enough to contain even very long words; word cloud will be placed in the center of the area + // TODO: dimensions probably should be larger, but `d3-cloud` has some performance issues related to these values + .size([5000, 5000]) + .padding(3) + .font(fontFamily) + .rotate(d => d.angle) + .fontSize(d => d.size) + .random(() => 0.5); // do not place words randomly - use compact layout +} + +function render(container, words) { + container = d3.select(container); + container.selectAll('*').remove(); + + const svg = container.append('svg'); + const g = svg.append('g'); + g.selectAll('text') + .data(words) + .enter() + .append('text') + .style('font-size', d => `${d.size}px`) + .style('font-family', d => d.font) + .style('fill', d => d.color) + .attr('text-anchor', 'middle') + .attr('transform', d => `translate(${[d.x, d.y]}) rotate(${d.rotate})`) + .text(d => d.text); + + const svgBounds = svg.node().getBoundingClientRect(); + const gBounds = g.node().getBoundingClientRect(); + + svg.attr('width', Math.ceil(gBounds.width)).attr('height', Math.ceil(gBounds.height)); + g.attr('transform', `translate(${svgBounds.left - gBounds.left},${svgBounds.top - gBounds.top})`); + + scaleElement(svg.node(), container.node()); +} + +export default function Renderer({ data, options }) { + const [container, setContainer] = useState(null); + const [words, setWords] = useState([]); + const layout = useMemo(createLayout, []); + + useEffect(() => { + layout.words(prepareWords(data.rows, options)).on('end', w => setWords(w)).start(); + return () => layout.on('end', null).stop(); + }, [layout, data, options, setWords]); + + useEffect(() => { + if (container) { + render(container, words); + } + }, [container, words]); + + useEffect(() => { + if (container) { + return resizeObserver(container, () => { + const svg = container.querySelector('svg'); + if (svg) { + scaleElement(svg, container); + } + }); + } + }, [container]); + + return (
); +} + +Renderer.propTypes = RendererPropTypes; diff --git a/client/app/visualizations/word-cloud/index.js b/client/app/visualizations/word-cloud/index.js index 1f57689bcc..db5765adef 100644 --- a/client/app/visualizations/word-cloud/index.js +++ b/client/app/visualizations/word-cloud/index.js @@ -1,145 +1,25 @@ -import d3 from 'd3'; -import cloud from 'd3-cloud'; -import { map, min, max, values } from 'lodash'; -import { angular2react } from 'angular2react'; +import { merge } from 'lodash'; import { registerVisualization } from '@/visualizations'; +import Renderer from './Renderer'; import Editor from './Editor'; -function findWordFrequencies(data, columnName) { - const wordsHash = {}; - - data.forEach((row) => { - const wordsList = row[columnName].toString().split(' '); - wordsList.forEach((d) => { - if (d in wordsHash) { - wordsHash[d] += 1; - } else { - wordsHash[d] = 1; - } - }); - }); - - return wordsHash; -} - -// target domain: [t1, t2] -const MIN_WORD_SIZE = 10; -const MAX_WORD_SIZE = 100; - -function createScale(wordCounts) { - wordCounts = values(wordCounts); - - // source domain: [s1, s2] - const minCount = min(wordCounts); - const maxCount = max(wordCounts); - - // Edge case - if all words have the same count; just set middle size for all - if (minCount === maxCount) { - return () => (MAX_WORD_SIZE + MIN_WORD_SIZE) / 2; - } - - // v is value from source domain: - // s1 <= v <= s2. - // We need to fit it target domain: - // t1 <= v" <= t2 - // 1. offset source value to zero point: - // v' = v - s1 - // 2. offset source and target domains to zero point: - // s' = s2 - s1 - // t' = t2 - t1 - // 3. compute fraction: - // f = v' / s'; - // 0 <= f <= 1 - // 4. map f to target domain: - // v" = f * t' + t1; - // t1 <= v" <= t' + t1; - // t1 <= v" <= t2 (because t' = t2 - t1, so t2 = t' + t1) - - const sourceScale = maxCount - minCount; - const targetScale = MAX_WORD_SIZE - MIN_WORD_SIZE; - - return value => ((value - minCount) / sourceScale) * targetScale + MIN_WORD_SIZE; -} - -const WordCloudRenderer = { - restrict: 'E', - bindings: { - data: '<', - options: '<', - }, - controller($scope, $element) { - $element[0].style.display = 'block'; - - const update = () => { - const data = this.data.rows; - const options = this.options; - - let wordsHash = {}; - if (options.column) { - wordsHash = findWordFrequencies(data, options.column); - } - - const scaleValue = createScale(wordsHash); - - const wordList = map(wordsHash, (count, key) => ({ - text: key, - size: scaleValue(count), - })); - - const fill = d3.scale.category20(); - const layout = cloud() - .size([500, 500]) - .words(wordList) - .padding(5) - .rotate(() => Math.floor(Math.random() * 2) * 90) - .font('Impact') - .fontSize(d => d.size); - - function draw(words) { - d3.select($element[0]).selectAll('*').remove(); - - d3.select($element[0]) - .append('svg') - .attr('width', layout.size()[0]) - .attr('height', layout.size()[1]) - .append('g') - .attr('transform', `translate(${layout.size()[0] / 2},${layout.size()[1] / 2})`) - .selectAll('text') - .data(words) - .enter() - .append('text') - .style('font-size', d => `${d.size}px`) - .style('font-family', 'Impact') - .style('fill', (d, i) => fill(i)) - .attr('text-anchor', 'middle') - .attr('transform', d => `translate(${[d.x, d.y]})rotate(${d.rotate})`) - .text(d => d.text); - } - - layout.on('end', draw); - - layout.start(); - }; - - $scope.$watch('$ctrl.data', update); - $scope.$watch('$ctrl.options', update, true); - }, +const DEFAULT_OPTIONS = { + column: '', + frequenciesColumn: '', + wordLengthLimit: { min: null, max: null }, + wordCountLimit: { min: null, max: null }, }; -export default function init(ngModule) { - ngModule.component('wordCloudRenderer', WordCloudRenderer); - - ngModule.run(($injector) => { - registerVisualization({ - type: 'WORD_CLOUD', - name: 'Word Cloud', - getOptions: options => ({ ...options }), - Renderer: angular2react('wordCloudRenderer', WordCloudRenderer, $injector), - Editor, +export default function init() { + registerVisualization({ + type: 'WORD_CLOUD', + name: 'Word Cloud', + getOptions: options => merge({}, DEFAULT_OPTIONS, options), + Renderer, + Editor, - defaultRows: 8, - }); + defaultRows: 8, }); } diff --git a/client/app/visualizations/word-cloud/renderer.less b/client/app/visualizations/word-cloud/renderer.less new file mode 100644 index 0000000000..aee6afcced --- /dev/null +++ b/client/app/visualizations/word-cloud/renderer.less @@ -0,0 +1,12 @@ +.word-cloud-visualization-container { + overflow: hidden; + height: 400px; + display: flex; + align-items: center; + justify-content: center; + + svg { + transform-origin: center center; + flex: 0 0 auto; + } +} diff --git a/client/cypress/integration/visualizations/edit_visualization_dialog_spec.js b/client/cypress/integration/visualizations/edit_visualization_dialog_spec.js index aa8b61d486..4ce3bea175 100644 --- a/client/cypress/integration/visualizations/edit_visualization_dialog_spec.js +++ b/client/cypress/integration/visualizations/edit_visualization_dialog_spec.js @@ -22,8 +22,23 @@ describe('Edit visualization dialog', () => { it('opens Edit Visualization dialog', () => { cy.getByTestId('EditVisualization').click(); cy.getByTestId('EditVisualizationDialog').should('exist'); - // Default visualization should be selected + // Default `Table` visualization should be selected cy.getByTestId('VisualizationType').should('exist').should('contain', 'Table'); cy.getByTestId('VisualizationName').should('exist').should('have.value', 'Table'); }); + + it('creates visualization with custom name', () => { + const visualizationName = 'Custom name'; + + cy.clickThrough(` + NewVisualization + VisualizationType + VisualizationType.TABLE + `); + + cy.getByTestId('VisualizationName').clear().type(visualizationName); + + cy.getByTestId('EditVisualizationDialog').contains('button', 'Save').click(); + cy.getByTestId('QueryPageVisualizationTabs').contains('li', visualizationName).should('exist'); + }); }); diff --git a/client/cypress/integration/visualizations/word_cloud_spec.js b/client/cypress/integration/visualizations/word_cloud_spec.js new file mode 100644 index 0000000000..ff687b31e2 --- /dev/null +++ b/client/cypress/integration/visualizations/word_cloud_spec.js @@ -0,0 +1,131 @@ +/* global cy, Cypress */ + +import { createQuery } from '../../support/redash-api'; + +const { map } = Cypress._; + +const SQL = ` + SELECT 'Lorem ipsum dolor' AS a, 'ipsum' AS b, 2 AS c UNION ALL + SELECT 'Lorem sit amet' AS a, 'amet' AS b, 2 AS c UNION ALL + SELECT 'dolor adipiscing elit' AS a, 'elit' AS b, 4 AS c UNION ALL + SELECT 'sed do sed' AS a, 'sed' AS b, 5 AS c UNION ALL + SELECT 'sed eiusmod tempor' AS a, 'tempor' AS b, 7 AS c +`; + +// Hack to fix Cypress -> Percy communication +// Word Cloud uses `font-family` defined in CSS with a lot of fallbacks, so +// it's almost impossible to know which font will be used on particular machine/browser. +// In Cypress browser it could be one font, in Percy - another. +// The issue is in how Percy takes screenshots: it takes a DOM/CSS/assets snapshot in Cypress, +// copies it to own servers and restores in own browsers. Word Cloud computes its layout +// using Cypress font, sets absolute positions for elements (in pixels), and when it is restored +// on Percy machines (with another font) - visualization gets messed up. +// Solution: explicitly provide some font that will be 100% the same on all CI machines. In this +// case, it's "Roboto" just because it's in the list of fallback fonts and we already have this +// webfont in assets folder (so browser can load it). +function injectFont(document) { + const style = document.createElement('style'); + style.setAttribute('id', 'percy-fix'); + style.setAttribute('type', 'text/css'); + + const fonts = [ + ['Roboto', 'Roboto-Light-webfont', 300], + ['Roboto', 'Roboto-Regular-webfont', 400], + ['Roboto', 'Roboto-Medium-webfont', 500], + ['Roboto', 'Roboto-Bold-webfont', 700], + ]; + + const basePath = '/static/fonts/roboto/'; + + // `insertRule` does not load font for some reason. Using text content works ¯\_(ツ)_/¯ + style.appendChild(document.createTextNode(map(fonts, ([fontFamily, fileName, fontWeight]) => (` + @font-face { + font-family: "${fontFamily}"; + font-weight: ${fontWeight}; + src: url("${basePath}${fileName}.eot"); + src: url("${basePath}${fileName}.eot?#iefix") format("embedded-opentype"), + url("${basePath}${fileName}.woff") format("woff"), + url("${basePath}${fileName}.ttf") format("truetype"), + url("${basePath}${fileName}.svg") format("svg"); + } + `)).join('\n\n'))); + document.getElementsByTagName('head')[0].appendChild(style); +} + +describe('Word Cloud', () => { + beforeEach(() => { + cy.login(); + createQuery({ query: SQL }).then(({ id }) => { + cy.visit(`queries/${id}/source`); + cy.getByTestId('ExecuteButton').click(); + }); + cy.document().then(injectFont); + }); + + it('creates visualization with automatic word frequencies', () => { + cy.clickThrough(` + NewVisualization + VisualizationType + VisualizationType.WORD_CLOUD + + WordCloud.WordsColumn + WordCloud.WordsColumn.a + `); + + // Wait for proper initialization of visualization + cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting + + cy.getByTestId('VisualizationPreview').find('svg text').should('have.length', 11); + + cy.percySnapshot('Visualizations - Word Cloud (Automatic word frequencies)'); + }); + + it('creates visualization with word frequencies from another column', () => { + cy.clickThrough(` + NewVisualization + VisualizationType + VisualizationType.WORD_CLOUD + + WordCloud.WordsColumn + WordCloud.WordsColumn.b + + WordCloud.FrequenciesColumn + WordCloud.FrequenciesColumn.c + `); + + // Wait for proper initialization of visualization + cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting + + cy.getByTestId('VisualizationPreview').find('svg text').should('have.length', 5); + + cy.percySnapshot('Visualizations - Word Cloud (Frequencies from another column)'); + }); + + it('creates visualization with word length and frequencies limits', () => { + cy.clickThrough(` + NewVisualization + VisualizationType + VisualizationType.WORD_CLOUD + + WordCloud.WordsColumn + WordCloud.WordsColumn.b + + WordCloud.FrequenciesColumn + WordCloud.FrequenciesColumn.c + `); + + cy.fillInputs({ + 'WordCloud.WordLengthLimit.Min': '4', + 'WordCloud.WordLengthLimit.Max': '5', + 'WordCloud.WordCountLimit.Min': '1', + 'WordCloud.WordCountLimit.Max': '3', + }); + + // Wait for proper initialization of visualization + cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting + + cy.getByTestId('VisualizationPreview').find('svg text').should('have.length', 2); + + cy.percySnapshot('Visualizations - Word Cloud (With filters)'); + }); +}); diff --git a/client/cypress/support/commands.js b/client/cypress/support/commands.js index 83c2681ee5..97efba27f2 100644 --- a/client/cypress/support/commands.js +++ b/client/cypress/support/commands.js @@ -1,5 +1,9 @@ +/* global Cypress */ + import '@percy/cypress'; // eslint-disable-line import/no-extraneous-dependencies, import/no-unresolved +const { each } = Cypress._; + Cypress.Commands.add('login', (email = 'admin@redash.io', password = 'password') => cy.request({ url: '/login', method: 'POST', @@ -20,3 +24,9 @@ Cypress.Commands.add('clickThrough', (elements) => { .forEach(element => cy.getByTestId(element).click()); return undefined; }); + +Cypress.Commands.add('fillInputs', (elements) => { + each(elements, (value, testId) => { + cy.getByTestId(testId).clear().type(value); + }); +}); diff --git a/webpack.config.js b/webpack.config.js index f0bcf90221..a413079ea7 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -75,7 +75,8 @@ const config = { { from: "client/app/unsupported.html" }, { from: "client/app/unsupportedRedirect.js" }, { from: "client/app/assets/css/*.css", to: "styles/", flatten: true }, - { from: "node_modules/jquery/dist/jquery.min.js", to: "js/jquery.min.js" } + { from: "node_modules/jquery/dist/jquery.min.js", to: "js/jquery.min.js" }, + { from: "client/app/assets/fonts", to: "fonts/" }, ]) ], optimization: {