diff --git a/plugins/tech-radar/src/components/ExampleComponent/ExampleComponent.tsx b/plugins/tech-radar/src/components/ExampleComponent/ExampleComponent.tsx deleted file mode 100644 index a01859993e..0000000000 --- a/plugins/tech-radar/src/components/ExampleComponent/ExampleComponent.tsx +++ /dev/null @@ -1,59 +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, { FC } from 'react'; -import { Typography, Grid } from '@material-ui/core'; -import { - InfoCard, - Header, - Page, - pageTheme, - Content, - ContentHeader, - HeaderLabel, - SupportButton, -} from '@backstage/core'; -import ExampleFetchComponent from '../ExampleFetchComponent'; - -const ExampleComponent: FC<{}> = () => ( - -
- - -
- - - A description of your plugin goes here. - - - - - - All content should be wrapped in a card like this. - - - - - - - - - - -
-); - -export default ExampleComponent; diff --git a/plugins/tech-radar/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx b/plugins/tech-radar/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx deleted file mode 100644 index a198bfcc3f..0000000000 --- a/plugins/tech-radar/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx +++ /dev/null @@ -1,119 +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, { FC } from 'react'; -import { makeStyles } from '@material-ui/core/styles'; -import Table from '@material-ui/core/Table'; -import TableBody from '@material-ui/core/TableBody'; -import TableCell from '@material-ui/core/TableCell'; -import TableContainer from '@material-ui/core/TableContainer'; -import TableHead from '@material-ui/core/TableHead'; -import TableRow from '@material-ui/core/TableRow'; -import Alert from '@material-ui/lab/Alert'; -import { useAsync } from 'react-use'; -import { Progress } from '@backstage/core'; - -const useStyles = makeStyles({ - table: { - minWidth: 650, - }, - avatar: { - height: 32, - width: 32, - borderRadius: '50%', - }, -}); - -type User = { - gender: string; // "male" - name: { - title: string; // "Mr", - first: string; // "Duane", - last: string; // "Reed" - }; - location: object; // {street: {number: 5060, name: "Hickory Creek Dr"}, city: "Albany", state: "New South Wales",…} - email: string; // "duane.reed@example.com" - login: object; // {uuid: "4b785022-9a23-4ab9-8a23-cb3fb43969a9", username: "blackdog796", password: "patch",…} - dob: object; // {date: "1983-06-22T12:30:23.016Z", age: 37} - registered: object; // {date: "2006-06-13T18:48:28.037Z", age: 14} - phone: string; // "07-2154-5651" - cell: string; // "0405-592-879" - id: { - name: string; // "TFN", - value: string; // "796260432" - }; - picture: { medium: string }; // {medium: "https://randomuser.me/api/portraits/men/95.jpg",…} - nat: string; // "AU" -}; - -type DenseTableProps = { - users: User[]; -}; - -export const DenseTable: FC = ({ users }) => { - const classes = useStyles(); - - return ( - - - - - Avatar - Name - Email - Nationality - - - - {users.map(user => ( - - - {user.name.first} - - - {user.name.first} {user.name.last} - - {user.email} - {user.nat} - - ))} - -
-
- ); -}; - -const ExampleFetchComponent: FC<{}> = () => { - const { value, loading, error } = useAsync(async (): Promise => { - const response = await fetch('https://randomuser.me/api/?results=20'); - const data = await response.json(); - return data.results; - }, []); - - if (loading) { - return ; - } else if (error) { - return {error.message}; - } - - return ; -}; - -export default ExampleFetchComponent; diff --git a/plugins/tech-radar/src/components/Radar/Radar.js b/plugins/tech-radar/src/components/Radar/Radar.js new file mode 100644 index 0000000000..8665f7b877 --- /dev/null +++ b/plugins/tech-radar/src/components/Radar/Radar.js @@ -0,0 +1,230 @@ +/* + * 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, colors) { + 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={entry => 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, +}; diff --git a/plugins/tech-radar/src/components/ExampleComponent/index.ts b/plugins/tech-radar/src/components/Radar/index.ts similarity index 92% rename from plugins/tech-radar/src/components/ExampleComponent/index.ts rename to plugins/tech-radar/src/components/Radar/index.ts index e785d45082..0d48a54c71 100644 --- a/plugins/tech-radar/src/components/ExampleComponent/index.ts +++ b/plugins/tech-radar/src/components/Radar/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { default } from './ExampleComponent'; +export { default } from './Radar'; diff --git a/plugins/tech-radar/src/components/RadarBubble/RadarBubble.css b/plugins/tech-radar/src/components/RadarBubble/RadarBubble.css new file mode 100644 index 0000000000..1eaa6c4179 --- /dev/null +++ b/plugins/tech-radar/src/components/RadarBubble/RadarBubble.css @@ -0,0 +1,20 @@ +.bubble { + pointer-events: none; + user-select: none; + opacity: 0; +} + +.visibleBubble { + composes: bubble; + opacity: 0.8; +} + +.background { + fill: #333; +} + +.text { + composes: text from './index.css'; + font-size: 10px; + fill: #fff; +} diff --git a/plugins/tech-radar/src/components/RadarBubble/RadarBubble.js b/plugins/tech-radar/src/components/RadarBubble/RadarBubble.js new file mode 100644 index 0000000000..88d5306aba --- /dev/null +++ b/plugins/tech-radar/src/components/RadarBubble/RadarBubble.js @@ -0,0 +1,94 @@ +/* + * 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 styles from './RadarBubble.css'; + +export default 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 } = this.props; + return ( + + + + {text} + + + + ); + } +} + +RadarBubble.propTypes = { + visible: PropTypes.bool.isRequired, + text: PropTypes.string.isRequired, + x: PropTypes.number.isRequired, + y: PropTypes.number.isRequired, +}; diff --git a/plugins/tech-radar/src/components/ExampleFetchComponent/index.ts b/plugins/tech-radar/src/components/RadarBubble/index.ts similarity index 92% rename from plugins/tech-radar/src/components/ExampleFetchComponent/index.ts rename to plugins/tech-radar/src/components/RadarBubble/index.ts index 28482f9fe1..840f4a3d21 100644 --- a/plugins/tech-radar/src/components/ExampleFetchComponent/index.ts +++ b/plugins/tech-radar/src/components/RadarBubble/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { default } from './ExampleFetchComponent'; +export { default } from './RadarBubble'; diff --git a/plugins/tech-radar/src/components/RadarEntry/RadarEntry.css b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.css new file mode 100644 index 0000000000..e534c859dd --- /dev/null +++ b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.css @@ -0,0 +1,10 @@ +.text { + composes: text from './index.css'; + font-size: 9px; + fill: #fff; + text-anchor: middle; +} + +.link { + cursor: pointer; +} diff --git a/plugins/tech-radar/src/components/RadarEntry/RadarEntry.js b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.js new file mode 100644 index 0000000000..3ed881b1a3 --- /dev/null +++ b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.js @@ -0,0 +1,80 @@ +/* + * 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 styles from './RadarEntry.css'; + +export default class RadarEntry extends React.PureComponent { + render() { + const { + moved, + color, + url, + number, + x, + y, + onMouseEnter, + onMouseLeave, + onClick, + } = 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, +}; diff --git a/plugins/tech-radar/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx b/plugins/tech-radar/src/components/RadarEntry/index.ts similarity index 56% rename from plugins/tech-radar/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx rename to plugins/tech-radar/src/components/RadarEntry/index.ts index 7fecdc6f11..661589f3de 100644 --- a/plugins/tech-radar/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx +++ b/plugins/tech-radar/src/components/RadarEntry/index.ts @@ -14,15 +14,4 @@ * limitations under the License. */ -import React from 'react'; -import { render } from '@testing-library/react'; -import mockFetch from 'jest-fetch-mock'; -import ExampleFetchComponent from './ExampleFetchComponent'; - -describe('ExampleFetchComponent', () => { - it('should render', async () => { - mockFetch.mockResponse(() => new Promise(() => {})); - const rendered = render(); - expect(await rendered.findByTestId('progress')).toBeInTheDocument(); - }); -}); +export { default } from './RadarEntry'; diff --git a/plugins/tech-radar/src/components/RadarFooter/RadarFooter.css b/plugins/tech-radar/src/components/RadarFooter/RadarFooter.css new file mode 100644 index 0000000000..7657b45a96 --- /dev/null +++ b/plugins/tech-radar/src/components/RadarFooter/RadarFooter.css @@ -0,0 +1,5 @@ +.text { + composes: text from './index.css'; + font-size: 10px; + fill: #000; +} diff --git a/plugins/tech-radar/src/components/ExampleComponent/ExampleComponent.test.tsx b/plugins/tech-radar/src/components/RadarFooter/RadarFooter.js similarity index 52% rename from plugins/tech-radar/src/components/ExampleComponent/ExampleComponent.test.tsx rename to plugins/tech-radar/src/components/RadarFooter/RadarFooter.js index 7a19c9df85..3d81b13204 100644 --- a/plugins/tech-radar/src/components/ExampleComponent/ExampleComponent.test.tsx +++ b/plugins/tech-radar/src/components/RadarFooter/RadarFooter.js @@ -15,20 +15,26 @@ */ import React from 'react'; -import { render } from '@testing-library/react'; -import mockFetch from 'jest-fetch-mock'; -import ExampleComponent from './ExampleComponent'; -import { ThemeProvider } from '@material-ui/core'; -import { lightTheme } from '@backstage/theme'; +import PropTypes from 'prop-types'; +import styles from './RadarFooter.css'; -describe('ExampleComponent', () => { - it('should render', () => { - mockFetch.mockResponse(() => new Promise(() => {})); - const rendered = render( - - - , +export default class RadarFooter extends React.PureComponent { + render() { + const { x, y } = this.props; + + return ( + + {'▲ moved up\u00a0\u00a0\u00a0\u00a0\u00a0▼ moved down'} + ); - expect(rendered.getByText('Welcome to tech-radar!')).toBeInTheDocument(); - }); -}); + } +} + +RadarFooter.propTypes = { + x: PropTypes.number.isRequired, + y: PropTypes.number.isRequired, +}; diff --git a/plugins/tech-radar/src/components/RadarFooter/index.ts b/plugins/tech-radar/src/components/RadarFooter/index.ts new file mode 100644 index 0000000000..6f81404a47 --- /dev/null +++ b/plugins/tech-radar/src/components/RadarFooter/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export { default } from './RadarFooter'; diff --git a/plugins/tech-radar/src/components/RadarGrid/RadarGrid.css b/plugins/tech-radar/src/components/RadarGrid/RadarGrid.css new file mode 100644 index 0000000000..0e95c74009 --- /dev/null +++ b/plugins/tech-radar/src/components/RadarGrid/RadarGrid.css @@ -0,0 +1,18 @@ +.ring { + fill: none; + stroke: #bbb; + stroke-width: 1px; +} + +.axis { + fill: none; + stroke: #bbb; + stroke-width: 1px; +} + +.text { + composes: text from './index.css'; + fill: #e5e5e5; + font-size: 25px; + font-weight: bold; +} diff --git a/plugins/tech-radar/src/components/RadarGrid/RadarGrid.js b/plugins/tech-radar/src/components/RadarGrid/RadarGrid.js new file mode 100644 index 0000000000..66f423075b --- /dev/null +++ b/plugins/tech-radar/src/components/RadarGrid/RadarGrid.js @@ -0,0 +1,76 @@ +/* + * 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 styles from './RadarGrid.css'; + +// 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. +export default class RadarGrid extends React.PureComponent { + render() { + const { radius, rings } = 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, +}; diff --git a/plugins/tech-radar/src/components/RadarGrid/index.ts b/plugins/tech-radar/src/components/RadarGrid/index.ts new file mode 100644 index 0000000000..5bd8afa782 --- /dev/null +++ b/plugins/tech-radar/src/components/RadarGrid/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export { default } from './RadarGrid'; diff --git a/plugins/tech-radar/src/components/RadarLegend/RadarLegend.css b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.css new file mode 100644 index 0000000000..c23b9198a4 --- /dev/null +++ b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.css @@ -0,0 +1,51 @@ +.quadrant { + height: 100%; + width: 100%; + overflow: hidden; + pointer-events: none; +} + +.quadrantHeading { + composes: text from './index.css'; + margin-top: 0; + margin-bottom: calc(18px * 0.375); + font-size: 18px; +} + +.rings { + columns: 3; +} + +.ring { + break-inside: avoid-column; + page-break-inside: avoid; + -webkit-column-break-inside: avoid; + font-size: 12px; +} + +.ringHeading { + composes: text from './index.css'; + margin-top: 0; + margin-bottom: calc(12px * 0.375); + font-size: 12px; + font-weight: bold; +} + +.ringList { + list-style-position: inside; + margin-top: 0; + padding-left: 0; + font-variant-numeric: proportional-nums; + -moz-font-feature-settings: 'pnum'; + -webkit-font-feature-settings: 'pnum'; + font-feature-settings: 'pnum'; +} + +.entry { + composes: text from './index.css'; + font-size: 11px; +} + +.entryLink { + pointer-events: auto; +} diff --git a/plugins/tech-radar/src/components/RadarLegend/RadarLegend.js b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.js new file mode 100644 index 0000000000..77e04da558 --- /dev/null +++ b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.js @@ -0,0 +1,152 @@ +/* + * 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 styles from './RadarLegend.css'; + +export default class RadarLegend extends React.PureComponent { + static _renderQuadrant( + segments, + quadrant, + rings, + onEntryMouseEnter, + onEntryMouseLeave, + onEntryClick, + ) { + return ( + +
+

{quadrant.name}

+
+ {rings.map(ring => + RadarLegend._renderRing( + ring, + RadarLegend._getSegment(segments, quadrant, ring), + onEntryMouseEnter, + onEntryMouseLeave, + onEntryClick, + ), + )} +
+
+
+ ); + } + + static _renderRing( + ring, + entries, + onEntryMouseEnter, + onEntryMouseLeave, + onEntryClick, + ) { + return ( +
+

{ring.name}

+ {entries.length === 0 ? ( +

(empty)

+ ) : ( +
    + {entries.map(entry => { + let node = {entry.title}; + + if (entry.url) { + node = ( + + {node} + + ); + } + + return ( +
  1. onEntryMouseEnter(entry)) + } + onMouseLeave={ + onEntryMouseEnter && (() => onEntryMouseLeave(entry)) + } + onClick={onEntryClick && (() => onEntryClick(entry))} + > + {node} +
  2. + ); + })} +
+ )} +
+ ); + } + + static _getSegment(segmented, quadrant, ring, ringOffset = 0) { + return (segmented[quadrant.idx] || {})[ring.idx + ringOffset] || []; + } + + render() { + const { + quadrants, + rings, + entries, + onEntryMouseEnter, + onEntryMouseLeave, + onEntryClick, + } = this.props; + + const 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] = []); + ringData.push(entry); + } + + return ( + + {quadrants.map(quadrant => + RadarLegend._renderQuadrant( + segments, + quadrant, + rings, + onEntryMouseEnter, + onEntryMouseLeave, + onEntryClick, + ), + )} + + ); + } +} + +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, + onEntryClick: PropTypes.func, +}; diff --git a/plugins/tech-radar/src/components/RadarLegend/index.ts b/plugins/tech-radar/src/components/RadarLegend/index.ts new file mode 100644 index 0000000000..7214fc7929 --- /dev/null +++ b/plugins/tech-radar/src/components/RadarLegend/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export { default } from './RadarLegend'; diff --git a/plugins/tech-radar/src/components/RadarPlot/RadarPlot.js b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.js new file mode 100644 index 0000000000..bf31c3e6fa --- /dev/null +++ b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.js @@ -0,0 +1,104 @@ +/* + * 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, + onEntryClick, + } = this.props; + + return ( + + onEntryMouseEnter(entry)) + } + onEntryMouseLeave={ + onEntryMouseLeave && (entry => onEntryMouseLeave(entry)) + } + onEntryClick={onEntryClick && (entry => onEntryClick(entry))} + /> + + + + {entries.map(entry => ( + onEntryMouseEnter(entry)) + } + onMouseLeave={ + onEntryMouseLeave && (() => onEntryMouseLeave(entry)) + } + onClick={onEntryClick && (() => onEntryClick(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, + onEntryClick: PropTypes.func, +}; diff --git a/plugins/tech-radar/src/components/RadarPlot/index.ts b/plugins/tech-radar/src/components/RadarPlot/index.ts new file mode 100644 index 0000000000..61ea1900d4 --- /dev/null +++ b/plugins/tech-radar/src/components/RadarPlot/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export { default } from './RadarPlot'; diff --git a/plugins/tech-radar/src/utils/prop-types.js b/plugins/tech-radar/src/utils/prop-types.js new file mode 100644 index 0000000000..d5491435b6 --- /dev/null +++ b/plugins/tech-radar/src/utils/prop-types.js @@ -0,0 +1,53 @@ +/* + * 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 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, +}; + +// 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 const ENTRY = { + id: PropTypes.string.isRequired, + idx: PropTypes.number, + // The quadrant where this entry belongs + quadrant: PropTypes.shape(QUADRANT).isRequired, + // The ring where this entry belongs + ring: PropTypes.shape(RING).isRequired, + // The label that's shown in the legend and on hover + title: PropTypes.string.isRequired, + // An URL to a longer description as to why this entry is where it is + url: PropTypes.string, + // How this entry has recently moved; -1 for "down", +1 for "up", 0 for not moved + moved: PropTypes.number, +}; + +// 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, +}); diff --git a/plugins/tech-radar/src/utils/segment.js b/plugins/tech-radar/src/utils/segment.js new file mode 100644 index 0000000000..a4cb921c48 --- /dev/null +++ b/plugins/tech-radar/src/utils/segment.js @@ -0,0 +1,107 @@ +/* + * 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. + */ + +export default class Segment { + constructor(quadrant, ring, radius, nextSeed) { + this.nextSeed = nextSeed; + this.polarMin = { + t: quadrant.radialMin, + r: ring.innerRadius, + }; + this.polarMax = { + t: quadrant.radialMax, + r: ring.outerRadius, + }; + this.cartesianMin = { + x: 15 * quadrant.offsetX, + y: 15 * quadrant.offsetY, + }; + this.cartesianMax = { + x: radius * quadrant.offsetX, + y: radius * quadrant.offsetY, + }; + } + + clipx(d) { + const c = boundedBox(d, this.cartesianMin, this.cartesianMax); + const p = boundedRing(polar(c), this.polarMin.r + 15, this.polarMax.r - 15); + d.x = cartesian(p).x; + return d.x; + } + + clipy(d) { + const c = boundedBox(d, this.cartesianMin, this.cartesianMax); + const p = boundedRing(polar(c), this.polarMin.r + 15, this.polarMax.r - 15); + d.y = cartesian(p).y; + return d.y; + } + + random() { + return cartesian({ + t: this._randomBetween(this.polarMin.t, this.polarMax.t), + r: this._normalBetween(this.polarMin.r, this.polarMax.r), + }); + } + + // custom random number generator, to make random sequence reproducible + // source: https://stackoverflow.com/questions/521295 + _random() { + const x = Math.sin(this.nextSeed()) * 10000; + return x - Math.floor(x); + } + + _randomBetween(min, max) { + return min + this._random() * (max - min); + } + + _normalBetween(min, max) { + return min + (this._random() + this._random()) * 0.5 * (max - min); + } +} + +function polar({ x, y }) { + return { + t: Math.atan2(y, x), + r: Math.sqrt(x * x + y * y), + }; +} + +function cartesian({ r, t }) { + return { + x: r * Math.cos(t), + y: r * Math.sin(t), + }; +} + +function boundedInterval(value, min, max) { + const low = Math.min(min, max); + const high = Math.max(min, max); + return Math.min(Math.max(value, low), high); +} + +function boundedRing(polarValue, rMin, rMax) { + return { + t: polarValue.t, + r: boundedInterval(polarValue.r, rMin, rMax), + }; +} + +function boundedBox(point, min, max) { + return { + x: boundedInterval(point.x, min.x, max.x), + y: boundedInterval(point.y, min.y, max.y), + }; +}