From 13ba3120c32dbb9b49402b789ff0d2bd44857d7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Marek?= Date: Wed, 28 Apr 2021 23:25:52 +1200 Subject: [PATCH] Full Radar grid with chart --- package.json | 1 + src/components/Chart/Axes.tsx | 7 +- src/components/Chart/BlipPoints.tsx | 122 +++++++++---- src/components/Chart/BlipShapes.tsx | 48 ++++++ src/components/Chart/QuadrantRings.tsx | 53 +++--- src/components/Chart/RadarChart.tsx | 75 ++++---- src/components/Chart/chart.scss | 11 ++ src/components/Header/Header.tsx | 10 +- src/components/IconLink/IconLink.tsx | 11 ++ src/components/PageIndex/PageIndex.tsx | 6 +- src/components/PageItem/PageItem.tsx | 11 +- .../PageItemMobile/PageItemMobile.tsx | 12 +- src/components/PageOverview/PageOverview.tsx | 4 +- src/components/PageQuadrant/PageQuadrant.tsx | 8 +- .../QuadrantSection/QuadrantSection.tsx | 18 +- src/components/RadarGrid/RadarGrid.tsx | 63 +++++++ src/components/RadarGrid/radar-grid.scss | 52 ++++++ src/components/Router.tsx | 4 +- src/config.ts | 128 +++++++------- src/icons/blip_changed.svg | 4 + src/icons/blip_default.svg | 4 + src/icons/blip_new.svg | 4 + src/styles/components/icon.scss | 21 +++ tasks/create-static.ts | 7 +- tasks/radar.ts | 13 +- tasks/radarjson.ts | 3 - yarn.lock | 162 ++---------------- 27 files changed, 493 insertions(+), 369 deletions(-) create mode 100644 src/components/Chart/BlipShapes.tsx create mode 100644 src/components/IconLink/IconLink.tsx create mode 100644 src/components/RadarGrid/RadarGrid.tsx create mode 100644 src/components/RadarGrid/radar-grid.scss create mode 100644 src/icons/blip_changed.svg create mode 100644 src/icons/blip_default.svg create mode 100644 src/icons/blip_new.svg diff --git a/package.json b/package.json index c923963..1ea94d1 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "react-dom": "^16.0.0", "react-faux-dom": "^4.5.0", "react-router-dom": "^5.2.0", + "react-tooltip": "^4.2.18", "resolve": "1.15.0", "resolve-url-loader": "3.1.2", "sass-loader": "8.0.2", diff --git a/src/components/Chart/Axes.tsx b/src/components/Chart/Axes.tsx index 23b495f..b2655c3 100644 --- a/src/components/Chart/Axes.tsx +++ b/src/components/Chart/Axes.tsx @@ -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); diff --git a/src/components/Chart/BlipPoints.tsx b/src/components/Chart/BlipPoints.tsx index 7cdd6ed..e3a5a26 100644 --- a/src/components/Chart/BlipPoints.tsx +++ b/src/components/Chart/BlipPoints.tsx @@ -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 ; + case blipFlags.changed.name: + return ; + default: + return ; + } + } - return el.toReact(); -} \ No newline at end of file + return ( + + {enrichedBlips.map((blip, index) => ( + renderBlip(blip, index) + ))} + + ); +}; \ No newline at end of file diff --git a/src/components/Chart/BlipShapes.tsx b/src/components/Chart/BlipShapes.tsx new file mode 100644 index 0000000..ba19348 --- /dev/null +++ b/src/components/Chart/BlipShapes.tsx @@ -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 ( + + ); +}; + +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 ( + + ); +}; + +export const DefaultBlip = ({blip, ...props}) => { + return ( + + ); +}; \ No newline at end of file diff --git a/src/components/Chart/QuadrantRings.tsx b/src/components/Chart/QuadrantRings.tsx index 0d65135..8ba4b20 100644 --- a/src/components/Chart/QuadrantRings.tsx +++ b/src/components/Chart/QuadrantRings.tsx @@ -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 ( - + {/* Definition of the quadrant gradient */} @@ -51,8 +40,8 @@ export default function QuadrantRings ({ quadrant }) { {/* Gradient background area */} ))} diff --git a/src/components/Chart/RadarChart.tsx b/src/components/Chart/RadarChart.tsx index 34ff64b..14cce3b 100644 --- a/src/components/Chart/RadarChart.tsx +++ b/src/components/Chart/RadarChart.tsx @@ -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 ( - - {/* Right hand-side label */} - - {ring.displayName} - - {/* Left hand-side label */} - - {ring.displayName} - + + {/* Right hand-side label */} + + {ring.displayName} + + {/* Left hand-side label */} + + {ring.displayName} + ); }; 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 ( -
+
- + + + + + + - - - - - - + {Object.keys(quadrantsMap).map((id, index) => ( + + ))} - {Object.keys(quadrantsMap).map((id, index) => ( - - ))} + {Object.keys(ringsMap).map((id, index) => ( + + ))} - {Object.keys(ringsMap).map((id, index) => ( - - ))} - - - + +
); } \ No newline at end of file diff --git a/src/components/Chart/chart.scss b/src/components/Chart/chart.scss index 2bb72cc..a707795 100644 --- a/src/components/Chart/chart.scss +++ b/src/components/Chart/chart.scss @@ -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; } \ No newline at end of file diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index a183423..d8abbdc 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -1,7 +1,7 @@ import React, { useState, useRef } from 'react'; import classNames from 'classnames'; import Branding from '../Branding/Branding'; -import Link from '../Link/Link'; +import IconLink from '../IconLink/IconLink'; import LogoLink from '../LogoLink/LogoLink'; import Search from '../Search/Search'; import { radarNameShort } from '../../config'; @@ -50,14 +50,10 @@ export default function Header({ pageName }: { pageName: string }) { }>
- - How to Use {radarNameShort}? - +
- - Technologies Overview - +