diff --git a/.vscode/settings.json b/.vscode/settings.json
index f5a05adc..1bcd52b2 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -4,6 +4,6 @@
"editor.formatOnSave": true,
"modulename": "pygpsclient",
"distname": "pygpsclient",
- "moduleversion": "1.5.0",
+ "moduleversion": "1.5.1",
"python.defaultInterpreterPath": "python3",
}
\ No newline at end of file
diff --git a/README.md b/README.md
index 48ece95a..9ca0f9bf 100644
--- a/README.md
+++ b/README.md
@@ -259,7 +259,7 @@ You will need to logout and login for the launcher to take effect.
| User-selectable 'widgets' | To show or hide the various widgets, go to Menu..View and click on the relevant hide/show option. |
|---------------------------|---------------------------------------------------------------------------------------------------|
|| Expandable banner showing key navigation status information based on messages received from receiver. To expand or collapse the banner or serial port configuration widgets, click the / buttons. **NB**: some fields (e.g. hdop/vdop, hacc/vacc) are only available from proprietary NMEA or UBX messages and may not be output by default. The minimum messages required to populate all available fields are: NMEA: GGA, GSA, GSV, RMC, UBX00 (proprietary); UBX: NAV-DOP, NAV-PVT, NAV_SAT |
-|| Configurable serial console widget showing all incoming data streams (both GNSS and RTK) in either parsed, binary or tabular hexadecimal formats. Double-click to copy contents of console to clipboard. The scroll behaviour and number of lines retained in the console can be configured via the settings panel. Supports user-configurable color tagging of selected strings for easy identification. Color tags are loaded from the `"colortag_b":` value (`0` = disable, `1` = enable) and `"colortags_l":` list (`[string, color]` pairs) in your json configuration file (see example provided). If color is set to "HALT", streaming will halt on any match and a warning displayed. NB: color tagging does impose a small performance overhead - turning it off will improve console response times at very high transaction rates.|
+|| Configurable serial console widget showing all incoming data streams (both GNSS and RTK) in either parsed, binary or tabular hexadecimal formats. Double-right-click to copy contents of console to clipboard. The scroll behaviour and number of lines retained in the console can be configured via the settings panel. Supports user-configurable color tagging of selected strings for easy identification. Color tags are loaded from the `"colortag_b":` value (`0` = disable, `1` = enable) and `"colortags_l":` list (`[string, color]` pairs) in your json configuration file (see example provided). If color is set to "HALT", streaming will halt on any match and a warning displayed. NB: color tagging does impose a small performance overhead - turning it off will improve console response times at very high transaction rates.|
|| Skyview widget showing current satellite visibility and position (elevation / azimuth). Satellite icon borders are colour-coded to distinguish between different GNSS constellations. For consistency between NMEA and UBX data sources, will display GLONASS NMEA SVID (65-96) rather than slot (1-24). |
|| Graphview widget showing current satellite reception (carrier-to-noise ratio or cnr). Double-click to toggle legend. |
|| Map widget with various modes of display. Map Type = 'world': a static offline Mercator world map showing current global location.
@@ -268,8 +268,9 @@ You will need to logout and login for the launcher to take effect.
|| Import Custom Map dialog . Click  to open the custom map image location (*the default file suffix is `*.tif` - select Show Options to select any file suffix `*.*`*). If the `rasterio` library is installed and the image is georeferenced (e.g. using [QGIS](https://qgis.org/)), the map extent will be automatically extracted - otherwise it must be entered manually. Import the custom map path anad extent settings by clicking . See [Creating Custom Maps for PyGPSClient](https://www.semuconsulting.com/gnsswiki/custommapwiki/) for tips on how to create a suitable georeferenced map image.|
|| Spectrum widget showing a spectrum analysis chart (*GNSS receiver must be capable of outputting UBX MON-SPAN messages*). Clicking anywhere in the spectrum chart will display the frequency and decibel reading at that point. Double-clicking anywhere in the chart will toggle the GNSS frequency band markers (L1, G2, etc.) on or off. Right-click anywhere in the chart to capture a snapshot of the spectrum data, which will then be superimposed on the live data. Double-right-click to clear snapshot. **NB:** Some receivers (e.g. NEO-F10N) will not output the requisite MON-SPAN messages unless the port baud rate is at least 57,600. |
|| System Monitor widget showing device cpu, memory and I/O utilisation (*GNSS receiver must be capable of outputting UBX MON-SYS and/or MON-COMMS messages*). Tick checkbox to toggle between actual (cumulative) I/O stats and pending I/O. |
-|| Scatterplot widget showing variability in position reporting over time. (Optional) Enter fixed reference position. Select Average to center plot on dynamic average position (*displayed at top left*), or Fixed to center on fixed reference position (*if entered*). Check Autorange to set plot range automatically. Set the update interval (e.g. 4 = every 4th navigation solution). Use the range slider or mouse wheel to adjust plot range. Right-click to set fixed reference point to the current mouse cursor position. Double-click clear existing plot. The default center mode, autorange, update increment, scale index and fixed reference position settings can be stored in the json configuration file as `scattercenter_s`/`scatterautorange_b`/`scatterinterval_n`/`scatterscale_n`/`scatterlat_f`/`scatterlon_f`. |
+|| Scatterplot widget showing variability in position reporting over time. (Optional) Enter fixed reference position. Select Average to center plot on dynamic average position (*displayed at top left*), or Fixed to center on fixed reference position (*if entered*). Check Autorange to set plot range automatically. Set the update interval (e.g. 4 = every 4th navigation solution). Use the range slider or mouse wheel to adjust plot range. Right-click to set fixed reference point to the current mouse cursor position. Double-click to clear the existing data. Settings may be saved to a json configuration file. |
| | Rover widget plots the relative 2D position, track and status information for the roving receiver in a fixed or moving base / rover RTK configuration. Can also display relative position of NTRIP mountpoint and receiver in a static RTK configuration. Double-click to clear existing plot. (*GNSS rover receiver must be capable of outputting UBX NAV-RELPOSNED messages.*) |
+| | Chart widget acts as a multi-channel "oscilloscope", allowing the user to plot a series of named numeric data attributes from any NMEA, UBX, RTCM or SPARTN data source, with configurable y (value) and x (time) axes. By default, the number of channels is set to 4, but this can be manually edited by the user via the json configuration file setting `chartsettings_d["numchn_n"]`. For each channel, user can select: (*optional*) identity of message source e.g. `NAV-PVT`; attribute name e.g. `hAcc`; scaling factor (divisor) e.g. 1000; y axis range e.g. 0 - 5. Wildcards are available for attribute groups - "\*" (average of group values), "+" (maximum of group values), "-" (minimum of group values) e.g. `cno*` will plot the average `cno` value for a group of satellites. Double-click to clear the existing data. Double-right-click to save the current chart data to the clipboard in CSV format. Settings may be saved to a json configuration file. |
---
## UBX Configuration Facilities
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 91b9d656..f85648df 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,5 +1,20 @@
# PyGPSClient Release Notes
+### RELEASE 1.5.1
+
+1. Add new user-selectable and configurable "Chart" widget:
+ - The Chart widget broadly emulates a multi-channel "oscilloscope", allowing the user to plot designated numeric data attribute values over time. By default, the number of channels is set to 4, but this can be manually edited by the user via the json configuration file setting `chartsettings_d["numchn_n"]`.
+ - Any numeric attribute from any incoming NMEA, UBX, RTCM or SPARTN message can be plotted.
+ - For each channel, user must specify the parsed data attribute name e.g. "hAcc" or "numSV".
+ - User can optionally specify a message identity e.g. "GNGGA" or "NAV-PVT", in which case only the attribute from that message identity will be plotted.
+ - Nested group attributes must include the full group index e.g. "cno_04". Alternatively, one of three wildcard characters '*', '+' or '-' can be appended, representing the average, minimum or maximum of the nested group values e.g. "cno\*" = (cno_01 + cno_02 + ... + cno_0n) / n ; "cno+" = max(cno_01, cno_02, ..., cno_0n).
+ - X (time) and Y (value) axes are fully configurable.
+ - Double-right-click will save the current chart data to the clipboard in CSV format.
+ - The maximum number of datapoints per channel is configurable, though **NB** the practical maximum will be dependent on available platform memory and performance. 100,000 datapoints per channel is roughly equivalent to 3 MB in-memory data.
+ - Chart settings will be saved to the json configuration file when "Save Configuration" is invoked.
+ - Principally intended to provide a real-time view of incoming data trends over relatively short periods (minutes or hours). *Analyses of much long time-series data (days or weeks) can probably be done more efficiently by saving a binary log of the incoming data and processing the data offline through a standard Python graphing tool like `matplotlib`*.
+1. Add Check (for updates) on startup option to About dialog (NB: check requires internet connectivity)
+
### RELEASE 1.5.0
FIXES:
diff --git a/docs/pygpsclient.rst b/docs/pygpsclient.rst
index e01b6689..35bdf89c 100644
--- a/docs/pygpsclient.rst
+++ b/docs/pygpsclient.rst
@@ -28,6 +28,22 @@ pygpsclient.banner\_frame module
:undoc-members:
:show-inheritance:
+pygpsclient.chart\_frame module
+-------------------------------
+
+.. automodule:: pygpsclient.chart_frame
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+pygpsclient.confirm\_box module
+-------------------------------
+
+.. automodule:: pygpsclient.confirm_box
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
pygpsclient.console\_frame module
---------------------------------
diff --git a/images/chart_widget.png b/images/chart_widget.png
new file mode 100644
index 00000000..5664a362
Binary files /dev/null and b/images/chart_widget.png differ
diff --git a/images/good_sats.png b/images/good_sats.png
deleted file mode 100644
index d0f360e0..00000000
Binary files a/images/good_sats.png and /dev/null differ
diff --git a/images/high_precision.png b/images/high_precision.png
deleted file mode 100644
index 855e8dbd..00000000
Binary files a/images/high_precision.png and /dev/null differ
diff --git a/images/low_precision.png b/images/low_precision.png
deleted file mode 100644
index ac476276..00000000
Binary files a/images/low_precision.png and /dev/null differ
diff --git a/images/poor_sats.png b/images/poor_sats.png
deleted file mode 100644
index 52431900..00000000
Binary files a/images/poor_sats.png and /dev/null differ
diff --git a/pygpsclient.json b/pygpsclient.json
index 38d5ffe1..c89cd9e2 100644
--- a/pygpsclient.json
+++ b/pygpsclient.json
@@ -9,6 +9,7 @@
"Spectrum": false,
"System Monitor": false,
"Scatter Plot": false,
+ "checkforupdate_b": 0,
"mapupdateinterval_n": 60,
"defaultport_s": "USB",
"mqapikey_s": "<======= YOUR MQ API KEY ======>",
@@ -110,12 +111,48 @@
"spartndecode_b": 0,
"spartnkey_s": "abcd1234abcd1234abcd1234abcd1234",
"spartnbasedate_n": -1,
- "scatterautorange_b": 0,
- "scattercenter_s": "Average",
- "scatterinterval_n": 1,
- "scatterscale_n": 6,
- "scatterlat_f": 37.23345133,
- "scatterlon_f": -115.81513477,
+ "scattersettings_d": {
+ "maxpoints_n": 2000,
+ "scatterautorange_b": 1,
+ "scattercenter_s": "Average",
+ "scatterinterval_n": 1,
+ "scatterscale_n": 12,
+ "scatterlat_f": 37.23345,
+ "scatterlon_f": -115.81513
+ },
+ "chartsettings_d": {
+ "numchn_n": 4,
+ "timrng_n": "240",
+ "maxpoints_n": "2000",
+ "0": {
+ "id_s": "NAV-PVT",
+ "name_s": "hAcc",
+ "scale_f": "100",
+ "miny_f": "0",
+ "maxy_f": "1"
+ },
+ "1": {
+ "id_s": "NAV-PVT",
+ "name_s": "pDOP",
+ "scale_f": "1",
+ "miny_f": "0",
+ "maxy_f": "10"
+ },
+ "2": {
+ "id_s": "NAV-PVT",
+ "name_s": "numSV",
+ "scale_f": "1",
+ "miny_f": "0",
+ "maxy_f": "50"
+ },
+ "3": {
+ "id_s": "NAV-PVT",
+ "name_s": "carrSoln",
+ "scale_f": "1",
+ "miny_f": "0",
+ "maxy_f": "5"
+ }
+ },
"ubxpresets_l": [
"Force HOT Reset (!!! Will require reconnection !!!), CFG, CFG-RST, 00000000, 1",
"Force WARM Reset (!!! Will require reconnection !!!), CFG, CFG-RST, 00010000, 1",
diff --git a/pyproject.toml b/pyproject.toml
index 9d6c728b..b5b12146 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,7 +7,7 @@ name = "pygpsclient"
authors = [{ name = "semuadmin", email = "semuadmin@semuconsulting.com" }]
maintainers = [{ name = "semuadmin", email = "semuadmin@semuconsulting.com" }]
description = "GNSS Diagnostic and UBX Configuration GUI Application"
-version = "1.5.0"
+version = "1.5.1"
license = { file = "LICENSE" }
keywords = [
"PyGPSClient",
diff --git a/src/pygpsclient/_version.py b/src/pygpsclient/_version.py
index 4d9c81e0..ee8102eb 100644
--- a/src/pygpsclient/_version.py
+++ b/src/pygpsclient/_version.py
@@ -8,4 +8,4 @@
:license: BSD 3-Clause
"""
-__version__ = "1.5.0"
+__version__ = "1.5.1"
diff --git a/src/pygpsclient/about_dialog.py b/src/pygpsclient/about_dialog.py
index 4ab7eb64..4c7932ac 100644
--- a/src/pygpsclient/about_dialog.py
+++ b/src/pygpsclient/about_dialog.py
@@ -13,7 +13,7 @@
from platform import python_version
from subprocess import CalledProcessError, run
from sys import platform
-from tkinter import Button, E, Frame, Label, Tcl, Toplevel, W
+from tkinter import Button, Checkbutton, E, Frame, IntVar, Label, Tcl, Toplevel, W
from webbrowser import open_new_tab
from PIL import Image, ImageTk
@@ -69,6 +69,9 @@ def __init__(self, app, **kwargs):
self._img_github = ImageTk.PhotoImage(Image.open(ICON_GITHUB).resize((32, 32)))
self._img_exit = ImageTk.PhotoImage(Image.open(ICON_EXIT))
self._img_sponsor = ImageTk.PhotoImage(Image.open(ICON_SPONSOR))
+ self._checkonstartup = IntVar()
+ cfu = self.__app.saved_config.get("checkforupdate_b", False)
+ self._checkonstartup.set(cfu)
self._updates = []
self._body()
@@ -112,6 +115,11 @@ def _body(self):
font=self.__app.font_sm,
cursor="hand2",
)
+ self._chk_checkupdate = Checkbutton(
+ self._frm_container,
+ text="Check on startup",
+ variable=self._checkonstartup,
+ )
self._lbl_giticon = Label(
self._frm_container,
image=self._img_github,
@@ -159,7 +167,10 @@ def _do_layout(self):
column=0, row=4 + i, columnspan=2, padx=2, pady=2
)
self._btn_checkupdate.grid(
- column=0, row=5 + i, ipadx=3, ipady=3, columnspan=2, padx=3, pady=3
+ column=0, row=5 + i, ipadx=3, ipady=3, padx=3, pady=3
+ )
+ self._chk_checkupdate.grid(
+ column=1, row=5 + i, ipadx=3, ipady=3, padx=3, pady=3
)
self._lbl_giticon.grid(column=0, row=6 + i, padx=(3, 1), pady=3, sticky=E)
self._lbl_sponsoricon.grid(column=1, row=6 + i, padx=(3, 1), pady=3, sticky=W)
@@ -181,6 +192,7 @@ def _attach_events(self):
self._lbl_copyright.bind("", self._on_license)
self._btn_ok.bind("", self._ok_press)
self._btn_ok.focus_set()
+ self._checkonstartup.trace_add("write", self._on_save_settings)
def _on_github(self, *args, **kwargs): # pylint: disable=unused-argument
"""
@@ -272,3 +284,10 @@ def _do_update(self, *args, **kwargs): # pylint: disable=unused-argument
self._btn_checkupdate.config(text="RESTART APP", fg="green")
self._btn_checkupdate.bind("", self.__app.on_exit)
+
+ def _on_save_settings(self, var, index, mode): # pylint: disable=unused-argument
+ """
+ Save current settings to saved app config dict.
+ """
+
+ self.__app.saved_config["checkforupdate_b"] = self._checkonstartup.get()
diff --git a/src/pygpsclient/app.py b/src/pygpsclient/app.py
index c835e3e9..4782b83b 100644
--- a/src/pygpsclient/app.py
+++ b/src/pygpsclient/app.py
@@ -54,7 +54,6 @@
from pygpsclient.globals import (
BADCOL,
CFG,
- CHECK_FOR_UPDATES,
CLASS,
CONFIGFILE,
CONNECTED,
@@ -111,19 +110,20 @@
)
from pygpsclient.ubx_handler import UBXHandler
from pygpsclient.widget_state import (
+ COL,
COLSPAN,
DEFAULT,
HIDE,
MAXCOLSPAN,
MAXROWSPAN,
MENU,
+ ROW,
ROWSPAN,
SHOW,
STICKY,
VISIBLE,
- WDGBANNER,
- WDGSETTINGS,
- WDGSTATUS,
+ WDGCHART,
+ WDGCONSOLE,
widget_state,
)
@@ -271,7 +271,7 @@ def __init__(self, master, *args, **kwargs): # pylint: disable=too-many-stateme
self.set_status(INTROTXTNOPORTS, BADCOL)
# Check for more recent version (if enabled)
- if CHECK_FOR_UPDATES:
+ if self.saved_config.get("checkforupdate_b", False):
self._check_update()
def _body(self):
@@ -283,10 +283,12 @@ def _body(self):
self.menu = MenuBar(self)
self.__master.config(menu=self.menu)
- # dynamically instantiate widgets defined in widgets_grid
+ # instantiate widgets
for value in widget_state.values():
- _ = setattr(
- self, value[FRAME], value[CLASS](self, borderwidth=2, relief="groove")
+ setattr(
+ self,
+ value[FRAME],
+ value[CLASS](self, borderwidth=2, relief="groove"),
)
def _do_layout(self):
@@ -295,97 +297,124 @@ def _do_layout(self):
widget visibility and menu label (show/hide).
"""
- col = mcol = 0
- row = mrow = 1
- for i, nam in enumerate(widget_state):
- if i > 2: # only position dynamic widgets
- col, row = self._grid_widget(nam, col, row)
- mcol = max(col, mcol)
- mrow = max(row, mrow)
+ col = 0
+ row = 1
+ maxcol = 0
+ maxrow = 0
+ men = 0
+ for name in widget_state:
+ col, row, maxcol, maxrow, men = self._widget_grid(
+ name, col, row, maxcol, maxrow, men
+ )
+ # ensure widgets expand to size of container (needed
+ # when not using 'pack' grid management)
+ # weight = 0 means fixed, non-expandable
+ # weight > 0 means expandable
for col in range(MAXCOLSPAN + 1):
- self.__master.grid_columnconfigure(col, weight=0 if col > mcol - 1 else 1)
- for row in range(1, MAXROWSPAN + 2):
- self.__master.grid_rowconfigure(row, weight=0 if row > mrow else 1)
-
- self._grid_widget(WDGSETTINGS, mcol, 1, 1, mrow) # always on top
- self._grid_widget(WDGBANNER, 0, 0, mcol + 1, 1) # always on right
- self._grid_widget(WDGSTATUS, 0, mrow + 1, mcol + 1, 1) # always on bottom
-
- def _grid_widget(
- self, nam: str, col: int, row: int, colspan: int = 1, rowspan: int = 1
+ self.__master.grid_columnconfigure(col, weight=0)
+ for row in range(MAXROWSPAN + 2):
+ self.__master.grid_rowconfigure(row, weight=0)
+ # print(f"{maxcol=} {maxrow=}")
+ for col in range(maxcol):
+ self.__master.grid_columnconfigure(col, weight=5)
+ for row in range(1, maxrow + 1):
+ self.__master.grid_rowconfigure(row, weight=5)
+
+ def _widget_grid(
+ self, name: str, col: int, row: int, maxcol: int, maxrow: int, men: int
) -> tuple:
"""
- Arrange individual widget and update menu label (show/hide).
+ Arrange widgets and update menu label (show/hide).
+
+ Widgets with explicit COL settings will be placed in fixed
+ positions; widgets with no COL setting will be arranged
+ dynamically.
- :param str nam: name of widget
- :param int col: column
+ :param str name: name of widget
+ :param int col: col
:param int row: row
- :param int colspan: optional columnspan
- :param int rowspan: optional rowspan
- :return: next available (col, row)
+ :param int maxcol: max cols
+ :param int maxrow: max rows
+ :param int men: menu position
+ :return: max row & col
:rtype: tuple
"""
- wdg = widget_state[nam]
+ wdg = widget_state[name]
+ dynamic = wdg.get(COL, None) is None
frm = getattr(self, wdg[FRAME])
if wdg[VISIBLE]:
- colspan = wdg.get(COLSPAN, colspan)
- rowspan = wdg.get(ROWSPAN, rowspan)
- if col >= MAXCOLSPAN and nam != WDGSETTINGS:
- col = 0
- row += rowspan
- # keep track of cumulative cols & rows
- ccol = wdg.get("col", col)
- crow = wdg.get("row", row)
+ self.widget_enable_messages(name)
+ fcol = wdg.get(COL, col)
+ frow = wdg.get(ROW, row)
+ colspan = wdg.get(COLSPAN, 1)
+ rowspan = wdg.get(ROWSPAN, 1)
+ if dynamic and fcol + colspan > MAXCOLSPAN:
+ fcol = 0
+ frow += 1
frm.grid(
- column=ccol,
- row=crow,
+ column=fcol,
+ row=frow,
columnspan=colspan,
rowspan=rowspan,
padx=2,
pady=2,
sticky=wdg.get(STICKY, (N, S, W, E)),
)
- col += colspan
lbl = HIDE
+ if dynamic:
+ col += colspan
+ if col >= MAXCOLSPAN:
+ col = 0
+ row += rowspan
+ maxcol = max(maxcol, fcol + colspan)
+ maxrow = max(maxrow, frow)
else:
frm.grid_forget()
lbl = SHOW
# update menu label (show/hide)
- if wdg[MENU] is not None:
- self.menu.view_menu.entryconfig(wdg[MENU], label=f"{lbl} {nam}")
+ if wdg.get(MENU, True):
+ self.menu.view_menu.entryconfig(men, label=f"{lbl} {name}")
+ men += 1
# force widget to rescale
frm.event_generate("")
- return col, row
+ return col, row, maxcol, maxrow, men
- def toggle_widget(self, widget: str):
+ def widget_toggle(self, name: str):
"""
Toggle widget visibility and enable or disable any
UBX messages required by widget.
- :param str widget: widget name
+ :param str name: widget name
"""
- wdg = widget_state[widget]
+ wdg = widget_state[name]
wdg[VISIBLE] = not wdg[VISIBLE]
+ self._do_layout()
+
+ def widget_enable_messages(self, name: str):
+ """
+ Enable any NMEA, UBX or RTCM messages required by widget.
+
+ :param str name: widget name
+ """
+ wdg = widget_state[name]
frm = getattr(self, wdg[FRAME])
if hasattr(frm, "enable_messages"):
frm.enable_messages(wdg[VISIBLE])
- self._do_layout()
-
- def reset_widgets(self):
+ def widget_reset(self):
"""
Reset widgets to default layout.
"""
for _, wdg in widget_state.items():
- wdg[VISIBLE] = wdg[DEFAULT]
+ wdg[VISIBLE] = wdg.get(DEFAULT, False)
self._do_layout()
def reset_gnssstatus(self):
@@ -515,6 +544,11 @@ def update_widgets(self):
try:
self._nowidgets = True
+ for key, vals in widget_state.items():
+ vis = self.saved_config.get(key, False)
+ vals[VISIBLE] = vis
+ if vis:
+ self._nowidgets = False
for key, vals in widget_state.items():
vis = self.saved_config.get(key, False)
vals[VISIBLE] = vis
@@ -966,20 +1000,24 @@ def process_data(self, raw_data: bytes, parsed_data: object, marker: str = ""):
elif msgprot == MQTT_PROTOCOL:
pass
+ # update chart data if chart is visible
+ if widget_state[WDGCHART][VISIBLE]:
+ getattr(self, widget_state[WDGCHART][FRAME]).update_data(parsed_data)
+
# update consoledata if console is visible and protocol not filtered
- if widget_state["Console"][VISIBLE] and (msgprot == 0 or msgprot & protfilter):
+ if widget_state[WDGCONSOLE][VISIBLE] and (msgprot == 0 or msgprot & protfilter):
self._consoledata.append((raw_data, parsed_data, marker))
# periodically update widgets if visible
if datetime.now() > self._last_gui_update + timedelta(
seconds=GUI_UPDATE_INTERVAL
):
- if widget_state["Console"][VISIBLE]:
+ if widget_state[WDGCONSOLE][VISIBLE]:
self.frm_console.update_console(self._consoledata)
self._consoledata = []
self.frm_banner.update_frame()
for _, widget in widget_state.items():
- frm = getattr(self, widget["frm"])
+ frm = getattr(self, widget[FRAME])
if hasattr(frm, "update_frame") and widget[VISIBLE]:
frm.update_frame()
self._last_gui_update = datetime.now()
diff --git a/src/pygpsclient/banner_frame.py b/src/pygpsclient/banner_frame.py
index 49f184d0..fceada40 100644
--- a/src/pygpsclient/banner_frame.py
+++ b/src/pygpsclient/banner_frame.py
@@ -12,8 +12,7 @@
:license: BSD 3-Clause
"""
-from platform import system
-from tkinter import SUNKEN, Button, E, Frame, Label, N, S, StringVar, W, font
+from tkinter import SUNKEN, Button, E, Frame, Label, N, S, StringVar, W
from PIL import Image, ImageTk
from pynmeagps.nmeahelpers import latlon2dmm, latlon2dms, llh2ecef
@@ -47,7 +46,7 @@
UIK,
UMK,
)
-from pygpsclient.helpers import m2ft, ms2kmph, ms2knots, ms2mph
+from pygpsclient.helpers import m2ft, ms2kmph, ms2knots, ms2mph, scale_font
DGPSYES = "YES"
DGPSNO = "N/A"
@@ -599,23 +598,14 @@ def _set_fontsize(self):
"""
w = self.width
- # Cater for slightly different font behaviour on Linux
- if system() in ("Windows", "Darwin"):
- val = 55
- lbl = 75
- sup = 85
- else:
- val = 75 # 70
- lbl = 90 # 85
- sup = 100 # 95
-
- sz = min(int(w / val), 18)
+ txt = 100
for ctl in (
self._lbl_status_preset,
self._lbl_time,
self._lbl_lat,
self._lbl_lon,
self._lbl_alt,
+ self._lbl_hae,
self._lbl_spd,
self._lbl_trk,
self._lbl_pdop,
@@ -624,14 +614,15 @@ def _set_fontsize(self):
self._lbl_siv,
self._lbl_diffcorr,
):
- ctl.config(font=font.Font(size=sz))
+ fnt, _ = scale_font(w, 16, txt)
+ ctl.config(font=fnt)
- sz = min(int(w / lbl), 14)
for ctl in (
self._lbl_ltime,
self._lbl_llat,
self._lbl_llon,
self._lbl_lalt,
+ self._lbl_lhae,
self._lbl_lspd,
self._lbl_ltrk,
self._lbl_lpdop,
@@ -641,9 +632,9 @@ def _set_fontsize(self):
self._lbl_lacc,
self._lbl_ldgps,
):
- ctl.config(font=font.Font(size=sz))
+ fnt, _ = scale_font(w, 12, txt)
+ ctl.config(font=fnt)
- sz = min(int(w / sup), 12)
for ctl in (
self._lbl_lalt_u,
self._lbl_lspd_u,
@@ -651,7 +642,8 @@ def _set_fontsize(self):
self._lbl_hvacc,
self._lbl_diffstat,
):
- ctl.config(font=font.Font(size=sz))
+ fnt, _ = scale_font(w, 10, txt)
+ ctl.config(font=fnt)
def _on_resize(self, event): # pylint: disable=unused-argument
"""
@@ -672,6 +664,4 @@ def get_size(self):
"""
self.update_idletasks() # Make sure we know about any resizing
- width = self.winfo_width()
- height = self.winfo_height()
- return (width, height)
+ return self.winfo_width(), self.winfo_height()
diff --git a/src/pygpsclient/chart_frame.py b/src/pygpsclient/chart_frame.py
new file mode 100644
index 00000000..e6b4b6c1
--- /dev/null
+++ b/src/pygpsclient/chart_frame.py
@@ -0,0 +1,766 @@
+"""
+chart_frame.py
+
+Chart frame class for PyGPSClient application.
+
+This emulates a 4-channel "oscilloscope", allowing the user to plot
+up to 4 named data attributes over time. X-axis and Y-axis scale and
+ranges are all configurable.
+
+Plot limited only by the number of data points that can be comfortably
+held in memory.
+
+Created on 24 Nov 2024
+
+:author: semuadmin
+:copyright: 2020 SEMU Consulting
+:license: BSD 3-Clause
+"""
+
+from random import choice
+from time import time
+from tkinter import (
+ ALL,
+ NE,
+ NORMAL,
+ NW,
+ SE,
+ SW,
+ Canvas,
+ E,
+ Entry,
+ Frame,
+ Label,
+ N,
+ S,
+ Spinbox,
+ StringVar,
+ W,
+)
+
+from pygpsclient.globals import (
+ AXISCOL,
+ BGCOL,
+ GRIDCOL,
+ READONLY,
+ RPTDELAY,
+ WIDGETU6,
+ AreaXY,
+)
+from pygpsclient.helpers import data2xy, fontheight, get_grid, scale_font, time2str
+
+MAXCHANS = 4
+RESFONT = 28 # font size relative to widget size
+MINFONT = 8 # minimum font size
+PLOTWID = 1
+PLOTCOLS = ("yellow", "cyan", "magenta", "deepskyblue")
+GRIDMINCOL = "grey30"
+LBLGRID = 5
+GRIDSTEPS = get_grid(21)
+XLBLSTEPS = get_grid(LBLGRID)
+YLBLSTEPS = get_grid(LBLGRID)
+ERRCOL = "coral"
+LBLCOL = "white"
+CONTRASTCOL = "black"
+MODEINIT = "chart"
+AXISTAG = "axt"
+AXISLBLTAG = "axl"
+# total capacity depends on available free memory...
+TIMRANGE = [int(i * 10**n) for n in (1, 2, 3, 4) for i in (1, 2.4, 3.6, 4.8, 6)]
+DPTRANGE = [int(i * 10**n) for n in (3, 4, 5, 6) for i in (1, 2, 5)]
+XRANGE = 120
+CHARTMINY = 0
+CHARTMAXY = 100
+CHARTSCALE = 1
+MINY = "MinY {}"
+MAXY = "MaxY {}"
+
+
+def gen_yrange() -> tuple:
+ """
+ Generate scale and max/min Y ranges for spinboxes.
+
+ :return: Y range
+ :rtype: tuple
+ """
+
+ srange = ()
+ for i in range(0, 8):
+ for n in (1, 2, 5):
+ srange += (n * 10**i,)
+ for i in range(8, 0, -1):
+ for n in (5, 2, 1):
+ v = n * 10**-i
+ if v == 4.9999999999999996e-06: # fix Python rounding quirk!
+ v = 5e-06
+ srange += (v,)
+
+ yrange = ("0",)
+ for i in range(0, 8):
+ for n in (1, 2, 5):
+ yrange += (n * 10**i,)
+ for i in range(8, -1, -1):
+ for n in (5, 2, 1):
+ yrange += (-n * 10**i,)
+
+ return srange, yrange
+
+
+class ChartviewFrame(Frame):
+ """
+ CHartview frame class.
+ """
+
+ def __init__(self, app, *args, **kwargs):
+ """
+ Constructor.
+
+ :param Frame app: reference to main tkinter application
+ :param args: optional args to pass to Frame parent class
+ :param kwargs: optional kwargs to pass to Frame parent class
+ """
+
+ self.__app = app # Reference to main application class
+ self.__master = self.__app.appmaster # Reference to root class (Tk)
+
+ Frame.__init__(self, self.__master, *args, **kwargs)
+
+ self.chartsettings = self.__app.saved_config.get("chartsettings_d", {})
+ def_w, def_h = WIDGETU6
+ self.width = kwargs.get("width", def_w)
+ self.height = kwargs.get("height", def_h)
+ self.configure(bg=BGCOL)
+ self._font = None
+ self._fonth = 6
+ self._xoff = 20 # chart X offset for labels
+ self._yoff = 20 # chart Y offset for labels
+ self._chart_data = {}
+ self._num_chans = self.chartsettings.get("numchn_n", MAXCHANS)
+ if self._num_chans % 2: # no channels must be even
+ self._num_chans += 1
+ self._plotcols = PLOTCOLS
+ self._font = self.__app.font_sm
+ self._fonth = fontheight(self._font)
+ # generate random plot colours for channels > 4
+ if self._num_chans > 4:
+ self._plotcols += tuple(
+ "#" + "".join([choice("9ABCDEF") for j in range(6)])
+ for i in range(self._num_chans - 4)
+ )
+ self._data_id = [None] * self._num_chans
+ self._data_name = [None] * self._num_chans
+ self._data_scale = [None] * self._num_chans
+ self._data_miny = [None] * self._num_chans
+ self._data_maxy = [None] * self._num_chans
+ self._mintim = 1e20
+ self._maxtim = 0
+ self._timrange = StringVar()
+ self._maxpoints = StringVar()
+ for chn in range(self._num_chans):
+ self._data_id[chn] = StringVar()
+ self._data_name[chn] = StringVar()
+ self._data_scale[chn] = StringVar()
+ self._data_miny[chn] = StringVar()
+ self._data_maxy[chn] = StringVar()
+ self._body()
+ self._do_layout()
+ # self._set_fontsize()
+ self._attach_events()
+ self.reset()
+
+ def _body(self):
+ """
+ Set up frame and widgets.
+ """
+
+ srange, yrange = gen_yrange()
+ # set column and row expand behaviour
+ for i in range(6):
+ self.grid_columnconfigure(i, weight=1, uniform="ent")
+ self.grid_rowconfigure(0, weight=1)
+ for i in range(1, 2 + self._num_chans):
+ self.grid_rowconfigure(i, weight=0)
+ self._can_chartview = Canvas(
+ self, width=self.width, height=self.height, bg=BGCOL
+ )
+ self._lbl_id = Label(
+ self,
+ text="Identity",
+ fg=LBLCOL,
+ bg=BGCOL,
+ )
+ self._lbl_name = Label(
+ self,
+ text="Name",
+ fg=LBLCOL,
+ bg=BGCOL,
+ )
+ self._lbl_scale = Label(
+ self,
+ text="Scale",
+ fg=LBLCOL,
+ bg=BGCOL,
+ )
+ self._lbl_miny = Label(
+ self,
+ text=MINY.format(""),
+ fg=LBLCOL,
+ bg=BGCOL,
+ )
+ self._lbl_maxy = Label(
+ self,
+ text=MAXY.format(""),
+ fg=LBLCOL,
+ bg=BGCOL,
+ )
+ self._lbl_timrange = Label(
+ self,
+ text="Time Range s",
+ fg=LBLCOL,
+ bg=BGCOL,
+ )
+ self._lbl_maxpoints = Label(
+ self,
+ text="Max Points/Chan",
+ fg=LBLCOL,
+ bg=BGCOL,
+ )
+ self._ent_id = [None] * self._num_chans
+ self._ent_name = [None] * self._num_chans
+ self._spn_scale = [None] * self._num_chans
+ self._spn_miny = [None] * self._num_chans
+ self._spn_maxy = [None] * self._num_chans
+ for chn in range(self._num_chans):
+ self._ent_id[chn] = Entry(
+ self,
+ textvariable=self._data_id[chn],
+ state=NORMAL,
+ relief="sunken",
+ width=10,
+ fg=self._plotcols[chn],
+ bg=BGCOL,
+ )
+ self._ent_name[chn] = Entry(
+ self,
+ textvariable=self._data_name[chn],
+ state=NORMAL,
+ relief="sunken",
+ width=10,
+ fg=self._plotcols[chn],
+ bg=BGCOL,
+ )
+ self._spn_scale[chn] = Spinbox(
+ self,
+ values=srange,
+ wrap=True,
+ textvariable=self._data_scale[chn],
+ state=NORMAL,
+ repeatdelay=RPTDELAY,
+ repeatinterval=RPTDELAY,
+ width=10,
+ fg=self._plotcols[chn],
+ bg=BGCOL,
+ )
+ self._spn_miny[chn] = Spinbox(
+ self,
+ values=yrange,
+ wrap=True,
+ textvariable=self._data_miny[chn],
+ state=NORMAL,
+ repeatdelay=RPTDELAY,
+ repeatinterval=RPTDELAY,
+ width=10,
+ fg=self._plotcols[chn],
+ bg=BGCOL,
+ )
+ self._spn_maxy[chn] = Spinbox(
+ self,
+ values=yrange,
+ wrap=True,
+ textvariable=self._data_maxy[chn],
+ state=NORMAL,
+ repeatdelay=RPTDELAY,
+ repeatinterval=RPTDELAY,
+ width=10,
+ fg=self._plotcols[chn],
+ bg=BGCOL,
+ )
+
+ self._spn_timrange = Spinbox(
+ self,
+ values=TIMRANGE,
+ wrap=True,
+ textvariable=self._timrange,
+ state=READONLY,
+ repeatdelay=RPTDELAY,
+ repeatinterval=RPTDELAY,
+ width=8,
+ fg=LBLCOL,
+ readonlybackground=BGCOL,
+ )
+ self._spn_maxpoints = Spinbox(
+ self,
+ values=DPTRANGE,
+ wrap=True,
+ textvariable=self._maxpoints,
+ state=READONLY,
+ repeatdelay=RPTDELAY,
+ repeatinterval=RPTDELAY,
+ width=8,
+ fg=LBLCOL,
+ readonlybackground=BGCOL,
+ )
+
+ def _do_layout(self):
+ """
+ Position widgets in frame.
+ """
+
+ self._can_chartview.grid(column=0, row=0, columnspan=6, sticky=(N, S, E, W))
+ self._lbl_id.grid(column=0, row=1, sticky=(W, E))
+ self._lbl_name.grid(column=1, row=1, sticky=(W, E))
+ self._lbl_scale.grid(column=2, row=1, sticky=(W, E))
+ self._lbl_miny.grid(column=3, row=1, sticky=(W, E))
+ self._lbl_maxy.grid(column=4, row=1, sticky=(W, E))
+ for chn in range(self._num_chans):
+ self._ent_id[chn].grid(column=0, row=2 + chn, sticky=(W, E))
+ self._ent_name[chn].grid(column=1, row=2 + chn, sticky=(W, E))
+ self._spn_scale[chn].grid(column=2, row=2 + chn, sticky=(W, E))
+ self._spn_miny[chn].grid(column=3, row=2 + chn, sticky=(W, E))
+ self._spn_maxy[chn].grid(column=4, row=2 + chn, sticky=(W, E))
+ self._lbl_timrange.grid(column=5, row=1, sticky=(W, E))
+ self._spn_timrange.grid(column=5, row=2, sticky=(W, E))
+ self._lbl_maxpoints.grid(column=5, row=3, sticky=(W, E))
+ self._spn_maxpoints.grid(column=5, row=4, sticky=(W, E))
+
+ def _attach_events(self):
+ """
+ Bind events to frame.
+ """
+
+ self.bind("", self._on_resize)
+ self._can_chartview.bind("", self._on_clear)
+ self._can_chartview.bind("", self._on_clipboard)
+ self._can_chartview.bind("", self._on_clipboard)
+ self._timrange.trace_add("write", self._on_save_settings)
+ self._maxpoints.trace_add("write", self._on_save_settings)
+ for chn in range(self._num_chans):
+ self._data_id[chn].trace_add("write", self._on_save_settings)
+ self._data_name[chn].trace_add("write", self._on_save_settings)
+ self._data_scale[chn].trace_add("write", self._on_save_settings)
+ self._data_miny[chn].trace_add("write", self._on_save_settings)
+ self._data_maxy[chn].trace_add("write", self._on_save_settings)
+
+ def reset(self):
+ """
+ Reset chart frame.
+ """
+
+ self._timrange.set(self.chartsettings.get("timrng_n", TIMRANGE[3])) # 60s
+ self._maxpoints.set(self.chartsettings.get("maxpoints_n", DPTRANGE[2])) # 5000
+ for chn in range(self._num_chans):
+ cst = self.chartsettings.get(str(chn), {})
+ self._data_id[chn].set(cst.get("id_s", ""))
+ self._data_name[chn].set(cst.get("name_s", ""))
+ self._data_scale[chn].set(cst.get("scale_f", 1))
+ self._data_miny[chn].set(cst.get("miny_f", CHARTMINY))
+ self._data_maxy[chn].set(cst.get("maxy_f", CHARTMAXY))
+
+ self._on_clear(None)
+
+ def _on_clear(self, event): # pylint: disable=unused-argument
+ """
+ Clear data.
+ """
+
+ for chn in range(self._num_chans):
+ self._chart_data[chn] = []
+ self._mintim = 1e20
+ self._maxtim = 0
+ self._can_chartview.delete(ALL)
+ self.update_frame()
+
+ def _valid_settings(self) -> bool:
+ """
+ Validate settings.
+
+ :return: True/False
+ :rtype: bool
+ """
+
+ try:
+ for chn in range(self._num_chans):
+ _ = float(self._data_scale[chn].get())
+ _ = float(self._data_miny[chn].get())
+ _ = float(self._data_maxy[chn].get())
+ _ = float(self._timrange.get())
+ return True
+ except ValueError:
+ self._disp_alert("Error: Invalid settings")
+ return False
+
+ def init_frame(self):
+ """
+ Initialise spectrum chart.
+ """
+
+ w, h = self.width, self.height
+ self._xoff = self._fonth * self._num_chans / 2 + 3 # chart X offset for labels
+ self._yoff = self._fonth + 3 # chart Y offset for labels
+ self._can_chartview.delete(ALL)
+
+ # draw grid
+ for i, p in enumerate(GRIDSTEPS):
+ y = (h - self._yoff) * p
+ col = AXISCOL if p in (0, 1.0) else GRIDMINCOL if i % LBLGRID else GRIDCOL
+ self._can_chartview.create_line(
+ self._xoff, y, w - self._xoff, y, fill=col, tags=AXISTAG
+ )
+ x = self._xoff + (w - self._xoff * 2) * p
+ self._can_chartview.create_line(
+ x, 0, x, h - self._yoff, fill=col, tags=AXISTAG
+ )
+
+ def update_data(self, parsed_data: object):
+ """
+ Update chart data from parsed message.
+
+ :param object parsed_data: parsed message
+ """
+
+ try:
+ maxpoints = int(self._maxpoints.get())
+ except ValueError:
+ maxpoints = DPTRANGE[2] # 5000
+
+ for chn in range(self._num_chans):
+ mid = self._data_id[chn].get()
+ name = self._data_name[chn].get()
+ if name == "":
+ continue
+ if mid != "":
+ if hasattr(parsed_data, "identity"):
+ if parsed_data.identity != mid:
+ continue
+
+ # wildcards *+-, sum, max or min of group of values
+ if name[-1] in ("*", "+", "-"):
+ vals = []
+ for attr in parsed_data.__dict__:
+ if name[:-1] in attr and name[0] != "_":
+ try:
+ vals.append(float(getattr(parsed_data, attr)))
+ except ValueError:
+ continue
+ if vals: # != []
+ if name[-1] == "+":
+ val = max(vals)
+ elif name[-1] == "-":
+ val = min(vals)
+ else:
+ val = sum(vals) / (len(vals) * 1.0)
+ else:
+ val = None
+ else:
+ if hasattr(parsed_data, name):
+ try:
+ val = float(getattr(parsed_data, name))
+ except ValueError:
+ val = None
+ else:
+ continue
+
+ now = time()
+ self._chart_data[chn].append((now, val))
+
+ # update X axis (time) range
+ self._mintim = min(now, self._mintim)
+ self._maxtim = max(now, self._maxtim)
+
+ # limit number of data points per channel
+ while len(self._chart_data[chn]) > maxpoints:
+ self._chart_data[chn].pop(0)
+
+ # flag if scaled value is out of range
+ self.flag_outofrange(chn, val)
+
+ def flag_outofrange(self, chn: int, val: float):
+ """
+ Flag if scaled value is over or under range.
+
+ :param int chn: channel number
+ :param float val: value
+ """
+
+ if val is None:
+ return
+
+ try:
+ minval = float(self._data_miny[chn].get())
+ maxval = float(self._data_maxy[chn].get())
+ scale = float(self._data_scale[chn].get())
+ ucol = ocol = BGCOL
+ ufcol = ofcol = self._plotcols[chn]
+ if val / scale < minval:
+ ucol = ERRCOL
+ ufcol = CONTRASTCOL
+ elif val / scale > maxval:
+ ocol = ERRCOL
+ ofcol = CONTRASTCOL
+ self._spn_miny[chn].configure(bg=ucol, fg=ufcol)
+ self._spn_maxy[chn].configure(bg=ocol, fg=ofcol)
+ except (TypeError, ValueError):
+ pass
+
+ def update_frame(self):
+ """
+ Plot selected chart data.
+ """
+
+ self._update_plot(self._chart_data)
+
+ def _update_plot(self, data: dict):
+ """
+ Update chart plot with data.
+
+ :param dict data: list of chart data
+ :param int xrange: number of points
+ """
+
+ if not self._valid_settings():
+ return
+
+ mintim = 1e20
+ maxtim = 0
+ w, h = self.width, self.height
+ self.init_frame()
+
+ # set default ranges for all channels
+ minval = [CHARTMINY] * self._num_chans
+ maxval = [CHARTMAXY] * self._num_chans
+ scale = [CHARTSCALE] * self._num_chans
+
+ # get X axis (time) range for all channels and draw labels
+ mintim, maxtim = self._mintim, self._maxtim
+ mintim = maxtim - float(self._timrange.get())
+ bounds = AreaXY(mintim, CHARTMINY, maxtim, CHARTMAXY)
+ self._can_chartview.delete(AXISLBLTAG)
+ self._draw_xaxis_labels(w, h, bounds, mintim, maxtim)
+ self._spn_timrange.configure(fg=LBLCOL, readonlybackground=BGCOL)
+
+ # plot each channel's data points
+ for chn in range(self._num_chans):
+
+ chncol = self._plotcols[chn]
+ minval[chn] = float(self._data_miny[chn].get())
+ maxval[chn] = float(self._data_maxy[chn].get())
+ scale[chn] = float(self._data_scale[chn].get())
+ bounds = AreaXY(mintim, minval[chn], maxtim, maxval[chn])
+
+ # draw Y axis (data value) labels for this channel
+ self._draw_yaxis_labels(w, h, bounds, minval[chn], maxval[chn], chn)
+
+ # plot each data point in channel
+ inr = False
+ for tim, val in data[chn]:
+
+ if val is None: # not numeric
+ continue
+
+ if scale[chn] != 1:
+ val /= scale[chn] # scale data
+
+ # convert datapoint to canvas x,y coordinates
+ x, y = data2xy(
+ w - self._xoff * 2,
+ h - self._yoff,
+ bounds,
+ tim,
+ val,
+ self._xoff,
+ )
+ if x <= self._xoff:
+ inr = False
+ # plot line
+ if inr:
+ x2, y2 = x, y
+ self._can_chartview.create_line(
+ x1,
+ y1,
+ x2,
+ y2,
+ fill=chncol,
+ width=PLOTWID,
+ tags=f"plot_{chn:1d}",
+ )
+ x1, y1 = x2, y2
+ else:
+ x1, y1 = max(x, self._xoff), y
+ inr = True
+
+ def _draw_xaxis_labels(
+ self, w: int, h: int, bounds: AreaXY, mintim: float, maxtim: float
+ ):
+ """
+ Draw X axis (time) labels.
+
+ :param int w: canvas width
+ :param int h: canvas height
+ :param AreaXY bounds: data bounds
+ :param float mintim: minimum time
+ :param float maxtim: maximum time
+ """
+ # pylint: disable=too-many-arguments, too-many-positional-arguments
+
+ for g in XLBLSTEPS:
+ xval = mintim + (maxtim - mintim) * g
+ x, _ = data2xy(w - self._xoff * 2, h - self._yoff, bounds, xval, 0)
+ if g == 0:
+ anc = NW
+ elif g == 1:
+ anc = NE
+ else:
+ anc = N
+ self._can_chartview.create_text(
+ x + self._xoff,
+ h - self._yoff,
+ text=time2str(xval),
+ anchor=anc,
+ fill=AXISCOL,
+ font=self._font,
+ tags=AXISLBLTAG,
+ )
+
+ def _draw_yaxis_labels(
+ self,
+ w: int,
+ h: int,
+ bounds: AreaXY,
+ minval: float,
+ maxval: float,
+ chn: int,
+ ):
+ """
+ Draw Y axis (data value) labels for this channel.
+
+ :param int w: canvas width
+ :param int h: canvas height
+ :param AreaXY bounds: data bounds
+ :param float minval: minimum val for chn
+ :param float maxval: maximum val for chn
+ :param int chn: channel
+ """
+ # pylint: disable=too-many-arguments, too-many-positional-arguments
+
+ col = self._plotcols[chn]
+ yo = 2 # avoid edges
+ # y axis labels alternate left and right
+ if chn % 2: # odd channels
+ x = w - yo - self._fonth * ((chn - 1) / 2)
+ else: # even channels
+ x = yo + self._fonth * (chn / 2)
+
+ for g in YLBLSTEPS:
+ yval = minval + (maxval - minval) * g
+ _, y = data2xy(
+ w - self._xoff * MAXCHANS / 2, h - self._yoff, bounds, 0, yval
+ )
+ if g == 0:
+ anc = SW if chn % 2 else NW
+ elif g == 1:
+ y += yo * 2 # avoid edges
+ anc = SE if chn % 2 else NE
+ else:
+ anc = S if chn % 2 else N
+ self._can_chartview.create_text(
+ x,
+ y,
+ text=yval,
+ fill=col,
+ font=self._font,
+ angle=90,
+ anchor=anc,
+ tags=AXISLBLTAG,
+ )
+
+ def _disp_alert(self, msg):
+ """
+ Display alert message on canvas.
+
+ :param str msg: error message
+ """
+
+ w, h = self.width, self.height
+
+ self._can_chartview.delete(ALL)
+ self._can_chartview.create_text(
+ w / 2,
+ h / 2,
+ text=msg,
+ fill="orange",
+ font=self._font,
+ anchor=S,
+ )
+
+ def _on_save_settings(self, var, index, mode): # pylint: disable=unused-argument
+ """
+ Save current settings to saved app config dict.
+ """
+
+ cst = {}
+ cst["numchn_n"] = self._num_chans
+ cst["timrng_n"] = self._timrange.get()
+ cst["maxpoints_n"] = self._maxpoints.get()
+ for chn in range(self._num_chans):
+ cst[chn] = {}
+ cst[chn]["id_s"] = self._data_id[chn].get()
+ cst[chn]["name_s"] = self._data_name[chn].get()
+ cst[chn]["scale_f"] = self._data_scale[chn].get()
+ cst[chn]["miny_f"] = self._data_miny[chn].get()
+ cst[chn]["maxy_f"] = self._data_maxy[chn].get()
+
+ self.__app.saved_config["chartsettings_d"] = cst
+
+ def _on_clipboard(self, event): # pylint: disable=unused-argument
+ """
+ Copy chart data to clipboard in CSV format.
+
+ :param event event: double click event
+ """
+
+ csv = (
+ f"PyGPSClient Chart Data,{time2str(time(),'%Y-%M-%d-%H:%M:%S')},"
+ f"Channels,{self._num_chans}\n"
+ )
+ for chn in range(self._num_chans):
+ csv += (
+ f"{chn},{self._ent_id[chn].get()},{self._ent_name[chn].get()},"
+ f"{len(self._chart_data[chn])}\nTime,Value\n"
+ )
+ for tim, val in self._chart_data[chn]:
+ csv += f"{time2str(tim,'%H:%M:%S.%f')},{val}\n"
+ csv += "\n"
+ self.__master.clipboard_clear()
+ self.__master.clipboard_append(csv)
+ self.__master.update()
+
+ def _on_resize(self, event): # pylint: disable=unused-argument
+ """
+ Resize frame.
+
+ :param event event: resize event
+ """
+
+ self.width, self.height = self.get_size()
+ self._font, self._fonth = scale_font(self.width, 6, 35, 16)
+
+ def get_size(self):
+ """
+ Get current canvas size.
+
+ :return: window size (width, height)
+ :rtype: tuple
+ """
+
+ self.update_idletasks() # Make sure we know about any resizing
+ return self._can_chartview.winfo_width(), self._can_chartview.winfo_height()
diff --git a/src/pygpsclient/confirm_box.py b/src/pygpsclient/confirm_box.py
new file mode 100644
index 00000000..d173005e
--- /dev/null
+++ b/src/pygpsclient/confirm_box.py
@@ -0,0 +1,96 @@
+"""
+confirm_box.py
+
+Confirm action dialog class.
+Provides better consistency across different OS platforms
+than using messagebox.askyesno()
+
+Created on 17 Apr 2021
+
+:author: semuadmin
+:copyright: 2020 SEMU Consulting
+:license: BSD 3-Clause
+
+"""
+
+from tkinter import Button, Label, Toplevel, W
+
+
+class ConfirmBox(Toplevel):
+ """
+ Confirm action dialog class.
+ Provides better consistency across different OS platforms
+ than using messagebox.askyesno()
+
+ Returns True if OK, False if Cancel
+ """
+
+ def __init__(self, parent, title, prompt):
+ """
+ Constructor
+
+ :param parent: parent dialog
+ :param string title: title
+ :param string prompt: prompt to be displayed
+ """
+
+ self.__master = parent
+ Toplevel.__init__(self, parent)
+ self.title(title) # pylint: disable=E1102
+ self.resizable(False, False)
+ Label(self, text=prompt, anchor=W).grid(
+ row=0, column=0, columnspan=2, padx=3, pady=5
+ )
+ Button(self, command=self._on_ok, text="OK", width=8).grid(
+ row=1, column=0, padx=3, pady=3
+ )
+ Button(self, command=self._on_cancel, text="Cancel", width=8).grid(
+ row=1, column=1, padx=3, pady=3
+ )
+ self.lift() # Put on top of
+ self.grab_set() # Make modal
+ self._rc = False
+
+ self._centre()
+
+ def _on_ok(self, event=None): # pylint: disable=unused-argument
+ """
+ OK button handler
+ """
+
+ self._rc = True
+ self.destroy()
+
+ def _on_cancel(self, event=None): # pylint: disable=unused-argument
+ """
+ Cancel button handler
+ """
+
+ self._rc = False
+ self.destroy()
+
+ def _centre(self):
+ """
+ Centre dialog in parent
+ """
+
+ # self.update_idletasks()
+ dw = self.winfo_width()
+ dh = self.winfo_height()
+ mx = self.__master.winfo_x()
+ my = self.__master.winfo_y()
+ mw = self.__master.winfo_width()
+ mh = self.__master.winfo_height()
+ self.geometry(f"+{int(mx + (mw/2 - dw/2))}+{int(my + (mh/2 - dh/2))}")
+
+ def show(self):
+ """
+ Show dialog
+
+ :return: True (OK) or False (Cancel)
+ :rtype: bool
+ """
+
+ self.wm_deiconify()
+ self.wait_window()
+ return self._rc
diff --git a/src/pygpsclient/console_frame.py b/src/pygpsclient/console_frame.py
index 61369688..6c9b22aa 100644
--- a/src/pygpsclient/console_frame.py
+++ b/src/pygpsclient/console_frame.py
@@ -17,7 +17,7 @@
:license: BSD 3-Clause
"""
-from tkinter import END, HORIZONTAL, VERTICAL, E, Frame, N, S, Scrollbar, Text, W
+from tkinter import END, HORIZONTAL, NONE, VERTICAL, E, Frame, N, S, Scrollbar, Text, W
from pyubx2 import hextable
@@ -32,10 +32,12 @@
FORMAT_HEXSTR,
FORMAT_HEXTAB,
FORMAT_PARSED,
+ WIDGETU3,
)
from pygpsclient.strings import HALTTAGWARN
HALT = "HALT"
+CONSOLELINES = 20
class ConsoleFrame(Frame):
@@ -57,7 +59,9 @@ def __init__(self, app, *args, **kwargs):
Frame.__init__(self, self.__master, *args, **kwargs)
- self.width, self.height = self.get_size()
+ def_w, def_h = WIDGETU3
+ self.width = kwargs.get("width", def_w)
+ self.height = kwargs.get("height", def_h)
self._colortags = self.__app.frm_settings.config.get(
"colortags_l", self.__app.frm_settings.config.get("colortags", [])
)
@@ -74,10 +78,10 @@ def _body(self):
self.option_add("*Font", self.__app.font_sm)
self._console_fg = FGCOL
self._console_bg = BGCOL
- self.width, self.height = self.get_size()
-
self.grid_columnconfigure(0, weight=1)
self.grid_rowconfigure(0, weight=1)
+ self.grid_columnconfigure(1, weight=0)
+ self.grid_rowconfigure(1, weight=0)
self.sblogv = Scrollbar(self, orient=VERTICAL)
self.sblogh = Scrollbar(self, orient=HORIZONTAL)
self.txt_console = Text(
@@ -86,8 +90,8 @@ def _body(self):
fg=self._console_fg,
yscrollcommand=self.sblogv.set,
xscrollcommand=self.sblogh.set,
- wrap="none",
- height=20,
+ wrap=NONE,
+ height=15,
)
self.sblogh.config(command=self.txt_console.xview)
self.sblogv.config(command=self.txt_console.yview)
@@ -118,6 +122,8 @@ def _attach_events(self):
self.bind("", self._on_resize)
self.txt_console.bind("", self._on_clipboard)
+ self.txt_console.bind("", self._on_clipboard)
+ self.txt_console.bind("", self._on_clipboard)
# self.txt_console.tag_bind(HALT, "<1>", self._on_halt) # doesn't seem to work on MacOS
def update_console(self, consoledata: list):
@@ -248,4 +254,4 @@ def get_size(self):
"""
self.update_idletasks() # Make sure we know about any resizing
- return (self.winfo_width(), self.winfo_height())
+ return self.winfo_width(), self.winfo_height()
diff --git a/src/pygpsclient/globals.py b/src/pygpsclient/globals.py
index e7a986c7..8caf7f3c 100644
--- a/src/pygpsclient/globals.py
+++ b/src/pygpsclient/globals.py
@@ -25,6 +25,8 @@
Point = namedtuple("Point", ["lat", "lon"])
# Area convention is minlat, minlon, maxlat, maxlon
Area = namedtuple("Area", ["lat1", "lon1", "lat2", "lon2"])
+PointXY = namedtuple("Point", ["x", "y"])
+AreaXY = namedtuple("Area", ["x1", "y1", "x2", "y2"])
def create_circle(self, x, y, r, **kwargs):
@@ -43,6 +45,7 @@ def create_circle(self, x, y, r, **kwargs):
HOME = Path.home()
APPNAME = __name__.split(".", 1)[0] # i.e. "pygpsclient"
+AXISCOL = "white"
BADCOL = "red"
BGCOL = "gray24" # default widget background color
BPSRATES = (
@@ -58,7 +61,6 @@ def create_circle(self, x, y, r, **kwargs):
4800,
)
CFG = "cfg"
-CHECK_FOR_UPDATES = False
CLASS = "cls"
COLORTAGS = "colortags"
CONFIGFILE = path.join(HOME, f"{APPNAME}.json")
@@ -137,6 +139,7 @@ def create_circle(self, x, y, r, **kwargs):
'http://www.topografix.com/GPX/1/1/gpx.xsd"'
)
GPX_TRACK_INTERVAL = 1 # minimum GPS track update interval (seconds)
+GRIDCOL = "grey40"
GUI_UPDATE_INTERVAL = 0.5 # minimum GUI widget update interval (seconds)
ICON_APP128 = path.join(DIRNAME, "resources/app-128.png")
ICON_BLANK = path.join(DIRNAME, "resources/blank-1-24.png")
@@ -259,6 +262,7 @@ def create_circle(self, x, y, r, **kwargs):
SPONSOR_URL = "https://buymeacoffee.com/semuconsulting"
SQRT2 = 0.7071067811865476 # square root of 2
THD = "thd"
+TIME0 = datetime(1970, 1, 1) # basedate for time()
TIMEOUTS = (
"0.1",
"0.2",
@@ -280,10 +284,11 @@ def create_circle(self, x, y, r, **kwargs):
UMK = "Metric kmph"
UMM = "Metric m/s"
UTF8 = "utf-8"
-WIDGETU1 = (250, 250) # small widget size
-WIDGETU2 = (350, 250) # medium widget size
-WIDGETU3 = (950, 350) # Console size
-WIDGETU4 = (600, 600) # GPX Track viewer size
+WIDGETU1 = (200, 200) # small widget size
+WIDGETU2 = (300, 200) # medium widget size
+WIDGETU3 = (800, 200) # Console size
+WIDGETU4 = (500, 500) # GPX Track viewer size
+WIDGETU6 = (400, 200) # Chart size
XML_HDR = ''
# UBX config widget signifiers - used to
diff --git a/src/pygpsclient/gpx_dialog.py b/src/pygpsclient/gpx_dialog.py
index 20f70c32..877a6d1b 100644
--- a/src/pygpsclient/gpx_dialog.py
+++ b/src/pygpsclient/gpx_dialog.py
@@ -19,6 +19,7 @@
from tkinter import (
ALL,
BOTH,
+ CENTER,
DISABLED,
NW,
YES,
@@ -160,7 +161,7 @@ def _body(self):
self._lbl_info = []
for i in range(MD_LINES):
self._lbl_info.append(
- Label(self._frm_info, textvariable=self._info[i], anchor="w")
+ Label(self._frm_info, textvariable=self._info[i], anchor=W)
)
self._btn_load = Button(
self._frm_controls,
@@ -342,8 +343,8 @@ def get_size(self):
:rtype: tuple
"""
- # self.update_idletasks() # Make sure we know about any resizing
- return (self.winfo_width(), self.winfo_height())
+ self.update_idletasks() # Make sure we know about any resizing
+ return self.winfo_width(), self.winfo_height()
def _on_resize(self, event):
"""
@@ -628,7 +629,7 @@ def _draw_profile(
x2, y2 = self._get_point(maxe, maxx, ele, maxx)
self._can_profile.create_line(x1, y1, x2 + 1, y1, fill="grey")
self._can_profile.create_text(
- x1 - 2, y1, text=f"{ele}", fill=ELEAX_COL, font=fnt, anchor="e"
+ x1 - 2, y1, text=f"{ele}", fill=ELEAX_COL, font=fnt, anchor=E
)
self._can_profile.create_text(
AXIS_XL - 2,
@@ -636,7 +637,7 @@ def _draw_profile(
text=ele_u,
fill=ELEAX_COL,
font=fnt,
- anchor="e",
+ anchor=E,
)
# plot speed (yR) axis grid
@@ -651,7 +652,7 @@ def _draw_profile(
text=f"{spd}",
fill=SPD_COL,
font=fnt,
- anchor="w",
+ anchor=W,
)
self._can_profile.create_text(
self.width - AXIS_XR + 1,
@@ -659,7 +660,7 @@ def _draw_profile(
text=spd_u,
fill=SPD_COL,
font=fnt,
- anchor="w",
+ anchor=W,
)
# plot trackpoint (X) axis grid
@@ -669,9 +670,9 @@ def _draw_profile(
x2, y2 = self._get_point(maxe, maxx, maxe, n)
self._can_profile.create_line(x1, y1 - 1, x1, y2, fill="grey")
for xtick in (
- (tim1, 0, "w"),
- (timm, maxx / 2, "center"),
- (tim2, maxx, "e"),
+ (tim1, 0, W),
+ (timm, maxx / 2, CENTER),
+ (tim2, maxx, E),
):
x, y = self._get_point(maxe, maxx, 0, xtick[1])
self._can_profile.create_text(
diff --git a/src/pygpsclient/graphview_frame.py b/src/pygpsclient/graphview_frame.py
index 08344347..10786b78 100644
--- a/src/pygpsclient/graphview_frame.py
+++ b/src/pygpsclient/graphview_frame.py
@@ -12,10 +12,18 @@
:license: BSD 3-Clause
"""
-from tkinter import ALL, BOTH, YES, Canvas, Frame, font
-
-from pygpsclient.globals import BGCOL, FGCOL, GNSS_LIST, MAX_SNR, WIDGETU2
-from pygpsclient.helpers import snr2col
+from tkinter import ALL, BOTH, YES, Canvas, E, Frame
+
+from pygpsclient.globals import (
+ AXISCOL,
+ BGCOL,
+ FGCOL,
+ GNSS_LIST,
+ GRIDCOL,
+ MAX_SNR,
+ WIDGETU2,
+)
+from pygpsclient.helpers import fontheight, scale_font, snr2col
# Relative offsets of graph axes and legend
AXIS_XL = 19
@@ -49,11 +57,11 @@ def __init__(self, app, *args, **kwargs):
def_w, def_h = WIDGETU2
self.width = kwargs.get("width", def_w)
self.height = kwargs.get("height", def_h)
+ self._font = self.__app.font_vsm
+ self._fonth = fontheight(self._font)
self._body()
self._attach_events()
- self.bind("", self._on_resize)
-
def _body(self):
"""
Set up frame and widgets.
@@ -71,6 +79,7 @@ def _attach_events(self):
Bind events to frame.
"""
+ self.bind("", self._on_resize)
self.can_graphview.bind("", self._on_legend)
def _on_legend(self, event): # pylint: disable=unused-argument
@@ -90,33 +99,34 @@ def init_frame(self):
"""
w, h = self.width, self.height
- resize_font = font.Font(size=min(int(h / 25), 10))
ticks = int(MAX_SNR / 10)
self.can_graphview.delete(ALL)
- self.can_graphview.create_line(AXIS_XL, 5, AXIS_XL, h - AXIS_Y, fill=FGCOL)
- self.can_graphview.create_line(
- w - AXIS_XR + 2, 5, w - AXIS_XR + 2, h - AXIS_Y, fill=FGCOL
- )
- self.can_graphview.create_text(
- AXIS_XR,
- 5,
- text="CN₀ dB",
- angle=90,
- fill=FGCOL,
- anchor="e",
- font=resize_font,
- )
for i in range(ticks, 0, -1):
y = (h - AXIS_Y) * i / ticks
- self.can_graphview.create_line(AXIS_XL, y, w - AXIS_XR + 2, y, fill=FGCOL)
+ self.can_graphview.create_line(
+ AXIS_XL, y, w - AXIS_XR + 2, y, fill=AXISCOL if i == ticks else GRIDCOL
+ )
self.can_graphview.create_text(
10,
y,
text=str(MAX_SNR - (i * 10)),
angle=90,
fill=FGCOL,
- font=resize_font,
+ font=self._font,
)
+ self.can_graphview.create_line(AXIS_XL, 5, AXIS_XL, h - AXIS_Y, fill=AXISCOL)
+ self.can_graphview.create_line(
+ w - AXIS_XR + 2, 5, w - AXIS_XR + 2, h - AXIS_Y, fill=AXISCOL
+ )
+ self.can_graphview.create_text(
+ AXIS_XR,
+ 5,
+ text="CN₀ dB",
+ angle=90,
+ fill=FGCOL,
+ anchor=E,
+ font=self._font,
+ )
if self.__app.frm_settings.config.get("legend_b", 1):
self._draw_legend()
@@ -128,7 +138,6 @@ def _draw_legend(self):
w = self.width / 10
h = self.height / 15
- resize_font = font.Font(size=min(int(self.height / 30), 10))
for i, (_, (gnssName, gnssCol)) in enumerate(GNSS_LIST.items()):
x = LEG_XOFF + w * i
@@ -146,7 +155,7 @@ def _draw_legend(self):
LEG_YOFF + h / 2,
text=gnssName,
fill=FGCOL,
- font=resize_font,
+ font=self._font,
)
def update_frame(self):
@@ -166,7 +175,8 @@ def update_frame(self):
offset = AXIS_XL + 2
colwidth = (w - AXIS_XL - AXIS_XR + 1) / siv
- resize_font = font.Font(size=min(int(colwidth / 2), 10))
+ # scale x axis label according to siv
+ svfont, _ = scale_font(self.width, 6, siv, 14)
for d in sorted(data.values()): # sort by ascending gnssid, svid
gnssId, prn, _, _, snr = d
if snr in ("", "0", 0):
@@ -190,7 +200,7 @@ def update_frame(self):
h - 10,
text=prn,
fill=FGCOL,
- font=resize_font,
+ font=svfont,
angle=35,
)
offset += colwidth
@@ -205,6 +215,7 @@ def _on_resize(self, event): # pylint: disable=unused-argument
"""
self.width, self.height = self.get_size()
+ self._font, self._fonth = scale_font(self.width, 8, 25, 16)
def get_size(self):
"""
@@ -215,6 +226,4 @@ def get_size(self):
"""
self.update_idletasks() # Make sure we know about any resizing
- width = self.can_graphview.winfo_width()
- height = self.can_graphview.winfo_height()
- return (width, height)
+ return self.can_graphview.winfo_width(), self.can_graphview.winfo_height()
diff --git a/src/pygpsclient/helpers.py b/src/pygpsclient/helpers.py
index 06936158..5d6ca83b 100644
--- a/src/pygpsclient/helpers.py
+++ b/src/pygpsclient/helpers.py
@@ -16,10 +16,10 @@
import os
from datetime import datetime, timedelta
from math import asin, atan, atan2, cos, degrees, pi, radians, sin, sqrt, trunc
-from platform import system
from socket import AF_INET, SOCK_DGRAM, socket
from time import strftime
-from tkinter import Button, Entry, Label, Toplevel, W, font
+from tkinter import Entry
+from tkinter.font import Font
from pynmeagps import WGS84_SMAJ_AXIS, haversine
from pyubx2 import SET, SET_LAYER_RAM, TXN_NONE, UBX_MSGIDS, UBXMessage, attsiz, atttyp
@@ -32,7 +32,9 @@
PUBLICIP_URL,
RCVR_CONNECTION,
ROMVER_NEW,
+ TIME0,
Area,
+ AreaXY,
Point,
)
from pygpsclient.strings import NA
@@ -59,86 +61,6 @@
POINTLIMIT = 500 # max number of shape points supported by MapQuest API
-class ConfirmBox(Toplevel):
- """
- Confirm action dialog class.
- Provides better consistency across different OS platforms
- than using messagebox.askyesno()
-
- Returns True if OK, False if Cancel
- """
-
- def __init__(self, parent, title, prompt):
- """
- Constructor
-
- :param parent: parent dialog
- :param string title: title
- :param string prompt: prompt to be displayed
- """
-
- self.__master = parent
- Toplevel.__init__(self, parent)
- self.title(title) # pylint: disable=E1102
- self.resizable(False, False)
- Label(self, text=prompt, anchor=W).grid(
- row=0, column=0, columnspan=2, padx=3, pady=5
- )
- Button(self, command=self._on_ok, text="OK", width=8).grid(
- row=1, column=0, padx=3, pady=3
- )
- Button(self, command=self._on_cancel, text="Cancel", width=8).grid(
- row=1, column=1, padx=3, pady=3
- )
- self.lift() # Put on top of
- self.grab_set() # Make modal
- self._rc = False
-
- self._centre()
-
- def _on_ok(self, event=None): # pylint: disable=unused-argument
- """
- OK button handler
- """
-
- self._rc = True
- self.destroy()
-
- def _on_cancel(self, event=None): # pylint: disable=unused-argument
- """
- Cancel button handler
- """
-
- self._rc = False
- self.destroy()
-
- def _centre(self):
- """
- Centre dialog in parent
- """
-
- # self.update_idletasks()
- dw = self.winfo_width()
- dh = self.winfo_height()
- mx = self.__master.winfo_x()
- my = self.__master.winfo_y()
- mw = self.__master.winfo_width()
- mh = self.__master.winfo_height()
- self.geometry(f"+{int(mx + (mw/2 - dw/2))}+{int(my + (mh/2 - dh/2))}")
-
- def show(self):
- """
- Show dialog
-
- :return: True (OK) or False (Cancel)
- :rtype: bool
- """
-
- self.wm_deiconify()
- self.wait_window()
- return self._rc
-
-
def cel2cart(elevation: float, azimuth: float) -> tuple:
"""
Convert celestial coordinates (degrees) to Cartesian coordinates.
@@ -784,24 +706,50 @@ def secs2unit(secs: int) -> tuple:
return val, SECSUNITS[i]
-def sizefont(height: int, lines: int, minfont: int) -> tuple:
+def fontwidth(fnt: Font, txt: str = "W") -> int:
+ """
+ Get font width.
+
+ :param Font fnt:font
+ :param txt: reference text ("W")
+ :return: font width in pixels
+ :rtype: int
+ """
+
+ return Font.measure(fnt, txt)
+
+
+def fontheight(fnt: Font) -> int:
+ """
+ Get font height.
+
+ :param Font fnt: font
+ :return: font height in pixels
+ :rtype: int
+ """
+
+ return Font.metrics(fnt, "linespace")
+
+
+def scale_font(
+ width: int, basesize: int, txtwidth: int, maxsize: int = 0, fnt: Font = None
+) -> tuple:
"""
- Set font size according to number of text lines on widget
- of given height.
+ Scale font size to widget width.
- :param int maxlines: max no of lines of text
- :param int minfont: min font size
- :returns: tuple of (font, fontheight)
+ :param int width: widget width
+ :param int bassiz: base font size
+ :param int txtwidth: reference text width
+ :param int maxsiz: max font size
+ :param Font fnt: default font
+ :return: tuple of scaled font, font height
:rtype: tuple
"""
- fh = 0
- fs = minfont
- while fh * lines < height:
- fnt = font.Font(size=fs)
- fh = fnt.metrics("linespace")
- fs += 1
- return fnt, fh
+ fnt = Font(size=12) if fnt is None else fnt
+ fs = basesize * width / fontwidth(fnt, "W" * txtwidth)
+ fnt = Font(size=int(min(fs, maxsize))) if maxsize else Font(size=int(fs))
+ return fnt, fontheight(fnt)
def setubxrate(app: object, mid: str, rate: int = 1, prot: str = "UBX") -> UBXMessage:
@@ -921,22 +869,6 @@ def config_nmea(state: int, port_type: str = "USB") -> UBXMessage:
return UBXMessage.config_set(layers, transaction, cfg_data)
-def adjust_dimensions(dim: int) -> int:
- """
- Adjust display dimensions for different operating systems.
-
- :param int dim: dimension
- :return: adjusted dimension
- :rtype: int
- """
-
- if system() == "Windows":
- return int(dim * 0.95)
- if system() == "Darwin":
- return int(dim * 0.93)
- return int(dim)
-
-
def ned2vector(n: float, e: float, d: float) -> tuple:
"""
Convert N,E,D relative position to 2D heading and distance.
@@ -1120,6 +1052,74 @@ def xy2ll(width: int, height: int, bounds: Area, xy: tuple) -> Point:
return Point(lat, lon)
+def data2xy(
+ width: int,
+ height: int,
+ bounds: AreaXY,
+ xdata: float,
+ ydata: float,
+ xoffset: float = 0,
+ yoffset: float = 0,
+) -> tuple:
+ """
+ Convert datapoint x,y to canvas x,y. Y is vertical axis.
+
+ :param int width: canvas width
+ :param int height: canvas height
+ :param AreaXY bounds: x,y bounds of data
+ :param float xdata: datapoint x
+ :param float ydata: datapoint y
+ :param float xoffset: canvas x offset
+ :param float yoffset: canvas y offset
+ :return: x,y canvas coordinates
+ :rtype: tuple
+ """
+ # pylint: disable=too-many-arguments, too-many-positional-arguments
+
+ try:
+ lw = bounds.x2 - bounds.x1
+ lh = bounds.y2 - bounds.y1
+ x = (xdata - bounds.x1) / (lw / width)
+ y = height - (ydata - bounds.y1) / (lh / height)
+ except ZeroDivisionError:
+ return 0, 0
+ return x + xoffset, y + yoffset
+
+
+def xy2data(
+ width: int,
+ height: int,
+ bounds: AreaXY,
+ x: int,
+ y: int,
+ xoffset: int = 0,
+ yoffset: int = 0,
+) -> tuple:
+ """
+ Convert canvas x,y to datapoint x,y. Y is vertical axis.
+
+ :param int width: canvas width
+ :param int height: canvas height
+ :param AreaXY bounds: x,y bounds of data
+ :param int x: canvas x coordinate
+ :param int y: canvas y coordinate
+ :param float xoffset: canvas x offset
+ :param float yoffset: canvas y offset
+ :return: xdata, ydata
+ :rtype: tuple
+ """
+ # pylint: disable=too-many-arguments, too-many-positional-arguments
+
+ try:
+ lw = bounds.x2 - bounds.x1
+ lh = bounds.y2 - bounds.y1
+ datax = bounds.x1 + (x - xoffset) / (width / lw)
+ datay = bounds.y1 + (height - (y - yoffset)) / (height / lh)
+ except ZeroDivisionError:
+ return 0, 0
+ return datax, datay
+
+
def points2area(points: tuple) -> Area:
"""
Convert 4 points to Area.
@@ -1135,18 +1135,10 @@ def points2area(points: tuple) -> Area:
if len(points) != 4:
raise ValueError("Exactly 4 points required")
- if points[2] < points[0]:
- minlat = points[2]
- maxlat = points[0]
- else:
- minlat = points[0]
- maxlat = points[2]
- if points[3] < points[1]:
- minlon = points[3]
- maxlon = points[1]
- else:
- minlon = points[1]
- maxlon = points[3]
+ minlat = min(points[0], points[2])
+ maxlat = max(points[0], points[2])
+ minlon = min(points[1], points[3])
+ maxlon = max(points[1], points[3])
return Area(minlat, minlon, maxlat, maxlon)
@@ -1169,3 +1161,51 @@ def limittrack(track: list, limit: int = POINTLIMIT) -> list:
if i % stp == 0:
points.append(p)
return points
+
+
+def get_grid(
+ num: int = 10, start: int = 0, stop: int = 1, endpoint: bool = True
+) -> tuple:
+ """
+ Generate linear grid steps for graphing widgets.
+
+ :param int num: number of increments (10)
+ :param int start: start point (0)
+ :param int stop: end point (1)
+ :param bool endpoint: include endpoint (True)
+ :return: linear grid increments
+ :rtype: tuple
+ """
+
+ def linspace(start, stop, num, endpoint):
+ """Generator for linear grid"""
+ num = int(num)
+ start = start * 1.0
+ stop = stop * 1.0
+
+ if num == 1:
+ yield round(stop, 4)
+ return
+ if endpoint:
+ step = (stop - start) / (num - 1)
+ else:
+ step = (stop - start) / num
+
+ for i in range(num):
+ yield round(start + step * i, 4)
+
+ return tuple(linspace(start, stop, num, endpoint))
+
+
+def time2str(tim: float, sformat: str = "%H:%M:%S") -> str:
+ """
+ Convert time float to formatted string.
+
+ :param float tim: time as float (seconds since 1970-01-01-00:00:00)
+ :param str sformat: string format ("%H:%M:%S")
+ :return: formated time string
+ :rtype: str
+ """
+
+ dt = TIME0 + timedelta(seconds=tim)
+ return dt.strftime(sformat)
diff --git a/src/pygpsclient/map_frame.py b/src/pygpsclient/map_frame.py
index 25bdda40..4e749f6a 100644
--- a/src/pygpsclient/map_frame.py
+++ b/src/pygpsclient/map_frame.py
@@ -25,7 +25,7 @@
from io import BytesIO
from os import getenv
from time import time
-from tkinter import ALL, CENTER, NW, Canvas, E, Frame, N, S, StringVar, W, font
+from tkinter import ALL, CENTER, NW, Canvas, E, Frame, N, S, StringVar, W
from PIL import Image, ImageTk, UnidentifiedImageError
from requests import ConnectionError as ConnError
@@ -44,7 +44,14 @@
WORLD,
Point,
)
-from pygpsclient.helpers import limittrack, ll2xy, points2area, xy2ll
+from pygpsclient.helpers import (
+ fontheight,
+ limittrack,
+ ll2xy,
+ points2area,
+ scale_font,
+ xy2ll,
+)
from pygpsclient.mapquest import (
MAP_UPDATE_INTERVAL,
MAPQTIMEOUT,
@@ -68,6 +75,7 @@
ZOOMEND = "lightgray"
POSCOL = "red"
TRK_COL = "magenta" # color of track
+INSET = 4
class MapviewFrame(Frame):
@@ -98,9 +106,6 @@ def __init__(self, app, *args, **kwargs):
self._marker = ImageTk.PhotoImage(Image.open(ICON_POS))
self._img_start = ImageTk.PhotoImage(Image.open(ICON_START))
self._img_end = ImageTk.PhotoImage(Image.open(ICON_END))
- self._resize_font = font.Font(size=min(int(self.height / 5), 30))
- self._resize_font_height = self._resize_font.metrics("linespace")
- self._resize_font_width = self._resize_font.measure("+")
self._zoom = int((MAX_ZOOM - MIN_ZOOM) / 2)
self._lastmaptype = ""
self._lastmappath = ""
@@ -109,6 +114,8 @@ def __init__(self, app, *args, **kwargs):
self._pos = None
self._track = []
self._maptype = StringVar()
+ self._font = self.__app.font_sm
+ self._fonth = fontheight(self._font)
self._body()
self._attach_events()
@@ -175,15 +182,18 @@ def on_zoom(self, event): # pylint: disable=unused-argument
refresh = False
w, h = self.width, self.height
- fw, fh = self._resize_font_width, self._resize_font_height
+ _, zfh = scale_font(self.width, 16, 10, 20)
# zoom out (-) if not already at min
zinc = 0
- if w > event.x > w - 2 - fw and h > event.y > h - fh:
+ if w > event.x > w - INSET - zfh and h > event.y > h - INSET - zfh:
if self._zoom > MIN_ZOOM:
zinc = -1 if event.num == 1 else MIN_ZOOM - self._zoom
refresh = True
# zoom in (+) if not already at max
- elif w > event.x > w - 2 - fw and h - fh > event.y > h - fh * 2:
+ elif (
+ w > event.x > w - INSET - zfh
+ and h - INSET - zfh * 2 > event.y > h - INSET - zfh * 3
+ ):
if self._zoom < MAX_ZOOM:
zinc = 1 if event.num == 1 else MAX_ZOOM - self._zoom
refresh = True
@@ -448,30 +458,32 @@ def _draw_zoom(self):
"""
w, h = self.width, self.height
- fw, fh = self._resize_font_width, self._resize_font_height
+ zfnt, zfh = scale_font(self.width, 16, 10, 20)
+ x = w - INSET - zfh / 2
+ y = h - INSET
self._can_mapview.create_text(
- w - 2 - fw / 2,
- h - 2 - fh,
+ x,
+ y - zfh * 2,
text="+",
- font=self._resize_font,
+ font=zfnt,
fill=ZOOMCOL if self._zoom < MAX_ZOOM else ZOOMEND,
- anchor="s",
+ anchor=S,
)
self._can_mapview.create_text(
- w - 2 - fw / 2,
- h - 2 - fh / 1.2,
+ x,
+ y - zfh,
text=self._zoom,
fill=ZOOMCOL,
- font=font.Font(size=8),
- # anchor="e",
+ font=zfnt,
+ anchor=S,
)
self._can_mapview.create_text(
- w - 2 - fw / 2,
- h - 2,
- text="−",
- font=self._resize_font,
+ x,
+ y,
+ text="\u2212",
+ font=zfnt,
fill=ZOOMCOL if self._zoom > MIN_ZOOM else ZOOMEND,
- anchor="s",
+ anchor=S,
)
def _disp_error(self, msg):
@@ -482,7 +494,6 @@ def _disp_error(self, msg):
"""
w, h = self.width, self.height
- resize_font = font.Font(size=min(int(w / 20), 14))
self._can_mapview.delete(ALL)
self._can_mapview.create_text(
@@ -490,8 +501,8 @@ def _disp_error(self, msg):
h / 2,
text=msg,
fill="orange",
- font=resize_font,
- anchor="s",
+ font=self._font,
+ anchor=S,
)
def reset_map_refresh(self):
@@ -509,9 +520,7 @@ def _on_resize(self, event): # pylint: disable=unused-argument
"""
self.width, self.height = self.get_size()
- self._resize_font = font.Font(size=min(int(self.height / 5), 30))
- self._resize_font_height = self._resize_font.metrics("linespace")
- self._resize_font_width = self._resize_font.measure("+")
+ self._font, self._fonth = scale_font(self.width, 10, 25, 20)
def get_size(self):
"""
@@ -522,6 +531,4 @@ def get_size(self):
"""
self.update_idletasks() # Make sure we know about any resizing
- width = self._can_mapview.winfo_width()
- height = self._can_mapview.winfo_height()
- return (width, height)
+ return self._can_mapview.winfo_width(), self._can_mapview.winfo_height()
diff --git a/src/pygpsclient/menu_bar.py b/src/pygpsclient/menu_bar.py
index 50e41997..8c9388d4 100644
--- a/src/pygpsclient/menu_bar.py
+++ b/src/pygpsclient/menu_bar.py
@@ -75,14 +75,14 @@ def __init__(self, app, *args, **kwargs):
# Menu labels are set in app._grid_widgets() function
self.view_menu = Menu(self, tearoff=False)
for wdg, wdict in widget_state.items():
- if wdict[MENU] is not None:
+ if wdict.get(MENU, True):
self.view_menu.add_command(
- underline=1, command=lambda i=wdg: self.__app.toggle_widget(i)
+ underline=1, command=lambda i=wdg: self.__app.widget_toggle(i)
)
self.view_menu.add_command(
underline=1,
label=MENURESET,
- command=lambda: self.__app.reset_widgets(), # pylint: disable=unnecessary-lambda
+ command=lambda: self.__app.widget_reset(), # pylint: disable=unnecessary-lambda
)
self.add_cascade(menu=self.view_menu, label=MENUVIEW)
diff --git a/src/pygpsclient/ntrip_client_dialog.py b/src/pygpsclient/ntrip_client_dialog.py
index 22447659..9f91a2e8 100644
--- a/src/pygpsclient/ntrip_client_dialog.py
+++ b/src/pygpsclient/ntrip_client_dialog.py
@@ -181,9 +181,7 @@ def _body(self):
server_callback=self._on_server,
)
self._frm_status = Frame(self._frm_container, borderwidth=2, relief="groove")
- self._lbl_status = Label(
- self._frm_status, textvariable=self._status, anchor="w"
- )
+ self._lbl_status = Label(self._frm_status, textvariable=self._status, anchor=W)
self._btn_exit = Button(
self._frm_status,
image=self._img_exit,
@@ -205,7 +203,7 @@ def _body(self):
self._frm_container,
textvariable=self._ntrip_mpdist,
width=30,
- anchor="w",
+ anchor=W,
)
self._lbl_sourcetable = Label(self._frm_container, text=LBLNTRIPSTR)
self._lbx_sourcetable = Listbox(
diff --git a/src/pygpsclient/rover_frame.py b/src/pygpsclient/rover_frame.py
index 168c6f30..3953e296 100644
--- a/src/pygpsclient/rover_frame.py
+++ b/src/pygpsclient/rover_frame.py
@@ -16,13 +16,13 @@
# pylint: disable=invalid-name
from math import cos, pi, sin
-from tkinter import ALL, BOTH, YES, Frame, font
+from tkinter import ALL, BOTH, NW, SW, YES, E, Frame, N, S, W
from pygpsclient.globals import BGCOL, FGCOL, WIDGETU2
-from pygpsclient.helpers import setubxrate
+from pygpsclient.helpers import fontheight, scale_font, setubxrate
from pygpsclient.skyview_frame import Canvas
-INSET = 5
+INSET = 4 # avoid edges
SQRT2 = 0.7071067811865476
MAXPOINTS = 100
PNTCOL = "orange"
@@ -55,7 +55,8 @@ def __init__(self, app, *args, **kwargs):
self.scale = 1
self.range = int(min(self.width / 2, self.height / 2)) - INSET
self.points = []
- self.lbl_font = font.Font(size=10)
+ self._font = self.__app.font_sm
+ self._fonth = fontheight(self._font)
self._body()
self._attach_events()
@@ -86,33 +87,33 @@ def init_frame(self):
self.canvas.create_line(0, height / 2, width, height / 2, fill=FGCOL)
self.canvas.create_line(width / 2, 0, width / 2, height, fill=FGCOL)
- ls = self.lbl_font.metrics("linespace")
+ fh = self._fonth
self.canvas.create_text(
- width - ls,
+ width - fh,
height / 2,
text="90\u00b0\n E",
fill=FGCOL,
- font=self.lbl_font,
- anchor="e",
+ font=self._font,
+ anchor=E,
)
self.canvas.create_text(
- ls,
+ fh,
height / 2,
text="270\u00b0\n W",
fill=FGCOL,
- font=self.lbl_font,
- anchor="w",
+ font=self._font,
+ anchor=W,
)
self.canvas.create_text(
- width / 2, ls, text="0\u00b0 N", fill=FGCOL, font=self.lbl_font, anchor="n"
+ width / 2, fh, text="0\u00b0 N", fill=FGCOL, font=self._font, anchor=N
)
self.canvas.create_text(
width / 2,
- height - ls,
+ height - fh,
text="180\u00b0 S",
fill=FGCOL,
- font=self.lbl_font,
- anchor="s",
+ font=self._font,
+ anchor=S,
)
for rds in range(self.range, 10, int(self.range / -4)):
@@ -135,7 +136,7 @@ def init_frame(self):
txt_y,
text=f"{rds*self.scale/mul:.0f} {unt}",
fill=FGCOL,
- font=self.lbl_font,
+ font=self._font,
)
def update_frame(self):
@@ -175,22 +176,22 @@ def update_frame(self):
self.init_frame()
# plot status information
- ls = self.lbl_font.metrics("linespace")
+ fh = self._fonth
self.canvas.create_text(
- ls,
- ls,
+ INSET,
+ INSET,
text=f"Len {dis:,.2f} ± {accdis:.2f} cm",
fill=PNTCOL,
- anchor="nw",
- font=self.lbl_font,
+ anchor=NW,
+ font=self._font,
)
self.canvas.create_text(
- ls,
- ls * 2,
+ INSET,
+ INSET + fh,
text=f"Hdg {hdg:.2f} ± {acchdg:.2f}",
fill=PNTCOL,
- anchor="nw",
- font=self.lbl_font,
+ anchor=NW,
+ font=self._font,
)
fixok = "FIX OK" if fixok else "NO FIX"
diffsoln = "DGPS" if diffsoln else "NO DGPS"
@@ -202,12 +203,12 @@ def update_frame(self):
carrsoln = "NO RTK"
moving = "MOVING" if moving else "STATIC"
self.canvas.create_text(
- ls,
- self.height - ls,
+ INSET,
+ self.height - INSET,
text=f"{fixok}\n{diffsoln}\n{valrp}\n{valhdg}\n{carrsoln}\n{moving}",
fill=PNTCOL,
- anchor="sw",
- font=self.lbl_font,
+ anchor=SW,
+ font=self._font,
)
# plot historical relative position track
@@ -288,6 +289,7 @@ def _on_resize(self, event): # pylint: disable=unused-argument
"""
self.width, self.height = self.get_size()
+ self._font, self._fonth = scale_font(self.width, 10, 30, 20)
self.init_frame()
def _on_clear(self, event): # pylint: disable=unused-argument
@@ -309,7 +311,4 @@ def get_size(self) -> tuple:
"""
self.update_idletasks() # Make sure we know about resizing
- width = self.canvas.winfo_width()
- height = self.canvas.winfo_height()
- self.lbl_font = font.Font(size=min(int(width / 25), 10))
- return (width, height)
+ return self.canvas.winfo_width(), self.canvas.winfo_height()
diff --git a/src/pygpsclient/scatter_frame.py b/src/pygpsclient/scatter_frame.py
index 49038c1f..16aee032 100644
--- a/src/pygpsclient/scatter_frame.py
+++ b/src/pygpsclient/scatter_frame.py
@@ -23,6 +23,7 @@
from tkinter import (
ALL,
HORIZONTAL,
+ NW,
Checkbutton,
E,
Entry,
@@ -34,7 +35,6 @@
Spinbox,
StringVar,
W,
- font,
)
try:
@@ -57,10 +57,12 @@
Point,
)
from pygpsclient.helpers import (
+ fontheight,
get_point_at_vector,
in_bounds,
ll2xy,
reorder_range,
+ scale_font,
xy2ll,
)
from pygpsclient.skyview_frame import Canvas
@@ -94,7 +96,7 @@ def __init__(self, app, *args, **kwargs):
"""
self.__app = app
self.__master = self.__app.appmaster
- config = self.__app.saved_config
+ config = self.__app.saved_config.get("scattersettings_d", {})
Frame.__init__(self, self.__master, *args, **kwargs)
@@ -102,7 +104,8 @@ def __init__(self, app, *args, **kwargs):
self.width = kwargs.get("width", def_w)
self.height = kwargs.get("height", def_h)
- self._lbl_font = font.Font(size=max(int(self.height / 40), 10))
+ self._font = self.__app.font_sm
+ self._fonth = fontheight(self._font)
self._points = []
self._average = None
self._stddev = None
@@ -121,6 +124,7 @@ def __init__(self, app, *args, **kwargs):
self._scale = IntVar()
self._reflat = StringVar()
self._reflon = StringVar()
+ self._maxpoints = config.get("maxpoints_n", MAXPOINTS)
reflat = config.get("scatterlat_f", 0.0)
reflon = config.get("scatterlon_f", 0.0)
self._reflat.set("Reference Lat" if reflat == 0.0 else reflat)
@@ -271,17 +275,6 @@ def _on_rescale(self, var, index, mode): # pylint: disable=unused-argument
self._on_save_settings(var, index, mode)
self._on_resize(None)
- def _on_resize(self, event): # pylint: disable=unused-argument
- """
- Resize frame.
-
- :param Event event: resize event
- """
-
- self.width, self.height = self.get_size()
- self._init_frame()
- self._redraw()
-
def _on_recenter(self, event):
"""
Right click centers on cursor.
@@ -313,21 +306,25 @@ def _on_clear(self, event): # pylint: disable=unused-argument
self._updcount = -1
self._init_frame()
- def _on_save_settings(self, var, index, mode): # pylint: disable=unused-argument)
+ def _on_save_settings(self, var, index, mode): # pylint: disable=unused-argument
"""
Save current settings to saved app config dict.
"""
- self.__app.saved_config["scatterautorange_b"] = self._autorange.get()
- self.__app.saved_config["scattercenter_s"] = self._centermode.get()
- self.__app.saved_config["scatterinterval_n"] = self._interval.get()
- self.__app.saved_config["scatterscale_n"] = self._scale.get()
+ sst = {}
+ sst["maxpoints_n"] = self._maxpoints
+ sst["scatterautorange_b"] = self._autorange.get()
+ sst["scattercenter_s"] = self._centermode.get()
+ sst["scatterinterval_n"] = self._interval.get()
+ sst["scatterscale_n"] = self._scale.get()
try:
- self.__app.saved_config["scatterlat_f"] = float(self._reflat.get())
- self.__app.saved_config["scatterlon_f"] = float(self._reflon.get())
+ sst["scatterlat_f"] = float(self._reflat.get())
+ sst["scatterlon_f"] = float(self._reflon.get())
except ValueError:
- self.__app.saved_config["scatterlat_f"] = 0.0
- self.__app.saved_config["scatterlon_f"] = 0.0
+ sst["scatterlat_f"] = 0.0
+ sst["scatterlon_f"] = 0.0
+
+ self.__app.saved_config["scattersettings_d"] = sst
def _init_frame(self):
"""
@@ -360,20 +357,20 @@ def _init_frame(self):
txt_x = width / 2 + SQRT2 * maxr * i / 4
txt_y = height / 2 + SQRT2 * maxr * i / 4
self.canvas.create_text(
- txt_x, txt_y, text=dist, fill=FGCOL, font=self._lbl_font
+ txt_x, txt_y, text=dist, fill=FGCOL, font=self._font
)
- for x, y, t in (
- (width / 2, 5, "N"),
- (width / 2, height - 5, "S"),
- (5, height / 2, "W"),
- (width - 5, height / 2, "E"),
+ for x, y, anc in (
+ (width / 2, 5, N),
+ (width / 2, height - 5, S),
+ (5, height / 2, W),
+ (width - 5, height / 2, E),
):
self.canvas.create_text(
- x, y, text=t, fill=FGCOL, font=self._lbl_font, anchor=t.lower()
+ x, y, text=anc.upper(), fill=FGCOL, font=self._font, anchor=anc
)
- def _draw_stats(self, lbl_font: font):
+ def _draw_stats(self, lbl_font: object):
"""
Draw the stats in the corner of the plot.
@@ -385,22 +382,22 @@ def _draw_stats(self, lbl_font: font):
self.canvas.delete(AVG)
y = 5
- fh = self._lbl_font.metrics("linespace")
+ fh = self._fonth
avg = f"Avg: {self._average.lat:.9f}, {self._average.lon:.9f}"
self.canvas.create_text(
- 5, y, text=avg, fill=PNTCOL, font=lbl_font, anchor="nw", tags=AVG
+ 5, y, text=avg, fill=PNTCOL, font=lbl_font, anchor=NW, tags=AVG
)
y += fh
if self._stddev is not None:
std = f"Std: {self._stddev.lat:.3e}, {self._stddev.lon:.3e}"
self.canvas.create_text(
- 5, y, text=std, fill=PNTCOL, font=lbl_font, anchor="nw", tags=AVG
+ 5, y, text=std, fill=PNTCOL, font=lbl_font, anchor=NW, tags=AVG
)
y += fh
np = len(self._points)
- pts = f"Pts: {np} {'!' if np >= MAXPOINTS else ''}"
+ pts = f"Pts: {np} {'!' if np >= self._maxpoints else ''}"
self.canvas.create_text(
- 5, y, text=pts, fill=PNTCOL, font=lbl_font, anchor="nw", tags=AVG
+ 5, y, text=pts, fill=PNTCOL, font=lbl_font, anchor=NW, tags=AVG
)
def _draw_point(self, position: Point, color: str = PNTCOL, size: int = 2):
@@ -500,7 +497,7 @@ def _redraw(self):
self._draw_point(self._fixed, FIXCOL, 3)
self._draw_point(self._points[-1], PNTTOPCOL)
- self._draw_stats(self._lbl_font)
+ self._draw_stats(self._font)
def update_frame(self):
"""
@@ -526,7 +523,7 @@ def update_frame(self):
return # Don't repeat exactly the last point.
self._points.append(pos)
- if len(self._points) > MAXPOINTS:
+ if len(self._points) > self._maxpoints:
self._cull_points()
self._set_average()
@@ -586,6 +583,18 @@ def _do_autorange(self, middle: Point):
self._scale.set(self._scale.get() - 1)
self._set_bounds(middle)
+ def _on_resize(self, event): # pylint: disable=unused-argument
+ """
+ Resize frame.
+
+ :param Event event: resize event
+ """
+
+ self.width, self.height = self.get_size()
+ self._font, self._fonth = scale_font(self.width, 10, 25, 20)
+ self._init_frame()
+ self._redraw()
+
def get_size(self) -> tuple:
"""
Get current canvas size.
@@ -595,7 +604,4 @@ def get_size(self) -> tuple:
"""
self.update_idletasks() # Make sure we know about resizing
- width = self.canvas.winfo_width()
- height = self.canvas.winfo_height()
- self._lbl_font = font.Font(size=max(int(height / 40), 10))
- return (width, height)
+ return self.canvas.winfo_width(), self.canvas.winfo_height()
diff --git a/src/pygpsclient/settings_frame.py b/src/pygpsclient/settings_frame.py
index 662c8ee4..97459640 100644
--- a/src/pygpsclient/settings_frame.py
+++ b/src/pygpsclient/settings_frame.py
@@ -19,6 +19,7 @@
# pylint: disable=unnecessary-lambda
+from platform import system
from socket import AF_INET6
from tkinter import (
ALL,
@@ -28,6 +29,7 @@
HORIZONTAL,
LEFT,
NORMAL,
+ NW,
RIGHT,
VERTICAL,
Button,
@@ -99,7 +101,7 @@
UMM,
WORLD,
)
-from pygpsclient.helpers import adjust_dimensions
+from pygpsclient.helpers import fontheight, fontwidth
from pygpsclient.mapquest import MAP_UPDATE_INTERVAL
from pygpsclient.serialconfig_frame import SerialConfigFrame
from pygpsclient.serverconfig_frame import ServerConfigFrame
@@ -123,8 +125,18 @@
MAXLINES = ("200", "500", "1000", "2000", "100")
MAPTYPES = (WORLD, MAP, SAT, CUSTOM)
-MINHEIGHT = 750
-MINWIDTH = 390
+# initial dimensions adjusted for different widget
+# rendering on different platforms
+if system() == "Linux": # Wayland
+ MINHEIGHT = 28
+ MINWIDTH = 28
+elif system() == "Darwin": # MacOS
+
+ MINHEIGHT = 38
+ MINWIDTH = 30
+else: # Windows and others
+ MINHEIGHT = 35
+ MINWIDTH = 26
class SettingsFrame(Frame):
@@ -194,7 +206,8 @@ def _container(self):
function which invokes the on_expand() method here.
"""
- dimw, dimh = [adjust_dimensions(x) for x in (MINWIDTH, MINHEIGHT)]
+ dimw = fontwidth(self.__app.font_md) * MINWIDTH
+ dimh = fontheight(self.__app.font_md) * MINHEIGHT
self._frm_main = Frame(self)
self._frm_main.pack(fill=BOTH, expand=1)
self_frm_scrollx = Frame(self._frm_main)
@@ -212,9 +225,7 @@ def _container(self):
y_scrollbar.pack(side=RIGHT, fill=Y)
self._can_container.configure(xscrollcommand=x_scrollbar.set)
self._can_container.configure(yscrollcommand=y_scrollbar.set)
- self._can_container.create_window(
- (0, 0), window=self._frm_container, anchor="nw"
- )
+ self._can_container.create_window((0, 0), window=self._frm_container, anchor=NW)
self._can_container.bind(
"",
lambda e: self._can_container.config(
@@ -933,19 +944,22 @@ def config(self) -> dict:
0,
),
),
- "scatterautorange_b": self.__app.saved_config.get(
- "scatterautorange_b", 0
- ),
- "scattercenter_s": self.__app.saved_config.get(
- "scattercenter_s", "Average"
+ "scattersettings_d": self.__app.saved_config.get(
+ "scattersettings_d",
+ {
+ "scatterautorange_b": 0,
+ "scattercenter_s": "Average",
+ "scatterinterval_n": 1,
+ "scatterscale_n": 6,
+ "scatterlat_f": 0.0,
+ "scatterlon_f": 0.0,
+ },
),
- "scatterinterval_n": self.__app.saved_config.get(
- "scatterinterval_n", 1
+ "chartsettings_d": self.__app.saved_config.get(
+ "chartsettings_d", {"numchn": 4, "timrng": 60}
),
- "scatterscale_n": self.__app.saved_config.get("scatterscale_n", 6),
- "scatterlat_f": self.__app.saved_config.get("scatterlat_f", 0.0),
- "scatterlon_f": self.__app.saved_config.get("scatterlon_f", 0.0),
# Manually edited config settings
+ "checkforupdate_b": self.__app.saved_config.get("checkforupdate_b", 0),
"spartndecode_b": self.__app.saved_config.get("spartndecode_b", 0),
"spartnkey_s": self.__app.saved_config.get(
"spartnkey_s", SPARTN_DEFAULT_KEY
diff --git a/src/pygpsclient/skyview_frame.py b/src/pygpsclient/skyview_frame.py
index 533501c9..fb5d043b 100644
--- a/src/pygpsclient/skyview_frame.py
+++ b/src/pygpsclient/skyview_frame.py
@@ -13,10 +13,10 @@
"""
from operator import itemgetter
-from tkinter import ALL, BOTH, YES, Canvas, Frame, font
+from tkinter import ALL, BOTH, YES, Canvas, Frame
from pygpsclient.globals import BGCOL, FGCOL, GNSS_LIST, WIDGETU1
-from pygpsclient.helpers import cel2cart, col2contrast, snr2col
+from pygpsclient.helpers import cel2cart, col2contrast, fontheight, scale_font, snr2col
OL_WID = 2
@@ -45,9 +45,10 @@ def __init__(self, app, *args, **kwargs):
self.height = kwargs.get("height", def_h)
self.bg_col = BGCOL
self.fg_col = FGCOL
+ self._font = self.__app.font_vsm
+ self._fonth = fontheight(self._font)
self._body()
-
- self.bind("", self._on_resize)
+ self._attach_events()
def _body(self):
"""
@@ -61,6 +62,13 @@ def _body(self):
)
self.can_satview.pack(fill=BOTH, expand=YES)
+ def _attach_events(self):
+ """
+ Bind events to frame.
+ """
+
+ self.bind("", self._on_resize)
+
def init_frame(self):
"""
Initialise satellite view
@@ -68,7 +76,6 @@ def init_frame(self):
w, h = self.width, self.height
axis_r = min(h, w) / 18
- resize_font = font.Font(size=min(int(w / 25), 8))
self.can_satview.delete(ALL)
maxr = min((h / 2), (w / 2)) - axis_r
for r in (0.2, 0.4, 0.6, 0.8, 1):
@@ -78,16 +85,16 @@ def init_frame(self):
self.can_satview.create_line(w / 2, 0, w / 2, h, fill=self.fg_col)
self.can_satview.create_line(0, h / 2, w, h / 2, fill=self.fg_col)
self.can_satview.create_text(
- w - axis_r, h / 2, text="90\u00b0\n E", fill=self.fg_col, font=resize_font
+ w - axis_r, h / 2, text="90\u00b0\n E", fill=self.fg_col, font=self._font
)
self.can_satview.create_text(
- axis_r, h / 2, text="270\u00b0\n W", fill=self.fg_col, font=resize_font
+ axis_r, h / 2, text="270\u00b0\n W", fill=self.fg_col, font=self._font
)
self.can_satview.create_text(
- w / 2, axis_r, text="0\u00b0 N", fill=self.fg_col, font=resize_font
+ w / 2, axis_r, text="0\u00b0 N", fill=self.fg_col, font=self._font
)
self.can_satview.create_text(
- w / 2, h - axis_r, text="180\u00b0 S", fill=self.fg_col, font=resize_font
+ w / 2, h - axis_r, text="180\u00b0 S", fill=self.fg_col, font=self._font
)
def update_frame(self):
@@ -99,7 +106,6 @@ def update_frame(self):
w, h = self.width, self.height
axis_r = min(h, w) / 18
maxr = min((h / 2), (w / 2)) - axis_r
- resize_font = font.Font(size=min(int(maxr / 10), 8))
self.init_frame()
for d in sorted(data.values(), key=itemgetter(4)): # sort by ascending snr
@@ -130,7 +136,7 @@ def update_frame(self):
y + (h / 2),
text=prn,
fill=col2contrast(bg_col),
- font=resize_font,
+ font=self._font,
)
except ValueError:
pass
@@ -145,6 +151,7 @@ def _on_resize(self, event): # pylint: disable=unused-argument
"""
self.width, self.height = self.get_size()
+ self._font, self._fonth = scale_font(self.width, 10, 25, 20)
def get_size(self):
"""
@@ -155,6 +162,4 @@ def get_size(self):
"""
self.update_idletasks() # Make sure we know about any resizing
- width = self.can_satview.winfo_width()
- height = self.can_satview.winfo_height()
- return (width, height)
+ return self.can_satview.winfo_width(), self.can_satview.winfo_height()
diff --git a/src/pygpsclient/spartn_dialog.py b/src/pygpsclient/spartn_dialog.py
index eed19e55..9cc9c6ce 100644
--- a/src/pygpsclient/spartn_dialog.py
+++ b/src/pygpsclient/spartn_dialog.py
@@ -86,9 +86,7 @@ def _body(self):
self._frm_container = Frame(self)
self._frm_status = Frame(self._frm_container, borderwidth=2, relief="groove")
- self._lbl_status = Label(
- self._frm_status, textvariable=self._status, anchor="w"
- )
+ self._lbl_status = Label(self._frm_status, textvariable=self._status, anchor=W)
self._btn_exit = Button(
self._frm_status,
image=self._img_exit,
diff --git a/src/pygpsclient/spectrum_frame.py b/src/pygpsclient/spectrum_frame.py
index c8ce1c00..46f96764 100644
--- a/src/pygpsclient/spectrum_frame.py
+++ b/src/pygpsclient/spectrum_frame.py
@@ -13,12 +13,21 @@
:license: BSD 3-Clause
"""
-from tkinter import ALL, Canvas, Checkbutton, E, Frame, IntVar, N, S, W, font
+from tkinter import ALL, NW, Canvas, Checkbutton, E, Frame, IntVar, N, S, W
from pyubx2 import UBXMessage
-from pygpsclient.globals import BGCOL, FGCOL, GNSS_LIST, PNTCOL, SPECTRUMVIEW, WIDGETU2
-from pygpsclient.helpers import setubxrate
+from pygpsclient.globals import (
+ BGCOL,
+ FGCOL,
+ GNSS_LIST,
+ GRIDCOL,
+ PNTCOL,
+ SPECTRUMVIEW,
+ WIDGETU2,
+ AreaXY,
+)
+from pygpsclient.helpers import data2xy, fontheight, scale_font, setubxrate, xy2data
from pygpsclient.strings import DLGENABLEMONSPAN, DLGNOMONSPAN, DLGWAITMONSPAN
# Graph dimensions
@@ -31,7 +40,6 @@
MAX_HZ = 1650000000
TICK_DB = 20 # 20 dB divisions
TICK_GHZ = 40000000 # 40 MHz divisions
-TICK_COL = "grey"
RF_BANDS = {
"B1": 1575420000,
"B3": 1268520000,
@@ -102,8 +110,9 @@ def __init__(self, app, *args, **kwargs):
self._chartpos = None
self._spectrum_snapshot = []
self._pgaoffset = IntVar()
+ self._font = self.__app.font_sm
+ self._fonth = fontheight(self._font)
self._body()
- self._set_fontsize()
self._attach_events()
def _body(self):
@@ -145,15 +154,24 @@ def init_frame(self):
Initialise spectrum chart.
"""
+ offset = self._fonth + 4
+ w = self.width - offset * 2
+ h = self.height - offset
+ bounds = AreaXY(self._minhz, self._mindb, self._maxhz, self._maxdb)
self.can_spectrumview.delete(ALL)
# plot y (dB) axis grid
i = 0
for db in range(self._mindb, self._maxdb, TICK_DB):
- x1, y1 = self._get_point(self._minhz, db)
- x2, y2 = self._get_point(self._maxhz, db)
+ x1, y1 = data2xy(w, h, bounds, self._minhz, db, offset)
+ x2, y2 = data2xy(w, h, bounds, self._maxhz, db, offset)
self.can_spectrumview.create_line(
- x1, y1, x2 + 1, y1, fill=TICK_COL if i else FGCOL, tags=MODEINIT
+ x1,
+ y1,
+ x2 + 1,
+ y1,
+ fill=GRIDCOL if i else FGCOL,
+ tags=MODEINIT,
)
self.can_spectrumview.create_text(
x1,
@@ -162,7 +180,7 @@ def init_frame(self):
angle=90,
fill=FGCOL,
font=self._font,
- anchor="s",
+ anchor=S,
tags=MODEINIT,
)
i += 1
@@ -170,10 +188,15 @@ def init_frame(self):
# plot x (Hz) axis grid
i = 0
for hz in range(self._minhz, self._maxhz, TICK_GHZ):
- x1, y1 = self._get_point(hz, self._mindb)
- x2, y2 = self._get_point(hz, self._maxdb)
+ x1, y1 = data2xy(w, h, bounds, hz, self._mindb, offset)
+ x2, y2 = data2xy(w, h, bounds, hz, self._maxdb, offset)
self.can_spectrumview.create_line(
- x1, y1 - 1, x1, y2, fill=TICK_COL if i else FGCOL, tags=MODEINIT
+ x1,
+ y1 - 1,
+ x1,
+ y2,
+ fill=GRIDCOL if i else FGCOL,
+ tags=MODEINIT,
)
self.can_spectrumview.create_text(
x1,
@@ -181,16 +204,22 @@ def init_frame(self):
text=f"{hz / 1e9:.2f}", # GHz
fill=FGCOL,
font=self._font,
- anchor="n",
+ anchor=N,
tags=MODEINIT,
)
i += 1
- x, y = self._get_point(self._maxhz, self._mindb)
+ x, y = data2xy(w, h, bounds, self._maxhz, self._mindb, offset)
self.can_spectrumview.create_text(
- x, y, text="GHz", fill=FGCOL, font=self._font, anchor="se", tags=MODEINIT
+ x,
+ y,
+ text="GHz",
+ fill=FGCOL,
+ font=self._font,
+ anchor="se",
+ tags=MODEINIT,
)
- x, y = self._get_point(self._minhz + self._fonth, self._maxdb - 5)
+ x, y = data2xy(w, h, bounds, self._minhz + self._fonth, self._maxdb - 5, offset)
self.can_spectrumview.create_text(
x,
y,
@@ -263,7 +292,7 @@ def update_pending(self, msg: UBXMessage):
h / 2,
text=DLGNOMONSPAN,
fill="orange",
- anchor="s",
+ anchor=S,
)
self._pending_confs.pop("ACK-NAK")
self._monspan_status = DLGNOMONSPAN
@@ -298,6 +327,10 @@ def _update_plot(self, rfblocks: list, mode: str = MODELIVE, colors: dict = None
:param str mode: plot mode ("live" or "snap"shot)
"""
+ offset = self._fonth + 4
+ w = self.width - offset * 2
+ h = self.height - offset
+
if colors is None:
colors = RF_LIST
@@ -307,50 +340,14 @@ def _update_plot(self, rfblocks: list, mode: str = MODELIVE, colors: dict = None
self._maxdb += 40
specxy, self._minhz, self._maxhz = self._get_limits(rfblocks)
+ bounds = AreaXY(self._minhz, self._mindb, self._maxhz, self._maxdb)
if mode == MODESNAP:
self.can_spectrumview.delete(MODESNAP)
else:
self.init_frame()
# plot frequency band markers
if self._showrf:
- for nam, frq in RF_BANDS.items():
- if self._minhz < frq < self._maxhz:
- x1, y1 = self._get_point(frq, self._maxdb)
- x2, y2 = self._get_point(frq, self._mindb)
- yoff, col = {
- "L": (self._fonth, GNSS_LIST[0][1]), # GPS
- "G": (self._fonth * 2, GNSS_LIST[6][1]), # GLONASS
- "E": (self._fonth * 3, GNSS_LIST[2][1]), # Galileo
- "S": (self._fonth * 3, GNSS_LIST[2][1]), # Galileo SAR
- "B": (self._fonth * 4, GNSS_LIST[3][1]), # Beidou
- }[nam[0:1]]
- if nam not in (
- "E1",
- "E5a",
- "E5b",
- "B2a",
- "B2",
- "B1",
- ): # same freq as other bands
- self.can_spectrumview.create_line(
- x1,
- y1,
- x1,
- y2,
- fill=col,
- dash=(5, 2),
- width=OL_WID,
- tags=mode,
- )
- self.can_spectrumview.create_text(
- x2 + 2,
- y2 - yoff - 1,
- text=nam,
- fill=col,
- anchor="nw",
- font=self._font,
- tags=mode,
- )
+ self._plot_rf_markers(w, h, bounds, offset, mode)
# for each RF block in MON-SPAN message
for i, rfblock in enumerate(specxy):
@@ -369,7 +366,7 @@ def _update_plot(self, rfblocks: list, mode: str = MODELIVE, colors: dict = None
text=f"RF {i + 1}",
fill=FGCOL,
font=self._font,
- anchor="s",
+ anchor=S,
tags=mode,
)
self.can_spectrumview.create_line(
@@ -387,10 +384,16 @@ def _update_plot(self, rfblocks: list, mode: str = MODELIVE, colors: dict = None
y2 = self.height - 5
for n, (hz, db) in enumerate(rfblock):
x1, y1 = x2, y2
- x2, y2 = self._get_point(hz, db)
+ x2, y2 = data2xy(w, h, bounds, hz, db, offset)
if n:
self.can_spectrumview.create_line(
- x1, y1, x2, y2, fill=col, width=OL_WID, tags=mode
+ x1,
+ y1,
+ x2,
+ y2,
+ fill=col,
+ width=OL_WID,
+ tags=mode,
)
# display any flagged chart position
@@ -406,49 +409,55 @@ def _update_plot(self, rfblocks: list, mode: str = MODELIVE, colors: dict = None
tags=mode,
)
- def _get_point(self, hz: float, db: float) -> tuple:
- """
- Convert (hz,db) values to canvas pixel coordinates (x,y).
-
- :param hz float: hz (x) value
- :param db float: db (y) value
- :return: (x, y) coordinates
- :rtype: tuple
- """
-
- offset = self._fonth + 4
- val_db = db - self._mindb
- range_db = self._maxdb - self._mindb
- range_x = self.width - offset * 2
- val_hz = hz - self._minhz
- range_hz = self._maxhz - self._minhz
- range_y = self.height - self._fonth
-
- x = offset + (val_hz * range_x / range_hz)
- y = self.height - offset - (val_db * range_y / range_db)
- return (int(x), int(y))
-
- def _get_hzdb(self, x: int, y: int) -> tuple:
- """
- Get frequency & level corresponding to cursor x,y position.
-
- :param x int: cursor x position
- :param y int: cursor y position
- :return: (Ghz, dB) values
- :rtype: tuple
- """
-
- offset = self._fonth + 4
- val_x = x - offset
- range_x = self.width - offset * 2
- range_db = self._maxdb - self._mindb
- val_y = y - self.height + offset
- range_y = self._fonth - self.height
- range_hz = self._maxhz - self._minhz
-
- hz = self._minhz + (val_x * range_hz / range_x)
- db = self._mindb + (val_y * range_db / range_y)
- return (hz / 1e9, db)
+ def _plot_rf_markers(self, w: int, h: int, bounds: AreaXY, offset: int, mode: int):
+ """
+ Plot RF band markers
+
+ :param int w: plots width
+ :param int h: plot height
+ :param AreaXY bounds: data bounds
+ :param offset: _description_
+ """
+ # pylint: disable=too-many-arguments, too-many-positional-arguments
+
+ for nam, frq in RF_BANDS.items():
+ if self._minhz < frq < self._maxhz:
+ x1, y1 = data2xy(w, h, bounds, frq, self._maxdb, offset)
+ x2, y2 = data2xy(w, h, bounds, frq, self._mindb, offset)
+ yoff, col = {
+ "L": (self._fonth, GNSS_LIST[0][1]), # GPS
+ "G": (self._fonth * 2, GNSS_LIST[6][1]), # GLONASS
+ "E": (self._fonth * 3, GNSS_LIST[2][1]), # Galileo
+ "S": (self._fonth * 3, GNSS_LIST[2][1]), # Galileo SAR
+ "B": (self._fonth * 4, GNSS_LIST[3][1]), # Beidou
+ }[nam[0:1]]
+ if nam not in (
+ "E1",
+ "E5a",
+ "E5b",
+ "B2a",
+ "B2",
+ "B1",
+ ): # same freq as other bands
+ self.can_spectrumview.create_line(
+ x1,
+ y1,
+ x1,
+ y2,
+ fill=col,
+ dash=(5, 2),
+ width=OL_WID,
+ tags=mode,
+ )
+ self.can_spectrumview.create_text(
+ x2 + 2,
+ y2 - yoff - 1,
+ text=nam,
+ fill=col,
+ anchor=NW,
+ font=self._font,
+ tags=mode,
+ )
def _get_limits(self, rfblocks: list) -> tuple:
"""
@@ -484,23 +493,17 @@ def _get_limits(self, rfblocks: list) -> tuple:
int(maxhz + TICK_GHZ / 2),
)
- def _on_resize(self, event): # pylint: disable=unused-argument
- """
- Resize frame.
-
- :param event event: resize event
- """
-
- self.width, self.height = self.get_size()
- self._chartpos = None
-
def _on_click(self, event):
"""
Save flagged chart position.
"""
- hz, db = self._get_hzdb(event.x, event.y)
- self._chartpos = (event.x, event.y, hz, db)
+ offset = self._fonth + 4
+ w = self.width - offset * 2
+ h = self.height - offset
+ bounds = AreaXY(self._minhz, self._mindb, self._maxhz, self._maxdb)
+ hz, db = xy2data(w, h, bounds, event.x - offset, event.y)
+ self._chartpos = (event.x, event.y, hz / 1e9, db)
def _on_toggle_rf(self, event): # pylint: disable=unused-argument
"""
@@ -525,6 +528,17 @@ def _on_clear_snapshot(self, event): # pylint: disable=unused-argument
self._spectrum_snapshot = []
self.can_spectrumview.delete("snap")
+ def _on_resize(self, event): # pylint: disable=unused-argument
+ """
+ Resize frame.
+
+ :param event event: resize event
+ """
+
+ self.width, self.height = self.get_size()
+ self._font, self._fonth = scale_font(self.width, 8, 25, 20)
+ self._chartpos = None
+
def get_size(self):
"""
Get current canvas size.
@@ -534,16 +548,4 @@ def get_size(self):
"""
self.update_idletasks() # Make sure we know about any resizing
- width = self.can_spectrumview.winfo_width()
- height = self.can_spectrumview.winfo_height()
- self._set_fontsize()
- return (width, height)
-
- def _set_fontsize(self):
- """
- Set font size and line spacing
- """
-
- dim = min(self.width, self.height)
- self._font = font.Font(size=max(int(dim * RESFONT / 1000), MINFONT))
- self._fonth = self._font.metrics("linespace")
+ return self.can_spectrumview.winfo_width(), self.can_spectrumview.winfo_height()
diff --git a/src/pygpsclient/sysmon_frame.py b/src/pygpsclient/sysmon_frame.py
index 366989bc..4219410a 100644
--- a/src/pygpsclient/sysmon_frame.py
+++ b/src/pygpsclient/sysmon_frame.py
@@ -14,17 +14,24 @@
:license: BSD 3-Clause
"""
-from tkinter import ALL, Canvas, E, Frame, IntVar, N, Radiobutton, S, W
+from tkinter import ALL, NW, Canvas, E, Frame, IntVar, N, Radiobutton, S, W
from pyubx2 import BOOTTYPE, UBXMessage
-from pygpsclient.globals import BGCOL, FGCOL, SYSMONVIEW, WIDGETU2
-from pygpsclient.helpers import bytes2unit, hsv2rgb, secs2unit, setubxrate, sizefont
+from pygpsclient.globals import BGCOL, FGCOL, PNTCOL, SYSMONVIEW, WIDGETU2
+from pygpsclient.helpers import (
+ bytes2unit,
+ fontheight,
+ hsv2rgb,
+ scale_font,
+ secs2unit,
+ setubxrate,
+)
from pygpsclient.strings import DLGENABLEMONSYS, DLGNOMONSYS, DLGWAITMONSYS, NA
MINFONT = 6 # minimum font size
MAXTEMP = 100 # °C
-XOFFSET = 10
+INSET = 4
SPACING = 5
DASH = (5, 2)
PORTIDS = {
@@ -69,9 +76,11 @@ def __init__(self, app, *args, **kwargs):
self._maxtemp = 0
self._waits = 0
self._mode = IntVar()
+ self._mode.set(0)
+ self._font = self.__app.font_sm
+ self._fonth = fontheight(self._font)
self._body()
self._attach_events()
- self._set_fontsize()
def _body(self):
"""
@@ -85,16 +94,18 @@ def _body(self):
text="Actual I/O",
variable=self._mode,
value=0,
- fg=FGCOL,
+ fg=PNTCOL,
bg=BGCOL,
+ # selectcolor=BGCOL,
)
self._rad_pending = Radiobutton(
self._frm_status,
text="Pending I/O",
variable=self._mode,
value=1,
- fg=FGCOL,
+ fg=PNTCOL,
bg=BGCOL,
+ # selectcolor=BGCOL,
)
self._can_sysmon.grid(column=0, row=0, padx=0, pady=0, sticky=(N, S, W, E))
self._frm_status.grid(column=0, row=1, padx=2, pady=2, sticky=(W, E))
@@ -225,13 +236,13 @@ def update_frame(self):
self.init_chart()
y = self._fonth
- y = self._chart_parm(XOFFSET, y, cpuLoadMax, cpuLoad, "CPU", "%")
- y = self._chart_parm(XOFFSET, y, memUsageMax, memUsage, "Memory", "%")
- y = self._chart_parm(XOFFSET, y, ioUsageMax, ioUsage, "I/O", "%")
+ y = self._chart_parm(INSET, y, cpuLoadMax, cpuLoad, "CPU", "%")
+ y = self._chart_parm(INSET, y, memUsageMax, memUsage, "Memory", "%")
+ y = self._chart_parm(INSET, y, ioUsageMax, ioUsage, "I/O", "%")
for port, pdata in sorted(commsdata.items()):
- y = self._chart_io(XOFFSET, y, port, pdata)
+ y = self._chart_io(INSET, y, port, pdata)
y += SPACING
- y = self._chart_parm(XOFFSET, y, self._maxtemp, tempValueP, "Temp", "°C")
+ y = self._chart_parm(INSET, y, self._maxtemp, tempValueP, "Temp", "°C")
rtm, rtmu = secs2unit(runTime)
rtf = "" if rtmu == "secs" else ",.02f"
@@ -241,11 +252,11 @@ def update_frame(self):
+ f"Notices: {noticeCount}, Warnings: {warnCount}, Errors: {errorCount}"
)
self._can_sysmon.create_text(
- XOFFSET,
+ INSET,
y,
text=txt,
fill=FGCOL,
- anchor="nw",
+ anchor=NW,
font=self._font,
)
except KeyError: # invalid sysmon-data or comms-data
@@ -272,7 +283,7 @@ def _chart_parm(
y,
text=f"{lbl}: {val} {unit}",
fill=FGCOL,
- anchor="w",
+ anchor=W,
font=self._font,
)
y += self._fonth
@@ -326,7 +337,7 @@ def _chart_io(self, xoffset: int, y: int, port: int, pdata: tuple):
y,
text=txt,
fill=FGCOL,
- anchor="w",
+ anchor=W,
font=self._font,
)
self._can_sysmon.create_text( # port
@@ -334,7 +345,7 @@ def _chart_io(self, xoffset: int, y: int, port: int, pdata: tuple):
y,
text="⇄",
fill=FGCOL,
- anchor="e",
+ anchor=E,
font=self._font,
)
p = -1
@@ -380,6 +391,7 @@ def _on_resize(self, event): # pylint: disable=unused-argument
"""
self.width, self.height = self.get_size()
+ self._font, self._fonth = scale_font(self.width, 10, 35, 20)
def get_size(self):
"""
@@ -390,14 +402,4 @@ def get_size(self):
"""
self.update_idletasks() # Make sure we know about any resizing
- width = self._can_sysmon.winfo_width()
- height = self._can_sysmon.winfo_height()
- self._set_fontsize()
- return (width, height)
-
- def _set_fontsize(self):
- """
- Set font size to accommodate specified number of lines on canvas.
- """
-
- self._font, self._fonth = sizefont(self.height, MAXLINES, MINFONT)
+ return self._can_sysmon.winfo_width(), self._can_sysmon.winfo_height()
diff --git a/src/pygpsclient/ubx_cfgval_frame.py b/src/pygpsclient/ubx_cfgval_frame.py
index e1b20b7f..47ebb3c1 100644
--- a/src/pygpsclient/ubx_cfgval_frame.py
+++ b/src/pygpsclient/ubx_cfgval_frame.py
@@ -104,9 +104,9 @@ def _body(self):
"""
self._lbl_configdb = Label(
- self, text="CFG-VALSET/DEL/GET Configuration Interface", anchor="w"
+ self, text="CFG-VALSET/DEL/GET Configuration Interface", anchor=W
)
- self._lbl_cat = Label(self, text="Category", anchor="w")
+ self._lbl_cat = Label(self, text="Category", anchor=W)
self._lbx_cat = Listbox(
self,
border=2,
@@ -121,7 +121,7 @@ def _body(self):
self._lbx_cat.config(xscrollcommand=self._scr_cath.set)
self._scr_catv.config(command=self._lbx_cat.yview)
self._scr_cath.config(command=self._lbx_cat.xview)
- self._lbl_parm = Label(self, text="Keyname", anchor="w")
+ self._lbl_parm = Label(self, text="Keyname", anchor=W)
self._lbx_parm = Listbox(
self,
border=2,
diff --git a/src/pygpsclient/ubx_config_dialog.py b/src/pygpsclient/ubx_config_dialog.py
index 4809d021..f89a1f49 100644
--- a/src/pygpsclient/ubx_config_dialog.py
+++ b/src/pygpsclient/ubx_config_dialog.py
@@ -99,9 +99,7 @@ def _body(self):
self._frm_container = Frame(self, borderwidth=2, relief="groove")
self._frm_status = Frame(self._frm_container, borderwidth=2, relief="groove")
- self._lbl_status = Label(
- self._frm_status, textvariable=self._status, anchor="w"
- )
+ self._lbl_status = Label(self._frm_status, textvariable=self._status, anchor=W)
self._btn_exit = Button(
self._frm_status,
image=self._img_exit,
@@ -317,7 +315,7 @@ def get_size(self):
"""
self.__master.update_idletasks() # Make sure we know about any resizing
- return (self.winfo_width(), self.winfo_height())
+ return self.winfo_width(), self.winfo_height()
@property
def container(self):
diff --git a/src/pygpsclient/ubx_dynamic_frame.py b/src/pygpsclient/ubx_dynamic_frame.py
index 51f958b6..70e67f37 100644
--- a/src/pygpsclient/ubx_dynamic_frame.py
+++ b/src/pygpsclient/ubx_dynamic_frame.py
@@ -26,6 +26,7 @@
ALL,
END,
LEFT,
+ NW,
VERTICAL,
Button,
Canvas,
@@ -125,7 +126,7 @@ def _body(self):
Set up frame and widgets.
"""
- self._lbl_cfg_dyn = Label(self, text=LBLCFGGENERIC, anchor="w")
+ self._lbl_cfg_dyn = Label(self, text=LBLCFGGENERIC, anchor=W)
self._lbx_cfg_cmd = Listbox(
self,
border=2,
@@ -146,7 +147,7 @@ def _body(self):
command=self._on_send_cfg,
font=self.__app.font_md,
)
- self._lbl_command = Label(self, text="", anchor="w")
+ self._lbl_command = Label(self, text="", anchor=W)
self._frm_container = Frame(self)
self._can_container = Canvas(self._frm_container)
self._frm_attrs = Frame(self._can_container)
@@ -160,7 +161,7 @@ def _body(self):
yscrollcommand=self._scr_container_ver.set,
xscrollcommand=self._scr_container_hor.set,
)
- self._can_container.create_window(0, 0, window=self._frm_attrs, anchor="nw")
+ self._can_container.create_window(0, 0, window=self._frm_attrs, anchor=NW)
def _do_layout(self):
"""
@@ -274,13 +275,13 @@ def _clear_widgets(self):
wdgs = self._frm_attrs.grid_slaves()
for wdg in wdgs:
wdg.destroy()
- Label(self._frm_attrs, text="Attribute", width=12, anchor="w").grid(
+ Label(self._frm_attrs, text="Attribute", width=12, anchor=W).grid(
column=0, row=0, padx=3, sticky=(W)
)
- Label(self._frm_attrs, text="Value", width=20, anchor="w").grid(
+ Label(self._frm_attrs, text="Value", width=20, anchor=W).grid(
column=1, row=0, padx=3, sticky=(W)
)
- Label(self._frm_attrs, text="Type", width=5, anchor="w").grid(
+ Label(self._frm_attrs, text="Type", width=5, anchor=W).grid(
column=2, row=0, padx=3, sticky=(W)
)
diff --git a/src/pygpsclient/ubx_msgrate_frame.py b/src/pygpsclient/ubx_msgrate_frame.py
index 75b02cf1..bf6a447e 100644
--- a/src/pygpsclient/ubx_msgrate_frame.py
+++ b/src/pygpsclient/ubx_msgrate_frame.py
@@ -87,7 +87,7 @@ def _body(self):
"""
MAX_RATE = 0xFF
- self._lbl_cfg_msg = Label(self, text=LBLCFGMSG, anchor="w")
+ self._lbl_cfg_msg = Label(self, text=LBLCFGMSG, anchor=W)
self._lbx_cfg_msg = Listbox(
self,
border=2,
diff --git a/src/pygpsclient/ubx_port_frame.py b/src/pygpsclient/ubx_port_frame.py
index 9d2db4aa..b3d79b45 100644
--- a/src/pygpsclient/ubx_port_frame.py
+++ b/src/pygpsclient/ubx_port_frame.py
@@ -76,7 +76,7 @@ def _body(self):
Set up frame and widgets.
"""
- self._lbl_cfg_port = Label(self, text=LBLCFGPRT, anchor="w")
+ self._lbl_cfg_port = Label(self, text=LBLCFGPRT, anchor=W)
self._lbl_ubx_portid = Label(self, text="Port ID")
self._spn_ubx_portid = Spinbox(
self,
diff --git a/src/pygpsclient/ubx_preset_frame.py b/src/pygpsclient/ubx_preset_frame.py
index b8f97508..0969dc60 100644
--- a/src/pygpsclient/ubx_preset_frame.py
+++ b/src/pygpsclient/ubx_preset_frame.py
@@ -30,6 +30,7 @@
from PIL import Image, ImageTk
from pyubx2 import POLL, SET, UBX_MSGIDS, UBX_PAYLOADS_POLL, UBXMessage
+from pygpsclient.confirm_box import ConfirmBox
from pygpsclient.globals import (
ICON_CONFIRMED,
ICON_PENDING,
@@ -39,7 +40,6 @@
SAVED_CONFIG,
UBX_PRESET,
)
-from pygpsclient.helpers import ConfirmBox
from pygpsclient.strings import (
DLGRESET,
DLGRESETCONFIRM,
@@ -148,7 +148,7 @@ def _body(self):
Set up frame and widgets.
"""
- self._lbl_presets = Label(self, text=LBLPRESET, anchor="w")
+ self._lbl_presets = Label(self, text=LBLPRESET, anchor=W)
self._lbx_preset = Listbox(
self,
border=2,
diff --git a/src/pygpsclient/ubx_recorder_frame.py b/src/pygpsclient/ubx_recorder_frame.py
index b193d749..ba05e89b 100644
--- a/src/pygpsclient/ubx_recorder_frame.py
+++ b/src/pygpsclient/ubx_recorder_frame.py
@@ -106,7 +106,7 @@ def _body(self):
Set up frame and widgets.
"""
- self._lbl_recorder = Label(self, text=LBLCFGRECORD, anchor="w")
+ self._lbl_recorder = Label(self, text=LBLCFGRECORD, anchor=W)
self._btn_load = Button(
self,
diff --git a/src/pygpsclient/ubx_solrate_frame.py b/src/pygpsclient/ubx_solrate_frame.py
index c8b29e5f..5e5d47b0 100644
--- a/src/pygpsclient/ubx_solrate_frame.py
+++ b/src/pygpsclient/ubx_solrate_frame.py
@@ -74,7 +74,7 @@ def _body(self):
Set up frame and widgets.
"""
- self._lbl_cfg_rate = Label(self, text=LBLCFGRATE, anchor="w")
+ self._lbl_cfg_rate = Label(self, text=LBLCFGRATE, anchor=W)
self._lbl_ubx_measint = Label(self, text="Solution Interval (ms)")
self._spn_ubx_measint = Spinbox(
self,
diff --git a/src/pygpsclient/widget_state.py b/src/pygpsclient/widget_state.py
index 9bacc50c..59783483 100644
--- a/src/pygpsclient/widget_state.py
+++ b/src/pygpsclient/widget_state.py
@@ -22,7 +22,10 @@ class definition and update `ubx_handler` to populate them.
:license: BSD 3-Clause
"""
+from tkinter import E, N, S, W
+
from pygpsclient.banner_frame import BannerFrame
+from pygpsclient.chart_frame import ChartviewFrame
from pygpsclient.console_frame import ConsoleFrame
from pygpsclient.globals import CLASS, FRAME
from pygpsclient.graphview_frame import GraphviewFrame
@@ -35,12 +38,15 @@ class definition and update `ubx_handler` to populate them.
from pygpsclient.status_frame import StatusFrame
from pygpsclient.sysmon_frame import SysmonFrame
+COL = "COL"
COLSPAN = "colspan"
DEFAULT = "def"
HIDE = "Hide"
MAXCOLSPAN = 4 # max no of widget columns
MAXROWSPAN = 4 # max no of widget rows
MENU = "men"
+RESET = "rst"
+ROW = "row"
ROWSPAN = "rowspan"
SHOW = "Show"
STICKY = "sty"
@@ -56,36 +62,45 @@ class definition and update `ubx_handler` to populate them.
WDGSPECTRUM = "Spectrum"
WDGSTATUS = "Status"
WDGSYSMON = "System Monitor"
+WDGCHART = "Chart Plot"
widget_state = {
- # these have a fixed relative position
+ # these widgets have fixed positions
WDGBANNER: { # always on top
- MENU: None,
DEFAULT: True,
+ MENU: False,
CLASS: BannerFrame,
FRAME: "frm_banner",
VISIBLE: True,
+ STICKY: (N, W, E, S),
+ COL: 0,
+ ROW: 0,
+ COLSPAN: 6,
},
WDGSETTINGS: { # always on right
- MENU: 0,
DEFAULT: True,
CLASS: SettingsFrame,
FRAME: "frm_settings",
VISIBLE: True,
- STICKY: ("n", "w", "e"),
+ STICKY: (N, W, E, S),
+ COL: 5,
+ ROW: 1,
+ ROWSPAN: 4,
},
WDGSTATUS: { # always on bottom
- MENU: 1,
DEFAULT: True,
+ MENU: False,
CLASS: StatusFrame,
FRAME: "frm_status",
VISIBLE: True,
- STICKY: ("w", "e"),
+ STICKY: (S, W, E),
+ COL: 0,
+ ROW: 5,
+ COLSPAN: 6,
},
- # dynamic relative position - these self-organise
- # depending on which has been selected
+ # these widgets rearrange dynamically according to
+ # which has been selected to be visible
WDGCONSOLE: {
- MENU: 2,
DEFAULT: True,
CLASS: ConsoleFrame,
FRAME: "frm_console",
@@ -93,53 +108,51 @@ class definition and update `ubx_handler` to populate them.
COLSPAN: MAXCOLSPAN,
},
WDGSATS: {
- MENU: 3,
DEFAULT: True,
CLASS: SkyviewFrame,
FRAME: "frm_satview",
VISIBLE: True,
},
WDGLEVELS: {
- MENU: 4,
DEFAULT: True,
CLASS: GraphviewFrame,
FRAME: "frm_graphview",
VISIBLE: True,
},
WDGMAP: {
- MENU: 5,
DEFAULT: True,
CLASS: MapviewFrame,
FRAME: "frm_mapview",
VISIBLE: True,
+ RESET: True,
},
WDGSPECTRUM: {
- MENU: 6,
- DEFAULT: False,
CLASS: SpectrumviewFrame,
FRAME: "frm_spectrumview",
VISIBLE: False,
- },
- WDGSYSMON: {
- MENU: 7,
- DEFAULT: False,
- CLASS: SysmonFrame,
- FRAME: "frm_sysmon",
- VISIBLE: False,
+ RESET: True,
},
WDGSCATTER: {
- MENU: 8,
- DEFAULT: False,
CLASS: ScatterViewFrame,
FRAME: "frm_scatterview",
VISIBLE: False,
},
WDGROVER: {
- MENU: 9,
DEFAULT: False,
CLASS: RoverFrame,
FRAME: "frm_roverview",
VISIBLE: False,
},
+ WDGCHART: {
+ CLASS: ChartviewFrame,
+ FRAME: "frm_chartview",
+ VISIBLE: False,
+ COLSPAN: 2,
+ },
+ WDGSYSMON: {
+ CLASS: SysmonFrame,
+ FRAME: "frm_sysmon",
+ VISIBLE: False,
+ },
# add any new widgets here
}
diff --git a/tests/test_static.py b/tests/test_static.py
index 2b8fe6a6..bb0ea894 100644
--- a/tests/test_static.py
+++ b/tests/test_static.py
@@ -13,7 +13,7 @@
from pyubx2 import UBXReader
-from pygpsclient.globals import Area, Point
+from pygpsclient.globals import Area, Point, AreaXY
from pygpsclient.helpers import (
bitsval,
bytes2unit,
@@ -22,7 +22,10 @@
corrage2int,
date2wnotow,
fix2desc,
+ data2xy,
+ xy2data,
ft2m,
+ get_grid,
get_mp_distance,
get_mp_info,
get_point_at_vector,
@@ -50,6 +53,7 @@
str2rgb,
stringvar2val,
svid2gnssid,
+ time2str,
val2sphp,
validURL,
wnotow2date,
@@ -61,7 +65,13 @@
mapq_decompress,
format_mapquest_request,
)
-from pygpsclient.widget_state import DEFAULT, FRAME, MENU, VISIBLE, widget_state
+from pygpsclient.widget_state import (
+ DEFAULT,
+ FRAME,
+ MENU,
+ VISIBLE,
+ widget_state,
+)
class StaticTest(unittest.TestCase):
@@ -403,8 +413,8 @@ def testwidgetgrid(self): # ensure widgets.py is correctly defined
NoneType = type(None)
for wdg, wdict in widget_state.items():
self.assertIsInstance(wdg, str),
- self.assertIsInstance(wdict[MENU], (int, NoneType)),
- self.assertIsInstance(wdict[DEFAULT], bool),
+ self.assertIsInstance(wdict.get(MENU, True), bool),
+ self.assertIsInstance(wdict.get(DEFAULT, False), bool),
self.assertIsInstance(wdict[FRAME], str),
self.assertEqual(wdict["frm"][0:4], "frm_"),
self.assertIsInstance(wdict[VISIBLE], bool),
@@ -536,6 +546,30 @@ def testxy2ll(self):
self.assertAlmostEqual(pos.lat, 53.52345, 5)
self.assertAlmostEqual(pos.lon, -1.81264, 5)
+ def testdata2xy(self):
+ bounds = AreaXY(-2, 53, -1, 54)
+ x, y = data2xy(600, 400, bounds, -1.5, 53.5)
+ self.assertEqual(x, 300, 5)
+ self.assertEqual(y, 200, 5)
+ x, y = data2xy(600, 400, bounds, -1.81264, 53.52345)
+ self.assertAlmostEqual(x, 112.416, 5)
+ self.assertAlmostEqual(y, 190.620, 5)
+ x, y = data2xy(600, 400, bounds, -1.81264, 53.52345, 7, 5)
+ self.assertAlmostEqual(x, 119.416, 5)
+ self.assertAlmostEqual(y, 195.620, 5)
+
+ def testxy2data(self):
+ bounds = AreaXY(-2, 53, -1, 54)
+ dx, dy = xy2data(600, 400, bounds, 300, 200)
+ self.assertEqual(dx, -1.5, 5)
+ self.assertEqual(dy, 53.5, 5)
+ dx, dy = xy2data(600, 400, bounds, 112.416, 190.620)
+ self.assertAlmostEqual(dx, -1.81264, 5)
+ self.assertAlmostEqual(dy, 53.52345, 5)
+ dx, dy = xy2data(600, 400, bounds, 119.416, 195.620, 7, 5)
+ self.assertAlmostEqual(dx, -1.81264, 5)
+ self.assertAlmostEqual(dy, 53.52345, 5)
+
def testpoints2area(self):
points = (53, -2, 54, -1)
res = points2area(points)
@@ -617,6 +651,61 @@ def testformat_mapquest_request(self):
# print(res)
self.assertEqual(res, EXPECTED_RESULT3)
+ def testgetgrid(self):
+ res = get_grid(7)
+ self.assertEqual(res, (0.0, 0.1667, 0.3333, 0.5, 0.6667, 0.8333, 1.0))
+ res = get_grid()
+ self.assertEqual(
+ res,
+ (0.0, 0.1111, 0.2222, 0.3333, 0.4444, 0.5556, 0.6667, 0.7778, 0.8889, 1.0),
+ )
+ res = get_grid(13)
+ self.assertEqual(
+ res,
+ (
+ 0.0,
+ 0.0833,
+ 0.1667,
+ 0.25,
+ 0.3333,
+ 0.4167,
+ 0.5,
+ 0.5833,
+ 0.6667,
+ 0.75,
+ 0.8333,
+ 0.9167,
+ 1.0,
+ ),
+ )
+ res = get_grid(13, 0, 100, False)
+ self.assertEqual(
+ res,
+ (
+ 0.0,
+ 7.6923,
+ 15.3846,
+ 23.0769,
+ 30.7692,
+ 38.4615,
+ 46.1538,
+ 53.8462,
+ 61.5385,
+ 69.2308,
+ 76.9231,
+ 84.6154,
+ 92.3077,
+ ),
+ )
+
+ def testtime2str(self):
+ res = time2str(1732547672)
+ self.assertEqual(res, "15:14:32")
+ res = time2str(1732547874.264534,"%H:%M:%S.%f")
+ self.assertEqual(res, "15:17:54.264534")
+ res = time2str(1732461337.123412, "%a, %d %b %Y %H:%M:%S +0000")
+ self.assertEqual(res, "Sun, 24 Nov 2024 15:15:37 +0000")
+
if __name__ == "__main__":
# import sys;sys.argv = ['', 'Test.testName']