Select to view content in your preferred language

Changing symbology dynamically based on selector filters in ArcGIS Dashboards

740
2
10-15-2025 08:25 AM
Labels (1)
GOffenberg
Emerging Contributor

Hi all,

I'm creating a dashboard that shows map data for different indicators and themes.
This is currently based on one feature layer with a single symbology defined in Map Viewer.

I was wondering if it’s possible to change the symbology dynamically based on the filters applied through the different selectors (Scale, Theme, and Indicator).
For example, one indicator might have much lower values than another, so the current symbology range doesn’t fit all cases.

Could this be achieved with Arcade or data expressions, or is there another recommended approach?

Kind regards,

Geert Offenberg

2 Replies
clt_cabq
Frequent Contributor

I've done something like this by having the same layer with different symbols applied and having them render only when being selected. For instance i have a set of points that normally just show location and can be selected by an administrative boundary, that same layer can also be selected using two different number selectors  based on different metrics of interest. If this could be done against a single layer that would be interesting, but I suspect not possible even using Arcade. 

BrennanSmith1
Frequent Contributor

Sorry for the late reply and the wall of text, but if I understand correctly, I believe we have implemented something similar in a dashboard I called 'Dynamic choropleth mapping'.  It was quite the task to figure out, so I want to document it here in case it helps someone else in the future.

We have an indicator for "Select Geography" where the user can pick between different sets of polygon features, like Council District, School District, or Zip Code.  Once a geography is selected, a dashboard list of the different variables / parameters is populated, like Average Household Size, Population, Median Income, etc.  Once we have selected both the geography and the variable, the map updates with color gradient symbology. We use another list to display a custom legend for that map. This was just a proof of concept, but it seemed to work. Note in the screenshots that the geography, symbology layer, and color scale are different, but it is all fed by a single hosted feature layer.

BrennanSmith1_0-1777399792006.png

 

BrennanSmith1_1-1777399816230.png

It was a bit tricky to set up.  I created a single feature class that contained every combination of geography (i.e. polygon sets) and variable (i.e. whatever census metrics we were interested in). This python script did that first part.  You end up with a feature class that has the geography (polygon source), feature_na (unique identifier for each feature within a source, e.g. zip code), metric_name (the variable you want to plot), value (the value of the metric), and filter_list (the concatenated string of geography source and metric name, used in the dashboard).

import arcpy
import pandas as pd
import os

# ==========================================
# 0. HELPER FUNCTIONS
# ==========================================

def arc_to_df(in_fc, input_fields=None, query=""):
    """Converts an arcpy table/feature class to a pandas DataFrame."""
    OIDFieldName = arcpy.Describe(in_fc).OIDFieldName
    if input_fields:
        final_fields = [OIDFieldName] + input_fields
    else:
        final_fields = [f.name for f in arcpy.ListFields(in_fc) if f.type not in ['Geometry','Blob']]
    
    data = [row for row in arcpy.da.SearchCursor(in_fc, final_fields, where_clause=query)]
    fc_dataframe = pd.DataFrame(data, columns=final_fields)
    fc_dataframe = fc_dataframe.set_index(OIDFieldName, drop=True)
    return fc_dataframe

# ==========================================
# 1. CONFIGURATION
# ==========================================

#This assumes you have already assembled a master table with every input geography and output metric.
hosted_table = "Demo_Data_Table" 

# Dictionary of { "Geography Type Name" : "Path to Feature Class or name in TOC" }
geo_inputs = {
    "Council District": "CD_Geo",
    "Constituent Services": "CS_Geo",
    "Zipcode": "Zip_Geo_Clip",
    "Elementary Sch. Dist.":"ElemSch_Geo",
    "Middle Sch. Dist.":"MidSch_Geo",
    "High Sch. Dist.":"HighSch_Geo"
}

# Dictionary of { "Input Field Name" : "Output Metric Description" }
metric_dict = {
    "populationtotals_totpop_cy": "Total Population",
    "populationtotals_dpop_cy":"Daytime Population",
    "householdtotals_tothh_cy": "Number of Households",
    "householdtotals_avghhsz_cy": "Average Household Size", 
    "homevalue_medval_cy":"Median Home Value",
    "householdincome_medhinc_cy":"Median Income"
}

# Output setup
workspace = arcpy.env.scratchGDB
out_fc_name = "opz_dashboard_metrics"
out_fc_path = os.path.join(workspace, out_fc_name)

# ==========================================
# 2. PANDAS DATA PREP (The Melt)
# ==========================================
print("Loading data from hosted table via arc_to_df...")

# Build the list of fields to pull from the hosted table (using lowercase)
## In our case, the geography field defines the polygon source (i.e. the keys in the geo_inputs dict)
## and the feature_na field is the unique descriptions for each individual polygon within a source (i.e. the zip codes)
fields_to_pull = ["geography", "feature_na"] + list(metric_dict.keys())

# Create the dataframe using your function
df = arc_to_df(hosted_table, input_fields=fields_to_pull)

# Ensure the merge key is a string to match the GIS data reliably
df['feature_na'] = df['feature_na'].astype(str)

print("Melting dataframe...")

# The magic 'melt' function: wide format to long format
melted_df = df.melt(
    id_vars=["geography", "feature_na"],          
    value_vars=list(metric_dict.keys()),          
    var_name="metric_code",                       
    value_name="value"                            
)

# Map the readable descriptions from your dictionary
melted_df["metric_name"] = melted_df["metric_code"].map(metric_dict)

# Make field that combines geography and metric name for filtering
melted_df['filter_list'] = melted_df['geography'] + " - " + melted_df['metric_name']

# Drop rows where the metric value is null so you don't draw empty polygons
melted_df = melted_df.dropna(subset=["value"])

# ==========================================
# 3. GEOMETRY LOOKUP
# ==========================================
print("Building geometry lookup dictionary from feature layers...")

# We will store geometries here -> {(geography, feature_na): SHAPE@}
geom_dict = {}

for geo_name, fc_path in geo_inputs.items():
    with arcpy.da.SearchCursor(fc_path, ["feature_na", "SHAPE@"]) as s_cursor:
        for row in s_cursor:
            feat_id = str(row[0]) 
            shape = row[1]
            geom_dict[(geo_name, feat_id)] = shape

# ==========================================
# 4. OUTPUT FEATURE CLASS CREATION
# ==========================================
print(f"Setting up output schema in: {workspace}")

first_layer = list(geo_inputs.values())[0]
desc = arcpy.Describe(first_layer)
sr = desc.spatialReference
geom_type = desc.shapeType

arcpy.env.overwriteOutput = True

arcpy.management.CreateFeatureclass(
    out_path=workspace,
    out_name=out_fc_name,
    geometry_type=geom_type,
    spatial_reference=sr
)

# Lowercase field names applied to the schema
arcpy.management.AddField(out_fc_path, "geography", "TEXT", field_length=50)
arcpy.management.AddField(out_fc_path, "feature_na", "TEXT", field_length=100)
arcpy.management.AddField(out_fc_path, "metric_name", "TEXT", field_length=100)
arcpy.management.AddField(out_fc_path, "value", "DOUBLE")
arcpy.management.AddField(out_fc_path, "filter_list", "TEXT", field_length=200)

# ==========================================
# 5. WRITE TO OUTPUT
# ==========================================
print("Writing merged data and geometries to output feature class...")

insert_fields = ["SHAPE@", "geography", "feature_na", "metric_name", "value","filter_list"]
total_inserted = 0
missing_geometries = 0

with arcpy.da.InsertCursor(out_fc_path, insert_fields) as i_cursor:
    # Iterate through the pandas dataframe rows using itertuples
    for row in melted_df.itertuples(index=False):
        # Accessing the properties via lowercase names based on the dataframe columns
        geo_name = row.geography
        feat_id = str(row.feature_na)
        metric_name = row.metric_name
        value = row.value
        f_list = row.filter_list
        
        # Look up the matching geometry based on the composite key
        shape = geom_dict.get((geo_name, feat_id))
        
        if shape:
            i_cursor.insertRow((shape, geo_name, feat_id, metric_name, value,f_list))
            total_inserted += 1
        else:
            missing_geometries += 1

