Merge remote-tracking branch 'jar0s/feature/radar-chart'

This commit is contained in:
Bastian Ike
2022-01-07 13:25:05 +01:00
28 changed files with 1238 additions and 46 deletions

View File

@@ -0,0 +1,46 @@
import React, { useRef, useLayoutEffect } from 'react';
import * as d3 from "d3";
export const YAxis: React.FC<{
scale: d3.ScaleLinear<number, number>
}> = ({ scale }) => {
const ref = useRef<SVGSVGElement>(null);
useLayoutEffect(() => {
if (ref.current == null) {
return
}
const axisGenerator = d3.axisLeft(scale).ticks(6);
d3.select(ref.current)
.attr('class', 'y-axis')
.call(axisGenerator)
.call(g => g.selectAll('.tick text').remove())
.call(g => g.selectAll('.tick line').remove())
.call(g => g.selectAll('.domain').remove());
}, [scale]);
return <g ref={ref} />;
};
export const XAxis: React.FC<{
scale: d3.ScaleLinear<number, number>
}> = ({ scale }) => {
const ref = useRef<SVGSVGElement>(null);
useLayoutEffect(() => {
if (ref.current == null) {
return
}
const axisGenerator = d3.axisBottom(scale).ticks(6);
d3.select(ref.current)
.attr('class', 'x-axis')
.call(axisGenerator)
.call(g => g.selectAll('.tick text').remove())
.call(g => g.selectAll('.tick line').remove())
.call(g => g.selectAll('.domain').remove());
}, [scale]);
return <g ref={ref} />;
};

View File

@@ -0,0 +1,126 @@
import React from 'react';
import { ScaleLinear } from 'd3';
import { FlagType, Item, Blip, Point } from '../../model';
import Link from '../Link/Link';
import { NewBlip, ChangedBlip, DefaultBlip } from './BlipShapes';
import { ConfigData } from '../../config';
/*
See https://medium.com/create-code/build-a-radar-diagram-with-d3-js-9db6458a9248
for a good explanation of formulas used to calculate various things in this component
*/
function generateCoordinates(blip: Blip, xScale: ScaleLinear<number, number>, yScale: ScaleLinear<number, number>, config: ConfigData): Point {
const pi = Math.PI,
ringRadius = config.chartConfig.ringsAttributes[blip.ringPosition].radius,
previousRingRadius = blip.ringPosition === 0 ? 0 : config.chartConfig.ringsAttributes[blip.ringPosition - 1].radius,
ringPadding = 0.7;
// radian between 0 and 90 degrees
const randomDegree = ((Math.random() * 90) * pi) / 180;
// random distance from the centre of the radar, but within given ring. Also, with some "padding" so the points don't touch ring borders.
const radius = randomBetween(previousRingRadius + ringPadding, ringRadius - ringPadding);
/*
Multiples of PI/2. To apply the calculated position to the specific quadrant.
Order here is counter-clockwise, so we need to "invert" quadrant positions (i.e. swap 2 with 4)
*/
const shift = pi * [1, 4, 3, 2][blip.quadrantPosition - 1] / 2;
return {
x: xScale(Math.cos(randomDegree + shift) * radius),
y: yScale(Math.sin(randomDegree + shift) * radius)
};
};
function randomBetween (min: number, max: number): number {
return Math.random() * (max - min) + min;
};
function distanceBetween(point1: Point, point2: Point): number {
const a = point2.x - point1.x;
const b = point2.y - point1.y;
return Math.sqrt((a * a) + (b * b));
};
function renderBlip(blip: Blip, index: number, config: ConfigData): JSX.Element {
const props = {
blip,
className: 'blip',
fill: blip.colour,
'data-background-color': blip.colour,
'data-text-color': blip.txtColour,
'data-tip': blip.title,
key: index
}
switch (blip.flag) {
case FlagType.new:
return <NewBlip {...props} config={config} />;
case FlagType.changed:
return <ChangedBlip {...props} config={config} />;
default:
return <DefaultBlip {...props} config={config} />;
}
};
const BlipPoints: React.FC<{
items: Item[]
xScale:ScaleLinear<number, number>
yScale:ScaleLinear<number, number>
config:ConfigData
}> = ({items, xScale, yScale, config}) => {
const blips: Blip[] = items.reduce((list: Blip[], item: Item) => {
if (!item.ring || !item.quadrant) {
// skip the blip if it doesn't have a ring or quadrant assigned
return list;
}
const quadrantConfig = config.quadrantsMap[item.quadrant];
if (!quadrantConfig) {
return list;
}
let blip: Blip = { ...item,
quadrantPosition: quadrantConfig.position,
ringPosition: config.rings.findIndex(r => r === item.ring),
colour: quadrantConfig.colour,
txtColour: quadrantConfig.txtColour,
coordinates: {x: 0, y: 0},
};
let point: Point;
let counter = 1;
let distanceBetweenCheck: boolean;
do {
const localpoint = generateCoordinates(blip, xScale, yScale, config);
point = localpoint
counter++;
/*
Generate position of the new blip until it has a satisfactory distance to every other blip (so that they don't touch each other)
and quadrant borders (so that they don't overlap quadrants)
This feels pretty inefficient, but good enough for now.
*/
distanceBetweenCheck = list.some(b => distanceBetween(localpoint, b.coordinates) < config.chartConfig.blipSize + config.chartConfig.blipSize / 2)
} while (counter < 100
&& (Math.abs(point.x - xScale(0)) < 15
|| Math.abs(point.y - yScale(0)) < 15
|| distanceBetweenCheck
));
blip.coordinates = point;
list.push(blip);
return list;
}, []);
return (
<g className="blips">
{blips.map((blip, index) => (
<Link pageName={`${blip.quadrant}/${blip.name}`} key={index}>
{renderBlip(blip, index, config)}
</Link>
))}
</g>
);
};
export default BlipPoints;

