Skip to content

Instantly share code, notes, and snippets.

@ImanMousavi
Last active December 27, 2022 12:25
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ImanMousavi/136f4d6007a4feb429264cf707a4ca85 to your computer and use it in GitHub Desktop.
Save ImanMousavi/136f4d6007a4feb429264cf707a4ca85 to your computer and use it in GitHub Desktop.
CryptoCurrency Live Ticker with Vue
<div id="app" v-cloak>
<header class="header-wrap">
<div class="header-row flex-row flex-middle flex-space">
<div class="if-small">
<div class="form-input dark">
<div class="push-right">🔎</div>
<input type="text" v-model="search" placeholder="Search token..." />
</div>
</div>
<div class="text-primary if-medium">
<h1 class="text-nowrap text-condense shadow-text">Crypto Live</h1>
</div>
<div class="flex-row flex-middle">
<div class="dropdown">
<div class="form-input text-nowrap shadow-box">▼ {{ limit }}</div>
<ul>
<li @click="setLimit( 0 )"><span class="text-faded">Show:</span> All</li>
<li @click="setLimit( 10 )"><span class="text-faded">Show:</span> 10</li>
<li @click="setLimit( 20 )"><span class="text-faded">Show:</span> 20</li>
<li @click="setLimit( 50 )"><span class="text-faded">Show:</span> 50</li>
<li @click="setLimit( 100 )"><span class="text-faded">Show:</span> 100</li>
</ul>
</div>
<div class="dropdown">
<div class="form-input text-nowrap shadow-box">▼ {{ sortLabel }}</div>
<ul>
<li @click="sortBy( 'token', 'asc' )"><span class="text-faded">Sort:</span> Token</li>
<li @click="sortBy( 'close', 'desc' )"><span class="text-faded">Sort:</span> Price</li>
<li @click="sortBy( 'assetVolume', 'desc' )"><span class="text-faded">Sort:</span> Volume</li>
<li @click="sortBy( 'percent', 'desc' )"><span class="text-faded">Sort:</span> Percent</li>
<li @click="sortBy( 'change', 'desc' )"><span class="text-faded">Sort:</span> Change</li>
<li @click="sortBy( 'trades', 'desc' )"><span class="text-faded">Sort:</span> Trades</li>
</ul>
</div>
<div class="dropdown">
<div class="form-input text-nowrap shadow-box">▼ {{ asset }}</div>
<ul>
<li @click="filterAsset( 'BTC' )"><span class="text-faded">Asset:</span> BTC</li>
<li @click="filterAsset( 'ETH' )"><span class="text-faded">Asset:</span> ETH</li>
<li @click="filterAsset( 'BNB' )"><span class="text-faded">Asset:</span> BNB</li>
<li @click="filterAsset( 'USDT' )"><span class="text-faded">Asset:</span> USDT</li>
</ul>
</div>
</div>
</div>
</header>
<!-- price list grid -->
<main class="main-wrap">
<div class="main-grid-list">
<div class="main-grid-item" v-for="c in coinsList" :key="c.symbol" :class="c.style">
<div class="main-grid-info flex-row flex-top flex-stretch">
<div class="push-right">
<img :src="c.icon" :alt="c.pair" />
</div>
<div class="flex-1 shadow-text">
<div class="flex-row flex-top flex-space">
<div class="text-left text-clip push-right">
<h1 class="text-primary text-clip">{{ c.token }}<small class="text-faded text-small text-condense">/{{ c.asset }}</small></h1>
<h2 class="text-bright text-clip">{{ c.close | toFixed( asset ) }}</h2>
</div>
<div class="text-right">
<div class="color text-big text-clip">{{ c.arrow }} {{ c.sign }}{{ c.percent | toFixed( 2 ) }}%</div>
<div class="text-clip">{{ c.sign }}{{ c.change | toFixed( asset ) }} <small class="text-faded">24h</small></div>
<div class="text-clip">{{ c.assetVolume | toMoney }} <small class="text-faded">Vol</small></div>
</div>
</div>
</div>
</div>
<div class="main-grid-chart">
<linechart :width="600" :height="40" :values="c.history"></linechart>
</div>
</div>
</div>
</main>
<!-- socket loader -->
<div class="loader-wrap" :class="{ 'visible': loaderVisible }">
<div class="loader-content">
<div v-if="status === 0"><i>📡</i> <br /> Connecting to API ...</div>
<div v-else-if="status === 1"><i>💬</i> <br /> Waiting for data from Socket API ...</div>
<div v-else-if="status === 2"> <br /> Connected to the Socket API</div>
<div v-else-if="status === -1"> <br /> Error connecting to API</div>
</div>
</div>
</div>
// common number filters
Vue.filter("toFixed", (num, asset) => {
if (typeof asset === "number") return Number(num).toFixed(asset);
return Number(num).toFixed(asset === "USDT" ? 3 : 8);
});
Vue.filter("toMoney", num => {
return Number(num)
.toFixed(0)
.replace(/./g, (c, i, a) => {
return i && c !== "." && (a.length - i) % 3 === 0 ? "," + c : c;
});
});
// component for creating line chart
Vue.component("linechart", {
props: {
width: { type: Number, default: 400, required: true },
height: { type: Number, default: 40, required: true },
values: { type: Array, default: [], required: true }
},
data() {
return { cx: 0, cy: 0 };
},
computed: {
viewBox() {
return "0 0 " + this.width + " " + this.height;
},
chartPoints() {
let data = this.getPoints();
let last = data.length ? data[data.length - 1] : { x: 0, y: 0 };
let list = data.map(d => d.x - 10 + "," + d.y);
this.cx = last.x - 5;
this.cy = last.y;
return list.join(" ");
}
},
methods: {
getPoints() {
this.width = parseFloat(this.width) || 0;
this.height = parseFloat(this.height) || 0;
let min = this.values.reduce(
(min, val) => (val < min ? val : min),
this.values[0]
);
let max = this.values.reduce(
(max, val) => (val > max ? val : max),
this.values[0]
);
let len = this.values.length;
let half = this.height / 2;
let range = max > min ? max - min : this.height;
let gap = len > 1 ? this.width / (len - 1) : 1;
let points = [];
for (let i = 0; i < len; ++i) {
let d = this.values[i];
let val = 2 * ((d - min) / range - 0.5);
let x = i * gap;
let y = -val * half * 0.8 + half;
points.push({ x, y });
}
return points;
}
},
template: `
<svg :viewBox="viewBox" xmlns="http://www.w3.org/2000/svg">
<polyline class="color" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" :points="chartPoints" />
<circle class="color" :cx="cx" :cy="cy" r="4" fill="#fff" stroke="none" />
</svg>`
});
// vue instance
new Vue({
// mount point
el: "#app",
// app data
data: {
endpoint: "wss://stream.binance.com:9443/ws/!ticker@arr",
iconbase:
"https://raw.githubusercontent.com/rainner/binance-watch/master/public/images/icons/",
cache: {}, // coins data cache
coins: [], // live coin list from api
asset: "BTC", // filter by base asset pair
search: "", // filter by search string
sort: "assetVolume", // sort by param
order: "desc", // sort order ( asc, desc )
limit: 50, // limit list
status: 0, // socket status ( 0: closed, 1: open, 2: active, -1: error )
sock: null, // socket inst
cx: 0,
cy: 0
},
// computed methods
computed: {
// process coins list
coinsList() {
let list = this.coins.slice();
let search = this.search
.replace(/[^\s\w\-\.]+/g, "")
.replace(/[\r\s\t\n]+/g, " ")
.trim();
if (this.asset) {
list = list.filter(i => i.asset === this.asset);
}
if (search && search.length > 1) {
let reg = new RegExp("^(" + search + ")", "i");
list = list.filter(i => reg.test(i.token));
}
if (this.sort) {
list = this.sortList(list, this.sort, this.order);
}
if (this.limit) {
list = list.slice(0, this.limit);
}
return list;
},
// show socket connection loader
loaderVisible() {
return this.status === 2 ? false : true;
},
// sort-by label for buttons, etc
sortLabel() {
switch (this.sort) {
case "token":
return "Token";
case "percent":
return "Percent";
case "close":
return "Price";
case "change":
return "Change";
case "assetVolume":
return "Volume";
case "tokenVolume":
return "Volume";
case "trades":
return "Trades";
default:
return "Default";
}
}
},
// custom methods
methods: {
// apply sorting and toggle order
sortBy(key, order) {
if (this.sort !== key) {
this.order = order || "asc";
} else {
this.order = this.order === "asc" ? "desc" : "asc";
}
this.sort = key;
},
// filter by asset
filterAsset(asset) {
this.asset = String(asset || "BTC");
},
// set list limit
setLimit(limit) {
this.limit = parseInt(limit) || 0;
},
// on socket connected
onSockOpen(e) {
this.status = 1; // open
console.info(
"WebSocketInfo:",
"Connection open (" + this.endpoint + ")."
);
},
// on socket closed
onSockClose(e) {
this.status = 0; // closed
console.info(
"WebSocketInfo:",
"Connection closed (" + this.endpoint + ")."
);
setTimeout(this.sockInit, 10000); // try again
},
// on socket error
onSockError(err) {
this.status = -1; // error
console.error("WebSocketError:", err.message || err);
setTimeout(this.sockInit, 10000); // try again
},
// process data from socket
onSockData(e) {
let list = JSON.parse(e.data) || [];
for (let item of list) {
// cleanup data for each coin
let c = this.getCoinData(item);
// keep to up 100 previous close prices in hostiry for each coin
c.history = this.cache.hasOwnProperty(c.symbol)
? this.cache[c.symbol].history
: this.fakeHistory(c.close);
if (c.history.length > 100)
c.history = c.history.slice(c.history.length - 100);
c.history.push(c.close);
// add coin data to cache
this.cache[c.symbol] = c;
}
// convert cache object to final prices list for each symbol
this.coins = Object.keys(this.cache).map(s => this.cache[s]);
this.status = 2; // active
},
// start socket connection
sockInit() {
if (this.status > 0) return;
try {
this.status = 0; // closed
this.sock = new WebSocket(this.endpoint);
this.sock.addEventListener("open", this.onSockOpen);
this.sock.addEventListener("close", this.onSockClose);
this.sock.addEventListener("error", this.onSockError);
this.sock.addEventListener("message", this.onSockData);
} catch (err) {
console.error("WebSocketError:", err.message || err);
this.status = -1; // error
this.sock = null;
}
},
// start socket connection
sockClose() {
if (this.sock) {
this.sock.close();
}
},
// come up with some fake history prices to fill in the initial line chart
fakeHistory(close) {
let num = close * 0.0001; // faction of current price
let min = -Math.abs(num);
let max = Math.abs(num);
let out = [];
for (let i = 0; i < 50; ++i) {
let rand = Math.random() * (max - min) + min;
out.push(close + rand);
}
return out;
},
// finalize data for each coin from socket
getCoinData(item) {
let reg = /^([A-Z]+)(BTC|ETH|BNB|USDT|TUSD)$/;
let symbol = String(item.s)
.replace(/[^\w\-]+/g, "")
.toUpperCase();
let token = symbol.replace(reg, "$1");
let asset = symbol.replace(reg, "$2");
let name = token;
let pair = token + "/" + asset;
let icon = this.iconbase + token.toLowerCase() + "_.png";
let open = parseFloat(item.o);
let high = parseFloat(item.h);
let low = parseFloat(item.l);
let close = parseFloat(item.c);
let change = parseFloat(item.p);
let percent = parseFloat(item.P);
let trades = parseInt(item.n);
let tokenVolume = Math.round(item.v);
let assetVolume = Math.round(item.q);
let sign = percent >= 0 ? "+" : "";
let arrow = percent >= 0 ? "▲" : "▼";
let info = [
pair,
close.toFixed(8),
"(",
arrow,
sign + percent.toFixed(2) + "%",
"|",
sign + change.toFixed(8),
")"
].join(" ");
let style = "";
if (percent > 0) style = "gain";
if (percent < 0) style = "loss";
return {
symbol,
token,
asset,
name,
pair,
icon,
open,
high,
low,
close,
change,
percent,
trades,
tokenVolume,
assetVolume,
sign,
arrow,
style,
info
};
},
// sort an array by key and order
sortList(list, key, order) {
return list.sort((a, b) => {
let _a = a[key];
let _b = b[key];
if (_a && _b) {
_a = typeof _a === "string" ? _a.toUpperCase() : _a;
_b = typeof _b === "string" ? _b.toUpperCase() : _b;
if (order === "asc") {
if (_a < _b) return -1;
if (_a > _b) return 1;
}
if (order === "desc") {
if (_a > _b) return -1;
if (_a < _b) return 1;
}
}
return 0;
});
}
},
// app mounted
mounted() {
this.sockInit();
},
// app destroyed
destroyed() {
this.sockClose();
}
});
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.17/vue.min.js"></script>
$padSpace: 1em;
$padSmall: $padSpace / 2;
$headerHeight: 4em;
$lineWidth: 2px;
$lineStyle: solid;
$lineColor: rgba( 255, 255, 255, 0.04 );
$lineJoin: 3px;
$colorDocument: #0c0d0f;
$colorDocumentText: desaturate( lighten( $colorDocument, 50% ), 20% );
$colorDocumentLight: lighten( $colorDocument, 8% );
$colorDocumentDark: darken( $colorDocument, 6% );
$colorDefault: #828a98;
$colorDefaultText: #dae5ed;
$colorPrimary: orange;
$colorPrimaryText: darken( $colorPrimary, 40% );
$colorSecondary: #20acea;
$colorSecondaryText: darken( $colorSecondary, 40% );
$colorGrey: #5c6776;
$colorGreyText: lighten( $colorGrey, 40% );
$colorBright: #f0f0f0;
$colorBrightText: #1f2833;
$colorGain: limegreen;
$colorGainText: lighten( $colorGain, 40% );
$colorLoss: crimson;
$colorLossText: lighten( $colorLoss, 40% );
$fontSize: 15px;
$fontSpace: 1.2em;
$fontWeight: 600;
$fontFamily: roboto;
$shadowPaper: 0 1px 2px rgba( 0, 0, 0, 0.3 );
$shadowDark: 0 1px 8px rgba( 0, 0, 0, 0.6 );
$shadowGlow: 0 0 10px rgba( 0, 0, 0, 0.2 );
// transition props
$fxSpeed: 300ms;
$fxEase: cubic-bezier( 0.215, 0.610, 0.355, 1.000 );
// screen sizes
$sizeSmall: 420px;
$sizeMedium: 720px;
$sizeLarge: 1200px;
// screen breakpoints
$screenSmall: "only screen and (min-width : #{$sizeSmall})";
$screenMedium: "only screen and (min-width : #{$sizeMedium})";
$screenLarge: "only screen and (min-width : #{$sizeLarge})";
// page reset
*, *:before, *:after {
margin: 0;
padding: 0;
border: 0;
outline: none;
background-color: transparent;
text-transform: none;
text-shadow: none;
box-shadow: none;
box-sizing: border-box;
appearance: none;
-webkit-overflow-scrolling: touch;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
transform-style: flat;
transition:
border-color $fxSpeed $fxEase,
background-color $fxSpeed $fxEase,
opacity $fxSpeed $fxEase,
transform $fxSpeed $fxEase;
}
// block types
article, aside, details, figcaption, figure, footer, header, hgroup,
menu, nav, section, main, summary, div, h1, h2, h3, h4, h5, h6, hr,
p, ol, ul, form, img {
display: block;
}
// document setup
html, body {
display: block;
position: relative;
max-width: 100vw;
min-height: 100vh;
}
html {
overflow: hidden;
overflow-y: scroll;
}
body {
font-family: $fontFamily;
font-weight: $fontWeight;
font-size: calc( #{$fontSize} - 6px );
line-height: $fontSpace;
background-color: $colorDocument;
color: $colorDocumentText;
@media #{$screenSmall} {
font-size: calc( #{$fontSize} - 4px );
}
@media #{$screenMedium} {
font-size: calc( #{$fontSize} - 2px );
}
@media #{$screenLarge} {
font-size: $fontSize;
}
}
// headings
h1, h2, h3, h4, h5, h6 {
display: block;
font-weight: inherit;
line-height: $fontSpace;
}
// horizontal lines
hr {
display: block;
overflow: hidden;
margin: $padSpace 0;
height: 0;
border: 0;
border-bottom: $lineWidth $lineStyle $lineColor;
}
// inputs
input, button, select, option, textarea {
display: block;
font-family: inherit;
font-size: inherit;
line-height: inherit;
color: inherit;
}
// media query helpers
.if-small {
display: none;
@media #{$screenSmall} {
display: initial;
}
}
.if-medium {
display: none;
@media #{$screenMedium} {
display: initial;
}
}
.if-large {
display: none;
@media #{$screenLarge} {
display: initial;
}
}
// not rendered
.hidden, [hidden], [v-cloak] {
display: none;
}
// visible but not usable
.disabled, [disabled] {
pointer-events: none;
opacity: 0.5;
}
// common card style
.card {
padding: $padSpace;
background-color: $colorDocumentLight;
border-radius: $lineJoin;
box-shadow: $shadowPaper;
}
// margin helpers
.push-top { margin-top: $padSpace; }
.push-right { margin-right: $padSpace; }
.push-bottom { margin-bottom: $padSpace; }
.push-left { margin-left: $padSpace; }
.push-all { margin: $padSpace; }
// padding helpers
.pad-top { padding-top: $padSpace; }
.pad-right { padding-right: $padSpace; }
.pad-bottom { padding-bottom: $padSpace; }
.pad-left { padding-left: $padSpace; }
.pad-all { padding: $padSpace; }
// border helpers
.border-top { border-top: $lineWidth $lineStyle $lineColor; }
.border-right { border-right: $lineWidth $lineStyle $lineColor; }
.border-bottom { border-bottom: $lineWidth $lineStyle $lineColor; }
.border-left { border-left: $lineWidth $lineStyle $lineColor; }
// flex helpers
.flex-row { display: flex; flex-direction: row; flex-wrap: nowrap; }
.flex-wrap { flex-wrap: wrap; }
.flex-left { justify-content: flex-start; }
.flex-center { justify-content: center; }
.flex-right { justify-content: flex-end; }
.flex-space { justify-content: space-between; }
.flex-around { justify-content: space-around; }
.flex-top { align-items: flex-start; }
.flex-middle { align-items: center; }
.flex-bottom { align-items: flex-end; }
.flex-1 { flex: 1; }
.flex-2 { flex: 2; }
.flex-3 { flex: 3; }
.flex-4 { flex: 4; }
.flex-5 { flex: 5; }
// text helpers
.text-left { text-align: left; }
.text-right { text-align: right; }
.text-center { text-align: center; }
.text-justify { text-align: justify; }
.text-uppercase { text-transform: uppercase; }
.text-lowercase { text-transform: lowercase; }
.text-capitalize { text-transform: capitalize; }
.text-underline { text-decoration: underline; }
.text-striked { text-decoration: line-through; }
.text-italic { font-style: italic; }
.text-bold { font-weight: bold; }
.text-nowrap { white-space: nowrap; }
.text-clip { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.text-primary { color: $colorPrimary; }
.text-secondary { color: $colorSecondary; }
.text-grey { color: $colorGrey; }
.text-bright { color: $colorBright; }
.text-faded { color: white; opacity: 0.3; }
.text-big { font-size: 120%; line-height: ( $fontSpace * 1.01 ); }
.text-small { font-size: 70%; line-height: ( $fontSpace * 0.95 ); }
.text-condense { letter-spacing: -1px; }
// shadow helpers
.shadow-box { box-shadow: $shadowPaper; }
.shadow-text { text-shadow: $shadowPaper; }
// input wrapper
.form-input {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
padding: ( $padSpace * .7 ) $padSpace;
color: $colorBright;
background-color: lighten( $colorDocument, 20% );
border-radius: 100px;
&.dark {
background-color: darken( $colorDocument, 20% );
}
& > input {
width: auto;
}
}
// menu dropdown
@keyframes dropdownShow {
0% { transform: translateY( 30px ); opacity: 0; }
100% { transform: translateY( 0 ); opacity: 1; }
}
.dropdown {
display: block;
position: relative;
cursor: pointer;
& > ul {
display: none;
list-style: none;
position: absolute;
transition: none;
animation: dropdownShow $fxSpeed $fxEase forwards;
right: 0;
top: 50%;
min-width: 200px;
max-width: 400px;
padding: ( $padSpace / 2 ) 0;
background-color: lighten( $colorDocumentLight, 4% );
border-radius: $lineJoin;
box-shadow: $shadowDark;
& > li {
display: block;
padding: ( $padSpace / 2 ) $padSpace;
background-color: rgba( #000, 0 );
color: $colorBright;
cursor: pointer;
& + li {
border-top: $lineWidth $lineStyle $lineColor;
}
&:hover {
background-color: rgba( #000, 0.1 );
}
}
}
&:hover > ul,
&:active > ul {
display: block;
}
}
// app header
.header-wrap {
position: fixed;
left: 0;
top: 0;
width: 100%;
background-color: $colorDocumentLight;
background-image: radial-gradient( ellipse at top, rgba( #fff, 0.1 ) 0%, transparent 60% );
box-shadow: $shadowDark;
z-index: 999;
.header-row {
height: $headerHeight;
padding: $padSpace;
.dropdown {
margin-left: .4em;
}
}
}
// price grid
.main-wrap {
position: relative;
padding: calc( #{$headerHeight} + #{$padSpace} ) $padSpace $padSpace $padSpace;
.main-grid-list {
display: grid;
grid-template-columns: repeat( auto-fill, minmax( 200px, 1fr ) );
grid-gap: $padSpace;
@media #{$screenSmall} {
grid-template-columns: repeat( auto-fill, minmax( 380px, 1fr ) );
}
.main-grid-item {
background-color: $colorDocumentLight;
background-image: linear-gradient( 65deg, rgba( #fff, 0.02 ) 40%, transparent 40% );
border-radius: $lineJoin;
box-shadow: $shadowPaper;
&.gain {
background-color: desaturate( darken( $colorGain, 35% ), 50% );
polyline.color { stroke: $colorGain; }
circle.color { fill: $colorGain; }
.color { color: $colorGain; }
}
&.loss {
background-color: desaturate( darken( $colorLoss, 32% ), 50% );
polyline.color { stroke: $colorLoss; }
circle.color { fill: $colorLoss; }
.color { color: $colorLoss; }
}
.main-grid-info {
padding: $padSpace;
img {
width: auto;
height: 42px;
@media #{$screenSmall} {
height: 52px;
}
@media #{$screenMedium} {
height: 64px;
}
}
}
.main-grid-chart {
padding: $padSpace;
background-image: radial-gradient( ellipse at top right, rgba( #000, 0.4 ) 0%, rgba( #000, 0 ) 60% );
}
}
}
}
// loader
.loader-wrap {
display: none;
flex-direction: row;
justify-content: center;
align-items: center;
position: fixed;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba( #000, 0.8 );
text-align: center;
z-index: 9999;
&.visible {
display: flex;
}
.loader-content {
padding: $padSpace ( $padSpace * 2 );
background-color: $colorDocumentLight;
border-radius: $lineJoin;
box-shadow: $shadowPaper;
i {
font-style: normal;
font-size: 600%;
line-height: normal;
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment