'How to use MultiSelection in Altair to select other elements (from a mapping/dictionary)?

I'm trying to figure out how to use MultiSelection in Altair to select an element, along with other elements which can be defined by a mapping. In my specific case, I want to click on a country, and then that country is highlighted one color, and all of it's neighboring (bordering) countries are highlighted in another color. I have a TopoJSON dataset with every country and its ID (id), along with the IDs of its neighboring countries (neighbors). I'm just not sure how to configure altair.MultiSelection to be able to select those neighboring countries (from their IDs) when an initial country is selected.

import altair as alt

#binding = alt.Binding(from_dict={1000 : 528})

countries = alt.topo_feature('countries-mod.json', 'countries')
#highlight = alt.selection_single(on='mouseover', fields=['id'], empty='none')
click_highlight = alt.selection_multi(on='click', fields=['id'], empty='none', toggle="true")

alt.Chart(countries).mark_geoshape(
    fill='#666666',
    stroke='white'
).project(
    type= 'mercator',
    scale= 325 ).properties(
    title='Europe (Mercator)',
    width=400, height=300
).configure_projection(
    center= [15,55]
).mark_geoshape().encode(
    color=alt.condition(click_highlight, alt.value('red'), alt.Color("#00000f:N")),
    tooltip='id:N'
).add_selection(click_highlight)

JSON file here: https://drive.google.com/file/d/1nzEPECp-2nZdk_HShpfgsIe08lTHllrc/view?usp=sharing



Solution 1:[1]

You can use the vega expression function indexof(array, value), together with accessing the values to highlight from the clicked selection via selection_name.column_name (this is click.highlight in my example below). Unfortunately there are a few gotchas that makes it a bit difficult to figure out the exact syntax.

Most notably, it seems like the click selection sends its values as a list by default, but as "Undefined" if nothing is selected. indexof does not work for an undefined value/empty selection (for either multi or single select), and will return an empty plot with nothing to click so we need to make sure that we provide one or multiple init value(s) as a list of dictionaries when creating the selection (you could build this up programmatically as long as it follow the format in my example below). indexof returns a -1 if no match is found, so we can use this in our comparison.

Altogether, an example could look something like what I have below where the outlined point(s) are the currently selected one(s) and the ones with full opacity are the ones that are highlighted based on another column in the dataframe (neighboring countries in your example).

import altair as alt
import pandas as pd


df = pd.DataFrame({
    'x': [0.5, 1, 1.5],
    'y': [0.5, 1, 1.5],
    'country': ['Canada', 'Mexico', 'USA'],
    'highlight': [['Canada'], ['USA'], ['USA', 'Canada']]
})

click = alt.selection_multi(
    fields=['highlight', 'country'],
    name='click',
    empty='none',
    init=[{'highlight': 'Canada'}, {'highlight': 'USA'}, {'country': 'USA'}]
)

alt.Chart(df).mark_circle(
    size=200,
    strokeWidth=3
).encode(
    x='x',
    y='y',
    opacity=alt.condition('indexof(click.highlight, datum.country) != -1', alt.value(1), alt.value(0.4)),
    stroke=alt.condition('indexof(click.country, datum.country) != -1', alt.value('#ff7f0e'), alt.value('#1f77b4')),
    tooltip='country'
).add_selection(
    click
)

enter image description here

To understand exactly what is going on, we can construct a more intricate example that computes as few different help columns and displays them on the side of the chart as a table. Note that in the example below I have also included a check for whether click.country is an array and wrap it into an array if needed, which allows us to start without an init in the selection (you can use this logic in the example above as well).

click = alt.selection_multi(
    fields=['highlight', 'country'],
    name='click',
    empty='none',
)

scatter = alt.Chart(df).mark_circle(
    size=200,
    strokeWidth=3
).transform_calculate(
    clicked = 'click.country',
    indexof = 'indexof(datum.country, datum.highlight)',
    clicked_indexof = 'indexof(isArray(click.highlight) ? click.highlight : [click.highlight], datum.country)',
).encode(
    x='x',
    y='y',
    opacity=alt.condition('datum.clicked_indexof != -1', alt.value(1), alt.value(0.4)),
    stroke=alt.condition('indexof(isArray(click.country) ? click.country : [click.country], datum.country) != -1', alt.value('#ff7f0e'), alt.value('#1f77b4')),
    tooltip='country'
).add_selection(
    click
)

# Base chart for data tables
ranked_text = scatter.mark_text(
    align='right',
    fontSize=14,
    fontWeight=800
).encode(
    x=alt.X(),
    y=alt.Y('row_number:O', axis=None),
    stroke=alt.Stroke(),  # A stroke makes the text hard to read
    color=alt.condition('indexof(isArray(click.country) ? click.country : [click.country], datum.country) != -1', alt.value('#ff7f0e'), alt.value('#1f77b4')),
).transform_window(
    row_number='row_number()'
).transform_window(
    rank='rank(row_number)'
).transform_filter(
    alt.datum.rank<16
)

# Create a table text column for each desired field in the data source
table_columns = []
for column in ['country', 'highlight', 'indexof', 'clicked', 'clicked_indexof']:
    table_columns.append(
        ranked_text.encode(
            text=f'{column}:N'
        ).properties(
            title=alt.TitleParams(
                text=column,
                align='right'
            )
        )
    )
 
(scatter | alt.hconcat(*table_columns)).configure_view(strokeWidth=0)

enter image description here

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 joelostblom