Module tagmaps.classes.interface

Module for tag maps tkinker interface

for (optional) user input

Expand source code
# -*- coding: utf-8 -*-

"""
Module for tag maps tkinker interface

for (optional) user input
"""

from __future__ import absolute_import

import threading
import sys
import traceback
import tkinter as tk
import tkinter.messagebox
from tkinter import TclError
from tkinter.messagebox import showerror
from typing import Dict, List, Optional, Iterable

import matplotlib.pyplot as plt
import numpy as np

from tagmaps.classes.cluster import ClusterGen
from tagmaps.classes.plotting import TPLT
from tagmaps.classes.shared_structure import (
    LOCATIONS, ItemCounter)
from tagmaps.classes.utils import Utils

# enable interactive mode for pyplot
plt.ion()
# label_size = 10
# plt.rcParams['xtick.labelsize'] = label_size
# plt.rcParams['ytick.labelsize'] = label_size
# Optional: set global plotting bounds
# plt.gca().set_xlim([limXMin, limXMax])
# plt.gca().set_ylim([limYMin, limYMax])
# sns.set_color_codes()
plt.style.use('ggplot')


class UserInterface():
    """User interface class for interacting input"""

    def __init__(self,
                 clusterer_list: Iterable[ClusterGen] = None,
                 location_names_dict: Dict[str, str] = None
                 ):
        """Prepare user interface and start Tkinter mainloop()
        """
        # threading.Thread.__init__(self)
        self._clst_list = list()
        # append clusters to list
        if not clusterer_list:
            raise ValueError('No clusterer selected.')
        for clusterer in clusterer_list:
            self._clst_list.append(clusterer)
        # select initial cluster
        self._clst = self._clst_list[0]
        self.location_names_dict = location_names_dict
        self.abort = False
        self.current_display_item = None
        # Initialize TKinter Interface
        self.app = App()
        # allow error reporting from console backend
        tk.Tk.report_callback_exception = self._report_callback_exception
        # definition of global vars for interface and graph design
        # Cluster preparation
        self.gen_preview_map = tk.IntVar(value=0)
        self.create_min_spanning_tree = False
        self.create_condensed_tree = False
        self.tk_scalebar = None
        # definition of global figure for reusing windows
        self.fig1 = None
        self.fig2 = None
        self.fig3 = None
        self.fig4 = None
        # user selected item
        self.lastselection = None
        # A frame is created for each window/part of the gui;
        # after it is used, it is destroyed with frame.destroy()
        header_frame = tk.Frame(self.app.floater)
        canvas = tk.Canvas(
            header_frame, width=150, height=220,
            highlightthickness=0, background="gray7")
        header_label = tk.Label(
            canvas, text="Optional: Exclude tags, "
            "emoji or locations.",
            background="gray7", fg="gray80",
            font="Arial 10 bold")
        header_label.pack(padx=10, pady=10)

        self._clst_index = tk.IntVar()
        self._clst_index.set(0)
        # Radiobuttons for selecting list
        idx = 0
        for clst in self._clst_list:
            radio_b = tk.Radiobutton(
                canvas, text=f'{clst.cls_type}',
                variable=self._clst_index,
                value=idx,
                indicatoron=0,
                command=self._change_clusterer,
                background="gray20",
                fg="gray80",
                borderwidth=0,
                font="Arial 10 bold",
                width=15)
            radio_b.pack(side='left')
            idx += 1
        canvas.pack(fill='both', padx=0, pady=0,)
        header_frame.pack(fill='both', padx=0, pady=0)
        listbox_frame = tk.Frame(self.app.floater)
        canvas = tk.Canvas(
            listbox_frame, width=150, height=220,
            highlightthickness=0, background="gray7")
        guide_label = tk.Label(
            canvas, text=f'Select all items you wish to exclude '
            f'from analysis \n '
            f'and click on remove. Proceed if ready.',
            background="gray7", fg="gray80")
        guide_label.pack(padx=10, pady=10)
        listbox_font = None
        listbox = tk.Listbox(
            canvas,
            selectmode=tk.EXTENDED, bd=0, background="gray29",
            fg="gray91", width=30, font=listbox_font)
        listbox.bind('<<ListboxSelect>>', self._onselect)
        scroll = tk.Scrollbar(canvas, orient=tk.VERTICAL,
                              background="gray20", borderwidth=0)
        scroll.configure(command=listbox.yview)
        scroll.pack(side="right", fill="y")
        listbox.pack()
        listbox.config(yscrollcommand=scroll.set)
        self.listbox = listbox
        self._populate_listbox()
        canvas.pack(fill='both', padx=0, pady=0)
        listbox_frame.pack(fill='both', padx=0, pady=0)
        buttons_frame = tk.Frame(self.app.floater)
        canvas = tk.Canvas(buttons_frame, width=150, height=200,
                           highlightthickness=0, background="gray7")
        UserInterface._create_button(
            canvas, "Remove Selected", lambda: self._delete_fromtoplist(
                self.listbox),
            left=False)
        check_b = tk.Checkbutton(
            canvas, text="Map Tags",
            variable=self.gen_preview_map,
            background="gray7", fg="gray80",
            borderwidth=0, font="Arial 10 bold")
        check_b.pack(padx=10, pady=10)
        self.tk_scalebar = tk.Scale(
            canvas,
            from_=(self._clst.cluster_distance/100),
            to=(self._clst.cluster_distance*2),
            orient=tk.HORIZONTAL,
            resolution=0.1,
            command=self._change_cluster_dist,
            length=300,
            label="Cluster Cut Distance (in Meters)",
            background="gray20",
            borderwidth=0,
            fg="gray80",
            font="Arial 10 bold")
        # set position of slider to center
        # (clusterTreeCuttingDist*10) - (clusterTreeCuttingDist/10)/2)
        self.tk_scalebar.set(self._clst.cluster_distance)
        self.tk_scalebar.pack()
        UserInterface._create_button(
            canvas, "Cluster Preview",
            self._cluster_current_display_item)
        UserInterface._create_button(
            canvas, "Scale Test",
            self._scaletest_current_display_item)
        UserInterface._create_button(
            canvas, "Proceed..", self._proceed_with_cluster)
        UserInterface._create_button(
            canvas, "Quit", self._quit_tkinter)
        canvas.pack(fill='both', padx=0, pady=0)
        buttons_frame.pack(fill='both', padx=0, pady=0)

    @staticmethod
    def _create_button(canvas, text: str, command, left: bool = True):
        """Create button with text and command

        and pack to canvas."""
        button = tk.Button(canvas, text=text,
                           command=command,
                           background="gray20",
                           fg="gray80",
                           borderwidth=0,
                           font="Arial 10 bold")
        if left:
            button.pack(padx=10, pady=10, side="left")
        else:
            button.pack(padx=10, pady=10)

    def _populate_listbox(self):
        """Populate tkinter listbox with records

        - only for first 1000 entries: top_list[:1000]
        """
        listbox = self.listbox
        top_list: List[ItemCounter] = self._clst.top_list
        loc_name_dict: Optional[Dict[str, str]] = self.location_names_dict
        # clear first
        listbox.delete(0, tk.END)
        # tkinter.messagebox.showinfo(f"length of list:", f"{len(top_list)}")
        # maximum of 1000 entries shown
        for item in top_list[:1000]:
            if self._clst.cls_type == LOCATIONS:
                item_name = Utils.get_locname(item.name, loc_name_dict)
            else:
                item_name = item.name
            try:
                # try inserting emoji first,
                # some emoji can be printed
                listbox.insert(tk.END, f'{item_name} ({item.ucount} user)')
            except TclError:
                # replace emoji by unicode name on error
                emoji = Utils.get_emojiname(item_name)
                listbox.insert(tk.END, f'{emoji} ({item.ucount} user)')

    def start(self):
        """Start up user interface after initialization

        this is the mainloop for the interface
        once it is destroyed, the regular process
        will continue
        """
        self.app.mainloop()
        # end of tkinter loop,
        # welcome back to console
        plt.close("all")

    def _cluster_preview(self, sel_item: str):
        """Show preview map(s) based on tag selection"""
        # Get cluster data first
        (_, _, points, sel_colors,
         mask_noisy, number_of_clusters) = self._clst.cluster_item(
             item=sel_item,
             preview_mode=True)
        ## Cluster Map Plot ##
        if not plt.fignum_exists(1):
            self.fig1 = plt.figure(1)
        else:
            self.fig1.clf()
        self.fig1.add_subplot(111)
        self.fig1 = TPLT.get_cluster_preview(
            points, sel_colors, sel_item, self._clst.bounds, mask_noisy,
            self._clst.cluster_distance, number_of_clusters,
            self._clst.autoselect_clusters, fig=self.fig1)
        self.fig1.canvas.draw_idle()
        ## Single Linkage Tree Plot ##
        if not plt.fignum_exists(2):
            self.fig2 = plt.figure(2)
        else:
            self.fig2.clf()
        axis = self.fig2.add_subplot(111)
        # p is the number of max count of leafs in the tree,
        # this should at least be the number of clusters*10,
        # not lower than 50 [but max 500 to not crash]
        self._clst.clusterer.single_linkage_tree_.plot(
            axis=axis,
            truncate_mode='lastp',
            p=max(50, min(number_of_clusters*10, 256)))
        item_name = sel_item
        TPLT.get_single_linkage_tree_preview(
            item_name, self.fig2, self._clst.cluster_distance,
            self._clst.cls_type)
        self.fig2.canvas.draw_idle()
        ## Condensed Tree Plot ##
        if self.create_condensed_tree:
            # Unfixed issue:
            # on consecutive updates
            # the colorbar is added multiple times
            # possible solution: retrieve only
            # condensed data from hdbscan
            # and create plot in tagmaps
            if not plt.fignum_exists(3):
                self.fig3 = plt.figure(3)
            else:
                self.fig3.clf()
            axis = self.fig3.add_subplot(111)
            self._clst.clusterer.condensed_tree_.plot(
                axis=axis,
                log_size=True
            )
            self.fig3.canvas.manager.set_window_title('Condensed Tree')
            TPLT.set_plt_suptitle(
                self.fig3, sel_item, self._clst.cls_type)
            TPLT.set_plt_tick_params(axis)
            self.fig3.canvas.draw_idle()
        if self.create_min_spanning_tree:
            if not plt.fignum_exists(4):
                self.fig4 = plt.figure(4)
            else:
                self.fig4.clf()
            axis = self.fig4.add_subplot(111)
            self._clst.clusterer.minimum_spanning_tree_.plot(
                axis=axis,
                edge_cmap='viridis',
                edge_alpha=0.6,
                node_size=10,
                edge_linewidth=1)
            self.fig4.canvas.manager.set_window_title(
                'Minimum Spanning Tree')
            # tkinter.messagebox.showinfo("messagr", str(type(ax)))
            TPLT.set_plt_suptitle(
                self.fig4, sel_item, self._clst.cls_type)
            axis.set_title(
                f'Minimum Spanning Tree @ {self._clst.cluster_distance}m',
                fontsize=12, loc='center')
            self.fig4.legend(fontsize=10)
            self.fig4.canvas.draw()
        TPLT.set_plt_tick_params(axis)
        self._update_scalebar()

    def _intf_selection_preview(self, sel_item: str):
        """Update preview map based on item selection"""
        # tkinter.messagebox.showinfo("Proceed", f'{sel_item}')
        points = self._clst.get_np_points(
            item=sel_item,
            silent=True)
        if points is None:
            tkinter.messagebox.showinfo(
                "No locations found.",
                "All locations for given item have been removed.")
            return
        if not plt.fignum_exists(1):
            self.fig1 = plt.figure(1)
        else:
            # clf() clears figure and subplots (axes)
            self.fig1.clf()
        self.fig1.add_subplot(111)
        self._intf_plot_points(sel_item, points)

    def _intf_plot_points(self, item_name: str, points):
        self.fig1 = TPLT.get_fig_points(
            self.fig1,
            points, self._clst.bounds)
        TPLT.set_plt_suptitle(
            self.fig1, item_name, self._clst.cls_type)

    def _report_callback_exception(self, __, val, ___):
        """Override for error reporting during tkinter mode"""
        showerror("Error", message=str(val))
        exc_type, exc_value, exc_traceback = sys.exc_info()
        print("*** print_tb:")
        traceback.print_tb(exc_traceback, limit=1, file=sys.stdout)
        print("*** print_exception:")
        traceback.print_exception(exc_type, exc_value, exc_traceback,
                                  limit=2, file=sys.stdout)
        print("*** print_exc:")
        traceback.print_exc()
        print("*** format_exc, first and last line:")
        formatted_lines = traceback.format_exc().splitlines()
        print(formatted_lines[0])
        print(formatted_lines[-1])
        print("*** format_exception:")
        print(repr(traceback.format_exception(exc_type, exc_value,
                                              exc_traceback)))
        print("*** extract_tb:")
        print(repr(traceback.extract_tb(exc_traceback)))
        print("*** format_tb:")
        print(repr(traceback.format_tb(exc_traceback)))
        print("*** tb_lineno:", exc_traceback.tb_lineno)

    def _quit_tkinter(self):
        """Exit Tkinter GUI

        ..to continue with code execution after mainloop()

        Notes:
        - see [1]
        https://stackoverflow.com/questions/35040168/python-tkinter-error-cant-evoke-event-command
        - root.quit() causes mainloop to exit, see [2]
        https://stackoverflow.com/questions/2307464/what-is-the-difference-between-root-destroy-and-root-quit

        """
        self.abort = True
        self.app.update()
        self.app.destroy()
        self.app.quit()

    def _proceed_with_cluster(self):
        self.app.update()
        self.app.destroy()
        self.app.quit()

    def _change_cluster_dist(self, val):
        """Changes cluster distance based on user input

        Args:
            val: new cluster distance
        """
        self._clst.cluster_distance = float(val)

    def _change_clusterer(self):
        """Changes clusterer based on user selection

        Either TAGS, EMOJI or LOCATIONS
        """
        self.current_display_item = None
        self._clst = self._clst_list[
            self._clst_index.get()]
        self._populate_listbox()
        self._update_scalebar()

    def _update_scalebar(self):
        """Adjust scalebar limits from cluster distance"""
        self.tk_scalebar.configure(
            from_=(self._clst.cluster_distance/100),
            to=(self._clst.cluster_distance*2))
        self.tk_scalebar.set(self._clst.cluster_distance)

    def _onselect(self, evt):
        """On user select item from list

        Args:
            evt (event object): event object for selected item
        """
        widg = evt.widget
        self.lastselection = widg.index(tk.ACTIVE)
        sel_index = widg.curselection()[0]
        if (self.gen_preview_map.get() == 1 and
                len(widg.curselection()) == 1):
            # generate preview map
            # only if selection box is checked
            # and only if one item is checked
            sel_item = self._clst.top_list[sel_index].name
            self._intf_selection_preview(
                sel_item)
            self.current_display_item = sel_item

    def _cluster_current_display_item(self):
        """Cluster button: use selected or first in list"""
        if self.current_display_item:
            # tkinter.messagebox.showinfo("Clusteritem: ",
            #                             f'{self.current_display_item}')
            self._cluster_preview(self.current_display_item)
        else:
            # use first in list
            self._cluster_preview(self._clst.top_list[0].name)

    def _scaletest_current_display_item(self):
        """Compute clustering across different scales and output results to txt"""
        if self.create_min_spanning_tree is False:
            tkinter.messagebox.showinfo(
                "Skip: ",
                f'Currently deactivated')
            return
        if self.current_display_item:
            sel_item = self.current_display_item
        else:
            sel_item = self._clst.top_list[0].name
        scalecalclist = []
        dmax = int(self._clst.cluster_distance*10)
        dmin = int(self._clst.cluster_distance/10)
        dstep = int(((self._clst.cluster_distance*10) -
                     (self._clst.cluster_distance/10))/100)
        mask_noisy = None
        number_of_clusters = 0
        for i in range(dmin, dmax, dstep):
            self._change_cluster_dist(i)
            self.tk_scalebar.set(i)
            self.app.update()
            # self._clst.cluster_distance = i
            clusters, __ = (
                self._clst.cluster_item(sel_item, None, True)
            )
            mask_noisy = (clusters == -1)
            # with noisy (=0)
            number_of_clusters = len(np.unique(clusters[~mask_noisy]))
            if number_of_clusters == 1:
                break
            form_string = (f'{i},{number_of_clusters},'
                           f'{mask_noisy.sum()},{len(mask_noisy)},\n')
            scalecalclist.append(form_string)
        with open(f'02_Output/scaletest_{sel_item.name}.txt',
                  "w", encoding='utf-8') as logfile_a:
            for scalecalc in scalecalclist:
                logfile_a.write(scalecalc)
        plt.figure(1).clf()
        # plt references the last figure accessed
        self.fig1 = plt.figure()
        TPLT.set_plt_suptitle(self.fig1, sel_item.name, self._clst.cls_type)
        self.fig1.canvas.manager.set_window_title('Cluster Preview')
        dist_text = ''
        if self._clst.autoselect_clusters is False:
            dist_text = f'@ {self._clst.cluster_distance}m'
        plt.title(f'Cluster Preview {dist_text}', fontsize=12, loc='center')
        if mask_noisy is None:
            return
        noisy_txt = f'{mask_noisy.sum()}/{len(mask_noisy)}'
        plt.text(self._clst.bounds.lim_lng_max, self._clst.bounds.lim_lat_max,
                 f'{number_of_clusters} Cluster (Noise: {noisy_txt})',
                 fontsize=10, horizontalalignment='right',
                 verticalalignment='top', fontweight='bold')

    def _delete_fromtoplist(self, listbox):
        """Remove entry from top_list

        - if location removed, clean post list too
        """
        # Delete from Listbox
        selection = listbox.curselection()
        id_list_selected = list()
        for index in selection[::-1]:
            listbox.delete(index)
            id_list_selected.append(self._clst.top_list[index].name)
            del self._clst.top_list[index]
        # remove all cleaned posts from processing list if
        # location is removed
        if self._clst.cls_type == LOCATIONS:
            self._delete_post_locations(id_list_selected)

    def _delete_post_locations(self,
                               post_locids: List[str]):
        """Remove all posts with post_locid from list

        Returns a list of values for fast lookup
                the dict itself is modified in place
                across clusters
                because dicts are mutable
        To-do:
            - update toplists (remove tags/emoji from
            posts that are removed)
        """
        # tkinter.messagebox.showinfo("Len before: ", len(cleaned_post_dict))
        # first get post guids to be removed
        # this is quite expensive,
        # perhaps there's a better way
        # tkinter.messagebox.showinfo("post_locids: ", f'{post_locids}')
        postguids_to_remove = [post_record.guid for post_record
                               in self._clst.cleaned_post_dict.values()
                               if post_record.loc_id in post_locids]
        if UserInterface._query_user(
                f'This will also remove '
                f'{len(postguids_to_remove)} posts from further processing.\n'
                f'Continue?', f'Continue?') is True:
            # the following code will remove
            # dict records and list entries from current clusterer
            for post_guid in postguids_to_remove:
                del self._clst.cleaned_post_dict[post_guid]
            # To modify the list in-place, assign to its slice:
            self._clst.cleaned_post_list[:] = list(
                self._clst.cleaned_post_dict.values())

    @staticmethod
    def _query_user(question_text: str,
                    title_text: str) -> bool:
        """Ask a question with Yes No options"""
        result = tkinter.messagebox.askquestion(
            title_text, question_text, icon='question')
        return bool(result == 'yes')


