├── .flake8 ├── .pylintrc ├── docs ├── requirements.txt ├── examplegui │ ├── basetk.py │ ├── guimpl.py │ ├── guitk.py │ └── guiqt.py ├── Makefile ├── make.bat ├── images │ └── flowchart.py ├── conf.py ├── gui.rst ├── charts.rst ├── index.rst ├── layout.rst ├── discrete.rst ├── intro.rst ├── api.rst └── guide.rst ├── pyproject.toml ├── ziaplot ├── annotations │ ├── __init__.py │ ├── text.py │ └── annotations.py ├── charts │ ├── __init__.py │ ├── pie.py │ └── bar.py ├── discrete │ ├── __init__.py │ ├── polar.py │ └── bars.py ├── diagrams │ ├── __init__.py │ ├── ticker.py │ ├── polar.py │ ├── smithgrid.py │ ├── graphlog.py │ └── oned.py ├── style │ ├── __init__.py │ ├── context.py │ ├── colors.py │ ├── style.py │ └── css.py ├── figures │ ├── __init__.py │ ├── implicit.py │ ├── polygon.py │ ├── integral.py │ ├── function.py │ └── point.py ├── geometry │ ├── __init__.py │ ├── function.py │ ├── circle.py │ ├── ellipse.py │ ├── line.py │ ├── geometry.py │ ├── bezier.py │ └── intersect.py ├── diagram_stack.py ├── __init__.py ├── container.py ├── config.py ├── element.py ├── drawable.py ├── util.py └── text.py ├── readthedocs.yml ├── README.md ├── .gitignore ├── LICENSE.txt ├── setup.cfg └── CHANGES.md /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 100 -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [FORMAT] 2 | max-line-length=120 3 | good-names=i,j,k,x,y,dx,dy,lw,ls,bar -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | jupyter_sphinx 2 | ipykernel 3 | ziamath>=0.6 4 | ziafont>=0.4 5 | latex2mathml -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" -------------------------------------------------------------------------------- /ziaplot/annotations/__init__.py: -------------------------------------------------------------------------------- 1 | from .annotations import Arrow, Angle 2 | from .text import Text 3 | -------------------------------------------------------------------------------- /ziaplot/charts/__init__.py: -------------------------------------------------------------------------------- 1 | from .bar import Bar, BarChart, BarChartHoriz, BarChartGrouped, BarChartGroupedHoriz, BarSeries 2 | from .pie import Pie, PieSlice 3 | -------------------------------------------------------------------------------- /ziaplot/discrete/__init__.py: -------------------------------------------------------------------------------- 1 | from .polylines import ( 2 | PolyLine, 3 | Scatter, 4 | ErrorBar, 5 | LineFill, 6 | Plot, 7 | Xy) 8 | from .contour import Contour 9 | from .polar import LinePolar 10 | from .bars import Bars, BarsHoriz, Histogram, HistogramHoriz -------------------------------------------------------------------------------- /readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | build: 3 | os: "ubuntu-20.04" 4 | tools: 5 | python: "3.10" 6 | sphinx: 7 | configuration: docs/conf.py 8 | fail_on_warning: false 9 | python: 10 | install: 11 | - requirements: docs/requirements.txt 12 | - method: pip 13 | path: . 14 | -------------------------------------------------------------------------------- /ziaplot/diagrams/__init__.py: -------------------------------------------------------------------------------- 1 | from .diagram import Diagram, LegendLoc, Ticks 2 | from .graph import Graph, GraphQuad, GraphQuadCentered 3 | from .graphlog import GraphLogX, GraphLogY, GraphLogXY 4 | from .oned import NumberLine 5 | from .polar import GraphPolar 6 | from .smith import GraphSmith, SmithConstReactance, SmithConstResistance 7 | from .ticker import ticker 8 | -------------------------------------------------------------------------------- /ziaplot/style/__init__.py: -------------------------------------------------------------------------------- 1 | from .themes import theme, theme_list, css, CSS_BLACKWHITE, CSS_NOGRID, CSS_NOBACKGROUND 2 | from .colors import ColorCycle, ColorFade 3 | from .style import ( 4 | MarkerTypes, 5 | DashTypes, 6 | ColorType, 7 | TextPosition, 8 | Halign, 9 | Valign, 10 | Style, 11 | AppliedStyle 12 | ) 13 | from .context import Css 14 | -------------------------------------------------------------------------------- /ziaplot/figures/__init__.py: -------------------------------------------------------------------------------- 1 | from .shapes import Circle, Ellipse, Rectangle, Arc, CompassArc 2 | from .polygon import Polygon 3 | from .function import Function 4 | from .point import Point 5 | from .line import Line, HLine, VLine, Segment, Vector 6 | from .integral import IntegralFill 7 | from .bezier import Bezier, Curve, CurveThreePoint, BezierSpline, BezierHobby 8 | from .implicit import Implicit 9 | -------------------------------------------------------------------------------- /docs/examplegui/basetk.py: -------------------------------------------------------------------------------- 1 | ''' Base TK program with no dependencies ''' 2 | 3 | import tkinter as tk 4 | 5 | class Window: 6 | def __init__(self, master): 7 | self.master = master 8 | master.title('Plot Example') 9 | self.button = tk.Button(self.master, text='Do nothing') 10 | self.button.pack() 11 | 12 | root = tk.Tk() 13 | gui = Window(root) 14 | root.mainloop() -------------------------------------------------------------------------------- /ziaplot/style/context.py: -------------------------------------------------------------------------------- 1 | ''' CSS Context Manager ''' 2 | from .. import diagram_stack 3 | 4 | 5 | class Css: 6 | ''' Context Manager for applying a CSS style to a group of Elements ''' 7 | def __init__(self, css: str): 8 | self.css = css 9 | 10 | def __enter__(self): 11 | diagram_stack.push_component(None) 12 | diagram_stack.apply_style.append(self.css) 13 | 14 | def __exit__(self, exc_type, exc_val, exc_tb): 15 | diagram_stack.push_component(None) 16 | diagram_stack.apply_style.pop() 17 | -------------------------------------------------------------------------------- /ziaplot/geometry/__init__.py: -------------------------------------------------------------------------------- 1 | from .geometry import ( 2 | PointType, 3 | LineType, 4 | CircleType, 5 | EllipseType, 6 | BezierType, 7 | distance, 8 | midpoint, 9 | isclose, 10 | unique_points, 11 | translate, 12 | rotate, 13 | reflect, 14 | image, 15 | angle_mean, 16 | angle_diff, 17 | angle_isbetween, 18 | select_which 19 | 20 | ) 21 | from . import circle 22 | from . import ellipse 23 | from . import line 24 | from . import function 25 | from . import intersect 26 | from . import bezier 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ziaplot 2 | 3 | Ziaplot is for easy, lightweight, and Pythonic plotting of geometric diagrams and discrete data in SVG format. 4 | 5 | In ziaplot, a diagram is made from one or more elements added to a Graph. 6 | Below, a PolyLine is added to a Graph. 7 | 8 | import ziaplot as zp 9 | with zp.Graph(): 10 | zp.PolyLine([1, 2, 3], [1, 4, 9]) 11 | 12 | Ziaplot can plot discrete XY data, geometric diagrams, callable functions, histograms, pie charts, and bar charts. 13 | Data can also be displayed in polar form or on a Smith Chart. 14 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /ziaplot/diagrams/ticker.py: -------------------------------------------------------------------------------- 1 | ''' Generate tick positions using slice notation ''' 2 | from .. import util 3 | 4 | 5 | class _Ticker: 6 | ''' Use to generate ticks using slice notation: 7 | 8 | Examples: 9 | ticker[0:10:1] # Generate ticks from 0 to 10 10 | ticker[0:10:2] # Step from 0-10 by 2's 11 | ''' 12 | def __getitem__(self, item): 13 | start, stop, step = item.start, item.stop, item.step 14 | if start is None: 15 | start = 0 16 | if stop is None: 17 | raise ValueError('stop value is required') 18 | if step is None: 19 | step = (stop - start) / 9 20 | return util.zrange(start, stop, step) 21 | 22 | 23 | ticker = _Ticker() 24 | -------------------------------------------------------------------------------- /ziaplot/discrete/polar.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Sequence 3 | import math 4 | 5 | from .polylines import PolyLine 6 | 7 | 8 | class LinePolar(PolyLine): 9 | ''' Define a data PolyLine using polar coordinates 10 | 11 | Args: 12 | radius: The radius values to plot 13 | theta: The theta values to plot, in degres or radians 14 | deg: Interpret theta as degrees instead of radians 15 | ''' 16 | def __init__(self, radius: Sequence[float], theta: Sequence[float], deg: bool = False): 17 | self.radius = radius 18 | self.theta = theta 19 | if deg: 20 | self.theta = [math.radians(t) for t in theta] 21 | x = [r * math.cos(t) for r, t in zip(self.radius, self.theta)] 22 | y = [r * math.sin(t) for r, t in zip(self.radius, self.theta)] 23 | super().__init__(x, y) 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # These are some examples of commonly ignored file patterns. 2 | # You should customize this list as applicable to your project. 3 | # Learn more about .gitignore: 4 | # https://www.atlassian.com/git/tutorials/saving-changes/gitignore 5 | 6 | .ipynb_checkpoints 7 | _build/ 8 | *.egg-info 9 | 10 | # Node artifact files 11 | node_modules/ 12 | dist/ 13 | 14 | # Compiled Java class files 15 | *.class 16 | 17 | # Compiled Python bytecode 18 | *.py[cod] 19 | 20 | # Log files 21 | *.log 22 | 23 | # Package files 24 | *.jar 25 | 26 | # Maven 27 | target/ 28 | dist/ 29 | 30 | # JetBrains IDE 31 | .idea/ 32 | 33 | # Unit test reports 34 | TEST*.xml 35 | 36 | # Generated by MacOS 37 | .DS_Store 38 | 39 | # Generated by Windows 40 | Thumbs.db 41 | 42 | # Applications 43 | *.app 44 | *.exe 45 | *.war 46 | 47 | # Large media files 48 | *.mp4 49 | *.tiff 50 | *.avi 51 | *.flv 52 | *.mov 53 | *.wmv 54 | 55 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /ziaplot/figures/implicit.py: -------------------------------------------------------------------------------- 1 | ''' Implicit Function ''' 2 | from typing import Callable 3 | 4 | from ..util import linspace 5 | from ..discrete import Contour 6 | from ..geometry import PointType 7 | 8 | 9 | class Implicit(Contour): 10 | ''' Plot an implicit function 11 | 12 | Args: 13 | f: A function of x and y, to plot 14 | f(x, y) = 0. 15 | xlim: Range of data for x direction 16 | ylim: Range of data for y direction 17 | n: Number of data points along x and y 18 | used to estimate the plot curves 19 | ''' 20 | def __init__(self, 21 | f: Callable, 22 | xlim: PointType = (-1, 1), 23 | ylim: PointType = (-1, 1), 24 | n: int = 100): 25 | self.func = f 26 | self.n = n 27 | x = linspace(*xlim, n) 28 | y = linspace(*ylim, n) 29 | z = [[f(xx, yy) for xx in x] for yy in y] 30 | super().__init__(x, y, z, levels=(0,)) 31 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021-2025 Collin J. Delker 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /ziaplot/geometry/function.py: -------------------------------------------------------------------------------- 1 | ''' Calculations on functions ''' 2 | import math 3 | 4 | from ..util import maximum, minimum, derivative 5 | from .geometry import PointType, LineType, FunctionType 6 | from . import line as _line 7 | 8 | 9 | def local_max(f: FunctionType, x1: float, x2: float) -> PointType: 10 | ''' Find local maximum of function f between x1 and x2 ''' 11 | x = maximum(f, x1, x2) 12 | y = f(x) 13 | return x, y 14 | 15 | 16 | def local_min(f: FunctionType, x1: float, x2: float) -> PointType: 17 | ''' Find local minimum of function f between x1 and x2 ''' 18 | x = minimum(f, x1, x2) 19 | y = f(x) 20 | return x, y 21 | 22 | 23 | def tangent(f: FunctionType, x: float) -> LineType: 24 | ''' Find tangent to function at x ''' 25 | slope = derivative(f, x) 26 | y = f(x) 27 | y = 0 if not math.isfinite(y) else y 28 | return _line.new_pointslope((x, y), slope) 29 | 30 | 31 | def normal(f: FunctionType, x: float) -> LineType: 32 | ''' Find normal to function at x ''' 33 | try: 34 | slope = -1 / derivative(f, x) 35 | except ZeroDivisionError: 36 | slope = math.inf 37 | y = f(x) 38 | y = 0 if not math.isfinite(y) else y 39 | return _line.new_pointslope((x, y), slope) 40 | -------------------------------------------------------------------------------- /ziaplot/diagram_stack.py: -------------------------------------------------------------------------------- 1 | ''' Diagram Stack for recording Diagrams added inside context managers ''' 2 | 3 | from __future__ import annotations 4 | from typing import Optional, TYPE_CHECKING 5 | 6 | 7 | if TYPE_CHECKING: 8 | from .drawable import Drawable 9 | from .container import Container 10 | 11 | 12 | diagram_stack: dict[Container, Optional[Drawable]] = {} 13 | pause: bool = False 14 | apply_style: list[str] = [] 15 | 16 | 17 | def push_diagram(diagram: Container) -> None: 18 | ''' Add a plot to the stack ''' 19 | diagram_stack[diagram] = None 20 | 21 | 22 | def pop_diagram(diagram: Container) -> None: 23 | ''' Remove the drawing from the stack ''' 24 | diagram_stack.pop(diagram) 25 | 26 | 27 | def push_component(comp: Optional[Drawable]) -> None: 28 | if not pause and len(diagram_stack) > 0: 29 | diagram, prev_comp = list(diagram_stack.items())[-1] 30 | if prev_comp is not None and prev_comp not in diagram: 31 | diagram.add(prev_comp) # type: ignore 32 | for sty in apply_style: 33 | if hasattr(prev_comp, 'style'): 34 | prev_comp.style(sty) 35 | diagram_stack[diagram] = comp 36 | 37 | 38 | def current_diagram() -> Optional[Drawable]: 39 | try: 40 | return list(diagram_stack.keys())[-1] 41 | except IndexError: 42 | return None 43 | -------------------------------------------------------------------------------- /docs/examplegui/guimpl.py: -------------------------------------------------------------------------------- 1 | ''' Example of matplotlib in a Tkinter GUI for comparison 2 | 3 | To build executable with pyinstaller: 4 | 5 | pyinstaller --windowed --onefile guimpl.py 6 | ''' 7 | 8 | import tkinter as tk 9 | import random 10 | 11 | from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg 12 | from matplotlib.figure import Figure 13 | 14 | 15 | class Window: 16 | def __init__(self, master): 17 | self.master = master 18 | master.title('Plot Example') 19 | self.button = tk.Button(self.master, text='Generate Data', command=self.makedata) 20 | self.button.pack() 21 | self.fig = Figure(figsize=(6,4), dpi=100) 22 | self.canvas = FigureCanvasTkAgg(self.fig, master=master) 23 | self.canvas.get_tk_widget().pack() 24 | 25 | self.makedata() 26 | 27 | def makedata(self): 28 | ''' Generate some randomized data and plot it. Convert the plot 29 | to PNG bytes and encode in base64 for Tkinter. 30 | ''' 31 | n = 15 32 | y = [(i*2) + random.normalvariate(10, 2) for i in range(n)] 33 | avg = sum(y)/len(y) 34 | x = list(range(n)) 35 | 36 | self.fig.clf() 37 | ax = self.fig.add_subplot(111) 38 | ax.plot(x, y, marker='o') 39 | ax.axhline(avg) 40 | self.canvas.draw() 41 | 42 | 43 | root = tk.Tk() 44 | gui = Window(root) 45 | root.mainloop() -------------------------------------------------------------------------------- /ziaplot/__init__.py: -------------------------------------------------------------------------------- 1 | from .discrete import ( 2 | PolyLine, 3 | Plot, 4 | Xy, 5 | Scatter, 6 | LinePolar, 7 | Bars, 8 | BarsHoriz, 9 | Histogram, 10 | HistogramHoriz, 11 | ErrorBar, 12 | LineFill, 13 | Contour 14 | ) 15 | from .figures import ( 16 | Function, 17 | Line, 18 | HLine, 19 | VLine, 20 | Point, 21 | Segment, 22 | Vector, 23 | IntegralFill, 24 | Bezier, 25 | BezierSpline, 26 | BezierHobby, 27 | Curve, 28 | CurveThreePoint, 29 | Implicit, 30 | Circle, 31 | Ellipse, 32 | Rectangle, 33 | Polygon, 34 | Arc, 35 | CompassArc 36 | ) 37 | from .diagrams import ( 38 | Diagram, 39 | Graph, 40 | GraphQuad, 41 | GraphQuadCentered, 42 | GraphLogY, 43 | GraphLogX, 44 | GraphLogXY, 45 | GraphPolar, 46 | GraphSmith, 47 | SmithConstResistance, 48 | SmithConstReactance, 49 | NumberLine, 50 | ticker 51 | ) 52 | from .annotations import Text, Angle, Arrow 53 | from .charts import Pie, PieSlice, BarChart, Bar, BarChartGrouped, BarSeries, BarChartHoriz, BarChartGroupedHoriz 54 | from .layout import LayoutH, LayoutV, LayoutGrid, LayoutEmpty 55 | from .config import config 56 | from .util import linspace 57 | from .style import theme, theme_list, css, Css, CSS_BLACKWHITE, CSS_NOGRID, CSS_NOBACKGROUND 58 | from .container import save 59 | from . import geometry 60 | 61 | 62 | __version__ = '0.9a0' 63 | -------------------------------------------------------------------------------- /docs/examplegui/guitk.py: -------------------------------------------------------------------------------- 1 | ''' Example of ziaplot in a Tkinter GUI 2 | 3 | To build executable with pyinstaller: 4 | 5 | pyinstaller --onefile guitk.py 6 | 7 | If/when tkinter natively supports svg (proposed for Tk 8.7), cairosvg won't be necessary. 8 | ''' 9 | 10 | import tkinter as tk 11 | import random 12 | import base64 13 | 14 | import ziaplot as zp 15 | 16 | 17 | class Window: 18 | def __init__(self, master): 19 | self.master = master 20 | master.title('Ziaplot in Tkinter') 21 | self.button = tk.Button(self.master, text='Generate Data', command=self.makedata) 22 | self.button.pack() 23 | self.plot = tk.PhotoImage() 24 | self.label = tk.Label(image=self.plot, height=400, width=600) 25 | self.label.pack() 26 | self.makedata() 27 | 28 | def makedata(self): 29 | ''' Generate some randomized data and plot it. Convert the plot 30 | to PNG bytes and encode in base64 for Tkinter. 31 | ''' 32 | n = 15 33 | y = [(i*2) + random.normalvariate(10, 2) for i in range(n)] 34 | avg = sum(y)/len(y) 35 | x = list(range(n)) 36 | 37 | p = zp.Graph() 38 | p += zp.PolyLine(x, y).marker('o') 39 | p += zp.HLine(avg) 40 | img = base64.encodebytes(p.imagebytes('png')) 41 | self.plot = tk.PhotoImage(data=img) 42 | self.label.configure(image=self.plot) 43 | 44 | root = tk.Tk() 45 | gui = Window(root) 46 | root.mainloop() 47 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = ziaplot 3 | version = attr: ziaplot.__version__ 4 | author = Collin J. Delker 5 | author_email = ziaplot@collindelker.com 6 | url = https://ziaplot.readthedocs.io 7 | description = Draw light-weight plots, graphs, and charts 8 | long_description = file: README.md 9 | long_description_content_type = text/markdown 10 | keywords = plot, chart, graph, smith chart, bar, pie 11 | license = MIT 12 | project_urls = 13 | Documentation = https://ziaplot.readthedocs.io 14 | Source Code = https://github.com/cdelker/ziaplot 15 | classifiers = 16 | Development Status :: 4 - Beta 17 | Programming Language :: Python :: 3 18 | Programming Language :: Python :: 3.9 19 | Programming Language :: Python :: 3.10 20 | Programming Language :: Python :: 3.11 21 | Programming Language :: Python :: 3.12 22 | Programming Language :: Python :: 3.13 23 | License :: OSI Approved :: MIT License 24 | Operating System :: OS Independent 25 | Intended Audience :: Education 26 | Intended Audience :: Science/Research 27 | Intended Audience :: End Users/Desktop 28 | Intended Audience :: Developers 29 | Topic :: Scientific/Engineering :: Mathematics 30 | Topic :: Scientific/Engineering :: Visualization 31 | 32 | 33 | [options] 34 | packages = find: 35 | zip_safe = False 36 | python_requires = >= 3.9 37 | include_package_data = True 38 | 39 | [options.extras_require] 40 | cairo = cairosvg 41 | math = ziafont>=0.10; ziamath>=0.12; latex2mathml 42 | -------------------------------------------------------------------------------- /docs/examplegui/guiqt.py: -------------------------------------------------------------------------------- 1 | ''' Ziaplot in QT example ''' 2 | 3 | import sys 4 | import random 5 | from PyQt5 import QtWidgets, QtGui, QtCore, QtSvg 6 | 7 | import ziaplot as zp 8 | 9 | 10 | class MainGUI(QtWidgets.QMainWindow): 11 | ''' Main Window ''' 12 | 13 | def __init__(self, parent=None): 14 | super(MainGUI, self).__init__(parent) 15 | self.setWindowTitle('Ziaplot in QT') 16 | 17 | self.button = QtWidgets.QPushButton('Generate Data') 18 | self.button.clicked.connect(self.makedata) 19 | self.image = QtSvg.QSvgWidget() 20 | self.image.setMinimumWidth(600) 21 | self.image.setMinimumHeight(400) 22 | 23 | layout = QtWidgets.QVBoxLayout() 24 | layout.addWidget(self.button) 25 | layout.addWidget(self.image) 26 | 27 | centralWidget = QtWidgets.QWidget(self) 28 | centralWidget.setLayout(layout) 29 | self.setCentralWidget(centralWidget) 30 | 31 | def makedata(self): 32 | ''' Generate some randomized data and plot it, then 33 | display using QSvgWidget. ''' 34 | n = 15 35 | y = [(i*2) + random.normalvariate(10, 2) for i in range(n)] 36 | avg = sum(y)/len(y) 37 | x = list(range(n)) 38 | p = zp.Graph() 39 | p += zp.PolyLine(x, y).marker('o') 40 | p += zp.HLine(avg) 41 | self.image.load(p.imagebytes()) 42 | 43 | 44 | def main(): 45 | app = QtWidgets.QApplication(sys.argv) 46 | main = MainGUI() 47 | main.show() 48 | app.exec_() 49 | 50 | if __name__ == '__main__': 51 | main() 52 | -------------------------------------------------------------------------------- /ziaplot/figures/polygon.py: -------------------------------------------------------------------------------- 1 | ''' Polygon ''' 2 | from __future__ import annotations 3 | from typing import Optional, Sequence 4 | 5 | from ..canvas import Canvas, Borders, ViewBox, DataRange 6 | from ..geometry import PointType 7 | from .shapes import Shape 8 | 9 | 10 | class Polygon(Shape): 11 | ''' A Polygon 12 | 13 | Args: 14 | v: Vertices 15 | ''' 16 | def __init__(self, verts: Sequence[PointType]): 17 | super().__init__() 18 | self.verts = verts 19 | 20 | def datarange(self) -> DataRange: 21 | ''' Get range of data ''' 22 | xs = [v[0] for v in self.verts] 23 | ys = [v[1] for v in self.verts] 24 | return DataRange(min(xs), 25 | max(xs), 26 | min(ys), 27 | max(ys)) 28 | 29 | def color(self, color: str) -> 'Polygon': 30 | ''' Sets the edge color ''' 31 | self._style.edge_color = color 32 | return self 33 | 34 | def fill(self, color: str) -> 'Polygon': 35 | ''' Set the region fill color and transparency 36 | 37 | Args: 38 | color: Fill color 39 | ''' 40 | self._style.fill_color = color 41 | return self 42 | 43 | def _xml(self, canvas: Canvas, databox: Optional[ViewBox] = None, 44 | borders: Optional[Borders] = None) -> None: 45 | ''' Add XML elements to the canvas ''' 46 | sty = self._build_style() 47 | canvas.poly(self.verts, 48 | color=sty.fill_color, 49 | strokecolor=sty.get_color(), 50 | strokewidth=sty.stroke_width, 51 | dataview=databox, 52 | zorder=self._zorder) 53 | -------------------------------------------------------------------------------- /docs/images/flowchart.py: -------------------------------------------------------------------------------- 1 | ''' Create the inheritance flowchart ''' 2 | import schemdraw 3 | from schemdraw import flow 4 | 5 | 6 | flow.Box.defaults['anchor'] = 'N' 7 | flow.Box.defaults['fill'] = 'azure' 8 | with schemdraw.Drawing(file='inheritance.svg'): 9 | draw = flow.Box(h=1).label('Drawable') 10 | cont = flow.Box(h=1).at((-5.5, -2)).label('Container') 11 | layout = flow.Box(h=1).at((-7.5, -4)).label('Layout') 12 | layoutgrid = flow.Box(h=1).at((-7.5, -6)).label('LayoutGrid') 13 | layout_ex = flow.Box().at((-7.5, -8)).label('LayoutH\nLayoutV') 14 | diag = flow.Box(h=1).at((-3.5, -4)).label('Diagram') 15 | graph = flow.Box().at((-3.5, -8)).label('Graph\nGraphQuad\nGraphLog\nGraphPolar\nGraphSmith\nBarChart\nPie') 16 | 17 | comp = flow.Box(h=1).at((5.5, -2)).label('Component') 18 | elm = flow.Box(h=1).at((2.5, -4)).label('Element') 19 | discrete = flow.Box(h=1).at((.5, -6)).label('Discrete') 20 | discrete_ex = flow.Box().at((.5, -8)).label('PolyLine\nScatter\nErrorBar\nLineFill\nLinePolar\nHistogram\nContour') 21 | geo = flow.Box().at((4.5, -8)).label('Point\nFunction\nLine\nSegment\nCurve\nBezier\nIntegralFill\nCircle\nEllipse\nRectangle\nBars\nPieSlice') 22 | annot = flow.Box(h=1).at((8.5, -4)).label('Annotations') 23 | annot_ex = flow.Box().at((8.5, -8)).label('Text\nArrow\nAngle') 24 | 25 | flow.Arrow().at(draw.S).to(cont.N) 26 | flow.Arrow().at(draw.S).to(comp.N) 27 | flow.Arrow().at(cont.S).to(layout.N) 28 | flow.Arrow().at(cont.S).to(diag.N) 29 | flow.Arrow().at(layout.S).to(layoutgrid.N) 30 | flow.Arrow().at(layoutgrid.S).to(layout_ex.N) 31 | flow.Arrow().at(diag.S).to(graph.N) 32 | flow.Arrow().at(comp.S).to(elm.N) 33 | flow.Arrow().at(elm.S).to(discrete.N) 34 | flow.Arrow().at(discrete.S).to(discrete_ex.N) 35 | flow.Arrow().at(elm.S).to(geo.N) 36 | flow.Arrow().at(comp.S).to(annot.N) 37 | flow.Arrow().at(annot.S).to(annot_ex.N) -------------------------------------------------------------------------------- /ziaplot/annotations/text.py: -------------------------------------------------------------------------------- 1 | '''' Text annotations ''' 2 | from __future__ import annotations 3 | from typing import Optional 4 | import math 5 | 6 | from ..text import Halign, Valign 7 | from ..canvas import Canvas, Borders, ViewBox, DataRange 8 | from ..element import Component 9 | 10 | 11 | class Text(Component): 12 | ''' A text element to draw at a specific x-y location 13 | 14 | Args: 15 | x: X-position for text 16 | y: Y-position for text 17 | s: String to draw 18 | halign: Horizontal alignment 19 | valign: Vertical alignment 20 | rotate: Rotation angle, in degrees 21 | ''' 22 | def __init__(self, x: float, y: float, s: str, 23 | halign: Halign = 'left', 24 | valign: Valign = 'bottom', 25 | rotate: Optional[float] = None): 26 | super().__init__() 27 | self.x = x 28 | self.y = y 29 | self.s = s 30 | self.halign = halign 31 | self.valign = valign 32 | self.rotate = rotate 33 | self._zorder: int = 10 34 | 35 | def color(self, color: str) -> 'Text': 36 | ''' Sets the text color ''' 37 | self._style.color = color 38 | return self 39 | 40 | def datarange(self) -> DataRange: 41 | ''' Get x-y datarange ''' 42 | return DataRange(None, None, None, None) 43 | 44 | def _logy(self) -> None: 45 | ''' Convert y coordinates to log(y) ''' 46 | self.y = math.log10(self.y) 47 | 48 | def _logx(self) -> None: 49 | ''' Convert x values to log(x) ''' 50 | self.x = math.log10(self.x) 51 | 52 | def _xml(self, canvas: Canvas, databox: Optional[ViewBox] = None, 53 | borders: Optional[Borders] = None) -> None: 54 | ''' Add XML elements to the canvas ''' 55 | sty = self._build_style() 56 | canvas.text(self.x, self.y, self.s, 57 | color=sty.get_color(), 58 | font=sty.font, 59 | size=sty.font_size, 60 | halign=self.halign, 61 | valign=self.valign, 62 | rotate=self.rotate, 63 | dataview=databox, 64 | zorder=self._zorder) 65 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | # import os 14 | # import sys 15 | # sys.path.insert(0, os.path.abspath('.')) 16 | 17 | import importlib 18 | 19 | # -- Project information ----------------------------------------------------- 20 | 21 | project = 'ziaplot' 22 | copyright = '2021-2025, Collin J. Delker' 23 | author = 'Collin J. Delker' 24 | 25 | release = importlib.metadata.version('ziaplot') 26 | 27 | 28 | # -- General configuration --------------------------------------------------- 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = [ 34 | 'jupyter_sphinx', 35 | 'sphinx.ext.autodoc', 36 | 'sphinx.ext.autodoc.typehints', 37 | 'sphinx.ext.napoleon' 38 | ] 39 | 40 | # Add any paths that contain templates here, relative to this directory. 41 | templates_path = [] 42 | 43 | # List of patterns, relative to source directory, that match files and 44 | # directories to ignore when looking for source files. 45 | # This pattern also affects html_static_path and html_extra_path. 46 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store', '**.ipynb_checkpoints'] 47 | 48 | 49 | # -- Options for HTML output ------------------------------------------------- 50 | 51 | # The theme to use for HTML and HTML Help pages. See the documentation for 52 | # a list of builtin themes. 53 | # 54 | html_theme = 'alabaster' 55 | 56 | # Add any paths that contain custom static files (such as style sheets) here, 57 | # relative to this directory. They are copied after the builtin static files, 58 | # so a file named "default.css" will overwrite the builtin "default.css". 59 | html_static_path = [] 60 | -------------------------------------------------------------------------------- /ziaplot/container.py: -------------------------------------------------------------------------------- 1 | ''' Drawing containers for holding Diagrams and Components ''' 2 | from __future__ import annotations 3 | 4 | from .drawable import Drawable 5 | from .style.style import Style, AppliedStyle 6 | from .style.css import CssStyle, parse_css, merge_css 7 | from .style.themes import zptheme 8 | from . import diagram_stack 9 | 10 | 11 | class Container(Drawable): 12 | ''' Drawing container base class (either Diagrams or Layouts) ''' 13 | def __init__(self) -> None: 14 | super().__init__() 15 | self._containerstyle = CssStyle() 16 | self._style = Style() 17 | self._cycleindex = 0 18 | self.width: float|None = None 19 | self.height: float|None = None 20 | self._zorder: int = 0 # Containers drawn at bottom 21 | 22 | def __contains__(self, comp: Drawable): 23 | return False # Checked in subclasses of Container 24 | 25 | def style(self, css: str) -> 'Drawable': 26 | '''Set the style for this Drawable using CSS elements ''' 27 | self._style = merge_css(self._style, css) 28 | return self 29 | 30 | def css(self, css: str) -> 'Container': 31 | ''' Set the CSS style ''' 32 | self._containerstyle = parse_css(css) 33 | return self 34 | 35 | def _build_style(self, name: str|None = None) -> AppliedStyle: 36 | ''' Build the Style ''' 37 | if name is None: 38 | classes = [p.__qualname__ for p in self.__class__.mro()] 39 | else: 40 | classes = [name, '*'] 41 | 42 | return zptheme.style( 43 | *classes, 44 | cssid=self._cssid, 45 | cssclass=self._csscls, 46 | container=self._containerstyle, 47 | instance=self._style) 48 | 49 | 50 | def save(fname: str) -> None: 51 | ''' Save the current drawing to a file. Must be 52 | used within a Diagram or Layout context manager. 53 | 54 | Args: 55 | fname: Filename, with extension. 56 | 57 | Notes: 58 | SVG format is always supported. EPS, PDF, and PNG formats are 59 | available when the `cairosvg` package is installed 60 | ''' 61 | diagram_stack.push_component(None) 62 | d = diagram_stack.current_diagram() 63 | if d is None: 64 | raise ValueError('No diagram to save. ziaplot.save must be run within context manager.') 65 | diagram_stack.pause = True 66 | d.save(fname) 67 | diagram_stack.pause = False 68 | -------------------------------------------------------------------------------- /ziaplot/config.py: -------------------------------------------------------------------------------- 1 | ''' Global configuration options ''' 2 | from typing import Literal 3 | 4 | try: 5 | import ziamath # type: ignore 6 | from ziafont import config as zfconfig 7 | except ImportError: 8 | zfconfig = None # type: ignore 9 | 10 | TextMode = Literal['text', 'path'] 11 | 12 | 13 | class Config: 14 | ''' Global configuration options for Ziaplot 15 | 16 | Attributes 17 | ---------- 18 | text: How to represent text elements in SVG. Either 'text' 19 | or 'path'. 20 | svg2: Use SVG2.0. Disable for better browser compatibility, 21 | at the expense of SVG size 22 | precision: SVG decimal precision for coordinates 23 | ''' 24 | _text: TextMode = 'path' if zfconfig is not None else 'text' 25 | _svg2: bool = True 26 | _precision: float = 4 27 | 28 | def __repr__(self): 29 | return f'ZPconfig(text={self.text}; svg2={self.svg2}; precision={self.precision})' 30 | 31 | @property 32 | def svg2(self) -> bool: 33 | if zfconfig is not None: 34 | return zfconfig.svg2 35 | else: 36 | return self._svg2 37 | 38 | @svg2.setter 39 | def svg2(self, value: bool) -> None: 40 | if zfconfig is not None: 41 | zfconfig.svg2 = value 42 | else: 43 | self._svg2 = value 44 | 45 | @property 46 | def precision(self) -> float: 47 | if zfconfig is not None: 48 | return zfconfig.precision 49 | else: 50 | return self._precision 51 | 52 | @precision.setter 53 | def precision(self, value: float) -> None: 54 | if zfconfig is not None: 55 | zfconfig.precision = value 56 | else: 57 | self._precision = value 58 | 59 | @property 60 | def text(self) -> TextMode: 61 | ''' One of 'path' or 'text'. In 'text' mode, text is drawn 62 | as SVG elements and will be searchable in the 63 | SVG, however it may render differently on systems without 64 | the same fonts installed. In 'path' mode, text is 65 | converted to SVG elements and will render 66 | independently of any fonts on the system. Path mode 67 | enables full rendering of math expressions, but also 68 | requires the ziafont/ziamath packages. 69 | ''' 70 | return self._text 71 | 72 | @text.setter 73 | def text(self, value: TextMode) -> None: 74 | if value == 'path' and zfconfig is None: 75 | raise ValueError('Path mode requires ziamath package') 76 | if value not in ['path', 'text']: 77 | raise ValueError('text mode must be "path" or "text".') 78 | self._text = value 79 | 80 | 81 | config = Config() 82 | -------------------------------------------------------------------------------- /ziaplot/figures/integral.py: -------------------------------------------------------------------------------- 1 | ''' Integral Fills ''' 2 | from __future__ import annotations 3 | from typing import Optional 4 | 5 | from .. import util 6 | from .. import geometry 7 | from ..canvas import Canvas, Borders, ViewBox 8 | from ..element import Element 9 | from .function import Function 10 | 11 | 12 | class IntegralFill(Element): 13 | ''' Fill between two functions or between a function and the x-axis 14 | 15 | Args: 16 | f: Function or Line instance 17 | f2: Another Function instance 18 | x1: Starting x value to fill 19 | x2: Ending x value to fill 20 | ''' 21 | _step_color = True 22 | 23 | def __init__(self, f: Function, f2: Optional[Function] = None, 24 | x1: Optional[float] = None, x2: Optional[float] = None): 25 | super().__init__() 26 | self.func = f 27 | self.func2 = f2 28 | self.x1 = x1 29 | self.x2 = x2 30 | self._zorder: int = 1 # Place below function lines 31 | 32 | def _xlimits(self, databox: ViewBox) -> tuple[float, float]: 33 | ''' Get x-limits to draw over ''' 34 | if self.x1 is not None: 35 | x1 = self.x1 36 | elif self.func.xrange: 37 | x1 = self.func.xrange[0] 38 | else: 39 | x1 = databox.x 40 | if self.x2 is not None: 41 | x2 = self.x2 42 | elif self.func.xrange: 43 | x2 = self.func.xrange[1] 44 | else: 45 | x2 = databox.x + databox.w 46 | return x1, x2 47 | 48 | def _xml(self, canvas: Canvas, databox: Optional[ViewBox] = None, 49 | borders: Optional[Borders] = None) -> None: 50 | ''' Add XML elements to the canvas ''' 51 | assert databox is not None 52 | x1, x2 = self._xlimits(databox) 53 | xrange = util.linspace(x1, x2, self.func.n) 54 | x, y = self.func._evaluate(xrange) 55 | 56 | if self.func2: 57 | _, ymin = self.func2._evaluate(xrange) 58 | else: 59 | ymin = [0] * len(x) 60 | 61 | xy = list(zip(x, y)) 62 | xy = xy + list(reversed(list(zip(x, ymin)))) 63 | 64 | sty = self._build_style() 65 | fill = sty.get_color() 66 | 67 | canvas.poly(xy, color=fill, 68 | strokecolor='none', 69 | dataview=databox, 70 | zorder=self._zorder) 71 | 72 | @classmethod 73 | def intersection(cls, f: Function, f2: Function, 74 | x1: float, x2: float): 75 | ''' Integral fill between intersection of f and f2, where 76 | x1 and x2 are points outside the intersection 77 | ''' 78 | mid = (x1+x2)/2 79 | a, _ = geometry.intersect.functions(f.y, f2.y, x1, mid) 80 | b, _ = geometry.intersect.functions(f.y, f2.y, mid, x2) 81 | return cls(f, f2, a, b) 82 | -------------------------------------------------------------------------------- /ziaplot/geometry/circle.py: -------------------------------------------------------------------------------- 1 | ''' Calculations on circles ''' 2 | from __future__ import annotations 3 | from typing import TYPE_CHECKING 4 | import math 5 | 6 | from .geometry import PointType, LineType, CircleType, isclose 7 | from . import line as _line 8 | 9 | if TYPE_CHECKING: 10 | from ..figures.shapes import Circle 11 | from ..figures.point import Point 12 | 13 | 14 | def point(circle: CircleType|'Circle', theta: float) -> PointType: 15 | ''' Coordinates of point on circle at angle theta (rad) ''' 16 | centerx, centery, radius, *_ = circle 17 | x = centerx + radius * math.cos(theta) 18 | y = centery + radius * math.sin(theta) 19 | return (x, y) 20 | 21 | 22 | def tangent_angle(theta: float) -> float: 23 | ''' Angle of tangent to circle at given theta around circle ''' 24 | return (theta + math.pi/2 + math.tau) % math.tau 25 | 26 | 27 | def tangent_at(circle: CircleType|'Circle', theta: float) -> LineType: 28 | ''' Find tanget to circle at given theta ''' 29 | x, y = point(circle, theta) 30 | phi = tangent_angle(theta) 31 | slope = math.tan(phi) 32 | intercept = -slope*x+y 33 | return _line.new(slope, intercept) 34 | 35 | 36 | def tangent_points(circle: CircleType|'Circle', 37 | p: PointType|'Point') -> tuple[PointType, PointType]: 38 | ''' Find the two points on the circle that form a tangent line through 39 | the given point 40 | ''' 41 | centerx, centery, radius, *_ = circle 42 | px, py = p 43 | 44 | px = px - centerx 45 | py = py - centery 46 | rsq = radius**2 47 | dsq = px**2 + py**2 48 | 49 | try: 50 | x1 = centerx + rsq/dsq*px + radius/dsq*math.sqrt(dsq - rsq) * (-py) 51 | y1 = centery + rsq/dsq*py + radius/dsq*math.sqrt(dsq - rsq) * px 52 | x2 = centerx + rsq/dsq*px - radius/dsq*math.sqrt(dsq - rsq) * (-py) 53 | y2 = centery + rsq/dsq*py - radius/dsq*math.sqrt(dsq - rsq) * px 54 | except ZeroDivisionError as exc: 55 | raise ValueError('Point is in circle. No tangent.') from exc 56 | return (x1, y1), (x2, y2) 57 | 58 | 59 | def tangent(circle: CircleType|'Circle', 60 | p: PointType|'Point') -> tuple[tuple[PointType, float], tuple[PointType, float]]: 61 | ''' Find tangent points and slope of the two tangents to the circle through the point p ''' 62 | centerx, centery, radius, *_ = circle 63 | p1, p2 = tangent_points(circle, p) 64 | 65 | x, y = p 66 | if isclose(p1, p): 67 | theta = math.atan2(p[1]-centery, p[0]-centerx) 68 | m1 = math.tan(tangent_angle(theta)) 69 | return ((x, y), m1), ((x, y), m1) 70 | 71 | try: 72 | m1 = (p1[1] - p[1]) / (p1[0] - p[0]) 73 | except ZeroDivisionError: 74 | m1 = math.inf 75 | 76 | try: 77 | m2 = (p2[1] - p[1]) / (p2[0] - p[0]) 78 | except ZeroDivisionError: 79 | m2 = math.inf 80 | 81 | return (p1, m1), (p2, m2) 82 | -------------------------------------------------------------------------------- /ziaplot/style/colors.py: -------------------------------------------------------------------------------- 1 | ''' Color cylces ''' 2 | from __future__ import annotations 3 | from typing import Sequence 4 | 5 | from ..util import interp, linspace 6 | 7 | 8 | class ColorCycle: 9 | ''' Color cycle for changing colors of plot lines 10 | 11 | Args: 12 | cycle: List of string colors, either SVG-compatible names 13 | or '#FFFFFF' hex values 14 | ''' 15 | def __init__(self, *colors: str): 16 | if len(colors) > 0: 17 | self.cycle = list(colors) 18 | else: 19 | self.cycle = ['#ba0c2f', '#ffc600', '#007a86', '#ed8b00', 20 | '#8a387c', '#a8aa19', '#63666a', '#c05131', 21 | '#d6a461', '#a7a8aa'] 22 | self._steps = 10 23 | 24 | def steps(self, n: int) -> None: 25 | ''' Set number of steps in cycle ''' 26 | pass # Nothing to do in regular cycle 27 | 28 | def __getitem__(self, item): 29 | if isinstance(item, str): 30 | try: 31 | item = int(item[1:]) # 'C0', etc. 32 | except ValueError: 33 | return item # named color 34 | return self.cycle[item % len(self.cycle)] 35 | 36 | 37 | class ColorFade(ColorCycle): 38 | ''' Color cycle for changing colors of plot lines by fading 39 | between two colors 40 | 41 | Args: 42 | colors: List of string colors, either SVG-compatible names 43 | or '#FFFFFF' hex values 44 | stops: List of stop positions for each color in the 45 | gradient, starting with 0 and ending with 1. 46 | ''' 47 | def __init__(self, *colors: str, stops: Sequence[float]|None = None): 48 | if not all(c[0] == '#' for c in colors): 49 | raise ValueError('ColorFade colors must be #FFFFFF format.') 50 | self._colors = list(colors) 51 | self._stops = stops 52 | if self._stops is not None: 53 | if len(self._stops) != len(colors): 54 | raise ValueError('Stops must be same length as colors') 55 | if self._stops[0] != 0 or self._stops[-1] != 1: 56 | raise ValueError('First stop must be 0 and last stop must be 1') 57 | 58 | self.steps(len(self._colors)) 59 | super().__init__(*self._colors) 60 | 61 | def colors(self, n: int) -> list[str]: 62 | ''' Get list of colors for n steps ''' 63 | self.steps(n) 64 | return self.cycle 65 | 66 | def steps(self, n: int) -> None: 67 | ''' Set number of steps between start and end color ''' 68 | self._steps = n 69 | 70 | if n < 2: 71 | self.cycle = self._colors 72 | return 73 | 74 | if self._stops is None: 75 | self._stops = linspace(0, 1, len(self._colors)) # Evenly spaced colors... 76 | 77 | norm_steps = linspace(0, 1, self._steps) 78 | 79 | stop_r = [int(c[1:3], 16) for c in self._colors] 80 | stop_g = [int(c[3:5], 16) for c in self._colors] 81 | stop_b = [int(c[5:7], 16) for c in self._colors] 82 | 83 | R = interp(norm_steps, self._stops, stop_r) 84 | G = interp(norm_steps, self._stops, stop_g) 85 | B = interp(norm_steps, self._stops, stop_b) 86 | R = [int(x) for x in R] 87 | G = [int(x) for x in G] 88 | B = [int(x) for x in B] 89 | self.cycle = [f'#{rr:02x}{gg:02x}{bb:02x}' for rr, gg, bb in zip(R, G, B)] 90 | -------------------------------------------------------------------------------- /ziaplot/style/style.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Sequence, Literal 3 | from dataclasses import dataclass, asdict 4 | 5 | 6 | MarkerTypes = Literal['round', 'o', 'square', 's', 'triangle', '^', 7 | 'triangled', 'v', 'larrow', '<', 'arrow', '>', 8 | '+', 'x', '-', '|', '||', '|||', 'undefined', None] 9 | DashTypes = Literal['-', ':', 'dotted', '--', 'dashed', '-.', '.-', 'dashdot'] 10 | ColorType = str 11 | TextPosition = Literal['N', 'E', 'S', 'W', 12 | 'NE', 'NW', 'SE', 'SW'] 13 | Halign = Literal['left', 'center', 'right'] 14 | Valign = Literal['top', 'center', 'baseline', 'base', 'bottom'] 15 | 16 | 17 | @dataclass 18 | class Style: 19 | ''' Style parameters, common to all Drawables 20 | 21 | All initiated to None, which means get from parent 22 | ''' 23 | color: ColorType | None = None 24 | edge_color: ColorType | None = None 25 | fill_color: ColorType | None = None 26 | stroke: DashTypes | None = None 27 | stroke_width: float | None = None 28 | 29 | shape: MarkerTypes | None = None 30 | radius: float | None = None 31 | edge_width: float | None = None 32 | 33 | font: str | None = None 34 | font_size: float | str | None = None 35 | num_format: str | None = None 36 | 37 | height: float | None = None 38 | width: float | None = None 39 | margin: float | None = None 40 | pad: float | None = None 41 | 42 | colorcycle: Sequence[ColorType] | None = None 43 | colorfade: Sequence[str] | None = None 44 | _cycleindex: int = 0 45 | 46 | def __post_init__(self): 47 | if isinstance(self.colorcycle, str): 48 | self.colorcycle = [self.colorcycle] 49 | 50 | def _set_cycle_index(self, i: int) -> None: 51 | self._cycleindex = i 52 | 53 | def values(self): 54 | ''' Get dict of values that are not None ''' 55 | return {k: v for k, v in asdict(self).items() if v is not None} 56 | 57 | 58 | @dataclass 59 | class AppliedStyle: 60 | ''' An applied style - Nones not allowed (mostly to help 61 | with typing) 62 | ''' 63 | color: ColorType 64 | edge_color: ColorType 65 | fill_color: ColorType 66 | stroke: DashTypes 67 | stroke_width: float 68 | 69 | shape: MarkerTypes 70 | radius: float 71 | edge_width: float 72 | 73 | font: str 74 | font_size: float 75 | num_format: str 76 | 77 | height: float 78 | width: float 79 | margin: float 80 | pad: float 81 | 82 | colorcycle: Sequence[ColorType] 83 | colorfade: Sequence[str] | None = None 84 | _cycleindex: int = 0 85 | 86 | def _set_cycle_index(self, i: int) -> None: 87 | self._cycleindex = i 88 | 89 | def values(self): 90 | ''' Get dict of values that are not None ''' 91 | return {k: v for k, v in asdict(self).items() if v is not None} 92 | 93 | def get_color(self) -> str: 94 | ''' Get color, pulling from colorcycle if necessary ''' 95 | 96 | if self.color in ['auto', None]: 97 | i = self._cycleindex 98 | return self.colorcycle[i % len(self.colorcycle)] 99 | 100 | elif self.color.startswith('C') and self.color[1:].isnumeric(): 101 | i = int(self.color[1:]) 102 | return self.colorcycle[i % len(self.colorcycle)] 103 | 104 | return self.color 105 | -------------------------------------------------------------------------------- /ziaplot/geometry/ellipse.py: -------------------------------------------------------------------------------- 1 | ''' Calculations on ellipses ''' 2 | from __future__ import annotations 3 | from typing import TYPE_CHECKING 4 | import math 5 | 6 | from .geometry import PointType, EllipseType, rotate, translate 7 | from . import line as _line 8 | 9 | if TYPE_CHECKING: 10 | from ..figures.shapes import Ellipse 11 | from ..figures.point import Point 12 | 13 | 14 | def _dxy(ellipse: EllipseType|'Ellipse', theta: float) -> PointType: 15 | ''' Get distance from center to point on ellipse at angle theta (rad) ''' 16 | centerx, centery, r1, r2, angle = ellipse 17 | e = math.sqrt(1 - (r2 / r1)**2) 18 | phi = math.atan(math.tan(angle) * r1 / r2) 19 | r = r1 * math.sqrt(1 - e**2 * math.sin(phi)**2) 20 | dx = r * math.cos(theta) 21 | dy = r * math.sin(theta) 22 | return (dx, dy) 23 | 24 | 25 | def point(ellipse: EllipseType|'Ellipse', theta: float) -> PointType: 26 | ''' Get point on ellipse at angle theta ''' 27 | dx, dy = _dxy(ellipse, theta) 28 | centerx, centery, r1, r2, etheta = ellipse 29 | if etheta: 30 | costh = math.cos(math.radians(etheta)) 31 | sinth = math.sin(math.radians(etheta)) 32 | dx, dy = dx * costh - dy * sinth, dx * sinth + dy * costh 33 | return (centerx + dx, centery + dy) 34 | 35 | 36 | def tangent_points(ellipse: EllipseType|'Ellipse', 37 | p: PointType|'Point') -> tuple[PointType, PointType]: 38 | ''' Find the two points on the Ellipse that form a tangent line through 39 | the given point 40 | ''' 41 | cx, cy, rx, ry, theta = ellipse 42 | px, py = p 43 | theta = math.radians(theta) 44 | 45 | # Shift to origin and rotate -theta 46 | px = px - cx 47 | py = py - cy 48 | if theta != 0: 49 | px, py = rotate((px, py), -theta) 50 | 51 | # Algebraic solution gives div/0 if px==rx 52 | if math.isclose(px, rx): 53 | m1 = math.inf 54 | m2 = (py-ry)*(py+ry)/(2*py*rx) 55 | else: 56 | m1 = (px*py - math.sqrt(px**2*ry**2 + py**2*rx**2 - rx**2*ry**2))/(px**2-rx**2) 57 | m2 = (px*py + math.sqrt(px**2*ry**2 + py**2*rx**2 - rx**2*ry**2))/(px**2-rx**2) 58 | 59 | # Tangent lines, untranslated 60 | tline1 = _line.new_pointslope((px, py), m1) 61 | tline2 = _line.new_pointslope((px, py), m2) 62 | 63 | # Points of tangency on the ellipse 64 | if math.isclose(px, rx): 65 | t1 = rx, 0. 66 | t2x = rx*(ry**2-py**2)/(py**2+ry**2) 67 | t2 = t2x, _line.yvalue(tline2, t2x) 68 | else: 69 | t1x = (2*m1**2*px/ry**2 - 2*m1*py/ry**2) / (2*m1**2/ry**2 + 2/rx**2) 70 | t2x = (2*m2**2*px/ry**2 - 2*m2*py/ry**2) / (2*m2**2/ry**2 + 2/rx**2) 71 | t1 = t1x, _line.yvalue(tline1, t1x) 72 | t2 = t2x, _line.yvalue(tline2, t2x) 73 | 74 | # Now translate and rotate back 75 | if theta != 0: 76 | m1 = math.tan(math.atan(m1) + theta) 77 | m2 = math.tan(math.atan(m2) + theta) 78 | t1 = rotate(t1, theta) 79 | t2 = rotate(t2, theta) 80 | t1 = translate(t1, (cx, cy)) 81 | t2 = translate(t2, (cx, cy)) 82 | return t1, t2 83 | 84 | 85 | def tangent_angle(ellipse: EllipseType|'Ellipse', theta: float) -> float: 86 | ''' Angle (radians) tangent to the Ellipse at theta (radians) ''' 87 | dx, dy = _dxy(ellipse, theta) 88 | centerx, centery, r1, r2, etheta = ellipse 89 | phi = math.atan2(dy * r1**2, dx * r2**2) 90 | tan = phi + math.pi/2 + math.radians(etheta) 91 | return (tan + math.tau) % math.tau 92 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | # Release notes 2 | 3 | ### 0.9 - In progress 4 | 5 | Contains breaking changes: 6 | 7 | - zp.calcs module was removed and merged with zp.geometry module. 8 | - Removed `index` parameter from intersection functions, replaced with smarter `which` parameter. 9 | - Changed Point, Circle, and Ellipse to take PointType (tuple) instead of separate x and y values. 10 | - Renamed `cssclass` method to `cls`. 11 | - Merged `BezierQuad` and `BezierCubic` into single `Bezier` class. 12 | 13 | 14 | Other enhancements: 15 | 16 | - Added Arc and ArcCompass geometric figures 17 | - Added Css context manager for temporarily applying a style 18 | - Apply stroke style to ellipses, rectangles, and arcs 19 | - Added `fill_color` css attribute for fill of geometric shapes 20 | - Added methods to bisect two points, and image and reflect points over lines 21 | 22 | 23 | 24 | ### 0.8 - 2025-03-02 25 | 26 | - Fix zorder of legends 27 | - Added BezierSpline and Hobby Curves 28 | - Add Polygon 29 | - Fix rectangles with negative width or height 30 | - Fix histogram with only one bin 31 | - Fix rectangle fill transparency 32 | - Added colorblind-friendly themes 33 | 34 | 35 | ### 0.7 - 2024-08-24 36 | 37 | - Added `NumberLine` graph type 38 | - Added a zorder parameter for layering drawing elements 39 | - Allow named font sizes, such as "large" and "small", in CSS 40 | - Change arrowhead marker to point at its data coordinate rather than be centered over it 41 | 42 | 43 | ### 0.6 - 2024-07-27 44 | 45 | This release includes major BREAKING CHANGES. The API was reworked to focus more on 46 | graphing of geometric primitives (Lines, Circles, Points, etc.) in addition to empirical data. 47 | It also adds a CSS styling system. 48 | 49 | - Added geometric objects and shapes, including `Line`, `Point`, `Circle`, `BezierQuad`, `Function`, etc., in the `geo` submodule. 50 | - The old `Line` has become `PolyLine`, with alias `Plot`. 51 | - The new `Line` represents a true Euclidean line rather than a set of (x, y) coordinates connected by line segments. 52 | - Added `LayoutGrid` for placing axes in a grid. `LayoutH` and `LayoutV` now inherit from `LayoutGrid`, and no longer support nested layouts. 53 | - Added `HistogramHoriz` for horizontal histograms 54 | - Added `Implicit` to plot implicit functions 55 | - Renamed axes to `Graph`, `GraphQuad`, `GraphPolar`, `GraphSmith`. `Diagram` added as an empty surface for geometric diagrams. 56 | - Added tangent and normal lines. 57 | - Added point placemenet at intersections and extrema. 58 | - Added `ticker` for easy forming of tick locations 59 | - Added `equal_aspect` method to equalize x and y scales 60 | - Changed to use a CSS-like styling system for applying styles and themes. 61 | - Added more legend location options 62 | 63 | 64 | ### 0.5 - 2023-12-20 65 | 66 | - Updated Pie and Bar interfaces 67 | - Fixed legend text spacing 68 | - Added context manager for axes and layouts 69 | - Allow more than 2 colors in colorfade 70 | - Added contour plots 71 | - Added LayoutGap for leaving space in a Layout 72 | 73 | 74 | ### 0.4 - 2022-06-20 75 | 76 | - Implement ziamath's Text object for plot labels 77 | - Added size property to plot style, combining canvas width and canvas height 78 | - Added global configuration object 79 | 80 | 81 | ### 0.3 82 | 83 | - Added an option to use SVG1.x format for compatibility since SVG2.0 is not fully supported in all browsers/renderers yet. 84 | 85 | 86 | ### 0.2 87 | 88 | - Use ziamath library to draw math text. 89 | 90 | 91 | ### 0.1 92 | 93 | Initial Release 94 | -------------------------------------------------------------------------------- /ziaplot/element.py: -------------------------------------------------------------------------------- 1 | ''' Geometric Element base classes 2 | 3 | Component: Anything that can be added to a Diagram 4 | Element: Geometric element made of points, lines, planes 5 | ''' 6 | from __future__ import annotations 7 | 8 | from . import diagram_stack 9 | from .drawable import Drawable 10 | from .canvas import DataRange 11 | from .style.style import Style, AppliedStyle, DashTypes 12 | from .style.css import merge_css, CssStyle 13 | from .style.themes import zptheme 14 | 15 | 16 | class Component(Drawable): 17 | ''' Base class for things added to a Diagram ''' 18 | _step_color = False 19 | 20 | def __init__(self) -> None: 21 | super().__init__() 22 | self._style = Style() 23 | self._containerstyle: CssStyle | None = None 24 | self._name: str|None = None 25 | self._zorder: int = 2 # Components draw on top of containers 26 | self._erased: bool = False 27 | diagram_stack.push_component(self) 28 | 29 | def style(self, style: str) -> 'Component': 30 | ''' Add CSS key-name pairs to the style ''' 31 | self._style = merge_css(self._style, style) 32 | return self 33 | 34 | def _build_style(self, name: str|None = None) -> AppliedStyle: 35 | ''' Build the style ''' 36 | if name is None: 37 | classes = [p.__qualname__ for p in self.__class__.mro()] 38 | else: 39 | classes = [name, '*'] 40 | 41 | return zptheme.style( 42 | *classes, 43 | cssid=self._cssid, 44 | cssclass=self._csscls, 45 | container=self._containerstyle, 46 | instance=self._style) 47 | 48 | def color(self, color: str) -> 'Component': 49 | ''' Sets the component's color ''' 50 | self._style.color = color 51 | return self 52 | 53 | def stroke(self, stroke: DashTypes) -> 'Component': 54 | ''' Sets the component's stroke/linestyle ''' 55 | self._style.stroke = stroke 56 | return self 57 | 58 | def strokewidth(self, width: float) -> 'Component': 59 | ''' Sets the component's strokewidth ''' 60 | self._style.stroke_width = width 61 | return self 62 | 63 | def datarange(self) -> DataRange: 64 | ''' Get range of data ''' 65 | return DataRange(None, None, None, None) 66 | 67 | def _logy(self) -> None: 68 | ''' Convert y coordinates to log(y) ''' 69 | 70 | def _logx(self) -> None: 71 | ''' Convert x values to log(x) ''' 72 | 73 | 74 | class Element(Component): 75 | ''' Base class for elements, defining a single object in a plot ''' 76 | _step_color = False # Whether to increment the color cycle 77 | legend_square = False # Draw legend item as a square 78 | 79 | def __init__(self) -> None: 80 | super().__init__() 81 | self._style = Style() 82 | self._name: str = '' 83 | self._containerstyle: CssStyle | None = None 84 | self._markername: str|None = None # SVG ID of marker for legend 85 | 86 | def _set_cycle_index(self, index: int = 0): 87 | ''' Set the index of this element within the colorcycle ''' 88 | self._style._set_cycle_index(index) 89 | 90 | def name(self, name: str) -> 'Element': 91 | ''' Sets the element name to include in the legend ''' 92 | self._name = name 93 | return self 94 | 95 | # def _tangent_slope(self, x: float) -> float: 96 | # ''' Calculate angle tangent to Element at x ''' 97 | # raise NotImplementedError 98 | -------------------------------------------------------------------------------- /docs/gui.rst: -------------------------------------------------------------------------------- 1 | .. _ziagui: 2 | 3 | Embedding in a GUI 4 | ================== 5 | 6 | 7 | Tkinter 8 | ------- 9 | 10 | Because Tkinter does not (yet) natively support SVG graphics, to use Ziaplot in a Tkinter user interface requires the `cairosvg` package to convert to PNG images. 11 | Tk's `PhotoImage` reads the PNG, which must be encoded in base-64. 12 | 13 | .. code-block:: python 14 | 15 | import tkinter as tk 16 | import random 17 | import base64 18 | import ziaplot as zp 19 | 20 | 21 | class Window: 22 | def __init__(self, master): 23 | self.master = master 24 | master.title('Ziaplot in Tkinter') 25 | self.button = tk.Button(self.master, text='Generate Data', command=self.makedata) 26 | self.button.pack() 27 | self.plot = tk.PhotoImage() 28 | self.label = tk.Label(image=self.plot, height=400, width=600) 29 | self.label.pack() 30 | self.makedata() 31 | 32 | def makedata(self): 33 | ''' Generate some randomized data and plot it. Convert the plot 34 | to PNG bytes and encode in base64 for Tkinter. 35 | ''' 36 | n = 15 37 | y = [(i*2) + random.normalvariate(10, 2) for i in range(n)] 38 | avg = sum(y)/len(y) 39 | x = list(range(n)) 40 | 41 | p = zp.Graph() 42 | p += zp.PolyLine(x, y).marker('o') 43 | p += zp.HLine(avg) 44 | img = base64.encodebytes(p.imagebytes('png')) 45 | self.plot = tk.PhotoImage(data=img) 46 | self.label.configure(image=self.plot) 47 | 48 | root = tk.Tk() 49 | gui = Window(root) 50 | root.mainloop() 51 | 52 | | 53 | 54 | PyQt5 55 | ----- 56 | 57 | PyQt5 has a built-in SVG renderer in `QtSvg.QSvgWidget()`. 58 | It can load Ziaplot `imagebytes()` directly. 59 | 60 | 61 | .. code-block:: python 62 | 63 | import sys 64 | import random 65 | from PyQt5 import QtWidgets, QtSvg 66 | 67 | import ziaplot as zp 68 | 69 | 70 | class MainGUI(QtWidgets.QMainWindow): 71 | ''' Main Window ''' 72 | 73 | def __init__(self, parent=None): 74 | super(MainGUI, self).__init__(parent) 75 | self.setWindowTitle('Ziaplot in QT') 76 | 77 | self.button = QtWidgets.QPushButton('Generate Data') 78 | self.button.clicked.connect(self.makedata) 79 | self.image = QtSvg.QSvgWidget() 80 | self.image.setMinimumWidth(600) 81 | self.image.setMinimumHeight(400) 82 | 83 | layout = QtWidgets.QVBoxLayout() 84 | layout.addWidget(self.button) 85 | layout.addWidget(self.image) 86 | 87 | centralWidget = QtWidgets.QWidget(self) 88 | centralWidget.setLayout(layout) 89 | self.setCentralWidget(centralWidget) 90 | 91 | def makedata(self): 92 | ''' Generate some randomized data and plot it, then 93 | display using QSvgWidget. ''' 94 | n = 15 95 | y = [(i*2) + random.normalvariate(10, 2) for i in range(n)] 96 | avg = sum(y)/len(y) 97 | x = list(range(n)) 98 | p = zp.Graph() 99 | p += zp.PolyLine(x, y).marker('o') 100 | p += zp.HLine(avg) 101 | self.image.load(p.imagebytes()) 102 | 103 | 104 | app = QtWidgets.QApplication(sys.argv) 105 | main = MainGUI() 106 | main.show() 107 | app.exec_() 108 | -------------------------------------------------------------------------------- /docs/charts.rst: -------------------------------------------------------------------------------- 1 | .. _Charts: 2 | 3 | Charts (Pie and Bar) 4 | ==================== 5 | 6 | .. jupyter-execute:: 7 | :hide-code: 8 | 9 | import math 10 | import ziaplot as zp 11 | zp.css('Canvas{width:400;height:300;}') 12 | 13 | 14 | The term "chart" is used for diagrams where the x value is qualitative. This includes Pie charts and bar charts. 15 | 16 | | 17 | 18 | BarChart 19 | -------- 20 | 21 | A bar chart. The Diagram is :py:class:`ziaplot.charts.bar.BarChart`, with 22 | :py:class:`ziaplot.charts.bar.Bar` instances added to it: 23 | 24 | .. jupyter-execute:: 25 | 26 | with zp.BarChart().axesnames('Month', 'Number').title('Single Series Bar Chart'): 27 | zp.Bar(3).name('January') 28 | zp.Bar(5).name('February') 29 | zp.Bar(4).name('March') 30 | zp.Bar(8).name('April') 31 | 32 | Or the `fromdict` class method creates the chart from a dictionary. 33 | 34 | .. jupyter-execute:: 35 | 36 | items = {'January': 4, 37 | 'February': 6, 38 | 'March': 2, 39 | 'April': 5} 40 | zp.BarChart.fromdict(items).axesnames('Month', 'Number').title('Bar Chart From Dictionary') 41 | 42 | 43 | BarChartGrouped 44 | --------------- 45 | 46 | A bar chart with multiple bars at each x value. The same-colored bars form a group or "series". 47 | 48 | .. jupyter-execute:: 49 | 50 | with zp.BarChartGrouped(groups=['January', 'February', 'March', 'April']): 51 | zp.BarSeries(4, 4, 5, 6).name('Apple') 52 | zp.BarSeries(3, 4, 4, 5).name('Blueberry') 53 | zp.BarSeries(2, 1, 5, 4).name('Cherry') 54 | 55 | 56 | :py:class:`ziaplot.charts.bar.BarChartGrouped` 57 | :py:class:`ziaplot.charts.bar.BarSeries` 58 | 59 | BarChartGroupedHoriz 60 | -------------------- 61 | 62 | A grouped bar chart with horizontal bars. 63 | 64 | .. jupyter-execute:: 65 | 66 | with zp.BarChartGroupedHoriz(groups=['January', 'February', 'March', 'April']): 67 | zp.BarSeries(4, 4, 5, 6).name('Apple') 68 | zp.BarSeries(3, 4, 4, 5).name('Blueberry') 69 | zp.BarSeries(2, 1, 5, 4).name('Cherry') 70 | 71 | | 72 | 73 | Pie 74 | --- 75 | 76 | A pie chart. The Diagram is :py:class:`ziaplot.charts.pie.Pie`, with :py:class:`ziaplot.charts.pie.PieSlice` added to it. 77 | 78 | .. jupyter-execute:: 79 | 80 | with zp.Pie(): 81 | zp.PieSlice(3).name('a') 82 | zp.PieSlice(10).name('b') 83 | zp.PieSlice(5).name('c') 84 | 85 | .. note:: 86 | 87 | The slice values are normalized so the pie will always fill to 100\%. 88 | 89 | 90 | Pie Charts may also be made from dictionaries or from lists. 91 | 92 | .. jupyter-execute:: 93 | 94 | zp.Pie().fromdict({'a': 20, 'b': 30, 'c': 40, 'd': 10}) 95 | 96 | .. jupyter-execute:: 97 | 98 | zp.Pie().fromlist((3, 4, 2, 2, 5, 1)) 99 | 100 | 101 | .. tip:: 102 | 103 | Use the `labelmode` parameter to change the label displayed outside each slice. 104 | Options are `name`, `value`, `percent`, or `none`. 105 | 106 | 107 | .. jupyter-execute:: 108 | 109 | with zp.Pie(labelmode='percent'): 110 | zp.PieSlice(3).name('a') 111 | zp.PieSlice(10).name('b') 112 | zp.PieSlice(5).name('c') 113 | 114 | .. tip:: 115 | 116 | Use `.extrude()` to pull a slice away from the center of the pie. 117 | 118 | 119 | .. jupyter-execute:: 120 | 121 | with zp.Pie(labelmode='value'): 122 | zp.PieSlice(3).name('a').extrude() 123 | zp.PieSlice(10).name('b') 124 | zp.PieSlice(5).name('c') -------------------------------------------------------------------------------- /ziaplot/drawable.py: -------------------------------------------------------------------------------- 1 | ''' SVG Drawable base class ''' 2 | 3 | from typing import Optional, Literal, Tuple 4 | import os 5 | import xml.etree.ElementTree as ET 6 | 7 | from .canvas import Canvas, Borders, ViewBox 8 | 9 | 10 | SpanType = Tuple[int, int] 11 | 12 | 13 | class Drawable: 14 | ''' Drawable SVG/XML object. Implements common XML and SVG functions, 15 | plus _repr_ for Jupyter 16 | ''' 17 | def __init__(self) -> None: 18 | self._cssid: str | None = None 19 | self._csscls: str | None = None 20 | self._span: SpanType = 1, 1 21 | self._zorder: int = 1 22 | 23 | def cssid(self, idn: str) -> 'Drawable': 24 | ''' Set the CSS id for the item. Matches items in CSS with #name selector ''' 25 | self._cssid = idn 26 | return self 27 | 28 | def cls(self, cls: str) -> 'Drawable': 29 | ''' Set the CSS class name for the item. Matches items in CSS with .name selector ''' 30 | self._csscls = cls 31 | return self 32 | 33 | def span(self, columns: int = 1, rows: int = 1) -> 'Drawable': 34 | ''' Set the row and column span for the item when placed in a 35 | grid layout. 36 | ''' 37 | self._span = columns, rows 38 | return self 39 | 40 | def zorder(self, zorder: int = 1) -> 'Drawable': 41 | ''' Set zorder for the drawable ''' 42 | self._zorder = zorder 43 | return self 44 | 45 | def _xml(self, canvas: Canvas, databox: Optional[ViewBox] = None, 46 | borders: Optional[Borders] = None) -> None: 47 | ''' Draw elements to canvas ''' 48 | 49 | def svgxml(self, border: bool = False) -> ET.Element: 50 | ''' Generate XML for a standalone SVG ''' 51 | canvas = Canvas(600, 400) 52 | if border: 53 | canvas.rect(0, 0, 600, 400) 54 | return canvas.xml() 55 | 56 | def svg(self) -> str: 57 | ''' Get SVG string representation ''' 58 | return ET.tostring(self.svgxml(), encoding='unicode') 59 | 60 | def _repr_svg_(self): 61 | ''' Representer function for Jupyter ''' 62 | return self.svg() 63 | 64 | def imagebytes(self, fmt: Literal['svg', 'eps', 'pdf', 'png'] = 'svg') -> bytes: 65 | ''' Get byte data for image 66 | 67 | Args: 68 | ext: File format extension. Will be extracted from 69 | fname if not provided. 70 | ''' 71 | img = self.svg().encode() 72 | if fmt != 'svg': 73 | import cairosvg # type: ignore 74 | 75 | if fmt == 'eps': 76 | img = cairosvg.svg2eps(img) 77 | elif fmt == 'pdf': 78 | img = cairosvg.svg2pdf(img) 79 | elif fmt == 'png': 80 | img = cairosvg.svg2png(img) 81 | else: 82 | raise ValueError( 83 | f'Cannot convert to {fmt} format. Supported formats: svg, eps, pdf, png') 84 | return img 85 | 86 | def save(self, fname: str): 87 | ''' Save image to a file 88 | 89 | Args: 90 | fname: Filename, with extension. 91 | 92 | Notes: 93 | SVG format is always supported. EPS, PDF, and PNG formats are 94 | available when the `cairosvg` package is installed 95 | ''' 96 | _, ext = os.path.splitext(fname) 97 | ext = ext.lower()[1:] 98 | 99 | img = self.imagebytes(ext) # type: ignore 100 | 101 | with open(fname, 'wb') as f: 102 | f.write(img) 103 | -------------------------------------------------------------------------------- /ziaplot/geometry/line.py: -------------------------------------------------------------------------------- 1 | ''' Calculations on lines ''' 2 | from __future__ import annotations 3 | import math 4 | from typing import TYPE_CHECKING 5 | 6 | from .geometry import LineType, PointType 7 | 8 | if TYPE_CHECKING: 9 | from ..figures.line import Line 10 | from ..figures.point import Point 11 | 12 | 13 | def new(slope: float, intercept: float) -> LineType: 14 | ''' Create a line from slope and intercept ''' 15 | b = 1 16 | c = intercept 17 | a = -slope 18 | return a, b, c 19 | 20 | 21 | def new_pointslope(point: PointType|'Point', slope: float) -> LineType: 22 | ''' Create a line from point and slope ''' 23 | if math.isfinite(slope): 24 | intercept = -slope*point[0] + point[1] 25 | return new(slope, intercept) 26 | return (1, 0, point[0]) # Vertical line 27 | 28 | 29 | def slope(line: LineType|'Line') -> float: 30 | ''' Get slope of the line ''' 31 | a, b, c = line 32 | try: 33 | return -a / b 34 | except ZeroDivisionError: 35 | return math.inf 36 | 37 | 38 | def intercept(line: LineType|'Line') -> float: 39 | ''' Get y-intercept of the line ''' 40 | a, b, c = line 41 | try: 42 | return c / b 43 | except ZeroDivisionError: 44 | return math.inf 45 | 46 | 47 | def xintercept(line: LineType|'Line') -> float: 48 | ''' Get x-intercept of the line ''' 49 | a, b, c = line 50 | try: 51 | return c / a 52 | except ZeroDivisionError: 53 | return math.inf 54 | 55 | 56 | def yvalue(line: LineType|'Line', x: float) -> float: 57 | ''' Get y value of the line at x ''' 58 | a, b, c = line 59 | try: 60 | return (c - a*x) / b 61 | except ZeroDivisionError: 62 | return math.nan # Vertical Line 63 | 64 | 65 | def xvalue(line: LineType|'Line', y: float) -> float: 66 | ''' Get x value of the line at y ''' 67 | a, b, c = line 68 | try: 69 | return (c - b*y) / a 70 | except ZeroDivisionError: 71 | return math.nan # Horizontal Line 72 | 73 | 74 | def normal_distance(line: LineType|'Line', point: PointType|'Point') -> float: 75 | ''' Normal distance from point to line ''' 76 | a, b, c = line 77 | x, y = point 78 | return abs(a*x + b*y - c) / math.sqrt(a**2 + b**2) 79 | 80 | 81 | def normal(line: LineType|'Line', point: PointType|'Point') -> LineType: 82 | ''' Find the line normal to given line through point ''' 83 | 84 | m = slope(line) 85 | try: 86 | normslope = -1 / m 87 | except ZeroDivisionError: 88 | normslope = math.inf 89 | return new_pointslope(point, normslope) 90 | 91 | 92 | def bisect(line1: LineType|'Line', line2: LineType|'Line') -> tuple[LineType, LineType]: 93 | ''' Find the two lines bisecting the given two lines ''' 94 | a1, b1, c1 = line1 95 | a2, b2, c2 = line2 96 | 97 | def bisect(sign=1): 98 | A = a1 / math.sqrt(a1**2+b1**2) + sign * a2 / math.sqrt(a2**2 + b2**2) 99 | B = b1 / math.sqrt(a1**2+b1**2) + sign * b2 / math.sqrt(a2**2 + b2**2) 100 | C = c1 / math.sqrt(a1**2+b1**2) + sign * c2 / math.sqrt(a2**2 + b2**2) 101 | return A, B, C 102 | 103 | return bisect(sign=1), bisect(sign=-1) 104 | 105 | 106 | def bisect_points(p1: PointType|'Point', p2: PointType|'Point') -> LineType: 107 | ''' Find the line bisecting the two points ''' 108 | mid = (p1[0]+p2[0])/2, (p1[1]+p2[1])/2 109 | slope = (p2[1]-p1[1]) / (p2[0]-p1[0]) 110 | try: 111 | m = -1/slope 112 | except ZeroDivisionError: 113 | m = math.inf 114 | return new_pointslope(mid, m) 115 | -------------------------------------------------------------------------------- /ziaplot/util.py: -------------------------------------------------------------------------------- 1 | ''' Utility Functions. Most are pure-python replacements for numpy functions ''' 2 | from __future__ import annotations 3 | from typing import Sequence, Callable 4 | import bisect 5 | import math 6 | 7 | 8 | def zrange(start: float, stop: float, step: float) -> list[float]: 9 | ''' Like builtin range, but works with floats ''' 10 | assert step > 0 11 | assert step < (stop-start) 12 | vals = [start] 13 | while abs(vals[-1] - stop)/step > .1 and vals[-1] < stop: # Wiggle room for float precision 14 | vals.append(vals[-1] + step) 15 | return vals 16 | 17 | 18 | def linspace(start: float, stop: float, num: int = 50) -> list[float]: 19 | ''' Generate list of evenly spaced points ''' 20 | if num < 2: 21 | return [stop] 22 | diff = (float(stop) - start)/(num - 1) 23 | return [diff * i + start for i in range(num)] 24 | 25 | 26 | def interpolate(x1: float, x2: float, y1: float, y2: float, x: float) -> float: 27 | ''' Perform linear interpolation for x between (x1,y1) and (x2,y2) ''' 28 | return ((y2 - y1) * x + x2 * y1 - x1 * y2) / (x2 - x1) 29 | 30 | 31 | def interp(newx: Sequence[float], xlist: Sequence[float], ylist: Sequence[float]) -> list[float]: 32 | ''' Interpolate list of newx values (replacement for np.interp) ''' 33 | newy = [] 34 | for x in newx: 35 | idx = bisect.bisect_left(xlist, x) 36 | y = interpolate(xlist[idx-1], xlist[idx], ylist[idx-1], ylist[idx], x) 37 | newy.append(y) 38 | return newy 39 | 40 | 41 | def root(f: Callable, a: float, b: float, tol=1E-4) -> float: 42 | ''' Find root of f between a and b ''' 43 | def samesign(x, y): 44 | return x*y > 0 45 | 46 | fa = f(a) 47 | if samesign(fa, f(b)): 48 | raise ValueError("Root not bounded by a and b.") 49 | 50 | m = (a + b) / 2 51 | fm = f(m) 52 | if abs(fm) < tol: 53 | return m 54 | 55 | if samesign(fa, fm): 56 | return root(f, m, b, tol) 57 | 58 | return root(f, a, m, tol) 59 | 60 | 61 | def root_newton(f: Callable, x0: float, tol=1E-4) -> float: 62 | ''' Find root using Newton-Raphson method 63 | 64 | Args: 65 | f: Function 66 | x0: Initial guess 67 | tol: Tolerance 68 | ''' 69 | if abs(f(x0)) < tol: 70 | return x0 71 | else: 72 | df = derivative(f, x0) 73 | return root_newton(f, x0 - f(x0)/df, tol) 74 | 75 | 76 | def minimum(f: Callable, a: float, b: float, tolerance: float = 1E-5): 77 | """ 78 | Golden-section search 79 | to find the minimum of f on [a,b] 80 | 81 | * f: a strictly unimodal function on [a,b] 82 | 83 | Example: 84 | >>> def f(x): return (x - 2) ** 2 85 | >>> x = gss(f, 1, 5) 86 | >>> print(f"{x:.5f}") 87 | 2.00000 88 | 89 | https://en.wikipedia.org/wiki/Golden-section_search 90 | """ 91 | invphi = (math.sqrt(5) - 1) / 2 # 1 / phi 92 | tolerance = (b-a)*tolerance 93 | 94 | while abs(b - a) > tolerance: 95 | c = b - (b - a) * invphi 96 | d = a + (b - a) * invphi 97 | if f(c) < f(d): 98 | b = d 99 | else: # f(c) > f(d) to find the maximum 100 | a = c 101 | 102 | return (b + a) / 2 103 | 104 | 105 | def maximum(f: Callable, a: float, b: float, tolerance: float = 1E-5): 106 | """ 107 | Golden-section search 108 | to find the maxumum of f on [a,b] 109 | 110 | * f: a strictly unimodal function on [a,b] 111 | 112 | Example: 113 | >>> def f(x): return (x - 2) ** 2 114 | >>> x = gss(f, 1, 5) 115 | >>> print(f"{x:.5f}") 116 | 2.00000 117 | 118 | https://en.wikipedia.org/wiki/Golden-section_search 119 | """ 120 | invphi = (math.sqrt(5) - 1) / 2 # 1 / phi 121 | tolerance = (b-a)*tolerance 122 | 123 | while abs(b - a) > tolerance: 124 | c = b - (b - a) * invphi 125 | d = a + (b - a) * invphi 126 | if f(c) > f(d): 127 | b = d 128 | else: # f(c) > f(d) to find the maximum 129 | a = c 130 | 131 | return (b + a) / 2 132 | 133 | 134 | def derivative(f: Callable, a: float): 135 | ''' Calculate derivative of f at a ''' 136 | h = a/1E6 if a != 0 else 1E-6 137 | return (f(a+h) - f(a)) / h 138 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Ziaplot 2 | ======= 3 | 4 | Ziaplot is for easy, lightweight, and Pythonic creation of geometric diagrams, data plots, 5 | and charts in SVG format. 6 | It can graph functional relationships, geometric figures, implicit functions, discrete (x, y) data, 7 | histograms, and pie and bar charts. 8 | 9 | 10 | .. jupyter-execute:: 11 | :hide-code: 12 | 13 | import math 14 | import random 15 | import ziaplot as zp 16 | zp.css('Canvas{width:300;height:250;}') 17 | 18 | random.seed(827243) 19 | x = zp.linspace(0, 10, 15) 20 | y = [0.5 * xx + random.random() for xx in x] 21 | x2 = zp.linspace(0, 10, 25) 22 | y2 = [.5 + .9 * xx + random.random() for xx in x2] 23 | 24 | with zp.LayoutGrid(columns=2).size(700, 800): 25 | with zp.Graph(): 26 | zp.PolyLine(x, y).marker('o') 27 | zp.PolyLine(x2, y2).marker('^') 28 | 29 | with (zp.GraphQuad().css(zp.CSS_BLACKWHITE+zp.CSS_NOGRID) 30 | .size(300, 250) 31 | .xrange(-1, 5).yrange(-1, 3)) as g: 32 | g.xticks(zp.ticker[0:5:1], minor=zp.ticker[0:5:.125]) 33 | g.yticks(zp.ticker[0:3:.5], minor=zp.ticker[0:2.75:.125]) 34 | f = zp.Function(lambda x: 0.6*math.cos(4.5*(x-4)+2.1) - 1.2*math.sin(x-4)+.1*x+.2, 35 | (.35, 4.2)).color('black') 36 | zp.Point.at_minimum(f, 1, 2).color('olive').guidex().guidey() 37 | zp.Point.at_maximum(f, 2, 2.5).color('red').guidex().guidey() 38 | zp.Point.at_maximum(f, 3, 4).color('blue').guidex().guidey() 39 | 40 | with (zp.GraphQuad() 41 | .axesnames('V', 'P') 42 | .css(zp.CSS_BLACKWHITE) 43 | .xrange(0, .9).yrange(0, 1) 44 | .noxticks().noyticks() 45 | .equal_aspect()): 46 | p1 = zp.Point((.1, .9)).label('1', 'NE') 47 | p2 = zp.Point((.6, .6)).label('2', 'NE') 48 | p3 = zp.Point((.8, .1)).label('3', 'E') 49 | p4 = zp.Point((.3, .3)).label('4', 'SW') 50 | zp.Curve(p2.point, p1.point).midmarker('>') 51 | zp.Curve(p3.point, p2.point).midmarker('>') 52 | zp.Curve(p3.point, p4.point).midmarker('<') 53 | zp.Curve(p4.point, p1.point).midmarker('<') 54 | 55 | with (zp.GraphQuad().css(zp.CSS_BLACKWHITE) 56 | .equal_aspect() 57 | .xticks((-1.2, 1.2)).yticks((-1.2, 1.2)) 58 | .noxticks().noyticks()): 59 | c = zp.Circle((0, 0), 1) 60 | r = c.radius_segment(65).label('1', 0.6, 'NW').color('blue') 61 | b = zp.Segment((0, 0), (r.p2[0], 0)).label('cos(θ)', .6, 'S') 62 | zp.Segment(r.p2, (r.p2[0], 0)).label('sin(θ)', .75, 'E').color('blue') 63 | zp.Angle(r, b, quad=4).label('θ', color='red').color('red') 64 | zp.Point.on_circle(c, 65) 65 | 66 | zp.Pie.fromdict({'a':3, 'b':2, 'c':3, 'd':2, 'e':4, 'f':2}).legend('none') 67 | 68 | with zp.BarChartGrouped(groups=('a', 'b', 'c', 'd')): 69 | zp.BarSeries(2, 2, 4, 3) 70 | zp.BarSeries(2, 3, 1, 4) 71 | 72 | 73 | | 74 | 75 | See the :ref:`Examples` for more, or jump in to the :ref:`Start`. 76 | 77 | Ziaplot is written in pure-Python, with no dependencies. 78 | An optional dependency of `cairosvg` can be installed to convert 79 | ziaplot's SVG images into PNG or other formats. 80 | 81 | 82 | | 83 | 84 | Support 85 | ------- 86 | 87 | If you appreciate Ziaplot, buy me a coffee to show your support! 88 | 89 | .. raw:: html 90 | 91 | 92 | 93 | | 94 | 95 | 96 | Source code is available on `Github `_. 97 | 98 | ---- 99 | 100 | 101 | 102 | 103 | .. toctree:: 104 | :maxdepth: 2 105 | :caption: Contents: 106 | 107 | intro.rst 108 | guide.rst 109 | graphs.rst 110 | geometric.rst 111 | discrete.rst 112 | charts.rst 113 | layout.rst 114 | style.rst 115 | examples.rst 116 | gui.rst 117 | api.rst 118 | -------------------------------------------------------------------------------- /ziaplot/style/css.py: -------------------------------------------------------------------------------- 1 | ''' Parse CSS-like style configuration ''' 2 | from __future__ import annotations 3 | from typing import Any, Sequence 4 | import re 5 | from dataclasses import dataclass, field, replace 6 | 7 | from .style import Style 8 | 9 | NAMED_SIZE_INCREMENTS = { 10 | 'xx-small': -6, 11 | 'x-small': -4, 12 | 'small': -2, 13 | 'medium': 0, 14 | 'normal': 0, 15 | 'large': 4, 16 | 'x-large': 8, 17 | 'xx-large': 12 18 | } 19 | 20 | 21 | def merge(style: Style, style2: Style) -> Style: 22 | ''' Merge style2 on top of style1, replacing any non-none items ''' 23 | if isinstance(style2.font_size, str) and isinstance(style.font_size, int): 24 | size_adder = NAMED_SIZE_INCREMENTS.get(style2.font_size, 0) 25 | style2.font_size = style.font_size + size_adder 26 | return replace(style, **style2.values()) 27 | 28 | 29 | def merge_css(style: Style, css: str) -> Style: 30 | ''' Merge the css items (no selectors) with the style ''' 31 | values = parse_style(css) 32 | return replace(style, **values) 33 | 34 | 35 | @dataclass 36 | class CssStyle: 37 | ''' Style objects loaded from a CSS 38 | 39 | Args: 40 | cssids: Styles from CSS selectors starting with # 41 | cssclasses: Styles from CSS selectors starting with . 42 | drawables: Styles from Ziaplot class names 43 | ''' 44 | cssids: dict[str, Style] = field(default_factory=dict) 45 | cssclasses: dict[str, Style] = field(default_factory=dict) 46 | drawables: dict[str, Style] = field(default_factory=dict) 47 | 48 | def extract(self, classnames: Sequence[str], 49 | cssclass: str | None = '', cssid: str | None = '') -> Style: 50 | ''' Get styles that match - from most general (classname) to most specific (id) ''' 51 | style = Style() 52 | for cls in classnames: 53 | style = merge(style, self.drawables.get(cls, Style())) 54 | 55 | if cssclass: 56 | style = merge(style, self.cssclasses.get(cssclass, Style())) 57 | 58 | if cssid: 59 | style = merge(style, self.cssids.get(cssid, Style())) 60 | return style 61 | 62 | 63 | def splitcolors(cssvalue: str) -> Any: 64 | ''' Split the comma-separated color values ''' 65 | # Split on comma but not if comma is within () 66 | # eg. red, blue is split at comma 67 | # rgb(1,2,3) is not 68 | return re.split(r',\s*(?![^()]*\))', cssvalue) 69 | 70 | 71 | def caster(cssvalue: str) -> int | float | str: 72 | ''' Attempt to cast the value to int or float ''' 73 | try: 74 | val = float(cssvalue) 75 | except ValueError: 76 | return cssvalue 77 | if val.is_integer(): 78 | return int(val) 79 | return val 80 | 81 | 82 | def parse_style(style: str | None) -> dict[str, Any]: 83 | ''' Parse items in one style group {...} ''' 84 | if style is None: 85 | return {} 86 | 87 | # Remove CSS comments inside /* ... */ 88 | style = re.sub(r'(\/\*[^*]*\*+([^/*][^*]*\*+)*\/)', '', style) 89 | 90 | items = {} 91 | for item in style.split(';'): 92 | item = item.strip() 93 | if item: 94 | key, val = item.split(':', maxsplit=1) 95 | if key.strip() in ['colorcycle', 'colorfade']: 96 | items[key.strip()] = splitcolors(val.strip()) 97 | else: 98 | items[key.strip()] = caster(val.strip()) 99 | return items 100 | 101 | 102 | def parse_css(css: str) -> CssStyle: 103 | ''' Parse full CSS ''' 104 | # Split groups of 'XXX { YYY }' 105 | matches = re.findall(r'(.*?)\{(.+?)\}', css, flags=re.MULTILINE | re.DOTALL) 106 | cssitems = [(idn.strip(), val.strip()) for idn, val in matches] 107 | 108 | def update(dict, selector, style): 109 | if selector not in dict: 110 | dict[selector] = style 111 | else: 112 | dict[selector] = merge(dict[selector], style) 113 | 114 | cssstyle = CssStyle() 115 | for selector, value in cssitems: 116 | style = Style(**parse_style(value)) 117 | if selector.startswith('#'): 118 | update(cssstyle.cssids, selector[1:], style) 119 | elif selector.startswith('.'): 120 | update(cssstyle.cssclasses, selector[1:], style) 121 | else: 122 | update(cssstyle.drawables, selector, style) 123 | return cssstyle 124 | -------------------------------------------------------------------------------- /docs/layout.rst: -------------------------------------------------------------------------------- 1 | Layout and Subplots 2 | =================== 3 | 4 | .. jupyter-execute:: 5 | :hide-code: 6 | 7 | import ziaplot as zp 8 | zp.css('Canvas{width:400;height:300;}') 9 | 10 | 11 | Multiple plots can be added to a single drawing using layouts. 12 | 13 | 14 | LayoutH 15 | ------- 16 | 17 | Arrange the contents horizontally. 18 | 19 | .. jupyter-execute:: 20 | 21 | with zp.LayoutH().size(400, 200): 22 | zp.Plot([1,3,5], [1,2,5]).marker('round') 23 | zp.Plot([1,3,5], [1,2,5]).marker('square') 24 | 25 | :py:class:`ziaplot.layout.LayoutH` 26 | 27 | 28 | .. note:: 29 | 30 | Use `column_gap` to specify the pixel separation between columns. 31 | 32 | 33 | | 34 | 35 | LayoutV 36 | ------- 37 | 38 | Arrange the contents vertically. 39 | 40 | .. jupyter-execute:: 41 | 42 | with zp.LayoutV().size(200, 400): 43 | zp.Plot([1,3,5], [1,2,5]).marker('round') 44 | zp.Plot([1,3,5], [1,2,5]).marker('square') 45 | 46 | :py:class:`ziaplot.layout.LayoutV` 47 | 48 | .. note:: 49 | 50 | Use `row_gap` to specify the pixel separation between rows. 51 | 52 | | 53 | 54 | GridLayout 55 | ---------- 56 | 57 | Arrange contents in a regular grid of rows and columns. 58 | 59 | .. note:: 60 | 61 | Specify the number of columns. The rows are added as needed. 62 | 63 | .. jupyter-execute:: 64 | 65 | with zp.LayoutGrid(columns=2): 66 | zp.Plot([1,2,3], [1,2,5]) 67 | zp.Plot([1,2,3], [1,2,5]).color('blue') 68 | zp.Plot([1,2,3], [1,2,5]).color('green') 69 | zp.Plot([1,2,3], [1,2,5]).color('orange') 70 | 71 | .. tip:: 72 | 73 | Use `.span` on contents to set the column and row span for items to span multiple grid cells. 74 | 75 | .. jupyter-execute:: 76 | 77 | with zp.LayoutGrid(columns=3).size(700, 400): 78 | zp.Plot([1,2,3], [1,2,5]).span(3) 79 | zp.Plot([1,2,3], [3,3,2]).color('blue').span(1, 2) 80 | zp.Plot([1,2,3], [4,1,3]).color('green') 81 | zp.Plot([1,2,3], [0,2,6]).color('orange') 82 | zp.Plot([1,2,3], [0,2,6]).color('cyan') 83 | zp.Plot([1,2,3], [0,2,6]).color('purple') 84 | 85 | 86 | .. tip:: 87 | 88 | Use :py:class:`ziaplot.layout.LayoutEmpty` to leave an empty spot in a layout. 89 | 90 | .. jupyter-execute:: 91 | 92 | with zp.LayoutGrid(columns=2): 93 | zp.Plot([0, 1], [0, 1]) 94 | zp.LayoutEmpty() 95 | zp.Plot([0, 1], [1, 1]).color('orange') 96 | zp.Plot([0, 1], [1, 0]).color('green') 97 | 98 | | 99 | 100 | Uneven row/column spacing 101 | ************************* 102 | 103 | 104 | Default LayoutGrids generate equal size columns and rows. 105 | Use `column_widths` and `row_heights` parameters with a string specifying the 106 | relative sizes for rows and columns. 107 | 108 | The string is space-delimited with each item either 109 | 110 | 1. a plain number representing the number of pixels 111 | 2. a percent of the whole width 112 | 3. a number with "fr" suffix representing fractions of the whole 113 | 114 | Examples: 115 | 116 | * "25% 1fr": First column takes 25%, second column the remainder 117 | * "200 1fr": First column takes 200 pixels, second column the remainder 118 | * "2fr 1fr": First column is twice the width of second 119 | 120 | .. jupyter-execute:: 121 | 122 | with zp.LayoutGrid(columns=2, column_widths='3fr 1fr', row_heights='35% 1fr'): 123 | zp.Plot([1,2,3], [1,2,5]) 124 | zp.Plot([1,2,3], [1,2,5]).color('blue') 125 | zp.Plot([1,2,3], [1,2,5]).color('green') 126 | zp.Plot([1,2,3], [1,2,5]).color('orange') 127 | 128 | 129 | Matching Ranges 130 | --------------- 131 | 132 | Side-by-side plots in a layout often should have the same range of data 133 | so they may be easily compared. 134 | This example shows two Graphs with different data scales. 135 | 136 | .. jupyter-execute:: 137 | 138 | x = [0, 1, 2, 3] 139 | y = [0, 1, 2, 3] 140 | y2 = [0, 2, 5, 6] 141 | 142 | with zp.LayoutH(): 143 | with zp.Graph(): 144 | zp.Scatter(x, y) 145 | with zp.Graph(): 146 | zp.Scatter(x, y2) 147 | 148 | One could manually set data ranges on the graphs, but the `match_x()` and `match_y()` 149 | methods automatically set the range of the graph equal to the range of another graph. 150 | 151 | .. jupyter-execute:: 152 | 153 | with zp.LayoutH(): 154 | with zp.Graph() as g1: 155 | zp.Scatter(x, y) 156 | with zp.Graph() as g2: 157 | zp.Scatter(x, y2) 158 | g1.match_y(g2) 159 | -------------------------------------------------------------------------------- /ziaplot/geometry/geometry.py: -------------------------------------------------------------------------------- 1 | ''' Geometry calculations ''' 2 | from __future__ import annotations 3 | from typing import Sequence, Callable, Tuple, TYPE_CHECKING 4 | import math 5 | 6 | if TYPE_CHECKING: 7 | from ..figures.line import Line 8 | from ..figures.point import Point 9 | 10 | 11 | PointType = Tuple[float, float] # (x, y) 12 | LineType = Tuple[float, float, float] # (a, b, c), where ax + by + c = 0 13 | CircleType = Tuple[float, float, float] # (centerx, centery, radius) 14 | EllipseType = Tuple[float, float, float, float, float] # (centerx, centery, radius1, radius2, rotation) 15 | ArcType = Tuple[float, float, float, float, float] # (centerx, centery, radius, theta1, theta2) 16 | FunctionType = Callable 17 | BezierType = Tuple[PointType, PointType, PointType, PointType] 18 | 19 | 20 | def select_which(points: Sequence[PointType], which: str) -> PointType: 21 | ''' Choose point from the list with the top-most or bottom-most 22 | y ccoordinate, or left-most or right-most x coordinate 23 | ''' 24 | if which.startswith('y'): 25 | pt = sorted(points, key=lambda x: x[1]) 26 | idx = int(which[1:]) 27 | return pt[idx] 28 | if which.startswith('x'): 29 | pt = sorted(points, key=lambda x: x[0]) 30 | idx = int(which[1:]) 31 | return pt[idx] 32 | 33 | if which.startswith('b'): 34 | return min(points, key=lambda x: x[1]) 35 | if which.startswith('t'): 36 | return max(points, key=lambda x: x[1]) 37 | if which.startswith('l'): 38 | return min(points, key=lambda x: x[0]) 39 | if which.startswith('r'): 40 | return max(points, key=lambda x: x[0]) 41 | raise ValueError(f'Unknown `which` parameter {which}') 42 | 43 | 44 | def distance(p1: PointType|'Point', p2: PointType|'Point') -> float: 45 | ''' Distance between two points ''' 46 | return math.sqrt((p1[0]- p2[0])**2 + (p1[1] - p2[1])**2) 47 | 48 | 49 | def midpoint(p1: PointType|'Point', p2: PointType|'Point') -> PointType: 50 | ''' Midpoint between two points ''' 51 | x = (p1[0] + p2[0])/2 52 | y = (p1[1] + p2[1])/2 53 | return (x, y) 54 | 55 | 56 | def point_slope(p1: PointType|'Point', p2: PointType|'Point') -> float: 57 | ''' Calculate slope between two points ''' 58 | return (p2[1] - p1[1]) / (p2[0] - p1[0]) 59 | 60 | 61 | def isclose(p1: PointType|'Point', p2: PointType|'Point') -> bool: 62 | ''' Determine if the points are identical (x and y within math.isclose) ''' 63 | return math.isclose(p1[0], p2[0]) and math.isclose(p1[1], p2[1]) 64 | 65 | 66 | def unique_points(points: list[PointType]) -> list[PointType]: 67 | ''' Remove duplicate (isclose) points from the lits ''' 68 | unique: list[PointType] = [] 69 | for item in points: 70 | if not any([isclose(item, x) for x in unique]): 71 | unique.append(item) 72 | return unique 73 | 74 | 75 | def translate(point: PointType|'Point', delta: PointType|'Point') -> PointType: 76 | ''' Translate the point by delta ''' 77 | return point[0]+delta[0], point[1]+delta[1] 78 | 79 | 80 | def rotate(point: PointType|'Point', theta: float) -> PointType: 81 | ''' Rotate the point theta radians about the origin ''' 82 | cth = math.cos(theta) 83 | sth = math.sin(theta) 84 | x = point[0] * cth - point[1] * sth 85 | y = point[0] * sth + point[1] * cth 86 | return x, y 87 | 88 | 89 | def reflect(point: PointType|'Point', line: LineType|'Line') -> PointType: 90 | ''' Reflect the point over the line ''' 91 | a, b, c = line 92 | x, y = point 93 | k = -2*(a*x + b*y - c)/(a**2 + b**2) 94 | return k*a + x, k*b + y 95 | 96 | 97 | def image(point: PointType|'Point', line: LineType|'Line') -> PointType: 98 | ''' Create a new point imaged onto the line (point on line at 99 | shortest distance to point) 100 | ''' 101 | a, b, c = line 102 | x, y = point 103 | k = -(a*x + b*y - c)/(a**2 + b**2) 104 | return k*a + x, k*b + y 105 | 106 | 107 | def angle_diff(theta1: float, theta2: float): 108 | ''' Get angular difference between theta2 and theta1 (radians) ''' 109 | delta = math.atan2(math.sin(theta2-theta1), math.cos(theta2-theta1)) 110 | if delta < 0: 111 | delta = delta + math.tau 112 | return delta 113 | 114 | 115 | def angle_isbetween(angle: float, theta1: float, theta2: float) -> bool: 116 | ''' Is angle between theta1 and theta2 counterclockwise? ''' 117 | delta = angle_diff(theta1, angle) 118 | length = angle_diff(theta1, theta2) 119 | return delta <= length 120 | 121 | 122 | def angle_mean(theta1: float, theta2: float) -> float: 123 | ''' Circular mean over 0 to 2pi (theta in radians) ''' 124 | sine = math.sin(theta1) + math.sin(theta2) 125 | cosine = math.cos(theta1) + math.cos(theta2) 126 | mean = math.atan2(sine, cosine) 127 | return (mean + math.tau) % math.tau 128 | -------------------------------------------------------------------------------- /ziaplot/geometry/bezier.py: -------------------------------------------------------------------------------- 1 | ''' Bezier Curve calculations ''' 2 | from __future__ import annotations 3 | from typing import TYPE_CHECKING 4 | import math 5 | import bisect 6 | from itertools import accumulate 7 | 8 | from .. import util 9 | from .geometry import PointType, BezierType, distance 10 | 11 | if TYPE_CHECKING: 12 | from ..figures.bezier import Bezier 13 | 14 | 15 | def quadratic_xy(b: BezierType|'Bezier', t: float) -> PointType: 16 | ''' Point on Quadratic Bezier at parameter t ''' 17 | (p1x, p1y), (p2x, p2y), (p3x, p3y), *_ = b 18 | x = p2x + (1-t)**2 * (p1x - p2x) + t**2 * (p3x - p2x) 19 | y = p2y + (1-t)**2 * (p1y - p2y) + t**2 * (p3y - p2y) 20 | return x, y 21 | 22 | 23 | def quadratic_tangent_slope(b: BezierType|'Bezier', t: float) -> float: 24 | ''' Slope of tanget at parameter t ''' 25 | (p1x, p1y), (p2x, p2y), (p3x, p3y), *_ = b 26 | bprime_x = 2*(1-t) * (p2x - p1x) + 2*t*(p3x - p2x) 27 | bprime_y = 2*(1-t) * (p2y - p1y) + 2*t*(p3y - p2y) 28 | return bprime_y / bprime_x 29 | 30 | 31 | def quadtratic_tangent_angle(b: BezierType|'Bezier', t: float) -> float: 32 | ''' Get angle of tangent at parameter t (radians) ''' 33 | (p1x, p1y), (p2x, p2y), (p3x, p3y), *_ = b 34 | bprime_x = 2*(1-t) * (p2x - p1x) + 2*t*(p3x - p2x) 35 | bprime_y = 2*(1-t) * (p2y - p1y) + 2*t*(p3y - p2y) 36 | return math.atan2(bprime_y, bprime_x) 37 | 38 | 39 | def cubic_xy(b: BezierType|'Bezier', t: float) -> PointType: 40 | ''' Point on cubic Bezier at parameter t ''' 41 | (p1x, p1y), (p2x, p2y), (p3x, p3y), (p4x, p4y) = b 42 | x = (p1x*(1-t)**3 + p2x*3*t*(1-t)**2 + p3x*3*(1-t)*t**2 + p4x*t**3) 43 | y = (p1y*(1-t)**3 + p2y*3*t*(1-t)**2 + p3y*3*(1-t)*t**2 + p4y*t**3) 44 | return (x, y) 45 | 46 | 47 | def cubic_tangent_slope(b: BezierType|'Bezier', t: float) -> float: 48 | ''' Slope of tanget at parameter t ''' 49 | (p1x, p1y), (p2x, p2y), (p3x, p3y), (p4x, p4y) = b 50 | bprime_x = 3*(1-t)**2 * (p2x-p1x) + 6*(1-t)*t*(p3x-p2x) + 3*t**2*(p4x-p3x) 51 | bprime_y = 3*(1-t)**2 * (p2y-p1y) + 6*(1-t)*t*(p3y-p2y) + 3*t**2*(p4y-p3y) 52 | return bprime_y / bprime_x 53 | 54 | 55 | def cubic_tangent_angle(b: BezierType|'Bezier', t: float) -> float: 56 | ''' Get angle of tangent at parameter t (radians) ''' 57 | (p1x, p1y), (p2x, p2y), (p3x, p3y), (p4x, p4y) = b 58 | bprime_x = 3*(1-t)**2 * (p2x-p1x) + 6*(1-t)*t*(p3x-p2x) + 3*t**2*(p4x-p3x) 59 | bprime_y = 3*(1-t)**2 * (p2y-p1y) + 6*(1-t)*t*(p3y-p2y) + 3*t**2*(p4y-p3y) 60 | return math.atan2(bprime_y, bprime_x) 61 | 62 | 63 | def xy(b: BezierType | 'Bezier', t: float) -> PointType: 64 | ''' Point on a Bezier curve at parameter t ''' 65 | if len(b) == 3: 66 | return quadratic_xy(b, t) # type: ignore 67 | return cubic_xy(b, t) 68 | 69 | 70 | def tangent_slope(b: BezierType | 'Bezier', t: float) -> float: 71 | if len(b) == 3: 72 | return quadratic_tangent_slope(b, t) # type: ignore 73 | return cubic_tangent_slope(b, t) 74 | 75 | 76 | def tangent_angle(b: BezierType | 'Bezier', t: float) -> float: 77 | if len(b) == 3: 78 | return quadtratic_tangent_angle(b, t) 79 | return cubic_tangent_angle(b, t) 80 | 81 | 82 | def length(b: BezierType|'Bezier', n: int = 50) -> float: 83 | ''' Compute approximate length of Bezier curve (Quadtratic or Cubic) 84 | 85 | Args: 86 | n: Number of points used for piecewise approximation of curve 87 | ''' 88 | t = util.linspace(0, 1, num=50) 89 | 90 | p = [xy(b, tt) for tt in t] 91 | dists = [distance(p[i], p[i+1]) for i in range(n-1)] 92 | return sum(dists) 93 | 94 | 95 | def equal_spaced_t( 96 | b: BezierType|'Bezier', 97 | nsegments: int = 2, 98 | n: int = 100) -> list[float]: 99 | ''' Find t values that approximately split the curve into 100 | equal-length segments. 101 | 102 | Args: 103 | b: The curve to split 104 | nsegments: Number of segments to split curve into 105 | n: Number of points used to approximate curve 106 | ''' 107 | t = util.linspace(0, 1, num=n) 108 | p = [xy(b, tt) for tt in t] 109 | dists = [distance(p[i], p[i+1]) for i in range(n-1)] 110 | length = sum(dists) 111 | delta = length / nsegments 112 | seg_points = [delta*i for i in range(nsegments+1)] 113 | cumsum = list(accumulate(dists)) 114 | t_points = [bisect.bisect_left(cumsum, seg_points[i]) for i in range(1, nsegments)] 115 | return [t[0]] + [t[tp] for tp in t_points] + [t[n-1]] 116 | 117 | 118 | def equal_spaced_points( 119 | b: BezierType|'Bezier', 120 | nsegments: int = 2, 121 | n: int = 100) -> list[PointType]: 122 | ''' Find (x, y) points spaced equally along a Bezier curve 123 | 124 | Args: 125 | bezier: The curve to split 126 | nsegments: Number of segments to split curve into 127 | n: Number of points used to approximate curve 128 | ''' 129 | t = equal_spaced_t(b, nsegments, n) 130 | return [xy(b, tt) for tt in t] 131 | -------------------------------------------------------------------------------- /docs/discrete.rst: -------------------------------------------------------------------------------- 1 | .. _Discrete: 2 | 3 | Discrete Data 4 | ============= 5 | 6 | Discrete data is plotted from arrays of x values and y values. 7 | 8 | .. jupyter-execute:: 9 | :hide-code: 10 | 11 | import math 12 | import ziaplot as zp 13 | zp.css('Canvas{width:400;height:300;}') 14 | 15 | All discrete data element classes share these styling methods: 16 | 17 | - color 18 | - stroke 19 | - strokewidth 20 | - marker 21 | 22 | For more complete styling options, see :ref:`Styling`. 23 | 24 | 25 | PolyLine 26 | -------- 27 | 28 | Connects the (x, y) pairs with line segments. 29 | 30 | .. jupyter-execute:: 31 | 32 | x = [i*0.1 for i in range(11)] 33 | y = [math.exp(xi)-1 for xi in x] 34 | y2 = [yi*2 for yi in y] 35 | y3 = [yi*3 for yi in y] 36 | y4 = [yi*4 for yi in y] 37 | with zp.Graph(): 38 | zp.PolyLine(x, y) 39 | zp.PolyLine(x, y2).marker('round', radius=8) 40 | zp.PolyLine(x, y3).stroke('dashed') 41 | zp.PolyLine(x, y4).color('purple').strokewidth(4) 42 | 43 | 44 | :py:class:`ziaplot.discrete.polylines.PolyLine` 45 | 46 | Alias: `Plot` 47 | 48 | 49 | .. tip:: 50 | 51 | Use `orient=True` in `.marker()` to point the markers in the direction 52 | of the line. 53 | 54 | .. jupyter-execute:: 55 | 56 | t = zp.linspace(-10, 10, 30) 57 | tsq = [ti**2 for ti in t] 58 | 59 | with zp.Graph(): 60 | zp.PolyLine(t, tsq).marker('arrow', orient=True) 61 | 62 | 63 | 64 | Scatter 65 | ------- 66 | 67 | Plots the (x, y) pairs as markers without connecting lines. 68 | 69 | .. jupyter-execute:: 70 | 71 | with zp.Graph(): 72 | zp.Scatter(x, y) 73 | 74 | 75 | :py:class:`ziaplot.discrete.polylines.Scatter` 76 | 77 | Alias: `Xy` 78 | 79 | 80 | ErrorBar 81 | -------- 82 | 83 | A PolyLine with optional error bars in x and y. 84 | 85 | 86 | .. jupyter-execute:: 87 | 88 | yerr = [yy/10 for yy in y] 89 | zp.ErrorBar(x, y, yerr=yerr) 90 | 91 | 92 | .. jupyter-execute:: 93 | 94 | xerr = [.1] * len(x) 95 | zp.ErrorBar(x, y, xerr=xerr) 96 | 97 | 98 | :py:class:`ziaplot.discrete.polylines.ErrorBar` 99 | 100 | 101 | 102 | LineFill 103 | -------- 104 | 105 | Fill the region between two y values. 106 | 107 | 108 | .. jupyter-execute:: 109 | 110 | zp.LineFill(x, ymin=y, ymax=y2).color('black').fill('blue 30%') 111 | 112 | 113 | :py:class:`ziaplot.discrete.polylines.LineFill` 114 | 115 | 116 | 117 | Histogram 118 | --------- 119 | 120 | Draws the histogram of a set of values. 121 | 122 | 123 | .. jupyter-execute:: 124 | 125 | import random 126 | v = [random.normalvariate(100, 5) for k in range(1000)] 127 | zp.Histogram(v) 128 | 129 | 130 | :py:class:`ziaplot.discrete.bars.Histogram` 131 | 132 | 133 | HistogramHoriz 134 | -------------- 135 | 136 | Histogram with the bars oriented horizontally. 137 | 138 | .. jupyter-execute:: 139 | 140 | zp.HistogramHoriz(v) 141 | 142 | 143 | :py:class:`ziaplot.discrete.bars.HistogramHoriz` 144 | 145 | .. note:: 146 | 147 | Use `bins` to set the number of bins in the histogram. 148 | 149 | .. jupyter-execute:: 150 | 151 | zp.Histogram(v, bins=7) 152 | 153 | .. seealso: 154 | 155 | For bar charts with qualitative independent variables, see :ref:`Charts`. 156 | 157 | 158 | LinePolar 159 | --------- 160 | 161 | Define a PolyLine using radius and angle (r, θ) polar coordinates. 162 | θ may be specified in radians or degrees. 163 | 164 | .. jupyter-execute:: 165 | 166 | th = zp.linspace(0, 2*math.pi, 500) 167 | r = [math.cos(7*t+math.pi/6) for t in th] 168 | 169 | with zp.GraphPolar(): 170 | zp.LinePolar(r, th, deg=False) 171 | 172 | 173 | :py:class:`ziaplot.discrete.polar.LinePolar` 174 | 175 | 176 | Contour 177 | ------- 178 | 179 | Countour plots are of 2-dimensional data. 180 | `x` and `y` are one-dimensional lists of values. 181 | `z` is a 2-dimensional (list of lists) array of height values. 182 | 183 | 184 | .. jupyter-execute:: 185 | 186 | x = y = zp.util.zrange(-2., 3., .1) 187 | z = [[2 * (math.exp(-xx**2 - yy**2) - math.exp(-(xx-1)**2 - (yy-1)**2)) for xx in x] for yy in y] 188 | 189 | with zp.Graph().size(400,300): 190 | p = zp.Contour(x, y, z, levels=12, colorbar='right') 191 | 192 | 193 | :py:class:`ziaplot.discrete.contour.Contour` 194 | 195 | 196 | .. note:: 197 | 198 | Use the `colorcycle` CSS attribute to set the colors. If two colors 199 | are provided, they fill fade evenly from the first to the second. 200 | Otherwise the contour levels will step through the list. 201 | 202 | 203 | .. hint:: 204 | 205 | The data for the above contour plot may be genereated more efficiently using Numpy (below), 206 | but Numpy is not a required dependency of ziaplot so it is not used in this documentation. 207 | The Contour algorithm will use Numpy for efficiency if it is installed. 208 | 209 | .. code-block:: python 210 | 211 | delta = 0.1 212 | x = np.arange(-2.0, 3.0, delta) 213 | y = np.arange(-2.0, 3.0, delta) 214 | X, Y = np.meshgrid(x, y) 215 | Z1 = np.exp(-X**2 - Y**2) 216 | Z2 = np.exp(-(X - 1)**2 - (Y - 1)**2) 217 | Z = (Z1 - Z2) * 2 218 | 219 | with zp.Graph().size(400,300): 220 | p = zp.Contour(x, y, Z, levels=12, colorbar='right') 221 | -------------------------------------------------------------------------------- /docs/intro.rst: -------------------------------------------------------------------------------- 1 | Introduction 2 | ============ 3 | 4 | 5 | Installation 6 | ------------ 7 | 8 | Ziaplot can be installed using pip: 9 | 10 | .. code-block:: bash 11 | 12 | pip install ziaplot 13 | 14 | 15 | For the optional cairosvg dependency (for saving images in formats other than SVG), install using: 16 | 17 | .. code-block:: bash 18 | 19 | pip install ziaplot[cairosvg] 20 | 21 | Or to enable LaTeX math expression rendering (via `ziamath `_), install using: 22 | 23 | .. code-block:: bash 24 | 25 | pip install ziaplot[math] 26 | 27 | | 28 | 29 | 30 | Quick Look 31 | ---------- 32 | 33 | .. jupyter-execute:: 34 | :hide-code: 35 | 36 | import math 37 | import ziaplot as zp 38 | zp.css('Canvas{width:300;height:300;}') 39 | 40 | 41 | Diagrams are built using a descriptive Python syntax, such that plotting geometric 42 | figures does not require discretizing the figure into an array of (x, y) pairs. 43 | 44 | .. jupyter-execute:: 45 | :hide-code: 46 | 47 | zp.css('Canvas{width:300;height:300;}') 48 | 49 | 50 | .. jupyter-execute:: 51 | 52 | import ziaplot as zp 53 | 54 | with zp.GraphQuad().xrange(-4, 4).yrange(-4, 4): 55 | line1 = zp.Line.from_slopeintercept(slope=0.25, intercept=1) 56 | line2 = zp.Line.from_slopeintercept(slope=-2, intercept=3) 57 | zp.Point.at_intersection(line1, line2) 58 | zp.Angle(line1, line2).label('θ') 59 | 60 | | 61 | 62 | Discrete data may just as easily be plotted: 63 | 64 | .. jupyter-execute:: 65 | 66 | x = [1, 2, 3, 4, 5] 67 | y = [0, .4, 1.2, 1.0, 1.4] 68 | with zp.Graph(): 69 | zp.PolyLine(x, y) 70 | 71 | | 72 | 73 | Simple style modifications may be made with chained methods: 74 | 75 | .. jupyter-execute:: 76 | 77 | with zp.Graph(): 78 | zp.PolyLine(x, y).color('green').marker('round') 79 | 80 | | 81 | 82 | or use the CSS-like :ref:`styling system` for full control of plotting styles: 83 | 84 | .. jupyter-execute:: 85 | 86 | css = ''' 87 | Graph { 88 | color: #EEEEFF; 89 | } 90 | PolyLine { 91 | color: blue; 92 | stroke_width: 4; 93 | } 94 | ''' 95 | with zp.Graph().css(css): 96 | zp.PolyLine(x, y) 97 | 98 | | 99 | 100 | Geometric diagrams without axes can also be created: 101 | 102 | .. jupyter-execute:: 103 | 104 | with zp.Diagram().css(zp.CSS_BLACKWHITE): 105 | circle = zp.Circle((0, 0), 1) 106 | zp.Point((0, 0)) 107 | r1 = circle.radius_segment(0) 108 | r2 = circle.radius_segment(45) 109 | zp.Angle(r1, r2, quad=4).label('α') 110 | 111 | 112 | | 113 | 114 | Bar charts and pie charts may be created from dictionaries or lists of the slice values: 115 | 116 | .. jupyter-execute:: 117 | :hide-code: 118 | 119 | zp.css('Canvas{width:350;height:300;}') 120 | 121 | 122 | .. jupyter-execute:: 123 | 124 | zp.Pie.fromdict({'Dogs':4, 'Cats':3, 'Birds':1, 'Gerbils': .5}).legend('none') 125 | 126 | 127 | .. jupyter-execute:: 128 | 129 | zp.BarChart.fromdict({'Dogs': 4, 'Cats': 3, 'Birds': 1, 'Gerbils': .5}) 130 | 131 | 132 | | 133 | 134 | Multiple diagrams may be combined into one image using horizontal, vertical, or grid layouts. 135 | 136 | .. jupyter-execute:: 137 | 138 | with zp.LayoutH().size(600, 300): 139 | zp.BarChart.fromdict({'Dogs': 4, 'Cats': 3, 'Birds': 1, 'Gerbils': .5}) 140 | zp.Pie.fromdict({'Dogs': 4, 'Cats': 3, 'Birds': 1, 'Gerbils': .5}).legend('none') 141 | 142 | | 143 | 144 | Concepts and Nomenclature 145 | ------------------------- 146 | 147 | Classes in ziaplot are structured as follows. 148 | Refer to this diagram for applying styles. For example, apply a style 149 | to "Annotation" to change the properties of all text, arrows, and angles. 150 | 151 | .. image:: images/inheritance.svg 152 | 153 | The ziaplot documentation (and code) uses these definitions: 154 | 155 | * **Drawing**: The entirety of an image, which may consist of one or more Diagrams. 156 | * **Diagram**: a surface to draw on as one section of a complete Drawing. 157 | * **Graph**: A Diagram with axes for locating data coordinates 158 | * **Chart**: A Graph with qualitative x-coordinates (Pie and Bar are Charts) 159 | * **Element**: An object, made of points, lines, or planes, drawn within a Diagram to represent geometric figures or data 160 | * **Annotation**: Text, arrows, etc., used to provide additional information 161 | * **Plot**: A verb, meaning locate and draw objects in a Diagram. 162 | * **Axis**: A line used for orienting space 163 | * **Axes**: Plural of Axis, usually meaning the x-axis and y-axis together 164 | * **Figure**: A geometric form consisting of points, lines, planes 165 | 166 | 167 | 168 | Why another plotting library? 169 | ----------------------------- 170 | 171 | Anyone who has been around Python long enough should be familiar with Matplotlib, the de facto standard for data visualization with Python. 172 | Matplotlib is powerful and flexible - it can plot anything. 173 | However, it was designed for plotting empirical data in the form of arrays of x and y values, so graphing true mathematical functions or 174 | geometric figures (lines, circles, segments, curves, etc.) typically becomes a chore of discretizing the function or shape into an array first. 175 | 176 | Additionally, Matplotlib has a confusing, non-Pythonic programming interface. 177 | What's the difference between a `figure()` and `Figure()`? 178 | Why does documentation sometimes use `plt..`, sometimes `ax..`, and sometimes the awful `from pylab import *`? 179 | It is also a huge dependency, requiring Numpy libraries and usually bundling several UI backends along with it. 180 | A simple Tkinter UI experiment (see :ref:`ziagui`), built into an executable with Pyinstaller, was 25 MB when the data was plotted with Ziaplot, but over 500 MB using Matplotlib! 181 | There are some Matplotlib alternatives. Seaborn just wraps Matplotlib to improve its interface. Plotly and Bokeh focus on interactivity and web applications. 182 | 183 | Ziaplot was created as a light-weight, easy to use, fast, and Pythonic library for making static diagrams and graphs in SVG format, 184 | while treating mathematical functions and geometric figures as first-class citizens. 185 | -------------------------------------------------------------------------------- /ziaplot/geometry/intersect.py: -------------------------------------------------------------------------------- 1 | ''' Methods for finding intersections between lines, circles, functions, etc. ''' 2 | from __future__ import annotations 3 | from typing import TYPE_CHECKING 4 | import math 5 | 6 | from ..util import root 7 | from .geometry import ( 8 | PointType, 9 | LineType, 10 | CircleType, 11 | ArcType, 12 | FunctionType, 13 | distance, 14 | angle_isbetween 15 | ) 16 | from . import line as _line 17 | 18 | if TYPE_CHECKING: 19 | from ..figures.line import Line 20 | from ..figures.shapes import Circle, Arc 21 | 22 | 23 | def lines(line1: LineType|'Line', line2: LineType|'Line') -> PointType: 24 | ''' Find point of intersection of two lines ''' 25 | a1, b1, c1 = line1 26 | a2, b2, c2 = line2 27 | d = a1*b2 - b1*a2 28 | dx = c1*b2 - b1*c2 29 | dy = a1*c2 - c1*a2 30 | if d == 0: 31 | raise ValueError('No intersection') 32 | return (dx/d, dy/d) 33 | 34 | 35 | def line_angle(line1: LineType|'Line', line2: LineType|'Line') -> float: 36 | ''' Find angle (rad) of intersection between the two lines ''' 37 | m1 = _line.slope(line1) 38 | m2 = _line.slope(line2) 39 | theta = abs(math.atan(m1) - math.atan(m2)) % math.tau 40 | return theta 41 | 42 | 43 | def line_circle(line: LineType|'Line', circle: CircleType|'Circle') -> tuple[PointType, PointType]: 44 | ''' Find intersections between line and circle ''' 45 | slope = _line.slope(line) 46 | centerx, centery, radius, *_ = circle 47 | 48 | if not math.isfinite(slope): 49 | xint = _line.xvalue(line, 0) 50 | A = 1.0 51 | B = -2*centery 52 | C = centerx**2 + centery**2 - radius**2 - 2*xint*centerx + xint**2 53 | try: 54 | d = math.sqrt(B**2 - 4*A*C) 55 | except ValueError as exc: 56 | if B**2 - 4*A*C < 1E-14: 57 | d = 0 # Close enough - point is tangent 58 | else: 59 | raise ValueError('No intersection') from exc 60 | y1 = (-B+d) / (2*A) 61 | y2 = (-B-d) / (2*A) 62 | x1 = _line.xvalue(line, y1) 63 | x2 = _line.xvalue(line, y2) 64 | 65 | else: 66 | intercept = _line.intercept(line) # Any point on the line 67 | A = slope**2 + 1 68 | B = 2*(slope*intercept - slope*centery - centerx) 69 | C = (centery**2 - radius**2 + centerx**2 - 2*intercept*centery + intercept**2) 70 | try: 71 | d = math.sqrt(B**2 - 4*A*C) 72 | except ValueError as exc: 73 | if B**2 - 4*A*C < 1E-14: 74 | d = 0 # Close enough - point is tangent 75 | else: 76 | raise ValueError('No intersection') from exc 77 | 78 | x1 = (-B + d) / (2*A) 79 | x2 = (-B - d) / (2*A) 80 | y1 = _line.yvalue(line, x1) 81 | y2 = _line.yvalue(line, x2) 82 | 83 | return (x1, y1), (x2, y2) 84 | 85 | 86 | def circles(circle1: CircleType|ArcType|'Circle', 87 | circle2: CircleType|ArcType|'Circle') -> tuple[PointType, PointType]: 88 | ''' Get points of intersection between two circles ''' 89 | x1, y1, r1, *_ = circle1 90 | x2, y2, r2, *_ = circle2 91 | dx, dy = x2 - x1, y2 - y1 92 | dist = distance((x1, y1), (x2, y2)) 93 | 94 | if dist > r1 + r2 or dist < abs(r1 - r2): 95 | # No intersections 96 | return (math.nan, math.nan), (math.nan, math.nan) 97 | elif dist == 0 and r1 == r2: 98 | # Identical circles (infinite intersections) 99 | return (math.nan, math.nan), (math.nan, math.nan) 100 | 101 | a = (r1*r1 - r2*r2 + dist*dist) / (2*dist) 102 | h = math.sqrt(r1*r1 - a*a) 103 | xm = x1 + a*dx/dist 104 | ym = y1 + a*dy/dist 105 | xs1 = xm + h*dy/dist 106 | xs2 = xm - h*dy/dist 107 | ys1 = ym - h*dx/dist 108 | ys2 = ym + h*dx/dist 109 | 110 | if len(tuple(circle1)) > 4: 111 | # circle1 is arc, ensure points fall on the arc 112 | atheta1, atheta2 = circle1[3], circle1[4] # type: ignore 113 | thetap1 = math.atan2(ys1-y1, xs1-x1) 114 | thetap2 = math.atan2(ys2-y1, xs2-x1) 115 | if not angle_isbetween(thetap1, atheta1, atheta2): 116 | xs1, ys1 = math.nan, math.nan 117 | if not angle_isbetween(thetap2, atheta1, atheta2): 118 | xs2, ys2 = math.nan, math.nan 119 | 120 | if len(tuple(circle2)) > 4: 121 | # circle2 is arc, ensure points fall on the arc 122 | atheta1, atheta2 = circle2[3], circle2[4] # type: ignore 123 | thetap1 = math.atan2(ys1-y2, xs1-x2) 124 | thetap2 = math.atan2(ys2-y2, xs2-x2) 125 | if not angle_isbetween(thetap1, atheta1, atheta2): 126 | xs1, ys1 = math.nan, math.nan 127 | if not angle_isbetween(thetap2, atheta1, atheta2): 128 | xs2, ys2 = math.nan, math.nan 129 | 130 | if not math.isfinite(xs1) and math.isfinite(xs2): 131 | (xs1, ys1), (xs2, ys2) = (xs2, ys2), (xs1, ys1) 132 | 133 | return (xs1, ys1), (xs2, ys2) 134 | 135 | 136 | def line_arc(line: LineType|'Line', arc: ArcType|'Arc'): 137 | ''' Find intersection of line and arc. Same as circle, but ensures point falls on the arc ''' 138 | centerx, centery, radius, atheta1, atheta2 = arc 139 | p1, p2 = line_circle(line, (centerx, centery, radius)) 140 | 141 | theta1 = math.atan2(p1[1]-centery, p1[0]-centerx) 142 | theta2 = math.atan2(p2[1]-centery, p2[0]-centerx) 143 | 144 | if not angle_isbetween(theta1, atheta1, atheta2): 145 | p1 = (math.nan, math.nan) 146 | if not angle_isbetween(theta2, atheta1, atheta2): 147 | p2 = (math.nan, math.nan) 148 | 149 | if not math.isfinite(p1[0]) and math.isfinite(p2[0]): 150 | p1, p2 = p2, p1 151 | 152 | return p1, p2 153 | 154 | 155 | def functions(f1: FunctionType, f2: FunctionType, 156 | x1: float, x2: float) -> PointType: 157 | ''' Find intersection between two Functions in the 158 | interval x1 to x2. 159 | Raises ValueError if the curves do not intersect. 160 | ''' 161 | tol = abs(x2-x1) * 1E-4 162 | try: 163 | x = root(lambda x: f1(x) - f2(x), 164 | a=x1, b=x2, tol=tol) 165 | except (ValueError, RecursionError) as exc: 166 | raise ValueError('No intersection found') from exc 167 | 168 | y = f1(x) 169 | return x, y 170 | -------------------------------------------------------------------------------- /ziaplot/discrete/bars.py: -------------------------------------------------------------------------------- 1 | ''' Histogram and bar chart Bars ''' 2 | from __future__ import annotations 3 | from typing import Optional, Sequence 4 | import math 5 | import xml.etree.ElementTree as ET 6 | from collections import Counter 7 | 8 | from ..text import Halign 9 | from ..canvas import Canvas, Borders, ViewBox, DataRange 10 | from ..diagrams import Graph 11 | from ..element import Element 12 | 13 | 14 | class Bars(Element): 15 | ''' A series of bars to add to an Graph (quantitative x values) 16 | For qualitative bar chart, use a BarChart instance. 17 | 18 | Args: 19 | x: X-values of each bar 20 | y: Y-values of each bar 21 | y2: Minimum y-values of each bar 22 | width: Width of all bars 23 | align: Bar position in relation to x value 24 | ''' 25 | _step_color = True 26 | legend_square = True 27 | 28 | def __init__(self, x: Sequence[float], y: Sequence[float], y2: Optional[Sequence[float]] = None, 29 | width: Optional[float] = None, align: Halign = 'center'): 30 | super().__init__() 31 | self.x = x 32 | self.y = y 33 | self.align = align 34 | if width is None: 35 | self.width = self.x[1]-self.x[0] 36 | if self.width == 0: 37 | self.width = 1 38 | else: 39 | self.width = width 40 | self.y2 = y2 if y2 is not None else [0] * len(self.x) 41 | 42 | def datarange(self): 43 | ''' Get x-y datarange ''' 44 | ymin, ymax = min(self.y2), max(self.y)+max(self.y)/25 45 | if self.align == 'left': 46 | xmin, xmax = min(self.x), max(self.x)+self.width 47 | elif self.align == 'center': 48 | xmin, xmax = min(self.x)-self.width/2, max(self.x)+self.width/2 49 | else: # self.align == 'right': 50 | xmin, xmax = min(self.x)-self.width, max(self.x) 51 | return DataRange(xmin, xmax, ymin, ymax) 52 | 53 | def _logy(self) -> None: 54 | ''' Convert y coordinates to log(y) ''' 55 | self.y = [math.log10(y) for y in self.y] 56 | 57 | def _logx(self) -> None: 58 | ''' Convert x values to log(x) ''' 59 | self.x = [math.log10(x) for x in self.x] 60 | self.width = math.log10(self.x[1] - math.log10(self.x[0])) 61 | 62 | def _xml(self, canvas: Canvas, databox: Optional[ViewBox] = None, 63 | borders: Optional[Borders] = None) -> None: 64 | ''' Add XML elements to the canvas ''' 65 | sty = self._build_style() 66 | color = sty.get_color() 67 | for x, y, y2 in zip(self.x, self.y, self.y2): 68 | if self.align == 'center': 69 | x -= self.width/2 70 | elif self.align == 'right': 71 | x -= self.width 72 | 73 | canvas.rect(x, y2, self.width, y-y2, 74 | fill=color, 75 | strokecolor=sty.edge_color, 76 | strokewidth=sty.edge_width, 77 | dataview=databox, 78 | zorder=self._zorder) 79 | 80 | def svgxml(self, border: bool = False) -> ET.Element: 81 | ''' Generate XML for standalone SVG ''' 82 | graph = Graph() 83 | graph.add(self) 84 | return graph.svgxml(border=border) 85 | 86 | 87 | class BarsHoriz(Bars): 88 | ''' Horizontal bars ''' 89 | def datarange(self) -> DataRange: 90 | ''' Get x-y datarange ''' 91 | rng = super().datarange() # Transpose it 92 | return DataRange(rng.ymin, rng.ymax, rng.xmin, rng.xmax) 93 | 94 | def _xml(self, canvas: Canvas, databox: Optional[ViewBox] = None, 95 | borders: Optional[Borders] = None) -> None: 96 | ''' Add XML elements to the canvas ''' 97 | sty = self._build_style() 98 | color = sty.get_color() 99 | for x, y, y2 in zip(self.x, self.y, self.y2): 100 | if self.align == 'center': 101 | x -= self.width/2 102 | elif self.align in ['right', 'top']: 103 | x -= self.width 104 | 105 | canvas.rect(y2, x, y-y2, 106 | self.width, 107 | fill=color, 108 | strokecolor=sty.edge_color, 109 | strokewidth=sty.edge_width, 110 | dataview=databox, 111 | zorder=self._zorder) 112 | 113 | 114 | class Histogram(Bars): 115 | ''' Histogram data 116 | 117 | Args: 118 | x: Data to show as histogram 119 | bins: Number of bins for histogram 120 | binrange: Tuple of (start, stop, step) defining bins 121 | density: Normalize the histogram 122 | weights: Weights to apply to each x value 123 | ''' 124 | def __init__(self, x: Sequence[float], bins: Optional[int] = None, 125 | binrange: Optional[tuple[float, float, float]] = None, 126 | density: bool = False, weights: Optional[Sequence[float]] = None): 127 | xmin = min(x) 128 | if binrange is not None: 129 | binleft = binrange[0] 130 | binright = binrange[1] 131 | binwidth = binrange[2] 132 | bins = math.ceil((binright - binleft) / binwidth) 133 | binlefts = [binleft + binwidth*i for i in range(bins)] 134 | elif bins is None: 135 | bins = math.ceil(math.sqrt(len(x))) 136 | binwidth = (max(x) - xmin) / bins 137 | binlefts = [xmin + binwidth*i for i in range(bins)] 138 | binright = binlefts[-1] + binwidth 139 | else: 140 | binwidth = (max(x) - xmin) / bins 141 | binlefts = [xmin + binwidth*i for i in range(bins)] 142 | binright = binlefts[-1] + binwidth 143 | 144 | binr = binright-binlefts[0] 145 | if binr != 0: 146 | xnorm = [(xx-binlefts[0])/binr * bins for xx in x] 147 | else: 148 | xnorm = [0] * len(x) 149 | xint = [math.floor(v) for v in xnorm] 150 | 151 | if weights is None: 152 | counter = Counter(xint) 153 | counts: list[float] = [counter[xx] for xx in range(bins)] 154 | if binrange is None: 155 | # If auto-binning, need to include rightmost endpoint 156 | counts[-1] += counter[bins] 157 | else: # weighed 158 | counts = [0] * bins 159 | for w, b in zip(weights, xint): 160 | try: 161 | counts[b] += w 162 | except IndexError: 163 | if b == len(counts) and binrange is None: 164 | # If auto-binning, need to include rightmost endpoint 165 | counts[-1] += w 166 | 167 | if density: 168 | cmax = sum(counts) * binwidth 169 | counts = [c/cmax for c in counts] 170 | 171 | super().__init__(binlefts, counts, align='left') 172 | 173 | 174 | class HistogramHoriz(Histogram, BarsHoriz): 175 | pass 176 | -------------------------------------------------------------------------------- /ziaplot/diagrams/polar.py: -------------------------------------------------------------------------------- 1 | ''' Polar plotting ''' 2 | from __future__ import annotations 3 | from typing import Optional 4 | from functools import lru_cache 5 | import math 6 | 7 | from ..text import Halign, Valign 8 | from .diagram import Diagram, Ticks 9 | from .graph import getticks 10 | from ..canvas import Canvas, Borders, ViewBox 11 | 12 | 13 | class GraphPolar(Diagram): 14 | ''' Polar Plot. Use with LinePolar to define lines in (radius, angle) 15 | format. 16 | 17 | Args: 18 | labeldeg: Draw theta labels in degrees vs. radians 19 | labeltheta: Angle for drawing R labels 20 | ''' 21 | def __init__(self, labeldeg: bool = True, labeltheta: float = 0): 22 | super().__init__() 23 | self.labeldegrees = labeldeg 24 | self.labeltheta: float = labeltheta 25 | 26 | def rrange(self, rmax: float) -> None: 27 | ''' Sets maximum radius to display ''' 28 | self.xrange(0, rmax) 29 | 30 | def yrange(self, ymin: float, ymax: float) -> None: 31 | ''' Sets range of y data ''' 32 | raise ValueError('Cannot set y (theta) range on polar plot') 33 | 34 | def _clearcache(self): 35 | ''' Clear the LRU cache when inputs change ''' 36 | super()._clearcache() 37 | self._maketicks.cache_clear() 38 | 39 | @lru_cache 40 | def _maketicks(self) -> Ticks: 41 | ''' Generate tick names and positions. Y/Theta ticks are always 42 | 0 to 360, but can be degrees or radians. X/Radius ticks 43 | depend on the data, but always start at 0. 44 | ''' 45 | xsty = self._build_style('Graph.TickX') 46 | _, xmax, _, _ = self.datarange() 47 | if self._xtickvalues: 48 | xticks = self._xtickvalues 49 | xmax = max(xmax, max(xticks)) 50 | else: 51 | xticks = getticks(0, xmax, maxticks=6) 52 | xmax = xticks[-1] 53 | 54 | xnames = self._xticknames 55 | if xnames is None: 56 | xnames = [format(xt, xsty.num_format) for xt in xticks] 57 | 58 | yticks = [0, 45, 90, 135, 180, 225, 270, 315] 59 | if self.labeldegrees: 60 | ynames = [f'{i}°' for i in yticks] 61 | else: 62 | ynames = ['0', 'π/4', 'π/2', '3π/4', 'π', '5π/4', '3π/2', '7π/4'] 63 | ticks = Ticks(xticks, yticks, xnames, ynames, 0, (0, xmax), (0, 360), None, None) 64 | return ticks 65 | 66 | def _drawframe(self, canvas: Canvas, ticks: Ticks) -> tuple[float, float, float]: 67 | ''' Draw the graph frame, ticks, and grid 68 | 69 | Args: 70 | canvas: SVG canvas to draw on 71 | ticks: Tick names and positions 72 | ''' 73 | sty = self._build_style() 74 | gridsty = self._build_style('Graph.GridX') 75 | ticksty = self._build_style('Graph.TickX') 76 | radius = min(canvas.viewbox.w, canvas.viewbox.h) / 2 - sty.pad*2 - sty.font_size*2 77 | cx = canvas.viewbox.x + canvas.viewbox.w/2 78 | cy = canvas.viewbox.y + canvas.viewbox.h/2 79 | 80 | if self._title: 81 | tsty = self._build_style('Graph.Title') 82 | radius -= tsty.font_size/2 83 | cy -= tsty.font_size/2 84 | canvas.text(canvas.viewbox.w/2, canvas.viewbox.h, 85 | self._title, font=tsty.font, 86 | size=tsty.font_size, 87 | color=tsty.get_color(), 88 | halign='center', valign='top') 89 | 90 | canvas.circle(cx, cy, radius, color=sty.get_color(), 91 | strokecolor=sty.edge_color, 92 | strokewidth=sty.edge_width, 93 | zorder=self._zorder) 94 | 95 | for i, rname in enumerate(ticks.xnames): 96 | if i in [0, len(ticks.xnames)-1]: 97 | continue 98 | r = radius / (len(ticks.xticks)-1) * i 99 | canvas.circle(cx, cy, r, strokecolor=gridsty.get_color(), 100 | strokewidth=gridsty.stroke_width, 101 | color='none', stroke=gridsty.stroke, 102 | zorder=self._zorder) 103 | 104 | textx = cx + r * math.cos(math.radians(self.labeltheta)) 105 | texty = cy + r * math.sin(math.radians(self.labeltheta)) 106 | canvas.text(textx, texty, rname, halign='center', 107 | color=ticksty.get_color()) 108 | 109 | for i, (theta, tname) in enumerate(zip(ticks.yticks, ticks.ynames)): 110 | thetarad = math.radians(theta) 111 | x = radius * math.cos(thetarad) 112 | y = radius * math.sin(thetarad) 113 | canvas.path([cx, cx+x], [cy, cy+y], 114 | color=gridsty.get_color(), 115 | width=gridsty.stroke_width, 116 | stroke=gridsty.stroke, 117 | zorder=self._zorder) 118 | 119 | labelx = cx + (radius+sty.margin) * math.cos(-thetarad) 120 | labely = cy - (radius+sty.margin) * math.sin(-thetarad) 121 | halign: Halign 122 | valign: Valign 123 | if abs(labelx - cx) < .1: 124 | halign = 'center' 125 | elif labelx > cx: 126 | halign = 'left' 127 | else: 128 | halign = 'right' 129 | if abs(labely - cy) < .1: 130 | valign = 'center' 131 | elif labely > cy: 132 | valign = 'bottom' 133 | else: 134 | valign = 'top' 135 | 136 | canvas.text(labelx, labely, tname, halign=halign, valign=valign, 137 | color=ticksty.get_color()) 138 | return radius, cx, cy 139 | 140 | def _draw_polarcontents(self, canvas: Canvas, radius: float, 141 | cx: float, cy: float, ticks: Ticks) -> None: 142 | ''' Draw all components 143 | 144 | Args: 145 | canvas: SVG canvas to draw on 146 | radius: radius of full circle 147 | cx, cy: canvas center of full circle 148 | ticks: Tick definitions 149 | ''' 150 | self._assign_component_colors(self.components) 151 | 152 | dradius = ticks.xticks[-1] 153 | databox = ViewBox(-dradius, -dradius, dradius*2, dradius*2) 154 | viewbox = ViewBox(cx-radius, cy-radius, radius*2, radius*2) 155 | canvas.setviewbox(viewbox) 156 | for f in self.components: 157 | f._xml(canvas, databox=databox) 158 | canvas.resetviewbox() 159 | 160 | def _xml(self, canvas: Canvas, databox: Optional[ViewBox] = None, 161 | borders: Optional[Borders] = None) -> None: 162 | ''' Add XML elements to the canvas ''' 163 | ticks = self._maketicks() 164 | radius, cx, cy = self._drawframe(canvas, ticks) 165 | axbox = ViewBox(cx-radius, cy-radius, radius*2, radius*2) 166 | self._draw_polarcontents(canvas, radius, cx, cy, ticks) 167 | self._drawlegend(canvas, axbox, ticks) 168 | -------------------------------------------------------------------------------- /ziaplot/figures/function.py: -------------------------------------------------------------------------------- 1 | ''' Graph mathematical functions ''' 2 | from __future__ import annotations 3 | from typing import Optional, Callable, Sequence 4 | from xml.etree import ElementTree as ET 5 | import math 6 | 7 | from .. import util 8 | from ..geometry import PointType 9 | from ..element import Element 10 | from ..style import MarkerTypes 11 | from ..canvas import Canvas, Borders, ViewBox 12 | from ..diagrams import Graph 13 | from .line import Line, Segment 14 | 15 | 16 | class Function(Element): 17 | ''' Plot a function 18 | 19 | Args: 20 | func: Callable function of x, returning y (e.g. lambda x: x**2) 21 | xmin: Minimum x value 22 | xmax: Maximum x value 23 | n: Number of datapoints for discrete representation 24 | ''' 25 | _step_color = True 26 | 27 | def __init__(self, 28 | func: Callable[[float], float], 29 | xrange: Optional[tuple[float, float]] = None, 30 | n: int = 200): 31 | super().__init__() 32 | self._func = func 33 | self.func = func 34 | self.xrange = xrange 35 | self.n = n 36 | self.startmark: MarkerTypes = None 37 | self.endmark: MarkerTypes = None 38 | self.midmark: MarkerTypes = None 39 | self.__logx = False 40 | self.__logy = False 41 | 42 | def endmarkers(self, start: MarkerTypes = '<', end: MarkerTypes = '>') -> 'Function': 43 | ''' Define markers to show at the start and end of the line. Use defaults 44 | to show arrowheads pointing outward in the direction of the line. 45 | ''' 46 | self.startmark = start 47 | self.endmark = end 48 | return self 49 | 50 | def midmarker(self, midmark: MarkerTypes = '<') -> 'Function': 51 | ''' Define marker for midpoint (x/2) of Function curve ''' 52 | self.midmark = midmark 53 | return self 54 | 55 | def _logy(self) -> None: 56 | ''' Convert y coordinates to log(y) ''' 57 | self.__logy = True 58 | 59 | def _logx(self) -> None: 60 | ''' Convert x values to log(x) ''' 61 | self.__logx = True 62 | 63 | def y(self, x: float) -> float: 64 | ''' Evaluate f(x) ''' 65 | y = self.func(x) 66 | if self.__logy: 67 | y = math.log10(y) if y > 0 else math.nan 68 | return y 69 | 70 | def x(self, y: float) -> float: 71 | ''' Calculate x at given y ''' 72 | x0 = self.xrange[0] if self.xrange else 1 73 | return util.root_newton(lambda x: self.func(x) - y, x0=x0, tol=y/1E4) 74 | 75 | def xy(self, x: float) -> PointType: 76 | ''' Calculate (x, y) on function at x ''' 77 | return x, self.y(x) 78 | 79 | def _tangent_slope(self, x: float) -> float: 80 | ''' Calculate angle tangent to function at x ''' 81 | return util.derivative(self.func, x) 82 | 83 | def tangent(self, x: float) -> Line: 84 | ''' Create tangent line to function at x ''' 85 | slope = self._tangent_slope(x) 86 | p = self.xy(x) 87 | return Line(p, slope) 88 | 89 | def normal(self, x: float) -> Line: 90 | ''' Create tangent line to function at x ''' 91 | slope = self._tangent_slope(x) 92 | p = self.xy(x) 93 | return Line(p, -1/slope) 94 | 95 | def secant(self, x1: float, x2: float) -> Line: 96 | ''' Create a Line connecting x1 and x2 on the funciton ''' 97 | p1 = self.xy(x1) 98 | p2 = self.xy(x2) 99 | return Line.from_points(p1, p2) 100 | 101 | def chord(self, x1: float, x2: float) -> Segment: 102 | ''' Create a chord Segment connecting x1 and x2 on the funciton ''' 103 | p1 = self.xy(x1) 104 | p2 = self.xy(x2) 105 | return Segment(p1, p2) 106 | 107 | def _local_max(self, x1: float, x2: float) -> float: 108 | ''' Return x value where maximum point occurs 109 | between x1 and x2 110 | ''' 111 | return util.maximum(self.func, x1, x2) 112 | 113 | def _local_min(self, x1: float, x2: float) -> float: 114 | ''' Return x value where minimum point occurs 115 | between x1 and x2 116 | ''' 117 | return util.minimum(self.func, x1, x2) 118 | 119 | def _evaluate(self, x: Sequence[float]) -> tuple[Sequence[float], Sequence[float]]: 120 | ''' Evaluate and return (x, y) in logscale if needed ''' 121 | y = [self.func(xx) for xx in x] 122 | if self.__logy: 123 | y = [math.log10(yy) if yy > 0 else math.nan for yy in y] 124 | if self.__logx: 125 | x = [math.log10(xx) if xx > 0 else math.nan for xx in x] 126 | return x, y 127 | 128 | def _xml(self, canvas: Canvas, databox: Optional[ViewBox] = None, 129 | borders: Optional[Borders] = None) -> None: 130 | ''' Add XML elements to the canvas ''' 131 | assert databox is not None 132 | sty = self._build_style() 133 | color = sty.get_color() 134 | 135 | xrange = self.xrange 136 | if xrange is None: 137 | xrange = databox.x, databox.x+databox.w 138 | x, y = self._evaluate(util.linspace(*xrange, self.n)) 139 | 140 | startmark = None 141 | endmark = None 142 | if self.startmark: 143 | startmark = canvas.definemarker(self.startmark, 144 | sty.radius, 145 | color, 146 | sty.edge_color, 147 | sty.edge_width, 148 | orient=True) 149 | if self.endmark: 150 | endmark = canvas.definemarker(self.endmark, 151 | sty.radius, 152 | color, 153 | sty.edge_color, 154 | sty.edge_width, 155 | orient=True) 156 | 157 | canvas.path(x, y, 158 | stroke=sty.stroke, 159 | color=color, 160 | width=sty.stroke_width, 161 | startmarker=startmark, 162 | endmarker=endmark, 163 | dataview=databox, 164 | zorder=self._zorder) 165 | 166 | if self.midmark: 167 | midmark = canvas.definemarker(self.midmark, 168 | sty.radius, 169 | color, 170 | sty.edge_color, 171 | sty.stroke_width, 172 | orient=True) 173 | midx = (xrange[0]+xrange[1])/2 174 | midy = self.y(midx) 175 | slope = self._tangent_slope(0.5) 176 | dx = midx/1E3 177 | midx1 = midx + dx 178 | midy1 = midy + dx*slope 179 | canvas.path([midx, midx1], [midy, midy1], 180 | color='none', 181 | startmarker=midmark, 182 | dataview=databox, 183 | zorder=self._zorder) 184 | 185 | def svgxml(self, border: bool = False) -> ET.Element: 186 | ''' Generate XML for standalone SVG ''' 187 | graph = Graph() 188 | graph.add(self) 189 | return graph.svgxml(border=border) 190 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | API 2 | ==== 3 | 4 | 5 | Base Classes 6 | ------------ 7 | 8 | .. autoclass:: ziaplot.drawable.Drawable 9 | :members: 10 | 11 | .. autoclass:: ziaplot.container.Container 12 | 13 | .. autoclass:: ziaplot.element.Element 14 | :members: 15 | 16 | | 17 | 18 | Diagrams and Graphs 19 | ------------------- 20 | 21 | .. autoclass:: ziaplot.diagrams.diagram.Diagram 22 | :members: 23 | 24 | .. autoclass:: ziaplot.diagrams.graph.Graph 25 | :members: 26 | 27 | .. autoclass:: ziaplot.diagrams.graph.GraphQuad 28 | :members: 29 | 30 | .. autoclass:: ziaplot.diagrams.graph.GraphQuadCentered 31 | :members: 32 | 33 | .. autoclass:: ziaplot.diagrams.graphlog.GraphLogY 34 | :members: 35 | 36 | .. autoclass:: ziaplot.diagrams.graphlog.GraphLogX 37 | :members: 38 | 39 | .. autoclass:: ziaplot.diagrams.graphlog.GraphLogXY 40 | :members: 41 | 42 | .. autoclass:: ziaplot.diagrams.polar.GraphPolar 43 | :members: 44 | 45 | .. autoclass:: ziaplot.diagrams.smith.GraphSmith 46 | :members: 47 | 48 | .. autoclass:: ziaplot.diagrams.ticker._Ticker 49 | :members: 50 | 51 | | 52 | 53 | Discrete Plot Types 54 | ------------------- 55 | 56 | .. autoclass:: ziaplot.discrete.polylines.PolyLine 57 | :members: 58 | 59 | .. autoclass:: ziaplot.discrete.polylines.Scatter 60 | :members: 61 | 62 | .. autoclass:: ziaplot.discrete.polylines.ErrorBar 63 | :members: 64 | 65 | .. autoclass:: ziaplot.discrete.polylines.LineFill 66 | :members: 67 | 68 | .. autoclass:: ziaplot.discrete.bars.Bars 69 | :members: 70 | 71 | .. autoclass:: ziaplot.discrete.bars.BarsHoriz 72 | :members: 73 | 74 | .. autoclass:: ziaplot.discrete.bars.Histogram 75 | :members: 76 | 77 | .. autoclass:: ziaplot.discrete.bars.HistogramHoriz 78 | :members: 79 | 80 | .. autoclass:: ziaplot.discrete.polar.LinePolar 81 | :members: 82 | 83 | .. autoclass:: ziaplot.discrete.contour.Contour 84 | :members: 85 | 86 | .. autoclass:: ziaplot.diagrams.smith.SmithConstResistance 87 | :members: 88 | 89 | .. autoclass:: ziaplot.diagrams.smith.SmithConstReactance 90 | :members: 91 | 92 | 93 | | 94 | 95 | Geometric Figures 96 | ----------------- 97 | 98 | .. autoclass:: ziaplot.figures.function.Function 99 | :members: 100 | 101 | .. autoclass:: ziaplot.figures.implicit.Implicit 102 | :members: 103 | 104 | .. autoclass:: ziaplot.figures.line.Line 105 | :members: 106 | 107 | .. autoclass:: ziaplot.figures.line.VLine 108 | :members: 109 | 110 | .. autoclass:: ziaplot.figures.line.HLine 111 | :members: 112 | 113 | .. autoclass:: ziaplot.figures.line.Segment 114 | :members: 115 | 116 | .. autoclass:: ziaplot.figures.line.Vector 117 | :members: 118 | 119 | .. autoclass:: ziaplot.figures.point.Point 120 | :members: 121 | 122 | .. autoclass:: ziaplot.figures.bezier.Bezier 123 | :members: 124 | 125 | .. autoclass:: ziaplot.figures.bezier.Curve 126 | :members: 127 | 128 | .. autoclass:: ziaplot.figures.bezier.CurveThreePoint 129 | :members: 130 | 131 | .. autoclass:: ziaplot.figures.integral.IntegralFill 132 | :members: 133 | 134 | .. autoclass:: ziaplot.figures.shapes.Circle 135 | :members: 136 | 137 | .. autoclass:: ziaplot.figures.shapes.Ellipse 138 | :members: 139 | 140 | .. autoclass:: ziaplot.figures.shapes.Rectangle 141 | :members: 142 | 143 | | 144 | 145 | 146 | Charts 147 | ------ 148 | 149 | .. autoclass:: ziaplot.charts.pie.Pie 150 | :members: 151 | 152 | .. autoclass:: ziaplot.charts.pie.PieSlice 153 | :members: 154 | 155 | .. autoclass:: ziaplot.charts.bar.BarChart 156 | :members: 157 | 158 | .. autoclass:: ziaplot.charts.bar.Bar 159 | :members: 160 | 161 | .. autoclass:: ziaplot.charts.bar.BarChartGrouped 162 | :members: 163 | 164 | .. autoclass:: ziaplot.charts.bar.BarSeries 165 | :members: 166 | 167 | | 168 | 169 | Annotations 170 | ----------- 171 | 172 | .. autoclass:: ziaplot.annotations.text.Text 173 | :members: 174 | 175 | .. autoclass:: ziaplot.annotations.annotations.Arrow 176 | :members: 177 | 178 | .. autoclass:: ziaplot.annotations.annotations.Angle 179 | :members: 180 | 181 | | 182 | 183 | Layouts 184 | ------- 185 | 186 | .. autoclass:: ziaplot.layout.LayoutH 187 | :members: 188 | 189 | .. autoclass:: ziaplot.layout.LayoutV 190 | :members: 191 | 192 | .. autoclass:: ziaplot.layout.LayoutGrid 193 | :members: 194 | 195 | .. autoclass:: ziaplot.layout.LayoutEmpty 196 | :members: 197 | 198 | 199 | 200 | Global Themes and CSS 201 | --------------------- 202 | 203 | .. autofunction:: ziaplot.style.themes.css 204 | 205 | .. autofunction:: ziaplot.style.themes.theme 206 | 207 | .. autofunction:: ziaplot.style.themes.theme_list 208 | 209 | 210 | 211 | General Functions 212 | ----------------- 213 | 214 | .. autofunction:: ziaplot.container.save 215 | 216 | 217 | Geometric Calculations 218 | ---------------------- 219 | 220 | A few calculation functions are made available to the user. 221 | 222 | .. autofunction:: ziaplot.geometry.distance 223 | 224 | .. autofunction:: ziaplot.geometry.isclose 225 | 226 | .. autofunction:: ziaplot.geometry.midpoint 227 | 228 | .. autofunction:: ziaplot.geometry.translate 229 | 230 | .. autofunction:: ziaplot.geometry.reflect 231 | 232 | .. autofunction:: ziaplot.geometry.rotate 233 | 234 | .. autofunction:: ziaplot.geometry.image 235 | 236 | .. autofunction:: ziaplot.geometry.angle_mean 237 | 238 | .. autofunction:: ziaplot.geometry.angle_diff 239 | 240 | .. autofunction:: ziaplot.geometry.angle_isbetween 241 | 242 | .. autofunction:: ziaplot.geometry.line.slope 243 | 244 | .. autofunction:: ziaplot.geometry.line.intercept 245 | 246 | .. autofunction:: ziaplot.geometry.line.xintercept 247 | 248 | .. autofunction:: ziaplot.geometry.line.yvalue 249 | 250 | .. autofunction:: ziaplot.geometry.line.xvalue 251 | 252 | .. autofunction:: ziaplot.geometry.line.normal_distance 253 | 254 | .. autofunction:: ziaplot.geometry.line.bisect 255 | 256 | .. autofunction:: ziaplot.geometry.line.bisect_points 257 | 258 | .. autofunction:: ziaplot.geometry.circle.point 259 | 260 | .. autofunction:: ziaplot.geometry.circle.tangent_angle 261 | 262 | .. autofunction:: ziaplot.geometry.circle.tangent_at 263 | 264 | .. autofunction:: ziaplot.geometry.circle.tangent_points 265 | 266 | .. autofunction:: ziaplot.geometry.circle.tangent 267 | 268 | .. autofunction:: ziaplot.geometry.ellipse.point 269 | 270 | .. autofunction:: ziaplot.geometry.ellipse.tangent_points 271 | 272 | .. autofunction:: ziaplot.geometry.ellipse.tangent_angle 273 | 274 | .. autofunction:: ziaplot.geometry.function.local_max 275 | 276 | .. autofunction:: ziaplot.geometry.function.local_min 277 | 278 | .. autofunction:: ziaplot.geometry.function.tangent 279 | 280 | .. autofunction:: ziaplot.geometry.function.normal 281 | 282 | .. autofunction:: ziaplot.geometry.bezier.xy 283 | 284 | .. autofunction:: ziaplot.geometry.bezier.tangent_slope 285 | 286 | .. autofunction:: ziaplot.geometry.bezier.tangent_angle 287 | 288 | .. autofunction:: ziaplot.geometry.bezier.length 289 | 290 | .. autofunction:: ziaplot.geometry.bezier.equal_spaced_points 291 | 292 | .. autofunction:: ziaplot.geometry.intersect.lines 293 | 294 | .. autofunction:: ziaplot.geometry.intersect.line_angle 295 | 296 | .. autofunction:: ziaplot.geometry.intersect.line_circle 297 | 298 | .. autofunction:: ziaplot.geometry.intersect.circles 299 | 300 | .. autofunction:: ziaplot.geometry.intersect.line_arc 301 | 302 | .. autofunction:: ziaplot.geometry.intersect.functions 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | .. autofunction:: ziaplot.util.linspace 311 | -------------------------------------------------------------------------------- /ziaplot/text.py: -------------------------------------------------------------------------------- 1 | ''' Methods for drawing text, either as elements or as 2 | paths via ziamath library 3 | ''' 4 | from __future__ import annotations 5 | from typing import Optional, Literal, cast 6 | import math 7 | 8 | import string 9 | from collections import namedtuple 10 | from xml.etree import ElementTree as ET 11 | 12 | try: 13 | import ziamath 14 | import ziafont 15 | except ImportError: 16 | ziamath = None # type: ignore 17 | else: 18 | ziamath.config.math.variant = 'sans' 19 | 20 | 21 | from .config import config 22 | 23 | TextPosition = Literal['N', 'E', 'S', 'W', 24 | 'NE', 'NW', 'SE', 'SW'] 25 | Halign = Literal['left', 'center', 'right'] 26 | Valign = Literal['top', 'center', 'baseline', 'base', 'bottom'] 27 | Size = namedtuple('Size', ['width', 'height']) 28 | 29 | 30 | def fmt(f: float) -> str: 31 | ''' String format, stripping trailing zeros ''' 32 | p = f'.{config.precision}f' 33 | s = format(float(f), p) 34 | return s.rstrip('0').rstrip('.') # Strip trailing zeros 35 | 36 | 37 | def text_align_ofst( 38 | pos: Optional[TextPosition], 39 | ofst: float) -> tuple[float, float, Halign, Valign]: 40 | ''' Get text pixel offset and alignment 41 | 42 | Args: 43 | pos: Text Position relative to anchor 44 | ofst: Pixel offset, directionless 45 | 46 | Returns: 47 | dx: x-offset 48 | dy: y-offset 49 | halign: Horizontal alignment 50 | valign: Vertical alignment 51 | ''' 52 | dx = dy = 0. 53 | halign, valign = 'center', 'center' 54 | if pos is None or 'N' in pos: 55 | valign = 'bottom' 56 | dy = ofst 57 | elif 'S' in pos: 58 | valign = 'top' 59 | dy = -ofst 60 | if pos is None or 'E' in pos: 61 | halign = 'left' 62 | dx = ofst 63 | elif 'W' in pos: 64 | halign = 'right' 65 | dx = -ofst 66 | 67 | if dx and dy: 68 | dx /= math.sqrt(2) 69 | dy /= math.sqrt(2) 70 | 71 | return dx, dy, cast(Halign, halign), cast(Valign, valign) 72 | 73 | 74 | def draw_text(x: float, y: float, s: str, svgelm: ET.Element, 75 | color: str = 'black', 76 | font: str = 'sans-serif', 77 | size: float = 14, 78 | halign: Halign = 'left', 79 | valign: Valign = 'bottom', 80 | rotate: Optional[float] = None): 81 | 82 | if config.text == 'path': 83 | draw_text_zia(x, y, s, svgelm=svgelm, 84 | color=color, font=font, size=size, 85 | halign=halign, valign=valign, rotate=rotate) 86 | else: 87 | draw_text_text(x, y, s, svgelm=svgelm, 88 | color=color, font=font, size=size, 89 | halign=halign, valign=valign, rotate=rotate) 90 | 91 | 92 | def draw_text_zia(x: float, y: float, s: str, svgelm: ET.Element, 93 | color: str = 'black', 94 | font: str = 'sans', 95 | size: float = 14, 96 | halign: Halign = 'left', 97 | valign: Valign = 'base', 98 | rotate: Optional[float] = None): 99 | math = ziamath.Text(s, size=size, textfont=font, color=color, 100 | halign=halign, valign=valign) 101 | textelm = math.drawon(svgelm, x, y) 102 | 103 | if rotate: 104 | textelm.attrib['transform'] = f' rotate({-rotate} {fmt(x)} {fmt(y)})' 105 | 106 | 107 | def draw_text_text(x: float, y: float, s: str, svgelm: ET.Element, 108 | color: str = 'black', 109 | font: str = 'sans-serif', 110 | size: float = 14, 111 | halign: Halign = 'left', 112 | valign: Valign = 'bottom', 113 | rotate: Optional[float] = None): 114 | anchor = {'center': 'middle', 115 | 'left': 'start', 116 | 'right': 'end'}.get(halign, 'left') 117 | baseline = {'center': 'middle', 118 | 'bottom': 'auto', 119 | 'top': 'hanging'}.get(valign, 'bottom') 120 | 121 | attrib = {'x': fmt(x), 122 | 'y': fmt(y), 123 | 'fill': color, 124 | 'font-size': str(size), 125 | 'font-family': font, 126 | 'text-anchor': anchor, 127 | 'dominant-baseline': baseline} 128 | 129 | if rotate: 130 | attrib['transform'] = f' rotate({-rotate} {fmt(x)} {fmt(y)})' 131 | 132 | txt = ET.SubElement(svgelm, 'text', attrib=attrib) 133 | txt.text = s 134 | 135 | 136 | def text_size(st: str, fontsize: float = 14, font: str = 'Arial') -> Size: 137 | ''' Estimate string width based on individual characters 138 | 139 | Args: 140 | st: string to estimate 141 | fontsize: font size 142 | font: font family 143 | 144 | Returns: 145 | Estimated width of string 146 | ''' 147 | if config.text == 'path': 148 | return text_size_zia(st, fontsize, font) 149 | else: 150 | return text_size_text(st, fontsize, font) 151 | 152 | 153 | def text_size_zia(st: str, fontsize: float = 14, font: str = 'sans') -> Size: 154 | text = ziamath.Text(st, size=fontsize) 155 | return Size(*text.getsize()) 156 | 157 | 158 | def text_size_text(st: str, fontsize: float = 14, font: str = 'Arial') -> Size: 159 | ''' Estimate string width based on individual characters 160 | 161 | Args: 162 | st: string to estimate 163 | fontsize: font size 164 | font: font family 165 | 166 | Returns: 167 | Estimated width of string 168 | ''' 169 | # adapted from https://stackoverflow.com/a/16008023/13826284 170 | # The only alternative is to draw the string to an actual canvas 171 | 172 | size = 0 # in milinches 173 | if 'times' in font.lower() or ('serif' in font.lower() and 'sans' not in font.lower()): 174 | # Estimates based on Times Roman 175 | for s in st: 176 | if s in 'lij:.,;t': 177 | size += 47 178 | elif s in '|': 179 | size += 37 180 | elif s in '![]fI/\\': 181 | size += 55 182 | elif s in '`-(){}r': 183 | size += 60 184 | elif s in 'sJ°': 185 | size += 68 186 | elif s in '"zcae?1': 187 | size += 74 188 | elif s in '*^kvxyμbdhnopqug#$_α' + string.digits: 189 | size += 85 190 | elif s in '#$+<>=~FSP': 191 | size += 95 192 | elif s in 'ELZT': 193 | size += 105 194 | elif s in 'BRC': 195 | size += 112 196 | elif s in 'DAwHUKVXYNQGO': 197 | size += 122 198 | elif s in '&mΩ': 199 | size += 130 200 | elif s in '%': 201 | size += 140 202 | elif s in 'MW@∠': 203 | size += 155 204 | else: 205 | size += 60 206 | 207 | else: # Arial, or other sans fonts 208 | for s in st: 209 | if s in 'lij|\' ': 210 | size += 37 211 | elif s in '![]fI.,:;/\\t': 212 | size += 50 213 | elif s in '`-(){}r"': 214 | size += 60 215 | elif s in '*^zcsJkvxyμ°': 216 | size += 85 217 | elif s in 'aebdhnopqug#$L+<>=?_~FZTα' + string.digits: 218 | size += 95 219 | elif s in 'BSPEAKVXY&UwNRCHD': 220 | size += 112 221 | elif s in 'QGOMm%@Ω': 222 | size += 140 223 | elif s in 'W∠': 224 | size += 155 225 | else: 226 | size += 75 227 | return Size(size * 72 / 1000.0 * (fontsize/12), fontsize) # to points 228 | -------------------------------------------------------------------------------- /ziaplot/diagrams/smithgrid.py: -------------------------------------------------------------------------------- 1 | ''' Positions of grid arcs for Smith charts ''' 2 | from __future__ import annotations 3 | from typing import Literal 4 | from dataclasses import dataclass 5 | import math 6 | 7 | 8 | SmithGridLevels = Literal['coarse', 'medium', 'fine', 'extrafine'] 9 | 10 | 11 | @dataclass 12 | class SmithGrid: 13 | ''' Grid specification for Smith Charts 14 | 15 | Attributes: 16 | circles: list of (R, xmax, xmin, major) 17 | arcs: list of (X, rmax, rmin, major) 18 | ''' 19 | circles: list[tuple[float, float, float, bool]] 20 | arcs: list[tuple[float, float, float, bool]] 21 | 22 | 23 | def buildgrid() -> dict[SmithGridLevels, SmithGrid]: 24 | ''' Get default Smith grid dictionary ''' 25 | grid: dict[SmithGridLevels, SmithGrid] = {} 26 | grid['coarse'] = SmithGrid( 27 | circles=[(.2, 2, 0, True), (.5, 5, 0, True), (1, 5, 0, True), 28 | (2, math.inf, 0, True), (5, math.inf, 0, True)], 29 | arcs=[(.2, 2, 0, True), (.5, 2, 0, True), (1, 5, 0, True), 30 | (2, 5, 0, True), (5, math.inf, 0, True)]) 31 | 32 | grid['medium'] = SmithGrid( 33 | circles=[(.1, 2, 0, True), (.3, 2, 0, True), (.5, 3, 0, True), 34 | (1, 3, 0, True), (1.5, 5, 0, True), (2, 5, 0, True), 35 | (3, 10, 0, True), (4, 10, 0, True), (5, 10, 0, True), 36 | (10, 20, 0, True), (20, math.inf, 0, True)], 37 | arcs=[(.2, 2, 0, True), (.4, 3, 0, True), (.6, 3, 0, True), 38 | (.8, 3, 0, True), (1, 5, 0, True), (1.5, 5, 0, True), 39 | (2, 5, 0, True), (3, 10, 0, True), (4, 10, 0, True), 40 | (5, 10, 0, True), (10, 20, 0, True), (20, math.inf, 0, True)]) 41 | 42 | grid['fine'] = SmithGrid( 43 | circles=[(.1, 1, 0, False), (.2, 2, 0, True), (.3, 1, 0, False), 44 | (.4, 5, 0, True), (.5, 1, 0, False), (.6, 2, 0, True), 45 | (.7, 1, 0, False), (.8, 2, 0, True), (.9, 1, 0, False), 46 | (1, 10, 0, True), (1.2, 2, 0, True), (1.4, 5, 0, True), 47 | (1.6, 2, 0, True), (1.8, 2, 0, False), (2.0, 10, 0, True), 48 | (2.5, 5, 0, False), (3, 5, 0, True), (4, 10, 0, True), 49 | (5, 10, 0, True), (6, 10, 0, False), (7, 10, 0, False), 50 | (8, 10, 0, False), (9, 10, 0, False), (10, 20, 0, True), 51 | (20, math.inf, 0, True)], 52 | arcs=[(.1, 1, 0, False), (.2, 2, 0, True), (.3, 1, 0, False), 53 | (.4, 5, 0, True), (.5, 1, 0, False), (.6, 2, 0, True), 54 | (.7, 1, 0, False), (.8, 2, 0, True), (.9, 1, 0, False), 55 | (1, 10, 0, True), (1.2, 2, 0, True), (1.4, 4, 0, True), 56 | (1.6, 2, 0, True), (1.8, 2, 0, False), (2, 10, 0, True), 57 | (2.5, 5, 0, False), (3, 10, 0, True), (3.5, 5, 0, False), 58 | (4, 10, 0, True), (5, 20, 0, True), (6, 10, 0, False), 59 | (7, 10, 0, False), (8, 10, 0, False), (9, 10, 0, False), 60 | (10, 20, 0, True), (15, 20, 0, False), (20, math.inf, 0, True)]) 61 | 62 | grid['extrafine'] = SmithGrid( 63 | circles=[ # Finest wedge by 0.01 64 | (.01, .2, 0, False), (.02, .5, 0, False), (.03, .2, 0, False), 65 | (.04, .5, 0, False), (.05, .2, 0, False), (.06, .5, 0, False), 66 | (.07, .2, 0, False), (.08, .5, 0, False), (.09, .2, 0, False), 67 | (.1, 2, 0, True), (.11, .2, 0, False), (.12, .5, 0, False), 68 | (.13, .2, 0, False), (.14, .5, 0, False), (.15, .2, 0, False), 69 | (.16, .5, 0, False), (.17, .2, 0, False), (.18, .5, 0, False), 70 | (.19, .2, 0, False), (.2, 5, 0, True), (.22, .5, 0, False), 71 | # Next wedge by 0.02 72 | (.24, .5, 0, False), (.26, .5, 0, False), (.28, .5, 0, False), 73 | (.3, 2, 0, True), (.32, .5, 0, False), (.34, .5, 0, False), 74 | (.36, .5, 0, False), (.38, .5, 0, False), (.4, 5, 0, True), 75 | (.42, .5, 0, False), (.44, .5, 0, False), (.46, .5, 0, False), 76 | (.48, .5, 0, False), (.5, 2, 0, True), 77 | # Wedge by 0.05 78 | (.55, 1, 0, False), (.6, 5, 0, True), (.65, 1, 0, False), 79 | (.7, 2, 0, True), (.75, 1, 0, False), (.8, 5, 0, True), 80 | (.85, 1, 0, False), (.9, 2, 0, True), (.95, 1, 0, False), 81 | (1, 10, 0, True), 82 | # "Sub-wedge" by 0.05x 83 | (.05, 1, .5, False), (.15, 1, .5, False), (.25, 1, .5, False), 84 | (.35, 1, .5, False), (.45, 1, .5, False), (1.1, 2, 0, False), 85 | # Wedge x.1 from 1-2 86 | (1.2, 5, 0, True), (1.3, 2, 0, False), (1.4, 5, 0, True), 87 | (1.5, 2, 0, False), (1.6, 5, 0, True), (1.7, 2, 0, False), 88 | (1.8, 5, 0, True), (1.9, 2, 0, False), (2.0, 20, 0, True), 89 | # Wedge from 3-10 90 | (2.2, 5, 0, False), (2.4, 5, 0, False), (2.6, 5, 0, False), 91 | (2.8, 5, 0, False), (3.0, 10, 0, True), (3.2, 5, 0, False), 92 | (3.4, 5, 0, False), (3.6, 5, 0, False), (3.8, 5, 0, False), 93 | (4.0, 20, 0, True), (4.2, 5, 0, False), (4.4, 5, 0, False), 94 | (4.6, 5, 0, False), (4.8, 5, 0, False), (5.0, 10, 0, True), 95 | # Wedge 5+ 96 | (6, 20, 0, False), (7, 10, 0, False), (8, 20, 0, False), 97 | (9, 10, 0, False), (10, math.inf, 0, True), (12, 20, 0, False), 98 | (14, 20, 0, False), (16, 20, 0, False), (18, 20, 0, False), 99 | (20, 50, 0, True), (30, 50, 0, False), (40, 50, 0, False), 100 | (50, math.inf, 0, True)], 101 | arcs=[ # Finest wedge 0.01x 102 | (.01, .2, 0, False), (.02, .5, 0, False), (.03, .2, 0, False), 103 | (.04, .5, 0, False), (.05, .2, 0, False), (.06, .5, 0, False), 104 | (.07, .2, 0, False), (.08, .5, 0, False), (.09, .2, 0, False), 105 | (.1, 2, 0, True), (.11, .2, 0, False), (.12, .5, 0, False), 106 | (.13, .2, 0, False), (.14, .5, 0, False), (.15, .2, 0, False), 107 | (.16, .5, 0, False), (.17, .2, 0, False), (.18, .5, 0, False), 108 | (.19, .2, 0, False), (.2, 5, 0, True), 109 | # Wedge 0.02x 110 | (.22, .5, 0, False), (.24, .5, 0, False), (.26, .5, 0, False), 111 | (.28, .5, 0, False), (.3, 2, 0, True), (.32, .5, 0, False), 112 | (.34, .5, 0, False), (.36, .5, 0, False), (.38, .5, 0, False), 113 | (.4, 5, 0, True), (.42, .5, 0, False), (.44, .5, 0, False), 114 | (.46, .5, 0, False), (.48, .5, 0, False), (.5, 2, 0, True), 115 | # Wedge by 0.05x 116 | (.55, 1, 0, False), (.6, 5, 0, True), (.65, 1, 0, False), 117 | (.7, 2, 0, True), (.75, 1, 0, False), (.8, 5, 0, True), 118 | (.85, 1, 0, False), (.9, 2, 0, True), (.95, 1, 0, False), 119 | (1, 10, 0, True), 120 | # Subwedge x0.05 bw 0.5 and 1 121 | (.05, 1, .5, False), (.15, 1, .5, False), (.25, 1, .5, False), 122 | (.35, 1, .5, False), (.45, 1, .5, False), (.55, 1, .5, False), 123 | # Wedge x.1 (1-2) 124 | (1.1, 2, 0, False), (1.2, 5, 0, True), (1.3, 2, 0, False), 125 | (1.4, 5, 0, True), (1.5, 2, 0, False), (1.6, 5, 0, True), 126 | (1.7, 2, 0, False), (1.8, 5, 0, True), (1.9, 2, 0, False), 127 | (2, 20, 0, True), 128 | # Wedge x.2 (2-5) 129 | (2.2, 5, 0, False), (2.4, 5, 0, False), (2.6, 5, 0, False), 130 | (2.8, 5, 0, False), (3, 10, 0, True), (3.2, 5, 0, False), 131 | (3.4, 5, 0, False), (3.6, 5, 0, False), (3.8, 5, 0, False), 132 | (4, 20, 0, True), (4.2, 5, 0, False), (4.4, 5, 0, False), 133 | (4.6, 5, 0, False), (4.8, 5, 0, False), (5, 10, 0, True), 134 | # Wedge > 5 135 | (6, 20, 0, False), (7, 10, 0, False), (8, 20, 0, False), 136 | (9, 10, 0, False), (10, 20, 0, True), (12, math.inf, 0, False), 137 | (14, 20, 0, False), (16, 20, 0, False), (18, 20, 0, False), 138 | (20, 50, 0, True), (30, 50, 0, False), (40, 50, 0, False), 139 | (50, math.inf, 0, True) 140 | ]) 141 | return grid 142 | 143 | 144 | smithgrids = buildgrid() -------------------------------------------------------------------------------- /ziaplot/diagrams/graphlog.py: -------------------------------------------------------------------------------- 1 | ''' Logscale Graphs ''' 2 | from __future__ import annotations 3 | from typing import Sequence 4 | from functools import lru_cache 5 | from copy import deepcopy 6 | import math 7 | 8 | from ..canvas import Canvas, ViewBox, DataRange 9 | from .. import text 10 | from .graph import Graph, Ticks 11 | 12 | 13 | def logticks(ticks: Sequence[float], divs=10) -> tuple[list[float], list[str], list[float]]: 14 | ''' Generate tick minor tick positions on log scale 15 | 16 | Args: 17 | ticks: Major tick positions generated from original maketicks 18 | divs: Number of minor divisions between major ticks 19 | 20 | Returns: 21 | ticks: Tick values on log scale (10**value) 22 | names: List of tick label names (g format) 23 | minor: Minor tick positions 24 | ''' 25 | values: list[float] = list(range(math.floor(ticks[0]), math.ceil(ticks[-1])+1)) 26 | names = [format(10**val, 'g') for val in values] 27 | 28 | minor = None 29 | if divs: 30 | if divs == 5: 31 | t = [2, 4, 6, 8] 32 | elif divs == 2: 33 | t = [5] 34 | else: # divs == 10: 35 | t = [1, 2, 3, 4, 5, 6, 7, 8, 9] 36 | 37 | minor = [] 38 | for major in values[1:]: 39 | minor.extend([math.log10(k*(10**major)/10) for k in t]) 40 | return values, names, minor 41 | 42 | 43 | class GraphLog(Graph): 44 | ''' Base Class for log-scale graph ''' 45 | def __init__(self): 46 | super().__init__() 47 | self.xlogdivisions = 10 48 | self.ylogdivisions = 10 49 | 50 | 51 | class GraphLogY(GraphLog): 52 | ''' Plot with Y on a log10 scale ''' 53 | def _clearcache(self): 54 | ''' Clear LRU cache when inputs changes ''' 55 | super()._clearcache() 56 | self._maketicks.cache_clear() 57 | self._borders.cache_clear() 58 | 59 | @lru_cache 60 | def datarange(self) -> DataRange: 61 | ''' Get range of data ''' 62 | drange = super().datarange() 63 | try: 64 | ymin = math.log10(drange.ymin) 65 | except ValueError: 66 | ymin = 0 67 | try: 68 | ymax = math.log10(drange.ymax) 69 | except ValueError: 70 | ymax = 1 71 | 72 | return DataRange(drange.xmin, drange.xmax, ymin, ymax) 73 | 74 | @lru_cache 75 | def _maketicks(self) -> Ticks: 76 | ''' Define ticks and tick labels. 77 | 78 | Returns: 79 | ticks: Tick names and positions 80 | ''' 81 | ticks = super()._maketicks() 82 | yticks, ynames, yminor = logticks(ticks.yticks, divs=self.ylogdivisions) 83 | yrange = yticks[0], yticks[-1] 84 | sty = self._build_style() 85 | 86 | ywidth = 0. 87 | for tick in ynames: 88 | ywidth = max(ywidth, text.text_size(tick, 89 | fontsize=sty.font_size, 90 | font=sty.font).width) 91 | 92 | ticks = Ticks(ticks.xticks, yticks, ticks.xnames, 93 | ynames, ywidth, ticks.xrange, yrange, 94 | None, yminor) 95 | return ticks 96 | 97 | def _drawcomponents(self, canvas: Canvas, diagbox: ViewBox, databox: ViewBox) -> None: 98 | ''' Draw all components to the graph 99 | 100 | Args: 101 | canvas: SVG canvas to draw on 102 | diagbox: ViewBox of diagram within the canvas 103 | databox: ViewBox of data to convert from data to svg coordinates 104 | ''' 105 | compbackup = self.components 106 | self.components = [deepcopy(c) for c in compbackup] 107 | for c in self.components: 108 | c._logy() 109 | 110 | super()._drawcomponents(canvas, diagbox, databox) 111 | self.components = compbackup 112 | 113 | 114 | class GraphLogX(GraphLog): 115 | ''' Plot with Y on a log10 scale ''' 116 | def _clearcache(self): 117 | ''' Clear LRU cache when inputs changes ''' 118 | super()._clearcache() 119 | self._maketicks.cache_clear() 120 | self._borders.cache_clear() 121 | 122 | @lru_cache 123 | def datarange(self) -> DataRange: 124 | ''' Get range of data ''' 125 | drange = super().datarange() 126 | try: 127 | xmin = math.log10(drange.xmin) 128 | except ValueError: 129 | xmin = 0 130 | try: 131 | xmax = math.log10(drange.xmax) 132 | except ValueError: 133 | xmax = 1 134 | return DataRange(xmin, xmax, 135 | drange.ymin, drange.ymax) 136 | 137 | @lru_cache 138 | def _maketicks(self) -> Ticks: 139 | ''' Define ticks and tick labels. 140 | 141 | Returns: 142 | ticks: Tick names and positions 143 | ''' 144 | ticks = super()._maketicks() 145 | xticks, xnames, xminor = logticks(ticks.xticks, divs=self.xlogdivisions) 146 | xrange = xticks[0], xticks[-1] 147 | 148 | ticks = Ticks(xticks, ticks.yticks, xnames, ticks.ynames, 149 | ticks.ywidth, xrange, ticks.yrange, xminor, None) 150 | return ticks 151 | 152 | def _drawcomponents(self, canvas: Canvas, diagbox: ViewBox, databox: ViewBox) -> None: 153 | ''' Draw all components to the graph 154 | 155 | Args: 156 | canvas: SVG canvas to draw on 157 | diagbox: ViewBox of diagram within the canvas 158 | databox: ViewBox of data to convert from data to svg coordinates 159 | ''' 160 | compbackup = self.components 161 | self.components = [deepcopy(c) for c in compbackup] 162 | for c in self.components: 163 | c._logx() 164 | 165 | super()._drawcomponents(canvas, diagbox, databox) 166 | self.components = compbackup 167 | 168 | 169 | class GraphLogXY(GraphLog): 170 | ''' Plot with X and Y on a log10 scale ''' 171 | def _clearcache(self): 172 | ''' Clear LRU cache when inputs changes ''' 173 | super()._clearcache() 174 | self._maketicks.cache_clear() 175 | self._borders.cache_clear() 176 | 177 | @lru_cache 178 | def datarange(self) -> DataRange: 179 | drange = super().datarange() 180 | try: 181 | xmin = math.log10(drange.xmin) 182 | except ValueError: 183 | xmin = 0 184 | try: 185 | ymin = math.log10(drange.ymin) 186 | except ValueError: 187 | ymin = 0 188 | try: 189 | xmax = math.log10(drange.xmax) 190 | except ValueError: 191 | xmax = 1 192 | try: 193 | ymax = math.log10(drange.ymax) 194 | except ValueError: 195 | ymax = 1 196 | 197 | return DataRange(xmin, xmax, ymin, ymax) 198 | 199 | @lru_cache 200 | def _maketicks(self) -> Ticks: 201 | ''' Define ticks and tick labels. 202 | 203 | Args: 204 | datarange: Range of x and y data 205 | 206 | Returns: 207 | ticks: Tick names and positions 208 | ''' 209 | ticks = super()._maketicks() 210 | xticks, xnames, xminor = logticks(ticks.xticks, divs=self.xlogdivisions) 211 | xrange = xticks[0], xticks[-1] 212 | yticks, ynames, yminor = logticks(ticks.yticks, divs=self.ylogdivisions) 213 | yrange = yticks[0], yticks[-1] 214 | sty = self._build_style() 215 | 216 | ywidth = 0. 217 | for tick in ynames: 218 | ywidth = max(ywidth, text.text_size(tick, 219 | fontsize=sty.font_size, 220 | font=sty.font).width) 221 | 222 | ticks = Ticks(xticks, yticks, xnames, ynames, ywidth, 223 | xrange, yrange, xminor, yminor) 224 | return ticks 225 | 226 | def _drawcomponents(self, canvas: Canvas, diagbox: ViewBox, databox: ViewBox) -> None: 227 | ''' Draw all components in the graph 228 | 229 | Args: 230 | canvas: SVG canvas to draw on 231 | diagbox: ViewBox of diagram within the canvas 232 | databox: ViewBox of data to convert from data to 233 | svg coordinates 234 | ''' 235 | compbackup = self.components 236 | self.components = [deepcopy(c) for c in compbackup] 237 | for c in self.components: 238 | c._logx() 239 | c._logy() 240 | 241 | super()._drawcomponents(canvas, diagbox, databox) 242 | self.components = compbackup 243 | -------------------------------------------------------------------------------- /docs/guide.rst: -------------------------------------------------------------------------------- 1 | .. _Start: 2 | 3 | Quick-Start Guide 4 | ================= 5 | 6 | .. jupyter-execute:: 7 | :hide-code: 8 | 9 | import math 10 | import ziaplot as zp 11 | zp.css('Canvas{width:300;height:250;}') 12 | 13 | 14 | This guide reviews common use cases, types of diagrams, and styling. 15 | For a description of all the graph and data types, see the other items in the menu. 16 | 17 | 18 | First Examples 19 | -------------- 20 | 21 | Start by importing ziaplot. In this documentation, it is imported as `zp`: 22 | 23 | .. code-block:: python 24 | 25 | import ziaplot as zp 26 | 27 | 28 | To create a drawing, first define a `Diagram` or `Graph` on which to draw, then add the elements 29 | representing geometric figures or data. 30 | A `Diagram` is a blank drawing surface, while a `Graph` is a `Diagram` that contains axes. 31 | 32 | Let's make a `Graph`. The `Graph` is typically created using a `with` block context manager. 33 | Anything created inside the with block is added to the `Graph`, such as this `Point`. 34 | 35 | .. jupyter-execute:: 36 | 37 | with zp.Graph(): 38 | zp.Point((1, 1)) 39 | 40 | .. tip:: 41 | 42 | If the code is run within a Jupyter Notebook, the drawing will be shown automatically in the 43 | cell's output. In Spyder, the drawing is shown in the "Plots" tab. 44 | 45 | To save the drawing to a file, use `.save()` with the name of an image file to write: 46 | 47 | .. jupyter-input:: 48 | 49 | with zp.Graph(): 50 | zp.Point((1, 1)) 51 | zp.save('my_point.svg') 52 | 53 | Items may also be added using the += operator, with the same results: 54 | 55 | .. jupyter-input:: 56 | 57 | p = zp.Graph() 58 | p += zp.Point((1, 1)) 59 | p.save('my_point.svg') 60 | 61 | .. note:: 62 | 63 | Ziaplot generates images using SVG2.0, which modern browsers display well. 64 | But some SVG renderers, including recent versions of Inkscape, Spyder, and some OS built-in image viewers, 65 | are not fully compatible with the SVG 2.0 specification. 66 | Use `zp.config.svg2 = False` to force SVG 1.x specifications for better compatibility. 67 | 68 | 69 | Other elements may be added by creating them inside the `with` block. 70 | A `Circle` is made given an x, y, and radius. A Line is added from a (x,y) point and slope. 71 | 72 | .. jupyter-execute:: 73 | 74 | with zp.Graph(): 75 | zp.Point((1, 1)) 76 | zp.Circle((1, 1), .5) 77 | zp.Line(point=(1, 1), slope=1) 78 | 79 | .. tip:: 80 | 81 | See :ref:`Line` for other ways of defining a line, such as using a slope and intercept. 82 | 83 | But wait, the circle looks squished! 84 | That's because a `Graph` doesn't necessarily scale the x- and y- coordinates the same. 85 | To fix the issue, use `.equal_aspect()` on the `Graph`: 86 | 87 | .. jupyter-execute:: 88 | 89 | with zp.Graph().equal_aspect(): 90 | zp.Point((1, 1)) 91 | zp.Circle((1, 1), .5) 92 | zp.Line(point=(1, 1), slope=1) 93 | 94 | A `Diagram` assumes equal aspect and doesn't have this problem, but there are no axes. 95 | 96 | .. jupyter-execute:: 97 | 98 | with zp.Diagram(): 99 | zp.Point((1, 1)) 100 | zp.Circle((1, 1), .5) 101 | zp.Line(point=(1, 1), slope=1) 102 | 103 | Now notice the domain and range of the axes. Both axis are shown on the interval 104 | from 0.4 to 1.6, chosen automatically to enclose the circle with a bit of margin. 105 | To expand (or shrink) the range, use methods `xrange` and `yrange`. 106 | 107 | .. jupyter-execute:: 108 | 109 | with zp.Graph().equal_aspect().xrange(-4, 4).yrange(-4, 4): 110 | zp.Point((1, 1)) 111 | zp.Circle((1, 1), .5) 112 | zp.Line(point=(1, 1), slope=1) 113 | 114 | 115 | .. tip:: 116 | 117 | See :ref:`Geometric` for other types of geometric figures. 118 | 119 | 120 | 121 | 122 | Discrete Data 123 | ------------- 124 | 125 | Next, let's make a diagram using discrete (x, y) data, which typically comes from 126 | measurements and observations rather than fundamental geometry. 127 | Discrete (x, y) data may be plotted using `PolyLine` or `Scatter`. 128 | 129 | A `PolyLine` connects the (x, y) pairs with line segments. It is not a Line 130 | in the geometric sense above. 131 | Here some made-up (x, y) values are created and drawn on a `GraphQuad` using a `PolyLine`. 132 | 133 | .. jupyter-execute:: 134 | 135 | x = [0, 1, 2, 3, 4, 5] 136 | y = [0, .8, 2.2, 2.8, 5.4, 4.8] 137 | 138 | with zp.GraphQuad(): 139 | zp.PolyLine(x, y) 140 | 141 | | 142 | 143 | Notice the difference between `GraphQuad` and `Graph`. A `GraphQuad` always draws 144 | the axes lines through the origin, with arrows pointing outward. 145 | The `Graph` draws the axes lines on the left and bottom sides of the frame, which may 146 | not always pass through the origin. 147 | 148 | .. seealso:: 149 | 150 | :ref:`Diagrams` lists other types of Ziaplot graphs, including logscale 151 | and polar coordinate systems. 152 | 153 | Using `Scatter` draws the same points as markers without the connecting lines. 154 | 155 | .. jupyter-execute:: 156 | 157 | with zp.Graph(): 158 | zp.Scatter(x, y) 159 | 160 | Additional data sets may be added. Each one is assigned a new color. 161 | 162 | 163 | .. jupyter-execute:: 164 | 165 | y2 = [1, 1.2, 1.8, 3.3, 4.2, 5.1] 166 | 167 | with zp.Graph(): 168 | zp.Scatter(x, y) 169 | zp.Scatter(x, y2) 170 | 171 | .. tip:: 172 | 173 | See :ref:`Discrete` for other discrete data types. 174 | 175 | 176 | Labels 177 | ------ 178 | 179 | The above graph isn't very useful without knowing what x- and y- axes represent, 180 | and what the two different sets of data mean. 181 | Use `.axesnames` on the Graph to specify names for the x and y axis. 182 | Use `.title` on the Graph to specify an overall title for the Graph. 183 | Use `.name` to give a name to each element, which will appear in a legend. 184 | 185 | .. jupyter-execute:: 186 | 187 | with zp.Graph().axesnames('Time (s)', 'Distance (cm)').title('Movement'): 188 | zp.Scatter(x, y).name('Trial 1') 189 | zp.Scatter(x, y2).name('Trial 2') 190 | 191 | Notice how the `axesnames` and `title` methods were chained together. 192 | Property-setting methods like these all return `self`, or the same object 193 | it modifies, allowing many properties to be set on one line of code. 194 | 195 | .. note:: 196 | 197 | Any text enclosed in dollar-signs `$..$` is interpreted as LaTeX math and will be typeset as math. 198 | This requires `ziamath `_ to be installed. 199 | 200 | 201 | Styles 202 | ------ 203 | 204 | Customizing styles of components uses a similar chained-method interface. 205 | Elements have `color`, `stroke`, `strokewidth` methods that can be called 206 | to modify their style. Switching the previous plot back to `PolyLine`: 207 | 208 | .. jupyter-execute:: 209 | 210 | with zp.Graph().axesnames('Time (s)', 'Distance (cm)').title('Movement'): 211 | zp.PolyLine(x, y).name('Trial 1').color('blue').stroke('dotted') 212 | zp.PolyLine(x, y2).name('Trial 2').color('purple').stroke('dashed').strokewidth(4) 213 | 214 | .. tip:: 215 | 216 | Colors may be a named color, like 'red', 'blue', or 'salmon', or it may 217 | be a hex color string, like '#fa8072'. 218 | Also, colors can be given an opacity value as a percent. Try 'red 20%', 219 | for example. 220 | 221 | 222 | More complex styles are modified using a CSS system. CSS may be added globally 223 | to all ziaplot Diagrams, or to specific Diagrams and Elements. 224 | 225 | .. jupyter-execute:: 226 | 227 | css = ''' 228 | Graph { 229 | color: #FFF8F8; 230 | } 231 | Graph.Legend { 232 | edge_color: blue; 233 | } 234 | PolyLine { 235 | stroke_width: 3; 236 | } 237 | #trial1 { 238 | color: royalblue 75%; 239 | } 240 | #trial2 { 241 | color: firebrick 75%; 242 | } 243 | ''' 244 | with zp.Graph().css(css).axesnames('Time (s)', 'Distance (cm)').title('Movement'): 245 | zp.PolyLine(x, y).name('Trial 1').cssid('trial1') 246 | zp.PolyLine(x, y2).name('Trial 2').cssid('trial2') 247 | 248 | 249 | .. tip:: 250 | 251 | See :ref:`Styling` for complete details on using CSS to apply styles. 252 | 253 | 254 | Themes 255 | ------ 256 | 257 | There are also some pre-made themes using CSS. Here, the "taffy" theme is applied 258 | to the same Graph. 259 | 260 | .. jupyter-execute:: 261 | 262 | zp.theme('taffy') 263 | 264 | with zp.Graph().axesnames('Time (s)', 'Distance (cm)').title('Movement') as p: 265 | p1 = zp.PolyLine(x, y).name('Trial 1').cssid('trial1') 266 | p2 = zp.PolyLine(x, y2).name('Trial 2').cssid('trial2') 267 | 268 | .. tip:: 269 | 270 | See :ref:`themes` for all the available built-in themes. 271 | -------------------------------------------------------------------------------- /ziaplot/charts/pie.py: -------------------------------------------------------------------------------- 1 | ''' Pie Charts ''' 2 | from __future__ import annotations 3 | from typing import Optional, Literal 4 | import math 5 | 6 | from ..text import Halign, Valign 7 | from ..diagrams import Diagram, Ticks 8 | from ..element import Element 9 | from ..canvas import Canvas, Borders, ViewBox 10 | from .. import diagram_stack 11 | 12 | 13 | PieLabelMode = Literal['name', 'percent', 'value'] 14 | 15 | 16 | class PieSlice(Element): 17 | ''' One slice of a pie. 18 | 19 | Args: 20 | value: value assigned to this slice. Percent 21 | will be calculated using total value of all 22 | the slices. 23 | ''' 24 | _step_color = True 25 | legend_square = True 26 | 27 | def __init__(self, value: float = 1): 28 | super().__init__() 29 | self.value = value 30 | self._extrude: float = 0 31 | 32 | def extrude(self, extrude: float = 20) -> 'PieSlice': 33 | ''' Extrude the slice ''' 34 | self._extrude = extrude 35 | return self 36 | 37 | def edgecolor(self, color: str) -> 'PieSlice': 38 | ''' Sets the slice stroke/linestyle ''' 39 | self._style.edge_color = color 40 | return self 41 | 42 | def edgewidth(self, width: float) -> 'PieSlice': 43 | ''' Set the slice edge width ''' 44 | self._style.stroke_width = width 45 | return self 46 | 47 | 48 | class Pie(Diagram): 49 | ''' Pie Chart. Total of all wedge values will be normalized to 100%. 50 | 51 | Args: 52 | labelmode: How to label each wedge - by `name`, `percent`, 53 | or `value`. 54 | ''' 55 | def __init__(self, labelmode: PieLabelMode = 'name'): 56 | ''' ''' 57 | super().__init__() 58 | self.labelmode = labelmode 59 | 60 | @classmethod 61 | def fromdict(cls, 62 | slices: dict[str, float], 63 | labelmode: PieLabelMode = 'name') -> 'Pie': 64 | ''' Create Pie from bars dictionary 65 | 66 | Args: 67 | slices: dictionary of name:value pairs 68 | labelmode: How to label each wedge - by `name`, `percent`, 69 | or `value`. 70 | ''' 71 | pie = cls(labelmode=labelmode) 72 | diagram_stack.pause = True 73 | for name, value in slices.items(): 74 | pie.add(PieSlice(value).name(name)) 75 | diagram_stack.pause = False 76 | return pie 77 | 78 | @classmethod 79 | def fromlist(cls, slices: list[float], 80 | labelmode: PieLabelMode = 'name') -> 'Pie': 81 | ''' Create Pie from list of values 82 | 83 | Args: 84 | slices: list of values 85 | labelmode: How to label each wedge - by `name`, `percent`, 86 | or `value`. 87 | ''' 88 | pie = cls(labelmode=labelmode) 89 | diagram_stack.pause = True 90 | for value in slices: 91 | pie.add(PieSlice(value)) 92 | diagram_stack.pause = False 93 | return pie 94 | 95 | def _legendloc(self, diagbox: ViewBox, ticks: Ticks, 96 | boxw: float, boxh: float) -> tuple[float, float]: 97 | ''' Calculate legend location 98 | 99 | Args: 100 | diagbox: ViewBox of the diagram 101 | ticks: Tick names and positions 102 | boxw: Width of legend box 103 | boxh: Height of legend box 104 | ''' 105 | xright = 0 106 | ytop = diagbox.y + diagbox.h - 1 107 | if self._legend in ['left', 'topleft']: 108 | xright = diagbox.x + boxw + 1 109 | elif self._legend in ['right', 'topright']: 110 | xright = diagbox.x + diagbox.w - 1 111 | elif self._legend == 'bottomleft': 112 | ytop = diagbox.y + boxh + 1 113 | xright = diagbox.x + boxw + 1 114 | else: # if self._legend == 'bottomright': 115 | ytop = diagbox.y + boxh + 1 116 | xright = diagbox.x + diagbox.w - 1 117 | return ytop, xright 118 | 119 | def _xml(self, canvas: Canvas, databox: Optional[ViewBox] = None, 120 | borders: Optional[Borders] = None) -> None: 121 | ''' Add XML elements to the canvas ''' 122 | slices = [c for c in self.components if isinstance(c, PieSlice)] 123 | self._assign_component_colors(slices) 124 | 125 | values = [w.value for w in slices] 126 | total = sum(values) 127 | thetas = [v/total*math.pi*2 for v in values] 128 | sty = self._build_style() 129 | cx = canvas.viewbox.x + canvas.viewbox.w/2 130 | cy = canvas.viewbox.y + canvas.viewbox.h/2 131 | radius = (min(canvas.viewbox.w, canvas.viewbox.h) / 2 - 132 | sty.margin*2) 133 | 134 | if any(w._extrude for w in slices): 135 | radius -= max(w._extrude for w in slices) 136 | 137 | if self._title: 138 | tsty = self._build_style('Graph.Title') 139 | radius -= tsty.font_size/2 140 | cy -= tsty.font_size/2 141 | canvas.text(cx, canvas.viewbox.y+canvas.viewbox.h, 142 | self._title, font=tsty.font, 143 | size=tsty.font_size, 144 | color=tsty.get_color(), 145 | halign='center', valign='top') 146 | 147 | if len(slices) == 1: 148 | slice = slices[0] 149 | slicestyle = slice._build_style() 150 | canvas.circle(cx, cy, radius, 151 | color=slicestyle.get_color(), 152 | strokecolor=slicestyle.edge_color, 153 | strokewidth=slicestyle.stroke_width, 154 | zorder=slice._zorder) 155 | 156 | if self.labelmode == 'name': 157 | labeltext = slice._name 158 | elif self.labelmode == 'value': 159 | labeltext = format(slice.value) 160 | elif self.labelmode == 'percent': 161 | labeltext = f'{slice.value/total*100:.1f}%' 162 | else: 163 | labeltext = '' 164 | if labeltext: 165 | tsty = self._build_style('PieSlice.Text') 166 | canvas.text(cx + radius * math.cos(math.pi/4), 167 | cy + radius * math.sin(math.pi/4), 168 | labeltext, 169 | font=tsty.font, 170 | size=tsty.font_size, 171 | color=tsty.get_color()) 172 | 173 | else: 174 | theta = -math.pi/2 # Current angle, start at top 175 | for i, slice in enumerate(slices): 176 | thetahalf = theta + thetas[i]/2 177 | slicestyle = slice._build_style() 178 | 179 | if slice._extrude: 180 | cxx = cx + slice._extrude * math.cos(thetahalf) 181 | cyy = cy - slice._extrude * math.sin(thetahalf) 182 | else: 183 | cxx = cx 184 | cyy = cy 185 | 186 | canvas.wedge(cxx, cyy, radius, thetas[i], starttheta=theta, 187 | color=slicestyle.get_color(), 188 | strokecolor=slicestyle.edge_color, 189 | strokewidth=slicestyle.stroke_width, 190 | zorder=slice._zorder) 191 | 192 | tstyle = self._build_style('PieSlice.Text') 193 | labelx = cxx + (radius+tstyle.margin) * math.cos(thetahalf) 194 | labely = cyy - (radius+tstyle.margin) * math.sin(thetahalf) 195 | halign: Halign = 'left' if labelx > cx else 'right' 196 | valign: Valign = 'bottom' if labely > cy else 'top' 197 | if self.labelmode == 'name': 198 | labeltext = slice._name 199 | elif self.labelmode == 'value': 200 | labeltext = format(slice.value) 201 | elif self.labelmode == 'percent': 202 | labeltext = f'{slice.value/total*100:.1f}%' 203 | else: 204 | labeltext = '' 205 | 206 | if labeltext: 207 | canvas.text(labelx, labely, 208 | labeltext, 209 | font=tstyle.font, 210 | size=tstyle.font_size, 211 | color=tstyle.get_color(), 212 | halign=halign, valign=valign) 213 | 214 | theta += thetas[i] 215 | 216 | if self._legend and self._legend != 'none': 217 | ticks = Ticks(xticks=None, yticks=None, xnames=None, ynames=None, 218 | ywidth=0, xrange=None, yrange=None, xminor=None, yminor=None) 219 | self._drawlegend(canvas, canvas.viewbox, ticks) 220 | -------------------------------------------------------------------------------- /ziaplot/diagrams/oned.py: -------------------------------------------------------------------------------- 1 | ''' One-dimensional Graph (Number line) ''' 2 | from __future__ import annotations 3 | from typing import Optional 4 | from functools import lru_cache 5 | 6 | from .graph import Graph 7 | from .. import text 8 | from ..canvas import Canvas, Transform, ViewBox, DataRange, Borders 9 | from .diagram import Ticks 10 | 11 | 12 | class NumberLine(Graph): 13 | ''' Number Line 14 | ''' 15 | def __init__(self): 16 | super().__init__() 17 | self._pad_datarange = False 18 | 19 | def _clearcache(self): 20 | ''' Clear LRU cache when inputs changes ''' 21 | super()._clearcache() 22 | self._maketicks.cache_clear() 23 | self._borders.cache_clear() 24 | 25 | @lru_cache 26 | def _borders(self) -> Borders: 27 | ''' Calculate bounding box of where to place diagram within frame, 28 | shifting left/up to account for labels 29 | 30 | Returns: 31 | ViewBox of diagram within the full frame 32 | ''' 33 | databox = self.datarange() 34 | ticks = self._maketicks() 35 | 36 | xsty = self._build_style('Graph.TickX') 37 | ysty = self._build_style('Graph.TickY') 38 | lsty = self._build_style('Graph.Legend') 39 | sty = self._build_style() 40 | arrowwidth = sty.edge_width * 3 41 | 42 | if databox.xmin == 0: 43 | leftborder = ticks.ywidth + ysty.height + ysty.margin 44 | else: 45 | leftborder = 1 46 | 47 | if databox.ymin == 0: 48 | botborder = xsty.height + xsty.font_size + 4 49 | else: 50 | botborder = 1 51 | 52 | rightborder = arrowwidth*2 53 | topborder = arrowwidth 54 | 55 | if self._legend == 'left': 56 | leftborder += lsty.edge_width 57 | elif self._legend == 'right': 58 | rightborder += lsty.edge_width 59 | 60 | drange = self.datarange() 61 | if drange.xmin == 0: 62 | leftborder += ticks.ywidth 63 | 64 | if self._xname: 65 | nsty = self._build_style('Graph.XName') 66 | rightborder += text.text_size( 67 | self._xname, font=nsty.font, 68 | fontsize=nsty.font_size).width 69 | 70 | if self._title: 71 | nsty = self._build_style('Graph.Title') 72 | topborder += nsty.font_size 73 | 74 | return Borders(leftborder, rightborder, topborder, botborder) 75 | 76 | @lru_cache 77 | def datarange(self) -> DataRange: 78 | ''' Get range of x-y data. ''' 79 | drange = super().datarange() 80 | xmin = min(0, drange.xmin) 81 | xmax = max(0, drange.xmax) 82 | ymin = min(0, drange.ymin) 83 | ymax = max(0, drange.ymax) 84 | 85 | if xmin == xmax == ymin == ymax == 0: 86 | ymin = xmin = -1 87 | ymax = xmax = 1 88 | return DataRange(xmin, xmax, ymin, ymax) 89 | 90 | def _drawtitle(self, canvas: Canvas, diagbox: ViewBox) -> None: 91 | ''' Draw title 92 | 93 | Args: 94 | canvas: SVG canvas to draw on 95 | diagbox: ViewBox of diagram within the canvas 96 | ''' 97 | if self._title: 98 | sty = self._build_style('Graph.Title') 99 | canvas.text(diagbox.x+diagbox.w/2, diagbox.y+diagbox.h, self._title, 100 | color=sty.get_color(), 101 | font=sty.font, 102 | size=sty.font_size, 103 | halign='center', valign='bottom') 104 | 105 | def _drawticks(self, canvas: Canvas, ticks: Ticks, diagbox: ViewBox, databox: ViewBox): 106 | ''' Draw tick marks and labels 107 | 108 | Args: 109 | canvas: SVG canvas to draw on 110 | ticks: Tick names and locations 111 | diagbox: ViewBox of diagram within the canvas 112 | databox: ViewBox of data to convert from data to 113 | svg coordinates 114 | ''' 115 | sty = self._build_style() 116 | xsty = self._build_style('Graph.TickX') 117 | 118 | xform = Transform(databox, diagbox) 119 | xleft = xform.apply(databox.x, 0) 120 | xrght = xform.apply(databox.x+databox.w, 0) 121 | sty = self._build_style() 122 | arrowwidth = sty.edge_width*3 123 | 124 | startmark = canvas.definemarker('larrow', radius=arrowwidth, 125 | color=sty.edge_color, 126 | strokecolor=sty.edge_color, 127 | orient=True) 128 | endmark = canvas.definemarker('arrow', radius=arrowwidth, 129 | strokecolor=sty.edge_color, 130 | color=sty.edge_color, orient=True) 131 | 132 | if databox.x == 0: 133 | xaxis = [xleft[0], 134 | xrght[0]-xsty.width] 135 | else: 136 | xaxis = [xleft[0]+arrowwidth+xsty.width, 137 | xrght[0]-arrowwidth-xsty.width] 138 | 139 | canvas.path(xaxis, 140 | [xleft[1], xrght[1]], 141 | color=sty.edge_color, 142 | width=sty.edge_width, 143 | startmarker=startmark, 144 | endmarker=endmark, 145 | zorder=self._zorder) 146 | 147 | if self.showxticks: 148 | for xtick, xtickname in zip(ticks.xticks, ticks.xnames): 149 | x, _ = xform.apply(xtick, 0) 150 | y1 = xleft[1] + xsty.height/2 151 | y2 = xleft[1] - xsty.height/2 152 | if xleft[0] < x < xrght[0]: 153 | # Don't draw ticks outside the arrows 154 | canvas.path([x, x], [y1, y2], color=xsty.get_color(), 155 | width=xsty.stroke_width, 156 | zorder=self._zorder) 157 | 158 | canvas.text(x, y2-xsty.margin, xtickname, 159 | color=xsty.get_color(), 160 | font=xsty.font, 161 | size=xsty.font_size, 162 | halign='center', valign='top') 163 | 164 | if ticks.xminor: 165 | xsty_minor = self._build_style('Graph.TickXMinor') 166 | for xminor in ticks.xminor: 167 | if xminor in ticks.xticks: 168 | continue # Don't double-draw 169 | x, _ = xform.apply(xminor, 0) 170 | y1 = xleft[1] + xsty_minor.height/2 171 | y2 = xleft[1] - xsty_minor.height/2 172 | canvas.path([x, x], [y1, y2], color=xsty_minor.get_color(), 173 | width=xsty_minor.stroke_width, 174 | zorder=self._zorder) 175 | 176 | if self._xname: 177 | sty = self._build_style('Graph.XName') 178 | canvas.text(xrght[0]+sty.margin+arrowwidth*1.5, 179 | xrght[1], 180 | self._xname, 181 | color=sty.get_color(), 182 | font=sty.font, 183 | size=sty.font_size, 184 | halign='left', valign='center') 185 | 186 | def _xml(self, canvas: Canvas, databox: Optional[ViewBox] = None, 187 | borders: Optional[Borders] = None) -> None: 188 | ''' Add XML elements to the canvas ''' 189 | ticks = self._maketicks() 190 | dborders = self._borders() 191 | if borders is not None: 192 | dborders = Borders( 193 | dborders.left if borders.left is None else borders.left, 194 | dborders.right if borders.right is None else borders.right, 195 | dborders.top if borders.top is None else borders.top, 196 | dborders.bottom if borders.bottom is None else borders.bottom) 197 | 198 | diagbox = ViewBox( 199 | canvas.viewbox.x + dborders.left, 200 | canvas.viewbox.y + dborders.bottom, 201 | canvas.viewbox.w - (dborders.left + dborders.right), 202 | canvas.viewbox.h - (dborders.top + dborders.bottom)) 203 | 204 | databox = ViewBox(ticks.xrange[0], ticks.yrange[0], 205 | ticks.xrange[1]-ticks.xrange[0], 206 | ticks.yrange[1]-ticks.yrange[0]) 207 | 208 | if self._equal_aspect: 209 | daspect = databox.w / databox.h 210 | diagaspect = diagbox.w / diagbox.h 211 | ratio = daspect / diagaspect 212 | diagbox = ViewBox( 213 | diagbox.x, 214 | diagbox.y, 215 | diagbox.w if ratio >= 1 else diagbox.w * ratio, 216 | diagbox.h if ratio <= 1 else diagbox.h / ratio 217 | ) 218 | 219 | self._drawticks(canvas, ticks, diagbox, databox) 220 | self._drawtitle(canvas, diagbox) 221 | self._drawcomponents(canvas, diagbox, databox) 222 | self._drawlegend(canvas, diagbox, ticks) 223 | -------------------------------------------------------------------------------- /ziaplot/charts/bar.py: -------------------------------------------------------------------------------- 1 | ''' Bar charts (qualitative independent variable) ''' 2 | from __future__ import annotations 3 | from typing import Optional, Sequence, Union 4 | 5 | from ..element import Element, Component 6 | from ..discrete import Bars, BarsHoriz 7 | from ..diagrams import Graph 8 | from ..style import AppliedStyle 9 | from ..canvas import Canvas, Borders, ViewBox 10 | from .. import diagram_stack 11 | 12 | 13 | class Bar(Element): 14 | ''' A single bar in a BarChart 15 | 16 | Args: 17 | value: value assigned to this bar. 18 | ''' 19 | _step_color = True 20 | 21 | def __init__(self, value: float = 1): 22 | self.value = value 23 | super().__init__() 24 | 25 | 26 | class BarChart(Graph): 27 | ''' A vertical bar chart with a single bar series. 28 | Independent variable is qualitative. 29 | 30 | Note: 31 | For a bar graph with quantitative x values, use Graph and add Bars instances. 32 | ''' 33 | def __init__(self) -> None: 34 | super().__init__() 35 | self.barlist: list[Bar] = [] 36 | self._horiz = False 37 | self._barwidth = 1. # Let each bar have data-width = 1 38 | self._legend = 'none' 39 | 40 | def add(self, bar: Component) -> None: 41 | ''' Add a single bar ''' 42 | assert isinstance(bar, Bar) 43 | diagram_stack.pause = True 44 | self.barlist.append(bar) 45 | newbar: Union[Bars, BarsHoriz] 46 | if self._horiz: 47 | newbar = BarsHoriz((0,), (bar.value,), width=self._barwidth, align='center') 48 | else: 49 | newbar = Bars((0,), (bar.value,), width=self._barwidth, align='center') 50 | 51 | if bar._style.color: 52 | newbar.color(bar._style.color) 53 | if bar._name: 54 | newbar.name(bar._name) 55 | 56 | super().add(newbar) 57 | diagram_stack.pause = False 58 | 59 | def _build_style(self, name: str | None = None) -> AppliedStyle: 60 | ''' Build the Style ''' 61 | if self._horiz: 62 | if name == 'Graph.TickX': 63 | name = 'BarChartHoriz.TickY' 64 | elif name == 'Graph.GridY': 65 | name = 'BarChartHoriz.GridY' 66 | elif name == 'Graph.GridY': 67 | name = 'BarChartHoriz.GridY' 68 | else: 69 | if name == 'Graph.TickY': 70 | name = 'BarChart.TickX' 71 | elif name == 'Graph.GridX': 72 | name = 'BarChart.GridX' 73 | elif name == 'Graph.GridY': 74 | name = 'BarChart.GridY' 75 | return super()._build_style(name) 76 | 77 | @classmethod 78 | def fromdict(cls, bars: dict[str, float]) -> 'BarChart': 79 | ''' Create a barchart from dictionary of name: value pairs ''' 80 | chart = cls() 81 | for name, value in bars.items(): 82 | diagram_stack.pause = True 83 | chart.add(Bar(value).name(name)) 84 | diagram_stack.pause = False 85 | return chart 86 | 87 | def _xml(self, canvas: Canvas, databox: Optional[ViewBox] = None, 88 | borders: Optional[Borders] = None) -> None: 89 | ''' Add XML elements to the canvas ''' 90 | sty = self._build_style() 91 | names = [str(bar._name) for bar in self.barlist] 92 | if self._horiz: 93 | elements = self.components[::-1] 94 | names = names[::-1] 95 | else: 96 | elements = self.components 97 | 98 | N = len(names) 99 | tickpos = [i * (self._barwidth + sty.margin) for i in range(N)] 100 | 101 | # Use named ticks 102 | if self._horiz: 103 | self.yticks(tickpos, names) 104 | else: 105 | self.xticks(tickpos, names) 106 | 107 | # Set bar x positions 108 | for tick, bar in zip(tickpos, elements): 109 | assert isinstance(bar, (Bars, BarsHoriz)) 110 | bar.x = (tick,) 111 | super()._xml(canvas, databox, borders) 112 | 113 | 114 | class BarChartHoriz(BarChart): 115 | ''' A horizontal bar chart with a single data series. 116 | Independent variable is qualitative. 117 | 118 | Note: 119 | For a bar graph with quantitative x values, use Graph and add Bars instances. 120 | ''' 121 | def __init__(self): 122 | super().__init__() 123 | self._horiz = True 124 | 125 | 126 | class BarSeries(Element): 127 | ''' A series of bars across all groups 128 | 129 | Args: 130 | values: values assigned to this bar series. 131 | ''' 132 | _step_color = True 133 | 134 | def __init__(self, *values: float): 135 | self.values = values 136 | super().__init__() 137 | 138 | 139 | class BarChartGrouped(Graph): 140 | ''' A grouped bar chart, where independent variable is qualitative. 141 | 142 | Args: 143 | groups: list of x value strings 144 | 145 | Note: 146 | For a bar graph with quantitative x values, use Graph and add Bars instances. 147 | ''' 148 | def __init__(self, groups: Sequence[str]): 149 | super().__init__() 150 | self.barlist: list[BarSeries] = [] 151 | self.groups = groups 152 | self._horiz = False 153 | self._barwidth = 1. # Let each bar have data-width = 1 154 | 155 | def add(self, barseries: Component) -> None: 156 | ''' Add a series of bars to the chart ''' 157 | assert isinstance(barseries, BarSeries) 158 | diagram_stack.pause = True 159 | self.barlist.append(barseries) 160 | # Use dummy x-values for now since we don't know how many series there will be 161 | x = list(range(len(barseries.values))) 162 | bar: Union[Bars, BarsHoriz] 163 | if self._horiz: 164 | values = list(reversed(barseries.values)) 165 | bar = BarsHoriz(x, values, width=self._barwidth, align='left') 166 | else: 167 | bar = Bars(x, barseries.values, width=self._barwidth, align='left') 168 | if barseries._style.color: 169 | bar.color(barseries._style.color) 170 | if barseries._name: 171 | bar.name(barseries._name) 172 | super().add(bar) 173 | diagram_stack.pause = False 174 | 175 | def _build_style(self, name: str | None = None) -> AppliedStyle: 176 | ''' Build the Style ''' 177 | if self._horiz: 178 | if name == 'Graph.TickX': 179 | name = 'BarChartHoriz.TickY' 180 | elif name == 'Graph.GridY': 181 | name = 'BarChartHoriz.GridY' 182 | elif name == 'Graph.GridY': 183 | name = 'BarChartHoriz.GridY' 184 | else: 185 | if name == 'Graph.TickY': 186 | name = 'BarChart.TickX' # OK 187 | elif name == 'Graph.GridX': 188 | name = 'BarChart.GridX' 189 | elif name == 'Graph.GridY': 190 | name = 'BarChart.GridY' 191 | return super()._build_style(name) 192 | 193 | @classmethod 194 | def fromdict(cls, bars: dict[str, Sequence[float]], 195 | groups: Sequence[str]) -> 'BarChartGrouped': 196 | ''' Create Bar Chart from dictionary of name: values list ''' 197 | chart = cls(groups) 198 | for name, values in bars.items(): 199 | diagram_stack.pause = True 200 | chart.add(BarSeries(*values).name(name)) 201 | diagram_stack.pause = False 202 | return chart 203 | 204 | def _xml(self, canvas: Canvas, databox: Optional[ViewBox] = None, 205 | borders: Optional[Borders] = None) -> None: 206 | ''' Add XML elements to the canvas ''' 207 | sty = self._build_style() 208 | num_series = len(self.barlist) 209 | num_groups = len(self.groups) 210 | bargap = sty.margin 211 | groupwidth = (self._barwidth*num_series) + bargap 212 | totwidth = groupwidth * num_groups + bargap 213 | # Use named ticks 214 | if self._horiz: 215 | yticks = [bargap + (groupwidth-bargap)/2 + k*groupwidth for k in range(num_groups)] 216 | self.yticks(yticks, self.groups[::-1]) 217 | self.yrange(0, totwidth) 218 | # Set bar x positions 219 | for i, bar in enumerate(self.components[::-1]): 220 | assert isinstance(bar, (Bars, BarsHoriz)) 221 | x = [bargap + self._barwidth*i + k*groupwidth for k in range(num_groups)] 222 | bar.x = x 223 | else: 224 | xticks = [bargap + (groupwidth-bargap)/2 + k*groupwidth for k in range(num_groups)] 225 | self.xticks(xticks, self.groups) 226 | self.xrange(0, totwidth) 227 | 228 | # Set bar x positions 229 | for i, bar in enumerate(self.components): 230 | assert isinstance(bar, (Bars, BarsHoriz)) 231 | x = [bargap + self._barwidth*i + k*groupwidth for k in range(num_groups)] 232 | bar.x = x 233 | super()._xml(canvas, databox, borders=borders) 234 | 235 | 236 | class BarChartGroupedHoriz(BarChartGrouped): 237 | ''' Horizontal Grouped Bar Chart ''' 238 | def __init__(self, groups: Sequence[str]): 239 | super().__init__(groups) 240 | self._horiz = True 241 | -------------------------------------------------------------------------------- /ziaplot/figures/point.py: -------------------------------------------------------------------------------- 1 | ''' Point with optional text and guide lines''' 2 | from __future__ import annotations 3 | from typing import Optional, Iterator 4 | import math 5 | 6 | from .. import geometry 7 | from ..geometry import PointType, LineType 8 | from ..text import TextPosition, text_align_ofst 9 | from ..style import MarkerTypes 10 | from ..canvas import Canvas, Borders, ViewBox, DataRange 11 | from ..element import Element 12 | from .shapes import Circle, Arc 13 | from .function import Function 14 | from .line import Line 15 | from .bezier import Bezier 16 | 17 | 18 | class Point(Element): 19 | ''' Point with optional text label 20 | 21 | Args: 22 | p: x, y tuple 23 | ''' 24 | _step_color = False 25 | 26 | def __init__(self, p: PointType): 27 | super().__init__() 28 | self.x, self.y = p 29 | self._text: Optional[str] = None 30 | self._text_pos: Optional[TextPosition] = None 31 | self._guidex: Optional[float] = None 32 | self._guidey: Optional[float] = None 33 | self._zorder: int = 6 # Points should usually be above other things 34 | 35 | def __getitem__(self, idx): 36 | return [self.x, self.y][idx] 37 | 38 | def __iter__(self) -> Iterator[float]: 39 | return iter(self.point) 40 | 41 | @property 42 | def point(self) -> PointType: 43 | ''' XY coordinate tuple ''' 44 | return self.x, self.y 45 | 46 | def marker(self, marker: MarkerTypes, radius: Optional[float] = None, 47 | orient: bool = False) -> 'Point': 48 | ''' Sets the point marker shape and size ''' 49 | self._style.shape = marker 50 | if radius: 51 | self._style.radius = radius 52 | return self 53 | 54 | def label(self, text: str, 55 | pos: TextPosition = 'NE') -> 'Point': 56 | ''' Add a text label to the point 57 | 58 | Args: 59 | text: Label 60 | text_pos: Position for label with repsect 61 | to the point (N, E, S, W, NE, NW, SE, SW) 62 | ''' 63 | self._text = text 64 | self._text_pos = pos 65 | return self 66 | 67 | def guidex(self, toy: float = 0) -> 'Point': 68 | ''' Draw a vertical guide line between point and toy ''' 69 | self._guidex = toy 70 | return self 71 | 72 | def guidey(self, tox: float = 0) -> 'Point': 73 | ''' Draw a horizontal guide line between point and tox ''' 74 | self._guidey = tox 75 | return self 76 | 77 | def datarange(self) -> DataRange: 78 | ''' Get x-y datarange ''' 79 | delta = .05 80 | dx = abs(self.x) * delta 81 | dy = abs(self.y) * delta 82 | return DataRange(self.x - dx, self.x + dx, 83 | self.y - dy, self.y + dy) 84 | 85 | def _logx(self) -> None: 86 | ''' Convert x coordinates to log(x) ''' 87 | self.x = math.log10(self.x) 88 | 89 | def _logy(self) -> None: 90 | ''' Convert y coordinates to log(y) ''' 91 | self.y = math.log10(self.y) 92 | 93 | def _xml(self, canvas: Canvas, databox: Optional[ViewBox] = None, 94 | borders: Optional[Borders] = None) -> None: 95 | ''' Add XML elements to the canvas ''' 96 | if self._guidex is not None: 97 | style = self._build_style('Point.GuideX') 98 | canvas.path([self.x, self.x], [self._guidex, self.y], 99 | color=style.get_color(), 100 | stroke=style.stroke, 101 | width=style.stroke_width, 102 | dataview=databox, 103 | zorder=self._zorder) 104 | if self._guidey is not None: 105 | style = self._build_style('Point.GuideY') 106 | canvas.path([self._guidey, self.x], [self.y, self.y], 107 | color=style.get_color(), 108 | stroke=style.stroke, 109 | width=style.stroke_width, 110 | dataview=databox, 111 | zorder=self._zorder) 112 | 113 | sty = self._build_style() 114 | markname = canvas.definemarker(sty.shape, 115 | sty.radius, 116 | sty.get_color(), 117 | sty.edge_color, 118 | sty.edge_width) 119 | canvas.path([self.x], [self.y], 120 | color=sty.get_color(), 121 | markerid=markname, 122 | dataview=databox, 123 | zorder=self._zorder) 124 | 125 | if self._text: 126 | style = self._build_style('Point.Text') 127 | dx, dy, halign, valign = text_align_ofst( 128 | self._text_pos, style.margin) 129 | 130 | canvas.text(self.x, self.y, self._text, 131 | color=style.get_color(), 132 | font=style.font, 133 | size=style.font_size, 134 | halign=halign, 135 | valign=valign, 136 | pixelofst=(dx, dy), 137 | dataview=databox) 138 | 139 | def reflect(self, line: LineType) -> 'Point': 140 | ''' Create a new point reflected over line ''' 141 | x, y = geometry.reflect(self, line) 142 | return Point((x, y)) 143 | 144 | def image(self, line: LineType) -> 'Point': 145 | ''' Create a new point imaged onto the line (point on line at 146 | shortest distance to point) 147 | ''' 148 | x, y = geometry.image(self, line) 149 | return Point((x, y)) 150 | 151 | def bisect(self, point: PointType) -> 'Line': 152 | ''' Create a new line bisecting the two points ''' 153 | return Line.from_standard(*geometry.line.bisect_points(self, point)) 154 | 155 | @classmethod 156 | def at(cls, f: Function, x: float) -> 'Point': 157 | ''' Draw a Point at y = f(x) ''' 158 | y = f.y(x) 159 | return cls((x, y)) 160 | 161 | @classmethod 162 | def at_y(cls, f: Function, y: float) -> 'Point': 163 | ''' Draw a Point at y = f(x) ''' 164 | x = f.x(y) 165 | return cls((x, y)) 166 | 167 | @classmethod 168 | def at_minimum(cls, f: Function, x1: float, x2: float) -> 'Point': 169 | ''' Draw a Point at local minimum of f between x1 and x2 ''' 170 | x, y = geometry.function.local_min(f.y, x1, x2) 171 | return cls((x, y)) 172 | 173 | @classmethod 174 | def at_maximum(cls, f: Function, x1: float, x2: float) -> 'Point': 175 | ''' Draw a Point at local maximum of f between x1 and x2 ''' 176 | x, y = geometry.function.local_max(f.y, x1, x2) 177 | return cls((x, y)) 178 | 179 | @classmethod 180 | def at_midpoint(cls, a: PointType, b: PointType) -> 'Point': 181 | ''' Draw a point at the midpoint between the two given points ''' 182 | x, y = geometry.midpoint(a, b) 183 | return cls((x, y)) 184 | 185 | @classmethod 186 | def on_circle(cls, circle: Circle, theta: float) -> 'Point': 187 | ''' Draw a Point on the circle at angle theta (degrees) ''' 188 | x, y = geometry.circle.point(circle, math.radians(theta)) 189 | return cls((x, y)) 190 | 191 | @classmethod 192 | def at_intersection(cls, f1: Function|Line|Circle|Arc, f2: Function|Line|Circle|Arc, 193 | bounds: Optional[tuple[float, float]] = None, 194 | which: str = 'top', 195 | offarc: bool = False 196 | ) -> 'Point': 197 | ''' Draw a Point at the intersection of two functions, lines, circles, or arcs. 198 | 199 | Args: 200 | f1: First function 201 | f2: Second function 202 | bounds: tuple of x values to bound the search. Only used for intersection 203 | of two Functions 204 | which: in cases where more than one intersection occurs, return the 205 | `top`, `bottom`, `left` or `right`-most point. 206 | ''' 207 | if isinstance(f1, Line) and isinstance(f2, Line): 208 | x, y = geometry.intersect.lines(f1, f2) 209 | 210 | elif isinstance(f1, (Arc, Circle)) and isinstance(f2, (Arc, Circle)): 211 | points = geometry.intersect.circles(f1, f2) 212 | x, y = geometry.select_which(points, which) 213 | 214 | elif isinstance(f1, Line) and isinstance(f2, Arc) and not offarc: 215 | points = geometry.intersect.line_arc(f1, f2) 216 | x, y = geometry.select_which(points, which) 217 | elif isinstance(f1, Arc) and isinstance(f2, Line) and not offarc: 218 | points = geometry.intersect.line_arc(f2, f1) 219 | x, y = geometry.select_which(points, which) 220 | elif isinstance(f1, Line) and isinstance(f2, Circle): 221 | points = geometry.intersect.line_circle(f1, f2) 222 | x, y = geometry.select_which(points, which) 223 | elif isinstance(f1, Circle) and isinstance(f2, Line): 224 | points = geometry.intersect.line_circle(f2, f1) 225 | x, y = geometry.select_which(points, which) 226 | 227 | else: 228 | if bounds is None: 229 | raise ValueError('bounds are required for intersection of non-line functions.') 230 | assert callable(f1.y) 231 | assert callable(f2.y) 232 | x, y = geometry.intersect.functions(f1.y, f2.y, *bounds) 233 | 234 | if not math.isfinite(x) or not math.isfinite(y): 235 | raise ValueError('No intersection found') 236 | 237 | return cls((x, y)) 238 | 239 | @classmethod 240 | def on_bezier(cls, b: Bezier, t: float) -> 'Point': 241 | ''' Create a Point on the Bezier curve ''' 242 | x, y = b.xy(t) 243 | return cls((x, y)) 244 | -------------------------------------------------------------------------------- /ziaplot/annotations/annotations.py: -------------------------------------------------------------------------------- 1 | ''' Annotations Angle and Arrow ''' 2 | from typing import Optional, Sequence, cast 3 | import math 4 | 5 | from .. import diagram_stack 6 | from ..geometry import angle_mean 7 | from ..style import MarkerTypes 8 | from ..text import TextPosition, Halign, Valign, text_align_ofst 9 | from ..canvas import Canvas, Borders, ViewBox 10 | from ..element import Component 11 | from ..figures.line import Line, LineLabel 12 | 13 | 14 | class Annotation(Component): 15 | ''' Base class for annotations such as Arrows, Angles. Use to apply styling. ''' 16 | 17 | 18 | class Arrow(Annotation): 19 | ''' An arrow pointing to an XY location, with optional 20 | text annotation 21 | 22 | Args: 23 | xy: XY position to point at 24 | xytail: XY-position of arrow tail 25 | s: String to draw at tail of arrow 26 | strofst: XY offset between text and arrow tail 27 | marker: Arrowhead marker shape 28 | tailmarker: Arrowhead tail marker 29 | ''' 30 | _step_color = False 31 | 32 | def __init__(self, xy: Sequence[float], xytail: Sequence[float], 33 | marker: MarkerTypes = 'arrow', 34 | tailmarker: Optional[MarkerTypes] = None): 35 | super().__init__() 36 | self.xy = xy 37 | self.xytail = xytail 38 | self._text: Optional[str] = None 39 | self._text_pos: Optional[TextPosition] = None 40 | self._tailmarker = tailmarker 41 | self._endmarker = marker 42 | self._zorder: int = 8 43 | 44 | def label(self, text: str, 45 | pos: TextPosition = 'NE') -> 'Arrow': 46 | ''' Add a text label to the point 47 | 48 | Args: 49 | text: Label 50 | text_pos: Position for label with repsect 51 | to the point (N, E, S, W, NE, NW, SE, SW) 52 | ''' 53 | self._text = text 54 | self._text_pos = pos 55 | return self 56 | 57 | def _xml(self, canvas: Canvas, databox: Optional[ViewBox] = None, 58 | borders: Optional[Borders] = None) -> None: 59 | ''' Add XML elements to the canvas ''' 60 | sty = self._build_style() 61 | color = sty.get_color() 62 | edgecolor = color 63 | if sty.edge_color not in [None, 'auto']: 64 | edgecolor = sty.edge_color 65 | 66 | tailmark = None 67 | endmark = None 68 | if self._tailmarker: 69 | tailmark = canvas.definemarker(self._tailmarker, 70 | sty.radius, 71 | color, 72 | edgecolor, 73 | sty.edge_width, 74 | orient=True) 75 | if self._endmarker: 76 | endmark = canvas.definemarker(self._endmarker, 77 | sty.radius, 78 | color, 79 | edgecolor, 80 | sty.edge_width, 81 | orient=True) 82 | 83 | x = self.xytail[0], self.xy[0] 84 | y = self.xytail[1], self.xy[1] 85 | canvas.path(x, y, 86 | stroke=sty.stroke, 87 | color=color, 88 | width=sty.stroke_width, 89 | startmarker=tailmark, 90 | endmarker=endmark, 91 | dataview=databox, 92 | zorder=self._zorder) 93 | 94 | if self._text: 95 | dx, dy, halign, valign = text_align_ofst( 96 | self._text_pos, sty.margin) 97 | 98 | tsty = self._build_style('Point.Text') 99 | canvas.text(self.xytail[0], self.xytail[1], self._text, 100 | color=sty.get_color(), 101 | font=tsty.font, 102 | size=tsty.font_size, 103 | halign=halign, 104 | valign=valign, 105 | pixelofst=(dx, dy), 106 | dataview=databox) 107 | 108 | 109 | class Angle(Annotation): 110 | ''' Draw angle between two Lines/Segments ''' 111 | def __init__(self, line1: Line, line2: Line, quad: int = 1, arcs: int = 1): 112 | super().__init__() 113 | self.line1 = line1 114 | self.line2 = line2 115 | self.quad = quad 116 | self.arcs = arcs 117 | self._label: Optional[LineLabel] = None 118 | self.square_right = True 119 | self._zorder: int = 8 120 | 121 | def label(self, label: str, color: Optional[str] = None, 122 | size: Optional[float] = None) -> 'Angle': 123 | self._label = LineLabel(label, color=color, size=size) 124 | return self 125 | 126 | def color(self, color: str) -> 'Angle': 127 | ''' Sets the color of the angle arc ''' 128 | self._style.color = color 129 | return self 130 | 131 | def strokewidth(self, width: float) -> 'Angle': 132 | ''' Sets the strokewidth of the angle arc ''' 133 | self._style.stroke_width = width 134 | return self 135 | 136 | def radius(self, radius: float, text_radius: Optional[float] = None) -> 'Angle': 137 | ''' Sets the radius of the angle arc ''' 138 | self._style.radius = radius 139 | if text_radius: 140 | self._style.margin = text_radius 141 | return self 142 | 143 | @classmethod 144 | def to_zero(cls, line: Line, quad: int = 1): 145 | ''' Create angle between line and y=0 ''' 146 | diagram_stack.pause = True 147 | line2 = Line((0, 0), 0) 148 | diagram_stack.pause = False 149 | return cls(line, line2, quad=quad) 150 | 151 | def _xml(self, canvas: Canvas, databox: Optional[ViewBox] = None, 152 | borders: Optional[Borders] = None) -> None: 153 | m1, m2 = self.line1.slope, self.line2.slope 154 | b1, b2 = self.line1.intercept, self.line2.intercept 155 | 156 | # Point of intersection 157 | x = (b2 - b1) / (m1 - m2) 158 | y = self.line1.y(x) 159 | if not math.isfinite(x): 160 | x = self.line1.point[0] 161 | y = self.line1.y(x) 162 | if not math.isfinite(y): 163 | y = self.line2.y(x) 164 | 165 | theta1 = math.atan(m1) 166 | theta2 = math.atan(m2) 167 | 168 | if m1 < m2: 169 | theta1, theta2 = theta2, theta1 170 | 171 | if self.quad == 2: 172 | theta2 += math.pi 173 | theta1 += math.pi 174 | theta2, theta1 = theta1, theta2 175 | 176 | elif self.quad == 3: 177 | theta1 += math.pi 178 | elif self.quad == 4: 179 | theta2, theta1 = theta1, theta2 180 | else: 181 | theta2 += math.pi 182 | 183 | theta1 = (theta1 + math.tau) % math.tau 184 | theta2 = (theta2 + math.tau) % math.tau 185 | 186 | # Calculate radius of angle arc in data coordinates 187 | assert databox is not None 188 | sty = self._build_style() 189 | 190 | r = sty.radius * databox.w / canvas.viewbox.w 191 | dtheta = abs(theta1 - theta2) % math.pi 192 | if self.square_right and math.isclose(dtheta, math.pi/2): 193 | # Right Angle 194 | r2 = r / math.sqrt(2) 195 | xpath = [x + r2 * math.cos(theta1), 196 | x + r * math.cos(theta1+math.pi/4), 197 | x + r2 * math.cos(theta2)] 198 | ypath = [y + r2 * math.sin(theta1), 199 | y + r * math.sin(theta1+math.pi/4), 200 | y + r2 * math.sin(theta2)] 201 | canvas.path(xpath, ypath, 202 | color=sty.color, 203 | width=sty.stroke_width, 204 | dataview=databox, 205 | zorder=self._zorder 206 | ) 207 | else: 208 | dradius = sty.margin * databox.w / canvas.viewbox.w 209 | for i in range(self.arcs): 210 | canvas.arc( 211 | x, y, r - i * dradius, 212 | math.degrees(theta1), 213 | math.degrees(theta2), 214 | strokecolor=sty.color, 215 | strokewidth=sty.stroke_width, 216 | dataview=databox, 217 | zorder=self._zorder 218 | ) 219 | 220 | if self._label: 221 | textstyle = self._build_style('Angle.Text') 222 | r = sty.radius + textstyle.margin 223 | color = self._label.color if self._label.color else textstyle.get_color() 224 | size = self._label.size if self._label.size else textstyle.font_size 225 | labelangle = angle_mean(theta1, theta2) 226 | dx = r * math.cos(labelangle) 227 | dy = r * math.sin(labelangle) 228 | 229 | if labelangle < math.tau/8 or labelangle > 7*math.tau/8: 230 | halign = 'left' 231 | elif 3 * math.tau / 8 < labelangle < 5 * math.tau / 8: 232 | halign = 'right' 233 | else: 234 | halign = 'center' 235 | 236 | if math.tau/8 < labelangle < 3 * math.tau / 8: 237 | valign = 'bottom' 238 | elif 5 * math.tau / 8 < labelangle < 7 * math.tau / 8: 239 | valign = 'top' 240 | else: 241 | valign = 'center' 242 | 243 | canvas.text(x, y, self._label.label, 244 | color=color, 245 | font=textstyle.font, 246 | size=size, 247 | halign=cast(Halign, halign), 248 | valign=cast(Valign, valign), 249 | pixelofst=(dx, dy), 250 | dataview=databox) 251 | --------------------------------------------------------------------------------