Functional Weave
Code in Rust

charts.line-chart@1.0.0

impl/python.py

4,992 bytes · the Python implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

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.extent ^1.0.0 · built alongside by fune
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.palette ^1.0.0 · built alongside by fune
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  ← from math.round-float ^1.0.0 · built alongside by fune

# 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,
    )