Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Shell: How can I make a text-mode bar chart from parsed data (numbers)?

Tags:

bash

charts

I am developing a Linux's Bash shell script that extracts data from a text file leaving only the numbers. These are my example parsed data:

3
4
4
5
6
7
8
8
9
11

I would like to create a simple text-mode bar chart like this one, but corresponding to these values:

Bar chart

Details:

  • I need the graphic chart to be vertical.
  • The first numbers should appear on the left, latest on the right.
  • A n (parsed number) characters high column is appropriate for me. So the first bar on the left in my example should be 3 characters high, the second 4, the third 4, the fourth 5, and so on.

More precisely, for this example, something (using the character) like:

         █
         █
        ██ 
       ███
      ████
     █████
    ██████
   ███████
 █████████
██████████
██████████
██████████

Note the 3 characters high for the first (left) column and 11 characters high for the last (right) column.
Same example with $ characters, to make it more readable:

         $
         $
        $$
       $$$
      $$$$
     $$$$$
    $$$$$$
   $$$$$$$
 $$$$$$$$$
$$$$$$$$$$
$$$$$$$$$$
$$$$$$$$$$

The closest I know of is my method for progress bar until now, that I have used in another scripts:

printf "\033[48;5;21m"   # Blue background color
for i in $(seq 1 $n); do printf " "; done   # Create bar using blue spaces

This is: filling each line printing a bar with n spaces. But this bar is horizontal, so it is not appropriate in this case.

I request some core loop example ideas to create this bar chart.

Under the suggestion of user Boardrider, solutions based on any unix-like tools are accepted. Solutions based on scripting languages (like Perl or Python) for the Linux shell are accepted too, as long as they use to be implemented on many devices.

like image 783
Sopalajo de Arrierez Avatar asked Jun 19 '15 02:06

Sopalajo de Arrierez


2 Answers

Here is a first and naive attempt... It's not a very efficient solution as data are parsed many times, but may it helps. In a way it's the first loop idea suggested by @Walter_A.

#!/bin/sh
#
## building a vertical bar graph of data file
## https://stackoverflow.com/q/30929012
##
## 1. required. Data file with one value per line and nothing else!
## /!\ provide the (relative or absolute) file path, not file content
: ${1:?" Please provide a file name"}
test -e "$1" || { echo "Sorry, can't find $1" 1>&2 ; exit 2 ; }
test -r "$1" || { echo "Sorry, can't access $1" 1>&2 ; exit 2 ; }
test -f "$1" || { echo "Sorry, bad format file $1" 1>&2 ; exit 2 ; }
test $( grep -cv '^[0-9][0-9]*$' "$1" 2>/dev/null ) -ne 0 || { echo "Sorry, bad data in $1" 1>&2 ; exit 3 ; }
# setting characters
## 2. optional. Ploting character (default is Dollar sign)
## /!\ for blank color use "\033[48;5;21m \033[0m" or you'll mess...
c_dot="$2"
: ${c_dot:='$'}
## 3. optional. Separator characher (default is Dash sign)
## /!\ as Space is not tested there will be extra characters...
c_sep="$3"
: ${c_sep:='-'}
# init...
len_w=$(wc -l "$1" | cut -d ' ' -f 1 )
l_sep=''
while test "$len_w" -gt 0
do
        l_sep="${l_sep}${c_sep}";
        len_w=$(($len_w-1))
