diff --git a/package.cordovabuild.json b/package.cordovabuild.json
index 20672bad0..943f06520 100644
--- a/package.cordovabuild.json
+++ b/package.cordovabuild.json
@@ -109,7 +109,6 @@
"angular": "1.6.7",
"angular-animate": "1.6.7",
"angular-local-storage": "^0.7.1",
- "angular-nvd3": "^1.0.7",
"angular-sanitize": "1.6.7",
"angular-simple-logger": "^0.1.7",
"angular-translate": "^2.18.1",
@@ -158,7 +157,6 @@
"moment-timezone": "^0.5.43",
"ng-i18next": "^1.0.7",
"npm": "^9.6.3",
- "nvd3": "^1.8.6",
"phonegap-plugin-barcodescanner": "git+https://github.com/phonegap/phonegap-plugin-barcodescanner#v8.1.0",
"prop-types": "^15.8.1",
"react": "^18.2.*",
diff --git a/package.serve.json b/package.serve.json
index f16c8bd66..a4e3194f3 100644
--- a/package.serve.json
+++ b/package.serve.json
@@ -55,7 +55,6 @@
"angular": "1.6.7",
"angular-animate": "1.6.7",
"angular-local-storage": "^0.7.1",
- "angular-nvd3": "^1.0.7",
"angular-sanitize": "1.6.7",
"angular-simple-logger": "^0.1.7",
"angular-translate": "^2.18.1",
@@ -83,7 +82,6 @@
"moment-timezone": "^0.5.43",
"ng-i18next": "^1.0.7",
"npm": "^9.6.3",
- "nvd3": "^1.8.6",
"prop-types": "^15.8.1",
"react": "^18.2.*",
"react-chartjs-2": "^5.2.0",
diff --git a/www/css/style.css b/www/css/style.css
index 5e923f5bd..8910b2258 100644
--- a/www/css/style.css
+++ b/www/css/style.css
@@ -18,9 +18,6 @@
max-height: 50px;
}
-/* nvd3 styles */
-@import 'nvd3/build/nv.d3.css';
-
.fill-container {
display: block;
position: relative;
@@ -746,15 +743,6 @@ timestamp-badge[light-bg] {
padding: 5% 10%;
}
-svg {
- display: block;
-}
-#chart, #chart svg {
- margin-right: 10px;
-}
-.nvd3, nv-noData {
- font-weight: 300 !important;
-}
.metric-datepicker {
/*height: 33px;*/
display: flex; /* establish flex container */
diff --git a/www/i18n/en.json b/www/i18n/en.json
index 758960ade..fe0df617a 100644
--- a/www/i18n/en.json
+++ b/www/i18n/en.json
@@ -36,7 +36,9 @@
"nuke-all": "Nuke all buffers and cache",
"test-notification": "Test local notification",
"check-log": "Check log",
+ "log-title" : "Log",
"check-sensed-data": "Check sensed data",
+ "sensed-title": "Sensed Data: Transitions",
"collection": "Collection",
"sync": "Sync",
"button-accept": "I accept",
@@ -101,9 +103,11 @@
"less-than": " less than ",
"less": " less ",
"week-before": "vs. week before",
+ "this-week": "this week",
"pick-a-date": "Pick a date",
"trips": "trips",
"hours": "hours",
+ "minutes": "minutes",
"custom": "Custom"
},
@@ -140,42 +144,43 @@
"no-travel-hint": "To see more, change the filters above or go record some travel!"
},
- "user-gender": "Gender",
- "gender-male": "Male",
- "gender-female": "Female",
- "user-height": "Height",
- "user-weight": "Weight",
- "user-age": "Age",
-
"main-metrics":{
"dashboard": "Dashboard",
"summary": "My Summary",
"chart": "Chart",
"change-data": "Change dates:",
- "distance": "My Distance",
- "trips": "My Trips",
- "duration": "My Duration",
+ "distance": "Distance",
+ "trips": "Trips",
+ "duration": "Duration",
"fav-mode": "My Favorite Mode",
"speed": "My Speed",
"footprint": "My Footprint",
+ "estimated-emissions": "Estimated CO₂ emissions",
"how-it-compares": "Ballpark comparisons",
- "optimal": "Optimal (perfect mode choice for all my trips):",
- "average": "Average for group:",
- "avoided": "CO₂ avoided (vs. all 'taxi'):",
+ "optimal": "Optimal (perfect mode choice for all my trips)",
+ "average": "Group Avg.",
+ "worst-case": "Worse Case",
"label-to-squish": "Label trips to collapse the range into a single number",
+ "range-uncertain-footnote": "²Due to the uncertainty of unlabeled trips, estimates may fall anywhere within the shown range. Label more trips for richer estimates.",
"lastweek": "My last week value:",
- "us-2030-goal": "US 2030 Goal Estimate:",
- "us-2050-goal": "US 2050 Goal Estimate:",
- "calories": "My Calories",
- "calibrate": "Calibrate",
+ "us-2030-goal": "2030 Guideline¹",
+ "us-2050-goal": "2050 Guideline¹",
+ "us-goals-footnote": "¹Guidelines based on US decarbonization goals, scaled to per-capita travel-related emissions.",
+ "past-week" : "Past Week",
+ "prev-week" : "Prev. Week",
"no-summary-data": "No summary data",
"mean-speed": "My Average Speed",
- "equals-cookies_one": "Equals at least {{count}} homemade chocolate chip cookie",
- "equals-cookies_other": "Equals at least {{count}} homemade chocolate chip cookies",
- "equals-icecream_one": "Equals at least {{count}} half cup vanilla ice cream",
- "equals-icecream_other": "Equals at least {{count}} half cups vanilla ice cream",
- "equals-bananas_one": "Equals at least {{count}} banana",
- "equals-bananas_other": "Equals at least {{count}} bananas"
+ "user-totals": "My Totals",
+ "group-totals": "Group Totals",
+ "active-minutes": "Active Minutes",
+ "weekly-active-minutes": "Weekly minutes of active travel",
+ "daily-active-minutes": "Daily minutes of active travel",
+ "active-minutes-table": "Table of active minutes metrics",
+ "weekly-goal": "Weekly Goal³",
+ "weekly-goal-footnote": "³Weekly goal based on CDC recommendation of 150 minutes of moderate activity per week.",
+ "labeled": "Labeled",
+ "unlabeled": "Unlabeled²",
+ "footprint-label": "Footprint (kg CO₂)"
},
"main-inf-scroll" : {
@@ -360,7 +365,9 @@
"errors": {
"while-populating-composite": "Error while populating composite trips",
"while-loading-another-week": "Error while loading travel of {{when}} week",
- "while-loading-specific-week": "Error while loading travel for the week of {{day}}"
+ "while-loading-specific-week": "Error while loading travel for the week of {{day}}",
+ "while-log-messages": "While getting messages from the log ",
+ "while-max-index" : "While getting max index "
},
"consent-text": {
"title":"NREL OPENPATH PRIVACY POLICY/TERMS OF USE",
diff --git a/www/index.js b/www/index.js
index 66a0d45df..17a5326d7 100644
--- a/www/index.js
+++ b/www/index.js
@@ -30,7 +30,6 @@ import './js/survey/input-matcher.js';
import './js/survey/multilabel/infinite_scroll_filters.js';
import './js/survey/multilabel/multi-label-ui.js';
import './js/diary.js';
-import './js/recent.js';
import './js/diary/services.js';
import './js/survey/external/launch.js';
import './js/survey/enketo/answer.js';
@@ -40,7 +39,6 @@ import './js/survey/enketo/infinite_scroll_filters.js';
import './js/survey/enketo/enketo-trip-button.js';
import './js/survey/enketo/enketo-demographics.js';
import './js/survey/enketo/enketo-add-note-button.js';
-import './js/metrics.js';
import './js/control/general-settings.js';
import './js/control/emailService.js';
import './js/control/uploadService.js';
diff --git a/www/js/appTheme.ts b/www/js/appTheme.ts
index 7571a564c..5f47f00b1 100644
--- a/www/js/appTheme.ts
+++ b/www/js/appTheme.ts
@@ -26,7 +26,8 @@ const AppTheme = {
level4: '#e0f0ff', // lch(94% 50 250)
level5: '#d6ebff', // lch(92% 50 250)
},
- success: '#38872e', // lch(50% 55 135)
+ success: '#00a665', // lch(60% 55 155)
+ warn: '#f8cf53', //lch(85% 65 85)
danger: '#f23934' // lch(55% 85 35)
},
roundness: 5,
diff --git a/www/js/components/ActionMenu.tsx b/www/js/components/ActionMenu.tsx
new file mode 100644
index 000000000..296717a00
--- /dev/null
+++ b/www/js/components/ActionMenu.tsx
@@ -0,0 +1,41 @@
+import React from "react";
+import { Modal } from "react-native";
+import { Dialog, Button, useTheme } from "react-native-paper";
+import { useTranslation } from "react-i18next";
+import { settingStyles } from "../control/ProfileSettings";
+
+const ActionMenu = ({vis, setVis, title, actionSet, onAction, onExit}) => {
+
+ const { t } = useTranslation();
+ const { colors } = useTheme();
+
+ return (
+ setVis(false)}
+ transparent={true}>
+
+
+ )
+}
+
+export default ActionMenu;
\ No newline at end of file
diff --git a/www/js/components/BarChart.tsx b/www/js/components/BarChart.tsx
index 6da3d2a2b..1e957923b 100644
--- a/www/js/components/BarChart.tsx
+++ b/www/js/components/BarChart.tsx
@@ -1,201 +1,27 @@
+import React from "react";
+import Chart, { Props as ChartProps } from "./Chart";
+import { useTheme } from "react-native-paper";
+import { getGradient } from "./charting";
-import React, { useRef, useState } from 'react';
-import { array, string, bool } from 'prop-types';
-import { angularize } from '../angular-react-helper';
-import { View } from 'react-native';
-import { useTheme } from 'react-native-paper';
-import { Chart, CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, TimeScale } from 'chart.js';
-import { Bar } from 'react-chartjs-2';
-import Annotation, { AnnotationOptions } from 'chartjs-plugin-annotation';
-
-Chart.register(
- CategoryScale,
- LinearScale,
- TimeScale,
- BarElement,
- Title,
- Tooltip,
- Legend,
- Annotation,
-);
-
-const BarChart = ({ chartData, axisTitle, lineAnnotations=null, isHorizontal=false }) => {
+type Props = Omit & {
+ meter?: {high: number, middle: number, dash_key: string},
+}
+const BarChart = ({ meter, ...rest }: Props) => {
const { colors } = useTheme();
- const [ numVisibleDatasets, setNumVisibleDatasets ] = useState(1);
-
- const barChartRef = useRef(null);
-
- const defaultPalette = [
- '#c95465', // red oklch(60% 0.15 14)
- '#4a71b1', // blue oklch(55% 0.11 260)
- '#d2824e', // orange oklch(68% 0.12 52)
- '#856b5d', // brown oklch(55% 0.04 50)
- '#59894f', // green oklch(58% 0.1 140)
- '#e0cc55', // yellow oklch(84% 0.14 100)
- '#b273ac', // purple oklch(64% 0.11 330)
- '#f09da6', // pink oklch(78% 0.1 12)
- '#b3aca8', // grey oklch(75% 0.01 55)
- '#80afad', // teal oklch(72% 0.05 192)
- ]
-
- const indexAxis = isHorizontal ? 'y' : 'x';
- function getChartHeight() {
- /* when horizontal charts have more data, they should get taller
- so they don't look squished */
- if (isHorizontal) {
- // 'ideal' chart height is based on the number of datasets and number of unique index values
- const uniqueIndexVals = [];
- chartData.forEach(e => e.records.forEach(r => {
- if (!uniqueIndexVals.includes(r[indexAxis])) uniqueIndexVals.push(r[indexAxis]);
- }));
- const numIndexVals = uniqueIndexVals.length;
- const idealChartHeight = numVisibleDatasets * numIndexVals * 8;
-
- /* each index val should be at least 20px tall for visibility,
- and the graph itself should be at least 250px tall */
- const minChartHeight = Math.max(numIndexVals * 20, 250);
-
- // return whichever is greater
- return { height: Math.max(idealChartHeight, minChartHeight) };
+ if (meter) {
+ rest.getColorForChartEl = (chart, dataset, ctx, colorFor) => {
+ const darkenDegree = colorFor == 'border' ? 0.25 : 0;
+ const alpha = colorFor == 'border' ? 1 : 0;
+ return getGradient(chart, meter, dataset, ctx, alpha, darkenDegree);
}
- // vertical charts will just match the parent container
- return { height: '100%' };
+ rest.borderWidth = 3;
}
return (
-
- ({
- label: d.label,
- data: d.records,
- // cycle through the default palette, repeat if necessary
- backgroundColor: defaultPalette[i % defaultPalette.length],
- }))
- }}
- options={{
- indexAxis: indexAxis,
- responsive: true,
- maintainAspectRatio: false,
- resizeDelay: 1,
- scales: {
- ...(isHorizontal ? {
- y: {
- offset: true,
- type: 'time',
- adapters: {
- date: { zone: 'utc' },
- },
- time: {
- unit: 'day',
- tooltipFormat: 'DDD', // Luxon "localized date with full month": e.g. August 6, 2014
- },
- beforeUpdate: (axis) => {
- setNumVisibleDatasets(axis.chart.getVisibleDatasetCount())
- },
- reverse: true,
- },
- x: {
- title: { display: true, text: axisTitle },
- },
- } : {
- x: {
- offset: true,
- type: 'time',
- adapters: {
- date: { zone: 'utc' },
- },
- time: {
- unit: 'day',
- tooltipFormat: 'DDD', // Luxon "localized date with full month": e.g. August 6, 2014
- },
- },
- y: {
- title: { display: true, text: axisTitle },
- },
- }),
- },
- plugins: {
- ...(lineAnnotations?.length > 0 && {
- annotation: {
- annotations: lineAnnotations.map((a, i) => ({
- type: 'line',
- label: {
- display: true,
- padding: { x: 3, y: 1 },
- borderRadius: 0,
- backgroundColor: 'rgba(0,0,0,.7)',
- color: 'rgba(255,255,255,1)',
- font: { size: 10 },
- position: 'start',
- content: a.label,
- },
- ...(isHorizontal ? { xMin: a.value, xMax: a.value }
- : { yMin: a.value, yMax: a.value }),
- borderColor: colors.onBackground,
- borderWidth: 2,
- borderDash: [3, 3],
- } satisfies AnnotationOptions)),
- }
- }),
- }
- }} />
-
- )
+
+ );
}
-BarChart.propTypes = {
- chartData: array,
- axisTitle: string,
- lineAnnotations: array,
- isHorizontal: bool,
-};
-
-angularize(BarChart, 'BarChart', 'emission.main.barchart');
export default BarChart;
-
-// const sampleAnnotations = [
-// { value: 35, label: 'Target1' },
-// { value: 65, label: 'Target2' },
-// ];
-
-// const sampleChartData = [
-// {
-// label: 'Primary',
-// records: [
-// { x: moment('2023-06-20'), y: 20 },
-// { x: moment('2023-06-21'), y: 30 },
-// { x: moment('2023-06-23'), y: 80 },
-// { x: moment('2023-06-24'), y: 40 },
-// ],
-// },
-// {
-// label: 'Secondary',
-// records: [
-// { x: moment('2023-06-21'), y: 10 },
-// { x: moment('2023-06-22'), y: 50 },
-// { x: moment('2023-06-23'), y: 30 },
-// { x: moment('2023-06-25'), y: 40 },
-// ],
-// },
-// {
-// label: 'Tertiary',
-// records: [
-// { x: moment('2023-06-20'), y: 30 },
-// { x: moment('2023-06-22'), y: 40 },
-// { x: moment('2023-06-24'), y: 10 },
-// { x: moment('2023-06-25'), y: 60 },
-// ],
-// },
-// {
-// label: 'Quaternary',
-// records: [
-// { x: moment('2023-06-22'), y: 10 },
-// { x: moment('2023-06-23'), y: 20 },
-// { x: moment('2023-06-24'), y: 30 },
-// { x: moment('2023-06-25'), y: 40 },
-// ],
-// },
-// ];
diff --git a/www/js/components/Carousel.tsx b/www/js/components/Carousel.tsx
new file mode 100644
index 000000000..28a31ff6a
--- /dev/null
+++ b/www/js/components/Carousel.tsx
@@ -0,0 +1,42 @@
+import React from 'react';
+import { ScrollView, View } from 'react-native';
+
+type Props = {
+ children: React.ReactNode,
+ cardWidth: number,
+ cardMargin: number,
+}
+const Carousel = ({ children, cardWidth, cardMargin }: Props) => {
+ const numCards = React.Children.count(children);
+ return (
+
+ {React.Children.map(children, (child, i) => (
+
+ {child}
+
+ ))}
+
+ )
+};
+
+export const s = {
+ carouselScroll: (cardMargin) => ({
+ // @ts-ignore, RN doesn't recognize `scrollSnapType`, but it does work on RN Web
+ scrollSnapType: 'x mandatory',
+ paddingVertical: 10,
+ }),
+ carouselCard: (cardWidth, cardMargin, isFirst, isLast) => ({
+ marginLeft: isFirst ? cardMargin : cardMargin/2,
+ marginRight: isLast ? cardMargin : cardMargin/2,
+ width: cardWidth,
+ scrollSnapAlign: 'center',
+ scrollSnapStop: 'always',
+ }),
+};
+
+export default Carousel;
diff --git a/www/js/components/Chart.tsx b/www/js/components/Chart.tsx
new file mode 100644
index 000000000..79c6e40e4
--- /dev/null
+++ b/www/js/components/Chart.tsx
@@ -0,0 +1,196 @@
+
+import React, { useEffect, useRef, useState, useMemo } from 'react';
+import { View } from 'react-native';
+import { useTheme } from 'react-native-paper';
+import { Chart as ChartJS, registerables } from 'chart.js';
+import { Chart as ChartJSChart } from 'react-chartjs-2';
+import Annotation, { AnnotationOptions, LabelPosition } from 'chartjs-plugin-annotation';
+import { dedupColors, getChartHeight, darkenOrLighten } from './charting';
+
+ChartJS.register(...registerables, Annotation);
+
+type XYPair = { x: number|string, y: number|string };
+type ChartDataset = {
+ label: string,
+ data: XYPair[],
+};
+
+export type Props = {
+ records: { label: string, x: number|string, y: number|string }[],
+ axisTitle: string,
+ type: 'bar'|'line',
+ getColorForLabel?: (label: string) => string,
+ getColorForChartEl?: (chart, currDataset: ChartDataset, ctx: ScriptableContext<'bar'|'line'>, colorFor: 'background'|'border') => string|CanvasGradient|null,
+ borderWidth?: number,
+ lineAnnotations?: { value: number, label?: string, color?:string, position?: LabelPosition }[],
+ isHorizontal?: boolean,
+ timeAxis?: boolean,
+ stacked?: boolean,
+}
+const Chart = ({ records, axisTitle, type, getColorForLabel, getColorForChartEl, borderWidth, lineAnnotations, isHorizontal, timeAxis, stacked }: Props) => {
+
+ const { colors } = useTheme();
+ const [ numVisibleDatasets, setNumVisibleDatasets ] = useState(1);
+
+ const indexAxis = isHorizontal ? 'y' : 'x';
+ const chartRef = useRef>(null);
+ const [chartDatasets, setChartDatasets] = useState([]);
+
+ const chartData = useMemo>(() => {
+ let labelColorMap; // object mapping labels to colors
+ if (getColorForLabel) {
+ const colorEntries = chartDatasets.map(d => [d.label, getColorForLabel(d.label)] );
+ labelColorMap = dedupColors(colorEntries);
+ }
+ return {
+ datasets: chartDatasets.map((e, i) => ({
+ ...e,
+ backgroundColor: (barCtx) => (
+ labelColorMap?.[e.label] || getColorForChartEl(chartRef.current, e, barCtx, 'background')
+ ),
+ borderColor: (barCtx) => (
+ darkenOrLighten(labelColorMap?.[e.label], -.5) || getColorForChartEl(chartRef.current, e, barCtx, 'border')
+ ),
+ borderWidth: borderWidth || 2,
+ borderRadius: 3,
+ })),
+ };
+ }, [chartDatasets, getColorForLabel]);
+
+ // group records by label (this is the format that Chart.js expects)
+ useEffect(() => {
+ const d = records?.reduce((acc, record) => {
+ const existing = acc.find(e => e.label == record.label);
+ if (!existing) {
+ acc.push({
+ label: record.label,
+ data: [{
+ x: record.x,
+ y: record.y,
+ }],
+ });
+ } else {
+ existing.data.push({
+ x: record.x,
+ y: record.y,
+ });
+ }
+ return acc;
+ }, [] as ChartDataset[]);
+ setChartDatasets(d);
+ }, [records]);
+
+ const annotationsAtTop = isHorizontal && lineAnnotations?.some(a => (!a.position || a.position == 'start'));
+
+ return (
+
+ {
+ setNumVisibleDatasets(axis.chart.getVisibleDatasetCount())
+ },
+ ticks: timeAxis ? {} : {
+ callback: (value, i) => {
+ const label = chartDatasets[0].data[i].y;
+ if (typeof label == 'string' && label.includes('\n'))
+ return label.split('\n');
+ return label;
+ },
+ font: { size: 11 }, // default is 12, we want a tad smaller
+ },
+ reverse: true,
+ stacked,
+ },
+ x: {
+ title: { display: true, text: axisTitle },
+ stacked,
+ },
+ } : {
+ x: {
+ offset: true,
+ type: timeAxis ? 'time' : 'category',
+ adapters: timeAxis ? {
+ date: { zone: 'utc' },
+ } : {},
+ time: timeAxis ? {
+ unit: 'day',
+ tooltipFormat: 'DDD', // Luxon "localized date with full month": e.g. August 6, 2014
+ } : {},
+ ticks: timeAxis ? {} : {
+ callback: (value, i) => {
+ console.log("testing vertical", chartData, i);
+ const label = chartDatasets[0].data[i].x;
+ if (typeof label == 'string' && label.includes('\n'))
+ return label.split('\n');
+ return label;
+ },
+ },
+ stacked,
+ },
+ y: {
+ title: { display: true, text: axisTitle },
+ stacked,
+ },
+ }),
+ },
+ plugins: {
+ ...(lineAnnotations?.length > 0 && {
+ annotation: {
+ clip: false,
+ annotations: lineAnnotations.map((a, i) => ({
+ type: 'line',
+ label: {
+ display: true,
+ padding: { x: 3, y: 1 },
+ borderRadius: 0,
+ backgroundColor: 'rgba(0,0,0,.7)',
+ color: 'rgba(255,255,255,1)',
+ font: { size: 10 },
+ position: a.position || 'start',
+ content: a.label,
+ yAdjust: annotationsAtTop ? -12 : 0,
+ },
+ ...(isHorizontal ? { xMin: a.value, xMax: a.value }
+ : { yMin: a.value, yMax: a.value }),
+ borderColor: a.color || colors.onBackground,
+ borderWidth: 3,
+ borderDash: [3, 3],
+ } satisfies AnnotationOptions)),
+ }
+ }),
+ }
+ }}
+ // if there are annotations at the top of the chart, it overlaps with the legend
+ // so we need to increase the spacing between the legend and the chart
+ // https://stackoverflow.com/a/73498454
+ plugins={annotationsAtTop && [{
+ id: "increase-legend-spacing",
+ beforeInit(chart) {
+ const originalFit = (chart.legend as any).fit;
+ (chart.legend as any).fit = function fit() {
+ originalFit.bind(chart.legend)();
+ this.height += 12;
+ };
+ }
+ }]} />
+
+ )
+}
+export default Chart;
diff --git a/www/js/components/LineChart.tsx b/www/js/components/LineChart.tsx
new file mode 100644
index 000000000..66d21aac2
--- /dev/null
+++ b/www/js/components/LineChart.tsx
@@ -0,0 +1,11 @@
+import React from "react";
+import Chart, { Props as ChartProps } from "./Chart";
+
+type Props = Omit & { }
+const LineChart = ({ ...rest }: Props) => {
+ return (
+
+ );
+}
+
+export default LineChart;
diff --git a/www/js/components/charting.ts b/www/js/components/charting.ts
new file mode 100644
index 000000000..f0da14619
--- /dev/null
+++ b/www/js/components/charting.ts
@@ -0,0 +1,161 @@
+import color from 'color';
+import { getBaseModeByKey } from '../diary/diaryHelper';
+import { readableLabelToKey } from '../survey/multilabel/confirmHelper';
+
+export const defaultPalette = [
+ '#c95465', // red oklch(60% 0.15 14)
+ '#4a71b1', // blue oklch(55% 0.11 260)
+ '#d2824e', // orange oklch(68% 0.12 52)
+ '#856b5d', // brown oklch(55% 0.04 50)
+ '#59894f', // green oklch(58% 0.1 140)
+ '#e0cc55', // yellow oklch(84% 0.14 100)
+ '#b273ac', // purple oklch(64% 0.11 330)
+ '#f09da6', // pink oklch(78% 0.1 12)
+ '#b3aca8', // grey oklch(75% 0.01 55)
+ '#80afad', // teal oklch(72% 0.05 192)
+];
+
+export function getChartHeight(chartDatasets, numVisibleDatasets, indexAxis, isHorizontal, stacked) {
+ /* when horizontal charts have more data, they should get taller
+ so they don't look squished */
+ if (isHorizontal) {
+ // 'ideal' chart height is based on the number of datasets and number of unique index values
+ const uniqueIndexVals = [];
+ chartDatasets.forEach(e => e.data.forEach(r => {
+ if (!uniqueIndexVals.includes(r[indexAxis])) uniqueIndexVals.push(r[indexAxis]);
+ }));
+ const numIndexVals = uniqueIndexVals.length;
+ const heightPerIndexVal = stacked ? 36 : numVisibleDatasets * 8;
+ const idealChartHeight = heightPerIndexVal * numIndexVals;
+
+ /* each index val should be at least 20px tall for visibility,
+ and the graph itself should be at least 250px tall */
+ const minChartHeight = Math.max(numIndexVals * 20, 250);
+
+ // return whichever is greater
+ return { height: Math.max(idealChartHeight, minChartHeight) };
+ }
+ // vertical charts should just fill the available space in the parent container
+ return { flex: 1 };
+}
+
+function getBarHeight(stacks) {
+ let totalHeight = 0;
+ console.log("ctx stacks", stacks.x);
+ for(let val in stacks.x) {
+ if(!val.startsWith('_')){
+ totalHeight += stacks.x[val];
+ console.log("ctx added ", val );
+ }
+ }
+ return totalHeight;
+}
+
+//fill pattern creation
+//https://stackoverflow.com/questions/28569667/fill-chart-js-bar-chart-with-diagonal-stripes-or-other-patterns
+function createDiagonalPattern(color = 'black') {
+ let shape = document.createElement('canvas')
+ shape.width = 10
+ shape.height = 10
+ let c = shape.getContext('2d')
+ c.strokeStyle = color
+ c.lineWidth = 2
+ c.beginPath()
+ c.moveTo(2, 0)
+ c.lineTo(10, 8)
+ c.stroke()
+ c.beginPath()
+ c.moveTo(0, 8)
+ c.lineTo(2, 10)
+ c.stroke()
+ return c.createPattern(shape, 'repeat')
+}
+
+export function getMeteredBackgroundColor(meter, currDataset, barCtx, colors, darken=0) {
+ if (!barCtx || !currDataset) return;
+ let bar_height = getBarHeight(barCtx.parsed._stacks);
+ console.debug("bar height for", barCtx.raw.y, " is ", bar_height, "which in chart is", currDataset);
+ let meteredColor;
+ if (bar_height > meter.high) meteredColor = colors.danger;
+ else if (bar_height > meter.middle) meteredColor = colors.warn;
+ else meteredColor = colors.success;
+ if (darken) {
+ return color(meteredColor).darken(darken).hex();
+ }
+ //if "unlabeled", etc -> stripes
+ if (currDataset.label == meter.dash_key) {
+ return createDiagonalPattern(meteredColor);
+ }
+ //if :labeled", etc -> solid
+ return meteredColor;
+}
+
+const meterColors = {
+ below: '#00cc95', // green oklch(75% 0.3 165)
+ // https://www.joshwcomeau.com/gradient-generator?colors=fcab00|ba0000&angle=90&colorMode=lab&precision=3&easingCurve=0.25|0.75|0.75|0.25
+ between: ['#fcab00', '#ef8215', '#db5e0c', '#ce3d03', '#b70100'], // yellow-orange-red
+ above: '#440000', // dark red
+}
+
+export function getGradient(chart, meter, currDataset, barCtx, alpha = null, darken = 0) {
+ const { ctx, chartArea, scales } = chart;
+ if (!chartArea) return null;
+ let gradient: CanvasGradient;
+ const total = getBarHeight(barCtx.parsed._stacks);
+ alpha = alpha || (currDataset.label == meter.dash_key ? 0.2 : 1);
+ if (total < meter.middle) {
+ const adjColor = darken||alpha ? color(meterColors.below).darken(darken).alpha(alpha).rgb().string() : meterColors.below;
+ return adjColor;
+ }
+ const scaleMaxX = scales.x._range.max;
+ gradient = ctx.createLinearGradient(chartArea.left, 0, chartArea.right, 0);
+ meterColors.between.forEach((clr, i) => {
+ const clrPosition = ((i + 1) / meterColors.between.length) * (meter.high - meter.middle) + meter.middle;
+ const adjColor = darken || alpha ? color(clr).darken(darken).alpha(alpha).rgb().string() : clr;
+ gradient.addColorStop(Math.min(clrPosition, scaleMaxX) / scaleMaxX, adjColor);
+ });
+ if (scaleMaxX > meter.high + 20) {
+ const adjColor = darken||alpha ? color(meterColors.above).darken(0.2).alpha(alpha).rgb().string() : meterColors.above;
+ gradient.addColorStop((meter.high+20) / scaleMaxX, adjColor);
+ }
+ return gradient;
+}
+
+/**
+ * @param baseColor a color string
+ * @param change a number between -1 and 1, indicating the amount to darken or lighten the color
+ * @returns an adjusted color, either darkened or lightened, depending on the sign of change
+ */
+export function darkenOrLighten(baseColor: string, change: number) {
+ if (!baseColor) return baseColor;
+ let colorObj = color(baseColor);
+ if(change < 0) {
+ // darkening appears more drastic than lightening, so we will be less aggressive (scale change by .5)
+ return colorObj.darken(Math.abs(change * .5)).hex();
+ } else {
+ return colorObj.lighten(Math.abs(change)).hex();
+ }
+}
+
+/**
+ * @param colors an array of colors, each of which is an array of [key, color string]
+ * @returns an object mapping keys to colors, with duplicates darkened/lightened to be distinguishable
+ */
+export const dedupColors = (colors: string[][]) => {
+ const dedupedColors = {};
+ const maxAdjustment = 0.7; // more than this is too drastic and the colors approach black/white
+ for (const [key, clr] of colors) {
+ if (!clr) continue; // skip empty colors
+ const duplicates = colors.filter(([k, c]) => c == clr);
+ if (duplicates.length > 1) {
+ // there are duplicates; calculate an evenly-spaced adjustment for each one
+ duplicates.forEach(([k, c], i) => {
+ const change = -maxAdjustment + (maxAdjustment*2 / (duplicates.length - 1)) * i;
+ dedupedColors[k] = darkenOrLighten(clr, change);
+ });
+ } else if (!dedupedColors[key]) {
+ dedupedColors[key] = clr; // not a dupe, & not already deduped, so use the color as-is
+ }
+ }
+ return dedupedColors;
+}
diff --git a/www/js/config/useImperialConfig.ts b/www/js/config/useImperialConfig.ts
index 3ddb287a2..58af79551 100644
--- a/www/js/config/useImperialConfig.ts
+++ b/www/js/config/useImperialConfig.ts
@@ -1,33 +1,40 @@
import React, { useEffect, useState } from "react";
import useAppConfig from "../useAppConfig";
+import i18next from "i18next";
const KM_TO_MILES = 0.621371;
-/* formatting distances for display:
- - if distance >= 100, round to the nearest integer
- e.g. "105 mi", "167 km"
- - if 1 <= distance < 100, round to 3 significant digits
- e.g. "7.02 mi", "11.3 km"
- - if distance < 1, round to 2 significant digits
- e.g. "0.47 mi", "0.75 km" */
-const formatDistance = (dist: number) => {
- if (dist < 1)
- return dist.toPrecision(2);
- if (dist < 100)
- return dist.toPrecision(3);
- return Math.round(dist).toString();
+const MPS_TO_KMPH = 3.6;
+
+// it might make sense to move this to a more general location in the codebase
+/* formatting units for display:
+ - if value >= 100, round to the nearest integer
+ e.g. "105 mi", "119 kmph"
+ - if 1 <= value < 100, round to 3 significant digits
+ e.g. "7.02 km", "11.3 mph"
+ - if value < 1, round to 2 decimal places
+ e.g. "0.07 mi", "0.75 km" */
+export const formatForDisplay = (value: number): string => {
+ let opts: Intl.NumberFormatOptions = {};
+ if (value >= 100)
+ opts.maximumFractionDigits = 0;
+ else if (value >= 1)
+ opts.maximumSignificantDigits = 3;
+ else
+ opts.maximumFractionDigits = 2;
+ return Intl.NumberFormat(i18next.language, opts).format(value);
}
-const getFormattedDistanceInKm = (distInMeters: string) =>
- formatDistance(Number.parseFloat(distInMeters) / 1000);
-
-const getFormattedDistanceInMiles = (distInMeters: string) =>
- formatDistance((Number.parseFloat(distInMeters) / 1000) * KM_TO_MILES);
-
-const getKmph = (metersPerSec) =>
- (metersPerSec * 3.6).toFixed(2);
+const convertDistance = (distMeters: number, imperial: boolean): number => {
+ if (imperial)
+ return (distMeters / 1000) * KM_TO_MILES;
+ return distMeters / 1000;
+}
-const getMph = (metersPerSecond) =>
- (KM_TO_MILES * Number.parseFloat(getKmph(metersPerSecond))).toFixed(2);
+const convertSpeed = (speedMetersPerSec: number, imperial: boolean): number => {
+ if (imperial)
+ return speedMetersPerSec * MPS_TO_KMPH * KM_TO_MILES;
+ return speedMetersPerSec * MPS_TO_KMPH;
+}
export function useImperialConfig() {
const { appConfig, loading } = useAppConfig();
@@ -41,7 +48,9 @@ export function useImperialConfig() {
return {
distanceSuffix: useImperial ? "mi" : "km",
speedSuffix: useImperial ? "mph" : "kmph",
- getFormattedDistance: useImperial ? getFormattedDistanceInMiles : getFormattedDistanceInKm,
- getFormattedSpeed: useImperial ? getMph : getKmph,
+ getFormattedDistance: useImperial ? (d) => formatForDisplay(convertDistance(d, true))
+ : (d) => formatForDisplay(convertDistance(d, false)),
+ getFormattedSpeed: useImperial ? (s) => formatForDisplay(convertSpeed(s, true))
+ : (s) => formatForDisplay(convertSpeed(s, false)),
}
}
diff --git a/www/js/control/AlertBar.jsx b/www/js/control/AlertBar.jsx
index c56db7ad2..fbac80056 100644
--- a/www/js/control/AlertBar.jsx
+++ b/www/js/control/AlertBar.jsx
@@ -4,7 +4,7 @@ import { Snackbar } from 'react-native-paper';
import { useTranslation } from "react-i18next";
import { SafeAreaView } from "react-native-safe-area-context";
-const AlertBar = ({visible, setVisible, messageKey, messageAddition}) => {
+const AlertBar = ({visible, setVisible, messageKey, messageAddition=undefined}) => {
const { t } = useTranslation();
const onDismissSnackBar = () => setVisible(false);
diff --git a/www/js/control/AppStatusModal.tsx b/www/js/control/AppStatusModal.tsx
index 64c63f720..34f5820bf 100644
--- a/www/js/control/AppStatusModal.tsx
+++ b/www/js/control/AppStatusModal.tsx
@@ -1,5 +1,5 @@
//component to view and manage permission settings
-import React, { useState, useEffect, useMemo } from "react";
+import React, { useState, useEffect, useMemo, useRef } from "react";
import { Modal, useWindowDimensions, ScrollView } from "react-native";
import { Dialog, Button, Text, useTheme } from 'react-native-paper';
import { useTranslation } from "react-i18next";
@@ -8,17 +8,16 @@ import useAppConfig from "../useAppConfig";
import useAppStateChange from "../useAppStateChange";
import ExplainPermissions from "../appstatus/ExplainPermissions";
import AlertBar from "./AlertBar";
+import { settingStyles } from "./ProfileSettings";
-const AppStatusModal = ({permitVis, setPermitVis, dialogStyle, settingsScope}) => {
+const AppStatusModal = ({permitVis, setPermitVis}) => {
const { t } = useTranslation();
const { colors } = useTheme();
const { appConfig, loading } = useAppConfig();
- console.log("settings scope in app status modal", settingsScope);
-
const { height: windowHeight } = useWindowDimensions();
- const [osver, setOsver] = useState(0);
- const [platform, setPlatform] = useState("");
+ const osver = useRef(0);
+ const platform = useRef("");
const [error, setError] = useState("");
const [errorVis, setErrorVis] = useState(false);
@@ -32,10 +31,12 @@ const AppStatusModal = ({permitVis, setPermitVis, dialogStyle, settingsScope}) =
let iconMap = (statusState) => statusState ? "check-circle-outline" : "alpha-x-circle-outline";
let colorMap = (statusState) => statusState ? colors.success : colors.danger;
- const overallStatus = useMemo(() => {
+ const overallStatus = useMemo(() => {
let status = true;
+ if (!checkList?.length) return undefined; // if checks not loaded yet, status is undetermined
checkList.forEach((lc) => {
- if(!lc.statusState){
+ console.debug('check in permission status for ' + lc.name + ':', lc.statusState);
+ if (lc.statusState === false) {
status = false;
}
})
@@ -97,17 +98,17 @@ const AppStatusModal = ({permitVis, setPermitVis, dialogStyle, settingsScope}) =
return checkOrFix(locPermissionsCheck, window['cordova'].plugins.BEMDataCollection.isValidLocationPermissions, false);
};
var androidSettingsDescTag = "intro.appstatus.locsettings.description.android-gte-9";
- if (osver < 9) {
+ if (osver.current < 9) {
androidSettingsDescTag = "intro.appstatus.locsettings.description.android-lt-9";
}
var androidPermDescTag = "intro.appstatus.locperms.description.android-gte-12";
- if(osver < 6) {
+ if(osver.current < 6) {
androidPermDescTag = 'intro.appstatus.locperms.description.android-lt-6';
- } else if (osver < 10) {
+ } else if (osver.current < 10) {
androidPermDescTag = "intro.appstatus.locperms.description.android-6-9";
- } else if (osver < 11) {
+ } else if (osver.current < 11) {
androidPermDescTag= "intro.appstatus.locperms.description.android-10";
- } else if (osver < 12) {
+ } else if (osver.current < 12) {
androidPermDescTag= "intro.appstatus.locperms.description.android-11";
}
console.log("description tags are "+androidSettingsDescTag+" "+androidPermDescTag);
@@ -115,14 +116,12 @@ const AppStatusModal = ({permitVis, setPermitVis, dialogStyle, settingsScope}) =
let locSettingsCheck = {
name: t("intro.appstatus.locsettings.name"),
desc: t(androidSettingsDescTag),
- statusState: false,
fix: fixSettings,
refresh: checkSettings
}
let locPermissionsCheck = {
name: t("intro.appstatus.locperms.name"),
desc: t(androidPermDescTag),
- statusState: false,
fix: fixPerms,
refresh: checkPerms
}
@@ -154,7 +153,7 @@ const AppStatusModal = ({permitVis, setPermitVis, dialogStyle, settingsScope}) =
};
var iOSSettingsDescTag = "intro.appstatus.locsettings.description.ios";
var iOSPermDescTag = "intro.appstatus.locperms.description.ios-gte-13";
- if(osver < 13) {
+ if(osver.current < 13) {
iOSPermDescTag = 'intro.appstatus.locperms.description.ios-lt-13';
}
console.log("description tags are "+iOSSettingsDescTag+" "+iOSPermDescTag);
@@ -162,14 +161,12 @@ const AppStatusModal = ({permitVis, setPermitVis, dialogStyle, settingsScope}) =
const locSettingsCheck = {
name: t("intro.appstatus.locsettings.name"),
desc: t(iOSSettingsDescTag),
- statusState: false,
fix: fixSettings,
refresh: checkSettings
};
const locPermissionsCheck = {
name: t("intro.appstatus.locperms.name"),
desc: t(iOSPermDescTag),
- statusState: false,
fix: fixPerms,
refresh: checkPerms
};
@@ -179,7 +176,7 @@ const AppStatusModal = ({permitVis, setPermitVis, dialogStyle, settingsScope}) =
}
function setupAndroidFitnessChecks() {
- if(osver >= 10){
+ if(osver.current >= 10){
let fixPerms = function() {
console.log("fix and refresh fitness permissions");
return checkOrFix(fitnessPermissionsCheck, window['cordova'].plugins.BEMDataCollection.fixFitnessPermissions,
@@ -270,10 +267,10 @@ const AppStatusModal = ({permitVis, setPermitVis, dialogStyle, settingsScope}) =
false);
};
var androidUnusedDescTag = "intro.appstatus.unusedapprestrict.description.android-disable-gte-13";
- if (osver == 12) {
+ if (osver.current == 12) {
androidUnusedDescTag= "intro.appstatus.unusedapprestrict.description.android-disable-12";
}
- else if (osver < 12) {
+ else if (osver.current < 12) {
androidUnusedDescTag= "intro.appstatus.unusedapprestrict.description.android-disable-lt-12";
}
let unusedAppsUnrestrictedCheck = {
@@ -298,9 +295,9 @@ const AppStatusModal = ({permitVis, setPermitVis, dialogStyle, settingsScope}) =
let overallFitnessName = t('intro.appstatus.overall-fitness-name-android');
let locExplanation = t('intro.appstatus.overall-loc-description');
- if(platform == "ios") {
+ if(platform.current == "ios") {
overallFitnessName = t('intro.appstatus.overall-fitness-name-ios');
- if(osver < 13) {
+ if(osver.current < 13) {
locExplanation = (t("intro.permissions.locationPermExplanation-ios-lt-13"));
} else {
locExplanation = (t("intro.permissions.locationPermExplanation-ios-gte-13"));
@@ -319,12 +316,13 @@ const AppStatusModal = ({permitVis, setPermitVis, dialogStyle, settingsScope}) =
}
function createChecklist(){
- if(platform == "android") {
+ console.debug("setting up checks, platform is " + platform.current + "and osver is " + osver.current);
+ if(platform.current == "android") {
setupAndroidLocChecks();
setupAndroidFitnessChecks();
setupAndroidNotificationChecks();
setupAndroidBackgroundRestrictionChecks();
- } else if (platform == "ios") {
+ } else if (platform.current == "ios") {
setupIOSLocChecks();
setupIOSFitnessChecks();
setupAndroidNotificationChecks();
@@ -369,17 +367,11 @@ const AppStatusModal = ({permitVis, setPermitVis, dialogStyle, settingsScope}) =
refreshAllChecks();
});
- //refresh when recompute message is broadcast
- settingsScope.$on("recomputeAppStatus", function() {
- console.log("PERMISSION CHECK: recomputing state");
- refreshAllChecks();
- });
-
//load when ready
useEffect(() => {
if (appConfig && window['device']?.platform) {
- setPlatform(window['device'].platform.toLowerCase());
- setOsver(window['device'].version.split(".")[0]);
+ platform.current = window['device'].platform.toLowerCase();
+ osver.current = window['device'].version.split(".")[0];
if(!haveSetText)
{
@@ -387,7 +379,7 @@ const AppStatusModal = ({permitVis, setPermitVis, dialogStyle, settingsScope}) =
setupPermissionText();
setHaveSetText(true);
}
- else{
+ if(!checkList || checkList.length == 0) {
console.log("setting up permissions");
createChecklist();
}
@@ -412,7 +404,7 @@ const AppStatusModal = ({permitVis, setPermitVis, dialogStyle, settingsScope}) =
setPermitVis(false)} transparent={true}>