from .charts_axis_grid_lines import grid_lines from .charts_axis_number_axis import number_axis from .charts_axis_time_axis import time_axis from .charts_extent import extent from .charts_format_format_tick import format_tick from .charts_layout_legend_rows import legend_rows from .charts_layout_plot_area import plot_area from .charts_layout_types import LegendItem, Margins from .charts_line_chart_types import LineChart, LineChartSpec, SeriesPath from .charts_palette import palette from .charts_scale_linear_scale import linear_scale from .charts_scale_nice_domain import nice_domain from .charts_scale_time_scale import time_scale from .charts_shape_line_path import line_path from .charts_shape_monotone_curve import monotone_curve from .charts_shape_step_path import step_path from .charts_shape_types import Point from .charts_ticks_nice_ticks import nice_ticks from .charts_ticks_tick_step import tick_step from .math_round_float import round_float # Fixed layout constants; the README explains each. CHAR_WIDTH = 7 SWATCH = 10 LEGEND_GAP = 16 LEGEND_ROW = 18 EDGE = 8 TICK_SIZE = 6 LABEL_PADDING = 3 TOP_WITHOUT_LEGEND = 12 RIGHT = 20 BOTTOM = 30 def line_chart(spec: LineChartSpec) -> LineChart: """A whole line chart as geometry, by the fixed rules in the README.""" width, height, series = spec.width, spec.height, spec.series if len(series) == 0: raise ValueError("a line chart needs at least one series") if (spec.x is None) == (spec.dates is None): raise ValueError("give exactly one of x and dates") count = len(spec.x) if spec.x is not None else len(spec.dates) for s in series: if len(s.values) != count: raise ValueError( 'series "%s" has %d values but there are %d x values' % (s.name, len(s.values), count) ) legend = [] top = TOP_WITHOUT_LEGEND if len(series) > 1: rows = legend_rows([s.name for s in series], width - 2 * EDGE, CHAR_WIDTH, SWATCH, LEGEND_GAP, LEGEND_ROW) legend = [ LegendItem( label=it.label, row=it.row, x=round_float(it.x + EDGE, 2), y=round_float(it.y + EDGE, 2), width=it.width, text_x=round_float(it.text_x + EDGE, 2), ) for it in rows ] top = EDGE + (rows[-1].row + 1) * LEGEND_ROW + EDGE values = [v for s in series for v in s.values] found = extent(values) if found is None: raise ValueError("a line chart needs at least one value to plot") lo, hi = found if spec.y_zero: lo = min(lo, 0.0) hi = max(hi, 0.0) if lo == hi: lo -= 1 hi += 1 y_count = max(2, int((height - top - BOTTOM) // 50)) y_domain = nice_domain([lo, hi], y_count).domain y_step = tick_step(y_domain[0], y_domain[1], y_count) widest = 0 for v in nice_ticks(y_domain[0], y_domain[1], y_count): widest = max(widest, len(format_tick(v, y_step))) left = TICK_SIZE + LABEL_PADDING + widest * CHAR_WIDTH + EDGE plot = plot_area(width, height, Margins(top=top, right=RIGHT, bottom=BOTTOM, left=left)) bottom_y = plot.y + plot.height y_range = [bottom_y, plot.y] y_axis = number_axis(y_domain, y_range, y_count, "left", plot.x, TICK_SIZE) x_range = [plot.x, plot.x + plot.width] x_count = max(2, int(plot.width // 80)) if spec.x is not None: x_domain = extent(spec.x) if x_domain is None or x_domain[0] == x_domain[1]: raise ValueError("a line chart needs at least two different x values") x_axis = number_axis(x_domain, x_range, x_count, "bottom", bottom_y, TICK_SIZE) xs = [linear_scale(x_domain, x_range, v, False) for v in spec.x] else: dates = spec.dates if len(dates) == 0 or min(dates) == max(dates): raise ValueError("a line chart needs at least two different x values") first, last = min(dates), max(dates) x_axis = time_axis([first, last], x_range, x_count, "bottom", bottom_y, TICK_SIZE) xs = [time_scale([first, last], x_range, d, False) for d in dates] colors = palette("okabe-ito", len(series), True) lines = [] for i, s in enumerate(series): points = [ Point(x=xs[j], y=None if v is None else linear_scale(y_domain, y_range, v, False)) for j, v in enumerate(s.values) ] if spec.curve == "monotone": path = monotone_curve(points) elif spec.curve == "step": path = step_path(points, "middle") else: path = line_path(points) lines.append(SeriesPath(name=s.name, color=colors[i], path=path)) return LineChart( width=round_float(width, 2), height=round_float(height, 2), plot=plot, x_axis=x_axis, y_axis=y_axis, grid_lines=grid_lines(y_axis, plot.width), lines=lines, legend=legend, )