Added actual center-of-mass calculation for territories.
[pyrisk.git] / pyrisk / graphics.py
index 646183440a308ba1d25896064f4e4b20dd1001b5..7085eb34ad45ce3d1b9611ec05b2288ac7a398b4 100644 (file)
@@ -20,6 +20,7 @@ Creates SVG files by hand.  See the TemplateLibrary class for a
 description of map specification format.
 """
 
+import math
 import operator
 import os
 import os.path
@@ -31,11 +32,12 @@ class Template (NameMixin):
     """Setup regions for a particular world.
     """
     def __init__(self, name, regions, continent_colors={},
-                 line_colors={}):
+                 line_colors={}, player_colors=[]):
         NameMixin.__init__(self, name)
         self.regions = regions
         self.continent_colors = continent_colors
         self.line_colors = line_colors
+        self.player_colors = player_colors
 
 class TemplateLibrary (object):
     """Create Templates on demand from a directory of template data.
@@ -45,10 +47,12 @@ class TemplateLibrary (object):
     def __init__(self, template_dir='share/templates/'):
         self.template_dir = os.path.abspath(os.path.expanduser(template_dir))
     def get(self, name):
-        region_pointlists,route_pointlists,continent_colors,line_colors = \
+        region_pointlists,route_pointlists,continent_colors,line_colors, \
+            player_colors = \
             self._get_data(name)
         regions = self._generate_regions(region_pointlists, route_pointlists)
-        return Template('template', regions, continent_colors, line_colors)
+        return Template(name, regions, continent_colors, line_colors, 
+                        player_colors)
     def _get_data(self, name):
         dirname = os.path.join(self.template_dir, name.lower())
         try:
@@ -69,11 +73,15 @@ class TemplateLibrary (object):
                 c = self._read_colors(path)
                 if name == 'continent':
                     continent_colors = c
-                else:
-                    assert name == 'line', name
+                elif name == 'line':
                     line_colors = c
+                else:
+                    assert name == 'player', name
+                    player_colors = []
+                    for k,v in sorted(c.items()):
+                        player_colors.append(v)
         return (region_pointlists, route_pointlists,
-                continent_colors, line_colors)
+                continent_colors, line_colors, player_colors)
     def _read_pointlist(self, filename):
         pointlist = []
         for line in open(filename, 'r'):
@@ -91,16 +99,19 @@ class TemplateLibrary (object):
             pointlist.append((x,y,label))
         return pointlist
     def _read_colors(self, filename):
-        colors = {}
+        colors = {'_attributes': {}}
         for line in open(filename, 'r'):
             line = line.strip()
             if len(line) == 0:
                 continue
             fields = line.split('\t')
-            name,color = [x.strip() for x in fields]
-            if color == '-':
-                color = None
-            colors[name] = color
+            name,value = [x.strip() for x in fields]
+            if value == '-':
+                value = None
+            if name.startswith('_'): # attribute setting
+                colors['_attributes'][name[1:]] = value
+            else: # continent color
+                colors[name] = value
         return colors
     def _generate_regions(self, region_pointlists, route_pointlists):
         regions = []
@@ -192,7 +203,7 @@ TEMPLATE_LIBRARY = TemplateLibrary()
 
 
 class Vector (tuple):
-    """Simple vector addition and subtraction.
+    """Simple cartesian vector operations.
 
     >>> v = Vector
     >>> a = v((0, 0))
@@ -206,6 +217,10 @@ class Vector (tuple):
     (-1, -2)
     >>> -c
     (-2, -3)
+    >>> b*0.5
+    (0.5, 0.5)
+    >>> c*(1, -1)
+    (2, -3)
     >>> a < b
     True
     >>> c > b
@@ -224,6 +239,16 @@ class Vector (tuple):
         new = self.__class__(map(operator.neg, self))
         self._set_name(new)
         return new
+    def mag(self):
+        """Return the magnitude.
+        """
+        return math.sqrt(sum([x**2 for x in self]))
+    def angle(self):
+        """Return the direction (must be in 2D).
+        """
+        if len(self) != 2:
+            raise ValueError('length != 2, %s' % (self))
+        return math.atan2(self[1], self[0])
     def __add__(self, other):
         if len(self) != len(other):
             raise ValueError('length missmatch %s, %s' % (self, other))
