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