diff --git a/packages/webapp/src/components/ChartSection/Markup.jsx b/packages/webapp/src/components/ChartSection/Markup.jsx index ce5b1870e..fdf959a59 100644 --- a/packages/webapp/src/components/ChartSection/Markup.jsx +++ b/packages/webapp/src/components/ChartSection/Markup.jsx @@ -4,6 +4,8 @@ import trimValues from '../../helpers/trimValues'; import Icon from '@material-ui/icons/ArrowForward'; import SectionHeading from '../SectionHeading'; import CssBaseline from '@material-ui/core/CssBaseline'; +import StickyHeading from './StickyHeading'; +import Minimap from '../Minimap'; import { DetailsWrapper, @@ -70,18 +72,20 @@ const Markup = (props) => { phases, anchor, title, - isConsolidatedChart + isConsolidatedChart, + minimapRender, } = props; - + return ( + {!!minimapRender && !!selected && } {!!selected && callDetails(selected, verb, subject, isConsolidatedChart)} {callChart(chart, onSelectedChange)} - {footer && {footer}} + {footer && {footer}} diff --git a/packages/webapp/src/components/ChartSection/StickyHeading/index.jsx b/packages/webapp/src/components/ChartSection/StickyHeading/index.jsx new file mode 100644 index 000000000..ef89e6e4c --- /dev/null +++ b/packages/webapp/src/components/ChartSection/StickyHeading/index.jsx @@ -0,0 +1,113 @@ +import React, { createPortal } from 'react'; +import styled from 'styled-components'; +import { darken } from 'polished'; +import { ArrowForward } from '@material-ui/icons'; +import { Button } from '@material-ui/core'; +import trimValue from '../../../helpers/trimValues'; + +const Wrapper = styled.div` +width: 100%; + position: fixed; + z-index: 99999; + top: 0; + left: 0; +` + +const Content = styled.div` + display: flex; + width: 100%; + align-items: center; + background: white; + box-shadow: rgba(0, 0, 0, 0.2) 0px 1px 8px 0px, rgba(0, 0, 0, 0.14) 0px 3px 4px 0px, rgba(0, 0, 0, 0.12) 0px 3px 3px -2px; + border-bottom: 2px solid white; +` + +const Icon = styled(ArrowForward)` + && { + height: 15px; + width: 15px; + } +`; + +const Info = styled.div` + width: 100%; + padding: 17px; + min-width: 0; +`; + +const ButtonWrapper = styled.a` + text-decoration: none; + padding-right: 17px; + display: block; +`; + +const ButtonStyle = styled(Button)` + && { + background-color: ${({ color }) => color}; + text-transform: none; + box-shadow: none; + min-width: 0; + font-weight: 700; + line-height: 120%; + text-align: center; + color: #000; + display: flex; + justify-content: space-between; + align-items: center; + + font-size: 12px; + padding: 9px; + + &:hover { + background-color: ${({ color }) => darken(0.1, color)}; + } + } +`; + +const TextExploreButton = styled.div` + padding-right: 12px; + white-space: nowrap; + + @media screen and (min-width: 450px) { + padding-right: 24px; + } +`; + +const InnerButton = ({ url, color, verb }) => ( + + {verb} + + +); + +const Title = styled.div` + font-size: 12px; + font-family: Roboto, sans-serif; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const Amount = styled.div` + font-size: 24px; + font-weight: bold; + font-family: Roboto, sans-serif; +`; + + +const MobileSectionHeading = ({ selected: { url, color, name, value, id }, verb, minimapRender }) => ( + + + + {name} + R{trimValue(value)} + + + + + +
{!!minimapRender && minimapRender(id)}
+
+); + +export default MobileSectionHeading; \ No newline at end of file diff --git a/packages/webapp/src/components/ChartSection/StickyHeading/styled.js b/packages/webapp/src/components/ChartSection/StickyHeading/styled.js new file mode 100644 index 000000000..1f6b74cd5 --- /dev/null +++ b/packages/webapp/src/components/ChartSection/StickyHeading/styled.js @@ -0,0 +1,164 @@ +import { + Typography, + Select, +} from '@material-ui/core'; + +import styled from 'styled-components'; + +const Wrapper = styled.div` + display: flex; + justify-content: center; + align-items: center; + margin-right: 16px; + margin-left: 16px; + border-bottom: 2px solid red; +`; + +const BudgetContainer = styled.div` + border-bottom: 1px solid #000; + margin-bottom: 20px; + max-width: 1200px; + padding-bottom: 16px; + width: 100%; + display: flex; + flex-direction: column; + + @media screen and (min-width: 950px) { + flex-direction: row; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + padding-bottom: 24px; + } +`; + +const BudgetHeadingAndShareIcon = styled.div` + display: flex; + justify-content: space-between; + align-items: right; +`; + +const BudgetHeading = styled(Typography)` + && { + font-weight: 700; + font-size: 18px; + line-height: 120%; + color: #000; + text-transform: Capitalize; + text-align: left; + + @media screen and (min-width: 950px) { + white-space: nowrap; + padding-right: 22px; + font-size: 32px; + } + } +`; + +const FormContainer = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 12px; + + @media screen and (min-width: 375px) { + width: 100%; + flex-wrap: wrap; + } + + @media screen and (min-width: 950px) { + width: auto; + flex-wrap: nowrap; + margin-top: 0; + } +`; + +const BudgetPhase = styled.div` + @media screen and (min-width: 375px) { + width: 60%; + } + + @media screen and (min-width: 950px) { + margin-right: 24px; + width: auto; + } +`; + +const SelectStyledPhase = styled(Select)` + && { + background: #d8d8d8; + border-radius: 3px; + padding: 8px 12px 8px 16px; + font-size: 14px; + line-height: 120%; + color: #000; + + & .selectMenu { + padding-right: 32px; + + @media screen and (min-width: 950px) { + padding-right: 56px; + } + } + + & .disabled { + color: rgba(0, 0, 0, 0.26); + } + + & .icon { + color: rgba(0, 0, 0, 0.26); + } + + @media screen and (min-width: 375px) { + width: 100%; + } + + @media screen and (min-width: 950px) { + font-size: 20px; + padding: 10px 16px; + width: auto; + } + } +`; + +const SelectStyled = styled(SelectStyledPhase)` + && { + @media screen and (min-width: 375px) { + width: 35%; + } + + @media screen and (min-width: 950px) { + font-size: 20px; + padding: 10px 16px; + width: auto; + } + } +`; + +const SpeedDialContainer = styled.div` + margin-right: 8px; +`; + +export { + Wrapper, + BudgetContainer, + BudgetHeadingAndShareIcon, + BudgetHeading, + FormContainer, + BudgetPhase, + SelectStyled, + SelectStyledPhase, + SpeedDialContainer, +} + +export default { + Wrapper, + BudgetContainer, + BudgetHeadingAndShareIcon, + BudgetHeading, + FormContainer, + BudgetPhase, + SelectStyled, + SelectStyledPhase, + SpeedDialContainer, +} diff --git a/packages/webapp/src/components/ChartSection/index.jsx b/packages/webapp/src/components/ChartSection/index.jsx index eef63a4a6..33a1726bc 100644 --- a/packages/webapp/src/components/ChartSection/index.jsx +++ b/packages/webapp/src/components/ChartSection/index.jsx @@ -1,8 +1,6 @@ import React, { Component } from 'react'; import Markup from './Markup'; - - class ChartSection extends Component { constructor(props) { super(props); @@ -21,7 +19,8 @@ class ChartSection extends Component { const { initialSelected } = this.props; if (event === null) { - return this.setState({ selected: initialSelected }); + this.setState({ selected: initialSelected }); + return null; } this.setState({ selected: event }); diff --git a/packages/webapp/src/components/ChartSection/index.story.jsx b/packages/webapp/src/components/ChartSection/index.story.jsx index d4b34ada4..4b75d8cff 100644 --- a/packages/webapp/src/components/ChartSection/index.story.jsx +++ b/packages/webapp/src/components/ChartSection/index.story.jsx @@ -1,4 +1,7 @@ import React from 'react'; +import Minimap from '../Minimap'; +import { mockProps as mockMinimapProps } from '../Minimap/schema'; +import { generateItems as mockChartItems } from '../StackChart/schema'; import { storiesOf } from '@storybook/react'; import ChartSection from './'; @@ -54,6 +57,8 @@ const Chart = ({ onSelectedChange }) => ( ); +const renderMinimap = () => + const national = () => ( ( /> ); - +const sticky = () => ( + } + verb='Explore' + subject='this department' + footer='Budget data from 1 April 2018 - 31 March 2019' + phases={phases} + years={years} + title='Provincial Budget Summary' + minimapRender={renderMinimap} + /> +); storiesOf('component.ChartSection', module) .add('National', national) .add('Provincial', provincial) + .add('Sticky', sticky); \ No newline at end of file diff --git a/packages/webapp/src/components/Minimap/Item.jsx b/packages/webapp/src/components/Minimap/Item.jsx new file mode 100644 index 000000000..5035dd412 --- /dev/null +++ b/packages/webapp/src/components/Minimap/Item.jsx @@ -0,0 +1,22 @@ +import React from 'react'; +import styled from 'styled-components'; + +const Block = styled.div` + position: relative; + width: ${({ width }) => width}%; + height: 100%; + transition: transform 0.3s; + transform-origin: 0 ${({ reverse }) => reverse ? '100%' : '0'}; + z-index: ${({ active }) => active ? '99999' : '1'}; + border: 2px solid transparent; + transform: scaleY(${({ active }) => active ? '1.66' : '1'}); + background: ${({ color, active }) => active ? 'black' : color}; +`; + +const Item = ({ convertHeightFn, selected, id, amount, color, reverse }) => { + const width = convertHeightFn(amount); + const active = selected === id; + return ; +} + +export default Item; diff --git a/packages/webapp/src/components/Minimap/__story__/Demo.jsx b/packages/webapp/src/components/Minimap/__story__/Demo.jsx new file mode 100644 index 000000000..988a667b0 --- /dev/null +++ b/packages/webapp/src/components/Minimap/__story__/Demo.jsx @@ -0,0 +1,16 @@ +/* eslint-disable no-console */ + +import React from 'react'; + +/** + * Imports everything needed to execute this specific test. + */ +import StackChart from '../index'; +import { mockProps } from '../schema'; + + +/** + * Executes test. + */ +const Demo = () => +export default Demo; diff --git a/packages/webapp/src/components/Minimap/__story__/Nested.jsx b/packages/webapp/src/components/Minimap/__story__/Nested.jsx new file mode 100644 index 000000000..889699db5 --- /dev/null +++ b/packages/webapp/src/components/Minimap/__story__/Nested.jsx @@ -0,0 +1,16 @@ +/* eslint-disable no-console */ + +import React from 'react'; + +/** + * Imports everything needed to execute this specific test. + */ +import StackChart from '../index'; +import { mockProps } from '../schema'; + + +/** + * Executes test. + */ +const Nested = () => +export default Nested; diff --git a/packages/webapp/src/components/Minimap/__story__/index.story.jsx b/packages/webapp/src/components/Minimap/__story__/index.story.jsx new file mode 100644 index 000000000..18014790b --- /dev/null +++ b/packages/webapp/src/components/Minimap/__story__/index.story.jsx @@ -0,0 +1,13 @@ +/* eslint-disable import/no-extraneous-dependencies */ +/* eslint-disable no-console */ + +import React, { Fragment } from 'react'; +import { CssBaseline } from '@material-ui/core'; +import { storiesOf } from '@storybook/react'; + +import Demo from './Demo'; +import Nested from './Nested'; + +storiesOf('component.Minimap', module) + .add('Demo', () => ) + .add('Nested', () => ); \ No newline at end of file diff --git a/packages/webapp/src/components/Minimap/coloursList.js b/packages/webapp/src/components/Minimap/coloursList.js new file mode 100644 index 000000000..100be1018 --- /dev/null +++ b/packages/webapp/src/components/Minimap/coloursList.js @@ -0,0 +1,6 @@ +import { colors } from './data.json'; +import { flatten } from 'lodash'; + +const coloursList = flatten(new Array(30).fill(null).map(() => colors)); + +export default coloursList; diff --git a/packages/webapp/src/components/Minimap/createWidthConvertor.js b/packages/webapp/src/components/Minimap/createWidthConvertor.js new file mode 100644 index 000000000..7b31876b1 --- /dev/null +++ b/packages/webapp/src/components/Minimap/createWidthConvertor.js @@ -0,0 +1,18 @@ +import { scaleLinear } from 'd3'; + +const calcMinAndMax = (items) => { + const extractAmount = ({ amount }) => amount; + const allAmounts = items.map(extractAmount); + + const minVal = Math.min(...allAmounts); + const maxVal = Math.max(...allAmounts); + + return [minVal, maxVal]; +}; + +const createWidthConvertor = (items) => { + const [min, max] = calcMinAndMax(items); + return scaleLinear().domain([min, max]).range([0, 100]); +} + +export default createWidthConvertor; diff --git a/packages/webapp/src/components/Minimap/data.json b/packages/webapp/src/components/Minimap/data.json new file mode 100644 index 000000000..dd48bbc51 --- /dev/null +++ b/packages/webapp/src/components/Minimap/data.json @@ -0,0 +1,6 @@ +{ + "colors": [ + "#626262", + "#9F9F9F" + ] +} diff --git a/packages/webapp/src/components/Minimap/index.jsx b/packages/webapp/src/components/Minimap/index.jsx new file mode 100644 index 000000000..f77296c0a --- /dev/null +++ b/packages/webapp/src/components/Minimap/index.jsx @@ -0,0 +1,26 @@ +import React from 'react'; +import Item from './Item'; +import createWidthConvertor from './createWidthConvertor'; +import transformItems from './transformItems'; +import styled from 'styled-components'; + +const Wrapper = styled.div` + display: flex; + width: 100%; + height: 6px; + justify-content: stretch; + background: #626262; +`; + +const Minimap = ({ items, selected, reverse }) => { + const convertHeightFn = createWidthConvertor(items); + const transformedItems = transformItems(items); + + return ( + + {transformedItems.map(props => )} + + ) +}; + +export default Minimap; diff --git a/packages/webapp/src/components/Minimap/schema.js b/packages/webapp/src/components/Minimap/schema.js new file mode 100644 index 000000000..e1447e9c6 --- /dev/null +++ b/packages/webapp/src/components/Minimap/schema.js @@ -0,0 +1,81 @@ +import faker from 'faker'; +import { flatten } from 'lodash'; + + + +/** + * The base schema of a single item representing a block in the treemap. + */ +// export type Titem = {id: string; name: string; amount: number; url: string | null; +// } +export const mockItem = () => ({ + id: faker.random.uuid(), + name: faker.commerce.productName(), + amount: parseFloat(faker.finance.amount(1000000000, 9000000000)), + url: faker.internet.url(), +}); + +/** + * An extension of `Titem` with a `children` property added. This additional property will contain + * all nested blocks inside this specific block (so that the user can drill down into the data) + */ +// export type TparentItem = TitemBase & { children: Titem[] }; +export const mockParent = () => ({ + ...mockItem(), + children: [1,2,3,4,5,6].map(mockItem), +}) + +/** + * The ID of the item that should be highlighted. + */ +// export type TSelected = string; +export const mockSelected = (items) => { + if (!items) { + return faker.random.uuid(); + } + + const allIds = items.map(({ id, children }) => { + if (!children) { + return id; + } + + return children.map(({ id }) => id) + }); + + return faker.random.arrayElement(flatten(allIds)); +} + +/** + * Utility function used to generated the mock data for the `items` prop passed to the component. If + * true is passed as an argument then all items generated will have children inside them (nested + * treemap), if false is passed it will not include children in the items in the root of the array. + * If nothing is passed as an argument then the function randomly assigns true or false to + * `parents`. + */ +// export type TgenerateItems = (boolean) => Titems[] | TparentItems[]; +export const generateItems = (parents) => { + if (parents === true) { + return [1,2,3,4,5,6].map(mockParent); + } + + if (parents === false) { + return [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20].map(mockItem); + } + + const randomFunction = faker.random.boolean() ? mockParent : mockItem; + return [1,2,3,4,5,6].map(randomFunction); +} + +/** + * Props accepted by `` component. + */ +// export type Tprops = {items: TparentItem[] | Titem[]; onSelectedChange: TonSelectedChange; +// } +export const mockProps = (parents) => { + const items = generateItems(parents); + + return { + items, + selected: mockSelected(items) + } +}; diff --git a/packages/webapp/src/components/Minimap/transformItems.js b/packages/webapp/src/components/Minimap/transformItems.js new file mode 100644 index 000000000..9f3c13e9d --- /dev/null +++ b/packages/webapp/src/components/Minimap/transformItems.js @@ -0,0 +1,24 @@ +import coloursList from './coloursList'; +import { flatten } from 'lodash'; + +const buildItem = overrideColor => ({ amount, id, children }, index) => { + const color = overrideColor || coloursList[index]; + + if (children) { + return children.map(buildItem(color)); + } + + return { + id, + amount, + color, + } +} + +const transformItems = (items) => { + const result = items.map(buildItem(null)); + return flatten(result); +} + + +export default transformItems; \ No newline at end of file diff --git a/packages/webapp/src/components/StackChart/Block.jsx b/packages/webapp/src/components/StackChart/Block.jsx new file mode 100644 index 000000000..b366deaa4 --- /dev/null +++ b/packages/webapp/src/components/StackChart/Block.jsx @@ -0,0 +1,39 @@ +import React from 'react'; +import styled from 'styled-components'; +import trimValues from '../../helpers/trimValues'; + +const Wrapper = styled.div` + min-height: 19px; + height: ${({ height }) => height}px; + border: 1px solid white; + transition: transform 0.2s; + transform: translateX(${({ active }) => active ? '4px' : 0}); + background: ${({ color }) => color}; + padding: ${({ isSmall }) => isSmall ? 2 : 10}px 10px; + display: flex; + width: 100%; +`; + +const Text = styled.div` + font-size: ${({ isSmall }) => isSmall ? '10' : '12'}px; + font-weight: bold; + font-family: Roboto, sans-serif; + opacity: ${({ active }) => active ? 1 : 0.6 } + flex-grow: ${({ fullWidth }) => fullWidth ? 1 : 0} +` + +const Block = ({ id, name, color, amount, convertHeightFn, htmlRef, selected, children }) => { + const height = convertHeightFn(amount); + const isSmall = height < 40; + const active = selected === id; + + return ( + + {name} + R{trimValues(amount, true)} + + ) +} + + +export default Block; diff --git a/packages/webapp/src/components/StackChart/BlockGroup.jsx b/packages/webapp/src/components/StackChart/BlockGroup.jsx new file mode 100644 index 000000000..cda78a7a4 --- /dev/null +++ b/packages/webapp/src/components/StackChart/BlockGroup.jsx @@ -0,0 +1,31 @@ +import React, { Fragment } from 'react'; +import Block from './Block'; +import styled from 'styled-components'; +import trimValues from '../../helpers/trimValues'; + +const Header = styled.div` + padding: 30px 0 10px; + display: flex; + width: 100%; +`; + +const Text = styled.div` + font-size: 12px; + font-weight: bold; + font-family: Roboto, sans-serif; + flex-grow: ${({ fullWidth }) => fullWidth ? 1 : 0}; +`; + +const BlockGroup = ({ name, children, convertHeightFn, amount, selected, htmlRef, headerHtml }) => ( +
+
+ {name} + R{trimValues(amount)} +
+
+ {children.map(props =>
)} +
+
+); + +export default BlockGroup; diff --git a/packages/webapp/src/components/StackChart/Item.jsx b/packages/webapp/src/components/StackChart/Item.jsx new file mode 100644 index 000000000..700c7a220 --- /dev/null +++ b/packages/webapp/src/components/StackChart/Item.jsx @@ -0,0 +1,14 @@ +import React from 'react'; + +import Block from './Block'; +import BlockGroup from './BlockGroup'; + +const Item = (props) => { + if (!props.children) { + return ; + } + + return +} + +export default Item; diff --git a/packages/webapp/src/components/StackChart/Markup.jsx b/packages/webapp/src/components/StackChart/Markup.jsx new file mode 100644 index 000000000..f1752fcba --- /dev/null +++ b/packages/webapp/src/components/StackChart/Markup.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import Item from './Item'; + +const Markup = ({ items, convertHeightFn, selected }) => { + if (!convertHeightFn) { + return null; + } + + return ( +
+ {items.map(props =>
)} +
+ ) +}; + +export default Markup; diff --git a/packages/webapp/src/components/StackChart/ScrollOffsetListener.js b/packages/webapp/src/components/StackChart/ScrollOffsetListener.js new file mode 100644 index 000000000..f74c9a327 --- /dev/null +++ b/packages/webapp/src/components/StackChart/ScrollOffsetListener.js @@ -0,0 +1,20 @@ +import { debounce } from 'lodash'; + +class ScrollOffsetListener { + constructor(callback, delay = 100) { + this.callback = callback; + this.debouncedScroll = debounce(this.onScroll, delay); + window.addEventListener('scroll', this.debouncedScroll, false); + } + + stop = () => { + window.removeEventListener('scroll', this.debouncedScroll, false); + } + + onScroll = () => { + return this.callback(window.scrollY); + } +} + + +export default ScrollOffsetListener; \ No newline at end of file diff --git a/packages/webapp/src/components/StackChart/__story__/Demo.jsx b/packages/webapp/src/components/StackChart/__story__/Demo.jsx new file mode 100644 index 000000000..988a667b0 --- /dev/null +++ b/packages/webapp/src/components/StackChart/__story__/Demo.jsx @@ -0,0 +1,16 @@ +/* eslint-disable no-console */ + +import React from 'react'; + +/** + * Imports everything needed to execute this specific test. + */ +import StackChart from '../index'; +import { mockProps } from '../schema'; + + +/** + * Executes test. + */ +const Demo = () => +export default Demo; diff --git a/packages/webapp/src/components/StackChart/__story__/Nested.jsx b/packages/webapp/src/components/StackChart/__story__/Nested.jsx new file mode 100644 index 000000000..889699db5 --- /dev/null +++ b/packages/webapp/src/components/StackChart/__story__/Nested.jsx @@ -0,0 +1,16 @@ +/* eslint-disable no-console */ + +import React from 'react'; + +/** + * Imports everything needed to execute this specific test. + */ +import StackChart from '../index'; +import { mockProps } from '../schema'; + + +/** + * Executes test. + */ +const Nested = () => +export default Nested; diff --git a/packages/webapp/src/components/StackChart/__story__/index.story.jsx b/packages/webapp/src/components/StackChart/__story__/index.story.jsx new file mode 100644 index 000000000..cb5b13db5 --- /dev/null +++ b/packages/webapp/src/components/StackChart/__story__/index.story.jsx @@ -0,0 +1,15 @@ +/* eslint-disable import/no-extraneous-dependencies */ +/* eslint-disable no-console */ + +import React, { Fragment } from 'react'; +import { CssBaseline } from '@material-ui/core'; +import { storiesOf } from '@storybook/react'; + +import Demo from './Demo'; +import Nested from './Nested'; + +storiesOf('component.StackChart', module) + .add('Basic', () => ) + .add('Nested', () => ) + .add('Basic w/ padding', () =>
) + .add('Nested w/ padding', () =>
); \ No newline at end of file diff --git a/packages/webapp/src/components/StackChart/calcActiveBlocks.js b/packages/webapp/src/components/StackChart/calcActiveBlocks.js new file mode 100644 index 000000000..eea139e6a --- /dev/null +++ b/packages/webapp/src/components/StackChart/calcActiveBlocks.js @@ -0,0 +1,43 @@ +const calcBlock = (threshold, scrollOffset, items) => { + const extractNode = ({ htmlRef: { current }}) => current; + const refsArray = items.map(extractNode); + + for (let index in refsArray) { + const block = refsArray[index]; + const blockTopFromTop = block.offsetTop - threshold; + const blockBottomFromTop = blockTopFromTop + block.clientHeight; + + if (scrollOffset > blockTopFromTop && scrollOffset < blockBottomFromTop) { + return index; + } + } + + return null; +} + +const calcActiveBlocks = (threshold, scrollOffset, items) => { + const rootIndex = calcBlock(threshold, scrollOffset, items); + const rootObject = rootIndex && items[rootIndex]; + + if (!rootObject) { + return { selected: null, zoom: null } + } + + if (!rootObject.children) { + return { selected: rootObject, zoom: null } + } + + const childIndex = calcBlock(threshold, scrollOffset, rootObject.children); + + if (!childIndex && rootIndex - 1 < 0) { + return null; + } + + if (!childIndex) { + return { selected: items[rootIndex - 1].children[rootObject.children.length - 1], zoom: items[rootIndex - 1].id } + } + + return { selected: rootObject.children[childIndex], zoom: rootObject.id } +} + +export default calcActiveBlocks; diff --git a/packages/webapp/src/components/StackChart/coloursList.js b/packages/webapp/src/components/StackChart/coloursList.js new file mode 100644 index 000000000..34699fe83 --- /dev/null +++ b/packages/webapp/src/components/StackChart/coloursList.js @@ -0,0 +1,6 @@ +import { colors } from './data.json'; +import { flatten } from 'lodash'; + +const coloursList = flatten(new Array(20).fill(null).map(() => colors)); + +export default coloursList; diff --git a/packages/webapp/src/components/StackChart/createHeightConvertor.js b/packages/webapp/src/components/StackChart/createHeightConvertor.js new file mode 100644 index 000000000..909013a6b --- /dev/null +++ b/packages/webapp/src/components/StackChart/createHeightConvertor.js @@ -0,0 +1,19 @@ +import { scaleLinear } from 'd3'; + +const calcMinAndMax = (items) => { + const extractAmount = ({ amount }) => amount; + const allAmounts = items.map(extractAmount); + + const minVal = Math.min(...allAmounts); + const maxVal = Math.max(...allAmounts); + + return [minVal, maxVal]; +}; + +const createHeightConvertor = (items) => { + const maxSize = window.innerHeight * 0.5; + const [min, max] = calcMinAndMax(items); + return scaleLinear().domain([min, max]).range([19, maxSize]); +} + +export default createHeightConvertor; diff --git a/packages/webapp/src/components/StackChart/data.json b/packages/webapp/src/components/StackChart/data.json new file mode 100644 index 000000000..900fadf7f --- /dev/null +++ b/packages/webapp/src/components/StackChart/data.json @@ -0,0 +1,24 @@ +{ + "colors": [ + "#FFD54F", + "#E57373", + "#4DD0E1", + "#7986CB", + "#BA68C8", + "#4DB6AC", + "#A1887F", + "#64B5F6", + "#FF8A65" + ], + "provinces": [ + "Eastern Cape", + "Free State", + "Gauteng", + "Limpopo", + "Mpumalanga", + "Northern Cape", + "Western Cape", + "North West", + "KwaZulu-Natal" + ] +} diff --git a/packages/webapp/src/components/StackChart/index.jsx b/packages/webapp/src/components/StackChart/index.jsx new file mode 100644 index 000000000..7f114a449 --- /dev/null +++ b/packages/webapp/src/components/StackChart/index.jsx @@ -0,0 +1,142 @@ +import React, { Component } from 'react'; +import createHeightConvertor from './createHeightConvertor'; +import ResizeWindowListener from '../../helpers/ResizeWindowListener'; +import calcActiveBlocks from './calcActiveBlocks'; +import Markup from './Markup'; +import ScrollOffsetListener from './ScrollOffsetListener'; +import transformItems from './transformItems'; + +class Treemap extends Component { + constructor(props) { + super(props); + const { items } = this.props; + + this.state = { + selected: null, + convertHeightFn: null, + scrollOffset: null, + zoom: null, + }; + + this.events = { + updateCurrentActive: this.updateCurrentActive.bind(this), + changeConvertHeightFnHandler: this.changeConvertHeightFnHandler.bind(this), + changeScrollOffsetHandler: this.changeScrollOffsetHandler.bind(this), + }; + + this.values = { + transformedItems: transformItems(items), + }; + } + + componentDidMount () { + const { events: { changeConvertHeightFnHandler, changeScrollOffsetHandler } } = this; + changeConvertHeightFnHandler(); + + this.values = { + ...this.values, + resizeListener: new ResizeWindowListener(changeConvertHeightFnHandler), + scrollListener: new ScrollOffsetListener(changeScrollOffsetHandler), + } + } + + componentDidUpdate() { + const { + values: { transformedItems }, + state: { scrollOffset }, + props: { threshold = 10 }, + events: { updateCurrentActive }, + } = this; + + if (scrollOffset || scrollOffset === 0) { + updateCurrentActive(threshold, scrollOffset, transformedItems); + } + } + + updateCurrentActive(threshold, scrollOffset, transformedItems) { + const { props: { onZoomChange, onSelectedChange, onActiveChange }, state: currentState } = this; + const activeBlocks = calcActiveBlocks(threshold, scrollOffset, transformedItems); + + if (!activeBlocks) { + return null; + } + + const { zoom: newZoom, selected: newSelected } = activeBlocks; + const noChange = currentState.selected === (newSelected && newSelected.id); + + if (noChange) { + return null; + } + + if (onActiveChange) { + const isCurrentlyActive = !!currentState.selected; + const willBeActive = !!newSelected; + + if (isCurrentlyActive !== willBeActive) { + onActiveChange(willBeActive); + } + } + + if (onZoomChange) { + onZoomChange(newZoom); + } + + if (onSelectedChange) { + const hasSelected = newSelected ? true : null; + const props = hasSelected && { + id: newSelected.id, + name: newSelected.name, + color: newSelected.color, + value: newSelected.amount, + url: newSelected.url, + zoom: newSelected.zoom, + }; + + onSelectedChange(props); + } + + this.setState({ + ...currentState, + selected: (newSelected && newSelected.id), + zoom: newZoom, + }); + } + + componentWillUnmount() { + const { resizeListener, scrollListener } = this.values; + + if (resizeListener) { + resizeListener.stop(); + } + + if (scrollListener) { + scrollListener.stop(); + } + } + + changeScrollOffsetHandler(scrollOffset) { + this.setState({ scrollOffset }) + } + + changeConvertHeightFnHandler() { + const { items } = this.props; + + const convertHeightFn = createHeightConvertor(items); + this.setState({ convertHeightFn }); + } + + render() { + const { state, events, values: { transformedItems: items, refsArray } } = this; + + const passedProps = { + ...state, + ...events, + items, + refsArray, + }; + + return ; + } +} + +export default Treemap; diff --git a/packages/webapp/src/components/StackChart/schema.js b/packages/webapp/src/components/StackChart/schema.js new file mode 100644 index 000000000..e891661e6 --- /dev/null +++ b/packages/webapp/src/components/StackChart/schema.js @@ -0,0 +1,69 @@ +import faker from 'faker'; + +/** + * The base schema of a single item representing a block in the treemap. + */ +// export type Titem = {id: string; name: string; amount: number; url: string | null; +// } +export const mockItem = () => ({ + id: faker.random.uuid(), + name: faker.commerce.productName(), + amount: parseFloat(faker.finance.amount(1000000000, 9000000000)), + url: faker.internet.url(), +}); + +/** + * An extension of `Titem` with a `children` property added. This additional property will contain + * all nested blocks inside this specific block (so that the user can drill down into the data) + */ +// export type TparentItem = TitemBase & { children: Titem[] }; +export const mockParent = () => ({ + ...mockItem(), + children: [1,2,3,4,5,6].map(mockItem), +}) + +/** + * Callback to fire when the designated area of the viewport overlaps with a new item on this chart, + * or when the chart is unselected. + */ +// export type TonSelectedChange = (any) => void; +export const mockOnSelectedChange = () => (value) => console.log(value); + +/** + * Callback to fire when the designated area of the viewport overlaps with a designated block-group + * on this chart, or when the chart is unselected. + */ +// export type TonZoomChange = (any) => void; +export const mockOnZoomChange = () => (value) => console.log(value); + +/** + * Utility function used to generated the mock data for the `items` prop passed to the component. If + * true is passed as an argument then all items generated will have children inside them (nested + * treemap), if false is passed it will not include children in the items in the root of the array. + * If nothing is passed as an argument then the function randomly assigns true or false to + * `parents`. + */ +// export type TgenerateItems = (boolean) => Titems[] | TparentItems[]; +export const generateItems = (parents) => { + if (parents === true) { + return [1,2,3,4,5,6].map(mockParent); + } + + if (parents === false) { + return [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20].map(mockItem); + } + + const randomFunction = faker.random.boolean() ? mockParent : mockItem; + return [1,2,3,4,5,6].map(randomFunction); +} + +/** + * Props accepted by `` component. + */ +// export type Tprops = {items: TparentItem[] | Titem[]; onSelectedChange: TonSelectedChange; +// } +export const mockProps = (parents) => ({ + items: generateItems(parents), + onSelectedChange: mockOnSelectedChange(), + onZoomChange: mockOnZoomChange(), +}); diff --git a/packages/webapp/src/components/StackChart/sortItems.js b/packages/webapp/src/components/StackChart/sortItems.js new file mode 100644 index 000000000..7635e7983 --- /dev/null +++ b/packages/webapp/src/components/StackChart/sortItems.js @@ -0,0 +1,23 @@ +const sortSingleItem = ({ amount: a }, { amount: b }) => b - a; + +const reformatAsArray = items => (key) => { + const province = items[key]; + const { children, ...info } = province; + + return { + ...info, + children: children.sort(sortSingleItem), + }; +}; + +const sortItems = (items) => { + if (Array.isArray(items)) { + return items.sort(sortSingleItem); + } + + const keysArray = Object.keys(items); + return keysArray.map(reformatAsArray(items)) +} + + +export default sortItems; diff --git a/packages/webapp/src/components/StackChart/transformItems.js b/packages/webapp/src/components/StackChart/transformItems.js new file mode 100644 index 000000000..68f385177 --- /dev/null +++ b/packages/webapp/src/components/StackChart/transformItems.js @@ -0,0 +1,33 @@ +import { createRef } from 'react'; +import coloursList from './coloursList'; + +const sortItems = ({ amount: a }, { amount: b }) => b - a; + +const sortChildrenOfItem = (item) => ({ + ...item, + children: !item.children ? null : item.children.sort(sortItems), +}); + +const addColourToItemAndChildren = (item, index) => ({ + ...item, + color: coloursList[index], + children: !item.children ? null : item.children.map(child => addColourToItemAndChildren(child, index)) +}); + +const addRefsToItemAndChildren = (item) => ({ + ...item, + htmlRef: createRef(), + headerRef: !!item.children && createRef(), + children: !item.children ? null : item.children.map(child => addRefsToItemAndChildren(child)) +}); + +const transformItems = (items) => { + const sortedItems = items.sort(sortItems); + const sortedChildren = sortedItems.map(sortChildrenOfItem); + const withRefs = sortedChildren.map(addRefsToItemAndChildren); + const withColours = withRefs.map(addColourToItemAndChildren); + + return withColours; +} + +export default transformItems; diff --git a/packages/webapp/src/views/ConsolidatedTreemap/index.jsx b/packages/webapp/src/views/ConsolidatedTreemap/index.jsx index 7fab73044..964826e1b 100644 --- a/packages/webapp/src/views/ConsolidatedTreemap/index.jsx +++ b/packages/webapp/src/views/ConsolidatedTreemap/index.jsx @@ -4,6 +4,7 @@ import MediaQuery from "react-media"; import calcIfForeignObjectIsSupported from './calcIfForeignObjectIsSupported'; import ChartSection from './../../components/ChartSection'; import Treemap from './../../components/Treemap'; +import StackChart from './../../components/StackChart'; const footer = ( @@ -13,10 +14,15 @@ const footer = ( ) -const Markup = ({ items, initialSelected }) => ( +const Markup = ({ items, initialSelected, isMobile }) => ( } + isMobile={isMobile} + hasChildren={false} + chart={(onSelectedChange) => isMobile + ? + : + } verb='Explore' subject='this focus area' title='Consolidated Budget Summary' @@ -32,8 +38,8 @@ const Markup = ({ items, initialSelected }) => ( ) const NationalTreemap = props => ( - - {matches => !!matches && calcIfForeignObjectIsSupported() && } + + {matches => calcIfForeignObjectIsSupported() && } ); diff --git a/packages/webapp/src/views/NationalTreemap/index.jsx b/packages/webapp/src/views/NationalTreemap/index.jsx index 619a66cfe..b4448cb32 100644 --- a/packages/webapp/src/views/NationalTreemap/index.jsx +++ b/packages/webapp/src/views/NationalTreemap/index.jsx @@ -1,22 +1,61 @@ -import React, { Fragment } from 'react'; +import React, { Component, Fragment } from 'react'; import MediaQuery from "react-media"; +import Minimap from '../../components/Minimap'; import calcIfForeignObjectIsSupported from './calcIfForeignObjectIsSupported'; import ChartSection from './../../components/ChartSection'; import Treemap from './../../components/Treemap'; +import StackChart from './../../components/StackChart'; + +class State extends Component { + constructor(props) { + super(props); + + this.state = { + active: false, + } + + this.events = { + changeActiveHandler: this.changeActiveHandler.bind(this), + } + } + + changeActiveHandler(active) { + if (active !== this.state.active) { + this.setState({ active }); + } + } + + render() { + const passedProps = { + ...this.props, + ...this.state, + ...this.events, + } + + return ; + } +} const footer = (
Please note the above treemap is a representation of expenditure of national government departments.
Budget data for the financial year 1 April 2019 - 31 March 2020
+
Direct charges against the National Revenue Fund are excluded
) +const mobileChart = (items, changeActiveHandler) => onSelectedChange => ; +const desktopChart = items => onSelectedChange => ; +const determineChart = (items, desktop, changeActiveHandler) => desktop ? desktopChart(items) : mobileChart(items, changeActiveHandler); + +const minimapFn = (items) => selected => ; +const determineMinimap = (items, desktop) => !desktop && minimapFn(items); -const Markup = ({ items, initialSelected }) => ( +const Markup = ({ items, initialSelected, desktop, active, changeActiveHandler }) => ( } + chart={determineChart(items, desktop, changeActiveHandler)} verb='Explore' subject='this department' title='National Budget Summary' @@ -27,13 +66,14 @@ const Markup = ({ items, initialSelected }) => ( disabled: "2019-20", }} anchor="national-treemap" + minimapRender={!!active && determineMinimap(items, desktop)} /> ) const NationalTreemap = props => ( - {matches => !!matches && calcIfForeignObjectIsSupported() && } + {matches => calcIfForeignObjectIsSupported() && } ); -export default NationalTreemap; +export default State; diff --git a/packages/webapp/src/views/ProvincialTreemap/index.jsx b/packages/webapp/src/views/ProvincialTreemap/index.jsx index 095fa6b68..dfdee7c70 100644 --- a/packages/webapp/src/views/ProvincialTreemap/index.jsx +++ b/packages/webapp/src/views/ProvincialTreemap/index.jsx @@ -4,12 +4,18 @@ import MediaQuery from "react-media"; import calcIfForeignObjectIsSupported from './calcIfForeignObjectIsSupported'; import ChartSection from './../../components/ChartSection'; import Treemap from './../../components/Treemap'; +import StackChart from './../../components/StackChart'; -const Markup = ({ items, initialSelected }) => ( +const Markup = ({ items, initialSelected, isMobile }) => ( } + chart={(onSelectedChange) => isMobile + ? + : + } verb='Explore' subject='this department' title='Provincial Budget Summary' @@ -24,8 +30,10 @@ const Markup = ({ items, initialSelected }) => ( ) const ProvincialTreemap = props => ( - - {matches => !!matches && calcIfForeignObjectIsSupported() && } + + { + matches => calcIfForeignObjectIsSupported() && + } );