@luxfi/ui 6.1.2 → 7.0.0

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.
Files changed (240) hide show
  1. package/dist/accordion.cjs +213 -0
  2. package/dist/accordion.d.cts +74 -0
  3. package/dist/accordion.d.ts +74 -0
  4. package/dist/accordion.js +186 -0
  5. package/dist/alert.cjs +553 -0
  6. package/dist/alert.d.cts +30 -0
  7. package/dist/alert.d.ts +30 -0
  8. package/dist/alert.js +531 -0
  9. package/dist/avatar.cjs +149 -0
  10. package/dist/avatar.d.cts +25 -0
  11. package/dist/avatar.d.ts +25 -0
  12. package/dist/avatar.js +125 -0
  13. package/dist/badge.cjs +611 -0
  14. package/dist/badge.d.cts +23 -0
  15. package/dist/badge.d.ts +23 -0
  16. package/dist/badge.js +589 -0
  17. package/dist/bank.cjs +272 -0
  18. package/dist/bank.d.cts +76 -0
  19. package/dist/bank.d.ts +76 -0
  20. package/dist/bank.js +258 -0
  21. package/dist/button.cjs +689 -0
  22. package/dist/button.d.cts +52 -0
  23. package/dist/button.d.ts +52 -0
  24. package/dist/button.js +664 -0
  25. package/dist/checkbox.cjs +265 -0
  26. package/dist/checkbox.d.cts +31 -0
  27. package/dist/checkbox.d.ts +31 -0
  28. package/dist/checkbox.js +241 -0
  29. package/dist/close-button.cjs +73 -0
  30. package/dist/close-button.d.cts +9 -0
  31. package/dist/close-button.d.ts +9 -0
  32. package/dist/close-button.js +51 -0
  33. package/dist/collapsible.cjs +702 -0
  34. package/dist/collapsible.d.cts +23 -0
  35. package/dist/collapsible.d.ts +23 -0
  36. package/dist/collapsible.js +679 -0
  37. package/dist/color-mode.cjs +96 -0
  38. package/dist/color-mode.d.cts +25 -0
  39. package/dist/color-mode.d.ts +25 -0
  40. package/dist/color-mode.js +72 -0
  41. package/dist/dialog.cjs +279 -0
  42. package/dist/dialog.d.cts +66 -0
  43. package/dist/dialog.d.ts +66 -0
  44. package/dist/dialog.js +246 -0
  45. package/dist/drawer.cjs +207 -0
  46. package/dist/drawer.d.cts +36 -0
  47. package/dist/drawer.d.ts +36 -0
  48. package/dist/drawer.js +175 -0
  49. package/dist/empty-state.cjs +93 -0
  50. package/dist/empty-state.d.cts +13 -0
  51. package/dist/empty-state.d.ts +13 -0
  52. package/dist/empty-state.js +71 -0
  53. package/dist/expiration-selector.cjs +138 -0
  54. package/dist/expiration-selector.d.cts +16 -0
  55. package/dist/expiration-selector.d.ts +16 -0
  56. package/dist/expiration-selector.js +116 -0
  57. package/dist/field.cjs +183 -0
  58. package/dist/field.d.cts +24 -0
  59. package/dist/field.d.ts +24 -0
  60. package/dist/field.js +160 -0
  61. package/dist/greeks-display.cjs +114 -0
  62. package/dist/greeks-display.d.cts +14 -0
  63. package/dist/greeks-display.d.ts +14 -0
  64. package/dist/greeks-display.js +92 -0
  65. package/dist/heading.cjs +46 -0
  66. package/dist/heading.d.cts +10 -0
  67. package/dist/heading.d.ts +10 -0
  68. package/dist/heading.js +40 -0
  69. package/dist/icon-button.cjs +491 -0
  70. package/dist/icon-button.d.cts +33 -0
  71. package/dist/icon-button.d.ts +33 -0
  72. package/dist/icon-button.js +470 -0
  73. package/dist/image.cjs +572 -0
  74. package/dist/image.d.cts +57 -0
  75. package/dist/image.d.ts +57 -0
  76. package/dist/image.js +551 -0
  77. package/dist/index.cjs +7316 -0
  78. package/dist/index.d.cts +58 -0
  79. package/dist/index.d.ts +58 -0
  80. package/dist/index.js +7137 -0
  81. package/dist/input-group.cjs +155 -0
  82. package/dist/input-group.d.cts +17 -0
  83. package/dist/input-group.d.ts +17 -0
  84. package/dist/input-group.js +133 -0
  85. package/dist/input.cjs +65 -0
  86. package/dist/input.d.cts +11 -0
  87. package/dist/input.d.ts +11 -0
  88. package/dist/input.js +59 -0
  89. package/dist/link.cjs +630 -0
  90. package/dist/link.d.cts +37 -0
  91. package/dist/link.d.ts +37 -0
  92. package/dist/link.js +606 -0
  93. package/dist/menu.cjs +305 -0
  94. package/dist/menu.d.cts +84 -0
  95. package/dist/menu.d.ts +84 -0
  96. package/dist/menu.js +269 -0
  97. package/dist/option-chain.cjs +221 -0
  98. package/dist/option-chain.d.cts +37 -0
  99. package/dist/option-chain.d.ts +37 -0
  100. package/dist/option-chain.js +199 -0
  101. package/dist/option-position.cjs +164 -0
  102. package/dist/option-position.d.cts +29 -0
  103. package/dist/option-position.d.ts +29 -0
  104. package/dist/option-position.js +142 -0
  105. package/dist/pin-input.cjs +182 -0
  106. package/dist/pin-input.d.cts +25 -0
  107. package/dist/pin-input.d.ts +25 -0
  108. package/dist/pin-input.js +160 -0
  109. package/dist/pnl-diagram.cjs +453 -0
  110. package/dist/pnl-diagram.d.cts +11 -0
  111. package/dist/pnl-diagram.d.ts +11 -0
  112. package/dist/pnl-diagram.js +431 -0
  113. package/dist/popover.cjs +327 -0
  114. package/dist/popover.d.cts +73 -0
  115. package/dist/popover.d.ts +73 -0
  116. package/dist/popover.js +294 -0
  117. package/dist/progress-circle.cjs +152 -0
  118. package/dist/progress-circle.d.cts +47 -0
  119. package/dist/progress-circle.d.ts +47 -0
  120. package/dist/progress-circle.js +128 -0
  121. package/dist/progress.cjs +117 -0
  122. package/dist/progress.d.cts +28 -0
  123. package/dist/progress.d.ts +28 -0
  124. package/dist/progress.js +94 -0
  125. package/dist/provider.cjs +62 -0
  126. package/dist/provider.d.cts +7 -0
  127. package/dist/provider.d.ts +7 -0
  128. package/dist/provider.js +40 -0
  129. package/dist/radio.cjs +177 -0
  130. package/dist/radio.d.cts +53 -0
  131. package/dist/radio.d.ts +53 -0
  132. package/dist/radio.js +153 -0
  133. package/dist/rating.cjs +80 -0
  134. package/dist/rating.d.cts +14 -0
  135. package/dist/rating.d.ts +14 -0
  136. package/dist/rating.js +58 -0
  137. package/dist/select.cjs +791 -0
  138. package/dist/select.d.cts +130 -0
  139. package/dist/select.d.ts +130 -0
  140. package/dist/select.js +757 -0
  141. package/dist/separator.cjs +57 -0
  142. package/dist/separator.d.cts +13 -0
  143. package/dist/separator.d.ts +13 -0
  144. package/dist/separator.js +51 -0
  145. package/dist/skeleton.cjs +370 -0
  146. package/dist/skeleton.d.cts +60 -0
  147. package/dist/skeleton.d.ts +60 -0
  148. package/dist/skeleton.js +346 -0
  149. package/dist/slider.cjs +138 -0
  150. package/dist/slider.d.cts +24 -0
  151. package/dist/slider.d.ts +24 -0
  152. package/dist/slider.js +115 -0
  153. package/dist/strategy-builder.cjs +373 -0
  154. package/dist/strategy-builder.d.cts +26 -0
  155. package/dist/strategy-builder.d.ts +26 -0
  156. package/dist/strategy-builder.js +351 -0
  157. package/dist/switch.cjs +163 -0
  158. package/dist/switch.d.cts +27 -0
  159. package/dist/switch.d.ts +27 -0
  160. package/dist/switch.js +140 -0
  161. package/dist/table.cjs +1044 -0
  162. package/dist/table.d.cts +107 -0
  163. package/dist/table.d.ts +107 -0
  164. package/dist/table.js +1013 -0
  165. package/dist/tabs.cjs +240 -0
  166. package/dist/tabs.d.cts +78 -0
  167. package/dist/tabs.d.ts +78 -0
  168. package/dist/tabs.js +213 -0
  169. package/dist/tag.cjs +651 -0
  170. package/dist/tag.d.cts +21 -0
  171. package/dist/tag.d.ts +21 -0
  172. package/dist/tag.js +628 -0
  173. package/dist/textarea.cjs +65 -0
  174. package/dist/textarea.d.cts +11 -0
  175. package/dist/textarea.d.ts +11 -0
  176. package/dist/textarea.js +59 -0
  177. package/dist/toaster.cjs +99 -0
  178. package/dist/toaster.d.cts +29 -0
  179. package/dist/toaster.d.ts +29 -0
  180. package/dist/toaster.js +96 -0
  181. package/dist/tooltip.cjs +171 -0
  182. package/dist/tooltip.d.cts +39 -0
  183. package/dist/tooltip.d.ts +39 -0
  184. package/dist/tooltip.js +148 -0
  185. package/dist/utils.cjs +11 -0
  186. package/dist/utils.d.cts +5 -0
  187. package/dist/utils.d.ts +5 -0
  188. package/dist/utils.js +9 -0
  189. package/package.json +284 -85
  190. package/src/accordion.tsx +285 -0
  191. package/src/alert.tsx +221 -0
  192. package/src/avatar.tsx +174 -0
  193. package/src/badge.tsx +158 -0
  194. package/src/bank.tsx +325 -0
  195. package/src/button.tsx +411 -0
  196. package/src/checkbox.tsx +307 -0
  197. package/src/close-button.tsx +51 -0
  198. package/src/collapsible.tsx +126 -0
  199. package/src/color-mode.tsx +125 -0
  200. package/src/dialog.tsx +356 -0
  201. package/src/drawer.tsx +186 -0
  202. package/src/empty-state.tsx +97 -0
  203. package/src/expiration-selector.tsx +163 -0
  204. package/src/field.tsx +202 -0
  205. package/src/greeks-display.tsx +138 -0
  206. package/src/heading.tsx +55 -0
  207. package/src/icon-button.tsx +192 -0
  208. package/src/image.tsx +280 -0
  209. package/src/index.ts +268 -0
  210. package/src/input-group.tsx +159 -0
  211. package/src/input.tsx +60 -0
  212. package/src/link.tsx +326 -0
  213. package/src/menu.tsx +471 -0
  214. package/src/option-chain.tsx +261 -0
  215. package/src/option-position.tsx +200 -0
  216. package/src/pin-input.tsx +187 -0
  217. package/src/pnl-diagram.tsx +458 -0
  218. package/src/popover.tsx +400 -0
  219. package/src/progress-circle.tsx +180 -0
  220. package/src/progress.tsx +109 -0
  221. package/src/provider.tsx +12 -0
  222. package/src/radio.tsx +175 -0
  223. package/src/rating.tsx +79 -0
  224. package/src/select.tsx +696 -0
  225. package/src/separator.tsx +59 -0
  226. package/src/skeleton.tsx +302 -0
  227. package/src/slider.tsx +152 -0
  228. package/src/strategy-builder.tsx +466 -0
  229. package/src/switch.tsx +158 -0
  230. package/src/table.tsx +621 -0
  231. package/src/tabs.tsx +354 -0
  232. package/src/tag.tsx +159 -0
  233. package/src/textarea.tsx +60 -0
  234. package/src/toaster.tsx +117 -0
  235. package/src/tokens.css +438 -0
  236. package/src/tooltip.tsx +184 -0
  237. package/src/utils/cn.ts +7 -0
  238. package/src/utils.ts +6 -0
  239. package/tokens.css +438 -0
  240. package/README.md +0 -109
