diff --git a/plugins/tech-radar/src/api.ts b/plugins/tech-radar/src/api.ts index 2e068fe49a..8a78da8027 100644 --- a/plugins/tech-radar/src/api.ts +++ b/plugins/tech-radar/src/api.ts @@ -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; } diff --git a/plugins/tech-radar/src/components/Radar/Radar.jsx b/plugins/tech-radar/src/components/Radar/Radar.jsx deleted file mode 100644 index 11f763f465..0000000000 --- a/plugins/tech-radar/src/components/Radar/Radar.jsx +++ /dev/null @@ -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 - │ │ . -- ~~~ -- . │ │ - │ │ .-~ ~-. │ │ - │ │ / \ │ │ - │ │ / \ │ │ - ┼───────────┼─────────────────────────────┼───────────┼─1 - ┼───────────┼─────────────────────────────┼───────────┼─2 - │ │ | | │ │ - │ │ \ / │ │ - │ │ \ / │ │ - │ │ `-. .-' │ │ - │ │ ~- . ___ . -~ │ │ - ┼───────────┼─────────────────────────────┼───────────┼─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 ( - { - this.node = node; - }} - width={width} - height={height} - {...this.props.svgProps} - > - this._setActiveEntry(entry)} - onEntryMouseLeave={() => this._clearActiveEntry()} - /> - - ); - } -} - -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, -}; diff --git a/plugins/tech-radar/src/components/Radar/Radar.test.tsx b/plugins/tech-radar/src/components/Radar/Radar.test.tsx index f68002ade2..469f0d85cf 100644 --- a/plugins/tech-radar/src/components/Radar/Radar.test.tsx +++ b/plugins/tech-radar/src/components/Radar/Radar.test.tsx @@ -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: '#', }, ], diff --git a/plugins/tech-radar/src/components/Radar/Radar.tsx b/plugins/tech-radar/src/components/Radar/Radar.tsx new file mode 100644 index 0000000000..1db655730d --- /dev/null +++ b/plugins/tech-radar/src/components/Radar/Radar.tsx @@ -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 => { + const { width, height, quadrants, rings, entries } = props; + const radius = Math.min(width, height) / 2; + + const [activeEntry, setActiveEntry] = useState(); + const node = useRef(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 ( + + setActiveEntry(entry)} + onEntryMouseLeave={() => setActiveEntry(null)} + /> + + ); +}; + +export default Radar; diff --git a/plugins/tech-radar/src/components/Radar/utils.ts b/plugins/tech-radar/src/components/Radar/utils.ts new file mode 100644 index 0000000000..a8896ddf31 --- /dev/null +++ b/plugins/tech-radar/src/components/Radar/utils.ts @@ -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 + │ │ . -- ~~~ -- . │ │ + │ │ .-~ ~-. │ │ + │ │ / \ │ │ + │ │ / \ │ │ + ┼───────────┼─────────────────────────────┼───────────┼─1 + ┼───────────┼─────────────────────────────┼───────────┼─2 + │ │ | | │ │ + │ │ \ / │ │ + │ │ \ / │ │ + │ │ `-. .-' │ │ + │ │ ~- . ___ . -~ │ │ + ┼───────────┼─────────────────────────────┼───────────┼─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; + }); +}; diff --git a/plugins/tech-radar/src/components/RadarBubble/RadarBubble.jsx b/plugins/tech-radar/src/components/RadarBubble/RadarBubble.jsx deleted file mode 100644 index 072f80ca20..0000000000 --- a/plugins/tech-radar/src/components/RadarBubble/RadarBubble.jsx +++ /dev/null @@ -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 ( - - - - {text} - - - - ); - } -} - -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); diff --git a/plugins/tech-radar/src/components/RadarBubble/RadarBubble.tsx b/plugins/tech-radar/src/components/RadarBubble/RadarBubble.tsx new file mode 100644 index 0000000000..28890f984b --- /dev/null +++ b/plugins/tech-radar/src/components/RadarBubble/RadarBubble.tsx @@ -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(() => ({ + 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 => { + const classes = useStyles(props); + const { visible, text } = props; + + const textElem = useRef(null); + const svgElem = useRef(null); + const rectElem = useRef(null); + const pathElem = useRef(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 ( + + + + {text} + + + + ); +}; + +export default RadarBubble; diff --git a/plugins/tech-radar/src/components/RadarEntry/RadarEntry.jsx b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.jsx deleted file mode 100644 index a6dc9e1fa0..0000000000 --- a/plugins/tech-radar/src/components/RadarEntry/RadarEntry.jsx +++ /dev/null @@ -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 = ; // triangle pointing up - } else if (moved < 0) { - blip = ; // triangle pointing down - } else { - blip = ; - } - - if (url) { - blip = ( - - {blip} - - ); - } - - return ( - - {blip} - - {number} - - - ); - } -} - -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); diff --git a/plugins/tech-radar/src/components/RadarEntry/RadarEntry.tsx b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.tsx new file mode 100644 index 0000000000..6006979e67 --- /dev/null +++ b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.tsx @@ -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) => void; + onMouseLeave?: (event: React.MouseEvent) => void; + onClick?: (event: React.MouseEvent) => void; +}; + +const useStyles = makeStyles(() => ({ + text: { + pointerEvents: 'none', + userSelect: 'none', + fontSize: '9px', + fill: '#fff', + textAnchor: 'middle', + }, + + link: { + cursor: 'pointer', + }, +})); + +const RadarEntry: FC = 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 = ; // triangle pointing up + } else if (moved && moved < 0) { + blip = ; // triangle pointing down + } else { + blip = ; + } + + if (url) { + blip = ( + + {blip} + + ); + } + + return ( + + {blip} + + {number} + + + ); +}; + +export default RadarEntry; diff --git a/plugins/tech-radar/src/components/RadarFooter/RadarFooter.jsx b/plugins/tech-radar/src/components/RadarFooter/RadarFooter.tsx similarity index 52% rename from plugins/tech-radar/src/components/RadarFooter/RadarFooter.jsx rename to plugins/tech-radar/src/components/RadarFooter/RadarFooter.tsx index c30079e5bf..7d60a63f59 100644 --- a/plugins/tech-radar/src/components/RadarFooter/RadarFooter.jsx +++ b/plugins/tech-radar/src/components/RadarFooter/RadarFooter.tsx @@ -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(() => ({ text: { pointerEvents: 'none', userSelect: 'none', fontSize: '10px', fill: '#000', }, +})); + +const RadarFooter: FC = props => { + const { x, y } = props; + const classes = useStyles(props); + + return ( + + {'▲ moved up\u00a0\u00a0\u00a0\u00a0\u00a0▼ moved down'} + + ); }; -class RadarFooter extends React.PureComponent { - render() { - const { x, y, classes } = this.props; - - return ( - - {'▲ moved up\u00a0\u00a0\u00a0\u00a0\u00a0▼ moved down'} - - ); - } -} - -RadarFooter.propTypes = { - x: PropTypes.number.isRequired, - y: PropTypes.number.isRequired, - classes: PropTypes.object.isRequired, -}; - -export default withStyles(styles)(RadarFooter); +export default RadarFooter; diff --git a/plugins/tech-radar/src/components/RadarGrid/RadarGrid.jsx b/plugins/tech-radar/src/components/RadarGrid/RadarGrid.jsx deleted file mode 100644 index ce6c046242..0000000000 --- a/plugins/tech-radar/src/components/RadarGrid/RadarGrid.jsx +++ /dev/null @@ -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) => [ - , - - {rings[ringIndex].name} - , - ]; - - const axisNodes = [ - // X axis - , - // Y 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); diff --git a/plugins/tech-radar/src/components/RadarGrid/RadarGrid.tsx b/plugins/tech-radar/src/components/RadarGrid/RadarGrid.tsx new file mode 100644 index 0000000000..514afbe958 --- /dev/null +++ b/plugins/tech-radar/src/components/RadarGrid/RadarGrid.tsx @@ -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(() => ({ + 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) => [ + , + + {rings[ringIndex].name} + , + ]; + + const axisNodes = [ + // X axis + , + // Y axis + , + ]; + + const ringNodes = rings.map(r => r.outerRadius).map(makeRingNode); + + return <>{axisNodes.concat(...ringNodes)}; +}; + +export default RadarGrid; diff --git a/plugins/tech-radar/src/components/RadarLegend/RadarLegend.jsx b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx similarity index 59% rename from plugins/tech-radar/src/components/RadarLegend/RadarLegend.jsx rename to plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx index 674dd34cbb..03146a8071 100644 --- a/plugins/tech-radar/src/components/RadarLegend/RadarLegend.jsx +++ b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx @@ -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(() => ({ 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 ( - -
-

