From 76bfa6c699a28927eeb290b89bb2722d320232fe Mon Sep 17 00:00:00 2001 From: Andrew Schonfeld Date: Fri, 22 May 2020 09:03:49 -0400 Subject: [PATCH] 1.8.14 * #168, updated ddefault colorscale for heatmaps to be Jet * #152, added scattermapbox as a valid map type --- dtale/charts/utils.py | 15 ++++- dtale/dash_application/charts.py | 50 ++++++++++++++++- dtale/dash_application/layout.py | 95 ++++++++++++++++++++++++++++---- dtale/dash_application/views.py | 23 ++++++-- dtale/static/css/dash.css | 10 +++- tests/dtale/test_dash.py | 52 ++++++++++++++++- 6 files changed, 220 insertions(+), 25 deletions(-) diff --git a/dtale/charts/utils.py b/dtale/charts/utils.py index 80aedaa6..a55f3cdc 100644 --- a/dtale/charts/utils.py +++ b/dtale/charts/utils.py @@ -10,6 +10,19 @@ YAXIS_CHARTS = ['line', 'bar', 'scatter'] ZAXIS_CHARTS = ['heatmap', '3d_scatter', 'surface'] MAX_GROUPS = 30 +MAPBOX_TOKEN = None + + +def get_mapbox_token(): + global MAPBOX_TOKEN + + return MAPBOX_TOKEN + + +def set_mapbox_token(token): + global MAPBOX_TOKEN + + MAPBOX_TOKEN = token def valid_chart(chart_type=None, x=None, y=None, z=None, **inputs): @@ -33,7 +46,7 @@ def valid_chart(chart_type=None, x=None, y=None, z=None, **inputs): map_type = inputs.get('map_type') if map_type == 'choropleth' and all(inputs.get(p) is not None for p in ['loc_mode', 'loc', 'map_val']): return True - elif map_type == 'scattergeo' and all(inputs.get(p) is not None for p in ['lat', 'lon']): + elif map_type in ['scattergeo', 'mapbox'] and all(inputs.get(p) is not None for p in ['lat', 'lon']): return True return False diff --git a/dtale/dash_application/charts.py b/dtale/dash_application/charts.py index 42a71e65..df9b6fbb 100644 --- a/dtale/dash_application/charts.py +++ b/dtale/dash_application/charts.py @@ -89,8 +89,11 @@ def chart_url_querystring(params, data=None, group_filter=None): if chart_type == 'bar': base_props += ['barmode', 'barsort'] elif chart_type == 'maps': - if params.get('map_type') == 'scattergeo': + map_type = params.get('map_type') + if map_type == 'scattergeo': base_props += ['map_type', 'lat', 'lon', 'map_val', 'scope', 'proj'] + elif map_type == 'mapbox': + base_props += ['map_type', 'lat', 'lon', 'map_val', 'mapbox_style'] else: base_props += ['map_type', 'loc_mode', 'loc', 'map_val'] base_props += ['map_group'] @@ -1013,8 +1016,9 @@ def map_builder(data_id, export=False, **inputs): try: if not valid_chart(**inputs): return None, None - props = ['map_type', 'loc_mode', 'loc', 'lat', 'lon', 'map_val', 'scope', 'proj', 'agg', 'animate_by'] - map_type, loc_mode, loc, lat, lon, map_val, scope, proj, agg, animate_by = (inputs.get(p) for p in props) + props = ['map_type', 'loc_mode', 'loc', 'lat', 'lon', 'map_val', 'scope', 'proj', 'mapbox_style', 'agg', + 'animate_by'] + map_type, loc_mode, loc, lat, lon, map_val, scope, proj, style, agg, animate_by = (inputs.get(p) for p in props) map_group, group_val = (inputs.get(p) for p in ['map_group', 'group_val']) raw_data = run_query( global_state.get_data(data_id), @@ -1069,6 +1073,46 @@ def build_frame(df): config=dict(topojsonURL='/maps/'), figure=figure_cfg ) + elif map_type == 'mapbox': + from dtale.charts.utils import get_mapbox_token + data, code = retrieve_chart_data(raw_data, lat, lon, map_val, animate_by, map_group, group_val=group_val) + if agg is not None: + data, agg_code = build_agg_data(raw_data, lat, lon, {}, agg, z=map_val, animate_by=animate_by) + code += agg_code + + mapbox_layout = {'style': style} + mapbox_token = get_mapbox_token() + if mapbox_token is not None: + mapbox_layout['accesstoken'] = mapbox_token + # if test_plotly_version('4.5.0') and animate_by is None: + # geo_layout['fitbounds'] = 'locations' + if len(mapbox_layout): + layout['mapbox'] = mapbox_layout + + chart_kwargs = dict(lon=data[lon], lat=data[lat], mode='markers', marker=dict(color='darkblue')) + if map_val is not None: + chart_kwargs['text'] = data[map_val] + chart_kwargs['marker'] = dict( + color=data[map_val], cmin=data[map_val].min(), cmax=data[map_val].max(), + colorscale=inputs.get('colorscale') or 'Jet', + colorbar_title=map_val + ) + figure_cfg = dict(data=[go.Scattermapbox(**chart_kwargs)], layout=layout) + if animate_by is not None: + def build_frame(df): + frame = dict(lon=df[lon], lat=df[lat], mode='markers') + if map_val is not None: + frame['text'] = df[map_val] + frame['marker'] = dict(color=df[map_val]) + return frame + + update_cfg_w_frames(figure_cfg, *build_map_frames(data, animate_by, build_frame)) + chart = graph_wrapper( + id='scattermapbox-graph', + style={'margin-right': 'auto', 'margin-left': 'auto', 'height': '95%'}, + config=dict(topojsonURL='/maps/'), + figure=figure_cfg + ) else: data, code = retrieve_chart_data(raw_data, loc, map_val, map_group, animate_by, group_val=group_val) if agg is not None: diff --git a/dtale/dash_application/layout.py b/dtale/dash_application/layout.py index 77d3c9f9..1e8c954b 100644 --- a/dtale/dash_application/layout.py +++ b/dtale/dash_application/layout.py @@ -37,12 +37,14 @@ def base_layout(github_fork, app_root, **kwargs): Fork me on GitHub ''' + favicon_path = '../../images/favicon.png' if is_app_root_defined(app_root): webroot_html = ''' '''.format(app_root=app_root) + favicon_path = '{}/images/favicon.png'.format(app_root) return ''' @@ -50,7 +52,7 @@ def base_layout(github_fork, app_root, **kwargs): {webroot_html} {metas} D-Tale Charts - + {css} @@ -97,7 +99,8 @@ def base_layout(github_fork, app_root, **kwargs): back_to_data_padding=back_to_data_padding, webroot_html=webroot_html, github_fork_html=github_fork_html, - app_root=app_root or '' + app_root=app_root if is_app_root_defined(app_root) else '', + favicon_path=favicon_path ) @@ -189,7 +192,11 @@ def build_option(value, label=None): FREQS = ['H', 'H2', 'WD', 'D', 'W', 'M', 'Q', 'Y'] FREQ_LABELS = dict(H='Hourly', H2='Hour', WD='Weekday', W='Weekly', M='Monthly', Q='Quarterly', Y='Yearly') -MAP_TYPES = [dict(value='choropleth'), dict(value='scattergeo', label='ScatterGeo')] +MAP_TYPES = [ + dict(value='choropleth', image=True), + dict(value='scattergeo', label='ScatterGeo', image=True), + dict(value='mapbox') +] SCOPES = ['world', 'usa', 'europe', 'asia', 'africa', 'north america', 'south america'] PROJECTIONS = ['equirectangular', 'mercator', 'orthographic', 'natural earth', 'kavrayskiy7', 'miller', 'robinson', 'eckert4', 'azimuthal equal area', 'azimuthal equidistant', 'conic equal area', 'conic conformal', @@ -229,6 +236,40 @@ def build_proj_hover(proj): ) +def build_mapbox_token_children(): + from dtale.charts.utils import get_mapbox_token + msg = 'To access additional styles enter a token here...' + if get_mapbox_token() is None: + msg = 'Change your token here...' + return [ + html.I(className='ico-help-outline', style=dict(color='white')), + html.Div( + [ + html.Span('Mapbox Access Token:'), + dcc.Input(id='mapbox-token-input', type='text', placeholder=msg, className='form-control', + value='', style={'lineHeight': 'inherit'}) + ], + className='hoverable__content', + style=dict(width='20em', right='-1.45em') + ) + ] + + +def build_mapbox_token_hover(): + return html.Span( + [ + 'Style', + html.Div( + build_mapbox_token_children(), + className='ml-3 hoverable', + style=dict(borderBottom='none'), + id='token-hover' + ) + ], + className='input-group-addon' + ) + + LOC_MODE_INFO = { 'ISO-3': dict( url=html.A('ISO-3', href='https://en.wikipedia.org/wiki/ISO_3166-1_alpha-3', target='_blank'), @@ -412,6 +453,17 @@ def build_map_options(df, type='choropleth', loc=None, lat=None, lon=None, map_v return loc_options, lat_options, lon_options, val_options +def build_mapbox_style_options(): + from dtale.charts.utils import get_mapbox_token + free_styles = ['open-street-map', 'carto-positron', 'carto-darkmatter', 'stamen-terrain', 'stamen-toner', + 'stamen-watercolor'] + token_styles = ['basic', 'streets', 'outdoors', 'light', 'dark', 'satellite', 'satellite-streets'] + styles = free_styles + if get_mapbox_token() is not None: + styles += token_styles + return [build_option(v) for v in styles] + + def bar_input_style(**inputs): """ Sets display CSS property for bar chart inputs @@ -478,10 +530,11 @@ def build_group_val_options(df, group_cols): def build_map_type_tabs(map_type): def _build_hoverable(): for t in MAP_TYPES: - yield html.Div([ - html.Span(t.get('label', t['value'].capitalize())), - html.Img(src=build_img_src(t['value'], img_type='map_type')) - ], className='col-md-6') + if t.get('image', False): + yield html.Div([ + html.Span(t.get('label', t['value'].capitalize())), + html.Img(src=build_img_src(t['value'], img_type='map_type')) + ], className='col-md-6') return html.Div( [dcc.Tabs( @@ -493,7 +546,7 @@ def _build_hoverable(): html.Div(list(_build_hoverable()), className='row'), className='hoverable__content map-types' )], - style=dict(paddingLeft=15, borderBottom='none'), className="pr-5 hoverable" + style=dict(paddingLeft=15, borderBottom='none', width='20em'), className="hoverable" ) @@ -544,11 +597,11 @@ def charts_layout(df, settings, **inputs): show_map = chart_type == 'maps' map_props = ['map_type', 'loc_mode', 'loc', 'lat', 'lon', 'map_val'] map_type, loc_mode, loc, lat, lon, map_val = (inputs.get(p) for p in map_props) - map_scope, proj = (inputs.get(p) for p in ['scope', 'proj']) + map_scope, proj, mapbox_style = (inputs.get(p) for p in ['scope', 'proj', 'mapbox_style']) loc_options, lat_options, lon_options, map_val_options = build_map_options(df, type=map_type, loc=loc, lat=lat, lon=lon, map_val=map_val) cscale_style = colorscale_input_style(**inputs) - default_cscale = 'Greens' if chart_type == 'heatmap' else 'Reds' + default_cscale = 'Jet' if chart_type == 'heatmap' else 'Reds' group_val_style, main_input_class = main_inputs_and_group_val_display(inputs) group_val = [json.dumps(gv) for gv in inputs.get('group_val') or []] @@ -698,7 +751,7 @@ def show_map_style(show): ), id='map-lat-input', label_class='input-group-addon d-block pt-1 pb-0', - style=show_map_style(map_type == 'scattergeo') + style=show_map_style(map_type in ['scattergeo', 'mapbox']) ), build_input( [html.Div('Lon'), html.Small('(Agg By)')], @@ -711,7 +764,7 @@ def show_map_style(show): ), id='map-lon-input', label_class='input-group-addon d-block pt-1 pb-0', - style=show_map_style(map_type == 'scattergeo') + style=show_map_style(map_type in ['scattergeo', 'mapbox']) ), build_input('Scope', dcc.Dropdown( id='map-scope-dropdown', @@ -719,6 +772,24 @@ def show_map_style(show): style=dict(width='inherit'), value=map_scope or 'world' ), id='map-scope-input', style=show_map_style(map_type == 'scattergeo')), + html.Div( + [ + html.Div( + [ + build_mapbox_token_hover(), + dcc.Dropdown( + id='map-mapbox-style-dropdown', + options=build_mapbox_style_options(), + style=dict(width='inherit'), + value=mapbox_style or 'open-street-map' + ) + ], + className='input-group mr-3', + ) + ], + id='map-mapbox-style-input', className='col-auto', + style=show_map_style(map_type == 'mapbox') + ), html.Div( [ html.Div( diff --git a/dtale/dash_application/views.py b/dtale/dash_application/views.py index 48a45dfa..4aa4cf63 100644 --- a/dtale/dash_application/views.py +++ b/dtale/dash_application/views.py @@ -16,6 +16,7 @@ build_input_options, build_loc_mode_hover_children, build_map_options, + build_mapbox_style_options, build_proj_hover_children, charts_layout, colorscale_input_style, @@ -197,6 +198,7 @@ def input_data(_ts, chart_type, x, y_multi, y_single, z, group, group_val, agg, Output('map-lat-input', 'style'), Output('map-lon-input', 'style'), Output('map-scope-input', 'style'), + Output('map-mapbox-style-input', 'style'), Output('map-proj-input', 'style'), Output('proj-hover', 'style'), Output('proj-hover', 'children'), @@ -211,16 +213,19 @@ def input_data(_ts, chart_type, x, y_multi, y_single, z, group, group_val, agg, Input('map-lon-dropdown', 'value'), Input('map-val-dropdown', 'value'), Input('map-scope-dropdown', 'value'), + Input('map-mapbox-style-dropdown', 'value'), Input('map-proj-dropdown', 'value'), Input('map-group-dropdown', 'value') ], [State('url', 'pathname')] ) - def map_data(map_type, loc_mode, loc, lat, lon, map_val, scope, proj, group, pathname): + def map_data(map_type, loc_mode, loc, lat, lon, map_val, scope, style, proj, group, pathname): data_id = get_data_id(pathname) map_type = map_type or 'choropleth' if map_type == 'choropleth': map_data = dict(map_type=map_type, loc_mode=loc_mode, loc=loc, map_val=map_val) + elif map_type == 'mapbox': + map_data = dict(map_type=map_type, lat=lat, lon=lon, map_val=map_val, mapbox_style=style) else: map_data = dict(map_type=map_type, lat=lat, lon=lon, map_val=map_val, scope=scope, proj=proj) @@ -230,17 +235,27 @@ def map_data(map_type, loc_mode, loc, lat, lon, map_val, scope, proj, group, pat loc_options, lat_options, lon_options, map_val_options = build_map_options(df, type=map_type, loc=loc, lat=lat, lon=lon, map_val=map_val) choro_style = {} if map_type == 'choropleth' else {'display': 'none'} + coord_style = {} if map_type in ['scattergeo', 'mapbox'] else {'display': 'none'} scatt_style = {} if map_type == 'scattergeo' else {'display': 'none'} + mapbox_style = {} if map_type == 'mapbox' else {'display': 'none'} proj_hover_style = {'display': 'none'} if proj is None else dict(borderBottom='none') proj_hopver_children = build_proj_hover_children(proj) loc_mode_hover_style = {'display': 'none'} if loc_mode is None else dict(borderBottom='none') loc_mode_children = build_loc_mode_hover_children(loc_mode) return ( - map_data, loc_options, lat_options, lon_options, map_val_options, choro_style, choro_style, scatt_style, - scatt_style, scatt_style, scatt_style, proj_hover_style, proj_hopver_children, loc_mode_hover_style, - loc_mode_children + map_data, loc_options, lat_options, lon_options, map_val_options, choro_style, choro_style, coord_style, + coord_style, scatt_style, mapbox_style, scatt_style, proj_hover_style, proj_hopver_children, + loc_mode_hover_style, loc_mode_children ) + @dash_app.callback(Output('map-mapbox-style-dropdown', 'options'), [Input('mapbox-token-input', 'value')]) + def update_mapbox_token(token): + from dtale.charts.utils import set_mapbox_token + + if token: + set_mapbox_token(token) + return build_mapbox_style_options() + @dash_app.callback( [ Output('y-multi-input', 'style'), diff --git a/dtale/static/css/dash.css b/dtale/static/css/dash.css index 4ec7ef26..2ccbcc4a 100644 --- a/dtale/static/css/dash.css +++ b/dtale/static/css/dash.css @@ -526,8 +526,8 @@ div.modebar > div.modebar-group:first-child /* hide plotly "export to png" */ min-width: 10em; } -#map-inputs > div.col-auto + div.col-auto { - padding-left: 0; +#map-inputs > div.col-auto { + padding-right: 0; } div#non-map-inputs > div, @@ -536,3 +536,9 @@ div#chart-inputs > div { padding-bottom: 0.25rem !important; } +#mapbox-token-input { + width: 100%; + border-top-left-radius: .25rem; + border-bottom-left-radius: .25rem; +} + diff --git a/tests/dtale/test_dash.py b/tests/dtale/test_dash.py index 90b56bf1..2b8bd6af 100644 --- a/tests/dtale/test_dash.py +++ b/tests/dtale/test_dash.py @@ -156,8 +156,8 @@ def test_map_data(unittest): '..map-input-data.data...map-loc-dropdown.options...map-lat-dropdown.options...' 'map-lon-dropdown.options...map-val-dropdown.options...map-loc-mode-input.style...' 'map-loc-input.style...map-lat-input.style...map-lon-input.style...map-scope-input.style...' - 'map-proj-input.style...proj-hover.style...proj-hover.children...loc-mode-hover.style...' - 'loc-mode-hover.children..' + 'map-mapbox-style-input.style...map-proj-input.style...proj-hover.style...proj-hover.children...' + 'loc-mode-hover.style...loc-mode-hover.children..' ), 'changedPropIds': ['map-type-tabs.value'], 'inputs': [ @@ -168,6 +168,7 @@ def test_map_data(unittest): {'id': 'map-lon-dropdown', 'property': 'value', 'value': None}, {'id': 'map-val-dropdown', 'property': 'value', 'value': None}, {'id': 'map-scope-dropdown', 'property': 'value', 'value': 'world'}, + {'id': 'map-mapbox-style-dropdown', 'property': 'value', 'value': 'open-street-map'}, {'id': 'map-proj-dropdown', 'property': 'value', 'value': None}, {'id': 'map-group-dropdown', 'property': 'value', 'value': None}, ], @@ -185,6 +186,12 @@ def test_map_data(unittest): unittest.assertEqual(resp_data['map-loc-mode-input']['style'], {'display': 'none'}) unittest.assertEqual(resp_data['map-lat-input']['style'], {}) + params['inputs'][0]['value'] = 'mapbox' + response = c.post('/charts/_dash-update-component', json=params) + resp_data = response.get_json()['response'] + unittest.assertEqual(resp_data['map-loc-mode-input']['style'], {'display': 'none'}) + unittest.assertEqual(resp_data['map-lat-input']['style'], {}) + params['inputs'][0]['value'] = 'choropleth' params['inputs'][-1]['value'] = 'foo' params['inputs'][-2]['value'] = 'hammer' @@ -1046,7 +1053,46 @@ def test_chart_building_map(unittest, state_data, scattergeo_data): params = build_chart_params(pathname, inputs, chart_inputs, map_inputs=map_inputs) response = c.post('/charts/_dash-update-component', json=params) resp_data = response.get_json()['response'] - print(resp_data['chart-content']['children']) + title = resp_data['chart-content']['children']['props']['children'][1]['props']['figure']['layout']['title'] + assert title['text'] == 'Map of val (No Aggregation) (cat == {})'.format(group_val) + + map_inputs['map_group'] = None + inputs['group_val'] = None + chart_inputs['animate_by'] = 'cat' + params = build_chart_params(pathname, inputs, chart_inputs, map_inputs=map_inputs) + response = c.post('/charts/_dash-update-component', json=params) + resp_data = response.get_json()['response'] + assert 'frames' in resp_data['chart-content']['children']['props']['children'][1]['props']['figure'] + + map_inputs['map_val'] = 'foo' + params = build_chart_params(pathname, inputs, chart_inputs, map_inputs=map_inputs) + response = c.post('/charts/_dash-update-component', json=params) + resp_data = response.get_json()['response'] + error = resp_data['chart-content']['children']['props']['children'][1]['props']['children'] + assert "'foo'" in error + + with app.test_client() as c: + with ExitStack() as stack: + df, _ = views.format_data(scattergeo_data) + stack.enter_context(mock.patch('dtale.global_state.DATA', {c.port: df})) + pathname = path_builder(c.port) + inputs = {'chart_type': 'maps', 'agg': 'raw'} + map_inputs = {'map_type': 'mapbox', 'lat': 'lat', 'lon': 'lon', 'map_val': 'val'} + chart_inputs = {'colorscale': 'Reds'} + params = build_chart_params(pathname, inputs, chart_inputs, map_inputs=map_inputs) + response = c.post('/charts/_dash-update-component', json=params) + chart_markup = response.get_json()['response']['chart-content']['children']['props']['children'][1] + unittest.assertEqual( + chart_markup['props']['figure']['layout']['title'], + {'text': 'Map of val (No Aggregation)'} + ) + + map_inputs['map_group'] = 'cat' + group_val = str(df['cat'].values[0]) + inputs['group_val'] = [dict(cat=group_val)] + params = build_chart_params(pathname, inputs, chart_inputs, map_inputs=map_inputs) + response = c.post('/charts/_dash-update-component', json=params) + resp_data = response.get_json()['response'] title = resp_data['chart-content']['children']['props']['children'][1]['props']['figure']['layout']['title'] assert title['text'] == 'Map of val (No Aggregation) (cat == {})'.format(group_val)