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

Create custom heat-map components, layouts, aggregation widgets #3196

Draft
wants to merge 15 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 8 additions & 1 deletion client/.storybook/preview.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,12 @@ import "bootstrap/dist/css/bootstrap.css"
import "index.css"

export const parameters = {
actions: { argTypesRegex: "^on[A-Z].*" }
actions: { argTypesRegex: "^on[A-Z].*" },
options: {
// Sort stories alphabetically
storySort: (a, b) =>
a[1].kind === b[1].kind
? 0
: a[1].id.localeCompare(b[1].id, undefined, { numeric: true })
}
}
2 changes: 1 addition & 1 deletion client/config/webpack.client.prod.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const clientConfig = merge.merge(common.clientConfig, {
new TerserPlugin({
cache: true,
parallel: true,
sourceMap: false // TODO: disabled until SourceMapDevToolPlugin supports caching in webpack 5
sourceMap: true // TODO: disabled until SourceMapDevToolPlugin supports caching in webpack 5
})
],
splitChunks: {
Expand Down
53 changes: 53 additions & 0 deletions client/src/components/Chart.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import useLayout from "layouts/useLayout"
import { LAYOUT_AGGREGATORS } from "layouts/utils"
import PropTypes from "prop-types"
import React, { useState } from "react"

const Chart = ({
items,
layoutType,
widgetElement: Widget,
widgetConfig,
style
}) => {
const aggregator = LAYOUT_AGGREGATORS[layoutType]
const [aggregatedItems] = aggregator(items)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A chart should not assume it contains aggregations. It could be a simple bar chart.
It should take a collection of items, and map them to the appropriate GUI components based on how it is configured.
I suggest to have chart take a collection of items. If they have been aggregated, it should take as an input the list of aggregated groupings as items, with the original items referenced from the aggregations

const [HeaderElement, layout, initViewState, ref] = useLayout(layoutType)

const [viewState, setViewState] = useState(initViewState)

return (
<>
<HeaderElement viewState={viewState} setViewState={setViewState} />
<svg ref={ref} style={style}>
{aggregatedItems.map(item => {
const boundingRect = layout(item, viewState)
// if it isn't in the layout ( e.g different year, month)
if (!boundingRect) {
return null
}
return (
<g
transform={`translate(${boundingRect.x}, ${boundingRect.y})`}
key={JSON.stringify(item)}
>
<Widget
item={item}
dimensions={boundingRect}
widgetConfig={widgetConfig}
/>
</g>
)
})}
</svg>
</>
)
}
Chart.propTypes = {
items: PropTypes.arrayOf(PropTypes.object),
layoutType: PropTypes.string,
widgetElement: PropTypes.func,
style: PropTypes.object,
widgetConfig: PropTypes.object
}
export default Chart
43 changes: 43 additions & 0 deletions client/src/components/DateChart.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import PropTypes from "prop-types"
import React from "react"

const DateChart = ({
items,
layout,
widgetElement: Widget,
viewState: viewDate,
widgetConfig
}) => {
return (
<>
{items.map(item => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the mapping of items to widgets should is not specific to dates, It should probably happen in Chart, as it is the main function of a Chart.
This will allow us not to have a specific DateChart component. This will allow us to have an instance of Chart that then can be reconfigured from date to geo, to etc. Doing so, will allow us to reuse the same instances of the widgets and be able to animate the transitions.

const boundingRect = layout(item, viewDate)
// if it isn't in the layout ( e.g different year, month)
if (!boundingRect) {
return null
}
return (
<g
transform={`translate(${boundingRect.x}, ${boundingRect.y})`}
key={item.date}
>
<Widget
item={item}
dimensions={boundingRect}
widgetConfig={widgetConfig}
/>
</g>
)
})}
</>
)
}
DateChart.propTypes = {
items: PropTypes.arrayOf(PropTypes.object),
layout: PropTypes.func,
widgetElement: PropTypes.func,
viewState: PropTypes.object,
widgetConfig: PropTypes.object
}

export default DateChart
10 changes: 10 additions & 0 deletions client/src/components/GeoChart.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import PropTypes from "prop-types"
import React from "react"

const GeoChart = ({ items }) => {
return <>{items.title}</>
}
GeoChart.propTypes = {
items: PropTypes.arrayOf(PropTypes.object)
}
export default GeoChart
46 changes: 46 additions & 0 deletions client/src/components/HeatMap.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import HeatWidget from "components/aggregations/HeatWidget"
import Chart from "components/Chart"
import { LAYOUT_TYPES } from "layouts/utils"
import PropTypes from "prop-types"
import React, { useState } from "react"

// TODO: this config can come from layouts/utils or be input to HeatMap or input from user
const heatConfig = {
low: 1,
mid: 3,
bgColor: "red",
textColor: "black"
}
const HeatMap = ({ items, style }) => {
const [layout, setLayout] = useState(LAYOUT_TYPES.YEAR)
return (
<div>
<div>
<label htmlFor="layouts">Layout:</label>
<select
value={layout}
name="layouts"
id="layouts"
onChange={e => setLayout(e.target.value)}
>
<option value={LAYOUT_TYPES.MONTH}>Month</option>
<option value={LAYOUT_TYPES.YEAR}>Year</option>
<option value={LAYOUT_TYPES.GEO}>Geo</option>
</select>
</div>
<Chart
items={items}
layoutType={layout}
widgetElement={HeatWidget}
style={style}
widgetConfig={heatConfig}
/>
</div>
)
}
HeatMap.propTypes = {
items: PropTypes.arrayOf(PropTypes.object),
style: PropTypes.object
}

export default HeatMap
87 changes: 87 additions & 0 deletions client/src/components/LayoutHeader.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import moment from "moment"
import PropTypes from "prop-types"
import React from "react"
import { Button } from "react-bootstrap"

export const DateHeader = ({ viewDate, setViewDate, dateScale, format }) => {
return (
<header
style={{
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
margin: "10px auto"
}}
>
<h2>{viewDate.format(format)}</h2>
<div>
<Button
onClick={() => setViewDate(date => moment(date).add(-1, dateScale))}
>
Prev
</Button>
<Button onClick={() => setViewDate(moment())}>Today</Button>
<Button
onClick={() => setViewDate(date => moment(date).add(1, dateScale))}
>
Next
</Button>
</div>
</header>
)
}

DateHeader.propTypes = {
viewDate: PropTypes.object,
setViewDate: PropTypes.func,
dateScale: PropTypes.string,
format: PropTypes.string
}

export const YearHeader = ({ viewState, setViewState }) => {
return (
<DateHeader
viewDate={viewState}
setViewDate={setViewState}
dateScale="years"
format="YYYY"
/>
)
}

YearHeader.propTypes = {
viewState: PropTypes.object,
setViewState: PropTypes.func
}

export const MonthHeader = ({ viewState, setViewState }) => {
return (
<DateHeader
viewDate={viewState}
setViewDate={setViewState}
dateScale="months"
format="MMMM-YYYY"
/>
)
}

MonthHeader.propTypes = {
viewState: PropTypes.object,
setViewState: PropTypes.func
}

// FIXME: fix when geolayout ready
export const GeoHeader = ({
viewState: viewLocation,
setViewState: setViewLocation
}) => {
console.log(setViewLocation)

return <header />
}

GeoHeader.propTypes = {
viewState: PropTypes.object,
setViewState: PropTypes.func
}
43 changes: 43 additions & 0 deletions client/src/components/aggregations/HeatWidget.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { numOfEventsToHeatBgc } from "components/aggregations/utils"
import PropTypes from "prop-types"
import React from "react"

// TODO: add color scales
// TODO: api will probably change
/**
* @param {object} item - aggregated item in the shape {aggregationKey: string, [aggregationKey]: object, numOfEvents: number}
* @param {object} dimensions - what view types you want to use
* @param {object} heatConfig - object in the form {low:number, mid:number, bgColor:string, textColor: string}
* 4 levels of tone for event count: if = 0, if <= low, if <= mid, if > mid
*/
const HeatWidget = ({ item, dimensions, widgetConfig: heatConfig }) => {
const bgc = numOfEventsToHeatBgc(item.numOfEvents, heatConfig)

return (
<>
<rect
width={dimensions.width}
height={dimensions.height}
stroke="purple"
strokeWidth="2"
fill={bgc}
/>
<text
x={dimensions.width / 2}
y={dimensions.height / 2}
dominantBaseline="middle"
fontSize="16"
fill={heatConfig.textColor}
textAnchor="middle"
>
{item.numOfEvents}
</text>
</>
)
}
HeatWidget.propTypes = {
item: PropTypes.object,
dimensions: PropTypes.object,
widgetConfig: PropTypes.object
}
export default HeatWidget
27 changes: 27 additions & 0 deletions client/src/components/aggregations/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -220,3 +220,30 @@ export function reportsToEvents(reports) {
}
})
}
export const COLOR_NAMES_TO_RGB = {
red: "rgb(155, 0, 0, ",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could probably make use of https://github.com/d3/d3-color
and possibly create continuous color palettes/legends:
https://github.com/d3/d3-scale-chromatic/blob/master/README.md

blue: "rgb(0, 0, 155, ",
green: "rgb(0, 155, 0, ",
pink: "rgb(194, 31, 169, ",
orange: "rgb(199, 135, 6, ",
white: "rgb(255, 255, 255, ",
brown: "rgb(128, 57, 30, "
}

