Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Tools Experience Transitions Sankey #68

Open
wants to merge 21 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
44175a1
[results] Init ToolsExperienceTransitionsBlock
plouc Feb 8, 2022
5212eeb
[results] add links percentages to ToolsExperienceTransitionsChart
plouc Feb 8, 2022
09e9b0b
[results] add nodes to ToolsExperienceTransitionsChart
plouc Feb 8, 2022
c77f758
[results] add links silouhette to ToolsExperienceTransitionsChart
plouc Feb 8, 2022
1b40a69
[results] only show currently selected links percentage on ToolsExper…
plouc Feb 8, 2022
54e77cf
[results] add years legend to ToolsExperienceTransitionsChart
plouc Feb 8, 2022
b718610
[results] highlight current experience links on ToolsExperienceTransi…
plouc Feb 8, 2022
53b3d79
[results] sync current experience ID accross all ToolsExperienceTrans…
plouc Feb 8, 2022
5729e59
[results] add the ability to comtrol the selected source experience I…
plouc Feb 8, 2022
c49a78e
[results] use styles from the theme for the ToolsExperienceTransition…
plouc Feb 8, 2022
6cf945b
[results] use styles from the theme for the ToolsExperienceTransition…
plouc Feb 8, 2022
af16e18
[results] use styles from the theme for the ToolsExperienceTransition…
plouc Feb 8, 2022
c2e8a1c
[results] use styles from the theme for the ToolsExperienceTransition…
plouc Feb 8, 2022
0203fdf
[results] add the ability to toggle link silhouettes on ToolsExperien…
plouc Feb 8, 2022
7733e54
[results] add tool legend to the ToolsExperienceTransitionsChart
plouc Feb 8, 2022
efb8fd2
[results] show currently highlighted transition on the ToolsExperienc…
plouc Feb 9, 2022
0f7822d
[results] enable the ToolsExperienceTransitionsBlock for other sections
plouc Feb 9, 2022
6950f3a
[results] remove unnecessary compute in ToolsExperienceTransitionsChart
plouc Feb 9, 2022
8bde2e6
[results] remove links silhouettes from ToolsExperienceTransitionsCha…
plouc Feb 9, 2022
a8af47c
[results] use getLinkId consistently to compute links uuid in ToolsEx…
plouc Feb 9, 2022
e392a65
[results] reformatting and doc improvments on ToolsExperienceTransiti…
plouc Feb 9, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import React, { useCallback, useMemo, useState } from 'react'
import styled from 'styled-components'
import { ToolExperienceId } from 'core/bucket_keys'
import { useLegends } from 'core/helpers/useBucketKeys'
import Block from 'core/blocks/block/BlockVariant'
import {
ToolsExperienceTransitionsBlockData,
ApiToolExperienceTransitions,
} from './types'
import { ToolsExperienceTransitionsChart } from './ToolsExperienceTransitionsChart'

export const ToolsExperienceTransitionsBlock = ({
block,
data,
}: {
block: ToolsExperienceTransitionsBlockData
data: ApiToolExperienceTransitions[]
}) => {
const filteredData = useMemo(() =>
data.filter(toolData => toolData.experienceTransitions.nodes.length > 0),
[data]
)

const [currentExperience, _setCurrentExperience] = useState<ToolExperienceId>('interested')
const [currentTransition, _setCurrentTransition] = useState<[ToolExperienceId, ToolExperienceId] | null>([
'interested',
'would_use'
])

// avoid creating a new transition array if the values don't change
const setCurrentTransition = useCallback((transition: [ToolExperienceId, ToolExperienceId] | null) => {
_setCurrentTransition((previous) => {
if (transition === null) return null
if (previous !== null && previous[0] === transition[0] && previous[1] === transition[1]) {
return previous
}

return transition
})
}, [_setCurrentTransition])

// reset the current transition when a new source experience is selected
const setCurrentExperience = useCallback((experience: ToolExperienceId) => {
if (experience === currentExperience) return

_setCurrentExperience(experience)
_setCurrentTransition(null)
}, [currentExperience, _setCurrentTransition])

const keys = data[0].experienceTransitions.keys
const legends = useLegends(block, keys, 'tools')
const legendProps = useMemo(() => ({
current: currentExperience,
onClick: ({ id }: { id: ToolExperienceId }) => {
setCurrentExperience(id)
}
}), [currentExperience, setCurrentExperience])

return (
<Block
block={block}
data={filteredData}
legends={legends}
legendProps={legendProps}
>
<Grid>
{filteredData.map(toolData => (
<ToolsExperienceTransitionsChart
key={toolData.id}
data={toolData}
currentExperience={currentExperience}
setCurrentExperience={setCurrentExperience}
currentTransition={currentTransition}
setCurrentTransition={setCurrentTransition}
/>
))}
</Grid>
</Block>
)
}

const Grid = styled.div`
display: grid;
width: 100%;
grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr));
column-gap: 24px;
row-gap: 16px;
`
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import React, { useMemo } from 'react'
import { ToolExperienceId } from 'core/bucket_keys'
import { SankeyNodeDatum, SankeyLinkDatum, SankeyYear } from '../types'
import { useChartContext } from './state'
import { YearsLegend } from './YearsLegend'
import { LinkPercentages } from './LinkPercentages'
import { Nodes } from './Nodes'
import { ExperienceLinks } from './ExperienceLinks'
import { TransitionLegend } from './TransitionLegend'

