Wykres punktowy : klasa Plot i płynne API

W poprzednim ćwiczeniu powstała biblioteka klas opisujących elementy SVG: Shape, Line, Circle, Rect, Group, Style oraz SVGDrawing. Na końcu pokazano także przykład programu, który za pomocą tych klas rysuje prosty wykres funkcji sinus i cosinus.

Celem tego ćwiczenia jest uporządkowanie tamtego przykładu i zamknięcie jego logiki w dwóch nowych klasach:

  • Axes — opisującej układ współrzędnych wykresu,

  • Plot — opisującej sam wykres punktowy.

Dodatkowym celem ćwiczenia jest zastosowanie płynnego API (fluent API) podczas konfiguracji obiektu Axes.

Zadanie

Napisz moduł plot.py zawierający klasy Axes oraz Plot.

Nowe klasy mają korzystać z wcześniej przygotowanej biblioteki SVG. W szczególności klasa Axes ma być również obiektem rysowalnym, czyli powinna dziedziczyć po klasie Shape i implementować metodę draw().

Klasa Axes

Klasa Axes ma opisywać prostokątny obszar wykresu oraz sposób mapowania wartości numerycznych na współrzędne ekranu. Konstruktor klasy powinien przyjmować:

  • identyfikator obiektu,

  • współrzędne prostokąta rysowania: x0, y0, x1, y1.

Przykład:

ax = Axes("ax1", 80, 40, 580, 260)

Klasa ma udostępniać metody konfiguracyjne w stylu płynnego API, tzn. każda z nich powinna zwracać self.

Wymagane metody:

  • bottom(xmin, xmax)

  • top(xmin, xmax)

  • left(ymin, ymax)

  • right(ymin, ymax)

  • with_xticks(n)

  • with_yticks(n)

  • with_stroke(color)

  • with_stroke_width(width)

Mile widziana (choć nie wymagana) jest też metoda with_grid(), która umożliwi dodanie linii siatki do wykresu.

Przykład użycia:

axes = (
    Axes("ax1", 80, 40, 580, 260)
    .bottom(0.0, 360)
    .left(-1.1, 1.1)
    .top(0.0, 360)
    .right(-1.1, 1.1)
    .with_xticks(6)
    .with_yticks(4)
    .with_grid(True)
    .with_stroke("black")
    .with_stroke_width(1.5)
)

Klasa Axes powinna także udostępniać metody przeliczające współrzędne z układu danych do układu ekranu:

  • map_x(x)

  • map_y(y)

Metoda draw() powinna zwracać fragment kodu SVG rysujący:

  • ramkę wykresu,

  • opcjonalną siatkę, jeśli została uwzględniona w implementacji.

  • znaczniki osi, jeśli zostały uwzględnione w implementacji.

Nie trzeba rysować napisów, etykiet ani legendy.

Klasa Plot

Klasa Plot ma być prostym opakowaniem nad obiektem SVGDrawing.

Konstruktor:

pl = Plot(axes)

gdzie axes jest obiektem klasy Axes.

Klasa Plot powinna:

  • przechowywać obiekt Axes,

  • utworzyć obiekt SVGDrawing,

  • dodać do rysunku obiekt Axes,

  • umożliwić nanoszenie punktów metodą scatter(),

  • umożliwić zapis wyniku metodą save_fig().

Wymagana metoda:

scatter(x, y, marker="o", style=None)

Metoda scatter() powinna:

  • przyjąć dwie listy liczb: x i y,

  • sprawdzić, czy obie listy mają tę samą długość,

  • przeliczyć współrzędne punktów za pomocą metod map_x() i map_y(),

  • utworzyć znaczniki odpowiedniego typu,

  • dodać je do rysunku jako grupę SVG.

Zakładamy, że obsługa markerów została wydzielona do osobnego modułu (np. przez klasę MarkerFactory), dlatego metoda scatter() powinna umieć korzystać z parametru marker.

Przykładowe znaczniki:

  • "o" — koło,

  • "d" — romb.

Uwagi

  1. Axes ma dziedziczyć po Shape.

  2. Każda metoda płynnego API ma zwracać self.

  3. Obiekt Axes ma sam umieć się narysować.

  4. Obiekt Plot ma korzystać z SVGDrawing, a nie zastępować go.

  5. Wykres ma być wykresem punktowym.

  6. Styl punktów ma być przekazywany obiektem klasy Style.

  7. Wynik ma zostać zapisany do pliku SVG.

Ćwiczenie to jest bezpośrednim rozwinięciem poprzedniego przykładu, w którym wykres był budowany „ręcznie” z obiektów Circle, Line, Group i SVGDrawing. Teraz ta sama logika ma zostać zamknięta w klasach wyższego poziomu.

Program testowy

Końcowa implementacja musi poprawnie działać z poniższym kodem:

import math
from plot import Axes, Plot
from svg_shapes import Style

axes = (
    Axes("ax1", 80, 40, 580, 260)
    .bottom(0.0, 360)
    .left(-1.1, 1.1)
    .top(0.0, 360)
    .right(-1.1, 1.1)
    .with_xticks(6)
    .with_yticks(4)
    .with_grid(True)
    .with_stroke("black")
    .with_stroke_width(1.5)
)

x = [i for i in range(0, 370, 10)]
ys = [math.sin(v*3.14159/180.0) for v in x]
yc = [math.cos(v*3.14159/180.0) for v in x]

pl = Plot(axes)
sin_style = Style(fill="#a6cee3", stroke_width=1.5, stroke="#1f78b4")
cos_style = Style(fill="#fdbf6f", stroke_width=1.5, stroke="#ff7f00")
pl.scatter(x, ys, marker="o", style=sin_style)
pl.scatter(x, yc, marker="d", style=cos_style)

pl.save_fig("fluent_plot.svg")

Wynik powinien przypominać wykres z poprzedniego ćwiczenia: ma zawierać ramkę, (opcjonalnie) siatkę oraz dwa zbiory punktów odpowiadające funkcjom sinus i cosinus, narysowane różnymi stylami.