print(f"Done! {total_inserted} records inserted.")
if missing_geometries > 0:
    print(f"Note: {missing_geometries} records from the hosted table had no matching geometry in the feature classes and were skipped.")

 

We then need to assign a color code to each polygon, based on the unique range of values for that combination of geography and metric. This script assigns a value 1-5 for the symbology_class field, and a color code to the color_hex field. Color schemes were set manually, use your preferred AI bot to save time updating the palettes dictionary. It also sets a unique value symbology for the layer, based on the color_hex field. Attribute mapping could be a cleaner way to achieve this, but doesn't publish online correctly.

import arcpy
import pandas as pd
import numpy as np

# ==========================================
# 1. CONFIGURATION
# ==========================================
# Path to your master output feature class. And to your working Map and Layer name for the sybology customization
fc_path = r"opz_dashboard_metrics"
layer_name = "opz_dashboard_metrics" 
map_name    = "Map"

# Define your specific palettes for known metrics (Light to Dark)
palettes = {
    # Populations (Purples & Reds)
    "Total Population": {
        1: "#F2F0F7", 2: "#CBC9E2", 3: "#9E9AC8", 4: "#756BB1", 5: "#54278F"
    },
    "Daytime Population": {
        1: "#FEE5D9", 2: "#FCAE91", 3: "#FB6A4A", 4: "#DE2D26", 5: "#A50F15"
    },
    # Households (Greens)
    "Number of Households": {
        1: "#EDF8E9", 2: "#BAE4B3", 3: "#74C476", 4: "#31A354", 5: "#006D2C"
    },
    "Average Household Size": {
        1: "#E5F5E0", 2: "#A1D99B", 3: "#74C476", 4: "#41AB5D", 5: "#005A32" # Slightly different green tint
    },
    # Wealth & Value (Oranges & Golds)
    "Median Home Value": {
        1: "#FEEDDE", 2: "#FDBE85", 3: "#FD8D3C", 4: "#E6550D", 5: "#A63603"
    },
    "Median Income": {
        1: "#FFFFD4", 2: "#FED98E", 3: "#FE9929", 4: "#D95F0E", 5: "#993404"
    }
}

# The fallback palette if a metric isn't found above (Blues)
default_palette = {
    1: "#EFF3FF", 2: "#BDD7E7", 3: "#6BAED6", 4: "#3182BD", 5: "#08519C"
}

# Apply function to read the metric, class, and handle the default fallback
def get_color(row):
    metric = row['metric_name']
    val_class = row['symbology_class']
    
    # Check for No Data / Nulls first
    if val_class == 0 or pd.isna(val_class):
        return "#CCCCCC" # Light Gray
    
    # Check if we have a specific palette mapped for this metric
    if metric in palettes and val_class in palettes[metric]:
        return palettes[metric][val_class]
    
    # If metric is not in palettes, fallback to the default Blues
    if val_class in default_palette:
        return default_palette[val_class]
        
    return "#CCCCCC" # Ultimate failsafe


# ==========================================
# 2. READ DATA AND CALCULATE BINS
# ==========================================
print("Reading feature class to calculate color bins...")

oid_field = arcpy.Describe(fc_path).OIDFieldName
fields = [oid_field, "geography", "metric_name", "value"]

# Pull data into a pandas dataframe
data = [row for row in arcpy.da.SearchCursor(fc_path, fields)]
df = pd.DataFrame(data, columns=fields)

# Function to calculate 1-5 rank within a specific group
def assign_class(group):
    valid_vals = group.dropna()
    if len(valid_vals) == 0:
        return pd.Series(index=group.index, dtype=float)
    
    # Calculate percentile rank, then multiply by 5 and round up to get 1-5 classes
    ranks = valid_vals.rank(pct=True)
    classes = np.ceil(ranks * 5).astype(int)
    return classes.clip(1, 5) # Ensure it stays cleanly within 1-5

print("Grouping and applying classes...")

# Apply the binning function grouped by Geography AND Metric Name
df['symbology_class'] = df.groupby(['geography', 'metric_name'])['value'].transform(assign_class)

