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
|
---|
9 | from gheat.models import Accesspoint, Gebruiker, Meting
|
---|
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 | """
|
---|
165 | numpoints = Meting.objects.num_points(self)
|
---|
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]
|
---|
179 | datum = datetime.datetime.fromtimestamp(timestamp)
|
---|
180 |
|
---|
181 | numpoints = Meting.objects.num_points(self, datum)
|
---|
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 |
|
---|
195 | _points = Meting.objects.points_inside(self)
|
---|
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
|
---|
205 | point_signaal = point.signaal
|
---|
206 | while point_signaal > 0:
|
---|
207 | result.append((x-self.pad,y-self.pad))
|
---|
208 | point_signaal = point_signaal - 1
|
---|
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 |
|
---|