From 804145610e80c6d1f5b83230dd4b6541037fc86a Mon Sep 17 00:00:00 2001 From: barbara-chaves Date: Wed, 4 Oct 2023 12:58:25 +0200 Subject: [PATCH 1/3] Create countries bubble chart --- app/assets/stylesheets/tpi/_bubble-chart.scss | 51 +++++ .../tpi/charts/country-bubble/Chart.js | 201 ++++++++++++++++++ .../country-bubble/CompaniesAccordion.js | 160 ++++++++++++++ .../tpi/charts/country-bubble/SingleCell.js | 109 ++++++++++ .../tpi/charts/country-bubble/constants.js | 18 ++ .../tpi/charts/country-bubble/index.js | 4 + app/views/tpi/ascor/_bubble_chart.html.erb | 7 +- 7 files changed, 548 insertions(+), 2 deletions(-) create mode 100644 app/javascript/components/tpi/charts/country-bubble/Chart.js create mode 100644 app/javascript/components/tpi/charts/country-bubble/CompaniesAccordion.js create mode 100644 app/javascript/components/tpi/charts/country-bubble/SingleCell.js create mode 100644 app/javascript/components/tpi/charts/country-bubble/constants.js create mode 100644 app/javascript/components/tpi/charts/country-bubble/index.js diff --git a/app/assets/stylesheets/tpi/_bubble-chart.scss b/app/assets/stylesheets/tpi/_bubble-chart.scss index f5b1e6b70..b290bdca1 100644 --- a/app/assets/stylesheets/tpi/_bubble-chart.scss +++ b/app/assets/stylesheets/tpi/_bubble-chart.scss @@ -62,6 +62,28 @@ $legend-image-width: 60px; } } +.bubble-chart__cell-country { + position: relative; + height: $cell-height-banks; + display: flex; + align-items: center; + border-right: calc(#{$tape-height / 2}) dashed $tape-color; + + & > *:first-child { + margin: auto; + z-index: 1; + } + + &::before { + background-color: $tape-color; + content: ""; + position: absolute; + top: calc(50% - #{$tape-height / 2}); + height: $tape-height; + width: calc(100% + #{$tape-height / 2}); + } +} + .bubble-chart_circle { circle:hover { stroke-width: 14; @@ -69,6 +91,13 @@ $legend-image-width: 60px; } } +.bubble-chart_circle_country { + circle:hover { + stroke-width: 3; + stroke: $black!important; + } +} + .bubble-tip { font-size: 14px; padding: 10px; @@ -172,6 +201,28 @@ $legend-image-width: 60px; } } +.bubble-chart__level-country { + border-right: calc(#{$tape-height / 2}) dotted $tape-color; + position: relative; + padding-left: 20px; + height: 100%; +} + +.bubble-chart__level-title-country { + height: 100%; + font-family: $font-family-bold; + font-size: 16px; + color: $black; +} + +.bubble-chart__level-area-country { + font-family: $font-family-bold; + font-size: 16px; + color: $black; + text-align: end; + margin-right: 14px; +} + .bubble-chart__container--banks { .bubble-chart__level-title { font-family: $font-family-bold; diff --git a/app/javascript/components/tpi/charts/country-bubble/Chart.js b/app/javascript/components/tpi/charts/country-bubble/Chart.js new file mode 100644 index 000000000..05d741adf --- /dev/null +++ b/app/javascript/components/tpi/charts/country-bubble/Chart.js @@ -0,0 +1,201 @@ +import React, { useEffect } from 'react'; +import PropTypes from 'prop-types'; +import SingleCell from './SingleCell'; + +import { SCORE_RANGES } from './constants'; + +const SINGLE_CELL_SVG_WIDTH = 120; +const SINGLE_CELL_SVG_HEIGHT = 100; + +let tooltip = null; + +const BubbleChart = ({ results, disabled_bubbles_areas }) => { + const tooltipEl = ''; + useEffect(() => { + document.body.insertAdjacentHTML('beforeend', tooltipEl); + tooltip = document.getElementById('bubble-chart-tooltip'); + }, []); + const ranges = SCORE_RANGES.map((range) => range.value); + + const parsedData = {}; + const pillars = {}; + + results.forEach((result) => { + if (parsedData[result.area] === undefined) { + parsedData[result.area] = Array.from({ length: ranges.length }, () => []); + } + const rangeIndex = SCORE_RANGES.findIndex( + (range) => result.result === range.value + ); + if (rangeIndex >= 0) { + parsedData[result.area][rangeIndex].push({ + ...result, + color: SCORE_RANGES[rangeIndex].color + }); + } else { + console.error('WRONG INDEX', result); + } + if (pillars[result.pillar] === undefined) { + pillars[result.pillar] = [result.area]; + } else { + pillars[result.pillar] = pillars[result.pillar].includes(result.area) + ? pillars[result.pillar] + : [...pillars[result.pillar], result.area]; + } + }); + + return ( +
+
Pillar
+
Area
+
+ {ranges.map((range) => ( +
+
{range}
+
+ ))} + {Object.keys(parsedData).map((area) => createRow(parsedData[area], area, pillars, disabled_bubbles_areas))} +
+ ); +}; + +const ForceLayoutBubbleChart = (countriesBubbles, uniqueKey) => { + const handleBubbleClick = (country) => window.open(country.path, '_blank'); + + return ( + + ); +}; + +const getTooltipText = ({ tooltipContent }) => { + if (tooltipContent) { + return ` +
${tooltipContent.header}
+
${tooltipContent.value}
+ `; + } + return ''; +}; + +const showTooltip = (node, u) => { + const bubble = u._groups[0][node.index]; + + tooltip.innerHTML = getTooltipText(node); + tooltip.removeAttribute('hidden'); + const bubbleBoundingRect = bubble.getBoundingClientRect(); + const topOffset = bubbleBoundingRect.top - tooltip.offsetHeight + window.scrollY; + const leftOffset = bubbleBoundingRect.left + + (bubbleBoundingRect.width - tooltip.offsetWidth) / 2 + + window.scrollX; + + tooltip.style.left = `${leftOffset}px`; + tooltip.style.top = `${topOffset}px`; +}; + +const hideTooltip = () => { + tooltip.setAttribute('hidden', true); +}; + +const createRow = (dataRow, area, pillars, disabled_bubbles_areas) => { + const pillarEntries = Object.entries(pillars); + + const pillarIndex = pillarEntries.findIndex(([, value]) => value.includes(area)); + const pillar = pillarEntries[pillarIndex]; + + const pillarSpan = pillar && pillar[1].length; + const pillarName = pillar[0]; + const pillarAcronym = pillarName + .split(' ') + .map((word) => word[0]) + .join(''); + + const areaIndex = pillar && pillar[1].findIndex((el) => el === area); + + return ( + +
+ {pillarIndex + 1}. {pillarName} +
+ {areaIndex === 0 && ( +
+
1 && '8px solid #E8E8E8', + borderRight: 'none', + height: '100%' + }} + /> +
+ )} +
+ {pillarAcronym} {areaIndex + 1}. {area} +
+ {dataRow.map((el, i) => { + const countriesBubbles = disabled_bubbles_areas.includes(area) + ? [] + : el.map((result) => ({ + value: 10, + tooltipContent: { + header: result.country_name, + value: result.result + }, + path: result.country_path, + color: result.color, + result: result.result + })); + + // Remove special characters from the key to be able to use d3-select as it uses querySelector + const cleanKey = area.replace(/[^a-zA-Z\-_:.]/g, ''); + const uniqueKey = `${cleanKey}-${el.length}-${i}`; + + return ( +
+ {ForceLayoutBubbleChart(countriesBubbles, uniqueKey)} +
+ ); + })} + + ); +}; +BubbleChart.defaultProps = { + disabled_bubbles_areas: [] +}; + +BubbleChart.propTypes = { + results: PropTypes.arrayOf( + PropTypes.shape({ + area: PropTypes.string.isRequired, + market_cap_group: PropTypes.string.isRequired, + country_id: PropTypes.number.isRequired, + country_path: PropTypes.string.isRequired, + country_name: PropTypes.string.isRequired, + result: PropTypes.string.isRequired, + pillar: PropTypes.string.isRequired + }) + ).isRequired, + disabled_bubbles_areas: PropTypes.arrayOf(PropTypes.string) +}; +export default BubbleChart; diff --git a/app/javascript/components/tpi/charts/country-bubble/CompaniesAccordion.js b/app/javascript/components/tpi/charts/country-bubble/CompaniesAccordion.js new file mode 100644 index 000000000..ead12348c --- /dev/null +++ b/app/javascript/components/tpi/charts/country-bubble/CompaniesAccordion.js @@ -0,0 +1,160 @@ +import React, {useState} from 'react'; +import PropTypes from 'prop-types'; +import Select, { components } from 'react-select'; +import uniq from 'lodash/uniq'; +import sortBy from 'lodash/sortBy'; +import cx from 'classnames'; + +import chevronDownIconBlack from 'images/icon_chevron_dark/chevron_down_black-1.svg'; +import chevronUpIconBlack from 'images/icon_chevron_dark/chevron-up.svg'; + +import { SCORE_RANGES } from './constants'; + +const blueDarkColor = '#2E3152'; +const secondaryColor = '#828397'; + +const customSelectTheme = theme => ({ + ...theme, + colors: { + ...theme.colors, + primary50: 'inherit' + } +}); + +const customSelectStyles = { + option: (provided, state) => ({ + ...provided, + color: state.isSelected ? blueDarkColor : secondaryColor, + backgroundColor: state.isSelected ? 'inherit' : 'inherit', + fontWeight: state.isSelected ? '800' : 'normal' + }), + control: (provided) => ({ + ...provided, + border: '1px solid', + boxShadow: '1px solid #2E3152', + borderRadius: 0 + }), + indicatorSeparator: () => ({ + display: 'none' + }), + menu: (provided) => ({ + ...provided, + margin: 0, + borderRadius: 0 + }), + container: (provided) => ({ + ...provided, + margin: '0 0.75rem 1.2rem' + }) +}; + +const DropdownIndicator = (props) => ( + + Accordion toggle + +); + +DropdownIndicator.propTypes = { + selectProps: PropTypes.object.isRequired +}; + +const CompaniesAccordion = ({ results, disabled_bubbles_areas }) => { + const areas = uniq(results.map(r => r.area)); + const selectOptions = areas.map((level) => ({label: level, value: level})); + const [openItems, setOpenItems] = useState([]); + const [activeOption, setActiveOption] = useState(selectOptions[0]); + + const ranges = SCORE_RANGES.map((range) => `${range.min}-${range.max}%`); + + const parsedData = {}; + results.forEach((result) => { + if (parsedData[result.area] === undefined) { + parsedData[result.area] = Array.from({ length: ranges.length }, () => []); + } + const rangeIndex = SCORE_RANGES.findIndex((range) => result.percentage >= range.min && result.percentage <= range.max); + if (rangeIndex >= 0) { + parsedData[result.area][rangeIndex].push({ + ...result, + color: SCORE_RANGES[rangeIndex].color + }); + } else { + console.error('WRONG INDEX', result); + } + }); + + const activeArea = disabled_bubbles_areas.includes(activeOption.value) + ? Array.from({ length: ranges.length }, () => []) + : parsedData[activeOption.value]; + + function setOpenItemByIndex(index) { + setOpenItems(openItems.includes(index) ? openItems.filter(i => i !== index) : [...openItems, index]); + } + + return ( +
+ { setActiveOption(e); }} - isSearchable={false} - styles={customSelectStyles} - components={{DropdownIndicator}} - theme={customSelectTheme} - /> - -
- {SCORE_RANGES.map((range, i) => ( -
-
setOpenItemByIndex(i)}> -
-
Score Range
-
{activeArea[i].length} {activeArea[i].length === 1 ? 'bank' : 'banks'}
-
-
{range.min}-{range.max}%
-
-
-
- {activeArea[i].length === 0 &&
No banks
} - {activeArea[i].length > 0 && ( -
    - {sortBy(activeArea[i], 'bank_name').map((bank, index) => ( -
  • { window.location.href = bank.bank_path; }} - > - {bank.bank_name} -
  • - ))} -
- )} -
-
- ))} -
-
- ); -}; - -CompaniesAccordion.defaultProps = { - disabled_bubbles_areas: [] -}; - -CompaniesAccordion.propTypes = { - results: PropTypes.arrayOf(PropTypes.shape({ - area: PropTypes.string.isRequired, - market_cap_group: PropTypes.string.isRequired, - percentage: PropTypes.number.isRequired, - bank_id: PropTypes.number.isRequired, - bank_name: PropTypes.string.isRequired, - bank_path: PropTypes.string.isRequired - })).isRequired, - disabled_bubbles_areas: PropTypes.arrayOf(PropTypes.string) -}; -export default CompaniesAccordion; diff --git a/app/javascript/components/tpi/charts/ascor-bubble/index.js b/app/javascript/components/tpi/charts/ascor-bubble/index.js index 8b9f31d85..9ec02597f 100644 --- a/app/javascript/components/tpi/charts/ascor-bubble/index.js +++ b/app/javascript/components/tpi/charts/ascor-bubble/index.js @@ -1,4 +1,3 @@ import Chart from './Chart'; -import CompaniesAccordion from './CompaniesAccordion'; -export {Chart, CompaniesAccordion}; +export { Chart };