Merge pull request #14038 from marcofaggian/master

fix(plugins/tech-radar): list rerenders on hover
This commit is contained in:
Patrik Oldsberg
2022-10-21 15:59:03 +02:00
committed by GitHub
10 changed files with 405 additions and 261 deletions
@@ -14,10 +14,10 @@
* limitations under the License.
*/
import React, { useState, useRef } from 'react';
import React, { useMemo, useRef, useState } from 'react';
import type { Entry, Quadrant, Ring } from '../../utils/types';
import RadarPlot from '../RadarPlot';
import type { Ring, Quadrant, Entry } from '../../utils/types';
import { adjustQuadrants, adjustRings, adjustEntries } from './utils';
import { adjustEntries, adjustQuadrants, adjustRings } from './utils';
export type Props = {
width: number;
@@ -28,17 +28,40 @@ export type Props = {
svgProps?: object;
};
const Radar = (props: Props): JSX.Element => {
const { width, height, quadrants, rings, entries } = props;
const Radar = ({
width,
height,
quadrants,
rings,
entries,
...props
}: Props): JSX.Element => {
const radius = Math.min(width, height) / 2;
// State
const [activeEntry, setActiveEntry] = useState<Entry>();
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, quadrants, rings, radius, activeEntry);
// Adjusted props
const adjustedQuadrants = useMemo(
() => adjustQuadrants(quadrants, radius, width, height),
[quadrants, radius, width, height],
);
const adjustedRings = useMemo(
() => adjustRings(rings, radius),
[radius, rings],
);
const adjustedEntries = useMemo(
() =>
adjustEntries(
entries,
adjustedQuadrants,
adjustedRings,
radius,
activeEntry,
),
[entries, adjustedQuadrants, adjustedRings, radius, activeEntry],
);
return (
<svg ref={node} width={width} height={height} {...props.svgProps}>
@@ -46,9 +69,9 @@ const Radar = (props: Props): JSX.Element => {
width={width}
height={height}
radius={radius}
entries={entries}
quadrants={quadrants}
rings={rings}
entries={adjustedEntries}
quadrants={adjustedQuadrants}
rings={adjustedRings}
activeEntry={activeEntry}
onEntryMouseEnter={entry => setActiveEntry(entry)}
onEntryMouseLeave={() => setActiveEntry(undefined)}
@@ -17,7 +17,7 @@
import color from 'color';
import { forceCollide, forceSimulation } from 'd3-force';
import Segment from '../../utils/segment';
import type { Ring, Quadrant, Entry } from '../../utils/types';
import type { Entry, Quadrant, Ring } from '../../utils/types';
export const adjustQuadrants = (
quadrants: Quadrant[],
@@ -81,18 +81,21 @@ export const adjustQuadrants = (
},
];
quadrants.forEach((quadrant, index) => {
return quadrants.map((quadrant, index) => {
const legendParam = legendParams[index % 4];
quadrant.index = index;
quadrant.radialMin = (index * Math.PI) / 2;
quadrant.radialMax = ((index + 1) * Math.PI) / 2;
quadrant.offsetX = index % 4 === 0 || index % 4 === 3 ? 1 : -1;
quadrant.offsetY = index % 4 === 0 || index % 4 === 1 ? 1 : -1;
quadrant.legendX = legendParam.x;
quadrant.legendY = legendParam.y;
quadrant.legendWidth = legendParam.width;
quadrant.legendHeight = legendParam.height;
return {
...quadrant,
index,
radialMin: (index * Math.PI) / 2,
radialMax: ((index + 1) * Math.PI) / 2,
offsetX: index % 4 === 0 || index % 4 === 3 ? 1 : -1,
offsetY: index % 4 === 0 || index % 4 === 1 ? 1 : -1,
legendX: legendParam.x,
legendY: legendParam.y,
legendWidth: legendParam.width,
legendHeight: legendParam.height,
};
});
};
@@ -102,9 +105,9 @@ export const adjustEntries = (
rings: Ring[],
radius: number,
activeEntry?: Entry,
) => {
): Entry[] => {
let seed = 42;
entries.forEach((entry, index) => {
const adjustedEntries = entries.map((entry, index) => {
const quadrant = quadrants.find(q => {
const match =
typeof entry.quadrant === 'object' ? entry.quadrant.id : entry.quadrant;
@@ -123,22 +126,26 @@ export const adjustEntries = (
if (!ring) {
throw new Error(`Unknown ring ${entry.ring} for entry ${entry.id}!`);
}
const segment = new Segment(quadrant, ring, radius, () => seed++);
const point = segment?.random();
entry.index = index;
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();
return {
...entry,
index: index,
quadrant: quadrant,
ring: ring,
segment,
x: point.x,
y: point.y,
color:
activeEntry && entry.id === activeEntry?.id
? entry.ring.color
: color(entry.ring.color).desaturate(0.5).lighten(0.1).string(),
};
});
const simulation = forceSimulation()
.nodes(entries)
.nodes(adjustedEntries)
.velocityDecay(0.19)
.force('collision', forceCollide().radius(12).strength(0.85))
.stop();
@@ -153,20 +160,21 @@ export const adjustEntries = (
) {
simulation.tick();
for (const entry of entries) {
for (const entry of adjustedEntries) {
if (entry.segment) {
entry.x = entry.segment.clipx(entry);
entry.y = entry.segment.clipy(entry);
}
}
}
return adjustedEntries;
};
export const adjustRings = (rings: Ring[], radius: number) => {
rings.forEach((ring, index) => {
ring.index = index;
ring.outerRadius = ((index + 2) / (rings.length + 1)) * radius;
ring.innerRadius =
((index === 0 ? 0 : index + 1) / (rings.length + 1)) * radius;
});
};
export const adjustRings = (rings: Ring[], radius: number) =>
rings.map((ring, index) => ({
...ring,
index,
outerRadius: ((index + 2) / (rings.length + 1)) * radius,
innerRadius: ((index === 0 ? 0 : index + 1) / (rings.length + 1)) * radius,
}));
@@ -14,15 +14,16 @@
* limitations under the License.
*/
import React from 'react';
import { render } from '@testing-library/react';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import { ThemeProvider } from '@material-ui/core';
import { render } from '@testing-library/react';
import React from 'react';
import GetBBoxPolyfill from '../../utils/polyfills/getBBox';
import RadarLegend, { Props } from './RadarLegend';
import RadarLegend from './RadarLegend';
import { RadarLegendProps } from './types';
const minProps: Props = {
const minProps: RadarLegendProps = {
quadrants: [{ id: 'languages', name: 'Languages' }],
rings: [{ id: 'use', name: 'USE', color: '#93c47d' }],
entries: [
@@ -1,5 +1,5 @@
/*
* Copyright 2020 The Backstage Authors
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -15,21 +15,9 @@
*/
import { makeStyles, Theme } from '@material-ui/core';
import React from 'react';
import { WithLink } from '../../utils/components';
import type { Entry, Quadrant, Ring } from '../../utils/types';
import { RadarDescription } from '../RadarDescription';
type Segments = {
[k: number]: { [k: number]: Entry[] };
};
export type Props = {
quadrants: Quadrant[];
rings: Ring[];
entries: Entry[];
onEntryMouseEnter?: (entry: Entry) => void;
onEntryMouseLeave?: (entry: Entry) => void;
};
import { RadarLegendQuadrant } from './RadarLegendQuadrant';
import { RadarLegendProps } from './types';
import { setupSegments } from './utils';
const useStyles = makeStyles<Theme>(theme => ({
quadrant: {
@@ -85,209 +73,25 @@ const useStyles = makeStyles<Theme>(theme => ({
},
}));
const RadarLegend = (props: Props): JSX.Element => {
const RadarLegend = ({
quadrants,
rings,
entries,
onEntryMouseEnter,
onEntryMouseLeave,
...props
}: RadarLegendProps): JSX.Element => {
const classes = useStyles(props);
const getSegment = (
segmented: Segments,
quadrant: Quadrant,
ring: Ring,
ringOffset = 0,
) => {
const quadrantIndex = quadrant.index;
const ringIndex = ring.index;
const segmentedData =
quadrantIndex === undefined ? {} : segmented[quadrantIndex] || {};
return ringIndex === undefined
? []
: segmentedData[ringIndex + ringOffset] || [];
};
type RadarLegendRingProps = {
ring: Ring;
entries: Entry[];
onEntryMouseEnter?: Props['onEntryMouseEnter'];
onEntryMouseLeave?: Props['onEntryMouseEnter'];
};
type RadarLegendLinkProps = {
url?: string;
description?: string;
title?: string;
};
const RadarLegendLink = ({
url,
description,
title,
}: RadarLegendLinkProps) => {
const [open, setOpen] = React.useState(false);
const handleClickOpen = () => {
setOpen(true);
};
const handleClose = () => {
setOpen(false);
};
const toggle = () => {
setOpen(!open);
};
if (description) {
return (
<>
<span
className={classes.entryLink}
onClick={handleClickOpen}
role="button"
tabIndex={0}
onKeyPress={toggle}
>
<span className={classes.entry}>{title}</span>
</span>
{open && (
<RadarDescription
open={open}
onClose={handleClose}
title={title ? title : 'no title'}
url={url}
description={description}
/>
)}
</>
);
}
return (
<WithLink url={url} className={classes.entryLink}>
<span className={classes.entry}>{title}</span>
</WithLink>
);
};
const RadarLegendRing = ({
ring,
entries,
onEntryMouseEnter,
onEntryMouseLeave,
}: RadarLegendRingProps) => {
return (
<div data-testid="radar-ring" key={ring.id} className={classes.ring}>
<h3 className={classes.ringHeading}>{ring.name}</h3>
{entries.length === 0 ? (
<p className={classes.ringEmpty}>(empty)</p>
) : (
<ol className={classes.ringList}>
{entries.map(entry => (
<li
key={entry.id}
value={(entry.index || 0) + 1}
onMouseEnter={
onEntryMouseEnter && (() => onEntryMouseEnter(entry))
}
onMouseLeave={
onEntryMouseLeave && (() => onEntryMouseLeave(entry))
}
>
<RadarLegendLink
url={entry.url}
title={entry.title}
description={entry.description}
/>
</li>
))}
</ol>
)}
</div>
);
};
type RadarLegendQuadrantProps = {
segments: Segments;
quadrant: Quadrant;
rings: Ring[];
onEntryMouseEnter: Props['onEntryMouseEnter'];
onEntryMouseLeave: Props['onEntryMouseLeave'];
};
const RadarLegendQuadrant = ({
segments,
quadrant,
rings,
onEntryMouseEnter,
onEntryMouseLeave,
}: RadarLegendQuadrantProps) => {
return (
<foreignObject
key={quadrant.id}
x={quadrant.legendX}
y={quadrant.legendY}
width={quadrant.legendWidth}
height={quadrant.legendHeight}
data-testid="radar-quadrant"
>
<div className={classes.quadrant}>
<h2 className={classes.quadrantHeading}>{quadrant.name}</h2>
<div className={classes.rings}>
{rings.map(ring => (
<RadarLegendRing
key={ring.id}
ring={ring}
entries={getSegment(segments, quadrant, ring)}
onEntryMouseEnter={onEntryMouseEnter}
onEntryMouseLeave={onEntryMouseLeave}
/>
))}
</div>
</div>
</foreignObject>
);
};
const setupSegments = (entries: Entry[]) => {
const segments: Segments = {};
for (const entry of entries) {
const quadrantIndex = entry.quadrant.index;
const ringIndex = entry.ring.index;
let quadrantData: { [k: number]: Entry[] } = {};
if (quadrantIndex !== undefined) {
if (segments[quadrantIndex] === undefined) {
segments[quadrantIndex] = {};
}
quadrantData = segments[quadrantIndex];
}
let ringData = [];
if (ringIndex !== undefined) {
if (quadrantData[ringIndex] === undefined) {
quadrantData[ringIndex] = [];
}
ringData = quadrantData[ringIndex];
}
ringData.push(entry);
}
return segments;
};
const { quadrants, rings, entries, onEntryMouseEnter, onEntryMouseLeave } =
props;
const segments: Segments = setupSegments(entries);
return (
<g data-testid="radar-legend">
{quadrants.map(quadrant => (
<RadarLegendQuadrant
key={quadrant.id}
segments={segments}
segments={setupSegments(entries)}
quadrant={quadrant}
rings={rings}
classes={classes}
onEntryMouseEnter={onEntryMouseEnter}
onEntryMouseLeave={onEntryMouseLeave}
/>
@@ -0,0 +1,77 @@
/*
* Copyright 2022 The Backstage Authors
*
* 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 { ClassNameMap } from '@material-ui/core/styles/withStyles';
import React from 'react';
import { WithLink } from '../../utils/components';
import { RadarDescription } from '../RadarDescription';
type RadarLegendLinkProps = {
url?: string;
description?: string;
title?: string;
classes: ClassNameMap<string>;
};
export const RadarLegendLink = ({
url,
description,
title,
classes,
}: RadarLegendLinkProps) => {
const [open, setOpen] = React.useState(false);
const handleClickOpen = () => {
setOpen(true);
};
const handleClose = () => {
setOpen(false);
};
const toggle = () => {
setOpen(!open);
};
if (description) {
return (
<>
<span
className={classes.entryLink}
onClick={handleClickOpen}
role="button"
tabIndex={0}
onKeyPress={toggle}
>
<span className={classes.entry}>{title}</span>
</span>
{open && (
<RadarDescription
open={open}
onClose={handleClose}
title={title ? title : 'no title'}
url={url}
description={description}
/>
)}
</>
);
}
return (
<WithLink url={url} className={classes.entryLink}>
<span className={classes.entry}>{title}</span>
</WithLink>
);
};
@@ -0,0 +1,67 @@
/*
* Copyright 2022 The Backstage Authors
*
* 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 { ClassNameMap } from '@material-ui/core/styles/withStyles';
import React from 'react';
import { Quadrant, Ring } from '../../utils/types';
import { RadarLegendRing } from './RadarLegendRing';
import { RadarLegendProps, Segments } from './types';
import { getSegment } from './utils';
type RadarLegendQuadrantProps = {
segments: Segments;
quadrant: Quadrant;
rings: Ring[];
classes: ClassNameMap<string>;
onEntryMouseEnter: RadarLegendProps['onEntryMouseEnter'];
onEntryMouseLeave: RadarLegendProps['onEntryMouseLeave'];
};
export const RadarLegendQuadrant = ({
segments,
quadrant,
rings,
classes,
onEntryMouseEnter,
onEntryMouseLeave,
}: RadarLegendQuadrantProps) => {
return (
<foreignObject
key={quadrant.id}
x={quadrant.legendX}
y={quadrant.legendY}
width={quadrant.legendWidth}
height={quadrant.legendHeight}
data-testid="radar-quadrant"
>
<div className={classes.quadrant}>
<h2 className={classes.quadrantHeading}>{quadrant.name}</h2>
<div className={classes.rings}>
{rings.map(ring => (
<RadarLegendRing
key={ring.id}
ring={ring}
classes={classes}
entries={getSegment(segments, quadrant, ring)}
onEntryMouseEnter={onEntryMouseEnter}
onEntryMouseLeave={onEntryMouseLeave}
/>
))}
</div>
</div>
</foreignObject>
);
};
@@ -0,0 +1,67 @@
/*
* Copyright 2022 The Backstage Authors
*
* 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 { ClassNameMap } from '@material-ui/core/styles/withStyles';
import React from 'react';
import { Entry, Ring } from '../../utils/types';
import { RadarLegendLink } from './RadarLegendLink';
import { RadarLegendProps } from './types';
type RadarLegendRingProps = {
ring: Ring;
entries: Entry[];
classes: ClassNameMap<string>;
onEntryMouseEnter?: RadarLegendProps['onEntryMouseEnter'];
onEntryMouseLeave?: RadarLegendProps['onEntryMouseEnter'];
};
export const RadarLegendRing = ({
ring,
entries,
classes,
onEntryMouseEnter,
onEntryMouseLeave,
}: RadarLegendRingProps) => {
return (
<div data-testid="radar-ring" key={ring.id} className={classes.ring}>
<h3 className={classes.ringHeading}>{ring.name}</h3>
{entries.length === 0 ? (
<p className={classes.ringEmpty}>(empty)</p>
) : (
<ol className={classes.ringList}>
{entries.map(entry => (
<li
key={entry.id}
value={(entry.index || 0) + 1}
onMouseEnter={
onEntryMouseEnter && (() => onEntryMouseEnter(entry))
}
onMouseLeave={
onEntryMouseLeave && (() => onEntryMouseLeave(entry))
}
>
<RadarLegendLink
classes={classes}
url={entry.url}
title={entry.title}
description={entry.description}
/>
</li>
))}
</ol>
)}
</div>
);
};
@@ -0,0 +1,29 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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 { Entry, Quadrant, Ring } from '../../utils/types';
export type Segments = {
[k: number]: { [k: number]: Entry[] };
};
export type RadarLegendProps = {
quadrants: Quadrant[];
rings: Ring[];
entries: Entry[];
onEntryMouseEnter?: (entry: Entry) => void;
onEntryMouseLeave?: (entry: Entry) => void;
};
@@ -0,0 +1,63 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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 { Entry, Quadrant, Ring } from '../../utils/types';
import { Segments } from './types';
export const setupSegments = (entries: Entry[]) => {
const segments: Segments = {};
for (const entry of entries) {
const quadrantIndex = entry.quadrant.index;
const ringIndex = entry.ring.index;
let quadrantData: { [k: number]: Entry[] } = {};
if (quadrantIndex !== undefined) {
if (segments[quadrantIndex] === undefined) {
segments[quadrantIndex] = {};
}
quadrantData = segments[quadrantIndex];
}
let ringData = [];
if (ringIndex !== undefined) {
if (quadrantData[ringIndex] === undefined) {
quadrantData[ringIndex] = [];
}
ringData = quadrantData[ringIndex];
}
ringData.push(entry);
}
return segments;
};
export const getSegment = (
segmented: Segments,
quadrant: Quadrant,
ring: Ring,
ringOffset = 0,
) => {
const quadrantIndex = quadrant.index;
const ringIndex = ring.index;
const segmentedData =
quadrantIndex === undefined ? {} : segmented[quadrantIndex] || {};
return ringIndex === undefined
? []
: segmentedData[ringIndex + ringOffset] || [];
};