Skip to content

Commit

Permalink
feat(layouts): add logo layout
Browse files Browse the repository at this point in the history
  • Loading branch information
loiccoyle committed Jul 1, 2024
1 parent 72f498f commit 1432548
Show file tree
Hide file tree
Showing 4 changed files with 188 additions and 35 deletions.
168 changes: 135 additions & 33 deletions tinyticker/layouts.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,8 @@
import numpy as np
from matplotlib.axes import Axes
from matplotlib.figure import Figure
from matplotlib.text import Text
from matplotlib.ticker import FormatStrFormatter
from PIL import Image
from PIL import Image, ImageFont, ImageDraw

from .config import LayoutConfig
from .tickers._base import TickerBase, TickerResponse
Expand Down Expand Up @@ -51,24 +50,33 @@
logger = logging.getLogger(__name__)


def _adjust_text_width(text: Text, max_width: int, fontsize: int) -> Text:
"""Adjust the fontsize of the text to fit within the provided width.
def _resize_aspect(image: Image.Image, size: Tuple[int, int]):
(width, height) = image.size
(target_width, target_height) = size
if width < height:
out = image.resize((round(width * target_height / height), target_height))
else:
out = image.resize((target_width, round(height * target_width / width)))
return out


def _fontsize_for_size(
text_size: Tuple[float, float], fontsize: float, size: Tuple[int, int]
) -> float:
"""Interpolates to get the maximum font size to fit the text within the provided size.
Args:
text: the `matplotlib.text.Text` object to adjust.
max_width: the maximum width the text can be.
fontsize: the desired fontsize.
text_size: The text width and height at current font size.
fontsize: The current font size
size: The target size to fit the text within.
Returns:
The adjusted `matplotlib.text.Text` object.
The computed font size.
"""
# try the provided fontsize
text.set_fontsize(fontsize)
text_width = text.get_window_extent().width
if text_width > max_width:
# adjust the fontsize to fit within the width
text.set_fontsize(fontsize * max_width / text_width)
return text
(text_width, text_height) = text_size
(width, height) = size
return min(fontsize * width / text_width, fontsize * height / text_height)


def _strip_ax(ax: Axes) -> None:
Expand Down Expand Up @@ -291,31 +299,125 @@ def big_price(size: Size, ticker: TickerBase, resp: TickerResponse) -> Image.Ima
"""Big price layout."""
perc_change = _perc_change(ticker, resp)
fig, (ax, _) = _historical_plot(size, ticker, resp)
_adjust_text_width(
fig.suptitle(
f"{ticker.config.symbol} {CURRENCY_SYMBOLS.get(ticker.currency, '$')}{resp.current_price:.2f}",
weight="bold",
x=0,
y=1,
horizontalalignment="left",
),
size[0],
18,
text = fig.suptitle(
f"{ticker.config.symbol} {CURRENCY_SYMBOLS.get(ticker.currency, '$')}{resp.current_price:.2f}",
weight="bold",
x=0,
y=1,
horizontalalignment="left",
fontsize=18,
)
text.set_fontsize(
_fontsize_for_size(
(text.get_window_extent().width, text.get_window_extent().height),
18,
(size[0], 22),
)
)

sub_string = f"{len(resp.historical)}x{ticker.config.interval} {perc_change:+.2f}%"
if ticker.config.avg_buy_price:
sub_string += f" ({_perc_change_abp(ticker, resp):+.2f}%)"

_adjust_text_width(
ax.set_title(
sub_string,
weight="bold",
loc="left",
),
size[0],
12,
text = ax.set_title(
sub_string,
weight="bold",
loc="left",
fontsize=12,
)
text.set_fontsize(
_fontsize_for_size(
(text.get_window_extent().width, text.get_window_extent().height),
12,
(size[0], 18),
)
)

ax = apply_layout_config(ax, ticker.config.layout, resp)
return _fig_to_image(fig)


@register
def logo(size: Size, ticker: TickerBase, resp: TickerResponse) -> Image.Image:
padding = min(8, int(0.05 * size[0]))
half_padding = round(padding / 2)
logo_height = int(size[0] * 0.4) - 2 * padding
logo_width = logo_height

small_font = ImageFont.truetype("DejaVuSansMono.ttf", size=12)
range_text = f"{len(resp.historical)}x{ticker.config.interval} {_perc_change(ticker, resp):+.2f}%"
range_text_bbox = small_font.getbbox(range_text)
plot_size = (size[0] - (logo_width + 2 * padding), logo_height - range_text_bbox[3])

fig, axes = _historical_plot(plot_size, ticker, resp)
apply_layout_config(axes[0], ticker.config.layout, resp)
axes[0].axhline(
resp.historical[["Open", "High", "Low", "Close"]].mean().mean(),
linestyle="dotted",
linewidth=1,
color="k",
)
img_plot = _fig_to_image(fig)

img = Image.new("RGB", size, "#ffffff")
img.paste(img_plot, (size[0] - plot_size[0] - half_padding, half_padding))
draw = ImageDraw.Draw(img)

if ticker.config.avg_buy_price:
range_text += f" ({_perc_change_abp(ticker, resp):+.2f}%)"

draw.text(
(size[0] - plot_size[0] - half_padding, plot_size[1] + half_padding),
range_text,
font=small_font,
fill=0,
)
available_space = size[1] - (plot_size[1] + (range_text_bbox[3]))

big_font = ImageFont.truetype("DejaVuSans.ttf")
price_text = f"{CURRENCY_SYMBOLS.get(ticker.currency, '$')}{resp.current_price:.2f}"
price_text_bbox = big_font.getbbox(price_text)

fontsize = _fontsize_for_size(
(price_text_bbox[2], price_text_bbox[3]),
big_font.size,
(size[0] - 2 * padding, available_space - padding),
)
big_font = ImageFont.truetype(big_font.path, size=round(fontsize))
# print(available_space)
draw.text(
(size[0] / 2, size[1]),
price_text,
fill=0,
font=big_font,
anchor="md",
)

if ticker.logo:
img.paste(
_resize_aspect(ticker.logo, (logo_width, logo_height)), (padding, padding)
)
else:
symbol_text_bbox = big_font.getbbox(ticker.config.symbol)
fontsize = _fontsize_for_size(
(symbol_text_bbox[2], symbol_text_bbox[3]),
big_font.size,
(logo_width, logo_height),
)
big_font = ImageFont.truetype(big_font.path, size=round(fontsize))

pos = (padding + logo_width / 2, padding + logo_height / 2)
draw.rounded_rectangle(
draw.textbbox(
pos,
ticker.config.symbol,
anchor="mm",
# a bit bigger to have some margin
font=ImageFont.truetype(big_font.path, size=round(big_font.size * 1.2)),
),
4,
fill="#cccccc",
)
draw.text(pos, ticker.config.symbol, anchor="mm", font=big_font, fill=0)

return img
14 changes: 13 additions & 1 deletion tinyticker/tickers/_base.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import dataclasses as dc
import logging
import time
from typing import Dict, Iterator, Optional, Tuple
from typing import Dict, Iterator, Literal, Optional, Tuple, Union

import pandas as pd
from PIL.Image import Image

from ..config import TickerConfig, TinytickerConfig

Expand Down Expand Up @@ -76,6 +77,7 @@ def from_config(

def __init__(self, config: TickerConfig) -> None:
self._log = logging.getLogger(__name__)
self._logo = None
self.config = config
self.interval_dt = INTERVAL_TIMEDELTAS[config.interval]
self.lookback = (
Expand All @@ -84,6 +86,16 @@ def __init__(self, config: TickerConfig) -> None:
else INTERVAL_LOOKBACKS[config.interval]
)

@property
def logo(self) -> Union[Image, Literal[False]]:
if self._logo is None:
self._logo = self._get_logo()
return self._logo # type: ignore

def _get_logo(self) -> Union[Image, Literal[False]]:
"""Get the logo, should return false if it couldn't be fetched."""
...

