Skip to content

Commit

Permalink
Merge branch 'develop' into feature/ascor-assessment-results
Browse files Browse the repository at this point in the history
  • Loading branch information
martintomas committed Oct 5, 2023
2 parents c0b162c + 4990e5c commit 8994a0c
Show file tree
Hide file tree
Showing 8 changed files with 404 additions and 5 deletions.
51 changes: 51 additions & 0 deletions app/assets/stylesheets/tpi/_bubble-chart.scss
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,42 @@ $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;
stroke: $black;
}
}

.bubble-chart_circle_country {
circle:hover {
stroke-width: 3;
stroke: $black!important;
}
}

.bubble-tip {
font-size: 14px;
padding: 10px;
Expand Down Expand Up @@ -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;
Expand Down
210 changes: 210 additions & 0 deletions app/javascript/components/tpi/charts/ascor-bubble/Chart.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import SingleCell from './SingleCell';

import { SCORE_RANGES } from './constants';

const SCALE = 1.25;

// radius of bubbles
const COMPANIES_MARKET_CAP_GROUPS = {
large: 10 * SCALE,
medium: 5 * SCALE,
small: 3 * SCALE
};

const SINGLE_CELL_SVG_WIDTH = 120;
const SINGLE_CELL_SVG_HEIGHT = 100;

let tooltip = null;

const BubbleChart = ({ results, disabled_bubbles_areas }) => {
const tooltipEl = '<div id="bubble-chart-tooltip" class="bubble-tip" hidden style="position:absolute;"></div>';
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 (
<div
className="bubble-chart__container bubble-chart__container is-hidden-touch"
style={{ gridTemplateColumns: '0.5fr 0.5fr 1.5fr 1fr 1fr 1fr' }}
>
<div className="bubble-chart__level-title-country">Pillar</div>
<div className="bubble-chart__level-title-country">Area</div>
<div />
{ranges.map((range) => (
<div className="bubble-chart__level-country" key={range}>
<div className="bubble-chart__level-title-country">{range}</div>
</div>
))}
{Object.keys(parsedData).map((area) => createRow(parsedData[area], area, pillars, disabled_bubbles_areas))}
</div>
);
};

const ForceLayoutBubbleChart = (countriesBubbles, uniqueKey) => {
const handleBubbleClick = (country) => window.open(country.path, '_blank');

return (
<SingleCell
width={SINGLE_CELL_SVG_WIDTH}
height={SINGLE_CELL_SVG_HEIGHT}
uniqueKey={uniqueKey}
handleNodeClick={handleBubbleClick}
showTooltip={showTooltip}
hideTooltip={hideTooltip}
data={countriesBubbles.length && countriesBubbles}
/>
);
};

const getTooltipText = ({ tooltipContent }) => {
if (tooltipContent) {
return `
<div class="bubble-tip-header">${tooltipContent.header}</div>
<div class="bubble-tip-text">${tooltipContent.value}</div>
`;
}
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 (
<React.Fragment key={Math.random()}>
<div
className="bubble-chart__level-area-country"
style={{
gridRow: `span ${pillarSpan}`,
display: areaIndex === 0 ? 'block' : 'none'
}}
>
{pillarIndex + 1}.&nbsp;{pillarName}
</div>
{areaIndex === 0 && (
<div
style={{
gridRow: `span ${pillarSpan}`,
height: '100%',
padding: '46px 0 46px'
}}
>
<div
style={{
border: pillarSpan > 1 && '8px solid #E8E8E8',
borderRight: 'none',
height: '100%'
}}
/>
</div>
)}
<div className="bubble-chart__level-area-country">
{pillarAcronym} {areaIndex + 1}. {area}
</div>
{dataRow.map((el, i) => {
const countriesBubbles = disabled_bubbles_areas.includes(area)
? []
: el.map((result) => ({
value: COMPANIES_MARKET_CAP_GROUPS[result.market_cap_group],
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 (
<div className="bubble-chart__cell-country" key={uniqueKey}>
{ForceLayoutBubbleChart(countriesBubbles, uniqueKey)}
</div>
);
})}
</React.Fragment>
);
};
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;
109 changes: 109 additions & 0 deletions app/javascript/components/tpi/charts/ascor-bubble/SingleCell.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { range } from 'd3-array';
import { select } from 'd3-selection';
import * as d3 from 'd3-force';

import { partialGradient } from './constants';

const SingleCell = ({
width,
height,
handleNodeClick,
data,
uniqueKey,
showTooltip,
hideTooltip
}) => {
const computizedKey = uniqueKey.split(' ').join('_');
const key = `${computizedKey.replace(/[&]/g, '_')}-${(
Math.random() * 100
).toFixed()}`;

const nodes = range(data.length).map(function (index) {
return {
color: data[index].color,
tooltipContent: data[index].tooltipContent,
path: data[index].path,
radius: data[index].value,
value: data[index].result
};
});

const simulation = () => {
d3.forceSimulation(nodes)
.force('charge', d3.forceManyBody().strength(60))
.force('y', d3.forceY().strength(0.45).y(0))
.force(
'collision',
d3.forceCollide().radius(function (d) {
return d.radius + 1;
})
)
.on('tick', ticked);
};

const ticked = () => {
const u = select(`#${key}`).select('g').selectAll('circle').data(nodes);

u.enter()
.append('circle')
.attr('r', (d) => d.radius)
.style('fill', (d) => (d.value === 'Partial' ? 'url(#partial-gradient)' : d.color))
.style('stroke', (d) => d.color)
.merge(u)
.attr('cx', (d) => d.x)
.attr('cy', (d) => d.y)
.on('mouseover', (d) => showTooltip(d, u))
.on('mouseout', hideTooltip)
.on('click', (d) => handleNodeClick(d));

u.exit().remove();
};

simulation();

return (
<Fragment>
<svg
id={key}
width="100%"
height="100%"
viewBox={`0 0 ${width} ${height}`}
>
<defs>
<pattern
id="partial-gradient"
patternUnits="userSpaceOnUse"
width="2"
height="8"
patternTransform="rotate(90)"
>
<rect
width="1"
height="8"
transform="translate(0,0)"
fill={partialGradient.color}
/>
</pattern>
</defs>
<g
className="bubble-chart_circle_country"
transform={`translate(${width / 2}, ${height / 2})`}
/>
</svg>
</Fragment>
);
};

SingleCell.propTypes = {
showTooltip: PropTypes.func.isRequired,
hideTooltip: PropTypes.func.isRequired,
width: PropTypes.number.isRequired,
height: PropTypes.number.isRequired,
handleNodeClick: PropTypes.func.isRequired,
data: PropTypes.oneOfType([PropTypes.number, PropTypes.array]).isRequired,
uniqueKey: PropTypes.string.isRequired
};

export default SingleCell;
Loading

0 comments on commit 8994a0c

Please sign in to comment.