import os, sys
import calendar
import numpy as np
import scipy.interpolate as interp
import warnings
from datetime import datetime as dt,timedelta
import scipy.ndimage as ndimage
import networkx as nx
from scipy.ndimage import gaussian_filter as gfilt

#Import internal scripts
from ..plot import Plot

#Import tools
from .tools import *
from ..utils import *
from .. import constants

try:
    import cartopy.feature as cfeature
    from cartopy import crs as ccrs
    from cartopy.mpl.gridliner import LONGITUDE_FORMATTER, LATITUDE_FORMATTER
except:
    warnings.warn("Warning: Cartopy is not installed in your python environment. Plotting functions will not work.")

try:
    import matplotlib.colors as mcolors
    import matplotlib.lines as mlines
    import matplotlib.patheffects as path_effects
    import matplotlib.pyplot as plt
    import matplotlib.ticker as mticker
    import matplotlib.patches as mpatches
    from matplotlib.offsetbox import TextArea, DrawingArea, OffsetImage, AnnotationBbox
except:
    warnings.warn("Warning: Matplotlib is not installed in your python environment. Plotting functions will not work.")

def plot_dot(ax,lon,lat,date,vmax,i_type,zorder,storm_data,prop,i):

    r"""
    Plot a dot on the map per user settings.

    Parameters
    ----------
    lon : int, float
        Longitude of the dot
    lat : int, float
        Latitude of the dot
    date : datetime.datetime
        Datetime object corresponding to the time of the dot in UTC
    vmax : int, float
        Sustained wind in knots
    i_type : str
        Storm type

    Other Parameters
    ----------------
    zorder : int
        Z-order of dots on the map.
    storm_data : dict
        Storm data dictionary.
    prop : dict
        Dictionary containing plot properties.

    Returns
    -------
    segmented_colors : bool
        Information for colorbar generation on whether a segmented colormap was used or not.
    """
    
    #Return cmap & levels if needed
    extra = {}

    #Determine fill color, with SSHWS scale used as default
    if prop['fillcolor'] == 'category':
        segmented_colors = True
        fill_color = get_colors_sshws(np.nan_to_num(vmax))

    #Use user-defined colormap if another storm variable
    elif isinstance(prop['fillcolor'],str) == True and prop['fillcolor'] in ['vmax','mslp','dvmax_dt','speed']:
        segmented_colors = True
        color_variable = storm_data[prop['fillcolor']]
        if prop['levels'] is None: #Auto-determine color levels if needed
            prop['levels'] = [np.nanmin(color_variable),np.nanmax(color_variable)]
        cmap,levels = get_cmap_levels(prop['fillcolor'],prop['cmap'],prop['levels'])
        fill_color = cmap((color_variable-min(levels))/(max(levels)-min(levels)))[i]
        extra['cmap'] = cmap
        extra['levels'] = levels

    #Otherwise go with user input as is
    else:
        segmented_colors = False
        fill_color = prop['fillcolor']

    #Determine dot type
    marker_type = '^'
    if i_type in constants.SUBTROPICAL_ONLY_STORM_TYPES:
        marker_type = 's'
    elif i_type in constants.TROPICAL_ONLY_STORM_TYPES:
        marker_type = 'o'

    #Plot marker
    ax.plot(lon,lat,marker_type,mfc=fill_color,mec='k',mew=0.5,
                 zorder=zorder,ms=prop['ms'],transform=ccrs.PlateCarree())

    return ax, segmented_colors, extra

def rgb(self,rgb):
    r,g,b = rgb
    r = int(r)
    g = int(g)
    b = int(b)
    return '#%02x%02x%02x' % (r, g, b)

def add_legend(ax,fig,prop,segmented_colors,levels=None,cmap=None,storm=None):

    #Linecolor category with dots
    if prop['fillcolor'] == 'category' and prop['dots'] == True:
        ex = mlines.Line2D([], [], linestyle='None', ms=prop['ms'], mec='k',mew=0.5, label='Non-Tropical', marker='^', color='w')
        sb = mlines.Line2D([], [], linestyle='None', ms=prop['ms'], mec='k',mew=0.5, label='Subtropical', marker='s', color='w')
        td = mlines.Line2D([], [], linestyle='None', ms=prop['ms'], mec='k',mew=0.5, label='Tropical Depression', marker='o', color=get_colors_sshws(33))
        ts = mlines.Line2D([], [], linestyle='None', ms=prop['ms'], mec='k',mew=0.5, label='Tropical Storm', marker='o', color=get_colors_sshws(34))
        c1 = mlines.Line2D([], [], linestyle='None', ms=prop['ms'], mec='k',mew=0.5, label='Category 1', marker='o', color=get_colors_sshws(64))
        c2 = mlines.Line2D([], [], linestyle='None', ms=prop['ms'], mec='k',mew=0.5, label='Category 2', marker='o', color=get_colors_sshws(83))
        c3 = mlines.Line2D([], [], linestyle='None', ms=prop['ms'], mec='k',mew=0.5, label='Category 3', marker='o', color=get_colors_sshws(96))
        c4 = mlines.Line2D([], [], linestyle='None', ms=prop['ms'], mec='k',mew=0.5, label='Category 4', marker='o', color=get_colors_sshws(113))
        c5 = mlines.Line2D([], [], linestyle='None', ms=prop['ms'], mec='k',mew=0.5, label='Category 5', marker='o', color=get_colors_sshws(137))
        l = ax.legend(handles=[ex,sb,td,ts,c1,c2,c3,c4,c5], prop={'size':11.5}, loc=1)
        l.set_zorder(1001)

    #Linecolor category without dots
    elif prop['linecolor'] == 'category' and prop['dots'] == False:
        ex = mlines.Line2D([], [], linestyle='dotted', label='Non-Tropical', color='k')
        td = mlines.Line2D([], [], linestyle='solid', label='Sub/Tropical Depression', color=get_colors_sshws(33))
        ts = mlines.Line2D([], [], linestyle='solid', label='Sub/Tropical Storm', color=get_colors_sshws(34))
        c1 = mlines.Line2D([], [], linestyle='solid', label='Category 1', color=get_colors_sshws(64))
        c2 = mlines.Line2D([], [], linestyle='solid', label='Category 2', color=get_colors_sshws(83))
        c3 = mlines.Line2D([], [], linestyle='solid', label='Category 3', color=get_colors_sshws(96))
        c4 = mlines.Line2D([], [], linestyle='solid', label='Category 4', color=get_colors_sshws(113))
        c5 = mlines.Line2D([], [], linestyle='solid', label='Category 5', color=get_colors_sshws(137))
        l = ax.legend(handles=[ex,td,ts,c1,c2,c3,c4,c5], prop={'size':11.5}, loc=1)
        l.set_zorder(1001)

    #Non-segmented custom colormap with dots
    elif prop['dots'] == True and segmented_colors == False:
        ex = mlines.Line2D([], [], linestyle='None', ms=prop['ms'], mec='k',mew=0.5, label='Non-Tropical', marker='^', color=prop['fillcolor'])
        sb = mlines.Line2D([], [], linestyle='None', ms=prop['ms'], mec='k',mew=0.5, label='Subtropical', marker='s', color=prop['fillcolor'])
        td = mlines.Line2D([], [], linestyle='None', ms=prop['ms'], mec='k',mew=0.5, label='Tropical', marker='o', color=prop['fillcolor'])
        handles=[ex,sb,td]
        l = ax.legend(handles=handles,fontsize=11.5, prop={'size':11.5}, loc=1)
        l.set_zorder(1001)

    #Non-segmented custom colormap without dots
    elif prop['dots'] == False and segmented_colors == False:
        ex = mlines.Line2D([], [], linestyle='dotted',label='Non-Tropical', color=prop['linecolor'])
        td = mlines.Line2D([], [], linestyle='solid',label='Tropical', color=prop['linecolor'])
        handles=[ex,td]
        l = ax.legend(handles=handles,fontsize=11.5, prop={'size':11.5}, loc=1)
        l.set_zorder(1001)

    #Custom colormap with dots
    elif prop['dots'] == True and segmented_colors == True:
        ex = mlines.Line2D([], [], linestyle='None', ms=prop['ms'], mec='k',mew=0.5, label='Non-Tropical', marker='^', color='w')
        sb = mlines.Line2D([], [], linestyle='None', ms=prop['ms'], mec='k',mew=0.5, label='Subtropical', marker='s', color='w')
        td = mlines.Line2D([], [], linestyle='None', ms=prop['ms'], mec='k',mew=0.5, label='Tropical', marker='o', color='w')
        handles=[ex,sb,td]
        for _ in range(7):
            handles.append(mlines.Line2D([], [], linestyle='-',label='',lw=0))
        l = ax.legend(handles=handles,fontsize=11.5)
        l.set_zorder(1001)
        plt.draw()

        #Get the bbox
        try:
            bb = l.legendPatch.get_bbox().inverse_transformed(fig.transFigure)
        except:
            bb = l.legendPatch.get_bbox().transformed(fig.transFigure.inverted())

        #Define colorbar axis
        cax = fig.add_axes([bb.x0+0.47*bb.width, bb.y0+.057*bb.height, 0.015, .65*bb.height])
        norm = mlib.colors.Normalize(vmin=min(levels), vmax=max(levels))
        cbmap = mlib.cm.ScalarMappable(norm=norm, cmap=cmap)
        cbar = fig.colorbar(cbmap,cax=cax,orientation='vertical',\
                                 ticks=levels)

        cax.tick_params(labelsize=11.5)
        cax.yaxis.set_ticks_position('left')
        cbar.set_label(prop['fillcolor'],fontsize=11.5,rotation=90)

        rect_offset = 0.0
        if prop['cmap'] == 'category' and prop['fillcolor'] == 'vmax':
            cax.yaxis.set_ticks(np.linspace(min(levels),max(levels),len(levels)))
            cax.yaxis.set_ticklabels(levels)
            cax2 = cax.twinx()
            cax2.yaxis.set_ticks_position('right')
            cax2.yaxis.set_ticks((np.linspace(0,1,len(levels))[:-1]+np.linspace(0,1,len(levels))[1:])*.5)
            cax2.set_yticklabels(['TD','TS','Cat-1','Cat-2','Cat-3','Cat-4','Cat-5'],fontsize=11.5)
            cax2.tick_params('both', length=0, width=0, which='major')
            cax.yaxis.set_ticks_position('left')
            rect_offset = 0.7
        if prop['fillcolor'] == 'date':
            cax.set_yticklabels([f'{mdates.num2date(i):%b %-d}' for i in levels],fontsize=11.5)

    #Custom colormap without dots
    else:
        ex = mlines.Line2D([], [], linestyle='dotted',label='Non-Tropical', color='k')
        td = mlines.Line2D([], [], linestyle='solid',label='Tropical', color='k')
        handles=[ex,td]
        for _ in range(7):
            handles.append(mlines.Line2D([], [], linestyle='-',label='',lw=0))
        l = ax.legend(handles=handles,fontsize=11.5)
        l.set_zorder(1001)
        plt.draw()

        #Get the bbox
        try:
            bb = l.legendPatch.get_bbox().inverse_transformed(fig.transFigure)
        except:
            bb = l.legendPatch.get_bbox().transformed(fig.transFigure.inverted())

        #Define colorbar axis
        cax = fig.add_axes([bb.x0+0.47*bb.width, bb.y0+.057*bb.height, 0.015, .65*bb.height])
        norm = mlib.colors.Normalize(vmin=min(levels), vmax=max(levels))
        cbmap = mlib.cm.ScalarMappable(norm=norm, cmap=cmap)
        cbar = fig.colorbar(cbmap,cax=cax,orientation='vertical',\
                                 ticks=levels)

        cax.tick_params(labelsize=11.5)
        cax.yaxis.set_ticks_position('left')
        cbarlab = make_var_label(prop['linecolor'],storm)
        cbar.set_label(cbarlab,fontsize=11.5,rotation=90)

        rect_offset = 0.0
        if prop['cmap'] == 'category' and prop['linecolor'] == 'vmax':
            cax.yaxis.set_ticks(np.linspace(min(levels),max(levels),len(levels)))
            cax.yaxis.set_ticklabels(levels)
            cax2 = cax.twinx()
            cax2.yaxis.set_ticks_position('right')
            cax2.yaxis.set_ticks((np.linspace(0,1,len(levels))[:-1]+np.linspace(0,1,len(levels))[1:])*.5)
            cax2.set_yticklabels(['TD','TS','Cat-1','Cat-2','Cat-3','Cat-4','Cat-5'],fontsize=11.5)
            cax2.tick_params('both', length=0, width=0, which='major')
            cax.yaxis.set_ticks_position('left')
            rect_offset = 0.7
        if prop['linecolor'] == 'date':
            cax.set_yticklabels([f'{mdates.num2date(i):%b %-d}' for i in levels],fontsize=11.5)

    return ax,fig

