2025, Nov 09 15:00
Fix a Non-Updating NiceGUI + Plotly Map: Use a Real Callback, Rebuild lon/lat, Update the Trace
Why your NiceGUI + Plotly map stays static or starts empty and how to fix it: pass an on_click callback, rebuild lon/lat, update chart.data[0], and refresh.
NiceGUI inputs are bound, the Plotly map renders, but the bounding box won’t budge. If you ran into a plot that either never updates or starts out empty for no obvious reason, the culprit is almost certainly how the click handler is wired, plus a subtlety around where and when you rebuild the data for the trace.
Repro: static bounding box and the premature empty plot
The following snippet shows the setup: four inputs bound to a dict, a Plotly map with a polygon, and a button intended to trigger a redraw. The button wiring is incorrect, which explains both the unresponsive plot and the “why is it empty before I even click?” behavior when clearing fig.data.
from nicegui import ui
import plotly.graph_objects as go
region_cfg = {
'north_lat': '',
'south_lat': '',
'west_lon': '',
'east_lon': '',
}
ui.input(label='North Latitude', value=45, placeholder='eg 45.0').bind_value_to(region_cfg, 'north_lat')
ui.input(label='South Latitude', value=40, placeholder='eg 40.0').bind_value_to(region_cfg, 'south_lat')
ui.input(label='West Longitude', value=-90, placeholder='eg -90.0').bind_value_to(region_cfg, 'west_lon')
ui.input(label='East Longitude', value=-88, placeholder='eg -88.0').bind_value_to(region_cfg, 'east_lon')
x_lon = [region_cfg['west_lon'], region_cfg['east_lon'], region_cfg['east_lon'], region_cfg['west_lon']]
y_lat = [region_cfg['north_lat'], region_cfg['north_lat'], region_cfg['south_lat'], region_cfg['south_lat']]
ctr_lon = (region_cfg['west_lon'] + region_cfg['east_lon']) / 2
ctr_lat = (region_cfg['north_lat'] + region_cfg['south_lat']) / 2
chart = go.Figure(go.Scattermap(
fill="toself",
lon=x_lon, lat=y_lat,
marker={'size': 10, 'color': 'orange'},
name='BBox'))
chart.update_layout(
margin=dict(l=0, r=0, t=0, b=0),
width=400,
showlegend=False,
map={
'style': 'carto-darkmatter',
'center': {'lon': ctr_lon, 'lat': ctr_lat},
'zoom': 5,
},
)
canvas = ui.plotly(chart)
def do_refresh():
# chart.data = [] # uncommenting this makes the graph start empty
canvas.update()
ui.button('Update', on_click=do_refresh()) # wrong: invokes immediately
ui.run()
What actually goes wrong
The key issue is the event handler. In GUI frameworks the handler must be a callback, which means you pass the function object, not the result of calling it. When you write on_click=do_refresh(), Python executes do_refresh right away during setup, assigns its return value to on_click, and there is nothing left to run on the actual click. If the handler clears the figure’s data, the plot starts empty because that code already ran at startup.
There is a second, more silent problem. Binding the inputs updates the dictionary, but previously computed lists like x_lon and y_lat won’t magically rebuild themselves. You have to recompute them from the current dict values inside the handler and assign them back to the existing Plotly trace. If the inputs deliver strings, converting to numbers is needed as well; a straightforward way is to let the binding do the conversion.
The fix: pass a callback and update the existing trace
The button should receive the function name without parentheses. Rebuild the lon/lat arrays inside that function, push the new data into the existing trace via chart.data[0], optionally update the map center, and ask NiceGUI to re-render.
from nicegui import ui
import plotly.graph_objects as go
region_cfg = {
'north_lat': '',
'south_lat': '',
'west_lon': '',
'east_lon': '',
}
ui.input(label='North Latitude', value=45, placeholder='eg 45.0')\
.bind_value_to(region_cfg, 'north_lat', forward=float)
ui.input(label='South Latitude', value=40, placeholder='eg 40.0')\
.bind_value_to(region_cfg, 'south_lat', forward=float)
ui.input(label='West Longitude', value=-90, placeholder='eg -90.0')\
.bind_value_to(region_cfg, 'west_lon', forward=float)
ui.input(label='East Longitude', value=-88, placeholder='eg -88.0')\
.bind_value_to(region_cfg, 'east_lon', forward=float)
x_lon = [region_cfg['west_lon'], region_cfg['east_lon'], region_cfg['east_lon'], region_cfg['west_lon']]
y_lat = [region_cfg['north_lat'], region_cfg['north_lat'], region_cfg['south_lat'], region_cfg['south_lat']]
ctr_lon = (region_cfg['west_lon'] + region_cfg['east_lon']) / 2
ctr_lat = (region_cfg['north_lat'] + region_cfg['south_lat']) / 2
chart = go.Figure(
go.Scattermap(
fill='toself',
lon=x_lon,
lat=y_lat,
marker={'size': 10, 'color': 'orange'},
name='BBox',
)
)
chart.update_layout(
margin=dict(l=0, r=0, t=0, b=0),
width=400,
showlegend=False,
map={
'style': 'carto-darkmatter',
'center': {'lon': ctr_lon, 'lat': ctr_lat},
'zoom': 5,
},
)
canvas = ui.plotly(chart)
def handle_redraw():
xs = [region_cfg['west_lon'], region_cfg['east_lon'], region_cfg['east_lon'], region_cfg['west_lon']]
ys = [region_cfg['north_lat'], region_cfg['north_lat'], region_cfg['south_lat'], region_cfg['south_lat']]
chart.data[0].lon = xs
chart.data[0].lat = ys
mid_x = (region_cfg['west_lon'] + region_cfg['east_lon']) / 2
mid_y = (region_cfg['north_lat'] + region_cfg['south_lat']) / 2
chart.update_layout(map={'center': {'lon': mid_x, 'lat': mid_y}})
canvas.update()
ui.button('Update', on_click=handle_redraw)
ui.run()
Why this happens across GUIs
The pattern is universal in event-driven UIs. You hand the framework a callback that it can invoke later. Passing a function reference allows the runtime to call it in response to an event; calling it during setup runs the code immediately, long before the user clicks anything. This is the same callback idea you will see in tkinter, PyQt, and even in JavaScript, where event listeners are registered but not executed until the event fires.
Why it’s worth remembering
Two habits save time when building interactive tooling. First, always pass a function reference to event hooks and only compute dynamic state inside those callbacks. Second, keep in mind what binding does and does not do: the bound dict will update, but any arrays or derived values you created earlier are stale until you rebuild them from the current state. For numeric domains such as map coordinates, ensure the values are numbers; using a converter during binding keeps the handler clean and prevents type surprises.
Takeaway
Wire the button with a callback, not a call. Recompute the lon/lat arrays inside that callback, update the existing trace via chart.data[0], recentre the map if needed, and ask NiceGUI to refresh the Plotly component. With those pieces in place the bounding box becomes interactive and the map will reflect every change in the input fields exactly when you ask it to.