class App(tk.Tk):
    """Tkinter interface wrapper

    Args:
        tk: reference to tkinter package

    """

    def __init__(self):
        tk.Tk.__init__(self)
        # this is needed to make the root disappear
        self.overrideredirect(True)
        self.geometry('%dx%d+%d+%d' % (0, 0, 0, 0))
        # create separate floating window
        self.floater = FloatingWindow(self)


class FloatingWindow(tk.Toplevel):
    """Toplevel tkinter floating window app

    Args:
        tk: Tkinter reference
    """

    def __init__(self, *args, **kwargs):
        tk.Toplevel.__init__(self, *args, **kwargs)
        self.overrideredirect(True)
        self.configure(background='gray7')
        # self.label = tk.Label(self, text="Click on the grip to move")
        self.grip = tk.Label(self, bitmap="gray25")
        self.grip.pack(side="left", fill="y")
        # self.label.pack(side="right", fill="both", expand=True)
        self.grip.bind("<ButtonPress-1>", self._start_move)
        self.grip.bind("<ButtonRelease-1>", self._stop_move)
        self.grip.bind("<B1-Motion>", self._on_motion)
        # center Floating Window
        w_win = self.winfo_reqwidth()
        h_win = self.winfo_reqheight()
        ws_win = self.winfo_screenwidth()
        hs_win = self.winfo_screenheight()
        x_win = (ws_win/2) - (w_win/2)
        y_win = (hs_win/2) - (h_win/2)
        self.geometry('+%d+%d' % (x_win, y_win))
        # coordinates of floating window
        self.x_move = None
        self.y_move = None

    def _start_move(self, event):
        self.x_move = event.x
        self.y_move = event.y

    def _stop_move(self, __):
        self.x_move = None
        self.y_move = None

    def _on_motion(self, event):
        deltax = event.x - self.x_move
        deltay = event.y - self.y_move
        x_win = self.winfo_x() + deltax
        y_win = self.winfo_y() + deltay
        self.geometry("+%s+%s" % (x_win, y_win))

