Skip to content

Commit

Permalink
Add by-hour trips histogram to Service details page (#931)
Browse files Browse the repository at this point in the history
* done???

* remove unused no-unused-modules overrides

* lint
  • Loading branch information
idreyn authored Jan 11, 2024
1 parent 3bf48a7 commit c05a643
Show file tree
Hide file tree
Showing 33 changed files with 435 additions and 87 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import React from 'react';

import { COLORS } from '../../../constants/colors';
import { ByHourHistogram } from './ByHourHistogram';

export default {
title: 'ByHourHistogram',
component: ByHourHistogram,
};

const simpleData = [
{
label: '2023',
data: [0, 0, 0, 0, 5, 6, 7, 8, 7, 6, 5, 5, 4, 4, 5, 6, 7, 8, 9, 10, 10, 9, 8, 7],
style: { opacity: 0.5 },
},
{
label: '2024',
data: [0, 0, 0, 0, 6, 7, 8, 9, 9, 7, 6, 5, 4, 5, 6, 6, 8, 9, 9, 11, 12, 10, 8, 8],
},
];

export const Default = () => (
<ByHourHistogram
data={simpleData}
valueAxis={{ title: 'Items', tooltipItemLabel: 'trips per direction' }}
style={{ color: COLORS.mbta.red }}
/>
);
121 changes: 121 additions & 0 deletions common/components/charts/ByHourHistogram/ByHourHistogram.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import React, { useMemo } from 'react';
import { Bar as BarChart } from 'react-chartjs-2';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Tooltip,
Legend,
} from 'chart.js';
import Color from 'color';

import { useBreakpoint } from '../../../hooks/useBreakpoint';
import type { ByHourDataset, DisplayStyle, ValueAxis as ValueAxis } from './types';
import { resolveStyle } from './styles';

ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip, Legend);

interface Props {
style?: Partial<DisplayStyle>;
data: ByHourDataset[];
valueAxis: ValueAxis;
}

const allTimeLabels = ['AM', 'PM']
.map((label) => Array.from({ length: 12 }, (_, i) => `${i === 0 ? 12 : i} ${label}`))
.flat();

const rotate = <T extends string | number>(arr: T[]): T[] => [...arr.slice(1), arr[0]];

const stripZeroHoursAndRotateMidnightToEnd = (
datasets: ByHourDataset[]
): { data: ByHourDataset[]; timeLabels: string[] } => {
let stripIndex = 0;
const rotatedDatasets = datasets.map((dataset) => ({ ...dataset, data: rotate(dataset.data) }));
for (let i = 0; i < rotatedDatasets[0].data.length; i++) {
if (rotatedDatasets.every((dataset) => dataset.data[i] === 0)) {
stripIndex++;
} else {
break;
}
}
const timeLabels = rotate(allTimeLabels).slice(stripIndex);
const strippedDatasets = rotatedDatasets.map((dataset) => {
return {
...dataset,
data: dataset.data.slice(stripIndex),
};
});
return { data: strippedDatasets, timeLabels };
};

export const ByHourHistogram: React.FC<Props> = (props) => {
const { data: dataWithZeros, valueAxis, style: baseStyle = null } = props;
const { data, timeLabels } = useMemo(
() => stripZeroHoursAndRotateMidnightToEnd(dataWithZeros),
[dataWithZeros]
);
const isMobile = !useBreakpoint('md');

const chartData = useMemo(() => {
return {
labels: timeLabels,
datasets: data.map((dataset) => {
const style = resolveStyle([baseStyle, dataset.style ?? null]);
return {
tooltip: {},
label: dataset.label,
data: dataset.data,
borderColor: style.color,
borderWidth: style.borderWidth,
backgroundColor: Color(style.color)
.alpha(style.opacity ?? 0)
.toString(),
};
}),
};
}, [data, baseStyle, timeLabels]);

const chartOptions = useMemo(() => {
return {
scales: {
x: {
grid: {
display: false,
},
},
y: {
title: {
display: true,
text: valueAxis.title,
},
beginAtZero: true,
grid: {
display: false,
},
},
},
plugins: {
legend: {
display: true,
position: 'bottom' as const,
},
tooltip: {
mode: 'index' as const,
callbacks: {
label: (context) => {
const { datasetIndex, dataIndex } = context;
const dataset = data[datasetIndex];
const value = dataset.data[dataIndex];
const { label } = dataset;
return `${label}: ${value} ${valueAxis.tooltipItemLabel ?? ''}`.trim();
},
},
},
},
};
}, [valueAxis.title, valueAxis.tooltipItemLabel, data]);

return <BarChart data={chartData} options={chartOptions} height={isMobile ? 50 : 70} />;
};
1 change: 1 addition & 0 deletions common/components/charts/ByHourHistogram/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ByHourHistogram } from './ByHourHistogram';
11 changes: 11 additions & 0 deletions common/components/charts/ByHourHistogram/styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { DisplayStyle } from './types';