class TrackPlot(Plot):
    
    def __init__(self):
        
        self.use_credit = True
    
    def plot_storms(self,storms,domain="dynamic",title="TC Track Composite",plot_all_dots=False,track_labels=False,ax=None,save_path=None,prop={},map_prop={}):
        
        r"""
        Creates a plot of a single or multiple storm tracks.
        
        Parameters
        ----------
        storms : list
            List of requested storms. List can contain either strings of storm ID (e.g., "AL052019"), tuples with storm name and year (e.g., ("Matthew",2016)), or dict entries.
        domain : str
            Domain for the plot. Can be one of the following:
            "dynamic" - default. Dynamically focuses the domain using the storm track(s) plotted.
            "north_atlantic" - North Atlantic Ocean basin
            "pacific" - East/Central Pacific Ocean basin
            "lonW/lonE/latS/latN" - Custom plot domain
        plot_all_dots : bool
            Whether to plot dots for all observations along the track. If false, dots will be plotted every 6 hours. Default is false.
        ax : axes
            Instance of axes to plot on. If none, one will be generated. Default is none.
        prop : dict
            Property of storm track lines.
        map_prop : dict
            Property of cartopy map.
        """
        
        #Set default properties
        default_prop={'dots':True,'fillcolor':'category','cmap':None,'levels':None,'linecolor':'k','linewidth':1.0,'ms':7.5,'plot_names':False}
        default_map_prop={'res':'m','land_color':'#FBF5EA','ocean_color':'#EDFBFF','linewidth':0.5,'linecolor':'k','figsize':(14,9),'dpi':200,'plot_gridlines':True}
        
        #Initialize plot
        prop = self.add_prop(prop,default_prop)
        map_prop = self.add_prop(map_prop,default_map_prop)
        self.plot_init(ax,map_prop)
        
        #--------------------------------------------------------------------------------------
        
        #Keep record of lat/lon coordinate extrema
        max_lat = None
        min_lat = None
        max_lon = None
        min_lon = None
        
        #Iterate through all storms provided
        for storm in storms:

            #Check for storm type, then get data for storm
            if isinstance(storm, str) == True:
                storm_data = self.data[storm]
            elif isinstance(storm, tuple) == True:
                storm = self.get_storm_id(storm[0],storm[1])
                storm_data = self.data[storm]
            elif isinstance(storm, dict) == True:
                storm_data = storm
            else:
                raise RuntimeError("Error: Storm must be a string (e.g., 'AL052019'), tuple (e.g., ('Matthew',2016)), or dict.")

            #Retrieve storm data
            lats = storm_data['lat']
            lons = storm_data['lon']
            vmax = storm_data['vmax']
            styp = storm_data['type']
            sdate = storm_data['date']

            #Account for cases crossing dateline
            if self.proj.proj4_params['lon_0'] == 180.0:
                new_lons = np.array(lons)
                new_lons[new_lons<0] = new_lons[new_lons<0]+360.0
                lons = new_lons.tolist()
            
            #Force dynamic_tropical to tropical if an invest
            invest_bool = False
            if 'invest' in storm_data.keys() and storm_data['invest'] == True:
                invest_bool = True
                if domain == 'dynamic_tropical': domain = 'dynamic'

            #Add to coordinate extrema
            if domain == 'dynamic_tropical':
                type_array = np.array(storm_data['type'])
                idx = np.where((type_array == 'SD') | (type_array == 'SS') | (type_array == 'TD') | (type_array == 'TS') | (type_array == 'HU'))
                use_lats = (np.array(storm_data['lat'])[idx]).tolist()
                use_lons = (np.array(lons)[idx]).tolist()
            else:
                use_lats = storm_data['lat']
                use_lons = np.copy(lons).tolist()

            #Add to coordinate extrema
            if max_lat is None:
                max_lat = max(use_lats)
            else:
                if max(use_lats) > max_lat: max_lat = max(use_lats)
            if min_lat is None:
                min_lat = min(use_lats)
            else:
                if min(use_lats) < min_lat: min_lat = min(use_lats)
            if max_lon is None:
                max_lon = max(use_lons)
            else:
                if max(use_lons) > max_lon: max_lon = max(use_lons)
            if min_lon is None:
                min_lon = min(use_lons)
            else:
                if min(use_lons) < min_lon: min_lon = min(use_lons)

            #Add storm label at start and end points
            if prop['plot_names'] == True:
                self.ax.text(lons[0]+0.0,storm_data['lat'][0]+1.0,f"{storm_data['name'].upper()} {storm_data['year']}",
                             fontsize=9,clip_on=True,zorder=1000,alpha=0.7,ha='center',va='center',transform=ccrs.PlateCarree())
                self.ax.text(lons[-1]+0.0,storm_data['lat'][-1]+1.0,f"{storm_data['name'].upper()} {storm_data['year']}",
                             fontsize=9,clip_on=True,zorder=1000,alpha=0.7,ha='center',va='center',transform=ccrs.PlateCarree())
            
            #Iterate over storm data to plot
            levels = None
            cmap = None
            for i,(i_lat,i_lon,i_vmax,i_mslp,i_date,i_type) in enumerate(zip(storm_data['lat'],lons,storm_data['vmax'],storm_data['mslp'],storm_data['date'],storm_data['type'])):

                #Determine line color, with SSHWS scale used as default
                if prop['linecolor'] == 'category':
                    segmented_colors = True
                    line_color = get_colors_sshws(np.nan_to_num(i_vmax))

                #Use user-defined colormap if another storm variable
                elif isinstance(prop['linecolor'],str) == True and prop['linecolor'] in ['vmax','mslp','dvmax_dt','speed']:
                    segmented_colors = True
                    try:
                        color_variable = storm_data[prop['linecolor']]
                    except:
                        raise ValueError("Storm object must be interpolated to hourly using 'storm.interp().plot(...)' in order to use 'dvmax_dt' or 'speed' for fill color")
                    if prop['levels'] is None: #Auto-determine color levels if needed
                        prop['levels'] = [np.nanmin(color_variable),np.nanmax(color_variable)]
                    cmap,levels = get_cmap_levels(prop['linecolor'],prop['cmap'],prop['levels'])
                    line_color = cmap((color_variable-min(levels))/(max(levels)-min(levels)))[i]

                #Otherwise go with user input as is
                else:
                    segmented_colors = False
                    line_color = prop['linecolor']

                #For tropical/subtropical types, color-code if requested
                if i > 0:
                    if i_type in constants.TROPICAL_STORM_TYPES and storm_data['type'][i-1] in constants.TROPICAL_STORM_TYPES:

                        #Plot underlying black and overlying colored line
                        self.ax.plot([lons[i-1],lons[i]],[storm_data['lat'][i-1],storm_data['lat'][i]],'-',
                                      linewidth=prop['linewidth']*1.33,color='k',zorder=3,
                                      transform=ccrs.PlateCarree())
                        self.ax.plot([lons[i-1],lons[i]],[storm_data['lat'][i-1],storm_data['lat'][i]],'-',
                                      linewidth=prop['linewidth'],color=line_color,zorder=4,
                                      transform=ccrs.PlateCarree())

                    #For non-tropical types, plot dotted lines
                    else:

                        #Restrict line width to 1.5 max
                        line_width = prop['linewidth'] + 0.0
                        if line_width > 1.5: line_width = 1.5

                        #Plot dotted line
                        self.ax.plot([lons[i-1],lons[i]],[storm_data['lat'][i-1],storm_data['lat'][i]],':',
                                      linewidth=line_width,color=line_color,zorder=4,
                                      transform=ccrs.PlateCarree(),
                                      path_effects=[path_effects.Stroke(linewidth=line_width*1.33, foreground='k'),
                                                    path_effects.Normal()])

                #Plot dots if requested
                if prop['dots'] == True:
                    if plot_all_dots == False and i_date.strftime('%H%M') not in constants.STANDARD_HOURS: continue
                    self.ax,segmented_colors,extra = plot_dot(self.ax,i_lon,i_lat,i_date,i_vmax,i_type,
                                                              zorder=5,storm_data=storm_data,prop=prop,i=i)
                    if 'cmap' in extra.keys(): cmap = extra['cmap']
                    if 'levels' in extra.keys(): levels = extra['levels']
                
                #Label track dots
                if track_labels in ['valid_utc']:
                    if track_labels == 'valid_utc':
                        strformat = '%H UTC \n%-m/%-d'
                        labels = {t.strftime(strformat):(x,y) for t,x,y in zip(sdate,lons,lats) if t.hour==0}
                        track = {t.strftime(strformat):(x,y) for t,x,y in zip(sdate,lons,lats)}
                    self.plot_track_labels(self.ax, labels, track, k=.9)

        #--------------------------------------------------------------------------------------
        
        #Storm-centered plot domain
        if domain == "dynamic" or domain == "dynamic_tropical":
            
            bound_w,bound_e,bound_s,bound_n = self.dynamic_map_extent(min_lon,max_lon,min_lat,max_lat)
            self.ax.set_extent([bound_w,bound_e,bound_s,bound_n], crs=ccrs.PlateCarree())
            
        #Pre-generated or custom domain
        else:
            bound_w,bound_e,bound_s,bound_n = self.set_projection(domain)
        
        #Plot parallels and meridians
        #This is currently not supported for all cartopy projections.
        if map_prop['plot_gridlines']:
            try:
                self.plot_lat_lon_lines([bound_w,bound_e,bound_s,bound_n])
            except:
                pass
        
        #--------------------------------------------------------------------------------------
        
        #Add left title
        if len(storms) > 1:
            if title != "": self.ax.set_title(f"{title}",loc='left',fontsize=17,fontweight='bold')
        else:
            #Add left title
            type_array = np.array(storm_data['type'])
            idx = np.where((type_array == 'SD') | (type_array == 'SS') | (type_array == 'TD') | (type_array == 'TS') | (type_array == 'HU'))
            
            #Check if storm is an invest with a leading 9
            add_ptc_flag = False
            check_invest = False
            if len(storm_data['id']) > 4 and str(storm_data['id'][2]) == "9":
                check_invest = True
            
            if len(storm_data['id']) == 0 and len(idx[0]) == 0:
                idx = np.array([True for i in type_array])
                tropical_vmax = np.array(storm_data['vmax'])
                self.ax.set_title(f"Cyclone {storm_data['name']}",loc='left',fontsize=17,fontweight='bold')
            elif check_invest == False and (invest_bool == False or len(idx[0]) > 0):
                tropical_vmax = np.array(storm_data['vmax'])[idx]

                #Coerce to include non-TC points if storm hasn't been designated yet
                if len(tropical_vmax) == 0 and len(storm_data['id']) > 4:
                    add_ptc_flag = True
                    idx = np.where((type_array == 'LO') | (type_array == 'DB'))
                    tropical_vmax = np.array(storm_data['vmax'])[idx]

                subtrop = classify_subtropical(np.array(storm_data['type']))
                peak_idx = storm_data['vmax'].index(np.nanmax(tropical_vmax))
                peak_basin = storm_data['wmo_basin'][peak_idx]
                storm_type = get_storm_classification(np.nanmax(tropical_vmax),subtrop,peak_basin)
                if add_ptc_flag == True: storm_type = "Potential Tropical Cyclone"
                self.ax.set_title(f"{storm_type} {storm_data['name']}",loc='left',fontsize=17,fontweight='bold')
            else:
                #Use all indices for invests
                idx = np.array([True for i in type_array])
                tropical_vmax = np.array(storm_data['vmax'])

                #Determine letter in front of invest
                add_letter = 'L'
                if storm_data['id'][0] == 'C':
                    add_letter = 'C'
                elif storm_data['id'][0] == 'E':
                    add_letter = 'E'
                elif storm_data['id'][0] == 'W':
                    add_letter = 'W'
                elif storm_data['id'][0] == 'I':
                    add_letter = 'I'
                elif storm_data['id'][0] == 'S':
                    add_letter = 'S'

                #Add title
                self.ax.set_title(f"INVEST {storm_data['id'][2:4]}{add_letter}",loc='left',fontsize=17,fontweight='bold')

            #Add right title
            ace = storm_data['ace']
            if add_ptc_flag == True: ace = 0.0
            type_array = np.array(storm_data['type'])
            
            #Get storm extrema for display
            mslp_key = 'mslp' if 'wmo_mslp' not in storm_data.keys() else 'wmo_mslp'
            if all_nan(np.array(storm_data[mslp_key])[idx]) == True:
                min_pres = "N/A"
            else:
                min_pres = int(np.nan_to_num(np.nanmin(np.array(storm_data[mslp_key])[idx])))
            if all_nan(np.array(storm_data['vmax'])[idx]) == True:
                max_wind = "N/A"
            else:
                max_wind = int(np.nan_to_num(np.nanmax(np.array(storm_data['vmax'])[idx])))
            start_date = dt.strftime(np.array(storm_data['date'])[idx][0],'%d %b %Y')
            end_date = dt.strftime(np.array(storm_data['date'])[idx][-1],'%d %b %Y')
            endash = u"\u2013"
            dot = u"\u2022"
            self.ax.set_title(f"{start_date} {endash} {end_date}\n{max_wind} kt {dot} {min_pres} hPa {dot} {ace:.1f} ACE",loc='right',fontsize=13)

        #--------------------------------------------------------------------------------------
        
        #Add plot credit
        warning_text=""
        if storm_data['source'] == 'ibtracs' and storm_data['source_info'] == 'World Meteorological Organization (official)':
            warning_text = f"This plot uses 10-minute averaged WMO official wind data converted\nto 1-minute average (factor of 0.88). Use this wind data with caution.\n\n"

            self.ax.text(0.99,0.01,warning_text,fontsize=9,color='k',alpha=0.7,
            transform=self.ax.transAxes,ha='right',va='bottom',zorder=10)
        
        credit_text = self.plot_credit()
        self.add_credit(credit_text)
        
        #--------------------------------------------------------------------------------------
                
        #Add legend
        self.ax,self.fig = add_legend(self.ax,self.fig,prop,segmented_colors,levels,cmap,storm_data)
                
        #-----------------------------------------------------------------------------------------
        
        #Save image if specified
        if save_path is not None and isinstance(save_path,str) == True:
            plt.savefig(save_path,bbox_inches='tight')
        
        #Return axis if specified, otherwise display figure
        return self.ax
        
    def plot_storm_nhc(self,forecast,track=None,track_labels='fhr',cone_days=5,domain="dynamic_forecast",ax=None,save_path=
None,prop={},map_prop={}):
        
        r"""
        Creates a plot of the operational NHC forecast track along with observed track data.
        
        Parameters
        ----------
        forecast : dict
            Dict entry containing forecast data.
        track : dict
            Dict entry containing observed track data. Default is none.
        track_labels : str
            Label forecast hours with the following methods:
            '' = no label
            'fhr' = forecast hour
            'valid_utc' = UTC valid time
            'valid_edt' = EDT valid time
        cone_days : int
            Number of days to plot the forecast cone. Default is 5 days. Can select 2, 3, 4 or 5 days.
        domain : str
            Domain for the plot. Can be one of the following:
            "dynamic_forecast" - default. Dynamically focuses the domain on the forecast track.
            "dynamic" - Dynamically focuses the domain on the combined observed and forecast track.
            "lonW/lonE/latS/latN" - Custom plot domain
        ax : axes
            Instance of axes to plot on. If none, one will be generated. Default is none.
        prop : dict
            Property of storm track lines.
        map_prop : dict
            Property of cartopy map.
        """
        
        #Set default properties
        default_prop={'dots':True,'fillcolor':'category','linecolor':'k','linewidth':1.0,'ms':7.5,'cone_lw':1.0,'cone_alpha':0.6}
        default_map_prop={'res':'m','land_color':'#FBF5EA','ocean_color':'#EDFBFF','linewidth':0.5,'linecolor':'k','figsize':(14,9),'dpi':200,'plot_gridlines':True}
        
        #Initialize plot
        prop = self.add_prop(prop,default_prop)
        map_prop = self.add_prop(map_prop,default_map_prop)
        self.plot_init(ax,map_prop)
        
        #--------------------------------------------------------------------------------------
        
        #Keep record of lat/lon coordinate extrema
        max_lat = None
        min_lat = None
        max_lon = None
        min_lon = None
        
        #Add storm or multiple storms
        if track != "":
            
            #Check for storm type, then get data for storm
            if isinstance(track, dict) == True:
                storm_data = track
            else:
                raise RuntimeError("Error: track must be of type dict.")
                
            #Retrieve storm data
            lats = storm_data['lat']
            lons = storm_data['lon']
            vmax = storm_data['vmax']
            styp = storm_data['type']
            sdate = storm_data['date']
            
            #Check if there's enough data points to plot
            matching_times = [i for i in sdate if i <= forecast['init']]
            check_length = len(matching_times)
            if check_length >= 2:

                #Subset until time of forecast
                matching_times = [i for i in sdate if i <= forecast['init']]
                plot_idx = sdate.index(matching_times[-1])+1
                lats = storm_data['lat'][:plot_idx]
                lons = storm_data['lon'][:plot_idx]
                vmax = storm_data['vmax'][:plot_idx]
                styp = storm_data['type'][:plot_idx]
                sdate = storm_data['date'][:plot_idx]

                #Account for cases crossing dateline
                if self.proj.proj4_params['lon_0'] == 180.0:
                    new_lons = np.array(lons)
                    new_lons[new_lons<0] = new_lons[new_lons<0]+360.0
                    lons = new_lons.tolist()
                
                #Connect to 1st forecast location
                fcst_hr = np.array(forecast['fhr'])
                start_slice = 0
                if 3 in fcst_hr: start_slice = 3
                iter_hr = np.array(forecast['fhr'])[fcst_hr>=start_slice][0]
                fcst_lon = np.array(forecast['lon'])[fcst_hr>=start_slice][0]
                fcst_lat = np.array(forecast['lat'])[fcst_hr>=start_slice][0]
                fcst_type = np.array(forecast['type'])[fcst_hr>=start_slice][0]
                fcst_vmax = np.array(forecast['vmax'])[fcst_hr>=start_slice][0]
                if fcst_type == "": fcst_type = get_storm_type(fcst_vmax,False)
                if self.proj.proj4_params['lon_0'] == 180.0:
                    if fcst_lon < 0: fcst_lon = fcst_lon + 360.0
                lons.append(fcst_lon)
                lats.append(fcst_lat)
                vmax.append(fcst_vmax)
                styp.append(fcst_type)
                sdate.append(sdate[-1]+timedelta(hours=start_slice))

                #Add to coordinate extrema
                if domain != "dynamic_forecast":
                    if max_lat is None:
                        max_lat = max(lats)
                    else:
                        if max(lats) > max_lat: max_lat = max(lats)
                    if min_lat is None:
                        min_lat = min(lats)
                    else:
                        if min(lats) < min_lat: min_lat = min(lats)
                    if max_lon is None:
                        max_lon = max(lons)
                    else:
                        if max(lons) > max_lon: max_lon = max(lons)
                    if min_lon is None:
                        min_lon = min(lons)
                    else:
                        if min(lons) < min_lon: min_lon = min(lons)
                else:
                    max_lat = lats[-1]+0.2
                    min_lat = lats[-2]-0.2
                    max_lon = lons[-1]+0.2
                    min_lon = lons[-2]-0.2

                #Plot storm line as specified
                if prop['linecolor'] == 'category':
                    type6 = np.array(styp)
                    for i in (np.arange(len(lats[1:]))+1):
                        ltype = 'solid'
                        if type6[i] not in constants.TROPICAL_STORM_TYPES: ltype = 'dotted'
                        self.ax.plot([lons[i-1],lons[i]],[lats[i-1],lats[i]],
                                      '-',color=get_colors_sshws(np.nan_to_num(vmax[i])),linewidth=prop['linewidth'],linestyle=ltype,
                                      transform=ccrs.PlateCarree(),
                                      path_effects=[path_effects.Stroke(linewidth=prop['linewidth']*1.25, foreground='k'), path_effects.Normal()])
                else:
                    self.ax.plot(lons,lats,'-',color=prop['linecolor'],linewidth=prop['linewidth'],transform=ccrs.PlateCarree())

                #Plot storm dots as specified
                if prop['dots'] == True:
                    for i,(ilon,ilat,iwnd,itype) in enumerate(zip(lons,lats,vmax,styp)):
                        mtype = '^'
                        if itype in constants.SUBTROPICAL_ONLY_STORM_TYPES:
                            mtype = 's'
                        elif itype in constants.TROPICAL_ONLY_STORM_TYPES:
                            mtype = 'o'
                        if prop['fillcolor'] == 'category':
                            ncol = get_colors_sshws(np.nan_to_num(iwnd))
                        else:
                            ncol = 'k'
                        self.ax.plot(ilon,ilat,mtype,color=ncol,mec='k',mew=0.5,ms=prop['ms'],transform=ccrs.PlateCarree())

        #--------------------------------------------------------------------------------------

        #Error check cone days
        if isinstance(cone_days,int) == False:
            raise TypeError("Error: cone_days must be of type int")
        if cone_days not in [5,4,3,2]:
            raise ValueError("Error: cone_days must be an int between 2 and 5.")
        
        #Error check forecast dict
        if isinstance(forecast, dict) == False:
            raise RuntimeError("Error: Forecast must be of type dict")
            
        #Determine first forecast index
        fcst_hr = np.array(forecast['fhr'])
        start_slice = 0
        if 3 in fcst_hr: start_slice = 3
        check_duration = fcst_hr[(fcst_hr>=start_slice) & (fcst_hr<=cone_days*24)]

        #Check for sufficiently many hours
        if len(check_duration) > 1:

            #Generate forecast cone for forecast data
            dateline = False
            if self.proj.proj4_params['lon_0'] == 180.0: dateline = True
            cone = generate_nhc_cone(forecast,forecast['basin'],dateline,cone_days)

            #Contour fill cone & account for dateline crossing
            if 'cone' in forecast.keys() and forecast['cone'] == False:
                pass
            else:
                cone_lon = cone['lon']
                cone_lat = cone['lat']
                cone_lon_2d = cone['lon2d']
                cone_lat_2d = cone['lat2d']
                if self.proj.proj4_params['lon_0'] == 180.0:
                    new_lons = np.array(cone_lon_2d)
                    new_lons[new_lons<0] = new_lons[new_lons<0]+360.0
                    cone_lon_2d = new_lons.tolist()
                    new_lons = np.array(cone_lon)
                    new_lons[new_lons<0] = new_lons[new_lons<0]+360.0
                    cone_lon = new_lons.tolist() 
                cone_2d = cone['cone']
                cone_2d = ndimage.gaussian_filter(cone_2d,sigma=0.5,order=0)
                self.ax.contourf(cone_lon_2d,cone_lat_2d,cone_2d,[0.9,1.1],colors=['#ffffff','#ffffff'],alpha=prop['cone_alpha'],zorder=2,transform=ccrs.PlateCarree())
                self.ax.contour(cone_lon_2d,cone_lat_2d,cone_2d,[0.9],linewidths=prop['cone_lw'],colors=['k'],zorder=3,transform=ccrs.PlateCarree())

            #Plot center line & account for dateline crossing
            center_lon = cone['center_lon']
            center_lat = cone['center_lat']
            if self.proj.proj4_params['lon_0'] == 180.0:
                new_lons = np.array(center_lon)
                new_lons[new_lons<0] = new_lons[new_lons<0]+360.0
                center_lon = new_lons.tolist()
            self.ax.plot(center_lon,center_lat,color='k',linewidth=2.0,zorder=4,transform=ccrs.PlateCarree())

            #Retrieve forecast dots
            iter_hr = np.array(forecast['fhr'])[(fcst_hr>=start_slice) & (fcst_hr<=cone_days*24)]
            fcst_lon = np.array(forecast['lon'])[(fcst_hr>=start_slice) & (fcst_hr<=cone_days*24)]
            fcst_lat = np.array(forecast['lat'])[(fcst_hr>=start_slice) & (fcst_hr<=cone_days*24)]
            fcst_type = np.array(forecast['type'])[(fcst_hr>=start_slice) & (fcst_hr<=cone_days*24)]
            fcst_vmax = np.array(forecast['vmax'])[(fcst_hr>=start_slice) & (fcst_hr<=cone_days*24)]
            
            #Account for cases crossing dateline
            if self.proj.proj4_params['lon_0'] == 180.0:
                new_lons = np.array(fcst_lon)
                new_lons[new_lons<0] = new_lons[new_lons<0]+360.0
                fcst_lon = new_lons.tolist()

            #Plot forecast dots
            for i,(ilon,ilat,itype,iwnd,ihr) in enumerate(zip(fcst_lon,fcst_lat,fcst_type,fcst_vmax,iter_hr)):
                mtype = '^'
                if itype in constants.SUBTROPICAL_ONLY_STORM_TYPES:
                    mtype = 's'
                elif itype in ['TD','TS','HU','']:
                    mtype = 'o'
                if prop['fillcolor'] == 'category':
                    ncol = get_colors_sshws(np.nan_to_num(iwnd))
                else:
                    ncol = 'k'
                #Marker width
                mew = 0.5; use_zorder=5
                if i == 0:
                    mew = 2.0; use_zorder=10
                self.ax.plot(ilon,ilat,mtype,color=ncol,mec='k',mew=mew,ms=prop['ms']*1.3,transform=ccrs.PlateCarree(),zorder=use_zorder)

            #Label forecast dots
            if track_labels in ['fhr','valid_utc','valid_edt','fhr_wind_kt','fhr_wind_mph']:
                valid_dates = [forecast['init']+timedelta(hours=int(i)) for i in iter_hr]
                if track_labels == 'fhr':
                    labels = [str(i) for i in iter_hr]
                if track_labels == 'fhr_wind_kt':
                    labels = [f"Hour {iter_hr[i]}\n{fcst_vmax[i]} kt" for i in range(len(iter_hr))]
                if track_labels == 'fhr_wind_mph':
                    labels = [f"Hour {iter_hr[i]}\n{knots_to_mph(fcst_vmax[i])} mph" for i in range(len(iter_hr))]
                if track_labels == 'valid_edt':
                    labels = [str(int(i.strftime('%I'))) + ' ' + i.strftime('%p %a') for i in [j-timedelta(hours=4) for j in valid_dates]]
                    edt_warning = True
                if track_labels == 'valid_utc':
                    labels = [f"{i.strftime('%H UTC')}\n{str(i.month)}/{str(i.day)}" for i in valid_dates]
                self.plot_nhc_labels(self.ax, fcst_lon, fcst_lat, labels, k=1.2)
                
            #Add cone coordinates to coordinate extrema
            if 'cone' in forecast.keys() and forecast['cone'] == False:
                if domain == "dynamic_forecast" or max_lat is None:
                    max_lat = max(center_lat)
                    min_lat = min(center_lat)
                    max_lon = max(center_lon)
                    min_lon = min(center_lon)
                else:
                    if max(center_lat) > max_lat: max_lat = max(center_lat)
                    if min(center_lat) < min_lat: min_lat = min(center_lat)
                    if max(center_lon) > max_lon: max_lon = max(center_lon)
                    if min(center_lon) < min_lon: min_lon = min(center_lon)
            else:
                if domain == "dynamic_forecast" or max_lat is None:
                    max_lat = max(cone_lat)
                    min_lat = min(cone_lat)
                    max_lon = max(cone_lon)
                    min_lon = min(cone_lon)
                else:
                    if max(cone_lat) > max_lat: max_lat = max(cone_lat)
                    if min(cone_lat) < min_lat: min_lat = min(cone_lat)
                    if max(cone_lon) > max_lon: max_lon = max(cone_lon)
                    if min(cone_lon) < min_lon: min_lon = min(cone_lon)

        #--------------------------------------------------------------------------------------

        #Storm-centered plot domain
        if domain == "dynamic" or domain == 'dynamic_forecast':
            
            bound_w,bound_e,bound_s,bound_n = self.dynamic_map_extent(min_lon,max_lon,min_lat,max_lat)
            self.ax.set_extent([bound_w,bound_e,bound_s,bound_n], crs=ccrs.PlateCarree())
            
        #Pre-generated or custom domain
        else:
            bound_w,bound_e,bound_s,bound_n = self.set_projection(domain)
        
        #Plot parallels and meridians
        #This is currently not supported for all cartopy projections.
        if map_prop['plot_gridlines']:
            try:
                self.plot_lat_lon_lines([bound_w,bound_e,bound_s,bound_n])
            except:
                pass
        
        #--------------------------------------------------------------------------------------
        
        #Identify storm type (subtropical, hurricane, etc)
        first_fcst_wind = np.array(forecast['vmax'])[fcst_hr >= start_slice][0]
        first_fcst_mslp = np.array(forecast['mslp'])[fcst_hr >= start_slice][0]
        first_fcst_type = np.array(forecast['type'])[fcst_hr >= start_slice][0]
        if all_nan(first_fcst_wind) == True:
            storm_type = 'Unknown'
        else:
            subtrop = first_fcst_type in constants.SUBTROPICAL_ONLY_STORM_TYPES
            cur_wind = first_fcst_wind + 0
            storm_type = get_storm_classification(np.nan_to_num(cur_wind),subtrop,'north_atlantic')
        
        #Identify storm name (and storm type, if post-tropical or potential TC)
        matching_times = [i for i in storm_data['date'] if i <= forecast['init']]
        if check_length < 2:
            if all_nan(first_fcst_wind) == True:
                storm_name = storm_data['name']
            else:
                storm_name = num_to_text(int(storm_data['id'][2:4])).upper()
                if first_fcst_wind >= 34 and first_fcst_type in constants.TROPICAL_STORM_TYPES: storm_name = storm_data['name'];
                if first_fcst_type not in constants.TROPICAL_STORM_TYPES: storm_type = 'Potential Tropical Cyclone'
        else:
            storm_name = num_to_text(int(storm_data['id'][2:4])).upper()
            storm_type = 'Potential Tropical Cyclone'
            storm_tropical = False
            if all_nan(vmax) == True:
                storm_type = 'Unknown'
                storm_name = storm_data['name']
            else:
                for i,(iwnd,ityp) in enumerate(zip(vmax,styp)):
                    if ityp in constants.TROPICAL_STORM_TYPES:
                        storm_tropical = True
                        subtrop = ityp in constants.SUBTROPICAL_ONLY_STORM_TYPES
                        storm_type = get_storm_classification(np.nan_to_num(iwnd),subtrop,'north_atlantic')
                        if np.isnan(iwnd) == True: storm_type = 'Unknown'
                    else:
                        if storm_tropical == True: storm_type = 'Post Tropical Cyclone'
                    if ityp in constants.NAMED_TROPICAL_STORM_TYPES:
                        storm_name = storm_data['name']
        
        #Fix storm types for non-NHC basins
        if 'cone' in forecast.keys():
            storm_type = get_storm_classification(first_fcst_wind,False,forecast['basin'])
        
        #Add left title
        self.ax.set_title(f"{storm_type} {storm_name}",loc='left',fontsize=17,fontweight='bold')

        endash = u"\u2013"
        dot = u"\u2022"
        
        #Get current advisory information
        first_fcst_wind = "N/A" if np.isnan(first_fcst_wind) == True else int(first_fcst_wind)
        first_fcst_mslp = "N/A" if np.isnan(first_fcst_mslp) == True else int(first_fcst_mslp)
        
        #Get time of advisory
        fcst_hr = forecast['fhr']
        start_slice = 0
        if 3 in fcst_hr: start_slice = 1
        forecast_date = (forecast['init']+timedelta(hours=fcst_hr[start_slice])).strftime("%H%M UTC %d %b %Y")
        forecast_id = forecast['advisory_num']
        
        if forecast_id == -1:
            title_text = f"Current Intensity: {knots_to_mph(first_fcst_wind)} mph {dot} {first_fcst_mslp} hPa"
            if 'cone' in forecast.keys() and forecast['cone'] == False:
                title_text += f"\nJTWC Issued: {forecast_date}"
            else:
                title_text += f"\nNHC Issued: {forecast_date}"
        else:
            if first_fcst_wind != "N/A": first_fcst_wind = knots_to_mph(first_fcst_wind)
            title_text = f"{first_fcst_wind} mph {dot} {first_fcst_mslp} hPa {dot} Forecast #{forecast_id}"
            title_text += f"\nForecast Issued: {forecast_date}"
        
        
        #Add right title
        self.ax.set_title(title_text,loc='right',fontsize=13)

        #--------------------------------------------------------------------------------------
        
        #Add legend
        if prop['fillcolor'] == 'category' or prop['linecolor'] == 'category':
            
            ex = mlines.Line2D([], [], linestyle='None', ms=prop['ms'], mec='k',mew=0.5, label='Non-Tropical', marker='^', color='w')
            sb = mlines.Line2D([], [], linestyle='None', ms=prop['ms'], mec='k',mew=0.5, label='Subtropical', marker='s', color='w')
            uk = mlines.Line2D([], [], linestyle='None', ms=prop['ms'], mec='k',mew=0.5, label='Unknown', marker='o', color='w')
            td = mlines.Line2D([], [], linestyle='None', ms=prop['ms'], mec='k',mew=0.5, label='Tropical Depression', marker='o', color=get_colors_sshws(33))
            ts = mlines.Line2D([], [], linestyle='None', ms=prop['ms'], mec='k',mew=0.5, label='Tropical Storm', marker='o', color=get_colors_sshws(34))
            c1 = mlines.Line2D([], [], linestyle='None', ms=prop['ms'], mec='k',mew=0.5, label='Category 1', marker='o', color=get_colors_sshws(64))
            c2 = mlines.Line2D([], [], linestyle='None', ms=prop['ms'], mec='k',mew=0.5, label='Category 2', marker='o', color=get_colors_sshws(83))
            c3 = mlines.Line2D([], [], linestyle='None', ms=prop['ms'], mec='k',mew=0.5, label='Category 3', marker='o', color=get_colors_sshws(96))
            c4 = mlines.Line2D([], [], linestyle='None', ms=prop['ms'], mec='k',mew=0.5, label='Category 4', marker='o', color=get_colors_sshws(113))
            c5 = mlines.Line2D([], [], linestyle='None', ms=prop['ms'], mec='k',mew=0.5, label='Category 5', marker='o', color=get_colors_sshws(137))
            self.ax.legend(handles=[ex,sb,uk,td,ts,c1,c2,c3,c4,c5], prop={'size':11.5})

        #Add forecast label warning
        try:
            if edt_warning == True:
                warning_text = "All times displayed are in EDT\n\n"
            else:
                warning_text = ""
        except:
            warning_text = ""
        try:
            warning_text += f"The cone of uncertainty in this product was generated internally using {cone['year']} official\nNHC cone radii. This cone differs slightly from the official NHC cone.\n\n"
        except:
            pass
        
        self.ax.text(0.99,0.01,warning_text,fontsize=9,color='k',alpha=0.7,
                transform=self.ax.transAxes,ha='right',va='bottom',zorder=10)
        
        credit_text = self.plot_credit()
        self.add_credit(credit_text)
        
        #Save image if specified
        if save_path is not None and isinstance(save_path,str) == True:
            plt.savefig(os.path.join(save_path,f"{storm_data['name']}_{storm_data['year']}_track.png"),bbox_inches='tight')
        
        #Return axis if specified, otherwise display figure
        return self.ax

    def plot_models(self,forecast,plot_btk,storm_dict,forecast_dict,models,domain,ax,prop,map_prop,save_path):
        
        r"""
        Plot multi-model forecast tracks.
        """
        
         #Set default properties
        default_map_prop={'res':'m','land_color':'#FBF5EA','ocean_color':'#EDFBFF','linewidth':0.5,'linecolor':'k','figsize':(14,9),'dpi':200,'plot_gridlines':True}
        default_model = {'nhc':'k',
                         'gfs':'#0000ff',
                         'ecm':'#ff1493',
                         'cmc':'#1e90ff',
                         'ukm':'#00ff00',
                         'hmon':'#ff8c00',
                         'hwrf':'#66cdaa'}
        default_prop = {'linewidth':2.5,'marker':'label','marker_hours':[24,48,72,96,120,144,168]}
        
        #Initialize plot
        prop = self.add_prop(prop,default_prop)
        map_prop = self.add_prop(map_prop,default_map_prop)
        model_prop = self.add_prop(models,default_model)
        self.plot_init(ax,map_prop)
        
        #Fix GFDL
        if 'gfdl' in forecast_dict.keys():
            model_prop['gfdl'] = model_prop['hmon']
        if 'jtwc' in forecast_dict.keys():
            model_prop['jtwc'] = model_prop['nhc']
        
        #================================================================================================
        
        #Keep record of lat/lon coordinate extrema
        lat_max_extrema = []
        lat_min_extrema = []
        lon_max_extrema = []
        lon_min_extrema = []
        
        #================================================================================================
        
        #Plot models
        for model in forecast_dict.keys():
            
            #Plot forecast track
            lons = forecast_dict[model]['lon']
            lats = forecast_dict[model]['lat']
            self.ax.plot(lons,lats,color=model_prop[model],linewidth=prop['linewidth'],label=model.upper(),transform=ccrs.PlateCarree())
            
            #Add labels if requested
            if prop['marker'] != None and len(prop['marker_hours']) >= 1:
                for hour in prop['marker_hours']:
                    if hour not in forecast_dict[model]['fhr']: continue
                    idx = forecast_dict[model]['fhr'].index(hour)
                    if prop['marker'] == 'label':
                        self.ax.text(lons[idx],lats[idx],str(hour),ha='center',va='center',zorder=100,transform=ccrs.PlateCarree())
                    elif prop['marker'] == 'dot':
                        self.ax.plot(lons[idx],lats[idx],'o',ms=prop['linewidth']*3,zorder=100,
                                     mfc=model_prop[model],mec='k',transform=ccrs.PlateCarree())
                    else:
                        raise ValueError("Acceptable values for 'marker' prop are 'label' or 'dot'.")
            
            #Add to lat/lon extrema
            lat_max_extrema.append(np.nanmax(lats))
            lat_min_extrema.append(np.nanmin(lats))
            lon_max_extrema.append(np.nanmax(lons))
            lon_min_extrema.append(np.nanmin(lons))
        
        #Plot best track if requested
        if plot_btk:
            
            #Account for cases crossing dateline
            if self.proj.proj4_params['lon_0'] == 180.0:
                new_lons = np.array(storm_dict['lon'])
                new_lons[new_lons<0] = new_lons[new_lons<0]+360.0
                use_lons = new_lons.tolist()
            else:
                use_lons = storm_dict['lon']
            
            #Determine maximum forecast hour
            max_fhr = max([max(forecast_dict[model]['fhr']) for model in forecast_dict.keys()])
            
            #Determine range of forecast data in best track
            idx_start = storm_dict['date'].index(forecast)
            end_date = forecast + timedelta(hours=max_fhr)
            if end_date in storm_dict['date']:
                idx_end = storm_dict['date'].index(end_date)
            else:
                idx_end = len(storm_dict['date'])
            
            #Plot best track
            lons = use_lons[idx_start:idx_end+1]
            lats = storm_dict['lat'][idx_start:idx_end+1]
            storm_dates = storm_dict['date'][idx_start:idx_end+1]
            self.ax.plot(lons,lats,':',color='k',linewidth=prop['linewidth']*0.8,label='Best Track',transform=ccrs.PlateCarree())
            
            #Add to lat/lon extrema
            lat_max_extrema.append(np.nanmax(lats))
            lat_min_extrema.append(np.nanmin(lats))
            lon_max_extrema.append(np.nanmax(lons))
            lon_min_extrema.append(np.nanmin(lons))
            
            #Add labels if requested
            if prop['marker'] != None and len(prop['marker_hours']) >= 1:
                for hour in prop['marker_hours']:
                    valid_date = forecast + timedelta(hours=hour)
                    if valid_date not in storm_dates: continue
                    idx = storm_dict['date'].index(valid_date)
                    if prop['marker'] == 'label':
                        self.ax.text(use_lons[idx],storm_dict['lat'][idx],
                                     str(hour),ha='center',va='center',zorder=100,clip_on=True,transform=ccrs.PlateCarree())
                    elif prop['marker'] == 'dot':
                        self.ax.plot(use_lons[idx],storm_dict['lat'][idx],'o',ms=prop['linewidth']*3,zorder=100,
                                     mfc='k',mec='k',transform=ccrs.PlateCarree())
                    else:
                        raise ValueError("Acceptable values for 'marker' prop are 'label' or 'dot'.")
        
        #================================================================================================
        
        #Calcuate lat/lon extrema
        lat_max_extrema = np.sort(lat_max_extrema)
        lat_min_extrema = np.sort(lat_min_extrema)
        lon_max_extrema = np.sort(lon_max_extrema)
        lon_min_extrema = np.sort(lon_min_extrema)
        
        max_lat = np.nanpercentile(lat_max_extrema,95)
        min_lat = np.nanpercentile(lat_min_extrema,5)
        max_lon = np.nanpercentile(lon_max_extrema,95)
        min_lon = np.nanpercentile(lon_min_extrema,5)
        
        #================================================================================================

        #Add legend
        import matplotlib.patches as mpatches
        import matplotlib.lines as mlines
        l = self.ax.legend(loc=1,prop={'size':12})
        l.set_zorder(1001)

        #Plot title
        plot_title = f"Model Forecast Tracks for {storm_dict['name'].title()}"
        self.ax.set_title(plot_title,fontsize=16,loc='left',fontweight='bold')

        title_str = f"Initialized {forecast.strftime('%H%M UTC %d %B %Y')}"
        self.ax.set_title(title_str,fontsize=12,loc='right')

        #--------------------------------------------------------------------------------------

        #Storm-centered plot domain
        if domain == "dynamic":

            bound_w,bound_e,bound_s,bound_n = self.dynamic_map_extent(min_lon,max_lon,min_lat,max_lat)
            self.ax.set_extent([bound_w,bound_e,bound_s,bound_n], crs=ccrs.PlateCarree())

        #Pre-generated or custom domain
        else:
            bound_w,bound_e,bound_s,bound_n = self.set_projection(domain)

        #Plot parallels and meridians
        #This is currently not supported for all cartopy projections.
        if map_prop['plot_gridlines']:
            try:
                self.plot_lat_lon_lines([bound_w,bound_e,bound_s,bound_n])
            except:
                pass

        #--------------------------------------------------------------------------------------

        credit_text = self.plot_credit()
        self.add_credit(credit_text)

        #Save image if specified
        if save_path is not None and isinstance(save_path,str) == True:
            plt.savefig(os.path.join(save_path),bbox_inches='tight')

        #Return axis if specified, otherwise display figure
        return self.ax
    
    def plot_ensembles(self,forecast,storm_dict,fhr,interpolate,prop_ensemble_members,prop_ensemble_mean,prop_gfs,prop_btk,prop_ellipse,prop_density,nens,
               domain,ds,ax,map_prop,save_path):
        
        r"""
        Plot GEFS ensemble forecast tracks.
        """
        
        #Set default properties
        default_map_prop={'res':'m','land_color':'#FBF5EA','ocean_color':'#EDFBFF','linewidth':0.5,'linecolor':'k','figsize':(14,9),'dpi':200,'plot_gridlines':True}
        default_prop_ensemble_members = {'plot':True, 'linewidth':0.2, 'linecolor':'k','color_var':None,'cmap':None,'levels':None}
        default_prop_ensemble_mean = {'plot':True, 'linewidth':3.0, 'linecolor':'k'}
        default_prop_gfs = {'plot':True, 'linewidth':3.0, 'linecolor':'r'}
        default_prop_btk = {'plot':True, 'linewidth':2.5, 'linecolor':'b'}
        default_prop_ellipse = {'plot':True, 'linewidth':3.0, 'linecolor':'b'}
        default_prop_density = {'plot':True, 'radius':200, 'cmap':plt.cm.plasma_r, 'levels':[1]+[i for i in range(10,101,10)]}
        
        #Initialize plot
        map_prop = self.add_prop(map_prop,default_map_prop)
        prop_ensemble_members = self.add_prop(prop_ensemble_members,default_prop_ensemble_members)
        prop_ensemble_mean = self.add_prop(prop_ensemble_mean,default_prop_ensemble_mean)
        prop_gfs = self.add_prop(prop_gfs,default_prop_gfs)
        prop_btk = self.add_prop(prop_btk,default_prop_btk)
        prop_ellipse = self.add_prop(prop_ellipse,default_prop_ellipse)
        prop_density = self.add_prop(prop_density,default_prop_density)
        self.plot_init(ax,map_prop)
        
        #================================================================================================
        
        #Get valid time
        hr = fhr
        
        #Keep record of lat/lon coordinate extrema
        lat_max_extrema = []
        lat_min_extrema = []
        lon_max_extrema = []
        lon_min_extrema = []

        #================================================================================================

        #Function for temporal interpolation
        import scipy.interpolate as interp
        def temporal_interpolation(value, orig_times, target_times):
            f = interp.interp1d(orig_times,value)
            ynew = f(target_times)
            return ynew
        
        #Plot density
        density_colorbar = False
        if prop_density['plot']:
            
            #Error check radius
            if prop_density['radius'] > 500 or prop_density['radius'] < 50:
                raise ValueError("Radius must be between 50 and 500 km.")
            
            if hr == None or (hr != None and hr in ds['gefs']['fhr']):

                #Create 0.25 degree grid for plotting
                gridlats = np.arange(0,90,0.25)
                if np.nanmax(storm_dict['lat']) < 0: gridlats = np.arange(-90,0,0.25)
                gridlons = np.arange(-180.0,180.1,0.25)
                if self.proj.proj4_params['lon_0'] == 180.0: gridlons = np.arange(0.0,360.1,0.25)
                gridlons2d,gridlats2d = np.meshgrid(gridlons,gridlats)
                griddata = np.zeros((gridlons2d.shape))

                #Iterate over all ensemble members
                if hr == None:
                    start_time = dt.now()
                    print("--> Starting to calculate track density")
                for ens in range(nens):

                    #Calculate for one hour
                    if hr != None:
                        
                        #Proceed if hour is available
                        if hr in ds[f'gefs_{ens}']['fhr']:
                            idx = ds[f'gefs_{ens}']['fhr'].index(hr)
                            griddata += add_radius_quick(gridlats, gridlons, ds[f'gefs_{ens}']['lat'][idx],
                                                   ds[f'gefs_{ens}']['lon'][idx], prop_density['radius'])
                    
                    #Calculate for cumulative
                    else:
                        
                        #Ensemble temporary gridded field
                        temp_grid = np.zeros((gridlons2d.shape))

                        #Interpolate temporally to hourly if requested
                        if interpolate:
                            if len(ds[f'gefs_{ens}']['lat']) == 0:
                                continue
                            elif len(ds[f'gefs_{ens}']['lat']) == 1:
                                new_lats = ds[f'gefs_{ens}']['lat']
                                new_lons = ds[f'gefs_{ens}']['lon']
                            else:
                                new_hours = np.arange(min(ds[f'gefs_{ens}']['fhr']),max(ds[f'gefs_{ens}']['fhr']),1)
                                new_lats = temporal_interpolation(ds[f'gefs_{ens}']['lat'],ds[f'gefs_{ens}']['fhr'],new_hours)
                                new_lons = temporal_interpolation(ds[f'gefs_{ens}']['lon'],ds[f'gefs_{ens}']['fhr'],new_hours)

                            #Iterate over all forecast hours
                            for i,(i_lon,i_lat) in enumerate(zip(new_lons,new_lats)):
                                radius_grid = add_radius_quick(gridlats, gridlons, i_lat, i_lon, prop_density['radius'])
                                temp_grid = np.maximum(temp_grid, radius_grid)
                        
                        #Otherwise don't interpolate
                        else:
                            
                            #Iterate over all forecast hours
                            for idx,iter_hr in enumerate(ds[f'gefs_{ens}']['fhr']):
                                radius_grid = add_radius(gridlats2d, gridlons2d, ds[f'gefs_{ens}']['lat'][idx],
                                                         ds[f'gefs_{ens}']['lon'][idx], prop_density['radius'])
                                temp_grid = np.maximum(temp_grid, radius_grid)

                        #Add temporary grid to full grid
                        griddata += temp_grid

                #Convert density to percent
                if hr == None:
                    time_elapsed = dt.now() - start_time
                    tsec = str(round(time_elapsed.total_seconds(),2))
                    print(f"--> Completed calculating track density ({tsec} seconds)")
                density_percent = (griddata / nens) * 100.0

                #Plot density
                norm = mcolors.BoundaryNorm(prop_density['levels'], prop_density['cmap'].N)
                cs = self.ax.contourf(gridlons, gridlats, density_percent, prop_density['levels'],
                                      cmap=prop_density['cmap'], norm=norm, alpha=0.6, transform=ccrs.PlateCarree())
                cbar = add_colorbar(cs,ticks=prop_density['levels'],ax=self.ax)
                cbar.ax.tick_params(labelsize=12)
                density_colorbar = True

        #-------------------------------------------------------------------
        #Plot ellipse
        if hr != None and hr in ds['gefs']['fhr'] and prop_ellipse['plot']:
            idx = ds['gefs']['fhr'].index(hr)

            #Account for cases crossing dateline
            if self.proj.proj4_params['lon_0'] == 180.0:
                new_lons = np.array(ds['gefs']['ellipse_lon'][idx])
                new_lons[new_lons<0] = new_lons[new_lons<0]+360.0
                new_lons = new_lons.tolist()
            else:
                new_lons = ds['gefs']['ellipse_lon'][idx]
            
            try:
                self.ax.plot(new_lons,ds['gefs']['ellipse_lat'][idx],'-',
                             color='w', linewidth=prop_ellipse['linewidth']*1.2,
                             transform=ccrs.PlateCarree(), alpha=0.8)
                self.ax.plot(new_lons,ds['gefs']['ellipse_lat'][idx],'-',
                             color=prop_ellipse['linecolor'], linewidth=prop_ellipse['linewidth'],
                             transform=ccrs.PlateCarree(), alpha=0.8)
            except:
                pass

        #-------------------------------------------------------------------
        #Plot GEFS member tracks
        for i in range(nens):

            #Update coordinate bounds
            skip_bounds = False
            if hr in ds[f'gefs_{i}']['fhr']:
                idx = ds[f'gefs_{i}']['fhr'].index(hr)
                use_lats = ds[f'gefs_{i}']['lat'][:idx+1]
                use_lons = ds[f'gefs_{i}']['lon'][:idx+1]
            elif len(ds[f'gefs_{i}']['fhr']) > 0:
                idx = 0
                if hr == None:
                    idx = len(ds[f'gefs_{i}']['lon'])
                else:
                    for idx_hr in ds[f'gefs_{i}']['fhr']:
                        if idx_hr <= hr: idx = ds[f'gefs_{i}']['fhr'].index(idx_hr)
                use_lons = ds[f'gefs_{i}']['lon'][:idx+1]
                use_lats = ds[f'gefs_{i}']['lat'][:idx+1]
            else:
                skip_bounds = True

            if skip_bounds == False:
                lat_max_extrema.append(np.nanmax(use_lats))
                lat_min_extrema.append(np.nanmin(use_lats))
                lon_max_extrema.append(np.nanmax(use_lons))
                lon_min_extrema.append(np.nanmin(use_lons))

                #Plot cumulative track
                if len(ds[f'gefs_{i}']['fhr']) > 0:
                    if prop_ensemble_members['color_var'] not in ['vmax','mslp']:
                        self.ax.plot(ds[f'gefs_{i}']['lon'][:idx+1], ds[f'gefs_{i}']['lat'][:idx+1], 
                                     linewidth=prop_ensemble_members['linewidth'],
                                     color=prop_ensemble_members['linecolor'], transform=ccrs.PlateCarree())
                    else:
                        #Color by variable
                        cmap = prop_ensemble_members['cmap']
                        levels = prop_ensemble_members['levels']
                        norm = mcolors.BoundaryNorm(levels,cmap.N)
                        for j in range(1,idx+1):
                            if j >= len(ds[f'gefs_{i}'][prop_ensemble_members['color_var']]): continue
                            i_val = ds[f'gefs_{i}'][prop_ensemble_members['color_var']][j]
                            color = 'w' if np.isnan(i_val) else cmap(norm(i_val))
                            self.ax.plot([ds[f'gefs_{i}']['lon'][j-1],ds[f'gefs_{i}']['lon'][j]],
                                         [ds[f'gefs_{i}']['lat'][j-1],ds[f'gefs_{i}']['lat'][j]], 
                                         linewidth=prop_ensemble_members['linewidth'],
                                         color=color, transform=ccrs.PlateCarree())
                        
                        #Add colorbar
                        if density_colorbar == False:
                            cs = plt.cm.ScalarMappable(cmap=cmap, norm=norm)
                            cs.set_array([])
                            cbar = add_colorbar(cs,ticks=prop_ensemble_members['levels'],ax=self.ax)
                            cbar.ax.tick_params(labelsize=12)
                            density_colorbar = True
                
                #Plot latest dot if applicable
                if hr in ds[f'gefs_{i}']['fhr']:
                    idx = ds[f'gefs_{i}']['fhr'].index(hr)
                    self.ax.plot(ds[f'gefs_{i}']['lon'][idx], ds[f'gefs_{i}']['lat'][idx], 'o', ms=4,
                                 mfc=prop_ensemble_members['linecolor'],mec='k',
                                 alpha=0.6,transform=ccrs.PlateCarree())

        #-------------------------------------------------------------------
        #Plot best track
        if prop_btk['plot']:
            
            #Account for cases crossing dateline
            if self.proj.proj4_params['lon_0'] == 180.0:
                new_lons = np.array(storm_dict['lon'])
                new_lons[new_lons<0] = new_lons[new_lons<0]+360.0
                new_lons = new_lons.tolist()
            else:
                new_lons = storm_dict['lon']
            
            #Get valid time
            valid_time = np.nan if hr == None else forecast + timedelta(hours=hr)
            end_time = forecast + timedelta(hours=240) if storm_dict['year'] >= 2015 else forecast + timedelta(hours=240)
            
            #Update coordinate bounds
            skip_bounds = False
            idx_start = storm_dict['date'].index(forecast)
            if valid_time in storm_dict['date']:
                idx = storm_dict['date'].index(valid_time)
                
                use_lats = storm_dict['lat'][idx_start:idx+1]
                use_lons = new_lons[idx_start:idx+1]
            else:
                idx = 0
                if hr == None:
                    if end_time in storm_dict['date']:
                        idx = storm_dict['date'].index(end_time)
                    else:
                        idx = len(storm_dict['lat'])
                else:
                    for idx_date in storm_dict['date']:
                        if idx_date <= valid_time: idx = storm_dict['date'].index(idx_date)
                use_lats = storm_dict['lat'][idx_start:idx+1]
                use_lons = new_lons[idx_start:idx+1]

            if skip_bounds == False:
                lat_max_extrema.append(np.nanmax(use_lats))
                lat_min_extrema.append(np.nanmin(use_lats))
                lon_max_extrema.append(np.nanmax(use_lons))
                lon_min_extrema.append(np.nanmin(use_lons))

            #Plot observed track before now
            self.ax.plot(new_lons[:idx_start+1], storm_dict['lat'][:idx_start+1],
                         linewidth=1.5, color='k', linestyle=":", transform=ccrs.PlateCarree())
            
            if valid_time in storm_dict['date']:
                idx = storm_dict['date'].index(valid_time)
                self.ax.plot(new_lons[idx_start:idx+1], storm_dict['lat'][idx_start:idx+1],
                             linewidth=prop_btk['linewidth'], color=prop_btk['linecolor'],transform=ccrs.PlateCarree())
                self.ax.plot(new_lons[idx], storm_dict['lat'][idx], 'o', ms=12,
                             mfc=prop_btk['linecolor'],mec='k', transform=ccrs.PlateCarree())
            elif len(storm_dict['date']) > 0:
                idx = 0
                if hr == None:
                    if end_time in storm_dict['date']:
                        idx = storm_dict['date'].index(end_time)
                    else:
                        idx = len(storm_dict['lat'])
                else:
                    for idx_date in storm_dict['date']:
                        if idx_date <= valid_time: idx = storm_dict['date'].index(idx_date)
                self.ax.plot(new_lons[idx_start:idx+1], storm_dict['lat'][idx_start:idx+1],
                             linewidth=prop_btk['linewidth'], color=prop_btk['linecolor'],transform=ccrs.PlateCarree())

        #-------------------------------------------------------------------
        #Plot operational GFS track
        if prop_gfs['plot']:

            #Update coordinate bounds
            skip_bounds = False
            if hr in ds['gfs']['fhr']:
                idx = ds['gfs']['fhr'].index(hr)
                use_lats = ds['gfs']['lat'][:idx+1]
                use_lons = ds['gfs']['lon'][:idx+1]
            elif len(ds['gfs']['fhr']) > 0:
                idx = 0
                if hr == None:
                    idx = len(ds['gfs']['lon'])
                else:
                    for idx_hr in ds['gfs']['fhr']:
                        if idx_hr <= hr: idx = ds['gfs']['fhr'].index(idx_hr)
                use_lats = ds['gfs']['lat'][:idx+1]
                use_lons = ds['gfs']['lon'][:idx+1]
            else:
                skip_bounds = True

            if skip_bounds == False:
                lat_max_extrema.append(np.nanmax(use_lats))
                lat_min_extrema.append(np.nanmin(use_lats))
                lon_max_extrema.append(np.nanmax(use_lons))
                lon_min_extrema.append(np.nanmin(use_lons))

            #Plot GFS forecast line and latest dot
            if hr in ds['gfs']['fhr']:
                idx = ds['gfs']['fhr'].index(hr)
                self.ax.plot(ds['gfs']['lon'][:idx+1], ds['gfs']['lat'][:idx+1],
                             linewidth=prop_gfs['linewidth'], color=prop_gfs['linecolor'], transform=ccrs.PlateCarree())
                self.ax.plot(ds['gfs']['lon'][idx], ds['gfs']['lat'][idx], 'o', ms=12,
                             mfc=prop_gfs['linecolor'],mec='k', transform=ccrs.PlateCarree())

            elif len(ds['gfs']['fhr']) > 0:
                idx = 0
                if hr == None:
                    idx = len(ds['gfs']['lon'])
                else:
                    for idx_hr in ds['gfs']['fhr']:
                        if idx_hr <= hr: idx = ds['gfs']['fhr'].index(idx_hr)
                self.ax.plot(ds['gfs']['lon'][:idx+1], ds['gfs']['lat'][:idx+1],
                             linewidth=prop_gfs['linewidth'], color=prop_gfs['linecolor'], transform=ccrs.PlateCarree())

        #-------------------------------------------------------------------
        #Plot ensemble mean track
        if prop_ensemble_mean['plot']:

            #Update coordinate bounds
            skip_bounds = False
            if hr in ds['gefs']['fhr']:
                idx = ds['gefs']['fhr'].index(hr)
                use_lats = ds['gefs']['lat'][:idx+1]
                use_lons = ds['gefs']['lon'][:idx+1]
            elif len(ds['gefs']['fhr']) > 0:
                idx = 0
                if hr == None:
                    idx = len(ds['gefs']['lon'])
                else:
                    for idx_hr in ds['gefs']['fhr']:
                        if idx_hr <= hr: idx = ds['gefs']['fhr'].index(idx_hr)
                use_lats = ds['gefs']['lat'][:idx+1]
                use_lons = ds['gefs']['lon'][:idx+1]
            else:
                skip_bounds = True

            if skip_bounds == False:
                lat_max_extrema.append(np.nanmax(use_lats))
                lat_min_extrema.append(np.nanmin(use_lats))
                lon_max_extrema.append(np.nanmax(use_lons))
                lon_min_extrema.append(np.nanmin(use_lons))

            if hr in ds['gefs']['fhr']:
                idx = ds['gefs']['fhr'].index(hr)
                self.ax.plot(ds['gefs']['lon'][:idx+1], ds['gefs']['lat'][:idx+1],
                             linewidth=prop_ensemble_mean['linewidth'],
                             color=prop_ensemble_mean['linecolor'], transform=ccrs.PlateCarree())
                self.ax.plot(ds['gefs']['lon'][idx], ds['gefs']['lat'][idx], 'o', ms=12,
                             mfc=prop_ensemble_mean['linecolor'],mec='k', transform=ccrs.PlateCarree())
            elif len(ds['gefs']['fhr']) > 0:
                idx = 0
                if hr == None:
                    idx = len(ds['gefs']['lon'])
                else:
                    for idx_hr in ds['gefs']['fhr']:
                        if idx_hr <= hr: idx = ds['gefs']['fhr'].index(idx_hr)
                self.ax.plot(ds['gefs']['lon'][:idx+1], ds['gefs']['lat'][:idx+1],
                             linewidth=prop_ensemble_mean['linewidth'],
                             color=prop_ensemble_mean['linecolor'], transform=ccrs.PlateCarree())
        
        #================================================================================================
        
        #Calcuate lat/lon extrema
        lat_max_extrema = np.sort(lat_max_extrema)
        lat_min_extrema = np.sort(lat_min_extrema)
        lon_max_extrema = np.sort(lon_max_extrema)
        lon_min_extrema = np.sort(lon_min_extrema)
        
        if hr == None:
            max_lat = np.nanpercentile(lat_max_extrema,95)
            min_lat = np.nanpercentile(lat_min_extrema,5)
            max_lon = np.nanpercentile(lon_max_extrema,95)
            min_lon = np.nanpercentile(lon_min_extrema,5)
        else:
            max_lat = np.nanmax(lat_max_extrema)
            min_lat = np.nanmin(lat_min_extrema)
            max_lon = np.nanmax(lon_max_extrema)
            min_lon = np.nanmin(lon_min_extrema)
        
        #================================================================================================
        
        #Add legend
        import matplotlib.patches as mpatches
        import matplotlib.lines as mlines
        p1 = mlines.Line2D([], [], color=prop_btk['linecolor'], linewidth=prop_btk['linewidth'], label='Best Track')
        p2 = mlines.Line2D([], [], color=prop_gfs['linecolor'], linewidth=prop_gfs['linewidth'], label='Deterministic GFS')
        p3 = mlines.Line2D([], [], color=prop_ensemble_mean['linecolor'], linewidth=prop_ensemble_mean['linewidth'], label='GEFS Mean')
        p4 = mlines.Line2D([], [], color=prop_ensemble_members['linecolor'], linewidth=prop_ensemble_members['linewidth'], label='GEFS Members')
        p5 = mlines.Line2D([], [], color='w', marker='o', ms=12, mec=prop_ellipse['linecolor'], mew=prop_ellipse['linewidth'], label='GEFS Ellipse')
        handles_list = []
        if prop_btk['plot']: handles_list.append(p1)
        if prop_gfs['plot']: handles_list.append(p2)
        if prop_ensemble_mean['plot']: handles_list.append(p3)
        if prop_ensemble_members['plot']: handles_list.append(p4)
        if hr != None and prop_ellipse['plot']: handles_list.append(p5)
        if len(handles_list) > 0:
            l = self.ax.legend(handles=handles_list,loc=1,prop={'size':12})
            l.set_zorder(1001)

        #Plot title
        format_title = {'vmax':'Ensemble member sustained wind (knots)','mslp':'Ensemble member minimum MSLP (hPa)'}
        plot_title = f"GEFS Forecast Tracks for {storm_dict['name'].title()}"
        if prop_density['plot']: plot_title += f"\nTrack Density ({np.int(prop_density['radius'])}-km radius)"
        if prop_ensemble_members['color_var'] in ['vmax','mslp']: plot_title += f"\n{format_title.get(prop_ensemble_members['color_var'])}"
        self.ax.set_title(plot_title,fontsize=16,loc='left',fontweight='bold')

        if hr == None:
            title_str = f"Initialized {forecast.strftime('%H%M UTC %d %B %Y')}"
        else:
            title_str = f"Hour {hr} | Valid {(forecast+timedelta(hours=hr)).strftime('%H%M UTC %d %B %Y')}\n"
            title_str += f"Initialized {forecast.strftime('%H%M UTC %d %B %Y')}"
        self.ax.set_title(title_str,fontsize=12,loc='right')

        #--------------------------------------------------------------------------------------

        #Storm-centered plot domain
        if domain == "dynamic":

            bound_w,bound_e,bound_s,bound_n = self.dynamic_map_extent(min_lon,max_lon,min_lat,max_lat)
            self.ax.set_extent([bound_w,bound_e,bound_s,bound_n], crs=ccrs.PlateCarree())

        #Pre-generated or custom domain
        else:
            bound_w,bound_e,bound_s,bound_n = self.set_projection(domain)

        #Plot parallels and meridians
        #This is currently not supported for all cartopy projections.
        if map_prop['plot_gridlines']:
            try:
                self.plot_lat_lon_lines([bound_w,bound_e,bound_s,bound_n])
            except:
                pass

        #--------------------------------------------------------------------------------------

        credit_text = self.plot_credit()
        self.add_credit(credit_text)

        #Save image if specified
        if save_path is not None and isinstance(save_path,str) == True:
            plt.savefig(os.path.join(save_path),bbox_inches='tight')

        #Return axis if specified, otherwise display figure
        return self.ax

    def plot_season(self,season,domain=None,ax=None,save_path=None,prop={},map_prop={}):
        
        r"""
        Creates a plot of a single season.
        
        Parameters
        ----------
        season : Season
            Instance of Season.
        ax : axes
            Instance of axes to plot on. If none, one will be generated. Default is none.
        prop : dict
            Property of storm track lines.
        map_prop : dict
            Property of cartopy map.
        """
        
        #Set default properties
        default_prop={'dots':False,'fillcolor':'category','cmap':None,'levels':None,
                      'linecolor':'category','linewidth':1.0,'ms':7.5,'plot_names':True}
        default_map_prop={'res':'m','land_color':'#FBF5EA','ocean_color':'#EDFBFF','linewidth':0.5,'linecolor':'k','figsize':(14,9),'dpi':200,'plot_gridlines':True}
        
        #Initialize plot
        prop = self.add_prop(prop,default_prop)
        map_prop = self.add_prop(map_prop,default_map_prop)
        self.plot_init(ax,map_prop)
        
        #--------------------------------------------------------------------------------------
        
        #Keep record of lat/lon coordinate extrema
        max_lat = None
        min_lat = None
        max_lon = None
        min_lon = None

        #Iterate over all storms in season object
        sinfo = season.summary()
        storms = season.dict.keys()
        for storm_idx,storm_key in enumerate(storms):

            #Get data for this storm
            storm = season.dict[storm_key]
            
            #Retrieve storm data
            lats = storm['lat']
            lons = storm['lon']
            vmax = storm['vmax']
            styp = storm['type']
            sdate = storm['date']

            #Account for cases crossing dateline
            if self.proj.proj4_params['lon_0'] == 180.0:
                new_lons = np.array(lons)
                new_lons[new_lons<0] = new_lons[new_lons<0]+360.0
                lons = new_lons.tolist()

            #Add to coordinate extrema
            if max_lat is None:
                max_lat = max(lats)
            else:
                if max(lats) > max_lat: max_lat = max(lats)
            if min_lat is None:
                min_lat = min(lats)
            else:
                if min(lats) < min_lat: min_lat = min(lats)
            if max_lon is None:
                max_lon = max(lons)
            else:
                if max(lons) > max_lon: max_lon = max(lons)
            if min_lon is None:
                min_lon = min(lons)
            else:
                if min(lons) < min_lon: min_lon = min(lons)
            
            #Add storm label at start and end points
            if prop['plot_names'] == True:
                self.ax.text(lons[0]+0.0,storm['lat'][0]+1.0,storm['name'].upper(),
                             fontsize=9,clip_on=True,zorder=1000,alpha=0.7,ha='center',va='center',transform=ccrs.PlateCarree())
                self.ax.text(lons[-1]+0.0,storm['lat'][-1]+1.0,storm['name'].upper(),
                             fontsize=9,clip_on=True,zorder=1000,alpha=0.7,ha='center',va='center',transform=ccrs.PlateCarree())

            #Iterate over storm data to plot
            levels = None
            cmap = None
            for i,(i_lat,i_lon,i_vmax,i_mslp,i_date,i_type) in enumerate(zip(storm['lat'],lons,storm['vmax'],storm['mslp'],storm['date'],storm['type'])):
                    
                #Determine line color, with SSHWS scale used as default
                if prop['linecolor'] == 'category':
                    segmented_colors = True
                    line_color = get_colors_sshws(np.nan_to_num(i_vmax))
                
                #Use user-defined colormap if another storm variable
                elif isinstance(prop['linecolor'],str) == True and prop['linecolor'] in ['vmax','mslp','dvmax_dt','speed']:
                    segmented_colors = True
                    try:
                        color_variable = storm[prop['linecolor']]
                    except:
                        raise ValueError("Storm object must be interpolated to hourly using 'storm.interp().plot(...)' in order to use 'dvmax_dt' or 'speed' for fill color")
                    if prop['levels'] is None: #Auto-determine color levels if needed
                        prop['levels'] = [np.nanmin(color_variable),np.nanmax(color_variable)]
                    cmap,levels = get_cmap_levels(prop['linecolor'],prop['cmap'],prop['levels'])
                    line_color = cmap((color_variable-min(levels))/(max(levels)-min(levels)))[i]
                
                #Otherwise go with user input as is
                else:
                    segmented_colors = False
                    line_color = prop['linecolor']

                #For tropical/subtropical types, color-code if requested
                if i > 0:
                    if i_type in constants.TROPICAL_STORM_TYPES and storm['type'][i-1] in constants.TROPICAL_STORM_TYPES:

                        #Plot underlying black and overlying colored line
                        self.ax.plot([lons[i-1],lons[i]],[storm['lat'][i-1],storm['lat'][i]],'-',
                                      linewidth=prop['linewidth']*1.33,color='k',zorder=storm_idx*5,
                                      transform=ccrs.PlateCarree())
                        self.ax.plot([lons[i-1],lons[i]],[storm['lat'][i-1],storm['lat'][i]],'-',
                                      linewidth=prop['linewidth'],color=line_color,zorder=i_vmax+(storm_idx*5),
                                      transform=ccrs.PlateCarree())

                    #For non-tropical types, plot dotted lines
                    else:

                        #Restrict line width to 1.5 max
                        line_width = prop['linewidth'] + 0.0
                        if line_width > 1.5: line_width = 1.5

                        #Plot dotted line
                        self.ax.plot([lons[i-1],lons[i]],[storm['lat'][i-1],storm['lat'][i]],':',
                                      linewidth=line_width,color=line_color,zorder=i_vmax+(storm_idx*5),
                                      transform=ccrs.PlateCarree(),
                                      path_effects=[path_effects.Stroke(linewidth=line_width*1.33, foreground='k'),
                                                    path_effects.Normal()])
                
                #Plot dots if requested
                if prop['dots'] == True:
                    self.ax,segmented_colors,extra = plot_dot(self.ax,i_lon,i_lat,i_date,i_vmax,i_type,
                                                              zorder=900+i_vmax,storm_data=storm,prop=prop,i=i)
                    if 'cmap' in extra.keys(): cmap = extra['cmap']
                    if 'levels' in extra.keys(): levels = extra['levels']

        #--------------------------------------------------------------------------------------
        
        #Pre-generated domains
        if domain is None:
            bound_w,bound_e,bound_s,bound_n = self.set_projection(season.basin)
        else:
            bound_w,bound_e,bound_s,bound_n = self.set_projection(domain)
            
        #Plot parallels and meridians
        #This is currently not supported for all cartopy projections.
        if map_prop['plot_gridlines']:
            try:
                self.plot_lat_lon_lines([bound_w,bound_e,bound_s,bound_n])
            except:
                pass
        
        #--------------------------------------------------------------------------------------
        
        #Add left title
        emdash = u"\u2014"
        basin_name = ((season.basin).replace("_"," ")).title()
        if season.basin == 'all':
            season_title = f"{season.year} Global Tropical Cyclone Season"
        elif season.basin == 'both':
            season_title = f"{season.year} Atlantic-Pacific Hurricane Season"
        elif season.basin in ['south_indian','south_atlantic','australia','south_pacific']:
            season_title = f"{season.year-1}{emdash}{season.year} {basin_name} Tropical Cyclone Season"
        elif season.basin in ['west_pacific']:
            season_title = f"{season.year} {basin_name.split(' ')[1]} Typhoon Season"
        else:
            season_title = f"{season.year} {basin_name.split(' ')[1]} Hurricane Season"
        self.ax.set_title(season_title,loc='left',fontsize=17,fontweight='bold')

        #Add right title
        endash = u"\u2013"
        dot = u"\u2022"
        count_named = sinfo['season_named']
        count_hurricane = sinfo['season_hurricane']
        count_major = sinfo['season_major']
        count_ace = sinfo['season_ace']
        if isinstance(season.year,list) == True:
            count_named = np.sum(sinfo['season_named'])
            count_hurricane = np.sum(sinfo['season_hurricane'])
            count_major = np.sum(sinfo['season_major'])
            count_ace = np.sum(sinfo['season_ace'])
        self.ax.set_title(f"{count_named} named {dot} {count_hurricane} hurricanes {dot} {count_major} major\n{count_ace:.1f} Cumulative ACE",loc='right',fontsize=13)

        #--------------------------------------------------------------------------------------
        
        #Add plot credit
        warning_text=""
        if storm['source'] == 'ibtracs' and storm['source_info'] == 'World Meteorological Organization (official)':
            warning_text = f"This plot uses 10-minute averaged WMO official wind data converted\nto 1-minute average (factor of 0.88). Use this wind data with caution.\n\n"

            self.ax.text(0.99,0.01,warning_text,fontsize=9,color='k',alpha=0.7,
            transform=self.ax.transAxes,ha='right',va='bottom',zorder=10)
        
        credit_text = self.plot_credit()
        self.add_credit(credit_text)
                
        #--------------------------------------------------------------------------------------
        
        #Add legend
        self.ax,self.fig = add_legend(self.ax,self.fig,prop,segmented_colors,levels,cmap,storm)
                
        #--------------------------------------------------------------------------------------
        
        #Save image if specified
        if save_path is not None and isinstance(save_path,str) == True:
            plt.savefig(save_path,bbox_inches='tight')
        
        #Return axis if specified, otherwise display figure
        return self.ax
        
    def plot_track_labels(self, ax, labels, track, k=0.01):

        label_nodes = list(labels.keys())
        labels['place1'] = (2*labels[label_nodes[0]][0]-labels[label_nodes[1]][0],\
                          2*labels[label_nodes[0]][1]-labels[label_nodes[1]][1])
        labels['place2'] = (2*labels[label_nodes[-1]][0]-labels[label_nodes[-2]][0],\
                          2*labels[label_nodes[-1]][1]-labels[label_nodes[-2]][1])
        track['place1'] = labels['place1']
        track['place2'] = labels['place2']
        
        G = nx.DiGraph()
        track_nodes = []
        init_pos = {}
        
        for lab in track.keys():
            labG = 'track_{0}'.format(lab)
            G.add_node(labG)
            track_nodes.append(labG)
            init_pos[labG] = track[lab]
            
        for lab in labels.keys():
            G.add_node(lab)
            G.add_edge(lab,'track_{0}'.format(lab))
            init_pos[lab] = labels[lab]
            
        pos = nx.spring_layout(G, pos=init_pos, fixed=track_nodes, k=k)

        # undo spring_layout's rescaling
        pos_after = np.vstack([pos[d] for d in track_nodes])
        pos_before = np.vstack([init_pos[d] for d in track_nodes])
        scale, shift_x = np.polyfit(pos_after[:,0], pos_before[:,0], 1)
        scale, shift_y = np.polyfit(pos_after[:,1], pos_before[:,1], 1)
        shift = np.array([shift_x, shift_y])
        for key, val in pos.items():
            pos[key] = (val*scale) + shift

        for label, _ in G.edges():
            if 'place' not in label:
                self.ax.annotate(label,
                            xy=init_pos[label], xycoords='data',
                            xytext=pos[label], textcoords='data', fontweight='bold', ha='center', va='center',
                            arrowprops=dict(arrowstyle="-",#->
                                            shrinkA=0, shrinkB=0,
                                            connectionstyle="arc3", 
                                            color='k'),
                            transform=ccrs.PlateCarree())
    
    def plot_nhc_labels(self, ax, x, y, labels, k=0.01):

        G = nx.DiGraph()
        data_nodes = []
        init_pos = {}
        for xi, yi, label in zip(x, y, labels):
            data_str = 'data_{0}'.format(label)
            G.add_node(data_str)
            G.add_node(label)
            G.add_edge(label, data_str)
            data_nodes.append(data_str)
            init_pos[data_str] = (xi, yi)
            init_pos[label] = (xi, yi)

        pos = nx.spring_layout(G, pos=init_pos, fixed=data_nodes, k=k)

        # undo spring_layout's rescaling
        pos_after = np.vstack([pos[d] for d in data_nodes])
        pos_before = np.vstack([init_pos[d] for d in data_nodes])
        scale, shift_x = np.polyfit(pos_after[:,0], pos_before[:,0], 1)
        scale, shift_y = np.polyfit(pos_after[:,1], pos_before[:,1], 1)
        shift = np.array([shift_x, shift_y])
        for key, val in pos.items():
            pos[key] = (val*scale) + shift

        #Apply coordinate transform
        transform = ccrs.PlateCarree()._as_mpl_transform(self.ax)
        
        start = False
        for label, data_str in G.edges():
            if start == False:
                start = True
                continue
            self.ax.annotate(label, #xycoords="data"
                        xy=pos[data_str], xycoords=transform,
                        xytext=pos[label], textcoords=transform, fontweight='bold', ha='center', va='center',
                        arrowprops=dict(arrowstyle="-",#->
                                        shrinkA=0, shrinkB=0,
                                        connectionstyle="arc3", 
                                        color='k'),
                        transform=ccrs.PlateCarree(),clip_on=True)

    def plot_gridded(self,xcoord,ycoord,zcoord,varname='type',VEC_FLAG=False,domain="north_atlantic",ax=None,prop={},map_prop={}):
        
        r"""
        Creates a plot of a single storm track.
        
        Parameters
        ----------
        storm : str, tuple or dict
            Requested storm. Can be either string of storm ID (e.g., "AL052019"), tuple with storm name and year (e.g., ("Matthew",2016)), or a dict entry.
        domain : str
            Domain for the plot. Default is TrackDataset basin. Can be one of the following:
            "north_atlantic" - North Atlantic Ocean basin
            "pacific" - East/Central Pacific Ocean basin
            "lonW/lonE/latS/latN" - Custom plot domain
        plot_all_dots : bool
            Whether to plot dots for all observations along the track. If false, dots will be plotted every 6 hours. Default is false.
        ax : axes
            Instance of axes to plot on. If none, one will be generated. Default is none.
        prop : dict
            Property of storm track lines.
        map_prop : dict
            Property of cartopy map.
        """
        
        #Set default properties
        default_prop={'cmap':'category','levels':None,\
                      'left_title':'','right_title':'All storms',
                      'plot_values':False,'values_size':None}
        default_map_prop={'res':'m','land_color':'#FBF5EA','ocean_color':'#EDFBFF','linewidth':0.5,'linecolor':'k','figsize':(14,9),'dpi':200,'plot_gridlines':True}
        
        #Initialize plot
        prop = self.add_prop(prop,default_prop)
        map_prop = self.add_prop(map_prop,default_map_prop)
        self.plot_init(ax,map_prop)
        
        #Determine if contour levels are automatically generated
        auto_levels = True if prop['levels'] is None or prop['levels'] == [] else False

        #Plot domain
        bound_w,bound_e,bound_s,bound_n = self.set_projection(domain)
        
        #Plot parallels and meridians
        #This is currently not supported for all cartopy projections.
        if map_prop['plot_gridlines']:
            try:
                self.plot_lat_lon_lines([bound_w,bound_e,bound_s,bound_n])
            except:
                pass
        
        #--------------------------------------------------------------------------------------

        if VEC_FLAG:
            vecmag = np.hypot(*zcoord)
            if prop['levels'] is None:
                prop['levels'] = (np.nanmin(vecmag),np.nanmax(vecmag))
        elif prop['levels'] is None:
            prop['levels'] = (np.nanmin(zcoord),np.nanmax(zcoord))
        cmap,clevs = get_cmap_levels(varname,prop['cmap'],prop['levels'])
        
        #Generate contourf levels
        if len(clevs) == 2:
            y0 = min(clevs)
            y1 = max(clevs)
            dy = (y1-y0)/8
            scalemag = int(np.log(dy)/np.log(10))
            dy_scaled = dy*10**-scalemag
            dc = min([1,2,5,10], key=lambda x:abs(x-dy_scaled))
            c0 = np.ceil(y0/dc*10**-scalemag)*dc*10**scalemag
            c1 = np.floor(y1/dc*10**-scalemag)*dc*10**scalemag
            clevs = np.arange(c0,c1+dc,dc)
        
        if varname == 'vmax' and prop['cmap'] == 'category':
            vmin = min(clevs); vmax = max(clevs)
        else:
            vmin = min(prop['levels']); vmax = max(prop['levels'])
        
        #For difference/change plots with automatically generated contour levels, ensure that 0 is in the middle
        if auto_levels == True:
            if varname in ['dvmax_dt','dmslp_dt'] or '\n' in prop['title_R']:
                max_val = np.max([np.abs(vmin),vmax])
                vmin = np.round(max_val * -1.0,2)
                vmax = np.round(max_val * 1.0,2)
                clevs = [vmin,np.round(vmin*0.5,2),0,np.round(vmax*0.5,2),vmax]
        
        if len(xcoord.shape) and len(ycoord.shape)==1:
            xcoord,ycoord = np.meshgrid(xcoord,ycoord)
        
        if VEC_FLAG:
            binsize = abs(xcoord[0,0]-xcoord[0,1])
            cbmap = self.ax.pcolor(xcoord,ycoord,vecmag[:-1,:-1],cmap=cmap,vmin=min(clevs),vmax=max(clevs),
                               transform=ccrs.PlateCarree())            
            zcoord = zcoord/vecmag*binsize
            x_center = (xcoord[:-1,:-1]+xcoord[1:,1:])*.5
            y_center = (ycoord[:-1,:-1]+ycoord[1:,1:])*.5
            u = zcoord[0][:-1,:-1]
            v = zcoord[1][:-1,:-1]
            if not prop['plot_values']:
                self.ax.quiver(x_center,y_center,u,v,color='w',alpha=0.6,transform=ccrs.PlateCarree(),\
                           pivot='mid',width=.001*binsize,headwidth=3.5,headlength=4.5,headaxislength=4)
            zcoord = vecmag
        
        else:
            print('--> Generating plot')
            #if varname=='date' and prop['smooth'] is not None:
            #    zcoord[np.isnan(zcoord)]=0
            #    zcoord=gfilt(zcoord,sigma=prop['smooth'])
            #    zcoord[zcoord<min(clevs)]=np.nan
            
            if prop['cmap']=='category' and varname=='vmax':
                norm = mcolors.BoundaryNorm(clevs,cmap.N)
                cbmap = self.ax.pcolor(xcoord,ycoord,zcoord[:-1,:-1],cmap=cmap,norm=norm,
                                       transform=ccrs.PlateCarree())
            else:
                norm = mcolors.Normalize(vmin=vmin,vmax=vmax)
                cbmap = self.ax.pcolor(xcoord,ycoord,zcoord[:-1,:-1],cmap=cmap,norm=norm,
                                       transform=ccrs.PlateCarree())
        if prop['plot_values']:
            binsize = abs(xcoord[0,0]-xcoord[0,1])
            x_center = (xcoord[:-1,:-1]+xcoord[1:,1:])*.5
            y_center = (ycoord[:-1,:-1]+ycoord[1:,1:])*.5
            xs = x_center.flatten(order='C')
            ys = y_center.flatten(order='C')
            zs = zcoord[:-1,:-1].flatten(order='C')
            if prop['values_size'] is None:
                fs = binsize*4
            else:
                fs = prop['values_size']
            for xtext,ytext,ztext in zip(xs,ys,zs):
                if not np.isnan(ztext) and xtext%360>bound_w%360 and xtext%360<bound_e%360 and\
                    ytext>bound_s and ytext<bound_n:
                    square_color = cmap(norm(ztext))
                    square_brightness = np.mean(square_color[:3])*square_color[3]
                    text_color = 'k' if square_brightness>0.5 else 'w' 
                    self.ax.text(xtext,ytext,ztext.astype(int),ha='center',va='center',fontsize=fs,\
                                     color=text_color,alpha=0.8,transform=ccrs.PlateCarree(), zorder=2)
                

        #--------------------------------------------------------------------------------------

        
        #Phantom legend
        handles=[]
        for _ in range(10):
            handles.append(mlines.Line2D([], [], linestyle='-',label='',lw=0))
        l = self.ax.legend(handles=handles,loc='upper left',fancybox=True,framealpha=0,fontsize=11.5)
        plt.draw()

        #Get the bbox
        try:
            bb = l.legendPatch.get_bbox().inverse_transformed(self.fig.transFigure)
        except:
            bb = l.legendPatch.get_bbox().transformed(self.fig.transFigure.inverted())
        bb_ax = self.ax.get_position()

        #Define colorbar axis
        cax = self.fig.add_axes([bb.x0+1.2*bb.width, bb.y0-.05*bb.height, 0.015, bb.height])