Classes

class App

Tkinter interface wrapper

Args

tk
reference to tkinter package

Return a new top level widget on screen SCREENNAME. A new Tcl interpreter will be created. BASENAME will be used for the identification of the profile file (see readprofile). It is constructed from sys.argv[0] without extensions if None is given. CLASSNAME is the name of the widget class.

Expand source code
class App(tk.Tk):
    """Tkinter interface wrapper

    Args:
        tk: reference to tkinter package

    """

    def __init__(self):
        tk.Tk.__init__(self)
        # this is needed to make the root disappear
        self.overrideredirect(True)
        self.geometry('%dx%d+%d+%d' % (0, 0, 0, 0))
        # create separate floating window
        self.floater = FloatingWindow(self)

Ancestors

  • tkinter.Tk
  • tkinter.Misc
  • tkinter.Wm
class FloatingWindow (*args, **kwargs)

Toplevel tkinter floating window app

Args

tk
Tkinter reference

Construct a toplevel widget with the parent MASTER.

Valid resource names: background, bd, bg, borderwidth, class, colormap, container, cursor, height, highlightbackground, highlightcolor, highlightthickness, menu, relief, screen, takefocus, use, visual, width.

Expand source code
class FloatingWindow(tk.Toplevel):
    """Toplevel tkinter floating window app

    Args:
        tk: Tkinter reference
    """

    def __init__(self, *args, **kwargs):
        tk.Toplevel.__init__(self, *args, **kwargs)
        self.overrideredirect(True)
        self.configure(background='gray7')
        # self.label = tk.Label(self, text="Click on the grip to move")
        self.grip = tk.Label(self, bitmap="gray25")
        self.grip.pack(side="left", fill="y")
        # self.label.pack(side="right", fill="both", expand=True)
        self.grip.bind("<ButtonPress-1>", self._start_move)
        self.grip.bind("<ButtonRelease-1>", self._stop_move)
        self.grip.bind("<B1-Motion>", self._on_motion)
        # center Floating Window
        w_win = self.winfo_reqwidth()
        h_win = self.winfo_reqheight()
        ws_win = self.winfo_screenwidth()
        hs_win = self.winfo_screenheight()
        x_win = (ws_win/2) - (w_win/2)
        y_win = (hs_win/2) - (h_win/2)
        self.geometry('+%d+%d' % (x_win, y_win))
        # coordinates of floating window
        self.x_move = None
        self.y_move = None

    def _start_move(self, event):
        self.x_move = event.x
        self.y_move = event.y

    def _stop_move(self, __):
        self.x_move = None
        self.y_move = None

    def _on_motion(self, event):
        deltax = event.x - self.x_move
        deltay = event.y - self.y_move
        x_win = self.winfo_x() + deltax
        y_win = self.winfo_y() + deltay
        self.geometry("+%s+%s" % (x_win, y_win))

