Group
Extension

Term-Fabulous/lib/Term/Fabulous/Widget/RadarChart.pm

package Term::Fabulous::Widget::RadarChart;

use v5.32;
use warnings;
use feature 'signatures';
no warnings 'experimental::signatures';

our $VERSION = '0.05';

use Object::Pad 0.825;

use Term::Fabulous::Role::HasSeries;
use Term::Fabulous::Widget::Chart;

class Term::Fabulous::Widget::RadarChart
	:isa(Term::Fabulous::Widget::Chart)
	:does(Term::Fabulous::Role::HasSeries)
	:strict(params)
{
	use Carp qw(croak);
	use List::Util ();
	use POSIX qw(floor ceil);
	use Scalar::Util qw(looks_like_number);
	use Term::Fabulous::Check qw(boolean one_of);
	use Term::Fabulous::Chart::Format qw(check_number_format format_value);
	use Term::Fabulous::Chart::Marker;
	use Term::Fabulous::Chart::Radial qw(CELL_ASPECT TAU circle_frame point_at);
	use Term::Fabulous::Chart::Raster;
	use Term::Fabulous::Chart::Scale::Linear;
	use Term::Fabulous::Chart::Surface;
	use Term::Fabulous::Chart::Transform qw(apply_transforms);
	use Term::Fabulous::Widget::Table::Value qw(number_of);

	use constant {
		FILL_OPACITY => 0.25,
		DOT_POINT    => 'dot',
		SQUARE_POINT => 'square',
		POINT        => "\x{2022}",
		LEGEND_POINT => "\x{25CF}",
	};

	my @GRID     = qw(polygon circle none);
	my @DEFAULTS = qw(marker line_style points point fill_opacity transform);

	field $initial_series :param(series) = [];
	field $labels         :param         = [];
	field $min            :param         = undef;
	field $max            :param         = undef;
	field $ticks          :param         = undef;
	field $grid           :param         = 'polygon';
	field $format         :param         = undef;
	field $start_angle    :param         = 0;

	ADJUST :params ( :$marker = undef, :$line_style = undef, :$points = undef, :$point = undef, :$fill_opacity = undef, :$transform = undef ) {
		my %given = ( marker => $marker, line_style => $line_style, points => $points, point => $point, fill_opacity => $fill_opacity, transform => $transform );
		$self->_set_default( $_ => $given{$_} ) foreach grep { defined $given{$_} } @DEFAULTS;
		$labels = $self->_checked_labels($labels);
		$self->_check_ends( $self->_checked_end( min => $min ), $self->_checked_end( max => $max ) );
		$self->_fail( 'ticks', 'a positive integer or undef', $ticks ) if defined $ticks && ( ref $ticks || $ticks !~ /\A[1-9][0-9]*\z/ );
		one_of( $self, grid => $grid, @GRID );
		$format = check_number_format( ref $self, 'format', $format );
		$self->_fail( 'start_angle', 'a number of degrees', $start_angle ) unless defined number_of($start_angle);
		croak ref($self) . ": series must be an array reference of series hashes" unless ref $initial_series eq 'ARRAY';
		$self->add_series($_) foreach @$initial_series;
		$initial_series = undef;
	}

	method default_series_type ()             { return 'area' }
	method series_types ()                    { return qw(area line) }
	method series_default_names ()            { return @DEFAULTS }
	method check_series_type ( $name, $type ) { return }

	# A point given by label needs an axis of that name.
	method check_series_points ( $name, $points ) {
		$self->_check_axes_of( $labels, $name, $points );
		return;
	}

	method _check_axes_of ( $axes, $name, $points ) {
		my %is_axis = map { $_ => 1 } @$axes;
		foreach my $point (@$points) {
			my $x = $point->[0] // next;
			croak ref($self) . ": series '$name' has a value for '$x', which is not one of the labels" unless $is_axis{$x};
		}
		return;
	}

	method _checked_labels ($value) {
		croak ref($self) . ": labels must be an array reference of strings" unless ref $value eq 'ARRAY' && !grep { !defined || ref } @$value;
		return [ map { "$_" } @$value ];
	}

	method _checked_end ( $name, $value ) {
		$self->_fail( $name, 'a number or undef', $value ) if defined $value && !defined number_of($value);
		return $value;
	}

	# Dies unless a min and a max, when both are given, leave room between
	# them.
	method _check_ends ( $low, $high ) {
		return unless defined $low && defined $high;
		croak ref($self) . ": min ($low) must be less than max ($high)" unless $low < $high;
		return;
	}

	# ---------------------------------------------------------------------
	# Accessors
	# ---------------------------------------------------------------------

	method _set ( $field_ref, $value ) {
		$$field_ref = $value;
		$self->mark_changed;
		return $value;
	}

	method min (@new) {
		return $min unless @new;
		$self->_check_ends( $self->_checked_end( min => $new[0] ), $max );
		return $self->_set( \$min, $new[0] );
	}

	method max (@new) {
		return $max unless @new;
		$self->_check_ends( $min, $self->_checked_end( max => $new[0] ) );
		return $self->_set( \$max, $new[0] );
	}

	method grid         (@new) { return @new ? $self->_set( \$grid, one_of( $self, grid => $new[0], @GRID ) ) : $grid }
	method format       (@new) { return @new ? $self->_set( \$format, check_number_format( ref $self, 'format', $new[0] ) ) : $format }
	method marker       (@new) { return $self->series_default( marker       => @new ) }
	method line_style   (@new) { return $self->series_default( line_style   => @new ) }
	method points       (@new) { return $self->series_default( points       => @new ) }
	method point        (@new) { return $self->series_default( point        => @new ) }
	method fill_opacity (@new) { return $self->series_default( fill_opacity => @new ) }

	# New labels must still give every value its axis.
	method labels (@new) {
		return [@$labels] unless @new;
		my $axes = $self->_checked_labels( $new[0] );
		$self->_check_axes_of( $axes, $_->name, $_->points ) foreach $self->all_series;
		return [ $self->_set( \$labels, $axes )->@* ];
	}

	method ticks (@new) {
		return $ticks unless @new;
		$self->_fail( 'ticks', 'a positive integer or undef', $new[0] ) if defined $new[0] && ( ref $new[0] || $new[0] !~ /\A[1-9][0-9]*\z/ );
		return $self->_set( \$ticks, $new[0] );
	}

	method start_angle (@new) {
		return $start_angle unless @new;
		$self->_fail( 'start_angle', 'a number of degrees', $new[0] ) unless defined number_of( $new[0] );
		return $self->_set( \$start_angle, $new[0] + 0 );
	}

	method transform (@new) {
		$self->_set_default( transform => $new[0] ) if @new;
		return;
	}

	# ---------------------------------------------------------------------
	# Data
	# ---------------------------------------------------------------------

	# The values of each visible series by axis: a number per label, in
	# the order of the labels ([ label, value ] points name their axis;
	# check_series_points saw to it that the label is one).
	method _prepared () {
		my %axis_of = map { $labels->[$_] => $_ } 0 .. $#$labels;
		my @prepared;
		foreach my $series ( $self->visible_series ) {
			my @values = (undef) x @$labels;
			my $index  = 0;
			foreach my $point ( $series->points->@* ) {
				my ( $x, $y ) = @$point;
				my $axis = defined $x ? $axis_of{$x} : $index;
				$values[$axis] = $y if $axis < @$labels;
				$index++;
			}
			if ( my $steps = $self->series_option( $series, 'transform' ) ) {
				( undef, my $ys ) = apply_transforms( $steps, [ 0 .. $#values ], \@values );
				@values = @$ys;
			}
			push @prepared, { series => $series, name => $series->name, values => \@values };
		}
		return @prepared;
	}

	# ---------------------------------------------------------------------
	# Drawing
	# ---------------------------------------------------------------------

	method default_legend_position () {
		return 'top';
	}

	method legend_entries ($look) {
		return map { { series => $_->name, label => $_->name, color => $_->color // $self->slot_color( $look, $_->slot ), symbol => $_->type eq 'line' ? 'line' : 'fill' } } $self->visible_series;
	}

	method draw_plot ( $surface, $x, $y, $width, $height, $look ) {
		my $count = @$labels;
		return if $count < 3;
		my @prepared = $self->_prepared;
		my $measure  = sub ($text) { Term::Fabulous::Chart::Surface->text_columns($text) };

		# Room for the axis labels around the circle: their width at the
		# sides, a row above and below.
		my $label_columns = List::Util::max( map { $measure->($_) } @$labels ) + 1;
		my $circle        = circle_frame( $label_columns, 1, $width - 2 * $label_columns, $height - 2 );
		return if $circle->{radius} < 2;
		my $radius = $circle->{radius};
		my $start  = $start_angle / 360;

		my @all   = grep { defined } map { $_->{values}->@* } @prepared;
		my $scale = Term::Fabulous::Chart::Scale::Linear->fit(
			extent  => @all ? [ List::Util::min(@all), List::Util::max(@all) ] : undef,
			cells   => List::Util::max( 2, floor( $radius / 2 ) + 1 ),
			zero    => 1,
			align   => 0,
			integer => !grep( { $_ != int } @all ) ? 1 : 0,
			format  => $format,
			( defined $min   ? ( min   => $min )   : () ),
			( defined $max   ? ( max   => $max )   : () ),
			( defined $ticks ? ( ticks => $ticks ) : () ),
		);
		my $distance_of = sub ($value) { List::Util::max( 0, $scale->position($value) ) * $radius };
		my $vertex      = sub ( $axis, $distance ) { [ point_at( $circle, $distance, $axis / $count, $start ) ] };

		# The web: rings at the ticks and a spoke to every axis.
		my $web = Term::Fabulous::Chart::Raster->new( marker => Term::Fabulous::Chart::Marker->named('braille'), columns => $width, rows => $height );
		if ( $grid ne 'none' ) {
			foreach my $tick ( $scale->ticks ) {
				my $distance = $distance_of->( $tick->{value} );
				next unless $distance > 0;
				my @ring
					= $grid eq 'circle'
					? map { [ point_at( $circle, $distance, $_ / List::Util::max( 24, floor( TAU * $distance * 2 ) ), $start ) ] } 0 .. List::Util::max( 24, floor( TAU * $distance * 2 ) )
					: map { $vertex->( $_ % $count, $distance ) } 0 .. $count;
				_polyline( $web, \@ring, $look->{grid}, undef );
			}
			_polyline( $web, [ $vertex->( $_, 0 ), $vertex->( $_, $radius ) ], $look->{grid}, undef ) foreach 0 .. $count - 1;
		}

		# The series: translucent fills, the web over them (its dots keep a
		# fill's color as their background), the outlines on top.
		my @order = sort { $self->is_emphasized( $look, $a->{name} ) <=> $self->is_emphasized( $look, $b->{name} ) } @prepared;
		my %fills;
		my @fill_order;
		my $lines = Term::Fabulous::Chart::Raster->new( marker => Term::Fabulous::Chart::Marker->named('braille'), columns => $width, rows => $height );
		my @points;
		foreach my $entry (@order) {
			my $series  = $entry->{series};
			my $color   = $self->shown_color( $look, $entry->{name}, $series->color // $self->slot_color( $look, $series->slot ) );
			my @targets = map { $self->register_target( series => $entry->{name}, index => $_, label => $labels->[$_], value => $entry->{values}[$_] ) } 0 .. $count - 1;
			my @corners = map { $vertex->( $_, $distance_of->( $entry->{values}[$_] // $scale->min ) ) } 0 .. $count - 1;

			# Between two corners the nearer one owns the fill and the
			# outline, so the pointer reports the nearest axis.
			my @midpoints = map { _midpoint( $corners[$_], $corners[ ( $_ + 1 ) % $count ] ) } 0 .. $count - 1;
			if ( $series->type eq 'area' ) {
				my $marker = $self->series_option( $series, 'marker' ) // 'quadrant';
				my $fill   = $fills{$marker} //= do {
					push @fill_order, $marker;
					Term::Fabulous::Chart::Raster->new( marker => Term::Fabulous::Chart::Marker->named($marker), columns => $width, rows => $height );
				};
				my ( $sx, $sy ) = ( $fill->marker->columns, $fill->marker->rows );
				my $opacity = ( $self->series_option( $series, 'fill_opacity' ) // FILL_OPACITY ) * $series->color_opacity;
				my $center  = [ $circle->{center_x}, $circle->{center_y} ];
				foreach my $axis ( 0 .. $count - 1 ) {
					my @sector = ( $center, $midpoints[ ( $axis - 1 ) % $count ], $corners[$axis], $midpoints[$axis] );
					$fill->fill_polygon( [ map { [ $_->[0] * $sx, $_->[1] * $sy ] } @sector ], $color, $targets[$axis], $opacity, $look->{base} );
				}
			}
			$lines->start_pattern( $series->dash_pattern( $self->series_option( $series, 'line_style' ) // 'solid' ) );
			foreach my $axis ( 0 .. $count - 1 ) {
				my ( $from, $middle, $to ) = ( $corners[$axis], $midpoints[$axis], $corners[ ( $axis + 1 ) % $count ] );
				$lines->line( $from->[0] * 2,   $from->[1] * 4,   $middle->[0] * 2, $middle->[1] * 4, $color, $targets[$axis] );
				$lines->line( $middle->[0] * 2, $middle->[1] * 4, $to->[0] * 2,     $to->[1] * 4,     $color, $targets[ ( $axis + 1 ) % $count ] );
			}
			$lines->start_pattern(undef);
			if ( $self->series_option( $series, 'points' ) ) {
				my $glyph  = $self->series_option( $series, 'point' ) // POINT;
				my @valued = grep { defined $entry->{values}[$_] } 0 .. $count - 1;
				if ( $glyph eq DOT_POINT || $glyph eq SQUARE_POINT ) {
					_raster_point( $lines, $glyph, $corners[$_], $color, $targets[$_] ) foreach @valued;
				}
				else {
					push @points, map { [ floor( $corners[$_][0] ), floor( $corners[$_][1] ), $glyph, $color, $targets[$_] ] } @valued;
				}
			}
		}
		$surface->composite( $fills{$_}, 'fill',   $x, $y ) foreach @fill_order;
		$surface->composite( $web,       'stroke', $x, $y );
		$surface->composite( $lines,     'stroke', $x, $y );
		foreach my $point (@points) {
			my ( $column, $row, $glyph, $color, $owner ) = @$point;
			$surface->put( $x + $column, $y + $row, $glyph, $color );
			$surface->set_owner( $x + $column, $y + $row, 'stroke', $owner );
		}

		# The axis labels around the web, and the values of the rings along
		# the first spoke: right of it when it is upright, above it when it
		# lies flat; never over an axis label.
		my @taken;    # [ row, first column, last column ] of each axis label
		foreach my $axis ( 0 .. $count - 1 ) {
			my $text = $labels->[$axis];
			my ( $at_x, $at_y ) = point_at( $circle, $radius + 1, $axis / $count, $start );
			my $dx            = $at_x - $circle->{center_x};
			my $width_of_text = $measure->($text);
			my $column
				= abs($dx) < 1 ? floor( $at_x - $width_of_text / 2 + 0.5 )
				: $dx > 0      ? ceil($at_x)
				:                floor($at_x) - $width_of_text + 1;
			$column = List::Util::max( 0, $column );
			$surface->text( $x + $column, $y + floor($at_y), $text, $look->{text}, max => $width - $column );
			push @taken, [ floor($at_y), $column, $column + $width_of_text - 1 ];
		}
		my ( $end_x, $end_y ) = point_at( $circle, $radius, 0, $start );
		my $spoke_is_flat = abs( $end_x - $circle->{center_x} ) > abs( $end_y - $circle->{center_y} ) * CELL_ASPECT;

		# A ring label needs a free column around it; axis labels and the
		# ring labels placed before it take their cells.
		foreach my $tick ( reverse $scale->ticks ) {
			my $distance = $distance_of->( $tick->{value} );
			next unless $distance > 0;
			my ( $at_x, $at_y ) = point_at( $circle, $distance, 0, $start );
			my $width_of_text = $measure->( $tick->{label} );
			my ( $column, $row ) = $spoke_is_flat ? ( floor( $at_x - $width_of_text / 2 + 0.5 ), floor($at_y) - 1 ) : ( floor($at_x) + 1, floor($at_y) );
			next if $row < 0 || $column < 0;
			my $last = $column + $width_of_text - 1;
			next if grep { $_->[0] == $row && $column <= $_->[2] + 1 && $last >= $_->[1] - 1 } @taken;
			$surface->text( $x + $column, $y + $row, $tick->{label}, $look->{label}, max => $width - $column );
			push @taken, [ $row, $column, $last ];
		}
		return;
	}

	# A dot or square point drawn into a raster: the subpixel at the
	# corner, or the two by two around it.
	sub _raster_point ( $raster, $glyph, $corner, $color, $owner ) {
		my ( $sx, $sy ) = ( $raster->marker->columns, $raster->marker->rows );
		if ( $glyph eq DOT_POINT ) {
			$raster->set( floor( $corner->[0] * $sx ), floor( $corner->[1] * $sy ), $color, $owner );
			return;
		}
		my ( $left, $top ) = ( floor( $corner->[0] * $sx - 0.5 ), floor( $corner->[1] * $sy - 0.5 ) );
		$raster->set( $left + $_ % 2, $top + int( $_ / 2 ), $color, $owner ) foreach 0 .. 3;
		return;
	}

	sub _midpoint ( $from, $to ) {
		return [ ( $from->[0] + $to->[0] ) / 2, ( $from->[1] + $to->[1] ) / 2 ];
	}

	sub _polyline ( $raster, $points, $color, $owner ) {
		foreach my $index ( 1 .. $#$points ) {
			my ( $from, $to ) = @$points[ $index - 1, $index ];
			$raster->line( $from->[0] * 2, $from->[1] * 4, $to->[0] * 2, $to->[1] * 4, $color, $owner );
		}
		return;
	}

	# ---------------------------------------------------------------------
	# KDL
	# ---------------------------------------------------------------------

	method layout_properties :common () {
		return (
			$class->SUPER::layout_properties,
			min          => 'scalar',
			max          => 'scalar',
			ticks        => 'scalar',
			grid         => 'scalar',
			format       => 'scalar',
			start_angle  => 'scalar',
			marker       => 'scalar',
			line_style   => 'scalar',
			points       => 'boolean',
			point        => 'scalar',
			fill_opacity => 'scalar',
			labels       => \&_parse_labels,
			series       => \&_parse_series,
		);
	}

	method apply_layout_settings :override (@settings) {
		my @ordered = map { $_->[1] } sort { $a->[0] <=> $b->[0] } map { [ $_->[0] eq 'series' ? 1 : 0, $_ ] } @settings;
		return $self->SUPER::apply_layout_settings(@ordered);
	}

	method _parse_labels ($kid) {
		my @labels = map { $_->as_perl } $kid->args->@*;
		croak ref($self) . ": layout property 'labels' takes one or more labels" unless @labels && !$kid->props->@* && !$kid->children->@*;
		$self->labels( \@labels );
		return;
	}

	# series "Alice" type="line" color="#3987e5" { data 4 5 3 4 2 }
	# series "Bob" { point "Speed" 4; point "Power" 3 }
	method _parse_series ($kid) {
		my @args = map { $_->as_perl } $kid->args->@*;
		croak ref($self) . ": layout property 'series' needs the series name as its one argument" unless @args == 1 && defined $args[0] && !ref $args[0];
		my %spec = ( name => "$args[0]", map { $_->[0] => $_->[1]->as_perl } $kid->props->@* );
		my @data;
		foreach my $child ( $kid->children->@* ) {
			my $name   = $child->name;
			my @values = map { $_->as_perl } $child->args->@*;
			croak ref($self) . ": a series node holds 'data' and 'point' nodes, got '$name'" unless $name eq 'data'  || $name eq 'point';
			croak ref($self) . ": '$name' in series '$args[0]' takes no properties or children" if $child->props->@* || $child->children->@*;
			if ( $name eq 'point' ) {
				croak ref($self) . ": 'point' in series '$args[0]' takes a label and a value" unless @values == 2 && defined $values[0] && !ref $values[0];
				push @data, [ "$values[0]", $values[1] ];
			}
			else {
				push @data, @values;
			}
		}
		$self->add_series( %spec, data => \@data );
		return;
	}
}

1;

__END__

=encoding UTF-8

=head1 NAME

Term::Fabulous::Widget::RadarChart - Several values per series on axes
around a center

=head1 SYNOPSIS

	use Term::Fabulous::Widget::RadarChart;

	my $chart = Term::Fabulous::Widget::RadarChart->new(
		title  => 'Laptop ratings',
		labels => [ 'Speed', 'Battery', 'Screen', 'Keyboard', 'Weight', 'Price' ],
		max    => 10,
		points => 1,
		series => [
			{ name => 'Model A', data => [ 9, 6, 8, 7, 5, 4 ] },
			{ name => 'Model B', data => [ 6, 9, 6, 8, 9, 7 ] },
			{ name => 'Average', type => 'line', line_style => 'dashed', data => [ [ Speed => 7 ], [ Price => 6 ] ] },
		],
	);

	$chart->set_data( 'Model A' => [ 9, 7, 8, 7, 6, 4 ] );
	$chart->grid('circle');

=begin html

<p><img src="https://raw.githubusercontent.com/davenonymous/perl-term-fabulous/v0.05/screenshots/widget-radar-chart.svg" alt="Two translucent polygons for two laptop models over a web of six axes labeled Speed, Battery, Screen, Keyboard, Weight and Price"></p>

=end html

F<examples/widgets/radar-chart.pl> draws this chart.

=head1 DESCRIPTION

A radar chart (a spider or web chart) compares profiles: each series
has one value per axis, the axes radiate from a center, and the values
of a series join into a polygon. One look shows where a profile is
strong and where it is weak, and how two profiles differ in shape.
It suits a handful of series over three to about eight axes whose values
share a scale (ratings, percentages).

The axes are the C<labels>, clockwise from 12 o'clock (C<start_angle>
turns them). Rings mark round values from the center (C<min>, 0 by
default) to the outer ring (C<max>, a round value above the data), as a
C<polygon> through the axes or a C<circle>, with a spoke from the
center to every axis label; the values of the rings are written along
the first axis. C<< grid =E<gt> 'none' >> leaves out the rings, the
spokes and the ring values. An C<area> series (the default type) is a
translucent polygon with its outline, a C<line> series only the outline;
C<points> marks the values. The web is drawn over the fills and under
the outlines, so it stays readable.

Hover emphasizes a series and reports the axis nearest to the pointer:
its label and the series' value there. The series methods are those of
L<Term::Fabulous::Role::HasSeries>; the title, legend (at the top by
default), colors and hover mechanics those of
L<Term::Fabulous::Widget::Chart>.

=head2 Data

	data => [ 9, 6, 8, 7, 5, 4 ]                                    # one value per label, in order
	data => [ [ Speed => 9 ], [ Screen => 8 ] ]                     # by label; the others are left out
	data => [ 9, undef, 8 ]                                         # a missing value

A series gives its values in the order of the labels, or as
C<[ label, value ]> pairs; a label that is not an axis dies when the data
is given (C<add_series>, C<set_data>, C<add_points>, ...), and the
series keeps the data it had. A missing value (C<undef>, or a label left
out) is drawn at the center. Values beyond the labels are ignored. A
series' C<transform> runs on its values in axis order.

=head1 CONSTRUCTOR

=head2 new

	my $chart = Term::Fabulous::Widget::RadarChart->new(%parameters);

The parameters of L<Term::Fabulous::Widget::Chart/CONSTRUCTOR>, and:

=over

=item C<labels>

An array reference of at least three strings: the axes. With fewer, no
plot is drawn. Default: none.

=item C<series>

An array reference of series hashes with C<name>, C<type> (C<area>, the
default, or C<line>), C<data>, C<color> and the options below. See
L<Term::Fabulous::Widget::XYChart/Series keys> for the common keys.

=item C<min>, C<max>

Numbers: the values at the center and at the outer ring; with both
given, C<min> must be less than C<max>. Default: C<undef>, from the data
(the center is 0 unless the data goes below). A C<min> not below the
C<max> dies when it is given, also through the accessors, and changes
nothing.

=item C<ticks>

A positive integer: about how many rings you want; the chart picks the
round step that comes closest. Default: C<undef>, which spaces the rings
about three rows apart, so a larger chart has more of them.

=item C<grid>

C<polygon> (the default): rings that run straight from axis to axis;
C<circle>: round rings; C<none>: no rings, spokes or ring values.

=item C<format>

How the ring values are written: C<si>, C<integer>, C<percent>, a
C<sprintf> format or a code reference; see
L<Term::Fabulous::Chart::Format>. Default: C<undef>, as many decimals
as the step between the rings needs.

=item C<start_angle>

Degrees clockwise from 12 o'clock for the first axis. Default: 0.

=item C<marker>, C<line_style>, C<points>, C<point>, C<fill_opacity>, C<transform>

Series options for every series without one of its own (a series hash
may set each of them too):

=over

=item C<marker>

The characters of the fill: C<quadrant> (the default), C<half>,
C<sextant> or C<braille>.

=item C<line_style>

The outline: C<solid> (the default), C<dashed> or C<dotted>.

=item C<points>, C<point>

C<points> true marks every value; C<point> is the mark: a single
character (a bullet, C<U+2022>, by default), or C<dot> (one Braille dot
at the corner) or C<square> (two by two Braille dots around it), drawn
with the outlines as in L<Term::Fabulous::Widget::XYChart/Points>.

=item C<fill_opacity>

0 to 1: how much of the fill color covers the background. Default: 0.25.

=item C<transform>

Steps that prepare the values; see
L<Term::Fabulous::Chart::Transform>.

=back

=back

=head1 METHODS

Every parameter except C<series> has an accessor of the same name
(the series methods are listed below): without an argument it returns
the value, with one it checks and sets it, and an invalid
value dies and changes nothing. C<labels> returns a copy, and dies
without changing anything for labels that would leave a value of a
series without its axis; C<transform> only sets, and returns nothing.

	$chart->labels( [ 'Speed', 'Battery', 'Screen', 'Keyboard', 'Weight', 'Price', 'Ports' ] );
	$chart->max(undef);         # from the data again
	$chart->fill_opacity(0.4);

The series are managed with the methods of
L<Term::Fabulous::Role::HasSeries> (C<add_series>, C<set_series>,
C<set_data>, C<add_points>, C<remove_series>, C<hide_series>, ...); the
chart also has C<hovered>, C<revision> and C<effective_background> from
L<Term::Fabulous::Widget::Chart>.

=head1 EVENTS

C<SeriesHover> (L<Term::Fabulous::Event::SeriesHover>) with the series,
the number of the nearest axis as the index, its label and the value
there.

=head1 KDL PROPERTIES

=for highlighter language=kdl

	use Term::Fabulous::Widget::RadarChart as RadarChart

	RadarChart "ratings" {
		labels "Speed" "Battery" "Screen" "Keyboard" "Weight" "Price"
		max 10
		grid "circle"
		points #true
		series "Model A" { data 9 6 8 7 5 4 }
		series "Average" type="line" line_style="dashed" {
			point "Speed" 7
			point "Price" 6
		}
	}

The properties of L<Term::Fabulous::Widget::Chart/KDL PROPERTIES>;
C<min>, C<max>, C<ticks>, C<grid>, C<format>, C<start_angle>,
C<marker>, C<line_style>, C<points>, C<point> and C<fill_opacity> as
the parameters; C<labels> with the axes as its arguments; and one
C<series> node per series with its name as the argument, C<type>,
C<color> and the series options as properties, and C<data> nodes
(values in axis order) or C<point "label" value> nodes in its block.
The labels are applied before the series.

=head1 SEE ALSO

L<Term::Fabulous::Widget::Chart> (title, legend, colors, hover),
L<Term::Fabulous::Role::HasSeries>, L<Term::Fabulous::Widget::PolarAreaChart>,
L<Term::Fabulous::Manual::Charts/CHARTS>,
L<Term::Fabulous::Cookbook::Charts/Compare profiles on radar and polar area charts (RadarChart, PolarAreaChart)>,
the example program F<examples/widgets/radar-chart.pl>.

=cut


Powered by Groonga
Maintained by Kenichi Ishigaki <ishigaki@cpan.org>. If you find anything, submit it on GitHub.