gov-uk-dashboards 26.24.0__tar.gz → 26.26.0__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.
Files changed (135) hide show
  1. {gov_uk_dashboards-26.24.0/gov_uk_dashboards.egg-info → gov_uk_dashboards-26.26.0}/PKG-INFO +1 -1
  2. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/dash/context_card.py +457 -25
  3. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/plotly/time_series_chart.py +83 -11
  4. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/lib/datetime_functions/datetime_functions.py +85 -0
  5. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0/gov_uk_dashboards.egg-info}/PKG-INFO +1 -1
  6. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/setup.py +1 -1
  7. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/LICENSE +0 -0
  8. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/MANIFEST.in +0 -0
  9. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/README.md +0 -0
  10. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/__init__.py +0 -0
  11. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/assets/__init__.py +0 -0
  12. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/assets/attach-event-to-dash.js +0 -0
  13. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/assets/custom_map_style_functions.js +0 -0
  14. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/assets/dashboard.css +0 -0
  15. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/assets/download-map.js +0 -0
  16. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/assets/fonts/bold-affa96571d-v2.woff +0 -0
  17. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/assets/fonts/bold-b542beb274-v2.woff2 +0 -0
  18. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/assets/fonts/light-94a07e06a1-v2.woff2 +0 -0
  19. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/assets/fonts/light-f591b13f7d-v2.woff +0 -0
  20. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/assets/get_assets_folder.py +0 -0
  21. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/assets/govuk-frontend-3.14.0.min.js +0 -0
  22. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/assets/images/CHASE_icon.svg +0 -0
  23. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/assets/images/DLUHC_WHITE_Master_AW_sm.png +0 -0
  24. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/assets/images/MHCLG-favicon.png +0 -0
  25. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/assets/images/MHCLG_favicon.png +0 -0
  26. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/assets/images/dcms_coatofarms.png +0 -0
  27. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/assets/images/dluhc_favicon.ico +0 -0
  28. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/assets/images/explore_data_logo.svg +0 -0
  29. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/assets/images/gov_favicon.ico +0 -0
  30. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/assets/images/govuk-crest.svg +0 -0
  31. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/assets/images/hm-government-logo.png +0 -0
  32. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/assets/images/mhclg_coat_of_arms.png +0 -0
  33. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/assets/images/mhclg_white_no_background.png +0 -0
  34. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/assets/images/oflog/MedianAbsolute.png +0 -0
  35. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/assets/images/oflog/how_to_1_selecting_data.png +0 -0
  36. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/assets/images/oflog/how_to_2_viewing_tabs.png +0 -0
  37. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/assets/images/oflog/how_to_3_viewing_charts.png +0 -0
  38. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/assets/images/oflog/how_to_4_viewing_trends.png +0 -0
  39. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/assets/images/oflog/how_to_5_viewing_tables.png +0 -0
  40. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/assets/index.html +0 -0
  41. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/assets/mobile-nav.js +0 -0
  42. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/assets/register_maps +0 -0
  43. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/assets/scripts.js +0 -0
  44. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/assets/topojson/usa_110m.json +0 -0
  45. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/assets/topojson/world_110m.json +0 -0
  46. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/colours.py +0 -0
  47. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/__init__.py +0 -0
  48. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/dash/__init__.py +0 -0
  49. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/dash/apply_and_reset_filters_buttons.py +0 -0
  50. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/dash/banners.py +0 -0
  51. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/dash/card.py +0 -0
  52. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/dash/card_full_width.py +0 -0
  53. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/dash/collapsible_panel.py +0 -0
  54. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/dash/comparison_la_filter_button.py +0 -0
  55. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/dash/context_banner.py +0 -0
  56. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/dash/dashboard_container.py +0 -0
  57. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/dash/data_quality_banner.py +0 -0
  58. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/dash/details.py +0 -0
  59. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/dash/download_button.py +0 -0
  60. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/dash/filter_panel.py +0 -0
  61. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/dash/footer.py +0 -0
  62. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/dash/graph.py +0 -0
  63. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/dash/green_button.py +0 -0
  64. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/dash/header.py +0 -0
  65. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/dash/heading.py +0 -0
  66. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/dash/home_page_link_button.py +0 -0
  67. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/dash/html_list.py +0 -0
  68. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/dash/key_value_pair.py +0 -0
  69. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/dash/main_content.py +0 -0
  70. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/dash/navbar.py +0 -0
  71. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/dash/no_data_message.py +0 -0
  72. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/dash/notification_banner.py +0 -0
  73. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/dash/paragraph.py +0 -0
  74. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/dash/phase_banner.py +0 -0
  75. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/dash/row_component.py +0 -0
  76. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/dash/side_navbar.py +0 -0
  77. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/dash/table.py +0 -0
  78. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/dash/tooltip.py +0 -0
  79. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/dash/tooltip_title.py +0 -0
  80. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/dash/visualisation_commentary.py +0 -0
  81. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/dash/visualisation_title.py +0 -0
  82. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/dash/warning_text.py +0 -0
  83. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/helpers/__init__.py +0 -0
  84. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/helpers/display_chart_or_table_with_header.py +0 -0
  85. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/helpers/generate_dash_graph_from_figure.py +0 -0
  86. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/helpers/get_chart_for_download.py +0 -0
  87. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/helpers/plotting_helper_functions.py +0 -0
  88. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/helpers/update_layout_bgcolor_margin.py +0 -0
  89. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/leaflet/__init__.py +0 -0
  90. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/leaflet/leaflet_choropleth_map.py +0 -0
  91. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/plotly/__init__.py +0 -0
  92. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/plotly/captioned_figure.py +0 -0
  93. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/plotly/choropleth_map.py +0 -0
  94. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/plotly/enums.py +0 -0
  95. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/components/plotly/stacked_barchart.py +0 -0
  96. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/constants.py +0 -0
  97. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/figures/__init__.py +0 -0
  98. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/figures/enums/__init__.py +0 -0
  99. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/figures/enums/dash_patterns.py +0 -0
  100. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/figures/line_chart.py +0 -0
  101. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/figures/styles/__init__.py +0 -0
  102. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/figures/styles/line_style.py +0 -0
  103. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/formatting/__init__.py +0 -0
  104. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/formatting/human_readable.py +0 -0
  105. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/formatting/number_formatting.py +0 -0
  106. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/formatting/round_and_add_prefix_and_suffix.py +0 -0
  107. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/formatting/rounding.py +0 -0
  108. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/formatting/text_functions.py +0 -0
  109. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/lib/__init__.py +0 -0
  110. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/lib/dap/__init__.py +0 -0
  111. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/lib/dap/dap_deployment.py +0 -0
  112. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/lib/dap/get_dataframe_from_cds.py +0 -0
  113. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/lib/datetime_functions/__init__.py +0 -0
  114. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/lib/download_functions/__init__.py +0 -0
  115. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/lib/download_functions/convert_fig_to_image_and_download.py +0 -0
  116. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/lib/download_functions/download_csv_with_headers.py +0 -0
  117. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/lib/enable_basic_auth.py +0 -0
  118. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/lib/http_headers.py +0 -0
  119. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/lib/logging.py +0 -0
  120. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/lib/testing_functions/__init__.py +0 -0
  121. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/lib/testing_functions/barchart_data_test_assertions.py +0 -0
  122. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/lib/testing_functions/data_test_assertions.py +0 -0
  123. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/lib/testing_functions/data_test_helper_functions.py +0 -0
  124. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/lib/testing_functions/timeseries_data_test_assertions.py +0 -0
  125. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/lib/warning_text_sensitive.py +0 -0
  126. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/log_kpi.py +0 -0
  127. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/symbols.py +0 -0
  128. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/template.html +0 -0
  129. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards/template.py +0 -0
  130. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards.egg-info/SOURCES.txt +0 -0
  131. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards.egg-info/dependency_links.txt +0 -0
  132. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards.egg-info/requires.txt +0 -0
  133. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/gov_uk_dashboards.egg-info/top_level.txt +0 -0
  134. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/pyproject.toml +0 -0
  135. {gov_uk_dashboards-26.24.0 → gov_uk_dashboards-26.26.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gov_uk_dashboards
3
- Version: 26.24.0
3
+ Version: 26.26.0
4
4
  Summary: Provides access to functionality common to creating a data dashboard.
5
5
  Author: Department for Levelling Up, Housing and Communities
6
6
  Description-Content-Type: text/markdown
@@ -7,13 +7,13 @@ import polars as pl
7
7
  from dateutil.relativedelta import relativedelta
8
8
  from dash import html
9
9
  from dash.development.base_component import Component
10
- from gov_uk_dashboards.components.dash import (
11
- heading2,
12
- )
10
+ from gov_uk_dashboards.components.dash import heading2, paragraph
11
+ from gov_uk_dashboards.components.dash.details import details
13
12
  from gov_uk_dashboards.formatting.number_formatting import add_commas, format_percentage
14
13
  from gov_uk_dashboards.lib.datetime_functions.datetime_functions import (
15
14
  convert_date_string_to_text_string,
16
15
  )
16
+ from gov_uk_dashboards.lib.datetime_functions.datetime_functions import convert_date
17
17
 
18
18
  from gov_uk_dashboards.constants import (
19
19
  CHANGED_FROM_GAP_STYLE,
@@ -24,7 +24,6 @@ from gov_uk_dashboards.constants import (
24
24
  METRIC_VALUE,
25
25
  PERCENTAGE_CHANGE_FROM_PREV_YEAR,
26
26
  PERCENTAGE_CHANGE_FROM_TWO_PREV_YEAR,
27
- PREVIOUS_2YEAR,
28
27
  PREVIOUS_YEAR,
29
28
  TWENTY_NINETEEN,
30
29
  TWENTY_NINETEEN_VALUE,
@@ -147,7 +146,6 @@ def get_changed_from_content(
147
146
  comparison_period_text: str = "",
148
147
  use_previous_value_rather_than_change: bool = False,
149
148
  use_difference_in_weeks_days: bool = False,
150
- percentage_change_rounding: int = 1,
151
149
  use_calculated_percentage_change: bool = False,
152
150
  use_number_rather_than_percentage: bool = False,
153
151
  ) -> Component:
@@ -177,8 +175,6 @@ def get_changed_from_content(
177
175
  use_difference_in_weeks_days (bool, optional): If True, show the difference in
178
176
  weeks/days (requires `current_value` and `previous_value` as day counts).
179
177
  Defaults to False.
180
- percentage_change_rounding (int, optional): Decimal places to round percentage change.
181
- Defaults to 1.
182
178
  use_calculated_percentage_change (bool, optional): If True, use the supplied
183
179
  `calculated_percentage_change` instead of computing it. Defaults to False.
184
180
  use_number_rather_than_percentage (bool, optional): If True, display the change as
@@ -193,7 +189,7 @@ def get_changed_from_content(
193
189
  """
194
190
  if use_previous_value_rather_than_change and use_difference_in_weeks_days:
195
191
  raise ValueError(
196
- "use_previous_value_rather_than_percentage_change and use_difference_in_weeks_days "
192
+ "use_previous_value_rather_than_change and use_difference_in_weeks_days "
197
193
  "both cannot be true"
198
194
  )
199
195
  if use_calculated_percentage_change:
@@ -237,6 +233,7 @@ def get_changed_from_content(
237
233
  # which is added from govuk-tag class
238
234
  )
239
235
  )
236
+ print("PREVIOUS VAKUE", previous_value)
240
237
  content.append(
241
238
  html.Span(
242
239
  f"{previous_value}{unit}",
@@ -277,7 +274,7 @@ def get_changed_from_content(
277
274
  )
278
275
  content.append(
279
276
  html.Span(
280
- f"{round(abs(percentage_change), percentage_change_rounding)}{unit}",
277
+ f"{format_percentage(abs(percentage_change))}{unit}",
281
278
  className="govuk-body-s govuk-!-margin-bottom-0 govuk-!-margin-right-1 "
282
279
  + "changed-from-number-formatting",
283
280
  )
@@ -330,7 +327,6 @@ def get_data_for_context_card(
330
327
  measure: str,
331
328
  df: pl.DataFrame,
332
329
  value_column: str = VALUE,
333
- include_data_from_2_years_ago: bool = False,
334
330
  display_value_as_int: bool = False,
335
331
  abbreviate_month: bool = True,
336
332
  include_percentage_change: bool = False,
@@ -345,8 +341,6 @@ def get_data_for_context_card(
345
341
  measure (str): The measure for which data is to be fetched.
346
342
  df (pl.DataFrame): The dataframe to fetch the measure from.
347
343
  value_column (str): The name of the column to get the value for.
348
- include_data_from_2_years_ago (bool): Whether to include data from 2 years ago. Defaults
349
- to False.
350
344
  display_value_as_int (bool): Whether to display the value as an int. Defaults to False.
351
345
  abbreviate_month (bool): Whether to abbreviate the month. Defaults to True.
352
346
  include_percentage_change (bool): Whether to include percentage change from previous year
@@ -396,19 +390,6 @@ def get_data_for_context_card(
396
390
  TWENTY_NINETEEN: {METRIC_VALUE: twenty_nineteen_data},
397
391
  }
398
392
 
399
- if include_data_from_2_years_ago:
400
- date_2_years_ago = get_a_previous_date(previous_year_date, "previous")
401
- data_from_2_years_ago = get_latest_data_for_year(
402
- df_measure,
403
- date_2_years_ago,
404
- value_column,
405
- abbreviate_month,
406
- data_expected_for_previous_year_and_previous_2years,
407
- include_percentage_change,
408
- previous_year_date,
409
- )
410
- data_to_return = {**data_to_return, PREVIOUS_2YEAR: data_from_2_years_ago}
411
-
412
393
  return data_to_return
413
394
 
414
395
 
@@ -438,6 +419,9 @@ def get_a_previous_date(
438
419
  return new_date
439
420
 
440
421
 
422
+ # if include_data_from_2_years_ago:
423
+
424
+
441
425
  def get_latest_data_for_year(
442
426
  df_measure: pl.DataFrame,
443
427
  date: str,
@@ -544,3 +528,451 @@ def get_latest_data_for_year(
544
528
  PERCENTAGE_CHANGE_FROM_TWO_PREV_YEAR
545
529
  )[0]
546
530
  return output
531
+
532
+
533
+ # pylint: disable=too-many-instance-attributes
534
+ # pylint: disable=too-few-public-methods
535
+ class ContextCard:
536
+ """Context card class"""
537
+
538
+ def __init__(
539
+ self,
540
+ df: pl.DataFrame,
541
+ measure: str,
542
+ title: str,
543
+ date_prefix: str,
544
+ units: str = None,
545
+ headline_figure_is_percentage: bool = False,
546
+ additional_text_and_position: tuple[str, int] = None,
547
+ date_format: str = "%d %b %Y",
548
+ use_previous_value_rather_than_change: bool = False, # rename????
549
+ use_difference_in_weeks_days: bool = False,
550
+ increase_is_positive: bool = True,
551
+ use_number_for_change_rather_than_percentage: bool = False, # rename????
552
+ details_summary_and_text: tuple[str, str] = None,
553
+ ):
554
+ """
555
+ A compact “context card” for a time-series measure that renders:
556
+ • a headline figure,
557
+ • a date label,
558
+ • up to two comparison tags (previous year, two years ago),
559
+ • optional title, units, inline note, and a collapsible details section.
560
+
561
+ The component supports three comparison display modes:
562
+ 1) percent change (default),
563
+ 2) show the comparison period’s value instead of percent change,
564
+ 3) show the time difference in weeks/days (for duration measures).
565
+
566
+ ----------
567
+ Parameters
568
+ ----------
569
+ df : pl.DataFrame
570
+ Input data for one or more measures across dates. Expected columns:
571
+ - MEASURE (categorical/str): identifies the measure.
572
+ - DATE_VALID (date or ISO string): observation date.
573
+ - VALUE (numeric): observed value for the measure.
574
+
575
+ Two data shapes are supported by the current implementation:
576
+
577
+ A) “Precomputed change” mode:
578
+ If the dataframe contains a column literally named
579
+ "Percentage change from prev year", `_filter_df` keeps only the latest
580
+ DATE_VALID row for the selected measure. In `_get_changed_from_content`,
581
+ percent-change tags are read directly from:
582
+ - PERCENTAGE_CHANGE_FROM_PREV_YEAR
583
+ - PERCENTAGE_CHANGE_FROM_TWO_PREV_YEAR
584
+
585
+ B) “Computed change” mode:
586
+ Otherwise, three dates are selected for the measure:
587
+ latest_date,
588
+ previous_date = get_a_previous_date(latest_date),
589
+ year_earlier_date = get_a_previous_date(previous_date),
590
+ then the frame is filtered to those dates and sorted descending.
591
+ Percent changes are computed from the numeric VALUEs in positions:
592
+ [0] latest, [1] previous year, [2] two years ago
593
+
594
+ measure : str
595
+ The measure name to filter from `df[MEASURE]`.
596
+
597
+ title : str
598
+ Optional title shown at the top of the card. If falsy, no title is rendered.
599
+
600
+ date_prefix : str
601
+ Text prefixed before the current date (e.g., "Data to", "Week ending").
602
+
603
+ units : str, optional
604
+ Units label inserted beneath the headline figure.
605
+
606
+ headline_figure_is_percentage : bool, default False
607
+ Controls headline formatting and some tag formatting.
608
+ If True, the headline uses `format_percentage(abs(value))` and appends "%".
609
+ If False, the headline uses `add_commas(value, remove_decimal_places=True)`.
610
+
611
+ additional_text_and_position : tuple[str, int], optional
612
+ An extra paragraph inserted into the content at the given index:
613
+ (text, position_index).
614
+
615
+ date_format : str, default "%d %b %Y"
616
+ Format string for the latest DATE_VALID (e.g., "05 Jan 2026").
617
+
618
+ use_previous_value_rather_than_change : bool, default False
619
+ When True, comparison tags show the comparison period’s VALUE instead of a
620
+ percent change (e.g., "up from 1,234 from previous year").
621
+ NOTE: This cannot be True at the same time as `use_difference_in_weeks_days`.
622
+
623
+ use_difference_in_weeks_days : bool, default False
624
+ When True, comparison tags show the difference between the current VALUE and
625
+ the comparison VALUE converted to weeks/days via `convert_days_to_weeks_and_days`.
626
+ Tag prefix becomes:
627
+ - "longer than ..." if percent-change is positive,
628
+ - "shorter than ..." if percent-change is negative,
629
+ - "unchanged from ..." if zero.
630
+ Requires both current and comparison values to be present.
631
+ NOTE: This cannot be True at the same time as `use_previous_value_rather_than_change`.
632
+
633
+ increase_is_positive : bool, default True
634
+ Controls the semantic mapping of change to colour/arrow:
635
+ - If True: increase → green ↑, decrease → red ↓
636
+ - If False: increase → red ↓, decrease → green ↑
637
+ Zero change renders a neutral (grey/right) style.
638
+
639
+ use_number_for_change_rather_than_percentage : bool, default False
640
+ Only affects the “previous-value” tag mode:
641
+ - If True and the comparison period is "previous year", the label text is
642
+ changed from "previous year" to "in previous year".
643
+ (No other behaviour is currently toggled by this flag in the present code.)
644
+
645
+ details_summary_and_text : tuple[str, str], optional
646
+ Renders a collapsible details section at the bottom with
647
+ (summary_text, details_body).
648
+
649
+ ----------
650
+ Behaviour summary
651
+ ----------
652
+ • The dataframe is filtered to `measure` and reduced to either:
653
+ - one latest row (if the literal column "Percentage change from prev year" exists), or
654
+ - three rows for the latest + two prior comparison dates (computed via
655
+ `get_a_previous_date`).
656
+
657
+ • Headline figure:
658
+ - If `use_difference_in_weeks_days` is True: `convert_days_to_weeks_and_days(VALUE[0])`
659
+ - Else if `headline_figure_is_percentage` is True:
660
+ `format_percentage(abs(VALUE[0])) + "%"`
661
+ - Else: `add_commas(VALUE[0], remove_decimal_places=True)`
662
+
663
+ • Date label:
664
+ Uses the latest DATE_VALID formatted via `convert_date(..., "%Y-%m-%d", date_format)`.
665
+
666
+ • Comparison tags:
667
+ Attempts to render up to two tags: vs "previous year" and vs "two years ago".
668
+ Tags are omitted if the required inputs are missing.
669
+
670
+ • Mutual exclusivity:
671
+ `use_previous_value_rather_than_change` and `use_difference_in_weeks_days`
672
+ cannot both be True (ValueError raised when building tag content).
673
+
674
+ • Layout:
675
+ Content order is:
676
+ [title?], headline, [units?], (date_prefix + date), comparison tags, [details?]
677
+ `additional_text_and_position` inserts an extra paragraph at the specified index.
678
+ """
679
+
680
+ self.measure = measure
681
+ self.title = title
682
+ self.units = units
683
+ self.headline_figure_is_percentage = headline_figure_is_percentage
684
+ self.additional_text_and_position = additional_text_and_position
685
+ self.date_prefix = date_prefix
686
+ self.date_format = date_format
687
+ self.use_previous_value_rather_than_change = (
688
+ use_previous_value_rather_than_change
689
+ )
690
+ self.use_difference_in_weeks_days = use_difference_in_weeks_days
691
+ self.increase_is_positive = increase_is_positive
692
+ self.use_number_for_change_rather_than_percentage = (
693
+ use_number_for_change_rather_than_percentage
694
+ )
695
+ self.df = self._filter_df(df)
696
+ self.headline_figure = self._get_headline_figure()
697
+ self.current_date = self._get_current_date()
698
+ self.details_summary_and_text = details_summary_and_text
699
+
700
+ def __call__(self):
701
+ card_content = [
702
+ html.Div(
703
+ self.headline_figure,
704
+ className="govuk-body govuk-!-font-weight-bold",
705
+ style=LARGE_BOLD_FONT_STYLE | {"marginBottom": "0px"},
706
+ ),
707
+ paragraph(f"{self.date_prefix} {self.current_date}"),
708
+ self._get_changed_from_content(),
709
+ ]
710
+ if self.title:
711
+ card_content.insert(0, heading2(self.title))
712
+ if self.additional_text_and_position:
713
+ card_content.insert(
714
+ self.additional_text_and_position[1],
715
+ paragraph(self.additional_text_and_position[0]),
716
+ )
717
+ if self.details_summary_and_text:
718
+ card_content.append(
719
+ html.Div(
720
+ [
721
+ details(
722
+ self.details_summary_and_text[0],
723
+ self.details_summary_and_text[1],
724
+ )
725
+ ],
726
+ style={"marginTop": "40px"}, # from h repo,
727
+ )
728
+ )
729
+ if self.units:
730
+ card_content.insert(
731
+ 1, html.Div(paragraph(self.units), style={"marginTop": "-15px"})
732
+ )
733
+ card_for_display = html.Div(
734
+ card_content,
735
+ className="context-card-grid-item",
736
+ )
737
+ return card_for_display
738
+
739
+ def _filter_df(self, df):
740
+ df_for_measure = df.filter(df[MEASURE] == self.measure)
741
+ latest_date = df_for_measure.select(pl.col(DATE_VALID).max()).item()
742
+
743
+ if "Percentage change from prev year" in df_for_measure.columns:
744
+ return df_for_measure.filter(pl.col(DATE_VALID) == latest_date)
745
+
746
+ previous_date = get_a_previous_date(latest_date)
747
+ year_earlier_date = get_a_previous_date(previous_date)
748
+
749
+ if not self.use_difference_in_weeks_days and not (
750
+ self.use_number_for_change_rather_than_percentage
751
+ and self.use_previous_value_rather_than_change
752
+ ):
753
+ df_for_measure = df_for_measure.with_columns(pl.col(VALUE).cast(pl.Int64))
754
+
755
+ return df_for_measure.filter(
756
+ pl.col(DATE_VALID).is_in([latest_date, previous_date, year_earlier_date])
757
+ ).sort(DATE_VALID, descending=True)
758
+
759
+ def _get_headline_figure(self):
760
+ unit = "%" if self.headline_figure_is_percentage else ""
761
+
762
+ return (
763
+ (
764
+ f"{str(format_percentage(abs(self.df[VALUE][0])))+unit}"
765
+ if self.headline_figure_is_percentage
766
+ else add_commas(self.df[VALUE][0], remove_decimal_places=True) + unit
767
+ )
768
+ if not self.use_difference_in_weeks_days
769
+ else convert_days_to_weeks_and_days(self.df[VALUE][0])
770
+ )
771
+
772
+ # pylint: disable=too-many-statements
773
+ def _get_current_date(self):
774
+ current_date = self.df[DATE_VALID][0]
775
+ return convert_date(current_date, "%Y-%m-%d", self.date_format)
776
+
777
+ def _get_changed_from_content(self):
778
+ if (
779
+ self.use_previous_value_rather_than_change
780
+ and self.use_difference_in_weeks_days
781
+ ):
782
+ raise ValueError(
783
+ "use_previous_value_rather_than_change and use_difference_in_weeks_days "
784
+ "both cannot be true"
785
+ )
786
+
787
+ # ---- helpers ----
788
+ def _scalar(x):
789
+ """Polars -> python scalar (handles Series/Expr-ish values)."""
790
+ if x is None:
791
+ return None
792
+ if isinstance(x, pl.Series):
793
+ return x.item() if len(x) else None
794
+ return x
795
+
796
+ def _build_tag(
797
+ *,
798
+ percentage_change: float | None,
799
+ comparison_period_text: str,
800
+ current_value: float | None = None,
801
+ previous_value: float | None = None,
802
+ ):
803
+ if percentage_change is None:
804
+ return None
805
+ increase_is_positive = getattr(self, "increase_is_positive", True)
806
+
807
+ if percentage_change > 0:
808
+ colour = "green" if increase_is_positive else "red"
809
+ arrow_direction = "up"
810
+ prefix = "up"
811
+ elif percentage_change < 0:
812
+ colour = "red" if increase_is_positive else "green"
813
+ arrow_direction = "down"
814
+ prefix = "down"
815
+ else:
816
+ colour = "grey"
817
+ arrow_direction = "right"
818
+ prefix = ""
819
+
820
+ box_style_class = (
821
+ f"govuk-tag govuk-tag--{colour} changed-from-box-formatting"
822
+ )
823
+ if percentage_change != 0:
824
+ box_style_class += f" changed-from-arrow_{arrow_direction}_{colour}"
825
+
826
+ unit = (
827
+ "%"
828
+ if (
829
+ self.headline_figure_is_percentage
830
+ and self.use_previous_value_rather_than_change
831
+ )
832
+ or not self.use_previous_value_rather_than_change
833
+ else ""
834
+ )
835
+
836
+ content = []
837
+
838
+ # Option A: show previous value rather than % change
839
+ if self.use_previous_value_rather_than_change:
840
+ if previous_value is None:
841
+ return None
842
+ if self.headline_figure_is_percentage:
843
+ previous_value = str(format_percentage(abs(previous_value)))
844
+ else:
845
+ previous_value = add_commas(
846
+ previous_value, remove_decimal_places=True
847
+ )
848
+ content.append(
849
+ html.Span(
850
+ (
851
+ f"{prefix} from "
852
+ if percentage_change != 0
853
+ else "unchanged from "
854
+ ),
855
+ className="govuk-body-s govuk-!-margin-bottom-0 text-color-inherit "
856
+ "text-no-transform",
857
+ )
858
+ )
859
+ content.append(
860
+ html.Span(
861
+ f"{previous_value}{unit}",
862
+ className="govuk-body-s govuk-!-margin-bottom-0 govuk-!-margin-right-1 "
863
+ "changed-from-number-formatting",
864
+ )
865
+ )
866
+ if self.use_number_for_change_rather_than_percentage:
867
+ if comparison_period_text == "previous year":
868
+ comparison_period_text = "in " + comparison_period_text
869
+
870
+ # Option B: show difference in weeks/days (requires values)
871
+ elif self.use_difference_in_weeks_days:
872
+ if current_value is None or previous_value is None:
873
+ return None
874
+
875
+ difference_in_weeks_and_days = convert_days_to_weeks_and_days(
876
+ current_value - previous_value
877
+ )
878
+
879
+ if percentage_change > 0:
880
+ comparison_period_text_prefix = "longer than "
881
+ elif percentage_change < 0:
882
+ comparison_period_text_prefix = "shorter than "
883
+ else:
884
+ comparison_period_text_prefix = "unchanged from "
885
+
886
+ content.append(
887
+ html.Span(
888
+ f"{difference_in_weeks_and_days}",
889
+ className="govuk-body-s govuk-!-margin-bottom-0 govuk-!-margin-right-1 "
890
+ "changed-from-number-formatting text-no-transform",
891
+ )
892
+ )
893
+
894
+ comparison_period_text = (
895
+ comparison_period_text_prefix + comparison_period_text
896
+ )
897
+
898
+ # Option C: default (% change)
899
+ else:
900
+ content.append(
901
+ html.Span(
902
+ f"{prefix} " if percentage_change != 0 else "unchanged from ",
903
+ className="govuk-body-s govuk-!-margin-bottom-0 text-color-inherit "
904
+ "text-no-transform",
905
+ )
906
+ )
907
+ content.append(
908
+ html.Span(
909
+ f"{format_percentage(abs(percentage_change))}{unit}",
910
+ className="govuk-body-s govuk-!-margin-bottom-0 govuk-!-margin-right-1 "
911
+ "changed-from-number-formatting",
912
+ )
913
+ )
914
+ comparison_period_text = "from " + comparison_period_text
915
+
916
+ content.append(
917
+ html.Span(
918
+ comparison_period_text,
919
+ className="govuk-body-s govuk-!-margin-bottom-0 text-color-inherit "
920
+ "text-no-transform",
921
+ )
922
+ )
923
+
924
+ return html.Div(
925
+ [html.Div(content, className=box_style_class)],
926
+ )
927
+
928
+ # ---- compute changes ----
929
+ current_value = None
930
+ prev_year_value = None
931
+ two_year_value = None
932
+
933
+ if self.df.height == 1:
934
+ # Using provided columns (likely already computed upstream)
935
+ pct_year = _scalar(self.df[PERCENTAGE_CHANGE_FROM_PREV_YEAR])
936
+ pct_2yr = _scalar(self.df[PERCENTAGE_CHANGE_FROM_TWO_PREV_YEAR])
937
+
938
+ # If you want to support previous-value/weeks-days in height==1 mode,
939
+ # you'd need extra columns for those values. Otherwise tags will return None for those
940
+ # modes.
941
+ else:
942
+ # Expect order: [latest, prev_year, two_year]
943
+ current_value = _scalar(self.df[VALUE][0])
944
+ prev_year_value = _scalar(self.df[VALUE][1])
945
+ two_year_value = _scalar(self.df[VALUE][2])
946
+
947
+ pct_year = (
948
+ None
949
+ if not prev_year_value
950
+ else ((current_value - prev_year_value) / prev_year_value) * 100
951
+ )
952
+ pct_2yr = (
953
+ None
954
+ if not two_year_value
955
+ else ((current_value - two_year_value) / two_year_value) * 100
956
+ )
957
+
958
+ # ---- build two tags ----
959
+ tag_last_year = _build_tag(
960
+ percentage_change=pct_year,
961
+ comparison_period_text="previous year",
962
+ current_value=current_value,
963
+ previous_value=prev_year_value,
964
+ )
965
+
966
+ tag_two_years = _build_tag(
967
+ percentage_change=pct_2yr,
968
+ comparison_period_text="two years ago",
969
+ current_value=current_value,
970
+ previous_value=two_year_value,
971
+ )
972
+ styled_tag_two_years = (
973
+ html.Div(tag_two_years, style=CHANGED_FROM_GAP_STYLE)
974
+ if tag_two_years is not None
975
+ else None
976
+ )
977
+ tags = [t for t in (tag_last_year, styled_tag_two_years) if t is not None]
978
+ return html.Div(tags) if tags else None
@@ -64,6 +64,7 @@ class TimeSeriesChart:
64
64
  filtered_df: pl.DataFrame,
65
65
  trace_name_list: list[str],
66
66
  dashed_trace_name_list: list[str] = None,
67
+ trace_colour_groups: list[str, str] = None,
67
68
  initially_hidden_traces: Optional[list[str]] = None,
68
69
  hover_data_for_traces_with_different_hover_for_last_point: Optional[
69
70
  HoverDataByTrace
@@ -99,6 +100,7 @@ class TimeSeriesChart:
99
100
  self.filtered_df = filtered_df
100
101
  self.trace_name_list = trace_name_list
101
102
  self.dashed_trace_name_list = dashed_trace_name_list
103
+ self.trace_colour_groups = trace_colour_groups
102
104
  self.initially_hidden_traces = initially_hidden_traces
103
105
  self.legend_dict = legend_dict
104
106
  self.trace_name_column = trace_name_column
@@ -677,27 +679,97 @@ class TimeSeriesChart:
677
679
  return df_list
678
680
 
679
681
  def _get_colour_list(self):
680
- """Returns a list of colours."""
682
+ """Returns a list of colours (one per trace in trace_name_list).
683
+
684
+ If `trace_colour_groups` is provided, traces in the same group share a colour.
685
+ Traces not in any group get their own colour from the palette.
686
+ """
687
+ palette = self._get_base_palette()
688
+ palette = self._apply_colour_shift(palette)
689
+
690
+ groups = getattr(self, "trace_colour_groups", None) or []
691
+ if not groups:
692
+ return palette[: len(self.trace_name_list)]
693
+
694
+ trace_to_group_id = self._build_trace_to_group_id(groups)
695
+ return self._assign_colours_with_groups(palette, trace_to_group_id)
696
+
697
+ def _get_base_palette(self) -> list[str]:
681
698
  number_of_traces = len(self.trace_name_list)
682
699
  if number_of_traces == 2 and self.filled_traces_dict is None:
683
- colour_list = [
700
+ return [
684
701
  AFAccessibleColours.DARK_BLUE.value,
685
702
  AFAccessibleColours.ORANGE.value,
686
- ] # if 2 lines should use dark blue & orange as have highest contrast ratio
687
- else:
688
- colour_list = AFAccessibleColours.CATEGORICAL.value.copy()
703
+ ]
704
+ return AFAccessibleColours.CATEGORICAL.value.copy()
705
+
706
+ def _apply_colour_shift(self, palette: list[str]) -> list[str]:
707
+ """Apply number_of_traces_colour_shift_dict; may replace palette with explicit list."""
708
+ number_of_traces = len(self.trace_name_list)
689
709
  colour_shift_dict = (
690
710
  {"default": 0}
691
711
  if self.number_of_traces_colour_shift_dict is None
692
712
  else self.number_of_traces_colour_shift_dict
693
713
  )
694
-
695
714
  colour_shift_value = colour_shift_dict.get(
696
715
  number_of_traces, colour_shift_dict["default"]
697
716
  )
717
+
698
718
  if isinstance(colour_shift_value, list):
699
- return colour_shift_value # list of colours
700
- while colour_shift_value > 0:
701
- colour_list.append(colour_list.pop(0))
702
- colour_shift_value -= 1
703
- return colour_list
719
+ return colour_shift_value
720
+
721
+ # rotate left by colour_shift_value
722
+ shift = int(colour_shift_value)
723
+ if shift <= 0:
724
+ return palette
725
+
726
+ shift = shift % len(palette)
727
+ return palette[shift:] + palette[:shift]
728
+
729
+ def _build_trace_to_group_id(self, groups: list[list[str]]) -> dict[str, int]:
730
+ """Validate groups and return a mapping of trace -> group_id."""
731
+ trace_set = set(self.trace_name_list)
732
+ trace_to_group_id: dict[str, int] = {}
733
+
734
+ for group_id, group in enumerate(groups):
735
+ for trace in group:
736
+ if trace not in trace_set:
737
+ raise ValueError(
738
+ f"trace_colour_groups contains '{trace}', but it's not in trace_name_list."
739
+ )
740
+ if trace in trace_to_group_id:
741
+ raise ValueError(
742
+ f"Trace '{trace}' appears in more than one trace_colour_group."
743
+ )
744
+ trace_to_group_id[trace] = group_id
745
+
746
+ return trace_to_group_id
747
+
748
+ def _assign_colours_with_groups(
749
+ self,
750
+ palette: list[str],
751
+ trace_to_group_id: dict[str, int],
752
+ ) -> list[str]:
753
+ """Assign one colour per group (first-seen), and one per ungrouped trace."""
754
+ group_colour: dict[int, str] = {}
755
+ trace_colour: dict[str, str] = {}
756
+ colour_idx = 0
757
+
758
+ # assign colours to groups in order of first appearance in trace_name_list
759
+ for trace in self.trace_name_list:
760
+ group_id = trace_to_group_id.get(trace)
761
+ if group_id is not None and group_id not in group_colour:
762
+ group_colour[group_id] = palette[colour_idx % len(palette)]
763
+ colour_idx += 1
764
+
765
+ # apply group colours to grouped traces
766
+ for trace, group_id in trace_to_group_id.items():
767
+ trace_colour[trace] = group_colour[group_id]
768
+
769
+ # assign colours to ungrouped traces
770
+ for trace in self.trace_name_list:
771
+ if trace not in trace_colour:
772
+ trace_colour[trace] = palette[colour_idx % len(palette)]
773
+ colour_idx += 1
774
+
775
+ return [trace_colour[t] for t in self.trace_name_list]