View File

@@ -0,0 +1,64 @@
import React from 'react';
import { ConfigData } from '../../config';
import { Blip } from '../../model';
type VisualBlipProps = {
className: string,
fill: string,
'data-background-color': string,
'data-text-color': string,
'data-tip': string,
key: number
}
export const ChangedBlip: React.FC<
{blip: Blip, config: ConfigData} & VisualBlipProps
> = ({blip, config, ...props}) => {
const centeredX = blip.coordinates.x - config.chartConfig.blipSize/2,
centeredY = blip.coordinates.y - config.chartConfig.blipSize/2;
return (
<rect
transform={`rotate(-45 ${centeredX} ${centeredY})`}
x={centeredX}
y={centeredY}
width={config.chartConfig.blipSize}
height={config.chartConfig.blipSize}
rx="3"
{...props}
/>
);
};
export const NewBlip: React.FC<
{blip: Blip, config: ConfigData} & VisualBlipProps
> = ({blip, config, ...props}) => {
const centeredX = blip.coordinates.x - config.chartConfig.blipSize/2,
centeredY = blip.coordinates.y - config.chartConfig.blipSize/2;
/*
The below is a predefined path of a triangle with rounded corners.
I didn't find any more human friendly way of doing this as all examples I found have tons of lines of code
e.g. https://observablehq.com/@perlmonger42/interactive-rounded-corners-on-svg-polygons-using-d3-js
*/
return (
<path
transform={`translate(${centeredX}, ${centeredY})`}
d="M.247 10.212l5.02-8.697a2 2 0 013.465 0l5.021 8.697a2 2 0 01-1.732 3H1.98a2 2 0 01-1.732-3z"
{...props}
/>
);
};
export const DefaultBlip: React.FC<
{blip: Blip, config: ConfigData} & VisualBlipProps
> = ({blip, config, ...props}) => {
return (
<circle
r={config.chartConfig.blipSize / 2}
cx={blip.coordinates.x}
cy={blip.coordinates.y}
{...props}
/>
);
};

View File

