@runaid/lactate-curve 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs ADDED
@@ -0,0 +1,1051 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ BasicCurveExample: () => BasicCurveExample,
34
+ CurveWithVo2maxExample: () => CurveWithVo2maxExample,
35
+ DomainsOverlayExample: () => DomainsOverlayExample,
36
+ FullyThemedExample: () => FullyThemedExample,
37
+ LactateCurveChart: () => LactateCurveChart,
38
+ RaceMarkerOverlayExample: () => RaceMarkerOverlayExample,
39
+ generateLactateCurve: () => generateLactateCurve,
40
+ projectMarkerToCurve: () => projectMarkerToCurve,
41
+ resolveZones: () => resolveZones
42
+ });
43
+ module.exports = __toCommonJS(index_exports);
44
+
45
+ // src/components/LactateCurveChart.tsx
46
+ var React2 = __toESM(require("react"), 1);
47
+ var import_recharts2 = require("recharts");
48
+
49
+ // src/lib/generateLactateCurve.ts
50
+ var MAX_LT1_BASELINE_DIP = 0.45;
51
+ var clamp = (value, min, max) => Math.min(max, Math.max(min, value));
52
+ var round = (value) => Math.round(value * 1e3) / 1e3;
53
+ var assertFinitePositive = (value, label) => {
54
+ if (!Number.isFinite(value)) {
55
+ throw new Error(`${label} must be a finite number.`);
56
+ }
57
+ };
58
+ var buildModerateDomainFunction = ({
59
+ minIntensity,
60
+ baselineLactate,
61
+ lt1
62
+ }) => {
63
+ const span = lt1.intensity - minIntensity;
64
+ const delta = lt1.lactate - baselineLactate;
65
+ if (span <= 0) {
66
+ throw new Error("Moderate domain span must be greater than 0.");
67
+ }
68
+ if (Math.abs(delta) < 1e-4) {
69
+ return () => baselineLactate;
70
+ }
71
+ if (delta > 0) {
72
+ return (intensity) => {
73
+ const t = clamp((intensity - minIntensity) / span, 0, 1);
74
+ return baselineLactate + delta * Math.pow(t, 4);
75
+ };
76
+ }
77
+ const dip = baselineLactate - lt1.lactate;
78
+ const dipAccent = 0.32;
79
+ return (intensity) => {
80
+ const t = clamp((intensity - minIntensity) / span, 0, 1);
81
+ return baselineLactate - dip * (t + dipAccent * 4 * t * (1 - t));
82
+ };
83
+ };
84
+ var buildHeavyDomainFunction = ({
85
+ lt1,
86
+ lt2
87
+ }) => {
88
+ const span = lt2.intensity - lt1.intensity;
89
+ if (span <= 0) {
90
+ throw new Error("Heavy domain span must be greater than 0.");
91
+ }
92
+ const slope = (lt2.lactate - lt1.lactate) / span;
93
+ return {
94
+ slope,
95
+ project: (intensity) => lt1.lactate + slope * (intensity - lt1.intensity)
96
+ };
97
+ };
98
+ var buildSevereDomainFunction = ({
99
+ lt2,
100
+ maxIntensity,
101
+ heavySlope
102
+ }) => {
103
+ const span = maxIntensity - lt2.intensity;
104
+ if (span <= 0) {
105
+ throw new Error("Severe domain span must be greater than 0.");
106
+ }
107
+ const curvature = clamp(2.4 / span, 0.02, 0.9);
108
+ const initialSevereSlope = heavySlope * 1.4;
109
+ return {
110
+ curvature,
111
+ initialSevereSlope,
112
+ project: (intensity) => {
113
+ const delta = Math.max(0, intensity - lt2.intensity);
114
+ return lt2.lactate + initialSevereSlope / curvature * (Math.exp(curvature * delta) - 1);
115
+ }
116
+ };
117
+ };
118
+ var generateLactateCurve = ({
119
+ baselineLactate,
120
+ lt1,
121
+ lt2,
122
+ vo2max,
123
+ minIntensity = 0,
124
+ maxIntensity,
125
+ intensityStep,
126
+ samples = 80
127
+ }) => {
128
+ assertFinitePositive(baselineLactate, "baselineLactate");
129
+ assertFinitePositive(lt1.intensity, "lt1 intensity");
130
+ assertFinitePositive(lt1.lactate, "lt1 lactate");
131
+ assertFinitePositive(lt2.intensity, "lt2 intensity");
132
+ assertFinitePositive(lt2.lactate, "lt2 lactate");
133
+ if (baselineLactate <= 0) {
134
+ throw new Error("baselineLactate must be greater than 0.");
135
+ }
136
+ if (baselineLactate - lt1.lactate > MAX_LT1_BASELINE_DIP) {
137
+ throw new Error(
138
+ `LT1 lactate can sit slightly below baseline, but the dip must be <= ${MAX_LT1_BASELINE_DIP} mmol/L.`
139
+ );
140
+ }
141
+ if (lt1.intensity <= minIntensity) {
142
+ throw new Error("LT1 intensity must be above the minimum chart intensity.");
143
+ }
144
+ if (lt1.intensity >= lt2.intensity) {
145
+ throw new Error("LT1 intensity must be below LT2 intensity.");
146
+ }
147
+ if (lt1.lactate >= lt2.lactate) {
148
+ throw new Error("LT1 lactate must be below LT2 lactate.");
149
+ }
150
+ if (vo2max) {
151
+ assertFinitePositive(vo2max.intensity, "vo2max intensity");
152
+ if (lt2.intensity >= vo2max.intensity) {
153
+ throw new Error("VO2max intensity must be greater than LT2 intensity.");
154
+ }
155
+ }
156
+ const maxX = maxIntensity ?? round(
157
+ vo2max ? vo2max.intensity * 1.08 : lt2.intensity + Math.max((lt2.intensity - lt1.intensity) * 0.6, lt2.intensity * 0.12)
158
+ );
159
+ if (maxX <= lt2.intensity) {
160
+ throw new Error("maxIntensity must extend beyond LT2.");
161
+ }
162
+ const moderateDomain = buildModerateDomainFunction({
163
+ minIntensity,
164
+ baselineLactate,
165
+ lt1
166
+ });
167
+ const heavyDomain = buildHeavyDomainFunction({ lt1, lt2 });
168
+ const severeDomain = buildSevereDomainFunction({
169
+ lt2,
170
+ maxIntensity: maxX,
171
+ heavySlope: heavyDomain.slope
172
+ });
173
+ const points = [];
174
+ const pointCount = intensityStep ? Math.max(2, Math.floor((maxX - minIntensity) / intensityStep) + 1) : samples;
175
+ for (let index = 0; index < pointCount; index += 1) {
176
+ const intensity = index === pointCount - 1 ? maxX : intensityStep ? minIntensity + intensityStep * index : minIntensity + (maxX - minIntensity) * index / (pointCount - 1);
177
+ let lactate;
178
+ if (intensity <= lt1.intensity) {
179
+ lactate = moderateDomain(intensity);
180
+ } else if (intensity <= lt2.intensity) {
181
+ lactate = heavyDomain.project(intensity);
182
+ } else {
183
+ lactate = severeDomain.project(intensity);
184
+ }
185
+ points.push({
186
+ intensity: round(intensity),
187
+ lactate: round(lactate)
188
+ });
189
+ }
190
+ const endPoint = points[points.length - 1];
191
+ const minimumModerateEstimate = lt1.lactate < baselineLactate ? lt1.lactate - (baselineLactate - lt1.lactate) * 0.12 : Math.min(baselineLactate, lt1.lactate);
192
+ return {
193
+ points,
194
+ anchors: {
195
+ baseline: { intensity: minIntensity, lactate: baselineLactate },
196
+ lt1,
197
+ lt2,
198
+ vo2max,
199
+ max: endPoint
200
+ },
201
+ domain: {
202
+ minIntensity,
203
+ maxIntensity: maxX,
204
+ minLactate: round(Math.max(0, minimumModerateEstimate - 0.12)),
205
+ maxLactate: round(endPoint.lactate * 1.08)
206
+ }
207
+ };
208
+ };
209
+
210
+ // src/lib/chart-defaults.ts
211
+ var DEFAULT_THEME = {
212
+ curveColor: "#e11d48",
213
+ axisColor: "#334155",
214
+ gridColor: "#cbd5e1",
215
+ labelColor: "#0f172a",
216
+ thresholdColor: "#0f172a",
217
+ fontFamily: "ui-sans-serif, system-ui, sans-serif",
218
+ backgroundColor: "#ffffff",
219
+ markerLabelColor: "#0f172a"
220
+ };
221
+ var DEFAULT_ZONE_OPACITY = 0.12;
222
+ var getDefaultZones = () => [
223
+ {
224
+ startIntensity: "min",
225
+ endIntensity: "lt1",
226
+ label: "Moderate domain",
227
+ color: "#22c55e",
228
+ opacity: DEFAULT_ZONE_OPACITY
229
+ },
230
+ {
231
+ startIntensity: "lt1",
232
+ endIntensity: "lt2",
233
+ label: "Heavy domain",
234
+ color: "#f59e0b",
235
+ opacity: DEFAULT_ZONE_OPACITY
236
+ },
237
+ {
238
+ startIntensity: "lt2",
239
+ endIntensity: "max",
240
+ label: "Severe domain",
241
+ color: "#ef4444",
242
+ opacity: DEFAULT_ZONE_OPACITY
243
+ }
244
+ ];
245
+
246
+ // src/lib/projectMarkerToCurve.ts
247
+ var projectIntensityToCurve = (intensity, points) => {
248
+ if (points.length < 2) {
249
+ throw new Error("At least two curve points are required to project onto the curve.");
250
+ }
251
+ const clampedIntensity = Math.min(
252
+ points[points.length - 1].intensity,
253
+ Math.max(points[0].intensity, intensity)
254
+ );
255
+ for (let index = 0; index < points.length - 1; index += 1) {
256
+ const current = points[index];
257
+ const next = points[index + 1];
258
+ if (clampedIntensity >= current.intensity && clampedIntensity <= next.intensity) {
259
+ const span = next.intensity - current.intensity || 1;
260
+ const ratio = (clampedIntensity - current.intensity) / span;
261
+ const lactate = current.lactate + (next.lactate - current.lactate) * ratio;
262
+ return {
263
+ intensity: clampedIntensity,
264
+ lactate: Math.round(lactate * 1e3) / 1e3
265
+ };
266
+ }
267
+ }
268
+ return {
269
+ intensity: clampedIntensity,
270
+ lactate: points[points.length - 1].lactate
271
+ };
272
+ };
273
+ var projectMarkerToCurve = (marker, points) => ({
274
+ ...marker,
275
+ ...projectIntensityToCurve(marker.intensity, points)
276
+ });
277
+
278
+ // src/lib/resolveZones.ts
279
+ var resolveBoundary = (boundary, anchors, fallback) => {
280
+ if (boundary === void 0) return fallback;
281
+ if (typeof boundary === "number") return boundary;
282
+ switch (boundary) {
283
+ case "min":
284
+ case "baseline":
285
+ return anchors.baseline.intensity;
286
+ case "lt1":
287
+ return anchors.lt1.intensity;
288
+ case "lt2":
289
+ return anchors.lt2.intensity;
290
+ case "vo2max":
291
+ return anchors.vo2max?.intensity ?? anchors.max.intensity;
292
+ case "max":
293
+ return anchors.max.intensity;
294
+ }
295
+ };
296
+ var resolveZones = ({
297
+ zones,
298
+ curve
299
+ }) => {
300
+ const sourceZones = zones ?? getDefaultZones();
301
+ return sourceZones.map((zone) => {
302
+ const startIntensity = resolveBoundary(
303
+ zone.startIntensity,
304
+ curve.anchors,
305
+ curve.domain.minIntensity
306
+ );
307
+ const endIntensity = resolveBoundary(
308
+ zone.endIntensity,
309
+ curve.anchors,
310
+ curve.domain.maxIntensity
311
+ );
312
+ return {
313
+ label: zone.label,
314
+ color: zone.color,
315
+ opacity: zone.opacity ?? DEFAULT_ZONE_OPACITY,
316
+ startIntensity,
317
+ endIntensity
318
+ };
319
+ }).filter((zone) => zone.endIntensity > zone.startIntensity);
320
+ };
321
+
322
+ // src/lib/utils.ts
323
+ var import_clsx = require("clsx");
324
+ var cn = (...inputs) => (0, import_clsx.clsx)(inputs);
325
+
326
+ // src/components/ui/chart.tsx
327
+ var React = __toESM(require("react"), 1);
328
+ var import_recharts = require("recharts");
329
+ var import_jsx_runtime = require("react/jsx-runtime");
330
+ var ChartContext = React.createContext(null);
331
+ var useChart = () => {
332
+ const context = React.useContext(ChartContext);
333
+ if (!context) {
334
+ throw new Error("Chart components must be used within a ChartContainer.");
335
+ }
336
+ return context;
337
+ };
338
+ var ChartContainer = React.forwardRef(
339
+ ({ config = {}, className, style, height = 360, children, ...props }, ref) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ChartContext.Provider, { value: { config }, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
340
+ "div",
341
+ {
342
+ ref,
343
+ className: cn("w-full", className),
344
+ style: { width: "100%", height, ...style },
345
+ ...props,
346
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_recharts.ResponsiveContainer, { width: "100%", height: "100%", children })
347
+ }
348
+ ) })
349
+ );
350
+ ChartContainer.displayName = "ChartContainer";
351
+ var ChartTooltip = import_recharts.Tooltip;
352
+ var ChartTooltipContent = React.forwardRef(
353
+ ({
354
+ active,
355
+ payload,
356
+ label,
357
+ className,
358
+ indicator = "dot",
359
+ labelFormatter,
360
+ valueFormatter,
361
+ ...props
362
+ }, ref) => {
363
+ const { config } = useChart();
364
+ if (!active || !payload?.length) {
365
+ return null;
366
+ }
367
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
368
+ "div",
369
+ {
370
+ ref,
371
+ className: cn(className),
372
+ style: {
373
+ border: "1px solid rgba(15, 23, 42, 0.15)",
374
+ borderRadius: 12,
375
+ background: "rgba(255,255,255,0.96)",
376
+ boxShadow: "0 12px 40px rgba(15, 23, 42, 0.12)",
377
+ padding: "0.75rem",
378
+ minWidth: 180
379
+ },
380
+ ...props,
381
+ children: [
382
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { fontSize: 12, fontWeight: 600, marginBottom: 8 }, children: labelFormatter ? labelFormatter(label ?? "") : label }),
383
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { display: "grid", gap: 6 }, children: payload.map((item) => {
384
+ const key = String(item.dataKey ?? item.name ?? "");
385
+ const entry = config[key];
386
+ const color = item.color ?? entry?.color ?? "#0f172a";
387
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
388
+ "div",
389
+ {
390
+ style: {
391
+ display: "flex",
392
+ alignItems: "center",
393
+ justifyContent: "space-between",
394
+ gap: 12,
395
+ fontSize: 12
396
+ },
397
+ children: [
398
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { style: { display: "inline-flex", alignItems: "center", gap: 8 }, children: [
399
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
400
+ "span",
401
+ {
402
+ "aria-hidden": "true",
403
+ style: {
404
+ width: indicator === "dot" ? 8 : 12,
405
+ height: 8,
406
+ borderRadius: indicator === "dot" ? 999 : 2,
407
+ background: color,
408
+ display: "inline-block"
409
+ }
410
+ }
411
+ ),
412
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: entry?.label ?? item.name })
413
+ ] }),
414
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { fontWeight: 600 }, children: valueFormatter ? valueFormatter(item.value ?? "", item.name ?? "") : item.value })
415
+ ]
416
+ },
417
+ key
418
+ );
419
+ }) })
420
+ ]
421
+ }
422
+ );
423
+ }
424
+ );
425
+ ChartTooltipContent.displayName = "ChartTooltipContent";
426
+
427
+ // src/components/LactateCurveChart.tsx
428
+ var import_jsx_runtime2 = require("react/jsx-runtime");
429
+ var mergeTheme = (theme) => ({
430
+ ...DEFAULT_THEME,
431
+ ...theme
432
+ });
433
+ var formatNumber = (value, precision) => value.toFixed(Math.max(0, precision));
434
+ var resolveCompressionEnd = (compression, values) => {
435
+ if (!compression) return null;
436
+ const end = compression.endIntensity;
437
+ if (typeof end === "number") return end;
438
+ if (end === "lt1") return values.lt1;
439
+ if (end === "lt2") return values.lt2;
440
+ if (end === "vo2max") return values.vo2max ?? null;
441
+ return null;
442
+ };
443
+ var defaultThresholdLabel = (key) => {
444
+ switch (key) {
445
+ case "lt1":
446
+ return "LT1";
447
+ case "lt2":
448
+ return "LT2";
449
+ case "vo2max":
450
+ return "VO2max";
451
+ }
452
+ };
453
+ var LactateCurveChart = ({
454
+ baselineLactate,
455
+ lt1,
456
+ lt2,
457
+ vo2max,
458
+ zones,
459
+ raceMarkers,
460
+ xAxisLabel = "Intensity",
461
+ yAxisLabel = "Blood lactate (mmol/L)",
462
+ xAxisPrecision = 1,
463
+ yAxisPrecision = 1,
464
+ showXAxisTicks = true,
465
+ xAxisCompression,
466
+ title = "Approximate lactate curve",
467
+ description = "A physiologically plausible lactate curve with thresholds, optional domains, and optional race markers.",
468
+ showThresholdLabels = true,
469
+ showThresholdGuides = true,
470
+ showThresholdCircles = true,
471
+ showGrid = true,
472
+ showLegend = true,
473
+ thresholdAnnotations,
474
+ theme,
475
+ icon,
476
+ labels,
477
+ minIntensity,
478
+ xAxisMax,
479
+ maxIntensity,
480
+ intensityStep,
481
+ curveSamples = 96,
482
+ height = 420,
483
+ style,
484
+ className
485
+ }) => {
486
+ const resolvedTheme = React2.useMemo(() => mergeTheme(theme), [theme]);
487
+ const curve = React2.useMemo(
488
+ () => generateLactateCurve({
489
+ baselineLactate,
490
+ lt1,
491
+ lt2,
492
+ vo2max,
493
+ minIntensity,
494
+ maxIntensity: xAxisMax ?? maxIntensity,
495
+ intensityStep,
496
+ samples: curveSamples
497
+ }),
498
+ [
499
+ baselineLactate,
500
+ curveSamples,
501
+ intensityStep,
502
+ lt1,
503
+ lt2,
504
+ maxIntensity,
505
+ minIntensity,
506
+ vo2max,
507
+ xAxisMax
508
+ ]
509
+ );
510
+ const resolvedZones = React2.useMemo(
511
+ () => zones === null ? [] : resolveZones({ zones, curve }),
512
+ [curve, zones]
513
+ );
514
+ const compression = React2.useMemo(() => {
515
+ const end = resolveCompressionEnd(xAxisCompression, {
516
+ lt1: lt1.intensity,
517
+ lt2: lt2.intensity,
518
+ vo2max: vo2max?.intensity
519
+ });
520
+ const scale = xAxisCompression?.scale ?? 0.42;
521
+ if (!end || end <= curve.domain.minIntensity || end >= curve.domain.maxIntensity) {
522
+ return null;
523
+ }
524
+ if (!(scale > 0 && scale < 1)) {
525
+ return null;
526
+ }
527
+ const transform = (value) => {
528
+ if (value <= end) {
529
+ return curve.domain.minIntensity + (value - curve.domain.minIntensity) * scale;
530
+ }
531
+ return curve.domain.minIntensity + (end - curve.domain.minIntensity) * scale + (value - end);
532
+ };
533
+ const inverse = (value) => {
534
+ const compressedEnd = curve.domain.minIntensity + (end - curve.domain.minIntensity) * scale;
535
+ if (value <= compressedEnd) {
536
+ return curve.domain.minIntensity + (value - curve.domain.minIntensity) / scale;
537
+ }
538
+ return end + (value - compressedEnd);
539
+ };
540
+ return {
541
+ end,
542
+ scale,
543
+ transform,
544
+ inverse
545
+ };
546
+ }, [
547
+ curve.domain.maxIntensity,
548
+ curve.domain.minIntensity,
549
+ lt1.intensity,
550
+ lt2.intensity,
551
+ vo2max?.intensity,
552
+ xAxisCompression
553
+ ]);
554
+ const displayPoints = React2.useMemo(
555
+ () => curve.points.map((point) => ({
556
+ ...point,
557
+ displayIntensity: compression?.transform(point.intensity) ?? point.intensity
558
+ })),
559
+ [compression, curve.points]
560
+ );
561
+ const projectedMarkers = React2.useMemo(
562
+ () => (raceMarkers ?? []).map((marker) => projectMarkerToCurve(marker, curve.points)),
563
+ [curve.points, raceMarkers]
564
+ );
565
+ const chartConfig = React2.useMemo(
566
+ () => ({
567
+ lactate: {
568
+ label: labels?.curve ?? "Lactate curve",
569
+ color: resolvedTheme.curveColor
570
+ },
571
+ raceMarkers: {
572
+ label: labels?.raceMarkers ?? "Race intensities",
573
+ color: resolvedTheme.curveColor
574
+ }
575
+ }),
576
+ [labels?.curve, labels?.raceMarkers, resolvedTheme.curveColor]
577
+ );
578
+ const thresholdEntries = [
579
+ { key: "lt1", point: { ...lt1, ...projectIntensityToCurve(lt1.intensity, curve.points) } },
580
+ { key: "lt2", point: { ...lt2, ...projectIntensityToCurve(lt2.intensity, curve.points) } },
581
+ vo2max ? {
582
+ key: "vo2max",
583
+ point: { ...vo2max, ...projectIntensityToCurve(vo2max.intensity, curve.points) }
584
+ } : null
585
+ ].filter(Boolean);
586
+ const displayDomain = React2.useMemo(
587
+ () => ({
588
+ min: compression?.transform(curve.domain.minIntensity) ?? curve.domain.minIntensity,
589
+ max: compression?.transform(curve.domain.maxIntensity) ?? curve.domain.maxIntensity
590
+ }),
591
+ [compression, curve.domain.maxIntensity, curve.domain.minIntensity]
592
+ );
593
+ const toDisplayIntensity = React2.useCallback(
594
+ (value) => compression?.transform(value) ?? value,
595
+ [compression]
596
+ );
597
+ const iconNode = typeof icon === "function" ? icon({
598
+ width: 18,
599
+ height: 18,
600
+ viewBox: "0 0 18 18"
601
+ }) : icon;
602
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
603
+ "figure",
604
+ {
605
+ className: cn(className),
606
+ style: {
607
+ margin: 0,
608
+ color: resolvedTheme.labelColor,
609
+ fontFamily: resolvedTheme.fontFamily,
610
+ ...style
611
+ },
612
+ children: [
613
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
614
+ "div",
615
+ {
616
+ style: {
617
+ display: "flex",
618
+ alignItems: "center",
619
+ justifyContent: "space-between",
620
+ gap: 16,
621
+ marginBottom: 16
622
+ },
623
+ children: [
624
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("figcaption", { children: [
625
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { style: { fontSize: 18, fontWeight: 700 }, children: title }),
626
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { style: { fontSize: 14, opacity: 0.78, marginTop: 4 }, children: description })
627
+ ] }),
628
+ iconNode ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { "aria-hidden": "true", children: iconNode }) : null
629
+ ]
630
+ }
631
+ ),
632
+ showLegend ? /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
633
+ "div",
634
+ {
635
+ "aria-label": "Chart legend",
636
+ style: {
637
+ display: "flex",
638
+ alignItems: "center",
639
+ gap: 14,
640
+ flexWrap: "wrap",
641
+ marginBottom: 12,
642
+ fontSize: 12,
643
+ color: resolvedTheme.labelColor
644
+ },
645
+ children: [
646
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { style: { display: "inline-flex", alignItems: "center", gap: 8 }, children: [
647
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
648
+ "span",
649
+ {
650
+ "aria-hidden": "true",
651
+ style: {
652
+ width: 24,
653
+ height: 0,
654
+ borderTop: `3px solid ${resolvedTheme.curveColor}`,
655
+ borderRadius: 999,
656
+ display: "inline-block"
657
+ }
658
+ }
659
+ ),
660
+ chartConfig.lactate.label
661
+ ] }),
662
+ projectedMarkers.length ? /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { style: { display: "inline-flex", alignItems: "center", gap: 8 }, children: [
663
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
664
+ "span",
665
+ {
666
+ "aria-hidden": "true",
667
+ style: {
668
+ width: 10,
669
+ height: 10,
670
+ borderRadius: 999,
671
+ background: resolvedTheme.curveColor,
672
+ display: "inline-block"
673
+ }
674
+ }
675
+ ),
676
+ chartConfig.raceMarkers.label
677
+ ] }) : null
678
+ ]
679
+ }
680
+ ) : null,
681
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
682
+ ChartContainer,
683
+ {
684
+ config: chartConfig,
685
+ height,
686
+ className: "lactate-curve-chart",
687
+ role: "img",
688
+ "aria-label": title,
689
+ style: {
690
+ background: resolvedTheme.backgroundColor,
691
+ border: "1px solid rgba(148, 163, 184, 0.22)",
692
+ borderRadius: 20,
693
+ padding: 12
694
+ },
695
+ children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
696
+ import_recharts2.LineChart,
697
+ {
698
+ data: displayPoints,
699
+ margin: { top: 24, right: 28, bottom: 28, left: 14 },
700
+ children: [
701
+ showGrid ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
702
+ import_recharts2.CartesianGrid,
703
+ {
704
+ stroke: resolvedTheme.gridColor,
705
+ strokeDasharray: "3 3",
706
+ vertical: false
707
+ }
708
+ ) : null,
709
+ resolvedZones.map((zone) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
710
+ import_recharts2.ReferenceArea,
711
+ {
712
+ x1: toDisplayIntensity(zone.startIntensity),
713
+ x2: toDisplayIntensity(zone.endIntensity),
714
+ fill: zone.color,
715
+ fillOpacity: zone.opacity,
716
+ strokeOpacity: 0,
717
+ ifOverflow: "visible"
718
+ },
719
+ `${zone.label}-${zone.startIntensity}-${zone.endIntensity}`
720
+ )),
721
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
722
+ import_recharts2.XAxis,
723
+ {
724
+ type: "number",
725
+ dataKey: "displayIntensity",
726
+ domain: [displayDomain.min, displayDomain.max],
727
+ tick: showXAxisTicks ? { fill: resolvedTheme.axisColor, fontFamily: resolvedTheme.fontFamily } : false,
728
+ axisLine: { stroke: resolvedTheme.axisColor },
729
+ tickLine: showXAxisTicks ? { stroke: resolvedTheme.axisColor } : false,
730
+ tickFormatter: (value) => formatNumber(
731
+ compression?.inverse(Number(value)) ?? Number(value),
732
+ xAxisPrecision
733
+ ),
734
+ children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
735
+ import_recharts2.Label,
736
+ {
737
+ value: xAxisLabel,
738
+ position: "insideBottom",
739
+ offset: -14,
740
+ style: {
741
+ fill: resolvedTheme.labelColor,
742
+ fontFamily: resolvedTheme.fontFamily
743
+ }
744
+ }
745
+ )
746
+ }
747
+ ),
748
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
749
+ import_recharts2.YAxis,
750
+ {
751
+ type: "number",
752
+ domain: [curve.domain.minLactate, curve.domain.maxLactate],
753
+ tick: { fill: resolvedTheme.axisColor, fontFamily: resolvedTheme.fontFamily },
754
+ axisLine: { stroke: resolvedTheme.axisColor },
755
+ tickLine: { stroke: resolvedTheme.axisColor },
756
+ tickFormatter: (value) => formatNumber(Number(value), yAxisPrecision),
757
+ width: 64,
758
+ children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
759
+ import_recharts2.Label,
760
+ {
761
+ angle: -90,
762
+ position: "insideLeft",
763
+ value: yAxisLabel,
764
+ style: {
765
+ fill: resolvedTheme.labelColor,
766
+ fontFamily: resolvedTheme.fontFamily,
767
+ textAnchor: "middle"
768
+ }
769
+ }
770
+ )
771
+ }
772
+ ),
773
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
774
+ ChartTooltip,
775
+ {
776
+ cursor: { stroke: resolvedTheme.gridColor, strokeDasharray: "3 3" },
777
+ content: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
778
+ ChartTooltipContent,
779
+ {
780
+ labelFormatter: (value) => `${xAxisLabel}: ${formatNumber(
781
+ compression?.inverse(Number(value)) ?? Number(value),
782
+ xAxisPrecision
783
+ )}`,
784
+ valueFormatter: (value, name) => name === "lactate" ? `${formatNumber(Number(value), yAxisPrecision)} mmol/L` : `${formatNumber(Number(value), xAxisPrecision)}`
785
+ }
786
+ )
787
+ }
788
+ ),
789
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
790
+ import_recharts2.Line,
791
+ {
792
+ type: "monotone",
793
+ dataKey: "lactate",
794
+ name: "lactate",
795
+ stroke: resolvedTheme.curveColor,
796
+ strokeWidth: 3,
797
+ dot: false,
798
+ isAnimationActive: false
799
+ }
800
+ ),
801
+ thresholdEntries.map(({ key, point }) => {
802
+ const annotation = thresholdAnnotations?.[key];
803
+ const color = annotation?.color ?? resolvedTheme.thresholdColor;
804
+ const showGuide = annotation?.showGuide ?? showThresholdGuides;
805
+ const showCircle = annotation?.showCircle ?? showThresholdCircles;
806
+ const labelText = annotation?.label ?? labels?.[key] ?? defaultThresholdLabel(key);
807
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(React2.Fragment, { children: [
808
+ showGuide ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
809
+ import_recharts2.ReferenceLine,
810
+ {
811
+ x: toDisplayIntensity(point.intensity),
812
+ stroke: color,
813
+ strokeDasharray: annotation?.strokeDasharray ?? "6 4",
814
+ strokeWidth: annotation?.lineWidth ?? 1.5,
815
+ ifOverflow: "visible",
816
+ label: showThresholdLabels ? {
817
+ value: labelText,
818
+ position: "top",
819
+ fill: color,
820
+ fontSize: 12,
821
+ fontWeight: 700,
822
+ fontFamily: resolvedTheme.fontFamily
823
+ } : void 0
824
+ }
825
+ ) : null,
826
+ showCircle ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
827
+ import_recharts2.ReferenceDot,
828
+ {
829
+ x: toDisplayIntensity(point.intensity),
830
+ y: point.lactate,
831
+ r: annotation?.circleRadius ?? 5,
832
+ fill: color,
833
+ stroke: resolvedTheme.backgroundColor,
834
+ strokeWidth: 2,
835
+ ifOverflow: "visible"
836
+ }
837
+ ) : null
838
+ ] }, key);
839
+ }),
840
+ projectedMarkers.map((marker) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
841
+ import_recharts2.ReferenceDot,
842
+ {
843
+ x: toDisplayIntensity(marker.intensity),
844
+ y: marker.lactate,
845
+ r: marker.radius ?? 5,
846
+ fill: marker.color ?? resolvedTheme.curveColor,
847
+ stroke: resolvedTheme.backgroundColor,
848
+ strokeWidth: 2,
849
+ ifOverflow: "visible",
850
+ label: marker.showLabel === false ? void 0 : {
851
+ value: marker.label,
852
+ position: "top",
853
+ offset: 10,
854
+ fill: resolvedTheme.markerLabelColor,
855
+ fontSize: 12,
856
+ fontWeight: 600,
857
+ fontFamily: resolvedTheme.fontFamily
858
+ }
859
+ },
860
+ `${marker.label}-${marker.intensity}`
861
+ ))
862
+ ]
863
+ }
864
+ )
865
+ }
866
+ ),
867
+ compression ? /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
868
+ "div",
869
+ {
870
+ style: {
871
+ marginTop: 10,
872
+ fontSize: 12,
873
+ color: resolvedTheme.labelColor,
874
+ opacity: 0.78
875
+ },
876
+ children: [
877
+ "X-axis break: the low-intensity region up to ",
878
+ formatNumber(
879
+ compression.end,
880
+ xAxisPrecision
881
+ ),
882
+ " is visually compressed."
883
+ ]
884
+ }
885
+ ) : null,
886
+ resolvedZones.length ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
887
+ "div",
888
+ {
889
+ "aria-label": labels?.zones ?? "Intensity domains",
890
+ style: {
891
+ display: "flex",
892
+ flexWrap: "wrap",
893
+ gap: 10,
894
+ marginTop: 14
895
+ },
896
+ children: resolvedZones.map((zone) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
897
+ "span",
898
+ {
899
+ style: {
900
+ display: "inline-flex",
901
+ alignItems: "center",
902
+ gap: 8,
903
+ fontSize: 12,
904
+ color: resolvedTheme.labelColor
905
+ },
906
+ children: [
907
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
908
+ "span",
909
+ {
910
+ "aria-hidden": "true",
911
+ style: {
912
+ width: 12,
913
+ height: 12,
914
+ borderRadius: 999,
915
+ background: zone.color,
916
+ opacity: Math.max(zone.opacity, 0.35)
917
+ }
918
+ }
919
+ ),
920
+ zone.label
921
+ ]
922
+ },
923
+ `${zone.label}-${zone.startIntensity}-${zone.endIntensity}-legend`
924
+ ))
925
+ }
926
+ ) : null
927
+ ]
928
+ }
929
+ );
930
+ };
931
+
932
+ // src/examples.tsx
933
+ var import_jsx_runtime3 = require("react/jsx-runtime");
934
+ var baseProps = {
935
+ baselineLactate: 1.1,
936
+ lt1: { intensity: 220, lactate: 1.9 },
937
+ lt2: { intensity: 305, lactate: 4.1 }
938
+ };
939
+ var BasicCurveExample = () => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(LactateCurveChart, { ...baseProps });
940
+ var CurveWithVo2maxExample = () => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
941
+ LactateCurveChart,
942
+ {
943
+ ...baseProps,
944
+ vo2max: { intensity: 360 },
945
+ description: "The severe-domain tail is anchored explicitly with a VO2max point.",
946
+ xAxisCompression: { endIntensity: "lt1", scale: 0.34 }
947
+ }
948
+ );
949
+ var DomainsOverlayExample = () => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
950
+ LactateCurveChart,
951
+ {
952
+ ...baseProps,
953
+ vo2max: { intensity: 355 },
954
+ xAxisLabel: "Running power (W)"
955
+ }
956
+ );
957
+ var RaceMarkerOverlayExample = () => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
958
+ LactateCurveChart,
959
+ {
960
+ ...baseProps,
961
+ vo2max: { intensity: 360 },
962
+ raceMarkers: [
963
+ { intensity: 252, label: "Marathon", color: "#0f766e" },
964
+ { intensity: 300, label: "Half", color: "#0369a1" },
965
+ { intensity: 312, label: "10k", color: "#7c3aed" },
966
+ { intensity: 329, label: "5k", color: "#dc2626" },
967
+ { intensity: 342, label: "3k", color: "#ea580c" }
968
+ ]
969
+ }
970
+ );
971
+ var BrandMark = () => /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("svg", { width: "22", height: "22", viewBox: "0 0 22 22", fill: "none", children: [
972
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("circle", { cx: "11", cy: "11", r: "10", fill: "#102a43" }),
973
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
974
+ "path",
975
+ {
976
+ d: "M6 13.8C8.7 13.8 10.8 12 11.8 8.8C12.7 12.7 14.8 15 17 15",
977
+ stroke: "#f0b429",
978
+ strokeWidth: "2.2",
979
+ strokeLinecap: "round",
980
+ strokeLinejoin: "round"
981
+ }
982
+ )
983
+ ] });
984
+ var FullyThemedExample = () => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
985
+ LactateCurveChart,
986
+ {
987
+ ...baseProps,
988
+ vo2max: { intensity: 362 },
989
+ title: "Threshold Map",
990
+ description: "A branded version for article embeds and product UI.",
991
+ xAxisLabel: "Power (W)",
992
+ yAxisLabel: "Lactate (mmol/L)",
993
+ icon: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(BrandMark, {}),
994
+ labels: {
995
+ curve: "Athlete curve",
996
+ zones: "Training domains",
997
+ lt1: "Aerobic threshold",
998
+ lt2: "Critical threshold",
999
+ vo2max: "VO2 ceiling"
1000
+ },
1001
+ raceMarkers: [
1002
+ { intensity: 255, label: "M", color: "#166534", radius: 6 },
1003
+ { intensity: 298, label: "HM", color: "#0f766e", radius: 6 },
1004
+ { intensity: 313, label: "10k", color: "#1d4ed8", radius: 6 },
1005
+ { intensity: 332, label: "5k", color: "#b91c1c", radius: 6 }
1006
+ ],
1007
+ theme: {
1008
+ curveColor: "#b91c1c",
1009
+ thresholdColor: "#102a43",
1010
+ axisColor: "#243b53",
1011
+ gridColor: "#d9e2ec",
1012
+ labelColor: "#102a43",
1013
+ markerLabelColor: "#102a43",
1014
+ backgroundColor: "#f8fbff",
1015
+ fontFamily: "Avenir Next, ui-sans-serif, system-ui, sans-serif"
1016
+ },
1017
+ zones: [
1018
+ {
1019
+ startIntensity: "min",
1020
+ endIntensity: "lt1",
1021
+ label: "Easy / moderate",
1022
+ color: "#a7f3d0"
1023
+ },
1024
+ {
1025
+ startIntensity: "lt1",
1026
+ endIntensity: "lt2",
1027
+ label: "Threshold / heavy",
1028
+ color: "#fde68a"
1029
+ },
1030
+ {
1031
+ startIntensity: "lt2",
1032
+ endIntensity: "max",
1033
+ label: "VO2 / severe",
1034
+ color: "#fecaca"
1035
+ }
1036
+ ]
1037
+ }
1038
+ );
1039
+ // Annotate the CommonJS export names for ESM import in node:
1040
+ 0 && (module.exports = {
1041
+ BasicCurveExample,
1042
+ CurveWithVo2maxExample,
1043
+ DomainsOverlayExample,
1044
+ FullyThemedExample,
1045
+ LactateCurveChart,
1046
+ RaceMarkerOverlayExample,
1047
+ generateLactateCurve,
1048
+ projectMarkerToCurve,
1049
+ resolveZones
1050
+ });
1051
+ //# sourceMappingURL=index.cjs.map