[9006] | 1 | import datetime
|
---|
| 2 | import os
|
---|
| 3 | import stat
|
---|
| 4 |
|
---|
| 5 | from django.core.exceptions import ImproperlyConfigured
|
---|
| 6 |
|
---|
| 7 | import gheat
|
---|
| 8 | import gheat.opacity
|
---|
[9026] | 9 | from gheat.models import Accespoint, Gebruiker, Meting
|
---|
[9006] | 10 | from gheat import gheatsettings as settings
|
---|
| 11 | from gheat import gmerc
|
---|
| 12 | from gheat import BUILD_EMPTIES, DIRMODE, SIZE, log
|
---|
| 13 |
|
---|
| 14 |
|
---|
| 15 | class ColorScheme(object):
|
---|
| 16 | """Base class for color scheme representations.
|
---|
| 17 | """
|
---|
| 18 |
|
---|
| 19 | def __init__(self, name, fspath):
|
---|
| 20 | """Takes the name and filesystem path of the defining PNG.
|
---|
| 21 | """
|
---|
| 22 | # if aspen.mode.DEVDEB:
|
---|
| 23 | # aspen.restarter.track(fspath)
|
---|
| 24 | self.hook_set(fspath)
|
---|
| 25 | self.empties_dir = os.path.join(settings.GHEAT_MEDIA_ROOT, name, 'empties')
|
---|
| 26 | self.build_empties()
|
---|
| 27 |
|
---|
| 28 |
|
---|
| 29 | def build_empties(self):
|
---|
| 30 | """Build empty tiles for this color scheme.
|
---|
| 31 | """
|
---|
| 32 | empties_dir = self.empties_dir
|
---|
| 33 |
|
---|
| 34 | if not BUILD_EMPTIES:
|
---|
| 35 | log.info("not building empty tiles for %s " % self)
|
---|
| 36 | else:
|
---|
| 37 | if not os.path.isdir(empties_dir):
|
---|
| 38 | os.makedirs(empties_dir, DIRMODE)
|
---|
| 39 | if not os.access(empties_dir, os.R_OK|os.W_OK|os.X_OK):
|
---|
| 40 | raise ImproperlyConfigured( "Permissions too restrictive on "
|
---|
| 41 | + "empties directory "
|
---|
| 42 | + "(%s)." % empties_dir
|
---|
| 43 | )
|
---|
| 44 | for fname in os.listdir(empties_dir):
|
---|
| 45 | if fname.endswith('.png'):
|
---|
| 46 | os.remove(os.path.join(empties_dir, fname))
|
---|
| 47 | for zoom, opacity in gheat.opacity.zoom_to_opacity.items():
|
---|
| 48 | fspath = os.path.join(empties_dir, str(zoom)+'.png')
|
---|
| 49 | self.hook_build_empty(opacity, fspath)
|
---|
| 50 |
|
---|
| 51 | log.info("building empty tiles in %s" % empties_dir)
|
---|
| 52 |
|
---|
| 53 |
|
---|
| 54 | def get_empty_fspath(self, zoom):
|
---|
| 55 | fspath = os.path.join(self.empties_dir, str(zoom)+'.png')
|
---|
| 56 | if not os.path.isfile(fspath):
|
---|
| 57 | self.build_empties() # so we can rebuild empties on the fly
|
---|
| 58 | return fspath
|
---|
| 59 |
|
---|
| 60 |
|
---|
| 61 | def hook_set(self):
|
---|
| 62 | """Set things that your backend will want later.
|
---|
| 63 | """
|
---|
| 64 | raise NotImplementedError
|
---|
| 65 |
|
---|
| 66 |
|
---|
| 67 | def hook_build_empty(self, opacity, fspath):
|
---|
| 68 | """Given an opacity and a path, save an empty tile.
|
---|
| 69 | """
|
---|
| 70 | raise NotImplementedError
|
---|
| 71 |
|
---|
| 72 |
|
---|
| 73 | class Dot(object):
|
---|
| 74 | """Base class for dot representations.
|
---|
| 75 |
|
---|
| 76 | Unlike color scheme, the same basic external API works for both backends.
|
---|
| 77 | How we compute that API is different, though.
|
---|
| 78 |
|
---|
| 79 | """
|
---|
| 80 |
|
---|
| 81 | def __init__(self, zoom):
|
---|
| 82 | """Takes a zoom level.
|
---|
| 83 | """
|
---|
| 84 | name = 'dot%d.png' % zoom
|
---|
| 85 | fspath = os.path.join(settings.GHEAT_CONF_DIR, 'dots', name)
|
---|
| 86 | self.img, self.half_size = self.hook_get(fspath)
|
---|
| 87 |
|
---|
| 88 | def hook_get(self, fspath):
|
---|
| 89 | """Given a filesystem path, return two items.
|
---|
| 90 | """
|
---|
| 91 | raise NotImplementedError
|
---|
| 92 |
|
---|
| 93 |
|
---|
| 94 | class Tile(object):
|
---|
| 95 | """Base class for tile representations.
|
---|
| 96 | """
|
---|
| 97 |
|
---|
| 98 | img = None
|
---|
| 99 |
|
---|
| 100 | def __init__(self, color_scheme, dots, zoom, x, y, fspath):
|
---|
| 101 | """x and y are tile coords per Google Maps.
|
---|
| 102 | """
|
---|
| 103 |
|
---|
| 104 | # Calculate some things.
|
---|
| 105 | # ======================
|
---|
| 106 |
|
---|
| 107 | dot = dots[zoom]
|
---|
| 108 |
|
---|
| 109 |
|
---|
| 110 | # Translate tile to pixel coords.
|
---|
| 111 | # -------------------------------
|
---|
| 112 |
|
---|
| 113 | x1 = x * SIZE
|
---|
| 114 | x2 = x1 + 255
|
---|
| 115 | y1 = y * SIZE
|
---|
| 116 | y2 = y1 + 255
|
---|
| 117 |
|
---|
| 118 |
|
---|
| 119 | # Expand bounds by one-half dot width.
|
---|
| 120 | # ------------------------------------
|
---|
| 121 |
|
---|
| 122 | x1 = x1 - dot.half_size
|
---|
| 123 | x2 = x2 + dot.half_size
|
---|
| 124 | y1 = y1 - dot.half_size
|
---|
| 125 | y2 = y2 + dot.half_size
|
---|
| 126 | expanded_size = (x2-x1, y2-y1)
|
---|
| 127 |
|
---|
| 128 |
|
---|
| 129 | # Translate new pixel bounds to lat/lng.
|
---|
| 130 | # --------------------------------------
|
---|
| 131 |
|
---|
| 132 | n, w = gmerc.px2ll(x1, y1, zoom)
|
---|
| 133 | s, e = gmerc.px2ll(x2, y2, zoom)
|
---|
| 134 |
|
---|
| 135 |
|
---|
| 136 | # Save
|
---|
| 137 | # ====
|
---|
| 138 |
|
---|
| 139 | self.dot = dot.img
|
---|
| 140 | self.pad = dot.half_size
|
---|
| 141 |
|
---|
| 142 | self.x = x
|
---|
| 143 | self.y = y
|
---|
| 144 |
|
---|
| 145 | self.x1 = x1
|
---|
| 146 | self.y1 = y1
|
---|
| 147 |
|
---|
| 148 | self.x2 = x2
|
---|
| 149 | self.y2 = y2
|
---|
| 150 |
|
---|
| 151 | self.expanded_size = expanded_size
|
---|
| 152 | self.llbound = (n,s,e,w)
|
---|
| 153 | self.zoom = zoom
|
---|
| 154 | self.fspath = fspath
|
---|
| 155 | self.opacity = gheat.opacity.zoom_to_opacity[zoom]
|
---|
| 156 | self.color_scheme = color_scheme
|
---|
| 157 |
|
---|
| 158 |
|
---|
| 159 | def is_empty(self):
|
---|
| 160 | """With attributes set on self, return a boolean.
|
---|
| 161 |
|
---|
| 162 | Calc lat/lng bounds of this tile (include half-dot-width of padding)
|
---|
| 163 | SELECT count(uid) FROM points
|
---|
| 164 | """
|
---|
[9026] | 165 | numpoints = Meting.objects.num_points(self)
|
---|
[9006] | 166 | return numpoints == 0
|
---|
| 167 |
|
---|
| 168 |
|
---|
| 169 | def is_stale(self):
|
---|
| 170 | """With attributes set on self, return a boolean.
|
---|
| 171 |
|
---|
| 172 | Calc lat/lng bounds of this tile (include half-dot-width of padding)
|
---|
| 173 | SELECT count(uid) FROM points WHERE modtime < modtime_tile
|
---|
| 174 | """
|
---|
| 175 | if not os.path.isfile(self.fspath):
|
---|
| 176 | return True
|
---|
| 177 |
|
---|
| 178 | timestamp = os.stat(self.fspath)[stat.ST_MTIME]
|
---|
[9026] | 179 | datum = datetime.datetime.fromtimestamp(timestamp)
|
---|
[9006] | 180 |
|
---|
[9026] | 181 | numpoints = Meting.objects.num_points(self, datum)
|
---|
[9006] | 182 |
|
---|
| 183 | return numpoints > 0
|
---|
| 184 |
|
---|
| 185 |
|
---|
| 186 | def rebuild(self):
|
---|
| 187 | """Rebuild the image at self.img. Real work delegated to subclasses.
|
---|
| 188 | """
|
---|
| 189 |
|
---|
| 190 | # Calculate points.
|
---|
| 191 | # =================
|
---|
| 192 | # Build a closure that gives us the x,y pixel coords of the points
|
---|
| 193 | # to be included on this tile, relative to the top-left of the tile.
|
---|
| 194 |
|
---|
[9026] | 195 | _points = Meting.objects.points_inside(self)
|
---|
[9006] | 196 |
|
---|
| 197 | def points():
|
---|
| 198 | """Yield x,y pixel coords within this tile, top-left of dot.
|
---|
| 199 | """
|
---|
| 200 | result = []
|
---|
| 201 | for point in _points:
|
---|
| 202 | x, y = gmerc.ll2px(point.latitude, point.longitude, self.zoom)
|
---|
| 203 | x = x - self.x1 # account for tile offset relative to
|
---|
| 204 | y = y - self.y1 # overall map
|
---|
[9026] | 205 | point_signaal = point.signaal
|
---|
| 206 | while point_signaal > 0:
|
---|
[9006] | 207 | result.append((x-self.pad,y-self.pad))
|
---|
[9026] | 208 | point_signaal = point_signaal - 1
|
---|
[9006] | 209 | return result
|
---|
| 210 |
|
---|
| 211 |
|
---|
| 212 | # Main logic
|
---|
| 213 | # ==========
|
---|
| 214 | # Hand off to the subclass to actually build the image, then come back
|
---|
| 215 | # here to maybe create a directory before handing back to the backend
|
---|
| 216 | # to actually write to disk.
|
---|
| 217 |
|
---|
| 218 | self.img = self.hook_rebuild(points())
|
---|
| 219 |
|
---|
| 220 | dirpath = os.path.dirname(self.fspath)
|
---|
| 221 | if dirpath and not os.path.isdir(dirpath):
|
---|
| 222 | os.makedirs(dirpath, DIRMODE)
|
---|
| 223 |
|
---|
| 224 |
|
---|
| 225 | def hook_rebuild(self, points, opacity):
|
---|
| 226 | """Rebuild and save the file using the current library.
|
---|
| 227 |
|
---|
| 228 | The algorithm runs something like this:
|
---|
| 229 |
|
---|
| 230 | o start a tile canvas/image that is a dots-worth oversized
|
---|
| 231 | o loop through points and multiply dots on the tile
|
---|
| 232 | o trim back down to straight tile size
|
---|
| 233 | o invert/colorize the image
|
---|
| 234 | o make it transparent
|
---|
| 235 |
|
---|
| 236 | Return the img object; it will be sent back to hook_save after a
|
---|
| 237 | directory is made if needed.
|
---|
| 238 |
|
---|
| 239 | Trim after looping because we multiply is the only step that needs the
|
---|
| 240 | extra information.
|
---|
| 241 |
|
---|
| 242 | The coloring and inverting can happen in the same pixel manipulation
|
---|
| 243 | because you can invert colors.png. That is a 1px by 256px PNG that maps
|
---|
| 244 | grayscale values to color values. You can customize that file to change
|
---|
| 245 | the coloration.
|
---|
| 246 |
|
---|
| 247 | """
|
---|
| 248 | raise NotImplementedError
|
---|
| 249 |
|
---|
| 250 |
|
---|
| 251 | def save(self):
|
---|
| 252 | """Write the image at self.img to disk.
|
---|
| 253 | """
|
---|
| 254 | raise NotImplementedError
|
---|
| 255 |
|
---|
| 256 |
|
---|