|
1 | 1 | <script lang="ts"> |
2 | | - import type { TopVaults } from './schemas'; |
| 2 | + import type { TopVaults, VaultInfo } from './schemas'; |
3 | 3 | import type { Chain } from '$lib/helpers/chain'; |
4 | | - import { getChain, getExplorerUrl } from '$lib/helpers/chain'; |
5 | 4 | import Alert from '$lib/components/Alert.svelte'; |
6 | 5 | import CryptoAddressWidget from '$lib/components/CryptoAddressWidget.svelte'; |
7 | 6 | import TextInput from '$lib/components/TextInput.svelte'; |
|
11 | 10 | import FeesCell from './FeesCell.svelte'; |
12 | 11 | import DepositEventsCell from './DepositEventsCell.svelte'; |
13 | 12 | import RiskCell from './RiskCell.svelte'; |
| 13 | + import IconChevronUp from '~icons/local/chevron-up'; |
| 14 | + import IconChevronDown from '~icons/local/chevron-down'; |
| 15 | + import { getChain, getExplorerUrl } from '$lib/helpers/chain'; |
14 | 16 | import { formatDollar, formatNumber, formatPercent, formatValue } from '$lib/helpers/formatters'; |
15 | 17 |
|
| 18 | + interface SortOptions { |
| 19 | + key: string; |
| 20 | + direction: 'asc' | 'desc'; |
| 21 | + compareFn: (a: VaultInfo, b: VaultInfo) => number; |
| 22 | + } |
| 23 | +
|
16 | 24 | interface Props { |
17 | 25 | topVaults: TopVaults; |
18 | 26 | chain?: Chain; |
|
26 | 34 | let filterValue = $state(''); |
27 | 35 |
|
28 | 36 | // filter vaults |
29 | | - let vaults = $derived.by(() => { |
| 37 | + let filteredVaults = $derived.by(() => { |
30 | 38 | const filterCompareStr = filterValue.trim().toLowerCase(); |
31 | 39 | return topVaults.vaults.filter((v) => { |
32 | 40 | const chain = getChain(v.chain_id); |
|
42 | 50 | return vaultCompareStr.toLowerCase().includes(filterCompareStr); |
43 | 51 | }); |
44 | 52 | }); |
| 53 | +
|
| 54 | + let sortOptions = $state<SortOptions>({ |
| 55 | + key: 'one_month_return_ann', |
| 56 | + direction: 'desc', |
| 57 | + compareFn: multiValCompare(['one_month_cagr_net', 'one_month_cagr']) |
| 58 | + }); |
| 59 | +
|
| 60 | + // sort vaults |
| 61 | + let sortedVaults = $derived.by(() => { |
| 62 | + const sorted = filteredVaults.toSorted(sortOptions.compareFn); |
| 63 | + if (sortOptions.direction === 'desc') sorted.reverse(); |
| 64 | + return sorted; |
| 65 | + }); |
| 66 | +
|
| 67 | + function multiValCompare(keys: (keyof VaultInfo)[], defaultValue = -Infinity) { |
| 68 | + return (a: VaultInfo, b: VaultInfo) => { |
| 69 | + for (const key of keys) { |
| 70 | + const aVal = a[key] as number | null; |
| 71 | + const bVal = b[key] as number | null; |
| 72 | + if (aVal === bVal) continue; |
| 73 | + return (aVal ?? defaultValue) - (bVal ?? defaultValue); |
| 74 | + } |
| 75 | + return 0; |
| 76 | + }; |
| 77 | + } |
| 78 | +
|
| 79 | + function stringCompare(sortBy: (v: VaultInfo) => string) { |
| 80 | + return (a: VaultInfo, b: VaultInfo) => { |
| 81 | + return sortBy(a).localeCompare(sortBy(b)); |
| 82 | + }; |
| 83 | + } |
| 84 | +
|
| 85 | + function sortBy( |
| 86 | + key: SortOptions['key'], |
| 87 | + direction: SortOptions['direction'], |
| 88 | + compareFn: (a: VaultInfo, b: VaultInfo) => number |
| 89 | + ) { |
| 90 | + if (sortOptions.key === key) { |
| 91 | + direction = sortOptions.direction === 'asc' ? 'desc' : 'asc'; |
| 92 | + } |
| 93 | + sortOptions = { key, direction, compareFn }; |
| 94 | + } |
45 | 95 | </script> |
46 | 96 |
|
| 97 | +{#snippet sortColHeader( |
| 98 | + label: string, |
| 99 | + key: string, |
| 100 | + direction: SortOptions['direction'], |
| 101 | + compareFn: SortOptions['compareFn'] |
| 102 | +)} |
| 103 | + <th class={key}> |
| 104 | + <button onclick={() => sortBy(key, direction, compareFn)}> |
| 105 | + <!-- eslint-disable-next-line svelte/no-at-html-tags --> |
| 106 | + {@html label} |
| 107 | + {#if sortOptions.key === key} |
| 108 | + {#if sortOptions.direction === 'asc'} |
| 109 | + <IconChevronUp /> |
| 110 | + {:else} |
| 111 | + <IconChevronDown /> |
| 112 | + {/if} |
| 113 | + {/if} |
| 114 | + </button> |
| 115 | + </th> |
| 116 | +{/snippet} |
| 117 | + |
47 | 118 | {#snippet multiVal<T = MaybeNumber>(values: [T, T], formatter: Formatter<T>)} |
48 | 119 | <div class="multiline multival"> |
49 | 120 | <span class="primary">{formatter(values[0])}</span> |
|
70 | 141 | <thead> |
71 | 142 | <tr> |
72 | 143 | <th class="index"></th> |
73 | | - <th class="chain"></th> |
74 | | - <th class="vault">Vault</th> |
75 | | - <th class="one_month_return_ann">1M return ann.<br />(net/​gross)</th> |
76 | | - <th class="three_months_return_ann">3M return ann.<br />(net/​gross)</th> |
77 | | - <th class="lifetime_return_ann">Lifetime return ann.<br />(net/​gross)</th> |
78 | | - <th class="lifetime_return_abs">Lifetime return abs.<br />(net/​gross)</th> |
79 | | - <th class="three_months_sharpe">3m Sharpe</th> |
80 | | - <th class="three_months_volatility">3M Vola-tility</th> |
81 | | - <th class="denomination">Denom-ination</th> |
82 | | - <th class="tvl">TVL USD<br />(current/​peak)</th> |
83 | | - <th class="age">Age (Years)</th> |
84 | | - <th class="fees">Fees<br />(mgmt/​perf)</th> |
85 | | - <th class="event_count">Deposit Events</th> |
86 | | - <th class="risk">Protocol Technical Risk</th> |
| 144 | + {@render sortColHeader( |
| 145 | + '', |
| 146 | + 'chain', |
| 147 | + 'asc', |
| 148 | + stringCompare((v) => getChain(v.chain_id)?.name ?? `Chain ${v.chain_id}`) |
| 149 | + )} |
| 150 | + {@render sortColHeader( |
| 151 | + 'Vault', |
| 152 | + 'vault', |
| 153 | + 'asc', |
| 154 | + stringCompare((v) => `${v.name.trim()} ${v.protocol}`) |
| 155 | + )} |
| 156 | + {@render sortColHeader( |
| 157 | + '1M return ann.<br/>(net/​gross)', |
| 158 | + 'one_month_return_ann', |
| 159 | + 'desc', |
| 160 | + multiValCompare(['one_month_cagr_net', 'one_month_cagr']) |
| 161 | + )} |
| 162 | + {@render sortColHeader( |
| 163 | + '3M return ann.<br/>(net/​gross)', |
| 164 | + 'three_months_return_ann', |
| 165 | + 'desc', |
| 166 | + multiValCompare(['three_months_cagr_net', 'three_months_cagr']) |
| 167 | + )} |
| 168 | + {@render sortColHeader( |
| 169 | + 'Lifetime return ann.<br/>(net/​gross)', |
| 170 | + 'lifetime_return_ann', |
| 171 | + 'desc', |
| 172 | + multiValCompare(['cagr_net', 'cagr']) |
| 173 | + )} |
| 174 | + {@render sortColHeader( |
| 175 | + 'Lifetime return abs.<br/>(net/​gross)', |
| 176 | + 'lifetime_return_abs', |
| 177 | + 'desc', |
| 178 | + multiValCompare(['lifetime_return_net', 'lifetime_return']) |
| 179 | + )} |
| 180 | + {@render sortColHeader( |
| 181 | + '3m Sharpe', |
| 182 | + 'three_months_sharpe', |
| 183 | + 'desc', |
| 184 | + multiValCompare(['three_months_sharpe']) |
| 185 | + )} |
| 186 | + {@render sortColHeader( |
| 187 | + '3M Vola­tility', |
| 188 | + 'three_months_volatility', |
| 189 | + 'asc', |
| 190 | + multiValCompare(['three_months_volatility']) |
| 191 | + )} |
| 192 | + {@render sortColHeader( |
| 193 | + 'Denom­ination', |
| 194 | + 'denomination', |
| 195 | + 'asc', |
| 196 | + stringCompare((v) => v.denomination) |
| 197 | + )} |
| 198 | + {@render sortColHeader( |
| 199 | + 'TVL USD<br/>(current/​peak)', |
| 200 | + 'tvl', |
| 201 | + 'desc', |
| 202 | + multiValCompare(['current_nav', 'peak_nav']) |
| 203 | + )} |
| 204 | + {@render sortColHeader('Age (Years)', 'age', 'desc', multiValCompare(['years']))} |
| 205 | + {@render sortColHeader( |
| 206 | + 'Fees<br />(mgmt/​perf)', |
| 207 | + 'fees', |
| 208 | + 'asc', |
| 209 | + multiValCompare(['mgmt_fee', 'perf_fee'], Infinity) |
| 210 | + )} |
| 211 | + {@render sortColHeader('Deposit Events', 'event_count', 'desc', multiValCompare(['event_count']))} |
| 212 | + {@render sortColHeader( |
| 213 | + 'Protocol Technical Risk', |
| 214 | + 'risk', |
| 215 | + 'asc', |
| 216 | + multiValCompare(['risk_numeric'], Infinity) |
| 217 | + )} |
87 | 218 | <th class="address">Vault Address</th> |
88 | 219 | </tr> |
89 | 220 | </thead> |
90 | 221 | <tbody> |
91 | | - {#each vaults as vault (vault.id)} |
| 222 | + {#each sortedVaults as vault (vault.id)} |
92 | 223 | {@const chain = getChain(vault.chain_id)} |
93 | 224 | <tr> |
94 | 225 | <!-- index cell is populated with row index via `rowNumber` CSS counter --> |
|
229 | 360 | background: color-mix(in srgb, var(--c-body), hsl(var(--hsl-box)) var(--box-4-alpha)); |
230 | 361 | /* sticky header border gets lost on scroll, so use box-shadow instead */ |
231 | 362 | box-shadow: inset 0px -2px var(--c-text-extra-light); |
232 | | - padding: 0.5rem; |
233 | 363 | /* add extra padding to bottom to account for the inset box-shadow */ |
234 | | - padding-bottom: calc(0.5rem + 2px); |
| 364 | + --th-padding: 0.5rem 0.5rem calc(0.5rem + 2px) 0.5rem; |
235 | 365 | font-weight: 900; |
236 | 366 | text-transform: uppercase; |
237 | 367 | text-align: left; |
238 | | - } |
239 | 368 |
|
240 | | - th.sorted { |
241 | | - padding-right: 1.125rem; |
| 369 | + &:not(:has(button)) { |
| 370 | + padding: var(--th-padding); |
| 371 | + } |
242 | 372 |
|
243 | | - svg { |
244 | | - position: absolute; |
245 | | - top: 0.5rem; |
246 | | - right: 0.25rem; |
| 373 | + button { |
| 374 | + display: flex; |
| 375 | + border: none; |
| 376 | + width: 100%; |
| 377 | + min-height: 4.75rem; |
| 378 | + padding: var(--th-padding); |
| 379 | + background: transparent; |
| 380 | + font: inherit; |
| 381 | + text-align: inherit; |
| 382 | + text-transform: inherit; |
| 383 | + cursor: pointer; |
| 384 | + } |
| 385 | + |
| 386 | + :global(.icon) { |
| 387 | + min-width: 1em; |
| 388 | + translate: 0.25rem 0; |
| 389 | + |
| 390 | + :global(*) { |
| 391 | + stroke-width: 3; |
| 392 | + } |
| 393 | + } |
| 394 | + |
| 395 | + /* custom alignment for chain sort indicator (no header label) */ |
| 396 | + &.chain :global(.icon) { |
| 397 | + translate: 0; |
247 | 398 | } |
248 | 399 | } |
249 | 400 |
|
|
252 | 403 | background: var(--c-body); |
253 | 404 | } |
254 | 405 |
|
255 | | - /* custom alignment for chain sort indicator (no header label) */ |
256 | | - th.chain.sorted svg { |
257 | | - right: 0.5rem; |
258 | | - } |
259 | | - |
260 | 406 | td.chain { |
261 | 407 | width: 1.875rem; |
262 | 408 | } |
|
265 | 411 | min-width: 12rem; |
266 | 412 | } |
267 | 413 |
|
268 | | - /* flip the sort indicator on columns that use inverted sort */ |
269 | | - :is(th.chain, th.vault, th.denomination, th.fees, th.risk) svg { |
270 | | - rotate: 180deg; |
271 | | - } |
272 | | - |
273 | 414 | td { |
274 | 415 | border-block: 1px solid var(--c-text-ultra-light); |
275 | 416 | padding: 0.25em 0.5em; |
|
0 commit comments