├── data ├── ex3.dat ├── ex2.dat ├── ex9.dat ├── ex1.dat ├── single_category.dat ├── ex_pipe.dat ├── ex7.dat ├── ex4.dat ├── ex6.dat ├── test_data-116.dat ├── ex8.dat ├── ex5.dat ├── gener_rdat.py └── cal.dat ├── tests ├── test_init.py ├── module-test3.py ├── module-test1.py ├── module-test2.py ├── test_normalize.py ├── test_charts.py ├── test_data_utils.py ├── test_read_data.py └── README.md ├── .gitignore ├── termgraph ├── module.py ├── constants.py ├── __init__.py ├── args.py ├── utils.py ├── termgraph.py ├── data.py └── chart.py ├── .github └── workflows │ └── checks.yml ├── LICENSE.txt ├── justfile ├── docs ├── assets │ ├── barchart-stacked.svg │ ├── barchart-multivar.svg │ └── cal-heatmap.svg ├── index.md ├── data-class.md ├── args-class.md ├── chart-classes.md └── README.md ├── pyproject.toml ├── README.md └── CONTRIBUTING.md /data/ex3.dat: -------------------------------------------------------------------------------- 1 | 1,2 2 | 2,3 3 | 3,4 4 | 4,5 5 | 5,6 6 | -------------------------------------------------------------------------------- /data/ex2.dat: -------------------------------------------------------------------------------- 1 | label 2 2 | longlabel 3 3 | foo 4 4 | barrific 5 5 | wut 6 6 | -------------------------------------------------------------------------------- /data/ex9.dat: -------------------------------------------------------------------------------- 1 | # Small Value Data 2 | A 1.32 3 | B 1.21 4 | C 0.50 5 | D 1.68 6 | E 1.00 7 | F 0.75 8 | G 0.00 9 | H 0.90 -------------------------------------------------------------------------------- /tests/test_init.py: -------------------------------------------------------------------------------- 1 | from termgraph import termgraph as tg 2 | 3 | 4 | def test_init_args(): 5 | tg.init_args() -------------------------------------------------------------------------------- /data/ex1.dat: -------------------------------------------------------------------------------- 1 | # Example Data Set 1 2 | 2007 183.32 3 | 2008 231.23 4 | 2009 16.43 5 | 2010 50.21 6 | 2011 508.97 7 | 2012 212.05 8 | 2014 1.0 9 | -------------------------------------------------------------------------------- /data/single_category.dat: -------------------------------------------------------------------------------- 1 | # Example Data Set 4 with 2 Categories 2 | @ Boys 3 | 2007,183 4 | 2008,231 5 | 2009,16 6 | 2010,50 7 | 2011,508 8 | 2012,212 9 | 2014,30 10 | -------------------------------------------------------------------------------- /data/ex_pipe.dat: -------------------------------------------------------------------------------- 1 | # Example Data with Pipe Delimiter 2 | @ Category A|Category B|Category C 3 | Label One|10.5|20.3|15.8 4 | Label Two|25.0|18.5|30.2 5 | Label Three|12.1|22.7|19.4 6 | -------------------------------------------------------------------------------- /data/ex7.dat: -------------------------------------------------------------------------------- 1 | # Example Data Set 7 without Categories' names 2 | 2007,183.32,190.52 3 | 2008,231.23,5.0 4 | 2009,16.43,53.1 5 | 2010,50.21,7 6 | 2011,508.97,10.45 7 | 2012,212.05,20.2 8 | 2014,30.0,20.0 9 | -------------------------------------------------------------------------------- /data/ex4.dat: -------------------------------------------------------------------------------- 1 | # Example Data Set 4 with 2 Categories 2 | @ Boys,Girls 3 | 2007,183.32,190.52 4 | 2008,231.23,5.0 5 | 2009,16.43,53.1 6 | 2010,50.21,7 7 | 2011,508.97,10.45 8 | 2012,212.05,20.2 9 | 2014,30.0,20.0 -------------------------------------------------------------------------------- /data/ex6.dat: -------------------------------------------------------------------------------- 1 | # Example Data Set 6 with Missing Values 2 | @ Boys,Girls 3 | 2007,183.32,190.52 4 | 2008,231.23,5.0 5 | 2009,16.43,53.1 6 | 2010,50.21,7 7 | 2011,508.97 8 | 2012,212.05,20.2 9 | 2014,10.0,9 10 | -------------------------------------------------------------------------------- /data/test_data-116.dat: -------------------------------------------------------------------------------- 1 | Select 2 | UnhandledCommand 3 | Hotkey 4 | Hotkey 5 | Hotkey 6 | TargetedOrderModern 7 | Hotkey 8 | TargetedOrderModern 9 | Hotkey 10 | Select 11 | RightClick 12 | RightClick 13 | RightClick 14 | SelectRemove -------------------------------------------------------------------------------- /data/ex8.dat: -------------------------------------------------------------------------------- 1 | # Example Data Set 8 with Categories of different scale 2 | @ People,Income 3 | 2007,100,500000 4 | 2008,450.23,760000 5 | 2009,255,1000000 6 | 2010,340,800000 7 | 2011,160,600400 8 | 2012,200,950000 9 | 2014,488,700000 10 | -------------------------------------------------------------------------------- /data/ex5.dat: -------------------------------------------------------------------------------- 1 | # Example Data Set 5 with 3 Categories 2 | @ Boys,Girls,Babies 3 | 2007,183.32,190.52,90.0 4 | 2008,231.23,50.0,80.6 5 | 2009,16.43,53.1,76.54 6 | 2010,50.21,7,0.0 7 | 2011,508.97,10.45,-27.0 8 | 2012,212.05,20.2,-4.4 9 | 2014,30.0,9,9.8 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # OS 3 | .DS_Store 4 | 5 | # Python 6 | .coverage 7 | dist/ 8 | htmlcov/ 9 | .mypy_cache/ 10 | *.pyc 11 | .pytest_cache/ 12 | .python-version 13 | .ropeproject/ 14 | .ruff_cache/ 15 | termgraph.egg-info/ 16 | uv.lock 17 | .venv/ 18 | 19 | # Editors 20 | .claude/ 21 | CLAUDE.md 22 | .gemini/ 23 | .vim/ 24 | .vimrc 25 | .vscode/ 26 | 27 | -------------------------------------------------------------------------------- /data/gener_rdat.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import random 3 | 4 | b = [2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018] 5 | a = [1, 40, 24, 26, 29, 80, 100, 36] 6 | 7 | BASE = 1990 8 | 9 | YEARS = 28 10 | 11 | f = open('random.dat', 'w') 12 | for offset in range(YEARS): 13 | date = BASE + offset 14 | value = random.randint(-500, 500) 15 | 16 | f.write(f'{date} {int(value)}\n') 17 | f.close() 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /tests/module-test3.py: -------------------------------------------------------------------------------- 1 | from termgraph import Data, Args, BarChart 2 | 3 | # Create data 4 | data = Data( 5 | labels=["Q1", "Q2", "Q3", "Q4"], 6 | data=[10, 20, 40, 26], 7 | ) 8 | 9 | # Configure chart options 10 | args = Args( 11 | title="Quarterly Sales", 12 | width=50, 13 | format="{:.0f}", 14 | suffix="K" 15 | ) 16 | 17 | # Create and display chart 18 | chart = BarChart(data, args) 19 | chart.draw() 20 | -------------------------------------------------------------------------------- /termgraph/module.py: -------------------------------------------------------------------------------- 1 | """This module allows drawing basic graphs in the terminal.""" 2 | 3 | # termgraph.py - draw basic graphs on terminal 4 | # https://github.com/mkaz/termgraph 5 | 6 | # Import all classes from their dedicated files to maintain backward compatibility 7 | from .data import Data 8 | from .args import Args 9 | from .chart import Colors, Chart, HorizontalChart, BarChart, StackedChart, HistogramChart 10 | 11 | # Re-export everything to maintain existing API 12 | __all__ = ['Data', 'Args', 'Colors', 'Chart', 'HorizontalChart', 'BarChart', 'StackedChart', 'HistogramChart'] 13 | -------------------------------------------------------------------------------- /termgraph/constants.py: -------------------------------------------------------------------------------- 1 | """Shared constants for termgraph.""" 2 | 3 | from __future__ import annotations 4 | 5 | # Calendar days 6 | DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] 7 | 8 | # Units for human-readable numbers 9 | UNITS = ["", "K", "M", "B", "T"] 10 | 11 | # Default delimiter 12 | DELIM = "," 13 | 14 | # Graph characters 15 | TICK = "▇" 16 | SM_TICK = "▏" 17 | 18 | # ANSI escape SGR Parameters color codes 19 | AVAILABLE_COLORS = { 20 | "red": 91, 21 | "blue": 94, 22 | "green": 92, 23 | "magenta": 95, 24 | "yellow": 93, 25 | "black": 90, 26 | "cyan": 96, 27 | } -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | lint-and-test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - name: Install uv 11 | uses: astral-sh/setup-uv@v3 12 | with: 13 | version: "latest" 14 | - name: Setup Python 15 | run: uv python install 3.9 16 | - name: Install dependencies 17 | run: uv sync --dev 18 | - name: Run lint 19 | run: uv run python -m ruff check . 20 | - name: Run typecheck 21 | run: uv run python -m mypy termgraph/ 22 | - name: Run tests 23 | run: uv run python -m pytest 24 | -------------------------------------------------------------------------------- /tests/module-test1.py: -------------------------------------------------------------------------------- 1 | from termgraph.data import Data 2 | from termgraph.args import Args 3 | from termgraph.chart import BarChart, StackedChart, Colors 4 | 5 | # Original Bar Chart 6 | print("--- Bar Chart ---") 7 | data = Data([[765, 787], [781, 769]], ["6th G", "7th G"], ["Boys", "Girls"]) 8 | chart = BarChart( 9 | data, 10 | Args( 11 | title="Total Marks Per Class", 12 | colors=[Colors.Red, Colors.Magenta], 13 | space_between=True, 14 | ), 15 | ) 16 | chart.draw() 17 | 18 | # Stacked Chart 19 | print("\n--- Stacked Chart ---") 20 | stacked_chart = StackedChart( 21 | data, 22 | Args( 23 | title="Total Marks Per Class (Stacked)", 24 | colors=[Colors.Green, Colors.Blue], 25 | space_between=True, 26 | ), 27 | ) 28 | stacked_chart.draw() -------------------------------------------------------------------------------- /data/cal.dat: -------------------------------------------------------------------------------- 1 | 2017-07-01 4.52 2 | 2017-07-06 4.81 3 | 2017-07-08 5.05 4 | 2017-07-15 2.00 5 | 2017-07-17 5.65 6 | 2017-07-19 5.15 7 | 2017-07-21 3.75 8 | 2017-08-02 3.72 9 | 2017-08-03 5.04 10 | 2017-08-05 4.60 11 | 2017-08-08 4.77 12 | 2017-08-10 5.44 13 | 2017-08-18 4.30 14 | 2017-08-24 4.84 15 | 2017-08-27 6.31 16 | 2017-08-31 4.31 17 | 2017-09-06 4.15 18 | 2017-09-08 5.19 19 | 2017-09-10 3.65 20 | 2017-09-13 4.01 21 | 2017-09-16 7.19 22 | 2017-09-21 4.21 23 | 2017-09-25 4.58 24 | 2017-09-28 8.09 25 | 2017-10-02 4.04 26 | 2017-10-09 4.29 27 | 2017-10-13 4.69 28 | 2017-10-18 4.31 29 | 2017-10-22 5.05 30 | 2017-10-28 13.10 31 | 2017-11-01 3.60 32 | 2017-11-10 4.70 33 | 2018-02-06 3.77 34 | 2018-03-05 3.80 35 | 2018-03-07 3.54 36 | 2018-05-16 3.42 37 | 2018-05-21 3.31 38 | 2018-06-11 3.55 39 | 2018-06-13 3.43 40 | 2018-06-16 3.79 41 | 2018-06-20 4.26 42 | 2018-06-21 0.00 43 | 2018-06-22 0.00 44 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Marcus Kazmierczak 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /termgraph/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ["main", "Data", "Args", "Colors", "Chart", "HorizontalChart", "BarChart", "StackedChart", "VerticalChart", "HistogramChart"] 2 | 3 | def __getattr__(name): 4 | if name == "main": 5 | from .termgraph import main 6 | return main 7 | elif name in ["Data", "Args", "Colors", "Chart", "HorizontalChart", "BarChart", "StackedChart", "VerticalChart", "HistogramChart"]: 8 | # Import from the new modular structure 9 | if name == "Data": 10 | from .data import Data 11 | return Data 12 | elif name == "Args": 13 | from .args import Args 14 | return Args 15 | elif name in ["Colors", "Chart", "HorizontalChart", "BarChart", "StackedChart", "VerticalChart", "HistogramChart"]: 16 | from .chart import Colors, Chart, HorizontalChart, BarChart, StackedChart, VerticalChart, HistogramChart 17 | return {"Colors": Colors, "Chart": Chart, "HorizontalChart": HorizontalChart, "BarChart": BarChart, "StackedChart": StackedChart, "VerticalChart": VerticalChart, "HistogramChart": HistogramChart}[name] 18 | raise AttributeError(f"module '{__name__}' has no attribute '{name}'") -------------------------------------------------------------------------------- /tests/module-test2.py: -------------------------------------------------------------------------------- 1 | from termgraph.data import Data 2 | from termgraph.args import Args 3 | from termgraph.chart import BarChart, HistogramChart, Colors 4 | 5 | # Original Bar Chart 6 | print("--- Simple Bar Chart ---") 7 | data = Data([[10], [50], [80], [100]], ["Label 1", "Label 2", "Label 3", "Label 4"]) 8 | chart = BarChart( 9 | data, 10 | Args( 11 | colors=[Colors.Red], 12 | suffix="%", 13 | ), 14 | ) 15 | chart.draw() 16 | 17 | # Different Scale Chart 18 | print("\n--- Different Scale Chart ---") 19 | diff_scale_data = Data([[10, 1000], [20, 2000], [30, 3000]], ["A", "B", "C"], ["Category 1", "Category 2"]) 20 | diff_scale_chart = BarChart( 21 | diff_scale_data, 22 | Args( 23 | title="Different Scale Example", 24 | different_scale=True, 25 | colors=[Colors.Cyan, Colors.Yellow] 26 | ) 27 | ) 28 | diff_scale_chart.draw() 29 | 30 | # Histogram 31 | print("\n--- Histogram ---") 32 | hist_data_values = [[1], [2], [2], [3], [3], [3], [4], [4], [4], [4], [5], [5], [5], [5], [5]] 33 | hist_data = Data(hist_data_values, [str(i) for i in range(len(hist_data_values))]) 34 | histogram = HistogramChart( 35 | hist_data, 36 | Args( 37 | title="Histogram Example", 38 | colors=[Colors.Green], 39 | bins=4 40 | ) 41 | ) 42 | histogram.draw() 43 | -------------------------------------------------------------------------------- /tests/test_normalize.py: -------------------------------------------------------------------------------- 1 | from termgraph import termgraph as tg 2 | 3 | 4 | def test_normalize_returns_correct_results(): 5 | expected = [ 6 | [18.00891997563707], 7 | [22.715484213214925], 8 | [1.6140440497475292], 9 | [4.932510757019078], 10 | [50.0], 11 | [20.83128671630941], 12 | [0.09823761714835845], 13 | ] 14 | results = tg.normalize( 15 | [[183.32], [231.23], [16.43], [50.21], [508.97], [212.05], [1.0]], 50 16 | ) 17 | assert results == expected 18 | 19 | 20 | def test_normalize_with_all_zeros_returns_correct_results(): 21 | expected = [ 22 | [0], 23 | [0], 24 | [0], 25 | [0], 26 | [0], 27 | [0], 28 | [0], 29 | ] 30 | results = tg.normalize([[0], [0], [0], [0], [0], [0], [0]], 50) 31 | assert results == expected 32 | 33 | 34 | def test_normalize_with_negative_datapoint_returns_correct_results(): 35 | expected = [ 36 | [18.625354066709058], 37 | [23.241227816636798], 38 | [2.546389964737846], 39 | [5.8009133475923464], 40 | [50.0], 41 | [21.393336801741913], 42 | [0.0], 43 | ] 44 | results = tg.normalize( 45 | [[183.32], [231.23], [16.43], [50.21], [508.97], [212.05], [-10.0]], 50 46 | ) 47 | assert results == expected 48 | 49 | 50 | def test_normalize_with_larger_width_does_not_normalize(): 51 | data = [[183.32], [231.23], [16.43], [50.21], [508.97], [212.05], [1.0]] 52 | expected = [ 53 | [7203.567990254828], 54 | [9086.193685285969], 55 | [645.6176198990117], 56 | [1973.0043028076311], 57 | [20000.0], 58 | [8332.514686523764], 59 | [39.29504685934338], 60 | ] 61 | results = tg.normalize(data, 20000) 62 | assert results == expected -------------------------------------------------------------------------------- /termgraph/args.py: -------------------------------------------------------------------------------- 1 | """Args class for termgraph - handles chart configuration options.""" 2 | 3 | from __future__ import annotations 4 | from typing import Union 5 | 6 | 7 | class Args: 8 | """Class representing the arguments to modify the graph.""" 9 | 10 | default = { 11 | "filename": "-", 12 | "title": None, 13 | "width": 50, 14 | "format": "{:<5.2f}", 15 | "suffix": "", 16 | "no_labels": False, 17 | "no_values": False, 18 | "space_between": False, 19 | "colors": None, 20 | "vertical": False, 21 | "stacked": False, 22 | "histogram": False, 23 | "bins": 5, 24 | "different_scale": False, 25 | "calendar": False, 26 | "start_dt": None, 27 | "custom_tick": "", 28 | "delim": "", 29 | "verbose": False, 30 | "label_before": False, 31 | "percentage": False, 32 | "no_readable": False, 33 | } 34 | 35 | def __init__(self, **kwargs): 36 | """Initialize the Args object.""" 37 | 38 | self.args = dict(self.default) 39 | 40 | for arg, value in list(kwargs.items()): 41 | if arg in self.args: 42 | self.args[arg] = value 43 | else: 44 | raise Exception(f"Invalid Argument: {arg}") 45 | 46 | def get_arg(self, arg: str) -> Union[int, str, bool, None]: 47 | """Returns the value for the argument given. 48 | 49 | :arg: The name of the argument. 50 | :returns: The value of the argument. 51 | 52 | """ 53 | 54 | if arg in self.args: 55 | return self.args[arg] 56 | else: 57 | raise Exception(f"Invalid Argument: {arg}") 58 | 59 | def update_args(self, **kwargs) -> None: 60 | """Updates the arguments""" 61 | 62 | for arg, value in list(kwargs.items()): 63 | if arg in self.args: 64 | self.args[arg] = value 65 | else: 66 | raise Exception(f"Invalid Argument: {arg}") -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | # Justfile for termgraph 2 | # Run `just` to see available recipes 3 | 4 | # Default recipe to display available commands 5 | default: 6 | @just --list 7 | 8 | # Install development dependencies 9 | install: 10 | uv sync --dev 11 | 12 | # Install production dependencies only 13 | install-prod: 14 | uv sync 15 | 16 | # Clean build artifacts 17 | clean: 18 | rm -rf dist/ 19 | rm -rf build/ 20 | rm -rf *.egg-info/ 21 | rm -rf htmlcov/ 22 | rm -rf .pytest_cache/ 23 | rm -rf .ruff_cache/ 24 | find . -type d -name __pycache__ -delete 25 | find . -type f -name "*.pyc" -delete 26 | 27 | # Run tests 28 | test: 29 | uv run python -m pytest 30 | 31 | # Run tests with verbose output 32 | test-verbose: 33 | uv run python -m pytest -v 34 | 35 | # Run specific test file 36 | test-file file: 37 | uv run python -m pytest {{file}} -v 38 | 39 | # Run module API tests (standalone executable tests) 40 | test-module: 41 | @echo "Running module-test1.py..." 42 | @uv run python tests/module-test1.py 43 | @echo "\nRunning module-test2.py..." 44 | @uv run python tests/module-test2.py 45 | @echo "\nRunning module-test3.py..." 46 | @uv run python tests/module-test3.py 47 | 48 | # Lint code with ruff 49 | lint: 50 | uv run python -m ruff check . 51 | 52 | # Format code with ruff 53 | lint-fix: 54 | uv run python -m ruff check --fix . 55 | uv run python -m ruff format . 56 | 57 | # Run mypy typecheck 58 | typecheck: 59 | uv run python -m mypy termgraph/ 60 | 61 | # Run all quality checks (lint, format, typecheck) 62 | check: lint typecheck 63 | 64 | # Build distribution packages 65 | build: clean 66 | uv run python -m build 67 | 68 | # Check distribution packages 69 | check-dist: build 70 | uv run python -m twine check dist/* 71 | 72 | # Publish to PyPI 73 | publish: build check-dist 74 | uv run python -m twine upload dist/* 75 | 76 | # Run the application with sample data 77 | run-example: 78 | uv run python -m termgraph.termgraph data/ex1.dat 79 | 80 | run file *args: 81 | uv run python -m termgraph.termgraph {{file}} {{args}} 82 | -------------------------------------------------------------------------------- /tests/test_charts.py: -------------------------------------------------------------------------------- 1 | from termgraph.data import Data 2 | from termgraph.args import Args 3 | from termgraph.chart import BarChart, VerticalChart 4 | 5 | def test_barchart_draws_correctly(): 6 | labels = ["2007", "2008", "2009", "2010", "2011", "2012", "2014"] 7 | data_values = [[183.32], [231.23], [16.43], [50.21], [508.97], [212.05], [1.0]] 8 | 9 | data = Data(data_values, labels) 10 | args = Args(width=50, no_labels=False, suffix="", no_values=False) 11 | 12 | chart = BarChart(data, args) 13 | 14 | # Capture the output of the draw method 15 | import io 16 | from contextlib import redirect_stdout 17 | 18 | f = io.StringIO() 19 | with redirect_stdout(f): 20 | chart.draw() 21 | output = f.getvalue() 22 | 23 | # Assert that the output contains the expected elements 24 | assert "2007: " in output 25 | assert "183.32" in output 26 | assert "2014: " in output 27 | assert "1.00" in output 28 | 29 | def test_verticalchart_draws_correctly(): 30 | labels = ["A", "B"] 31 | data_values = [[10], [20]] 32 | 33 | data = Data(data_values, labels) 34 | args = Args(width=10) 35 | 36 | chart = VerticalChart(data, args) 37 | 38 | import io 39 | from contextlib import redirect_stdout 40 | 41 | f = io.StringIO() 42 | with redirect_stdout(f): 43 | chart.draw() 44 | output = f.getvalue() 45 | 46 | # Assert that the output contains the expected elements 47 | assert "▇" in output 48 | assert "A" in output 49 | assert "B" in output 50 | assert "10" in output 51 | assert "20" in output 52 | 53 | def test_custom_tick_appears_in_output(): 54 | labels = ["A", "B"] 55 | data_values = [[10], [20]] 56 | 57 | data = Data(data_values, labels) 58 | args = Args(custom_tick="😀") 59 | 60 | chart = BarChart(data, args) 61 | 62 | import io 63 | from contextlib import redirect_stdout 64 | 65 | f = io.StringIO() 66 | with redirect_stdout(f): 67 | chart.draw() 68 | output = f.getvalue() 69 | 70 | # Assert that the custom tick appears in the output 71 | assert "😀" in output 72 | -------------------------------------------------------------------------------- /docs/assets/barchart-stacked.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 17 | 18 | 2007: 19 | ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 20 | ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 21 | 373.84 22 | 2008: 23 | ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 24 | 25 | 236.23 26 | 2009: 27 | 28 | ▇▇▇▇▇ 29 | 69.53 30 | 2010: 31 | ▇▇▇▇ 32 | 33 | 57.21 34 | 2011: 35 | ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 36 | 37 | 519.42 38 | 2012: 39 | ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 40 | 41 | 232.25 42 | 2014: 43 | ▇▇ 44 | 45 | 50.00 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | 2 | [project] 3 | name = "termgraph" 4 | version = "0.7.5" 5 | description = "A command-line tool that draws basic graphs in the terminal." 6 | readme = "README.md" 7 | license = "MIT" 8 | authors = [ 9 | {name = "mkaz", email = "marcus@mkaz.com"}, 10 | ] 11 | maintainers = [ 12 | {name = "mkaz", email = "marcus@mkaz.com"}, 13 | ] 14 | classifiers = [ 15 | "Development Status :: 5 - Production/Stable", 16 | "Environment :: Console", 17 | "Intended Audience :: Developers", 18 | "License :: OSI Approved :: MIT License", 19 | "Natural Language :: English", 20 | "Operating System :: MacOS :: MacOS X", 21 | "Operating System :: POSIX", 22 | "Programming Language :: Python", 23 | "Programming Language :: Python :: 3.9", 24 | "Programming Language :: Python :: 3.10", 25 | "Programming Language :: Python :: 3.11", 26 | "Programming Language :: Python :: 3.12", 27 | "Topic :: Utilities", 28 | "Topic :: Software Development :: Libraries :: Python Modules", 29 | ] 30 | keywords = ["python", "CLI", "tool", "drawing", "graphs", "shell", "terminal", "visualization"] 31 | requires-python = ">=3.9" 32 | dependencies = [ 33 | "colorama>=0.4.6", 34 | ] 35 | 36 | [dependency-groups] 37 | dev = [ 38 | "pytest>=8.4.1", 39 | "ruff>=0.1.0", 40 | "build>=1.3.0", 41 | "twine>=6.1.0", 42 | "mypy>=1.17.1", 43 | "types-colorama>=0.4.15.20250801", 44 | ] 45 | 46 | [project.urls] 47 | Homepage = "https://github.com/mkaz/termgraph" 48 | Repository = "https://github.com/mkaz/termgraph" 49 | Issues = "https://github.com/mkaz/termgraph/issues" 50 | Changelog = "https://github.com/mkaz/termgraph/releases" 51 | 52 | [project.scripts] 53 | termgraph = "termgraph.termgraph:main" 54 | 55 | [build-system] 56 | requires = ["hatchling"] 57 | build-backend = "hatchling.build" 58 | 59 | # Ruff configuration 60 | [tool.ruff] 61 | target-version = "py39" 62 | 63 | # Pytest configuration 64 | [tool.pytest.ini_options] 65 | minversion = "8.4" 66 | addopts = [ 67 | "--strict-markers", 68 | "--strict-config", 69 | ] 70 | testpaths = ["tests"] 71 | markers = [ 72 | "slow: marks tests as slow (deselect with '-m \"not slow\"')", 73 | "integration: marks tests as integration tests", 74 | ] 75 | 76 | -------------------------------------------------------------------------------- /termgraph/utils.py: -------------------------------------------------------------------------------- 1 | """Shared utility functions for termgraph.""" 2 | 3 | from __future__ import annotations 4 | import math 5 | import sys 6 | from .constants import UNITS, TICK, SM_TICK 7 | 8 | 9 | def cvt_to_readable(num, percentage=False): 10 | """Return the number in a human readable format. 11 | 12 | Examples: 13 | 125000 -> (125.0, 'K') 14 | 12550 -> (12.55, 'K') 15 | 19561100 -> (19.561, 'M') 16 | """ 17 | 18 | if percentage: 19 | return (num * 100, '%') 20 | 21 | if num >= 1 or num <= -1: 22 | neg = num < 0 23 | num = abs(num) 24 | 25 | # Find the degree of the number like if it is in thousands or millions, etc. 26 | index = math.floor(math.log(num) / math.log(1000)) 27 | 28 | # Converts the number to the human readable format and returns it. 29 | newNum = round(num / (1000**index), 3) 30 | newNum *= -1 if neg else 1 31 | degree = UNITS[index] 32 | 33 | else: 34 | newNum = num 35 | degree = UNITS[0] 36 | 37 | return (newNum, degree) 38 | 39 | 40 | 41 | 42 | def print_row_core( 43 | value: float, 44 | num_blocks: int, 45 | val_min: float, 46 | color: int | None = None, 47 | label_before: bool = False, 48 | zero_as_small_tick: bool = False, 49 | tick: str = TICK, 50 | ) -> None: 51 | """Core logic for printing a row of bars in horizontal graphs. 52 | 53 | Args: 54 | value: The data value being displayed 55 | num_blocks: Number of blocks/ticks to print 56 | val_min: Minimum value in dataset 57 | color: ANSI color code (optional) 58 | label_before: Whether to use small tick for zero values with label_before 59 | zero_as_small_tick: Additional condition for using small tick on zero 60 | tick: Custom tick character to use (defaults to TICK constant) 61 | """ 62 | sys.stdout.write("\033[0m") # no color 63 | 64 | if (num_blocks < 1 and (value > val_min or value > 0)) or ( 65 | zero_as_small_tick and value == 0.0 66 | ): 67 | # Print something if it's not the smallest 68 | # and the normal value is less than one. 69 | sys.stdout.write(SM_TICK) 70 | else: 71 | if color: 72 | sys.stdout.write(f"\033[{color}m") # Start to write colorized. 73 | for _ in range(num_blocks): 74 | sys.stdout.write(tick) 75 | 76 | if color: 77 | sys.stdout.write("\033[0m") # Back to original. 78 | -------------------------------------------------------------------------------- /tests/test_data_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from termgraph.data import Data 3 | 4 | 5 | def test_find_min_returns_lowest_value(): 6 | data_values = [[183.32], [231.23], [16.43], [50.21], [508.97], [212.05], [1.0]] 7 | labels = [str(i) for i in range(len(data_values))] 8 | data_obj = Data(data_values, labels) 9 | minimum = data_obj.find_min() 10 | assert minimum == 1.0 11 | 12 | 13 | def test_find_max_returns_highest_value(): 14 | data_values = [[183.32], [231.23], [16.43], [50.21], [508.97], [212.05], [1.0]] 15 | labels = [str(i) for i in range(len(data_values))] 16 | data_obj = Data(data_values, labels) 17 | maximum = data_obj.find_max() 18 | assert maximum == 508.97 19 | 20 | 21 | def test_find_max_label_length_returns_correct_length(): 22 | labels1 = ["2007", "2008", "2009", "2010", "2011", "2012", "2014"] 23 | data_obj1 = Data([[0]] * len(labels1), labels1) 24 | length = data_obj1.find_max_label_length() 25 | assert length == 4 26 | 27 | labels2 = ["aaaaaaaa", "bbb", "cccccccccccccc", "z"] 28 | data_obj2 = Data([[0]] * len(labels2), labels2) 29 | length = data_obj2.find_max_label_length() 30 | assert length == 14 31 | 32 | 33 | # Data validation tests 34 | def test_data_empty_labels_raises_exception(): 35 | """Test that Data raises exception when labels list is empty""" 36 | labels = [] 37 | data = [[183.32], [231.23]] 38 | with pytest.raises(Exception, match="No labels provided"): 39 | Data(data, labels) 40 | 41 | 42 | def test_data_empty_data_raises_exception(): 43 | """Test that Data raises exception when data list is empty""" 44 | labels = ["2007", "2008"] 45 | data = [] 46 | with pytest.raises(Exception, match="No data provided"): 47 | Data(data, labels) 48 | 49 | 50 | def test_data_mismatching_data_and_labels_count_raises_exception(): 51 | """Test that Data raises exception when data and labels have different lengths""" 52 | labels = ["2007", "2008", "2009", "2010", "2011", "2012", "2014"] 53 | data = [[183.32], [231.23], [16.43], [50.21], [508.97], [212.05]] 54 | with pytest.raises(Exception, match="dimensions of the data and labels must be the same"): 55 | Data(data, labels) 56 | 57 | 58 | def test_data_missing_values_for_categories_raises_exception(): 59 | """Test that Data raises exception when rows have inconsistent category counts""" 60 | labels = ["2007", "2008", "2009", "2010", "2011", "2012", "2014"] 61 | data = [ 62 | [183.32, 190.52], 63 | [231.23, 5.0], 64 | [16.43, 53.1], 65 | [50.21, 7.0], 66 | [508.97, 10.45], 67 | [212.05], # Missing second category 68 | [30.0, 20.0], 69 | ] 70 | with pytest.raises(Exception, match="inner dimensions of the data are different"): 71 | Data(data, labels) -------------------------------------------------------------------------------- /docs/assets/barchart-multivar.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 17 | 18 | 19 | Boys 20 | 21 | Girls 22 | Math 23 | : 24 | ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 25 | 78 26 | ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 27 | 82 28 | Reading: 29 | ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 30 | 84 31 | ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 32 | 79 33 | Science: 34 | ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 35 | 81 36 | ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 37 | 77 38 | Art 39 | : 40 | ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 41 | 89 42 | ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 43 | 65 44 | PE 45 | : 46 | ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 47 | 83 48 | ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 49 | 96 50 | Music 51 | : 52 | ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 53 | 71 54 | ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 55 | 88 56 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /docs/assets/cal-heatmap.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 17 | 18 | Jun 19 | Jul 20 | Aug 21 | Sep 22 | Oct 23 | Nov 24 | Dec 25 | Jan 26 | Feb 27 | Mar 28 | Apr 29 | May 30 | Jun 31 | Mon: 32 | 33 | ▒▒▒ 34 | 35 | 36 | 37 | Tue: 38 | 39 | 40 | Wed: 41 | 42 | 43 | ▒▒ 44 | 45 | 46 | 47 | 48 | ▒▒ 49 | Thu: 50 | 51 | ▒▒ 52 | ▒▒ 53 | ▒▓ 54 | Fri: 55 | 56 | 57 | 58 | 59 | 60 | Sat: 61 | ▒▒░ 62 | 63 | 64 | 65 | 66 | Sun: 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /tests/test_read_data.py: -------------------------------------------------------------------------------- 1 | import tempfile 2 | from unittest.mock import patch 3 | from io import StringIO 4 | from termgraph import termgraph as tg 5 | 6 | 7 | def test_read_data_returns_correct_results(): 8 | args = { 9 | "filename": "data/ex4.dat", 10 | "title": None, 11 | "width": 50, 12 | "format": "{:<5.2f}", 13 | "suffix": "", 14 | "no_labels": False, 15 | "color": None, 16 | "vertical": False, 17 | "stacked": False, 18 | "different_scale": False, 19 | "calendar": False, 20 | "start_dt": None, 21 | "custom_tick": "", 22 | "delim": "", 23 | "verbose": False, 24 | "version": False, 25 | } 26 | categories, labels, data, colors = tg.read_data(args) 27 | assert categories == ["Boys", "Girls"] 28 | assert labels == ["2007", "2008", "2009", "2010", "2011", "2012", "2014"] 29 | assert data == [ 30 | [183.32, 190.52], 31 | [231.23, 5.0], 32 | [16.43, 53.1], 33 | [50.21, 7.0], 34 | [508.97, 10.45], 35 | [212.05, 20.2], 36 | [30.0, 20.0], 37 | ] 38 | assert colors == [] 39 | 40 | 41 | def test_concatenate_neighboring_labels(): 42 | with tempfile.NamedTemporaryFile("w") as tmp: 43 | args = { 44 | "filename": tmp.name, 45 | "title": None, 46 | "width": 50, 47 | "format": "{:<5.2f}", 48 | "suffix": "", 49 | "no_labels": False, 50 | "color": None, 51 | "vertical": False, 52 | "stacked": False, 53 | "different_scale": False, 54 | "calendar": False, 55 | "start_dt": None, 56 | "custom_tick": "", 57 | "delim": "", 58 | "verbose": False, 59 | "version": False, 60 | } 61 | tmp.write( 62 | """ 63 | label one 1 2 3 64 | label two 5 6 7 65 | label three 9 10 11 66 | """ 67 | ) 68 | tmp.seek(0) 69 | categories, labels, data, colors = tg.read_data(args) 70 | assert labels == ["label one", "label two", "label three"] 71 | assert data == [[1.0, 2.0, 3.0], [5.0, 6.0, 7.0], [9.0, 10.0, 11.0]] 72 | 73 | 74 | def test_labels_at_end_of_row(): 75 | """Check that we can identify labels that come after the data""" 76 | with tempfile.NamedTemporaryFile("w") as tmp: 77 | args = { 78 | "filename": tmp.name, 79 | "title": None, 80 | "width": 50, 81 | "format": "{:<5.2f}", 82 | "suffix": "", 83 | "no_labels": False, 84 | "color": None, 85 | "vertical": False, 86 | "stacked": False, 87 | "different_scale": False, 88 | "calendar": False, 89 | "start_dt": None, 90 | "custom_tick": "", 91 | "delim": "", 92 | "verbose": False, 93 | "version": False, 94 | } 95 | tmp.write( 96 | """ 97 | 1 2 3 A 98 | 5 6 7 B 99 | 9 10 11 C 100 | """ 101 | ) 102 | tmp.seek(0) 103 | categories, labels, data, colors = tg.read_data(args) 104 | assert labels == ["A", "B", "C"] 105 | assert data == [[1.0, 2.0, 3.0], [5.0, 6.0, 7.0], [9.0, 10.0, 11.0]] 106 | 107 | 108 | def test_read_data_verbose(): 109 | with patch("sys.stdout", new=StringIO()) as output: 110 | args = { 111 | "filename": "data/ex1.dat", 112 | "title": None, 113 | "width": 50, 114 | "format": "{:<5.2f}", 115 | "suffix": "", 116 | "no_labels": False, 117 | "color": None, 118 | "vertical": False, 119 | "stacked": False, 120 | "different_scale": False, 121 | "calendar": False, 122 | "start_dt": None, 123 | "custom_tick": "", 124 | "delim": "", 125 | "verbose": True, 126 | "version": False, 127 | } 128 | tg.read_data(args) 129 | output = output.getvalue().strip() 130 | assert output == ">> Reading data from data/ex1.dat" 131 | 132 | 133 | def test_read_data_custom_delimiter(): 134 | """Test that custom delimiter (pipe) works correctly""" 135 | args = { 136 | "filename": "data/ex_pipe.dat", 137 | "title": None, 138 | "width": 50, 139 | "format": "{:<5.2f}", 140 | "suffix": "", 141 | "no_labels": False, 142 | "color": None, 143 | "vertical": False, 144 | "stacked": False, 145 | "different_scale": False, 146 | "calendar": False, 147 | "start_dt": None, 148 | "custom_tick": "", 149 | "delim": "|", 150 | "verbose": False, 151 | "version": False, 152 | } 153 | categories, labels, data, colors = tg.read_data(args) 154 | assert categories == ["Category A", "Category B", "Category C"] 155 | assert labels == ["Label One", "Label Two", "Label Three"] 156 | assert data == [[10.5, 20.3, 15.8], [25.0, 18.5, 30.2], [12.1, 22.7, 19.4]] 157 | assert colors == [] 158 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Termgraph API Documentation 2 | 3 | Welcome to the Termgraph Python API documentation. Termgraph is a command-line tool for creating terminal-based charts and graphs, and can also be used as a Python library for programmatic chart generation. 4 | 5 | ## Documentation Structure 6 | 7 | ### 📖 **[Getting Started](README.md)** 8 | Complete overview with examples and best practices for using termgraph as a Python module. 9 | 10 | ### 📊 **[Data Class](data-class.md)** 11 | Learn how to prepare and structure your data for charts: 12 | - Data validation and normalization 13 | - Working with flat and nested data 14 | - Handling categories and labels 15 | - Examples with real-world data 16 | 17 | ### 📈 **[Chart Classes](chart-classes.md)** 18 | Comprehensive guide to all available chart types: 19 | - **BarChart** - Horizontal bar charts (single and multi-series) 20 | - **StackedChart** - Stacked bar charts for part-to-whole relationships 21 | - **VerticalChart** - Column charts for time series data 22 | - **HistogramChart** - Distribution charts for continuous data 23 | - **Colors** - Color constants for styling 24 | 25 | ### ⚙️ **[Args Class](args-class.md)** 26 | Configuration options for customizing chart appearance and behavior: 27 | - Chart dimensions and layout 28 | - Value formatting and display 29 | - Color schemes and styling 30 | - Chart-specific options 31 | 32 | ## Quick Navigation 33 | 34 | ### Common Tasks 35 | 36 | | Task | Documentation | 37 | |------|---------------| 38 | | Create a simple bar chart | [README.md - Quick Start](README.md#quick-start) | 39 | | Work with multi-series data | [Data Class - Multi-Series](data-class.md#multi-series-data-with-categories) | 40 | | Customize chart colors | [Args Class - Colors](args-class.md#colors) | 41 | | Format numeric values | [Args Class - Value Formatting](args-class.md#value-formatting) | 42 | | Create stacked charts | [Chart Classes - StackedChart](chart-classes.md#stackedchart) | 43 | | Handle negative values | [Data Class - Negative Values](data-class.md#working-with-negative-values) | 44 | 45 | ### Chart Type Selection 46 | 47 | | Data Type | Recommended Chart | Documentation | 48 | |-----------|------------------|---------------| 49 | | Simple categories | BarChart | [Chart Classes - BarChart](chart-classes.md#barchart) | 50 | | Time series | VerticalChart | [Chart Classes - VerticalChart](chart-classes.md#verticalchart) | 51 | | Parts of a whole | StackedChart | [Chart Classes - StackedChart](chart-classes.md#stackedchart) | 52 | | Data distribution | HistogramChart | [Chart Classes - HistogramChart](chart-classes.md#histogramchart) | 53 | | Multi-series comparison | BarChart (multi-series) | [Chart Classes - Multi-Series](chart-classes.md#multi-series-example) | 54 | 55 | ### Example Scenarios 56 | 57 | | Scenario | Example Location | 58 | |----------|------------------| 59 | | Financial dashboard | [README.md - Financial Dashboard](README.md#financial-dashboard) | 60 | | Performance metrics | [README.md - Performance Metrics](README.md#performance-metrics-dashboard) | 61 | | Project tracking | [README.md - Project Status](README.md#project-status-tracking) | 62 | | Sales analysis | [README.md - Sales Trend](README.md#sales-trend-analysis) | 63 | | Data validation | [Data Class - Validation](data-class.md#data-validation-examples) | 64 | 65 | ## Code Examples by Complexity 66 | 67 | ### Beginner 68 | - [Simple bar chart](README.md#simple-bar-chart) 69 | - [Basic styling](README.md#styled-chart) 70 | - [Single series data](data-class.md#basic-flat-data) 71 | 72 | ### Intermediate 73 | - [Multi-series charts](chart-classes.md#multi-series-example) 74 | - [Custom formatting](args-class.md#advanced-formatting) 75 | - [Different chart types](README.md#chart-types) 76 | 77 | ### Advanced 78 | - [Financial dashboard](README.md#financial-dashboard) 79 | - [Different scales](chart-classes.md#different-scale-example) 80 | - [Complex data structures](data-class.md#multi-series-data-with-categories) 81 | 82 | ## API Classes Overview 83 | 84 | ```python 85 | from termgraph import Data, BarChart, StackedChart, VerticalChart, HistogramChart, Args, Colors 86 | 87 | # Core workflow 88 | data = Data([10, 20, 30], ["A", "B", "C"]) # Data preparation 89 | args = Args(width=50, colors=[Colors.Blue]) # Configuration 90 | chart = BarChart(data, args) # Chart creation 91 | chart.draw() # Visualization 92 | ``` 93 | 94 | ## Installation and Setup 95 | 96 | ```bash 97 | # Install termgraph 98 | pip install termgraph 99 | 100 | # Basic usage in Python 101 | python -c "from termgraph import Data, BarChart; chart = BarChart(Data([1,2,3], ['A','B','C'])); chart.draw()" 102 | ``` 103 | 104 | ## Support and Contributing 105 | 106 | - **Issues**: Report bugs and feature requests on [GitHub Issues](https://github.com/mkaz/termgraph/issues) 107 | - **Documentation**: This documentation is maintained alongside the codebase 108 | - **Examples**: Additional examples can be found in the repository's test files 109 | 110 | --- 111 | 112 | **Note**: This documentation covers using termgraph as a Python library. For command-line usage, see the main README in the repository root. -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | # Tests 2 | 3 | This directory contains the test suite for termgraph, organized into logical groups for better maintainability and clarity. 4 | 5 | ## Test Organization 6 | 7 | The test suite is split into focused files based on functionality: 8 | 9 | ### `test_check_data.py` 10 | 11 | Tests for the `check_data()` function that validates input data and arguments. 12 | - Data validation (empty labels, empty data) 13 | - Label/data size matching 14 | - Color validation 15 | - Category validation 16 | - Error handling and exit codes 17 | 18 | ### `test_data_utils.py` 19 | 20 | Tests for utility functions that operate on data. 21 | - `find_min()` - finding minimum values in datasets 22 | - `find_max()` - finding maximum values in datasets 23 | - `find_max_label_length()` - calculating label dimensions 24 | 25 | ### `test_normalize.py` 26 | 27 | Tests for data normalization functionality. 28 | - Basic normalization with various datasets 29 | - Edge cases (all zeros, negative values) 30 | - Different width scaling 31 | - Boundary conditions 32 | 33 | ### `test_rendering.py` 34 | 35 | Tests for chart rendering and display functions. 36 | - `horiz_rows()` - horizontal chart row generation 37 | - `vertically()` - vertical chart rendering 38 | - Chart formatting and layout 39 | 40 | ### `test_read_data.py` 41 | 42 | Tests for data input and parsing functionality. 43 | - File reading from various formats 44 | - Label parsing (beginning, end, multi-word) 45 | - Category detection 46 | - Verbose output 47 | - Data format validation 48 | 49 | ### `test_init.py` 50 | 51 | Tests for initialization and setup functions. 52 | - Argument parsing and initialization 53 | 54 | ### `test_charts.py` 55 | 56 | Tests for chart class integration. 57 | - BarChart rendering with Data and Args classes 58 | - VerticalChart rendering 59 | - Chart output validation 60 | 61 | ### Module Tests (`module-test*.py`) 62 | 63 | Standalone executable tests that demonstrate real-world module API usage: 64 | 65 | - **`module-test1.py`** - Multi-category data with BarChart and StackedChart 66 | - **`module-test2.py`** - Simple bar chart, different scale charts, and histograms 67 | - **`module-test3.py`** - Basic usage example matching README documentation 68 | 69 | These are integration tests that run as standalone Python scripts and produce visual output. 70 | 71 | ## Running Tests 72 | 73 | ### All Tests 74 | 75 | ```bash 76 | just test # Run all pytest unit tests 77 | just test-verbose # Run all pytest tests with verbose output 78 | just test-module # Run module API integration tests 79 | ``` 80 | 81 | ### Individual Test Files 82 | 83 | ```bash 84 | # Run specific pytest test file 85 | just test-file tests/test_check_data.py 86 | just test-file tests/test_normalize.py 87 | 88 | # Run individual module test 89 | uv run python tests/module-test1.py 90 | uv run python tests/module-test2.py 91 | uv run python tests/module-test3.py 92 | ``` 93 | 94 | ### Specific Tests 95 | 96 | ```bash 97 | uv run python -m pytest tests/test_check_data.py::test_check_data_empty_labels_exits_with_one 98 | uv run python -m pytest tests/test_normalize.py::test_normalize_with_negative_datapoint_returns_correct_results 99 | ``` 100 | 101 | ## Adding New Tests 102 | 103 | ### Unit Tests (pytest) 104 | 105 | When adding new unit tests, place them in the appropriate file based on functionality: 106 | 107 | - **Data validation** → `test_check_data.py` 108 | - **Math/calculation utilities** → `test_data_utils.py` 109 | - **Data scaling/normalization** → `test_normalize.py` 110 | - **Chart class integration** → `test_charts.py` 111 | - **File parsing/input** → `test_read_data.py` 112 | - **Setup/configuration** → `test_init.py` 113 | 114 | If your test doesn't fit into any existing category, consider: 115 | 116 | 1. Whether it belongs in an existing file with a broader scope 117 | 2. Creating a new focused test file (e.g., `test_calendar.py` for calendar-specific functionality) 118 | 119 | ### Module Integration Tests 120 | 121 | Module tests (`module-test*.py`) are standalone scripts that demonstrate real-world usage: 122 | 123 | - Create a new `module-test#.py` file for end-to-end demonstrations 124 | - These should be runnable directly: `uv run python tests/module-test#.py` 125 | - Focus on realistic usage scenarios, not edge cases 126 | - Include visual output to verify charts render correctly 127 | 128 | ## Test Conventions 129 | 130 | - Use descriptive test names that explain what is being tested 131 | - Include docstrings for complex test scenarios 132 | - Use `pytest.raises(SystemExit)` for testing error conditions that call `sys.exit()` 133 | - Mock external dependencies (files, stdout) when needed 134 | - Keep test data realistic but minimal 135 | 136 | ## Dependencies 137 | 138 | Tests use the following packages: 139 | - `pytest` - Test runner and framework 140 | - `tempfile` - For creating temporary test files 141 | - `unittest.mock` - For mocking dependencies 142 | - `io.StringIO` - For capturing stdout in tests 143 | 144 | ## File Structure 145 | 146 | ``` 147 | tests/ 148 | ├── README.md # This file 149 | ├── test_check_data.py # Data validation tests 150 | ├── test_charts.py # Chart class integration tests 151 | ├── test_data_utils.py # Utility function tests 152 | ├── test_init.py # Initialization tests 153 | ├── test_normalize.py # Data normalization tests 154 | ├── test_read_data.py # Data reading/parsing tests 155 | ├── module-test1.py # Module API integration test (multi-category) 156 | ├── module-test2.py # Module API integration test (various chart types) 157 | ├── module-test3.py # Module API integration test (basic example) 158 | └── coverage-report.sh # Coverage report generator 159 | ``` 160 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Termgraph 2 | 3 | A command-line tool and Python library that draws basic graphs in the terminal. 4 | 5 | Graph types supported: 6 | - Bar Graphs 7 | - Color charts 8 | - Multi-variable 9 | - Stacked charts 10 | - Histograms 11 | - Horizontal or Vertical 12 | - Calendar heatmaps 13 | - Emoji! 14 | 15 | ## Quick Start 16 | 17 | ### Command Line Usage 18 | 19 | ``` 20 | $ termgraph data/ex1.dat 21 | 22 | # Reading data from data/ex1.dat 23 | 24 | 2007: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 183.32 25 | 2008: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 231.23 26 | 2009: ▇ 16.43 27 | 2010: ▇▇▇▇ 50.21 28 | 2011: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 508.97 29 | 2012: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 212.05 30 | 2014: ▏ 1.00 31 | ``` 32 | 33 | ### Python Module Usage 34 | 35 | ```python 36 | from termgraph import Data, Args, BarChart 37 | 38 | # Create data 39 | data = Data([[10], [25], [50], [40]], ["Q1", "Q2", "Q3", "Q4"]) 40 | 41 | # Configure chart options 42 | args = Args( 43 | title="Quarterly Sales", 44 | width=50, 45 | format="{:.0f}", 46 | suffix="K" 47 | ) 48 | 49 | # Create and display chart 50 | chart = BarChart(data, args) 51 | chart.draw() 52 | ``` 53 | 54 | Output: 55 | ``` 56 | # Quarterly Sales 57 | 58 | Q1: ▇▇▇▇▇▇▇▇▇▇ 10K 59 | Q2: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 25K 60 | Q3: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 50K 61 | Q4: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 40K 62 | ``` 63 | 64 | ## More Examples 65 | 66 | ### Custom Tick Marks 67 | 68 | An example using emoji as custom tick: 69 | 70 | ``` 71 | termgraph data/ex1.dat --custom-tick "🏃" --width 20 --title "Running Data" 72 | 73 | # Running Data 74 | 75 | 2007: 🏃🏃🏃🏃🏃🏃🏃 183.32 76 | 2008: 🏃🏃🏃🏃🏃🏃🏃🏃🏃 231.23 77 | 2009: 16.43 78 | 2010: 🏃 50.21 79 | 2011: 🏃🏃🏃🏃🏃🏃🏃🏃🏃🏃🏃🏃🏃🏃🏃🏃🏃🏃🏃🏃 508.97 80 | 2012: 🏃🏃🏃🏃🏃🏃🏃🏃 212.05 81 | 2014: 1.00 82 | 83 | ``` 84 | 85 | ### Color Charts 86 | 87 | Note: Color charts use ANSI escape codes, so may not be able to copy/paste from terminal into other uses. 88 | 89 | ```bash 90 | $ termgraph data/ex4.dat --color {cyan/yellow} --space-between 91 | ``` 92 | 93 | ![Bar chart with multiple variables](/docs/assets/barchart-multivar.svg) 94 | 95 | --- 96 | 97 | ``` 98 | termgraph data/ex7.dat --color {green,magenta} --stacked 99 | ``` 100 | 101 | ![Stacked Bar Chart](/docs/assets/barchart-stacked.svg) 102 | 103 | ### Calendar Heatmap 104 | 105 | Calendar Heatmap, expects first column to be date in yyyy-mm-dd 106 | 107 | ``` 108 | $ termgraph --calendar --start-dt 2017-07-01 data/cal.dat 109 | ``` 110 | 111 | ![Calendar Heatmap](/docs/assets/cal-heatmap.svg) 112 | 113 | 114 | 115 | ## Usage 116 | 117 | #### Command Line Interface 118 | 119 | * Create data file with two columns either comma or space separated. 120 | The first column is your labels, the second column is a numeric data 121 | 122 | * termgraph [datafile] 123 | 124 | * Help: termgraph -h 125 | 126 | #### Command Line Arguments 127 | 128 | ``` 129 | usage: termgraph [-h] [options] [filename] 130 | 131 | draw basic graphs on terminal 132 | 133 | positional arguments: 134 | filename data file name (comma or space separated). Defaults to stdin. 135 | 136 | options: 137 | -h, --help show this help message and exit 138 | --title TITLE Title of graph 139 | --width WIDTH width of graph in characters default:50 140 | --format FORMAT format specifier to use. 141 | --suffix SUFFIX string to add as a suffix to all data points. 142 | --no-labels Do not print the label column 143 | --no-values Do not print the values at end 144 | --space-between Print a new line after every field 145 | --color [COLOR ...] Graph bar color( s ) 146 | --vertical Vertical graph 147 | --stacked Stacked bar graph 148 | --histogram Histogram 149 | --bins BINS Bins of Histogram 150 | --different-scale Categories have different scales. 151 | --calendar Calendar Heatmap chart 152 | --start-dt START_DT Start date for Calendar chart 153 | --custom-tick CUSTOM_TICK 154 | Custom tick mark, emoji approved 155 | --delim DELIM Custom delimiter, default , or space 156 | --verbose Verbose output, helpful for debugging 157 | --label-before Display the values before the bars 158 | --no-readable Disable the readable numbers 159 | --percentage Display the number in percentage 160 | --version Display version and exit 161 | ``` 162 | 163 | ### Python API 164 | 165 | All chart types are available as classes: 166 | 167 | ```python 168 | from termgraph import ( 169 | Data, Args, 170 | BarChart, StackedChart, VerticalChart, HistogramChart 171 | ) 172 | 173 | # Basic setup 174 | data = Data([[10], [20]], ["A", "B"]) 175 | args = Args(title="My Chart") 176 | 177 | # Choose your chart type 178 | chart = BarChart(data, args) # Horizontal bars 179 | # chart = StackedChart(data, args) # Stacked bars 180 | # chart = VerticalChart(data, args) # Vertical bars 181 | # chart = HistogramChart(data, args) # Histogram 182 | 183 | chart.draw() 184 | ``` 185 | 186 | **📚 [Complete Python API Documentation](docs/)** 187 | 188 | For comprehensive examples, detailed API reference, and advanced usage patterns, see the complete documentation: 189 | - **[Getting Started Guide](docs/README.md)** - Examples and best practices 190 | - **[Data Class API](docs/data-class.md)** - Data preparation and validation 191 | - **[Chart Classes API](docs/chart-classes.md)** - All chart types with examples 192 | - **[Args Configuration](docs/args-class.md)** - Complete configuration options 193 | 194 | Quick Args options: 195 | - `title`: Chart title 196 | - `width`: Width in characters (default: 50) 197 | - `format`: Number format string (default: "{:<5.2f}") 198 | - `suffix`: Add suffix to all values 199 | - `no_labels`: Don't show labels 200 | - `no_values`: Don't show values 201 | - `colors`: List of color names 202 | 203 | ## Background 204 | 205 | I wanted a quick way to visualize data stored in a simple text file. I initially created some scripts in R that generated graphs but this was a two step process of creating the graph and then opening the generated graph. 206 | 207 | After seeing [command-line sparklines](https://github.com/holman/spark) I figured I could do the same thing using block characters for bar charts. 208 | 209 | ### Contribute 210 | 211 | All contributions are welcome! For detailed information about the project structure, development workflow, and contribution guidelines, please see [CONTRIBUTING.md](CONTRIBUTING.md). 212 | 213 | **Quick Start:** 214 | - 🐛 **Bug reports** and 🚀 **feature requests**: Use [GitHub Issues](https://github.com/mkaz/termgraph/issues) 215 | - 🔧 **Code contributions**: See our [development workflow](CONTRIBUTING.md#development-workflow) 216 | - 📚 **Documentation**: Help improve our guides and examples 217 | 218 | **Code Quality:** We use `ruff` for linting and formatting, `mypy` for type checking, and maintain comprehensive test coverage. 219 | 220 | Thanks to all the [contributors](https://github.com/mkaz/termgraph/graphs/contributors)! 221 | 222 | 223 | ### License 224 | 225 | MIT License, see [LICENSE.txt](LICENSE.txt) 226 | -------------------------------------------------------------------------------- /docs/data-class.md: -------------------------------------------------------------------------------- 1 | # Data Class API Documentation 2 | 3 | The `Data` class is the core data container for termgraph charts. It handles data validation, normalization, and provides methods for working with both flat and nested data structures. 4 | 5 | ## Constructor 6 | 7 | ```python 8 | from termgraph import Data 9 | 10 | # Basic usage 11 | data = Data(data=[10, 20, 30, 40], labels=["Q1", "Q2", "Q3", "Q4"]) 12 | 13 | # With categories for multi-series data 14 | data = Data( 15 | data=[[10, 15], [20, 25], [30, 35], [40, 45]], 16 | labels=["Q1", "Q2", "Q3", "Q4"], 17 | categories=["Product A", "Product B"] 18 | ) 19 | ``` 20 | 21 | ### Parameters 22 | 23 | - **data** (list): Required. The numeric data to be graphed. Can be: 24 | - Flat list: `[10, 20, 30, 40]` 25 | - Nested list: `[[10, 15], [20, 25], [30, 35]]` 26 | - **labels** (list[str]): Required. Labels for each data point/row 27 | - **categories** (list[str], optional): Category names for multi-series data 28 | 29 | ### Validation 30 | 31 | The constructor validates that: 32 | - Both `data` and `labels` are provided 33 | - Data and labels have the same length 34 | - For nested data, all inner lists have consistent dimensions 35 | 36 | ## Methods 37 | 38 | ### Data Statistics 39 | 40 | #### `find_min() -> Union[int, float]` 41 | Returns the minimum value in the dataset. 42 | 43 | ```python 44 | # Flat data 45 | data = Data([10, 5, 30, 15], ["A", "B", "C", "D"]) 46 | print(data.find_min()) # Output: 5 47 | 48 | # Nested data 49 | data = Data([[10, 5], [30, 15], [20, 25]], ["X", "Y", "Z"]) 50 | print(data.find_min()) # Output: 5 51 | ``` 52 | 53 | #### `find_max() -> Union[int, float]` 54 | Returns the maximum value in the dataset. 55 | 56 | ```python 57 | # Flat data 58 | data = Data([10, 5, 30, 15], ["A", "B", "C", "D"]) 59 | print(data.find_max()) # Output: 30 60 | 61 | # Nested data 62 | data = Data([[10, 5], [30, 15], [20, 25]], ["X", "Y", "Z"]) 63 | print(data.find_max()) # Output: 30 64 | ``` 65 | 66 | ### Label Methods 67 | 68 | #### `find_min_label_length() -> int` 69 | Returns the length of the shortest label. 70 | 71 | ```python 72 | data = Data([10, 20, 30], ["A", "BB", "CCC"]) 73 | print(data.find_min_label_length()) # Output: 1 74 | ``` 75 | 76 | #### `find_max_label_length() -> int` 77 | Returns the length of the longest label. 78 | 79 | ```python 80 | data = Data([10, 20, 30], ["A", "BB", "CCC"]) 81 | print(data.find_max_label_length()) # Output: 3 82 | ``` 83 | 84 | ### Data Normalization 85 | 86 | #### `normalize(width: int) -> list` 87 | Normalizes data values to fit within the specified width for chart rendering. 88 | 89 | ```python 90 | # Flat data normalization 91 | data = Data([10, 20, 30, 40], ["Q1", "Q2", "Q3", "Q4"]) 92 | normalized = data.normalize(50) # Normalize to 50 character width 93 | print(normalized) # [12.5, 25.0, 37.5, 50.0] 94 | 95 | # Nested data normalization 96 | data = Data([[10, 15], [20, 25], [30, 35]], ["X", "Y", "Z"]) 97 | normalized = data.normalize(40) 98 | print(normalized) # [[11.43, 17.14], [22.86, 28.57], [34.29, 40.0]] 99 | ``` 100 | 101 | The normalize method: 102 | - Handles negative values by offsetting all data 103 | - Scales values proportionally to the target width 104 | - Preserves relative relationships between data points 105 | - Works with both flat and nested data structures 106 | 107 | ## Properties 108 | 109 | ### `data` 110 | The raw data provided during initialization. 111 | 112 | ### `labels` 113 | The labels for each data row/point. 114 | 115 | ### `categories` 116 | Category names for multi-series data (empty list if not provided). 117 | 118 | ### `dims` 119 | Tuple representing the dimensions of the data structure. 120 | 121 | ```python 122 | # Flat data 123 | data = Data([10, 20, 30], ["A", "B", "C"]) 124 | print(data.dims) # (3,) 125 | 126 | # Nested data 127 | data = Data([[10, 15], [20, 25]], ["X", "Y"]) 128 | print(data.dims) # (2, 2) 129 | ``` 130 | 131 | ## Examples 132 | 133 | ### Basic Flat Data 134 | 135 | ```python 136 | from termgraph import Data 137 | 138 | # Simple sales data 139 | sales_data = Data( 140 | data=[150, 230, 180, 290, 210], 141 | labels=["Jan", "Feb", "Mar", "Apr", "May"] 142 | ) 143 | 144 | print(f"Min sales: {sales_data.find_min()}") # Min sales: 150 145 | print(f"Max sales: {sales_data.find_max()}") # Max sales: 290 146 | print(f"Data: {sales_data}") 147 | ``` 148 | 149 | ### Multi-Series Data with Categories 150 | 151 | ```python 152 | from termgraph import Data 153 | 154 | # Quarterly sales for two products 155 | quarterly_data = Data( 156 | data=[ 157 | [120, 80], # Q1: Product A, Product B 158 | [150, 95], # Q2: Product A, Product B 159 | [180, 110], # Q3: Product A, Product B 160 | [200, 125] # Q4: Product A, Product B 161 | ], 162 | labels=["Q1", "Q2", "Q3", "Q4"], 163 | categories=["Product A", "Product B"] 164 | ) 165 | 166 | print(f"Dimensions: {quarterly_data.dims}") # (4, 2) 167 | print(f"Categories: {quarterly_data.categories}") 168 | ``` 169 | 170 | ### Working with Negative Values 171 | 172 | ```python 173 | from termgraph import Data 174 | 175 | # Profit/Loss data with negative values 176 | pnl_data = Data( 177 | data=[-50, 30, -20, 80, 45], 178 | labels=["Jan", "Feb", "Mar", "Apr", "May"] 179 | ) 180 | 181 | # Normalization handles negative values automatically 182 | normalized = pnl_data.normalize(40) 183 | print(f"Normalized data: {normalized}") 184 | ``` 185 | 186 | ### Data Validation Examples 187 | 188 | ```python 189 | from termgraph import Data 190 | 191 | # These will raise exceptions: 192 | try: 193 | # Missing labels 194 | Data([10, 20, 30]) 195 | except Exception as e: 196 | print(f"Error: {e}") 197 | 198 | try: 199 | # Mismatched dimensions 200 | Data([10, 20], ["A", "B", "C"]) 201 | except Exception as e: 202 | print(f"Error: {e}") 203 | 204 | try: 205 | # Inconsistent nested structure 206 | Data([[10, 20], [30]], ["A", "B"]) 207 | except Exception as e: 208 | print(f"Error: {e}") 209 | ``` 210 | 211 | ## String Representation 212 | 213 | The `Data` class provides a tabular string representation: 214 | 215 | ```python 216 | data = Data( 217 | data=[[100, 150], [200, 250], [300, 350]], 218 | labels=["Product 1", "Product 2", "Product 3"], 219 | categories=["Sales", "Revenue"] 220 | ) 221 | 222 | print(data) 223 | ``` 224 | 225 | Output: 226 | ``` 227 | Labels | Data 228 | -----------|--------------- 229 | Product 1 | (Sales) 100 230 | | (Revenue) 150 231 | | 232 | Product 2 | (Sales) 200 233 | | (Revenue) 250 234 | | 235 | Product 3 | (Sales) 300 236 | | (Revenue) 350 237 | | 238 | ``` 239 | 240 | ## Integration with Charts 241 | 242 | The `Data` class is designed to work seamlessly with all chart types: 243 | 244 | ```python 245 | from termgraph import Data, BarChart, Args 246 | 247 | # Create data 248 | data = Data([45, 32, 78, 56, 23], ["A", "B", "C", "D", "E"]) 249 | 250 | # Create chart with custom arguments 251 | args = Args(width=60, colors=["red"]) 252 | chart = BarChart(data, args) 253 | 254 | # Draw the chart 255 | chart.draw() 256 | ``` 257 | 258 | For more examples of using Data with different chart types, see the [Chart Classes Documentation](chart-classes.md). -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Termgraph 2 | 3 | Thank you for your interest in contributing to Termgraph! This document provides guidance on the project structure and how to contribute effectively. 4 | 5 | ## Project Structure 6 | 7 | Termgraph has been designed with a clean, modular architecture that supports both command-line usage and programmatic access. 8 | 9 | ### Architecture Overview 10 | 11 | ``` 12 | termgraph/ 13 | ├── termgraph/ 14 | │ ├── __init__.py # Package entry point with lazy imports 15 | │ ├── termgraph.py # CLI implementation and main entry point 16 | │ ├── data.py # Data class - handles all data operations 17 | │ ├── args.py # Args class - configuration management 18 | │ ├── chart.py # Chart classes - rendering and display 19 | │ ├── module.py # Backward compatibility module 20 | │ ├── utils.py # Utility functions (formatting, normalization) 21 | │ └── constants.py # Shared constants (colors, characters, units) 22 | ├── tests/ # Test suite organized by functionality 23 | ├── data/ # Sample data files for testing 24 | └── README.md # Project documentation 25 | ``` 26 | 27 | ### Core Components 28 | 29 | #### **Data Class (`data.py`)** 30 | The `Data` class is responsible for: 31 | - Data validation and structure verification 32 | - Finding min/max values and label lengths 33 | - Handling categories and multi-dimensional data 34 | - Providing a clean interface for data operations 35 | 36 | ```python 37 | from termgraph import Data 38 | 39 | data = Data([[1, 2], [3, 4]], ["Label1", "Label2"]) 40 | print(data.find_max()) # 4 41 | ``` 42 | 43 | #### **Args Class (`args.py`)** 44 | The `Args` class manages: 45 | - Chart configuration options 46 | - Default values and validation 47 | - Type-safe access to arguments 48 | 49 | ```python 50 | from termgraph import Args 51 | 52 | args = Args(width=100, title="My Chart", percentage=True) 53 | print(args.get_arg("width")) # 100 54 | ``` 55 | 56 | #### **Chart Classes (`chart.py`)** 57 | Chart classes handle: 58 | - Chart rendering and display logic 59 | - Header and legend printing 60 | - Color management 61 | - Different chart types (Bar, Horizontal, etc.) 62 | 63 | ```python 64 | from termgraph import Data, Args, BarChart 65 | 66 | data = Data([[10], [20]], ["A", "B"]) 67 | args = Args(title="Test Chart") 68 | chart = BarChart(data, args) 69 | chart.draw() 70 | ``` 71 | 72 | ### Design Principles 73 | 74 | 1. **Class-Based Architecture**: Everything is organized around focused classes with clear responsibilities 75 | 2. **Single Source of Truth**: No duplicate implementations - data operations live in Data class, rendering in Chart classes 76 | 3. **Backward Compatibility**: All existing APIs are maintained through import forwarding 77 | 4. **Modular Organization**: Each class has its own file for better maintainability 78 | 79 | ## Development Workflow 80 | 81 | ### Setting Up Development Environment 82 | 83 | 1. **Clone the repository:** 84 | ```bash 85 | git clone https://github.com/mkaz/termgraph.git 86 | cd termgraph 87 | ``` 88 | 89 | 2. **Install development dependencies:** 90 | ```bash 91 | just install 92 | # or manually: uv sync --dev 93 | ``` 94 | 95 | 3. **Run tests to verify setup:** 96 | ```bash 97 | just test 98 | ``` 99 | 100 | ### Development Commands 101 | 102 | We use `just` as our command runner. Available commands: 103 | 104 | ```bash 105 | just # Show available commands 106 | just install # Install development dependencies 107 | just test # Run the test suite 108 | just test-file # Run specific test file 109 | just lint # Check code with ruff 110 | just lint-fix # Fix code formatting issues 111 | just typecheck # Run mypy type checking 112 | just check # Run all quality checks (lint + typecheck) 113 | just run-example # Run with sample data 114 | ``` 115 | 116 | ### Testing 117 | 118 | Our test suite is organized by functionality: 119 | 120 | ``` 121 | tests/ 122 | ├── README.md # Testing documentation 123 | ├── test_check_data.py # Data validation tests 124 | ├── test_data_utils.py # Data utility function tests 125 | ├── test_normalize.py # Data normalization tests 126 | ├── test_rendering.py # Chart rendering tests 127 | ├── test_read_data.py # Data parsing tests 128 | └── test_init.py # Initialization tests 129 | ``` 130 | 131 | **Adding New Tests:** 132 | - Data validation → `test_check_data.py` 133 | - Data operations → `test_data_utils.py` 134 | - Chart rendering → `test_rendering.py` 135 | - File parsing → `test_read_data.py` 136 | 137 | ### Code Quality 138 | 139 | We maintain high code quality through: 140 | 141 | - **Ruff** for linting and formatting 142 | - **MyPy** for type checking 143 | - **Comprehensive test suite** with good coverage 144 | - **Clear naming conventions** and documentation 145 | 146 | **Before submitting a PR:** 147 | 1. Run `just check` to verify code quality 148 | 2. Run `just test` to ensure all tests pass 149 | 3. Add tests for new functionality 150 | 4. Update documentation if needed 151 | 152 | ## Contributing Guidelines 153 | 154 | ### Reporting Issues 155 | 156 | When reporting bugs or requesting features: 157 | 1. Check existing [GitHub Issues](https://github.com/mkaz/termgraph/issues) 158 | 2. Provide clear reproduction steps for bugs 159 | 3. Include sample data files when relevant 160 | 4. Specify your Python version and OS 161 | 162 | ### Pull Requests 163 | 164 | 1. **Fork the repository** and create a feature branch 165 | 2. **Write tests** for new functionality 166 | 3. **Follow existing code patterns** and class structure 167 | 4. **Maintain backward compatibility** - don't break existing APIs 168 | 5. **Update documentation** if your changes affect usage 169 | 6. **Run quality checks** before submitting 170 | 171 | ### API Design Guidelines 172 | 173 | When adding new features: 174 | 175 | #### **For Data Operations:** 176 | - Add methods to the `Data` class 177 | - Ensure they work with the existing data structure 178 | - Add corresponding procedural functions in `data.py` for backward compatibility 179 | 180 | #### **For Chart Options:** 181 | - Add new arguments to `Args.default` dictionary 182 | - Update CLI argument parsing in `termgraph.py` 183 | - Ensure the option works in both CLI and programmatic usage 184 | 185 | #### **For Chart Types:** 186 | - Extend existing chart classes or create new ones inheriting from `Chart` 187 | - Follow the existing rendering patterns 188 | - Ensure compatibility with all chart options (colors, formatting, etc.) 189 | 190 | ### Examples of Good Contributions 191 | 192 | #### Adding a New Chart Option: 193 | ```python 194 | # 1. Add to Args.default in args.py 195 | "new_option": False, 196 | 197 | # 2. Add CLI argument in termgraph.py 198 | parser.add_argument("--new-option", action="store_true", help="Enable new option") 199 | 200 | # 3. Use in chart rendering 201 | if self.args.get_arg("new_option"): 202 | # implement feature 203 | ``` 204 | 205 | #### Adding a Data Operation: 206 | ```python 207 | # 1. Add method to Data class in data.py 208 | def new_operation(self) -> float: 209 | return some_calculation(self.data) 210 | 211 | # 2. Add backward compatibility function 212 | def new_operation(data: list) -> float: 213 | data_obj = Data(data, [str(i) for i in range(len(data))]) 214 | return data_obj.new_operation() 215 | 216 | # 3. Add tests in test_data_utils.py 217 | def test_new_operation(): 218 | # test implementation 219 | ``` 220 | 221 | ## Questions? 222 | 223 | - 💬 **Discussion**: Use [GitHub Issues](https://github.com/mkaz/termgraph/issues) for questions 224 | - 🐛 **Bugs**: Report via [GitHub Issues](https://github.com/mkaz/termgraph/issues) 225 | - 🚀 **Features**: Request via [GitHub Issues](https://github.com/mkaz/termgraph/issues) 226 | 227 | Thank you for contributing to Termgraph! 🎉 -------------------------------------------------------------------------------- /docs/args-class.md: -------------------------------------------------------------------------------- 1 | # Args Class API Documentation 2 | 3 | The `Args` class manages chart configuration options and arguments. It provides a centralized way to control chart appearance, behavior, and formatting. 4 | 5 | ## Constructor 6 | 7 | ```python 8 | from termgraph import Args 9 | 10 | # Default args 11 | args = Args() 12 | 13 | # Custom args 14 | args = Args( 15 | width=60, 16 | title="My Chart", 17 | colors=["red", "blue"], 18 | suffix=" units" 19 | ) 20 | ``` 21 | 22 | ### Parameters 23 | 24 | All parameters are optional and provided as keyword arguments. If not specified, default values are used. 25 | 26 | ## Configuration Options 27 | 28 | ### Chart Dimensions 29 | 30 | #### `width` (int, default: 50) 31 | The width of the chart in characters. 32 | 33 | ```python 34 | args = Args(width=80) # Wide chart 35 | args = Args(width=30) # Narrow chart 36 | ``` 37 | 38 | ### Chart Appearance 39 | 40 | #### `title` (str, default: None) 41 | Chart title displayed at the top. 42 | 43 | ```python 44 | args = Args(title="Monthly Sales Report") 45 | ``` 46 | 47 | #### `colors` (list, default: None) 48 | List of color codes for chart series. Use color names or ANSI codes. 49 | 50 | ```python 51 | from termgraph import Colors 52 | 53 | args = Args(colors=[Colors.Blue, Colors.Green, Colors.Red]) 54 | # Or use color names 55 | args = Args(colors=["blue", "green", "red"]) 56 | ``` 57 | 58 | Available color constants: 59 | - `Colors.Black` 60 | - `Colors.Red` 61 | - `Colors.Green` 62 | - `Colors.Yellow` 63 | - `Colors.Blue` 64 | - `Colors.Magenta` 65 | - `Colors.Cyan` 66 | 67 | #### `custom_tick` (str, default: "") 68 | Custom character to use for chart bars instead of the default block character. 69 | 70 | ```python 71 | args = Args(custom_tick="*") # Use asterisks 72 | args = Args(custom_tick="=") # Use equals signs 73 | args = Args(custom_tick="█") # Use solid blocks 74 | ``` 75 | 76 | ### Value Formatting 77 | 78 | #### `format` (str, default: "{:<5.2f}") 79 | Python format string for numeric values. 80 | 81 | ```python 82 | args = Args(format="{:<6.1f}") # 6 chars wide, 1 decimal 83 | args = Args(format="{:>8.0f}") # Right-aligned, no decimals 84 | args = Args(format="{:+.2f}") # Always show sign 85 | ``` 86 | 87 | #### `suffix` (str, default: "") 88 | Text appended to each value. 89 | 90 | ```python 91 | args = Args(suffix=" units") # Append units 92 | args = Args(suffix="K") # Thousands 93 | args = Args(suffix="$") # Currency 94 | ``` 95 | 96 | #### `percentage` (bool, default: False) 97 | Format values as percentages. 98 | 99 | ```python 100 | # Convert 0.75 to 75% 101 | args = Args(percentage=True) 102 | ``` 103 | 104 | #### `no_readable` (bool, default: False) 105 | Disable automatic conversion of large numbers to readable format (e.g., 1000 → 1K). 106 | 107 | ```python 108 | args = Args(no_readable=True) # Show raw numbers 109 | ``` 110 | 111 | ### Label and Value Display 112 | 113 | #### `no_labels` (bool, default: False) 114 | Hide row labels. 115 | 116 | ```python 117 | args = Args(no_labels=True) # Hide all labels 118 | ``` 119 | 120 | #### `no_values` (bool, default: False) 121 | Hide numeric values next to bars. 122 | 123 | ```python 124 | args = Args(no_values=True) # Show only bars 125 | ``` 126 | 127 | #### `label_before` (bool, default: False) 128 | Display labels before the bars instead of to the left with colons. 129 | 130 | ```python 131 | args = Args(label_before=True) 132 | # Changes "Label: ████" to "Label ████" 133 | ``` 134 | 135 | ### Chart Layout 136 | 137 | #### `space_between` (bool, default: False) 138 | Add blank lines between chart rows. 139 | 140 | ```python 141 | args = Args(space_between=True) # More readable spacing 142 | ``` 143 | 144 | #### `vertical` (bool, default: False) 145 | Create vertical/column charts instead of horizontal bars. 146 | 147 | ```python 148 | args = Args(vertical=True) # Column chart 149 | ``` 150 | 151 | ### Multi-Series Options 152 | 153 | #### `different_scale` (bool, default: False) 154 | Use different scales for each data series in multi-series charts. 155 | 156 | ```python 157 | # Useful when series have very different ranges 158 | args = Args(different_scale=True) 159 | ``` 160 | 161 | #### `stacked` (bool, default: False) 162 | Create stacked bar charts for multi-series data. 163 | 164 | ```python 165 | args = Args(stacked=True) # Stack series on top of each other 166 | ``` 167 | 168 | ### Histogram Options 169 | 170 | #### `histogram` (bool, default: False) 171 | Enable histogram mode. 172 | 173 | ```python 174 | args = Args(histogram=True, bins=10) 175 | ``` 176 | 177 | #### `bins` (int, default: 5) 178 | Number of bins for histogram charts. 179 | 180 | ```python 181 | args = Args(bins=8) # 8 histogram bins 182 | args = Args(bins=15) # Fine-grained histogram 183 | ``` 184 | 185 | ### Data Input Options 186 | 187 | #### `filename` (str, default: "-") 188 | Input filename (used by CLI, usually not needed for API usage). 189 | 190 | #### `delim` (str, default: "") 191 | Custom delimiter for data parsing. 192 | 193 | ### Calendar Options 194 | 195 | #### `calendar` (bool, default: False) 196 | Enable calendar heatmap mode. 197 | 198 | #### `start_dt` (date, default: None) 199 | Start date for calendar charts. 200 | 201 | ### Debugging 202 | 203 | #### `verbose` (bool, default: False) 204 | Enable verbose output for debugging. 205 | 206 | ```python 207 | args = Args(verbose=True) # Show debug information 208 | ``` 209 | 210 | ## Methods 211 | 212 | ### `get_arg(arg: str) -> Union[int, str, bool, None]` 213 | Get the value of a specific argument. 214 | 215 | ```python 216 | args = Args(width=60, title="Test") 217 | 218 | width = args.get_arg("width") # Returns 60 219 | title = args.get_arg("title") # Returns "Test" 220 | ``` 221 | 222 | ### `update_args(**kwargs) -> None` 223 | Update multiple arguments after initialization. 224 | 225 | ```python 226 | args = Args(width=50) 227 | args.update_args(width=80, title="Updated Chart") 228 | ``` 229 | 230 | ## Examples 231 | 232 | ### Basic Configuration 233 | 234 | ```python 235 | from termgraph import Args, Colors 236 | 237 | # Simple configuration 238 | args = Args( 239 | width=50, 240 | title="Sales Data", 241 | colors=[Colors.Blue], 242 | suffix=" units" 243 | ) 244 | ``` 245 | 246 | ### Advanced Formatting 247 | 248 | ```python 249 | from termgraph import Args 250 | 251 | # Detailed formatting options 252 | args = Args( 253 | width=70, 254 | title="Financial Performance Q4 2023", 255 | format="{:>8.2f}", # Right-aligned, 2 decimals 256 | suffix="K USD", # Thousands of dollars 257 | space_between=True, # Better readability 258 | no_readable=True # Show exact numbers 259 | ) 260 | ``` 261 | 262 | ### Multi-Series Configuration 263 | 264 | ```python 265 | from termgraph import Args, Colors 266 | 267 | # Multi-series with different colors 268 | args = Args( 269 | width=60, 270 | title="Product Comparison", 271 | colors=[Colors.Green, Colors.Blue, Colors.Red], 272 | different_scale=False, # Use same scale 273 | space_between=True 274 | ) 275 | ``` 276 | 277 | ### Stacked Chart Configuration 278 | 279 | ```python 280 | from termgraph import Args, Colors 281 | 282 | # Stacked chart setup 283 | args = Args( 284 | width=50, 285 | title="Market Share Evolution", 286 | colors=[Colors.Blue, Colors.Green, Colors.Yellow], 287 | stacked=True, 288 | format="{:<4.0f}", 289 | suffix="%" 290 | ) 291 | ``` 292 | 293 | ### Histogram Configuration 294 | 295 | ```python 296 | from termgraph import Args, Colors 297 | 298 | # Histogram settings 299 | args = Args( 300 | width=60, 301 | title="Data Distribution", 302 | histogram=True, 303 | bins=10, 304 | colors=[Colors.Cyan], 305 | format="{:<3.0f}" 306 | ) 307 | ``` 308 | 309 | ### Minimal Bar Chart 310 | 311 | ```python 312 | from termgraph import Args 313 | 314 | # Clean, minimal appearance 315 | args = Args( 316 | width=40, 317 | no_values=True, # Hide numbers 318 | custom_tick="▓", # Custom bar character 319 | colors=["green"] 320 | ) 321 | ``` 322 | 323 | ### Percentage Chart 324 | 325 | ```python 326 | from termgraph import Args, Colors 327 | 328 | # Percentage display 329 | args = Args( 330 | width=50, 331 | title="Completion Status", 332 | percentage=True, 333 | colors=[Colors.Green], 334 | format="{:<3.0f}", 335 | suffix="%" 336 | ) 337 | ``` 338 | 339 | ### Vertical Chart Configuration 340 | 341 | ```python 342 | from termgraph import Args, Colors 343 | 344 | # Column chart setup 345 | args = Args( 346 | width=30, 347 | title="Vertical Sales Chart", 348 | vertical=True, 349 | colors=[Colors.Blue], 350 | no_labels=False 351 | ) 352 | ``` 353 | 354 | ## Default Values Reference 355 | 356 | ```python 357 | # All default values 358 | default_args = { 359 | "filename": "-", 360 | "title": None, 361 | "width": 50, 362 | "format": "{:<5.2f}", 363 | "suffix": "", 364 | "no_labels": False, 365 | "no_values": False, 366 | "space_between": False, 367 | "colors": None, 368 | "vertical": False, 369 | "stacked": False, 370 | "histogram": False, 371 | "bins": 5, 372 | "different_scale": False, 373 | "calendar": False, 374 | "start_dt": None, 375 | "custom_tick": "", 376 | "delim": "", 377 | "verbose": False, 378 | "label_before": False, 379 | "percentage": False, 380 | "no_readable": False, 381 | } 382 | ``` 383 | 384 | ## Error Handling 385 | 386 | The Args class validates argument names: 387 | 388 | ```python 389 | from termgraph import Args 390 | 391 | try: 392 | # Invalid argument name 393 | args = Args(invalid_option=True) 394 | except Exception as e: 395 | print(f"Error: {e}") # "Invalid Argument: invalid_option" 396 | 397 | try: 398 | # Invalid argument in get_arg 399 | args = Args() 400 | value = args.get_arg("nonexistent") 401 | except Exception as e: 402 | print(f"Error: {e}") # "Invalid Argument: nonexistent" 403 | ``` 404 | 405 | ## Integration with Charts 406 | 407 | The Args object is passed to chart constructors: 408 | 409 | ```python 410 | from termgraph import Data, BarChart, Args, Colors 411 | 412 | # Create data and args 413 | data = Data([10, 20, 30], ["A", "B", "C"]) 414 | args = Args( 415 | width=40, 416 | title="Sample Chart", 417 | colors=[Colors.Green] 418 | ) 419 | 420 | # Create and draw chart 421 | chart = BarChart(data, args) 422 | chart.draw() 423 | ``` 424 | 425 | For more information about chart types, see [Chart Classes Documentation](chart-classes.md). 426 | For data preparation, see [Data Class Documentation](data-class.md). -------------------------------------------------------------------------------- /termgraph/termgraph.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import argparse 3 | import sys 4 | from datetime import datetime, timedelta 5 | from colorama import just_fix_windows_console 6 | import os 7 | import re 8 | import importlib.metadata 9 | 10 | from .constants import AVAILABLE_COLORS, DAYS 11 | from .data import Data 12 | from .args import Args 13 | from .chart import Chart, BarChart, StackedChart, HistogramChart, VerticalChart 14 | 15 | __version__ = importlib.metadata.version("termgraph") 16 | 17 | # colorama 18 | just_fix_windows_console() 19 | 20 | 21 | def init_args() -> dict: 22 | """Parse and return the arguments.""" 23 | parser = argparse.ArgumentParser(description="draw basic graphs on terminal") 24 | parser.add_argument( 25 | "filename", 26 | nargs="?", 27 | default="-", 28 | help="data file name (comma or space separated). Defaults to stdin.", 29 | ) 30 | parser.add_argument("--title", help="Title of graph") 31 | parser.add_argument( 32 | "--width", type=int, default=50, help="width of graph in characters default:50" 33 | ) 34 | parser.add_argument("--format", default="{:<5.2f}", help="format specifier to use.") 35 | parser.add_argument( 36 | "--suffix", default="", help="string to add as a suffix to all data points." 37 | ) 38 | parser.add_argument( 39 | "--no-labels", action="store_true", help="Do not print the label column" 40 | ) 41 | parser.add_argument( 42 | "--no-values", action="store_true", help="Do not print the values at end" 43 | ) 44 | parser.add_argument( 45 | "--space-between", 46 | action="store_true", 47 | help="Print a new line after every field", 48 | ) 49 | parser.add_argument("--color", nargs="*", help="Graph bar color( s )") 50 | parser.add_argument("--vertical", action="store_true", help="Vertical graph") 51 | parser.add_argument("--stacked", action="store_true", help="Stacked bar graph") 52 | parser.add_argument("--histogram", action="store_true", help="Histogram") 53 | parser.add_argument("--bins", default=5, type=int, help="Bins of Histogram") 54 | parser.add_argument( 55 | "--different-scale", 56 | action="store_true", 57 | help="Categories have different scales.", 58 | ) 59 | parser.add_argument( 60 | "--calendar", action="store_true", help="Calendar Heatmap chart" 61 | ) 62 | parser.add_argument("--start-dt", help="Start date for Calendar chart") 63 | parser.add_argument( 64 | "--custom-tick", default="", help="Custom tick mark, emoji approved" 65 | ) 66 | parser.add_argument( 67 | "--delim", default="", help="Custom delimiter, default , or space" 68 | ) 69 | parser.add_argument( 70 | "--verbose", action="store_true", help="Verbose output, helpful for debugging" 71 | ) 72 | parser.add_argument( 73 | "--label-before", 74 | action="store_true", 75 | default=False, 76 | help="Display the values before the bars", 77 | ) 78 | parser.add_argument( 79 | "--version", action="store_true", help="Display version and exit" 80 | ) 81 | parser.add_argument( 82 | "--no-readable", action="store_true", help="Disable the readable numbers" 83 | ) 84 | parser.add_argument( 85 | "--percentage", action="store_true", help="Display the number in percentage" 86 | ) 87 | 88 | if len(sys.argv) == 1: 89 | if sys.stdin.isatty(): 90 | parser.print_usage() 91 | sys.exit(2) 92 | 93 | args = vars(parser.parse_args()) 94 | 95 | return args 96 | 97 | 98 | def main(): 99 | """Main function.""" 100 | args = init_args() 101 | 102 | if args["version"]: 103 | print(f"termgraph v{__version__}") 104 | sys.exit() 105 | 106 | data_obj = Data.from_file(args["filename"], args) 107 | colors = _extract_colors(data_obj, args) 108 | 109 | try: 110 | if args["calendar"]: 111 | # calendar_heatmap still uses old interface 112 | calendar_heatmap(data_obj.data, data_obj.labels, args) 113 | else: 114 | chart(data_obj, args, colors) 115 | except BrokenPipeError: 116 | pass 117 | 118 | 119 | def chart(data_obj: Data, args: dict, colors: list) -> None: 120 | """Handle the normalization of data and the printing of the graph.""" 121 | # Convert CLI args dict to chart Args class, mapping incompatible keys 122 | chart_args_dict = dict(args) 123 | if "color" in chart_args_dict: 124 | chart_args_dict["colors"] = chart_args_dict.pop("color") 125 | 126 | # Remove CLI-specific args that don't belong in chart Args 127 | cli_only_args = ["filename", "delim", "verbose", "version"] 128 | for cli_arg in cli_only_args: 129 | chart_args_dict.pop(cli_arg, None) 130 | 131 | chart_args = Args(**chart_args_dict) 132 | if colors: 133 | chart_args.update_args(colors=colors) 134 | 135 | # Choose chart type 136 | chart_obj: Chart 137 | if args["stacked"]: 138 | chart_obj = StackedChart(data_obj, chart_args) 139 | elif args["histogram"]: 140 | chart_obj = HistogramChart(data_obj, chart_args) 141 | elif args["vertical"]: 142 | chart_obj = VerticalChart(data_obj, chart_args) 143 | else: 144 | chart_obj = BarChart(data_obj, chart_args) 145 | 146 | chart_obj.draw() 147 | 148 | 149 | def _extract_colors(data_obj: Data, args: dict) -> list: 150 | """Extract and validate colors from args based on data dimensions. 151 | 152 | Args: 153 | data_obj: Data object containing the chart data 154 | args: Dictionary of arguments including optional "color" and "stacked" 155 | 156 | Returns: 157 | List of color codes for each category 158 | """ 159 | colors = [] 160 | 161 | # Determine number of categories from data dimensions 162 | if data_obj.dims and len(data_obj.dims) > 1: 163 | len_categories = data_obj.dims[1] 164 | else: 165 | len_categories = 1 166 | 167 | # If user inserts colors, they should be as many as the categories. 168 | if args.get("color") is not None: 169 | # Decompose arguments for Windows 170 | if os.name == "nt": 171 | colorargs = re.findall(r"[a-z]+", args["color"][0]) 172 | if len(colorargs) != len_categories: 173 | print(">> Error: Color and category array sizes don't match") 174 | for color in colorargs: 175 | if color not in AVAILABLE_COLORS: 176 | print( 177 | ">> Error: invalid color. choose from 'red', 'blue', 'green', 'magenta', 'yellow', 'black', 'cyan'" 178 | ) 179 | sys.exit(2) 180 | else: 181 | if len(args["color"]) != len_categories: 182 | print(">> Error: Color and category array sizes don't match") 183 | for color in args["color"]: 184 | if color not in AVAILABLE_COLORS: 185 | print( 186 | ">> Error: invalid color. choose from 'red', 'blue', 'green', 'magenta', 'yellow', 'black', 'cyan'" 187 | ) 188 | sys.exit(2) 189 | 190 | if os.name == "nt": 191 | for color in colorargs: 192 | colors.append(AVAILABLE_COLORS.get(color)) 193 | else: 194 | for color in args["color"]: 195 | colors.append(AVAILABLE_COLORS.get(color)) 196 | 197 | # If user hasn't inserted colors, pick the first n colors 198 | # from the dict (n = number of categories). 199 | if args.get("stacked") and not colors: 200 | colors = [v for v in list(AVAILABLE_COLORS.values())[:len_categories]] 201 | 202 | return colors 203 | 204 | def read_data(args: dict) -> tuple[list, list, list, list]: 205 | """Read data from a file or stdin and returns it. 206 | 207 | DEPRECATED: This function is deprecated. Use Data.from_file() and _extract_colors() instead. 208 | 209 | Filename includes (categories), labels and data. 210 | We append categories and labels to lists. 211 | Data are inserted to a list of lists due to the categories. 212 | 213 | i.e. 214 | labels = ['2001', '2002', '2003', ...] 215 | categories = ['boys', 'girls'] 216 | data = [ [20.4, 40.5], [30.7, 100.0], ...]""" 217 | 218 | # Use new Data.from_file() method 219 | data_obj = Data.from_file(args["filename"], args) 220 | colors = _extract_colors(data_obj, args) 221 | 222 | return data_obj.categories, data_obj.labels, data_obj.data, colors 223 | 224 | 225 | def calendar_heatmap(data: dict, labels: list, args: dict) -> None: 226 | """Print a calendar heatmap.""" 227 | if args["color"]: 228 | colornum = AVAILABLE_COLORS.get(args["color"][0]) 229 | else: 230 | colornum = AVAILABLE_COLORS.get("blue") 231 | 232 | dt_dict = {} 233 | for i in range(len(labels)): 234 | dt_dict[labels[i]] = data[i][0] 235 | 236 | # get max value 237 | max_val = float(max(data)[0]) 238 | 239 | tick_1 = "░" 240 | tick_2 = "▒" 241 | tick_3 = "▓" 242 | tick_4 = "█" 243 | 244 | if args["custom_tick"]: 245 | tick_1 = tick_2 = tick_3 = tick_4 = args["custom_tick"] 246 | 247 | # check if start day set, otherwise use one year ago 248 | if args["start_dt"]: 249 | start_dt = datetime.strptime(args["start_dt"], "%Y-%m-%d") 250 | else: 251 | start = datetime.now() 252 | start_dt = datetime(year=start.year - 1, month=start.month, day=start.day) 253 | 254 | # modify start date to be a Monday, subtract weekday() from day 255 | start_dt = start_dt - timedelta(start_dt.weekday()) 256 | 257 | # TODO: legend doesn't line up properly for all start dates/data 258 | # top legend for months 259 | sys.stdout.write(" ") 260 | for month in range(13): 261 | month_dt = datetime( 262 | year=start_dt.year, month=start_dt.month, day=1 263 | ) + timedelta(days=month * 31) 264 | sys.stdout.write(month_dt.strftime("%b") + " ") 265 | if args["custom_tick"]: # assume custom tick is emoji which is one wider 266 | sys.stdout.write(" ") 267 | 268 | sys.stdout.write("\n") 269 | 270 | for day in range(7): 271 | sys.stdout.write(DAYS[day] + ": ") 272 | for week in range(53): 273 | day_ = start_dt + timedelta(days=day + week * 7) 274 | day_str = day_.strftime("%Y-%m-%d") 275 | 276 | if day_str in dt_dict: 277 | if dt_dict[day_str] > max_val * 0.75: 278 | tick = tick_4 279 | elif dt_dict[day_str] > max_val * 0.50: 280 | tick = tick_3 281 | elif dt_dict[day_str] > max_val * 0.25: 282 | tick = tick_2 283 | # show nothing if value is zero 284 | elif dt_dict[day_str] == 0.0: 285 | tick = " " 286 | # show values for less than 0.25 287 | else: 288 | tick = tick_1 289 | else: 290 | tick = " " 291 | 292 | if colornum: 293 | sys.stdout.write(f"\033[{colornum}m") 294 | 295 | sys.stdout.write(tick) 296 | if colornum: 297 | sys.stdout.write("\033[0m") 298 | 299 | sys.stdout.write("\n") 300 | 301 | 302 | # DEPRECATED: Use Data.normalize() directly instead 303 | def normalize(data: list, width: int) -> list: 304 | """Normalize the data and return it. 305 | 306 | DEPRECATED: This function is deprecated. Use Data(data, labels).normalize(width) directly. 307 | """ 308 | # Create a temporary Data object and use its normalize method 309 | temp_data = Data(data, [f"label_{i}" for i in range(len(data))]) 310 | return temp_data.normalize(width) 311 | 312 | 313 | if __name__ == "__main__": 314 | main() 315 | -------------------------------------------------------------------------------- /termgraph/data.py: -------------------------------------------------------------------------------- 1 | """Data class for termgraph - handles all data-related operations.""" 2 | 3 | from __future__ import annotations 4 | from typing import Union 5 | import sys 6 | from .constants import DELIM 7 | 8 | 9 | class Data: 10 | """Class representing the data for the chart.""" 11 | 12 | def __init__( 13 | self, 14 | data: list, 15 | labels: list[str], 16 | categories: Union[list[str], None] = None, 17 | ): 18 | """Initialize data 19 | 20 | :data: The data to graph on the chart 21 | :labels: The labels of the data 22 | :categories: The categories of the data 23 | 24 | Can be called with positional or keyword arguments: 25 | - Data([10, 20, 40, 26], ["Q1", "Q2", "Q3", "Q4"]) 26 | - Data(data=[10, 20, 40, 26], labels=["Q1", "Q2", "Q3", "Q4"]) 27 | - Data(labels=["Q1", "Q2", "Q3", "Q4"], data=[10, 20, 40, 26]) 28 | """ 29 | 30 | if data is None or labels is None: 31 | raise Exception("Both 'data' and 'labels' parameters are required") 32 | 33 | if not labels: 34 | raise Exception("No labels provided") 35 | 36 | if not data: 37 | raise Exception("No data provided") 38 | 39 | if len(data) != len(labels): 40 | raise Exception("The dimensions of the data and labels must be the same") 41 | 42 | self.labels = labels 43 | self.data = data 44 | self.categories = categories or [] 45 | self.dims = self._find_dims(data, labels) 46 | 47 | @classmethod 48 | def from_file(cls, filename: str, args: dict) -> Data: 49 | """Read data from a file or stdin and return a Data object. 50 | 51 | This method handles reading chart data from files or stdin (when filename is "-"). 52 | The file format supports: 53 | - Comments: lines starting with # 54 | - Categories: lines starting with @ followed by category names 55 | - Data rows: label followed by numeric values 56 | 57 | Args: 58 | filename: Path to data file, or "-" for stdin 59 | args: Dictionary of arguments including optional "delim" and "verbose" 60 | 61 | Returns: 62 | Data object with parsed data, labels, and categories 63 | 64 | Example file format: 65 | @ Boys Girls 66 | 2001 20.4 40.5 67 | 2002 30.7 100.0 68 | """ 69 | stdin = filename == "-" 70 | 71 | # Get delimiter from args or use default 72 | delim = args.get("delim") or DELIM 73 | 74 | if args.get("verbose"): 75 | print(f">> Reading data from {('stdin' if stdin else filename)}") 76 | 77 | categories: list[str] = [] 78 | labels: list[str] = [] 79 | data: list = [] 80 | 81 | f = None 82 | 83 | try: 84 | f = sys.stdin if stdin else open(filename, "r") 85 | for line in f: 86 | line = line.strip() 87 | if line: 88 | if not line.startswith("#"): 89 | # Line contains categories. 90 | if line.startswith("@"): 91 | cols = line.split(delim) 92 | cols[0] = cols[0].replace("@ ", "") 93 | categories = cols 94 | 95 | # Line contains label and values. 96 | else: 97 | if line.find(delim) > 0: 98 | cols = line.split(delim) 99 | row_delim = delim 100 | else: 101 | cols = line.split() 102 | row_delim = " " 103 | labeled_row = _label_row([col.strip() for col in cols], row_delim) 104 | data.append(labeled_row.data) 105 | labels.append(labeled_row.label) 106 | except FileNotFoundError: 107 | print(f">> Error: The specified file [{filename}] does not exist.") 108 | sys.exit() 109 | except IOError: 110 | print("An IOError has occurred!") 111 | sys.exit() 112 | finally: 113 | if f is not None: 114 | f.close() 115 | 116 | return cls(data, labels, categories) 117 | 118 | def _find_dims(self, data, labels, dims=None) -> Union[tuple[int], None]: 119 | if dims is None: 120 | dims = [] 121 | if all([isinstance(data[i], list) for i in range(len(data))]): 122 | last = None 123 | 124 | for i in range(len(data)): 125 | curr = self._find_dims(data[i], labels[i], dims + [len(data)]) 126 | 127 | if i != 0 and last != curr: 128 | raise Exception( 129 | f"The inner dimensions of the data are different\nThe dimensions of {data[i - 1]} is different than the dimensions of {data[i]}" 130 | ) 131 | 132 | last = curr 133 | 134 | return last 135 | 136 | else: 137 | dims.append(len(data)) 138 | 139 | return tuple(dims) 140 | 141 | def find_min(self) -> Union[int, float]: 142 | """Return the minimum value in sublist of list.""" 143 | # Check if data is flat (list of numbers) or nested (list of lists) 144 | is_flat = all(not isinstance(item, list) for item in self.data) 145 | 146 | if is_flat: 147 | return min(self.data) 148 | else: 149 | return min(value for sublist in self.data for value in sublist) 150 | 151 | def find_max(self) -> Union[int, float]: 152 | """Return the maximum value in sublist of list.""" 153 | # Check if data is flat (list of numbers) or nested (list of lists) 154 | is_flat = all(not isinstance(item, list) for item in self.data) 155 | 156 | if is_flat: 157 | return max(self.data) 158 | else: 159 | return max(value for sublist in self.data for value in sublist) 160 | 161 | def find_min_label_length(self) -> int: 162 | """Return the minimum length for the labels.""" 163 | return min(len(label) for label in self.labels) 164 | 165 | def find_max_label_length(self) -> int: 166 | """Return the maximum length for the labels.""" 167 | return max(len(label) for label in self.labels) 168 | 169 | def __str__(self): 170 | """Returns the string representation of the data. 171 | :returns: The data in a tabular format 172 | """ 173 | 174 | maxlen_labels = max([len(label) for label in self.labels] + [len("Labels")]) + 1 175 | 176 | if len(self.categories) == 0: 177 | maxlen_data = max([len(str(data)) for data in self.data]) + 1 178 | 179 | else: 180 | maxlen_categories = max([len(category) for category in self.categories]) 181 | maxlen_data = ( 182 | max( 183 | [ 184 | len(str(self.data[i][j])) 185 | for i in range(len(self.data)) 186 | for j in range(len(self.categories)) 187 | ] 188 | ) 189 | + maxlen_categories 190 | + 4 191 | ) 192 | 193 | output = [ 194 | f"{' ' * (maxlen_labels - len('Labels'))}Labels | Data", 195 | f"{'-' * (maxlen_labels + 1)}|{'-' * (maxlen_data + 1)}", 196 | ] 197 | 198 | for i in range(len(self.data)): 199 | line = f"{' ' * (maxlen_labels - len(self.labels[i])) + self.labels[i]} |" 200 | 201 | if len(self.categories) == 0: 202 | line += f" {self.data[i]}" 203 | 204 | else: 205 | for j in range(len(self.categories)): 206 | if j == 0: 207 | line += f" ({self.categories[j]}) {self.data[i][0]}\n" 208 | 209 | else: 210 | line += f"{' ' * maxlen_labels} | ({self.categories[j]}) {self.data[i][j]}" 211 | line += ( 212 | "\n" 213 | if j < len(self.categories) - 1 214 | else f"\n{' ' * maxlen_labels} |" 215 | ) 216 | 217 | output.append(line) 218 | 219 | return "\n".join(output) 220 | 221 | def normalize(self, width: int) -> list: 222 | """Normalize the data and return it.""" 223 | # Check if data is flat (list of numbers) or nested (list of lists) 224 | is_flat = all(not isinstance(item, list) for item in self.data) 225 | 226 | if is_flat: 227 | # Handle flat list data 228 | min_datum = min(self.data) 229 | if min_datum < 0: 230 | min_datum = abs(min_datum) 231 | data_offset = [d + min_datum for d in self.data] 232 | else: 233 | data_offset = self.data 234 | 235 | min_datum = min(data_offset) 236 | max_datum = max(data_offset) 237 | 238 | if min_datum == max_datum: 239 | return data_offset 240 | 241 | norm_factor = width / float(max_datum) 242 | return [v * norm_factor for v in data_offset] 243 | else: 244 | # Handle nested list data (original logic) 245 | data_offset = [] 246 | min_datum = min(value for sublist in self.data for value in sublist) 247 | if min_datum < 0: 248 | min_datum = abs(min_datum) 249 | for datum in self.data: 250 | data_offset.append([d + min_datum for d in datum]) 251 | else: 252 | data_offset = self.data 253 | min_datum = min(value for sublist in data_offset for value in sublist) 254 | max_datum = max(value for sublist in data_offset for value in sublist) 255 | 256 | if min_datum == max_datum: 257 | return data_offset 258 | 259 | # max_dat / width is the value for a single tick. norm_factor is the 260 | # inverse of this value 261 | # If you divide a number to the value of single tick, you will find how 262 | # many ticks it does contain basically. 263 | norm_factor = width / float(max_datum) 264 | normal_data = [] 265 | for datum in data_offset: 266 | normal_data.append([v * norm_factor for v in datum]) 267 | 268 | return normal_data 269 | 270 | def __repr__(self): 271 | return f"Data(data={self.data if len(str(self.data)) < 25 else str(self.data)[:25] + '...'}, labels={self.labels}, categories={self.categories})" 272 | 273 | 274 | class _LabeledRow: 275 | """Internal helper class for parsing data rows with labels.""" 276 | def __init__(self, label: str, data: list[float]): 277 | self.label = label 278 | self.data = data 279 | 280 | 281 | def _label_row(row: list[str], delim: str) -> _LabeledRow: 282 | """Parse a row of data, extracting label and numeric values.""" 283 | data = [] 284 | labels: list[str] = [] 285 | labelling = False 286 | 287 | for text in row: 288 | datum = _maybe_float(text) 289 | if datum is None and not labels: 290 | labels.append(text) 291 | labelling = True 292 | elif datum is None and labelling: 293 | labels.append(text) 294 | elif datum is not None: 295 | data.append(datum) 296 | labelling = False 297 | else: 298 | raise ValueError(f"Multiple labels not allowed: {labels}, {text}") 299 | 300 | if labels: 301 | label = delim.join(labels) 302 | else: 303 | label = row[0] 304 | data.pop(0) 305 | 306 | return _LabeledRow(label=label, data=data) 307 | 308 | 309 | def _maybe_float(text: str) -> float | None: 310 | """Try to convert text to float, return None if not possible.""" 311 | try: 312 | return float(text) 313 | except ValueError: 314 | return None 315 | -------------------------------------------------------------------------------- /docs/chart-classes.md: -------------------------------------------------------------------------------- 1 | # Chart Classes API Documentation 2 | 3 | Termgraph provides several chart classes for different visualization needs. All chart classes inherit from the base `Chart` class and work with `Data` and `Args` objects. 4 | 5 | ## Base Classes 6 | 7 | ### Chart (Abstract Base Class) 8 | 9 | The `Chart` class is the foundation for all chart types. 10 | 11 | ```python 12 | from termgraph import Chart, Data, Args 13 | 14 | # Chart is abstract - use specific chart implementations 15 | # chart = Chart(data, args) # Don't do this 16 | ``` 17 | 18 | **Constructor Parameters:** 19 | - **data** (Data): Data object containing the values and labels 20 | - **args** (Args): Configuration arguments for the chart 21 | 22 | **Methods:** 23 | - `draw()`: Abstract method implemented by subclasses to render the chart 24 | 25 | ### Colors Class 26 | 27 | Provides predefined color constants for chart styling. 28 | 29 | ```python 30 | from termgraph import Colors 31 | 32 | # Available colors 33 | Colors.Black # Black color code 34 | Colors.Red # Red color code 35 | Colors.Green # Green color code 36 | Colors.Yellow # Yellow color code 37 | Colors.Blue # Blue color code 38 | Colors.Magenta # Magenta color code 39 | Colors.Cyan # Cyan color code 40 | ``` 41 | 42 | ## Chart Types 43 | 44 | ### BarChart 45 | 46 | Creates horizontal bar charts. Supports both single and multi-series data. 47 | 48 | ```python 49 | from termgraph import Data, BarChart, Args 50 | 51 | # Single series bar chart 52 | data = Data([23, 45, 56, 78, 32], ["A", "B", "C", "D", "E"]) 53 | chart = BarChart(data) 54 | chart.draw() 55 | ``` 56 | 57 | **Features:** 58 | - Horizontal bars with customizable width 59 | - Multi-series support with categories 60 | - Different scaling options 61 | - Color support 62 | - Value formatting 63 | 64 | #### Single Series Example 65 | 66 | ```python 67 | from termgraph import Data, BarChart, Args, Colors 68 | 69 | # Simple bar chart 70 | data = Data( 71 | data=[150, 230, 180, 290, 210], 72 | labels=["Jan", "Feb", "Mar", "Apr", "May"] 73 | ) 74 | 75 | args = Args( 76 | width=50, 77 | title="Monthly Sales", 78 | colors=[Colors.Green], 79 | suffix=" units" 80 | ) 81 | 82 | chart = BarChart(data, args) 83 | chart.draw() 84 | ``` 85 | 86 | Output: 87 | ``` 88 | # Monthly Sales 89 | 90 | Jan : ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 150.00 units 91 | Feb : ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 230.00 units 92 | Mar : ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 180.00 units 93 | Apr : ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 290.00 units 94 | May : ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 210.00 units 95 | ``` 96 | 97 | #### Multi-Series Example 98 | 99 | ```python 100 | from termgraph import Data, BarChart, Args, Colors 101 | 102 | # Multi-series bar chart 103 | data = Data( 104 | data=[ 105 | [120, 80], # Q1: Product A, Product B 106 | [150, 95], # Q2: Product A, Product B 107 | [180, 110], # Q3: Product A, Product B 108 | [200, 125] # Q4: Product A, Product B 109 | ], 110 | labels=["Q1", "Q2", "Q3", "Q4"], 111 | categories=["Product A", "Product B"] 112 | ) 113 | 114 | args = Args( 115 | width=40, 116 | title="Quarterly Sales by Product", 117 | colors=[Colors.Blue, Colors.Red], 118 | space_between=True 119 | ) 120 | 121 | chart = BarChart(data, args) 122 | chart.draw() 123 | ``` 124 | 125 | Output: 126 | ``` 127 | # Quarterly Sales by Product 128 | 129 | ▇ Product A ▇ Product B 130 | 131 | Q1: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 120.00 132 | ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 80.00 133 | 134 | Q2: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 150.00 135 | ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 95.00 136 | 137 | Q3: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 180.00 138 | ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 110.00 139 | 140 | Q4: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 200.00 141 | ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 125.00 142 | ``` 143 | 144 | #### Different Scale Example 145 | 146 | ```python 147 | from termgraph import Data, BarChart, Args, Colors 148 | 149 | # Different scales for each series 150 | data = Data( 151 | data=[ 152 | [1200, 45], # Revenue (thousands), Satisfaction (%) 153 | [1500, 52], 154 | [1800, 48], 155 | [2000, 58] 156 | ], 157 | labels=["Q1", "Q2", "Q3", "Q4"], 158 | categories=["Revenue ($K)", "Satisfaction (%)"] 159 | ) 160 | 161 | args = Args( 162 | width=30, 163 | different_scale=True, # Each series uses its own scale 164 | colors=[Colors.Green, Colors.Yellow], 165 | title="Revenue vs Customer Satisfaction" 166 | ) 167 | 168 | chart = BarChart(data, args) 169 | chart.draw() 170 | ``` 171 | 172 | ### StackedChart 173 | 174 | Creates stacked bar charts where multiple values are stacked on top of each other. 175 | 176 | ```python 177 | from termgraph import Data, StackedChart, Args, Colors 178 | 179 | # Stacked bar chart 180 | data = Data( 181 | data=[ 182 | [30, 20, 10], # Desktop, Mobile, Tablet 183 | [25, 30, 15], 184 | [20, 35, 20], 185 | [15, 40, 25] 186 | ], 187 | labels=["Q1", "Q2", "Q3", "Q4"], 188 | categories=["Desktop", "Mobile", "Tablet"] 189 | ) 190 | 191 | args = Args( 192 | width=50, 193 | title="Traffic Sources by Quarter", 194 | colors=[Colors.Blue, Colors.Green, Colors.Yellow] 195 | ) 196 | 197 | chart = StackedChart(data, args) 198 | chart.draw() 199 | ``` 200 | 201 | Output: 202 | ``` 203 | # Traffic Sources by Quarter 204 | 205 | ▇ Desktop ▇ Mobile ▇ Tablet 206 | 207 | Q1: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 60.00 208 | Q2: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 70.00 209 | Q3: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 75.00 210 | Q4: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 80.00 211 | ``` 212 | 213 | ### VerticalChart 214 | 215 | Creates vertical bar charts (column charts). 216 | 217 | ```python 218 | from termgraph import Data, VerticalChart, Args, Colors 219 | 220 | # Vertical bar chart 221 | data = Data( 222 | data=[23, 45, 56, 78, 32], 223 | labels=["A", "B", "C", "D", "E"] 224 | ) 225 | 226 | args = Args( 227 | width=40, 228 | title="Vertical Chart Example", 229 | colors=[Colors.Cyan] 230 | ) 231 | 232 | chart = VerticalChart(data, args) 233 | chart.draw() 234 | ``` 235 | 236 | Output: 237 | ``` 238 | # Vertical Chart Example 239 | 240 | ▇ 241 | ▇ 242 | ▇ ▇ 243 | ▇ ▇ 244 | ▇ ▇ ▇ ▇ 245 | ▇ ▇ ▇ ▇ 246 | ▇ ▇ ▇ ▇ ▇ 247 | ▇ ▇ ▇ ▇ ▇ 248 | ▇ ▇ ▇ ▇ ▇ 249 | ---------------- 250 | 23.00 45.00 56.00 78.00 32.00 251 | ---------------- 252 | A B C D E 253 | ``` 254 | 255 | ### HistogramChart 256 | 257 | Creates histogram charts that bin continuous data into ranges. 258 | 259 | ```python 260 | from termgraph import Data, HistogramChart, Args, Colors 261 | 262 | # Histogram chart 263 | # Note: For histograms, data should be the raw values you want to bin 264 | data = Data( 265 | data=[[12.5, 15.3, 18.7, 22.1, 25.6, 28.9, 32.4, 35.8, 38.2, 41.7]], 266 | labels=["Temperature Readings"] 267 | ) 268 | 269 | args = Args( 270 | width=40, 271 | bins=5, # Number of bins 272 | title="Temperature Distribution", 273 | colors=[Colors.Red] 274 | ) 275 | 276 | chart = HistogramChart(data, args) 277 | chart.draw() 278 | ``` 279 | 280 | Output: 281 | ``` 282 | # Temperature Distribution 283 | 284 | 12.0 – 18.0: ▇▇▇▇▇▇▇▇▇▇▇▇ 3.00 285 | 18.0 – 24.0: ▇▇▇▇▇▇▇▇ 2.00 286 | 24.0 – 30.0: ▇▇▇▇▇▇▇▇ 2.00 287 | 30.0 – 36.0: ▇▇▇▇▇▇▇▇ 2.00 288 | 36.0 – 42.0: ▇▇▇▇▇▇▇▇▇▇▇▇ 3.00 289 | ``` 290 | 291 | ## Configuration Options 292 | 293 | All chart classes accept an `Args` object for configuration. See [Args Class Documentation](args-class.md) for complete details. 294 | 295 | ### Common Options 296 | 297 | ```python 298 | from termgraph import Args, Colors 299 | 300 | args = Args( 301 | width=50, # Chart width in characters 302 | title="My Chart", # Chart title 303 | colors=[Colors.Blue], # Colors for series 304 | suffix=" units", # Value suffix 305 | format="{:<6.1f}", # Value formatting 306 | no_labels=False, # Hide labels 307 | no_values=False, # Hide values 308 | space_between=True # Add space between bars 309 | ) 310 | ``` 311 | 312 | ### Chart-Specific Options 313 | 314 | ```python 315 | # Bar chart specific 316 | args = Args( 317 | different_scale=True, # Use different scales for multi-series 318 | label_before=True # Show labels before bars 319 | ) 320 | 321 | # Histogram specific 322 | args = Args( 323 | bins=10 # Number of histogram bins 324 | ) 325 | 326 | # Vertical chart specific 327 | args = Args( 328 | vertical=True # Enable vertical mode 329 | ) 330 | ``` 331 | 332 | ## Advanced Examples 333 | 334 | ### Complex Multi-Series Chart 335 | 336 | ```python 337 | from termgraph import Data, BarChart, Args, Colors 338 | 339 | # Sales data across multiple regions and quarters 340 | data = Data( 341 | data=[ 342 | [150, 120, 90], # Q1: North, South, West 343 | [180, 140, 110], # Q2: North, South, West 344 | [200, 160, 130], # Q3: North, South, West 345 | [220, 180, 150] # Q4: North, South, West 346 | ], 347 | labels=["Q1 2023", "Q2 2023", "Q3 2023", "Q4 2023"], 348 | categories=["North Region", "South Region", "West Region"] 349 | ) 350 | 351 | args = Args( 352 | width=60, 353 | title="Regional Sales Performance", 354 | colors=[Colors.Blue, Colors.Green, Colors.Yellow], 355 | space_between=True, 356 | suffix="K", 357 | format="{:<5.0f}" 358 | ) 359 | 360 | chart = BarChart(data, args) 361 | chart.draw() 362 | ``` 363 | 364 | ### Percentage Data with Custom Formatting 365 | 366 | ```python 367 | from termgraph import Data, BarChart, Args, Colors 368 | 369 | # Percentage completion data 370 | data = Data( 371 | data=[0.65, 0.80, 0.45, 0.92, 0.73], 372 | labels=["Project A", "Project B", "Project C", "Project D", "Project E"] 373 | ) 374 | 375 | args = Args( 376 | width=40, 377 | title="Project Completion Status", 378 | colors=[Colors.Green], 379 | percentage=True, # Format as percentages 380 | format="{:<4.0f}", 381 | suffix="%" 382 | ) 383 | 384 | chart = BarChart(data, args) 385 | chart.draw() 386 | ``` 387 | 388 | ### Negative Values Handling 389 | 390 | ```python 391 | from termgraph import Data, BarChart, Args, Colors 392 | 393 | # Profit/Loss data with negative values 394 | data = Data( 395 | data=[-50, 30, -20, 80, 45, -15], 396 | labels=["Jan", "Feb", "Mar", "Apr", "May", "Jun"] 397 | ) 398 | 399 | args = Args( 400 | width=50, 401 | title="Monthly P&L", 402 | colors=[Colors.Red], # Single color for all bars 403 | suffix="K", 404 | format="{:<+6.0f}" # Show + for positive values 405 | ) 406 | 407 | chart = BarChart(data, args) 408 | chart.draw() 409 | ``` 410 | 411 | ## Error Handling 412 | 413 | Charts will handle common data issues gracefully: 414 | 415 | ```python 416 | from termgraph import Data, BarChart 417 | 418 | try: 419 | # Empty data 420 | data = Data([], []) 421 | chart = BarChart(data) 422 | chart.draw() 423 | except Exception as e: 424 | print(f"Error: {e}") 425 | 426 | try: 427 | # Mismatched dimensions 428 | data = Data([10, 20], ["A"]) 429 | chart = BarChart(data) 430 | chart.draw() 431 | except Exception as e: 432 | print(f"Error: {e}") 433 | ``` 434 | 435 | ## Integration Example 436 | 437 | Complete example showing how to use multiple chart types with the same data: 438 | 439 | ```python 440 | from termgraph import Data, BarChart, StackedChart, VerticalChart, Args, Colors 441 | 442 | # Sample data 443 | data = Data( 444 | data=[[30, 45], [25, 50], [40, 35], [35, 40]], 445 | labels=["Q1", "Q2", "Q3", "Q4"], 446 | categories=["Revenue", "Expenses"] 447 | ) 448 | 449 | args = Args( 450 | width=40, 451 | title="Financial Data", 452 | colors=[Colors.Green, Colors.Red] 453 | ) 454 | 455 | # Different chart types with same data 456 | print("=== Bar Chart ===") 457 | bar_chart = BarChart(data, args) 458 | bar_chart.draw() 459 | 460 | print("\n=== Stacked Chart ===") 461 | stacked_chart = StackedChart(data, args) 462 | stacked_chart.draw() 463 | 464 | print("\n=== Vertical Chart ===") 465 | vertical_chart = VerticalChart(data, args) 466 | vertical_chart.draw() 467 | ``` 468 | 469 | For more information about data preparation, see [Data Class Documentation](data-class.md). 470 | For configuration options, see [Args Class Documentation](args-class.md). -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Termgraph Python API Documentation 2 | 3 | Termgraph is a Python library for creating terminal-based charts and graphs. This documentation covers using termgraph as a Python module for programmatic chart generation. 4 | 5 | ## Quick Start 6 | 7 | ```python 8 | from termgraph import Data, BarChart, Args 9 | 10 | # Create data 11 | data = Data([23, 45, 56, 78, 32], ["A", "B", "C", "D", "E"]) 12 | 13 | # Create and display chart 14 | chart = BarChart(data) 15 | chart.draw() 16 | ``` 17 | 18 | ## Installation 19 | 20 | ```bash 21 | pip install termgraph 22 | ``` 23 | 24 | ## Core Components 25 | 26 | Termgraph consists of three main classes: 27 | 28 | 1. **[Data](data-class.md)** - Handles data storage, validation, and normalization 29 | 2. **[Chart Classes](chart-classes.md)** - Various chart types (Bar, Stacked, Vertical, Histogram) 30 | 3. **[Args](args-class.md)** - Configuration and styling options 31 | 32 | ## Basic Usage 33 | 34 | ### Simple Bar Chart 35 | 36 | ```python 37 | from termgraph import Data, BarChart 38 | 39 | # Sales data 40 | data = Data( 41 | data=[150, 230, 180, 290, 210], 42 | labels=["Jan", "Feb", "Mar", "Apr", "May"] 43 | ) 44 | 45 | chart = BarChart(data) 46 | chart.draw() 47 | ``` 48 | 49 | Output: 50 | ``` 51 | Jan: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 150.00 52 | Feb: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 230.00 53 | Mar: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 180.00 54 | Apr: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 290.00 55 | May: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 210.00 56 | ``` 57 | 58 | ### Styled Chart 59 | 60 | ```python 61 | from termgraph import Data, BarChart, Args, Colors 62 | 63 | data = Data( 64 | data=[150, 230, 180, 290, 210], 65 | labels=["Jan", "Feb", "Mar", "Apr", "May"] 66 | ) 67 | 68 | args = Args( 69 | width=60, 70 | title="Monthly Sales Report", 71 | colors=[Colors.Green], 72 | suffix=" units", 73 | format="{:<6.0f}" 74 | ) 75 | 76 | chart = BarChart(data, args) 77 | chart.draw() 78 | ``` 79 | 80 | Output: 81 | ``` 82 | # Monthly Sales Report 83 | 84 | Jan: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 150 units 85 | Feb: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 230 units 86 | Mar: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 180 units 87 | Apr: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 290 units 88 | May: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 210 units 89 | ``` 90 | 91 | ## Chart Types 92 | 93 | ### Bar Chart (Horizontal) 94 | 95 | Best for comparing values across categories. 96 | 97 | ```python 98 | from termgraph import Data, BarChart, Args, Colors 99 | 100 | # Single series 101 | data = Data([45, 32, 78, 56, 23], ["Product A", "Product B", "Product C", "Product D", "Product E"]) 102 | chart = BarChart(data, Args(colors=[Colors.Blue])) 103 | chart.draw() 104 | ``` 105 | 106 | ### Multi-Series Bar Chart 107 | 108 | Compare multiple data series side by side. 109 | 110 | ```python 111 | from termgraph import Data, BarChart, Args, Colors 112 | 113 | # Quarterly sales for two products 114 | data = Data( 115 | data=[ 116 | [120, 80], # Q1: Product A, Product B 117 | [150, 95], # Q2: Product A, Product B 118 | [180, 110], # Q3: Product A, Product B 119 | [200, 125] # Q4: Product A, Product B 120 | ], 121 | labels=["Q1", "Q2", "Q3", "Q4"], 122 | categories=["Product A", "Product B"] 123 | ) 124 | 125 | args = Args( 126 | title="Quarterly Sales Comparison", 127 | colors=[Colors.Blue, Colors.Red], 128 | width=50 129 | ) 130 | 131 | chart = BarChart(data, args) 132 | chart.draw() 133 | ``` 134 | 135 | ### Stacked Bar Chart 136 | 137 | Show parts of a whole. 138 | 139 | ```python 140 | from termgraph import Data, StackedChart, Args, Colors 141 | 142 | # Budget breakdown 143 | data = Data( 144 | data=[ 145 | [30, 20, 10], # Q1: Marketing, Development, Operations 146 | [35, 25, 15], # Q2: Marketing, Development, Operations 147 | [40, 30, 20], # Q3: Marketing, Development, Operations 148 | [45, 35, 25] # Q4: Marketing, Development, Operations 149 | ], 150 | labels=["Q1", "Q2", "Q3", "Q4"], 151 | categories=["Marketing", "Development", "Operations"] 152 | ) 153 | 154 | args = Args( 155 | title="Budget Allocation by Quarter", 156 | colors=[Colors.Green, Colors.Blue, Colors.Yellow], 157 | suffix="K" 158 | ) 159 | 160 | chart = StackedChart(data, args) 161 | chart.draw() 162 | ``` 163 | 164 | ### Vertical Chart (Column Chart) 165 | 166 | Good for time series or when you have many categories. 167 | 168 | ```python 169 | from termgraph import Data, VerticalChart, Args, Colors 170 | 171 | data = Data([23, 45, 56, 78, 32, 67, 45], ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]) 172 | 173 | args = Args( 174 | title="Daily Website Visits", 175 | colors=[Colors.Cyan], 176 | width=40 177 | ) 178 | 179 | chart = VerticalChart(data, args) 180 | chart.draw() 181 | ``` 182 | 183 | ### Histogram 184 | 185 | Show distribution of continuous data. 186 | 187 | ```python 188 | from termgraph import Data, HistogramChart, Args, Colors 189 | 190 | # Temperature readings that will be binned 191 | data = Data( 192 | data=[[15.2, 18.7, 22.1, 19.5, 25.3, 28.9, 24.4, 21.8, 26.2, 23.7, 20.1, 17.6]], 193 | labels=["Temperature Data"] 194 | ) 195 | 196 | args = Args( 197 | title="Temperature Distribution", 198 | bins=6, 199 | colors=[Colors.Red], 200 | width=50 201 | ) 202 | 203 | chart = HistogramChart(data, args) 204 | chart.draw() 205 | ``` 206 | 207 | ## Advanced Examples 208 | 209 | ### Financial Dashboard 210 | 211 | ```python 212 | from termgraph import Data, BarChart, StackedChart, Args, Colors 213 | 214 | # Revenue data 215 | revenue_data = Data( 216 | data=[ 217 | [450, 380, 290], # Q1: Americas, EMEA, APAC 218 | [520, 420, 340], # Q2: Americas, EMEA, APAC 219 | [480, 450, 380], # Q3: Americas, EMEA, APAC 220 | [600, 480, 420] # Q4: Americas, EMEA, APAC 221 | ], 222 | labels=["Q1 2023", "Q2 2023", "Q3 2023", "Q4 2023"], 223 | categories=["Americas", "EMEA", "APAC"] 224 | ) 225 | 226 | # Revenue by region (stacked) 227 | print("=== Revenue by Region (Stacked) ===") 228 | stacked_args = Args( 229 | title="Quarterly Revenue by Region", 230 | colors=[Colors.Blue, Colors.Green, Colors.Yellow], 231 | width=60, 232 | suffix="K USD", 233 | format="{:<5.0f}" 234 | ) 235 | stacked_chart = StackedChart(revenue_data, stacked_args) 236 | stacked_chart.draw() 237 | 238 | # Regional comparison (side by side) 239 | print("\n=== Regional Performance Comparison ===") 240 | bar_args = Args( 241 | title="Regional Performance by Quarter", 242 | colors=[Colors.Blue, Colors.Green, Colors.Yellow], 243 | width=60, 244 | suffix="K USD", 245 | space_between=True 246 | ) 247 | bar_chart = BarChart(revenue_data, bar_args) 248 | bar_chart.draw() 249 | ``` 250 | 251 | ### Performance Metrics Dashboard 252 | 253 | ```python 254 | from termgraph import Data, BarChart, Args, Colors 255 | 256 | # Performance metrics with different scales 257 | performance_data = Data( 258 | data=[ 259 | [95.5, 1250], # Uptime %, Response Time (ms) 260 | [98.2, 980], 261 | [97.1, 1100], 262 | [99.1, 850], 263 | [96.8, 1300] 264 | ], 265 | labels=["Week 1", "Week 2", "Week 3", "Week 4", "Week 5"], 266 | categories=["Uptime (%)", "Response Time (ms)"] 267 | ) 268 | 269 | args = Args( 270 | title="System Performance Metrics", 271 | colors=[Colors.Green, Colors.Red], 272 | different_scale=True, # Use different scales for each metric 273 | width=50, 274 | space_between=True 275 | ) 276 | 277 | chart = BarChart(performance_data, args) 278 | chart.draw() 279 | ``` 280 | 281 | ### Project Status Tracking 282 | 283 | ```python 284 | from termgraph import Data, BarChart, Args, Colors 285 | 286 | # Project completion percentages 287 | projects_data = Data( 288 | data=[0.85, 0.62, 0.93, 0.78, 0.45, 0.91], 289 | labels=["Website Redesign", "Mobile App", "Database Migration", "API v2", "Documentation", "Testing Suite"] 290 | ) 291 | 292 | args = Args( 293 | title="Project Completion Status", 294 | colors=[Colors.Green], 295 | percentage=True, 296 | width=50, 297 | format="{:<3.0f}", 298 | suffix="%" 299 | ) 300 | 301 | chart = BarChart(projects_data, args) 302 | chart.draw() 303 | ``` 304 | 305 | ### Sales Trend Analysis 306 | 307 | ```python 308 | from termgraph import Data, VerticalChart, BarChart, Args, Colors 309 | 310 | # Monthly sales trend 311 | monthly_sales = Data( 312 | data=[120, 135, 150, 145, 160, 175, 185, 170, 190, 200, 195, 210], 313 | labels=["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] 314 | ) 315 | 316 | # Vertical chart for trend visualization 317 | print("=== Monthly Sales Trend ===") 318 | vertical_args = Args( 319 | title="2023 Sales Trend", 320 | colors=[Colors.Blue], 321 | width=60 322 | ) 323 | vertical_chart = VerticalChart(monthly_sales, vertical_args) 324 | vertical_chart.draw() 325 | 326 | # Horizontal chart for precise values 327 | print("\n=== Detailed Monthly Sales ===") 328 | bar_args = Args( 329 | title="2023 Monthly Sales Details", 330 | colors=[Colors.Green], 331 | width=50, 332 | suffix="K USD", 333 | format="{:<5.0f}" 334 | ) 335 | bar_chart = BarChart(monthly_sales, bar_args) 336 | bar_chart.draw() 337 | ``` 338 | 339 | ## Data Formats 340 | 341 | ### Flat Data (Single Series) 342 | 343 | ```python 344 | # Simple list of numbers 345 | data = Data([10, 20, 30, 40], ["A", "B", "C", "D"]) 346 | ``` 347 | 348 | ### Nested Data (Multi-Series) 349 | 350 | ```python 351 | # List of lists for multiple data series 352 | data = Data( 353 | data=[ 354 | [10, 15, 20], # Row 1: three values 355 | [25, 30, 35], # Row 2: three values 356 | [40, 45, 50] # Row 3: three values 357 | ], 358 | labels=["Category 1", "Category 2", "Category 3"], 359 | categories=["Series A", "Series B", "Series C"] 360 | ) 361 | ``` 362 | 363 | ### Working with Real Data 364 | 365 | ```python 366 | import csv 367 | from termgraph import Data, BarChart, Args, Colors 368 | 369 | # Reading from CSV (example) 370 | def load_csv_data(filename): 371 | """Load data from CSV file.""" 372 | data = [] 373 | labels = [] 374 | 375 | with open(filename, 'r') as f: 376 | reader = csv.reader(f) 377 | next(reader) # Skip header 378 | 379 | for row in reader: 380 | labels.append(row[0]) 381 | data.append(float(row[1])) 382 | 383 | return Data(data, labels) 384 | 385 | # Usage 386 | # data = load_csv_data('sales_data.csv') 387 | # chart = BarChart(data, Args(title="Sales Data", colors=[Colors.Blue])) 388 | # chart.draw() 389 | ``` 390 | 391 | ## Error Handling 392 | 393 | ```python 394 | from termgraph import Data, BarChart 395 | 396 | try: 397 | # This will raise an exception - mismatched dimensions 398 | data = Data([10, 20, 30], ["A", "B"]) 399 | chart = BarChart(data) 400 | chart.draw() 401 | except Exception as e: 402 | print(f"Error creating chart: {e}") 403 | 404 | try: 405 | # This will raise an exception - empty data 406 | data = Data([], []) 407 | chart = BarChart(data) 408 | chart.draw() 409 | except Exception as e: 410 | print(f"Error with empty data: {e}") 411 | ``` 412 | 413 | ## Best Practices 414 | 415 | ### 1. Choose the Right Chart Type 416 | 417 | - **Bar Chart**: Comparing categories or values 418 | - **Stacked Chart**: Showing parts of a whole 419 | - **Vertical Chart**: Time series or many categories 420 | - **Histogram**: Distribution of continuous data 421 | 422 | ### 2. Use Appropriate Colors 423 | 424 | ```python 425 | # Good: Different colors for different series 426 | args = Args(colors=[Colors.Blue, Colors.Green, Colors.Red]) 427 | 428 | # Good: Single color for single series 429 | args = Args(colors=[Colors.Blue]) 430 | 431 | # Avoid: Too many similar colors 432 | ``` 433 | 434 | ### 3. Format Values Appropriately 435 | 436 | ```python 437 | # For percentages 438 | args = Args(percentage=True, format="{:<3.0f}", suffix="%") 439 | 440 | # For currency 441 | args = Args(format="{:<6.2f}", suffix=" USD") 442 | 443 | # For large numbers 444 | args = Args(suffix="K", format="{:<5.0f}") # Use K, M, etc. 445 | ``` 446 | 447 | ### 4. Set Appropriate Width 448 | 449 | ```python 450 | # Narrow for simple data 451 | args = Args(width=30) 452 | 453 | # Wide for detailed data 454 | args = Args(width=80) 455 | 456 | # Consider terminal width limitations 457 | ``` 458 | 459 | ## API Reference 460 | 461 | - **[Data Class](data-class.md)** - Data handling and normalization 462 | - **[Chart Classes](chart-classes.md)** - All available chart types 463 | - **[Args Class](args-class.md)** - Configuration options 464 | 465 | ## Examples Repository 466 | 467 | For more examples and use cases, see the test files in the repository: 468 | - Basic usage examples 469 | - Complex multi-series charts 470 | - Real-world data scenarios 471 | - Integration patterns 472 | 473 | ## Contributing 474 | 475 | If you find issues or want to contribute improvements to the API documentation, please visit the [GitHub repository](https://github.com/mkaz/termgraph). -------------------------------------------------------------------------------- /termgraph/chart.py: -------------------------------------------------------------------------------- 1 | """Chart classes for termgraph - handles chart rendering and display.""" 2 | 3 | from __future__ import annotations 4 | import math 5 | import sys 6 | from typing import Union, List, Tuple 7 | from itertools import zip_longest 8 | from colorama import just_fix_windows_console 9 | from .constants import TICK, SM_TICK, AVAILABLE_COLORS 10 | from .utils import cvt_to_readable, print_row_core 11 | from .data import Data 12 | from .args import Args 13 | 14 | # colorama 15 | just_fix_windows_console() 16 | 17 | def format_value( 18 | value: Union[int, float], format_str_arg, percentage_arg, suffix_arg 19 | ) -> str: 20 | """Format a value consistently across chart types.""" 21 | # Handle type conversions and defaults 22 | if format_str_arg is None or not isinstance(format_str_arg, str): 23 | format_str = "{:<5.2f}" 24 | else: 25 | format_str = format_str_arg 26 | 27 | if percentage_arg is None or not isinstance(percentage_arg, bool): 28 | percentage = False 29 | else: 30 | percentage = percentage_arg 31 | 32 | if suffix_arg is None or not isinstance(suffix_arg, str): 33 | suffix = "" 34 | else: 35 | suffix = suffix_arg 36 | 37 | formatted_val = format_str.format(value) 38 | 39 | if percentage and "%" not in formatted_val: 40 | try: 41 | # Convert to percentage 42 | numeric_value = float(formatted_val) 43 | formatted_val = f"{numeric_value * 100:.0f}%" 44 | except ValueError: 45 | # If conversion fails, just add % suffix 46 | formatted_val += "%" 47 | 48 | return f" {formatted_val}{suffix}" 49 | 50 | 51 | class Colors: 52 | """Class representing available color values for graphs.""" 53 | 54 | Black = AVAILABLE_COLORS["black"] 55 | Red = AVAILABLE_COLORS["red"] 56 | Green = AVAILABLE_COLORS["green"] 57 | Yellow = AVAILABLE_COLORS["yellow"] 58 | Blue = AVAILABLE_COLORS["blue"] 59 | Magenta = AVAILABLE_COLORS["magenta"] 60 | Cyan = AVAILABLE_COLORS["cyan"] 61 | 62 | 63 | class Chart: 64 | """Class representing a chart""" 65 | 66 | def __init__(self, data: Data, args: Args): 67 | """Initialize the chart 68 | 69 | :data: The data to be displayed on the chart 70 | :args: The arguments for the chart 71 | 72 | """ 73 | 74 | self.data = data 75 | self.args = args 76 | self.normal_data = self._normalize() 77 | 78 | def draw(self) -> None: 79 | """Draw the chart with the given data""" 80 | 81 | raise NotImplementedError() 82 | 83 | def _print_header(self) -> None: 84 | title = self.args.get_arg("title") 85 | 86 | custom_tick = self.args.get_arg("custom_tick") 87 | tick = custom_tick if isinstance(custom_tick, str) and custom_tick else TICK 88 | 89 | if title is not None: 90 | print("") 91 | print(f"# {title}\n") 92 | 93 | if len(self.data.categories) > 0: 94 | colors = self.args.get_arg("colors") 95 | 96 | # Print categories' names above the graph. 97 | for i in range(len(self.data.categories)): 98 | if colors is not None and isinstance(colors, list): 99 | sys.stdout.write(f"\033[{colors[i]}m") # Start to write colorized. 100 | 101 | sys.stdout.write(f"{tick} {self.data.categories[i]} ") 102 | if colors: 103 | sys.stdout.write("\033[0m") # Back to original. 104 | 105 | print("\n") 106 | 107 | def _normalize(self) -> list[list[float]]: 108 | """Normalize the data and return it.""" 109 | width = self.args.get_arg("width") 110 | if not isinstance(width, int): 111 | width = 50 # Default width 112 | return self.data.normalize(width) 113 | 114 | 115 | class HorizontalChart(Chart): 116 | """Class representing a horizontal chart""" 117 | 118 | def __init__(self, data: Data, args: Args = Args()): 119 | """Initialize the chart 120 | 121 | :data: The data to be displayed on the chart 122 | :args: The arguments for the chart 123 | 124 | """ 125 | 126 | super().__init__(data, args) 127 | 128 | def print_row( 129 | self, 130 | value: Union[int, float], 131 | num_blocks: Union[int, float], 132 | val_min: Union[int, float], 133 | color: Union[int, None], 134 | label: str = "", 135 | tail: str = "", 136 | ) -> None: 137 | """A method to print a row for a horizontal graphs. 138 | i.e: 139 | 1: ▇▇ 2 140 | 2: ▇▇▇ 3 141 | 3: ▇▇▇▇ 4 142 | """ 143 | doprint = self.args.get_arg("label_before") and not self.args.get_arg( 144 | "vertical" 145 | ) 146 | 147 | if doprint: 148 | print(label, tail, " ", end="") 149 | 150 | # Get custom tick if set, otherwise use default TICK 151 | custom_tick = self.args.get_arg("custom_tick") 152 | tick = custom_tick if isinstance(custom_tick, str) and custom_tick else TICK 153 | 154 | print_row_core( 155 | value=float(value), 156 | num_blocks=int(num_blocks), 157 | val_min=float(val_min), 158 | color=color, 159 | zero_as_small_tick=bool(self.args.get_arg("label_before")), 160 | tick=tick, 161 | ) 162 | 163 | if doprint: 164 | print() 165 | 166 | 167 | class BarChart(HorizontalChart): 168 | """Class representing a bar chart""" 169 | 170 | def __init__(self, data: Data, args: Args = Args()): 171 | """Initialize the bar chart 172 | 173 | :data: The data to be displayed on the chart 174 | :args: The arguments for the chart 175 | 176 | """ 177 | 178 | super().__init__(data, args) 179 | 180 | def _normalize(self) -> list[list[float]]: 181 | """Normalize the data and return it.""" 182 | if self.args.get_arg("different_scale"): 183 | # Normalization per category 184 | normal_data: List[List[float]] = [[] for _ in range(len(self.data.data))] 185 | width = self.args.get_arg("width") 186 | if not isinstance(width, int): 187 | width = 50 # Default width 188 | 189 | if self.data.dims and len(self.data.dims) > 1: 190 | for i in range(self.data.dims[1]): 191 | cat_data = [[dat[i]] for dat in self.data.data] 192 | 193 | # Create temporary Data object for category data 194 | from .data import Data 195 | temp_data = Data(cat_data, [f"cat_{j}" for j in range(len(cat_data))]) 196 | normal_cat_data = temp_data.normalize(width) 197 | 198 | for row_idx, norm_val in enumerate(normal_cat_data): 199 | normal_data[row_idx].append(norm_val[0]) 200 | return normal_data 201 | else: 202 | return super()._normalize() 203 | 204 | def draw(self) -> None: 205 | """Draws the chart""" 206 | self._print_header() 207 | 208 | colors = ( 209 | self.args.get_arg("colors") 210 | if self.args.get_arg("colors") is not None 211 | else [None] 212 | * (self.data.dims[1] if self.data.dims and len(self.data.dims) > 1 else 1) 213 | ) 214 | 215 | val_min = self.data.find_min() 216 | 217 | for i in range(len(self.data.labels)): 218 | if self.args.get_arg("no_labels"): 219 | # Hide the labels. 220 | label = "" 221 | else: 222 | if self.args.get_arg("label_before"): 223 | fmt = "{:<{x}}" 224 | else: 225 | fmt = "{:<{x}}: " 226 | 227 | label = fmt.format( 228 | self.data.labels[i], x=self.data.find_max_label_length() 229 | ) 230 | 231 | values = self.data.data[i] 232 | num_blocks = self.normal_data[i] 233 | 234 | # Handle both flat data (numbers) and nested data (lists) 235 | if not isinstance(values, list): 236 | # Flat data: convert single value to list 237 | values = [values] 238 | if isinstance(num_blocks, list): 239 | num_blocks = num_blocks 240 | else: 241 | num_blocks = [num_blocks] 242 | 243 | if self.args.get_arg("space_between") and i != 0: 244 | print() 245 | 246 | for j in range(len(values)): 247 | # In Multiple series graph 1st category has label at the beginning, 248 | # whereas the rest categories have only spaces. 249 | if j > 0: 250 | len_label = len(label) 251 | label = " " * len_label 252 | 253 | if self.args.get_arg("label_before"): 254 | fmt = "{}{}{}" 255 | 256 | else: 257 | fmt = " {}{}{}" 258 | 259 | if self.args.get_arg("no_values"): 260 | tail = self.args.get_arg("suffix") 261 | 262 | else: 263 | val, deg = cvt_to_readable( 264 | values[j], self.args.get_arg("percentage") 265 | ) 266 | format_str = self.args.get_arg("format") 267 | if isinstance(format_str, str): 268 | formatted_val = format_str.format(val) 269 | else: 270 | formatted_val = f"{val:<5.2f}" # Default format 271 | tail = fmt.format( 272 | formatted_val, 273 | deg, 274 | self.args.get_arg("suffix"), 275 | ) 276 | 277 | if colors and isinstance(colors, list) and j < len(colors): 278 | color = colors[j] 279 | else: 280 | color = None 281 | 282 | if not self.args.get_arg("label_before") and not self.args.get_arg( 283 | "vertical" 284 | ): 285 | print(label, end="") 286 | 287 | self.print_row( 288 | value=values[j], 289 | num_blocks=int(num_blocks[j]), 290 | val_min=val_min, 291 | color=color, 292 | label=label, 293 | tail=str(tail) if tail is not None else "", 294 | ) 295 | 296 | if not self.args.get_arg("label_before") and not self.args.get_arg( 297 | "vertical" 298 | ): 299 | print(tail) 300 | 301 | 302 | class StackedChart(HorizontalChart): 303 | """Class representing a stacked bar chart""" 304 | 305 | def __init__(self, data: Data, args: Args = Args()): 306 | """Initialize the stacked chart 307 | 308 | :data: The data to be displayed on the chart 309 | :args: The arguments for the chart 310 | """ 311 | super().__init__(data, args) 312 | 313 | def draw(self) -> None: 314 | """Draws the stacked chart""" 315 | self._print_header() 316 | 317 | colors_arg = self.args.get_arg("colors") 318 | if isinstance(colors_arg, list): 319 | colors = colors_arg 320 | else: 321 | colors = [None] * ( 322 | self.data.dims[1] if self.data.dims and len(self.data.dims) > 1 else 1 323 | ) 324 | 325 | val_min = self.data.find_min() 326 | normal_data = self._normalize() 327 | 328 | # Get custom tick if set, otherwise use default TICK 329 | custom_tick = self.args.get_arg("custom_tick") 330 | tick = custom_tick if isinstance(custom_tick, str) and custom_tick else TICK 331 | 332 | for i in range(len(self.data.labels)): 333 | if self.args.get_arg("no_labels"): 334 | # Hide the labels. 335 | label = "" 336 | else: 337 | label = f"{self.data.labels[i]:<{self.data.find_max_label_length()}}: " 338 | 339 | if self.args.get_arg("space_between") and i != 0: 340 | print() 341 | 342 | print(label, end="") 343 | 344 | values = self.data.data[i] 345 | num_blocks = normal_data[i] 346 | 347 | for j in range(len(values)): 348 | print_row_core( 349 | value=values[j], 350 | num_blocks=int(num_blocks[j]), 351 | val_min=val_min, 352 | color=colors[j] if j < len(colors) else None, 353 | zero_as_small_tick=False, 354 | tick=tick, 355 | ) 356 | 357 | if self.args.get_arg("no_values"): 358 | # Hide the values. 359 | tail = "" 360 | else: 361 | tail = format_value( 362 | sum(values), 363 | self.args.get_arg("format"), 364 | self.args.get_arg("percentage"), 365 | self.args.get_arg("suffix"), 366 | ) 367 | 368 | print(tail) 369 | 370 | 371 | class VerticalChart(Chart): 372 | """Class representing a vertical chart""" 373 | 374 | def __init__(self, data: Data, args: Args = Args()): 375 | """Initialize the vertical chart""" 376 | super().__init__(data, args) 377 | self.value_list: list[str] = [] 378 | self.zipped_list: list[tuple[str, ...]] = [] 379 | self.vertical_list: list[str] = [] 380 | self.maxi = 0 381 | 382 | def _prepare_vertical(self, value: float, num_blocks: int): 383 | """Prepare the vertical graph data.""" 384 | self.value_list.append(str(value)) 385 | 386 | if self.maxi < num_blocks: 387 | self.maxi = num_blocks 388 | 389 | if num_blocks > 0: 390 | self.vertical_list.append((TICK * num_blocks)) 391 | else: 392 | self.vertical_list.append(SM_TICK) 393 | 394 | def draw(self) -> None: 395 | """Draws the vertical chart""" 396 | self._print_header() 397 | 398 | colors = self.args.get_arg("colors") 399 | color = colors[0] if colors and isinstance(colors, list) else None 400 | 401 | for i in range(len(self.data.labels)): 402 | values = self.data.data[i] 403 | num_blocks = self.normal_data[i] 404 | for j in range(len(values)): 405 | self._prepare_vertical(values[j], int(num_blocks[j])) 406 | 407 | # Zip_longest method in order to turn them vertically. 408 | for row in zip_longest(*self.vertical_list, fillvalue=" "): 409 | self.zipped_list.append(row) 410 | 411 | result_list: List[Tuple[str, ...]] = [] 412 | 413 | if self.zipped_list: 414 | counter = 0 415 | width = self.args.get_arg("width") 416 | if not isinstance(width, int): 417 | width = 50 # Default width 418 | 419 | # Combined with the maxi variable, escapes the appending method at 420 | # the correct point or the default one (width). 421 | for row in reversed(self.zipped_list): 422 | result_list.append(row) 423 | counter += 1 424 | 425 | if self.maxi == width: 426 | if counter == width: 427 | break 428 | else: 429 | if counter == self.maxi: 430 | break 431 | 432 | if color: 433 | sys.stdout.write(f"\033[{color}m") 434 | 435 | for row in result_list: 436 | print(*row) 437 | 438 | sys.stdout.write("\033[0m") 439 | 440 | if result_list and not self.args.get_arg("no_values"): 441 | print("-" * len(result_list[0]) * 2) 442 | print(" ".join(self.value_list)) 443 | 444 | if result_list and not self.args.get_arg("no_labels"): 445 | print("-" * len(result_list[0]) * 2) 446 | # Print Labels 447 | labels = self.data.labels 448 | if labels: 449 | print(" ".join(labels)) 450 | 451 | 452 | class HistogramChart(Chart): 453 | """Class representing a histogram chart""" 454 | 455 | def __init__(self, data: Data, args: Args = Args()): 456 | """Initialize the histogram chart 457 | 458 | :data: The data to be displayed on the chart 459 | :args: The arguments for the chart 460 | """ 461 | super().__init__(data, args) 462 | 463 | def draw(self) -> None: 464 | """Draws the histogram chart""" 465 | self._print_header() 466 | 467 | colors_arg = self.args.get_arg("colors") 468 | if isinstance(colors_arg, list): 469 | colors = colors_arg 470 | else: 471 | colors = [None] 472 | 473 | val_min = self.data.find_min() 474 | val_max = self.data.find_max() 475 | 476 | # Calculate borders 477 | class_min = math.floor(val_min) 478 | class_max = math.ceil(val_max) 479 | class_range = class_max - class_min 480 | bins_arg = self.args.get_arg("bins") 481 | if isinstance(bins_arg, int): 482 | bins_count = bins_arg 483 | else: 484 | bins_count = 5 # default 485 | class_width = class_range / bins_count 486 | 487 | border = float(class_min) 488 | borders = [] 489 | max_len = len(str(border)) 490 | 491 | for b in range(bins_count + 1): 492 | borders.append(border) 493 | len_border = len(str(border)) 494 | if len_border > max_len: 495 | max_len = len_border 496 | border += class_width 497 | border = round(border, 1) 498 | 499 | # Count num of data via border 500 | count_list = [] 501 | 502 | for start, end in zip(borders[:-1], borders[1:]): 503 | count = 0 504 | # Count values in this bin range 505 | for row in self.data.data: 506 | for v in row: # Handle multi-dimensional data 507 | if start <= v < end: 508 | count += 1 509 | 510 | count_list.append([count]) 511 | 512 | # Handle the case where the maximum value is exactly equal to the upper border 513 | # Calculate total number of max values and add them to the last bin 514 | count = sum(1 for row in self.data.data for v in row if v == class_max) 515 | count_list[-1][0] += count 516 | 517 | width_arg = self.args.get_arg("width") 518 | if isinstance(width_arg, int): 519 | width = width_arg 520 | else: 521 | width = 50 # default 522 | 523 | # Create temporary Data object for count data 524 | from .data import Data 525 | temp_data = Data(count_list, [f"bin_{i}" for i in range(len(count_list))]) 526 | normal_counts = temp_data.normalize(width) 527 | 528 | # Get custom tick if set, otherwise use default TICK 529 | custom_tick = self.args.get_arg("custom_tick") 530 | tick = custom_tick if isinstance(custom_tick, str) and custom_tick else TICK 531 | 532 | for i, (start_border, end_border) in enumerate(zip(borders[:-1], borders[1:])): 533 | if colors and colors[0]: 534 | color = colors[0] 535 | else: 536 | color = None 537 | 538 | if not self.args.get_arg("no_labels"): 539 | print(f"{start_border:{max_len}} – {end_border:{max_len}}: ", end="") 540 | 541 | num_blocks = normal_counts[i] 542 | 543 | print_row_core( 544 | value=count_list[i][0], 545 | num_blocks=int(num_blocks[0]), 546 | val_min=0, # Histogram always starts from 0 547 | color=color, 548 | zero_as_small_tick=False, 549 | tick=tick, 550 | ) 551 | 552 | if self.args.get_arg("no_values"): 553 | tail = "" 554 | else: 555 | tail = format_value( 556 | count_list[i][0], 557 | self.args.get_arg("format"), 558 | self.args.get_arg("percentage"), 559 | self.args.get_arg("suffix"), 560 | ) 561 | print(tail) 562 | --------------------------------------------------------------------------------