# -*- coding: utf-8 -*-
#
# Licensed under the terms of the BSD 3-Clause
# (see plotpy/LICENSE for details)
# pylint: disable=C0103
from __future__ import annotations
from sys import maxsize
from typing import TYPE_CHECKING
import numpy as np
from guidata.configtools import get_icon
from guidata.dataset import update_dataset
from guidata.utils.misc import assert_interfaces_valid
from qtpy import QtCore as QC
from qtpy import QtGui as QG
from qwt import QwtPlotItem
from plotpy.config import _
from plotpy.interfaces import IBasePlotItem, ISerializableType, ITrackableItemType
from plotpy.styles import PolygonMapParam
if TYPE_CHECKING:
import guidata.io
from qtpy.QtCore import QPointF
from qwt import QwtScaleMap
from plotpy.interfaces import IItemType
from plotpy.styles.base import ItemParameters
def simplify_poly(pts, off, scale, bounds):
"""Simplify a polygon by removing points outside the canvas"""
ax, bx, ay, by = scale
a = np.array([[ax, ay]])
b = np.array([[bx, by]])
_pts = a * pts + b
poly = []
NP = off.shape[0]
for i in range(off.shape[0]):
i0 = off[i, 1]
if i + 1 < NP:
i1 = off[i + 1, 1]
else:
i1 = pts.shape[0]
poly.append((_pts[i0:i1], i))
return poly
class PolygonMapItem(QwtPlotItem):
"""Construct a PolygonMapItem object
Args:
param: Polygon parameters
"""
__implements__ = (IBasePlotItem, ISerializableType)
_readonly = False
_private = False
_can_select = False
_can_resize = False
_can_move = False
_can_rotate = False
def __init__(self, param: PolygonMapParam | None = None) -> None:
super().__init__()
if param is None:
self.param = PolygonMapParam(_("PolygonMap"), icon="polygonmap.png")
else:
self.param = param
self.selected = False
self.immutable = True # set to false to allow moving points around
self._pts = None # Array of points Mx2
self._n = None # Array of polygon offsets/ends Nx1
# (polygon k points are _pts[_n[k-1]:_n[k]])
self._c = None # Color of polygon Nx2 [border,background] as RGBA uint32
self.update_params()
self.setIcon(get_icon("polygonmap.png"))
def types(self) -> tuple[type[IItemType], ...]:
"""Returns a group or category for this item.
This should be a tuple of class objects inheriting from IItemType
Returns:
tuple: Tuple of class objects inheriting from IItemType
"""
return (ITrackableItemType, ISerializableType)
def can_select(self) -> bool:
"""
Returns True if this item can be selected
Returns:
bool: True if item can be selected, False otherwise
"""
return self._can_select
def can_resize(self) -> bool:
"""
Returns True if this item can be resized
Returns:
bool: True if item can be resized, False otherwise
"""
return self._can_resize
def can_rotate(self) -> bool:
"""
Returns True if this item can be rotated
Returns:
bool: True if item can be rotated, False otherwise
"""
return self._can_rotate
def can_move(self) -> bool:
"""
Returns True if this item can be moved
Returns:
bool: True if item can be moved, False otherwise
"""
return self._can_move
def set_selectable(self, state: bool) -> None:
"""Set item selectable state
Args:
state: True if item is selectable, False otherwise
"""
self._can_select = state
def set_resizable(self, state: bool) -> None:
"""Set item resizable state
(or any action triggered when moving an handle, e.g. rotation)
Args:
state: True if item is resizable, False otherwise
"""
self._can_resize = state
def set_movable(self, state: bool) -> None:
"""Set item movable state
Args:
state: True if item is movable, False otherwise
"""
self._can_move = state
def set_rotatable(self, state: bool) -> None:
"""Set item rotatable state
Args:
state: True if item is rotatable, False otherwise
"""
self._can_rotate = state
def __reduce__(self):
state = (self.param, self._pts, self._n, self._c, self.z())
res = (PolygonMapItem, (), state)
return res
def __setstate__(self, state):
param, pts, n, c, z = state
self.param = param
self.set_data(pts, n, c)
self.setZ(z)
self.update_params()
def serialize(
self,
writer: guidata.io.HDF5Writer | guidata.io.INIWriter | guidata.io.JSONWriter,
) -> None:
"""Serialize object to HDF5 writer
Args:
writer: HDF5, INI or JSON writer
"""
writer.write(self._pts, group_name="Pdata")
writer.write(self._n, group_name="Ndata")
writer.write(self._c, group_name="Cdata")
writer.write(self.z(), group_name="z")
self.param.update_param(self)
writer.write(self.param, group_name="param")
def deserialize(
self,
reader: guidata.io.HDF5Reader | guidata.io.INIReader | guidata.io.JSONReader,
) -> None:
"""Deserialize object from HDF5 reader
Args:
reader: HDF5, INI or JSON reader
"""
pts = reader.read(group_name="Pdata", func=reader.read_array)
n = reader.read(group_name="Ndata", func=reader.read_array)
c = reader.read(group_name="Cdata", func=reader.read_array)
self.set_data(pts, n, c)
self.setZ(reader.read("z"))
self.param = PolygonMapParam(_("PolygonMap"), icon="polygonmap.png")
reader.read("param", instance=self.param)
self.update_params()
def set_readonly(self, state: bool) -> None:
"""Set object readonly state
Args:
state: True if object is readonly, False otherwise
"""
self._readonly = state
def is_readonly(self) -> bool:
"""Return object readonly state
Returns:
bool: True if object is readonly, False otherwise
"""
return self._readonly
def set_private(self, state: bool) -> None:
"""Set object as private
Args:
state: True if object is private, False otherwise
"""
self._private = state
def is_private(self) -> bool:
"""Return True if object is private
Returns:
bool: True if object is private, False otherwise
"""
return self._private
def invalidate_plot(self) -> None:
"""Invalidate the plot to force a redraw"""
plot = self.plot()
if plot is not None:
plot.invalidate()
def select(self) -> None:
"""
Select the object and eventually change its appearance to highlight the
fact that it's selected
"""
def unselect(self) -> None:
"""
Unselect the object and eventually restore its original appearance to
highlight the fact that it's not selected anymore
"""
def get_data(self):
"""Return curve data x, y (NumPy arrays)"""
return self._pts, self._n, self._c
def set_data(self, pts, n, c):
"""
Set curve data:
* x: NumPy array
* y: NumPy array
"""
self._pts = np.asarray(pts)
self._n = np.asarray(n)
self._c = np.asarray(c)
xmin, ymin = self._pts.min(axis=0)
xmax, ymax = self._pts.max(axis=0)
self.bounds = QC.QRectF(xmin, ymin, xmax - xmin, ymax - ymin)
def is_empty(self) -> bool:
"""Return True if the item is empty
Returns:
True if the item is empty, False otherwise
"""
return self._pts is None or self._pts.size == 0
def hit_test(self, pos: QPointF) -> tuple[float, float, bool, None]:
"""Return a tuple (distance, attach point, inside, other_object)
Args:
pos: Position
Returns:
tuple: Tuple with four elements: (distance, attach point, inside,
other_object).
Description of the returned values:
* distance: distance in pixels (canvas coordinates) to the closest
attach point
* attach point: handle of the attach point
* inside: True if the mouse button has been clicked inside the object
* other_object: if not None, reference of the object which will be
considered as hit instead of self
"""
if self.is_empty():
return maxsize, 0, False, None
# TODO: Implement PolygonMapItem.hit_test
return maxsize, 0, False, None
def get_closest_coordinates(self, x: float, y: float) -> tuple[float, float]:
"""
Get the closest coordinates to the given point
Args:
x: X coordinate
y: Y coordinate
Returns:
tuple[float, float]: Closest coordinates
"""
# TODO: Implement PolygonMapItem.get_closest_coordinates
return x, y
def get_coordinates_label(self, x: float, y: float) -> str:
"""
Get the coordinates label for the given coordinates
Args:
x: X coordinate
y: Y coordinate
Returns:
str: Coordinates label
"""
title = self.title().text()
return f"{title}:
x = {x:f}
y = {y:f}"
def move_local_point_to(self, handle: int, pos: QPointF, ctrl: bool = None) -> None:
"""Move a handle as returned by hit_test to the new position
Args:
handle: Handle
pos: Position
ctrl: True if button is being pressed, False otherwise
"""
def move_local_shape(self, old_pos: QPointF, new_pos: QPointF) -> None:
"""Translate the shape such that old_pos becomes new_pos in canvas coordinates
Args:
old_pos: Old position
new_pos: New position
"""
def move_with_selection(self, delta_x: float, delta_y: float) -> None:
"""Translate the item together with other selected items
Args:
delta_x: Translation in plot coordinates along x-axis
delta_y: Translation in plot coordinates along y-axis
"""
def update_params(self) -> None:
"""Update object properties from item parameters (dataset)"""
self.param.update_item(self)
if self.selected:
self.select()
def update_item_parameters(self) -> None:
"""Update item parameters (dataset) from object properties"""
self.param.update_param(self)
def get_item_parameters(self, itemparams: ItemParameters) -> None:
"""
Appends datasets to the list of DataSets describing the parameters
used to customize apearance of this item
Args:
itemparams: Item parameters
"""
itemparams.add("CurveParam", self, self.param)
def set_item_parameters(self, itemparams: ItemParameters) -> None:
"""
Change the appearance of this item according
to the parameter set provided
Args:
itemparams: Item parameters
"""
update_dataset(self.param, itemparams.get("PolygonMapParam"), visible_only=True)
self.update_params()
def draw(
self,
painter: QG.QPainter,
xMap: QwtScaleMap,
yMap: QwtScaleMap,
canvasRect: QC.QRectF,
) -> None:
"""Draw the item
Args:
painter: Painter
xMap: X axis scale map
yMap: Y axis scale map
canvasRect: Canvas rectangle
"""
p1x = xMap.p1()
s1x = xMap.s1()
ax = (xMap.p2() - p1x) / (xMap.s2() - s1x)
p1y = yMap.p1()
s1y = yMap.s1()
ay = (yMap.p2() - p1y) / (yMap.s2() - s1y)
bx, by = p1x - s1x * ax, p1y - s1y * ay
_c = self._c
_n = self._n
fgcol = QG.QColor()
bgcol = QG.QColor()
polygons = simplify_poly(
self._pts, _n, (ax, bx, ay, by), canvasRect.getCoords()
)
for poly, num in polygons:
points = []
for i in range(poly.shape[0]):
points.append(QC.QPointF(poly[i, 0], poly[i, 1]))
pg = QG.QPolygonF(points)
fgcol.setRgba(int(_c[num, 0]))
bgcol.setRgba(int(_c[num, 1]))
painter.setPen(QG.QPen(fgcol))
painter.setBrush(QG.QBrush(bgcol))
painter.drawPolygon(pg)
def boundingRect(self) -> QC.QRectF:
"""Return the bounding rectangle of the shape
Returns:
Bounding rectangle of the shape
"""
return self.bounds
assert_interfaces_valid(PolygonMapItem)