Temp
@@ -1004,10 +1013,10 @@ const AdvancedDiveProfileChart = ({
{hasDeco && hasStopdepth && (
-
Ceiling
+
Ceiling{isDecoCalculated ? ' (Calculated)' : ''}
)}
diff --git a/frontend/src/components/TissueHeatmap.jsx b/frontend/src/components/TissueHeatmap.jsx
new file mode 100644
index 00000000..b4ee1f71
--- /dev/null
+++ b/frontend/src/components/TissueHeatmap.jsx
@@ -0,0 +1,129 @@
+import PropTypes from 'prop-types';
+import { useMemo } from 'react';
+
+const COMPARTMENTS = 16;
+const HALFTIMES = [5, 8, 12.5, 18.5, 27, 38.3, 54.3, 77, 109, 146, 187, 239, 305, 390, 498, 635];
+
+/**
+ * Renders a tissue saturation heatmap similar to octo-deco.nl.
+ * Shows time on X-axis and 16 Bühlmann compartments on Y-axis.
+ */
+const TissueHeatmap = ({ heatmapData, samples }) => {
+ // We need to downsample heatmapData if it's too large (e.g. > 1000 points)
+ // to maintain browser performance while keeping visual accuracy.
+ const processedData = useMemo(() => {
+ if (!heatmapData || heatmapData.length === 0 || !samples || samples.length === 0) return null;
+
+ // Ensure lengths match
+ const dataLen = Math.min(heatmapData.length, samples.length);
+ const targetPoints = 200; // Resolution of the heatmap
+ const step = Math.max(1, Math.floor(dataLen / targetPoints));
+
+ const rows = [];
+ for (let i = 0; i < dataLen; i += step) {
+ rows.push({
+ time: samples[i].time_minutes,
+ values: heatmapData[i],
+ });
+ }
+ return rows;
+ }, [heatmapData, samples]);
+
+ if (!processedData) return null;
+
+ // Color mapping: Blue (Ongassing) -> Green (Safe Offgassing) -> Yellow (Caution) -> Red (Deco/Violation)
+ const getColor = val => {
+ if (val < -100) return '#1e3a8a'; // Deep Blue (Fast ongassing)
+ if (val < -50) return '#3b82f6'; // Blue (Moderate ongassing)
+ if (val < 0) return '#93c5fd'; // Light Blue (Slow ongassing)
+ if (val === 0) return '#f3f4f6'; // Equilibrium (Gray)
+ if (val < 50) return '#bbf7d0'; // Safe Offgassing (Light Green)
+ if (val < 80) return '#22c55e'; // Safe Offgassing (Green)
+ if (val < 99) return '#eab308'; // Caution (Yellow)
+ return '#ef4444'; // Deco/M-Value (Red)
+ };
+
+ return (
+
+
+
+ Tissue Loading (ZH-L16)
+
+
GF99% Evolution
+
+
+
+ {/* The Heatmap Grid */}
+
+ {/* Y-Axis Labels (Compartments) */}
+
+ {HALFTIMES.slice()
+ .reverse()
+ .map((ht, i) => (
+ {ht}m
+ ))}
+
+
+ {/* Heatmap Columns */}
+
+ {processedData.map((col, colIdx) => (
+
+ {col.values.map((val, rowIdx) => (
+
+ {/* Simple Tooltip on hover */}
+
+ Time: {col.time.toFixed(1)}m
+ Comp: {HALFTIMES[rowIdx]}m
+ Load: {val}%
+
+
+ ))}
+
+ ))}
+
+
+
+ {/* X-Axis Legend */}
+
+ 0 min
+ {processedData[processedData.length - 1].time.toFixed(0)} min
+
+
+
+ {/* Legend */}
+
+
+ );
+};
+
+TissueHeatmap.propTypes = {
+ heatmapData: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.number)),
+ samples: PropTypes.arrayOf(PropTypes.object),
+};
+
+export default TissueHeatmap;
diff --git a/frontend/src/components/TissueSaturationChart.jsx b/frontend/src/components/TissueSaturationChart.jsx
new file mode 100644
index 00000000..c38de940
--- /dev/null
+++ b/frontend/src/components/TissueSaturationChart.jsx
@@ -0,0 +1,153 @@
+import PropTypes from 'prop-types';
+import { useMemo } from 'react';
+import {
+ BarChart,
+ Bar,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ ResponsiveContainer,
+ Cell,
+ ReferenceLine,
+} from 'recharts';
+
+const HALFTIMES = [5, 8, 12.5, 18.5, 27, 38.3, 54.3, 77, 109, 146, 187, 239, 305, 390, 498, 635];
+
+/**
+ * Visualizes tissue saturation for all 16 Bühlmann compartments at the end of a dive.
+ */
+const TissueSaturationChart = ({ saturationData, gfHigh }) => {
+ const data = useMemo(() => {
+ if (!saturationData || saturationData.length !== 16) return [];
+ return saturationData.map((gf99, index) => ({
+ name: `C${index + 1}`,
+ halftime: HALFTIMES[index],
+ gf99: gf99,
+ display_name: `${HALFTIMES[index]}m`,
+ }));
+ }, [saturationData]);
+
+ if (data.length === 0) return null;
+
+ return (
+
+
+
+
Final Tissue Status
+
+ Relative saturation (GF99) of all 16 Bühlmann ZH-L16 compartments after surfacing.
+
+
+ {gfHigh && (
+
+ GF High: {gfHigh}
+
+ )}
+
+
+
+
+
+
+
+ Math.max(110, dataMax)]}
+ unit='%'
+ />
+ {
+ if (active && payload && payload.length) {
+ const d = payload[0].payload;
+ return (
+
+
Compartment {d.name}
+
Halftime: {d.halftime} min
+
+ Saturation: {d.gf99}%
+
+
+ );
+ }
+ return null;
+ }}
+ />
+
+ {gfHigh && (
+
+ )}
+
+ {data.map((entry, index) => {
+ let color = '#22c55e'; // Green (< 80%)
+ if (entry.gf99 > 100)
+ color = '#ef4444'; // Red (> M-Value)
+ else if (entry.gf99 > 80)
+ color = '#eab308'; // Yellow (Near M-Value)
+ else if (gfHigh && entry.gf99 > gfHigh) color = '#3b82f6'; // Blue (Above GF High but below M-Value)
+
+ return | ;
+ })}
+
+
+
+
+
+
+
+
+
+
+
Over M-Value (>100%)
+
+ {gfHigh && (
+
+ )}
+
+
+ );
+};
+
+TissueSaturationChart.propTypes = {
+ saturationData: PropTypes.arrayOf(PropTypes.number),
+ gfHigh: PropTypes.number,
+};
+
+export default TissueSaturationChart;
diff --git a/frontend/src/pages/DiveDetail.jsx b/frontend/src/pages/DiveDetail.jsx
index ca7e550e..663c75a5 100644
--- a/frontend/src/pages/DiveDetail.jsx
+++ b/frontend/src/pages/DiveDetail.jsx
@@ -78,13 +78,15 @@ import { handleRateLimitError } from '../utils/rateLimitHandler';
import { calculateRouteBearings, formatBearing } from '../utils/routeUtils';
import { slugify } from '../utils/slugify';
import { getTagColor } from '../utils/tagHelpers';
-import { renderTextWithLinks } from '../utils/textHelpers';
+import { renderTextWithLinks, parseGradientFactors } from '../utils/textHelpers';
import { isYouTubeUrl, isVimeoUrl } from '../utils/youtubeHelpers';
import NotFound from './NotFound';
import UnprocessableEntity from './UnprocessableEntity';
const AdvancedDiveProfileChart = lazy(() => import('../components/AdvancedDiveProfileChart'));
+const TissueSaturationChart = lazy(() => import('../components/TissueSaturationChart'));
+const TissueHeatmap = lazy(() => import('../components/TissueHeatmap'));
const DiveDetail = () => {
const { id, slug } = useParams();
@@ -978,6 +980,23 @@ const DiveDetail = () => {
}
/>
+ )}
+
+ {profileData?.tissue_saturation && (
+
{
});
};
+/**
+ * Parses Gradient Factors (GF Low/High) from a text string.
+ * @param {string} text - The text to parse
+ * @returns {Object|null} Object with low and high properties or null
+ */
+export const parseGradientFactors = text => {
+ if (!text || typeof text !== 'string') return null;
+
+ // Matches patterns like 'GF 30/70', 'GF: 30/70', '(GF 30/70)', 'GF 30 / 70'
+ const pattern = /(?:GF|Gradient Factor)[s]?[:]?\s*\(?(\d+)\s*\/\s*(\d+)\)?/i;
+ const match = text.match(pattern);
+
+ if (match) {
+ return {
+ low: parseInt(match[1], 10),
+ high: parseInt(match[2], 10),
+ };
+ }
+ return null;
+};
+
/**
* Formats gas strings for display, e.g., "Nitrox 32%" to "EAN32"
* @param {string} gasStr - The gas string to format