-
Notifications
You must be signed in to change notification settings - Fork 0
/
canvas.py
486 lines (461 loc) · 19.3 KB
/
canvas.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
from Tkinter import Tk, Text, BOTH, W, N, E, S, CENTER, Canvas
from ttk import Frame, Button, Label, Style
from myat.utils import utils
import math, webbrowser, re
def resaturate(cstr, pct):
"""Re-saturate a color string, like "#9a86db". pct is a positive number to
make it less saturated, and negative to make it more saturated."""
if pct == 0 or cstr[0] != '#' or len(cstr) != 7:
return cstr
nstr = '#'
for i1 in range(3):
chex = int(cstr[i1*2+1:i1*2+3], 16)
# Idea is, move each of RGB proportionally away from (or towards) white
# by the given percentage.
chex += int(pct * (255 - chex) / 100)
if chex > 255:
chex = 255
elif chex < 0:
chex = 0
nstr += '%02x' % chex
return nstr
class at_graph(Frame):
def __init__(self, parent):
Frame.__init__(self, parent)
self.parent = parent
self.u = utils('atoutput.pkl')
self.km = dict()
self.price = dict()
self.km[0] = (min(self.u.all_km), max(self.u.all_km))
self.price[0] = (min(self.u.all_price), max(self.u.all_price))
self.zoom_level = 0
try:
self.parent.title("Auto trader results")
self.is_standalone = True
except:
self.is_standalone = False
self.style = Style()
self.style.theme_use("classic")
# Assume the parent is the root widget; make the frame take up the
# entire widget size.
print self.is_standalone
if self.is_standalone:
self.w, self.h = map(int,
self.parent.geometry().split('+')[0].split('x'))
self.w, self.h = 800, 800
else:
self.w, self.h = 600, 600
self.c = None
# Are they hovering over a data point?
self.is_hovering = False
# Filter the description strings: lower and whiten any non-matching
# data point.
self.filter = ''
self.re = list()
self.replot()
def replot(self, zlfrac=None):
"""Replot the graph. If zlfrac is not None, then it should be a
fractional value between 0 and 1; this is used to do smooth zooming,
which doesn't plot the axes (it only redraws the car points)."""
if self.c is not None:
self.c.destroy()
self.c = Canvas(self, height=self.h, width=self.w, bd=1, bg='#f3f5f9')
self.c.grid(sticky=S, pady=1, padx=1)
zl = self.zoom_level
if zlfrac is not None:
z1l, z1h = self.zoom_price_start
z2l, z2h = self.zoom_price_end
price_low = z1l + (z2l - z1l) * zlfrac
price_high = z1h + (z2h - z1h) * zlfrac
z1l, z1h = self.zoom_km_start
z2l, z2h = self.zoom_km_end
km_low = z1l + (z2l - z1l) * zlfrac
km_high = z1h + (z2h - z1h) * zlfrac
self.axis((price_low, price_high), 'y', draw=False)
self.axis((km_low, km_high), 'x', draw=False)
self.car_points(draw=False)
else:
self.axis(self.price[zl], 'y')
self.axis(self.km[zl], 'x')
self.car_points()
self.pack(fill=BOTH, expand=1)
def xyp(self, x, y):
"Given x in km and y in $, return canvas position (xp, yp)."
xp = int(math.floor((1.0 * x - self.x1) / (self.x2 - self.x1) \
* (self.xp2 - self.xp1) + self.xp1 + 0.5))
yp = int(math.floor((1.0 * y - self.y1) / (self.y2 - self.y1) \
* (self.yp2 - self.yp1) + self.yp1 + 0.5))
return (xp, yp)
def axis(self, arange, ax, draw=True):
"Add an axis ax='x' or 'y', with arange=(min, max) values."
if draw:
a1, a2, ast = self.u.axis(*arange)
else:
a1, a2 = arange
ast = (a2 - a1) * 0.2
nt = int(math.floor((a2 - a1) / ast + 0.5)) + 1
st_offset = 50
# Remember the min and max axis values, along with the canvas points
# that correspond to each location (xp1 and xp2). This allows us to
# calculate where on the canvas a particular (x, y) value falls.
if ax == 'x':
self.x1, self.x2 = a1, a2
self.xp1, self.xp2 = st_offset, self.w - st_offset
self.xtick = [a1 + i * ast for i in range(nt)]
# Remember where the midpoint of the axis is, relative to canvas.
self.xmid = (self.xp1 + self.xp2) / 2
else:
self.y1, self.y2 = a1, a2
self.yp1, self.yp2 = self.h - st_offset, st_offset
self.ytick = [a1 + i * ast for i in range(nt)]
# Remember where the midpoint of the axis is, relative to canvas.
self.ymid = (self.yp1 + self.yp2) / 2
# Figure out tick labels.
atick = ['%g' % ((a1 + i * ast) / 1000) for i in range(nt)]
# Figure out maximum decimal places on all tick labels, and ensure
# they all have that many decimal places.
max_dec = max(map(lambda x: 0 if '.' not in x
else len(x.split('.')[1]), atick))
if max_dec > 0:
atick = map(lambda x: x + '.' + '0'*max_dec if '.' not in x
else x + '0'*(max_dec - len(x.split('.')[1])), atick)
yst, xst = self.h - st_offset, st_offset
# Draw axis line proper, and axis label.
if draw:
if ax == 'x':
self.c.create_line(xst, yst, self.w - st_offset, yst)
xp = (xst + self.w - st_offset) / 2
self.c.create_text(xp, yst + 30, text='Mileage (km x 1000)')
else:
self.c.create_line(xst, yst, xst, st_offset)
self.c.create_text(xst, st_offset - 30, text='Price')
self.c.create_text(xst, st_offset - 15, text='($000)')
tick_anchor = [N, E][ax == 'y']
tick_x, tick_y = xst, yst
tick_step = ([self.w, self.h][ax == 'y'] - st_offset * 2 * 1.0) / \
(nt - 1)
label_offset = 3
for i1, tick in enumerate(atick):
x_of, y_of = -label_offset, label_offset
if ax == 'y':
y_of = int(-i1 * tick_step)
else:
x_of = int(i1 * tick_step)
if draw:
self.c.create_text(tick_x + x_of, tick_y + y_of,
text=tick, anchor=tick_anchor)
x_mini, y_mini = 0, 0
x_maxi, y_maxi = 0, 0
if ax == 'y':
x_of += label_offset
x_mini, x_maxi = 8, self.w - st_offset * 2
# Remember what y coord this grid line is at.
if i1 == 0:
self.y_grid = []
self.y_grid.append(tick_y + y_of)
else:
y_of -= label_offset
y_mini, y_maxi = -8, st_offset * 2 - self.h
# Remember what x coord this grid line is at.
if i1 == 0:
self.x_grid = []
self.x_grid.append(tick_x + x_of)
if draw:
# Draw the little solid tick, next to the axis.
self.c.create_line(tick_x + x_of, tick_y + y_of,
tick_x + x_of + x_mini, tick_y + y_of + y_mini)
# Draw a dashed grid line, across the entire graph.
self.c.create_line(tick_x + x_of, tick_y + y_of,
tick_x + x_of + x_maxi, tick_y + y_of + y_maxi,
dash=(1, 3))
def car_points(self, draw=True):
"Plot the cars themselves."
# 199 215 151 151 199 224 230 162 157 250 224 167 178 165 192 249 200 216 204 204 204 191 173 158
color_order = ['#c7d797', '#97c7e0', '#e6a29d', '#fae0a7', '#b2a5c0',
'#f9c8d8', '#bfad9e', '#cccccc']
#color_order = ['#98df8a', '#dbdb8d', '#aec7e8', '#c9acd4', '#f7b6d2',
# '#ffbb80', '#dc9b8d', '#e9ab17', '#dddddd']
# Those colors above aren't saturated enough. Saturate them more.
color_order = map(lambda x: resaturate(x, -80), color_order)
# Change color depending on year.
cy = dict()
for i1, year in enumerate(reversed(sorted(set(self.u.all_year)))):
cy[year] = color_order[-1]
if i1 < len(color_order):
cy[year] = color_order[i1]
i1 = -1
# Tuples of (index into self.u.all_* arrays, x position, y position).
self.ov_dict = dict()
if draw:
self.c.focus_set()
self.c.bind('<Button-1>', func=self.zoom)
self.c.bind('<Button-2>', func=self.unzoom)
self.c.bind('<Left>', func=self.left_key)
self.c.bind('<Right>', func=self.right_key)
self.c.bind('<Up>', func=self.up_key)
self.c.bind('<Down>', func=self.down_key)
legend = set()
osz = 3 + self.zoom_level * 1
# Total vehicle count, and vehicles which pass the filter count.
self.vcount = self.fcount = 0
for year, km, price in zip(self.u.all_year, self.u.all_km,
self.u.all_price):
x, y = self.xyp(km, price)
i1 += 1
if x < self.x_grid[0] or x > self.x_grid[-1] or \
y > self.y_grid[0] or y < self.y_grid[-1]:
continue
self.vcount += 1
legend.add((year, cy[year]))
filtered = False
if not re.search(self.filter, self.u.all_descr[i1], re.I):
filtered = True
# If a data point is filtered out, make its outline reflect its
# model year, and fill it with white.
#
# Else, make its outline and fill color reflect the model year, and
# upon mouseover, make it entirely red.
ov = self.c.create_oval(x-osz, y-osz, x+osz, y+osz,
outline=cy[year],
activeoutline=['red', cy[year]][filtered],
fill=[cy[year], 'white'][filtered],
activefill=['red', 'white'][filtered],
)
self.ov_dict[ov] = (i1, x, y, cy[year], filtered)
# If a data point is filtered out, mousing over it does nothing,
# and also, lower it behind everything else.
if filtered:
self.c.lower(ov)
else:
self.fcount += 1
if draw:
use_tag = 'Tag %d' % i1
self.c.addtag_withtag(use_tag, ov)
self.c.tag_bind(use_tag, sequence='<Enter>',
func=self.mouseover)
self.c.tag_bind(use_tag, sequence='<Leave>',
func=self.mouseoff)
self.c.tag_bind(use_tag, sequence='<Button-1>',
func=self.select)
if draw:
# OK, add a legend for every year that's displayed.
i1 = 0
for yr, color in reversed(sorted(legend)):
xp, yp = self.x_grid[-1] + 10, self.y_grid[-1] + 15 * i1
self.c.create_oval(xp-osz, yp-osz, xp+osz, yp+osz,
outline=color, fill=color)
self.c.create_text(xp + 8, yp, text=str(yr), anchor=W)
i1 += 1
# And, add a title.
tistr = 'Vehicle count: %d' % self.vcount
if self.fcount != self.vcount:
tistr = 'Filtered vehicle count: %d' % self.fcount
xp = (self.x_grid[0] + self.x_grid[-1]) / 2
yp = self.y_grid[-1] - 30
self.c.create_text(xp, yp, text=tistr, font=('Helvetica', '16'))
zstr1 = 'Click on a blank graph location to zoom in'
zstr2 = 'Right click to zoom out'
if self.zoom_level == 0:
zstr = zstr1
elif self.zoom_level == 2:
zstr = zstr2
else:
zstr = zstr1 + ', or r' + zstr2[1:]
self.c.create_text(xp, yp + 16, text=zstr, font=('Helvetica', '14'))
def mouseover(self, event):
oval = event.widget.find_closest(event.x, event.y)[0]
# XXX Sometimes, the closest widget is an axis grid line, not an oval.
# Need to handle this correctly eventually.
if oval not in self.ov_dict:
return
self.is_hovering = True
ind, x, y, color, filtered = self.ov_dict[oval]
# Figure out how high the box needs to be by creating the text off-
# graph, then getting its bbox and deleting it.
w = 200
de_text = self.u.all_descr[ind]
deobj = self.c.create_text(self.w + 3, self.h + 3, text=de_text,
anchor=N+W, width=w-6, font=('Helvetica', '14'))
bbox = self.c.bbox(deobj)
self.c.delete(deobj)
h = 18 + bbox[3] - bbox[1]
border = 5
if x > self.xmid:
x -= (w + border)
else:
x += border
if y > self.ymid:
y -= (h + border)
else:
y += border
self.re = list()
self.re.append(self.c.create_rectangle(x, y, x + w, y + h,
fill=resaturate(color, 50)))
pr_text = '$%s' % self.u.commafy(self.u.all_price[ind])
self.re.append(self.c.create_text(x + 3, y + 3, text=pr_text,
anchor=N+W, font=('Helvetica', '10')))
km_text = '%skm' % self.u.commafy(self.u.all_km[ind])
self.re.append(self.c.create_text(x + w - 3, y + 3, text=km_text,
anchor=N+E, font=('Helvetica', '10')))
wh_text = self.u.all_wherestr[ind]
if wh_text[0].isdigit():
wh_text += ' away'
self.re.append(self.c.create_text(x + w/2, y + 3, text=wh_text,
anchor=N, font=('Helvetica', '10')))
self.re.append(self.c.create_text(x + 3, y + 16, text=de_text,
anchor=N+W, width=w-6, font=('Helvetica', '14')))
def set_filter(self, st):
"Given a string 'st', filter ovals whose description doesn't match."
self.filter = st
self.replot()
def mouseoff(self, event):
"Code for mousing off a data point."
# The tooptip rectangle and all its sub-objects need to be destroyed.
map(self.c.delete, self.re)
# Also, need to note that we're no longer over an oval -- that way,
# Button-1 events will cause a zoom, rather than launching a web page.
self.is_hovering = False
def _zoom_animation(self):
import time
from math import tanh
scale = 5
for i1 in range(-scale, scale+1):
self.replot(zlfrac=0.5 + 0.5*tanh(i1*2.0/scale)/tanh(2.0))
self.c.update()
def zoom(self, event):
# Only zoom in if we're actually within the graph boundaries.
if event.x <= self.x_grid[0] or event.x > self.x_grid[-1]:
return
if event.y >= self.y_grid[0] or event.y < self.y_grid[-1]:
return
# Don't zoom if we're hovering over a data point: let the web browser
# event handler operate.
if self.is_hovering:
return
# Don't zoom in more than twice.
if self.zoom_level >= 2:
return
# Find the grid square which we're inside.
for i1 in range(len(self.x_grid) - 1):
if event.x <= self.x_grid[i1 + 1]:
xgrid = i1 + 1
break
for i1 in range(len(self.y_grid) - 1):
if event.y >= self.y_grid[i1 + 1]:
ygrid = i1 + 1
break
self.zoom_level += 1
zl = self.zoom_level
# Make the limits of the new graph be those of the grid square which
# was clicked inside.
self.km[zl] = (self.xtick[xgrid-1], self.xtick[xgrid])
self.price[zl] = (self.ytick[ygrid-1], self.ytick[ygrid])
if zl == 1:
self.zoom_price_start = self.u.axis(*self.price[0])[:2]
self.zoom_km_start = self.u.axis(*self.km[0])[:2]
else:
self.zoom_price_start = self.price[zl - 1]
self.zoom_km_start = self.km[zl - 1]
self.zoom_price_end = self.price[zl]
self.zoom_km_end = self.km[zl]
self._zoom_animation()
self.replot()
def unzoom(self, event):
# If already at maximum zoom, nothing to be done.
if self.zoom_level == 0:
return
# If not clicking inside graph boundaries, don't unzoom.
if event.x <= self.x_grid[0] or event.x > self.x_grid[-1]:
return
if event.y >= self.y_grid[0] or event.y < self.y_grid[-1]:
return
self.zoom_level -= 1
zl = self.zoom_level
self.zoom_price_start = self.price[zl + 1]
self.zoom_km_start = self.km[zl + 1]
if zl == 0:
self.zoom_price_end = self.u.axis(*self.price[0])[:2]
self.zoom_km_end = self.u.axis(*self.km[0])[:2]
else:
self.zoom_price_end = self.price[zl]
self.zoom_km_end = self.km[zl]
self._zoom_animation()
self.replot()
def left_key(self, event):
zl = self.zoom_level
if zl == 0:
return
# If at left edge already, don't scroll.
kz = self.km[zl]
if self.km[0][0] > kz[0]:
return
self.zoom_price_start = self.zoom_price_end = self.price[zl]
self.zoom_km_start = kz
self.km[zl] = (kz[0] - (kz[1] - kz[0]), kz[0])
self.zoom_km_end = self.km[zl]
self._zoom_animation()
self.replot()
def right_key(self, event):
zl = self.zoom_level
if zl == 0:
return
# If at right edge already, don't scroll.
kz = self.km[zl]
if self.km[0][1] < kz[1]:
return
self.zoom_price_start = self.zoom_price_end = self.price[zl]
self.zoom_km_start = kz
self.km[zl] = (kz[1], kz[1] + (kz[1] - kz[0]))
self.zoom_km_end = self.km[zl]
self._zoom_animation()
self.replot()
def down_key(self, event):
zl = self.zoom_level
if zl == 0:
return
# If at bottom edge already, don't scroll.
pz = self.price[zl]
if self.price[0][0] > pz[0]:
return
self.zoom_km_start = self.zoom_km_end = self.km[zl]
self.zoom_price_start = pz
self.price[zl] = (pz[0] - (pz[1] - pz[0]), pz[0])
self.zoom_price_end = self.price[zl]
self._zoom_animation()
self.replot()
def up_key(self, event):
zl = self.zoom_level
if zl == 0:
return
# If at top edge already, don't scroll.
pz = self.price[zl]
if self.price[0][1] < pz[1]:
return
self.zoom_km_start = self.zoom_km_end = self.km[zl]
self.zoom_price_start = pz
self.price[zl] = (pz[1], pz[1] + (pz[1] - pz[0]))
self.zoom_price_end = self.price[zl]
self._zoom_animation()
self.replot()
def select(self, event):
"Open a web page, when a data point has been clicked on."
oval = event.widget.find_closest(event.x, event.y)[0]
# XXX As above, sometimes the closest widget is a grid line, not an
# oval. Need to handle this correctly, eventually.
if oval not in self.ov_dict:
return
ind, xp, yp, color, filtered = self.ov_dict[oval]
webbrowser.open(self.u.all_alink[ind])
# For running from the command line, change "False" to "True" below.
if True:
self = None
root = None
def main():
global self, root
root = Tk()
root.geometry("800x800+0+0")
self = at_graph(root)
self.pack(fill=BOTH, expand=1)
#root.mainloop()
if __name__ == '__main__':
main()