frame.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603
  1. """!
  2. @package frame
  3. @brief Timeline Tool
  4. Classes:
  5. - frame::DataCursor
  6. - frame::TimelineFrame
  7. - frame::LookUp
  8. (C) 2012-13 by the GRASS Development Team
  9. This program is free software under the GNU General Public License
  10. (>=v2). Read the file COPYING that comes with GRASS for details.
  11. @author Anna Kratochvilova <kratochanna gmail.com>
  12. """
  13. import os
  14. import sys
  15. import wx
  16. from math import ceil
  17. from itertools import cycle
  18. import numpy as np
  19. try:
  20. import matplotlib
  21. # The recommended way to use wx with mpl is with the WXAgg
  22. # backend.
  23. matplotlib.use('WXAgg')
  24. from matplotlib.figure import Figure
  25. import matplotlib.pyplot as plt
  26. from matplotlib.backends.backend_wxagg import \
  27. FigureCanvasWxAgg as FigCanvas, \
  28. NavigationToolbar2WxAgg as NavigationToolbar
  29. import matplotlib.dates as mdates
  30. from matplotlib import cbook
  31. except ImportError:
  32. raise ImportError(_('The Timeline Tool needs "Matplotlib" package to be installed.'))
  33. if __name__ == '__main__':
  34. sys.path.append(os.path.join(os.environ['GISBASE'], "etc", "gui", "wxpython"))
  35. import grass.script as grass
  36. from core.utils import _
  37. import grass.temporal as tgis
  38. from core.gcmd import GError, GException, RunCommand
  39. from gui_core import gselect
  40. from core import globalvar
  41. ALPHA = 0.5
  42. COLORS = ['b', 'g', 'r', 'c', 'm', 'y', 'k']
  43. def check_version(*version):
  44. """!Checks if given version or newer is installed"""
  45. versionInstalled = []
  46. for i in matplotlib.__version__.split('.'):
  47. try:
  48. v = int(i)
  49. versionInstalled.append(v)
  50. except ValueError:
  51. versionInstalled.append(0)
  52. if versionInstalled < list(version):
  53. return False
  54. else:
  55. return True
  56. class TimelineFrame(wx.Frame):
  57. """!The main frame of the application"""
  58. def __init__(self, parent):
  59. wx.Frame.__init__(self, parent, id=wx.ID_ANY, title=_("Timeline Tool"))
  60. tgis.init()
  61. self.datasets = []
  62. self.timeData = {}
  63. self._layout()
  64. self.temporalType = None
  65. self.unit = None
  66. def _layout(self):
  67. """!Creates the main panel with all the controls on it:
  68. * mpl canvas
  69. * mpl navigation toolbar
  70. * Control panel for interaction
  71. """
  72. self.panel = wx.Panel(self)
  73. # Create the mpl Figure and FigCanvas objects.
  74. # 5x4 inches, 100 dots-per-inch
  75. #
  76. # color = wx.SystemSettings.GetColour(wx.SYS_COLOUR_BACKGROUND)
  77. self.fig = Figure((5.0, 4.0), facecolor=(1, 1, 1))
  78. self.canvas = FigCanvas(self.panel, wx.ID_ANY, self.fig)
  79. # axes are initialized later
  80. self.axes2d = None
  81. self.axes3d = None
  82. # Create the navigation toolbar, tied to the canvas
  83. #
  84. self.toolbar = NavigationToolbar(self.canvas)
  85. #
  86. # Layout
  87. #
  88. self.vbox = wx.BoxSizer(wx.VERTICAL)
  89. self.vbox.Add(self.canvas, 1, wx.LEFT | wx.TOP | wx.EXPAND)
  90. self.vbox.Add(self.toolbar, 0, wx.EXPAND)
  91. self.vbox.AddSpacer(10)
  92. gridSizer = wx.GridBagSizer(hgap=5, vgap=5)
  93. self.datasetSelect = gselect.Select(parent=self.panel, id=wx.ID_ANY,
  94. size=globalvar.DIALOG_GSELECT_SIZE,
  95. type='stds', multiple=True)
  96. self.drawButton = wx.Button(self.panel, id=wx.ID_ANY, label=_("Draw"))
  97. self.drawButton.Bind(wx.EVT_BUTTON, self.OnRedraw)
  98. self.helpButton = wx.Button(self.panel, id=wx.ID_ANY, label=_("Help"))
  99. self.helpButton.Bind(wx.EVT_BUTTON, self.OnHelp)
  100. self.view3dCheck = wx.CheckBox(self.panel, id=wx.ID_ANY,
  101. label=_("3D plot of spatio-temporal extents"))
  102. self.view3dCheck.Bind(wx.EVT_CHECKBOX, self.OnRedraw)
  103. if not check_version(1, 0, 0):
  104. self.view3dCheck.SetLabel(_("3D plot of spatio-temporal extents "
  105. "(matplotlib >= 1.0.0)"))
  106. self.view3dCheck.Disable()
  107. gridSizer.Add(wx.StaticText(self.panel, id=wx.ID_ANY,
  108. label=_("Select space time dataset(s):")),
  109. pos=(0, 0), flag=wx.EXPAND | wx.ALIGN_CENTER_VERTICAL)
  110. gridSizer.Add(self.datasetSelect, pos=(1, 0), flag=wx.EXPAND)
  111. gridSizer.Add(self.drawButton, pos=(1, 1), flag=wx.EXPAND)
  112. gridSizer.Add(self.helpButton, pos=(1, 2), flag=wx.EXPAND)
  113. gridSizer.Add(self.view3dCheck, pos=(2, 0), flag=wx.EXPAND | wx.ALIGN_CENTER_VERTICAL)
  114. self.vbox.Add(gridSizer, proportion=0, flag=wx.EXPAND | wx.ALL, border=10)
  115. self.panel.SetSizer(self.vbox)
  116. self.vbox.Fit(self)
  117. def _getData(self, timeseries):
  118. """!Load data and read properties"""
  119. self.timeData = {}
  120. mode = None
  121. unit = None
  122. for series in timeseries:
  123. name = series[0] + '@' + series[1]
  124. etype = series[2]
  125. sp = tgis.dataset_factory(etype, name)
  126. if not sp.is_in_db():
  127. GError(self, message=_("Dataset <%s> not found in temporal database") % (name))
  128. return
  129. sp.select()
  130. self.timeData[name] = {}
  131. self.timeData[name]['elementType'] = series[2]
  132. self.timeData[name]['temporalType'] = sp.get_temporal_type() # abs/rel
  133. if mode is None:
  134. mode = self.timeData[name]['temporalType']
  135. elif self.timeData[name]['temporalType'] != mode:
  136. GError(parent=self, message=_("Datasets have different temporal type "
  137. "(absolute x relative), which is not allowed."))
  138. return
  139. # check topology
  140. maps = sp.get_registered_maps_as_objects()
  141. self.timeData[name]['validTopology'] = sp.check_temporal_topology(maps)
  142. self.timeData[name]['temporalMapType'] = sp.get_map_time() # point/interval
  143. self.timeData[name]['unit'] = None # only with relative
  144. if self.timeData[name]['temporalType'] == 'relative':
  145. start, end, self.timeData[name]['unit'] = sp.get_relative_time()
  146. if unit is None:
  147. unit = self.timeData[name]['unit']
  148. elif self.timeData[name]['unit'] != unit:
  149. GError(self, _("Datasets have different time unit which is not allowed."))
  150. return
  151. self.timeData[name]['start_datetime'] = []
  152. # self.timeData[name]['start_plot'] = []
  153. self.timeData[name]['end_datetime'] = []
  154. # self.timeData[name]['end_plot'] = []
  155. self.timeData[name]['names'] = []
  156. self.timeData[name]['north'] = []
  157. self.timeData[name]['south'] = []
  158. self.timeData[name]['west'] = []
  159. self.timeData[name]['east'] = []
  160. columns = ','.join(['name', 'start_time', 'end_time',
  161. 'north', 'south', 'west', 'east'])
  162. rows = sp.get_registered_maps(columns=columns, where=None,
  163. order='start_time', dbif=None)
  164. if rows is None:
  165. rows = []
  166. for row in rows:
  167. mapName, start, end, north, south, west, east = row
  168. self.timeData[name]['start_datetime'].append(start)
  169. self.timeData[name]['end_datetime'].append(end)
  170. self.timeData[name]['names'].append(mapName)
  171. self.timeData[name]['north'].append(north)
  172. self.timeData[name]['south'].append(south)
  173. self.timeData[name]['west'].append(west)
  174. self.timeData[name]['east'].append(east)
  175. self.temporalType = mode
  176. self.unit = unit
  177. def _draw3dFigure(self):
  178. """!Draws 3d view (spatio-temporal extents).
  179. Only for matplotlib versions >= 1.0.0.
  180. Earlier versions cannot draw time ticks and alpha
  181. and it has a slightly different API.
  182. """
  183. self.axes3d.clear()
  184. self.axes3d.grid(False)
  185. # self.axes3d.grid(True)
  186. if self.temporalType == 'absolute':
  187. if check_version(1, 1, 0):
  188. self.axes3d.zaxis_date()
  189. convert = mdates.date2num
  190. else:
  191. convert = lambda x: x
  192. colors = cycle(COLORS)
  193. plots = []
  194. for name in self.datasets:
  195. name = name[0] + '@' + name[1]
  196. startZ = convert(self.timeData[name]['start_datetime'])
  197. mapType = self.timeData[name]['temporalMapType']
  198. if mapType == 'interval':
  199. dZ = convert(self.timeData[name]['end_datetime']) - startZ
  200. else:
  201. dZ = [0] * len(startZ)
  202. startX = self.timeData[name]['west']
  203. dX = self.timeData[name]['east'] - np.array(startX)
  204. startY = self.timeData[name]['south']
  205. dY = self.timeData[name]['north'] - np.array(startY)
  206. color = colors.next()
  207. plots.append(self.axes3d.bar3d(startX, startY, startZ, dX, dY, dZ,
  208. color=color, alpha=ALPHA))
  209. params = grass.read_command('g.proj', flags='g')
  210. params = grass.parse_key_val(params)
  211. if 'unit' in params:
  212. self.axes3d.set_xlabel(_("X [%s]") % params['unit'])
  213. self.axes3d.set_ylabel(_("Y [%s]") % params['unit'])
  214. else:
  215. self.axes3d.set_xlabel(_("X"))
  216. self.axes3d.set_ylabel(_("Y"))
  217. self.axes3d.set_zlabel(_('Time'))
  218. self.axes3d.mouse_init()
  219. self.canvas.draw()
  220. def _draw2dFigure(self):
  221. """!Draws 2D plot (temporal extents)"""
  222. self.axes2d.clear()
  223. self.axes2d.grid(True)
  224. if self.temporalType == 'absolute':
  225. self.axes2d.xaxis_date()
  226. self.fig.autofmt_xdate()
  227. convert = mdates.date2num
  228. else:
  229. convert = lambda x: x
  230. colors = cycle(COLORS)
  231. yticksNames = []
  232. yticksPos = []
  233. plots = []
  234. lookUp = LookUp(self.timeData)
  235. for i, name in enumerate(self.datasets):
  236. name = name[0] + '@' + name[1]
  237. yticksNames.append(name)
  238. yticksPos.append(i)
  239. barData = []
  240. pointData = []
  241. mapType = self.timeData[name]['temporalMapType']
  242. start = convert(self.timeData[name]['start_datetime'])
  243. # TODO: mixed
  244. if mapType == 'interval':
  245. end = convert(self.timeData[name]['end_datetime'])
  246. lookUpData = zip(start, end)
  247. duration = end - np.array(start)
  248. barData = zip(start, duration)
  249. lookUp.AddDataset(type_='bar', yrange=(i - 0.1, i + 0.1),
  250. xranges=lookUpData, datasetName=name)
  251. else:
  252. # self.timeData[name]['end_plot'] = None
  253. pointData = start
  254. lookUp.AddDataset(type_='point', yrange=i, xranges=pointData, datasetName=name)
  255. color = colors.next()
  256. if mapType == 'interval':
  257. plots.append(self.axes2d.broken_barh(xranges=barData, yrange=(i - 0.1, 0.2),
  258. facecolors=color, alpha=ALPHA))
  259. else:
  260. plots.append(self.axes2d.plot(pointData, [i] * len(pointData),
  261. marker='o', linestyle='None', color=color)[0])
  262. if self.temporalType == 'absolute':
  263. pass
  264. # self.axes2d.set_xlabel(_("Time"))
  265. else:
  266. self.axes2d.set_xlabel(_("Time [%s]") % self.unit)
  267. self.axes2d.set_yticks(yticksPos)
  268. self.axes2d.set_yticklabels(yticksNames)
  269. self.axes2d.set_ylim(min(yticksPos) - 1, max(yticksPos) + 1)
  270. # adjust xlim
  271. xlim = self.axes2d.get_xlim()
  272. padding = ceil((xlim[1] - xlim[0]) / 20.)
  273. self.axes2d.set_xlim(xlim[0] - padding, xlim[1] + padding)
  274. self.canvas.draw()
  275. DataCursor(plots, lookUp, InfoFormat)
  276. def OnRedraw(self, event):
  277. """!Required redrawing."""
  278. datasets = self.datasetSelect.GetValue().strip()
  279. if not datasets:
  280. return
  281. datasets = datasets.split(',')
  282. try:
  283. datasets = self._checkDatasets(datasets)
  284. except GException:
  285. GError(parent=self, message=_("Invalid input data"))
  286. return
  287. self.datasets = datasets
  288. self._redraw()
  289. def _redraw(self):
  290. """!Readraw data.
  291. Decides if to draw also 3D and adjusts layout if needed.
  292. """
  293. self._getData(self.datasets)
  294. # axes3d are physically removed
  295. if not self.axes2d:
  296. self.axes2d = self.fig.add_subplot(1, 1, 1)
  297. self._draw2dFigure()
  298. if check_version(1, 0, 0):
  299. if self.view3dCheck.IsChecked():
  300. self.axes2d.change_geometry(2, 1, 1)
  301. if not self.axes3d:
  302. # do not remove this import - unused but it is required for 3D
  303. from mpl_toolkits.mplot3d import Axes3D # pylint: disable=W0611
  304. self.axes3d = self.fig.add_subplot(2, 1, 2, projection='3d')
  305. self.axes3d.set_visible(True)
  306. self._draw3dFigure()
  307. else:
  308. if self.axes3d:
  309. self.fig.delaxes(self.axes3d)
  310. self.axes3d = None
  311. self.axes2d.change_geometry(1, 1, 1)
  312. self.canvas.draw()
  313. if check_version(1, 1, 0):
  314. # not working, maybe someone is more lucky
  315. try:
  316. plt.tight_layout()
  317. except:
  318. pass
  319. def _checkDatasets(self, datasets):
  320. """!Checks and validates datasets.
  321. Reports also type of dataset (e.g. 'strds').
  322. @return (mapName, mapset, type)
  323. """
  324. validated = []
  325. tDict = tgis.tlist_grouped('stds', group_type=True)
  326. # nested list with '(map, mapset, etype)' items
  327. allDatasets = [[[(map, mapset, etype) for map in maps]
  328. for etype, maps in etypesDict.iteritems()]
  329. for mapset, etypesDict in tDict.iteritems()]
  330. # flatten this list
  331. allDatasets = reduce(lambda x, y: x + y, reduce(lambda x, y: x + y, allDatasets))
  332. for dataset in datasets:
  333. errorMsg = _("Space time dataset <%s> not found.") % dataset
  334. if dataset.find("@") >= 0:
  335. nameShort, mapset = dataset.split('@', 1)
  336. indices = [n for n, (mapName, mapsetName, etype) in enumerate(allDatasets)
  337. if nameShort == mapName and mapsetName == mapset]
  338. else:
  339. indices = [n for n, (mapName, mapset, etype) in enumerate(allDatasets)
  340. if dataset == mapName]
  341. if len(indices) == 0:
  342. raise GException(errorMsg)
  343. elif len(indices) >= 2:
  344. dlg = wx.SingleChoiceDialog(self,
  345. message=_("Please specify the space time dataset <%s>." % dataset),
  346. caption=_("Ambiguous dataset name"),
  347. choices=[("%(map)s@%(mapset)s: %(etype)s" % {'map': allDatasets[i][0],
  348. 'mapset': allDatasets[i][1],
  349. 'etype': allDatasets[i][2]})
  350. for i in indices],
  351. style=wx.CHOICEDLG_STYLE | wx.OK)
  352. if dlg.ShowModal() == wx.ID_OK:
  353. index = dlg.GetSelection()
  354. validated.append(allDatasets[indices[index]])
  355. else:
  356. validated.append(allDatasets[indices[0]])
  357. return validated
  358. def OnHelp(self, event):
  359. RunCommand('g.manual', quiet=True, entry='g.gui.timeline')
  360. # interface
  361. def SetDatasets(self, datasets):
  362. """!Set data"""
  363. if not datasets:
  364. return
  365. try:
  366. datasets = self._checkDatasets(datasets)
  367. except GException:
  368. GError(parent=self, message=_("Invalid input data"))
  369. return
  370. self.datasets = datasets
  371. self.datasetSelect.SetValue(','.join(map(lambda x: x[0] + '@' + x[1], datasets)))
  372. self._redraw()
  373. def Show3D(self, show):
  374. """!Show also 3D if possible"""
  375. if check_version(1, 0, 0):
  376. self.view3dCheck.SetValue(show)
  377. class LookUp:
  378. """!Helper class for searching info by coordinates"""
  379. def __init__(self, timeData):
  380. self.data = {}
  381. self.timeData = timeData
  382. def AddDataset(self, type_, yrange, xranges, datasetName):
  383. if type_ == 'bar':
  384. self.data[yrange] = {'name': datasetName}
  385. for i, (start, end) in enumerate(xranges):
  386. self.data[yrange][(start, end)] = i
  387. elif type_ == 'point':
  388. self.data[(yrange, yrange)] = {'name': datasetName}
  389. for i, start in enumerate(xranges):
  390. self.data[(yrange, yrange)][(start, start)] = i
  391. def GetInformation(self, x, y):
  392. keys = None
  393. for keyY in self.data.keys():
  394. if keyY[0] <= y <= keyY[1]:
  395. for keyX in self.data[keyY].keys():
  396. if keyX != 'name' and keyX[0] <= x <= keyX[1]:
  397. keys = keyY, keyX
  398. break
  399. if keys:
  400. break
  401. if not keys:
  402. return None
  403. datasetName = self.data[keys[0]]['name']
  404. mapIndex = self.data[keys[0]][keys[1]]
  405. return self.timeData, datasetName, mapIndex
  406. def InfoFormat(timeData, datasetName, mapIndex):
  407. """!Formats information about dataset"""
  408. text = []
  409. etype = timeData[datasetName]['elementType']
  410. if etype == 'strds':
  411. text.append(_("Space time raster dataset: %s") % datasetName)
  412. elif etype == 'stvds':
  413. text.append(_("Space time vector dataset: %s") % datasetName)
  414. elif etype == 'str3ds':
  415. text.append(_("Space time 3D raster dataset: %s") % datasetName)
  416. text.append(_("Map name: %s") % timeData[datasetName]['names'][mapIndex])
  417. text.append(_("Start time: %s") % timeData[datasetName]['start_datetime'][mapIndex])
  418. text.append(_("End time: %s") % timeData[datasetName]['end_datetime'][mapIndex])
  419. if not timeData[datasetName]['validTopology']:
  420. text.append(_("WARNING: invalid topology"))
  421. text.append(_("\nPress Del to dismiss."))
  422. return '\n'.join(text)
  423. class DataCursor(object):
  424. """A simple data cursor widget that displays the x,y location of a
  425. matplotlib artist when it is selected.
  426. Source: http://stackoverflow.com/questions/4652439/
  427. is-there-a-matplotlib-equivalent-of-matlabs-datacursormode/4674445
  428. """
  429. def __init__(self, artists, lookUp, formatFunction, tolerance=5, offsets=(-30, 30),
  430. display_all=False):
  431. """Create the data cursor and connect it to the relevant figure.
  432. "artists" is the matplotlib artist or sequence of artists that will be
  433. selected.
  434. "tolerance" is the radius (in points) that the mouse click must be
  435. within to select the artist.
  436. "offsets" is a tuple of (x,y) offsets in points from the selected
  437. point to the displayed annotation box
  438. "display_all" controls whether more than one annotation box will
  439. be shown if there are multiple axes. Only one will be shown
  440. per-axis, regardless.
  441. """
  442. self.lookUp = lookUp
  443. self.formatFunction = formatFunction
  444. self.offsets = offsets
  445. self.display_all = display_all
  446. if not cbook.iterable(artists):
  447. artists = [artists]
  448. self.artists = artists
  449. self.axes = tuple(set(art.axes for art in self.artists))
  450. self.figures = tuple(set(ax.figure for ax in self.axes))
  451. self.annotations = {}
  452. for ax in self.axes:
  453. self.annotations[ax] = self.annotate(ax)
  454. for artist in self.artists:
  455. artist.set_picker(tolerance)
  456. for fig in self.figures:
  457. fig.canvas.mpl_connect('pick_event', self)
  458. fig.canvas.mpl_connect('key_press_event', self.keyPressed)
  459. def keyPressed(self, event):
  460. """!Key pressed - hide annotation if Delete was pressed"""
  461. if event.key != 'delete':
  462. return
  463. for ax in self.axes:
  464. self.annotations[ax].set_visible(False)
  465. event.canvas.draw()
  466. def annotate(self, ax):
  467. """Draws and hides the annotation box for the given axis "ax"."""
  468. annotation = ax.annotate(self.formatFunction, xy=(0, 0), ha='center',
  469. xytext=self.offsets, textcoords='offset points', va='bottom',
  470. bbox=dict(boxstyle='round,pad=0.5', fc='yellow', alpha=0.7),
  471. arrowprops=dict(arrowstyle='->', connectionstyle='arc3,rad=0'),
  472. annotation_clip=False, multialignment='left')
  473. annotation.set_visible(False)
  474. return annotation
  475. def __call__(self, event):
  476. """Intended to be called through "mpl_connect"."""
  477. # Rather than trying to interpolate, just display the clicked coords
  478. # This will only be called if it's within "tolerance", anyway.
  479. x, y = event.mouseevent.xdata, event.mouseevent.ydata
  480. annotation = self.annotations[event.artist.axes]
  481. if x is not None:
  482. if not self.display_all:
  483. # Hide any other annotation boxes...
  484. for ann in self.annotations.values():
  485. ann.set_visible(False)
  486. # Update the annotation in the current axis..
  487. annotation.xy = x, y
  488. if 'Line2D' in str(type(event.artist)):
  489. y = event.artist.get_ydata()[0]
  490. xData = event.artist.get_xdata()
  491. x = xData[np.argmin(abs(xData - x))]
  492. info = self.lookUp.GetInformation(x, y)
  493. if not info:
  494. return
  495. text = self.formatFunction(*info)
  496. annotation.set_text(text)
  497. annotation.set_visible(True)
  498. event.canvas.draw()
  499. def run(parent=None, datasets=None):
  500. frame = TimelineFrame(parent)
  501. if datasets:
  502. frame.SetDatasets(datasets)
  503. frame.Show()
  504. if __name__ == '__main__':
  505. run()