done
unset len_w
# part1: chart
echo ".${c_sep}${l_sep}${c_sep}."
len_h=$(sort -n "$1" | tail -n 1)
nbr_d=${#len_h}
while test "$len_h" -gt 0
do
        printf '| '
        for a_val in $(cat "$1")
        do
                test "$a_val" -ge "$len_h" && printf "$c_dot" || printf ' '
        done
        echo ' |'
        len_h=$(($len_h-1))
done
unset len_h
# part2: legend
echo "|${c_sep}${l_sep}${c_sep}|"
while test "$nbr_d" -gt 0
do
        printf '| '
        for a_val in $(cat "$1")
        do
                printf "%1s" $(echo "$a_val" | cut -c "$nbr_d")
        done
        echo ' |'
        nbr_d=$(($nbr_d-1))
done
unset nbr_d
# end
echo "'${c_sep}${l_sep}${c_sep}'"
unset c_sep
exit 0

EDIT 1: Here is a rework on the script. It correct separators handling (just try with ' ' or '|' as third argument to see), but it may look less readable as I use arguments number instead of a additional variable.

EDIT 2: It also deals with negative integers... and you may change the ground (5th parameter)

#!/bin/sh
#
## building a vertical bar graph of data file
## https://stackoverflow.com/q/30929012
##
## 1. required. Data file with one value per line and nothing else!
## /!\ provide the (relative or absolute) file path, not file content
: ${1:?" Please provide a file name"}
[ -e "$1" ] || { echo "Sorry, can't find $1" 1>&2 ; exit 2 ; }
[ -r "$1" ] || { echo "Sorry, can't access $1" 1>&2 ; exit 2 ; }
[ -f "$1" ] || { echo "Sorry, bad format file $1" 1>&2 ; exit 2 ; }
[ $( grep -cv '^[-0-9][0-9]*$' "$1" 2>/dev/null ) -ne 0 ] || { echo "Sorry, bad data in $1" 1>&2 ; exit 3 ; }
## /!\ following parameters should result to a single character
## /!\ for blank color use "\033[48;5;21m \033[0m" or you'll mess...
## 2. optional. Ploting character (default is Dollar sign)
## 3. optional. Horizontal border characher (default is Dash sign)
## 4. optional. Columns separator characher (default is Pipe sign)
## (!) however, when no arg provided the graph is just framed in a table
## 5. optional. Ground level integer value (default is Zero)
test "${5:-0}" -eq "${5:-0}" 2>/dev/null || { echo "oops, bad parameter $5" 1>&2 ; exit 3 ; }
# init...
_long=$(wc -l < "$1" ) # width : number of data/lines in file
if [ -n "$4" ]
then
        _long=$((_long*2-3))
fi
_line=''
while [ "$_long" -gt 0 ]
do
        _line="${_line}${3:--}"
        _long=$((_long-1))
done
unset _long
_from=$(sort -n "$1" | tail -n 1 ) # max int
_stop=$(sort -n "$1" | head -n 1 ) # min int

This rework comes in two flavors. The first produces an output like the previous one.

# begin
echo "${4-.}${3:--}${_line}${3:--}${4-.}"
# upper/positive
if [ $_from -gt ${5:-0} ]
then
        while [ $_from -gt ${5:-0} ]
        do
                printf "${4:-| }"
                for _cint in $(cat "$1" )
                do
                        if [ $_cint -ge $_from ]
                        then
                                printf "${2:-$}$4"
                        else
                                printf " $4"
                        fi
                done
                echo " ${4:-|}"
                _from=$((_from-1))
        done
        echo "${4-|}${3:--}${_line}${3:--}${4-|}"
fi
unset _from
# center/legend
_long=$(wc -L < "$1" ) # height : number of chararcters in longuest line...
while [ $_long -ge 0 ]
do
        printf "${4:-| }"
        for _cint in $(cat "$1" )
        do
                printf "%1s$4" $(echo "$_cint" | cut -c "$_long" )
        done
        echo " ${4:-|}"
        _long=$((_long-1))
done
unset _long
# lower/negative
if [ $_stop -lt ${5:-0} ]
then
        _from=${5:-0}
        echo "${4-|}${3:--}${_line}${3:--}${4-|}"
        while [ $_from -gt $_stop ]
        do
                printf "${4:-| }"
                for _cint in $(cat "$1" )
                do
                        if [ $_cint -lt $_from ]
                        then
                                printf "${2:-$}$4"
                        else
                                printf " $4"
                        fi
                done
                echo " ${4:-|}"
                _from=$((_from-1))
        done
fi
unset _stop
# end
echo "${4-'}${3:--}${_line}${3:--}${4-'}"
exit 0

Notice : There're two checks in order to avoid extra loop when all values are positive (above the ground) or negative (bellow the ground) ! Well, maybe I should always put the "center/legend" part at the end? It looks a bit ugly when there're both positive and negative values first, and when only negative integers it looks strange that labels don't read in the opposite and have unpleasant minus sign.
Also notice that wc -L is not POSIX... ...so another loop may be needed.

Here is another variant with legend number in the right size instead of the bottom. Doing so, I save an extra loop but I don't really like the output (I prefer values on the left rather than the right side, but it's a taste isn't it ?)

# begin
printf "${4-.}${3:--}${_line}${3:--}${4-.}"
# upper/positive
if [ $_from -gt ${5:-0} ]
then
        echo ""
        while [ $_from -gt ${5:-0} ]
        do
                _ctxt=''
                printf "${4:-| }"
                for _cint in $(cat "$1" )
                do
                        if [ $_cint -ge $_from ]
                        then
                                printf "${2:-$}$4"
                        else
                                printf " $4"
                        fi
                        if [ $_cint -eq $_from ]
                        then
                                _ctxt="_ $_from"
                        fi
                done
                echo " ${4:-}${_ctxt}"
                _from=$((_from-1))
        done
        _from=$((_from+1))
else
        echo "_ ${1}"
fi
# center/ground
if [ $_stop -lt ${5:-0} ] && [ $_from -gt ${5:-0} ]
then
        echo "${4-|}${3:--}${_line}${3:--}${4-|}_ ${1}"
fi
# lower/negative
if [ $_stop -lt ${5:-0} ]
then
        _from=${5:-0}
        while [ $_from -gt $_stop ]
        do
                _ctxt=''
                printf "${4:-| }"
                for _cint in $(cat "$1" )
                do
                        if [ $_cint -lt $_from ]
                        then
                                printf "${2:-$}$4"
                        else
                                printf " $4"
                        fi
                        if [ $_cint -eq $((_from-1)) ]
                        then
                                _ctxt="_ $_cint"
                        fi
                done
                echo " ${4:-|}${_ctxt}"
                _from=$((_from-1))
        done
fi
# end
unset _from
printf "${4-'}${3:--}${_line}${3:--}${4-'}"
if [ $_stop -lt ${5:-0} ]
then
        echo ""
else
        echo "_ ${1}"
fi
unset _stop
exit 0

EDIT 3: There're some extra checks so an extra ground line isn't added when there's only positive or negative numbers.

Finally, i think the final solution is a mix of both, where value are displayed on the side and the position of value in the center. Then it's more close to GNU Plot's output.

# init...
_long=$(wc -l < "$1" )
if [ -n "$4" ]
then
        _long=$((_long*2-3))
fi
_line=''
while [ $_long -gt 0 ]
do
        _line="${_line}${3:--}"
        _long=$((_long-1))
done
unset _long
_from=$(sort -n "$1" | tail -n 1 ) # max int
_stop=$(sort -n "$1" | head -n 1 ) # min int
# begin
echo "${4-.}${3:--}${_line}${3:--}${4-.}"
# upper/positive
if [ $_from -gt ${5:-0} ]
then
        while [ $_from -gt ${5:-0} ]
        do
                _ctxt=''
                printf "${4:-| }"
                for _cint in $(cat "$1" )
                do
                        if [ $_cint -ge $_from ]
                        then
                                printf "${2:-$}$4"
                        else
                                printf " $4"
                        fi
                        if [ $_cint -eq $_from ]
                        then
                                _ctxt="_ $_from"
                        fi
                done
                echo " ${4:-|}$_ctxt"
                _from=$((_from-1))
        done
        echo "${4-|}${3:--}${_line}${3:--}${4-|}"
fi
# center/ground
_size=$(wc -l < "$1" ) # width : number of data/lines in file
##_long=${#_size} # height : number of chararcters in long
#_long=1
##while [ $_long -gt 0 ]
#while [ $_long -le ${#_size} ]
#do
       #_rank=1
       #printf "${4:-| }"
       #while [ $_rank -le $_size ]
       #do
               #printf "%1s$4" $( printf "%0${#_size}d" $_rank  | cut -c $_long )
               #_rank=$((_rank+1))
       #done
       #printf " ${4:-|}"
       ##_long=$((_long-1))
       #_long=$((_long+1))
       ##if [ $_long -eq 0 ]
       #if [ $_long -eq ${#_size} ]
       #then
               #printf "_ ${1}"
       #fi
       #echo ''
#done
_rank=1
printf "${4:-| }"
while [ $_rank -le $_size ]
do
        printf "%1s$4" $( expr "$_rank" : '.*\(.\)$' )
        _rank=$((_rank+1))
done
echo " ${4:-|}_ ${1}"
# lower/negative
if [ $_stop -lt ${5:-0} ]
then
        echo "${4-|}${3:--}${_line}${3:--}${4-|}"
        while [ $_from -gt $_stop ]
        do
                _ctxt=''
                printf "${4:-| }"
                for _cint in $(cat "$1" )
                do
                        if [ $_cint -lt $_from ]
                        then
                                printf "${2:-$}${4}"
                        else
                                printf " $4"
                        fi
                        if [ $_cint -eq $((_from-1)) ]
                        then
                                _ctxt="_ $_cint"
                        fi
                done
                echo " ${4:-|}$_ctxt"
                _from=$((_from-1))
        done
fi
unset _from
unset _stop
# end
echo "${4-'}${3:--}${_line}${3:--}${4-'}"
exit 0

A last improvement would be the ability to scale...

like image 130
gildux Avatar answered Nov 14 '22 23:11

gildux


Methinks it can be a bit simpler...

#!/bin/bash

# Asume the data is in a textfile "/tmp/testdata.txt". One value per line.

MAXVALUE=$( sort -nr /tmp/testdata.txt | head -n1 )     # get the highest value
CHARTHEIGHT=$MAXVALUE                                   # use it for height of chart 
                                                        # (can be other value)
CHARTLINE=$CHARTHEIGHT                                  # variable used to parse the lines

while [ $CHARTLINE -gt 0 ]; do                          # start the first line
    (( CHARTLINE-- ))                                    
    REDUCTION=$(( $MAXVALUE*$CHARTLINE/$CHARTHEIGHT ))  # subtract this from the VALUE
    while read VALUE; do
        VALUE=$(( $VALUE-$REDUCTION ))
        if [ $VALUE -le 0 ]; then                       # check new VALUE
             echo -n "    " 
        else
             echo -n "▓▓▓ "
        fi
    done < /tmp/testdata.txt
    echo
done
echo
exit

This script will parse the data every line, reduces the read value and checks if there's something left. If so, display a block; if not, display a space. Repeat every line with different REDUCTION value. The script can be expanded to included a legend, colours, half/quarter blocks, etc...

Apart from SORT and HEAD it's all in BASH commands

like image 35
Ursus Grumpious Avatar answered Nov 14 '22 22:11

Ursus Grumpious