From 05d72aa21207844035307542f9c3e9d11a9a879a Mon Sep 17 00:00:00 2001 From: Devin Matte Date: Tue, 16 Apr 2024 08:44:36 -0400 Subject: [PATCH] Highlight unusually low headways in chart (#972) --- common/components/charts/Legend.tsx | 16 +++-- .../components/charts/SingleDayLineChart.tsx | 56 +++++++++++++---- common/components/general/DataPair.tsx | 2 +- .../components/widgets/MiniWidgetCreator.tsx | 2 +- common/constants/colors.ts | 1 + common/types/basicWidgets.tsx | 4 +- common/types/charts.ts | 2 + common/utils/time.tsx | 6 +- common/utils/widgets.ts | 62 +++++++++++++++++-- .../headways/charts/HeadwaysSingleChart.tsx | 1 + 10 files changed, 121 insertions(+), 31 deletions(-) diff --git a/common/components/charts/Legend.tsx b/common/components/charts/Legend.tsx index 82f12495f..e42280ab8 100644 --- a/common/components/charts/Legend.tsx +++ b/common/components/charts/Legend.tsx @@ -3,7 +3,11 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Disclosure } from '@headlessui/react'; import React from 'react'; -export const LegendSingleDay: React.FC = () => { +interface LegendProps { + showUnderRatio?: boolean; +} + +export const LegendSingleDay: React.FC = ({ showUnderRatio = false }) => { return ( {({ open }) => ( @@ -19,7 +23,7 @@ export const LegendSingleDay: React.FC = () => { 'grid w-full grid-cols-2 items-baseline p-1 px-4 text-left text-xs lg:flex lg:flex-row lg:gap-4' } > - + )} @@ -27,7 +31,7 @@ export const LegendSingleDay: React.FC = () => { ); }; -const LegendSingle: React.FC = () => { +const LegendSingle: React.FC = ({ showUnderRatio = false }) => { return ( <>
@@ -43,19 +47,19 @@ const LegendSingle: React.FC = () => {

- {'25%+ above'} + {'25%+ off'}

{' '} - {'50%+ above'} + {'50%+ off'}

{' '} - {'100%+ above'} + {'100%+ off'}

); diff --git a/common/components/charts/SingleDayLineChart.tsx b/common/components/charts/SingleDayLineChart.tsx index 3d54735f9..9d8e09136 100644 --- a/common/components/charts/SingleDayLineChart.tsx +++ b/common/components/charts/SingleDayLineChart.tsx @@ -20,20 +20,32 @@ import { LegendSingleDay } from './Legend'; import { ChartDiv } from './ChartDiv'; import { ChartBorder } from './ChartBorder'; -const pointColors = (data: DataPoint[], metric_field: string, benchmark_field?: string) => { +const pointColors = ( + data: DataPoint[], + metric_field: string, + benchmark_field?: string, + showUnderRatio?: boolean +) => { return data.map((point: DataPoint) => { if (benchmark_field) { const ratio = point[metric_field] / point[benchmark_field]; if (point[benchmark_field] === null) { - return CHART_COLORS.GREY; //grey + return CHART_COLORS.GREY; + } else if (ratio <= 0.05 && showUnderRatio) { + // Not actually 100% off, but we want to show it as an extreme + return CHART_COLORS.PURPLE; + } else if (ratio <= 0.5 && showUnderRatio) { + return CHART_COLORS.RED; + } else if (ratio <= 0.75 && showUnderRatio) { + return CHART_COLORS.YELLOW; } else if (ratio <= 1.25) { - return CHART_COLORS.GREEN; //green + return CHART_COLORS.GREEN; } else if (ratio <= 1.5) { - return CHART_COLORS.YELLOW; //yellow + return CHART_COLORS.YELLOW; } else if (ratio <= 2.0) { - return CHART_COLORS.RED; //red + return CHART_COLORS.RED; } else if (ratio > 2.0) { - return CHART_COLORS.PURPLE; //purple + return CHART_COLORS.PURPLE; } } @@ -41,9 +53,13 @@ const pointColors = (data: DataPoint[], metric_field: string, benchmark_field?: }); }; -const departureFromNormalString = (metric: number, benchmark: number) => { +const departureFromNormalString = (metric: number, benchmark: number, showUnderRatio?: boolean) => { const ratio = metric / benchmark; - if (!isFinite(ratio) || ratio <= 1.25) { + if (showUnderRatio && ratio <= 0.5) { + return '50%+ under schedule'; + } else if (showUnderRatio && ratio <= 0.75) { + return '25%+ under schedule'; + } else if (!isFinite(ratio) || ratio <= 1.25) { return ''; } else if (ratio <= 1.5) { return '25%+ over schedule'; @@ -67,6 +83,7 @@ export const SingleDayLineChart: React.FC = ({ location, units, showLegend = true, + showUnderRatio = false, }) => { const ref = useRef(); const alerts = useAlertStore((store) => store.alerts)?.filter((alert) => alert.applied); @@ -104,9 +121,19 @@ export const SingleDayLineChart: React.FC = ({ label: `Actual`, fill: false, borderColor: '#a0a0a030', - pointBackgroundColor: pointColors(data, metricField, benchmarkField), + pointBackgroundColor: pointColors( + data, + metricField, + benchmarkField, + showUnderRatio + ), pointHoverRadius: 3, - pointHoverBackgroundColor: pointColors(data, metricField, benchmarkField), + pointHoverBackgroundColor: pointColors( + data, + metricField, + benchmarkField, + showUnderRatio + ), pointRadius: 3, pointHitRadius: 10, data: convertedData, @@ -148,7 +175,8 @@ export const SingleDayLineChart: React.FC = ({ afterBody: (tooltipItems) => { return departureFromNormalString( tooltipItems[0].parsed.y, - tooltipItems[1]?.parsed.y + tooltipItems[1]?.parsed.y, + showUnderRatio ); }, }, @@ -223,7 +251,11 @@ export const SingleDayLineChart: React.FC = ({
{alerts && }
- {showLegend && benchmarkField ? :
} + {showLegend && benchmarkField ? ( + + ) : ( +
+ )} {date && ( = ({ children, last }) => {
{children} diff --git a/common/components/widgets/MiniWidgetCreator.tsx b/common/components/widgets/MiniWidgetCreator.tsx index 45247f5b2..0456ee667 100644 --- a/common/components/widgets/MiniWidgetCreator.tsx +++ b/common/components/widgets/MiniWidgetCreator.tsx @@ -4,7 +4,7 @@ import { DataPair } from '../general/DataPair'; import { SmallDelta } from './internal/SmallDelta'; import { SmallData } from './internal/SmallData'; -interface MiniWidgetObject { +export interface MiniWidgetObject { type: 'delta' | 'data' | string; widgetValue: WidgetValueInterface; text: string; diff --git a/common/constants/colors.ts b/common/constants/colors.ts index 51646f430..af92c7083 100644 --- a/common/constants/colors.ts +++ b/common/constants/colors.ts @@ -31,6 +31,7 @@ export const COLORS = { // Colors for charts export const CHART_COLORS = { GREY: '#1c1c1c', + BLUE: '#0096FF', GREEN: '#64b96a', YELLOW: '#f5ed00', RED: '#c33149', diff --git a/common/types/basicWidgets.tsx b/common/types/basicWidgets.tsx index 6b2d94d68..f542a3b4b 100644 --- a/common/types/basicWidgets.tsx +++ b/common/types/basicWidgets.tsx @@ -111,7 +111,7 @@ export class SZWidgetValue extends BaseWidgetValue implements WidgetValueInterfa getFormattedValue(isLarge?: boolean) { if (typeof this.value === 'undefined') return '...'; return ( -

+

{' '}

@@ -131,7 +131,7 @@ export class PercentageWidgetValue extends BaseWidgetValue implements WidgetValu getFormattedValue(isLarge?: boolean) { if (this.value === undefined) return '...'; return ( -

+

{' '}

diff --git a/common/types/charts.ts b/common/types/charts.ts index 87adf4cb8..0f6f0e6ad 100644 --- a/common/types/charts.ts +++ b/common/types/charts.ts @@ -80,6 +80,8 @@ export interface LineProps { includeBothStopsForLocation?: boolean; fname: DataName; showLegend?: boolean; + /** Show ratios under 1.00 differently in chart */ + showUnderRatio?: boolean; } export interface AggregateLineProps extends LineProps { diff --git a/common/utils/time.tsx b/common/utils/time.tsx index 26271d9ed..3ecb90274 100644 --- a/common/utils/time.tsx +++ b/common/utils/time.tsx @@ -25,14 +25,14 @@ export const getFormattedTimeValue = (value: number, isLarge?: boolean) => { switch (true) { case absValue < 100: return ( -

+

); case absValue < 3600: return ( -

+

{' '} @@ -41,7 +41,7 @@ export const getFormattedTimeValue = (value: number, isLarge?: boolean) => { ); default: return ( -

+

{' '} diff --git a/common/utils/widgets.ts b/common/utils/widgets.ts index 2345784d4..3987bcb67 100644 --- a/common/utils/widgets.ts +++ b/common/utils/widgets.ts @@ -1,5 +1,12 @@ -import { MPHWidgetValue, TimeWidgetValue } from '../types/basicWidgets'; -import type { AggregateDataPoint, SingleDayDataPoint } from '../types/charts'; +import type { MiniWidgetObject } from '../components/widgets/MiniWidgetCreator'; +import { MPHWidgetValue, PercentageWidgetValue, TimeWidgetValue } from '../types/basicWidgets'; +import { + BenchmarkFieldKeys, + type AggregateDataPoint, + type SingleDayDataPoint, + MetricFieldKeys, +} from '../types/charts'; +import type { DataPoint } from '../types/dataPoints'; const getAverage = (data: (number | undefined)[]) => { const { length } = data; @@ -17,6 +24,30 @@ const getAverage = (data: (number | undefined)[]) => { } }; +const getBunchedPercentage = (data: SingleDayDataPoint[]) => { + const bunchedPoints = data.map((point: DataPoint) => { + const ratio = + point[MetricFieldKeys.headwayTimeSec] / point[BenchmarkFieldKeys.benchmarkHeadwayTimeSec]; + if (ratio <= 0.5) { + return 1; + } + return 0; + }); + return bunchedPoints.reduce((a, b) => a + b, 0) / data.length; +}; + +const getOnTimePercentage = (data: SingleDayDataPoint[]) => { + const onTimePoints = data.map((point: DataPoint) => { + const ratio = + point[MetricFieldKeys.headwayTimeSec] / point[BenchmarkFieldKeys.benchmarkHeadwayTimeSec]; + if (ratio > 0.75 && ratio < 1.25) { + return 1; + } + return 0; + }); + return onTimePoints.reduce((a, b) => a + b, 0) / data.length; +}; + const getPeaks = (data: (number | undefined)[]) => { data.sort((a, b) => { if (b !== undefined && a !== undefined) return a - b; @@ -68,7 +99,9 @@ const getSingleDayPointsOfInterest = ( const _data = getSingleDayNumberArray(data, type); const { max, min, median, p10, p90 } = getPeaks(_data); const average = getAverage(_data); - return { max, min, median, average, p10, p90 }; + const onTimePercentage = getOnTimePercentage(data); + const bunchedPercentage = getBunchedPercentage(data); + return { max, min, median, average, p10, p90, onTimePercentage, bunchedPercentage }; }; function getWidget(type: string, value: number | undefined) { @@ -82,9 +115,11 @@ function getWidget(type: string, value: number | undefined) { export const getSingleDayWidgets = ( data: SingleDayDataPoint[], type: 'traveltimes' | 'dwells' | 'headways' | 'speeds' -) => { - const { max, min, median, average, p10, p90 } = getSingleDayPointsOfInterest(data, type); - return [ +): MiniWidgetObject[] => { + const { max, min, median, average, p10, p90, onTimePercentage, bunchedPercentage } = + getSingleDayPointsOfInterest(data, type); + + const widgets: MiniWidgetObject[] = [ { text: 'Avg', widgetValue: getWidget(type, average), type: 'data' }, { text: 'Median', widgetValue: getWidget(type, median), type: 'data' }, { text: '10%', widgetValue: getWidget(type, p10), type: 'data' }, @@ -92,4 +127,19 @@ export const getSingleDayWidgets = ( { text: 'Min', widgetValue: getWidget(type, min), type: 'data' }, { text: 'Max', widgetValue: getWidget(type, max), type: 'data' }, ]; + + if (type === 'headways') { + widgets.push({ + text: 'On Time Trips', + widgetValue: new PercentageWidgetValue(onTimePercentage), + type: 'data', + }); + widgets.push({ + text: 'Bunched Trips', + widgetValue: new PercentageWidgetValue(bunchedPercentage), + type: 'data', + }); + } + + return widgets; }; diff --git a/modules/headways/charts/HeadwaysSingleChart.tsx b/modules/headways/charts/HeadwaysSingleChart.tsx index 5c4962e7d..f648b26c1 100644 --- a/modules/headways/charts/HeadwaysSingleChart.tsx +++ b/modules/headways/charts/HeadwaysSingleChart.tsx @@ -33,6 +33,7 @@ export const HeadwaysSingleChart: React.FC = ({ fname={'headways'} units={'Minutes'} showLegend={showLegend && anyHeadwayBenchmarks} + showUnderRatio={true} /> ); }, [linePath, headways, date, fromStation, toStation, showLegend, anyHeadwayBenchmarks]);