convert all images to webp

This commit is contained in:
Sam 2024-08-01 14:06:16 +01:00
commit f4cb647a64
87 changed files with 2767 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
/public
*.old
.hugo_build.lock
*.json
*__pycache__*

3
README.md Normal file
View File

@ -0,0 +1,3 @@
# My personal website ([https://bitlab21.com](https://bitlab21.com))
This site is made using [Hugo](https://gohugo.io/), a static website generator. The backend (which powers the api for getting data for the charts) is build in python and served using gunicorn.

5
archetypes/default.md Normal file
View File

@ -0,0 +1,5 @@
+++
title = '{{ replace .File.ContentBaseName "-" " " | title }}'
date = {{ .Date }}
draft = true
+++

80
backend/app.py Normal file
View File

@ -0,0 +1,80 @@
from flask import Flask, request, json, Response
from flask_cors import CORS
import orjson
app = Flask(__name__)
CORS(app)
@app.route('/bitcoin_business_growth_by_country', methods=['GET'])
def business_growth():
# Parse args from request
latest_date = request.args.get('latest_date')
country_names = request.args.get('countries') # change this line
cumulative_period_type = request.args.get('cumulative_period_type')
# Open json locally
with open('../data/final__bitcoin_business_growth_by_country.json', 'rb') as f:
data = orjson.loads(f.read())
# Filter based on args
if latest_date:
latest_date_bool = latest_date == 'true'
filtered_data = [item for item in data if item['latest_date'] == latest_date_bool]
else:
filtered_data = data
if country_names:
countries = [name.strip() for name in country_names.split(",")]
filtered_data = [item for item in filtered_data if item['country_name'] in countries]
if cumulative_period_type:
filtered_data = [item for item in filtered_data if item['cumulative_period_type'] == cumulative_period_type]
# Sort by date
sorted_data = sorted(filtered_data, key=lambda x: x['date'], reverse=True)
# Return json
return Response(json.dumps(sorted_data), mimetype='application/json')
@app.route('/price', methods=['GET'])
def price():
# Open json locally
with open('../data/final__price.json', 'rb') as f:
data = orjson.loads(f.read())
# Return json
return Response(json.dumps(data), mimetype='application/json')
@app.route('/miner_rewards', methods=['GET'])
def miner_rewards():
# Open json locally
with open('../data/final__miner_rewards.json', 'rb') as f:
data = orjson.loads(f.read())
# Return json
return Response(json.dumps(data), mimetype='application/json')
@app.route('/hashrate', methods=['GET'])
def hashrate():
# Open json locally
with open('../data/dev/final__hashrate.json', 'rb') as f:
data = orjson.loads(f.read())
# Return json
return Response(json.dumps(data), mimetype='application/json')
@app.route('/feerates', methods=['GET'])
def feerates():
# Open json locally
with open('../data/final__feerate_percentiles.json', 'rb') as f:
data = orjson.loads(f.read())
# Return json
return Response(json.dumps(data), mimetype='application/json')
if __name__ == '__main__':
app.run()

13
backend/bitlab21.service Normal file
View File

@ -0,0 +1,13 @@
[Unit]
Description=Gunicorn instance to serve bitlab21.com
After=network.target
[Service]
User=admin
Group=www-data
WorkingDirectory=/var/www/bitlab21.com/backend
Environment="PATH=/var/www/bitlab21.com/.venv/bin"
ExecStart=/var/www/bitlab21.com/.venv/bin/gunicorn --workers 4 --bind unix:bitlab21.sock -m 007 app:app
[Install]
WantedBy=multi-user.target

43
content/_index.md Normal file
View File

@ -0,0 +1,43 @@
# Bitlab21
Welcome to Bitlab21! My name is Sam Chance, and this is my personal site.
I host various content here that interests me.
Feel free to look around. If you have any questions, then you can email me on *contact@sjplab.com*.
More about my professional life [here](/cv).
This site is still very much under construction.
### Shortcuts to some of my work:
[Bitcoin metrics](/bitcoin) A selection of charts and metrics obtained from public data
[Semita Maps](https://semitamaps.com) A free map printing service based on Openstreetmaps
[Code base](https://git.bitlab21.com) My personal self-hosted git repo using Gitea
[Nixos config](https://git.bitlab21.com/sam/nixos) My Nixos configuration
[Recipes](/recipes) My personal recipe book
### Software I use:
[**Neovim**](https://neovim.io/) for text editing (my neovim config is part of my [nixos](https://git.bitlab21.com/sam/nixos) configuration)
I use **Linux** on all my machines
**Nixos** and **Arch Linux** are my Linux distros of choice
[**dwm**](https://dwm.suckless.org) is my window manager (my dwm [config](https://git.bitlab21.com/sam/dwm))
[**st**](https://st.suckless.org) is my terminal emulator (my st [config](https://git.bitlab21.com/sam/st))
I use [**QGIS**](https://www.qgis.org/) and [**Postgis**](https://postgis.net/) for geospatial work
[**DBT**](https://github.com/dbt-labs/dbt-core) for data modelling
[**Postgres**](https://www.postgresql.org/) is my relational database of choice to power my backends
[**Hugo**](https://gohugo.io/) to build this website

View File

@ -0,0 +1,5 @@
---
title: "Bitcoin"
---
Below are various bitcoin related metrics and charts.

View File

@ -0,0 +1,15 @@
---
title: "Feerate Percentiles"
date: 2023-06-20T22:47:18+01:00
author:
name: "Sam Chance"
header_image: "/pics/charts/feerate-percentile.webp"
summary: "Bar chart showing historical median daily feerate percentiles for the Bitcoin protocol."
chart: "/js/feerate_percentile.js"
---
This chart shows historical median daily feerate percentiles for the Bitcoin
protocol. The data represents a daily aggregation of all blocks. This data was
extracted from the blockchain using the `bitcoin-cli getblockstats` command.
{{< chart src="/js/feerate-percentile.js" >}}

View File

@ -0,0 +1,15 @@
---
title: "Global Bitcoin Business Growth"
date: 2024-04-04T09:16:33+01:00
author:
name: "Sam Chance"
header_image: "/pics/charts/growth.webp"
summary: "Growth of bitcoin businesses based on OSM data"
---
The following table shows bitcoin business growth around the world for the selected period in the dropdown. The chart displays yearly cumulative number of bitcoin businesses for the countries selected in the table.
Data is obtained from Openstreetmaps and is updated roughly every 2 hours.
Select growth period: {{< dropdown-filter id=cumulative_period_type select="365 day,28 day,7 day,1 day" >}}
{{< chart src="/js/bitcoin-business-growth-chart.js" >}}
{{< table src="/js/bitcoin-business-growth-table.js" >}}

View File

@ -0,0 +1,12 @@
---
title: "Bitcoin Hashrate and Difficulty"
date: 2023-07-02T20:47:12+01:00
author:
name: "Sam Chance"
header_image: "/pics/charts/hashrate.webp"
summary: "Timeseries chart showing the Bitcoin network hashrate and difficulty."
---
The estimated hashrate and difficulty of the Bitcoin network, accompanied by the 28-day moving average.
{{< chart src="/js/hashrate.js" >}}

View File

@ -0,0 +1,14 @@
---
title: "Miner Rewards"
date: 2023-07-29T20:53:12+01:00
author:
name: "Sam Chance"
summary: "Miner rewards"
header_image: "/pics/charts/rewards.webp"
draft: false
chart: "/js/miner-rewards.js"
---
Total daily aggregated miner income denominated in USD for that day.
{{< chart src="/js/miner-rewards.js" >}}

15
content/bitcoin/price.md Normal file
View File

@ -0,0 +1,15 @@
---
title: "Bitcoin Price USD"
date: 2023-07-15T01:00:57+01:00
author:
name: "Sam Chance"
header_image: "/pics/charts/price.webp"
summary: "Daily bitcoin price. Data is obtained from CoinGecko using their public API."
---
Daily bitcoin price. Data is obtained from CoinGecko using their
public API.
{{< bitcoin-price >}}
{{< chart src="/js/bitcoin-price.js" >}}

View File

@ -0,0 +1,355 @@
---
title: "Artix Linux Installation Guide"
date: 2024-02-24T17:04:21Z
author:
name: "Sam Chance"
header_image: "/pics/blog/artix-logo.webp"
draft: False
summary: "This guide will run through the process of installing Artix Linux with runit as the init system on an encrypted disk partition."
---
This guide will run through the process of installing Artix Linux, which is a
fork of Arch Linux without SystemD. For the init system I'll be installing
Runit, but you can install any init system of your choosing.
I'll be creating an encrypted partition and installing on a UEFI system.
If you wish to install using legacy boot, or you don't need to encrypt your
drive, then follow the installation guide on the [Artix Wiki](https://wiki.artixlinux.org/Main/Installation) instead.
Download the latest Artix ISO from [here](https://artixlinux.org/download.php)
and write to a usb flashdrive. I recommend using
[Ventoy](https://www.ventoy.net/en/index.html), but you can use the dd command
to burn the ISO image directly to the usb drive (just make sure there is no
important data on the disk beforehand)
{{< highlight shell >}}
dd if=artix-linux.iso of=/dev/sdX status=progress
{{</ highlight >}}
Boot into the ISO image and select the appropriate keyboard layout. Then start
the live environment. The first step is to partition the hard drive. In this
guide I'll be using an encrypted partition on an UEFI system. If if you want a
different configuration, please consult the [Arch
wiki](https://wiki.archlinux.org/title/Partitioning#Example_layouts).
![artix-keyboard-select](/pics/blog/artix-keyboard-select.webp)
## Partition layout
The layout for this installation is as follows:
| mount point | drive | partition type | size |
|-------------|-----------|-------------------------|---------------|
| boot/efi | /dev/sda1 | efi partition | 1G |
| / | /dev/sda2 | encrypted luks partiton | rest of drive |
There is a 1GB efi partition at the beginning of the drive for the bootloader,
then the rest of the drive will be encrypted and contain our root and home
directory.
This installation assums the system will boot using UEFI. If you wish to
install on a legacy system, this process will not work. To check if your system
is UEFI, the run this command:
{{< highlight shell >}}
cat /sys/firmware/efi/fw_platform_size
{{</ highlight >}}
If the command returns `64`, then the system uses UEFI to boot.
## Create New Partition
Login to the Artix live cd using with username: `root`, password: `artix`
List all drives attached to system:
{{< highlight shell >}}
lsblk
{{</ highlight >}}
![artix-lsblk](/pics/blog/artix-lsblk.webp)
Locate the target drive (in this case `/dev/sda`) where we will install Artix.
Run:
{{< highlight shell >}}
fdisk /dev/sda
{{</ highlight >}}
Run through the options to partition the disk:
* Press (g) to create a new empty GPT partition table
* Press (n) to add a new partition
* Choose default partition number (1)
* Choose default first sector (2048)
* Set last sector as (+1G)
* Press (t) to change partition type
* Set partition type to "EFI System" (usually option 1 - press L to see all options)
* Press (n) to create a second partition for the rest of the drive. Choose all default settings
* Press (w) to write and exit
You should now have two partitions under `/dev/sda`:
![artix-lsblk1](/pics/blog/artix-lsblk1.webp)
`/dev/sda1` is the unencrypted boot partition, and `/dev/sda2` will be where we store our encrypted volume.
## Encryption using luks cryptsetup
Firstly create an encrypted container on the second partition. For this we will use luks encryption:
{{< highlight shell >}}
cryptsetup luksFormat /dev/sda2
{{</ highlight >}}
Enter a suitably strong passphrase.
Next we need to open and mount the encrypted vault to install Artix.
{{< highlight shell >}}
cryptsetup luksOpen /dev/sda2 crypt
{{</ highlight >}}
Enter your passphrase. This will open the encrypted vault and make it mountable
under the name "crypt" (accessible from `/dev/mapper/crypt`). You can choose a different name if you wish.
## Create Filesystems
Format the boot/efi partition using fat32:
{{< highlight shell >}}
mkfs.fat -F32 /dev/sda1
{{</ highlight >}}
And create a btrfs file system on the opened and decrypted luks vault:
{{< highlight shell >}}
mkfs.btrfs /dev/mapper/crypt
{{</ highlight >}}
To check everything is in order, run:
{{< highlight shell >}}
lsblk -f
{{</ highlight >}}
It should look something like this:
![artix-lsblk2](/pics/blog/artix-lsblk2.webp)
Note the UUIDs - they will be needed later for setting up decryption during boot.
Then we mount the partitions:
{{< highlight shell >}}
mount /dev/mapper/crypt /mnt
mkdir /mnt/boot
mount /dev/sda1 /mnt/boot
{{</ highlight >}}
## Install Artix
Use the `basestrap` command to install Artix linux and other essential packages
to the mounted partition. You can also install packages from the Arch repos
here too:
{{< highlight shell >}}
basestrap -i /mnt base base-devel runit elogind-runit linux linux-firmware grub efibootmgr
networkmanager networkmanager-runit cryptsetup lvm2 lvm2-runit neovim vim
openssh openssh-runit
{{</ highlight >}}
This will install about 1.4GB of packages onto your system.
## Generate Fstab
This generates an fstab file for automatically mounting drives during system boot.
{{< highlight shell >}}
fstabgen -U /mnt >> /mnt/etc/fstab
{{</ highlight >}}
## Chroot Into the Install
Chroot will transport us into the installation:
{{< highlight shell >}}
artix-chroot /mnt
{{</ highlight >}}
## General Arch Setup
More info about each of these steps on the [Artix Wiki](https://wiki.artixlinux.org/Main/Installation) and the [Arch Wiki](https://wiki.archlinux.org/title/Installation_guide#Time_zone)
Set timezone
{{< highlight shell >}}
ln -sf /usr/share/zoneinfo/Europe/London /etc/localtime
{{</ highlight >}}
Set system clock
{{< highlight shell >}}
hwclock --systohc
{{</ highlight >}}
Set locales
{{< highlight shell >}}
vim /etc/locale.gen
# uncomment your layouts, e.g. for me: "en_GB.UTF-8 UTF-8"
{{</ highlight >}}
Generate locale
{{< highlight shell >}}
locale-gen
{{</ highlight >}}
Set systemwide locale
{{< highlight shell >}}
vim /etc/locale.conf
#and append:
export LANG="en_GB.UTF-8 UTF-8"
export LC_COLLATE="C"
{{</ highlight >}}
## Setup mkinitcpio.conf
`/etc/mkinitcpio.conf` is the configuration file for setting up the initial
ramdisk environment. This is an small environment which loads various kernel
modules and sets the system up before handing control to the init system. As we
have installed Linux on an encrypted partition, we need to tell the ramdisk
environment how to decrypt this partition.
To do this we need to add some modules to the HOOKS line:
{{< highlight text >}}
HOOKS=(base udev autodetect modconf kms keyboard keymap consolefont block encrypt lvm2 filesystems fsck)
add these modules---------------------------------------------------------^-------^
{{</ highlight >}}
Here we add `encrypt` and `lvm2` to the HOOKS. These modules will now get
loaded before boot and will enable the system to decrypt the root partition.
Next, regenerate the ramdisk environment based on the `linux` preset:
{{< highlight shell >}}
mkinitcpio --preset linux
{{</ highlight >}}
## Grub Bootloader
We now need to tell the bootloader where both our encrypted luks
vault is (so it can decrypt it) and where the decrypted root partition is in
order to boot the system. For this, we'll need two UUIDs, one for the encrypted
luks vault (referenced as `cryptdevice=UUID`), and another for the decrypted
filesystem (referenced as `root=UUID`). We can obtain this information from the
`lsblk -f` command
We can output this to the `/etc/default/grub` file. We can do this using the
following command, I advice double checking this command before running it, as
it may not work on your system if it is set it up differently (e.g. you're
not using btrfs):
Also, REMEMBER TO `APPEND` USING TWO ARROWS `>>`!! Else you'll overwrite the
grub file and will need to reinstall.
{{< highlight shell >}}
lsblk -f | grep "crypto_LUKS\|btrfs" | sed "s/crypto_LUKS/#cryptdevice=UUID/;s/btrfs/#root=UUID/" | awk '{print $2"="$3}' >> /etc/default/grub
{{</ highlight >}}
This will append the following to the grub file:
{{< highlight text >}}
#cryptdevice=UUID=<long-uuid-string>
#root=UUID=<long-uuid-string>
{{</ highlight >}}
Now we can open `/etc/default/grub`. We need to insert the two new strings at
the bottom of the file into the `GRUB_CMDLINE_LINUX_DEFAULT` string. It should
look something like this (remember to also add a volume name after the
cryptdevice=UUID string - e.g. here I've called it `cryptlvm` - you can call it
whatever you like)
{{< highlight text >}}
GRUB_CMDLINE_LINUX_DEFAULT="loglevel=3 quiet cryptdevice=UUID=<long-uuid-string>:cryptlvm root=UUID=<long-uuid-string>"
{{</ highlight >}}
## Install grub
For efi systems grub is installed with the following command:
{{< highlight shell >}}
grub-install --target=x86_64-efi --efi-directory=/boot --bootloader-id=grub
grub-mkconfig -o /boot/grub/grub.cfg
{{</ highlight >}}
![artix-grub-install](/pics/blog/artix-grub-install.webp)
## Add Users
Set a password for the root user:
{{< highlight shell >}}
passwd
{{</ highlight >}}
Create regular user and add to wheel group. Set a password for that user.
{{< highlight shell >}}
useradd -G wheel -m user
passwd user
{{</ highlight >}}
Edit the sudoers file to allow sudo root commands for user.
{{< highlight shell >}}
EDITOR=vim visudo`
{{</ highlight >}}
Then uncomment the following line:
{{< highlight text >}}
%wheel ALL=(ALL:ALL) ALL
{{</ highlight >}}
## Network Config
set hostname (replace `<my-hostname>` with a suitable name for your system)
{{< highlight shell >}}
echo "<my-hostname>" > /etc/hostname
{{</ highlight >}}
Add hosts to `/etc/hosts`
{{< highlight text >}}
127.0.0.1 localhost
::1 localhost
127.0.1.1 <my-hostname>.localdomain <my-hostname>
{{</ highlight >}}
Install dhcp client
{{< highlight shell >}}
pacman -S dhcpcd
{{</ highlight >}}
Enable networkmanager service with runit
{{< highlight shell >}}
ln -s /etc/runit/sv/NetworkManager /etc/runit/runsvdir/current
{{</ highlight >}}
## Exit chroot, unmount partition and reboot
{{< highlight shell >}}
exit
umount -R /mnt
reboot
{{</ highlight >}}
If everything worked, then the system should be successfully setup. After
reboot, it should ask for a passphrase to access the encrypted partition. You
can then login using the user account that we created. At this point, we can
install a graphical environment.

28
content/cv.md Normal file
View File

@ -0,0 +1,28 @@
# Sam Chance
## Analytics Engineer
Analytics Engineer with over 4 years experience in designing and implementing data models, pipelines, and analytics solutions. Proficient in SQL, data warehousing, dbt and building dashboards for company metrics.
## Skills
- SQL
- Python
- Scripting/Programming
- Data Warehousing
- Data Modelling
- DBT
- Data and Statistical Analysis
- System Administration
## Work Experience
### Growth Analyst
What3Words - May 2020 to Feb 2022
### Analytics Engineer
What3Words - Feb 2022 to Apr 2024
## Education
### MSc Marine Biology
Bangor University - 2017 to 2018
### BSc Marine Biology & Zoology
Bangor University - 2008 to 2011

View File

@ -0,0 +1,5 @@
---
title: Recipes
---
Several of my favourite recipes! I'm often asked by people if I can share a recipe with them, so I decided to publish them here!

View File

@ -0,0 +1,38 @@
---
title: "Authentic Patan Karahi"
date: 2023-11-20T12:13:11Z
author:
name: "Sam Chance"
draft: false
header_image: /pics/recipes/karahi.webp
summary: "A flavorful dish inspired by Pashtun cuisine featuring succulent pieces of meat, slow cooked with fresh garlic, chilies and tomatoes"
---
A flavorful dish inspired by Pashtun cuisine featuring succulent pieces of meat, slow cooked with fresh garlic, chilies and tomatoes.
![Karahi](/pics/recipes/karahi.webp)
*serves:* 3 | *prep time* 10 mins | *cook time:* 1.5 hour
### Ingredients
- 5 large tomatoes (fresh, chopped)
- 10 long green chilies (chopped, for flavor, not spicy)
- 2 very hot chilies (chopped)
- large thumb sized piece of fresh ginger (sliced)
- 1 whole bulb garlic (minced)
- bunch of coriander
- 1kg lamb (chopped with bones)
- salt (to be added at the end of cooking - else it will dry out the meat)
- beef fat
- Boiling water.
### Method
1. In a wok or karahi dish (an open pan without the lid) and on high heat, fry the lamb for 5 mins in beef fat until brown.
2. Add the minced garlic and the hot chilies and cook for 3 more minutes.
3. Then add the chopped tomatoes. Cook for 10 minutes with the lid on (until tomatoes are soft).
4. Add the boiling water until the meat is just submerged.
5. Cook on low heat for 40 mins.
6. Check if meat is tender. If not, cook for a further 20 min.
7. Once the meat is tender, remove the lid and cook on a high heat until the water evaporates (stir continuously to prevent burning).
8. Once the water has evaporated, add the chopped non-spicy green chilies and cook until the oil has separated from the dish (and id floating on the top - do not remove the oil!).
9. Add the salt (to taste), along with the ginger and garnish with fresh chopped coriander.

View File

@ -0,0 +1,38 @@
---
title: "Banana Cake"
date: 2023-04-04T17:39:42+01:00
author:
name: "Sam Chance"
draft: false
header_image: /pics/recipes/banana-cake.webp
summary: "Fruity banana cake recipe"
---
Fruity banana cake recipe.
![Banana Cake](/pics/recipes/banana-cake.webp)
*serves:* 8 | *prep time* 20 mins | *cook time:* 1 hour
### Ingredients
- 4 medium eggs
- 200 grams salted butter
- 4 ripe bananas
- 200 grams plain flour
- 70 grams light brown sugar
- 40 grams dark brown sugar
- 60 grams full fat yoghurt
- 100 grams chopped walnuts
- 100 grams raisins
- 2.5 tsp baking powder
- 1 tsp baking soda
- 1 tsp vanilla extract
- 1 tsp allspice
- 2 tsp cinnamon
### Method
1. Whisk eggs, sugar and melted butter in a bowl until fluffy (approx 3 mins by hand).
2. Add vanilla, milk, yogurt and dried fruits - gently mix together until combined.
3. Sieve flour and baking soda/powder into the same bowl. Gently fold with a spoon until combined.
4. Add the banana and mix well.
5. Pour into baking dish and bake @ 170C for 35-40 mins.

View File

@ -0,0 +1,38 @@
---
title: "Carrot Cake"
date: 2023-04-04T16:41:57+01:00
author:
name: "Sam Chance"
draft: false
header_image: /pics/recipes/carrot-cake.webp
summary: "Moist and rich carrot cake with dried fruit and nuts"
---
Moist and rich carrot cake with dried fruit and nuts.
![Carrot Cake](/pics/recipes/carrot-cake.webp)
*serves: 8* | *prep time: 20 mins* | *cook time: 1 hour*
### Ingredients
- 4 free range eggs
- 200 grams salted butter
- 350 grams grated carrots
- 100 grams raisins
- 100 grams chopped walnuts
- 200 grams plain flour
- 3 tsp ground spice mix (allspice & cinnamon)
- 2.5 tsp baking powder
- 1 tsp baking soda
- 120 grams light brown sugar
- 50 grams dark brown sugar
### Method
1. Melt the butter in the microwave (on low).
2. Add sugar and eggs to a bowel then add the melted butter (make sure the butter isn't too hot).
3. Whisk together for a couple of mins until it forms a thick batter.
4. Sift in the flour, baking powder/soda and spices then fold gently until combined.
5. Add the grated carrots, nuts and dried fruit to the mix. Fold until combined.
6. Take a cake tin and line with butter or oil.
7. Spoon in the cake batter and bake for 1 hour @ 170 degrees C.

View File

@ -0,0 +1,44 @@
---
title: "Cast Iron Pizza"
date: 2023-04-04T16:02:12+01:00
author:
name: "Sam Chance"
draft: false
header_image: /pics/recipes/cast-iron-pizza2.webp
summary: "Tasty and fast way to make an authentic pizza at home"
---
Tasty and fast way to make an authentic pizza at home. Adjust toppings to your liking!
I like to add sliced bell pepper, pepperoni and chilli flakes to mine!
![cast iron pizza](/pics/recipes/cast-iron-pizza2.webp)
*serves:* 1 (1 cast iron pizza) | *prep time:* 2 hrs | *cook time:* 20 mins
### Ingredients
***for the dough:***
- 125 grams strong white flour
- 0.25 tsp dried yeast
- 0.5 tsp kosher salt
- 6 mm olive oil
- 75 mm tepid water
***for the topping:***
- 100g of canned finely chopped tomatoes
- 1 tbsp of tomato puree
- 150g low moisture mozzarella
- pinch of rice flour
### Method
1. To make the dough, mix the flour, yeast and salt together in a large bowl and stir in the olive oil and. Gradually add the water, mixing well to form a soft dough.
2. Turn the dough out on to a floured surface and knead for about 5 minutes, until smooth and elastic. Transfer to a clean bowl, cover with a teatowel and leave to rise for about 1½ hours, until doubled in size.
3. Make the tomato sauce by mixing the canned tomatoes with the tomato puree.
4. Apply olive oil to cast iron pan with a pinch of rice flour to prevent sticking.
5. Stretch dough to the edges of the pan then top with a few dollops of tomato sauce. Spread over the dough to the edges.
6. Add mozzarella and other toppings of choice.
7. Place on hob on medium high heat to cook the bottom. When the bottom is starting to look burnt, transfer to an oven grill and cook the top for several minutes until the peaks of the cheese start to burn.
![cast iron pizza](/pics/recipes/cast-iron-pizza4.webp)
![cast iron pizza](/pics/recipes/cast-iron-pizza5.webp)
![cast iron pizza](/pics/recipes/cast-iron-pizza1.webp)
![cast iron pizza](/pics/recipes/cast-iron-pizza3.webp)

View File

@ -0,0 +1,44 @@
---
title: "Coconut Milk Chicken Curry"
date: 2023-06-17T21:41:53+01:00
author:
name: "Sam Chance"
header_image: /pics/recipes/chicken-curry.webp
summary: "Indian style aromatic chicken curry cooked in coconut milk"
---
Indian style aromatic chicken curry cooked in coconut milk.
![Chicken Curry](/pics/recipes/chicken-curry.webp)
***Serves:*** 6 | ***Prep time:*** 20 mins | ***Cook time:*** 1 hour
### Ingredients
- 1kg boneless chicken thigh
- 3 medium onions, diced
- 4 tomatoes, diced
- 50 grams (at least) of fresh ginger
- 2 tbsp coconut fat
- 6 garlic cloves
- 2 fresh chilies (the spicy kind)
- 1 lemon or lime
- 8 clove pods, ground
- 8 cardamon pods, ground with skins removed
- 3 tsp cumin
- 2 tsp smoked paprika
- 1 tsp turmeric
- 3 tsp coriander powder
- 1 tps white pepper powder
- 1 tsp dried chili flakes
- 2 tbsp tomato paste
- 1 tsp hot chili powder (optional)
### Method
1. Slice the onions and fry on medium heat for 10 mins until soft.
2. As the onions are frying, grind the cardamon and cloves to a fine powder in a pestle and mortar. Set aside.
3. Remove the skin from the ginger and garlic. Roughly chop with a couple of hot chilies and pound to a paste in the pestle and mortar.
4. When the onions have softened, add the ginger/garlic paste to the pan and fry on medium for a few minutes. Then add the powdered spices along with enough water to turn the mixture into a thick paste. Fry like this for several more minutes until aromatic. Allow the paste to start sticking to the bottom of the pan, then mix before it burns. Repeat this step to intensify the flavour.
5. Add the chopped tomatoes to the pan and mix into the paste. Also add a splash of water if the paste is too thick.
6. Cook the tomatoes for a few minutes then add the diced chicken thigh. Fry the chicken in the paste for a few minutes, then squeeze in the lime and add the tomato paste.
7. Add the coconut milk along with some water, and cook uncovered on a low heat for 45 minutes, until the colour turns a rich golden brown colour.
8. Serve with rice.

View File

@ -0,0 +1,32 @@
---
title: "Chocolate Fudge Brownies"
date: 2023-04-04T00:30:51+01:00
author:
name: "Sam Chance"
draft: False
header_image: /pics/recipes/chocolate-brownie.webp
summary: "Delicious homemade chocolate fudge brownies"
---
Delicious homemade chocolate fudge brownies.
With crunchy edges and a rich fudgy dark chocolate centre!
![Chocolate Brownie](/pics/recipes/chocolate-brownie.webp)
### Ingredients
- 4 large eggs
- 110g flour
- 180g 100% dark chocolate
- 270g light brown sugar
- 110g dark brown sugar
- 220g salted butter
### Method
1. Melt the butter in a bowl over a pan of hot water.
2. While the butter is melting, combine eggs and sugar and beat until a thick pancake batter consistency.
3. When the butter is hot, add the dark chocolate and gently mix until melted to make a ganache.
4. Mix the ganache with the beaten eggs and gently whisk for another minute or so.
5. Sieve the flour and cocoa powder and fold into the batter mix.
6. Pour mixture into cake tin that has been greased with butter or oil.
7. Bake at 170 degree C for about 50 minutes.
8. Leave to cool completely before removing from cake tin and cutting into slices.

View File

@ -0,0 +1,44 @@
---
title: "Egyptian Bechamel"
date: 2023-04-04T17:51:22+01:00
author:
name: "Sam Chance"
header_image: /pics/recipes/minced-beef-cheese-pasta-bake.webp
summary: "Creamy Egyptian cheese pasta bake with minced beef covered in bechamel sauce"
---
Creamy Egyptian cheese pasta bake with minced beef covered in bechamel sauce.
![Minced Beef Pasta Bake](/pics/recipes/minced-beef-cheese-pasta-bake.webp)
*serves:* 8 | *prep time:* 20 mins | *cook time:* 35 mins
### Ingredients
- 750 grams minced beef (10 - 15% fat)
- 500 grams pasta
- 4 tbsp olive oil
- 200 grams grated cheddar
- 200 grams mozzarella
- 2 medium onions, diced
- 3 medium tomatoes, sliced
- 0.5 grated nutmeg
- 2 tsp cayenne
- 3 tsp cumin
- 3 tsp smoked paprika
- 4 tsp dried herbs
- salt and pepper to taste
***for the white sauce:***
- 75 grams plain flour
- 75 grams butter
- 750 ml full fat milk
### Method
1. Fry the minced beef in olive oil until brown and caramelized, then add the onions and soften.
2. As the beef is frying, make a roux. Fry the butter and flour together for a few mins until the color changed, then add the milk and whisk until it starts to thicken. Then add half of the grated cheddar. Keep whisking until smooth, then add the nutmeg, salt and pepper. Set aside.
3. Boil the pasta for approx 10 mins or until al-dente.
4. When the meat is brown and the onions are starting to soften add the cayenne, paprika, and cumin.
5. When the pasta is cooked, add half of the sauce and mix.
6. Make the first layer of pasta with sauce in the baking dish, then add the meat as the second layer and add the sliced tomatoes on top of the beef. Finally, add the top layer of pasta on top of the meat, then cover with the rest of the sauce.
7. Scatter the rest of the mozzarella and cheddar on top. Bake on 180 c (fan) for approx 30-40 mins, or until the peaks of the cheese turn golden brown (but don't let all of the mozzarella go brown because we still want some stretch!

View File

@ -0,0 +1,66 @@
---
title: "Lamb Kabsa"
date: 2023-04-23T11:34:43+01:00
author:
name: "Sam Chance"
header_image: /pics/recipes/lamb-kabsa1.webp
summary: "Traditional aromatic Saudi lamb kabsa"
---
Traditional aromatic Saudi lamb kabsa.
<!--more-->
![lamb kabsa](/pics/recipes/lamb-kabsa1.webp)
***serves*** 4 | ***prep time*** 20 mins | ***cook time*** 1.5-2 hrs
### Ingredients
- two medium onions
- 800g - 1kg meat (lamb or beef)
- 6 cloves of garlic
- 3 tomatoes
- 2 cups of rice
- 1 bell pepper
- some hot chillies
- 2 tbsp tomato paste
- 2 cinnamon sticks
- 7 cardamon pods
- 8 cloves
- 3 bay leaves
- 2 dried limes
- 1 tbsp cumin
- 1 tbsp coriander
- 1 tbsp paprika
- 1 tsp black pepper
- 1 tsp tumeric
***for the sauce***
- 2 tomatoes
- 2 cloves of garlic
- 1 hot chilli
- some corriander or mint
- 1 tsp cumin
- salt
- 1 tbsp lemon juice
### Method
1. Add the meat to a pan with some olive oil and fry until slightly brown.
2. Add the diced onions and whole spices to the pan. Fry on med-low until the onion begins to caramalise.
3. Add the chopped garlic and fry for a minute followed by the powdered spices.
4. At this point, fry the mixture until the aromatics from the spices are released, then add the tomato paste and cook out for a couple of minutes.
5. Dice the tomatoes and add to the pan - cook for 5 minutes until soft.
6. Cover the mixture with hot water and cook on medium heat for 1 hour.
7. After about 30-40 mins of cooking, prepare the basmati rice. Wash several times and then soak in water for up to 30 minutes then set aside.
8. When the meat is tender, remove from the broth and set aside. Then strain the broth through a seive to remove the whole spices.
9. Add the meat back to the pan and add the two cups of washed rice.
10. Add some salt to taste and throw in some bell peppers and hot chillies, then add 3 cups of the meat broth. Place a lid on the pan and set the heat to high for a few minutes to bring the mixture to a boil. Then simmer on a low heat for 20-30 minutes until the rice absorbs all of the water.
***for the fresh tomato sauce***
11. Slice the fresh ingredients (tomatoes, garlic, chillies and herbs) and blitz with the spices and lemon juice in a food blender.
12. Cover and set aside in the fridge.
***when the kabsa is ready***
13. Remove the meat from the rice and set aside. Then on a large serving dish add a layer of rice and top with the meat.
14. Serve with the fresh sauce.

View File

@ -0,0 +1,55 @@
---
title: "Lasagne"
date: 2023-04-04T17:05:54+01:00
author:
name: "Sam Chance"
draft: false
header_image: /pics/recipes/lasagne.webp
summary: "Traditional lasagne layered with beef ragu and bechamel sauce"
---
Traditional lasagne layered with beef ragu and bechamel sauce.
![Lasagne](/pics/recipes/lasagne.webp)
*serves:* 8 | *prep time:* 1 hour | *cook time:* 50 mins
## Lasagne
### Ingredients
***for the ragu sauce:***
- 8 tbsp olive oil
- 3 medium onions, finely chopped
- 1 large carrot, grated
- 8 cloves of garlic, crushed
- 750 grams minced beef (10 - 15% fat)
- 500 grams smoked bacon (halal is fine)
- 200 ml milk
- 2 x 400g cans of chopped tomatoes
- 1/2 tube (100g) tomato puree
- 4 tbsp mixed dried herbs
- 2 beef stock cubes
- 5 tbsp balsamic vinegar
- 400g dried lasagne pasta sheets
- 150g mozzarella
- 100g cheddar
- 5 tsp smoked paprika
- 3 tsp cayenne (optional for a kick)
- 3 tsp cumin powder
- 0.5 tsp dried garlic powder
- 0.5 tsp white pepper
***for the white sauce:***
- 1.25 litres milk
- 100g butter
- 100g plain flour
- half nutmeg, finely grated
### Method
1. Start by browning meat on medium low heat. Chop bacon into lardons and fry in olive oil till lightly brown. Add minced beef and also fry till brown. Add splashes of water periodically to prevent burning.
2. Whilst meat is browning, make the béchamel sauce. Melt butter and mix in flour. Cook together for a few mins until the color starts to change. Add milk and gently whisk. Keep whisking periodically till thick. Add nutmeg and simmer on low for a few mins. Keep an eye to make sure it doesn't burn to the bottom of the pan. Once thick and creamy, take off heat, cover and set aside.
3. Once the meat has browned, Add onions, minced garlic and carrots. Fry with the meat till the veg is soft. Careful not to burn the garlic, you can add small splashes of water periodically to prevent burning.
4. Add tomato puree, ground spices, stock cubes and a splash of water to the meat/veg. Mix till a brown sloppy paste. Keep cooking on medium heat and stirring constantly for about 5 mins to cook out spices. Can add salt here too, but be careful as there is already plenty of salt in the bacon and stock.
5. Add cans of tomatoes and balsamic vinegar. Cook uncovered on medium low for 30-40 mins. Keep stirring to prevent burning.
6. Heat the oven to 180C/160C fan/gas 4. Spread a spoonful of the meat sauce over the base of a roughly 3.5-litre baking dish. Cover with a single layer of dried pasta sheets, snapping them to fit if needed, then top with a quarter of the béchamel. Throw over a handful of the mozzarella then spoon over a third of the meat sauce.
7. Repeat the layers pasta, béchamel, meat and mozzarella two more times to use all the meat sauce. Add a final layer of pasta, the last of the béchamel, remaining mozzarella and the cheddar. Sit the dish on a baking tray to catch spills and bake for 50 mins until bubbling, browned and crisp on top.

View File

@ -0,0 +1,43 @@
---
title: "Slow Beef Curry"
date: 2023-04-17T22:43:53+01:00
author:
name: "Sam Chance"
header_image: /pics/recipes/slow-beef-curry1.webp
summary: "Delicious and rich slow cooked beef curry"
---
Delicious and rich slow cooked beef curry. Can also use lamb.
![Slow Cooked Beef](/pics/recipes/slow-beef-curry1.webp)
***Serves:*** 4 | ***Prep time:*** 20 mins | ***Cook time:*** 2-3 hours
### Ingredients
- 750 grams beef chuck or lamb leg
- 50 - 100 ml heavy cream
- 2 medium onions, diced
- 3 tomatoes, diced
- 50 grams (at least) of fresh ginger
- 6 garlic cloves
- 6 clove pods, ground
- 8 cardamon pods, ground with skins removed
- 5 tsp cumin
- 2 tsp smoked paprika
- 3 tps red paprika
- 1/2 tps white pepper powder
- 1 tsp dried chilli flakes
- 2 tbsp tomato paste
- 1 tsp hot chilli powder (optional)
### Method
1. Dice the beef or lamb and fry on medium high heat in beef fat until brown.
2. While the beef is browning, crush the garlic and ginger together with a pestle and mortor.
3. Add the onions, tomatoes, garlic/ginger paste to the beef (along with a slpash of water if it looks like there is a danger of burning). Turn the heat down to medium and fry with the beef until soft.
4. When the onions and tomatoes have softened, add the dried spices along with the tomato puree. Add a good splash of water and mix together to form a slurry with the beef. Turn the heat down to medium low.
5. The aim here is to cook the mixture for about 20 mins with low moisture. Careful, because there is a danger it will burn if left unattended. This will cause the curry to darken as the spices and onions caramalise together with the meat.
6. When the mixture is a dark brown colour, add a good pinch of salt along with enough water to cover the beef.
7. Simmer uncovered on a very low heat for about an hour then stir in the cream. You can add another tbsp of tomato puree at this point too if you need to add some more richness to the sauce. After that, continue to cook on low for about another hour, or until the meat is very tender. Add more water at any point if the curry becomes too thick or looks like it will burn.
![Slow Cooked Beef](/pics/recipes/slow-beef-curry.webp)

12
data/navbarlinks.yaml Normal file
View File

@ -0,0 +1,12 @@
- url: "/recipes"
name: Recipes
- url: "/bitcoin"
name: Bitcoin Metrics
- url: "https://semitamaps.com"
name: Map Printing
- url: "/blog"
name: Blog
- url: "https://git.bitlab21.com"
name: Git
- url: "https://chat.bitlab21.com/conversejs"
name: Jabber Client

5
hugo.toml Normal file
View File

@ -0,0 +1,5 @@
baseURL = 'https://bitlab21.com/'
languageCode = 'en-gb'
title = 'Bitlab21'
markup.highlight.noClasses=false

View File

@ -0,0 +1,7 @@
<!doctype html>
<head>
{{ partial "head.html" . }}
</head>
<html lang="{{ .Site.LanguageCode }}">
{{ template "partials/body.html" . }}
</html>

View File

@ -0,0 +1,5 @@
{{ define "main" }}
<div class="page-content">
{{ .Content }}
</div>
{{ end }}

View File

@ -0,0 +1,27 @@
{{ define "main" }}
<div class="page-content">
<h1>{{ .Title }}</h1>
{{ .Content }}
</div>
<div class="list-content-container">
<div class="article-card-flex-container">
{{ range.Pages }}
<a class="article-card" href="{{ .RelPermalink }}">
<div class="article-card-info">
<img class="article-card-thumbnail" src="{{ .Params.header_image }}" />
<div class="article-card-summary">
<h3><strong>{{ .Title | safeHTML }}</strong></h3>
<p>{{ .Summary | safeHTML }}</p>
<br />
</div>
{{ if isset .Params "date" }}
<div class="article-card-author-row">
<time>{{ .Date.Format "January 2, 2006" }}</time>
</div>
{{ end }}
</div>
</a>
{{ end }}
</div>
</div>
{{ end }}

View File

@ -0,0 +1,3 @@
{{ define "main" }}
{{ template "partials/main-article.html" . }}
{{ end }}

View File

@ -0,0 +1,14 @@
<!doctype html>
<head>
{{ partial "head.html" . }}
</head>
<script
type="text/javascript"
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"
></script>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.min.js"></script>
<div class="main-page">
<html lang="{{ .Site.LanguageCode }}">
{{ template "partials/body.html" . }}
</html>
</div>

View File

@ -0,0 +1,5 @@
{{ define "main" }}
<div class="page-content">
{{ .Content }}
</div>
{{ end }}

8
layouts/index.html Normal file
View File

@ -0,0 +1,8 @@
{{ define "main" }}
<div class="page-content">
<div class="home-page">
<div class="home-page-content">{{ .Content }}</div>
<figure class="profile-img"><img src="/sam.webp" /></figure>
</div>
</div>
{{ end }}

View File

@ -0,0 +1,8 @@
<body>
{{ partial "header.html" . }}
<main class="main-content">
{{- block "main" . }}
{{ end -}}
</main>
{{ partial "footer.html" . }}
</body>

View File

@ -0,0 +1,5 @@
<div id="chart">
<canvas id="{{ .id }}"></canvas>
<script src="{{ .src }}"></script>
</div>
<script src="/js/chart-params.js"></script>

View File

@ -0,0 +1,8 @@
<footer>
<div id="footer">
<p>
2023 BitLab21. Uncopywrited.
<a href="https://unlicense.org/">View License.</a>
</p>
</div>
</footer>

View File

@ -0,0 +1,7 @@
<html lang="en">
<meta charset="UTF-8" />
<link rel="icon" type="image/png" sizes="48x48" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="/css/style.css" type="text/css" media="all" />
<link rel="stylesheet" href="/css/syntax.css" />
</html>

View File

@ -0,0 +1,22 @@
<header>
<nav class="navbar" role="navigation">
<div class="navbar__left">
<a href="/"><strong>Bitlab21.com</strong></a>
</div>
<div class="navbar__right">
<div class="navbar-links">{{ partial "navbarlinks.html" . }}</div>
<div class="navbar-dropdown">
<button class="hamburger-dropbtn">
<img
class="hamburger"
src="/hamburger-menu.svg"
alt="description of image"
/>
</button>
<div class="navbar-dropdown-content">
{{ partial "navbarlinks.html" . }}
</div>
</div>
</div>
</nav>
</header>

View File

@ -0,0 +1,15 @@
<article class="main-article">
<header>
<h1>{{ .Title }}</h1>
<div class="author-row">
{{ with .Params.author }}
<strong><p class="author-name">{{ .name }}</p></strong>
<p class="author-name">on</p>
{{ end }}
{{ if isset .Params "date" }}
<time>{{ .Date.Format "January 2, 2006" }}</time>
{{ end }}
</div>
</header>
{{ .Content }}
</article>

View File

@ -0,0 +1,3 @@
{{ range $.Site.Data.navbarlinks }}
<ul><a href="{{ .url }}">{{ .name }}</a></ul>
{{ end }}

View File

@ -0,0 +1,2 @@
<script src="{{ .src }}"></script>
<table id="jsonTableContainer"></table>

View File

@ -0,0 +1,19 @@
<!doctype html>
<html>
<body>
<p id="price"></p>
<script>
fetch("https://api.bitlab21.com/price")
.then((response) => response.json())
.then((data) => {
var lastElement = data[data.length - 1];
document.getElementById("price").innerHTML =
`The current price is: <em>$ ${lastElement.price.toLocaleString()}</em> (${lastElement.date}) `;
})
.catch((error) => {
console.error("Error:", error);
});
</script>
</body>
</html>

View File

@ -0,0 +1,2 @@
{{ $id := .Get "src" | md5 }}
{{ partial "chart.html" (dict "src" (.Get "src") "id" $id) }}

View File

@ -0,0 +1,7 @@
<select id={{ .Get "id" }} class="dropdownFilter">
{{ with .Get "select" }}
{{ range $index, $element := split . "," }}
<option value="{{ $element }}">{{ $element }}</option>
{{ end }}
</select>
{{ end }}

View File

@ -0,0 +1,5 @@
{{ $lang := .Get 0 }}
<div class="codeblock" data-lang="{{ $lang }}">
<pre><code class="{{ $lang }}">{{ highlight (trim .Inner "\n") $lang "" }}</code></pre>
</div>

View File

@ -0,0 +1,2 @@
{{ $id := .Get "src" | md5 }}
{{ partial "table.html" (dict "src" (.Get "src") "id" $id) }}

18
shell.nix Normal file
View File

@ -0,0 +1,18 @@
{ pkgs ? import <nixpkgs> { } }:
pkgs.mkShell
{
nativeBuildInputs = with pkgs; [
python312Packages.flask
python312Packages.flask-cors
python312Packages.requests
python312Packages.pandas
python312Packages.orjson
hugo
];
shellHook = ''
${pkgs.cowsay}/bin/cowsay "Welcome to the bitlab development environment!" | ${pkgs.lolcat}/bin/lolcat
'';
}

568
static/css/style.css Normal file
View File

@ -0,0 +1,568 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--font-family: sans-serif;
--font-size: 10;
--author-row-font-size: 12px;
--heading1-font-size: 25px;
--code-block-font-size: 10px;
--chart-modifier-height: 60px;
--article-max-width: 60vw;
--content-padding: 40px;
--content-margin: 40px;
--element-padding: 20px;
--element-margin: 10px;
--article-margin: 10px;
--heading-margin: 30px;
--radius: 5px;
--line-height: 1.5;
--checkbox-width: max-content;
--checkbox-font-size: 16px;
--avatar-width: 20px;
--background-color: #181a1b;
--text-color: #e0ddd9;
--subtext-color: #6c757d;
--link-color: #749571;
--hover-color: #0056b3;
--heading1-color: #fc8452;
--heading2-color: #e0ddd9;
--heading3-color: #e0ddd9;
--navbar-background-color: #343451;
--navbar-text-color: #e0ddd9;
--navbar-hover: #50507c;
--footer-background-color: #333;
--summary-container-hover-bg: #343a40;
--codeblock-bg-color: #0d1117;
--inline-code-bg-color: #749571;
--table-even-row-bg-color: #343a40;
--table-odd-row-bg-color: #515c66;
--table-header-bg: #212529;
--table-font-color: #e0ddd9;
--table-header-font-size: 14px;
--table-row-font-size: 12px;
}
@media (max-width: 800px) {
:root {
--font-size: 10;
--author-row-font-size: 12px;
--heading1-font-size: 25px;
--code-block-font-size: 10px;
--avatar-width: 20px;
--article-max-width: 100%;
--table-header-font-size: 14px;
--table-row-font-size: 12px;
}
}
body {
font-family: var(--font-family);
background-color: var(--background-color);
font-size: var(--font-size);
color: var(--text-color);
}
.main-page {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.main-content {
flex-grow: 1;
}
a {
text-decoration: none;
color: var(--link-color);
}
a:hover {
color: var(--hover-color);
}
.page-content {
color: var(--text-color);
padding-top: var(--content-padding);
padding: var(--content-padding);
padding-left: var(--content-padding);
padding-right: var(--content-padding);
max-width: 1000px;
line-height: var(--line-height);
text-align: left;
}
.page-content h1 {
color: var(--heading1-color);
}
.home-page {
display: flex;
align-items: flex-start;
}
.profile-img {
margin-right: 20px;
}
.profile-img img {
width: 300px;
height: auto;
}
@media (max-width: 800px) {
.home-page {
flex-direction: column;
align-items: center;
}
.profile-img {
margin-right: 0px;
order: 1;
height: auto;
}
.profile-img img {
width: 200px;
height: auto;
}
.home-page-content {
order: 2;
}
}
/* Articles */
.main-article {
padding: var(--content-padding);
width: var(--article-max-width);
line-height: 1.5;
text-align: left;
}
.main-article h1 {
color: var(--heading1-color);
font-size: var(--heading1-font-size);
}
.main-article h2,
h3 {
margin-bottom: var(--article-margin);
margin-top: var(--heading-margin);
color: var(--heading2-color);
}
.main-article ul,
ol {
margin-bottom: var(--article-margin);
margin-left: var(--article-margin);
}
.main-article li {
margin: 5px;
}
.main-article img {
max-width: 60vw;
margin-bottom: var(--article-margin);
}
time {
color: var(--subtext-color);
margin-bottom: var(--article-margin);
}
.article-card-flex-container {
margin-left: var(--content-margin);
margin-right: var(--content-margin);
margin-bottom: var(--content-margin);
display: flex;
flex-wrap: wrap;
justify-content: left;
line-height: var(--line-height);
text-align: left;
}
.article-card {
margin: var(--element-margin);
background-color: black;
width: 200px;
min-height: 100%;
border-radius: var(--radius);
transition: background-color 0.3s ease;
text-decoration: none; /* Remove underline */
position: relative;
}
.article-card-text p,
.article-text a {
color: var(--subtext-color);
margin-bottom: var(--element-margin);
}
.article-card-summary h3 {
color: var(--heading3-color);
margin: var(--article-margin);
font-size: 16px;
font-family: Arial, Helvetica, sans-serif;
}
.article-card-summary p {
color: var(--subtext-color);
margin: var(--article-margin);
}
.article-card-info {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.article-card-thumbnail {
max-width: 100%;
border-top-left-radius: var(--radius);
border-top-right-radius: var(--radius);
}
.article-card-author-row {
font-size: 10px;
position: absolute;
bottom: 0;
width: 100%;
margin: var(--element-margin);
}
.article-card:hover {
background-color: var(--summary-container-hover-bg);
}
/* Navbar */
.navbar {
background-color: var(--navbar-background-color);
display: flex;
justify-content: space-between;
position: sticky;
top: 0;
height: 50px;
width: 100%;
z-index: 999;
}
.navbar a {
color: var(--navbar-text-color);
padding: 10px;
margin: 5px;
text-decoration: none;
transition: background-color 0.3s ease;
border-radius: var(--radius);
}
.navbar a:hover {
padding: 10px;
background-color: var(--navbar-hover);
border-radius: var(--radius);
color: var(--text-color);
}
.navbar__left a {
text-decoration: none !important;
color: var(--text-color) !important;
font-size: 22px;
}
.navbar__left {
display: flex;
align-items: center;
}
.navbar__right {
display: flex;
align-items: center;
}
.navbar-links {
display: flex;
align-items: center;
margin-right: var(--element-margin);
}
.navbar-dropdown {
display: none;
}
@media (max-width: 800px) {
.navbar-links {
display: none;
}
.navbar-dropdown {
display: block;
align-items: center;
float: right;
max-height: 60px;
}
.hamburger {
width: 30px;
margin-right: var(--article-margin);
}
.navbar-dropdown .hamburger-dropbtn {
font-size: 66px;
display: flex;
align-items: center;
justify-content: center;
border: none;
max-height: 60px;
outline: none;
color: white;
background-color: inherit;
font-family: inherit;
margin: 0;
border-radius: var(--radius);
}
.navbar-dropdown:hover .hamburger {
background-color: var(--navbar-hover);
border-radius: var(--radius);
}
.navbar-dropdown-content {
display: none;
position: absolute;
right: 20px;
width: 300px;
background-color: var(--navbar-background-color);
min-width: 160px;
box-shadow: 0px 8px 16px 0px rgba(0, 0, 0, 0.2);
z-index: 1;
border-radius: var(--radius);
}
/* Links inside the dropdown */
.navbar-dropdown-content a {
float: none;
color: var(--text-color);
padding: 12px 16px;
text-decoration: none;
display: block;
text-align: left;
}
/* Add a grey background color to dropdown links on hover */
.navbar-dropdown-content a:hover {
background-color: var(--navbar-hover);
}
/* Show the dropdown menu on hover */
.navbar-dropdown:hover .navbar-dropdown-content {
display: block;
}
}
/* Charts */
.chart-flex-container {
display: flex;
}
.chart-flex-container article {
flex: 1;
}
#chart {
width: 100%;
height: 50vh;
}
.chart-area {
width: 60%;
margin: var(--article-margin);
}
/* @media only screen and (max-width: 800px) {
.chart-flex-container {
flex-direction: column;
}
.chart-area {
width: 95%;
margin: var(--article-margin);
}
#chart {
width: 100%;
height: 30vh;
order: 1;
}
.chart-flex-container article {
order: 2;
}
} */
/* Footer */
footer {
background-color: var(--footer-background-color);
color: var(--text-color);
padding: 20px;
display: flex;
justify-content: space-between;
}
footer a {
color: #f2f2f2;
transition: background-color 0.3s ease;
}
footer a:hover {
background-color: #ddd;
color: black;
}
/* Chart modifiers */
.chart-modifiers {
height: var(--chart-modifier-height);
}
.ck-button {
width: var(--checkbox-width);
color: white;
font-size: var(--checkbox-font-size);
cursor: pointer;
overflow: auto;
display: flex;
margin-left: 50px;
padding-top: 10px;
}
.ck-button label span {
border-radius: 4px;
text-align: center;
align-items: center;
justify-content: center;
background-color: #a19fbc;
margin: 5px;
display: flex;
width: 100px;
height: 30px;
}
.ck-button input:checked + span {
background-color: #666fbc;
}
.author-row {
display: flex;
justify-content: left;
align-items: center;
color: var(--subtext-color);
font-size: var(--author-row-font-size);
}
.avatar-container {
width: var(--avatar-width);
margin-right: var(--element-margin);
}
.avatar {
display: block;
width: 100%;
height: auto;
}
.author-name {
margin-right: var(--element-margin);
}
article p {
margin-bottom: var(--article-margin);
}
article em,
article strong {
color: var(--heading-color);
}
/* Code block */
pre {
overflow-x: auto;
}
.codeblock {
position: relative;
background-color: var(--codeblock-bg-color);
max-width: 100vw;
width: 60vw;
padding: var(--element-padding);
margin-top: var(--article-margin);
margin-bottom: var(--article-margin);
border-radius: var(--radius);
font-size: var(--code-block-font-size);
}
/* Code block language label */
.codeblock::before {
content: attr(data-lang);
position: absolute;
color: var(--heading1-color);
right: 5px;
top: 0px;
font-size: var(--code-block-font-size);
}
/* Styles for inline <code> */
:not(pre) > code {
color: #343a40;
background-color: var(--inline-code-bg-color);
padding: 0.1em 0.3em;
border-radius: var(--radius);
font-size: var(--code-block-font-size);
}
#jsonTableContainer {
max-width: 150%;
}
/* Tables */
table {
width: 100%;
border-collapse: collapse;
color: var(--table-font-color);
margin-bottom: var(--article-margin);
}
th {
background-color: var(--table-header-bg);
padding: 5px;
text-align: left;
font-size: var(--table-header-font-size);
}
td {
padding: 5px;
text-align: left;
max-width: 90px;
}
@media only screen and (max-width: 800px) {
#scrollable {
max-width: 90px;
max-height: 100%;
margin: 0;
padding: 0;
overflow: auto;
white-space: nowrap;
}
}
tr:nth-child(even) {
background-color: var(--table-even-row-bg-color);
font-size: var(--table-row-font-size);
}
tr:nth-child(odd) {
background-color: var(--table-odd-row-bg-color);
font-size: var(--table-row-font-size);
}

86
static/css/syntax.css Normal file
View File

@ -0,0 +1,86 @@
/* Background */ .bg { color: #e6edf3; background-color: #0d1117; }
/* PreWrapper */ .chroma { color: #e6edf3; background-color: #0d1117; }
/* Other */ .chroma .x { }
/* Error */ .chroma .err { color: #f85149 }
/* CodeLine */ .chroma .cl { }
/* LineLink */ .chroma .lnlinks { outline: none; text-decoration: none; color: inherit }
/* LineTableTD */ .chroma .lntd { vertical-align: top; padding: 0; margin: 0; border: 0; }
/* LineTable */ .chroma .lntable { border-spacing: 0; padding: 0; margin: 0; border: 0; }
/* LineHighlight */ .chroma .hl { color: #6e7681 }
/* LineNumbersTable */ .chroma .lnt { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #737679 }
/* LineNumbers */ .chroma .ln { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #6e7681 }
/* Line */ .chroma .line { display: flex; }
/* Keyword */ .chroma .k { color: #ff7b72 }
/* KeywordConstant */ .chroma .kc { color: #79c0ff }
/* KeywordDeclaration */ .chroma .kd { color: #ff7b72 }
/* KeywordNamespace */ .chroma .kn { color: #ff7b72 }
/* KeywordPseudo */ .chroma .kp { color: #79c0ff }
/* KeywordReserved */ .chroma .kr { color: #ff7b72 }
/* KeywordType */ .chroma .kt { color: #ff7b72 }
/* Name */ .chroma .n { }
/* NameAttribute */ .chroma .na { }
/* NameBuiltin */ .chroma .nb { }
/* NameBuiltinPseudo */ .chroma .bp { }
/* NameClass */ .chroma .nc { color: #f0883e; font-weight: bold }
/* NameConstant */ .chroma .no { color: #79c0ff; font-weight: bold }
/* NameDecorator */ .chroma .nd { color: #d2a8ff; font-weight: bold }
/* NameEntity */ .chroma .ni { color: #ffa657 }
/* NameException */ .chroma .ne { color: #f0883e; font-weight: bold }
/* NameFunction */ .chroma .nf { color: #d2a8ff; font-weight: bold }
/* NameFunctionMagic */ .chroma .fm { }
/* NameLabel */ .chroma .nl { color: #79c0ff; font-weight: bold }
/* NameNamespace */ .chroma .nn { color: #ff7b72 }
/* NameOther */ .chroma .nx { }
/* NameProperty */ .chroma .py { color: #79c0ff }
/* NameTag */ .chroma .nt { color: #7ee787 }
/* NameVariable */ .chroma .nv { color: #79c0ff }
/* NameVariableClass */ .chroma .vc { }
/* NameVariableGlobal */ .chroma .vg { }
/* NameVariableInstance */ .chroma .vi { }
/* NameVariableMagic */ .chroma .vm { }
/* Literal */ .chroma .l { color: #a5d6ff }
/* LiteralDate */ .chroma .ld { color: #79c0ff }
/* LiteralString */ .chroma .s { color: #a5d6ff }
/* LiteralStringAffix */ .chroma .sa { color: #79c0ff }
/* LiteralStringBacktick */ .chroma .sb { color: #a5d6ff }
/* LiteralStringChar */ .chroma .sc { color: #a5d6ff }
/* LiteralStringDelimiter */ .chroma .dl { color: #79c0ff }
/* LiteralStringDoc */ .chroma .sd { color: #a5d6ff }
/* LiteralStringDouble */ .chroma .s2 { color: #a5d6ff }
/* LiteralStringEscape */ .chroma .se { color: #79c0ff }
/* LiteralStringHeredoc */ .chroma .sh { color: #79c0ff }
/* LiteralStringInterpol */ .chroma .si { color: #a5d6ff }
/* LiteralStringOther */ .chroma .sx { color: #a5d6ff }
/* LiteralStringRegex */ .chroma .sr { color: #79c0ff }
/* LiteralStringSingle */ .chroma .s1 { color: #a5d6ff }
/* LiteralStringSymbol */ .chroma .ss { color: #a5d6ff }
/* LiteralNumber */ .chroma .m { color: #a5d6ff }
/* LiteralNumberBin */ .chroma .mb { color: #a5d6ff }
/* LiteralNumberFloat */ .chroma .mf { color: #a5d6ff }
/* LiteralNumberHex */ .chroma .mh { color: #a5d6ff }
/* LiteralNumberInteger */ .chroma .mi { color: #a5d6ff }
/* LiteralNumberIntegerLong */ .chroma .il { color: #a5d6ff }
/* LiteralNumberOct */ .chroma .mo { color: #a5d6ff }
/* Operator */ .chroma .o { color: #ff7b72; font-weight: bold }
/* OperatorWord */ .chroma .ow { color: #ff7b72; font-weight: bold }
/* Punctuation */ .chroma .p { }
/* Comment */ .chroma .c { color: #8b949e; font-style: italic }
/* CommentHashbang */ .chroma .ch { color: #8b949e; font-style: italic }
/* CommentMultiline */ .chroma .cm { color: #8b949e; font-style: italic }
/* CommentSingle */ .chroma .c1 { color: #8b949e; font-style: italic }
/* CommentSpecial */ .chroma .cs { color: #8b949e; font-weight: bold; font-style: italic }
/* CommentPreproc */ .chroma .cp { color: #8b949e; font-weight: bold; font-style: italic }
/* CommentPreprocFile */ .chroma .cpf { color: #8b949e; font-weight: bold; font-style: italic }
/* Generic */ .chroma .g { }
/* GenericDeleted */ .chroma .gd { color: #ffa198; background-color: #490202 }
/* GenericEmph */ .chroma .ge { font-style: italic }
/* GenericError */ .chroma .gr { color: #ffa198 }
/* GenericHeading */ .chroma .gh { color: #79c0ff; font-weight: bold }
/* GenericInserted */ .chroma .gi { color: #56d364; background-color: #0f5323 }
/* GenericOutput */ .chroma .go { color: #8b949e }
/* GenericPrompt */ .chroma .gp { color: #8b949e }
/* GenericStrong */ .chroma .gs { font-weight: bold }
/* GenericSubheading */ .chroma .gu { color: #79c0ff }
/* GenericTraceback */ .chroma .gt { color: #ff7b72 }
/* GenericUnderline */ .chroma .gl { text-decoration: underline }
/* TextWhitespace */ .chroma .w { color: #6e7681 }

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

11
static/hamburger-menu.svg Normal file
View File

@ -0,0 +1,11 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg fill="#e0ddd9" width="800px" height="800px" viewBox="0 0 32 32" enable-background="new 0 0 32 32" id="Glyph" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
<g id="SVGRepo_iconCarrier">
<path d="M26,16c0,1.104-0.896,2-2,2H8c-1.104,0-2-0.896-2-2s0.896-2,2-2h16C25.104,14,26,14.896,26,16z" id="XMLID_314_"/>
<path d="M26,8c0,1.104-0.896,2-2,2H8c-1.104,0-2-0.896-2-2s0.896-2,2-2h16C25.104,6,26,6.896,26,8z" id="XMLID_315_"/>
<path d="M26,24c0,1.104-0.896,2-2,2H8c-1.104,0-2-0.896-2-2s0.896-2,2-2h16C25.104,22,26,22.896,26,24z" id="XMLID_316_"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 941 B

View File

@ -0,0 +1,229 @@
let chartData = [];
let myChart;
async function fetchDataForChart(str) {
try {
const apiEndpoint = `https://api.bitlab21.com/bitcoin_business_growth_by_country?cumulative_period_type=365 day&countries=${str}`;
const response = await fetch(apiEndpoint);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const fetchedData = await response.json();
const newData = fetchedData.reduce((acc, item) => {
const objectId = item.country_name;
if (!acc[objectId]) {
acc[objectId] = [];
}
acc[objectId].push([item.date, item.cumulative_current_value]);
return acc;
}, {});
chartData = { ...chartData, ...newData };
updateChart();
} catch (error) {
console.error("Fetching data failed:", error);
}
}
function updateChart() {
let chartDataMap = new Map();
for (let objectId in chartData) {
chartDataMap.set(objectId, chartData[objectId]);
}
option = {
backgroundColor: backgroundColor,
grid: grid,
tooltip: {
backgroundColor: tooltipBgColor,
order: "seriesDesc",
textStyle: textStyleMain,
trigger: "axis",
},
toolbox: toolboxParams,
xAxis: {
axisTick: axisTick,
axisLabel: axisLabel,
axisLine: axisLine,
type: "time",
name: "Date",
},
yAxis: {
axisTick: axisTick,
splitLine: {
show: false,
},
axisLabel: axisLabel,
axisLine: axisLine,
},
series: Array.from(chartDataMap.entries()).map(([name, data]) => ({
name,
type: "line",
data,
showSymbol: false,
})),
};
myChart.setOption(option, true);
}
function handleCheckboxChange(event) {
if (event.target.type === "checkbox") {
const boxChecked = event.target.checked;
const boxId = event.target.id;
// Remove unchecked boxes
if (boxChecked === false) {
delete chartData[boxId];
updateChart();
// Add checked boxes
} else {
fetchDataForChart(boxId);
}
}
}
document.addEventListener("reloadTable", () => {
myChart = echarts.init(document.getElementById("chart"));
jsonTableCheckedBoxes = document.querySelectorAll(
'#jsonTableContainer input[type="checkbox"]:checked',
);
const checkedBoxes = Array.from(jsonTableCheckedBoxes).map(
(checkbox) => checkbox.id,
);
const str = checkedBoxes.join(",");
chartData = [];
fetchDataForChart(str);
jsonTable = document.getElementById("jsonTableContainer");
jsonTable.removeEventListener("change", handleCheckboxChange);
jsonTable.addEventListener("change", handleCheckboxChange);
});
// let checkedBoxes = [];
// let chartData = {};
// const apiEndpoint = {{ .Get "endpoint" }};
// const apiCategoryVariable = {{ .Get "apiCategoryVariable" }};
// let myChart;
// let chartDataMap = new Map();
//
// // Define base chart parameters
// const chartBaseConfig = {
// backgroundColor: backgroundColor,
// grid: grid,
// tooltip: {
// backgroundColor: tooltipBgColor,
// order: "seriesDesc",
// textStyle: textStyleMain,
// trigger: "axis",
// },
// toolbox: toolboxParams,
// xAxis: {
// axisTick: axisTick,
// axisLabel: axisLabel,
// axisLine: axisLine,
// type: "time",
// name: "Date",
// },
// yAxis: {
// axisTick: axisTick,
// splitLine: {
// show: false,
// },
// axisLabel: axisLabel,
// axisLine: axisLine,
// },
// series: [],
// };
//
// function fetchDataForChart(params) {
// return fetch(
// apiEndpoint+"&"+apiCategoryVariable+"="+params,
// )
// .then((response) => response.json())
// .then((data) => {
// // Transform data using reduce
// // so we end with an object where each element is
// // a separate country containing an array of
// // data
// const chartData = data.reduce((acc, item) => {
// const objectId = item.{{ .Get "objectCategoryId" | safeJS }};
// if (!acc[objectId]) {
// acc[objectId] = [];
// }
// acc[objectId].push([item.{{ .Get "plotXVariable" | safeJS }}, item.{{ .Get "plotYVariable" | safeJS }}]);
// return acc;
// }, {});
// return chartData;
// });
// }
//
// // Draw chart with data from chartData
// function updateChart() {
// for (let objectId in chartData) {
// chartDataMap.set(objectId, chartData[objectId]);
// }
//
// // To remove keys in chartDataMap
// chartDataMap.forEach((value, key) => {
// if (!chartData.hasOwnProperty(key)) {
// chartDataMap.delete(key);
// }
// });
// option = {
// ...chartBaseConfig,
//
// series: Array.from(chartDataMap.entries()).map(
// ([name, data]) => ({
// name,
// type: "line",
// data,
// showSymbol: false,
// }),
// ),
// };
// myChart.setOption(option, true);
// }
//
// // Listen for reloadTable event
// document
// .getElementById("jsonTableContainer")
// .addEventListener("reloadTable", (event) => {
// myChart = echarts.init(document.getElementById("chart"));
// console.log("table reloaded")
//
// // Get initial checkedBoxes when table loaded
// // and cast to string for passing to API
// let checkedBoxes = Array.from(
// document.querySelectorAll(
// '#jsonTableContainer input[type="checkbox"]:checked',
// ),
// ).map((checkbox) => checkbox.id);
//
// let str = checkedBoxes.join(",");
// fetchDataForChart(str).then((initialData) => {
// chartData = initialData;
// updateChart();
// });
// console.log(str)
//
// // Listen for checkbox events in the table
// document
// .getElementById("jsonTableContainer")
// .addEventListener("change", (event) => {
// if (event.target.type === "checkbox") {
// boxChecked = event.target.checked
// boxId = event.target.id
// // Remove unchecked boxes
//
// if (boxChecked === false) {
// delete chartData[boxId];
// updateChart();
// console.log("Removed "+boxId+" from chartData")
// // Add checked boxes
// } else {
// fetchDataForChart(boxId).then((newData) => {
// chartData = { ...chartData, ...newData };
// updateChart();
// console.log("added "+boxId+" to chartData")
// });
// }
// }});
// });
//

View File

@ -0,0 +1,98 @@
async function fetchDataForTable() {
try {
const dropdown = document.querySelector(".dropdownFilter");
let selectedIndex = dropdown.selectedIndex;
let selectedValue = dropdown.options[selectedIndex].value;
const apiEndpoint =
"https://api.bitlab21.com/bitcoin_business_growth_by_country?latest_date=true";
const response = await fetch(
apiEndpoint + `&cumulative_period_type=${selectedValue}`,
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const fetchedData = await response.json();
data = fetchedData;
createTable(data);
} catch (error) {
console.error("Fetching data failed:", error);
}
}
function createTable(data) {
const jsonTableContainer = document.getElementById("jsonTableContainer");
jsonTableContainer.innerHTML = "";
const table = document.createElement("table");
let checkedBoxes = [];
const thead = document.createElement("thead");
const headerRow = document.createElement("tr");
tableHeaders = [
"Select",
"Country",
"Previous #",
"Current #",
"Diff",
"% Diff",
];
tableHeaders.forEach((header) => {
const th = document.createElement("th");
th.textContent = header;
headerRow.appendChild(th);
});
thead.appendChild(headerRow);
table.appendChild(thead);
const tbody = document.createElement("tbody");
filteredData = data.filter((item) => 1 === 1);
filteredData.sort((a, b) => {
const sortField = "diff";
return Math.abs(b[sortField]) - Math.abs(a[sortField]);
});
filteredData.forEach((row, index) => {
const tr = document.createElement("tr");
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = index < 5; // Check the top 5 rows by default
checkbox.addEventListener("change", function () {
if (this.checked) {
checkedBoxes.push(row["country_name"]);
} else {
checkedBoxes = checkedBoxes.filter((id) => id !== row["country_name"]);
}
});
checkbox.id = row["country_name"];
tr.appendChild(document.createElement("td")).append(checkbox);
columnNames = [
"country_name",
"cumulative_previous_value",
"cumulative_current_value",
"diff",
"pct_diff",
];
columnNames.forEach((columnName) => {
const td = document.createElement("td");
const div = document.createElement("div");
div.id = "scrollable";
div.textContent = row[columnName];
td.appendChild(div);
tr.appendChild(td);
});
tbody.appendChild(tr);
if (checkbox.checked) {
checkedBoxes.push(row["country_name"]);
}
});
table.appendChild(tbody);
const tableContainer = document.getElementById("jsonTableContainer");
tableContainer.appendChild(table);
tableContainer.dispatchEvent(
new CustomEvent("reloadTable", { bubbles: true }),
);
}
window.onload = () => {
const dropdown = document.querySelector(".dropdownFilter");
fetchDataForTable();
dropdown.addEventListener("change", () => {
fetchDataForTable();
});
};

View File

@ -0,0 +1,58 @@
let dataArr = [];
const myChart = echarts.init(document.getElementById("chart"));
$.get("https://api.bitlab21.com/price", {}, (response) => {
dataArr = response;
console.log(dataArr);
initEchart();
});
function initEchart() {
const option = {
backgroundColor: backgroundColor,
tooltip: {
backgroundColor: tooltipBgColor,
order: "seriesDesc",
textStyle: textStyleMain,
trigger: "axis",
},
toolbox: toolboxParams,
xAxis: {
data: dataArr.map((row) => row.date),
axisTick: axisTick,
axisLabel: axisLabel,
axisLine: axisLine,
},
grid: grid,
dataZoom: dataZoom(),
yAxis: [
{
type: "value",
name: "Price (USD)",
nameLocation: "middle",
nameTextStyle: {
fontSize: 12 * fontScale,
padding: [0, 0, 40, 0],
color: "#eff1d6",
},
position: "left",
alignTicks: true,
axisTick: axisTick,
axisLine: axisLine,
splitLine: {
show: false,
},
axisLabel: axisLabel,
},
],
series: [
{
type: "line",
name: "Price (USD)",
data: dataArr.map((row) => row.price),
},
],
};
myChart.setOption(option);
}

104
static/js/chart-params.js Normal file
View File

@ -0,0 +1,104 @@
const fontScale = 1;
const backgroundColor = "#181a1b";
const tooltipBgColor = "#00557f";
const toolboxParams = {
itemSize: 8 * fontScale,
showTitle: true,
top: "-1%",
right: "20%",
iconStyle: {
borderColor: "#eff1d6",
borderWidth: 2,
},
feature: {
dataZoom: {
yAxisIndex: "none",
},
restore: {},
saveAsImage: {
pixelRatio: 2,
},
},
};
const textStyleMain = {
fontFamily: "sans-serif",
fontSize: 12 * fontScale,
color: "#eff1d6",
};
const axisLabel = {
fontSize: 12 * fontScale,
color: "#eff1d6",
margin: 10,
};
const axisLine = {
show: true,
lineStyle: {
color: "#eff1d6",
width: 2,
},
};
const axisTick = { show: true };
const grid = {
bottom: 30,
top: 10,
left: 15,
containLabel: true,
};
const yaxisTextStyle = {
fontSize: 12 * fontScale,
padding: [0, 0, 10, 0],
color: "#eff1d6",
};
const yaxisTextStyle2 = {
fontSize: 12 * fontScale,
padding: [50, 0, 0, 0],
color: "#eff1d6",
};
function dataZoom(start = 90, end = 100, bottom = 15, height = 15) {
const dataZoom = [
{
start: start,
end: end,
bottom: bottom,
height: height,
},
];
return dataZoom;
}
function nFormatter(value, digits) {
const lookup = [
{ value: 1, symbol: "" },
{ value: 1e3, symbol: "k" },
{ value: 1e6, symbol: "M" },
{ value: 1e9, symbol: "G" },
{ value: 1e12, symbol: "T" },
{ value: 1e15, symbol: "P" },
{ value: 1e18, symbol: "E" },
];
const rx = /\.0+$|(\.[0-9]*[1-9])0+$/;
const item = lookup
.slice()
.reverse()
.find((item) => value >= item.value);
return item
? (value / item.value).toFixed(digits).replace(rx, "$1") + item.symbol
: "0";
}
$(window).on("resize", function () {
if (myChart != null && myChart != undefined) {
myChart.resize();
}
});

View File

@ -0,0 +1,101 @@
let dataArr = [];
const myChart = echarts.init(document.getElementById("chart"));
$.get("https://api.bitlab21.com/feerates", {}, (response) => {
dataArr = response;
console.log(dataArr);
initEchart();
});
function initEchart() {
const option = {
backgroundColor: backgroundColor,
tooltip: {
valueFormatter: (value) => `${value.toFixed(0)} sats/vByte`,
backgroundColor: tooltipBgColor,
order: "seriesDesc",
textStyle: textStyleMain,
trigger: "axis",
},
toolbox: toolboxParams,
xAxis: {
data: dataArr.map((row) => row.date),
axisTick: axisTick,
axisLabel: axisLabel,
axisLine: axisLine,
},
yAxis: {
axisTick: axisTick,
splitLine: {
show: false,
},
axisLabel: axisLabel,
axisLine: axisLine,
},
grid: grid,
dataZoom: dataZoom((start = 98)),
series: [
{
type: "line",
id: "min-feerate",
name: "min",
showSymbol: false,
barCategoryGap: "0%",
data: [0],
},
{
type: "bar",
stack: "value",
name: "10th",
barCategoryGap: "0%",
data: dataArr.map((row) => row.feerate_10th),
},
{
type: "bar",
stack: "value",
name: "25th",
barCategoryGap: "0%",
data: dataArr.map((row) => row.feerate_25th),
},
{
type: "bar",
stack: "value",
name: "50th",
barCategoryGap: "0%",
data: dataArr.map((row) => row.feerate_50th),
},
{
type: "line",
id: "mean-feerate",
name: "mean",
showSymbol: false,
barCategoryGap: "0%",
data: [0],
},
{
type: "bar",
stack: "value",
name: "75th",
barCategoryGap: "0%",
data: dataArr.map((row) => row.feerate_75th),
},
{
type: "bar",
stack: "value",
name: "90th",
barCategoryGap: "0%",
data: dataArr.map((row) => row.feerate_90th),
},
{
type: "line",
id: "max-feerate",
name: "max",
showSymbol: false,
barCategoryGap: "0%",
data: [0],
},
],
};
myChart.setOption(option);
}

108
static/js/hashrate.js Normal file
View File

@ -0,0 +1,108 @@
let dataArr = [];
const myChart = echarts.init(document.getElementById("chart"));
$.get("https://api.bitlab21.com/hashrate", {}, (response) => {
dataArr = response;
console.log(dataArr);
initEchart();
});
function initEchart() {
const option = {
backgroundColor: backgroundColor,
tooltip: {
backgroundColor: tooltipBgColor,
order: "seriesDesc",
valueFormatter(value, index) {
return nFormatter(value, 0);
},
textStyle: textStyleMain,
trigger: "axis",
},
toolbox: toolboxParams,
xAxis: {
data: dataArr.map((row) => row.date),
axisTick: axisTick,
axisLabel: axisLabel,
axisLine: axisLine,
},
grid: grid,
dataZoom: dataZoom(),
yAxis: [
{
type: "value",
name: "Hashrate (H/s)",
nameLocation: "middle",
nameTextStyle: {
fontSize: 12 * fontScale,
padding: [0, 0, 20, 0],
color: "#eff1d6",
},
position: "left",
alignTicks: true,
axisTick: axisTick,
splitLine: {
show: false,
},
axisLine: axisLine,
axisLabel: {
fontSize: 12 * fontScale,
color: "#eff1d6",
formatter(value, index) {
return nFormatter(value, 0);
},
},
},
{
type: "value",
name: "Difficulty",
nameLocation: "middle",
nameTextStyle: {
fontSize: 12 * fontScale,
padding: [20, 0, 0, 0],
color: "#eff1d6",
},
axisTick: axisTick,
position: "right",
alignTicks: true,
axisLine: axisLine,
splitLine: {
show: false,
},
axisLabel: {
fontSize: 12 * fontScale,
color: "#eff1d6",
formatter(value, index) {
return nFormatter(value, 0);
},
},
},
],
series: [
{
type: "line",
name: "Hashrate",
barCategoryGap: "0%",
data: dataArr.map((row) => row.hashrate),
},
{
type: "line",
color: "red",
id: "rolling-average",
name: "MA28",
showSymbol: false,
barCategoryGap: "0%",
data: dataArr.map((row) => row.hashrate28),
},
{
type: "line",
yAxisIndex: 1,
name: "Difficulty",
barCategoryGap: "0%",
data: dataArr.map((row) => row.difficulty),
},
],
};
myChart.setOption(option);
}

140
static/js/miner-rewards.js Normal file
View File

@ -0,0 +1,140 @@
let dataArr = [];
const myChart = echarts.init(document.getElementById("chart"));
$.get("https://api.bitlab21.com/miner_rewards", {}, (response) => {
dataArr = response;
console.log(dataArr);
initEchart();
});
function initEchart() {
const option = {
backgroundColor: backgroundColor,
tooltip: {
backgroundColor: tooltipBgColor,
order: "seriesDesc",
textStyle: textStyleMain,
trigger: "axis",
},
toolbox: toolboxParams,
xAxis: {
data: dataArr.map((row) => row.date),
axisTick: axisTick,
axisLabel: axisLabel,
axisLine: axisLine,
},
grid: grid,
dataZoom: dataZoom(97),
yAxis: [
{
type: "value",
nameGap: 50,
name: "Total Daily Rewards (USD)",
nameLocation: "middle",
nameTextStyle: {
fontSize: 12 * fontScale,
padding: [0, 0, 15, 0],
color: "#eff1d6",
},
position: "left",
alignTicks: true,
axisTick: axisTick,
axisLine: axisLine,
splitLine: {
show: false,
},
axisLabel: axisLabel,
},
{
type: "value",
name: "Block Subsidy (BTC)",
nameGap: -30,
nameLocation: "middle",
nameTextStyle: yaxisTextStyle2,
axisTick: axisTick,
position: "right",
alignTicks: true,
axisLine: axisLine,
splitLine: {
show: false,
},
axisLabel: {
fontSize: 12 * fontScale,
color: "#eff1d6",
},
},
],
series: [
{
type: "line",
name: "Daily Subsidy (USD)",
smooth: true,
stack: "Total",
areaStyle: {},
symbol: "none",
lineStyle: {
width: 0,
},
data: dataArr.map((row) => row.subsidy_usd),
},
{
type: "line",
name: "Daily Fees (USD)",
smooth: true,
stack: "Total",
areaStyle: {},
symbol: "none",
lineStyle: {
width: 0,
},
data: dataArr.map((row) => row.totalfee_usd),
},
{
type: "line",
name: "Daily Total Reward (USD)",
smooth: true,
symbol: "none",
lineStyle: {
width: 1,
color: "#eff1d6",
},
data: dataArr.map((row) => row.total_reward_usd),
},
{
type: "line",
yAxisIndex: 1,
name: "Block Subsidy (BTC)",
symbol: "none",
lineStyle: {
width: 3,
},
data: dataArr.map((row) => row.block_subsidy),
},
],
};
myChart.setOption(option);
}
const checkboxLog = document.body.querySelector("#checkbox-log");
checkboxLog.addEventListener("change", (e) => {
const isChecked = e.target.checked;
myChart.setOption({
yAxis: [
{
type: "value",
type: isChecked ? "log" : "value",
name: "Price (USD)",
nameLocation: "middle",
splitNumber: isChecked ? 5 : 5,
nameTextStyle: yaxisTextStyle,
position: "left",
alignTicks: true,
axisLine: axisLine,
axisLabel: xaxisLabel,
},
],
dataZoom: dataZoom((start = isChecked ? 0 : 90)),
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
static/sam.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB