Full Radar grid with chart

This commit is contained in:
Jarosław Marek
2021-04-28 23:25:52 +12:00
parent ad4c8475f5
commit 13ba3120c3
27 changed files with 493 additions and 369 deletions

View File

@@ -1,7 +1,8 @@
// TODO remove faux-dom and start using the React hook approach
import ReactFauxDOM from 'react-faux-dom';
import * as d3 from 'd3';
import * as d3 from "d3";
export const LeftAxis = ({ scale }) => {
export const YAxis = ({ scale }) => {
const el = ReactFauxDOM.createElement('g');
const axisGenerator = d3.axisLeft(scale).ticks(6);
@@ -14,7 +15,7 @@ export const LeftAxis = ({ scale }) => {
return el.toReact();
};
export const BottomAxis = ({ scale }) => {
export const XAxis = ({ scale }) => {
const el = ReactFauxDOM.createElement('g');
const axisGenerator = d3.axisBottom(scale).ticks(6);

View File

@@ -1,66 +1,112 @@
import ReactFauxDOM from 'react-faux-dom';
import * as d3 from "d3";
import { quadrantsMap, ringsMap } from '../../config';
import React from 'react';
import { quadrantsMap, ringsMap, chartConfig, blipFlags } from '../..//config';
import { NewBlip, ChangedBlip, DefaultBlip } from './BlipShapes';
/*
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
*/
const generateCoordinates = (enrichedBlip, xScale, yScale) => {
const pi = Math.PI;
// radian between 5 and 85
const randomDegree = ((Math.random() * 80 + 5) * pi) / 180;
const radius = enrichedBlip.ringPosition - 0.2;
const r = Math.random() * 0.6 + (radius - 0.6);
// multiples of PI/2
const shift = pi * [1, 4, 3, 2][enrichedBlip.quadrantPosition - 1] / 2;
const pi = Math.PI,
ringRadius = chartConfig.ringsAttributes[enrichedBlip.ringPosition - 1].radius,
previousRingRadius = enrichedBlip.ringPosition == 1 ? 0 : chartConfig.ringsAttributes[enrichedBlip.ringPosition - 2].radius,
ringPadding = 0.6;
// 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 and starts at the top left, so we need to "invert" quadrant positions
*/
const shift = pi * [4, 3, 2, 1][enrichedBlip.quadrantPosition - 1] / 2;
return {
x: xScale(Math.cos(randomDegree + shift) * r),
y: yScale(Math.sin(randomDegree + shift) * r)
x: xScale(Math.cos(randomDegree + shift) * radius),
y: yScale(Math.sin(randomDegree + shift) * radius)
};
};
const distanceBetweenPoints = (point1, point2) => {
const randomBetween = (min, max) => {
return Math.random() * (max - min) + min;
};
const distanceBetween = (point1, point2) => {
const a = point2.x - point1.x;
const b = point2.y - point1.y;
return Math.sqrt((a * a) + (b * b));
};
};
export default function BlipPoints({blips, xScale, yScale, navigate}) {
export default function BlipPoints({blips, xScale, yScale}) {
const enrichedBlips = blips.reduce((list, blip) => {
if (!blip.ring || !blip.quadrant) {
// skip the blip if it doesn't have a ring or quadrant assigned
return list;
}
blip.ringPosition = ringsMap[blip.ring].position;
blip.quadrantPosition = quadrantsMap[blip.quadrant].position;
blip.colour = quadrantsMap[blip.quadrant].colour;
let enrichedBlip = { ...blip,
ringPosition: ringsMap[blip.ring].position,
quadrantPosition: quadrantsMap[blip.quadrant].position,
colour: quadrantsMap[blip.quadrant].colour,
txtColour: quadrantsMap[blip.quadrant].txtColour
};
let point;
let counter = 1;
do {
point = generateCoordinates(blip, xScale, yScale);
point = generateCoordinates(enrichedBlip, xScale, yScale);
counter++;
// generate position of the new blip until it has a satisfactory distance to every other blip
// this feels pretty inefficient, but good enough for now
} while (list.some(item => distanceBetweenPoints(point, item) < 8) || counter > 100);
/*
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.
*/
} while (counter < 100
&& (Math.abs(point.x - xScale(0)) < 15
|| Math.abs(point.y - yScale(0)) < 15
|| list.some(item => distanceBetween(point, item) < chartConfig.blipSize + chartConfig.blipSize / 2)
));
blip.x = point.x;
blip.y = point.y;
enrichedBlip.x = point.x;
enrichedBlip.y = point.y;
list.push(blip);
list.push(enrichedBlip);
return list;
}, []);
const el = ReactFauxDOM.createElement('g');
const handleClick = (pageName, event) => {
event.preventDefault();
navigate(pageName);
}
d3.select(el)
.attr('class', 'circles')
.selectAll('circle')
.data(enrichedBlips)
.enter().append('circle')
.attr('fill', blip => blip.colour)
.attr('r', 3)
.attr('data-value', blip => blip.title)
.attr('cx', blip => blip.x)
.attr('cy', blip => blip.y)
const renderBlip = (blip, index) => {
const props = {
blip,
className: 'blip',
fill: blip.colour,
'data-background-color': blip.colour,
'data-text-color': blip.txtColour,
'data-tip': blip.title,
onClick: handleClick.bind(this, `${blip.quadrant}/${blip.name}`),
key: index
}
switch (blip.flag) {
case blipFlags.new.name:
return <NewBlip {...props} />;
case blipFlags.changed.name:
return <ChangedBlip {...props} />;
default:
return <DefaultBlip {...props} />;
}
}
return el.toReact();
}
return (
<g className="blips">
{enrichedBlips.map((blip, index) => (
renderBlip(blip, index)
))}
</g>
);
};

View File

@@ -0,0 +1,48 @@
import React from 'react';
import { chartConfig } from '../../config';
export const ChangedBlip = ({blip, ...props}) => {
const centeredX = blip.x - chartConfig.blipSize/2,
centeredY = blip.y - chartConfig.blipSize/2;
return (
<rect
transform={`rotate(-45 ${centeredX} ${centeredY})`}
x={centeredX}
y={centeredY}
width={chartConfig.blipSize}
height={chartConfig.blipSize}
rx="3"
{...props}
/>
);
};
export const NewBlip = ({blip, ...props}) => {
const centeredX = blip.x - chartConfig.blipSize/2,
centeredY = blip.y - 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 = ({blip, ...props}) => {
return (
<circle
r={chartConfig.blipSize / 2}
cx={blip.x}
cy={blip.y}
{...props}
/>
);
};

View File

@@ -2,45 +2,34 @@ import React from 'react';
import * as d3 from 'd3';
import { chartConfig } from '../../config';
const size = chartConfig.canvasSize / 2;
const arcPath = (quadrantPosition, ringPosition, xScale) => {
const startAngle = (quadrantPosition - 1) * Math.PI / 2,
endAngle = quadrantPosition * Math.PI / 2,
arcAttrs = chartConfig.ringsAttributes[ringPosition - 1],
ringRadiusPx = xScale(arcAttrs.radius) - xScale(0),
arc = d3.arc();
const arcPath = (quadrantPosition, ringPosition) => {
// order from the centre outwards
const arcAttributes = [
{radius: size / 4, width: 6},
{radius: size / 2, width: 4},
{radius: (size / 4 * 3), width: 2},
{radius: size, width: 2}
]
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 = arcAttributes[ringPosition - 1];
const arc = d3.arc();
return arc({
innerRadius: arcAttrs.radius + (arcAttrs.width / 2),
outerRadius: arcAttrs.radius - (arcAttrs.width / 2),
innerRadius: ringRadiusPx - arcAttrs.arcWidth,
outerRadius: ringRadiusPx,
startAngle,
endAngle
});
}
export default function QuadrantRings ({ quadrant }) {
// order from top left clockwise
export default function QuadrantRings ({ quadrant, xScale}) {
// order from top-right clockwise
const gradientAttributes = [
{x: 0, y: 0, cx: 1, cy: 1, r: 1},
{x: size, y: 0, cx: 0, cy: 1, r: 1},
{x: size, y: size, cx: 0, cy: 0, r: 1},
{x: 0, y: size, cx: 1, cy: 0, 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},
{x: 0, y: 0, cx: 1, cy: 1, r: 1}
];
const gradientId = `${quadrant.position}-radial-gradient`;
const gradientId = `${quadrant.position}-radial-gradient`,
quadrantSize = chartConfig.size / 2;
return (
<g>
<g className="quadrant-ring">
{/* Definition of the quadrant gradient */}
<defs>
<radialGradient id={gradientId} {...gradientAttributes[quadrant.position - 1]}>
@@ -51,8 +40,8 @@ export default function QuadrantRings ({ quadrant }) {
{/* Gradient background area */}
<rect
width={size}
height={size}
width={quadrantSize}
height={quadrantSize}
x={gradientAttributes[quadrant.position - 1].x}
y={gradientAttributes[quadrant.position - 1].y}
fill={`url(#${gradientId})`}
@@ -64,8 +53,8 @@ export default function QuadrantRings ({ quadrant }) {
<path
key={index}
fill={quadrant.colour}
d={arcPath(quadrant.position, ringPosition)}
style={{transform: `translate(${size}px, ${size}px)`}}
d={arcPath(quadrant.position, ringPosition, xScale)}
style={{transform: `translate(${quadrantSize}px, ${quadrantSize}px)`}}
/>
))}

View File

@@ -1,60 +1,63 @@
import React from 'react';
import * as d3 from "d3";
import './chart.scss';
import { blipFlags, chartConfig, quadrantsMap, ringsMap } from '../../config';
import { LeftAxis, BottomAxis } from './Axes';
import ReactTooltip from 'react-tooltip';
import { chartConfig, quadrantsMap, ringsMap } from '../../config';
import { YAxis, XAxis } from './Axes';
import QuadrantRings from './QuadrantRings';
import BlipPoints from './BlipPoints';
const RingLabel = ({ring}) => {
const middlePoint = chartConfig.canvasSize / 2;
const shift = (ring.position - 1) * chartConfig.canvasSize / 8 + chartConfig.canvasSize / 16;
import './chart.scss';
const RingLabel = ({ring, xScale, yScale}) => {
const ringRadius = chartConfig.ringsAttributes[ring.position - 1].radius,
previousRingRadius = ring.position == 1 ? 0 : chartConfig.ringsAttributes[ring.position - 2].radius,
// middle point in between two ring arcs
distanceFromCentre = previousRingRadius + (ringRadius - previousRingRadius) / 2;
return (
<g>
{/* Right hand-side label */}
<text x={middlePoint + shift} y={middlePoint} textAnchor="middle" dy=".35em">
{ring.displayName}
</text>
{/* Left hand-side label */}
<text x={middlePoint - shift} y={middlePoint} textAnchor="middle" dy=".35em">
{ring.displayName}
</text>
<g className="ring-label">
{/* Right hand-side label */}
<text x={xScale(distanceFromCentre)} y={yScale(0)} textAnchor="middle" dy=".35em">
{ring.displayName}
</text>
{/* Left hand-side label */}
<text x={xScale(-distanceFromCentre)} y={yScale(0)} textAnchor="middle" dy=".35em">
{ring.displayName}
</text>
</g>
);
};
export default function RadarChart({ blips }) {
const xScale = d3.scaleLinear()
.domain([-4, 4])
.range([0, chartConfig.canvasSize]);
.domain(chartConfig.scale)
.range([0, chartConfig.size]);
const yScale = d3.scaleLinear()
.domain([-4, 4])
.range([chartConfig.canvasSize, 0]);
.domain(chartConfig.scale)
.range([chartConfig.size, 0]);
return (
<div className="chart">
<div className="chart" style={{maxWidth: `${chartConfig.size}px`}}>
<svg viewBox={`0 0 ${chartConfig.size} ${chartConfig.size}`}>
<g transform={`translate(${chartConfig.margin}, ${chartConfig.margin})`}>
<g transform={`translate(${xScale(0)}, 0)`}>
<YAxis scale={yScale}/>
</g>
<g transform={`translate(0, ${yScale(0)})`}>
<XAxis scale={xScale}/>
</g>
<g transform={`translate(${xScale.range()[1] / 2}, 0)`}>
<LeftAxis scale={yScale}/>
</g>
<g transform={`translate(0, ${yScale.range()[0] / 2})`}>
<BottomAxis scale={xScale}/>
</g>
{Object.keys(quadrantsMap).map((id, index) => (
<QuadrantRings key={index} quadrant={quadrantsMap[id]} xScale={xScale} />
))}
{Object.keys(quadrantsMap).map((id, index) => (
<QuadrantRings key={index} quadrant={quadrantsMap[id]} />
))}
{Object.keys(ringsMap).map((id, index) => (
<RingLabel key={index} ring={ringsMap[id]} xScale={xScale} yScale={yScale} />
))}
{Object.keys(ringsMap).map((id, index) => (
<RingLabel key={index} ring={ringsMap[id]} />
))}
<BlipPoints blips={blips} xScale={xScale} yScale={yScale}/>
</g>
<BlipPoints blips={blips} xScale={xScale} yScale={yScale}/>
</svg>
<ReactTooltip className="tooltip" offset={{top: -5}}/>
</div>
);
}

View File

@@ -2,4 +2,15 @@
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;
}