The sequel to clusterview. Built around the point and cluster structure of the kmeans project, aims to improve upon the design and structural weakness of clusterview and add many interesting interactive ways to explore kmeans.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

395 lines
11 KiB

from math import floor
from kmeans.clustering.point import Point as BasePoint
from clusterview2.colors import Color
from clusterview2.exceptions import ExceededWindowBoundsError
class Point(BasePoint):
"""
A class representing a point. A point
has a point_size bounding box around
it.
"""
def __init__(self, x, y, color, point_size,
viewport_width, viewport_height, weight=1.0):
"""
Initializes a new point with a point_size bounding box, viewport
awareness, and a color.
Initialized with additional viewport data to make sure the
move function refuses to move a point outside the screen.
@param x The x-coordinate.
@param y The y-coordinate.
@param color The color of the point.
@param point_size The size of the point in pixels.
@param viewport_width The width of the viewport.
@param viewport_height The height of the viewport.
"""
if not isinstance(color, Color):
raise ValueError("Point must be initialized with a color of " +
"type Color.")
self._point_size = point_size
# Unfortunately, it appears decorated property methods are not
# inheirited and instead of redoing everything we will just repeat
# the properties here.
self._x = x
self._y = y
self._cluster = None
self._weight = weight
self._color = color
self._viewport_width = viewport_width
self._viewport_height = viewport_height
self._calculate_hitbox()
self._check_window_bounds(x, y)
self._selected = False
self._attributes = []
@property
def x(self):
return self._x
@property
def y(self):
return self._y
@property
def weight(self):
return self._weight
@weight.setter
def weight(self, weight):
self._weight = weight
@property
def cluster(self):
return self._cluster
@cluster.setter
def cluster(self, cluster):
self._cluster = cluster
@property
def point_size(self):
return self._point_size
@property
def selected(self):
return self._selected
@property
def color(self):
return self._color
@color.setter
def color(self, color):
if not isinstance(color, Color):
raise ValueError('Point color must be of type Color.')
self._color = color
@property
def attributes(self):
return self._attributes
def add_attribute(self, attr):
self._attributes.append(attr)
def _calculate_hitbox(self):
"""
Calculates the hit box for the point given the current
position (center) and the point size.
"""
half_point = floor(self.point_size / 2.0)
self._top_left_corner = (self._x - half_point,
self._y + half_point)
self._bottom_right_corner = (self._x + half_point,
self._y - half_point)
def _check_window_bounds(self, x, y):
"""
Simple window bound check that raises an exception when
the point (x, y) exceeds the known viewport bounds.
@param x The x-coordinate under test.
@param y The y-coordinate under test.
@raises ExceededWindowBoundsError If the viewport bounds are exceeded.
"""
half_point = floor(self.point_size / 2.0)
# Screen size in pixels is always positive
# We need to include the half point here because
# the (x, y) for a point is the center of the square and we
# do not want the EDGES to exceed the viewport bounds.
if (x > self._viewport_width - half_point or
y > self._viewport_height - half_point or
x < half_point or
y < half_point):
raise ExceededWindowBoundsError
def move(self, dx, dy):
"""
Adds the deltas dx and dy to the point.
@param dx The delta in the x direction.
@param dy The delta in the y direction.
"""
self._check_window_bounds(self._x + dx, self._y + dy)
self._x += dx
self._y += dy
# It's important to note as we move the point we need to
# make sure we are constantly updating it's hitbox.
self._calculate_hitbox()
def __eq__(self, other):
"""
Override for class equality.
@param other The other object.
"""
return (self._x == other.x and
self._y == other.y and
self._color == other.color and
self._attributes == other.attributes and
self._point_size == other.point_size)
def __repr__(self):
# For some reason I had to split this instead of using one giant
# string chained with `+` inside of `()`.
s = "<POINT "
s += f"X: {self._x} | Y: {self._y} | "
s += f"SIZE: {self._point_size} | "
s += f"COLOR: {self._color} | "
s += f"WEIGHT: {self._weight} | "
s += f"VIEWPORT_WIDTH: {self._viewport_width} | "
s += f"VIEWPORT_HEIGHT: {self._viewport_height}"
s += ">"
return s
def select(self):
"""
Selects the point.
"""
self._selected = True
def unselect(self):
"""
Unselects the point.
"""
self._selected = False
def hit(self, x, y):
"""
Determines if the point was hit inside of it's bounding box.
The condition for hit is simple - consider the following
bounding box:
-------------
| |
| (x,y) |
| |
-------------
Where the clicked location is in the center. Then the top
left corner is defined as (x - half_point_size, y + half_point_size)
and the bottom corner is (x + half_point_size, y - half_point_size)
So long as x and y are greater than the top left and less than the
top right it is considered a hit.
This function is necessary for properly deleting and selecting points.
"""
return (x >= self._top_left_corner[0] and
x <= self._bottom_right_corner[0] and
y <= self._top_left_corner[1] and
y >= self._bottom_right_corner[1])
class Attribute:
def __init__(self, name, value):
"""
Initializes an attribute.
"""
self._name = name
self._value = value
class PointSet:
"""
Useful container for points. Since points are not hashable (they are
modified in place by move) we are forced to back the PointSet with an
array. However, it is still a "set" in the "uniqueness among all points"
sense because `add_point` will reject a point with a duplicate center.
"""
def __init__(self, point_size, viewport_width, viewport_height):
"""
Initializes a point container with points of size point_size.
@param point_size The size of the points.
@param viewport_width The width of the viewport for bounds
calculations.
@param viewport_height The height of the viewport for bounds
calculations.
"""
self._points = []
self._point_size = point_size
self._viewport_width = viewport_width
self._viewport_height = viewport_height
def __eq__(self, other):
other_points = list(other.points)
return (self._points == other_points and
self._point_size == other.point_size and
self._viewport_width == other.viewport_width and
self._viewport_height == other.viewport_height)
def __repr__(self):
s = []
for p in self._points:
s.append(str(p))
return ",".join(s)
def clear(self):
self._points = []
@property
def points(self):
"""
Getter for points. Returns a generator for
looping.
"""
for point in self._points:
yield point
def clear_points(self):
self._points = []
@property
def point_size(self):
return self._point_size
@property
def viewport_height(self):
return self._viewport_height
@property
def viewport_width(self):
return self._viewport_width
@viewport_height.setter
def viewport_height(self, height):
self._viewport_height = height
@viewport_width.setter
def viewport_width(self, width):
self._viewport_width = width
def empty(self):
return len(self._points) == 0
def clear_selection(self):
"""
Handy helper function to clear all selected points.
"""
for p in self._points:
p.unselect()
def add_point(self, x, y, color, weight=1.0, attrs=[]):
"""
Adds a point in screen coordinates and an optional attribute to
the list.
@param x The x-coordinate.
@param y The y-coordinate.
@param color The color of the point.
@param weight The point weight.
@param attr An optional attribute.
@raises ExceededWindowBoundsError If the point could not be constructed
because it would be outside the
window bounds.
"""
if attrs != [] and not all(isinstance(x, Attribute) for x in attrs):
raise ValueError("Attributes in add_point must be an " +
"attribute array.")
if not isinstance(color, Color):
raise ValueError("Point color must be a Color enum.")
if not isinstance(weight, float):
raise ValueError("Point weight must be a float.")
point = Point(x, y, color, self._point_size,
self._viewport_width, self._viewport_height, weight)
for attr in attrs:
point.add_attribute(attr)
if point in self._points:
# Silently reject a duplicate point (same center).
return
self._points.append(point)
def remove_point(self, x, y):
"""
Removes a point from the point set based on a bounding
box calculation.
Removing a point is an exercise is determining which points
have been hit, and then pulling them out of the list.
If two points have a section overlapping, and the user clicks
the overlapped section, both points will be removed.
Currently O(n).
@param x The x-coordinate.
@param y The y-coordinate.
"""
for p in self._points:
if p.hit(x, y):
self._points.remove(p)
def groups(self):
"""
Returns a map from color to point representing each point's group
membership based on color.
"""
g = {}
for p in self._points:
if p.color not in g:
# Create the key for the group color since it does
# not exist.
g[p.color] = []
g[p.color].append(p)
return g