@@ -0,0 +1,74 @@
import React from 'react';
import * as d3 from 'd3';
import { QuadrantConfig } from '../../model';
import { ConfigData } from '../../config';
function arcPath(quadrantPosition: number, ringPosition: number, xScale: d3.ScaleLinear<number, number>, config: ConfigData) {
const startAngle = quadrantPosition === 1 ?
3 * Math.PI / 2
: (quadrantPosition - 2) * Math.PI / 2
const endAngle = quadrantPosition === 1 ?
4 * Math.PI / 2
: (quadrantPosition -1) * Math.PI / 2
const arcAttrs = config.chartConfig.ringsAttributes[ringPosition],
ringRadiusPx = xScale(arcAttrs.radius) - xScale(0),
arc = d3.arc();
return arc({
innerRadius: ringRadiusPx - arcAttrs.arcWidth,
outerRadius: ringRadiusPx,
startAngle,
endAngle
}) || undefined;
}
const QuadrantRings: React.FC<{
quadrant: QuadrantConfig
xScale: d3.ScaleLinear<number, number>
config: ConfigData
}> = ({ quadrant, xScale, config}) => {
// order from top-right clockwise
const gradientAttributes = [
{x: 0, y: 0, cx: 1, cy: 1, r: 1},
{x: xScale(0), y: 0, cx: 0, cy: 1, r: 1},
{x: xScale(0), y: xScale(0), cx: 0, cy: 0, r: 1},
{x: 0, y: xScale(0), cx: 1, cy: 0, r: 1}
];
const gradientId = `${quadrant.position}-radial-gradient`,
quadrantSize = config.chartConfig.size / 2;
return (
<g className="quadrant-ring">
{/* Definition of the quadrant gradient */}
<defs>
<radialGradient id={gradientId} {...gradientAttributes[quadrant.position - 1]}>
<stop offset="0%" stopColor={quadrant.colour}></stop>
<stop offset="100%" stopColor={quadrant.colour} stopOpacity="0"></stop>
</radialGradient>
</defs>
{/* Gradient background area */}
<rect
width={quadrantSize}
height={quadrantSize}
x={gradientAttributes[quadrant.position - 1].x}
y={gradientAttributes[quadrant.position - 1].y}
fill={`url(#${gradientId})`}
style={{opacity: 0.5}}
/>
{/* Rings' arcs */}
{Array.from(config.rings).map((ringPosition, index) => (
<path
key={index}
fill={quadrant.colour}
d={arcPath(quadrant.position, index, xScale, config)}
style={{transform: `translate(${quadrantSize}px, ${quadrantSize}px)`}}
/>
))}
</g>
);
}
export default QuadrantRings;

View File

@@ -0,0 +1,77 @@
import React from 'react';
import * as d3 from "d3";
import ReactTooltip from 'react-tooltip';
import { Item } from '../../model';
import { YAxis, XAxis } from './Axes';
import QuadrantRings from './QuadrantRings';
import BlipPoints from './BlipPoints';
import './chart.scss';
import { ConfigData } from '../../config';
const RingLabel: React.FC<{
ring: string
xScale: d3.ScaleLinear<number, number>
yScale: d3.ScaleLinear<number, number>
config: ConfigData
}> = ({ring, xScale, yScale, config}) => {
const ringIndex = config.rings.findIndex(r => r === ring)
const ringRadius = config.chartConfig.ringsAttributes[ringIndex].radius,
previousRingRadius = ringIndex === 0 ? 0 : config.chartConfig.ringsAttributes[ringIndex - 1].radius,
// middle point in between two ring arcs
distanceFromCentre = previousRingRadius + (ringRadius - previousRingRadius) / 2;
return (
<g className="ring-label">
{/* Right hand-side label */}
<text x={xScale(distanceFromCentre)} y={yScale(0)} textAnchor="middle" dy=".35em">
{ring}
</text>
{/* Left hand-side label */}
<text x={xScale(-distanceFromCentre)} y={yScale(0)} textAnchor="middle" dy=".35em">
{ring}
</text>
</g>
);
};
const RadarChart: React.FC<{
items: Item[]
config: ConfigData
}> = ({ items, config }) => {
const xScale = d3.scaleLinear()
.domain(config.chartConfig.scale)
.range([0, config.chartConfig.size]);
const yScale = d3.scaleLinear()
.domain(config.chartConfig.scale)
.range([config.chartConfig.size, 0]);
return (
<div className="chart" style={{maxWidth: `${config.chartConfig.size}px`}}>
<svg viewBox={`0 0 ${config.chartConfig.size} ${config.chartConfig.size}`}>
<g transform={`translate(${xScale(0)}, 0)`}>
<YAxis scale={yScale}/>
</g>
<g transform={`translate(0, ${yScale(0)})`}>
<XAxis scale={xScale}/>
</g>
{Object.values(config.quadrantsMap).map((value, index) => (
<QuadrantRings key={index} quadrant={value} xScale={xScale} config={config} />
))}
{Array.from(config.rings).map((ring: string, index) => (
<RingLabel key={index} ring={ring} xScale={xScale} yScale={yScale} config={config} />
))}
<BlipPoints items={items} xScale={xScale} yScale={yScale} config={config} />
</svg>
<ReactTooltip className="tooltip" offset={{top: -5}}/>
</div>
);
}
export default RadarChart;

View File

@@ -0,0 +1,20 @@
.chart {
fill: white;
font-size: 12px;
text-align: center;
position: relative;
margin: 0 auto;
}
.chart .blip:hover {
cursor: pointer;
}
.chart .tooltip {
padding: 5px 10px;
border-radius: 11px;
}
.chart .ring-label {
text-transform: uppercase;
}