diff --git a/examples/qt/lineplot.py b/examples/qt/lineplot.py new file mode 100644 index 000000000..7be82e913 --- /dev/null +++ b/examples/qt/lineplot.py @@ -0,0 +1,319 @@ +""" +Lineplot Qt +=========== + +Complex example for lineplot in PyQt that displays 3 traces. +The plot is a standard black on white with a legend on the right. + +""" +import sys +import numpy as np +import time +from math import pi, cos, sin, ceil, log10 + + +try: + from PyQt6.QtWidgets import QApplication, QWidget, QVBoxLayout, QMainWindow + from PyQt6.QtCore import QTimer, Qt + +except ImportError: + from PyQt5.QtWidgets import QApplication, QWidget, QVBoxLayout, QMainWindow + from PyQt5.QtCore import QTimer, Qt + + +import fastplotlib as fpl + +def rotate(angle, axis_x, axis_y, axis_z): + """ + Quaternion representing rotation around the given axis by the given angle. + """ + a2 = angle/2.0 + c = cos(a2) + s = sin(a2) + return (axis_x * s, axis_y * s, axis_z * s, c) + +class FastPlotMain(QMainWindow): + + MAJOR_TICKS = 5 + MINOR_TICKS = 4 + DATAPOINTS = 50000 # number of data points per line + INTERVAL = 16 # display refresh in milliseconds + WHITE = (1.0, 1.0, 1.0, 1.0) + BLACK = (0.0, 0.0, 0.0, 1.0) + RED = (1.0, 0.0, 0.0, 1.0) + GREEN = (0.0, 1.0, 0.0, 1.0) + BLUE = (0.0, 0.0, 1.0, 1.0) + DARK_GRAY = (0.2, 0.2, 0.2, 1.0) + LIGHT_GRAY = (0.9, 0.9, 0.9, 1.0) + + def __init__(self): + super().__init__() + + # ─── Window ────────────────────────────────────────────────────────────── + + self.setWindowTitle("fastplotlib Line Plot Test") + self.resize(800, 600) + + # ─── Figure & Subplot ──────────────────────────────────────────────────── + + self.fig = fpl.Figure( + (1, 1), + size=(800, 600), + names = "Line Plot", + ) + + # Subplot + self.ax = self.fig[0, 0] + + # Turn on axi rulers and option grid + self.ax.axes.visible = True + self.ax.background_color = self.WHITE + if self.ax.axes.grids: + self.ax.axes.grids.xy.visible = True + self.ax.axes.grids.xy.color = self.DARK_GRAY + + # ─── Docks: Title & Axis Labels ────────────────────────────────────────── + + # Title + self.ax.docks["top"].size = 30 + self.ax.docks["top"].add_text( + "Line Plots", + font_size=16, + face_color=(0, 0, 0, 1), + anchor="middle-center", + offset=(0, 0, 0), + ) + self.ax.docks["top"].background_color = self.WHITE + + # X label + self.ax.docks["bottom"].size = 30 + self.ax.docks["bottom"].add_text( + "X", + font_size=16, + face_color=(0, 0, 0, 1), + anchor="middle-center", + offset=(0, 0, 0), + ) + self.ax.docks["bottom"].background_color = self.WHITE + + # Y label + q = rotate(pi/2.0, 0., 0., 1.) # rotate 90 deg around z-axis + self.ax.docks["left"].size = 30 + self.ax.docks["left"].add_text( + "Y", + font_size=16, + face_color=(0, 0, 0, 1), + anchor="middle-center", + offset=(0, 0, 0), + rotation=q, + ) + self.ax.docks["left"].background_color = self.WHITE + + # ─── Data & Graphics ───────────────────────────────────────────────────── + + # Prepare your data buffers + t = np.linspace(-2*np.pi,2*np.pi,self.DATAPOINTS, dtype = np.float32) + self.t = t + self.phase1 = 0.0 + self.phase2 = pi/2. + N = self.t.size + self.z = np.zeros_like(self.t, dtype=np.float32) + + # Pre-allocate three (N×3) float32 buffers: + self.buf1 = np.empty((N, 3), dtype=np.float32) + self.buf2 = np.empty((N, 3), dtype=np.float32) + self.buf3 = np.empty((N, 3), dtype=np.float32) + + # Copy the constant x and z columns once: + self.buf1[:, 0] = self.t; self.buf1[:, 2] = self.z + self.buf2[:, 0] = self.t; self.buf2[:, 2] = self.z + self.buf3[:, 0] = self.t; self.buf3[:, 2] = self.z + + # Colors (uniform for all points in a line) + rgba1 = np.tile(np.array(self.RED, dtype=np.float32), (self.DATAPOINTS, 1)) # RED + rgba2 = np.tile(np.array(self.BLACK, dtype=np.float32), (self.DATAPOINTS, 1)) # BLACK + rgba3 = np.tile(np.array(self.BLUE, dtype=np.float32), (self.DATAPOINTS, 1)) # BLUE + + # Add the lines to the plot axis + self.line1 = self.ax.add_line(self.buf1, colors=rgba1) + self.line2 = self.ax.add_line(self.buf2, colors=rgba2) + self.line3 = self.ax.add_line(self.buf3, colors=rgba3) + + # ─── View & Axes Ticks ──────────────────────────────────────────────────────── + + self.ax.axes.update_using_camera() + self.ax.auto_scale(maintain_aspect=True) + # Zoom + self.ax.camera.local.scale_x *= 1.0 + self.ax.camera.local.scale_y *= 1.0 + # Draw the axes with ticks + self.updateAxesTicks(self.ax, self.MAJOR_TICKS, self.MINOR_TICKS) + + # ─── Legend ───────────────────────────────────────────────────────────── + + from fastplotlib.legends import Legend + legend_dock = self.ax.docks["right"] # options are right, left, top, bottom + legend_dock.background_color = self.WHITE + # legend_dock = self.ax # not working yet, no floating legend on top of plot + legend_dock.size = 200 # if top/bottom dock that is the height of dock in pixels, + # if left/right dock that is the width of the dock in pixels, + self.legend = Legend( + plot_area=legend_dock, # the plot area to attach the legend to + background_color=self.LIGHT_GRAY, # optional: the background color of the legend + max_rows = 5 # how many items per column before wrapping + ) + self.lines = [self.line1, self.line2, self.line3] + self.labels = ["sin(x)", "rand + 1", "sin(x + θ) - 1"] + for lg, label in zip(self.lines, self.labels): + self.legend.add_graphic(lg, label) + + self.legend.update_using_camera() + + # ─── Finalize and Show ──────────────────────────────────────────────────── + + canvas = self.fig.show(autoscale=True, maintain_aspect=True) # show the figure + self.setCentralWidget(canvas) + self.fig.canvas.request_draw() + + # ─── Animation Timer ─────────────────────────────────────────────────── + + timer = QTimer(self) + timer.timeout.connect(self.animate) + timer.start(self.INTERVAL) + + # ─── One Time Scaling ──────────────────────────────────────────────────────── + # This is to ensure that the plot is at least once autoscaled, otherwise user + # sees no plot area + + QTimer.singleShot( + 100, # 0 ms → next Qt loop + self.autoScale # the slot to call + ) + + # ─── Benchmark ────────────────────────────────────────────────────────── + + self.last_time = time.perf_counter() + self.num_segments = 0 + + + def autoScale(self): + """Run once, right after the first frame is ready.""" + ax = getattr(self, "ax", None) + if ax is None: + return # nothing to autoscale + + self.ax.auto_scale(maintain_aspect=True, zoom=0.9) + self.updateAxesTicks(self.ax, self.MAJOR_TICKS, self.MINOR_TICKS) + self.fig.canvas.request_draw() + + def updateAxesTicks(self, subplot, n_major, n_minor): + """ + Update the tick marks of the x and y axis. + """ + + # Helper to pick a "nice" power‐of‐10 step and decimal precision + def nice_step(lo, hi, n): + rng = (hi - lo) / n + exp = 0 if rng <= 0 else ceil(log10(rng)) + return 10 ** exp, max(0, -exp) + + # Grab world‐space extent from the rulers + xr, yr = subplot.axes.x, subplot.axes.y + + xmin, _, _ = xr.start_pos + xmax, _, _ = xr.end_pos + _, ymin, _ = yr.start_pos + _, ymax, _ = yr.end_pos + + # Compute the steps + maj_x, dec_x = nice_step(xmin, xmax, n_major) + maj_y, dec_y = nice_step(ymin, ymax, n_major) + + + # Apply to each ruler + for r, maj, dec in ( + (xr, maj_x, dec_x), + (yr, maj_y, dec_y), + ): + + r.line.material.color = self.BLACK # Ruler color + + r.major_step = maj # Major step + r.minor_step = maj / n_minor # Minor step + + r.tick_side = "left" if r is yr else "right" # Tick side + r.tick_format = f".{dec}f" # Label format, TODO this is invalid + + if r.ticks is not None: + r.ticks.material.color = self.BLACK # Major ticks color + + if r.points is not None: + r.points.material.color = self.BLACK # Major ticks color + + if r.text is not None: + r.text.material.color = self.BLACK + + if subplot.axes.grids: + gxy = subplot.axes.grids.xy + gxy.visible = True # Show the grid + gxy.axis_color = self.BLACK # Axis color + gxy.major_color = self.BLACK # Major grid color + gxy.minor_color = self.DARK_GRAY # Minor grid color + gxy.major_thickness = 1.0 # Major grid thickness + gxy.minor_thickness = 0.5 # Minor grid thickness + + subplot.axes.update_using_camera() # Update the axes with the new ticks + + def animate(self): + + # Increment phases (animate plots) + self.phase1 += 0.01 * self.INTERVAL + self.phase2 += 0.0101 * self.INTERVAL + + # Generate the data + + # Line 1: sin(t+phase1) in-place + np.sin(self.t + self.phase1, out=self.buf1[:, 1]) + + # Line 2: rand+1; since rand() has no `out` kwarg, write into buf2[:,1] by slicing: + self.buf2[:, 1] = np.random.rand(self.t.size).astype(np.float32) + 1.0 + + # Line 3: sin(t+phase2)-1 in-place + np.sin(self.t + self.phase2, out=self.buf3[:, 1]) + self.buf3[:, 1] -= 1.0 + + # Update the data in the plot lines + self.line1.data = self.buf1 + self.line2.data = self.buf2 + self.line3.data = self.buf3 + + # Update the axes + #self.updateAxesTicks(self.ax, self.MAJOR_TICKS, self.MINOR_TICKS) + + # Redraw the figure + self.fig.canvas.request_draw() + + # Benchmark number of segments per second + self.num_segments += 3 * self.t.size + + current_time = time.perf_counter() + if current_time - self.last_time >= 1.0: + print(f"Segments/s: {self.num_segments}, Segments/Frame: {3*self.t.size}, Frames/s: {1000/(self.INTERVAL):.2f}") + self.last_time = current_time + self.num_segments = 0 + + def closeEvent(self, ev): + fpl.loop.stop() + super().closeEvent(ev) + + +if __name__ == "__main__": + app = QApplication(sys.argv) + # app.setStyle("Fusion") + # app.setStyleSheet("QWidget { background-color: white; }") + fpl.loop._app = app + + win = FastPlotMain() + win.show() + + sys.exit(app.exec()) diff --git a/fastplotlib/legends/legend.py b/fastplotlib/legends/legend.py index 69a556109..ac506fa75 100644 --- a/fastplotlib/legends/legend.py +++ b/fastplotlib/legends/legend.py @@ -55,11 +55,25 @@ def __init__( "Must specify `label` or Graphic must have a `name` to auto-use as the label" ) - # for now only support lines with a single color - if np.unique(graphic.colors.value, axis=0).shape[0] > 1: + # # for now only support lines with a single color + # if np.unique(graphic.colors.value, axis=0).shape[0] > 1: + # raise ValueError("Use colorbars for multi-colored lines, not legends") + + # color = pygfx.Color(np.unique(graphic.colors.value, axis=0).ravel()) + + # handle both per-vertex and uniform colors + col = graphic.colors + if hasattr(col, "value"): + vals = col.value + else: + # uniform_color=True → a single pygfx.Color + vals = np.array([[col.r, col.g, col.b, col.a]], dtype=float) + + if np.unique(vals, axis=0).shape[0] > 1: raise ValueError("Use colorbars for multi-colored lines, not legends") - color = pygfx.Color(np.unique(graphic.colors.value, axis=0).ravel()) + # pick the unique RGBA row and wrap in a pygfx.Color + color = pygfx.Color(np.unique(vals, axis=0).ravel()) self._parent = parent @@ -68,30 +82,25 @@ def __init__( graphic.colors.add_event_handler(self._update_color) # construct Line WorldObject - data = np.array([[0, 0, 0], [3, 0, 0]], dtype=np.float32) + data = np.array([[0, 0, 0], [5, 0, 0]], dtype=np.float32) material = pygfx.LineMaterial self._line_world_object = pygfx.Line( geometry=pygfx.Geometry(positions=data), - material=material(thickness=8, color=self._color), + material=material(thickness=4, color=self._color), ) - # self._line_world_object.world.x = position[0] - self._label_world_object = pygfx.Text( - geometry=pygfx.TextGeometry( - text=str(label), - font_size=6, - screen_space=False, - anchor="middle-left", - ), - material=pygfx.TextMaterial( - color="w", - outline_color="w", - outline_thickness=0, - ), + str(label), + font_size=6, + screen_space=False, + anchor="middle-left", ) + mat = self._label_world_object.material + mat.color = color + mat.outline_color = (0, 0, 0, 1) # or whatever you like + mat.outline_thickness = 0 self.world_object = pygfx.Group() self.world_object.add(self._line_world_object, self._label_world_object) @@ -126,9 +135,18 @@ def _update_color(self, ev: GraphicFeatureEvent): self._color = new_color[0] self._line_world_object.material.color = pygfx.Color(self._color) + # def _highlight_graphic(self, graphic: Graphic, ev): + # graphic_color = pygfx.Color(np.unique(graphic.colors.value, axis=0).ravel()) + def _highlight_graphic(self, graphic: Graphic, ev): - graphic_color = pygfx.Color(np.unique(graphic.colors.value, axis=0).ravel()) + # same fallback for uniform vs. per-vertex colors + col = graphic.colors + if hasattr(col, "value"): + vals = col.value + else: + vals = np.array([[col.r, col.g, col.b, col.a]], dtype=float) + graphic_color = pygfx.Color(np.unique(vals, axis=0).ravel()) if graphic_color == self._parent.highlight_color: graphic.colors = self._color else: @@ -142,6 +160,7 @@ class Legend(Graphic): def __init__( self, plot_area, + background_color: str | tuple | np.ndarray = (0.1, 0.1, 0.1, 1.0), highlight_color: str | tuple | np.ndarray = "w", max_rows: int = 5, *args, @@ -154,6 +173,9 @@ def __init__( plot_area: Union[Plot, Subplot, Dock] plot area to put the legend in + background_color: Union[str, tuple, np.ndarray], default (0.1, 0.1, 0.1, 1.0) + highlight color + highlight_color: Union[str, tuple, np.ndarray], default "w" highlight color @@ -172,10 +194,11 @@ def __init__( self._legend_items_group = pygfx.Group() self._set_world_object(group) + w, h = (30, 10) self._mesh = pygfx.Mesh( - pygfx.box_geometry(50, 10, 1), + pygfx.box_geometry(w, h, 1), pygfx.MeshBasicMaterial( - color=pygfx.Color([0.1, 0.1, 0.1, 1]), wireframe_thickness=10 + color=pygfx.Color(background_color), wireframe_thickness=10 ), ) @@ -204,6 +227,8 @@ def __init__( self._row_counter = 0 self._col_counter = 0 + self._padding = 3 + def graphics(self) -> tuple[Graphic, ...]: return tuple(self._graphics) @@ -223,41 +248,31 @@ def add_graphic(self, graphic: Graphic, label: str = None): self._check_label_unique(label) - new_col_ix = self._col_counter - new_row_ix = self._row_counter + # Prepare column and row indices and positions + col_idx = self._col_counter + row_idx = self._row_counter - x_pos = 0 - y_pos = 0 + if row_idx >= self._max_rows: + # Start new column + col_idx += 1 - if self._row_counter == self._max_rows: - # set counters - new_col_ix = self._col_counter + 1 - - # get x position offset for this new column of LegendItems - # start by getting the LegendItems in the previous column - prev_column_items: list[LegendItem] = list(self._items.values())[ - -self._max_rows : - ] - # x position of LegendItems in previous column - x_pos = prev_column_items[-1].world_object.world.x + # Get last column's items + prev_items = list(self._items.values())[-self._max_rows :] + # Compute maximum width of that column max_width = 0 - # get width of widest LegendItem in previous column to add to x_pos offset for this column - for item in prev_column_items: + for item in prev_items: bbox = item.world_object.get_world_bounding_box() - width, height, depth = np.ptp(bbox, axis=0) - max_width = max(max_width, width) - - # x position offset for this new column - x_pos = x_pos + max_width + 15 # add 15 for spacing + w, *_ = np.ptp(bbox, axis=0) + max_width = max(max_width, w) - # rest row index for next iteration - new_row_ix = 1 + x_pos = max_width + 15 # shift new column by that width + spacing + y_pos = 0 + row_idx = 1 else: - if len(self._items) > 0: - x_pos = list(self._items.values())[-1].world_object.world.x - - y_pos = new_row_ix * -10 - new_row_ix = self._row_counter + 1 + # Same column: flush to left + x_pos = 0 + y_pos = -row_idx * 10 # each row is 10px down + row_idx += 1 if isinstance(graphic, LineGraphic): legend_item = LineLegendItem(self, graphic, label, position=(x_pos, y_pos)) @@ -272,19 +287,74 @@ def add_graphic(self, graphic: Graphic, label: str = None): graphic.add_event_handler(partial(self.remove_graphic, graphic), "deleted") - self._col_counter = new_col_ix - self._row_counter = new_row_ix + self._col_counter = col_idx + self._row_counter = row_idx def _reset_mesh_dims(self): + + # Bounding box of all legend items bbox = self._legend_items_group.get_world_bounding_box() + (left, bottom, _), (right, top, _) = bbox + # width, height, _ = np.ptp(bbox, axis=0) + + pos = self._mesh.geometry.positions.data + pos[mesh_masks.x_left] = left - self._padding + pos[mesh_masks.x_right] = right + self._padding + pos[mesh_masks.y_bottom] = bottom - self._padding + pos[mesh_masks.y_top] = top + self._padding + self._mesh.geometry.positions.update_range() + + def update_using_camera(self): + """ + Update the legend position and scale using the camera. + This only works if legend is in a Dock, not a Plot or Subplot. + """ - width, height, _ = np.ptp(bbox, axis=0) + # Update Scaling - self._mesh.geometry.positions.data[mesh_masks.x_right] = width + 7 - self._mesh.geometry.positions.data[mesh_masks.x_left] = -5 - self._mesh.geometry.positions.data[mesh_masks.y_bottom] = 0 - self._mesh.geometry.positions.data[mesh_masks.y_bottom] = -height - 3 - self._mesh.geometry.positions.update_range() + # Legend bounding box + # (legend_left, legend_bottom, _), (legend_right, legend_top, _) = \ + # self._legend_items_group.get_world_bounding_box() + # legend_w = legend_right - legend_left + # legend_h = legend_top - legend_bottom + + # Panel bounding box + (panel_left, panel_bottom, _), (panel_right, panel_top, _) = ( + self.world_object.get_world_bounding_box() + ) + + panel_w = panel_right - panel_left + panel_h = panel_top - panel_bottom + + # Camera bounding box + dock = self._plot_area + state = dock.camera.get_state() + cam_w, cam_h = state["width"], state["height"] + cam_x, cam_y = state["position"][:2] + + # Scale the legend to fit the camera + # Panel dimensions work better + scale_x = cam_w / panel_w + scale_y = cam_h / panel_h + scale = min(scale_x, scale_y) + tf = self.world_object.local + tf.scale_x *= scale + tf.scale_y *= scale + + # Used to figure out scaling and position: + # print(self._legend_items_group.get_world_bounding_box()) + # print(self._mesh.get_world_bounding_box()) + # print(self.world_object.get_world_bounding_box()) + # print(cam_w, cam_h) + # print(scale_x, scale_y, scale) + + # Update Position + + # This was hand tuned as the mix of dock size/borders, mesh padding, and legend size + # did not yield a simple formula + wobj = self.world_object.world + wobj.x = cam_x + panel_left + 6 * scale # hand tuned + wobj.y = cam_y + panel_top def remove_graphic(self, graphic: Graphic): self._graphics.remove(graphic)