/**
* Used to entirely replace the default nivo Sankey component,
* the default component is only used to compute node positions
* and links.
*
* This should be passed as the only layer to the Sankey component.
*/
export const CustomSankey = ({ nodes, links }: {
nodes: SankeyNodeDatum[]
links: SankeyLinkDatum[]
}) => {
const {
currentExperience,
setCurrentExperience,
currentTransition,
} = useChartContext()

const { years, linksByExperience } = useMemo(() => {
const _years: SankeyYear[] = []

const _linksByExperience: Partial<Record<ToolExperienceId, {
experience: ToolExperienceId
links: SankeyLinkDatum[]
}>> = {}

nodes.forEach(node => {
let year = _years.find(y => y.year === node.year)
if (!year) {
year = {
year: node.year,
x: node.x0 + (node.x1 - node.x0) / 2,
}
_years.push(year)
}
})

links.forEach(link => {
if (!_linksByExperience[link.source.choice]) {
_linksByExperience[link.source.choice] = {
experience: link.source.choice,
links: [],
}
}

_linksByExperience[link.source.choice]!.links.push(link)
})

_years.sort((a, b) => a.year - b.year)

return {
years: _years,
linksByExperience: Object.values(_linksByExperience),
}
}, [nodes, links])

const currentLinks = useMemo(() =>
linksByExperience.find(link => link.experience === currentExperience),
[linksByExperience, currentExperience]
)

let currentTransitionLink: SankeyLinkDatum | undefined = undefined
if (currentTransition !== null) {
currentTransitionLink = currentLinks!.links.find(link => {
return link.source.choice === currentTransition[0] && link.target.choice === currentTransition[1]
})
}

return (
<>
<YearsLegend years={years} />
<Nodes
nodes={nodes}
currentExperience={currentExperience}
setCurrentExperience={setCurrentExperience}
/>
{linksByExperience.map(links => (
<ExperienceLinks
key={links.experience}
experience={links.experience}
links={links.links}
isActive={links.experience === currentExperience}
/>
))}
<LinkPercentages links={currentLinks!.links} />
{currentTransitionLink && <TransitionLegend link={currentTransitionLink} />}
</>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import React, { memo, useMemo } from 'react'
import { useSpring, animated, config } from '@react-spring/web'
import { ToolExperienceId } from 'core/bucket_keys'
import { SankeyLinkDatum } from '../types'
import { LinkWithGradient} from './LinkWithGradient'
import { useChartContext } from './state'

export const NonMemoizedExperienceLinks = ({
experience,
links,
isActive
}: {
experience: ToolExperienceId
links: SankeyLinkDatum[]
isActive: boolean
}) => {
const { toolId } = useChartContext()
const maskId = `${experience}LinksMask${toolId}`
const maxWidth = useMemo(() => Math.max(...links.map((link) => link.target.x0)), [links])
const maskStyles = useSpring({
width: isActive ? maxWidth : 0,
opacity: isActive ? 1 : 0,
config: config.slow,
})

return (
<>
<mask id={maskId}>
<animated.rect
width={maskStyles.width}
height={1000}
fill="white"
/>
</mask>
<animated.g
mask={`url(#${maskId})`}
opacity={maskStyles.opacity}
>
{links.map((link) => (
<LinkWithGradient
key={`${link.source.id}.${link.target.id}`}
link={link}
isActive={isActive}
/>
))}
</animated.g>
</>
)
}

export const ExperienceLinks = memo(NonMemoizedExperienceLinks)
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import React from 'react'
import styled, { DefaultTheme, useTheme } from 'styled-components'
// @ts-ignore: indirect dependency managed by nivo
import { interpolateRgb } from 'd3-interpolate'
import { useTransition, animated, to, config } from '@react-spring/web'
import { SankeyLinkDatum } from '../types'

const getLinkCenter = (link: SankeyLinkDatum) => {
const x = link.target.x0 + (link.source.x1 - link.target.x0) / 2
const y = link.pos0 + (link.pos1 - link.pos0) / 2

return {x, y}
}

const getLinkColor = (link: SankeyLinkDatum, theme: DefaultTheme) =>
interpolateRgb(
theme.colors.ranges.tools[link.source.choice][0],
theme.colors.ranges.tools[link.target.choice][0]
)(.5)

export const LinkPercentages = ({
links,
}: {
links: SankeyLinkDatum[]
}) => {
const theme = useTheme()

const percentages = links.filter(link => link.thickness > 6)
.map(link => {
return {
id: `${link.source.id}.${link.target.id}`,
percentage: link.percentage,
color: getLinkColor(link, theme),
...getLinkCenter(link),
}
})

const transition = useTransition<(typeof percentages)[number], {
x: number
y: number
percentage: number
opacity: number
}>(percentages, {
keys: p => p.id,
from: (percentage) => ({
x: percentage.x,
y: percentage.y,
percentage: percentage.percentage,
opacity: 0,
}),
enter: (percentage) => ({
x: percentage.x,
y: percentage.y,
percentage: percentage.percentage,
opacity: 1,
}),
update: (percentage) => ({
x: percentage.x,
y: percentage.y,
percentage: percentage.percentage,
opacity: 1,
}),
leave: {
opacity: 0,
},
config: config.slow,
})

return (
<>
{transition(({ x, y, opacity }, percentage) => {
return (
<animated.g
key={percentage.id}
opacity={opacity}
transform={to([x, y], (_x, _y) => {
return `translate(${_x},${_y})`
})}
style={{
pointerEvents: 'none'
}}
>
<Label
textAnchor="middle"
dominantBaseline="central"
style={{
stroke: percentage.color,
strokeWidth: 4,
}}
>
{percentage.percentage}%
</Label>
<Label
textAnchor="middle"
dominantBaseline="central"
>
{percentage.percentage}%
</Label>
</animated.g>
)
})}
</>
)
}

const Label = styled.text`
font-size: ${({ theme }) => theme.typography.size.smaller};
font-weight: ${({ theme }) => theme.typography.weight.medium};
fill: ${({ theme }) => theme.colors.textInverted};
stroke-linejoin: round;
`
Loading