First stab at the radar chart

This commit is contained in:
Jarosław Marek
2021-04-28 00:33:49 +12:00
parent e8381eb332
commit ad4c8475f5
13 changed files with 745 additions and 12 deletions

View File

@@ -0,0 +1,28 @@
import ReactFauxDOM from 'react-faux-dom';
import * as d3 from 'd3';
export const LeftAxis = ({ scale }) => {
const el = ReactFauxDOM.createElement('g');
const axisGenerator = d3.axisLeft(scale).ticks(6);
d3.select(el).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());
return el.toReact();
};
export const BottomAxis = ({ scale }) => {
const el = ReactFauxDOM.createElement('g');
const axisGenerator = d3.axisBottom(scale).ticks(6);
d3.select(el).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());
return el.toReact();
};

View File

@@ -0,0 +1,66 @@
import ReactFauxDOM from 'react-faux-dom';
import * as d3 from "d3";
import { quadrantsMap, ringsMap } from '../../config';
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;
return {
x: xScale(Math.cos(randomDegree + shift) * r),
y: yScale(Math.sin(randomDegree + shift) * r)
};
};
const distanceBetweenPoints = (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}) {
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 point;
let counter = 1;
do {
point = generateCoordinates(blip, 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);
blip.x = point.x;
blip.y = point.y;
list.push(blip);
return list;
}, []);
const el = ReactFauxDOM.createElement('g');
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)
return el.toReact();
}

View File

@@ -0,0 +1,74 @@
import React from 'react';
import * as d3 from 'd3';
import { chartConfig } from '../../config';
const size = chartConfig.canvasSize / 2;
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),
startAngle,
endAngle
});
}
export default function QuadrantRings ({ quadrant }) {
// order from top left 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}
];
const gradientId = `${quadrant.position}-radial-gradient`;
return (
<g>
{/* 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={size}
height={size}
x={gradientAttributes[quadrant.position - 1].x}
y={gradientAttributes[quadrant.position - 1].y}
fill={`url(#${gradientId})`}
style={{opacity: 0.5}}
/>
{/* Rings' arcs */}
{[1, 2, 3, 4].map((ringPosition, index) => (
<path
key={index}
fill={quadrant.colour}
d={arcPath(quadrant.position, ringPosition)}
style={{transform: `translate(${size}px, ${size}px)`}}
/>
))}
</g>
);
}

View File

@@ -0,0 +1,60 @@
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 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;
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>
);
};
export default function RadarChart({ blips }) {
const xScale = d3.scaleLinear()
.domain([-4, 4])
.range([0, chartConfig.canvasSize]);
const yScale = d3.scaleLinear()
.domain([-4, 4])
.range([chartConfig.canvasSize, 0]);
return (
<div className="chart">
<svg viewBox={`0 0 ${chartConfig.size} ${chartConfig.size}`}>
<g transform={`translate(${chartConfig.margin}, ${chartConfig.margin})`}>
<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]} />
))}
{Object.keys(ringsMap).map((id, index) => (
<RingLabel key={index} ring={ringsMap[id]} />
))}
<BlipPoints blips={blips} xScale={xScale} yScale={yScale}/>
</g>
</svg>
</div>
);
}

View File

@@ -0,0 +1,5 @@
.chart {
fill: white;
font-size: 12px;
text-align: center;
}

View File

@@ -1,6 +1,7 @@
import React from 'react';
import './flag.scss';
import {FlagType} from "../../model";
import {blipFlags} from "../../config";
interface ItemFlag {
flag: FlagType;
@@ -9,7 +10,7 @@ interface ItemFlag {
export default function Flag({ item, short = false }: { item: ItemFlag; short?: boolean }) {
const ucFirst = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
if (item.flag !== 'default') {
if (item.flag !== blipFlags.default.name) {
let name = item.flag.toUpperCase();
let title = ucFirst(item.flag);
if (short === true) {

View File

@@ -3,6 +3,7 @@ import { formatRelease } from '../../date';
import { featuredOnly, Item } from '../../model';
import HeroHeadline from '../HeroHeadline/HeroHeadline';
import QuadrantGrid from '../QuadrantGrid/QuadrantGrid';
import RadarChart from '../Chart/RadarChart';
import Fadeable from '../Fadeable/Fadeable';
import SetTitle from '../SetTitle';
import { radarName, radarNameShort } from '../../config';
@@ -24,6 +25,7 @@ export default function PageIndex({ leaving, onLeave, items, releases }: PageInd
<div className='headline-group'>
<HeroHeadline alt={`Version #${numberOfReleases}`}>{radarName}</HeroHeadline>
</div>
<RadarChart blips={featuredOnly(items)} />
<QuadrantGrid items={featuredOnly(items)} />
<div className='publish-date'>Published {formatRelease(newestRelease)}</div>
</Fadeable>

View File

@@ -10,6 +10,38 @@ export const quadrants = [
'tools',
];
// Quadrants positions start from the top left and go clockwise
export const quadrantsMap = {
'languages-and-frameworks': {
displayName: 'Languages & Frameworks',
colour: '#84BFA4',
position: 1
},
'methods-and-patterns': {
displayName: 'Methods & Patterns',
colour: '#248EA6',
position: 2
},
'platforms-and-aoe-services': {
displayName: 'Platforms and Operations',
colour: '#F25244',
position: 3
},
'tools': {
displayName: 'Tools',
colour: '#F2A25C',
position: 4
},
};
const chartMargin = 20,
chartSize = 900;
export const chartConfig = {
margin: chartMargin,
size: chartSize,
canvasSize: chartSize - chartMargin * 2
};
export const rings = [
'all',
'adopt',
@@ -18,6 +50,33 @@ export const rings = [
'hold'
] as const;
// rings positions start at the centre and go outwards
export const ringsMap = {
'adopt': {
displayName: 'ADOPT',
position: 1
},
'trial': {
displayName: 'TRIAL',
position: 2
},
'assess': {
displayName: 'ASSESS',
position: 3
},
'hold': {
displayName: 'HOLD',
position: 4
}
};
// TODO replace with TS enum
export const blipFlags = {
new: { name: 'new', short: 'N' },
changed: { name: 'changed', short: 'C' },
default: { name: 'default', short: '' }
}
export type Ring = typeof rings[number]
export const getItemPageNames = (items: Item[]) => items.map(item => `${item.quadrant}/${item.name}`);