@@ -237,10 +262,48 @@ class Vector (tuple):
         self._set_name(new, other)
         return new
     def __mul__(self, other):
+        """Return the elementwise product (with vectors) or scalar
+        product (with numbers).
+        """
+        if hasattr(other, '__iter__'): # dot product
+            if len(self) != len(other):
+                raise ValueError('length missmatch %s, %s' % (self, other))
+            new = self.__class__([s*o for s,o in zip(self, other)])
+            #map(operator.mul, self, other))
+        else: # scalar product
+            new = self.__class__([x*other for x in self])
+        self._set_name(new, other)
+        return new
+    def triple_angle(self, previous, next):
+        """Return the angle between (previous->self) and (self->next)
+        in radians.
+        """
+        angle = (next-self).angle() - (self-previous).angle()
+        while angle > math.pi:
+            angle -= 2*math.pi
+        while angle < -math.pi:
+            angle += 2*math.pi
+        return angle
+    def cross(self, other):
+        """Return the cross product.
+        
+        In 2D, just return the z component of the result.
+        """
         if len(self) != len(other):
             raise ValueError('length missmatch %s, %s' % (self, other))
-        new = self.__class__(map(operator.mul, self, other))
-        self._set_name(new, other)
+        if len(self) not in [2,3]:
+            raise ValueError('cross product not defined in %dD, %s, %s'
+                             % (len(self), self, other))
+        if len(self) == 2:
+            s = self.__class__((self[0], self[1], 0))
+            o = self.__class__((other[0], other[1], 0))
+        else:
+            s = self; o = other
+        new = self.__class__((s[1]*o[2]-s[2]*o[1],
+                              -s[0]*o[2]-s[2]*o[0],
+                              s[0]*o[1]-s[1]*o[0]))
+        if len(self) == 2:
+            return new[2]
         return new
 
 def nameless(vector):
@@ -279,20 +342,27 @@ class Route (ID_CmpMixin):
 class Region (NameMixin, ID_CmpMixin, list):
     """Contains a list of boundaries and a label.
 
-    Regions can be Territories, sections of ocean, etc.
+    Regions can be Territories or pieces of Territories (e.g separate
+    islands, such as in Indonesia).
+
+    Boundaries must be entered counter clockwise (otherwise you
+    will get an error).
 
     >>> r = Region('Earth',
-    ...            [Boundary([(0,0), (0,1)]),
-    ...             Boundary([(0,0), (1,0)]),
-    ...             Boundary([(0,0), (0,1)]),
-    ...             Boundary([(0,0), (1,0)])],
-    ...            [True, True, False, False],
-    ...            (0.5, 0.5))
+    ...            [Boundary([(0,0), (10,0)]),
+    ...             Boundary([(0,0), (0,10)]),
+    ...             Boundary([(0,0), (10,0)]),
+    ...             Boundary([(0,0), (0,10)])],
+    ...            [True, True, False, False])
     >>> r.outline
-    [(0, 0), (0, 1), (1, 1), (1, 0), (0, 0)]
+    [(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)]
+    >>> r.area()
+    100.0
+    >>> r.center_of_mass()
+    (5, 5)
     """
     def __init__(self, name, boundaries, head_to_tail, routes=None,
-                 route_head_to_tail=None, label_offset=(0,0)):
+                 route_head_to_tail=None):
         NameMixin.__init__(self, name)
         list.__init__(self, boundaries)
         ID_CmpMixin.__init__(self)
@@ -306,7 +376,6 @@ class Region (NameMixin, ID_CmpMixin, list):
             self.routes = []
             self.route_head_to_tail = []
         self.route_starts = [] # set by .locate_routes
-        self.label_offset = Vector(label_offset)
         self.generate_outline() # sets .outline, .starts
         self.x_min = min([b.x_min+s[0] for b,s in zip(self, self.starts)])
         self.x_max = max([b.x_max+s[0] for b,s in zip(self, self.starts)])
@@ -333,8 +402,13 @@ class Region (NameMixin, ID_CmpMixin, list):
             self.starts.append(pos)
             for p in new:
                 points.append(pos+p)
-        assert points[-1] == points[0], '%s: %s' % (self, points)
+        assert points[-1] == points[0], '%s: %s (not closed)' % (self, points)
         self.outline = points
