apmtools 0.2.26__tar.gz → 0.2.28__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: apmtools
3
- Version: 0.2.026
3
+ Version: 0.2.028
4
4
  Summary: A collection of tools for processing air pollution monitoring data
5
5
  Project-URL: Homepage, https://github.com/federlorenz/apmtools
6
6
  Author-email: Federico Lorenzetti <lorenzetti.federico@gmail.com>
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "apmtools"
7
- version = "0.2.026"
7
+ version = "0.2.028"
8
8
  authors = [
9
9
  { name="Federico Lorenzetti", email="lorenzetti.federico@gmail.com" },
10
10
  ]
@@ -508,279 +508,307 @@ def create_summary_app(
508
508
 
509
509
  @module.server
510
510
  def plot_tab_server(input, output, session, initial_filters=None):
511
- initial_filters = initial_filters or {}
512
-
513
- @render.ui
514
- def subset_filters():
515
- controls = []
516
- for i, name in enumerate(list(input.subset_columns())):
517
- if name not in column_map:
518
- continue
519
- choices, _ = _subset_choices(summary, column_map[name])
520
- controls.append(ui.input_selectize(
521
- f"subset_values_{i}", f"{name} values", choices=choices,
522
- selected=initial_filters.get(
523
- "values", {}).get(name, []),
524
- multiple=True,
525
- ))
526
- return ui.div(*controls) if controls else ui.help_text("Select columns to filter this plot.")
527
-
528
- @reactive.calc
529
- def filter_state():
530
- names = list(input.subset_columns())
531
- values = {}
532
- for i, name in enumerate(names):
533
- if name in column_map:
534
- values[name] = list(input[f"subset_values_{i}"]())
535
- return {"columns": names, "values": values,
536
- "start": input.datetime_start().strip(),
537
- "end": input.datetime_end().strip()}
538
-
539
- @reactive.calc
540
- def filtered_summary():
541
- state = filter_state()
542
- filters = {}
543
- for name, keys in state["values"].items():
544
- _, mapping = _subset_choices(summary, column_map[name])
545
- selected = [mapping[key] for key in keys if key in mapping]
546
- if selected:
547
- filters[column_map[name]] = selected
548
- return _apply_filters(summary, filters, state["start"], state["end"])
549
-
550
- @render.data_frame
551
- def plot_table():
552
- df = selected_data()
553
- return render.DataGrid(pd.DataFrame(df).reset_index(drop=True),
554
- filters=True, height="420px")
555
-
556
- @render.ui
557
- def x_control():
558
- kind = input.plot_type()
559
- if kind in ("box", "bar"):
560
- choices = categorical_columns
561
- multiple = True
562
- elif kind == "line":
563
- choices = datetime_columns
564
- multiple = False
565
- else:
566
- choices = numeric_columns
567
- multiple = False
568
-
569
- if not choices:
570
- return ui.help_text(f"No compatible X variables for {kind}.")
571
- selected = [x] if x in choices else [choices[0]]
572
- if multiple:
573
- return ui.input_selectize(
574
- "x", "X-axis / categories",
575
- choices={c: c for c in choices},
576
- selected=selected, multiple=True,
577
- )
578
- return ui.input_select(
579
- "x", "X-axis / variable",
511
+ initial_filters = initial_filters or {}
512
+
513
+ @render.ui
514
+ def subset_filters():
515
+ controls = []
516
+ for i, name in enumerate(list(input.subset_columns())):
517
+ if name not in column_map:
518
+ continue
519
+ choices, _ = _subset_choices(summary, column_map[name])
520
+ controls.append(ui.input_selectize(
521
+ f"subset_values_{i}", f"{name} values", choices=choices,
522
+ selected=initial_filters.get(
523
+ "values", {}).get(name, []),
524
+ multiple=True,
525
+ ))
526
+ return ui.div(*controls) if controls else ui.help_text("Select columns to filter this plot.")
527
+
528
+ @reactive.calc
529
+ def filter_state():
530
+ names = list(input.subset_columns())
531
+ values = {}
532
+ for i, name in enumerate(names):
533
+ if name in column_map:
534
+ values[name] = list(input[f"subset_values_{i}"]())
535
+ return {"columns": names, "values": values,
536
+ "start": input.datetime_start().strip(),
537
+ "end": input.datetime_end().strip()}
538
+
539
+ @reactive.calc
540
+ def filtered_summary():
541
+ state = filter_state()
542
+ filters = {}
543
+ for name, keys in state["values"].items():
544
+ _, mapping = _subset_choices(summary, column_map[name])
545
+ selected = [mapping[key] for key in keys if key in mapping]
546
+ if selected:
547
+ filters[column_map[name]] = selected
548
+ return _apply_filters(summary, filters, state["start"], state["end"])
549
+
550
+ @render.data_frame
551
+ def plot_table():
552
+ df = selected_data()
553
+ return render.DataGrid(pd.DataFrame(df).reset_index(drop=True),
554
+ filters=True, height="420px")
555
+
556
+ @render.ui
557
+ def x_control():
558
+ kind = input.plot_type()
559
+ if kind in ("box", "bar"):
560
+ choices = categorical_columns
561
+ multiple = True
562
+ elif kind == "line":
563
+ choices = datetime_columns
564
+ multiple = False
565
+ else:
566
+ choices = numeric_columns
567
+ multiple = False
568
+
569
+ if not choices:
570
+ return ui.help_text(f"No compatible X variables for {kind}.")
571
+ selected = [x] if x in choices else [choices[0]]
572
+ if multiple:
573
+ return ui.input_selectize(
574
+ "x", "X-axis / categories",
580
575
  choices={c: c for c in choices},
581
- selected=selected[0],
576
+ selected=selected, multiple=True,
582
577
  )
578
+ return ui.input_select(
579
+ "x", "X-axis / variable",
580
+ choices={c: c for c in choices},
581
+ selected=selected[0],
582
+ )
583
583
 
584
- @render.ui
585
- def y_control():
586
- kind = input.plot_type()
587
- if kind == "histogram":
588
- return ui.div()
589
- if not numeric_columns:
590
- return ui.help_text("No numeric Y variables available.")
591
- selected = next(
592
- (v for v in initial_y if v in numeric_columns), numeric_columns[0])
593
- if kind in ("box", "bar", "scatter"):
594
- return ui.input_select(
595
- "y", "Y-axis / variable",
596
- choices={c: c for c in numeric_columns},
597
- selected=selected,
598
- )
599
- return ui.input_selectize(
600
- "y", "Y-axis / variables",
584
+ @render.ui
585
+ def y_control():
586
+ kind = input.plot_type()
587
+ if kind == "histogram":
588
+ return ui.div()
589
+ if not numeric_columns:
590
+ return ui.help_text("No numeric Y variables available.")
591
+ selected = next(
592
+ (v for v in initial_y if v in numeric_columns), numeric_columns[0])
593
+ if kind in ("box", "bar", "scatter"):
594
+ return ui.input_select(
595
+ "y", "Y-axis / variable",
601
596
  choices={c: c for c in numeric_columns},
602
- selected=[v for v in initial_y if v in numeric_columns] or [
603
- selected],
604
- multiple=True,
597
+ selected=selected,
605
598
  )
599
+ return ui.input_selectize(
600
+ "y", "Y-axis / variables",
601
+ choices={c: c for c in numeric_columns},
602
+ selected=[v for v in initial_y if v in numeric_columns] or [
603
+ selected],
604
+ multiple=True,
605
+ )
606
606
 
607
- @reactive.calc
608
- def selected_data():
609
- current = filtered_summary()
610
- kind = input.plot_type()
611
- x_input = input.x()
612
- selected_x = ([x_input] if isinstance(
613
- x_input, str) else list(x_input or []))
614
- if not selected_x:
615
- return pd.DataFrame()
616
-
617
- if kind == "histogram":
618
- value_names = selected_x
619
- else:
620
- y_input = input.y()
621
- value_names = ([y_input] if isinstance(
622
- y_input, str) else list(y_input or []))
623
- if not value_names:
624
- return pd.DataFrame()
625
-
626
- selected_groups = list(input.group_by())
627
- grouping_by = [column_map[c] for c in selected_groups]
628
- frequency = FREQUENCIES[input.frequency()]
629
- aggregate = AGGREGATES[input.aggregate()]
630
- value_columns = [column_map[c] for c in value_names]
631
-
632
- if kind in ("box", "bar"):
633
- for c in selected_x:
634
- if column_map[c] not in grouping_by:
635
- grouping_by.append(column_map[c])
636
- if kind == "scatter" and frequency is not None:
637
- # Both numerical axes must be aggregated on the same rows.
638
- value_columns = list(dict.fromkeys(
639
- [column_map[selected_x[0]], *value_columns]
640
- ))
641
- if frequency is not None:
642
- current = _aggregate(
643
- current, value_columns, frequency, grouping_by, aggregate
644
- )
645
- return current
646
-
647
- @render_plotly
648
- def plot():
649
- plot_df = selected_data()
650
- selected_plot = input.plot_type()
651
- x_input = input.x()
652
- selected_x = ([x_input] if isinstance(
653
- x_input, str) else list(x_input or []))
654
- y_input = None if selected_plot == "histogram" else input.y()
655
- selected_y = ([y_input] if isinstance(
607
+ @reactive.calc
608
+ def selected_data():
609
+ current = filtered_summary()
610
+ kind = input.plot_type()
611
+ x_input = input.x()
612
+ selected_x = ([x_input] if isinstance(
613
+ x_input, str) else list(x_input or []))
614
+ if not selected_x:
615
+ return pd.DataFrame()
616
+
617
+ if kind == "histogram":
618
+ value_names = selected_x
619
+ else:
620
+ y_input = input.y()
621
+ value_names = ([y_input] if isinstance(
656
622
  y_input, str) else list(y_input or []))
657
- selected_groups = list(input.group_by())
658
- frequency = FREQUENCIES[input.frequency()]
659
-
660
- if plot_df.empty or not selected_x or (
661
- selected_plot != "histogram" and not selected_y
662
- ):
663
- return px.scatter(title="No data for the selected filters")
664
-
665
- aggregate = AGGREGATES[input.aggregate()]
666
-
667
- def mapped(column):
668
- original = column_map[column]
669
- return f"{original}_{aggregate}" if frequency is not None else original
670
-
671
- if selected_plot == "histogram":
672
- x_column = mapped(selected_x[0])
673
- y_columns = []
674
- elif selected_plot in ("box", "bar"):
675
- x_columns = [column_map[c] for c in selected_x]
676
- x_column = x_columns[0]
677
- y_columns = [mapped(selected_y[0])]
678
- elif selected_plot == "scatter":
679
- x_column = mapped(selected_x[0])
680
- y_columns = [mapped(selected_y[0])]
681
- else:
682
- x_column = frequency if frequency is not None else column_map[selected_x[0]]
683
- y_columns = [mapped(c) for c in selected_y]
684
-
685
- required = [x_column, *y_columns]
686
- if selected_plot in ("box", "bar"):
687
- required.extend(x_columns)
688
- if any(c not in plot_df.columns for c in required):
689
- return px.scatter(title="Selected variables are unavailable after aggregation")
690
-
691
- if selected_plot in ("box", "bar"):
692
- plot_df = plot_df.copy()
693
- plot_df["__plot_category"] = plot_df[x_columns].astype(
623
+ if not value_names:
624
+ return pd.DataFrame()
625
+
626
+ selected_groups = list(input.group_by())
627
+ grouping_by = [column_map[c] for c in selected_groups]
628
+ frequency = FREQUENCIES[input.frequency()]
629
+ aggregate = AGGREGATES[input.aggregate()]
630
+ value_columns = [column_map[c] for c in value_names]
631
+
632
+ if kind in ("box", "bar"):
633
+ for c in selected_x:
634
+ if column_map[c] not in grouping_by:
635
+ grouping_by.append(column_map[c])
636
+ if kind == "scatter" and frequency is not None:
637
+ # Both numerical axes must be aggregated on the same rows.
638
+ value_columns = list(dict.fromkeys(
639
+ [column_map[selected_x[0]], *value_columns]
640
+ ))
641
+ if frequency is not None:
642
+ current = _aggregate(
643
+ current, value_columns, frequency, grouping_by, aggregate
644
+ )
645
+ return current
646
+
647
+ @render_plotly
648
+ def plot():
649
+ plot_df = selected_data()
650
+ selected_plot = input.plot_type()
651
+ x_input = input.x()
652
+ selected_x = ([x_input] if isinstance(
653
+ x_input, str) else list(x_input or []))
654
+ y_input = None if selected_plot == "histogram" else input.y()
655
+ selected_y = ([y_input] if isinstance(
656
+ y_input, str) else list(y_input or []))
657
+ selected_groups = list(input.group_by())
658
+ frequency = FREQUENCIES[input.frequency()]
659
+
660
+ if plot_df.empty or not selected_x or (
661
+ selected_plot != "histogram" and not selected_y
662
+ ):
663
+ return px.scatter(title="No data for the selected filters")
664
+
665
+ aggregate = AGGREGATES[input.aggregate()]
666
+
667
+ def mapped(column):
668
+ original = column_map[column]
669
+ return f"{original}_{aggregate}" if frequency is not None else original
670
+
671
+ if selected_plot == "histogram":
672
+ x_column = mapped(selected_x[0])
673
+ y_columns = []
674
+ elif selected_plot in ("box", "bar"):
675
+ x_columns = [column_map[c] for c in selected_x]
676
+ x_column = x_columns[0]
677
+ y_columns = [mapped(selected_y[0])]
678
+ elif selected_plot == "scatter":
679
+ x_column = mapped(selected_x[0])
680
+ y_columns = [mapped(selected_y[0])]
681
+ else:
682
+ x_column = frequency if frequency is not None else column_map[selected_x[0]]
683
+ y_columns = [mapped(c) for c in selected_y]
684
+
685
+ required = [x_column, *y_columns]
686
+ if selected_plot in ("box", "bar"):
687
+ required.extend(x_columns)
688
+ if any(c not in plot_df.columns for c in required):
689
+ return px.scatter(title="Selected variables are unavailable after aggregation")
690
+
691
+ if selected_plot in ("box", "bar"):
692
+ plot_df = plot_df.copy()
693
+ plot_df["__plot_category"] = plot_df[x_columns].astype(
694
+ str).agg(" | ".join, axis=1)
695
+ x_column = "__plot_category"
696
+
697
+ # Multiple grouping columns are supported by group_stat(). For
698
+ # plotting, Plotly needs one color/category column, so combine the
699
+ # selected grouping columns into a readable composite label.
700
+ group_columns = [column_map[c] for c in selected_groups
701
+ if column_map[c] in plot_df.columns]
702
+ if group_columns:
703
+ plot_df = plot_df.copy()
704
+ plot_df["__plot_group"] = (
705
+ plot_df[group_columns].astype(
694
706
  str).agg(" | ".join, axis=1)
695
- x_column = "__plot_category"
707
+ )
696
708
 
697
- # Multiple grouping columns are supported by group_stat(). For
698
- # plotting, Plotly needs one color/category column, so combine the
699
- # selected grouping columns into a readable composite label.
700
- group_columns = [column_map[c] for c in selected_groups
701
- if column_map[c] in plot_df.columns]
702
- if group_columns:
703
- plot_df = plot_df.copy()
704
- plot_df["__plot_group"] = (
705
- plot_df[group_columns].astype(
706
- str).agg(" | ".join, axis=1)
709
+ if selected_plot == "histogram":
710
+ fig = _histogram_figure(
711
+ plot_df,
712
+ x_column,
713
+ group_column="__plot_group" if group_columns else None,
714
+ bins=int(input.bins()),
715
+ density=input.hist_density() == "True",
716
+ stacked=input.hist_stacked() == "True",
717
+ histtype=input.hist_type(),
718
+ cumulative=int(input.hist_cumulative()),
719
+ )
720
+ elif selected_plot == "box":
721
+ fig = px.box(
722
+ plot_df, x=x_column, y=y_columns[0],
723
+ color="__plot_group" if group_columns else None,
724
+ points="outliers",
725
+ )
726
+ elif selected_plot == "bar":
727
+ fig = px.bar(
728
+ plot_df, x=x_column, y=y_columns[0],
729
+ color="__plot_group" if group_columns else None,
730
+ barmode="group",
731
+ )
732
+ elif selected_plot == "scatter":
733
+ fig = px.scatter(
734
+ plot_df, x=x_column, y=y_columns[0],
735
+ color="__plot_group" if group_columns else None,
736
+ )
737
+ else:
738
+ # ``identifier`` identifies an individual input monitor. It
739
+ # must therefore be used as Plotly's line-group key even when
740
+ # it is not one of the user-selected colour/group columns.
741
+ # This prevents observations from two different monitors
742
+ # being connected by a line merely because they share the
743
+ # same selected group. The identifier is deliberately NOT
744
+ # used for colour, so monitors in the same selected group
745
+ # retain the same colour. After frequency aggregation,
746
+ # identifier is only available if it was selected as a
747
+ # grouping variable. If it is absent, discontinuous runs are
748
+ # inferred from gaps larger than the selected frequency.
749
+ identifier_column = (
750
+ column_map["identifier"]
751
+ if (
752
+ "identifier" in column_map
753
+ and column_map["identifier"] in plot_df.columns
707
754
  )
755
+ else None
756
+ )
708
757
 
709
- if selected_plot == "histogram":
710
- fig = _histogram_figure(
758
+ section_column = None
759
+ if frequency is not None and identifier_column is None:
760
+ plot_df = _add_gap_sections(
711
761
  plot_df,
712
- x_column,
713
- group_column="__plot_group" if group_columns else None,
714
- bins=int(input.bins()),
715
- density=input.hist_density() == "True",
716
- stacked=input.hist_stacked() == "True",
717
- histtype=input.hist_type(),
718
- cumulative=int(input.hist_cumulative()),
719
- )
720
- elif selected_plot == "box":
721
- fig = px.box(
722
- plot_df, x=x_column, y=y_columns[0],
723
- color="__plot_group" if group_columns else None,
724
- points="outliers",
725
- )
726
- elif selected_plot == "bar":
727
- fig = px.bar(
728
- plot_df, x=x_column, y=y_columns[0],
729
- color="__plot_group" if group_columns else None,
730
- barmode="group",
762
+ x_column=x_column,
763
+ group_columns=group_columns,
764
+ frequency=frequency,
731
765
  )
732
- elif selected_plot == "scatter":
733
- fig = px.scatter(
734
- plot_df, x=x_column, y=y_columns[0],
735
- color="__plot_group" if group_columns else None,
766
+ section_column = "__line_section"
767
+
768
+ if group_columns:
769
+ group_column = "__plot_group"
770
+ line_columns = [x_column, group_column, *y_columns]
771
+ line_group_column = identifier_column or section_column
772
+ if line_group_column is not None:
773
+ line_columns.append(line_group_column)
774
+
775
+ # Keep whichever column controls line segmentation as an
776
+ # id_var during melt(). Otherwise pandas drops it and
777
+ # Plotly cannot resolve line_group (e.g. __line_section).
778
+ id_vars = [x_column, group_column]
779
+ if line_group_column is not None:
780
+ id_vars.append(line_group_column)
781
+
782
+ long_df = plot_df[line_columns].melt(
783
+ id_vars=id_vars,
784
+ var_name="variable",
785
+ value_name="value",
736
786
  )
737
- else:
738
- # ``identifier`` identifies an individual input monitor. It
739
- # must therefore be used as Plotly's line-group key even when
740
- # it is not one of the user-selected colour/group columns.
741
- # This prevents observations from two different monitors
742
- # being connected by a line merely because they share the
743
- # same selected group. The identifier is deliberately NOT
744
- # used for colour, so monitors in the same selected group
745
- # retain the same colour. After frequency aggregation,
746
- # identifier is only available if it was selected as a
747
- # grouping variable. If it is absent, discontinuous runs are
748
- # inferred from gaps larger than the selected frequency.
749
- identifier_column = (
750
- column_map["identifier"]
751
- if (
752
- "identifier" in column_map
753
- and column_map["identifier"] in plot_df.columns
754
- )
755
- else None
787
+ fig = px.line(
788
+ long_df,
789
+ x=x_column,
790
+ y="value",
791
+ color=group_column,
792
+ line_group=line_group_column,
793
+ facet_row="variable" if len(
794
+ y_columns) > 1 else None,
756
795
  )
757
-
758
- section_column = None
759
- if frequency is not None and identifier_column is None:
760
- plot_df = _add_gap_sections(
796
+ elif identifier_column is not None:
797
+ # With no explicit grouping, retain the normal Y-variable
798
+ # colouring while using identifier to keep each monitor's
799
+ # time series separate.
800
+ if len(y_columns) == 1:
801
+ fig = px.line(
761
802
  plot_df,
762
- x_column=x_column,
763
- group_columns=group_columns,
764
- frequency=frequency,
803
+ x=x_column,
804
+ y=y_columns[0],
805
+ line_group=identifier_column,
765
806
  )
766
- section_column = "__line_section"
767
-
768
- if group_columns:
769
- group_column = "__plot_group"
770
- line_columns = [x_column, group_column, *y_columns]
771
- line_group_column = identifier_column or section_column
772
- if line_group_column is not None:
773
- line_columns.append(line_group_column)
774
-
775
- # Keep whichever column controls line segmentation as an
776
- # id_var during melt(). Otherwise pandas drops it and
777
- # Plotly cannot resolve line_group (e.g. __line_section).
778
- id_vars = [x_column, group_column]
779
- if line_group_column is not None:
780
- id_vars.append(line_group_column)
781
-
807
+ else:
808
+ line_columns = [
809
+ x_column, identifier_column, *y_columns]
782
810
  long_df = plot_df[line_columns].melt(
783
- id_vars=id_vars,
811
+ id_vars=[x_column, identifier_column],
784
812
  var_name="variable",
785
813
  value_name="value",
786
814
  )
@@ -788,74 +816,46 @@ def create_summary_app(
788
816
  long_df,
789
817
  x=x_column,
790
818
  y="value",
791
- color=group_column,
792
- line_group=line_group_column,
793
- facet_row="variable" if len(
794
- y_columns) > 1 else None,
819
+ color="variable",
820
+ line_group=identifier_column,
821
+ facet_row="variable",
822
+ )
823
+ elif section_column is not None:
824
+ if len(y_columns) == 1:
825
+ fig = px.line(
826
+ plot_df,
827
+ x=x_column,
828
+ y=y_columns[0],
829
+ line_group=section_column,
795
830
  )
796
- elif identifier_column is not None:
797
- # With no explicit grouping, retain the normal Y-variable
798
- # colouring while using identifier to keep each monitor's
799
- # time series separate.
800
- if len(y_columns) == 1:
801
- fig = px.line(
802
- plot_df,
803
- x=x_column,
804
- y=y_columns[0],
805
- line_group=identifier_column,
806
- )
807
- else:
808
- line_columns = [
809
- x_column, identifier_column, *y_columns]
810
- long_df = plot_df[line_columns].melt(
811
- id_vars=[x_column, identifier_column],
812
- var_name="variable",
813
- value_name="value",
814
- )
815
- fig = px.line(
816
- long_df,
817
- x=x_column,
818
- y="value",
819
- color="variable",
820
- line_group=identifier_column,
821
- facet_row="variable",
822
- )
823
- elif section_column is not None:
824
- if len(y_columns) == 1:
825
- fig = px.line(
826
- plot_df,
827
- x=x_column,
828
- y=y_columns[0],
829
- line_group=section_column,
830
- )
831
- else:
832
- line_columns = [
833
- x_column, section_column, *y_columns]
834
- long_df = plot_df[line_columns].melt(
835
- id_vars=[x_column, section_column],
836
- var_name="variable",
837
- value_name="value",
838
- )
839
- fig = px.line(
840
- long_df,
841
- x=x_column,
842
- y="value",
843
- color="variable",
844
- line_group=section_column,
845
- facet_row="variable",
846
- )
847
831
  else:
848
- fig = px.line(plot_df, x=x_column, y=y_columns)
832
+ line_columns = [
833
+ x_column, section_column, *y_columns]
834
+ long_df = plot_df[line_columns].melt(
835
+ id_vars=[x_column, section_column],
836
+ var_name="variable",
837
+ value_name="value",
838
+ )
839
+ fig = px.line(
840
+ long_df,
841
+ x=x_column,
842
+ y="value",
843
+ color="variable",
844
+ line_group=section_column,
845
+ facet_row="variable",
846
+ )
847
+ else:
848
+ fig = px.line(plot_df, x=x_column, y=y_columns)
849
849
 
850
- fig.update_layout(
851
- title=input.title(),
852
- hovermode="x unified" if selected_plot == "line" else "closest",
853
- margin=dict(l=60, r=30, t=70, b=60),
854
- autosize=True,
855
- )
856
- return fig
850
+ fig.update_layout(
851
+ title=input.title(),
852
+ hovermode="x unified" if selected_plot == "line" else "closest",
853
+ margin=dict(l=60, r=30, t=70, b=60),
854
+ autosize=True,
855
+ )
856
+ return fig
857
857
 
858
- return filter_state
858
+ return filter_state
859
859
 
860
860
  app_ui = ui.page_fluid(
861
861
  ui.tags.style("""
File without changes
File without changes
File without changes