diff --git a/src/.editorconfig b/src/.editorconfig deleted file mode 100644 index 59d9a3a3..00000000 --- a/src/.editorconfig +++ /dev/null @@ -1,16 +0,0 @@ -# Editor configuration, see https://editorconfig.org -root = true - -[*] -charset = utf-8 -indent_style = space -indent_size = 2 -insert_final_newline = true -trim_trailing_whitespace = true - -[*.ts] -quote_type = single - -[*.md] -max_line_length = off -trim_trailing_whitespace = false diff --git a/src/.eslintrc.js b/src/.eslintrc.js deleted file mode 100644 index 2691e522..00000000 --- a/src/.eslintrc.js +++ /dev/null @@ -1,64 +0,0 @@ -module.exports = { - "root": true, - "ignorePatterns": [ - "projects/**/*" - ], - "overrides": [ - { - "files": [ - "*.ts" - ], - "parserOptions": { - "project": [ - "tsconfig.json" - ], - "tsconfigRootDir": __dirname, - "createDefaultProgram": true - }, - "extends": [ - "eslint:recommended", - "plugin:@typescript-eslint/recommended", - "plugin:@angular-eslint/recommended", - "plugin:@angular-eslint/template/process-inline-templates", - "plugin:prettier/recommended" - ], - "rules": { - "@angular-eslint/directive-selector": [ - "error", - { - "type": "attribute", - "prefix": "dive", - "style": "camelCase" - } - ], - "@angular-eslint/component-selector": [ - "error", - { - "type": "element", - "prefix": "dive", - "style": "kebab-case" - } - ] - } - }, - { - "files": [ - "*.html" - ], - "extends": [ - "plugin:@angular-eslint/template/recommended", - "plugin:@angular-eslint/template/accessibility" - ], - "rules": {} - }, - { - "files": ["*.html"], - "excludedFiles": ["*inline-template-*.component.html"], - "extends": ["plugin:prettier/recommended"], - "rules": { - // NOTE: WE ARE OVERRIDING THE DEFAULT CONFIG TO ALWAYS SET THE PARSER TO ANGULAR - "prettier/prettier": ["error", { "parser": "angular" }] - } - } - ] -} diff --git a/src/.gitignore b/src/.gitignore new file mode 100644 index 00000000..3ac33ef0 --- /dev/null +++ b/src/.gitignore @@ -0,0 +1,5 @@ +# Next.js build artifacts +.next/ +out/ +node_modules/ + diff --git a/src/.prettierignore b/src/.prettierignore deleted file mode 100644 index b980335a..00000000 --- a/src/.prettierignore +++ /dev/null @@ -1,4 +0,0 @@ -build -coverage -e2e -node_modules \ No newline at end of file diff --git a/src/.prettierrc b/src/.prettierrc deleted file mode 100644 index 4ce7a55a..00000000 --- a/src/.prettierrc +++ /dev/null @@ -1,12 +0,0 @@ -{ - "tabWidth": 2, - "useTabs": false, - "singleQuote": true, - "semi": true, - "bracketSpacing": true, - "arrowParens": "avoid", - "trailingComma": "es5", - "bracketSameLine": true, - "printWidth": 160, - "htmlWhitespaceSensitivity": "ignore" -} diff --git a/src/angular.json b/src/angular.json deleted file mode 100644 index b6ce0f16..00000000 --- a/src/angular.json +++ /dev/null @@ -1,122 +0,0 @@ -{ - "$schema": "./node_modules/@angular/cli/lib/config/schema.json", - "version": 1, - "newProjectRoot": "projects", - "projects": { - "dive-intelligence": { - "projectType": "application", - "schematics": { - "@schematics/angular:component": { - "style": "scss" - } - }, - "root": "", - "sourceRoot": "src", - "prefix": "dive", - "architect": { - "build": { - "builder": "@angular-devkit/build-angular:application", - "options": { - "outputPath": { - "base": "dist/dive-intelligence" - }, - "index": "src/index.html", - "polyfills": [ - "zone.js" - ], - "tsConfig": "tsconfig.app.json", - "inlineStyleLanguage": "scss", - "assets": [ - "src/favicon.ico", - "src/assets" - ], - "styles": [ - "src/styles.scss" - ], - "scripts": [], - "browser": "src/main.ts" - }, - "configurations": { - "production": { - "budgets": [ - { - "type": "initial", - "maximumWarning": "500kb", - "maximumError": "5mb" - }, - { - "type": "anyComponentStyle", - "maximumWarning": "2kb", - "maximumError": "4kb" - } - ], - "outputHashing": "all", - "sourceMap": true - }, - "development": { - "optimization": false, - "extractLicenses": false, - "sourceMap": true, - "namedChunks": true - } - }, - "defaultConfiguration": "production" - }, - "serve": { - "builder": "@angular-devkit/build-angular:dev-server", - "configurations": { - "production": { - "buildTarget": "dive-intelligence:build:production" - }, - "development": { - "buildTarget": "dive-intelligence:build:development" - } - }, - "defaultConfiguration": "development" - }, - "extract-i18n": { - "builder": "@angular-devkit/build-angular:extract-i18n", - "options": { - "buildTarget": "dive-intelligence:build" - } - }, - "test": { - "builder": "@angular-devkit/build-angular:karma", - "options": { - "polyfills": [ - "zone.js", - "zone.js/testing" - ], - "tsConfig": "tsconfig.spec.json", - "inlineStyleLanguage": "scss", - "assets": [ - "src/favicon.ico", - "src/assets" - ], - "styles": [ - "src/styles.scss" - ], - "scripts": [], - "karmaConfig": "karma.conf.js", - "codeCoverage": true - } - }, - "lint": { - "builder": "@angular-eslint/builder:lint", - "options": { - "lintFilePatterns": [ - "src/**/*.ts", - "src/**/*.html" - ] - } - } - } - } - }, - "cli": { - "schematicCollections": [ - "@angular-eslint/schematics" - ], - "analytics": false - } -} diff --git a/src/app/change-depth/page.tsx b/src/app/change-depth/page.tsx new file mode 100644 index 00000000..5062a133 --- /dev/null +++ b/src/app/change-depth/page.tsx @@ -0,0 +1,64 @@ +'use client'; + +import React, { useState, useMemo } from 'react'; +import { useRouter } from 'next/navigation'; +import { Box, Button, TextField, Typography, Paper } from '@mui/material'; +import Link from 'next/link'; +import CurrentStats from '../components/CurrentStats'; +import NewDepthStats from '../components/NewDepthStats'; +import { useDivePlanner, useAddChangeDepthSegment } from '../contexts/DivePlannerContext'; + +export default function ChangeDepthPage() { + const router = useRouter(); + const { divePlanner } = useDivePlanner(); + const addChangeDepthSegment = useAddChangeDepthSegment(); + + const initialDepth = useMemo(() => { + if (divePlanner && divePlanner.getDiveSegments().length > 0) { + return divePlanner.getCurrentDepth(); + } + return 0; + }, [divePlanner]); + + const [newDepth, setNewDepth] = useState(initialDepth); + + const handleNewDepthChange = (e: React.ChangeEvent) => { + const value = Math.max(0, parseInt(e.target.value) || 0); + setNewDepth(value); + }; + + const handleSave = () => { + addChangeDepthSegment(newDepth); + router.push('/dive-overview'); + }; + + return ( + + + + Select new depth + + + + + + + + + + + + + ); +} diff --git a/src/app/change-gas/page.tsx b/src/app/change-gas/page.tsx new file mode 100644 index 00000000..2f2b0bae --- /dev/null +++ b/src/app/change-gas/page.tsx @@ -0,0 +1,62 @@ +'use client'; + +import React, { useState, useCallback, useMemo } from 'react'; +import { useRouter } from 'next/navigation'; +import { Box, Button, Typography } from '@mui/material'; +import Link from 'next/link'; +import CurrentStats from '../components/CurrentStats'; +import NewGasInput from '../components/NewGasInput'; +import NewGasStats from '../components/NewGasStats'; +import { BreathingGas } from '../dive-planner-service/BreathingGas'; +import { useDivePlanner, useAddChangeGasSegment } from '../contexts/DivePlannerContext'; + +export default function ChangeGasPage() { + const router = useRouter(); + const { divePlanner } = useDivePlanner(); + const addChangeGasSegment = useAddChangeGasSegment(); + + const initialGas = useMemo(() => { + if (divePlanner && divePlanner.getDiveSegments().length > 0) { + return divePlanner.getCurrentGas(); + } + return null; + }, [divePlanner]); + + const [newGas, setNewGas] = useState(initialGas); + + const handleNewGasSelected = useCallback((gas: BreathingGas) => { + setNewGas(gas); + }, []); + + const handleSave = () => { + if (newGas) { + addChangeGasSegment(newGas); + router.push('/dive-overview'); + } + }; + + if (!newGas) { + return Loading...; + } + + return ( + + + + Select new gas + + + + + + + + + + + ); +} diff --git a/src/app/components/CeilingChart.tsx b/src/app/components/CeilingChart.tsx new file mode 100644 index 00000000..c22b2eb1 --- /dev/null +++ b/src/app/components/CeilingChart.tsx @@ -0,0 +1,115 @@ +'use client'; + +import React, { useMemo } from 'react'; +import { Paper, Typography } from '@mui/material'; +import dynamic from 'next/dynamic'; +import { useDivePlanner } from '../contexts/DivePlannerContext'; + +const Plot = dynamic(() => import('react-plotly.js'), { ssr: false }); + +const PRIMARY_COLOR = '#3F51B5'; +const NEW_DEPTH_COLOR = 'red'; + +interface CeilingChartProps { + timeAtDepth: number; +} + +export default function CeilingChart({ timeAtDepth }: CeilingChartProps) { + const { divePlanner } = useDivePlanner(); + + // Guard against accessing dive data before dive is started + if (!divePlanner || divePlanner.getDiveSegments().length < 2) { + return ( + + No active dive. + + ); + } + + const currentDepth = divePlanner.getCurrentDepth(); + const currentGas = divePlanner.getCurrentGas(); + + const ceilingData = divePlanner.getCeilingChartData(currentDepth, currentGas); + const chartData = useMemo(() => { + const x = ceilingData.map(d => new Date(1970, 1, 1, 0, 0, d.time, 0)); + const y = ceilingData.map(d => d.ceiling); + + return [ + { + x, + y, + type: 'scatter' as const, + mode: 'lines' as const, + name: 'Ceiling', + line: { + color: PRIMARY_COLOR, + width: 2, + }, + hovertemplate: `%{y:.0f}m`, + }, + ]; + }, [ceilingData]); + + const getCeilingChartLayout = (): Partial => { + return { + autosize: true, + showlegend: false, + title: { + text: 'Ceiling Over Time', + y: 0.98, + }, + margin: { l: 35, r: 10, b: 30, t: 20, pad: 10 }, + xaxis: { + fixedrange: true, + tickformat: '%H:%M:%S', + }, + yaxis: { + fixedrange: true, + autorange: 'reversed', + }, + hovermode: 'x unified', + hoverlabel: { + bgcolor: 'rgba(200, 200, 200, 0.25)', + bordercolor: 'rgba(200, 200, 200, 0.4)', + }, + shapes: [ + { + type: 'line', + xref: 'x', + yref: 'paper', + x0: new Date(1970, 1, 1, 0, 0, timeAtDepth * 60, 0), + x1: new Date(1970, 1, 1, 0, 0, timeAtDepth * 60, 0), + y0: 0, + y1: 1, + line: { + color: NEW_DEPTH_COLOR, + width: 1, + dash: 'dot', + }, + }, + ], + }; + }; + + const getChartOptions = (): Partial => { + return { + responsive: true, + displaylogo: false, + displayModeBar: false, + autosizable: true, + scrollZoom: false, + editable: false, + }; + }; + + return ( + + + + ); +} diff --git a/src/app/components/CurrentStats.tsx b/src/app/components/CurrentStats.tsx new file mode 100644 index 00000000..d5fa5eb8 --- /dev/null +++ b/src/app/components/CurrentStats.tsx @@ -0,0 +1,114 @@ +'use client'; + +import React from 'react'; +import { Box, Typography, Paper, Tooltip } from '@mui/material'; +import WarningIcon from '@mui/icons-material/Warning'; +import ErrorIcon from '@mui/icons-material/Error'; +import { useDivePlanner } from '../contexts/DivePlannerContext'; +import { humanDuration } from '../utility/formatters'; +import { ceilingWithThreshold } from '../utility/utility'; + +export default function CurrentStats() { + const { divePlanner } = useDivePlanner(); + + // Guard against accessing dive data before dive is started + if (!divePlanner || divePlanner.getDiveSegments().length < 2) { + return ( + + No active dive. Please start a dive first. + + ); + } + + const currentDepth = divePlanner.getCurrentDepth(); + const currentGas = divePlanner.getCurrentGas(); + const currentCeiling = divePlanner.getCurrentCeiling(); + const instantCeiling = divePlanner.getCurrentInstantCeiling(); + const settings = divePlanner.settings; + + const getPO2 = () => currentGas.getPO2(currentDepth); + const getEND = () => ceilingWithThreshold(currentGas.getEND(currentDepth)); + + const getPO2WarningMessage = () => divePlanner.getPO2WarningMessage(getPO2()); + const getPO2ErrorMessage = () => divePlanner.getPO2ErrorMessage(getPO2()); + const getENDErrorMessage = () => divePlanner.getENDErrorMessage(getEND()); + + const getNoDecoLimit = () => { + const ndl = divePlanner.getNoDecoLimit(currentDepth, currentGas, 0); + if (ndl === undefined) { + return '> 5 hours'; + } + return humanDuration(ndl); + }; + + const hasCurrentPO2Warning = getPO2WarningMessage() !== undefined; + const hasCurrentPO2Error = getPO2ErrorMessage() !== undefined; + const hasCurrentENDError = getENDErrorMessage() !== undefined; + + return ( + + + + Current Depth: {currentDepth}m + + + + No Deco Limit: {getNoDecoLimit()} + + + + + Current Ceiling: {currentCeiling}m + + + + Current Gas: + + + + Max Depth (PO2): {currentGas.maxDepthPO2}m ({currentGas.maxDepthPO2Deco}m deco) + + + + + Max Depth (END): {currentGas.maxDepthEND}m + + + + + Min Depth (Hypoxia): {currentGas.minDepth}m + + + + + + PO2: {getPO2().toFixed(2)} + + + {hasCurrentPO2Warning && ( + + + + )} + {hasCurrentPO2Error && ( + + + + )} + + + + + END: {getEND()}m + + + {hasCurrentENDError && ( + + + + )} + + + + ); +} diff --git a/src/app/components/CustomGasInput.tsx b/src/app/components/CustomGasInput.tsx new file mode 100644 index 00000000..74c276b5 --- /dev/null +++ b/src/app/components/CustomGasInput.tsx @@ -0,0 +1,67 @@ +'use client'; + +import React, { useState, useCallback, useMemo } from 'react'; +import { TextField, Box, Tooltip } from '@mui/material'; +import { BreathingGas } from '../dive-planner-service/BreathingGas'; +import { useDivePlanner } from '../contexts/DivePlannerContext'; + +interface CustomGasInputProps { + disabled?: boolean; + onGasChanged: (gas: BreathingGas) => void; +} + +export default function CustomGasInput({ disabled = false, onGasChanged }: CustomGasInputProps) { + const { divePlanner } = useDivePlanner(); + const [oxygen, setOxygen] = useState(21); + const [helium, setHelium] = useState(0); + + const nitrogen = useMemo(() => 100 - oxygen - helium, [oxygen, helium]); + + const handleOxygenChange = useCallback((e: React.ChangeEvent) => { + const value = Math.max(0, Math.min(100, parseInt(e.target.value) || 0)); + setOxygen(value); + const newNitrogen = 100 - value - helium; + const gas = BreathingGas.create(value, helium, newNitrogen, divePlanner.settings); + onGasChanged(gas); + }, [helium, divePlanner.settings, onGasChanged]); + + const handleHeliumChange = useCallback((e: React.ChangeEvent) => { + const value = Math.max(0, Math.min(100, parseInt(e.target.value) || 0)); + setHelium(value); + const newNitrogen = 100 - oxygen - value; + const gas = BreathingGas.create(oxygen, value, newNitrogen, divePlanner.settings); + onGasChanged(gas); + }, [oxygen, divePlanner.settings, onGasChanged]); + + return ( + + + + + + + + ); +} diff --git a/src/app/components/DepthChart.tsx b/src/app/components/DepthChart.tsx new file mode 100644 index 00000000..10e7ed92 --- /dev/null +++ b/src/app/components/DepthChart.tsx @@ -0,0 +1,112 @@ +'use client'; + +import React from 'react'; +import { Paper, Typography } from '@mui/material'; +import dynamic from 'next/dynamic'; +import { useDivePlanner } from '../contexts/DivePlannerContext'; + +const Plot = dynamic(() => import('react-plotly.js'), { ssr: false }); + +const PRIMARY_COLOR = '#3F51B5'; // Indigo 500 +const ERROR_COLOR = 'red'; + +export default function DepthChart() { + const { divePlanner } = useDivePlanner(); + + const getShowGraphs = () => { + return divePlanner.getDiveSegments().length > 2; + }; + + const getDepthChartData = () => { + const depthData = divePlanner.getDepthChartData(); + const x = depthData.map(d => new Date(1970, 1, 1, 0, 0, d.time, 0)); + const y = depthData.map(d => d.depth); + const y2 = depthData.map(d => d.ceiling); + + return [ + { + x, + y, + type: 'scatter' as const, + mode: 'lines' as const, + name: 'Depth', + line: { + color: PRIMARY_COLOR, + width: 5, + }, + hovertemplate: `%{y:.0f}m`, + }, + { + x, + y: y2, + type: 'scatter' as const, + mode: 'lines' as const, + name: 'Ceiling', + fill: 'tozeroy' as const, + marker: { + color: ERROR_COLOR, + }, + line: { + dash: 'dot' as const, + width: 0, + }, + hovertemplate: `%{y:.0f}m`, + }, + ]; + }; + + const getDepthChartLayout = (): Partial => { + return { + autosize: true, + showlegend: false, + title: { + text: 'Depth vs Ceiling', + y: 0.98, + }, + margin: { l: 35, r: 10, b: 30, t: 20, pad: 10 }, + xaxis: { + fixedrange: true, + tickformat: '%H:%M:%S', + }, + yaxis: { + fixedrange: true, + autorange: 'reversed', + }, + hovermode: 'x unified', + hoverlabel: { + bgcolor: 'rgba(200, 200, 200, 0.4)', + bordercolor: 'rgba(200, 200, 200, 0.4)', + }, + }; + }; + + const getChartOptions = (): Partial => { + return { + responsive: true, + displaylogo: false, + displayModeBar: false, + autosizable: true, + scrollZoom: false, + editable: false, + }; + }; + + if (!getShowGraphs()) { + return ( + + Create dive segments to populate graphs + + ); + } + + return ( + + + + ); +} diff --git a/src/app/components/DivePlan.tsx b/src/app/components/DivePlan.tsx new file mode 100644 index 00000000..8abb6ab1 --- /dev/null +++ b/src/app/components/DivePlan.tsx @@ -0,0 +1,62 @@ +'use client'; + +import React from 'react'; +import Link from 'next/link'; +import { + Box, + List, + ListItem, + ListItemIcon, + ListItemText, + Fab, + Tooltip, + Paper, +} from '@mui/material'; +import Icon from '@mui/material/Icon'; +import HeightIcon from '@mui/icons-material/Height'; +import AirIcon from '@mui/icons-material/Air'; +import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; +import { useDivePlanner } from '../contexts/DivePlannerContext'; +import { colonDuration } from '../utility/formatters'; + +export default function DivePlan() { + const { divePlanner } = useDivePlanner(); + const planEvents = divePlanner.getDiveSegments(); + + return ( + + + + {planEvents.map((event, index) => ( + + + {event.Icon} + + } + /> + + ))} + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/app/components/DiveSettings.tsx b/src/app/components/DiveSettings.tsx new file mode 100644 index 00000000..a71b7503 --- /dev/null +++ b/src/app/components/DiveSettings.tsx @@ -0,0 +1,150 @@ +'use client'; + +import React, { useState } from 'react'; +import { + Box, + TextField, + FormControlLabel, + Switch, + Typography, + Tooltip, + Paper, +} from '@mui/material'; +import SettingsIcon from '@mui/icons-material/Settings'; +import { useDivePlanner } from '../contexts/DivePlannerContext'; + +export default function DiveSettings() { + const { divePlanner, updateSetting } = useDivePlanner(); + const settings = divePlanner.settings; + + const [ascentRate, setAscentRate] = useState(settings.ascentRate); + const [descentRate, setDescentRate] = useState(settings.descentRate); + const [isOxygenNarcotic, setIsOxygenNarcotic] = useState(settings.isOxygenNarcotic); + const [workingPO2Maximum, setWorkingPO2Maximum] = useState(settings.workingPO2Maximum); + const [decoPO2Maximum, setDecoPO2Maximum] = useState(settings.decoPO2Maximum); + const [pO2Minimum, setPO2Minimum] = useState(settings.pO2Minimum); + const [ENDErrorThreshold, setENDErrorThreshold] = useState(settings.ENDErrorThreshold); + + const handleDescentRateChange = (e: React.ChangeEvent) => { + const value = parseInt(e.target.value) || 1; + setDescentRate(value); + updateSetting('descentRate', value); + }; + + const handleAscentRateChange = (e: React.ChangeEvent) => { + const value = parseInt(e.target.value) || 1; + setAscentRate(value); + updateSetting('ascentRate', value); + }; + + const handleOxygenNarcoticChange = (e: React.ChangeEvent) => { + const value = e.target.checked; + setIsOxygenNarcotic(value); + updateSetting('isOxygenNarcotic', value); + }; + + const handleWorkingPO2MaximumChange = (e: React.ChangeEvent) => { + const value = parseFloat(e.target.value) || 1.4; + setWorkingPO2Maximum(value); + updateSetting('workingPO2Maximum', value); + }; + + const handleDecoPO2MaximumChange = (e: React.ChangeEvent) => { + const value = parseFloat(e.target.value) || 1.6; + setDecoPO2Maximum(value); + updateSetting('decoPO2Maximum', value); + }; + + const handlePO2MinimumChange = (e: React.ChangeEvent) => { + const value = parseFloat(e.target.value) || 0.18; + setPO2Minimum(value); + updateSetting('pO2Minimum', value); + }; + + const handleENDErrorThresholdChange = (e: React.ChangeEvent) => { + const value = parseInt(e.target.value) || 30; + setENDErrorThreshold(value); + updateSetting('ENDErrorThreshold', value); + }; + + return ( + + + + Dive Settings + + + + + + + + + + + + + + + + + + + + + + } + label="Is Oxygen Narcotic?" + /> + + + + + ); +} diff --git a/src/app/components/DiveSummary.tsx b/src/app/components/DiveSummary.tsx new file mode 100644 index 00000000..71f8b708 --- /dev/null +++ b/src/app/components/DiveSummary.tsx @@ -0,0 +1,26 @@ +'use client'; + +import React from 'react'; +import { Box, Typography, Paper } from '@mui/material'; +import { useDivePlanner } from '../contexts/DivePlannerContext'; +import { humanDuration } from '../utility/formatters'; + +export default function DiveSummary() { + const { divePlanner, updateTrigger } = useDivePlanner(); + + return ( + + + + Dive Duration: {humanDuration(divePlanner.getDiveDuration())} + + + Max Depth: {divePlanner.getMaxDepth()}m + + + Average Depth: {Math.round(divePlanner.getAverageDepth())}m + + + + ); +} diff --git a/src/app/components/ENDChart.tsx b/src/app/components/ENDChart.tsx new file mode 100644 index 00000000..a3293ebf --- /dev/null +++ b/src/app/components/ENDChart.tsx @@ -0,0 +1,108 @@ +'use client'; + +import React from 'react'; +import { Paper } from '@mui/material'; +import dynamic from 'next/dynamic'; +import { useDivePlanner } from '../contexts/DivePlannerContext'; + +const Plot = dynamic(() => import('react-plotly.js'), { ssr: false }); + +const PRIMARY_COLOR = '#3F51B5'; +const ERROR_COLOR = 'red'; + +export default function ENDChart() { + const { divePlanner, updateTrigger } = useDivePlanner(); + + const getShowGraphs = () => { + return divePlanner.getDiveSegments().length > 2; + }; + + const getENDChartData = () => { + const endData = divePlanner.getENDChartData(); + const x = endData.map(d => new Date(1970, 1, 1, 0, 0, d.time, 0)); + const y = endData.map(d => d.end); + const errorLimit = endData.map(d => d.errorLimit); + + return [ + { + x, + y, + type: 'scatter' as const, + mode: 'lines' as const, + name: 'END', + line: { + color: PRIMARY_COLOR, + width: 5, + }, + hovertemplate: `%{y:.0f}m`, + }, + { + x, + y: errorLimit, + type: 'scatter' as const, + mode: 'lines' as const, + name: 'Error Limit', + marker: { + color: ERROR_COLOR, + }, + line: { + dash: 'dot' as const, + width: 2, + }, + hovertemplate: `%{y:.0f}m`, + }, + ]; + }; + + const getENDChartLayout = (): Partial => { + return { + autosize: true, + showlegend: false, + title: { + text: 'Equivalent Narcotic Depth', + y: 0.98, + }, + margin: { l: 35, r: 10, b: 30, t: 20, pad: 10 }, + xaxis: { + fixedrange: true, + tickformat: '%H:%M:%S', + }, + yaxis: { + fixedrange: true, + rangemode: 'tozero', + zeroline: false, + }, + hovermode: 'x unified', + hoverlabel: { + bgcolor: 'rgba(200, 200, 200, 0.4)', + bordercolor: 'rgba(200, 200, 200, 0.4)', + }, + }; + }; + + const getChartOptions = (): Partial => { + return { + responsive: true, + displaylogo: false, + displayModeBar: false, + autosizable: true, + scrollZoom: false, + editable: false, + }; + }; + + if (!getShowGraphs()) { + return null; + } + + return ( + + + + ); +} diff --git a/src/app/components/ErrorList.tsx b/src/app/components/ErrorList.tsx new file mode 100644 index 00000000..cfaf16ca --- /dev/null +++ b/src/app/components/ErrorList.tsx @@ -0,0 +1,74 @@ +'use client'; + +import React from 'react'; +import { Box, Typography, Paper } from '@mui/material'; +import ErrorIcon from '@mui/icons-material/Error'; +import { useDivePlanner } from '../contexts/DivePlannerContext'; +import { humanDuration } from '../utility/formatters'; + +export default function ErrorList() { + const { divePlanner, updateTrigger } = useDivePlanner(); + + const getCeilingError = () => divePlanner.getCeilingError(); + const getPO2Error = () => divePlanner.getPO2Error(); + const getHypoxicError = () => divePlanner.getHypoxicError(); + const getENDError = () => divePlanner.getENDError(); + + const showCeilingError = () => getCeilingError().duration > 0; + const showPO2Error = () => getPO2Error().duration > 0; + const showHypoxicError = () => getHypoxicError().duration > 0; + const showENDError = () => getENDError().duration > 0; + + const hasErrors = showCeilingError() || showPO2Error() || showHypoxicError() || showENDError(); + + if (!hasErrors) { + return null; + } + + return ( + + + {showCeilingError() && ( + + + + + Exceeded ceiling by up to {getCeilingError().amount.toFixed(1)}m for {humanDuration(getCeilingError().duration)} + + + + )} + {showPO2Error() && ( + + + + + Exceeded safe PO2 for {humanDuration(getPO2Error().duration)}, up to PO2 = {getPO2Error().maxPO2.toFixed(2)} + + + + )} + {showHypoxicError() && ( + + + + + Hypoxic gas as low as {getHypoxicError().minPO2.toFixed(3)} for {humanDuration(getHypoxicError().duration)} + + + + )} + {showENDError() && ( + + + + + END as deep as {getENDError().end.toFixed(1)}m for {humanDuration(getENDError().duration)} + + + + )} + + + ); +} diff --git a/src/app/components/NewDepthStats.tsx b/src/app/components/NewDepthStats.tsx new file mode 100644 index 00000000..72b6f887 --- /dev/null +++ b/src/app/components/NewDepthStats.tsx @@ -0,0 +1,107 @@ +'use client'; + +import React, { useMemo } from 'react'; +import { Box, Typography, Paper, Tooltip } from '@mui/material'; +import WarningIcon from '@mui/icons-material/Warning'; +import ErrorIcon from '@mui/icons-material/Error'; +import { useDivePlanner } from '../contexts/DivePlannerContext'; +import { humanDuration } from '../utility/formatters'; +import { ceilingWithThreshold } from '../utility/utility'; + +interface NewDepthStatsProps { + newDepth: number; +} + +export default function NewDepthStats({ newDepth }: NewDepthStatsProps) { + const { divePlanner } = useDivePlanner(); + const settings = divePlanner.settings; + + // Guard against accessing dive data before dive is started + if (!divePlanner || divePlanner.getDiveSegments().length < 2) { + return ( + + No active dive. + + ); + } + + const currentGas = divePlanner.getCurrentGas(); + const currentDepth = divePlanner.getCurrentDepth(); + const travelTime = divePlanner.getTravelTime(newDepth); + const isDescent = newDepth >= currentDepth; + const PO2 = currentGas.getPO2(newDepth); + const END = ceilingWithThreshold(currentGas.getEND(newDepth)); + + const getPO2WarningMessage = () => divePlanner.getPO2WarningMessage(PO2); + const getPO2ErrorMessage = () => divePlanner.getPO2ErrorMessage(PO2); + const getENDErrorMessage = () => divePlanner.getENDErrorMessage(END); + + const getNoDecoLimit = () => { + const ndl = divePlanner.getNoDecoLimit(newDepth, currentGas, 0); + if (ndl === undefined) { + return '> 5 hours'; + } + return humanDuration(ndl); + }; + + const hasPO2Warning = getPO2WarningMessage() !== undefined; + const hasPO2Error = getPO2ErrorMessage() !== undefined; + const hasENDError = getENDErrorMessage() !== undefined; + const ceiling = divePlanner.getNewCeiling(newDepth, 0); + const instantCeiling = divePlanner.getNewInstantCeiling(newDepth, 0); + + return ( + + + {isDescent ? ( + + Descent Time: {humanDuration(travelTime)} @ {settings.descentRate}m/min + + ) : ( + + Ascent Time: {humanDuration(travelTime)} @ {settings.ascentRate}m/min + + )} + + + + PO2: {PO2.toFixed(2)} + + + {hasPO2Warning && ( + + + + )} + {hasPO2Error && ( + + + + )} + + + + + END: {END}m + + + {hasENDError && ( + + + + )} + + + + No Deco Limit: {getNoDecoLimit()} + + + + + Ceiling: {ceiling}m + + + + + ); +} diff --git a/src/app/components/NewGasInput.tsx b/src/app/components/NewGasInput.tsx new file mode 100644 index 00000000..68e7d2e4 --- /dev/null +++ b/src/app/components/NewGasInput.tsx @@ -0,0 +1,137 @@ +'use client'; + +import React, { useState, useCallback } from 'react'; +import { + Box, + Radio, + RadioGroup, + FormControlLabel, + FormControl, + Select, + MenuItem, + Tooltip, + Paper, + Typography, +} from '@mui/material'; +import { SelectChangeEvent } from '@mui/material/Select'; +import CustomGasInput from './CustomGasInput'; +import { BreathingGas } from '../dive-planner-service/BreathingGas'; +import { useDivePlanner } from '../contexts/DivePlannerContext'; + +interface NewGasInputProps { + onNewGasSelected: (gas: BreathingGas) => void; +} + +export default function NewGasInput({ onNewGasSelected }: NewGasInputProps) { + const { divePlanner } = useDivePlanner(); + const settings = divePlanner.settings; + + const [newGasSelectedOption, setNewGasSelectedOption] = useState('current'); + const [standardGasIndex, setStandardGasIndex] = useState(''); + const [customGas, setCustomGas] = useState( + BreathingGas.create(21, 0, 79, settings) + ); + + const currentGas = divePlanner.getCurrentGas(); + const StandardGases = divePlanner.getStandardGases(); + const optimalGas = divePlanner.getOptimalDecoGas(divePlanner.getCurrentDepth()); + + const calculateNewGas = useCallback((option: string, stdGasIndex: number | '', custGas: BreathingGas) => { + let newGas = currentGas; + + if (option === 'standard' && stdGasIndex !== '' && StandardGases[stdGasIndex]) { + newGas = StandardGases[stdGasIndex]; + } else if (option === 'custom') { + newGas = custGas; + } else if (option === 'optimal') { + newGas = optimalGas; + } + + onNewGasSelected(newGas); + }, [currentGas, StandardGases, optimalGas, onNewGasSelected]); + + const handleGasTypeChange = (event: React.ChangeEvent) => { + const option = event.target.value; + setNewGasSelectedOption(option); + calculateNewGas(option, standardGasIndex, customGas); + }; + + const handleStandardGasChange = (event: SelectChangeEvent) => { + const index = event.target.value as number; + setStandardGasIndex(index); + calculateNewGas(newGasSelectedOption, index, customGas); + }; + + const handleCustomGasChanged = useCallback((gas: BreathingGas) => { + setCustomGas(gas); + if (newGasSelectedOption === 'custom') { + onNewGasSelected(gas); + } + }, [newGasSelectedOption, onNewGasSelected]); + + const isStandardGasDisabled = newGasSelectedOption !== 'standard'; + const isCustomGasDisabled = newGasSelectedOption !== 'custom'; + + return ( + + + + + + } + label={ + +
Current Gas
+ +
+ } + /> + + } + label={ + +
Optimal Deco Gas
+ +
+ } + /> +
+
+ + } label="Standard Gas" /> + + + + } label="Custom gas" /> + + +
+
+
+
+ ); +} diff --git a/src/app/components/NewGasStats.tsx b/src/app/components/NewGasStats.tsx new file mode 100644 index 00000000..66c6b067 --- /dev/null +++ b/src/app/components/NewGasStats.tsx @@ -0,0 +1,108 @@ +'use client'; + +import React from 'react'; +import { Box, Typography, Paper, Tooltip } from '@mui/material'; +import WarningIcon from '@mui/icons-material/Warning'; +import ErrorIcon from '@mui/icons-material/Error'; +import { BreathingGas } from '../dive-planner-service/BreathingGas'; +import { useDivePlanner } from '../contexts/DivePlannerContext'; +import { humanDuration } from '../utility/formatters'; +import { ceilingWithThreshold } from '../utility/utility'; + +interface NewGasStatsProps { + newGas: BreathingGas; +} + +export default function NewGasStats({ newGas }: NewGasStatsProps) { + const { divePlanner } = useDivePlanner(); + const settings = divePlanner.settings; + + // Guard against accessing dive data before dive is started + if (!divePlanner || divePlanner.getDiveSegments().length < 2) { + return ( + + No active dive. + + ); + } + + const currentDepth = divePlanner.getCurrentDepth(); + const newGasPO2 = newGas.getPO2(currentDepth); + const newGasEND = ceilingWithThreshold(newGas.getEND(currentDepth)); + + const getPO2WarningMessage = () => divePlanner.getPO2WarningMessage(newGasPO2); + const getPO2ErrorMessage = () => divePlanner.getPO2ErrorMessage(newGasPO2); + const getENDErrorMessage = () => divePlanner.getENDErrorMessage(newGasEND); + + const getNoDecoLimit = () => { + const ndl = divePlanner.getNoDecoLimit(currentDepth, newGas, 0); + if (ndl === undefined) { + return '> 5 hours'; + } + return humanDuration(ndl); + }; + + const hasNewGasPO2Warning = getPO2WarningMessage() !== undefined; + const hasNewGasPO2Error = getPO2ErrorMessage() !== undefined; + const hasNewGasENDError = getENDErrorMessage() !== undefined; + + return ( + + + + + + + PO2: {newGasPO2.toFixed(2)} + + + {hasNewGasPO2Warning && ( + + + + )} + {hasNewGasPO2Error && ( + + + + )} + + + + + END: {newGasEND}m + + + {hasNewGasENDError && ( + + + + )} + + + + No Deco Limit: {getNoDecoLimit()} + + + + + + + Max Depth (PO2): {newGas.maxDepthPO2}m ({newGas.maxDepthPO2Deco}m deco) + + + + + Max Depth (END): {newGas.maxDepthEND}m + + + + + Min Depth (Hypoxia): {newGas.minDepth}m + + + + + + ); +} diff --git a/src/app/components/NewTimeStats.tsx b/src/app/components/NewTimeStats.tsx new file mode 100644 index 00000000..4ff9d890 --- /dev/null +++ b/src/app/components/NewTimeStats.tsx @@ -0,0 +1,109 @@ +'use client'; + +import React from 'react'; +import { Box, Typography, Paper, Tooltip, Divider } from '@mui/material'; +import { useDivePlanner } from '../contexts/DivePlannerContext'; +import { humanDuration } from '../utility/formatters'; +import { ceilingWithThreshold } from '../utility/utility'; +import { BreathingGas } from '../dive-planner-service/BreathingGas'; + +interface NewTimeStatsProps { + timeAtDepth: number; +} + +export default function NewTimeStats({ timeAtDepth }: NewTimeStatsProps) { + const { divePlanner } = useDivePlanner(); + + // Guard against accessing dive data before dive is started + if (!divePlanner || divePlanner.getDiveSegments().length < 2) { + return ( + + No active dive. + + ); + } + + const currentDepth = divePlanner.getCurrentDepth(); + const totalDiveDuration = divePlanner.getCurrentDiveTime() + timeAtDepth * 60; + const ceiling = divePlanner.getNewCeiling(currentDepth, timeAtDepth * 60); + const instantCeiling = divePlanner.getNewInstantCeiling(currentDepth, timeAtDepth * 60); + + const getNoDecoLimit = () => { + const ndl = divePlanner.getNoDecoLimit(currentDepth, divePlanner.getCurrentGas(), timeAtDepth * 60); + if (ndl === undefined) { + return '> 5 hours'; + } + return humanDuration(ndl); + }; + + const getDecoMilestones = () => { + const ceilingData = divePlanner.getCeilingChartData(currentDepth, divePlanner.getCurrentGas()); + const ceilingValues = ceilingData.map(d => ceilingWithThreshold(d.ceiling)); + const standardGases = divePlanner.getStandardGases(); + const decoGases = standardGases.filter(g => g.maxDecoDepth < ceilingValues[0]); + const milestones: { duration: number; gas: string; depth: number; tooltip: string }[] = []; + let decoComplete = 0; + + for (let t = 0; t < ceilingValues.length && decoComplete === 0; t++) { + const gasToRemove: BreathingGas[] = []; + for (const gas of decoGases) { + if (ceilingWithThreshold(ceilingValues[t]) <= gas.maxDecoDepth) { + const tooltip = `If you spend ${humanDuration(t)} at ${currentDepth}m, the ceiling will rise to ${ceilingValues[t]}m which allow you to ascend and switch to ${gas.name}`; + milestones.push({ duration: t, gas: gas.name, depth: ceilingValues[t], tooltip: tooltip }); + gasToRemove.push(gas); + } + } + + for (const gas of gasToRemove) { + decoGases.splice(decoGases.indexOf(gas), 1); + } + + if (ceilingValues[t] === 0 && decoComplete === 0) { + decoComplete = t; + } + } + + if (ceilingValues[0] > 0 && decoComplete > 0) { + const tooltip = `If you spend ${humanDuration(decoComplete)} at ${currentDepth}m your decompression will be complete and you can ascend directly to the surface`; + milestones.push({ duration: decoComplete, gas: 'Deco complete', depth: 0, tooltip: tooltip }); + } + + return milestones; + }; + + const decoMilestones = getDecoMilestones(); + + return ( + + + + + No Deco Limit: {getNoDecoLimit()} + + + + + Ceiling: {ceiling}m + + + + + Total Dive Duration: {humanDuration(totalDiveDuration)} + + + {decoMilestones.length > 0 && ( + <> + + {decoMilestones.map((milestone, index) => ( + + + {humanDuration(milestone.duration)}: {milestone.gas} @ {milestone.depth}m + + + ))} + + )} + + + ); +} diff --git a/src/app/components/PO2Chart.tsx b/src/app/components/PO2Chart.tsx new file mode 100644 index 00000000..21162d35 --- /dev/null +++ b/src/app/components/PO2Chart.tsx @@ -0,0 +1,141 @@ +'use client'; + +import React from 'react'; +import { Paper, Typography } from '@mui/material'; +import dynamic from 'next/dynamic'; +import { useDivePlanner } from '../contexts/DivePlannerContext'; + +const Plot = dynamic(() => import('react-plotly.js'), { ssr: false }); + +const PRIMARY_COLOR = '#3F51B5'; +const ERROR_COLOR = 'red'; +const WARNING_COLOR = 'orange'; + +export default function PO2Chart() { + const { divePlanner, updateTrigger } = useDivePlanner(); + + const getShowGraphs = () => { + return divePlanner.getDiveSegments().length > 2; + }; + + const getPO2ChartData = () => { + const pO2Data = divePlanner.getPO2ChartData(); + const x = pO2Data.map(d => new Date(1970, 1, 1, 0, 0, d.time, 0)); + const y = pO2Data.map(d => d.pO2); + const limit = pO2Data.map(d => d.limit); + const decoLimit = pO2Data.map(d => d.decoLimit); + const minLimit = pO2Data.map(d => d.min); + + return [ + { + x, + y, + type: 'scatter' as const, + mode: 'lines' as const, + name: 'PO2', + line: { + color: PRIMARY_COLOR, + width: 5, + }, + hovertemplate: `%{y:.2f}`, + }, + { + x, + y: minLimit, + type: 'scatter' as const, + mode: 'lines' as const, + name: 'Min Limit (Hypoxia)', + marker: { + color: ERROR_COLOR, + }, + line: { + dash: 'dot' as const, + width: 2, + }, + hovertemplate: `%{y:.2f}`, + }, + { + x, + y: limit, + type: 'scatter' as const, + mode: 'lines' as const, + name: 'Working Limit', + marker: { + color: WARNING_COLOR, + }, + line: { + dash: 'dot' as const, + width: 2, + }, + hovertemplate: `%{y:.2f}`, + }, + { + x, + y: decoLimit, + type: 'scatter' as const, + mode: 'lines' as const, + name: 'Deco Limit', + marker: { + color: ERROR_COLOR, + }, + line: { + dash: 'dot' as const, + width: 2, + }, + hovertemplate: `%{y:.2f}`, + }, + ]; + }; + + const getPO2ChartLayout = (): Partial => { + return { + autosize: true, + showlegend: false, + title: { + text: 'Gas PO2', + y: 0.98, + }, + margin: { l: 35, r: 10, b: 30, t: 20, pad: 10 }, + xaxis: { + fixedrange: true, + tickformat: '%H:%M:%S', + }, + yaxis: { + fixedrange: true, + rangemode: 'tozero', + zeroline: false, + }, + hovermode: 'x unified', + hoverlabel: { + bgcolor: 'rgba(200, 200, 200, 0.4)', + bordercolor: 'rgba(200, 200, 200, 0.4)', + }, + }; + }; + + const getChartOptions = (): Partial => { + return { + responsive: true, + displaylogo: false, + displayModeBar: false, + autosizable: true, + scrollZoom: false, + editable: false, + }; + }; + + if (!getShowGraphs()) { + return null; + } + + return ( + + + + ); +} diff --git a/src/app/components/StandardGasList.tsx b/src/app/components/StandardGasList.tsx new file mode 100644 index 00000000..38f928df --- /dev/null +++ b/src/app/components/StandardGasList.tsx @@ -0,0 +1,67 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { + List, + ListItemButton, + ListItemText, + Tooltip, + Paper, +} from '@mui/material'; +import { BreathingGas } from '../dive-planner-service/BreathingGas'; +import { useDivePlanner } from '../contexts/DivePlannerContext'; + +interface StandardGasListProps { + disabled?: boolean; + onGasSelected: (gas: BreathingGas) => void; +} + +export default function StandardGasList({ disabled = false, onGasSelected }: StandardGasListProps) { + const { divePlanner } = useDivePlanner(); + const [standardGases, setStandardGases] = useState([]); + const [selectedGas, setSelectedGas] = useState(null); + + useEffect(() => { + const gases = divePlanner.getStandardGases(); + setStandardGases(gases); + if (gases.length > 0 && !selectedGas) { + setSelectedGas(gases[0]); + } + }, [divePlanner, selectedGas]); + + const handleGasChange = (gas: BreathingGas) => { + if (!disabled) { + setSelectedGas(gas); + onGasSelected(gas); + } + }; + + const getGasTooltip = (gas: BreathingGas): string => { + return `Max Depth (PO2): ${gas.maxDepthPO2}m (${gas.maxDepthPO2Deco}m deco)\nMax Depth (END): ${gas.maxDepthEND}m\nMin Depth (Hypoxia): ${gas.minDepth}m`; + }; + + return ( + + + {standardGases.map((gas, index) => ( + {getGasTooltip(gas)}} + placement="left" + > + handleGasChange(gas)} + disabled={disabled} + > + } + /> + + + ))} + + + ); +} diff --git a/src/app/components/StartGasStats.tsx b/src/app/components/StartGasStats.tsx new file mode 100644 index 00000000..b4df4326 --- /dev/null +++ b/src/app/components/StartGasStats.tsx @@ -0,0 +1,49 @@ +'use client'; + +import React from 'react'; +import { Box, Tooltip, Typography, Paper } from '@mui/material'; +import ErrorIcon from '@mui/icons-material/Error'; +import { BreathingGas } from '../dive-planner-service/BreathingGas'; +import { useDivePlanner } from '../contexts/DivePlannerContext'; + +interface StartGasStatsProps { + gas: BreathingGas; +} + +export default function StartGasStats({ gas }: StartGasStatsProps) { + const { divePlanner } = useDivePlanner(); + const settings = divePlanner.settings; + + const isMinDepthError = () => { + return gas.minDepth > 0; + }; + + return ( + + + + + Max Depth (PO2): {gas.maxDepthPO2}m ({gas.maxDepthPO2Deco}m deco) + + + + + Max Depth (END): {gas.maxDepthEND}m + + + + + + Min Depth (Hypoxia): {gas.minDepth}m + + + {isMinDepthError() && ( + + + + )} + + + + ); +} diff --git a/src/app/components/TissuesCeilingChart.tsx b/src/app/components/TissuesCeilingChart.tsx new file mode 100644 index 00000000..9431177b --- /dev/null +++ b/src/app/components/TissuesCeilingChart.tsx @@ -0,0 +1,106 @@ +'use client'; + +import React from 'react'; +import { Paper } from '@mui/material'; +import dynamic from 'next/dynamic'; +import { useDivePlanner } from '../contexts/DivePlannerContext'; + +const Plot = dynamic(() => import('react-plotly.js'), { ssr: false }); + +const PRIMARY_COLOR = '#3F51B5'; + +export default function TissuesCeilingChart() { + const { divePlanner, updateTrigger } = useDivePlanner(); + + const getShowGraphs = () => { + return divePlanner.getDiveSegments().length > 2; + }; + + const getTissuesCeilingChartData = () => { + const ceilingData = divePlanner.getTissuesCeilingChartData(); + const x = ceilingData.map(d => new Date(1970, 1, 1, 0, 0, d.time, 0)); + + const ceilingTraces: Plotly.Data[] = []; + + for (let i = 1; i <= 16; i++) { + ceilingTraces.push({ + x, + y: ceilingData.map(d => d.tissuesCeiling[i - 1]), + type: 'scatter' as const, + mode: 'lines' as const, + name: `Tissue ${i} Ceiling`, + line: { + width: 2, + }, + hovertemplate: `%{y:.0f}m`, + }); + } + + return [ + { + x, + y: ceilingData.map(d => d.depth), + type: 'scatter' as const, + mode: 'lines' as const, + name: 'Depth', + line: { + color: PRIMARY_COLOR, + width: 5, + }, + hovertemplate: `%{y:.0f}m`, + }, + ...ceilingTraces, + ]; + }; + + const getTissuesCeilingChartLayout = (): Partial => { + return { + autosize: true, + showlegend: false, + title: { + text: 'Tissues Ceiling vs Depth', + y: 0.98, + }, + margin: { l: 35, r: 10, b: 30, t: 20, pad: 10 }, + xaxis: { + fixedrange: true, + tickformat: '%H:%M:%S', + }, + yaxis: { + fixedrange: true, + autorange: 'reversed', + }, + hovermode: 'x unified', + hoverlabel: { + bgcolor: 'rgba(200, 200, 200, 0.4)', + bordercolor: 'rgba(200, 200, 200, 0.4)', + }, + }; + }; + + const getChartOptions = (): Partial => { + return { + responsive: true, + displaylogo: false, + displayModeBar: false, + autosizable: true, + scrollZoom: false, + editable: false, + }; + }; + + if (!getShowGraphs()) { + return null; + } + + return ( + + + + ); +} diff --git a/src/app/components/TissuesPHeChart.tsx b/src/app/components/TissuesPHeChart.tsx new file mode 100644 index 00000000..9f219e63 --- /dev/null +++ b/src/app/components/TissuesPHeChart.tsx @@ -0,0 +1,107 @@ +'use client'; + +import React from 'react'; +import { Paper } from '@mui/material'; +import dynamic from 'next/dynamic'; +import { useDivePlanner } from '../contexts/DivePlannerContext'; + +const Plot = dynamic(() => import('react-plotly.js'), { ssr: false }); + +const PRIMARY_COLOR = '#3F51B5'; + +export default function TissuesPHeChart() { + const { divePlanner, updateTrigger } = useDivePlanner(); + + const getShowGraphs = () => { + return divePlanner.getDiveSegments().length > 2; + }; + + const getTissuesPHeChartData = () => { + const pheData = divePlanner.getTissuesPHeChartData(); + const x = pheData.map(d => new Date(1970, 1, 1, 0, 0, d.time, 0)); + + const tissueTraces: Plotly.Data[] = []; + + for (let i = 1; i <= 16; i++) { + tissueTraces.push({ + x, + y: pheData.map(d => d.tissuesPHe[i - 1]), + type: 'scatter' as const, + mode: 'lines' as const, + name: `Tissue ${i} PHe`, + line: { + width: 2, + }, + hovertemplate: `%{y:.2f}`, + }); + } + + return [ + { + x, + y: pheData.map(d => d.gasPHe), + type: 'scatter' as const, + mode: 'lines' as const, + name: 'Gas PHe', + line: { + color: PRIMARY_COLOR, + width: 5, + }, + hovertemplate: `%{y:.2f}`, + }, + ...tissueTraces, + ]; + }; + + const getTissuesPHeChartLayout = (): Partial => { + return { + autosize: true, + showlegend: false, + title: { + text: 'Tissues PHe', + y: 0.98, + }, + margin: { l: 35, r: 10, b: 30, t: 20, pad: 10 }, + xaxis: { + fixedrange: true, + tickformat: '%H:%M:%S', + }, + yaxis: { + fixedrange: true, + rangemode: 'tozero', + zeroline: false, + }, + hovermode: 'x unified', + hoverlabel: { + bgcolor: 'rgba(200, 200, 200, 0.4)', + bordercolor: 'rgba(200, 200, 200, 0.4)', + }, + }; + }; + + const getChartOptions = (): Partial => { + return { + responsive: true, + displaylogo: false, + displayModeBar: false, + autosizable: true, + scrollZoom: false, + editable: false, + }; + }; + + if (!getShowGraphs()) { + return null; + } + + return ( + + + + ); +} diff --git a/src/app/components/TissuesPN2Chart.tsx b/src/app/components/TissuesPN2Chart.tsx new file mode 100644 index 00000000..50265043 --- /dev/null +++ b/src/app/components/TissuesPN2Chart.tsx @@ -0,0 +1,107 @@ +'use client'; + +import React from 'react'; +import { Paper } from '@mui/material'; +import dynamic from 'next/dynamic'; +import { useDivePlanner } from '../contexts/DivePlannerContext'; + +const Plot = dynamic(() => import('react-plotly.js'), { ssr: false }); + +const PRIMARY_COLOR = '#3F51B5'; + +export default function TissuesPN2Chart() { + const { divePlanner, updateTrigger } = useDivePlanner(); + + const getShowGraphs = () => { + return divePlanner.getDiveSegments().length > 2; + }; + + const getTissuesPN2ChartData = () => { + const pn2Data = divePlanner.getTissuesPN2ChartData(); + const x = pn2Data.map(d => new Date(1970, 1, 1, 0, 0, d.time, 0)); + + const tissueTraces: Plotly.Data[] = []; + + for (let i = 1; i <= 16; i++) { + tissueTraces.push({ + x, + y: pn2Data.map(d => d.tissuesPN2[i - 1]), + type: 'scatter' as const, + mode: 'lines' as const, + name: `Tissue ${i} PN2`, + line: { + width: 2, + }, + hovertemplate: `%{y:.2f}`, + }); + } + + return [ + { + x, + y: pn2Data.map(d => d.gasPN2), + type: 'scatter' as const, + mode: 'lines' as const, + name: 'Gas PN2', + line: { + color: PRIMARY_COLOR, + width: 5, + }, + hovertemplate: `%{y:.2f}`, + }, + ...tissueTraces, + ]; + }; + + const getTissuesPN2ChartLayout = (): Partial => { + return { + autosize: true, + showlegend: false, + title: { + text: 'Tissues PN2', + y: 0.98, + }, + margin: { l: 35, r: 10, b: 30, t: 20, pad: 10 }, + xaxis: { + fixedrange: true, + tickformat: '%H:%M:%S', + }, + yaxis: { + fixedrange: true, + rangemode: 'tozero', + zeroline: false, + }, + hovermode: 'x unified', + hoverlabel: { + bgcolor: 'rgba(200, 200, 200, 0.4)', + bordercolor: 'rgba(200, 200, 200, 0.4)', + }, + }; + }; + + const getChartOptions = (): Partial => { + return { + responsive: true, + displaylogo: false, + displayModeBar: false, + autosizable: true, + scrollZoom: false, + editable: false, + }; + }; + + if (!getShowGraphs()) { + return null; + } + + return ( + + + + ); +} diff --git a/src/app/contexts/DivePlannerContext.tsx b/src/app/contexts/DivePlannerContext.tsx new file mode 100644 index 00000000..e718dbbd --- /dev/null +++ b/src/app/contexts/DivePlannerContext.tsx @@ -0,0 +1,117 @@ +'use client'; + +import React, { createContext, useContext, useState, useMemo, useCallback, ReactNode } from 'react'; +import { DivePlannerService } from '../dive-planner-service/DivePlannerService'; +import { DiveSettingsService } from '../dive-planner-service/DiveSettings.service'; +import { BreathingGas } from '../dive-planner-service/BreathingGas'; + +interface DivePlannerContextType { + divePlanner: DivePlannerService; + updateTrigger: number; + forceUpdate: () => void; + updateSetting: (key: string, value: number | boolean) => void; +} + +const DivePlannerContext = createContext(undefined); + +export function DivePlannerProvider({ children }: { children: ReactNode }) { + const [updateTrigger, setUpdateTrigger] = useState(0); + + const divePlanner = useMemo(() => { + const settings = new DiveSettingsService(); + return new DivePlannerService(settings); + }, []); + + const forceUpdate = useCallback(() => { + setUpdateTrigger(prev => prev + 1); + }, []); + + const updateSetting = useCallback((key: string, value: number | boolean) => { + const settings = divePlanner.settings; + switch (key) { + case 'descentRate': + settings.descentRate = value as number; + break; + case 'ascentRate': + settings.ascentRate = value as number; + break; + case 'isOxygenNarcotic': + settings.isOxygenNarcotic = value as boolean; + break; + case 'workingPO2Maximum': + settings.workingPO2Maximum = value as number; + break; + case 'decoPO2Maximum': + settings.decoPO2Maximum = value as number; + break; + case 'pO2Minimum': + settings.pO2Minimum = value as number; + break; + case 'ENDErrorThreshold': + settings.ENDErrorThreshold = value as number; + break; + } + setUpdateTrigger(prev => prev + 1); + }, [divePlanner]); + + const value = useMemo(() => ({ + divePlanner, + updateTrigger, + forceUpdate, + updateSetting, + }), [divePlanner, updateTrigger, forceUpdate, updateSetting]); + + return ( + + {children} + + ); +} + +export function useDivePlanner() { + const context = useContext(DivePlannerContext); + if (context === undefined) { + throw new Error('useDivePlanner must be used within a DivePlannerProvider'); + } + return context; +} + +// Hook for starting a dive +export function useStartDive() { + const { divePlanner, forceUpdate } = useDivePlanner(); + + return useCallback((startGas: BreathingGas) => { + divePlanner.startDive(startGas); + forceUpdate(); + }, [divePlanner, forceUpdate]); +} + +// Hook for adding a change depth segment +export function useAddChangeDepthSegment() { + const { divePlanner, forceUpdate } = useDivePlanner(); + + return useCallback((newDepth: number) => { + divePlanner.addChangeDepthSegment(newDepth); + forceUpdate(); + }, [divePlanner, forceUpdate]); +} + +// Hook for adding a change gas segment +export function useAddChangeGasSegment() { + const { divePlanner, forceUpdate } = useDivePlanner(); + + return useCallback((newGas: BreathingGas) => { + divePlanner.addChangeGasSegment(newGas); + forceUpdate(); + }, [divePlanner, forceUpdate]); +} + +// Hook for adding a maintain depth segment +export function useAddMaintainDepthSegment() { + const { divePlanner, forceUpdate } = useDivePlanner(); + + return useCallback((timeAtDepth: number) => { + divePlanner.addMaintainDepthSegment(timeAtDepth); + forceUpdate(); + }, [divePlanner, forceUpdate]); +} diff --git a/src/app/dive-overview/page.tsx b/src/app/dive-overview/page.tsx new file mode 100644 index 00000000..4c0ddd24 --- /dev/null +++ b/src/app/dive-overview/page.tsx @@ -0,0 +1,35 @@ +'use client'; + +import React from 'react'; +import { Box } from '@mui/material'; +import DivePlan from '../components/DivePlan'; +import DiveSummary from '../components/DiveSummary'; +import ErrorList from '../components/ErrorList'; +import DepthChart from '../components/DepthChart'; +import PO2Chart from '../components/PO2Chart'; +import ENDChart from '../components/ENDChart'; +import TissuesCeilingChart from '../components/TissuesCeilingChart'; +import TissuesPN2Chart from '../components/TissuesPN2Chart'; +import TissuesPHeChart from '../components/TissuesPHeChart'; + +export default function DiveOverviewPage() { + return ( + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/src/app/dive-planner-service/BreathingGas.ts b/src/app/dive-planner-service/BreathingGas.ts similarity index 100% rename from src/src/app/dive-planner-service/BreathingGas.ts rename to src/app/dive-planner-service/BreathingGas.ts diff --git a/src/src/app/dive-planner-service/BuhlmannZHL16C.ts b/src/app/dive-planner-service/BuhlmannZHL16C.ts similarity index 100% rename from src/src/app/dive-planner-service/BuhlmannZHL16C.ts rename to src/app/dive-planner-service/BuhlmannZHL16C.ts diff --git a/src/src/app/dive-planner-service/ChartGenerator.service.ts b/src/app/dive-planner-service/ChartGenerator.service.ts similarity index 98% rename from src/src/app/dive-planner-service/ChartGenerator.service.ts rename to src/app/dive-planner-service/ChartGenerator.service.ts index acf06ff0..ba5629ab 100644 --- a/src/src/app/dive-planner-service/ChartGenerator.service.ts +++ b/src/app/dive-planner-service/ChartGenerator.service.ts @@ -1,12 +1,8 @@ -import { Injectable } from '@angular/core'; import { DiveProfile } from './DiveProfile'; import { BreathingGas } from './BreathingGas'; import { DiveSegmentFactoryService } from './DiveSegmentFactory.service'; import { DiveSettingsService } from './DiveSettings.service'; -@Injectable({ - providedIn: 'root', -}) export class ChartGeneratorService { constructor( private diveSegmentFactory: DiveSegmentFactoryService, diff --git a/src/src/app/dive-planner-service/DivePlannerService.ts b/src/app/dive-planner-service/DivePlannerService.ts similarity index 87% rename from src/src/app/dive-planner-service/DivePlannerService.ts rename to src/app/dive-planner-service/DivePlannerService.ts index f053949b..5cf1806b 100644 --- a/src/src/app/dive-planner-service/DivePlannerService.ts +++ b/src/app/dive-planner-service/DivePlannerService.ts @@ -1,26 +1,23 @@ -import { Injectable } from '@angular/core'; import { BreathingGas } from './BreathingGas'; import { DiveSegment } from './DiveSegment'; import { DiveSegmentFactoryService } from './DiveSegmentFactory.service'; import { DiveProfile } from './DiveProfile'; -import { ApplicationInsightsService } from '../application-insights-service/application-insights.service'; import { DiveSettingsService } from './DiveSettings.service'; import { ChartGeneratorService } from './ChartGenerator.service'; -@Injectable({ - providedIn: 'root', -}) export class DivePlannerService { private diveID = crypto.randomUUID(); - private diveProfile: DiveProfile = new DiveProfile(this.settings, this.diveSegmentFactory); + private diveProfile: DiveProfile; + private chartGenerator: ChartGeneratorService; + private diveSegmentFactory: DiveSegmentFactoryService; constructor( - private diveSegmentFactory: DiveSegmentFactoryService, - private appInsights: ApplicationInsightsService, - private chartGenerator: ChartGeneratorService, public settings: DiveSettingsService ) { - BreathingGas.GenerateStandardGases(this.settings); + this.diveSegmentFactory = new DiveSegmentFactoryService(settings); + this.chartGenerator = new ChartGeneratorService(this.diveSegmentFactory, settings); + this.diveProfile = new DiveProfile(settings, this.diveSegmentFactory); + BreathingGas.GenerateStandardGases(settings); } getStandardGases(): BreathingGas[] { @@ -28,12 +25,9 @@ export class DivePlannerService { } startDive(startGas: BreathingGas) { + this.diveProfile = new DiveProfile(this.settings, this.diveSegmentFactory); this.diveProfile.startDive(startGas); this.diveID = crypto.randomUUID(); - this.appInsights.trackEvent('StartDive', { - diveID: this.diveID, - startGas: { description: startGas.description, oxygen: startGas.oxygen, helium: startGas.helium, nitrogen: startGas.nitrogen }, - }); } getDiveSegments(): DiveSegment[] { diff --git a/src/src/app/dive-planner-service/DiveProfile.ts b/src/app/dive-planner-service/DiveProfile.ts similarity index 100% rename from src/src/app/dive-planner-service/DiveProfile.ts rename to src/app/dive-planner-service/DiveProfile.ts diff --git a/src/src/app/dive-planner-service/DiveSegment.ts b/src/app/dive-planner-service/DiveSegment.ts similarity index 100% rename from src/src/app/dive-planner-service/DiveSegment.ts rename to src/app/dive-planner-service/DiveSegment.ts diff --git a/src/src/app/dive-planner-service/DiveSegmentFactory.service.ts b/src/app/dive-planner-service/DiveSegmentFactory.service.ts similarity index 80% rename from src/src/app/dive-planner-service/DiveSegmentFactory.service.ts rename to src/app/dive-planner-service/DiveSegmentFactory.service.ts index 1381496b..2fdffb33 100644 --- a/src/src/app/dive-planner-service/DiveSegmentFactory.service.ts +++ b/src/app/dive-planner-service/DiveSegmentFactory.service.ts @@ -1,21 +1,16 @@ -import { Injectable } from '@angular/core'; -import { HumanDurationPipe } from '../pipes/human-duration.pipe'; +import { humanDuration } from '../utility/formatters'; import { DiveSegment } from './DiveSegment'; import { BreathingGas } from './BreathingGas'; import { DiveSettingsService } from './DiveSettings.service'; -@Injectable({ - providedIn: 'root', -}) export class DiveSegmentFactoryService { constructor( - private humanDurationPipe: HumanDurationPipe, private settings: DiveSettingsService ) {} createEndDiveSegment(startTime: number, depth: number, gas: BreathingGas): DiveSegment { const ascentTime = this.getTravelTime(depth, 0); - const ascentTimeDuration = this.humanDurationPipe.transform(ascentTime); + const ascentTimeDuration = humanDuration(ascentTime); const endTime = startTime + ascentTime; return new DiveSegment( @@ -41,8 +36,8 @@ export class DiveSegmentFactoryService { const title = newDepth > previousDepth ? `Descend to ${newDepth}m` : `Ascend to ${newDepth}m`; const description = newDepth > previousDepth - ? `Descent time: ${this.humanDurationPipe.transform(travelTime)} @ ${this.settings.descentRate}m/min` - : `Ascent time: ${this.humanDurationPipe.transform(travelTime)} @ ${this.settings.ascentRate}m/min`; + ? `Descent time: ${humanDuration(travelTime)} @ ${this.settings.descentRate}m/min` + : `Ascent time: ${humanDuration(travelTime)} @ ${this.settings.ascentRate}m/min`; const icon = newDepth > previousDepth ? 'arrow_downward' : 'arrow_upward'; return new DiveSegment(startTime, endTime, title, description, previousDepth, newDepth, gas, icon, this.settings); @@ -58,7 +53,7 @@ export class DiveSegmentFactoryService { createMaintainDepthSegment(startTime: number, depth: number, duration: number, gas: BreathingGas) { const endTime = startTime + duration; const title = `Maintain Depth at ${depth}m`; - const description = `Time: ${this.humanDurationPipe.transform(duration)}`; + const description = `Time: ${humanDuration(duration)}`; const icon = 'arrow_forward'; return new DiveSegment(startTime, endTime, title, description, depth, depth, gas, icon, this.settings); diff --git a/src/src/app/dive-planner-service/DiveSettings.service.ts b/src/app/dive-planner-service/DiveSettings.service.ts similarity index 96% rename from src/src/app/dive-planner-service/DiveSettings.service.ts rename to src/app/dive-planner-service/DiveSettings.service.ts index 95eb1c1f..c4c3a52c 100644 --- a/src/src/app/dive-planner-service/DiveSettings.service.ts +++ b/src/app/dive-planner-service/DiveSettings.service.ts @@ -1,8 +1,3 @@ -import { Injectable } from '@angular/core'; - -@Injectable({ - providedIn: 'root', -}) export class DiveSettingsService { private _ascentRate = 10; private _descentRate = 20; diff --git a/src/src/app/dive-planner-service/Tissue.ts b/src/app/dive-planner-service/Tissue.ts similarity index 100% rename from src/src/app/dive-planner-service/Tissue.ts rename to src/app/dive-planner-service/Tissue.ts diff --git a/src/app/favicon.ico b/src/app/favicon.ico new file mode 100644 index 00000000..718d6fea Binary files /dev/null and b/src/app/favicon.ico differ diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 00000000..cb919cd9 --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,24 @@ +html, body { + height: 100%; + margin: 0; + padding: 0; + font-family: Roboto, "Helvetica Neue", sans-serif; +} + +.tooltip-wide .MuiTooltip-tooltip { + max-width: unset; +} + +sup, sub { + vertical-align: baseline; + position: relative; + top: -0.4em; +} + +sub { + top: 0.4em; +} + +.hidden { + display: none !important; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 00000000..96726ae8 --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,30 @@ +import type { Metadata } from "next"; +import Providers from "./providers"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "DiveIntelligence", + description: "Dive planning calculator", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + + + + + + + {children} + + + + ); +} diff --git a/src/app/maintain-depth/page.tsx b/src/app/maintain-depth/page.tsx new file mode 100644 index 00000000..0ddc698f --- /dev/null +++ b/src/app/maintain-depth/page.tsx @@ -0,0 +1,73 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { Box, Button, TextField, Typography, Paper } from '@mui/material'; +import Link from 'next/link'; +import CurrentStats from '../components/CurrentStats'; +import NewTimeStats from '../components/NewTimeStats'; +import CeilingChart from '../components/CeilingChart'; +import { useDivePlanner, useAddMaintainDepthSegment } from '../contexts/DivePlannerContext'; + +export default function MaintainDepthPage() { + const router = useRouter(); + const { divePlanner } = useDivePlanner(); + const addMaintainDepthSegment = useAddMaintainDepthSegment(); + const [timeAtDepth, setTimeAtDepth] = useState(0); + const [isReady, setIsReady] = useState(false); + + useEffect(() => { + if (divePlanner && divePlanner.getDiveSegments().length > 0) { + setIsReady(true); + } + }, [divePlanner]); + + const handleTimeAtDepthChange = (e: React.ChangeEvent) => { + const value = Math.max(0, parseInt(e.target.value) || 0); + setTimeAtDepth(value); + }; + + const handleSave = () => { + addMaintainDepthSegment(timeAtDepth * 60); + router.push('/dive-overview'); + }; + + if (!isReady) { + return Loading...; + } + + return ( + + + + Select time at depth + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/app/new-dive/page.tsx b/src/app/new-dive/page.tsx new file mode 100644 index 00000000..2a1ee1ba --- /dev/null +++ b/src/app/new-dive/page.tsx @@ -0,0 +1,112 @@ +'use client'; + +import React, { useState, useCallback, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { + Box, + Button, + Radio, + RadioGroup, + FormControlLabel, + Typography, + Paper, +} from '@mui/material'; +import StandardGasList from '../components/StandardGasList'; +import CustomGasInput from '../components/CustomGasInput'; +import DiveSettings from '../components/DiveSettings'; +import StartGasStats from '../components/StartGasStats'; +import { BreathingGas } from '../dive-planner-service/BreathingGas'; +import { useDivePlanner, useStartDive } from '../contexts/DivePlannerContext'; + +export default function NewDivePage() { + const router = useRouter(); + const { divePlanner } = useDivePlanner(); + const startDive = useStartDive(); + const [gasType, setGasType] = useState('standard'); + const [selectedStandardGas, setSelectedStandardGas] = useState(null); + const [customGas, setCustomGas] = useState(null); + + useEffect(() => { + const gases = divePlanner.getStandardGases(); + if (gases.length > 0 && !selectedStandardGas) { + setSelectedStandardGas(gases[0]); + } + if (!customGas) { + setCustomGas(BreathingGas.create(21, 0, 79, divePlanner.settings)); + } + }, [divePlanner, selectedStandardGas, customGas]); + + const handleGasTypeChange = (event: React.ChangeEvent) => { + setGasType(event.target.value); + }; + + const handleStandardGasSelected = useCallback((gas: BreathingGas) => { + setSelectedStandardGas(gas); + }, []); + + const handleCustomGasChanged = useCallback((gas: BreathingGas) => { + setCustomGas(gas); + }, []); + + const getSelectedGas = () => { + if (gasType === 'standard') { + return selectedStandardGas; + } + return customGas; + }; + + const handleSave = () => { + const selectedGas = getSelectedGas(); + if (selectedGas) { + startDive(selectedGas); + router.push('/dive-overview'); + } + }; + + const selectedGas = getSelectedGas(); + + return ( + + + Select the starting gas for the dive + + + + + } label="Standard gas" /> + + + + + } label="Custom gas" /> + + + + + + + + + + {selectedGas && } + + + + + + + + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 00000000..ac0d8950 --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,103 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Button, Box } from '@mui/material'; +import Link from 'next/link'; +import Image from 'next/image'; + +export default function Home() { + const [apiLoaded, setApiLoaded] = useState(false); + + useEffect(() => { + if (!apiLoaded) { + const tag = document.createElement('script'); + tag.src = 'https://www.youtube.com/iframe_api'; + document.body.appendChild(tag); + setApiLoaded(true); + } + }, [apiLoaded]); + + return ( + + + + + + +