#        cbmap = mlib.cm.ScalarMappable(norm=norm, cmap=cmap)
        cbar = self.fig.colorbar(cbmap,cax=cax,orientation='vertical',\
                                 ticks=clevs)
            
        """
        if len(prop['levels'])>2:
            cax.yaxis.set_ticks(np.linspace(min(clevs),max(clevs),len(clevs)))
            cax.yaxis.set_ticks(np.linspace(0,1,len(clevs)))
            cax.yaxis.set_ticklabels(clevs)
        else:
            cax.yaxis.set_ticks(clevs)
        """
        cax.tick_params(labelsize=11.5)
        cax.yaxis.set_ticks_position('left')
    
        rect_offset = 0.0
        if prop['cmap']=='category' and varname=='vmax':
            cax.yaxis.set_ticks(np.linspace(min(clevs),max(clevs),len(clevs)))
            cax.yaxis.set_ticklabels(clevs)
            cax2 = cax.twinx()
            cax2.yaxis.set_ticks_position('right')
            cax2.yaxis.set_ticks((np.linspace(0,1,len(clevs))[:-1]+np.linspace(0,1,len(clevs))[1:])*.5)
            cax2.set_yticklabels(['TD','TS','Cat-1','Cat-2','Cat-3','Cat-4','Cat-5'],fontsize=11.5)
            cax2.tick_params('both', length=0, width=0, which='major')
            cax.yaxis.set_ticks_position('left')
            
            rect_offset = 0.7
        if varname == 'date':
            cax.set_yticklabels([f'{mdates.num2date(i):%b %-d}' for i in clevs],fontsize=11.5)
            
        rectangle = mpatches.Rectangle((bb.x0,bb.y0-0.1*bb.height),(2+rect_offset)*bb.width,1.1*bb.height,\
                                       fc = 'w',edgecolor = '0.8',alpha = 0.8,\
                                       transform=self.fig.transFigure, zorder=3)
        self.ax.add_patch(rectangle)
        
        #--------------------------------------------------------------------------------------
        
        #Add left title
        try:
            self.ax.set_title(prop['title_L'],loc='left',fontsize=17,fontweight='bold')
        except:
            pass
        
        #Add right title
        try:
            self.ax.set_title(prop['title_R'],loc='right',fontsize=15)
        except:
            pass
        
        #--------------------------------------------------------------------------------------
        
        #Add plot credit
        text = self.plot_credit()
        self.add_credit(text)
        
        #Return axis if specified, otherwise display figure
        return self.ax

    def plot_summary(self,storms,forecasts,shapefiles,valid_date,domain,ax=None,save_path=None,two_prop={},invest_prop={},storm_prop={},cone_prop={},map_prop={}):
        
        r"""
        Creates a realtime summary plot.
        """
        
        #Set default properties
        default_two_prop={'plot':True,'fontsize':12,'days':5,'ms':15}
        default_invest_prop={'plot':True,'fontsize':12,'linewidth':0.8,'linecolor':'k','linestyle':'dotted','ms':14}
        default_storm_prop={'plot':True,'fontsize':12,'linewidth':0.8,'linecolor':'k','linestyle':'dotted','fillcolor':'category','label_category':True,'ms':14}
        default_cone_prop={'plot':True,'linewidth':1.5,'linecolor':'k','alpha':0.6,'days':5,'fillcolor':'category','label_category':True,'ms':12}
        default_map_prop={'res':'m','land_color':'#FBF5EA','ocean_color':'#EDFBFF','linewidth':0.5,'linecolor':'k','figsize':(14,9),'dpi':200,'plot_gridlines':True}
        if domain == 'all': default_map_prop['res'] = 'l'
        
        #Initialize plot
        two_prop = self.add_prop(two_prop,default_two_prop)
        invest_prop = self.add_prop(invest_prop,default_invest_prop)
        storm_prop = self.add_prop(storm_prop,default_storm_prop)
        cone_prop = self.add_prop(cone_prop,default_cone_prop)
        map_prop = self.add_prop(map_prop,default_map_prop)
        self.plot_init(ax,map_prop)
        
        #Plot domain
        bound_w,bound_e,bound_s,bound_n = self.set_projection(domain)
        
        #Format title
        add_title = ""
        if two_prop['plot'] == True:
            if two_prop['days'] == 2 or valid_date <= dt(2014,7,1):
                add_title = " & NHC 2-Day Formation Outlook"
            else:
                add_title = " & NHC 5-Day Formation Outlook"
        
        #--------------------------------------------------------------------------------------
        
        bbox_prop = {'facecolor':'white','alpha':0.5,'edgecolor':'black','boxstyle':'round,pad=0.3'}
        
        if two_prop['plot'] == True:
            
            #Store color
            color_base = {'Low':'yellow','Medium':'orange','High':'red'}

            #Plot areas
            if shapefiles['areas'] != None:
                for record, geom in zip(shapefiles['areas'].records(), shapefiles['areas'].geometries()):

                    #Read relevant data
                    if 'RISK2DAY' in record.attributes.keys() or 'RISK5DAY' in record.attributes.keys():
                        if two_prop['days'] == 2:
                            color = color_base.get(record.attributes['RISK2DAY'],'yellow')
                        else:
                            color = color_base.get(record.attributes['RISK5DAY'],'yellow')
                    else:
                        color = color_base.get(record.attributes['GENCAT'],'yellow')

                    #Plot area
                    self.ax.add_feature(cfeature.ShapelyFeature([geom], ccrs.PlateCarree()),
                                        facecolor=color, edgecolor=color, alpha=0.3, linewidth=1.5, zorder=3)

                    #Plot hatching
                    self.ax.add_feature(cfeature.ShapelyFeature([geom], ccrs.PlateCarree()),
                                        facecolor='none', edgecolor='k', linewidth=2.25, zorder=4)
                    self.ax.add_feature(cfeature.ShapelyFeature([geom], ccrs.PlateCarree()),
                                        facecolor='none', edgecolor=color, linewidth=1.5, zorder=4)
                    
                    #Add label if needed
                    plot_coords = []
                    if 'GENCAT' in record.attributes.keys() or shapefiles['points'] == None:
                        bounds = record.geometry.bounds
                        plot_coords.append((bounds[0] + bounds[2]) * 0.5) #lon
                        plot_coords.append(bounds[1]) #lat
                        plot_coords.append(record.attributes['GENPROB'])
                    else:
                        found_areas = []
                        for i_record, i_point in zip(shapefiles['points'].records(), shapefiles['points'].geometries()):
                            found_areas.append(i_record.attributes['AREA'])
                        if 'AREA' in record.attributes.keys() and record.attributes['AREA'] not in found_areas:
                            bounds = record.geometry.bounds
                            plot_coords.append((bounds[0] + bounds[2]) * 0.5) #lon
                            plot_coords.append(bounds[1]) #lat
                            if two_prop['days'] == 2:
                                plot_coords.append(record.attributes['PROB2DAY'])
                            else:
                                plot_coords.append(record.attributes['PROB5DAY'])
                    
                    if len(plot_coords) > 0:
                        #Transform coordinates for label
                        x1, y1 = self.ax.projection.transform_point(plot_coords[0], plot_coords[1], ccrs.PlateCarree())
                        x2, y2 = self.ax.transData.transform((x1, y1))
                        x, y = self.ax.transAxes.inverted().transform((x2, y2))

                        # plot same point but using axes coordinates
                        text = plot_coords[2]
                        a = self.ax.text(x,y-0.02,text,ha='center',va='top',transform=self.ax.transAxes,zorder=30,fontweight='bold',fontsize=two_prop['fontsize'],clip_on=True,bbox=bbox_prop)
                        a.set_path_effects([path_effects.Stroke(linewidth=0.5,foreground='w'),path_effects.Normal()])

            #Plot points
            if shapefiles['points'] != None:
                for record, point in zip(shapefiles['points'].records(), shapefiles['points'].geometries()):

                    #Read relevant data
                    lon = (list(point.coords)[0][0])
                    lat = (list(point.coords)[0][1])
                    prob_2day = record.attributes['PROB2DAY'].replace(" ","")
                    prob_5day = record.attributes['PROB5DAY'].replace(" ","")
                    risk_2day = record.attributes['RISK2DAY'].replace(" ","")
                    risk_5day = record.attributes['RISK5DAY'].replace(" ","")

                    #Label area
                    if two_prop['days'] == 2:
                        color = color_base.get(risk_2day,'yellow')
                        text = prob_2day
                    else:
                        color = color_base.get(risk_5day,'yellow')
                        text = prob_5day
                    self.ax.plot(lon,lat,'X',ms=two_prop['ms'],color=color,mec='k',mew=1.5*(two_prop['ms']/15.0),transform=ccrs.PlateCarree(),zorder=20)

                    #Transform coordinates for label
                    x1, y1 = self.ax.projection.transform_point(lon, lat, ccrs.PlateCarree())
                    x2, y2 = self.ax.transData.transform((x1, y1))
                    x, y = self.ax.transAxes.inverted().transform((x2, y2))

                    # plot same point but using axes coordinates
                    a = self.ax.text(x,y-0.03,text,ha='center',va='top',transform=self.ax.transAxes,zorder=30,fontweight='bold',fontsize=two_prop['fontsize'],clip_on=True,bbox=bbox_prop)
                    a.set_path_effects([path_effects.Stroke(linewidth=0.5,foreground='w'),path_effects.Normal()])
        
        #--------------------------------------------------------------------------------------
        
        if invest_prop['plot'] == True or storm_prop['plot'] == True:
            
            #Iterate over all storms
            for storm_idx,storm in enumerate(storms):
                
                #Skip if it's already associated with a risk area, if TWO is being plotted
                if storm.prob_2day != 'N/A' and two_prop['plot'] == True: continue
                
                #Plot invests
                if storm.invest and invest_prop['plot'] == True:
                    
                    #Test
                    self.ax.plot(storm.lon[-1],storm.lat[-1],'X',ms=invest_prop['ms'],color='k',transform=ccrs.PlateCarree(),zorder=20)
                    
                    #Transform coordinates for label
                    x1, y1 = self.ax.projection.transform_point(storm.lon[-1], storm.lat[-1], ccrs.PlateCarree())
                    x2, y2 = self.ax.transData.transform((x1, y1))
                    x, y = self.ax.transAxes.inverted().transform((x2, y2))

                    # plot same point but using axes coordinates
                    a = self.ax.text(x,y-0.03,f"{storm.name.title()}",ha='center',va='top',transform=self.ax.transAxes,zorder=30,fontweight='bold',fontsize=invest_prop['fontsize'],clip_on=True,bbox=bbox_prop)
                    a.set_path_effects([path_effects.Stroke(linewidth=0.5,foreground='w'),path_effects.Normal()])
                    
                    #Plot archive track
                    if invest_prop['linewidth'] > 0:
                        self.ax.plot(storm.lon,storm.lat,color=invest_prop['linecolor'],linestyle=invest_prop['linestyle'],zorder=5,transform=ccrs.PlateCarree())
                
                #Plot TCs
                elif storm.invest == False and storm_prop['plot'] == True:
                    
                    #Label dot
                    #self.ax.plot(storm.lon[-1],storm.lat[-1],'o',ms=14,color='none',mec='k',mew=3.0,transform=ccrs.PlateCarree(),zorder=5)
                    #self.ax.plot(storm.lon[-1],storm.lat[-1],'o',ms=14,color='none',mec='r',mew=2.0,transform=ccrs.PlateCarree(),zorder=6)
                    category = str(wind_to_category(storm.vmax[-1]))
                    color = get_colors_sshws(storm.vmax[-1])
                    if category == "0": category = 'S'
                    if category == "-1": category = 'D'
                    if np.isnan(storm.vmax[-1]):
                        category = 'U'
                        color = 'w'
                    
                    if storm_prop['fillcolor'] == 'none':
                        self.ax.plot(storm.lon[-1],storm.lat[-1],'o',ms=storm_prop['ms'],color='none',mec='k',mew=3.0,transform=ccrs.PlateCarree(),zorder=20)
                        self.ax.plot(storm.lon[-1],storm.lat[-1],'o',ms=storm_prop['ms'],color='none',mec='r',mew=2.0,transform=ccrs.PlateCarree(),zorder=21)
                    
                    else:
                        if storm_prop['fillcolor'] != 'category': color = storm_prop['fillcolor']
                        self.ax.plot(storm.lon[-1],storm.lat[-1],'o',ms=storm_prop['ms']*1.14,color='k',transform=ccrs.PlateCarree(),zorder=20)
                        self.ax.plot(storm.lon[-1],storm.lat[-1],'o',ms=storm_prop['ms'],color=color,transform=ccrs.PlateCarree(),zorder=21)
                        
                        if storm_prop['label_category'] == True:
                            color = mcolors.to_rgb(color)
                            red,green,blue = color
                            textcolor = 'w'
                            if (red*0.299 + green*0.587 + blue*0.114) > (160.0/255.0): textcolor = 'k'
                            self.ax.text(storm.lon[-1],storm.lat[-1],category,fontsize=storm_prop['ms']*0.83,ha='center',va='center',color=textcolor,
                                         zorder=30,transform=ccrs.PlateCarree(),clip_on=True)
                    
                    #Transform coordinates for label
                    x1, y1 = self.ax.projection.transform_point(storm.lon[-1], storm.lat[-1], ccrs.PlateCarree())
                    x2, y2 = self.ax.transData.transform((x1, y1))
                    x, y = self.ax.transAxes.inverted().transform((x2, y2))

                    # plot same point but using axes coordinates
                    a = self.ax.text(x,y-0.03,f"{storm.name.title()}",ha='center',va='top',transform=self.ax.transAxes,zorder=30,fontweight='bold',fontsize=storm_prop['fontsize'],clip_on=True,bbox=bbox_prop)
                    a.set_path_effects([path_effects.Stroke(linewidth=0.5,foreground='w'),path_effects.Normal()])
                    
                    #Plot archive track
                    if storm_prop['linewidth'] > 0:
                        self.ax.plot(storm.lon,storm.lat,color=storm_prop['linecolor'],linestyle=storm_prop['linestyle'],zorder=5,transform=ccrs.PlateCarree())
                        
                    #Plot cone
                    if cone_prop['plot'] == True:
                        
                        #Retrieve cone
                        forecast_dict = forecasts[storm_idx]
                        
                        try:
                            #Fix longitudes for cone if crossing dateline
                            if np.nanmax(forecast_dict['lon']) > 165 or np.nanmin(forecast_dict['lon']) < -165:
                                forecast_dict['lon'] = [i if i > 0 else i + 360.0 for i in forecast_dict['lon']]
                            cone = generate_nhc_cone(forecast_dict,storm.basin,cone_days=cone_prop['days'])

                            #Plot cone
                            if cone_prop['alpha'] > 0 and storm.basin in constants.NHC_BASINS:
                                cone_2d = cone['cone']
                                cone_2d = ndimage.gaussian_filter(cone_2d,sigma=0.5,order=0)
                                self.ax.contourf(cone['lon2d'],cone['lat2d'],cone_2d,[0.9,1.1],colors=['#ffffff','#ffffff'],alpha=cone_prop['alpha'],zorder=4,transform=ccrs.PlateCarree())
                                self.ax.contour(cone['lon2d'],cone['lat2d'],cone_2d,[0.9],linewidths=1.5,colors=['k'],zorder=4,transform=ccrs.PlateCarree())

                            #Plot center line & account for dateline crossing
                            if cone_prop['linewidth'] > 0:
                                self.ax.plot(cone['center_lon'],cone['center_lat'],color='w',linewidth=2.5,zorder=5,transform=ccrs.PlateCarree())
                                self.ax.plot(cone['center_lon'],cone['center_lat'],color='k',linewidth=2.0,zorder=6,transform=ccrs.PlateCarree()) 

                            #Plot forecast dots
                            for idx in range(len(forecast_dict['lat'])):
                                if forecast_dict['fhr'][idx]/24.0 > cone_prop['days']: continue
                                if cone_prop['ms'] == 0: continue
                                color = get_colors_sshws(forecast_dict['vmax'][idx])
                                if np.isnan(forecast_dict['vmax'][idx]): color = 'w'
                                if cone_prop['fillcolor'] != 'category': color = cone_prop['fillcolor']
                                
                                marker = 'o'
                                if forecast_dict['type'][idx] not in constants.TROPICAL_STORM_TYPES: marker = '^'
                                if np.isnan(forecast_dict['vmax'][idx]): marker = 'o'
                                self.ax.plot(forecast_dict['lon'][idx],forecast_dict['lat'][idx],marker,ms=cone_prop['ms'],mfc=color,mec='k',zorder=7,transform=ccrs.PlateCarree(),clip_on=True)

                                if cone_prop['label_category'] == True and marker == 'o':
                                    category = str(wind_to_category(forecast_dict['vmax'][idx]))
                                    if category == "0": category = 'S'
                                    if category == "-1": category = 'D'
                                    if np.isnan(forecast_dict['vmax'][idx]): category = 'U'

                                    color = mcolors.to_rgb(color)
                                    red,green,blue = color
                                    textcolor = 'w'
                                    if (red*0.299 + green*0.587 + blue*0.114) > (160.0/255.0): textcolor = 'k'

                                    self.ax.text(forecast_dict['lon'][idx],forecast_dict['lat'][idx],category,fontsize=cone_prop['ms']*0.81,ha='center',va='center',color=textcolor,
                                                zorder=19,transform=ccrs.PlateCarree(),clip_on=True)
                        except:
                            pass
        
        #--------------------------------------------------------------------------------------
        
        #Plot domain
        bound_w,bound_e,bound_s,bound_n = self.set_projection(domain)
        
        #Plot parallels and meridians
        #This is currently not supported for all cartopy projections.
        if map_prop['plot_gridlines']:
            try:
                self.plot_lat_lon_lines([bound_w,bound_e,bound_s,bound_n])
            except:
                pass
        
        #--------------------------------------------------------------------------------------
        
        #Add title
        self.ax.set_title(f"Summary{add_title}",loc='left',fontsize=17,fontweight='bold')
        self.ax.set_title(f"Valid: {valid_date.strftime('%H UTC %d %b %Y')}",loc='right',fontsize=13)

        #--------------------------------------------------------------------------------------
        
        #Add credit
        credit_text = self.plot_credit()
        self.add_credit(credit_text)
        
        #--------------------------------------------------------------------------------------
                
        #Add legend
        #self.add_legend(prop,segmented_colors,levels,cmap,storm_data)
                
        #-----------------------------------------------------------------------------------------
        
        #Save image if specified
        if save_path is not None and isinstance(save_path,str) == True:
            plt.savefig(save_path,bbox_inches='tight')
        
        #Return axis if specified, otherwise display figure
        return self.ax
