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