Color-coded watchlist for multiple addresses/txs

This commit is contained in:
Mononaut 2022-02-08 22:50:04 -06:00
parent 0cce3ca92f
commit d03467b895
8 changed files with 303 additions and 29 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "bitfeed-client", "name": "bitfeed-client",
"version": "2.1.4", "version": "2.1.5",
"scripts": { "scripts": {
"build": "rollup -c", "build": "rollup -c",
"dev": "rollup -c -w", "dev": "rollup -c -w",

View File

@ -1,37 +1,268 @@
<script> <script>
import { tick } from 'svelte'
import { fade } from 'svelte/transition'
import { flip } from 'svelte/animate'
import Icon from './Icon.svelte'
import SearchIcon from '../assets/icon/cil-search.svg' import SearchIcon from '../assets/icon/cil-search.svg'
import PlusIcon from '../assets/icon/cil-plus.svg'
import CrossIcon from '../assets/icon/cil-x.svg'
import AddressIcon from '../assets/icon/cil-wallet.svg'
import TxIcon from '../assets/icon/cil-arrow-circle-right.svg'
import { matchQuery } from '../utils/search.js' import { matchQuery } from '../utils/search.js'
import { highlight } from '../stores.js' import { highlight, newHighlightQuery, highlightingFull } from '../stores.js'
import { hcl } from 'd3-color'
const highlightColors = [
{ h: 0.03, l: 0.35 },
{ h: 0.40, l: 0.35 },
{ h: 0.65, l: 0.35 },
{ h: 0.85, l: 0.35 },
{ h: 0.12, l: 0.35 },
]
const highlightHexColors = highlightColors.map(c => hclToHex(c))
const usedColors = [false, false, false, false, false]
const queryIcons = {
txid: TxIcon,
address: AddressIcon
}
const queryType = {
txid: 'transaction',
address: 'address'
}
export let tab
let query let query
let matchedQuery
let queryAddress
let queryColor
let queryColorHex
let watchlist = []
init ()
setNextColor()
$: { $: {
if (query) { if ($newHighlightQuery) {
const matchedQuery = matchQuery(query.trim()) matchedQuery = matchQuery($newHighlightQuery)
if (matchedQuery) { if (matchedQuery) {
$highlight = [matchedQuery] matchedQuery.color = queryColor
} else $highlight = [] matchedQuery.colorHex = queryColorHex
} else $highlight = [] add()
query = null
}
$newHighlightQuery = null
}
}
$: {
$highlight = matchedQuery ? [ ...watchlist, matchedQuery ] : watchlist
}
$: {
localStorage.setItem('highlight', JSON.stringify(watchlist))
}
$: {
if (query) {
matchedQuery = matchQuery(query.trim())
if (matchedQuery) {
matchedQuery.color = queryColor
matchedQuery.colorHex = queryColorHex
}
} else matchedQuery = null
}
$: {
$highlightingFull = watchlist.length >= 5
}
function init () {
const val = localStorage.getItem('highlight')
if (val != null) {
try {
watchlist = JSON.parse(val)
watchlist.forEach(q => {
const i = highlightHexColors.findIndex(c => c === q.colorHex)
if (i >= 0) usedColors[i] = q.colorHex
else console.log('unknown color')
})
} catch (e) {
console.log('failed to parse cached highlight queries')
}
}
}
function setNextColor () {
const nextIndex = usedColors.findIndex(used => !used)
if (nextIndex >= 0) {
queryColor = highlightColors[nextIndex]
queryColorHex = highlightHexColors[nextIndex]
usedColors[nextIndex] = queryColorHex
}
}
function clearUsedColor (hex) {
const clearIndex = usedColors.findIndex(used => used === hex)
usedColors[clearIndex] = false
}
function hclToHex (color) {
return hcl(color.h * 360, 78.225, color.l * 150).hex()
}
async function add () {
if (matchedQuery && !$highlightingFull) {
watchlist.push({
...matchedQuery
})
watchlist = watchlist
query = null
setNextColor()
if (tab) {
await tick()
tab.updateContentHeight(true)
}
}
}
async function remove (index) {
const wasFull = $highlightingFull
const removed = watchlist.splice(index,1)
if (removed.length) {
clearUsedColor(removed[0].colorHex)
watchlist = watchlist
if (tab) {
await tick()
tab.updateContentHeight(true)
}
if (wasFull) setNextColor()
}
}
function searchSubmit (e) {
e.preventDefault()
add()
return false
} }
</script> </script>
<style type="text/scss"> <style type="text/scss">
.search { .search {
.search-input { width: 274px;
color: var(--palette-a);
border: solid 3px var(--grey);
margin: 0;
width: 100%;
&:active { .watched, .input-wrapper {
border-color: var(--palette-good); width: 100%;
display: flex;
flex-direction: row;
align-items: baseline;
--input-color: var(--bold-a);
.search-form {
width: 100%;
margin: 0;
padding: 0;
.search-submit {
display: none;
}
}
.search-input {
background: none;
border: none;
outline: none;
margin: 0;
color: var(--input-color);
width: 100%;
&.disabled {
color: var(--palette-e);
user-select: none;
pointer-events: none;
}
}
.query {
width: 100%;
flex-grow: 1;
flex-shrink: 1;
overflow: hidden;
text-overflow: ellipsis;
}
.input-icon {
font-size: 24px;
margin: 7px;
transition: opacity 300ms, color 300ms, background 300ms;
color: var(--input-color);
&.add-query {
color: var(--light-good);
}
&.remove-query {
color: var(--light-bad);
}
&.icon-button {
cursor: pointer;
}
&.hidden {
opacity: 0;
}
&.icon-button {
background: var(--palette-d);
padding: 3px;
border-radius: 5px;
&:hover {
background: var(--palette-e);
}
&.disabled {
color: var(--palette-e);
background: none;
}
}
}
}
.input-wrapper {
border-bottom: solid 3px var(--input-color);
&.full {
border-color: var(--palette-e);
} }
} }
} }
</style> </style>
<div class="search tab-content"> <div class="search tab-content">
<div class="input-wrapper"> <div class="input-wrapper" class:full={$highlightingFull} style="--input-color: {queryColorHex};">
<input class="search-input" type="text" bind:value={query} placeholder="Enter an address or txid..."> {#if !$highlightingFull }
<form class="search-form" action="" on:submit={searchSubmit}>
<input class="search-input" type="text" bind:value={query} placeholder="Enter an address or txid...">
<button type="submit" class="search-submit" />
</form>
{:else}
<input class="search-input disabled" type="text" placeholder="Watchlist is full">
{/if}
<div class="input-icon add-query icon-button" class:disabled={matchedQuery == null || $highlightingFull} on:click={add} title="Add to watchlist">
<Icon icon={PlusIcon}/>
</div>
</div>
<div class="watchlist">
{#each watchlist as watched, index (watched.colorHex)}
<div
class="watched"
transition:fade={{ duration: 200 }}
animate:flip={{ duration: 200 }}
>
<div class="input-icon query-type" style="color: {watched.colorHex};" title={queryType[watched.query]}>
<Icon icon={queryIcons[watched.query]} />
</div>
<span class="query" style="color: {watched.colorHex};">{ watched.value }</span>
<div class="input-icon remove-query icon-button" on:click={() => remove(index)} title="Remove from watchlist">
<Icon icon={CrossIcon} />
</div>
</div>
{/each}
</div> </div>
</div> </div>

View File

@ -22,6 +22,8 @@ import SearchTab from '../components/SearchTab.svelte'
import { sidebarToggle, overlay, currentBlock, blockVisible, haveSupporters } from '../stores.js' import { sidebarToggle, overlay, currentBlock, blockVisible, haveSupporters } from '../stores.js'
let searchTabComponent
let blockHidden = false let blockHidden = false
$: blockHidden = ($currentBlock && !$blockVisible) $: blockHidden = ($currentBlock && !$blockVisible)
@ -114,12 +116,12 @@ function showBlock () {
<MempoolLegend /> <MempoolLegend />
</div> </div>
</SidebarTab> </SidebarTab>
<SidebarTab open={$sidebarToggle === 'search'} on:click={() => {settings('search')}} tooltip="Search & Highlight"> <SidebarTab open={$sidebarToggle === 'search'} on:click={() => {settings('search')}} tooltip="Search & Highlight" bind:this={searchTabComponent}>
<span slot="tab" title="Search & Highlight"> <span slot="tab" title="Search & Highlight">
<Icon icon={searchIcon} color="var(--bold-a)" /> <Icon icon={searchIcon} color="var(--bold-a)" />
</span> </span>
<div slot="content"> <div slot="content">
<SearchTab /> <SearchTab tab={searchTabComponent} />
</div> </div>
</SidebarTab> </SidebarTab>
<SidebarTab open={$sidebarToggle === 'settings'} on:click={() => {settings('settings')}} tooltip="Settings"> <SidebarTab open={$sidebarToggle === 'settings'} on:click={() => {settings('settings')}} tooltip="Settings">

View File

@ -9,7 +9,7 @@ let entered = false
let contentElement let contentElement
let contentSlotElement let contentSlotElement
async function updateContentHeight (isOpen) { export async function updateContentHeight (isOpen) {
if (contentElement && contentSlotElement) { if (contentElement && contentSlotElement) {
if (isOpen) { if (isOpen) {
contentElement.style.height = `${contentSlotElement.clientHeight}px` contentElement.style.height = `${contentSlotElement.clientHeight}px`

View File

@ -1,6 +1,8 @@
<script> <script>
import Icon from './Icon.svelte'
import BookmarkIcon from '../assets/icon/cil-bookmark.svg'
import { longBtcFormat, integerFormat } from '../utils/format.js' import { longBtcFormat, integerFormat } from '../utils/format.js'
import { exchangeRates, settings } from '../stores.js' import { exchangeRates, settings, sidebarToggle, newHighlightQuery, highlightingFull } from '../stores.js'
import { formatCurrency } from '../utils/fx.js' import { formatCurrency } from '../utils/fx.js'
export let tx export let tx
@ -32,6 +34,13 @@ $: {
function formatBTC (sats) { function formatBTC (sats) {
return `₿ ${longBtcFormat.format(sats/100000000)}` return `₿ ${longBtcFormat.format(sats/100000000)}`
} }
function highlight () {
if (!$highlightingFull && tx && tx.id) {
$newHighlightQuery = tx.id
$sidebarToggle = 'search'
}
}
</script> </script>
<style type="text/scss"> <style type="text/scss">
@ -81,10 +90,32 @@ function formatBTC (sats) {
word-break: break-all; word-break: break-all;
} }
} }
.icon-button {
float: right;
font-size: 24px;
margin: 0;
transition: opacity 300ms, color 300ms, background 300ms;
background: var(--palette-c);
color: var(--bold-a);
cursor: pointer;
padding: 5px;
border-radius: 5px;
&:hover {
background: var(--palette-e);
}
&.disabled {
color: var(--palette-e);
background: none;
}
}
} }
</style> </style>
<div class="tx-info" class:above style="left: {clampedX}px; top: {clampedY}px"> <div class="tx-info" class:above style="left: {clampedX}px; top: {clampedY}px">
<div class="icon-button" class:disabled={$highlightingFull} on:click={highlight} title="Add to watchlist">
<Icon icon={BookmarkIcon}/>
</div>
<p class="field hash"> <p class="field hash">
TxID: { tx.id } TxID: { tx.id }
</p> </p>

View File

@ -88,19 +88,22 @@ export default class BitcoinTx {
this.highlight = false this.highlight = false
} }
applyHighlighting (criteria, color = highlightColor) { applyHighlighting (criteria) {
let color
this.highlight = false this.highlight = false
criteria.forEach(criterion => { criteria.forEach(criterion => {
if (criterion.txid === this.id) { if (criterion.txid === this.id) {
this.highlight = true this.highlight = true
color = criterion.color
} else if (criterion.address && criterion.scriptPubKey) { } else if (criterion.address && criterion.scriptPubKey) {
this.outputs.forEach(output => { this.outputs.forEach(output => {
if (output.script_pub_key === criterion.scriptPubKey) { if (output.script_pub_key === criterion.scriptPubKey) {
this.highlight = true this.highlight = true
color = criterion.color
} }
}) })
} }
}) })
this.view.setHighlight(this.highlight, color) this.view.setHighlight(this.highlight, color || highlightColor)
} }
} }

View File

@ -121,3 +121,5 @@ const newVisitor = !localStorage.getItem('seen-welcome-msg')
export const overlay = writable(null) export const overlay = writable(null)
export const highlight = writable([]) export const highlight = writable([])
export const newHighlightQuery = writable(null)
export const highlightingFull = writable(false)

View File

@ -11,25 +11,28 @@ export function matchQuery (query) {
const asInt = parseInt(q) const asInt = parseInt(q)
// Remember to update the bounds in // Remember to update the bounds in
if (!isNaN(asInt) && asInt >= 0 && `${asInt}` === q) { if (!isNaN(asInt) && asInt >= 0 && `${asInt}` === q) {
return { return null /*{
query: 'blockheight', query: 'blockheight',
height: asInt height: asInt,
} value: asInt
}*/
} }
// Looks like a block hash? // Looks like a block hash?
if (/^0{8}[a-f0-9]{56}$/.test(q)) { if (/^0{8}[a-f0-9]{56}$/.test(q)) {
return { return null /* {
query: 'blockhash', query: 'blockhash',
hash: q hash: query,
} value: query,
}*/
} }
// Looks like a transaction id? // Looks like a transaction id?
if (/^[a-f0-9]{64}$/.test(q)) { if (/^[a-f0-9]{64}$/.test(q)) {
return { return {
query: 'txid', query: 'txid',
txid: q txid: q,
value: q
} }
} }
@ -47,6 +50,7 @@ export function matchQuery (query) {
encoding: 'base58', encoding: 'base58',
addressType, addressType,
address: query, address: query,
value: query,
scriptPubKey: addressToSPK(query) scriptPubKey: addressToSPK(query)
} }
} }
@ -67,6 +71,7 @@ export function matchQuery (query) {
encoding: 'bech32', encoding: 'bech32',
addressType, addressType,
address: query, address: query,
value: query,
scriptPubKey: addressToSPK(query) scriptPubKey: addressToSPK(query)
} }
} }