Issue 661 Tech Radar Typescript refactor (#1080)

* Refactor RadarFooter

* Refactor RadarLegend Pt 1

* Refactor RadarLegend Pt 2

Seeing some errors that I don't fully understand around the proptypes
assertions seemingly being treated as null | undefined values.

It looks like I'll need to type Segment, which is not explicitly
in-scope for this issue.

* Fix segments regression

I incorrectly added type safety to the original implementation, causing
the segments to no longer be populated here. This change fixes that
regression while retaining the type safety.

This is all necessary because qidx and ridx are apparently both
nullable.

* Resolve remaining RadarLegend warnings

* Refactor RadarEntry

* Forcibly skip hooks to revert migration prettify

* Refactor RadarGrid

* Refactor RadarPlot & Best effort with PropTypes

A global search looks like no other plugins use PropTypes. I'm about to
remove them in the next commit because shapes don't seem to cooperate
with TypeScript, making the PropTypes either so vague that they're
mostly useless or specific enough to fail the build.

With that said, I wanted to get this version committed in case I need to
roll back to here. I wouldn't want to redo this work.

* Remove PropTypes from refactored components

The build now passes.

* Refactor RadarBubble

* Refactor Radar

* Move Radar functions out of component file

I could see these being split into separate files, but I think this is
enough for now.

* Small function signature change

* Align types

I'm not confident in this change. I'm very unclear about the reasoning
behind the type definitions in api.ts as they don't reflect the shape of
the values that I see when I print `data.entries` in the console.

The value of data.entries reflects a fully-populated Entry[], not a
RadarEntry[].

As such, I'm also not sure about the sample data. I would be more
hesitant to updated it, but the fact that the rest of the components
seem to be expecting that data makes it seem fairly clear that it must
be required to be in the updated shape.
This commit is contained in:
Nathan Calvank
2020-06-08 00:13:03 -07:00
committed by GitHub
parent fb8e834b15
commit 42130988fe
17 changed files with 832 additions and 804 deletions
+2 -2
View File
@@ -35,8 +35,8 @@ export interface RadarEntry {
key: string; // react key
id: string;
moved: number;
quadrant: string;
ring: string;
quadrant: RadarQuadrant;
ring: RadarRing;
title: string;
url: string;
}
@@ -1,223 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { forceCollide, forceSimulation } from 'd3-force';
import RadarPlot from '../RadarPlot';
import Segment from '../../utils/segment';
import color from 'color';
export default class Radar extends React.Component {
static adjustQuadrants(quadrants, radius, width, height) {
/*
0 1 2 3 ← x stops index
│ │ │ │ ↓ y stops index
┼───────────┼─────────────────────────────┼───────────┼─0
│ │ . -- ~~~ -- . │ │
│ │ .-~ ~-. │ │
│ <Q3> │ / \ │ <Q2> │
│ │ / \ │ │
┼───────────┼─────────────────────────────┼───────────┼─1
┼───────────┼─────────────────────────────┼───────────┼─2
│ │ | | │ │
│ │ \ / │ │
│ <Q1> │ \ / │ <Q0> │
│ │ `-. .-' │ │
│ │ ~- . ___ . -~ │ │
┼───────────┼─────────────────────────────┼───────────┼─3
*/
const margin = 16;
const xStops = [
margin,
width / 2 - radius - margin,
width / 2 + radius + margin,
width - margin,
];
const yStops = [margin, height / 2 - margin, height / 2, height - margin];
// The quadrant parameters correspond to Q[0..3] above. They are in this order because of the
// original Zalando code; maybe we should refactor them to be in reverse order?
const legendParams = [
{
x: xStops[2],
y: yStops[2],
width: xStops[3] - xStops[2],
height: yStops[3] - yStops[2],
},
{
x: xStops[0],
y: yStops[2],
width: xStops[1] - xStops[0],
height: yStops[3] - yStops[2],
},
{
x: xStops[0],
y: yStops[0],
width: xStops[1] - xStops[0],
height: yStops[1] - yStops[0],
},
{
x: xStops[2],
y: yStops[0],
width: xStops[3] - xStops[2],
height: yStops[1] - yStops[0],
},
];
quadrants.forEach((quadrant, idx) => {
const legendParam = legendParams[idx % 4];
quadrant.idx = idx;
quadrant.radialMin = (idx * Math.PI) / 2;
quadrant.radialMax = ((idx + 1) * Math.PI) / 2;
quadrant.offsetX = idx % 4 === 0 || idx % 4 === 3 ? 1 : -1;
quadrant.offsetY = idx % 4 === 0 || idx % 4 === 1 ? 1 : -1;
quadrant.legendX = legendParam.x;
quadrant.legendY = legendParam.y;
quadrant.legendWidth = legendParam.width;
quadrant.legendHeight = legendParam.height;
});
}
static adjustRings(rings, radius) {
rings.forEach((ring, idx) => {
ring.idx = idx;
ring.outerRadius = ((idx + 2) / (rings.length + 1)) * radius;
ring.innerRadius =
((idx === 0 ? 0 : idx + 1) / (rings.length + 1)) * radius;
});
}
static adjustEntries(entries, activeEntry, quadrants, rings, radius) {
let seed = 42;
entries.forEach((entry, idx) => {
const quadrant = quadrants.find(q => {
const match =
typeof entry.quadrant === 'object'
? entry.quadrant.id
: entry.quadrant;
return q.id === match;
});
const ring = rings.find(r => {
const match =
typeof entry.ring === 'object' ? entry.ring.id : entry.ring;
return r.id === match;
});
if (!quadrant) {
throw new Error(
`Unknown quadrant ${entry.quadrant} for entry ${entry.id}!`,
);
}
if (!ring) {
throw new Error(`Unknown ring ${entry.ring} for entry ${entry.id}!`);
}
entry.idx = idx;
entry.quadrant = quadrant;
entry.ring = ring;
entry.segment = new Segment(quadrant, ring, radius, () => seed++);
const point = entry.segment.random();
entry.x = point.x;
entry.y = point.y;
entry.active = activeEntry ? entry.id === activeEntry.id : false;
entry.color = entry.active
? entry.ring.color
: color(entry.ring.color).desaturate(0.5).lighten(0.1).string();
});
const simulation = forceSimulation()
.nodes(entries)
.velocityDecay(0.19)
.force('collision', forceCollide().radius(12).strength(0.85))
.stop();
for (
let i = 0,
n = Math.ceil(
Math.log(simulation.alphaMin()) /
Math.log(1 - simulation.alphaDecay()),
);
i < n;
++i
) {
simulation.tick();
for (const entry of entries) {
entry.x = entry.segment.clipx(entry);
entry.y = entry.segment.clipy(entry);
}
}
}
constructor(props) {
super(props);
this.state = { activeEntry: null };
}
_setActiveEntry(entry) {
this.setState({ activeEntry: entry });
}
_clearActiveEntry() {
this.setState({ activeEntry: null });
}
render() {
// TODO(dflemstr): most of this method can be heavily memoized if performance becomes a problem
const { width, height, quadrants, rings, entries } = this.props;
const { activeEntry } = this.state;
const radius = Math.min(width, height) / 2;
Radar.adjustQuadrants(quadrants, radius, width, height);
Radar.adjustRings(rings, radius);
Radar.adjustEntries(entries, activeEntry, quadrants, rings, radius);
return (
<svg
ref={node => {
this.node = node;
}}
width={width}
height={height}
{...this.props.svgProps}
>
<RadarPlot
width={width}
height={height}
radius={radius}
entries={entries}
quadrants={quadrants}
rings={rings}
activeEntry={activeEntry}
onEntryMouseEnter={entry => this._setActiveEntry(entry)}
onEntryMouseLeave={() => this._clearActiveEntry()}
/>
</svg>
);
}
}
Radar.propTypes = {
width: PropTypes.number.isRequired,
height: PropTypes.number.isRequired,
quadrants: PropTypes.arrayOf(PropTypes.object).isRequired,
rings: PropTypes.arrayOf(PropTypes.object).isRequired,
entries: PropTypes.arrayOf(PropTypes.object).isRequired,
svgProps: PropTypes.object,
};
@@ -31,9 +31,9 @@ const minProps = {
{
id: 'typescript',
title: 'TypeScript',
quadrant: 'languages',
quadrant: { id: 'languages', name: 'Languages' },
moved: 0,
ring: 'use',
ring: { id: 'use', name: 'USE', color: '#93c47d' },
url: '#',
},
],
@@ -0,0 +1,60 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { FC, useState, useRef } from 'react';
import RadarPlot from '../RadarPlot';
import { Ring, Quadrant, Entry } from '../../utils/types';
import { adjustQuadrants, adjustRings, adjustEntries } from './utils';
type Props = {
width: number;
height: number;
quadrants: Quadrant[];
rings: Ring[];
entries: Entry[];
svgProps?: object;
};
const Radar: FC<Props> = props => {
const { width, height, quadrants, rings, entries } = props;
const radius = Math.min(width, height) / 2;
const [activeEntry, setActiveEntry] = useState<Entry | null>();
const node = useRef<SVGSVGElement>(null);
// TODO(dflemstr): most of this can be heavily memoized if performance becomes a problem
adjustQuadrants(quadrants, radius, width, height);
adjustRings(rings, radius);
adjustEntries(entries, activeEntry, quadrants, rings, radius);
return (
<svg ref={node} width={width} height={height} {...props.svgProps}>
<RadarPlot
width={width}
height={height}
radius={radius}
entries={entries}
quadrants={quadrants}
rings={rings}
activeEntry={activeEntry || undefined}
onEntryMouseEnter={entry => setActiveEntry(entry)}
onEntryMouseLeave={() => setActiveEntry(null)}
/>
</svg>
);
};
export default Radar;
@@ -0,0 +1,172 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import color from 'color';
import { forceCollide, forceSimulation } from 'd3-force';
import Segment from '../../utils/segment';
import { Ring, Quadrant, Entry } from '../../utils/types';
export const adjustQuadrants = (
quadrants: Quadrant[],
radius: number,
width: number,
height: number,
) => {
/*
0 1 2 3 ← x stops index
│ │ │ │ ↓ y stops index
┼───────────┼─────────────────────────────┼───────────┼─0
│ │ . -- ~~~ -- . │ │
│ │ .-~ ~-. │ │
│ <Q3> │ / \ │ <Q2> │
│ │ / \ │ │
┼───────────┼─────────────────────────────┼───────────┼─1
┼───────────┼─────────────────────────────┼───────────┼─2
│ │ | | │ │
│ │ \ / │ │
│ <Q1> │ \ / │ <Q0> │
│ │ `-. .-' │ │
│ │ ~- . ___ . -~ │ │
┼───────────┼─────────────────────────────┼───────────┼─3
*/
const margin = 16;
const xStops = [
margin,
width / 2 - radius - margin,
width / 2 + radius + margin,
width - margin,
];
const yStops = [margin, height / 2 - margin, height / 2, height - margin];
// The quadrant parameters correspond to Q[0..3] above. They are in this order because of the
// original Zalando code; maybe we should refactor them to be in reverse order?
const legendParams = [
{
x: xStops[2],
y: yStops[2],
width: xStops[3] - xStops[2],
height: yStops[3] - yStops[2],
},
{
x: xStops[0],
y: yStops[2],
width: xStops[1] - xStops[0],
height: yStops[3] - yStops[2],
},
{
x: xStops[0],
y: yStops[0],
width: xStops[1] - xStops[0],
height: yStops[1] - yStops[0],
},
{
x: xStops[2],
y: yStops[0],
width: xStops[3] - xStops[2],
height: yStops[1] - yStops[0],
},
];
quadrants.forEach((quadrant, idx) => {
const legendParam = legendParams[idx % 4];
quadrant.idx = idx;
quadrant.radialMin = (idx * Math.PI) / 2;
quadrant.radialMax = ((idx + 1) * Math.PI) / 2;
quadrant.offsetX = idx % 4 === 0 || idx % 4 === 3 ? 1 : -1;
quadrant.offsetY = idx % 4 === 0 || idx % 4 === 1 ? 1 : -1;
quadrant.legendX = legendParam.x;
quadrant.legendY = legendParam.y;
quadrant.legendWidth = legendParam.width;
quadrant.legendHeight = legendParam.height;
});
};
export const adjustEntries = (
entries: Entry[],
activeEntry: Entry | null | undefined,
quadrants: Quadrant[],
rings: Ring[],
radius: number,
) => {
let seed = 42;
entries.forEach((entry, idx) => {
const quadrant = quadrants.find(q => {
const match =
typeof entry.quadrant === 'object' ? entry.quadrant.id : entry.quadrant;
return q.id === match;
});
const ring = rings.find(r => {
const match = typeof entry.ring === 'object' ? entry.ring.id : entry.ring;
return r.id === match;
});
if (!quadrant) {
throw new Error(
`Unknown quadrant ${entry.quadrant} for entry ${entry.id}!`,
);
}
if (!ring) {
throw new Error(`Unknown ring ${entry.ring} for entry ${entry.id}!`);
}
entry.idx = idx;
entry.quadrant = quadrant;
entry.ring = ring;
entry.segment = new Segment(quadrant, ring, radius, () => seed++);
const point = entry.segment.random();
entry.x = point.x;
entry.y = point.y;
entry.active = activeEntry ? entry.id === activeEntry.id : false;
entry.color = entry.active
? entry.ring.color
: color(entry.ring.color).desaturate(0.5).lighten(0.1).string();
});
const simulation = forceSimulation()
.nodes(entries)
.velocityDecay(0.19)
.force('collision', forceCollide().radius(12).strength(0.85))
.stop();
for (
let i = 0,
n = Math.ceil(
Math.log(simulation.alphaMin()) / Math.log(1 - simulation.alphaDecay()),
);
i < n;
++i
) {
simulation.tick();
for (const entry of entries) {
if (entry.segment) {
entry.x = entry.segment.clipx(entry);
entry.y = entry.segment.clipy(entry);
}
}
}
};
export const adjustRings = (rings: Ring[], radius: number) => {
rings.forEach((ring, idx) => {
ring.idx = idx;
ring.outerRadius = ((idx + 2) / (rings.length + 1)) * radius;
ring.innerRadius =
((idx === 0 ? 0 : idx + 1) / (rings.length + 1)) * radius;
});
};
@@ -1,124 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { withStyles } from '@material-ui/core';
const styles = {
bubble: {
pointerEvents: 'none',
userSelect: 'none',
opacity: 0,
},
visibleBubble: {
pointerEvents: 'none',
userSelect: 'none',
opacity: 0.8,
},
background: {
fill: '#333',
},
text: {
pointerEvents: 'none',
userSelect: 'none',
fontSize: '10px',
fill: '#fff',
},
};
class RadarBubble extends React.PureComponent {
componentDidMount() {
this._updatePosition();
}
componentDidUpdate() {
this._updatePosition();
}
_setRect = rect => {
this.rect = rect;
};
_setNode = node => {
this.node = node;
};
_setText = text => {
this.text = text;
};
_setPath = path => {
this.path = path;
};
_updatePosition() {
// We can't do this in render() because we need to measure how big the text is to draw the bubble around it
// this.text will not be set during testing because there is no real DOM
if (this.text) {
const { x, y } = this.props;
const bbox = this.text.getBBox();
const marginX = 5;
const marginY = 4;
this.node.setAttribute(
'transform',
`translate(${x - bbox.width / 2}, ${y - bbox.height - marginY})`,
);
this.rect.setAttribute('x', -marginX);
this.rect.setAttribute('y', -bbox.height);
this.rect.setAttribute('width', bbox.width + 2 * marginX);
this.rect.setAttribute('height', bbox.height + marginY);
this.path.setAttribute(
'transform',
`translate(${bbox.width / 2 - marginX}, ${marginY - 1})`,
);
}
}
render() {
const { visible, text, classes } = this.props;
return (
<g
ref={this._setNode}
x={0}
y={0}
className={visible ? classes.visibleBubble : classes.bubble}
>
<rect
ref={this._setRect}
rx={4}
ry={4}
className={classes.background}
/>
<text ref={this._setText} className={classes.text}>
{text}
</text>
<path
ref={this._setPath}
d="M 0,0 10,0 5,8 z"
className={classes.background}
/>
</g>
);
}
}
RadarBubble.propTypes = {
visible: PropTypes.bool.isRequired,
text: PropTypes.string.isRequired,
x: PropTypes.number.isRequired,
y: PropTypes.number.isRequired,
classes: PropTypes.object.isRequired,
};
export default withStyles(styles)(RadarBubble);
@@ -0,0 +1,119 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { FC, useRef, useEffect, useLayoutEffect } from 'react';
import { makeStyles, Theme } from '@material-ui/core';
type Props = {
visible: boolean;
text: string;
x: number;
y: number;
};
const useStyles = makeStyles<Theme>(() => ({
bubble: {
pointerEvents: 'none',
userSelect: 'none',
opacity: 0,
},
visibleBubble: {
pointerEvents: 'none',
userSelect: 'none',
opacity: 0.8,
},
background: {
fill: '#333',
},
text: {
pointerEvents: 'none',
userSelect: 'none',
fontSize: '10px',
fill: '#fff',
},
}));
const RadarBubble: FC<Props> = props => {
const classes = useStyles(props);
const { visible, text } = props;
const textElem = useRef<SVGTextElement>(null);
const svgElem = useRef<SVGGElement>(null);
const rectElem = useRef<SVGRectElement>(null);
const pathElem = useRef<SVGPathElement>(null);
const updatePosition = () => {
if (textElem.current) {
const { x, y } = props;
const bbox = textElem.current.getBBox();
const marginX = 5;
const marginY = 4;
if (svgElem.current) {
svgElem.current.setAttribute(
'transform',
`translate(${x - bbox.width / 2}, ${y - bbox.height - marginY})`,
);
}
if (rectElem.current) {
rectElem.current.setAttribute('x', String(-marginX));
rectElem.current.setAttribute('y', String(-bbox.height));
rectElem.current.setAttribute(
'width',
String(bbox.width + 2 * marginX),
);
rectElem.current.setAttribute('height', String(bbox.height + marginY));
}
if (pathElem.current) {
pathElem.current.setAttribute(
'transform',
`translate(${bbox.width / 2 - marginX}, ${marginY - 1})`,
);
}
}
};
useEffect(() => {
updatePosition();
}, []);
useLayoutEffect(() => {
updatePosition();
});
return (
<g
ref={svgElem}
x={0}
y={0}
className={visible ? classes.visibleBubble : classes.bubble}
>
<rect ref={rectElem} rx={4} ry={4} className={classes.background} />
<text ref={textElem} className={classes.text}>
{text}
</text>
<path
ref={pathElem}
d="M 0,0 10,0 5,8 z"
className={classes.background}
/>
</g>
);
};
export default RadarBubble;
@@ -1,98 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { withStyles } from '@material-ui/core';
const styles = {
text: {
pointerEvents: 'none',
userSelect: 'none',
fontSize: '9px',
fill: '#fff',
textAnchor: 'middle',
},
link: {
cursor: 'pointer',
},
};
class RadarEntry extends React.PureComponent {
render() {
const {
moved,
color,
url,
number,
x,
y,
onMouseEnter,
onMouseLeave,
onClick,
classes,
} = this.props;
const style = { fill: color };
let blip;
if (moved > 0) {
blip = <path d="M -11,5 11,5 0,-13 z" style={style} />; // triangle pointing up
} else if (moved < 0) {
blip = <path d="M -11,-5 11,-5 0,13 z" style={style} />; // triangle pointing down
} else {
blip = <circle r={9} style={style} />;
}
if (url) {
blip = (
<a href={url} className={classes.link}>
{blip}
</a>
);
}
return (
<g
transform={`translate(${x}, ${y})`}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onClick={onClick}
>
{blip}
<text y={3} className={classes.text}>
{number}
</text>
</g>
);
}
}
RadarEntry.propTypes = {
x: PropTypes.number.isRequired,
y: PropTypes.number.isRequired,
number: PropTypes.number.isRequired,
color: PropTypes.string.isRequired,
url: PropTypes.string,
moved: PropTypes.number,
onMouseEnter: PropTypes.func,
onMouseLeave: PropTypes.func,
onClick: PropTypes.func,
classes: PropTypes.object.isRequired,
};
export default withStyles(styles)(RadarEntry);
@@ -0,0 +1,95 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { FC } from 'react';
import { makeStyles, Theme } from '@material-ui/core';
type Props = {
x: number;
y: number;
number: number;
color: string;
url?: string;
moved?: number;
onMouseEnter?: (event: React.MouseEvent<SVGGElement, MouseEvent>) => void;
onMouseLeave?: (event: React.MouseEvent<SVGGElement, MouseEvent>) => void;
onClick?: (event: React.MouseEvent<SVGGElement, MouseEvent>) => void;
};
const useStyles = makeStyles<Theme>(() => ({
text: {
pointerEvents: 'none',
userSelect: 'none',
fontSize: '9px',
fill: '#fff',
textAnchor: 'middle',
},
link: {
cursor: 'pointer',
},
}));
const RadarEntry: FC<Props> = props => {
const classes = useStyles(props);
const {
moved,
color,
url,
number,
x,
y,
onMouseEnter,
onMouseLeave,
onClick,
} = props;
const style = { fill: color };
let blip;
if (moved && moved > 0) {
blip = <path d="M -11,5 11,5 0,-13 z" style={style} />; // triangle pointing up
} else if (moved && moved < 0) {
blip = <path d="M -11,-5 11,-5 0,13 z" style={style} />; // triangle pointing down
} else {
blip = <circle r={9} style={style} />;
}
if (url) {
blip = (
<a href={url} className={classes.link}>
{blip}
</a>
);
}
return (
<g
transform={`translate(${x}, ${y})`}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onClick={onClick}
>
{blip}
<text y={3} className={classes.text}>
{number}
</text>
</g>
);
};
export default RadarEntry;
@@ -14,39 +14,32 @@
* limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { withStyles } from '@material-ui/core';
import React, { FC } from 'react';
import { makeStyles, Theme } from '@material-ui/core';
const styles = {
type Props = {
x: number;
y: number;
};
const useStyles = makeStyles<Theme>(() => ({
text: {
pointerEvents: 'none',
userSelect: 'none',
fontSize: '10px',
fill: '#000',
},
}));
const RadarFooter: FC<Props> = props => {
const { x, y } = props;
const classes = useStyles(props);
return (
<text transform={`translate(${x}, ${y})`} className={classes.text}>
{'▲ moved up\u00a0\u00a0\u00a0\u00a0\u00a0▼ moved down'}
</text>
);
};
class RadarFooter extends React.PureComponent {
render() {
const { x, y, classes } = this.props;
return (
<text
transform={`translate(${x}, ${y})`}
space="preserve"
className={classes.text}
>
{'▲ moved up\u00a0\u00a0\u00a0\u00a0\u00a0▼ moved down'}
</text>
);
}
}
RadarFooter.propTypes = {
x: PropTypes.number.isRequired,
y: PropTypes.number.isRequired,
classes: PropTypes.object.isRequired,
};
export default withStyles(styles)(RadarFooter);
export default RadarFooter;
@@ -1,99 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { withStyles } from '@material-ui/core';
import * as CommonPropTypes from '../../utils/prop-types';
const styles = {
ring: {
fill: 'none',
stroke: '#bbb',
strokeWidth: '1px',
},
axis: {
fill: 'none',
stroke: '#bbb',
strokeWidth: '1px',
},
text: {
pointerEvents: 'none',
userSelect: 'none',
fill: '#e5e5e5',
fontSize: '25px',
fontWeight: 800,
},
};
// A component for the background grid of the radar, with axes, rings etc. It will render around the origin, i.e.
// assume that (0, 0) is in the middle of the drawing.
class RadarGrid extends React.PureComponent {
render() {
const { radius, rings, classes } = this.props;
const makeRingNode = (ringRadius, ringIndex) => [
<circle
key={`c${ringIndex}`}
cx={0}
cy={0}
r={ringRadius}
className={classes.ring}
/>,
<text
key={`t${ringIndex}`}
y={-ringRadius + 42}
textAnchor="middle"
className={classes.text}
>
{rings[ringIndex].name}
</text>,
];
const axisNodes = [
// X axis
<line
key="x"
x1={0}
y1={-radius}
x2={0}
y2={radius}
className={classes.axis}
/>,
// Y axis
<line
key="y"
x1={-radius}
y1={0}
x2={radius}
y2={0}
className={classes.axis}
/>,
];
const ringNodes = rings.map(r => r.outerRadius).map(makeRingNode);
return axisNodes.concat(ringNodes);
}
}
RadarGrid.propTypes = {
radius: PropTypes.number.isRequired,
rings: PropTypes.arrayOf(PropTypes.shape(CommonPropTypes.RING)).isRequired,
classes: PropTypes.object.isRequired, // this is the withStyles HOC
};
export default withStyles(styles)(RadarGrid);
@@ -0,0 +1,96 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { makeStyles, Theme } from '@material-ui/core';
import { Ring } from '../../utils/types';
type Props = {
radius: number;
rings: Ring[];
};
const useStyles = makeStyles<Theme>(() => ({
ring: {
fill: 'none',
stroke: '#bbb',
strokeWidth: '1px',
},
axis: {
fill: 'none',
stroke: '#bbb',
strokeWidth: '1px',
},
text: {
pointerEvents: 'none',
userSelect: 'none',
fill: '#e5e5e5',
fontSize: '25px',
fontWeight: 800,
},
}));
// A component for the background grid of the radar, with axes, rings etc. It will render around the origin, i.e.
// assume that (0, 0) is in the middle of the drawing.
const RadarGrid = (props: Props) => {
const { radius, rings } = props;
const classes = useStyles(props);
const makeRingNode = (ringRadius: number | undefined, ringIndex: number) => [
<circle
key={`c${ringIndex}`}
cx={0}
cy={0}
r={ringRadius}
className={classes.ring}
/>,
<text
key={`t${ringIndex}`}
y={ringRadius !== undefined ? -ringRadius + 42 : undefined}
textAnchor="middle"
className={classes.text}
>
{rings[ringIndex].name}
</text>,
];
const axisNodes = [
// X axis
<line
key="x"
x1={0}
y1={-radius}
x2={0}
y2={radius}
className={classes.axis}
/>,
// Y axis
<line
key="y"
x1={-radius}
y1={0}
x2={radius}
y2={0}
className={classes.axis}
/>,
];
const ringNodes = rings.map(r => r.outerRadius).map(makeRingNode);
return <>{axisNodes.concat(...ringNodes)}</>;
};
export default RadarGrid;
@@ -13,12 +13,23 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { withStyles } from '@material-ui/core';
import * as CommonPropTypes from '../../utils/prop-types';
import React, { FC } from 'react';
import { makeStyles, Theme } from '@material-ui/core';
import { Quadrant, Ring, Entry } from '../../utils/types';
const styles = {
type Segments = {
[k: number]: { [k: number]: Entry[] };
};
type Props = {
quadrants: Quadrant[];
rings: Ring[];
entries: Entry[];
onEntryMouseEnter?: (entry: Entry) => void;
onEntryMouseLeave?: (entry: Entry) => void;
};
const useStyles = makeStyles<Theme>(() => ({
quadrant: {
height: '100%',
width: '100%',
@@ -66,50 +77,29 @@ const styles = {
entryLink: {
pointerEvents: 'none',
},
};
}));
class RadarLegend extends React.PureComponent {
static _renderQuadrant(
segments,
quadrant,
rings,
onEntryMouseEnter,
onEntryMouseLeave,
classes,
) {
return (
<foreignObject
key={quadrant.id}
x={quadrant.legendX}
y={quadrant.legendY}
width={quadrant.legendWidth}
height={quadrant.legendHeight}
>
<div className={classes.quadrant}>
<h2 className={classes.quadrantHeading}>{quadrant.name}</h2>
<div className={classes.rings}>
{rings.map(ring =>
RadarLegend._renderRing(
ring,
RadarLegend._getSegment(segments, quadrant, ring),
onEntryMouseEnter,
onEntryMouseLeave,
classes,
),
)}
</div>
</div>
</foreignObject>
);
}
const RadarLegend: FC<Props> = props => {
const classes = useStyles(props);
static _renderRing(
ring,
entries,
onEntryMouseEnter,
onEntryMouseLeave,
classes,
) {
const _getSegment = (
segmented: Segments,
quadrant: Quadrant,
ring: Ring,
ringOffset = 0,
) => {
const qidx = quadrant.idx;
const ridx = ring.idx;
const segmentedData = qidx === undefined ? {} : segmented[qidx] || {};
return ridx === undefined ? [] : segmentedData[ridx + ringOffset] || [];
};
const _renderRing = (
ring: Ring,
entries: Entry[],
onEntryMouseEnter?: Props['onEntryMouseEnter'],
onEntryMouseLeave?: Props['onEntryMouseEnter'],
) => {
return (
<div key={ring.id} className={classes.ring}>
<h3 className={classes.ringHeading}>{ring.name}</h3>
@@ -131,12 +121,12 @@ class RadarLegend extends React.PureComponent {
return (
<li
key={entry.id}
value={entry.idx + 1}
value={(entry.idx || 0) + 1}
onMouseEnter={
onEntryMouseEnter && (() => onEntryMouseEnter(entry))
}
onMouseLeave={
onEntryMouseEnter && (() => onEntryMouseLeave(entry))
onEntryMouseLeave && (() => onEntryMouseLeave(entry))
}
>
{node}
@@ -147,57 +137,93 @@ class RadarLegend extends React.PureComponent {
)}
</div>
);
}
};
static _getSegment(segmented, quadrant, ring, ringOffset = 0) {
return (segmented[quadrant.idx] || {})[ring.idx + ringOffset] || [];
}
const _renderQuadrant = (
segments: Segments,
quadrant: Quadrant,
rings: Ring[],
onEntryMouseEnter: Props['onEntryMouseEnter'],
onEntryMouseLeave: Props['onEntryMouseLeave'],
) => {
return (
<foreignObject
key={quadrant.id}
x={quadrant.legendX}
y={quadrant.legendY}
width={quadrant.legendWidth}
height={quadrant.legendHeight}
>
<div className={classes.quadrant}>
<h2 className={classes.quadrantHeading}>{quadrant.name}</h2>
<div className={classes.rings}>
{rings.map(ring =>
_renderRing(
ring,
_getSegment(segments, quadrant, ring),
onEntryMouseEnter,
onEntryMouseLeave,
),
)}
</div>
</div>
</foreignObject>
);
};
render() {
const {
quadrants,
rings,
entries,
onEntryMouseEnter,
onEntryMouseLeave,
classes,
} = this.props;
const segments = {};
const _setupSegments = (entries: Entry[]) => {
const segments: Segments = {};
for (const entry of entries) {
const qidx = entry.quadrant.idx;
const ridx = entry.ring.idx;
const quadrantData = segments[qidx] || (segments[qidx] = {});
const ringData = quadrantData[ridx] || (quadrantData[ridx] = []);
let quadrantData: { [k: number]: Entry[] } = {};
if (qidx !== undefined) {
if (segments[qidx] === undefined) {
segments[qidx] = {};
}
quadrantData = segments[qidx];
}
let ringData = [];
if (ridx !== undefined) {
if (quadrantData[ridx] === undefined) {
quadrantData[ridx] = [];
}
ringData = quadrantData[ridx];
}
ringData.push(entry);
}
return (
<g>
{quadrants.map(quadrant =>
RadarLegend._renderQuadrant(
segments,
quadrant,
rings,
onEntryMouseEnter,
onEntryMouseLeave,
classes,
),
)}
</g>
);
}
}
return segments;
};
RadarLegend.propTypes = {
quadrants: PropTypes.arrayOf(PropTypes.shape(CommonPropTypes.QUADRANT))
.isRequired,
rings: PropTypes.arrayOf(PropTypes.shape(CommonPropTypes.RING)).isRequired,
entries: PropTypes.arrayOf(PropTypes.shape(CommonPropTypes.ENTRY)).isRequired,
onEntryMouseEnter: PropTypes.func,
onEntryMouseLeave: PropTypes.func,
classes: PropTypes.object.isRequired,
const {
quadrants,
rings,
entries,
onEntryMouseEnter,
onEntryMouseLeave,
} = props;
const segments: Segments = _setupSegments(entries);
return (
<g>
{quadrants.map(quadrant =>
_renderQuadrant(
segments,
quadrant,
rings,
onEntryMouseEnter,
onEntryMouseLeave,
),
)}
</g>
);
};
export default withStyles(styles)(RadarLegend);
export default RadarLegend;
@@ -1,100 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import * as CommonPropTypes from '../../utils/prop-types';
import RadarGrid from '../RadarGrid';
import RadarEntry from '../RadarEntry';
import RadarBubble from '../RadarBubble';
import RadarFooter from '../RadarFooter';
import RadarLegend from '../RadarLegend';
// A component that draws the radar circle.
export default class RadarPlot extends React.PureComponent {
render() {
const {
width,
height,
radius,
quadrants,
rings,
entries,
activeEntry,
onEntryMouseEnter,
onEntryMouseLeave,
} = this.props;
return (
<g>
<RadarLegend
quadrants={quadrants}
rings={rings}
entries={entries}
onEntryMouseEnter={
onEntryMouseEnter && (entry => onEntryMouseEnter(entry))
}
onEntryMouseLeave={
onEntryMouseLeave && (entry => onEntryMouseLeave(entry))
}
/>
<g transform={`translate(${width / 2}, ${height / 2})`}>
<RadarGrid radius={radius} rings={rings} />
<RadarFooter x={-0.5 * width} y={0.5 * height} />
{entries.map(entry => (
<RadarEntry
key={entry.id}
x={entry.x}
y={entry.y}
color={entry.color}
title={entry.title}
number={entry.idx + 1}
url={entry.url}
moved={entry.moved}
active={activeEntry && activeEntry.id === entry.id}
onMouseEnter={
onEntryMouseEnter && (() => onEntryMouseEnter(entry))
}
onMouseLeave={
onEntryMouseLeave && (() => onEntryMouseLeave(entry))
}
/>
))}
<RadarBubble
visible={!!activeEntry}
text={activeEntry ? activeEntry.title : ''}
x={activeEntry ? activeEntry.x : 0}
y={activeEntry ? activeEntry.y : 0}
/>
</g>
</g>
);
}
}
RadarPlot.propTypes = {
width: PropTypes.number.isRequired,
height: PropTypes.number.isRequired,
radius: PropTypes.number.isRequired,
rings: PropTypes.arrayOf(PropTypes.shape(CommonPropTypes.RING)).isRequired,
quadrants: PropTypes.arrayOf(PropTypes.shape(CommonPropTypes.QUADRANT))
.isRequired,
entries: PropTypes.arrayOf(PropTypes.shape(CommonPropTypes.ENTRY)).isRequired,
activeEntry: PropTypes.object,
onEntryMouseEnter: PropTypes.func,
onEntryMouseLeave: PropTypes.func,
};
@@ -0,0 +1,92 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { FC } from 'react';
import { Quadrant, Ring, Entry } from '../../utils/types';
import RadarGrid from '../RadarGrid';
import RadarEntry from '../RadarEntry';
import RadarBubble from '../RadarBubble';
import RadarFooter from '../RadarFooter';
import RadarLegend from '../RadarLegend';
type Props = {
width: number;
height: number;
radius: number;
rings: Ring[];
quadrants: Quadrant[];
entries: Entry[];
activeEntry?: Entry;
onEntryMouseEnter?: (entry: Entry) => void;
onEntryMouseLeave?: (entry: Entry) => void;
};
// A component that draws the radar circle.
const RadarPlot: FC<Props> = props => {
const {
width,
height,
radius,
quadrants,
rings,
entries,
activeEntry,
onEntryMouseEnter,
onEntryMouseLeave,
} = props;
return (
<g>
<RadarLegend
quadrants={quadrants}
rings={rings}
entries={entries}
onEntryMouseEnter={
onEntryMouseEnter && (entry => onEntryMouseEnter(entry))
}
onEntryMouseLeave={
onEntryMouseLeave && (entry => onEntryMouseLeave(entry))
}
/>
<g transform={`translate(${width / 2}, ${height / 2})`}>
<RadarGrid radius={radius} rings={rings} />
<RadarFooter x={-0.5 * width} y={0.5 * height} />
{entries.map(entry => (
<RadarEntry
key={entry.id}
x={entry.x || 0}
y={entry.y || 0}
color={entry.color || ''}
number={((entry && entry.idx) || 0) + 1}
url={entry.url}
moved={entry.moved}
onMouseEnter={onEntryMouseEnter && (() => onEntryMouseEnter(entry))}
onMouseLeave={onEntryMouseLeave && (() => onEntryMouseLeave(entry))}
/>
))}
<RadarBubble
visible={!!activeEntry}
text={activeEntry ? activeEntry.title : ''}
x={activeEntry ? activeEntry.x || 0 : 0}
y={activeEntry ? activeEntry.y || 0 : 0}
/>
</g>
</g>
);
};
export default RadarPlot;
+14 -14
View File
@@ -36,48 +36,48 @@ quadrants.push({ id: 'process', name: 'Process' });
const entries = new Array<RadarEntry>();
entries.push({
moved: 0,
ring: 'use',
ring: { id: 'use', name: 'USE', color: '#93c47d' },
url: '#',
key: 'javascript',
id: 'javascript',
title: 'JavaScript',
quadrant: 'languages',
quadrant: { id: 'languages', name: 'Languages' },
});
entries.push({
moved: 0,
ring: 'use',
ring: { id: 'use', name: 'USE', color: '#93c47d' },
url: '#',
key: 'typescript',
id: 'typescript',
title: 'TypeScript',
quadrant: 'languages',
quadrant: { id: 'languages', name: 'Languages' },
});
entries.push({
moved: 0,
ring: 'use',
ring: { id: 'use', name: 'USE', color: '#93c47d' },
url: '#',
key: 'webpack',
id: 'webpack',
title: 'Webpack',
quadrant: 'frameworks',
quadrant: { id: 'frameworks', name: 'Frameworks' },
});
entries.push({
moved: 0,
ring: 'use',
ring: { id: 'use', name: 'USE', color: '#93c47d' },
url: '#',
key: 'react',
id: 'react',
title: 'React',
quadrant: 'frameworks',
quadrant: { id: 'frameworks', name: 'Frameworks' },
});
entries.push({
moved: 0,
ring: 'use',
ring: { id: 'use', name: 'USE', color: '#93c47d' },
url: '#',
key: 'code-reviews',
id: 'code-reviews',
title: 'Code Reviews',
quadrant: 'process',
quadrant: { id: 'process', name: 'Process' },
});
entries.push({
moved: 0,
@@ -85,17 +85,17 @@ entries.push({
key: 'mob-programming',
id: 'mob-programming',
title: 'Mob Programming',
quadrant: 'process',
ring: 'assess',
quadrant: { id: 'process', name: 'Process' },
ring: { id: 'assess', name: 'ASSESS', color: '#fbdb84' },
});
entries.push({
moved: 0,
ring: 'use',
ring: { id: 'use', name: 'USE', color: '#93c47d' },
url: '#',
key: 'github-actions',
id: 'github-actions',
title: 'GitHub Actions',
quadrant: 'infrastructure',
quadrant: { id: 'infrastructure', name: 'Infrastructure' },
});
export default function getSampleData(): Promise<TechRadarLoaderResponse> {
@@ -14,40 +14,59 @@
* limitations under the License.
*/
import PropTypes from 'prop-types';
// Parameters for a ring; its index in an array determines how close to the center this ring is.
export const RING = {
id: PropTypes.string.isRequired,
idx: PropTypes.number,
name: PropTypes.string.isRequired,
color: PropTypes.string.isRequired,
export type Ring = {
id: string;
idx?: number;
name: string;
color: string;
outerRadius?: number;
innerRadius?: number;
};
// Parameters for a quadrant (there should be exactly 4 of course)
export const QUADRANT = {
id: PropTypes.string.isRequired,
idx: PropTypes.number,
name: PropTypes.string.isRequired,
export type Quadrant = {
id: string;
idx?: number;
name: string;
legendX?: number;
legendY?: number;
legendWidth?: number;
legendHeight?: number;
radialMin?: number;
radialMax?: number;
offsetX?: number;
offsetY?: number;
};
export const ENTRY = {
id: PropTypes.string.isRequired,
idx: PropTypes.number,
export type Segment = {
clipx: Function;
clipy: Function;
random: Function;
};
export type Entry = {
id: string;
idx?: number;
x?: number;
y?: number;
color?: string;
segment?: Segment;
// The quadrant where this entry belongs
quadrant: PropTypes.shape(QUADRANT).isRequired,
quadrant: Quadrant;
// The ring where this entry belongs
ring: PropTypes.shape(RING).isRequired,
ring: Ring;
// The label that's shown in the legend and on hover
title: PropTypes.string.isRequired,
title: string;
// An URL to a longer description as to why this entry is where it is
url: PropTypes.string,
url?: string;
// How this entry has recently moved; -1 for "down", +1 for "up", 0 for not moved
moved: PropTypes.number,
moved?: number;
active?: boolean;
};
// The same as ENTRY except quadrant/ring are declared by their string ID instead of being the actual objects
export const DECLARED_ENTRY = Object.assign({}, ENTRY, {
quadrant: PropTypes.string.isRequired,
ring: PropTypes.string.isRequired,
});
// The same as Entry except quadrant/ring are declared by their string ID instead of being the actual objects
export type DeclaredEntry = Entry & {
quadrant: string;
ring: string;
};