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

@@ -1,6 +1,7 @@
import React, { MouseEventHandler } from "react";
import classNames from "classnames";
import "./badge.scss";
type BadgeProps = {
onClick?: MouseEventHandler;
big?: boolean;

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;
}

View File

@@ -1,8 +1,6 @@
import React from "react";
import { FlagType } from "../../model";
import "./flag.scss";
export type FlagType = "new" | "changed" | "default";
interface ItemFlag {
flag: FlagType;
}
@@ -16,7 +14,7 @@ export default function Flag({
}) {
const ucFirst = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
if (item.flag !== "default") {
if (item.flag !== FlagType.default) {
let name = item.flag.toUpperCase();
let title = ucFirst(item.flag);
if (short === true) {

View File

@@ -1,7 +1,7 @@
import { Item } from "../../model";
import { FlagType, Item } from "../../model";
export const item: Item = {
flag: "default",
flag: FlagType.default,
featured: false,
revisions: [
{

View File

@@ -1,4 +1,3 @@
import React from "react";
import Badge from "../Badge/Badge";
import { formatRelease } from "../../date";
import { Revision } from "../../model";

View File

@@ -1,6 +1,7 @@
import React from "react";
import { Link as RLink } from "react-router-dom";
import "./link.scss";
type LinkProps = {
pageName: string;
style?: React.CSSProperties;

View File

@@ -33,14 +33,13 @@ const PageHelp: React.FC<Props> = ({ leaving, onLeave }) => {
<React.Fragment key={headline}>
<h3>{headline}</h3>
{values.map((element, index) => {
const content = sanitizeHtml(element, {
allowedTags: ['b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li'],
allowedAttributes: {
'a': ['href', 'target']
},
});
return (
<p key={index} dangerouslySetInnerHTML={sanitize(element)}></p>
<p key={index} dangerouslySetInnerHTML={sanitize(element, {
allowedTags: ['b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li'],
allowedAttributes: {
'a': ['href', 'target']
},
})}></p>
)
})
}

View File

@@ -1,7 +1,8 @@
import { formatRelease } from "../../date";
import { featuredOnly, Item } from "../../model";
import { featuredOnly, Item, HomepageOption } from "../../model";
import HeroHeadline from "../HeroHeadline/HeroHeadline";
import QuadrantGrid from "../QuadrantGrid/QuadrantGrid";
import RadarGrid from '../RadarGrid/RadarGrid';
import Fadeable from "../Fadeable/Fadeable";
import SetTitle from "../SetTitle";
import { ConfigData, radarName, radarNameShort } from "../../config";
@@ -28,6 +29,8 @@ export default function PageIndex({
const newestRelease = releases.slice(-1)[0];
const numberOfReleases = releases.length;
const showChart = config.homepageContent === HomepageOption.chart || config.homepageContent === HomepageOption.both;
const showColumns = config.homepageContent === HomepageOption.columns || config.homepageContent === HomepageOption.both;
return (
<Fadeable leaving={leaving} onLeave={onLeave}>
<SetTitle title={radarNameShort} />
@@ -36,7 +39,12 @@ export default function PageIndex({
{radarName}
</HeroHeadline>
</div>
<QuadrantGrid items={featuredOnly(items)} config={config} />
{showChart && (
<RadarGrid items={featuredOnly(items)} config={config} />
)}
{showColumns && (
<QuadrantGrid items={featuredOnly(items)} config={config} />
)}
<div className="publish-date">
{publishedLabel} {formatRelease(newestRelease)}
</div>

View File

@@ -0,0 +1,75 @@
import React from "react";
import RadarChart from "../Chart/RadarChart";
import { ConfigData } from "../../config";
import { Item, QuadrantConfig } from "../../model";
import "./radar-grid.scss";
import Link from "../Link/Link";
const QuadrantLabel: React.FC<{
quadrantConfig: QuadrantConfig;
quadrantName: string;
quadrantLabel: string;
}> = ({ quadrantConfig, quadrantName, quadrantLabel }) => {
const stylesMap = [
{ top: 0, left: 0 },
{ top: 0, right: 0 },
{ bottom: 0, right: 0 },
{ bottom: 0, left: 0 },
];
return (
<div className="quadrant-label" style={stylesMap[quadrantConfig.position - 1]}>
<div className="split">
<div className="split__left">
<small>Quadrant {quadrantConfig.position}</small>
</div>
<div className="split__right">
<Link className="icon-link" pageName={`${quadrantName}`}>
<span className="icon icon--pie icon-link__icon" />
Zoom In
</Link>
</div>
</div>
<hr style={{ borderColor: quadrantConfig.colour }} />
<h4 className="headline">{quadrantLabel}</h4>
<div className="description">{quadrantConfig.description}</div>
</div>
);
};
const Legend: React.FC = () => {
return (
<div className="radar-legend">
<div className="wrapper">
<span className="icon icon--blip_new"></span>
New in this version
</div>
<div className="wrapper">
<span className="icon icon--blip_changed"></span>
Recently changed
</div>
<div className="wrapper">
<span className="icon icon--blip_default"></span>
Unchanged
</div>
</div>
);
};
const RadarGrid: React.FC<{ items: Item[]; config: ConfigData }> = ({
items,
config,
}) => {
return (
<div className="radar-grid">
<RadarChart items={items} config={config} />
{Object.entries(config.quadrantsMap).map(([name, quadrant], index) => (
<QuadrantLabel key={index} quadrantConfig={quadrant} quadrantName={name} quadrantLabel={config.quadrants[name]} />
))}
<Legend />
</div>
);
};
export default RadarGrid;

View File

@@ -0,0 +1,59 @@
.radar-grid {
position: relative;
margin-bottom: 50px;
color: white;
display: none;
}
@media screen and (min-width: 800px) {
.radar-grid {
display: block;
}
}
.radar-grid .quadrant-label {
position: absolute;
width: 20%;
}
.quadrant-label .split {
font-size: 12 px;
text-transform: uppercase;
}
.quadrant-label hr {
width: 100%;
margin: 10px 0 10px 0;
}
.quadrant-label .description {
font-size: 14px;
color: #a6b1bb
}
.quadrant-label .icon-link {
font-size: 12px;
}
.quadrant-label .icon-link .icon {
background-size: 18px 18px;
width: 18px;
height: 18px;
}
.radar-grid .radar-legend {
position: absolute;
width: 15%;
right: 0;
top: 45%;
}
.radar-legend .wrapper {
margin-bottom: 10px;
}
.radar-legend .icon {
background-position: center;
margin-right: 5px;
}

View File

@@ -1,9 +1,17 @@
import { Item } from "./model";
import {Item, HomepageOption, QuadrantConfig} from './model';
export interface ConfigData {
quadrants: { [key: string]: string };
rings: string[];
showEmptyRings: boolean;
quadrantsMap: { [quadrant: string]: QuadrantConfig };
chartConfig: {
size: number,
scale: number[],
blipSize: number,
ringsAttributes: {radius: number, arcWidth: number}[]
};
homepageContent: HomepageOption;
}
export const radarName =

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<rect transform="rotate(-45 8 8)" x="2" y="2" width="12" height="12" rx="3" fill="#a6b1bb"></rect>
</svg>

After

Width:  |  Height:  |  Size: 319 B

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="6" fill="#a6b1bb"></circle>
</svg>

After

Width:  |  Height:  |  Size: 273 B

4
src/icons/blip_new.svg Normal file
View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg viewbox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path fill="#a6b1bb" 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"></path>
</svg>

After

Width:  |  Height:  |  Size: 345 B

View File

@@ -1,3 +1,9 @@
export enum HomepageOption {
chart = "chart",
columns = "columns",
both = "both"
}
export type ItemAttributes = {
name: string;
ring: string;
@@ -6,7 +12,11 @@ export type ItemAttributes = {
featured?: boolean;
};
export type FlagType = "new" | "changed" | "default";
export enum FlagType {
new = 'new',
changed = 'changed',
default = 'default'
}
export type Item = ItemAttributes & {
featured: boolean;
@@ -16,6 +26,14 @@ export type Item = ItemAttributes & {
revisions: Revision[];
};
export type Blip = Item & {
quadrantPosition: number
ringPosition: number
colour: string
txtColour: string
coordinates: Point
}
export type Revision = ItemAttributes & {
body: string;
fileName: string;
@@ -26,6 +44,13 @@ export type Quadrant = {
[name: string]: Item[];
};
export type QuadrantConfig = {
colour: string,
txtColour: string,
position: number,
description: string
}
export type Radar = {
items: Item[];
releases: string[];
@@ -40,6 +65,11 @@ export const featuredOnly = (items: Item[]) =>
export const nonFeaturedOnly = (items: Item[]) =>
items.filter((item) => !item.featured);
export type Point = {
x: number,
y: number
}
export const groupByQuadrants = (items: Item[]): Group =>
items.reduce(
(quadrants, item: Item) => ({

View File

@@ -30,4 +30,25 @@
&--close {
background-image: url("../../icons/close.svg");
}
&--blip_new {
background-image: url('../../icons/blip_new.svg');
width: 18px;
height: 18px;
background-size: 18px;
}
&--blip_changed {
background-image: url('../../icons/blip_changed.svg');
width: 18px;
height: 18px;
background-size: 18px;
}
&--blip_default {
background-image: url('../../icons/blip_default.svg');
width: 18px;
height: 18px;
background-size: 18px;
}
}