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:
@@ -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;
|
||||
+20
-27
@@ -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;
|
||||
+117
-91
@@ -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;
|
||||
@@ -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> {
|
||||
|
||||
+43
-24
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user