@@ -239,6 +239,8 @@ def dot_row(color, text):
239239
240240app .layout = html .Div ([
241241 dcc .Store (id = "refresh-store" , data = 0 ),
242+ dcc .Download (id = "download-ts" ),
243+ dcc .Download (id = "download-forecast" ),
242244 html .Div ([
243245 html .Span ("Environmental Monitor" , style = {"fontSize" :"15px" ,"fontWeight" :"500" ,"color" :"rgba(26, 26, 24, 1)" }),
244246 html .Div ([
@@ -262,6 +264,65 @@ def dot_row(color, text):
262264 html .Div ([html .Div ("Pipeline status" , style = SEC ), html .Div (id = "status-panel" )]),
263265 html .Div ([html .Div ("Model metrics" , style = SEC ), html .Div (id = "metrics-panel" )]),
264266 html .Div ([html .Div ("Index statistics" , style = SEC ), html .Div (id = "stats-panel" )]),
267+
268+ # ── Download ──────────────────────────────────────────────────────
269+ html .Div ([
270+ html .Div ("Download data" , style = SEC ),
271+ html .Button ("⬇ Time series (CSV)" , id = "btn-dl-ts" ,
272+ n_clicks = 0 , style = {
273+ "width" :"100%" ,"marginBottom" :"6px" ,"padding" :"7px 10px" ,
274+ "fontSize" :"12px" ,"borderRadius" :"6px" ,"cursor" :"pointer" ,
275+ "border" :"0.5px solid rgba(232,231,226,1)" ,
276+ "background" :"rgba(247,246,242,1)" ,"color" :"rgba(26,26,24,1)" ,
277+ "textAlign" :"left" ,
278+ }),
279+ html .Button ("⬇ Forecast (CSV)" , id = "btn-dl-forecast" ,
280+ n_clicks = 0 , style = {
281+ "width" :"100%" ,"padding" :"7px 10px" ,
282+ "fontSize" :"12px" ,"borderRadius" :"6px" ,"cursor" :"pointer" ,
283+ "border" :"0.5px solid rgba(232,231,226,1)" ,
284+ "background" :"rgba(247,246,242,1)" ,"color" :"rgba(26,26,24,1)" ,
285+ "textAlign" :"left" ,
286+ }),
287+ ]),
288+
289+ # ── About / credits ───────────────────────────────────────────────
290+ html .Div (style = {"marginTop" :"auto" ,"paddingTop" :"16px" ,
291+ "borderTop" :"0.5px solid rgba(232,231,226,1)" }, children = [
292+ html .Div ("About" , style = SEC ),
293+ html .Div ([
294+ html .Span ("Data: " , style = {"color" :"rgba(136,136,136,1)" ,"fontSize" :"12px" }),
295+ html .A ("Sentinel-2 via AWS Earth Search" ,
296+ href = "https://earth-search.aws.element84.com/v1" ,
297+ target = "_blank" ,
298+ style = {"fontSize" :"12px" ,"color" :"rgba(55,138,221,1)" }),
299+ ], style = {"marginBottom" :"5px" }),
300+ html .Div ([
301+ html .Span ("Data: " , style = {"color" :"rgba(136,136,136,1)" ,"fontSize" :"12px" }),
302+ html .A ("HLS via Microsoft Planetary Computer" ,
303+ href = "https://planetarycomputer.microsoft.com" ,
304+ target = "_blank" ,
305+ style = {"fontSize" :"12px" ,"color" :"rgba(55,138,221,1)" }),
306+ ], style = {"marginBottom" :"10px" }),
307+ html .Div ([
308+ html .A ("⌥ GitHub repo" ,
309+ href = "https://github.com/astroAycha/geospatial_mlops" ,
310+ target = "_blank" ,
311+ style = {"fontSize" :"12px" ,"color" :"rgba(55,138,221,1)" ,
312+ "display" :"block" ,"marginBottom" :"5px" }),
313+ html .A ("◎ aychatammour.com" ,
314+ href = "https://aychatammour.com" ,
315+ target = "_blank" ,
316+ style = {"fontSize" :"12px" ,"color" :"rgba(55,138,221,1)" ,
317+ "display" :"block" ,"marginBottom" :"5px" }),
318+ html .Span ("Questions? " ,
319+ style = {"fontSize" :"12px" ,"color" :"rgba(136,136,136,1)" }),
320+ html .A ("Get in touch" ,
321+ href = "mailto:contact@aychatammour.com" ,
322+ style = {"fontSize" :"12px" ,"color" :"rgba(55,138,221,1)" }),
323+ ]),
324+ ]),
325+
265326 ], style = SIDEBAR ),
266327
267328 html .Div ([
@@ -461,5 +522,58 @@ def _cb(aoi_value, _r=None, _key=key):
461522 make_chart_callback (_key , _id )
462523
463524
525+
526+ # ── Download callbacks ─────────────────────────────────────────────────────────
527+
528+ @callback (
529+ Output ("download-ts" , "data" ),
530+ Input ("btn-dl-ts" , "n_clicks" ),
531+ Input ("aoi-dropdown" , "value" ),
532+ prevent_initial_call = True ,
533+ )
534+ def download_ts (n_clicks , aoi_value ):
535+ """Download full time series as CSV."""
536+ from dash import ctx
537+ if ctx .triggered_id != "btn-dl-ts" or not aoi_value :
538+ return dash .no_update
539+ parsed = parse_sel (aoi_value )
540+ if not parsed :
541+ return dash .no_update
542+ country , aoi_name = parsed
543+ ts_df = read_ts (country , aoi_name )
544+ if ts_df .empty :
545+ return dash .no_update
546+ filename = f"{ aoi_name } _time_series.csv"
547+ return dcc .send_data_frame (ts_df .to_csv , filename , index = False )
548+
549+
550+ @callback (
551+ Output ("download-forecast" , "data" ),
552+ Input ("btn-dl-forecast" , "n_clicks" ),
553+ Input ("aoi-dropdown" , "value" ),
554+ prevent_initial_call = True ,
555+ )
556+ def download_forecast (n_clicks , aoi_value ):
557+ """Download latest forecast as CSV."""
558+ from dash import ctx
559+ if ctx .triggered_id != "btn-dl-forecast" or not aoi_value :
560+ return dash .no_update
561+ parsed = parse_sel (aoi_value )
562+ if not parsed :
563+ return dash .no_update
564+ country , aoi_name = parsed
565+ fc_df = read_forecasts (country , aoi_name )
566+ if fc_df .empty :
567+ return dash .no_update
568+ # Rename columns to be more user-friendly
569+ fc_df = fc_df .rename (columns = {
570+ "unique_id" : "index" ,
571+ "ds" : "date" ,
572+ "XGBRegressor" : "forecast_value" ,
573+ })
574+ filename = f"{ aoi_name } _forecast.csv"
575+ return dcc .send_data_frame (fc_df .to_csv , filename , index = False )
576+
577+
464578if __name__ == "__main__" :
465579 app .run (host = "0.0.0.0" , port = 7860 , debug = False )
0 commit comments