Ancestors

  • tkinter.Toplevel
  • tkinter.BaseWidget
  • tkinter.Misc
  • tkinter.Wm
class UserInterface (clusterer_list: Iterable[ClusterGen] = None, location_names_dict: Dict[str, str] = None)

User interface class for interacting input

Prepare user interface and start Tkinter mainloop()

Expand source code
class UserInterface():
    """User interface class for interacting input"""

    def __init__(self,
                 clusterer_list: Iterable[ClusterGen] = None,
                 location_names_dict: Dict[str, str] = None
                 ):
        """Prepare user interface and start Tkinter mainloop()
        """
        # threading.Thread.__init__(self)
        self._clst_list = list()
        # append clusters to list
        if not clusterer_list:
            raise ValueError('No clusterer selected.')
        for clusterer in clusterer_list:
            self._clst_list.append(clusterer)
        # select initial cluster
        self._clst = self._clst_list[0]
        self.location_names_dict = location_names_dict
        self.abort = False
        self.current_display_item = None
        # Initialize TKinter Interface
        self.app = App()
        # allow error reporting from console backend
        tk.Tk.report_callback_exception = self._report_callback_exception
        # definition of global vars for interface and graph design
        # Cluster preparation
        self.gen_preview_map = tk.IntVar(value=0)
        self.create_min_spanning_tree = False
        self.create_condensed_tree = False
        self.tk_scalebar = None
        # definition of global figure for reusing windows
        self.fig1 = None
        self.fig2 = None
        self.fig3 = None
        self.fig4 = None
        # user selected item
        self.lastselection = None
        # A frame is created for each window/part of the gui;
        # after it is used, it is destroyed with frame.destroy()
        header_frame = tk.Frame(self.app.floater)
        canvas = tk.Canvas(
            header_frame, width=150, height=220,
            highlightthickness=0, background="gray7")
        header_label = tk.Label(
            canvas, text="Optional: Exclude tags, "
            "emoji or locations.",
            background="gray7", fg="gray80",
            font="Arial 10 bold")
        header_label.pack(padx=10, pady=10)

        self._clst_index = tk.IntVar()
        self._clst_index.set(0)
        # Radiobuttons for selecting list
        idx = 0
        for clst in self._clst_list:
            radio_b = tk.Radiobutton(
                canvas, text=f'{clst.cls_type}',
                variable=self._clst_index,
                value=idx,
                indicatoron=0,
                command=self._change_clusterer,
                background="gray20",
                fg="gray80",
                borderwidth=0,
                font="Arial 10 bold",
                width=15)
            radio_b.pack(side='left')
            idx += 1
        canvas.pack(fill='both', padx=0, pady=0,)
        header_frame.pack(fill='both', padx=0, pady=0)
        listbox_frame = tk.Frame(self.app.floater)
        canvas = tk.Canvas(
            listbox_frame, width=150, height=220,
            highlightthickness=0, background="gray7")
        guide_label = tk.Label(
            canvas, text=f'Select all items you wish to exclude '
            f'from analysis \n '
            f'and click on remove. Proceed if ready.',
            background="gray7", fg="gray80")
        guide_label.pack(padx=10, pady=10)
        listbox_font = None
        listbox = tk.Listbox(
            canvas,
            selectmode=tk.EXTENDED, bd=0, background="gray29",
            fg="gray91", width=30, font=listbox_font)
        listbox.bind('<<ListboxSelect>>', self._onselect)
        scroll = tk.Scrollbar(canvas, orient=tk.VERTICAL,
                              background="gray20", borderwidth=0)
        scroll.configure(command=listbox.yview)
        scroll.pack(side="right", fill="y")
        listbox.pack()
        listbox.config(yscrollcommand=scroll.set)
        self.listbox = listbox
        self._populate_listbox()
        canvas.pack(fill='both', padx=0, pady=0)
        listbox_frame.pack(fill='both', padx=0, pady=0)
        buttons_frame = tk.Frame(self.app.floater)
        canvas = tk.Canvas(buttons_frame, width=150, height=200,
                           highlightthickness=0, background="gray7")
        UserInterface._create_button(
            canvas, "Remove Selected", lambda: self._delete_fromtoplist(
                self.listbox),
            left=False)
        check_b = tk.Checkbutton(
            canvas, text="Map Tags",
            variable=self.gen_preview_map,
            background="gray7", fg="gray80",
            borderwidth=0, font="Arial 10 bold")
        check_b.pack(padx=10, pady=10)
        self.tk_scalebar = tk.Scale(
            canvas,
            from_=(self._clst.cluster_distance/100),
            to=(self._clst.cluster_distance*2),
            orient=tk.HORIZONTAL,
            resolution=0.1,
            command=self._change_cluster_dist,
            length=300,
            label="Cluster Cut Distance (in Meters)",
            background="gray20",
            borderwidth=0,
            fg="gray80",
            font="Arial 10 bold")
        # set position of slider to center
        # (clusterTreeCuttingDist*10) - (clusterTreeCuttingDist/10)/2)
        self.tk_scalebar.set(self._clst.cluster_distance)
        self.tk_scalebar.pack()
        UserInterface._create_button(
            canvas, "Cluster Preview",
            self._cluster_current_display_item)
        UserInterface._create_button(
            canvas, "Scale Test",
            self._scaletest_current_display_item)
        UserInterface._create_button(
            canvas, "Proceed..", self._proceed_with_cluster)
        UserInterface._create_button(
            canvas, "Quit", self._quit_tkinter)
        canvas.pack(fill='both', padx=0, pady=0)
        buttons_frame.pack(fill='both', padx=0, pady=0)

    @staticmethod
    def _create_button(canvas, text: str, command, left: bool = True):
        """Create button with text and command

        and pack to canvas."""
        button = tk.Button(canvas, text=text,
                           command=command,
                           background="gray20",
                           fg="gray80",
                           borderwidth=0,
                           font="Arial 10 bold")
        if left:
            button.pack(padx=10, pady=10, side="left")
        else:
            button.pack(padx=10, pady=10)

    def _populate_listbox(self):
        """Populate tkinter listbox with records

        - only for first 1000 entries: top_list[:1000]
        """
        listbox = self.listbox
        top_list: List[ItemCounter] = self._clst.top_list
        loc_name_dict: Optional[Dict[str, str]] = self.location_names_dict
        # clear first
        listbox.delete(0, tk.END)
        # tkinter.messagebox.showinfo(f"length of list:", f"{len(top_list)}")
        # maximum of 1000 entries shown
        for item in top_list[:1000]:
            if self._clst.cls_type == LOCATIONS:
                item_name = Utils.get_locname(item.name, loc_name_dict)
            else:
                item_name = item.name
            try:
                # try inserting emoji first,
                # some emoji can be printed
                listbox.insert(tk.END, f'{item_name} ({item.ucount} user)')
            except TclError:
                # replace emoji by unicode name on error
                emoji = Utils.get_emojiname(item_name)
                listbox.insert(tk.END, f'{emoji} ({item.ucount} user)')

    def start(self):
        """Start up user interface after initialization

        this is the mainloop for the interface
        once it is destroyed, the regular process
        will continue
        """
        self.app.mainloop()
        # end of tkinter loop,
        # welcome back to console
        plt.close("all")

    def _cluster_preview(self, sel_item: str):
        """Show preview map(s) based on tag selection"""
        # Get cluster data first
        (_, _, points, sel_colors,
         mask_noisy, number_of_clusters) = self._clst.cluster_item(
             item=sel_item,
             preview_mode=True)
        ## Cluster Map Plot ##
        if not plt.fignum_exists(1):
            self.fig1 = plt.figure(1)
        else:
            self.fig1.clf()
        self.fig1.add_subplot(111)
        self.fig1 = TPLT.get_cluster_preview(
            points, sel_colors, sel_item, self._clst.bounds, mask_noisy,
            self._clst.cluster_distance, number_of_clusters,
            self._clst.autoselect_clusters, fig=self.fig1)
        self.fig1.canvas.draw_idle()
        ## Single Linkage Tree Plot ##
        if not plt.fignum_exists(2):
            self.fig2 = plt.figure(2)
        else:
            self.fig2.clf()
        axis = self.fig2.add_subplot(111)
        # p is the number of max count of leafs in the tree,
        # this should at least be the number of clusters*10,
        # not lower than 50 [but max 500 to not crash]
        self._clst.clusterer.single_linkage_tree_.plot(
            axis=axis,
            truncate_mode='lastp',
            p=max(50, min(number_of_clusters*10, 256)))
        item_name = sel_item
        TPLT.get_single_linkage_tree_preview(
            item_name, self.fig2, self._clst.cluster_distance,
            self._clst.cls_type)
        self.fig2.canvas.draw_idle()
        ## Condensed Tree Plot ##
        if self.create_condensed_tree:
            # Unfixed issue:
            # on consecutive updates
            # the colorbar is added multiple times
            # possible solution: retrieve only
            # condensed data from hdbscan
            # and create plot in tagmaps
            if not plt.fignum_exists(3):
                self.fig3 = plt.figure(3)
            else:
                self.fig3.clf()
            axis = self.fig3.add_subplot(111)
            self._clst.clusterer.condensed_tree_.plot(
                axis=axis,
                log_size=True
            )
            self.fig3.canvas.manager.set_window_title('Condensed Tree')
            TPLT.set_plt_suptitle(
                self.fig3, sel_item, self._clst.cls_type)
            TPLT.set_plt_tick_params(axis)
            self.fig3.canvas.draw_idle()
        if self.create_min_spanning_tree:
            if not plt.fignum_exists(4):
                self.fig4 = plt.figure(4)
            else:
                self.fig4.clf()
            axis = self.fig4.add_subplot(111)
            self._clst.clusterer.minimum_spanning_tree_.plot(
                axis=axis,
                edge_cmap='viridis',
                edge_alpha=0.6,
                node_size=10,
                edge_linewidth=1)
            self.fig4.canvas.manager.set_window_title(
                'Minimum Spanning Tree')
            # tkinter.messagebox.showinfo("messagr", str(type(ax)))
            TPLT.set_plt_suptitle(
                self.fig4, sel_item, self._clst.cls_type)
            axis.set_title(
                f'Minimum Spanning Tree @ {self._clst.cluster_distance}m',
                fontsize=12, loc='center')
            self.fig4.legend(fontsize=10)
            self.fig4.canvas.draw()
        TPLT.set_plt_tick_params(axis)
        self._update_scalebar()

    def _intf_selection_preview(self, sel_item: str):
        """Update preview map based on item selection"""
        # tkinter.messagebox.showinfo("Proceed", f'{sel_item}')
        points = self._clst.get_np_points(
            item=sel_item,
            silent=True)
        if points is None:
            tkinter.messagebox.showinfo(
                "No locations found.",
                "All locations for given item have been removed.")
            return
        if not plt.fignum_exists(1):
            self.fig1 = plt.figure(1)
        else:
            # clf() clears figure and subplots (axes)
            self.fig1.clf()
        self.fig1.add_subplot(111)
        self._intf_plot_points(sel_item, points)

    def _intf_plot_points(self, item_name: str, points):
        self.fig1 = TPLT.get_fig_points(
            self.fig1,
            points, self._clst.bounds)
        TPLT.set_plt_suptitle(
            self.fig1, item_name, self._clst.cls_type)

    def _report_callback_exception(self, __, val, ___):
        """Override for error reporting during tkinter mode"""
        showerror("Error", message=str(val))
        exc_type, exc_value, exc_traceback = sys.exc_info()
        print("*** print_tb:")
        traceback.print_tb(exc_traceback, limit=1, file=sys.stdout)
        print("*** print_exception:")
        traceback.print_exception(exc_type, exc_value, exc_traceback,
                                  limit=2, file=sys.stdout)
        print("*** print_exc:")
        traceback.print_exc()
        print("*** format_exc, first and last line:")
        formatted_lines = traceback.format_exc().splitlines()
        print(formatted_lines[0])
        print(formatted_lines[-1])
        print("*** format_exception:")
        print(repr(traceback.format_exception(exc_type, exc_value,
                                              exc_traceback)))
        print("*** extract_tb:")
        print(repr(traceback.extract_tb(exc_traceback)))
        print("*** format_tb:")
        print(repr(traceback.format_tb(exc_traceback)))
        print("*** tb_lineno:", exc_traceback.tb_lineno)

    def _quit_tkinter(self):
        """Exit Tkinter GUI

        ..to continue with code execution after mainloop()

        Notes:
        - see [1]
        https://stackoverflow.com/questions/35040168/python-tkinter-error-cant-evoke-event-command
        - root.quit() causes mainloop to exit, see [2]
        https://stackoverflow.com/questions/2307464/what-is-the-difference-between-root-destroy-and-root-quit

        """
        self.abort = True
        self.app.update()
        self.app.destroy()
        self.app.quit()

    def _proceed_with_cluster(self):
        self.app.update()
        self.app.destroy()
        self.app.quit()

    def _change_cluster_dist(self, val):
        """Changes cluster distance based on user input

        Args:
            val: new cluster distance
        """
        self._clst.cluster_distance = float(val)

    def _change_clusterer(self):
        """Changes clusterer based on user selection

        Either TAGS, EMOJI or LOCATIONS
        """
        self.current_display_item = None
        self._clst = self._clst_list[
            self._clst_index.get()]
        self._populate_listbox()
        self._update_scalebar()

    def _update_scalebar(self):
        """Adjust scalebar limits from cluster distance"""
        self.tk_scalebar.configure(
            from_=(self._clst.cluster_distance/100),
            to=(self._clst.cluster_distance*2))
        self.tk_scalebar.set(self._clst.cluster_distance)

    def _onselect(self, evt):
        """On user select item from list

        Args:
            evt (event object): event object for selected item
        """
        widg = evt.widget
        self.lastselection = widg.index(tk.ACTIVE)
        sel_index = widg.curselection()[0]
        if (self.gen_preview_map.get() == 1 and
                len(widg.curselection()) == 1):
            # generate preview map
            # only if selection box is checked
            # and only if one item is checked
            sel_item = self._clst.top_list[sel_index].name
            self._intf_selection_preview(
                sel_item)
            self.current_display_item = sel_item

    def _cluster_current_display_item(self):
        """Cluster button: use selected or first in list"""
        if self.current_display_item:
            # tkinter.messagebox.showinfo("Clusteritem: ",
            #                             f'{self.current_display_item}')
            self._cluster_preview(self.current_display_item)
        else:
            # use first in list
            self._cluster_preview(self._clst.top_list[0].name)

    def _scaletest_current_display_item(self):
        """Compute clustering across different scales and output results to txt"""
        if self.create_min_spanning_tree is False:
            tkinter.messagebox.showinfo(
                "Skip: ",
                f'Currently deactivated')
            return
        if self.current_display_item:
            sel_item = self.current_display_item
        else:
            sel_item = self._clst.top_list[0].name
        scalecalclist = []
        dmax = int(self._clst.cluster_distance*10)
        dmin = int(self._clst.cluster_distance/10)
        dstep = int(((self._clst.cluster_distance*10) -
                     (self._clst.cluster_distance/10))/100)
        mask_noisy = None
        number_of_clusters = 0
        for i in range(dmin, dmax, dstep):
            self._change_cluster_dist(i)
            self.tk_scalebar.set(i)
            self.app.update()
            # self._clst.cluster_distance = i
            clusters, __ = (
                self._clst.cluster_item(sel_item, None, True)
            )
            mask_noisy = (clusters == -1)
            # with noisy (=0)
            number_of_clusters = len(np.unique(clusters[~mask_noisy]))
            if number_of_clusters == 1:
                break
            form_string = (f'{i},{number_of_clusters},'
                           f'{mask_noisy.sum()},{len(mask_noisy)},\n')
            scalecalclist.append(form_string)
        with open(f'02_Output/scaletest_{sel_item.name}.txt',
                  "w", encoding='utf-8') as logfile_a:
            for scalecalc in scalecalclist:
                logfile_a.write(scalecalc)
        plt.figure(1).clf()
        # plt references the last figure accessed
        self.fig1 = plt.figure()
        TPLT.set_plt_suptitle(self.fig1, sel_item.name, self._clst.cls_type)
        self.fig1.canvas.manager.set_window_title('Cluster Preview')
        dist_text = ''
        if self._clst.autoselect_clusters is False:
            dist_text = f'@ {self._clst.cluster_distance}m'
        plt.title(f'Cluster Preview {dist_text}', fontsize=12, loc='center')
        if mask_noisy is None:
            return
        noisy_txt = f'{mask_noisy.sum()}/{len(mask_noisy)}'
        plt.text(self._clst.bounds.lim_lng_max, self._clst.bounds.lim_lat_max,
                 f'{number_of_clusters} Cluster (Noise: {noisy_txt})',
                 fontsize=10, horizontalalignment='right',
                 verticalalignment='top', fontweight='bold')

    def _delete_fromtoplist(self, listbox):
        """Remove entry from top_list

        - if location removed, clean post list too
        """
        # Delete from Listbox
        selection = listbox.curselection()
        id_list_selected = list()
        for index in selection[::-1]:
            listbox.delete(index)
            id_list_selected.append(self._clst.top_list[index].name)
            del self._clst.top_list[index]
        # remove all cleaned posts from processing list if
        # location is removed
        if self._clst.cls_type == LOCATIONS:
            self._delete_post_locations(id_list_selected)

    def _delete_post_locations(self,
                               post_locids: List[str]):
        """Remove all posts with post_locid from list

        Returns a list of values for fast lookup
                the dict itself is modified in place
                across clusters
                because dicts are mutable
        To-do:
            - update toplists (remove tags/emoji from
            posts that are removed)
        """
        # tkinter.messagebox.showinfo("Len before: ", len(cleaned_post_dict))
        # first get post guids to be removed
        # this is quite expensive,
        # perhaps there's a better way
        # tkinter.messagebox.showinfo("post_locids: ", f'{post_locids}')
        postguids_to_remove = [post_record.guid for post_record
                               in self._clst.cleaned_post_dict.values()
                               if post_record.loc_id in post_locids]
        if UserInterface._query_user(
                f'This will also remove '
                f'{len(postguids_to_remove)} posts from further processing.\n'
                f'Continue?', f'Continue?') is True:
            # the following code will remove
            # dict records and list entries from current clusterer
            for post_guid in postguids_to_remove:
                del self._clst.cleaned_post_dict[post_guid]
            # To modify the list in-place, assign to its slice:
            self._clst.cleaned_post_list[:] = list(
                self._clst.cleaned_post_dict.values())

    @staticmethod
    def _query_user(question_text: str,
                    title_text: str) -> bool:
        """Ask a question with Yes No options"""
        result = tkinter.messagebox.askquestion(
            title_text, question_text, icon='question')
        return bool(result == 'yes')

Methods

def start(self)

Start up user interface after initialization

this is the mainloop for the interface once it is destroyed, the regular process will continue

Expand source code
def start(self):
    """Start up user interface after initialization

    this is the mainloop for the interface
    once it is destroyed, the regular process
    will continue
    """
    self.app.mainloop()
    # end of tkinter loop,
    # welcome back to console
    plt.close("all")