# Apply get_color across the dataframe (axis=1 means row-by-row) to turn class into hex
df['color_hex'] = df.apply(get_color, axis=1)

# ==========================================
# 3. WRITE COLORS BACK TO FEATURE CLASS
# ==========================================
print("Writing colors back to feature class...")

# Add fields for the class and the hex code (skipping if they already exist)
existing_fields = [f.name for f in arcpy.ListFields(fc_path)]
if "symbology_class" not in existing_fields:
    arcpy.management.AddField(fc_path, "symbology_class", "SHORT")
if "color_hex" not in existing_fields:
    arcpy.management.AddField(fc_path, "color_hex", "TEXT", field_length=10)

# Convert dataframe to a dictionary for lightning-fast lookup during the Update Cursor
# Format: { OID : {'symbology_class': 3, 'color_hex': '#6BAED6'} }
update_dict = df.set_index(oid_field)[['symbology_class', 'color_hex']].to_dict('index')

updated_count = 0
with arcpy.da.UpdateCursor(fc_path, [oid_field, "symbology_class", "color_hex"]) as u_cursor:
    for row in u_cursor:
        oid = row[0]
        if oid in update_dict:
            row[1] = update_dict[oid]['symbology_class']
            
            # Ensure we don't write NaN to the integer field
            if pd.isna(row[1]):
                row[1] = 0 
                
            row[2] = update_dict[oid]['color_hex']
            u_cursor.updateRow(row)
            updated_count += 1

print(f"Success! Updated colors for {updated_count} features.")



r'''
How to map the Hex Field in ArcGIS Pro
Set to Single Symbol: In the main Symbology pane for your layer, ensure the primary symbology is set to Single Symbol.

Open the Symbol Formatter: Click directly on the colored polygon patch next to "Symbol" to open the Format Polygon Symbol pane.

Go to Symbol Layers: Click the Properties tab, and then click the Layers icon (it looks like three stacked sheets of paper).

Enable Connections: Look near the top right of this panel for a tiny icon that looks like a database with a link or gear on it. Hover over it—it should say Allow symbol property connections. Click it so it turns blue/active.

Connect the Color: Now, look down at your Color dropdown. Because you clicked that button in Step 4, there will be a new, tiny database icon sitting right next to the color picker. Click that little database icon.

Select the Field: A "Set Attribute Mapping" dialog will pop up. In the Field dropdown, select your color_hex field. Click OK.

Apply: Click the Apply button at the bottom right of the pane.

Your map should instantly snap to using the exact hex colors we generated in the script.

'''

### BUMMER!  This approach doesn't publish to portal, too advanced.  Do this instead to hardcode unique values
# ==========================================
# 1. CONFIGURATION
# ==========================================

def hex_to_rgb_dict(hex_string):
    """Converts a hex color string to an ArcPy-friendly RGB dictionary."""
    h = hex_string.lstrip('#')
    # Convert hex to integers and append 100 for full opacity
    return {'RGB': [int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16), 100]}
    
    
# Target the active map and your specific layer
aprx = arcpy.mp.ArcGISProject("CURRENT")
m = aprx.listMaps(map_name)[0] # Change "Map" if your map frame is named differently

# The name of your layer exactly as it appears in the Table of Contents
lyr = m.listLayers(layer_name)[0]

# ==========================================
# 2. APPLY UNIQUE VALUES SYMBOLOGY
# ==========================================
print(f"Updating symbology for '{layer_name}'...")

sym = lyr.symbology

# Change renderer to Unique Values
if hasattr(sym, 'updateRenderer'):
    sym.updateRenderer('UniqueValueRenderer')
    
    # Set the field to our hex codes
    sym.renderer.fields = ["color_hex"]
    
    # Apply it back to the layer so Pro reads the data and generates the classes
    lyr.symbology = sym 
    
    # Re-read the symbology object to access the newly generated classes
    sym = lyr.symbology 
    
    # ==========================================
    # 3. MATCH COLORS TO HEX STRINGS
    # ==========================================
    update_count = 0
    
    # Loop through the unique value groups and items
    for grp in sym.renderer.groups:
        for itm in grp.items:
            hex_string = itm.label # The label is our actual hex code (e.g., "#08519C")
            
            # Skip the "<all other values>" bucket
            if hex_string.startswith("#"):
                try:
                    # Translate the hex to RGB and apply it
                    itm.symbol.color = hex_to_rgb_dict(hex_string)
                    update_count += 1
                except Exception as e:
                    print(f"  Could not set color for {hex_string}: {e}")
    
    # Apply the final color changes back to the layer
    lyr.symbology = sym
    print(f"Success! Matched {update_count} unique symbol patches to their hex codes.")