export const defaultStyle: DisplayStyle = {

Check warning on line 3 in common/components/charts/ByHourHistogram/styles.ts

View workflow job for this annotation

GitHub Actions / frontend (20, 3.11)

exported declaration 'defaultStyle' not used within other modules
color: 'black',
borderWidth: 0,
opacity: 1,
};

export const resolveStyle = (styles: (null | Partial<DisplayStyle>)[]): DisplayStyle => {
return styles.reduce((a, b) => ({ ...a, ...b }), defaultStyle) as DisplayStyle;
};
18 changes: 18 additions & 0 deletions common/components/charts/ByHourHistogram/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export type DisplayStyle = {
color: string;
opacity?: number;
borderWidth: number;
};

type ByHourData = number[];

export type ByHourDataset = {
label: string;
data: ByHourData;
style?: Partial<DisplayStyle>;
};

export type ValueAxis = {
title: string;
tooltipItemLabel?: string;
};
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
/* eslint-disable import/no-unused-modules */
import React, { useMemo } from 'react';
import { Line as LineChart } from 'react-chartjs-2';
import {
Expand Down
7 changes: 5 additions & 2 deletions common/components/graphics/ChartPlaceHolder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,20 @@ import { ErrorNotice } from '../notices/ErrorNotice';
import { LoadingSpinner } from './LoadingSpinner';

interface ChartPlaceHolder {
query: UseQueryResult<unknown>;
query?: UseQueryResult<unknown>;
readyState?: 'waiting' | 'error';
isInverse?: boolean;
}

export const ChartPlaceHolder: React.FC<ChartPlaceHolder> = ({
query,
readyState,
isInverse: inverse = false,
}) => {
const isError = query?.isError || readyState === 'error';
return (
<div className="relative flex h-60 w-full items-center justify-center">
{query.isError ? <ErrorNotice inverse={inverse} /> : <LoadingSpinner />}
{isError ? <ErrorNotice inverse={inverse} /> : <LoadingSpinner />}
</div>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import { useDateStore } from '../../../state/dateStore';
import { useDatePresetStore } from '../../../state/datePresetStore';
import type { OverviewDatePresetKey } from '../../../constants/dates';
import { OverviewRangeTypes } from '../../../constants/dates';
import { useDelimitatedRoute } from '../../../utils/router';

export const OverviewDateSelection = () => {
const router = useRouter();
const { line } = useDelimitatedRoute();
const setDatePreset = useDatePresetStore((state) => state.setDatePreset);
const selectedView = router.query.view ?? 'year';
const selectedIndex = Object.keys(OverviewRangeTypes).findIndex((view) => view === selectedView);
Expand All @@ -28,6 +30,7 @@ export const OverviewDateSelection = () => {
additionalButtonClass="w-fit text-xs sm:text-base md:text-xs lg:text-sm"
additionalDivClass="md:max-w-md h-10 md:h-7"
isOverview
line={line}
/>
);
};
2 changes: 1 addition & 1 deletion common/components/maps/LineMap.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,4 @@

.inner {
overflow-x: scroll;
}
}
73 changes: 63 additions & 10 deletions common/components/widgets/Widget.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,69 @@
import classNames from 'classnames';
import React from 'react';
import { BusDataNotice } from '../notices/BusDataNotice';
import React, { useState } from 'react';
import type { UseQueryResult } from '@tanstack/react-query';
import { ErrorBoundary } from 'react-error-boundary';

interface WidgetPageProps {
children?: React.ReactNode;
import { ChartPlaceHolder } from '../graphics/ChartPlaceHolder';
import { WidgetDiv } from './WidgetDiv';
import { WidgetTitle } from './WidgetTitle';

type ReadyDependency = unknown | UseQueryResult;
type ReadyState = 'ready' | 'waiting' | 'error';

interface Props {
ready?: ReadyDependency | ReadyDependency[];
title: React.ReactNode;
subtitle?: React.ReactNode;
children: React.ReactNode;
}

export const WidgetPage: React.FC<WidgetPageProps> = ({ children }) => {
const isUseQueryResult = (obj: unknown): obj is UseQueryResult => {
return !!obj && typeof obj === 'object' && 'isLoading' in obj && 'isError' in obj;
};

const getReadyState = (ready: ReadyDependency | ReadyDependency[]) => {
const readyArray = Array.isArray(ready) ? ready : [ready];
const eachState: ReadyState[] = readyArray.map((entry) =>
isUseQueryResult(entry)
? entry.isError
? 'error'
: entry.data
? 'ready'
: 'waiting'
: entry
? 'ready'
: 'waiting'
);
if (eachState.some((state) => state === 'error')) {
return 'error';
}
if (eachState.some((state) => state === 'waiting')) {
return 'waiting';
}
return 'ready';
};

export const Widget: React.FC<Props> = (props) => {
const { title, subtitle, children, ready: readyDependencies } = props;
const [hasError, setHasError] = useState<boolean>();

const readyState = hasError
? 'error'
: readyDependencies
? getReadyState(readyDependencies)
: 'ready';

return (
<div className={classNames('flex w-full flex-1 flex-col items-center gap-y-2 lg:p-0')}>
{children}
<BusDataNotice />
</div>
<WidgetDiv>
<WidgetTitle title={title} subtitle={subtitle} />
{readyState === 'ready' ? (
<ErrorBoundary onError={() => setHasError(true)} fallbackRender={() => null}>
{children}
</ErrorBoundary>
) : (
<div className="relative flex h-full">
<ChartPlaceHolder readyState={readyState} />
</div>
)}
</WidgetDiv>
);
};
16 changes: 16 additions & 0 deletions common/components/widgets/WidgetPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import classNames from 'classnames';
import React from 'react';
import { BusDataNotice } from '../notices/BusDataNotice';

interface WidgetPageProps {
children?: React.ReactNode;
}

export const WidgetPage: React.FC<WidgetPageProps> = ({ children }) => {
return (
<div className={classNames('flex w-full flex-1 flex-col items-center gap-y-2 lg:p-0')}>
{children}
<BusDataNotice />
</div>
);
};
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import React from 'react';
import classNames from 'classnames';
import type { Location } from '../../common/types/charts';
import type { Line } from '../../common/types/lines';
import { useBreakpoint } from '../../common/hooks/useBreakpoint';
import { useDelimitatedRoute } from '../../common/utils/router';
import { getSelectedDates } from '../../common/state/utils/dateStoreUtils';
import { LocationTitle } from './LocationTitle';
import type { Location } from '../../types/charts';
import type { Line } from '../../types/lines';
import { useBreakpoint } from '../../hooks/useBreakpoint';
import { useDelimitatedRoute } from '../../utils/router';
import { getSelectedDates } from '../../state/utils/dateStoreUtils';
import { LocationTitle } from '../../../modules/dashboard/LocationTitle';

interface WidgetTitle {
title: string;
subtitle?: string;
title: React.ReactNode;
subtitle?: React.ReactNode;
location?: Location;
both?: boolean;
line?: Line;
Expand Down
3 changes: 3 additions & 0 deletions common/components/widgets/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { Widget } from './Widget';
export { WidgetDiv } from './WidgetDiv';
export { WidgetTitle } from './WidgetTitle';
2 changes: 1 addition & 1 deletion common/layouts/DashboardLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react';
import { useBreakpoint } from '../hooks/useBreakpoint';
import { WidgetPage } from '../components/widgets/Widget';
import { WidgetPage } from '../components/widgets/WidgetPage';
import { MobileHeader } from '../../modules/dashboard/MobileHeader';
import { DesktopHeader } from '../../modules/dashboard/DesktopHeader';
import { useDelimitatedRoute } from '../utils/router';
Expand Down
2 changes: 1 addition & 1 deletion common/state/utils/dateStoreUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export const getSelectedDates = (dateConfig: {
viewInput.endDate
).format(SMALL_DATE_FORMAT)}`;
if (startDate && endDate) {
return `${formatDate(startDate)} - ${formatDate(endDate)}`;
return `${formatDate(startDate)} ${formatDate(endDate)}`;
}

if (startDate) return formatDate(startDate);
Expand Down
1 change: 1 addition & 0 deletions common/types/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface Route {
page: Page;
query: QueryParams;
tab: Tab;
color: string;
}

export const DATE_PARAMS = ['startDate', 'endDate', 'date'];
Expand Down
2 changes: 1 addition & 1 deletion common/utils/date.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export const prettyDate = (dateString: string, withDow: boolean) => {
export const prettyDate = (dateString: string, withDow: boolean = false) => {
const options: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'long',
Expand Down
Loading

0 comments on commit c05a643

Please sign in to comment.