+        total_angle = 0
+        for prev,p,next in self._unique_triples():
+            total_angle += p.triple_angle(prev, next)
+        assert abs(total_angle-2*math.pi) < 0.1, \
+            "%s: %s (not CCW: %.2f)" % (self, points, total_angle)
     def locate_routes(self):
         self.route_starts = []
         for route,htt in zip(self.routes, self.route_head_to_tail):
@@ -346,21 +420,94 @@ class Region (NameMixin, ID_CmpMixin, list):
                 if hasattr(point, 'name') and point.name == anchor.name:
                     self.route_starts.append(point-nameless(anchor))
                     break
+    def area(self):
+        """Return the region's enclosed area as a float.
+        """
+        return sum([self._triangle_area(a,b,c) for a,b,c in self._triangles()])
+    def center_of_mass(self):
+        """Return a vector locating the region's center of mass.
+
+        Truncated to the lower-left pixel.
+        """
+        cas = []
+        for a,b,c in self._triangles():
+            cas.append((self._triangle_center_of_mass(a,b,c),
+                        self._triangle_area(a,b,c)))
+        m = sum([a for c,a in cas])
+        average = Vector((int(sum([c[0]*a for c,a in cas])/m),
+                          int(sum([c[1]*a for c,a in cas])/m)))
+        return average
+    def _unique_triples(self):
+        """Iterate through consecutive triples (prev, p, next)
+        for every unique point in self.outline.
+        """
+        unique = self.outline[:-1]
+        assert len(unique) >= 3, \
+            '%s: %s (< 3 unique points)' % (self, self.outline)
+        for i,p in enumerate(unique):
+            prev = unique[i-1]
+            next = unique[(i+1)%len(unique)]
+            yield (prev, p, next)
+    def _triangles(self):
+        """Iterate through CCW triangles composing the region.
+
+        >>> r = Region('Earth',
+        ...            [Boundary([(0,0),(1,-1),(0,3),(-1,0),(0,0)])],
+        ...            [True])
+        >>> for triangle in r._triangles():
+        ...     print triangle
+        ((0, 0), (1, -1), (0, 3))
+        ((-1, 0), (0, 0), (0, 3))
+        """
+        points = self.outline[:-1]
+        while len(points) >= 3:
+            for i,p in enumerate(points):
+                prev = points[i-1]
+                next = points[(i+1)%len(points)]
+                if p.triple_angle(prev, next) > 0:
+                    break # found first CCW triangle
+            points.remove(p) # drop the outer point
+            yield (prev, p, next)
+    def _triangle_area(self, a, b, c):
+        """
+        >>> r = Region('Earth',
+        ...            [Boundary([(0,0), (1,0), (1,1), (0,0)])],
+        ...            [True])
+        >>> triangle = r.outline[:3]
+        >>> triangle
+        [(0, 0), (1, 0), (1, 1)]
+        >>> r._triangle_area(*triangle)
+        0.5
+        """
+        return abs(0.5 * (b-a).cross(c-a))
+    def _triangle_center_of_mass(self, a, b, c):
+        """
+        >>> r = Region('Earth',
+        ...            [Boundary([(0,0), (1,0.3), (0.5,1.2), (0,0)])],
+        ...            [True])
+        >>> triangle = r.outline[:3]
+        >>> ['(%.2f, %.2f)' % x for x in triangle]
+        ['(0.00, 0.00)', '(1.00, 0.30)', '(0.50, 1.20)']
+        >>> r._triangle_center_of_mass(*triangle)
+        (0.5, 0.5)
+        """
+        return (a+b+c)*(1/3.)
 
 class WorldRenderer (object):
-    def __init__(self, template_lib=None, line_width=2, buf=10, dpcm=60):
+    def __init__(self, template_lib=None, line_width=2, buf=10, dpcm=50):
         self.template_lib = template_lib
         if self.template_lib == None:
             self.template_lib = TEMPLATE_LIBRARY
         self.buf = buf
         self.line_width = line_width
         self.dpcm = dpcm
-    def render(self, world):
+        self.army_scale = 3
+    def render(self, world, players):
         template = self.template_lib.get(world.name)
         if template == None:
             template = self._auto_template(world)
