convert all images to webp
|
@ -0,0 +1,5 @@
|
|||
/public
|
||||
*.old
|
||||
.hugo_build.lock
|
||||
*.json
|
||||
*__pycache__*
|
|
@ -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.
|
|
@ -0,0 +1,5 @@
|
|||
+++
|
||||
title = '{{ replace .File.ContentBaseName "-" " " | title }}'
|
||||
date = {{ .Date }}
|
||||
draft = true
|
||||
+++
|
|
@ -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()
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: "Bitcoin"
|
||||
---
|
||||
|
||||
Below are various bitcoin related metrics and charts.
|
|
@ -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" >}}
|
|
@ -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" >}}
|
|
@ -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" >}}
|
|
@ -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" >}}
|
|
@ -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" >}}
|
|
@ -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.
|
|
@ -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
|
|
@ -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!
|
|
@ -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.
|
|
@ -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.
|
|
@ -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.
|
||||
|
|
@ -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)
|
|
@ -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.
|
|
@ -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.
|
|
@ -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!
|
||||
|
|
@ -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.
|
|
@ -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.
|
||||
|
|
@ -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)
|
|
@ -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
|
|
@ -0,0 +1,5 @@
|
|||
baseURL = 'https://bitlab21.com/'
|
||||
languageCode = 'en-gb'
|
||||
title = 'Bitlab21'
|
||||
|
||||
markup.highlight.noClasses=false
|
|
@ -0,0 +1,7 @@
|
|||
<!doctype html>
|
||||
<head>
|
||||
{{ partial "head.html" . }}
|
||||
</head>
|
||||
<html lang="{{ .Site.LanguageCode }}">
|
||||
{{ template "partials/body.html" . }}
|
||||
</html>
|
|
@ -0,0 +1,5 @@
|
|||
{{ define "main" }}
|
||||
<div class="page-content">
|
||||
{{ .Content }}
|
||||
</div>
|
||||
{{ end }}
|
|
@ -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 }}
|
|
@ -0,0 +1,3 @@
|
|||
{{ define "main" }}
|
||||
{{ template "partials/main-article.html" . }}
|
||||
{{ end }}
|
|
@ -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>
|
|
@ -0,0 +1,5 @@
|
|||
{{ define "main" }}
|
||||
<div class="page-content">
|
||||
{{ .Content }}
|
||||
</div>
|
||||
{{ end }}
|
|
@ -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 }}
|
|
@ -0,0 +1,8 @@
|
|||
<body>
|
||||
{{ partial "header.html" . }}
|
||||
<main class="main-content">
|
||||
{{- block "main" . }}
|
||||
{{ end -}}
|
||||
</main>
|
||||
{{ partial "footer.html" . }}
|
||||
</body>
|
|
@ -0,0 +1,5 @@
|
|||
<div id="chart">
|
||||
<canvas id="{{ .id }}"></canvas>
|
||||
<script src="{{ .src }}"></script>
|
||||
</div>
|
||||
<script src="/js/chart-params.js"></script>
|
|
@ -0,0 +1,8 @@
|
|||
<footer>
|
||||
<div id="footer">
|
||||
<p>
|
||||
2023 BitLab21. Uncopywrited.
|
||||
<a href="https://unlicense.org/">View License.</a>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -0,0 +1,3 @@
|
|||
{{ range $.Site.Data.navbarlinks }}
|
||||
<ul><a href="{{ .url }}">{{ .name }}</a></ul>
|
||||
{{ end }}
|
|
@ -0,0 +1,2 @@
|
|||
<script src="{{ .src }}"></script>
|
||||
<table id="jsonTableContainer"></table>
|
|
@ -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>
|
|
@ -0,0 +1,2 @@
|
|||
{{ $id := .Get "src" | md5 }}
|
||||
{{ partial "chart.html" (dict "src" (.Get "src") "id" $id) }}
|
|
@ -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 }}
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
{{ $id := .Get "src" | md5 }}
|
||||
{{ partial "table.html" (dict "src" (.Get "src") "id" $id) }}
|
|
@ -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
|
||||
'';
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
|
@ -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 }
|
After Width: | Height: | Size: 15 KiB |
|
@ -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 |
|
@ -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")
|
||||
// });
|
||||
// }
|
||||
// }});
|
||||
// });
|
||||
//
|
|
@ -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();
|
||||
});
|
||||
};
|
|
@ -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);
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
});
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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)),
|
||||
});
|
||||
});
|
After Width: | Height: | Size: 26 KiB |
After Width: | Height: | Size: 16 KiB |
After Width: | Height: | Size: 12 KiB |
After Width: | Height: | Size: 36 KiB |
After Width: | Height: | Size: 17 KiB |
After Width: | Height: | Size: 14 KiB |
After Width: | Height: | Size: 3.5 KiB |
After Width: | Height: | Size: 6.2 KiB |
After Width: | Height: | Size: 7.8 KiB |
After Width: | Height: | Size: 9.6 KiB |
After Width: | Height: | Size: 5.7 KiB |
After Width: | Height: | Size: 22 KiB |
After Width: | Height: | Size: 53 KiB |
After Width: | Height: | Size: 85 KiB |
After Width: | Height: | Size: 49 KiB |
After Width: | Height: | Size: 99 KiB |
After Width: | Height: | Size: 13 KiB |
After Width: | Height: | Size: 21 KiB |
After Width: | Height: | Size: 25 KiB |
After Width: | Height: | Size: 29 KiB |
After Width: | Height: | Size: 33 KiB |
After Width: | Height: | Size: 33 KiB |
After Width: | Height: | Size: 42 KiB |
After Width: | Height: | Size: 47 KiB |
After Width: | Height: | Size: 10 KiB |
After Width: | Height: | Size: 32 KiB |
After Width: | Height: | Size: 34 KiB |
After Width: | Height: | Size: 14 KiB |