def _single_tick(self) -> Tuple[pd.DataFrame, Optional[float]]: ...

def single_tick(self) -> TickerResponse:
Expand Down
21 changes: 21 additions & 0 deletions tinyticker/tickers/crypto.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import io
import logging
from typing import Dict, Optional, Tuple

import cryptocompare
import pandas as pd
import requests
from PIL import Image

from .. import utils
from ..config import TickerConfig
Expand All @@ -17,6 +20,7 @@
}

LOGGER = logging.getLogger(__name__)
LOGO_API = "https://api.coingecko.com/api/v3/search"


def get_cryptocompare(
Expand Down Expand Up @@ -119,6 +123,23 @@ def __init__(self, api_key: str, config: TickerConfig) -> None:
cryptocompare.cryptocompare._set_api_key_parameter(self.api_key)
super().__init__(config)

def _get_logo(self):
api = f"{LOGO_API}/?query={self.config.symbol}"
resp = requests.get(api)
if not resp.ok:
return False
try:
img = Image.open(
io.BytesIO(requests.get(resp.json()["coins"][0]["large"]).content)
)
if img.mode == "RGBA":
# remove transparancy make it white
background = Image.new("RGBA", img.size, (255, 255, 255))
img = Image.alpha_composite(background, img)
return img
except Exception:
return False

def _single_tick(self) -> Tuple[pd.DataFrame, Optional[float]]:
LOGGER.info("Crypto tick: %s", self.config.symbol)
historical = get_cryptocompare(
Expand Down
20 changes: 19 additions & 1 deletion tinyticker/tickers/stock.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import io
import logging
from typing import Optional, Tuple
from typing import Literal, Optional, Tuple, Union

import pandas as pd
import yfinance
from PIL import Image
from yfinance.scrapers.quote import requests

from .. import utils
from ._base import TickerBase

LOGGER = logging.getLogger(__name__)
LOGO_API = "https://logo.clearbit.com"


class TickerStock(TickerBase):
Expand All @@ -20,6 +24,20 @@ def __init__(self, config) -> None:
self._yf_ticker = yfinance.Ticker(self.config.symbol)
self.currency = self._yf_ticker.fast_info.get("currency", "USD").upper() # type: ignore

def _get_logo(self) -> Union[Image.Image, Literal[False]]:
url = self._yf_ticker.info.get("website", None)
if url is None:
return False
resp = requests.get(f"{LOGO_API}/{url}")
if not resp.ok:
return False
img = Image.open(io.BytesIO(resp.content))
if img.mode == "RGBA":
# remove transparancy make it white
background = Image.new("RGBA", img.size, (255, 255, 255))
img = Image.alpha_composite(background, img)
return img

def _get_yfinance_start_end(self) -> Tuple[pd.Timestamp, pd.Timestamp]:
end = utils.now()
# depending on the interval we need to increase the time range to compensate for the market
Expand Down

0 comments on commit 1432548

Please sign in to comment.