-        return self.render_template(world, template)
-    def render_template(self, world, template):
+        return self.render_template(world, players, template)
+    def render_template(self, world, players, template):
         region_pos,width,height = self._locate(template)
         lines = [
             '<?xml version="1.0" standalone="no"?>',
@@ -370,20 +517,32 @@ class WorldRenderer (object):
             % (float(width)/self.dpcm, float(height)/self.dpcm,
                width, height),
             '     xmlns="http://www.w3.org/2000/svg" version="1.1">',
-            '<desc>PyRisk world: %s</desc>' % template,
-            ]
+            '<title>%s</title>' % template,
+            '<desc>A PyRisk world snapshot</desc>',
+           ]
+        terr_regions = {}
         drawn_rts = {}
         for r in template.regions:
             t = self._matching_territory(world, r)
+            if t.name in terr_regions:
+                terr_regions[t.name].append(r)
+            else:
+                terr_regions[t.name] = [r]
             c_col = template.continent_colors[t.continent.name]
+            if len(template.continent_colors['_attributes']) == 0:
+                c_col_attr = ''
+            else:
+                attrs = template.continent_colors['_attributes'].items()
+                c_col_attr = ''.join([' %s="%s"' % (k,v) for k,v in attrs])
             if template.line_colors['border'] == None:
                 b_col_attr = ''
             else:
-                b_col_attr = 'stroke="%s" stroke-width="%d"' \
+                b_col_attr = ' stroke="%s" stroke-width="%d"' \
                     % (template.line_colors['border'], self.line_width)
             lines.extend([
-                    '<!-- %s -->' % r,
-                    '<polygon fill="%s" %s' % (c_col, b_col_attr),
+                    '<polygon title="%s / %s / %s"'
+                    % (t, t.player, t.armies),                    
+                    '         fill="%s"%s%s' % (c_col, c_col_attr, b_col_attr),
                     '         points="%s" />'
                     % ' '.join(['%d,%d' % ((region_pos[id(r)]+p)
                                            *(1,-1) # svg y value increases down
@@ -409,11 +568,25 @@ class WorldRenderer (object):
                                            +(0,height)) # shift back into bbox
                                     for p in rt])
                         ])
-        lines.extend([
-                '<circle fill="black" cx="0" cy="0" r="20" />',
-                '<circle fill="green" cx="%d" cy="%d" r="20" />'
-                 % (width, height)
-                ])
+        for t in world.territories():
+            regions = terr_regions[t.name]
+            center = self._territory_center(region_pos, regions)
+            radius = self.army_scale*math.sqrt(t.armies)
+            color = template.player_colors[players.index(t.player)]
+            if color == None:
+                continue
+            if template.line_colors['army'] == None:
+                b_col_attr = ''
+            else:
+                b_col_attr = ' stroke="%s" stroke-width="%d"' \
+                    % (template.line_colors['army'], self.line_width)
+                radius += self.line_width/2.
+            lines.extend([
+                    '<circle title="%s / %s / %s"' % (t, t.player, t.armies),
+                    '         fill="%s"%s' % (color, b_col_attr),
+                    '         cx="%d" cy="%d" r="%.1f" />'
+                    % (center[0], center[1]*-1+height, radius),
+                    ])
         lines.extend(['</svg>', ''])
         return '\n'.join(lines)
     def _locate(self, template):
@@ -480,6 +653,18 @@ class WorldRenderer (object):
         assert t != None, 'No territory in %s associated with region %s' \
             % (world, region)
         return t
+    def _territory_center(self, region_pos, regions):
+        """Return the center of mass of a territory composed of regions.
+        """
+        cas = []
+        for r in regions:
+            center = r.center_of_mass() + region_pos[id(r)]
+            area = r.area()
+            cas.append((center, area))
+        m = sum([a for c,a in cas])
+        average = Vector((int(sum([c[0]*a for c,a in cas])/m),
+                          int(sum([c[1]*a for c,a in cas])/m)))
+        return average
     def _auto_template(self, world):
         raise NotImplementedError
 
@@ -489,9 +674,14 @@ def test():
     return failures
 
 def render_earth():
-    from .base import generate_earth
+    from .base import generate_earth,Player,Engine
+    players = [Player('Alice'), Player('Bob'), Player('Charlie'),
+               Player('Eve'), Player('Mallory'), Player('Zoe')]
+    world = generate_earth()
+    e = Engine(world, players)
+    e.setup()
     r = WorldRenderer()
-    print r.render(generate_earth())
+    print r.render(e.world, players)
     #f = open('world.svg', 'w')
     #f.write(r.render(generate_earth()))
     #f.close()