Skip to content

Commit

Permalink
Merge pull request #307 from amosproj/exploration-preview-animation
Browse files Browse the repository at this point in the history
Exploration preview animation
  • Loading branch information
joluj authored Jun 16, 2021
2 parents 95be29b + 8623270 commit cba125a
Show file tree
Hide file tree
Showing 5 changed files with 209 additions and 31 deletions.
10 changes: 5 additions & 5 deletions frontend/cypress/integration/exploration/exploration.e2e.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,20 @@ context('Exploration', () => {
cy.get('.Previews');

const layoutsDataValues = Object.values(layoutsData);
cy.get('.LayoutPreview').each(($el, index) => {
cy.wrap($el).should('have.text', layoutsDataValues[index].description);
});
for (const layoutsDataValue of layoutsDataValues) {
cy.get('.LayoutPreview').contains(layoutsDataValue.description);
}
});

it('routes to graph page', () => {
cy.get('.Previews');
cy.get('.LayoutPreview').eq(0).click();
cy.get('.LayoutPreview').contains('Structural Layout').click();
cy.url().should('eq', `http://localhost:3000${layoutsData.C.path}`);
});

it('routes to hierarchical page', () => {
cy.get('.Previews');
cy.get('.LayoutPreview').eq(2).click();
cy.get('.LayoutPreview').contains('Hierarchical Layout').click();
cy.url().should('eq', `http://localhost:3000${layoutsData.H.path}`);
});

