Skip to content

Commit

Permalink
Migrate Cohort to React: Cornelius library
Browse files Browse the repository at this point in the history
  • Loading branch information
kravets-levko committed Oct 21, 2019
1 parent 44d1f8a commit 5ebf982
Show file tree
Hide file tree
Showing 3 changed files with 196 additions and 29 deletions.
211 changes: 184 additions & 27 deletions client/app/visualizations/cohort/Cornelius.jsx
Original file line number Diff line number Diff line change
@@ -1,40 +1,197 @@
import 'cornelius/src/cornelius';
import React, { useState, useEffect } from 'react';
import { isNil, isFinite, each, map, extend, min, max } from 'lodash';
import React, { useMemo } from 'react';
import PropTypes from 'prop-types';

import './cornelius.less';

const CorneliusJs = global.Cornelius;

export default function Cornelius({ initialDate, data, timeInterval }) {
const [container, setContainer] = useState(null);

useEffect(() => {
if (container) {
CorneliusJs.draw({
initialDate,
container,
cohort: data,
title: null,
timeInterval,
labels: {
time: 'Time',
people: 'Users',
weekOf: 'Week of',
},
});
const defaultOptions = {
title: null,
monthNames: ['January', 'February', 'March', 'April', 'May', 'June', 'July',
'August', 'September', 'October', 'November', 'December'],
shortMonthNames: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul',
'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
repeatLevels: {
'low': [0, 10], // eslint-disable-line quote-props
'medium-low': [10, 20],
'medium': [20, 30], // eslint-disable-line quote-props
'medium-high': [30, 40],
'high': [40, 60], // eslint-disable-line quote-props
'hot': [60, 70], // eslint-disable-line quote-props
'extra-hot': [70, 100],
},
labels: {
time: 'Time',
people: 'People',
weekOf: 'Week of',
},
timeInterval: 'monthly',
drawEmptyCells: true,
rawNumberOnHover: true,
displayAbsoluteValues: false,
initialIntervalNumber: 1,
maxColumns: Number.MAX_SAFE_INTEGER,
formatLabel: {
header(i) {
return i === 0 ? this.labels.people : (this.initialIntervalNumber - 1 + i).toString();
},
daily(date, i) {
date.setDate(date.getDate() + i);
return this.monthNames[date.getMonth()] + ' ' + date.getDate() + ', ' + date.getFullYear();
},
weekly(date, i) {
date.setDate(date.getDate() + i * 7);
return this.labels.weekOf + ' ' + this.shortMonthNames[date.getMonth()] + ' ' +
date.getDate() + ', ' + date.getFullYear();
},
monthly(date, i) {
date.setMonth(date.getMonth() + i);
return this.monthNames[date.getMonth()] + ' ' + date.getFullYear();
},
yearly(date, i) {
return date.getFullYear() + i;
},
},
};

function prepareOptions(options) {
return extend({}, defaultOptions, options, {
repeatLevels: extend({}, defaultOptions.repeatLevels, options.repeatLevels),
labels: extend({}, defaultOptions.labels, options.labels),
formatLabel: extend({}, defaultOptions.formatLabel, options.formatLabel),
});
}

function formatHeaderLabel(options, ...args) {
return options.formatLabel.header.apply(options, args);
}

function formatTimeLabel(options, ...args) {
const format = (options.formatLabel[options.timeInterval]) || (() => { throw new Error('Interval not supported'); });
return format.apply(options, args);
}

function classNameFor(options, percentageValue) {
let highestLevel = null;

const classNames = [options.displayAbsoluteValues ? 'absolute' : 'percentage'];

each(options.repeatLevels, (bounds, level) => {
highestLevel = level;
if ((percentageValue >= bounds[0]) && (percentageValue < bounds[1])) {
return false; // break
}
}, [container, initialDate, data, timeInterval]);
});

// handle 100% case
if (highestLevel) {
classNames.push(highestLevel);
}

return map(classNames, c => `cornelius-${c}`).join(' ');
}

function CorneliusHeader({ options, maxRowLength }) { // eslint-disable-line react/prop-types
const cells = [];
for (let i = 1; i < maxRowLength; i += 1) {
cells.push(<th key={`col${i}`} className="cornelius-people">{formatHeaderLabel(options, i)}</th>);
}

return (
<tr>
<th className="cornelius-time">{options.labels.time}</th>
<th className="cornelius-people">{formatHeaderLabel(options, 0)}</th>
{cells}
</tr>
);
}

function CorneliusRow({ options, data, index, maxRowLength }) { // eslint-disable-line react/prop-types
const [baseValue] = data;

const cells = [];
for (let i = 0; i < maxRowLength; i += 1) {
const value = data[i];
const percentageValue = isFinite(value / baseValue) ? value / baseValue * 100 : null;
const cellProps = { key: `col${i}` };

if (isNil(percentageValue) && (i !== 0)) {
if (options.drawEmptyCells) {
cellProps.className = 'cornelius-empty';
cellProps.children = '-';
}
} else {
cellProps.className = i === 0 ? 'cornelius-people' : classNameFor(options, percentageValue);
cellProps.children = i === 0 || options.displayAbsoluteValues ? value : percentageValue.toFixed(2);
}

cells.push(<td {...cellProps} />);
}

return (
<tr>
<td className="cornelius-label">{formatTimeLabel(options, new Date(options.initialDate.getTime()), index)}</td>
{cells}
</tr>
);
}

export default function Cornelius({ data, options }) {
options = useMemo(() => prepareOptions(options), [options]);

const maxRowLength = useMemo(() => min([
max(map(data, d => d.length)) || 0,
options.maxColumns,
]), [data]);

if (data.length === 0) {
return null;
}

return (
<div className="cornelius-container">
{options.title && <div className="cornelius-title">{options.title}</div>}

return <div ref={setContainer} />;
<table className="cornelius-table">
<tbody>
<CorneliusHeader options={options} maxRowLength={maxRowLength} />
{map(data, (row, index) => (
<CorneliusRow key={`row${index}`} options={options} data={row} index={index} maxRowLength={maxRowLength} />
))}
</tbody>
</table>
</div>
);
}

Cornelius.propTypes = {
initialDate: PropTypes.instanceOf(Date).isRequired,
data: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.number)).isRequired,
timeInterval: PropTypes.oneOf(['daily', 'weekly', 'monthly']),
data: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.number)),
options: PropTypes.shape({
initialDate: PropTypes.instanceOf(Date).isRequired,
title: PropTypes.string,
monthNames: PropTypes.arrayOf(PropTypes.string),
shortMonthNames: PropTypes.arrayOf(PropTypes.string),
repeatLevels: PropTypes.object,
labels: PropTypes.shape({
time: PropTypes.string,
people: PropTypes.string,
weekOf: PropTypes.string,
}),
timeInterval: PropTypes.oneOf(['daily', 'weekly', 'monthly']),
drawEmptyCells: PropTypes.bool,
rawNumberOnHover: PropTypes.bool,
displayAbsoluteValues: PropTypes.bool,
initialIntervalNumber: PropTypes.number,
formatLabel: PropTypes.shape({
header: PropTypes.func,
daily: PropTypes.func,
weekly: PropTypes.func,
monthly: PropTypes.func,
yearly: PropTypes.func,
}),
}),
};

Cornelius.defaultProps = {
timeInterval: 'daily',
data: [],
options: {},
};
12 changes: 11 additions & 1 deletion client/app/visualizations/cohort/Renderer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,23 @@ import Cornelius from './Cornelius';
export default function Renderer({ data, options }) {
const { data: cohortData, initialDate } = useMemo(() => prepareData(data, options), [data, options]);

const corneliusOptions = useMemo(() => ({
initialDate,
timeInterval: options.timeInterval,
labels: {
time: 'Time',
people: 'Users',
weekOf: 'Week of',
},
}), [options, initialDate]);

if (cohortData.length === 0) {
return null;
}

return (
<div className="cohort-visualization-container">
<Cornelius data={cohortData} initialDate={initialDate} timeInterval={options.timeInterval} />
<Cornelius data={cohortData} options={corneliusOptions} />
</div>
);
}
Expand Down
2 changes: 1 addition & 1 deletion client/app/visualizations/cohort/cornelius.less
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
.cornelius-container {
.cornelius-title {
text-align: center;
padding-bottom: 20px;
padding-bottom: 10px;
font-weight: bold;
font-size: 14pt;
color: #3A3838;
Expand Down

0 comments on commit 5ebf982

Please sign in to comment.