{quadrant.name}

-
- {rings.map(ring => - RadarLegend._renderRing( - ring, - RadarLegend._getSegment(segments, quadrant, ring), - onEntryMouseEnter, - onEntryMouseLeave, - classes, - ), - )} -
-
-
- ); - } +const RadarLegend: FC = 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 (

{ring.name}

@@ -131,12 +121,12 @@ class RadarLegend extends React.PureComponent { return (
  • onEntryMouseEnter(entry)) } onMouseLeave={ - onEntryMouseEnter && (() => onEntryMouseLeave(entry)) + onEntryMouseLeave && (() => onEntryMouseLeave(entry)) } > {node} @@ -147,57 +137,93 @@ class RadarLegend extends React.PureComponent { )}
  • ); - } + }; - 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 ( + +
    +

    {quadrant.name}

    +
    + {rings.map(ring => + _renderRing( + ring, + _getSegment(segments, quadrant, ring), + onEntryMouseEnter, + onEntryMouseLeave, + ), + )} +
    +
    +
    + ); + }; - 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 ( - - {quadrants.map(quadrant => - RadarLegend._renderQuadrant( - segments, - quadrant, - rings, - onEntryMouseEnter, - onEntryMouseLeave, - classes, - ), - )} - - ); - } -} + 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 ( + + {quadrants.map(quadrant => + _renderQuadrant( + segments, + quadrant, + rings, + onEntryMouseEnter, + onEntryMouseLeave, + ), + )} + + ); }; -export default withStyles(styles)(RadarLegend); +export default RadarLegend; diff --git a/plugins/tech-radar/src/components/RadarPlot/RadarPlot.jsx b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.jsx deleted file mode 100644 index b93d20cbff..0000000000 --- a/plugins/tech-radar/src/components/RadarPlot/RadarPlot.jsx +++ /dev/null @@ -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 ( - - onEntryMouseEnter(entry)) - } - onEntryMouseLeave={ - onEntryMouseLeave && (entry => onEntryMouseLeave(entry)) - } - /> - - - - {entries.map(entry => ( - onEntryMouseEnter(entry)) - } - onMouseLeave={ - onEntryMouseLeave && (() => onEntryMouseLeave(entry)) - } - /> - ))} - - - - ); - } -} - -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, -}; diff --git a/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx new file mode 100644 index 0000000000..48ad660dde --- /dev/null +++ b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx @@ -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 => { + const { + width, + height, + radius, + quadrants, + rings, + entries, + activeEntry, + onEntryMouseEnter, + onEntryMouseLeave, + } = props; + + return ( + + onEntryMouseEnter(entry)) + } + onEntryMouseLeave={ + onEntryMouseLeave && (entry => onEntryMouseLeave(entry)) + } + /> + + + + {entries.map(entry => ( + onEntryMouseEnter(entry))} + onMouseLeave={onEntryMouseLeave && (() => onEntryMouseLeave(entry))} + /> + ))} + + + + ); +}; + +export default RadarPlot; diff --git a/plugins/tech-radar/src/sampleData.ts b/plugins/tech-radar/src/sampleData.ts index 8372e8a69f..5976d07559 100644 --- a/plugins/tech-radar/src/sampleData.ts +++ b/plugins/tech-radar/src/sampleData.ts @@ -36,48 +36,48 @@ quadrants.push({ id: 'process', name: 'Process' }); const entries = new Array(); 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 { diff --git a/plugins/tech-radar/src/utils/prop-types.js b/plugins/tech-radar/src/utils/types.ts similarity index 57% rename from plugins/tech-radar/src/utils/prop-types.js rename to plugins/tech-radar/src/utils/types.ts index d5491435b6..14a0554a1f 100644 --- a/plugins/tech-radar/src/utils/prop-types.js +++ b/plugins/tech-radar/src/utils/types.ts @@ -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; +};