From 2625698f1fb5dd87e5845f521dac37d58b006214 Mon Sep 17 00:00:00 2001 From: Victor Malai Date: Mon, 8 Mar 2021 11:51:43 +0200 Subject: [PATCH] feat: change font size on responsive for sankey and sunburst chart (#977) * feat: change font size on responsive * remove logs * Sunburst * WIP * Sankey label hide * Format correct object * add styled components * Replace document with container * Return overlapping elements * Drag * Fix lint * Fix test & make tooltip absoliute based on cursor position * Update storybook for charts * Resizable in separate page * Fix responsive class for sunburst * type --- .../legacy-plugin-chart-sankey/Stories.tsx | 17 +++ .../legacy-plugin-chart-sunburst/Stories.tsx | 19 ++++ .../src/ReactSankey.jsx | 10 +- .../legacy-plugin-chart-sankey/src/Sankey.js | 31 +++++- .../src/tests/utils.test.js | 93 ++++++++++++++++ .../src/transformProps.js | 3 + .../legacy-plugin-chart-sankey/src/utils.ts | 74 +++++++++++++ .../src/Sunburst.css | 27 +++-- .../src/Sunburst.js | 104 ++++++++++++++---- 9 files changed, 332 insertions(+), 46 deletions(-) create mode 100644 superset-frontend/temporary_superset_ui/superset-ui/plugins/legacy-plugin-chart-sankey/src/tests/utils.test.js create mode 100644 superset-frontend/temporary_superset_ui/superset-ui/plugins/legacy-plugin-chart-sankey/src/utils.ts diff --git a/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-demo/storybook/stories/plugins/legacy-plugin-chart-sankey/Stories.tsx b/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-demo/storybook/stories/plugins/legacy-plugin-chart-sankey/Stories.tsx index 0a7f01a05957..6af47a80b0c2 100644 --- a/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-demo/storybook/stories/plugins/legacy-plugin-chart-sankey/Stories.tsx +++ b/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-demo/storybook/stories/plugins/legacy-plugin-chart-sankey/Stories.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { SuperChart } from '@superset-ui/core'; import SankeyChartPlugin from '@superset-ui/legacy-plugin-chart-sankey'; +import ResizableChartDemo from '../../../shared/components/ResizableChartDemo'; import data from './data'; new SankeyChartPlugin().configure({ key: 'sankey' }).register(); @@ -21,3 +22,19 @@ export const basic = () => ( }} /> ); + +export const resizable = () => ( + + {({ width, height }) => ( + + )} + +); diff --git a/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-demo/storybook/stories/plugins/legacy-plugin-chart-sunburst/Stories.tsx b/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-demo/storybook/stories/plugins/legacy-plugin-chart-sunburst/Stories.tsx index ce4153c8d90d..56c5422f2ce0 100644 --- a/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-demo/storybook/stories/plugins/legacy-plugin-chart-sunburst/Stories.tsx +++ b/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-demo/storybook/stories/plugins/legacy-plugin-chart-sunburst/Stories.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { SuperChart } from '@superset-ui/core'; import SunburstChartPlugin from '@superset-ui/legacy-plugin-chart-sunburst'; +import ResizableChartDemo from '../../../shared/components/ResizableChartDemo'; import data from './data'; new SunburstChartPlugin().configure({ key: 'sunburst' }).register(); @@ -23,3 +24,21 @@ export const basic = () => ( }} /> ); + +export const resizable = () => ( + + {({ width, height }) => ( + + )} + +); diff --git a/superset-frontend/temporary_superset_ui/superset-ui/plugins/legacy-plugin-chart-sankey/src/ReactSankey.jsx b/superset-frontend/temporary_superset_ui/superset-ui/plugins/legacy-plugin-chart-sankey/src/ReactSankey.jsx index 228c9ed6331c..b8c6278f069e 100644 --- a/superset-frontend/temporary_superset_ui/superset-ui/plugins/legacy-plugin-chart-sankey/src/ReactSankey.jsx +++ b/superset-frontend/temporary_superset_ui/superset-ui/plugins/legacy-plugin-chart-sankey/src/ReactSankey.jsx @@ -44,6 +44,7 @@ export default styled(SankeyComponent)` text { pointer-events: none; text-shadow: 0 1px 0 #fff; + font-size: ${({ fontSize }) => fontSize}em; } } .link { @@ -54,15 +55,18 @@ export default styled(SankeyComponent)` stroke-opacity: 0.5; } } + .opacity-0 { + opacity: 0; + } } - .superset-legacy-chart-sankey-tooltip { + .sankey-tooltip { position: absolute; width: auto; background: #ddd; padding: 10px; - font-size: ${({ theme }) => theme.typography.sizes.s}; + font-size: ${({ fontSize }) => fontSize}em; font-weight: ${({ theme }) => theme.typography.weights.light}; - color: #333; + color: #000; border: 1px solid #fff; text-align: center; pointer-events: none; diff --git a/superset-frontend/temporary_superset_ui/superset-ui/plugins/legacy-plugin-chart-sankey/src/Sankey.js b/superset-frontend/temporary_superset_ui/superset-ui/plugins/legacy-plugin-chart-sankey/src/Sankey.js index 23cbd31b1cb1..f7a2ee0305e5 100644 --- a/superset-frontend/temporary_superset_ui/superset-ui/plugins/legacy-plugin-chart-sankey/src/Sankey.js +++ b/superset-frontend/temporary_superset_ui/superset-ui/plugins/legacy-plugin-chart-sankey/src/Sankey.js @@ -22,6 +22,7 @@ import d3 from 'd3'; import PropTypes from 'prop-types'; import { sankey as d3Sankey } from 'd3-sankey'; import { getNumberFormatter, NumberFormats, CategoricalColorNamespace } from '@superset-ui/core'; +import { getOverlappingElements } from './utils'; const propTypes = { data: PropTypes.arrayOf( @@ -40,9 +41,8 @@ const formatNumber = getNumberFormatter(NumberFormats.FLOAT); function Sankey(element, props) { const { data, width, height, colorScheme } = props; - const div = d3.select(element); - div.classed('superset-legacy-chart-sankey', true); + div.classed(`superset-legacy-chart-sankey`, true); const margin = { top: 5, right: 5, @@ -53,6 +53,7 @@ function Sankey(element, props) { const innerHeight = height - margin.top - margin.bottom; div.selectAll('*').remove(); + const tooltip = div.append('div').attr('class', 'sankey-tooltip').style('opacity', 0); const svg = div .append('svg') .attr('width', innerWidth + margin.left + margin.right) @@ -60,8 +61,6 @@ function Sankey(element, props) { .append('g') .attr('transform', `translate(${margin.left},${margin.top})`); - const tooltip = div.append('div').attr('class', 'sankey-tooltip').style('opacity', 0); - const colorFn = CategoricalColorNamespace.getScale(colorScheme); const sankey = d3Sankey().nodeWidth(15).nodePadding(10).size([innerWidth, innerHeight]); @@ -119,6 +118,7 @@ function Sankey(element, props) { .duration(200) .style('left', `${d3.event.offsetX + 10}px`) .style('top', `${d3.event.offsetY + 10}px`) + .style('position', 'absolute') .style('opacity', 0.95); } @@ -148,6 +148,23 @@ function Sankey(element, props) { link.attr('d', path); } + function checkVisibility() { + const elements = div.selectAll('.node')[0] ?? []; + const overlappingElements = getOverlappingElements(elements); + + elements.forEach(el => { + const text = el.getElementsByTagName('text')[0]; + + if (text) { + if (overlappingElements.includes(el)) { + text.classList.add('opacity-0'); + } else { + text.classList.remove('opacity-0'); + } + } + }); + } + const node = svg .append('g') .selectAll('.node') @@ -163,7 +180,8 @@ function Sankey(element, props) { .on('dragstart', function dragStart() { this.parentNode.append(this); }) - .on('drag', dragmove), + .on('drag', dragmove) + .on('dragend', checkVisibility), ); const minRectHeight = 5; node @@ -188,9 +206,12 @@ function Sankey(element, props) { .attr('text-anchor', 'end') .attr('transform', null) .text(d => d.name) + .attr('class', 'opacity-0') .filter(d => d.x < innerWidth / 2) .attr('x', 6 + sankey.nodeWidth()) .attr('text-anchor', 'start'); + + checkVisibility(); } Sankey.displayName = 'Sankey'; diff --git a/superset-frontend/temporary_superset_ui/superset-ui/plugins/legacy-plugin-chart-sankey/src/tests/utils.test.js b/superset-frontend/temporary_superset_ui/superset-ui/plugins/legacy-plugin-chart-sankey/src/tests/utils.test.js new file mode 100644 index 000000000000..d679d6bbc6c8 --- /dev/null +++ b/superset-frontend/temporary_superset_ui/superset-ui/plugins/legacy-plugin-chart-sankey/src/tests/utils.test.js @@ -0,0 +1,93 @@ +/** + * 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 { getOverlappingElements, isOverlapping } from '../utils'; + +const overlapRects = [ + { + x: 10, + y: 10, + width: 10, + height: 10, + }, + { + x: 12, + y: 12, + width: 12, + height: 12, + }, + { + x: 32, + y: 32, + width: 32, + height: 32, + }, +]; + +const notOverlapRects = [ + { + x: 10, + y: 10, + width: 10, + height: 10, + }, + { + x: 24, + y: 15, + width: 15, + height: 15, + }, + { + x: 32, + y: 32, + width: 32, + height: 32, + }, +]; + +const createSVGs = objects => + objects.map(data => { + const el = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + el.getBoundingClientRect = jest.fn(() => data); + + return el; + }); + +// https://www.khanacademy.org/computer-programming/rectx-y-width-height-radius/839496660 +describe('legacy-plugin-chart-sankey/utils', () => { + it('isOverlapping to be truthy', () => { + const [rect1, rect2] = overlapRects; + expect(isOverlapping(rect1, rect2)).toBeTruthy(); + }); + + it('isOverlapping to be falsy', () => { + const [rect1, rect2] = notOverlapRects; + expect(isOverlapping(rect1, rect2)).toBeFalsy(); + }); + + it('getOverlappingElements to be truthy', () => { + const elements = createSVGs(overlapRects); + expect(getOverlappingElements(elements).length).toBe(2); + }); + + it('getOverlappingElements to be falsy', () => { + const elements = createSVGs(notOverlapRects); + expect(getOverlappingElements(elements).length).toBe(0); + }); +}); diff --git a/superset-frontend/temporary_superset_ui/superset-ui/plugins/legacy-plugin-chart-sankey/src/transformProps.js b/superset-frontend/temporary_superset_ui/superset-ui/plugins/legacy-plugin-chart-sankey/src/transformProps.js index 938d30f9df77..5297994fb952 100644 --- a/superset-frontend/temporary_superset_ui/superset-ui/plugins/legacy-plugin-chart-sankey/src/transformProps.js +++ b/superset-frontend/temporary_superset_ui/superset-ui/plugins/legacy-plugin-chart-sankey/src/transformProps.js @@ -16,6 +16,8 @@ * specific language governing permissions and limitations * under the License. */ +import { getLabelFontSize } from './utils'; + export default function transformProps(chartProps) { const { width, height, formData, queriesData } = chartProps; const { colorScheme } = formData; @@ -25,5 +27,6 @@ export default function transformProps(chartProps) { height, data: queriesData[0].data, colorScheme, + fontSize: getLabelFontSize(width), }; } diff --git a/superset-frontend/temporary_superset_ui/superset-ui/plugins/legacy-plugin-chart-sankey/src/utils.ts b/superset-frontend/temporary_superset_ui/superset-ui/plugins/legacy-plugin-chart-sankey/src/utils.ts new file mode 100644 index 000000000000..b84afe89eba2 --- /dev/null +++ b/superset-frontend/temporary_superset_ui/superset-ui/plugins/legacy-plugin-chart-sankey/src/utils.ts @@ -0,0 +1,74 @@ +/** + * 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. + */ +type Rect = { + x: number; + y: number; + width: number; + height: number; +}; + +export function getLabelFontSize(width: number): number { + if (width > 550) { + return 0.8; + } + + if (width > 400 && width <= 550) { + return 0.55; + } + + return 0.45; +} + +export const isOverlapping = (rect1: Rect, rect2: Rect): boolean => { + const { x: x1, y: y1, width: width1, height: height1 } = rect1; + const { x: x2, y: y2, width: width2, height: height2 } = rect2; + + return !(x1 > x2 + width2 || x1 + width1 < x2 || y1 > y2 + height2 || y1 + height1 < y2); +}; + +export const getRectangle = (element: SVGElement, offset = 0): Rect => { + const { x, y, width, height } = element.getBoundingClientRect(); + + return { + x, + y: y + offset, + width, + height: height - offset * 2, + }; +}; + +export const getOverlappingElements = (elements: SVGElement[]): SVGElement[] => { + const overlappingElements: SVGElement[] = []; + + elements.forEach((e1, index1) => { + const rect1: Rect = getRectangle(e1, 1); + + elements.forEach((e2, index2) => { + if (index2 <= index1) return; + const rect2: Rect = getRectangle(e2, 1); + + if (isOverlapping(rect1, rect2)) { + overlappingElements.push(elements[index2]); + overlappingElements.push(elements[index1]); + } + }); + }); + + return overlappingElements; +}; diff --git a/superset-frontend/temporary_superset_ui/superset-ui/plugins/legacy-plugin-chart-sunburst/src/Sunburst.css b/superset-frontend/temporary_superset_ui/superset-ui/plugins/legacy-plugin-chart-sunburst/src/Sunburst.css index 2f836ac48db0..0afe0a87951c 100644 --- a/superset-frontend/temporary_superset_ui/superset-ui/plugins/legacy-plugin-chart-sunburst/src/Sunburst.css +++ b/superset-frontend/temporary_superset_ui/superset-ui/plugins/legacy-plugin-chart-sunburst/src/Sunburst.css @@ -16,7 +16,6 @@ * specific language governing permissions and limitations * under the License. */ - .superset-legacy-chart-sunburst text { text-rendering: optimizeLegibility; } @@ -51,21 +50,21 @@ } /* dashboard specific */ -/* -.dashboard .superset-legacy-chart-sunburst text { - font-size: 1em; +.dashboard-chart.sunburst { + overflow: visible; } -.dashboard .superset-legacy-chart-sunburst .path-abs-percent { - font-size: 2em; - font-weight: 700; +.superset-legacy-chart-sunburst svg { + overflow: visible; } -.dashboard .superset-legacy-chart-sunburst .path-cond-percent { - font-size: 1.5em; +.superset-legacy-chart-sunburst.m text { + font-size: 0.55em; } -.dashboard .superset-legacy-chart-sunburst .path-metrics { - font-size: 1em; +.superset-legacy-chart-sunburst.s text { + font-size: 0.45em; } -.dashboard .superset-legacy-chart-sunburst .path-ratio { - font-size: 1em; +.superset-legacy-chart-sunburst.l text { + font-size: 0.75em; +} +.superset-legacy-chart-sunburst .path-abs-percent { + font-weight: 700; } -*/ diff --git a/superset-frontend/temporary_superset_ui/superset-ui/plugins/legacy-plugin-chart-sunburst/src/Sunburst.js b/superset-frontend/temporary_superset_ui/superset-ui/plugins/legacy-plugin-chart-sunburst/src/Sunburst.js index e5be0f00309d..88dd7464e173 100644 --- a/superset-frontend/temporary_superset_ui/superset-ui/plugins/legacy-plugin-chart-sunburst/src/Sunburst.js +++ b/superset-frontend/temporary_superset_ui/superset-ui/plugins/legacy-plugin-chart-sunburst/src/Sunburst.js @@ -131,12 +131,37 @@ function buildHierarchy(rows) { return root; } +function getResponsiveContainerClass(width) { + if (width > 500) { + return 'l'; + } + + if (width > 200 && width <= 500) { + return 'm'; + } + + return 's'; +} + +function getYOffset(width) { + if (width > 500) { + return ['0', '20', '40', '60']; + } + + if (width > 200 && width <= 500) { + return ['0', '15', '30', '45']; + } + + return ['0', '10', '20', '30']; +} + // Modified from http://bl.ocks.org/kerryrodden/7090426 function Sunburst(element, props) { const container = d3.select(element); - container.classed('superset-legacy-chart-sunburst', true); const { data, width, height, colorScheme, linearColorScheme, metrics, numberFormat } = props; - + const responsiveClass = getResponsiveContainerClass(width); + const isSmallWidth = responsiveClass === 's'; + container.attr('class', `superset-legacy-chart-sunburst ${responsiveClass}`); // vars with shared scope within this function const margin = { top: 10, right: 5, bottom: 10, left: 5 }; const containerWidth = width; @@ -202,22 +227,35 @@ function Sunburst(element, props) { // Generate a string that describes the points of a breadcrumb polygon. function breadcrumbPoints(d, i) { const points = []; - points.push('0,0'); - points.push(`${breadcrumbDims.width},0`); - points.push( - `${breadcrumbDims.width + breadcrumbDims.tipTailWidth},${breadcrumbDims.height / 2}`, - ); - points.push(`${breadcrumbDims.width},${breadcrumbDims.height}`); - points.push(`0,${breadcrumbDims.height}`); - if (i > 0) { - // Leftmost breadcrumb; don't include 6th vertex. - points.push(`${breadcrumbDims.tipTailWidth},${breadcrumbDims.height / 2}`); + if (isSmallWidth) { + points.push('0,0'); + points.push(`${width},0`); + points.push(`${width},0`); + points.push(`${width},${breadcrumbDims.height}`); + points.push(`0,${breadcrumbDims.height}`); + if (i > 0) { + // Leftmost breadcrumb; don't include 6th vertex. + // points.push(`${breadcrumbDims.tipTailWidth},${breadcrumbDims.height / 2}`); + } + } else { + points.push('0,0'); + points.push(`${breadcrumbDims.width},0`); + points.push( + `${breadcrumbDims.width + breadcrumbDims.tipTailWidth},${breadcrumbDims.height / 2}`, + ); + points.push(`${breadcrumbDims.width},${breadcrumbDims.height}`); + points.push(`0,${breadcrumbDims.height}`); + if (i > 0) { + // Leftmost breadcrumb; don't include 6th vertex. + points.push(`${breadcrumbDims.tipTailWidth},${breadcrumbDims.height / 2}`); + } } return points.join(' '); } function updateBreadcrumbs(sequenceArray, percentageString) { + const breadcrumbWidth = isSmallWidth ? width : breadcrumbDims.width; const g = breadcrumbs.selectAll('g').data(sequenceArray, d => d.name + d.depth); // Add breadcrumb and label for entering nodes. @@ -232,9 +270,9 @@ function Sunburst(element, props) { entering .append('svg:text') - .attr('x', (breadcrumbDims.width + breadcrumbDims.tipTailWidth) / 2) + .attr('x', (breadcrumbWidth + breadcrumbDims.tipTailWidth) / 2) .attr('y', breadcrumbDims.height / 4) - .attr('dy', '0.85em') + .attr('dy', '0.35em') .style('fill', d => { // Make text white or black based on the lightness of the background const col = d3.hsl( @@ -245,13 +283,15 @@ function Sunburst(element, props) { }) .attr('class', 'step-label') .text(d => d.name.replace(/_/g, ' ')) - .call(wrapSvgText, breadcrumbDims.width, breadcrumbDims.height / 2); + .call(wrapSvgText, breadcrumbWidth, breadcrumbDims.height / 2); // Set position for entering and updating nodes. - g.attr( - 'transform', - (d, i) => `translate(${i * (breadcrumbDims.width + breadcrumbDims.spacing)}, 0)`, - ); + g.attr('transform', (d, i) => { + if (isSmallWidth) { + return `translate(0, ${i * (breadcrumbDims.height + breadcrumbDims.spacing)})`; + } + return `translate(${i * (breadcrumbDims.width + breadcrumbDims.spacing)}, 0)`; + }); // Remove exiting nodes. g.exit().remove(); @@ -259,8 +299,20 @@ function Sunburst(element, props) { // Now move and update the percentage at the end. breadcrumbs .select('.end-label') - .attr('x', (sequenceArray.length + 0.5) * (breadcrumbDims.width + breadcrumbDims.spacing)) - .attr('y', breadcrumbDims.height / 2) + .attr('x', () => { + if (isSmallWidth) { + return (breadcrumbWidth + breadcrumbDims.tipTailWidth) / 2; + } + + return (sequenceArray.length + 0.5) * (breadcrumbDims.width + breadcrumbDims.spacing); + }) + .attr('y', () => { + if (isSmallWidth) { + return (sequenceArray.length + 1) * breadcrumbDims.height; + } + + return breadcrumbDims.height / 2; + }) .attr('dy', '0.35em') .text(percentageString); @@ -280,7 +332,7 @@ function Sunburst(element, props) { const conditionalPercString = parentOfD ? formatPerc(conditionalPercentage) : ''; // 3 levels of text if inner-most level, 4 otherwise - const yOffsets = ['-25', '7', '35', '60']; + const yOffsets = getYOffset(width); let offsetIndex = 0; // If metrics match, assume we are coloring by category @@ -365,7 +417,7 @@ function Sunburst(element, props) { // Main function to draw and set up the visualization, once we have the data. function createVisualization(rows) { const root = buildHierarchy(rows); - + maxBreadcrumbs = rows[0].length - 2; vis = svg .append('svg:g') .attr('class', 'sunburst-vis') @@ -373,7 +425,11 @@ function Sunburst(element, props) { 'transform', 'translate(' + `${margin.left + visWidth / 2},` + - `${margin.top + breadcrumbHeight + visHeight / 2}` + + `${ + margin.top + + (isSmallWidth ? breadcrumbHeight * maxBreadcrumbs : breadcrumbHeight) + + visHeight / 2 + }` + ')', ) .on('mouseleave', mouseleave);