/**
* There are 4 levels, [none, low, mid, high], none=0 assumed, we need two values: low, mid
* Available colors ["red", "blue", "green", "pink", "orange", "brown"]
* @param {number} numOfEvents - number of items to scale
* @param {object} scale - example object: {low: 3, mid: 6, bgColor: "red"}
*/
export function numOfEventsToHeatBgc(numOfEvents, scale) {
if (numOfEvents === 0) {
return "transparent"
} else if (numOfEvents <= scale.low) {
return `${COLOR_NAMES_TO_RGB[scale.bgColor]}0.25)`
} else if (numOfEvents <= scale.mid) {
return `${COLOR_NAMES_TO_RGB[scale.bgColor]}0.5)`
} else {
return `${COLOR_NAMES_TO_RGB[scale.bgColor]}1.0)`
}
}
33 changes: 33 additions & 0 deletions client/src/layouts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import geoLayout from "layouts/geoLayout"
import monthLayout from "layouts/monthLayout"
import { LAYOUT_TYPES } from "layouts/utils"
import yearLayout from "layouts/yearLayout"
import * as d3 from "d3"

const LAYOUTS = {
[LAYOUT_TYPES.GEO]: geoLayout(
d3.geoMercator().fitSize([1000, 500], {
type: "FeatureCollection",
features: [
{
type: "Feature",
geometry: {
type: "Polygon",
coordinates: [
[
[45, 5],
[55, 5],
[55, 15],
[45, 15]
]
]
}
}
]
})
),
[LAYOUT_TYPES.MONTH]: monthLayout,
[LAYOUT_TYPES.YEAR]: yearLayout
}

export default LAYOUTS
8 changes: 8 additions & 0 deletions client/src/layouts/geoLayout.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
const geoLayout = projection => (item, dimensions, viewLocation) => ({
x: projection(item.coordinates)[0],
y: projection(item.coordinates)[1],
width: 30,
height: 30
})

export default geoLayout
Loading