Skip to content

Commit

Permalink
Time brush (#215)
Browse files Browse the repository at this point in the history
* time brush progress

* yarn version check

* yarn version check

* time brush component

* yarn ci fix

* yarn build fixes

* yarn ci fixes

* yarn build fix

* yarn lint fixes

* yarn fixes

* yarn fix

* Chris feedback

* feedback implemented

* yarn fix and format

* yarn fix

* move code to utils

* yarn fix

* move d3 code

* yarn fix

* yarn fix

* yarn fix

* yarn build

* yarn build
  • Loading branch information
gaudyb committed Sep 9, 2023
1 parent 6d55ad2 commit 8da64e9
Show file tree
Hide file tree
Showing 12 changed files with 1,812 additions and 0 deletions.
14 changes: 14 additions & 0 deletions .yarn/versions/4aa2a41d.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
releases:
"@essex/components": minor

declined:
- "@essex/boolean-expression-component"
- "@essex/charts-react"
- "@essex/hierarchy-browser"
- "@essex/hooks"
- "@essex/msal-interactor"
- "@essex/semantic-components"
- essex-toolkit-stories
- "@essex/thematic-lineup"
- "@essex/toolbox"
- "@essex/toposort"
1 change: 1 addition & 0 deletions packages/components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"d3-selection": "^3.0.0",
"lodash-es": "^4.17.21",
"markdown-to-jsx": "^7.3.2",
"moment": "^2.29.4",
"react-animate-height": "^3.2.2",
"react-if": "^4.1.5"
},
Expand Down
127 changes: 127 additions & 0 deletions packages/components/src/TimeBrush/SparkBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/*!
* Copyright (c) Microsoft. All rights reserved.
* Licensed under the MIT license. See LICENSE file in the project.
*/
import {
createBarGroup,
generate,
markState,
selectAll,
selectBarGroup,
} from './SparkBar.utils.js'
import type { GroupedTerm, SparkbarProps } from './TimeBrush.types.js'
import { SelectionState } from '@thematic/core'
import { useThematic } from '@thematic/react'
import {
memo,
useCallback,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react'

const DEFAULT_BAR_WIDTH = 8
const DEFAULT_BAR_GAP = 1

export const Sparkbar: React.FC<SparkbarProps> = memo(function Sparkbar({
data,
width,
height,
barWidth = DEFAULT_BAR_WIDTH,
barGap = DEFAULT_BAR_GAP,
id,
value,
nodata,
selected,
onClick,
xScale,
marked,
}) {
const theme = useThematic()
const ref = useRef(null)
const nodataFn = useCallback(
(d: unknown) => {
if (nodata) {
return nodata(d)
}
return false
},
[nodata],
)
const [hovered, setHovered] = useState<any>(null)
const handleHover = useCallback((d: any) => setHovered(d), [])
const [barGroup, setBarGroup] = useState<any>()

useLayoutEffect(() => {
setBarGroup(createBarGroup(ref, theme, width, height))
}, [theme, data, width, height])

useLayoutEffect(() => {
generate(
barGroup,
data as GroupedTerm[],
nodataFn,
value,
height,
xScale,
barWidth,
barGap,
theme,
)
}, [
theme,
data,
barGroup,
width,
height,
barWidth,
barGap,
value,
nodataFn,
xScale,
])

useLayoutEffect(() => {
selectBarGroup(barGroup, handleHover, onClick)
}, [data, barGroup, id, onClick, handleHover])

useLayoutEffect(() => {
selectAll(barGroup, onClick)
}, [data, barGroup, onClick])

// generate a complimentary highlight
const highlight = useMemo(() => theme.scales().nominal(10)(1).hex(), [theme])

useLayoutEffect(() => {
const getSelectionState = (d: any) => {
if (nodataFn(d)) {
return SelectionState.NoData
}
if (d === hovered) {
return SelectionState.Hovered
}
const sel = selected ? selected(d) : false
if (sel) {
return SelectionState.Selected
}
return SelectionState.Normal
}
markState(barGroup, getSelectionState, marked, highlight, theme)
}, [
theme,
data,
barGroup,
highlight,
nodataFn,
id,
hovered,
selected,
marked,
])

// force width of container to exactly match the svg
const divStyle = useMemo(() => ({ width, height }), [width, height])

return <div ref={ref} style={divStyle} />
})
115 changes: 115 additions & 0 deletions packages/components/src/TimeBrush/SparkBar.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/*!
* Copyright (c) Microsoft. All rights reserved.
* Licensed under the MIT license. See LICENSE file in the project.
*/
import type { GroupedTerm } from './TimeBrush.types.js'
import { line, rect, svg } from '@thematic/d3'
import { scaleLinear } from 'd3-scale'
import { select } from 'd3-selection'

export function createBarGroup(
ref: any,
theme: any,
width: number,
height: number,
): any {
select(ref.current).select('svg').remove()
const g = select(ref.current)
.append('svg')
.attr('class', 'sparkbar-chart')
.attr('width', width)
.attr('height', height)
.call(svg as any, theme.chart())
.append('g')
.attr('class', 'sparkbar-plotarea')
g.append('rect')
.attr('width', width)
.attr('height', height)
.call(rect as any, theme.plotArea())
return g.append('g').attr('class', 'sparkbar-bars')
}

export function generate(
barGroup: any,
data: GroupedTerm[],
nodataFn: any,
value: any,
height: number,
xScale: any,
barWidth: number,
barGap: number,
theme: any,
) {
if (data.length > 0) {
const ext = data.reduce<[number, number]>(
(acc: any, cur: any) => {
if (!nodataFn(cur)) {
const val = value(cur)
return [Math.min(acc[0], val), Math.max(acc[1], val)]
}
return acc
},
[Number.MAX_VALUE, Number.MIN_VALUE],
)
const hScale = scaleLinear().domain(ext).range([0, height])
const x = xScale ? xScale : (d: any, i: any) => i * (barWidth + barGap)
const h = (d: any) => (nodataFn(d) ? height : hScale(value(d)))
const y = (d: any) => height - (h(d) || 0)

if (barGroup) {
barGroup.selectAll('*').remove()
barGroup
.selectAll('.bar')
.data(data)
.enter()
.append('line')
.attr('class', 'bar')
.attr('x1', x)
.attr('x2', x)
.attr('y1', y)
.attr('y2', height)
.call(line as any, theme.line())
.attr('stroke-width', barWidth)
}
}
}

export function selectBarGroup(barGroup: any, handleHover: any, onClick: any) {
if (barGroup) {
barGroup
.selectAll('.bar')
.on('mouseover', (d: any) => handleHover(d))
.on('mouseout', () => handleHover(null))
.on('click', onClick)
}
}

export function selectAll(barGroup: any, onClick: any) {
const cursor = onClick ? 'pointer' : 'default'
if (barGroup) {
barGroup.selectAll('.bar').style('cursor', cursor)
}
}

export function markState(
barGroup: any,
getSelectionState: any,
marked: any,
highlight: any,
theme: any,
) {
if (barGroup) {
barGroup.selectAll('.bar').attr('stroke', (d: any) => {
const selectionState = getSelectionState(d)
const mark = marked ? marked(d) : false
return mark
? highlight
: theme
.line({
selectionState,
})
.stroke()
.hex()
})
}
}
75 changes: 75 additions & 0 deletions packages/components/src/TimeBrush/TermBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*!
* Copyright (c) Microsoft. All rights reserved.
* Licensed under the MIT license. See LICENSE file in the project.
*/
import { Sparkbar } from './SparkBar.js'
import type { TermBarProps } from './TimeBrush.types.js'
import { scaleTime } from 'd3-scale'
import moment from 'moment'
import { memo, useCallback, useMemo } from 'react'

const DEFAULT_BAR_WIDTH = 4

export const TermBar: React.FC<TermBarProps> = memo(function TermBar({
terms,
width,
height,
barWidth = DEFAULT_BAR_WIDTH,
dateExtent,
selectionExtent,
markedDate,
}) {
const id = useCallback((d: any) => `${d.term}-${d.date}`, [])
const accessor = useCallback((d: any) => d.count, [])
const selected = useCallback(
(d: any) => {
if (selectionExtent) {
return (
d.date.valueOf() >= selectionExtent[0].valueOf() &&
d.date.valueOf() <= selectionExtent[1].valueOf()
)
}
return false
},
[selectionExtent],
)
const md = useMemo(
() => (markedDate ? moment.utc(markedDate) : null),
[markedDate],
)
const marked = useCallback((d: any) => !!md?.isSame(d.date, 'day'), [md])
const nodata = useCallback((d: any) => d.count < 0, [])
// use an extent if one is provided, otherwise compute from supplied values
const domain = useMemo(() => {
if (dateExtent) {
return dateExtent
}
const moments = terms.map((t: any) => moment(t.date))
const min = moment.min(moments)
const max = moment.max(moments)
return [min, max]
}, [terms, dateExtent])
const time = useMemo(
() =>
scaleTime()
.domain(domain)
.range([barWidth / 2, width - barWidth / 2]),
[domain, width, barWidth],
)

const xScale = useMemo(() => (d: any, i: number) => time(d.date), [time])
return (
<Sparkbar
data={terms}
width={width}
height={height}
id={id}
value={accessor}
xScale={xScale as (input: unknown, i: number) => number}
selected={selected}
barWidth={barWidth}
marked={marked}
nodata={nodata}
/>
)
})
Loading

0 comments on commit 8da64e9

Please sign in to comment.