First stab at the radar chart
This commit is contained in:
28
src/components/Chart/Axes.tsx
Normal file
28
src/components/Chart/Axes.tsx
Normal 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();
|
||||
};
|
||||
66
src/components/Chart/BlipPoints.tsx
Normal file
66
src/components/Chart/BlipPoints.tsx
Normal 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();
|
||||
}
|
||||
74
src/components/Chart/QuadrantRings.tsx
Normal file
74
src/components/Chart/QuadrantRings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
src/components/Chart/RadarChart.tsx
Normal file
60
src/components/Chart/RadarChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
src/components/Chart/chart.scss
Normal file
5
src/components/Chart/chart.scss
Normal file
@@ -0,0 +1,5 @@
|
||||
.chart {
|
||||
fill: white;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user