diff --git a/client/.storybook/preview.js b/client/.storybook/preview.js index 00a21d01f4..05544d95d5 100644 --- a/client/.storybook/preview.js +++ b/client/.storybook/preview.js @@ -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 }) + } } diff --git a/client/config/webpack.client.prod.js b/client/config/webpack.client.prod.js index c4e2b540b3..e4927cc123 100644 --- a/client/config/webpack.client.prod.js +++ b/client/config/webpack.client.prod.js @@ -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: { diff --git a/client/src/components/Chart.js b/client/src/components/Chart.js new file mode 100644 index 0000000000..1270409cd5 --- /dev/null +++ b/client/src/components/Chart.js @@ -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) + const [HeaderElement, layout, initViewState, ref] = useLayout(layoutType) + + const [viewState, setViewState] = useState(initViewState) + + return ( + <> + + + {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 ( + + + + ) + })} + + + ) +} +Chart.propTypes = { + items: PropTypes.arrayOf(PropTypes.object), + layoutType: PropTypes.string, + widgetElement: PropTypes.func, + style: PropTypes.object, + widgetConfig: PropTypes.object +} +export default Chart diff --git a/client/src/components/DateChart.js b/client/src/components/DateChart.js new file mode 100644 index 0000000000..2b284fde0b --- /dev/null +++ b/client/src/components/DateChart.js @@ -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 => { + const boundingRect = layout(item, viewDate) + // if it isn't in the layout ( e.g different year, month) + if (!boundingRect) { + return null + } + return ( + + + + ) + })} + + ) +} +DateChart.propTypes = { + items: PropTypes.arrayOf(PropTypes.object), + layout: PropTypes.func, + widgetElement: PropTypes.func, + viewState: PropTypes.object, + widgetConfig: PropTypes.object +} + +export default DateChart diff --git a/client/src/components/GeoChart.js b/client/src/components/GeoChart.js new file mode 100644 index 0000000000..fefbed6e97 --- /dev/null +++ b/client/src/components/GeoChart.js @@ -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 diff --git a/client/src/components/HeatMap.js b/client/src/components/HeatMap.js new file mode 100644 index 0000000000..b552771a27 --- /dev/null +++ b/client/src/components/HeatMap.js @@ -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 ( +
+
+ + +
+ +
+ ) +} +HeatMap.propTypes = { + items: PropTypes.arrayOf(PropTypes.object), + style: PropTypes.object +} + +export default HeatMap diff --git a/client/src/components/LayoutHeader.js b/client/src/components/LayoutHeader.js new file mode 100644 index 0000000000..f880788cb8 --- /dev/null +++ b/client/src/components/LayoutHeader.js @@ -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 ( +
+

{viewDate.format(format)}

+
+ + + +
+
+ ) +} + +DateHeader.propTypes = { + viewDate: PropTypes.object, + setViewDate: PropTypes.func, + dateScale: PropTypes.string, + format: PropTypes.string +} + +export const YearHeader = ({ viewState, setViewState }) => { + return ( + + ) +} + +YearHeader.propTypes = { + viewState: PropTypes.object, + setViewState: PropTypes.func +} + +export const MonthHeader = ({ viewState, setViewState }) => { + return ( + + ) +} + +MonthHeader.propTypes = { + viewState: PropTypes.object, + setViewState: PropTypes.func +} + +// FIXME: fix when geolayout ready +export const GeoHeader = ({ + viewState: viewLocation, + setViewState: setViewLocation +}) => { + console.log(setViewLocation) + + return
+} + +GeoHeader.propTypes = { + viewState: PropTypes.object, + setViewState: PropTypes.func +} diff --git a/client/src/components/aggregations/HeatWidget.js b/client/src/components/aggregations/HeatWidget.js new file mode 100644 index 0000000000..d6b2cd2078 --- /dev/null +++ b/client/src/components/aggregations/HeatWidget.js @@ -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 ( + <> + + + {item.numOfEvents} + + + ) +} +HeatWidget.propTypes = { + item: PropTypes.object, + dimensions: PropTypes.object, + widgetConfig: PropTypes.object +} +export default HeatWidget diff --git a/client/src/components/aggregations/utils.js b/client/src/components/aggregations/utils.js index c31ade1df3..31741d297b 100644 --- a/client/src/components/aggregations/utils.js +++ b/client/src/components/aggregations/utils.js @@ -220,3 +220,30 @@ export function reportsToEvents(reports) { } }) } +export const COLOR_NAMES_TO_RGB = { + red: "rgb(155, 0, 0, ", + 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)` + } +} diff --git a/client/src/layouts.js b/client/src/layouts.js new file mode 100644 index 0000000000..5e87d6e47a --- /dev/null +++ b/client/src/layouts.js @@ -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 diff --git a/client/src/layouts/geoLayout.js b/client/src/layouts/geoLayout.js new file mode 100644 index 0000000000..b311801366 --- /dev/null +++ b/client/src/layouts/geoLayout.js @@ -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 diff --git a/client/src/layouts/monthLayout.js b/client/src/layouts/monthLayout.js new file mode 100644 index 0000000000..9ca7035dea --- /dev/null +++ b/client/src/layouts/monthLayout.js @@ -0,0 +1,37 @@ +import { DATE_LAYOUT_FORMAT } from "layouts/utils" +import moment from "moment" +const monthLayout = (item, dimensions, viewDate) => { + // figure out which month + const momentDate = moment(item.date, DATE_LAYOUT_FORMAT) + + if (!viewDate.isSame(momentDate, "month")) { + return null + } + /** Monday-Tuesday .... + * [0,0]-[1,0]********** + * [0,1]*************** + * . + * . + **/ + // This is the [0,0] day of the month + const firstDayofFirstWeekOfTheMonth = moment(momentDate) + .startOf("month") + .startOf("isoWeek") + + const endOfMonth = moment(momentDate).endOf("month") + + const numOfWeeks = endOfMonth.diff(firstDayofFirstWeekOfTheMonth, "weeks") + 1 + const daysDiff = momentDate.diff(firstDayofFirstWeekOfTheMonth, "days") + + const weekDiff = Math.floor(daysDiff / 7) + const weekDayDiff = daysDiff % 7 + + return { + x: (dimensions.width * weekDayDiff) / 7, + y: (dimensions.height * weekDiff) / numOfWeeks, + width: dimensions.width / 7, + height: dimensions.height / numOfWeeks + } +} + +export default monthLayout diff --git a/client/src/layouts/useLayout.js b/client/src/layouts/useLayout.js new file mode 100644 index 0000000000..07acd8f04d --- /dev/null +++ b/client/src/layouts/useLayout.js @@ -0,0 +1,25 @@ +import LAYOUTS from "layouts" +import { INIT_LAYOUT_STATES, LAYOUT_HEADERS } from "layouts/utils" +import { useMemo } from "react" +import useDimensions from "react-use-dimensions" + +const useLayout = layoutType => { + const [ref, dimensions] = useDimensions() + const vars = useMemo(() => { + const chartHeader = LAYOUT_HEADERS[layoutType] + const specificLayout = LAYOUTS[layoutType] + const initViewState = INIT_LAYOUT_STATES[layoutType] + // we will cal the layout with item and args related to that layout (e.g selectedDate for date layouts, map location for geo layout) + const layout = (item, viewArgs) => { + return !dimensions?.width || !dimensions?.height + ? null + : specificLayout(item, dimensions, viewArgs) + } + + return [chartHeader, layout, initViewState] + }, [layoutType, dimensions]) + + return [...vars, ref] +} + +export default useLayout diff --git a/client/src/layouts/utils.js b/client/src/layouts/utils.js new file mode 100644 index 0000000000..75d8aa0991 --- /dev/null +++ b/client/src/layouts/utils.js @@ -0,0 +1,73 @@ +import * as LayoutHeaders from "components/LayoutHeader" +import _groupBy from "lodash/groupBy" +import moment from "moment" +import * as d3 from "d3" + +export const LAYOUT_TYPES = { + YEAR: "year", + MONTH: "month", + GEO: "geo" +} + +export const DATE_LAYOUT_FORMAT = "DD-MM-YYYY" + +export const LAYOUT_AGGREGATORS = { + [LAYOUT_TYPES.GEO]: groupByLocation, + [LAYOUT_TYPES.MONTH]: groupByDay, + [LAYOUT_TYPES.YEAR]: groupByDay +} + +export const INIT_LAYOUT_STATES = { + // FIXME: Add default location when ready + [LAYOUT_TYPES.GEO]: {}, + [LAYOUT_TYPES.MONTH]: moment(), + [LAYOUT_TYPES.YEAR]: moment() +} + +export const LAYOUT_HEADERS = { + [LAYOUT_TYPES.GEO]: LayoutHeaders.GeoHeader, + [LAYOUT_TYPES.MONTH]: LayoutHeaders.MonthHeader, + [LAYOUT_TYPES.YEAR]: LayoutHeaders.YearHeader +} + +export function groupByDay(inItems) { + const aggregationKey = "date" + const outItems = [] + // group items by same day in an object { day1: [item1, item2], day2: [item3, item4]} + const tempItemsObj = _groupBy(inItems, item => + moment(item[aggregationKey]).format(DATE_LAYOUT_FORMAT) + ) + + // aggregate from that object to a list of objects + Object.keys(tempItemsObj).forEach(dayStr => { + outItems.push({ + aggregationKey, + [aggregationKey]: dayStr, + numOfEvents: tempItemsObj[dayStr].length + }) + }) + + return [outItems, aggregationKey] +} + +export function groupByLocation(inItems) { + const reducer = (clusters, currentItem) => { + for (const cluster of clusters) { + for (const clusterItem of cluster.items) { + if ( + d3.geoDistance(clusterItem.coordinates, currentItem.coordinates) < + 0.01 + ) { + cluster.items.push(currentItem) + return clusters + } + } + } + clusters.push({ + coordinates: currentItem.coordinates, + items: [currentItem] + }) + return clusters + } + return [inItems.reduce(reducer, []), "coordinates"] +} diff --git a/client/src/layouts/yearLayout.js b/client/src/layouts/yearLayout.js new file mode 100644 index 0000000000..2b5d9952db --- /dev/null +++ b/client/src/layouts/yearLayout.js @@ -0,0 +1,40 @@ +import { DATE_LAYOUT_FORMAT } from "layouts/utils" +import moment from "moment" + +const yearLayout = (item, dimensions, viewDate) => { + // figure out which year input is + const momentDate = moment(item.date, DATE_LAYOUT_FORMAT) + if (!viewDate.isSame(momentDate, "year")) { + return null + } + // figure out where the item located according to its day + // calculate how much x-y translation needed + /** Jan Feb .... + * Monday [0,0][1,0]********** + * Tuesday [0,1]*************** + * . + * . + **/ + // First day of first week of the year is basically [0,0] coordinates of the chart + const firstDayOfFirstWeekofTheYear = moment(momentDate) + .startOf("year") + .startOf("isoWeek") + + const endOfYear = moment(momentDate).endOf("year") + + const numOfWeeks = endOfYear.diff(firstDayOfFirstWeekofTheYear, "weeks") + 1 + const dayDiff = momentDate.diff(firstDayOfFirstWeekofTheYear, "days") + + const weekDiff = Math.floor(dayDiff / 7) + + const weekDayDiff = dayDiff % 7 + + return { + x: (dimensions.width * weekDiff) / numOfWeeks, + y: (dimensions.height * weekDayDiff) / 7, + width: dimensions.width / numOfWeeks, + height: dimensions.height / 7 + } +} + +export default yearLayout diff --git a/client/stories/Button.stories.js b/client/stories/Button.stories.js index 2854110cae..c871c58ee9 100644 --- a/client/stories/Button.stories.js +++ b/client/stories/Button.stories.js @@ -1,9 +1,8 @@ import React from "react" - import { Button } from "./Button" export default { - title: "Example/Button", + title: "Storybook/Examples/Button", component: Button, argTypes: { backgroundColor: { control: "color" } diff --git a/client/stories/GeneralBanner.stories.js b/client/stories/GeneralBanner.stories.js index fd046ba913..5e2632bddd 100644 --- a/client/stories/GeneralBanner.stories.js +++ b/client/stories/GeneralBanner.stories.js @@ -1,5 +1,5 @@ +import GeneralBanner from "components/GeneralBanner" import React from "react" -import GeneralBanner from "../src/components/GeneralBanner" export default { title: "ANET/GeneralBanner", diff --git a/client/stories/Header.stories.js b/client/stories/Header.stories.js index 37c563fc2b..06bcb1fc0f 100644 --- a/client/stories/Header.stories.js +++ b/client/stories/Header.stories.js @@ -1,9 +1,8 @@ import React from "react" - import { Header } from "./Header" export default { - title: "Example/Header", + title: "Storybook/Examples/Header", component: Header } diff --git a/client/stories/HeatMap.stories.js b/client/stories/HeatMap.stories.js new file mode 100644 index 0000000000..2623f62205 --- /dev/null +++ b/client/stories/HeatMap.stories.js @@ -0,0 +1,63 @@ +import HeatMap from "components/HeatMap" +import moment from "moment" +import React from "react" + +// import faker from "faker" + +export default { + title: "ANET/HeatMap", + component: HeatMap +} + +const containerStyle = { + float: "right", + overflow: "hidden", + width: "100%", + minWidth: "700px", // week 7days, at least 100px width for a day + height: "350px", // week 7days, at least 50px height for a day, + outline: "2px solid red" +} + +const defaultItems = generateMockData(400) + +const defaultArgs = { + items: defaultItems, + style: containerStyle +} + +const Template = args => +// FIXME: Add when ready +// export const Geo = Template.bind({}) + +// Geo.args = { +// ...defaultArgs, +// layoutType: LAYOUT_TYPES.GEO +// } + +export const HEATMAP = Template.bind({}) + +HEATMAP.args = { + ...defaultArgs +} + +function generateMockData(numOfItems) { + const items = [] + const plusMinusDaysInterval = 0.5 * 366 + const maxVal = plusMinusDaysInterval + const minVal = -maxVal + + for (let i = 0; i < numOfItems; i++) { + const randomNumOfEvents = Math.floor(Math.random() * 10) + const newItem = { + date: moment().add( + Math.floor(Math.random() * (maxVal - minVal) + minVal), + "days" + ), + coordinates: [45 + Math.random() * 10, 5 + Math.random() * 10], + numOfEvents: randomNumOfEvents + } + newItem.id = newItem.date.format() + items.push(newItem) + } + return items +} diff --git a/client/stories/Introduction.stories.mdx b/client/stories/Introduction.stories.mdx index cdf16c59c7..ee02199888 100644 --- a/client/stories/Introduction.stories.mdx +++ b/client/stories/Introduction.stories.mdx @@ -8,7 +8,7 @@ import Plugin from './assets/plugin.svg'; import Repo from './assets/repo.svg'; import StackAlt from './assets/stackalt.svg'; - + # Welcome to Storybook diff --git a/client/stories/Page.stories.js b/client/stories/Page.stories.js index 4952a48c19..8caf526c4d 100644 --- a/client/stories/Page.stories.js +++ b/client/stories/Page.stories.js @@ -1,10 +1,9 @@ import React from "react" - -import { Page } from "./Page" import * as HeaderStories from "./Header.stories" +import { Page } from "./Page" export default { - title: "Example/Page", + title: "Storybook/Examples/Page", component: Page } diff --git a/client/stories/RichTextEditor.stories.js b/client/stories/RichTextEditor.stories.js index 56323b59b0..afdd8c2470 100644 --- a/client/stories/RichTextEditor.stories.js +++ b/client/stories/RichTextEditor.stories.js @@ -1,5 +1,5 @@ +import RichTextEditor from "components/RichTextEditor" import React from "react" -import RichTextEditor from "../src/components/RichTextEditor" export default { title: "ANET/RichTextEditor",