mapdata 1.10.1__tar.gz → 2.0.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: mapdata
3
- Version: 1.10.1
3
+ Version: 2.0.2
4
4
  Summary: An interactive map and table explorer for geographic coordinates in a CSV file
5
5
  Home-page: https://osdn.net/project/mapdata/
6
6
  Author: Dreas Nielsen
@@ -27,6 +27,7 @@ Requires: pyproj
27
27
  Requires: odfpy
28
28
  Requires: openpyxl
29
29
  Requires: xlrd
30
+ Requires: matplotlib
30
31
  Requires-Python: >=3.8
31
32
  Description-Content-Type: text/markdown
32
33
  License-File: LICENSE.txt
@@ -63,6 +64,12 @@ export the map to an image file, and quit.
63
64
 
64
65
  Selected rows in the data table can be exported to a CSV or spreadsheet file.
65
66
 
67
+ Data can also be displayed in several different types of plots: box plots, scatter
68
+ plots, line charts, and counts of categorical and quantitative variables. Plots
69
+ can use either all data or only data values that are selected in the map and
70
+ table. Plots have a live connection to the data table, so when selections are
71
+ changed the plots are automatically updated.
72
+
66
73
 
67
74
  Complete documentation is at [https://mapdata.osdn.io](https://mapdata.osdn.io).
68
75
 
@@ -30,6 +30,12 @@ export the map to an image file, and quit.
30
30
 
31
31
  Selected rows in the data table can be exported to a CSV or spreadsheet file.
32
32
 
33
+ Data can also be displayed in several different types of plots: box plots, scatter
34
+ plots, line charts, and counts of categorical and quantitative variables. Plots
35
+ can use either all data or only data values that are selected in the map and
36
+ table. Plots have a live connection to the data table, so when selections are
37
+ changed the plots are automatically updated.
38
+
33
39
 
34
40
  Complete documentation is at [https://mapdata.osdn.io](https://mapdata.osdn.io).
35
41
 
@@ -3,7 +3,9 @@
3
3
  # mapdata.py
4
4
  #
5
5
  # PURPOSE
6
- # Create a simple interactive map of data points in Tkinter.
6
+ # Display a simple interactive map of data points, allowing points to
7
+ # be highlighted by clicking on the map or table or by querying,
8
+ # and allowing some simple data plots.
7
9
  #
8
10
  # COPYRIGHT AND LICENSE
9
11
  # Copyright (c) 2023, R. Dreas Nielsen
@@ -17,72 +19,13 @@
17
19
  # GNU General Public License for more details.
18
20
  # The GNU General Public License is available at <http://www.gnu.org/licenses/>
19
21
  #
20
- # NOTES
21
- # 1.
22
- #
23
22
  # AUTHOR
24
23
  # Dreas Nielsen (RDN)
25
24
  #
26
- # HISTORY
27
- # Date Remarks
28
- # ---------- -----------------------------------------------------
29
- # 2023-03-27 Created. RDN.
30
- # 2023-04-16 Added CsvFile() and treeview_table(), and began
31
- # MapUI(). RDN.
32
- # 2023-04-24 Completed MapUI() and tested to success.
33
- # 2023-04-25 Added map control buttons to zoom, focus, and un-select. RDN.
34
- # 2023-04-27 Adapted to missing coordinates in the data table and
35
- # to avoid an exception when table columns are resized but
36
- # no row selected. RDN.
37
- # 2023-04-29 Allowed single or multiple selection, and zoom to
38
- # selected markers when multiple are selected.
39
- # Added menu items to save selected table rows and to save
40
- # the map as a Postscript document. Added menu item to
41
- # change the marker used. RDN.
42
- # 2023-04-30 Reduced the set of colors that can be selected for the marker. RDN.
43
- # 2023-05-01 Added a Quit option to the file menu and removed the bottom
44
- # button frame. Implemented reading of default and custom
45
- # configuration files, and import of an .xbm symbol file.
46
- # Enabled operation as a full GUI application--note that this
47
- # requires a change to the command-line filename from an argument
48
- # to an option. When the application is started without a command
49
- # line specification, PIL emits error messages to stderr but
50
- # ordinarily this is not seen and the program's operation is
51
- # unaffected. RDN.
52
- # 2023-05-02 Changed the default label color and locations, added global
53
- # settings for the location symbol, color, font, font size,
54
- # font color, and font location, and allowed all of those to
55
- # be changed via the configuration file. RDN.
56
- # 2023-05-08 Added conversion of other coordinate reference systems to 4326. RDN.
57
- # 2023-05-09 Added ability to switch CRSs for the same data. RDN.
58
- # 2023-05-11 Fixed map controls when resizing. Added command-line arguments
59
- # to export the map and quit. RDN.
60
- # 2023-05-12 Added export of configuration settings. Cleaned up help
61
- # dialogs. RDN.
62
- # 2023-05-14 Put the map and table in a PanedWindow. RDN.
63
- # 2023-05-16 Fixed label wrapping in Windows on the CSV open dialog. Corrected
64
- # binding of Return and Escape keys to dialog button actions. Adujsted
65
- # button position in MsgDialog. RDN.
66
- # 2023-05-24 Modified dialogs, added 'Help' buttons. RDN.
67
- # 2023-05-30 Partially implemented data query dialog--functional, but no sidebar
68
- # listing column values. RDN.
69
- # 2023-05-31 Added sidebar listing column values to the data query dialog.
70
- # Double-clicking on a listed column value adds it to the end of the
71
- # SQL entry widget. RDN.
72
- # 2023-06-01 Modified to select all rows in the table with the same lat/lon as
73
- # a clicked map location, even when multiselect=0. RDN.
74
- # 2023-06-02 Added 'Invert' menu option on new 'Selections' menu. RDN.
75
- # 2023-06-03 Centered dialogs, made dialogs un-resizable, and added menu
76
- # hotkeys. RDN.
77
- # 2023-06-04 Added menu option to import data from spreadsheets. RDN.
78
- # 2023-06-08 Added menu option to import data from databases. RDN.
79
- # 2023-06-09 Added DuckDB as a data source and modifed startup to
80
- # allow spreadsheets to be selected on the command line. RDN.
81
- # 2023-06-12 Corrected Help links for spreadsheet and database dialogs. RDN.
82
25
  # ==================================================================
83
26
 
84
- version = "1.10.1"
85
- vdate = "2023-06-12"
27
+ version = "2.0.2"
28
+ vdate = "2023-06-15"
86
29
 
87
30
  copyright = "2023"
88
31
 
@@ -96,8 +39,13 @@ from configparser import ConfigParser
96
39
  import csv
97
40
  import re
98
41
  import datetime
42
+ import dateutil.parser as dateparser
99
43
  import time
44
+ import math
45
+ import collections
100
46
  import webbrowser
47
+ import threading
48
+ import queue
101
49
  import sqlite3
102
50
  import tempfile
103
51
  import tkinter as tk
@@ -113,6 +61,10 @@ import odf.number
113
61
  import odf.style
114
62
  import xlrd
115
63
  import openpyxl
64
+ import matplotlib
65
+ matplotlib.use('TkAgg')
66
+ from matplotlib.figure import Figure
67
+ from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk
116
68
 
117
69
 
118
70
  # Default name of configuration file. Files with other names may be read.
@@ -558,13 +510,105 @@ def db_colnames(tbl_hdrs):
558
510
  colnames.append(dquote(hdr))
559
511
  return colnames
560
512
 
513
+ def isint(v):
514
+ if v is None or (type(v) is str and v.strip() == ''):
515
+ return None
516
+ if type(v) == int:
517
+ return True
518
+ if type(v) == float:
519
+ return False
520
+ try:
521
+ int(v)
522
+ return True
523
+ except ValueError:
524
+ return False
525
+
561
526
  def isfloat(v):
527
+ if v is None or (type(v) is str and v.strip() == ''):
528
+ return None
562
529
  try:
563
530
  float(v)
564
531
  return True
565
532
  except ValueError:
566
533
  return False
567
534
 
535
+ def dt_type(v):
536
+ # Type of date/time: timestamp, date, time, or None
537
+ if v is None or (type(v) is str and v.strip() == ''):
538
+ return None
539
+ t = None
540
+ try:
541
+ t = dateparser.isoparse(v)
542
+ except:
543
+ try:
544
+ t = dateparser.parse(v, ignoretz=True)
545
+ except:
546
+ pass
547
+ if t is None:
548
+ return None
549
+ if t.time() == datetime.time(0,0):
550
+ return "date"
551
+ else:
552
+ return "timestamp"
553
+
554
+ def data_type(v):
555
+ # Characterizes the value v as one of a simple set of data types.
556
+ # Returns "timestamp", "date", "int", "float", or "string"
557
+ if v is None or (type(v) is str and v == ''):
558
+ return None
559
+ if isint(v):
560
+ return "int"
561
+ if isfloat(v):
562
+ return "float"
563
+ dt = dt_type(v)
564
+ if dt is not None:
565
+ return dt
566
+ return "string"
567
+
568
+ def data_type_cast_fn(data_type_str):
569
+ if data_type_str == "string":
570
+ return str
571
+ elif data_type_str == "date":
572
+ return datetime.date
573
+ elif data_type_str == "timestamp":
574
+ return datetime.timestamp
575
+ elif data_type_str == "int":
576
+ return int
577
+ elif data_type_str == "float":
578
+ return float
579
+
580
+ def common_data_type(values):
581
+ # Returns a data type common to all the values in the list.
582
+ # This is "string" unlees all types are the same.
583
+ # Null (None) values are ignored. If all values are null, return "string".
584
+ val2 = [v for v in values if v is not None and not (type(v) is str and v.strip() == '')]
585
+ if len(val2) == 0:
586
+ return "string"
587
+ else:
588
+ types = [data_type(v) for v in val2]
589
+ if len(set(types)) == 1:
590
+ return types[0]
591
+ else:
592
+ return "string"
593
+
594
+ def set_data_types(headers, rows, data_type_list):
595
+ return_queue = queue.Queue()
596
+ def eval_columns(headers, rows, return_queue):
597
+ coltypes = []
598
+ for i, colname in enumerate(headers):
599
+ datavals = [row[i] for row in rows]
600
+ non_null = [d for d in datavals if d is not None and not (type(d) is str and d.strip() == '')]
601
+ nullcount = len(datavals) - len(non_null)
602
+ uniquevals = len(set(non_null))
603
+ coltypes.append((colname, common_data_type(datavals), nullcount, uniquevals))
604
+ return_queue.put(coltypes)
605
+ t = threading.Thread(target=eval_columns, args=(headers, rows, return_queue))
606
+ t.start()
607
+ return (t, return_queue)
608
+
609
+ # Translations to SQLite type affinity names
610
+ sqlite_type_x = {'int': 'INTEGER', 'float': 'REAL', 'string': 'TEXT', 'timestamp': 'TEXT', 'date': 'TEXT'}
611
+
568
612
  def center_window(win):
569
613
  win.update_idletasks()
570
614
  m = re.match(r"(\d+)x(\d+)\+(-?\d+)\+(-?\d+)", win.geometry())
@@ -577,6 +621,11 @@ def center_window(win):
577
621
  ypos = (sht/2) - (wht/2)
578
622
  win.geometry("%dx%d+%d+%d" % (wwd, wht, xpos, ypos))
579
623
 
624
+ def raise_window(win):
625
+ win.attributes('-topmost', 1)
626
+ win.attributes('-topmost', 0)
627
+
628
+
580
629
 
581
630
  class MapUI(object):
582
631
  def __init__(self, src_name, message, lat_col, lon_col, crs=4326, sheet=None,
@@ -593,7 +642,6 @@ class MapUI(object):
593
642
  else:
594
643
  # src_name is a filename, either CSV or spreadsheet
595
644
  fn, ext = os.path.splitext(src_name)
596
- print("data source extension = %s" % ext)
597
645
  if ext.lower() == ".csv":
598
646
  try:
599
647
  headers, rows = file_data(src_name)
@@ -637,6 +685,8 @@ class MapUI(object):
637
685
  self.max_lat = None
638
686
  self.min_lon = None
639
687
  self.max_lon = None
688
+ # List of PlotDialog objects, so they can have data pushed or be deleted.
689
+ self.plot_list = []
640
690
  # Database connection is set in 'add_data()'; variables are initialized here
641
691
  self.dbtmpdir = None
642
692
  self.dbname = None
@@ -795,8 +845,12 @@ class MapUI(object):
795
845
  new_marker = self.map_widget.set_marker(lat_val, lon_val, icon=self.sel_marker_icon)
796
846
  new_markers.append(new_marker)
797
847
  for m in self.sel_map_markers:
798
- self.map_widget.delete(m)
848
+ m.delete()
799
849
  self.sel_map_markers = new_markers
850
+ else:
851
+ for m in self.sel_map_markers:
852
+ m.delete()
853
+ self.update_plot_data()
800
854
  self.set_status()
801
855
  def set_sel_marker(self, symbol, color):
802
856
  select_marker = tk.BitmapImage(data=icon_xbm[symbol], foreground=color)
@@ -879,7 +933,12 @@ class MapUI(object):
879
933
  self.loc_map_markers.append(mkr)
880
934
  else:
881
935
  self.missing_latlon += 1
936
+ self.update_plot_data()
882
937
  def add_data(self, rows, headers, lat_col, lon_col, label_col, symbol_col, color_col):
938
+ # Launch separate process to determine data types
939
+ self.data_types = []
940
+ (dt_thread, dt_queue) = set_data_types(headers, rows, self.data_types)
941
+ # Re-set all data-specific variables and widgets
883
942
  self.lat_col = lat_col
884
943
  self.lon_col = lon_col
885
944
  self.src_lat_col = lat_col
@@ -969,6 +1028,9 @@ class MapUI(object):
969
1028
  cur.close()
970
1029
  # Initial value for user-entered WHERE clause
971
1030
  self.whereclause = ""
1031
+ # Save data types for use in column selection for plotting
1032
+ dt_thread.join()
1033
+ self.data_types = dt_queue.get(block=True)
972
1034
  # Return frame and data table
973
1035
  return tframe, tdata
974
1036
  def remove_data(self):
@@ -977,6 +1039,7 @@ class MapUI(object):
977
1039
  while len(self.loc_map_markers) > 0:
978
1040
  self.loc_map_markers.pop().delete()
979
1041
  self.map_widget.delete_all_marker()
1042
+ self.close_all_plots()
980
1043
  self.tableframe.destroy()
981
1044
  self.tbl.destroy()
982
1045
  def set_tbl_selectmode(self):
@@ -1023,15 +1086,6 @@ class MapUI(object):
1023
1086
  self.replace_data(rows, headers, lat_col, lon_col, id_col, sym_col, col_col)
1024
1087
  if desc is not None and desc != '':
1025
1088
  self.msg_label['text'] = desc
1026
- #def first_data_file(self):
1027
- # dfd = DataFileDialog()
1028
- # fn, id_col, lat_col, lon_col, crs, sym_col, col_col, title = dfd.get_datafile()
1029
- # if fn is None or fn == '':
1030
- # self.cancel()
1031
- # else:
1032
- # self.crs = crs
1033
- # headers, rows = file_data(fn)
1034
- # self.tableframe, self.tbl = self.add_data(rows, headers, lat_col, lon_col, id_col, sym_col, col_col)
1035
1089
  def zoom_full(self):
1036
1090
  self.map_widget.fit_bounding_box((self.max_lat, self.min_lon), (self.min_lat, self.max_lon))
1037
1091
  def zoom_selected(self):
@@ -1061,6 +1115,7 @@ class MapUI(object):
1061
1115
  self.map_widget.delete(m)
1062
1116
  self.tbl.selection_remove(*self.tbl.selection())
1063
1117
  self.sel_map_markers = []
1118
+ self.update_plot_data()
1064
1119
  self.set_status()
1065
1120
  def change_basemap(self, *args):
1066
1121
  new_map = self.basemap_var.get()
@@ -1091,6 +1146,7 @@ class MapUI(object):
1091
1146
  new_marker = self.map_widget.set_marker(lat, lon, icon=self.sel_marker_icon)
1092
1147
  if not new_marker in self.sel_map_markers:
1093
1148
  self.sel_map_markers.append(new_marker)
1149
+ self.update_plot_data()
1094
1150
  self.set_status()
1095
1151
  def set_status(self):
1096
1152
  statusmsg = " %d rows" % self.table_row_count
@@ -1101,6 +1157,45 @@ class MapUI(object):
1101
1157
  if self.multiselect_var.get() == "1":
1102
1158
  statusmsg = statusmsg + " | Ctrl-click to select multiple rows"
1103
1159
  self.tblframe.statusbar.config(text = statusmsg)
1160
+
1161
+ def get_all_data(self, column_list):
1162
+ # Plotting support. Return all data for the specified columns as a list of column-oriented lists.
1163
+ res = []
1164
+ for c in column_list:
1165
+ i = self.headers.index(c)
1166
+ res.append([row[i] for row in self.rows])
1167
+ return res
1168
+ def get_sel_data(self, column_list):
1169
+ # Plotting support. Return data from selected rows for the specified columns, as a list of lists.
1170
+ res = [[] for _ in column_list]
1171
+ indices = [self.headers.index(c) for c in column_list]
1172
+ for sel_row in self.tbl.selection():
1173
+ datarow = self.tbl.item(sel_row)["values"]
1174
+ for i, index in enumerate(indices):
1175
+ res[i].append(datarow[index])
1176
+ return res
1177
+ def update_plot_data(self, all_data=False):
1178
+ for plot in self.plot_list:
1179
+ if all_data or plot.sel_only_var.get() == "1":
1180
+ plot.q_redraw()
1181
+ def remove_plot(self, plot_obj):
1182
+ # For use by the plot 'do_close()' method.
1183
+ try:
1184
+ self.plot_list.remove(plot_obj)
1185
+ except:
1186
+ pass
1187
+ def close_plot(self, plot_obj):
1188
+ try:
1189
+ plot_obj.do_close()
1190
+ self.remove_plot()
1191
+ except:
1192
+ pass
1193
+ def close_all_plots(self):
1194
+ while len(self.plot_list) > 0:
1195
+ self.plot_list[0].do_close()
1196
+ # The callback will remove the plot.
1197
+ self.plot_list = []
1198
+
1104
1199
  def change_crs(self):
1105
1200
  crsdlg = NewCrsDialog(self.crs)
1106
1201
  new_crs = crsdlg.get_crs()
@@ -1187,11 +1282,13 @@ class MapUI(object):
1187
1282
  tbl_menu = tk.Menu(mnu, tearoff=0)
1188
1283
  map_menu = tk.Menu(mnu, tearoff=0)
1189
1284
  sel_menu = tk.Menu(mnu, tearoff=0)
1285
+ plot_menu = tk.Menu(mnu, tearoff=0)
1190
1286
  help_menu = tk.Menu(mnu, tearoff=0)
1191
1287
  mnu.add_cascade(label="File", menu=file_menu, underline=0)
1192
1288
  mnu.add_cascade(label="Table", menu=tbl_menu, underline=0)
1193
1289
  mnu.add_cascade(label="Map", menu=map_menu, underline=0)
1194
1290
  mnu.add_cascade(label="Selections", menu=sel_menu, underline=0)
1291
+ mnu.add_cascade(label="Plot", menu=plot_menu, underline=0)
1195
1292
  mnu.add_cascade(label="Help", menu=help_menu, underline=0)
1196
1293
  def save_table():
1197
1294
  if table_object.selection():
@@ -1309,6 +1406,11 @@ class MapUI(object):
1309
1406
  f.write("label_size=%s\n" % label_size)
1310
1407
  f.write("label_bold=%s\n" % ('No' if not label_bold else 'Yes'))
1311
1408
  f.write("label_position=%s\n" % label_position)
1409
+ def show_data_types():
1410
+ dlg = MsgDialog("Data Types", "Data types, data completeness, and number of unique\nnon-missing values for columns of the data table:")
1411
+ tframe, tdata = treeview_table(dlg.content_frame, self.data_types, ["Column", "Type", "Missing", "Unique"], "browse")
1412
+ tframe.grid(row=0, column=0, sticky=tk.NSEW)
1413
+ dlg.show()
1312
1414
  def run_query():
1313
1415
  dlg = QueryDialog(self.headers, self.db, self.whereclause)
1314
1416
  whereclause = dlg.get_where()
@@ -1343,6 +1445,11 @@ class MapUI(object):
1343
1445
  self.tbl.selection_set(tuple(new_selections))
1344
1446
  self.mark_map(None)
1345
1447
  self.set_status()
1448
+ def new_plot():
1449
+ dlg = PlotDialog(self, self.data_types)
1450
+ self.plot_list.append(dlg)
1451
+ dlg.show
1452
+
1346
1453
  def online_help():
1347
1454
  webbrowser.open("https://mapdata.osdn.io", new=2, autoraise=True)
1348
1455
  def show_config_files():
@@ -1376,6 +1483,7 @@ Copyright %s, R Dreas Nielsen
1376
1483
  file_menu.add_command(label="Quit", command = self.cancel, underline=0)
1377
1484
  tbl_menu.add_command(label="Un-select all", command = self.unselect_map, underline=0)
1378
1485
  tbl_menu.add_command(label="Export selected", command = save_table, underline=1)
1486
+ tbl_menu.add_command(label="Data types", command = show_data_types, underline=5)
1379
1487
  map_menu.add_command(label="Change marker", command = change_marker, underline=7)
1380
1488
  map_menu.add_command(label="Zoom selected", command = self.zoom_selected, underline=5)
1381
1489
  map_menu.add_command(label="Zoom full", command = self.zoom_full, underline=5)
@@ -1386,6 +1494,8 @@ Copyright %s, R Dreas Nielsen
1386
1494
  sel_menu.add_command(label="Invert", command = invert_selections, underline=0)
1387
1495
  sel_menu.add_command(label="Un-select all", command = self.unselect_map, underline=0)
1388
1496
  sel_menu.add_command(label="Set by query", command = run_query, underline=7)
1497
+ plot_menu.add_command(label="New", command = new_plot, underline=0)
1498
+ plot_menu.add_command(label="Close all", command = self.close_all_plots, underline=0)
1389
1499
  help_menu.add_command(label="Online help", command = online_help, underline=7)
1390
1500
  help_menu.add_command(label="Config files", command = show_config_files, underline=0)
1391
1501
  help_menu.add_command(label="About", command = show_about, underline=0)
@@ -1749,10 +1859,11 @@ class ImportSpreadsheetDialog(object):
1749
1859
  def w1_next():
1750
1860
  # Open spreadsheet, get sheet names
1751
1861
  fn, ext = os.path.splitext(self.fn_var.get())
1862
+ ext = ext.lower()
1752
1863
  try:
1753
- if ext.lower() == '.ods':
1864
+ if ext == '.ods':
1754
1865
  sso = OdsFile()
1755
- elif ext.lower() == '.xlsx':
1866
+ elif ext == '.xlsx':
1756
1867
  sso = XlsxFile()
1757
1868
  else:
1758
1869
  sso = XlsFile()
@@ -1762,10 +1873,17 @@ class ImportSpreadsheetDialog(object):
1762
1873
  sso.open(self.fn_var.get())
1763
1874
  self.sheet_list = sso.sheetnames()
1764
1875
  self.sheet_sel["values"] = self.sheet_list
1765
- try:
1766
- sso.close()
1767
- except:
1768
- pass
1876
+ if ext in ('.ods', '.xlsx'):
1877
+ try:
1878
+ sso.close()
1879
+ except:
1880
+ pass
1881
+ else:
1882
+ try:
1883
+ sso.release_resources()
1884
+ del sso
1885
+ except:
1886
+ pass
1769
1887
  wiz2_frame.lift()
1770
1888
 
1771
1889
  w1btn_frame = tk.Frame(wiz1_frame, borderwidth=3, relief=tk.RIDGE)
@@ -2525,9 +2643,366 @@ class QueryDialog(object):
2525
2643
  return None
2526
2644
 
2527
2645
 
2528
- def make_top(win):
2529
- win.attributes('-topmost', 1)
2530
- win.attributes('-topmost', 0)
2646
+ class PlotDialog(object):
2647
+ def __init__(self, parent, column_specs):
2648
+ self.parent = parent
2649
+ self.column_specs = column_specs
2650
+ self.dataset = None
2651
+ self.data_labels = None
2652
+ self.plot_data = None
2653
+ self.plot_data_labels = None
2654
+ self.dlg = tk.Toplevel()
2655
+ self.dlg.title("Plot")
2656
+ self.dlg.columnconfigure(0, weight=1)
2657
+
2658
+ # Message
2659
+ prompt_frame = tk.Frame(self.dlg)
2660
+ prompt_frame.grid(row=0, column=0, sticky=tk.NSEW, pady=(3,3))
2661
+ prompt_frame.columnconfigure(0, weight=1)
2662
+ msg_lbl = ttk.Label(prompt_frame, width=70, text="Select the type of plot, columns for X and Y data, and whether to show all data or only selected data.")
2663
+ msg_lbl.grid(row=0, column=0, sticky=tk.W, padx=(6,6), pady=(3,3))
2664
+ def wrap_msg(event):
2665
+ msg_lbl.configure(wraplength=event.width - 5)
2666
+ msg_lbl.bind("<Configure>", wrap_msg)
2667
+
2668
+ # Controls
2669
+ ctrl_frame = tk.Frame(self.dlg)
2670
+ ctrl_frame.grid(row=1, column=0, sticky=tk.N+tk.EW)
2671
+
2672
+ self.type_var = tk.StringVar(ctrl_frame, "")
2673
+ type_lbl = ttk.Label(ctrl_frame, text="Plot type:")
2674
+ type_lbl.grid(row=0, column=0, sticky=tk.E, padx=(6,3), pady=(3,3))
2675
+ self.type_sel = ttk.Combobox(ctrl_frame, state="readonly", textvariable=self.type_var, width=20,
2676
+ values=["Box plot", "Category counts", "Line plot", "Scatter plot", "Value counts", "Y range plot"])
2677
+ self.type_sel.grid(row=0, column=1, sticky=tk.W, padx=(3,6), pady=(3,3))
2678
+ self.type_sel.bind("<<ComboboxSelected>>", self.set_xy)
2679
+
2680
+ self.sel_only_var = tk.StringVar(ctrl_frame, "0")
2681
+ self.sel_only_ck = ttk.Checkbutton(ctrl_frame, text="Selected data only", command=self.q_redraw, variable=self.sel_only_var,
2682
+ onvalue="1", offvalue="0")
2683
+ self.sel_only_ck.grid(row=1, column=0, columnspan=2, sticky=tk.W, padx=(6,6), pady=(3,3))
2684
+
2685
+ self.x_var = tk.StringVar(ctrl_frame, "")
2686
+ x_lbl = ttk.Label(ctrl_frame, text="X column:")
2687
+ x_lbl.grid(row=0, column=2, sticky=tk.E, padx=(6,3), pady=(3,3))
2688
+ self.x_sel = ttk.Combobox(ctrl_frame, state="disabled", textvariable=self.x_var, width=20)
2689
+ self.x_sel.grid(row=0, column=3, sticky=tk.W, padx=(3,6), pady=(3,3))
2690
+ self.x_sel.bind("<<ComboboxSelected>>", self.q_redraw)
2691
+
2692
+ self.y_var = tk.StringVar(ctrl_frame, "")
2693
+ y_lbl = ttk.Label(ctrl_frame, text="Y column:")
2694
+ y_lbl.grid(row=1, column=2, sticky=tk.E, padx=(6,3), pady=(3,3))
2695
+ self.y_sel = ttk.Combobox(ctrl_frame, state="disabled", textvariable=self.y_var, width=20)
2696
+ self.y_sel.grid(row=1, column=3, sticky=tk.W, padx=(3,6), pady=(3,3))
2697
+ self.y_sel.bind("<<ComboboxSelected>>", self.q_redraw)
2698
+
2699
+ self.xlog_var = tk.StringVar(ctrl_frame, "0")
2700
+ self.xlog_ck = ttk.Checkbutton(ctrl_frame, text="Log X", state="disabled", command=self.q_redraw, variable=self.xlog_var,
2701
+ onvalue="1", offvalue="0")
2702
+ self.xlog_ck.grid(row=0, column=4, sticky=tk.W, padx=(6,6), pady=(3,3))
2703
+
2704
+ self.ylog_var = tk.StringVar(ctrl_frame, "0")
2705
+ self.ylog_ck = ttk.Checkbutton(ctrl_frame, text="Log Y", state="disabled", command=self.q_redraw, variable=self.ylog_var,
2706
+ onvalue="1", offvalue="0")
2707
+ self.ylog_ck.grid(row=1, column=4, sticky=tk.W, padx=(6,6), pady=(3,3))
2708
+
2709
+ # Plot
2710
+ self.content_frame = tk.Frame(self.dlg, borderwidth=3, relief=tk.RIDGE)
2711
+ self.content_frame.grid(row=2, column=0, sticky=tk.NSEW)
2712
+ self.dlg.rowconfigure(2, weight=1)
2713
+ self.dlg.columnconfigure(0, weight=1)
2714
+ self.content_frame.rowconfigure(0, weight=1)
2715
+ self.content_frame.columnconfigure(0, weight=1)
2716
+ self.plotfig = Figure(dpi=100)
2717
+ self.plotfig.set_figheight(5)
2718
+ self.plotfig_canvas = FigureCanvasTkAgg(self.plotfig, self.content_frame)
2719
+ self.plot_nav = NavigationToolbar2Tk(self.plotfig_canvas, self.content_frame)
2720
+ self.plot_axes = self.plotfig.add_subplot(111)
2721
+ self.plotfig_canvas.get_tk_widget().pack(side=tk.TOP, fill=tk.BOTH, expand=1)
2722
+ self.plot_nav.update()
2723
+
2724
+ # Buttons
2725
+ btn_frame = tk.Frame(self.dlg, borderwidth=3, relief=tk.RIDGE)
2726
+ btn_frame.columnconfigure(0, weight=1)
2727
+ btn_frame.grid(row=3, column=0, sticky=tk.EW, pady=(3,3))
2728
+ btn_frame.columnconfigure(0, weight=0)
2729
+ btn_frame.columnconfigure(1, weight=0)
2730
+ btn_frame.columnconfigure(2, weight=0)
2731
+ btn_frame.columnconfigure(3, weight=1)
2732
+ self.canceled = False
2733
+ self.help_btn = ttk.Button(btn_frame, text="Help", command=self.do_help)
2734
+ self.help_btn.grid(row=0, column=0, sticky=tk.W, padx=(6,3))
2735
+ self.data_btn = ttk.Button(btn_frame, text="Source Data", state="disabled", command=self.show_data)
2736
+ self.data_btn.grid(row=0, column=1, sticky=tk.W, padx=(3,3))
2737
+ self.plot_data_btn = ttk.Button(btn_frame, text="Plot Data", state="disabled", command=self.show_plot_data)
2738
+ self.plot_data_btn.grid(row=0, column=2, sticky=tk.W, padx=(3,6))
2739
+ close_btn = ttk.Button(btn_frame, text="Close", command=self.do_close)
2740
+ close_btn.grid(row=0, column=3, sticky=tk.E, padx=(6,6))
2741
+ self.dlg.bind("<Escape>", self.do_close)
2742
+ center_window(self.dlg)
2743
+ raise_window(self.dlg)
2744
+
2745
+ def do_help(self):
2746
+ webbrowser.open("https://mapdata.osdn.io/dialogs.html#plot-dialog", new=2, autoraise=True)
2747
+
2748
+ def show_data(self):
2749
+ # Show data that have been collected for plotting, but not summarized as needed for a particular plot type.
2750
+ if self.dataset is not None:
2751
+ dlg = MsgDialog("Source Data", "Original data:")
2752
+ variables = len(self.dataset)
2753
+ rowwise_data = []
2754
+ for i in range(len(self.dataset[0])):
2755
+ row = []
2756
+ for j in range(variables):
2757
+ row.append(self.dataset[j][i])
2758
+ rowwise_data.append(row)
2759
+ tframe, tdata = treeview_table(dlg.content_frame, rowwise_data, self.data_labels)
2760
+ tframe.grid(row=0, column=0, sticky=tk.NSEW)
2761
+ dlg.show()
2762
+
2763
+ def show_plot_data(self):
2764
+ # Show data that have been collected for plotting, but not summarized as needed for a particular plot type.
2765
+ if self.plot_data is not None:
2766
+ dlg = MsgDialog("Data for Plotting", "Data to be plotted:")
2767
+ variables = len(self.plot_data)
2768
+ rowwise_data = []
2769
+ max_data_len = max([len(self.plot_data[i]) for i in range(variables)])
2770
+ for i in range(max_data_len):
2771
+ row = []
2772
+ for j in range(variables):
2773
+ try:
2774
+ # Boxplot data are not necessarily a full matrix
2775
+ row.append(self.plot_data[j][i])
2776
+ except:
2777
+ row.append(None)
2778
+ rowwise_data.append(row)
2779
+ tframe, tdata = treeview_table(dlg.content_frame, rowwise_data, self.plot_data_labels)
2780
+ tframe.grid(row=0, column=0, sticky=tk.NSEW)
2781
+ dlg.show()
2782
+
2783
+ def set_xy(self, *args):
2784
+ # Enable X and Y value selection, and set values based on plot type and column types.
2785
+ # Conditionally (re)draw the plot.
2786
+ self.plotfig.clear()
2787
+ self.plot_axes = self.plotfig.add_subplot(111)
2788
+ self.plotfig_canvas.draw()
2789
+ self.dataset = None
2790
+ self.data_labels = None
2791
+ self.plot_data = None
2792
+ self.plot_data_labels = None
2793
+ self.data_btn["state"] = "disabled"
2794
+ self.plot_data_btn["state"] = "disabled"
2795
+ categ_columns = [c[0] for c in self.column_specs if c[1] == "string"]
2796
+ quant_columns = [c[0] for c in self.column_specs if c[1] != "string"]
2797
+ plot_type = self.type_var.get()
2798
+ self.x_var.set('')
2799
+ self.y_var.set('')
2800
+ if plot_type == "Category counts":
2801
+ self.x_sel["values"] = categ_columns
2802
+ self.xlog_ck["state"] = "disabled"
2803
+ self.x_sel["state"] = "readonly"
2804
+ self.y_sel["state"] = "disabled"
2805
+ self.ylog_ck["state"] = "disabled"
2806
+ elif plot_type == "Value counts":
2807
+ self.x_sel["values"] = quant_columns
2808
+ self.xlog_ck["state"] = "normal"
2809
+ self.x_sel["state"] = "readonly"
2810
+ self.y_sel["state"] = "disabled"
2811
+ self.ylog_ck["state"] = "disabled"
2812
+ elif plot_type == "Box plot":
2813
+ self.x_sel["values"] = categ_columns
2814
+ self.xlog_ck["state"] = "disabled"
2815
+ self.y_sel["values"] = quant_columns
2816
+ self.x_sel["state"] = "readonly"
2817
+ self.y_sel["state"] = "readonly"
2818
+ self.ylog_ck["state"] = "normal"
2819
+ else:
2820
+ self.x_sel["values"] = quant_columns
2821
+ self.xlog_ck["state"] = "normal"
2822
+ self.y_sel["values"] = quant_columns
2823
+ self.x_sel["state"] = "readonly"
2824
+ self.y_sel["state"] = "readonly"
2825
+ self.ylog_ck["state"] = "normal"
2826
+
2827
+ def q_redraw(self, *args):
2828
+ # Conditionally (re)draw the plot.
2829
+ plot_type = self.type_var.get()
2830
+ can_redraw = (plot_type in ("Category counts", "Value counts") and self.x_var.get() != '') \
2831
+ or (plot_type in ("Scatter plot", "Line plot", "Box plot", "Y range plot") and self.x_var.get() != '' and self.y_var.get() != '')
2832
+ if can_redraw:
2833
+ self.plotfig.clear()
2834
+ self.plot_axes = self.plotfig.add_subplot(111)
2835
+ self.plotfig_canvas.draw()
2836
+ self.get_data()
2837
+ if self.dataset is not None:
2838
+ self.redraw()
2839
+
2840
+ def get_data(self):
2841
+ self.data_btn["state"] = "disabled"
2842
+ self.plot_data_btn["state"] = "disabled"
2843
+ self.dataset = None
2844
+ plot_type = self.type_var.get()
2845
+ column_list = [self.x_var.get()]
2846
+ if self.y_var.get() != '':
2847
+ column_list.append(self.y_var.get())
2848
+ if self.sel_only_var.get() == "1":
2849
+ dataset = self.parent.get_sel_data(column_list)
2850
+ else:
2851
+ dataset = self.parent.get_all_data(column_list)
2852
+ if dataset is None or len(dataset[0]) == 0:
2853
+ self.dataset = None
2854
+ self.data_labels = None
2855
+ self.plot_data = None
2856
+ self.plot_data_labels = None
2857
+ self.data_btn["state"] = "disabled"
2858
+ self.plot_data_btn["state"] = "disabled"
2859
+ else:
2860
+ # Remove missing data
2861
+ column_indexes = range(len(dataset))
2862
+ clean_data = [[] for _ in dataset]
2863
+ for i in range(len(dataset[0])):
2864
+ ok = True
2865
+ for col in column_indexes:
2866
+ if dataset[col][i] is None or dataset[col][i] == '':
2867
+ ok = False
2868
+ if ok:
2869
+ for col in column_indexes:
2870
+ clean_data[col].append(dataset[col][i])
2871
+ dataset = None
2872
+ # Convert quantitative data types
2873
+ if plot_type != "Category counts":
2874
+ x_data_type = [cs[1] for cs in self.column_specs if cs[0] == self.x_var.get()][0]
2875
+ cast_fn = data_type_cast_fn(x_data_type)
2876
+ for i in range(len(clean_data[0])):
2877
+ clean_data[0][i] = cast_fn(clean_data[0][i])
2878
+ if self.y_sel["state"] != "disabled" and self.y_var.get() != "" and len(clean_data) > 1:
2879
+ y_data_type = [cs[1] for cs in self.column_specs if cs[0] == self.y_var.get()][0]
2880
+ cast_fn = data_type_cast_fn(y_data_type)
2881
+ for i in range(len(clean_data[1])):
2882
+ clean_data[1][i] = cast_fn(clean_data[1][i])
2883
+ # Log-transform data if specified.
2884
+ if (self.xlog_ck["state"] != "disabled" and self.xlog_var.get() == "1") or (self.ylog_ck["state"] != "disabled" and self.ylog_var.get() == "1" and len(clean_data) > 1):
2885
+ log_data = [[] for _ in clean_data]
2886
+ log_error = False
2887
+ if self.xlog_ck["state"] != "disabled" and self.xlog_var.get() == "1":
2888
+ for i in range(len(clean_data[0])):
2889
+ try:
2890
+ log_data[0].append(math.log10(clean_data[0][i]))
2891
+ except:
2892
+ log_error = True
2893
+ break
2894
+ else:
2895
+ log_data[0] = clean_data[0]
2896
+ if not log_error and self.ylog_ck["state"] != "disabled" and self.ylog_var.get() == "1" and len(clean_data) > 1:
2897
+ for i in range(len(clean_data[1])):
2898
+ try:
2899
+ log_data[1].append(math.log10(clean_data[1][i]))
2900
+ except:
2901
+ log_error = True
2902
+ break
2903
+ else:
2904
+ if len(clean_data) > 1:
2905
+ log_data[1] = clean_data[1]
2906
+ if not log_error:
2907
+ clean_data = log_data
2908
+ log_data = None
2909
+ self.dataset = clean_data
2910
+ if self.y_var.get() != '':
2911
+ self.data_labels = [self.x_var.get(), self.y_var.get()]
2912
+ else:
2913
+ self.data_labels = [self.x_var.get()]
2914
+ self.data_btn["state"] = "normal"
2915
+ # Summarize and sort the data as needed for each type of plot.
2916
+ if plot_type == "Category counts":
2917
+ # Count of values for each X, ordered by X
2918
+ counter = collections.Counter(self.dataset[0])
2919
+ x_vals = list(counter.keys())
2920
+ x_vals.sort()
2921
+ x_counts = [counter[k] for k in x_vals]
2922
+ self.plot_data = [x_vals, x_counts]
2923
+ self.plot_data_labels = [self.x_var.get(), "Count"]
2924
+ elif plot_type == "Box plot":
2925
+ # A list of Y values for each X value
2926
+ x_vals = list(set(self.dataset[0]))
2927
+ ds = list(zip(self.dataset[0], self.dataset[1]))
2928
+ plot_data = []
2929
+ for x in x_vals:
2930
+ plot_data.append([d[1] for d in ds if d[0] == x])
2931
+ self.plot_data = plot_data
2932
+ self.plot_data_labels = x_vals
2933
+ elif plot_type == "Y range plot":
2934
+ # Min and max Y for each X
2935
+ x_vals = list(set(self.dataset[0]))
2936
+ x_vals.sort()
2937
+ y_vals = [[None, None]] * len(x_vals)
2938
+ plotdata = dict(zip(x_vals, y_vals))
2939
+ for i in range(len(self.dataset[0])):
2940
+ x = self.dataset[0][i]
2941
+ y = self.dataset[1][i]
2942
+ y_vals = plotdata[x]
2943
+ if y_vals[0] is None or y < y_vals[0]:
2944
+ plotdata[x][0] = y
2945
+ if y_vals[1] is None or y > y_vals[1]:
2946
+ plotdata[x][1] = y
2947
+ y1 = [plotdata[x][0] for x in x_vals]
2948
+ y2 = [plotdata[x][1] for x in x_vals]
2949
+ self.plot_data = [x_vals, y1, y2]
2950
+ self.plot_data_labels = [self.x_var.get(), self.y_var.get() + " min", self.y_var.get() + " max"]
2951
+ elif plot_type == "Line plot":
2952
+ # Sort by X
2953
+ ds = list(zip(self.dataset[0], self.dataset[1]))
2954
+ ds.sort()
2955
+ ds2 = list(zip(*ds))
2956
+ self.plot_data = [list(ds2[0]), list(ds2[1])]
2957
+ self.plot_data_labels = self.data_labels
2958
+ elif plot_type in ("Value counts", "Scatter plot", "Y range plot"):
2959
+ # No special preparation
2960
+ self.plot_data = self.dataset
2961
+ self.plot_data_labels = self.data_labels
2962
+ self.plot_data_btn["state"] = "normal"
2963
+
2964
+ def redraw(self):
2965
+ #self.plotfig.clear()
2966
+ #self.plot_axes = self.plotfig.add_subplot(111)
2967
+ #self.plotfig_canvas.draw()
2968
+ plot_type = self.type_var.get()
2969
+ if self.plot_data is not None and len(self.plot_data[0]) > 0:
2970
+ if plot_type == "Category counts":
2971
+ self.plot_axes.bar(self.plot_data[0], self.plot_data[1])
2972
+ self.plot_axes.set_xlabel(self.x_var.get())
2973
+ self.plot_axes.set_ylabel("Counts")
2974
+ elif plot_type == "Value counts":
2975
+ self.plot_axes.hist(self.plot_data[0])
2976
+ self.plot_axes.set_xlabel(self.x_var.get())
2977
+ self.plot_axes.set_ylabel("Counts")
2978
+ elif plot_type == "Scatter plot":
2979
+ self.plot_axes.scatter(self.plot_data[0], self.plot_data[1])
2980
+ self.plot_axes.set_xlabel(self.x_var.get())
2981
+ self.plot_axes.set_ylabel(self.y_var.get())
2982
+ elif plot_type == "Line plot":
2983
+ self.plot_axes.plot(self.plot_data[0], self.plot_data[1])
2984
+ self.plot_axes.set_xlabel(self.x_var.get())
2985
+ self.plot_axes.set_ylabel(self.y_var.get())
2986
+ elif plot_type == "Y range plot":
2987
+ self.plot_axes.fill_between(self.plot_data[0], self.plot_data[1], self.plot_data[2])
2988
+ self.plot_axes.set_xlabel(self.x_var.get())
2989
+ self.plot_axes.set_ylabel(self.y_var.get())
2990
+ elif plot_type == "Box plot":
2991
+ self.plot_axes.boxplot(self.plot_data, labels=self.plot_data_labels)
2992
+ self.plot_axes.set_xlabel(self.x_var.get())
2993
+ self.plot_axes.set_ylabel(self.y_var.get())
2994
+ self.plotfig_canvas.draw()
2995
+ self.plot_nav.update()
2996
+
2997
+ def do_close(self, *args):
2998
+ self.parent.remove_plot(self)
2999
+ self.dlg.destroy()
3000
+ def show(self):
3001
+ self.dlg.update_idle_tasks()
3002
+ self.dlg.minsize(width=500, height=500)
3003
+ self.dlg.wait_window(self.dlg)
3004
+
3005
+
2531
3006
 
2532
3007
  class MsgDialog(object):
2533
3008
  #def __init__(self, title, message, width=400, height=400):
@@ -2538,9 +3013,11 @@ class MsgDialog(object):
2538
3013
  prompt_frame.grid(row=0, column=0, sticky=tk.NSEW, pady=(3,3))
2539
3014
  msg_lbl = ttk.Label(prompt_frame, text=message)
2540
3015
  msg_lbl.grid(row=0, column=0, padx=(6,6), pady=(3,3))
3016
+ self.content_frame = tk.Frame(self.dlg)
3017
+ self.content_frame.grid(row=1, column=0, sticky=tk.NSEW)
2541
3018
  btn_frame = tk.Frame(self.dlg, borderwidth=3, relief=tk.RIDGE)
2542
3019
  btn_frame.columnconfigure(0, weight=1)
2543
- btn_frame.grid(row=1, column=0, sticky=tk.EW, pady=(3,3))
3020
+ btn_frame.grid(row=2, column=0, sticky=tk.EW, pady=(3,3))
2544
3021
  btn_frame.columnconfigure(0, weight=1)
2545
3022
  # Buttons
2546
3023
  self.canceled = False
@@ -2553,7 +3030,7 @@ class MsgDialog(object):
2553
3030
  def show(self):
2554
3031
  self.dlg.grab_set()
2555
3032
  center_window(self.dlg)
2556
- make_top(self.dlg)
3033
+ raise_window(self.dlg)
2557
3034
  self.dlg.resizable(False, False)
2558
3035
  self.dlg.wait_window(self.dlg)
2559
3036
 
@@ -2619,7 +3096,7 @@ class SelDataSrcDialog(object):
2619
3096
  def select(self):
2620
3097
  self.dlg.grab_set()
2621
3098
  center_window(self.dlg)
2622
- make_top(self.dlg)
3099
+ raise_window(self.dlg)
2623
3100
  self.dlg.resizable(False, False)
2624
3101
  self.dlg.wait_window(self.dlg)
2625
3102
  return self.rv
@@ -3025,7 +3502,7 @@ class XlsFile(object):
3025
3502
  self.wbk = xlrd.open_workbook(filename, encoding_override=self.encoding)
3026
3503
  self.datemode = self.wbk.datemode
3027
3504
  def sheetnames(self):
3028
- return self.wbk.sheets()
3505
+ return self.wbk.sheet_names()
3029
3506
  def sheet_named(self, sheetname):
3030
3507
  # Return the sheet with the matching name. If the name is actually an integer,
3031
3508
  # return that sheet number.
@@ -3185,7 +3662,8 @@ class XlsxFile(object):
3185
3662
  def xls_data(filename, sheetname, junk_header_rows, encoding=None):
3186
3663
  # Returns the data from the specified worksheet as a list of headers and a list of lists of rows.
3187
3664
  root, ext = os.path.splitext(filename)
3188
- if ext == "xls":
3665
+ ext = ext.lower()
3666
+ if ext == ".xls":
3189
3667
  wbk = XlsFile()
3190
3668
  else:
3191
3669
  wbk = XlsxFile()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: mapdata
3
- Version: 1.10.1
3
+ Version: 2.0.2
4
4
  Summary: An interactive map and table explorer for geographic coordinates in a CSV file
5
5
  Home-page: https://osdn.net/project/mapdata/
6
6
  Author: Dreas Nielsen
@@ -27,6 +27,7 @@ Requires: pyproj
27
27
  Requires: odfpy
28
28
  Requires: openpyxl
29
29
  Requires: xlrd
30
+ Requires: matplotlib
30
31
  Requires-Python: >=3.8
31
32
  Description-Content-Type: text/markdown
32
33
  License-File: LICENSE.txt
@@ -63,6 +64,12 @@ export the map to an image file, and quit.
63
64
 
64
65
  Selected rows in the data table can be exported to a CSV or spreadsheet file.
65
66
 
67
+ Data can also be displayed in several different types of plots: box plots, scatter
68
+ plots, line charts, and counts of categorical and quantitative variables. Plots
69
+ can use either all data or only data values that are selected in the map and
70
+ table. Plots have a live connection to the data table, so when selections are
71
+ changed the plots are automatically updated.
72
+
66
73
 
67
74
  Complete documentation is at [https://mapdata.osdn.io](https://mapdata.osdn.io).
68
75
 
@@ -5,14 +5,14 @@ with io.open('README.md', encoding='utf-8') as f:
5
5
  long_description = f.read()
6
6
 
7
7
  setuptools.setup(name='mapdata',
8
- version='1.10.1',
8
+ version='2.0.2',
9
9
  description="An interactive map and table explorer for geographic coordinates in a CSV file",
10
10
  author='Dreas Nielsen',
11
11
  author_email='dreas.nielsen@gmail.com',
12
12
  url='https://osdn.net/project/mapdata/',
13
13
  scripts=['mapdata/mapdata.py'],
14
14
  license='GPL',
15
- requires=['tkintermapview', 'pyproj', 'odfpy', 'openpyxl', 'xlrd'],
15
+ requires=['tkintermapview', 'pyproj', 'odfpy', 'openpyxl', 'xlrd', 'matplotlib'],
16
16
  python_requires = '>=3.8',
17
17
  classifiers=[
18
18
  'Development Status :: 5 - Production/Stable',
File without changes
File without changes
File without changes