@@ -0,0 +1,458 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+
5
+ import { cn } from './utils';
6
+
7
+ import type { StrategyLeg } from './strategy-builder';
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Types
11
+ // ---------------------------------------------------------------------------
12
+
13
+ export type { StrategyLeg };
14
+
15
+ export interface PnlDiagramProps extends React.HTMLAttributes<HTMLDivElement> {
16
+ readonly legs: StrategyLeg[];
17
+ readonly spotPrice: number;
18
+ readonly multiplier?: number;
19
+ }
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // P/L calculation
23
+ // ---------------------------------------------------------------------------
24
+
25
+ function calcLegPnl(leg: StrategyLeg, price: number, multiplier: number): number {
26
+ const premium = leg.premium ?? 0;
27
+ let intrinsic: number;
28
+ if (leg.type === 'call') {
29
+ intrinsic = Math.max(price - leg.strike, 0);
30
+ } else {
31
+ intrinsic = Math.max(leg.strike - price, 0);
32
+ }
33
+ const sign = leg.action === 'buy' ? 1 : -1;
34
+ return sign * (intrinsic - premium) * leg.quantity * multiplier;
35
+ }
36
+
37
+ function calcTotalPnl(legs: StrategyLeg[], price: number, multiplier: number): number {
38
+ return legs.reduce((sum, leg) => sum + calcLegPnl(leg, price, multiplier), 0);
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // SVG helpers
43
+ // ---------------------------------------------------------------------------
44
+
45
+ interface ViewBox {
46
+ xMin: number;
47
+ xMax: number;
48
+ yMin: number;
49
+ yMax: number;
50
+ }
51
+
52
+ function buildPath(points: Array<{ x: number; y: number }>, vb: ViewBox, width: number, height: number): string {
53
+ if (points.length === 0) return '';
54
+ const scaleX = (x: number) => ((x - vb.xMin) / (vb.xMax - vb.xMin)) * width;
55
+ const scaleY = (y: number) => height - ((y - vb.yMin) / (vb.yMax - vb.yMin)) * height;
56
+
57
+ const parts = points.map((p, i) => {
58
+ const sx = scaleX(p.x);
59
+ const sy = scaleY(p.y);
60
+ return i === 0 ? `M${sx},${sy}` : `L${sx},${sy}`;
61
+ });
62
+ return parts.join(' ');
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Leg colors
67
+ // ---------------------------------------------------------------------------
68
+
69
+ const LEG_COLORS = ['#6366f1', '#f59e0b', '#ec4899', '#06b6d4'];
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // PnlDiagram
73
+ // ---------------------------------------------------------------------------
74
+
75
+ export const PnlDiagram = React.forwardRef<HTMLDivElement, PnlDiagramProps>(
76
+ function PnlDiagram(props, ref) {
77
+ const {
78
+ legs,
79
+ spotPrice,
80
+ multiplier = 100,
81
+ className,
82
+ ...rest
83
+ } = props;
84
+
85
+ const containerRef = React.useRef<HTMLDivElement>(null);
86
+ const [hoverX, setHoverX] = React.useState<number | null>(null);
87
+ const [dims, setDims] = React.useState({ width: 600, height: 240 });
88
+
89
+ // Observe container size
90
+ React.useEffect(() => {
91
+ const el = containerRef.current;
92
+ if (!el) return;
93
+ const observer = new ResizeObserver((entries) => {
94
+ const entry = entries[0];
95
+ if (entry) {
96
+ setDims({
97
+ width: Math.max(entry.contentRect.width, 200),
98
+ height: Math.max(entry.contentRect.height, 120),
99
+ });
100
+ }
101
+ });
102
+ observer.observe(el);
103
+ return () => observer.disconnect();
104
+ }, []);
105
+
106
+ // Compute price range: strikes +/- 20%
107
+ const allStrikes = legs.map((l) => l.strike);
108
+ const minStrike = Math.min(...allStrikes, spotPrice);
109
+ const maxStrike = Math.max(...allStrikes, spotPrice);
110
+ const range = maxStrike - minStrike || spotPrice * 0.1;
111
+ const xMin = minStrike - range * 0.3;
112
+ const xMax = maxStrike + range * 0.3;
113
+
114
+ // Sample points
115
+ const SAMPLES = 200;
116
+ const step = (xMax - xMin) / SAMPLES;
117
+
118
+ const combinedPoints = React.useMemo(() => {
119
+ const pts: Array<{ x: number; y: number }> = [];
120
+ for (let i = 0; i <= SAMPLES; i++) {
121
+ const price = xMin + i * step;
122
+ pts.push({ x: price, y: calcTotalPnl(legs, price, multiplier) });
123
+ }
124
+ return pts;
125
+ }, [legs, multiplier, xMin, step]);
126
+
127
+ const legPointSets = React.useMemo(() => {
128
+ return legs.map((leg) => {
129
+ const pts: Array<{ x: number; y: number }> = [];
130
+ for (let i = 0; i <= SAMPLES; i++) {
131
+ const price = xMin + i * step;
132
+ pts.push({ x: price, y: calcLegPnl(leg, price, multiplier) });
133
+ }
134
+ return pts;
135
+ });
136
+ }, [legs, multiplier, xMin, step]);
137
+
138
+ // Y-axis range
139
+ const allY = combinedPoints.map((p) => p.y);
140
+ const rawYMin = Math.min(...allY);
141
+ const rawYMax = Math.max(...allY);
142
+ const yPad = Math.max((rawYMax - rawYMin) * 0.15, 10);
143
+ const vb: ViewBox = { xMin, xMax, yMin: rawYMin - yPad, yMax: rawYMax + yPad };
144
+
145
+ const { width, height } = dims;
146
+ const PADDING = { top: 20, bottom: 28, left: 56, right: 16 };
147
+ const chartW = width - PADDING.left - PADDING.right;
148
+ const chartH = height - PADDING.top - PADDING.bottom;
149
+
150
+ const scaleX = (x: number) => PADDING.left + ((x - vb.xMin) / (vb.xMax - vb.xMin)) * chartW;
151
+ const scaleY = (y: number) => PADDING.top + chartH - ((y - vb.yMin) / (vb.yMax - vb.yMin)) * chartH;
152
+
153
+ // Breakeven points (where combined PnL crosses zero)
154
+ const breakevens = React.useMemo(() => {
155
+ const pts: number[] = [];
156
+ for (let i = 1; i < combinedPoints.length; i++) {
157
+ const prev = combinedPoints[i - 1]!;
158
+ const curr = combinedPoints[i]!;
159
+ if ((prev.y <= 0 && curr.y >= 0) || (prev.y >= 0 && curr.y <= 0)) {
160
+ // Linear interpolation
161
+ const ratio = Math.abs(prev.y) / (Math.abs(prev.y) + Math.abs(curr.y));
162
+ pts.push(prev.x + ratio * (curr.x - prev.x));
163
+ }
164
+ }
165
+ return pts;
166
+ }, [combinedPoints]);
167
+
168
+ // Max gain / max loss
169
+ const maxGain = Math.max(...allY);
170
+ const maxLoss = Math.min(...allY);
171
+ const maxGainPoint = combinedPoints.find((p) => p.y === maxGain);
172
+ const maxLossPoint = combinedPoints.find((p) => p.y === maxLoss);
173
+
174
+ // Zero line
175
+ const zeroY = scaleY(0);
176
+
177
+ // Hover
178
+ const handleMouseMove = React.useCallback(
179
+ (e: React.MouseEvent<SVGSVGElement>) => {
180
+ const rect = e.currentTarget.getBoundingClientRect();
181
+ const mouseX = e.clientX - rect.left;
182
+ const price = vb.xMin + ((mouseX - PADDING.left) / chartW) * (vb.xMax - vb.xMin);
183
+ if (price >= vb.xMin && price <= vb.xMax) {
184
+ setHoverX(price);
185
+ } else {
186
+ setHoverX(null);
187
+ }
188
+ },
189
+ [vb.xMin, vb.xMax, chartW],
190
+ );
191
+
192
+ const handleMouseLeave = React.useCallback(() => setHoverX(null), []);
193
+
194
+ const hoverPnl = hoverX !== null ? calcTotalPnl(legs, hoverX, multiplier) : null;
195
+
196
+ // Combined path with fill
197
+ const combinedPath = buildPath(
198
+ combinedPoints.map((p) => ({ x: scaleX(p.x), y: scaleY(p.y) })).map((p) => ({ x: p.x - PADDING.left, y: p.y - PADDING.top })),
199
+ { xMin: 0, xMax: chartW, yMin: 0, yMax: chartH },
200
+ chartW,
201
+ chartH,
202
+ );
203
+
204
+ return (
205
+ <div
206
+ ref={ref}
207
+ className={cn('w-full', className)}
208
+ {...rest}
209
+ >
210
+ <div ref={containerRef} className="w-full h-full min-h-[200px]">
211
+ <svg
212
+ width={width}
213
+ height={height}
214
+ viewBox={`0 0 ${width} ${height}`}
215
+ className="select-none"
216
+ onMouseMove={handleMouseMove}
217
+ onMouseLeave={handleMouseLeave}
218
+ >
219
+ {/* Background */}
220
+ <rect x={PADDING.left} y={PADDING.top} width={chartW} height={chartH} fill="#09090b" rx="2" />
221
+
222
+ {/* Grid lines */}
223
+ {Array.from({ length: 5 }).map((_, i) => {
224
+ const y = PADDING.top + (chartH / 4) * i;
225
+ const val = vb.yMax - ((vb.yMax - vb.yMin) / 4) * i;
226
+ return (
227
+ <g key={`grid-y-${i}`}>
228
+ <line
229
+ x1={PADDING.left} y1={y} x2={PADDING.left + chartW} y2={y}
230
+ stroke="#27272a" strokeWidth="1" strokeDasharray="2,2"
231
+ />
232
+ <text
233
+ x={PADDING.left - 4} y={y + 3}
234
+ fill="#71717a" fontSize="9" fontFamily="monospace" textAnchor="end"
235
+ >
236
+ {val >= 0 ? '+' : ''}{val.toFixed(0)}
237
+ </text>
238
+ </g>
239
+ );
240
+ })}
241
+
242
+ {/* Zero line */}
243
+ {zeroY >= PADDING.top && zeroY <= PADDING.top + chartH && (
244
+ <line
245
+ x1={PADDING.left} y1={zeroY} x2={PADDING.left + chartW} y2={zeroY}
246
+ stroke="#3f3f46" strokeWidth="1"
247
+ />
248
+ )}
249
+
250
+ {/* Individual leg lines (dashed) */}
251
+ {legPointSets.map((pts, i) => {
252
+ const path = pts.map((p, j) => {
253
+ const sx = scaleX(p.x);
254
+ const sy = scaleY(p.y);
255
+ return j === 0 ? `M${sx},${sy}` : `L${sx},${sy}`;
256
+ }).join(' ');
257
+ return (
258
+ <path
259
+ key={`leg-${i}`}
260
+ d={path}
261
+ fill="none"
262
+ stroke={LEG_COLORS[i % LEG_COLORS.length]}
263
+ strokeWidth="1"
264
+ strokeDasharray="4,3"
265
+ opacity="0.5"
266
+ />
267
+ );
268
+ })}
269
+
270
+ {/* Combined P/L fill (green above zero, red below) */}
271
+ {combinedPoints.length > 0 && (() => {
272
+ const path = combinedPoints.map((p, i) => {
273
+ const sx = scaleX(p.x);
274
+ const sy = scaleY(p.y);
275
+ return i === 0 ? `M${sx},${sy}` : `L${sx},${sy}`;
276
+ }).join(' ');
277
+ const lastX = scaleX(combinedPoints[combinedPoints.length - 1]!.x);
278
+ const firstX = scaleX(combinedPoints[0]!.x);
279
+ const clampedZeroY = Math.max(PADDING.top, Math.min(PADDING.top + chartH, zeroY));
280
+ const fillPath = `${path} L${lastX},${clampedZeroY} L${firstX},${clampedZeroY} Z`;
281
+ return (
282
+ <>
283
+ <defs>
284
+ <linearGradient id="pnl-grad" x1="0" y1="0" x2="0" y2="1">
285
+ <stop offset="0%" stopColor="#22c55e" stopOpacity="0.15" />
286
+ <stop offset="50%" stopColor="#22c55e" stopOpacity="0.02" />
287
+ <stop offset="50%" stopColor="#ef4444" stopOpacity="0.02" />
288
+ <stop offset="100%" stopColor="#ef4444" stopOpacity="0.15" />
289
+ </linearGradient>
290
+ </defs>
291
+ <path d={fillPath} fill="url(#pnl-grad)" />
292
+ </>
293
+ );
294
+ })()}
295
+
296
+ {/* Combined P/L line */}
297
+ {combinedPoints.length > 0 && (
298
+ <path
299
+ d={combinedPoints.map((p, i) => {
300
+ const sx = scaleX(p.x);
301
+ const sy = scaleY(p.y);
302
+ return i === 0 ? `M${sx},${sy}` : `L${sx},${sy}`;
303
+ }).join(' ')}
304
+ fill="none"
305
+ stroke="#e4e4e7"
306
+ strokeWidth="1.5"
307
+ />
308
+ )}
309
+
310
+ {/* Current price vertical line */}
311
+ <line
312
+ x1={scaleX(spotPrice)} y1={PADDING.top}
313
+ x2={scaleX(spotPrice)} y2={PADDING.top + chartH}
314
+ stroke="#a1a1aa" strokeWidth="1" strokeDasharray="3,3"
315
+ />
316
+ <text
317
+ x={scaleX(spotPrice)} y={PADDING.top + chartH + 14}
318
+ fill="#a1a1aa" fontSize="9" fontFamily="monospace" textAnchor="middle"
319
+ >
320
+ {spotPrice.toFixed(2)}
321
+ </text>
322
+
323
+ {/* Breakeven markers */}
324
+ {breakevens.map((be, i) => (
325
+ <g key={`be-${i}`}>
326
+ <circle cx={scaleX(be)} cy={zeroY} r="3" fill="#fbbf24" stroke="#18181b" strokeWidth="1" />
327
+ <text
328
+ x={scaleX(be)} y={PADDING.top + chartH + 14}
329
+ fill="#fbbf24" fontSize="8" fontFamily="monospace" textAnchor="middle"
330
+ >
331
+ {be.toFixed(1)}
332
+ </text>
333
+ </g>
334
+ ))}
335
+
336
+ {/* Max gain marker */}
337
+ {maxGainPoint && maxGain > 0 && (
338
+ <g>
339
+ <polygon
340
+ points={`${scaleX(maxGainPoint.x)},${scaleY(maxGainPoint.y) - 8} ${scaleX(maxGainPoint.x) - 4},${scaleY(maxGainPoint.y) - 2} ${scaleX(maxGainPoint.x) + 4},${scaleY(maxGainPoint.y) - 2}`}
341
+ fill="#22c55e"
342
+ />
343
+ </g>
344
+ )}
345
+
346
+ {/* Max loss marker */}
347
+ {maxLossPoint && maxLoss < 0 && (
348
+ <g>
349
+ <polygon
350
+ points={`${scaleX(maxLossPoint.x)},${scaleY(maxLossPoint.y) + 8} ${scaleX(maxLossPoint.x) - 4},${scaleY(maxLossPoint.y) + 2} ${scaleX(maxLossPoint.x) + 4},${scaleY(maxLossPoint.y) + 2}`}
351
+ fill="#ef4444"
352
+ />
353
+ </g>
354
+ )}
355
+
356
+ {/* Hover crosshair */}
357
+ {hoverX !== null && hoverPnl !== null && (
358
+ <g>
359
+ <line
360
+ x1={scaleX(hoverX)} y1={PADDING.top}
361
+ x2={scaleX(hoverX)} y2={PADDING.top + chartH}
362
+ stroke="#52525b" strokeWidth="1"
363
+ />
364
+ <circle
365
+ cx={scaleX(hoverX)}
366
+ cy={scaleY(hoverPnl)}
367
+ r="3"
368
+ fill={hoverPnl >= 0 ? '#22c55e' : '#ef4444'}
369
+ stroke="#18181b"
370
+ strokeWidth="1"
371
+ />
372
+ {/* Tooltip */}
373
+ <rect
374
+ x={scaleX(hoverX) + 8}
375
+ y={scaleY(hoverPnl) - 20}
376
+ width="90"
377
+ height="28"
378
+ rx="3"
379
+ fill="#18181b"
380
+ stroke="#3f3f46"
381
+ strokeWidth="1"
382
+ />
383
+ <text
384
+ x={scaleX(hoverX) + 14}
385
+ y={scaleY(hoverPnl) - 10}
386
+ fill="#a1a1aa"
387
+ fontSize="8"
388
+ fontFamily="monospace"
389
+ >
390
+ ${hoverX.toFixed(2)}
391
+ </text>
392
+ <text
393
+ x={scaleX(hoverX) + 14}
394
+ y={scaleY(hoverPnl) + 1}
395
+ fill={hoverPnl >= 0 ? '#22c55e' : '#ef4444'}
396
+ fontSize="9"
397
+ fontFamily="monospace"
398
+ fontWeight="600"
399
+ >
400
+ {hoverPnl >= 0 ? '+' : ''}{hoverPnl.toFixed(2)}
401
+ </text>
402
+ </g>
403
+ )}
404
+
405
+ {/* X-axis labels */}
406
+ {Array.from({ length: 5 }).map((_, i) => {
407
+ const x = vb.xMin + ((vb.xMax - vb.xMin) / 4) * i;
408
+ return (
409
+ <text
410
+ key={`x-label-${i}`}
411
+ x={scaleX(x)}
412
+ y={PADDING.top + chartH + 14}
413
+ fill="#52525b"
414
+ fontSize="8"
415
+ fontFamily="monospace"
416
+ textAnchor="middle"
417
+ >
418
+ {x.toFixed(0)}
419
+ </text>
420
+ );
421
+ })}
422
+ </svg>
423
+ </div>
424
+
425
+ {/* Legend */}
426
+ <div className="flex flex-wrap items-center gap-4 px-2 pt-1">
427
+ <div className="flex items-center gap-1.5">
428
+ <div className="h-0.5 w-4 bg-zinc-200 rounded" />
429
+ <span className="text-[10px] text-zinc-400">Combined</span>
430
+ </div>
431
+ {legs.map((leg, i) => (
432
+ <div key={i} className="flex items-center gap-1.5">
433
+ <div
434
+ className="h-0.5 w-4 rounded"
435
+ style={{
436
+ backgroundColor: LEG_COLORS[i % LEG_COLORS.length],
437
+ opacity: 0.6,
438
+ backgroundImage: `repeating-linear-gradient(90deg, transparent, transparent 2px, ${LEG_COLORS[i % LEG_COLORS.length]}00 2px, ${LEG_COLORS[i % LEG_COLORS.length]}00 5px)`,
439
+ }}
440
+ />
441
+ <span className="text-[10px] text-zinc-500">
442
+ {leg.action === 'buy' ? 'B' : 'S'} {leg.quantity} {leg.strike} {leg.type === 'call' ? 'C' : 'P'}
443
+ </span>
444
+ </div>
445
+ ))}
446
+ {breakevens.length > 0 && (
447
+ <div className="flex items-center gap-1.5">
448
+ <div className="h-1.5 w-1.5 rounded-full bg-amber-400" />
449
+ <span className="text-[10px] text-zinc-500">
450
+ BE: {breakevens.map((b) => b.toFixed(1)).join(', ')}
451
+ </span>
452
+ </div>
453
+ )}
454
+ </div>
455
+ </div>
456
+ );
457
+ },
458
+ );