Expand Down
140 changes: 140 additions & 0 deletions frontend/src/common/AnimatedList/AnimatedList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import React, {
Key,
RefObject,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { List } from '@material-ui/core';

/**
* GUI element of the {@link AnimatedList}.
*/
export type AnimatedListElement = JSX.Element & {
ref?: RefObject<HTMLElement>;
};

type Props = {
/**
* List-items; must have a key and a ref.
*/
children: AnimatedListElement[];
className?: string;
};

/**
* Information about items in the AnimatedList-
*/
type ItemGuiInformation = {
/**
* Map with key = element key and value = bounding box.
*/
boxes: Map<Key, DOMRect>;
/**
* Scroll position at the creation of objects of this type.
*/
scrollY: number;
};

/**
* Returns an {@link ItemGuiInformation} object for the given list items
* @param children elements of the list
*/
function createItemGuiInformation(
children: AnimatedListElement[]
): ItemGuiInformation {
const boundingBoxes = new Map<Key, DOMRect>();

React.Children.forEach(children, (child) => {
const domNode = child.ref?.current;
if (domNode && child.key != null) {
boundingBoxes.set(child.key, domNode.getBoundingClientRect());
}
});

return { boxes: boundingBoxes, scrollY: window.scrollY };
}

/**
* Outputs the previous state from a variable of useState.
* See https://reactjs.org/docs/hooks-faq.html#how-to-get-the-previous-props-or-state
*/
function usePrevious<T>(value: T) {
const prevValue = useRef<T>();

useEffect(() => {
prevValue.current = value;
}, [value]);

return prevValue.current;
}

/**
* Returns a list that animated the reordering of its children.
* @param children list-items
* @param className react standard className attribute
*/
function AnimatedList({ children, className }: Props): JSX.Element {
const [currItemGuiInformation, setCurrItemGuiInformation] =
useState<ItemGuiInformation>({
boxes: new Map(),
scrollY: 0,
});
const prevItemGuiInformation = usePrevious(currItemGuiInformation);

// calculates the boxes while blocking the next render => consistent result
useLayoutEffect(() => {
// When children change (e.g. reorder) => update item information
setCurrItemGuiInformation(createItemGuiInformation(children));
}, [children]);

useEffect(() => {
// if prevItemGuiInformation exists ... (else no animation on first draw)
if (prevItemGuiInformation && prevItemGuiInformation.boxes.size > 0) {
// Check each child for changed position
React.Children.forEach(children, (child) => {
const domNode = child.ref?.current;
// current/new bounding box for that child
const currBox = currItemGuiInformation.boxes.get(child.key ?? '');
// previous bounding box
const prevBox = prevItemGuiInformation.boxes.get(child.key ?? '');

// If box/box-position does not exists (e.g. on new item) => stop
if (domNode == null || currBox === undefined || prevBox === undefined)
return;

// y-positions with cleaned scrolling (y independent of scrolling)
// e.g. pos = 50, then 25px scrolling => normal y would be 75, cleaned is still 50
const currScrollCleanedY = prevBox.top - currItemGuiInformation.scrollY;
const prevScrollCleanedY = currBox.top - prevItemGuiInformation.scrollY;

// difference of prev and curr position
const deltaY = currScrollCleanedY - prevScrollCleanedY;

// if difference is there => animate
if (deltaY !== 0) {
requestAnimationFrame(() => {
// first, put item to its prev position
domNode.style.transform = `translateY(${deltaY}px)`;
domNode.style.transition = 'transform 0s';

requestAnimationFrame(() => {
// after the previous frame, remove the transition and play the animation
domNode.style.transform = '';
domNode.style.transition = 'transform 500ms';
});
});
}
});
}
}, [currItemGuiInformation, children]);

return <List className={className}>{children}</List>;
}

AnimatedList.defaultProps = {
className: '',
};

export default AnimatedList;
61 changes: 41 additions & 20 deletions frontend/src/exploration/Exploration.tsx
Original file line number Diff line number Diff line change
@@ -1,38 +1,59 @@
import React from 'react';
import React, { useRef, useState } from 'react';
import { Box, makeStyles } from '@material-ui/core';
import { createStyles } from '@material-ui/core/styles';
import { createStyles, Theme } from '@material-ui/core/styles';
import useResizeObserver from '@react-hook/resize-observer';
import Previews from './previews/Previews';
import Questions from './questions/Questions';

const useStyle = makeStyles((theme) =>
type StyleProps = {
previewHeaderHeight: number;
};

const useStyle = makeStyles<Theme, StyleProps>((theme) =>
createStyles({
container: {
margin: theme.spacing(4),
margin: theme.spacing(10),
// top margin should be a bit smaller
marginTop: theme.spacing(5),
gap: theme.spacing(4),
},
preview: {
height: '66vh',
},
questions: (props) => ({
marginTop: props.previewHeaderHeight,
}),
})
);

function Exploration(): JSX.Element {
const classes = useStyle();
// ref to the main content container
const containerRef = useRef<HTMLDivElement>(null);

// height of the header of the preview
const [previewHeaderHeight, setPreviewHeaderHeight] = useState<number>(0);

// Whenever the document resizes, calculate the height of the the header of the previews
// so that the questions are on the same height as the preview card.
// Used in favor to fixes pixels in order to react on a possible multi lined header.
useResizeObserver(containerRef, () => {
setPreviewHeaderHeight(
document.querySelector('.Previews h1')?.getBoundingClientRect().height ??
0
);
});

const classes = useStyle({ previewHeaderHeight });
return (
<>
<h1>Exploration</h1>
<Box
className={classes.container}
display="flex"
justifyContent="space-around"
>
<Box flex={3}>
<Questions />
</Box>
<Box flex={2} className={classes.preview}>
<Previews />
<h1 id="ExplorationHeader">Exploration</h1>
<div ref={containerRef}>
<Box className={classes.container} display="flex">
<Box flex={3} className={classes.questions}>
<Questions />
</Box>
<Box flex={2}>
<Previews />
</Box>
</Box>
</Box>
</div>
</>
);
}
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/exploration/previews/LayoutCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ function LayoutCard(layout: LayoutDefinition): JSX.Element {
const imagePath = `/exploration-preview/${filename}`;

return (
<ListItem key={description}>
<ListItem>
<Link to={path} style={{ textDecoration: 'none' }}>
<Card className={`${classes.card} LayoutPreview`}>
<CardMedia
Expand Down
27 changes: 22 additions & 5 deletions frontend/src/exploration/previews/Previews.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import React from 'react';
import { Box, List, makeStyles, Paper } from '@material-ui/core';
import React, { createRef } from 'react';
import { Box, makeStyles, Paper } from '@material-ui/core';
import { createStyles } from '@material-ui/core/styles';
import LayoutCard from './LayoutCard';
import useService from '../../dependency-injection/useService';
import ExplorationStore from '../../stores/exploration/ExplorationStore';
import useObservable from '../../utils/useObservable';
import layoutsData from './layoutsData';
import { ExplorationWeight } from '../../stores/exploration';
import AnimatedList from '../../common/AnimatedList/AnimatedList';

const useStyle = makeStyles(() =>
createStyles({
Expand All @@ -17,6 +18,12 @@ const useStyle = makeStyles(() =>
paper: {
maxHeight: '100%',
overflow: 'auto',
maxWidth: '22em',
margin: 'auto',
},
header: {
margin: 0,
textAlign: 'center',
},
})
);
Expand All @@ -37,14 +44,24 @@ function Previews(): JSX.Element {
const layoutsPreviewData = (
Object.keys(weights) as (keyof ExplorationWeight)[]
)
.sort((a, b) => weights[a] - weights[b])
.sort((a, b) => weights[a] - weights[b] || a.localeCompare(b))
.map((layout) => layoutsData[layout]);

return (
<Box className={`${classes.container} Previews`}>
<h1>Recommended Visualisations</h1>
<h1 className={classes.header}>Recommended Visualisations</h1>
<Paper className={classes.paper}>
<List>{layoutsPreviewData.map((elem) => LayoutCard(elem))}</List>
<AnimatedList>
{layoutsPreviewData.map((preview) => (
<div key={preview.description} ref={createRef()}>
<LayoutCard
filename={preview.filename}
description={preview.description}
path={preview.path}
/>
</div>
))}
</AnimatedList>
</Paper>
</Box>
);
Expand Down

0 comments on commit cba125a

Please sign in to comment.