else:
    print("Error: Layer does not support Unique Values.")

 

Once you publish this layer and get a dashboard ready, we build a data expression for both lists [Select Choropleth Layer for Map] and [Legend for Choropleth Layer].   This first list needs to Action Filter both the map layer and legend list, using "Render Only When Filtered."

/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
/*

This populates the list of geography - metric

Selections here will propagate forward to filter the map layer and the legend
*/

var fs = FeatureSetByPortalItem(Portal('URL'), 'itemID',0)

// 2. Group the data by the filter_list field to get unique values
var unique_menu = GroupBy(
    fs,
    ['filter_list', 'geography'], 
    [{ name: 'record_count', expression: '1', statistic: 'COUNT' }] 
);

// 3. Sort alphabetically 
return OrderBy(unique_menu, 'filter_list ASC');
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////


/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
/*
Legend Data expression

This populates the list of geography - metric

Selections here will propagate forward to filter the map layer and the legend
*/

var fs = FeatureSetByPortalItem(Portal('URL'), 'itemID',0)

// We group by BOTH the filter string and the class so your Dashboard Category Selector still works!
var grouped = GroupBy(
    fs,
    ['filter_list', 'symbology_class'],
    [
        {name: 'min_val', expression: 'value', statistic: 'MIN'},
        {name: 'max_val', expression: 'value', statistic: 'MAX'},
        {name: 'hex_code', expression: 'color_hex', statistic: 'MAX'}
    ]
);

// Sort so Class 5 is always at the top of the legend
return OrderBy(grouped, 'symbology_class DESC');
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

 

We then use Advanced Formatting on the legend list, to make the legend labels and format to 3 sig figs.

 

/*
ADVANCED FORMATTING FOR LEGEND list

This does rounding to 3 sig figs, and creates the legend labels
*/

// Function to dynamically round to a specific number of significant figures
function formatSigFigs(val, sigFigs) {
    // Handle 0 or nulls so the math doesn't break
    if (IsEmpty(val) || val == 0) { 
        return "0"; 
    }
    
    // Calculate base-10 log by dividing natural Log(val) by natural Log(10)
    var mag = Floor(Log(Abs(val)) / Log(10));
    
    // Create a scale factor based on the requested sig figs
    var scale = Pow(10, (sigFigs - 1) - mag);
    
    // Do the actual mathematical rounding
    var rounded = Round(val * scale) / scale;
    
    // Apply commas for thousands, and allow up to 3 decimal places for small numbers
    return Text(rounded, '#,###.###'); 
}

// Pass our min and max values through the sig fig function (asking for 3 sig figs)
var minVal = formatSigFigs($datapoint["min_val"], 3);
var maxVal = formatSigFigs($datapoint["max_val"], 3);
var hex = $datapoint["hex_code"];

// Create the label text
var legendLabel = minVal + " to " + maxVal;

// Handle the fallback/No Data class
if ($datapoint["symbology_class"] == 0) {
    legendLabel = "No Data";
}

// Return the variables to your HTML
return {
    attributes: {
        labelText: legendLabel,
        hexColor: hex
    }
}

 

And finally, we format the HTML of the legend table to make it look like a legend, with colored squares:

<figure class="table" style="width:100%;">
    <table style="border-collapse:collapse;">
        <tbody>
            <tr>
                <td style="background-color:{expression/hexColor};border-color:#a6a6a6;border-radius:3px;height:25px;width:35px;">
                    &nbsp;
                </td>
                <td style="color:#4c4c4c;font-size:14px;padding-left:15px;">
                    {expression/labelText}
                </td>
            </tr>
        </tbody>
    </table>
</figure>

 

 

0 Kudos