maidr 1.0.0__tar.gz → 1.2.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 (52) hide show
  1. {maidr-1.0.0 → maidr-1.2.0}/PKG-INFO +3 -1
  2. {maidr-1.0.0 → maidr-1.2.0}/maidr/__init__.py +4 -1
  3. {maidr-1.0.0 → maidr-1.2.0}/maidr/api.py +4 -3
  4. {maidr-1.0.0 → maidr-1.2.0}/maidr/core/enum/plot_type.py +2 -0
  5. maidr-1.2.0/maidr/core/enum/smooth_keywords.py +10 -0
  6. {maidr-1.0.0 → maidr-1.2.0}/maidr/core/figure_manager.py +9 -3
  7. {maidr-1.0.0 → maidr-1.2.0}/maidr/core/maidr.py +40 -15
  8. {maidr-1.0.0 → maidr-1.2.0}/maidr/core/plot/boxplot.py +30 -7
  9. maidr-1.2.0/maidr/core/plot/candlestick.py +239 -0
  10. {maidr-1.0.0 → maidr-1.2.0}/maidr/core/plot/maidr_plot_factory.py +28 -9
  11. maidr-1.2.0/maidr/core/plot/regplot.py +79 -0
  12. {maidr-1.0.0 → maidr-1.2.0}/maidr/patch/boxplot.py +1 -2
  13. maidr-1.2.0/maidr/patch/candlestick.py +57 -0
  14. {maidr-1.0.0 → maidr-1.2.0}/maidr/patch/common.py +2 -1
  15. {maidr-1.0.0 → maidr-1.2.0}/maidr/patch/histogram.py +29 -1
  16. maidr-1.2.0/maidr/patch/kdeplot.py +63 -0
  17. {maidr-1.0.0 → maidr-1.2.0}/maidr/patch/lineplot.py +1 -0
  18. maidr-1.2.0/maidr/patch/regplot.py +81 -0
  19. maidr-1.2.0/maidr/util/dedup_utils.py +12 -0
  20. maidr-1.2.0/maidr/util/regression_line_utils.py +19 -0
  21. maidr-1.2.0/maidr/util/svg_utils.py +46 -0
  22. {maidr-1.0.0 → maidr-1.2.0}/pyproject.toml +4 -2
  23. {maidr-1.0.0 → maidr-1.2.0}/LICENSE +0 -0
  24. {maidr-1.0.0 → maidr-1.2.0}/README.md +0 -0
  25. {maidr-1.0.0 → maidr-1.2.0}/maidr/core/__init__.py +0 -0
  26. {maidr-1.0.0 → maidr-1.2.0}/maidr/core/context_manager.py +0 -0
  27. {maidr-1.0.0 → maidr-1.2.0}/maidr/core/enum/__init__.py +0 -0
  28. {maidr-1.0.0 → maidr-1.2.0}/maidr/core/enum/library.py +0 -0
  29. {maidr-1.0.0 → maidr-1.2.0}/maidr/core/enum/maidr_key.py +0 -0
  30. {maidr-1.0.0 → maidr-1.2.0}/maidr/core/plot/__init__.py +0 -0
  31. {maidr-1.0.0 → maidr-1.2.0}/maidr/core/plot/barplot.py +0 -0
  32. {maidr-1.0.0 → maidr-1.2.0}/maidr/core/plot/grouped_barplot.py +0 -0
  33. {maidr-1.0.0 → maidr-1.2.0}/maidr/core/plot/heatmap.py +0 -0
  34. {maidr-1.0.0 → maidr-1.2.0}/maidr/core/plot/histogram.py +0 -0
  35. {maidr-1.0.0 → maidr-1.2.0}/maidr/core/plot/lineplot.py +0 -0
  36. {maidr-1.0.0 → maidr-1.2.0}/maidr/core/plot/maidr_plot.py +0 -0
  37. {maidr-1.0.0 → maidr-1.2.0}/maidr/core/plot/scatterplot.py +0 -0
  38. {maidr-1.0.0 → maidr-1.2.0}/maidr/exception/__init__.py +0 -0
  39. {maidr-1.0.0 → maidr-1.2.0}/maidr/exception/extraction_error.py +0 -0
  40. {maidr-1.0.0 → maidr-1.2.0}/maidr/patch/__init__.py +0 -0
  41. {maidr-1.0.0 → maidr-1.2.0}/maidr/patch/barplot.py +0 -0
  42. {maidr-1.0.0 → maidr-1.2.0}/maidr/patch/clear.py +0 -0
  43. {maidr-1.0.0 → maidr-1.2.0}/maidr/patch/heatmap.py +0 -0
  44. {maidr-1.0.0 → maidr-1.2.0}/maidr/patch/highlight.py +0 -0
  45. {maidr-1.0.0 → maidr-1.2.0}/maidr/patch/scatterplot.py +0 -0
  46. {maidr-1.0.0 → maidr-1.2.0}/maidr/util/__init__.py +0 -0
  47. {maidr-1.0.0 → maidr-1.2.0}/maidr/util/environment.py +0 -0
  48. {maidr-1.0.0 → maidr-1.2.0}/maidr/util/mixin/__init__.py +0 -0
  49. {maidr-1.0.0 → maidr-1.2.0}/maidr/util/mixin/extractor_mixin.py +0 -0
  50. {maidr-1.0.0 → maidr-1.2.0}/maidr/util/mixin/merger_mixin.py +0 -0
  51. {maidr-1.0.0 → maidr-1.2.0}/maidr/widget/__init__.py +0 -0
  52. {maidr-1.0.0 → maidr-1.2.0}/maidr/widget/shiny.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: maidr
3
- Version: 1.0.0
3
+ Version: 1.2.0
4
4
  Summary: Multimodal Access and Interactive Data Representations
5
5
  License: GPL-3.0-or-later
6
6
  Keywords: accessibility,visualization,sonification,braille,tactile,multimodal,data representation,blind,low vision,visual impairments
@@ -29,8 +29,10 @@ Requires-Dist: htmltools (>=0.5)
29
29
  Requires-Dist: jupyter (>=1.0.0,<2.0.0)
30
30
  Requires-Dist: lxml (>=5.1.0)
31
31
  Requires-Dist: matplotlib (>=3.8)
32
+ Requires-Dist: mplfinance (>=0.12.10b0,<0.13.0)
32
33
  Requires-Dist: numpy (>=1.26)
33
34
  Requires-Dist: seaborn (>=0.12)
35
+ Requires-Dist: statsmodels (>=0.14.4,<0.15.0)
34
36
  Requires-Dist: virtualenv (>=20.26.6,<21.0.0)
35
37
  Requires-Dist: wrapt (>=1.16.0,<2.0.0)
36
38
  Project-URL: Homepage, https://xability.github.io/py-maidr
@@ -1,4 +1,4 @@
1
- __version__ = "1.0.0"
1
+ __version__ = "1.2.0"
2
2
 
3
3
  from .api import close, render, save_html, show, stacked
4
4
  from .core import Maidr
@@ -6,12 +6,15 @@ from .core.enum import PlotType
6
6
  from .patch import (
7
7
  barplot,
8
8
  boxplot,
9
+ candlestick,
9
10
  clear,
10
11
  heatmap,
11
12
  highlight,
12
13
  histogram,
13
14
  lineplot,
14
15
  scatterplot,
16
+ regplot,
17
+ kdeplot,
15
18
  )
16
19
 
17
20
  __all__ = [
@@ -40,9 +40,10 @@ def save_html(
40
40
  if isinstance(ax, list):
41
41
  for axes in ax:
42
42
  maidr = FigureManager.get_maidr(axes.get_figure())
43
- htmls.append(maidr.render())
44
- htmls[-1].save_html(file, libdir=lib_dir, include_version=include_version)
45
- return htmls[-1]
43
+ htmls.append(maidr._create_html_doc(use_iframe=False))
44
+ return htmls[-1].save_html(
45
+ file, libdir=lib_dir, include_version=include_version
46
+ )
46
47
  else:
47
48
  maidr = FigureManager.get_maidr(ax.get_figure())
48
49
  return maidr.save_html(file, lib_dir=lib_dir, include_version=include_version)
@@ -13,3 +13,5 @@ class PlotType(str, Enum):
13
13
  LINE = "line"
14
14
  SCATTER = "point"
15
15
  STACKED = "stacked_bar"
16
+ SMOOTH = "smooth"
17
+ CANDLESTICK = "candlestick"
@@ -0,0 +1,10 @@
1
+ # Keywords used to detect smooth/regression lines in MAIDR
2
+ SMOOTH_KEYWORDS = [
3
+ "smooth",
4
+ "lowess",
5
+ "regression",
6
+ "linear regression",
7
+ "kde",
8
+ "density",
9
+ "gaussian",
10
+ ]
@@ -48,18 +48,24 @@ class FigureManager:
48
48
  return cls._instance
49
49
 
50
50
  @classmethod
51
- def create_maidr(cls, ax: Axes, plot_type: PlotType, **kwargs) -> Maidr:
51
+ def create_maidr(
52
+ cls, axes: Axes | list[Axes], plot_type: PlotType, **kwargs
53
+ ) -> Maidr:
52
54
  """Create a Maidr instance for the given Axes and plot type, and adds a plot to it."""
53
- if ax is None:
55
+ if axes is None:
54
56
  raise ValueError("No plot found.")
55
57
  if plot_type is None:
56
58
  raise ValueError("No plot type found.")
59
+ if isinstance(axes, list):
60
+ ax = axes[0]
61
+ else:
62
+ ax = axes
57
63
  if ax.get_figure() is None:
58
64
  raise ValueError(f"No figure found for axis: {ax}.")
59
65
 
60
66
  # Add plot to the Maidr object associated with the plot's figure.
61
67
  maidr = cls._get_maidr(ax.get_figure(), plot_type)
62
- plot = MaidrPlotFactory.create(ax, plot_type, **kwargs)
68
+ plot = MaidrPlotFactory.create(axes, plot_type, **kwargs)
63
69
  maidr.plots.append(plot)
64
70
  maidr.selector_ids.append(Maidr._unique_id())
65
71
  return maidr
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from datetime import datetime
4
+
3
5
  import io
4
6
  import json
5
7
  import os
@@ -12,12 +14,13 @@ import matplotlib.pyplot as plt
12
14
  from htmltools import HTML, HTMLDocument, Tag, tags
13
15
  from lxml import etree
14
16
  from matplotlib.figure import Figure
17
+ from maidr.core.enum.plot_type import PlotType
15
18
 
16
19
  from maidr.core.context_manager import HighlightContextManager
17
20
  from maidr.core.enum.maidr_key import MaidrKey
18
- from maidr.core.enum.plot_type import PlotType
19
21
  from maidr.core.plot import MaidrPlot
20
22
  from maidr.util.environment import Environment
23
+ from maidr.util.dedup_utils import deduplicate_smooth_and_line
21
24
 
22
25
 
23
26
  class Maidr:
@@ -62,7 +65,7 @@ class Maidr:
62
65
 
63
66
  def render(self) -> Tag:
64
67
  """Return the maidr plot inside an iframe."""
65
- return self._create_html_tag()
68
+ return self._create_html_tag(use_iframe=True)
66
69
 
67
70
  def save_html(
68
71
  self, file: str, *, lib_dir: str | None = "lib", include_version: bool = True
@@ -80,7 +83,9 @@ class Maidr:
80
83
  include_version : bool, default=True
81
84
  Whether to include the version number in the dependency folder name.
82
85
  """
83
- html = self._create_html_doc()
86
+ html = self._create_html_doc(
87
+ use_iframe=False
88
+ ) # Always use direct HTML for saving
84
89
  return html.save_html(file, libdir=lib_dir, include_version=include_version)
85
90
 
86
91
  def show(
@@ -96,7 +101,7 @@ class Maidr:
96
101
  renderer : Literal["auto", "ipython", "browser"], default="auto"
97
102
  The renderer to use for the HTML preview.
98
103
  """
99
- html = self._create_html_tag()
104
+ html = self._create_html_tag(use_iframe=True) # Always use iframe for display
100
105
  _renderer = Environment.get_renderer()
101
106
  if _renderer == "browser" or (
102
107
  Environment.is_interactive_shell() and not Environment.is_notebook()
@@ -122,10 +127,12 @@ class Maidr:
122
127
  os.makedirs(static_temp_dir, exist_ok=True)
123
128
 
124
129
  temp_file_path = os.path.join(static_temp_dir, "maidr_plot.html")
125
- html_file_path = self.save_html(temp_file_path)
130
+ html_file_path = self.save_html(
131
+ temp_file_path
132
+ ) # This will use use_iframe=False
126
133
  webbrowser.open(f"file://{html_file_path}")
127
134
 
128
- def _create_html_tag(self) -> Tag:
135
+ def _create_html_tag(self, use_iframe: bool = True) -> Tag:
129
136
  """Create the MAIDR HTML using HTML tags."""
130
137
  tagged_elements: list[Any] = [
131
138
  element for plot in self._plots for element in plot.elements
@@ -141,16 +148,32 @@ class Maidr:
141
148
  maidr = f"\nlet maidr = {json.dumps(self._flatten_maidr(), indent=2)}\n"
142
149
 
143
150
  # Inject plot's svg and MAIDR structure into html tag.
144
- return Maidr._inject_plot(svg, maidr, self.maidr_id)
151
+ return Maidr._inject_plot(svg, maidr, self.maidr_id, use_iframe)
145
152
 
146
- def _create_html_doc(self) -> HTMLDocument:
153
+ def _create_html_doc(self, use_iframe: bool = True) -> HTMLDocument:
147
154
  """Create an HTML document from Tag objects."""
148
- return HTMLDocument(self._create_html_tag(), lang="en")
155
+ return HTMLDocument(self._create_html_tag(use_iframe), lang="en")
149
156
 
150
157
  def _flatten_maidr(self) -> dict | list[dict]:
151
158
  """Return a single plot schema or a list of schemas from the Maidr instance."""
152
159
  if self.plot_type in (PlotType.DODGED, PlotType.STACKED):
153
160
  self._plots = [self._plots[0]]
161
+ # Deduplicate: if any SMOOTH plots exist, remove LINE plots
162
+ self._plots = deduplicate_smooth_and_line(self._plots)
163
+
164
+ unique_gids = set()
165
+ deduped_plots = []
166
+ for plot in self._plots:
167
+ if getattr(plot, "type", None) == PlotType.SMOOTH:
168
+ gid = getattr(plot, "_smooth_gid", None)
169
+ if gid and gid in unique_gids:
170
+ continue
171
+ if gid:
172
+ unique_gids.add(gid)
173
+ deduped_plots.append(plot)
174
+ else:
175
+ deduped_plots.append(plot)
176
+ self._plots = deduped_plots
154
177
 
155
178
  plot_schemas = []
156
179
 
@@ -243,17 +266,19 @@ class Maidr:
243
266
  return str(uuid.uuid4())
244
267
 
245
268
  @staticmethod
246
- def _inject_plot(plot: HTML, maidr: str, maidr_id) -> Tag:
269
+ def _inject_plot(plot: HTML, maidr: str, maidr_id, use_iframe: bool = True) -> Tag:
247
270
  """Embed the plot and associated MAIDR scripts into the HTML structure."""
248
- # MAIDR_TS_CDN_URL = "http://localhost:8080/maidr.js" # DEMO URL
249
- MAIDR_TS_CDN_URL = "https://cdn.jsdelivr.net/npm/maidr@3.11.4/dist/maidr.js"
271
+ # MAIDR_TS_CDN_URL = "http://localhost:8888/tree/maidr/core/maidr.js" # DEMO URL
272
+ MAIDR_TS_CDN_URL = "https://cdn.jsdelivr.net/npm/maidr@latest/dist/maidr.js"
273
+ # Append a query parameter (using TIMESTAMP) to bust the cache (so that the latest (non-cached) version is always loaded).
274
+ TIMESTAMP = datetime.now().strftime("%Y%m%d%H%M%S")
250
275
 
251
276
  script = f"""
252
- if (!document.querySelector('script[src="{MAIDR_TS_CDN_URL}"]'))
277
+ if (!document.querySelector('script[src="{MAIDR_TS_CDN_URL}?v={TIMESTAMP}"]'))
253
278
  {{
254
279
  var script = document.createElement('script');
255
280
  script.type = 'module';
256
- script.src = '{MAIDR_TS_CDN_URL}';
281
+ script.src = '{MAIDR_TS_CDN_URL}?v={TIMESTAMP}';
257
282
  script.addEventListener('load', function() {{
258
283
  window.main();
259
284
  }});
@@ -279,7 +304,7 @@ class Maidr:
279
304
  # Render the plot inside an iframe if in a Jupyter notebook, Google Colab
280
305
  # or VSCode notebook. No need for iframe if this is a Quarto document.
281
306
  # For TypeScript we will use iframe by default for now
282
- if Environment.is_notebook() or Environment.is_shiny():
307
+ if use_iframe and (Environment.is_notebook() or Environment.is_shiny()):
283
308
  unique_id = "iframe_" + Maidr._unique_id()
284
309
 
285
310
  def generate_iframe_script(unique_id: str) -> str:
@@ -178,19 +178,39 @@ class BoxPlot(
178
178
  "boxes": [],
179
179
  "outliers": [],
180
180
  }
181
+ self.lower_outliers_count = []
181
182
 
182
183
  def _get_selector(self) -> list[dict]:
183
184
  mins, maxs, medians, boxes, outliers = self.elements_map.values()
184
185
  selector = []
185
- for min, max, median, box, outlier in zip(mins, maxs, medians, boxes, outliers):
186
+
187
+ for (
188
+ min,
189
+ max,
190
+ median,
191
+ box,
192
+ outlier,
193
+ lower_outliers_count,
194
+ ) in zip(
195
+ mins,
196
+ maxs,
197
+ medians,
198
+ boxes,
199
+ outliers,
200
+ self.lower_outliers_count,
201
+ ):
186
202
  selector.append(
187
203
  {
188
- MaidrKey.LOWER_OUTLIER.value: ["g[id=" + outlier + "] > g > use"],
189
- MaidrKey.MIN.value: "g[id=" + min + "] > path",
190
- MaidrKey.MAX.value: "g[id=" + max + "] > path",
191
- MaidrKey.Q2.value: "g[id=" + median + "] > path",
192
- MaidrKey.IQ.value: "g[id=" + box + "] > path",
193
- MaidrKey.UPPER_OUTLIER.value: ["g[id=" + outlier + "] > g > use"],
204
+ MaidrKey.LOWER_OUTLIER.value: [
205
+ f"g[id='{outlier}'] > g > :nth-child(-n+{lower_outliers_count} of use:not([visibility='hidden']))"
206
+ ],
207
+ MaidrKey.MIN.value: f"g[id='{min}'] > path",
208
+ MaidrKey.MAX.value: f"g[id='{max}'] > path",
209
+ MaidrKey.Q2.value: f"g[id='{median}'] > path",
210
+ MaidrKey.IQ.value: f"g[id='{box}'] > path",
211
+ MaidrKey.UPPER_OUTLIER.value: [
212
+ f"g[id='{outlier}'] > g > :nth-child(n+{lower_outliers_count + 1} of use:not([visibility='hidden']))"
213
+ ],
194
214
  }
195
215
  )
196
216
  return selector if self._orientation == "vert" else list(reversed(selector))
@@ -217,6 +237,9 @@ class BoxPlot(
217
237
  medians = self._bxp_extractor.extract_medians(bxp_stats["medians"])
218
238
  outliers = self._bxp_extractor.extract_outliers(bxp_stats["fliers"], caps)
219
239
 
240
+ for outlier in outliers:
241
+ self.lower_outliers_count.append(len(outlier[MaidrKey.LOWER_OUTLIER.value]))
242
+
220
243
  caps_elements = self._bxp_elements_extractor.extract_caps(bxp_stats["caps"])
221
244
  bxp_maidr = []
222
245
 
@@ -0,0 +1,239 @@
1
+ import matplotlib.dates as mdates
2
+ import numpy as np
3
+ from matplotlib.axes import Axes
4
+ from matplotlib.collections import LineCollection, PatchCollection
5
+ from matplotlib.patches import Rectangle
6
+
7
+ from maidr.core.enum.plot_type import PlotType
8
+ from maidr.core.plot.maidr_plot import MaidrPlot
9
+
10
+
11
+ class CandlestickPlot(MaidrPlot):
12
+ def __init__(self, axes: list[Axes], **kwargs) -> None:
13
+ """
14
+ Initialize the CandlestickPlot.
15
+
16
+ Parameters
17
+ ----------
18
+ axes : list[Axes]
19
+ A list of Matplotlib Axes objects. Expected to contain at least
20
+ one Axes for OHLC data, and optionally a second for volume data.
21
+ **kwargs : dict
22
+ Additional keyword arguments.
23
+ """
24
+ self.axes = axes
25
+ # Ensure there's at least one axis for the superclass init
26
+ if not axes:
27
+ raise ValueError("Axes list cannot be empty.")
28
+ super().__init__(axes[0], PlotType.CANDLESTICK)
29
+
30
+ def _extract_plot_data(self) -> list[dict]:
31
+ """
32
+ Extracts candlestick (OHLC) and volume data from the plot axes.
33
+
34
+ This method assumes that the candlestick chart is structured with
35
+ LineCollection for wicks and PatchCollection of Rectangles for bodies
36
+ on the first axis (self.axes[0]). Volume data is expected as a
37
+ PatchCollection of Rectangles on the second axis (self.axes[1]), if present.
38
+ Open and close prices are inferred from the body rectangle's color.
39
+
40
+ Returns
41
+ -------
42
+ list[dict]
43
+ A list of dictionaries, where each dictionary represents a data point
44
+ with 'value' (date string YYYY-MM-DD), 'open', 'high', 'low',
45
+ 'close', and 'volume'. Fields that cannot be extracted will be None.
46
+
47
+ Examples
48
+ --------
49
+ Assuming a plot has been generated and `plot_instance.axes` is populated:
50
+ >>> data = plot_instance._extract_plot_data()
51
+ >>> print(data[0])
52
+ {
53
+ 'value': '2021-01-01',
54
+ 'open': 100.0,
55
+ 'high': 100.9,
56
+ 'low': 99.27,
57
+ 'close': 100.75,
58
+ 'volume': 171914,
59
+ }
60
+ """
61
+ if not self.axes:
62
+ return []
63
+
64
+ plot_data: list[dict] = []
65
+ ax_ohlc: Axes = self.axes[0]
66
+
67
+ body_rectangles: list[Rectangle] = []
68
+ wick_collection: LineCollection | None = None
69
+
70
+ # Find candlestick body Rectangles from the OHLC axis
71
+ # Prefer PatchCollection containing Rectangles, fallback to individual Rectangles in ax.patches
72
+ for collection in ax_ohlc.collections:
73
+ if isinstance(collection, PatchCollection):
74
+ # Check if the collection's patches are Rectangles
75
+ try:
76
+ # Iterating a PatchCollection yields its constituent Patch objects
77
+ patches_are_rects = all(
78
+ isinstance(p, Rectangle) for p in collection
79
+ )
80
+ if (
81
+ patches_are_rects and len(collection.get_paths()) > 0
82
+ ): # Ensure it has paths and they are Rectangles
83
+ for (
84
+ patch
85
+ ) in collection: # Iterate to get actual Rectangle objects
86
+ if isinstance(patch, Rectangle):
87
+ body_rectangles.append(patch)
88
+ if (
89
+ body_rectangles
90
+ ): # If we found rectangles this way, assume this is the primary body collection
91
+ break
92
+ except Exception:
93
+ # Could fail if collection is not iterable in the expected way or patches are not Rectangles
94
+ pass
95
+
96
+ if not body_rectangles:
97
+ for patch in ax_ohlc.patches:
98
+ if isinstance(patch, Rectangle):
99
+ body_rectangles.append(patch)
100
+
101
+ if not body_rectangles:
102
+ pass
103
+
104
+ ax_for_wicks: Axes | None = None
105
+ if len(self.axes) > 1:
106
+ ax_for_wicks = self.axes[1]
107
+
108
+ if ax_for_wicks:
109
+ # Attempt 1: Find wicks in ax_for_wicks.collections (as a LineCollection)
110
+ for collection in ax_for_wicks.collections:
111
+ if isinstance(collection, LineCollection):
112
+ segments = collection.get_segments()
113
+ # Check if the collection contains segments and the first segment looks like a vertical line
114
+ if segments is not None and len(segments) > 0:
115
+ first_segment = segments[0]
116
+ if (
117
+ len(first_segment) == 2 # Segment consists of two points
118
+ and len(first_segment[0]) == 2 # First point has (x, y)
119
+ and len(first_segment[1]) == 2 # Second point has (x, y)
120
+ and np.isclose(
121
+ first_segment[0][0], first_segment[1][0]
122
+ ) # X-coordinates are close (vertical)
123
+ ):
124
+ wick_collection = collection
125
+ break # Found a suitable LineCollection
126
+
127
+ # Attempt 2: If no LineCollection found, try to find wicks from individual Line2D objects in ax_for_wicks.get_lines()
128
+ if not wick_collection and hasattr(ax_for_wicks, "get_lines"):
129
+ potential_wick_segments = []
130
+ for line in ax_for_wicks.get_lines(): # Iterate over Line2D objects
131
+ x_data, y_data = line.get_data()
132
+ # A wick is typically a vertical line defined by two points.
133
+ if len(x_data) == 2 and len(y_data) == 2:
134
+ if np.isclose(x_data[0], x_data[1]): # Check for verticality
135
+ # Create a segment in the format [[x1, y1], [x2, y2]]
136
+ segment = [
137
+ [x_data[0], y_data[0]],
138
+ [x_data[1], y_data[1]],
139
+ ]
140
+ potential_wick_segments.append(segment)
141
+
142
+ if potential_wick_segments:
143
+ # If wick segments were found from individual lines,
144
+ # create a new LineCollection to hold them.
145
+ # This allows the downstream processing logic
146
+ # for wicks to remain consistent.
147
+ # Basic properties are set; color/linestyle
148
+ # are defaults and may not match
149
+ # the original plot's styling if that
150
+ # were relevant for segment extraction.
151
+ wick_collection = LineCollection(
152
+ potential_wick_segments,
153
+ colors="k", # Default color for the temporary collection
154
+ linestyles="solid", # Default linestyle
155
+ )
156
+
157
+ # Process wicks into a map: x_coordinate -> (low_price, high_price)
158
+ wick_segments_map: dict[float, tuple[float, float]] = {}
159
+ if wick_collection:
160
+ for seg in wick_collection.get_segments():
161
+ if len(seg) == 2 and len(seg[0]) == 2 and len(seg[1]) == 2:
162
+ # Ensure x-coordinates are (nearly) identical for a vertical wick line
163
+ if np.isclose(seg[0][0], seg[1][0]):
164
+ x_coord = seg[0][0] # Matplotlib date number
165
+ low_price = min(seg[0][1], seg[1][1])
166
+ high_price = max(seg[0][1], seg[1][1])
167
+ wick_segments_map[x_coord] = (low_price, high_price)
168
+
169
+ body_rectangles.sort(key=lambda r: r.get_x())
170
+
171
+ for rect in body_rectangles:
172
+ x_left = rect.get_x()
173
+ width = rect.get_width()
174
+ x_center_num = x_left + width / 2.0
175
+
176
+ try:
177
+ date_dt = mdates.num2date(x_center_num)
178
+ date_str = date_dt.strftime("%Y-%m-%d")
179
+ except ValueError:
180
+ date_str = f"raw_date_{x_center_num:.2f}"
181
+
182
+ y_bottom = rect.get_y()
183
+ height = rect.get_height()
184
+ face_color = rect.get_facecolor() # RGBA tuple
185
+
186
+ # Infer open and close prices
187
+ # Heuristic: Green component > Red component for an "up" candle (close > open)
188
+ # This assumes standard green for up, red for down.
189
+ # A more robust method would involve knowing the exact up/down colors used.
190
+ is_up_candle = (
191
+ face_color[1] > face_color[0]
192
+ ) # Compare Green and Red components
193
+
194
+ if is_up_candle: # Typically green: price went up
195
+ open_price = y_bottom
196
+ close_price = y_bottom + height
197
+ else: # Typically red: price went down (or other color)
198
+ close_price = y_bottom
199
+ open_price = y_bottom + height
200
+
201
+ matched_wick_data = None
202
+ closest_wick_x = None
203
+ min_diff = float("inf")
204
+
205
+ for wick_x, prices in wick_segments_map.items():
206
+ diff = abs(wick_x - x_center_num)
207
+ if diff < min_diff:
208
+ min_diff = diff
209
+ closest_wick_x = wick_x
210
+
211
+ # Tolerance for matching wick x-coordinate (e.g., 10% of candle width)
212
+ if closest_wick_x is not None and min_diff < (width * 0.1):
213
+ matched_wick_data = wick_segments_map[closest_wick_x]
214
+
215
+ if matched_wick_data:
216
+ low_price, high_price = matched_wick_data
217
+ # Ensure high >= max(open,close) and low <= min(open,close)
218
+ high_price = max(high_price, open_price, close_price)
219
+ low_price = min(low_price, open_price, close_price)
220
+ else:
221
+ # Fallback if no wick found: high is max(open,close), low is min(open,close)
222
+ high_price = max(open_price, close_price)
223
+ low_price = min(open_price, close_price)
224
+
225
+ plot_data.append(
226
+ {
227
+ "value": date_str,
228
+ "open": open_price,
229
+ "high": high_price,
230
+ "low": low_price,
231
+ "close": close_price,
232
+ "volume": 0,
233
+ }
234
+ )
235
+ self._elements.extend(body_rectangles)
236
+ return plot_data
237
+
238
+ def _extract_axes_data(self) -> dict:
239
+ return {}
@@ -5,12 +5,14 @@ from matplotlib.axes import Axes
5
5
  from maidr.core.enum import PlotType
6
6
  from maidr.core.plot.barplot import BarPlot
7
7
  from maidr.core.plot.boxplot import BoxPlot
8
+ from maidr.core.plot.candlestick import CandlestickPlot
8
9
  from maidr.core.plot.grouped_barplot import GroupedBarPlot
9
10
  from maidr.core.plot.heatmap import HeatPlot
10
11
  from maidr.core.plot.histogram import HistPlot
11
12
  from maidr.core.plot.lineplot import MultiLinePlot
12
13
  from maidr.core.plot.maidr_plot import MaidrPlot
13
14
  from maidr.core.plot.scatterplot import ScatterPlot
15
+ from maidr.core.plot.regplot import SmoothPlot
14
16
 
15
17
 
16
18
  class MaidrPlotFactory:
@@ -29,20 +31,37 @@ class MaidrPlotFactory:
29
31
  """
30
32
 
31
33
  @staticmethod
32
- def create(ax: Axes, plot_type: PlotType, **kwargs) -> MaidrPlot:
33
- if PlotType.BAR == plot_type or PlotType.COUNT == plot_type:
34
- return BarPlot(ax)
34
+ def create(ax: Axes | list[Axes], plot_type: PlotType, **kwargs) -> MaidrPlot:
35
+ if isinstance(ax, list):
36
+ single_ax = ax[0]
37
+ else:
38
+ single_ax = ax
39
+
40
+ if plot_type == PlotType.CANDLESTICK:
41
+ if isinstance(ax, list):
42
+ # If ax is a list of lists, flatten it
43
+ if ax and isinstance(ax[0], list):
44
+ axes = ax[0] # Take the first inner list
45
+ else:
46
+ axes = ax # Use the list as-is
47
+ else:
48
+ axes = [ax] # Wrap single axes in list
49
+ return CandlestickPlot(axes, **kwargs)
50
+ elif PlotType.BAR == plot_type or PlotType.COUNT == plot_type:
51
+ return BarPlot(single_ax)
35
52
  elif PlotType.BOX == plot_type:
36
- return BoxPlot(ax, **kwargs)
53
+ return BoxPlot(single_ax, **kwargs)
37
54
  elif PlotType.HEAT == plot_type:
38
- return HeatPlot(ax, **kwargs)
55
+ return HeatPlot(single_ax, **kwargs)
39
56
  elif PlotType.HIST == plot_type:
40
- return HistPlot(ax)
57
+ return HistPlot(single_ax)
41
58
  elif PlotType.LINE == plot_type:
42
- return MultiLinePlot(ax)
59
+ return MultiLinePlot(single_ax)
43
60
  elif PlotType.SCATTER == plot_type:
44
- return ScatterPlot(ax)
61
+ return ScatterPlot(single_ax)
45
62
  elif PlotType.DODGED == plot_type or PlotType.STACKED == plot_type:
46
- return GroupedBarPlot(ax, plot_type, **kwargs)
63
+ return GroupedBarPlot(single_ax, plot_type, **kwargs)
64
+ elif PlotType.SMOOTH == plot_type:
65
+ return SmoothPlot(single_ax, **kwargs)
47
66
  else:
48
67
  raise TypeError(f"Unsupported plot type: {plot_type}.")
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+
3
+ from matplotlib.axes import Axes
4
+ from maidr.core.plot.maidr_plot import MaidrPlot
5
+ from maidr.exception.extraction_error import ExtractionError
6
+ import numpy as np
7
+ from maidr.core.enum.plot_type import PlotType
8
+ from maidr.core.enum.maidr_key import MaidrKey
9
+ from maidr.util.regression_line_utils import find_regression_line
10
+ from maidr.util.svg_utils import data_to_svg_coords
11
+
12
+
13
+ class SmoothPlot(MaidrPlot):
14
+ """
15
+ Extracts and represents a regression line as a smooth plot for MAIDR.
16
+
17
+ Parameters
18
+ ----------
19
+ ax : Axes
20
+ The matplotlib axes object containing the regression line.
21
+ """
22
+
23
+ def __init__(self, ax: Axes, **kwargs):
24
+ """
25
+ Initialize a SmoothPlot for a regression line.
26
+
27
+ Parameters
28
+ ----------
29
+ ax : Axes
30
+ The matplotlib axes object containing the regression line.
31
+ """
32
+ super().__init__(ax, PlotType.SMOOTH)
33
+ self._smooth_gid = None
34
+ self._regression_line = kwargs.get("regression_line", None)
35
+ self._poly_gid = kwargs.get("poly_gid", None)
36
+ self._is_polycollection = kwargs.get("is_polycollection", False)
37
+
38
+ def _get_selector(self):
39
+ """
40
+ Return the CSS selector for highlighting the regression line or PolyCollection in the SVG output.
41
+ """
42
+ if self._is_polycollection and self._poly_gid:
43
+ return [f"g[id='{self._poly_gid}'] > defs > path"]
44
+ if self._smooth_gid:
45
+ return [f"g[id='{self._smooth_gid}'] path"]
46
+ return ["g[id^='maidr-'] path"]
47
+
48
+ def _extract_plot_data(self) -> list:
49
+ """
50
+ Extract XY data from the regression line for serialization, including SVG coordinates.
51
+
52
+ Returns
53
+ -------
54
+ list
55
+ A list of lists containing dictionaries with X and Y coordinates, and SVG coordinates.
56
+ """
57
+ regression_line = (
58
+ self._regression_line
59
+ if self._regression_line is not None
60
+ else find_regression_line(self.ax)
61
+ )
62
+ if regression_line is None:
63
+ raise ExtractionError(PlotType.SMOOTH, self.ax)
64
+ self._elements.append(regression_line)
65
+ self._smooth_gid = regression_line.get_gid()
66
+ xydata = np.asarray(regression_line.get_xydata())
67
+ x_data, y_data = xydata[:, 0], xydata[:, 1]
68
+ x_svg, y_svg = data_to_svg_coords(self.ax, x_data, y_data)
69
+ return [
70
+ [
71
+ {
72
+ MaidrKey.X: float(x),
73
+ MaidrKey.Y: float(y),
74
+ "svg_x": float(sx),
75
+ "svg_y": float(sy),
76
+ }
77
+ for x, y, sx, sy in zip(x_data, y_data, x_svg, y_svg)
78
+ ]
79
+ ]
@@ -1,10 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import wrapt
4
-
5
4
  from matplotlib.axes import Axes
6
5
 
7
- from maidr.core.context_manager import ContextManager, BoxplotContextManager
6
+ from maidr.core.context_manager import BoxplotContextManager, ContextManager
8
7
  from maidr.core.enum import PlotType
9
8
  from maidr.core.figure_manager import FigureManager
10
9
 
@@ -0,0 +1,57 @@
1
+ import wrapt
2
+ from typing import Any, Callable, Dict, Tuple
3
+ from matplotlib.patches import Rectangle
4
+ from mplfinance import original_flavor
5
+
6
+ from maidr.core.context_manager import ContextManager
7
+ from maidr.core.enum.plot_type import PlotType
8
+ from maidr.core.figure_manager import FigureManager
9
+
10
+
11
+ def candlestick(
12
+ wrapped: Callable[..., list[Rectangle]],
13
+ instance: Any,
14
+ args: Tuple[Any, ...],
15
+ kwargs: Dict[str, Any],
16
+ ) -> list[Rectangle]:
17
+ """
18
+ Patch function for candlestick plots.
19
+
20
+ This function patches the candlestick plotting function to extract
21
+ candlestick data and create MAIDR plot instances for visualization.
22
+
23
+ Parameters
24
+ ----------
25
+ wrapped : Callable[..., list[Rectangle]]
26
+ The original candlestick function to be wrapped.
27
+ instance : Any
28
+ The instance of the class where the function is being patched.
29
+ args : Tuple[Any, ...]
30
+ Positional arguments passed to the original function.
31
+ kwargs : Dict[str, Any]
32
+ Keyword arguments passed to the original function.
33
+
34
+ Returns
35
+ -------
36
+ list[Rectangle]
37
+ The list of Rectangle objects returned by the original candlestick function.
38
+
39
+ Notes
40
+ -----
41
+ This wrapper function is used by the wrapt library to patch the mplfinance
42
+ candlestick function. It creates MAIDR plot instances for candlestick charts
43
+ while preserving the original functionality.
44
+ """
45
+ with ContextManager.set_internal_context():
46
+ # Patch the plotting function.
47
+ plot = wrapped(*args, **kwargs)
48
+
49
+ axes = []
50
+ for ax in plot:
51
+ axes.append(FigureManager.get_axes(ax))
52
+ FigureManager.create_maidr(axes, PlotType.CANDLESTICK)
53
+
54
+ return plot
55
+
56
+
57
+ wrapt.wrap_function_wrapper(original_flavor, "_candlestick", candlestick)
@@ -22,6 +22,7 @@ def common(plot_type, wrapped, _, args, kwargs) -> Any:
22
22
 
23
23
  # Extract the data points for MAIDR from the plot.
24
24
  ax = FigureManager.get_axes(plot)
25
- FigureManager.create_maidr(ax, plot_type)
25
+ kwargs.pop("ax", None)
26
+ FigureManager.create_maidr(ax, plot_type, **kwargs)
26
27
 
27
28
  return plot
@@ -6,6 +6,8 @@ import numpy as np
6
6
  from matplotlib.axes import Axes
7
7
  from matplotlib.container import BarContainer
8
8
  from matplotlib.patches import Polygon
9
+ from matplotlib.lines import Line2D
10
+ import uuid
9
11
 
10
12
  from maidr.core.context_manager import ContextManager
11
13
  from maidr.core.enum import PlotType
@@ -21,6 +23,9 @@ def mpl_hist(
21
23
  np.ndarray,
22
24
  BarContainer | Polygon | list[BarContainer | Polygon],
23
25
  ]:
26
+ """
27
+ Patch matplotlib Axes.hist to register HIST layer for MAIDR.
28
+ """
24
29
  # Don't proceed if the call is made internally by the patched function.
25
30
  if ContextManager.is_internal_context():
26
31
  return wrapped(*args, **kwargs)
@@ -40,4 +45,27 @@ def mpl_hist(
40
45
 
41
46
  @wrapt.patch_function_wrapper("seaborn", "histplot")
42
47
  def sns_hist(wrapped, instance, args, kwargs) -> Axes:
43
- return common(PlotType.HIST, wrapped, instance, args, kwargs)
48
+ """
49
+ Patch seaborn.histplot to register HIST and (if kde=True) SMOOTH layers for MAIDR.
50
+ """
51
+ # Register the histogram as HIST as before
52
+ ax = common(PlotType.HIST, wrapped, instance, args, kwargs)
53
+ # Only register KDE overlay as SMOOTH if kde=True was set
54
+ kde_enabled = kwargs.get("kde", False)
55
+ if kde_enabled:
56
+ # Find the KDE line(s) and register as SMOOTH
57
+ axes = ax if isinstance(ax, Axes) else getattr(ax, "axes", None)
58
+ if axes is not None:
59
+ for line in axes.get_lines():
60
+ if isinstance(line, Line2D):
61
+ if line.get_gid() is None:
62
+ gid = f"maidr-{uuid.uuid4()}"
63
+ line.set_gid(gid)
64
+ common(
65
+ PlotType.SMOOTH,
66
+ lambda *a, **k: axes,
67
+ instance,
68
+ args,
69
+ dict(kwargs, regression_line=line),
70
+ )
71
+ return ax
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ import wrapt
4
+ import uuid
5
+ import numpy as np
6
+ from matplotlib.axes import Axes
7
+ from matplotlib.lines import Line2D
8
+ from matplotlib.collections import PolyCollection
9
+ from maidr.core.enum import PlotType
10
+ from maidr.patch.common import common
11
+ from maidr.core.context_manager import ContextManager
12
+ from maidr.util.svg_utils import unique_lines_by_xy
13
+
14
+
15
+ def kde(wrapped, instance, args, kwargs) -> Axes | Line2D | PolyCollection:
16
+ """
17
+ Patch for seaborn.kdeplot: register all unique lines and/or filled boundaries as SMOOTH.
18
+ """
19
+ with ContextManager.set_internal_context():
20
+ plot = wrapped(*args, **kwargs)
21
+ ax = plot if isinstance(plot, Axes) else getattr(plot, "axes", None)
22
+ if ax is not None:
23
+ # Register all unique Line2D objects
24
+ lines = [line for line in ax.get_lines() if isinstance(line, Line2D)]
25
+ for kde_line in unique_lines_by_xy(lines):
26
+ if kde_line.get_gid() is None:
27
+ gid = f"maidr-{uuid.uuid4()}"
28
+ kde_line.set_gid(gid)
29
+ common(
30
+ PlotType.SMOOTH,
31
+ lambda *a, **k: ax,
32
+ instance,
33
+ args,
34
+ dict(kwargs, regression_line=kde_line),
35
+ )
36
+ # Register all PolyCollection boundaries as SMOOTH
37
+ for poly in [c for c in ax.collections if isinstance(c, PolyCollection)]:
38
+ if poly.get_paths():
39
+ path = poly.get_paths()[0]
40
+ boundary = path.vertices
41
+ # Defensive: ensure boundary is a numpy array
42
+ boundary = np.asarray(boundary)
43
+ kde_line = Line2D(boundary[:, 0], boundary[:, 1])
44
+ gid = f"maidr-{uuid.uuid4()}"
45
+ kde_line.set_gid(gid)
46
+ poly.set_gid(gid) # Assign gid to PolyCollection group
47
+ common(
48
+ PlotType.SMOOTH,
49
+ lambda *a, **k: ax,
50
+ instance,
51
+ args,
52
+ dict(
53
+ kwargs,
54
+ regression_line=kde_line,
55
+ poly_gid=gid,
56
+ is_polycollection=True,
57
+ ),
58
+ )
59
+ return plot
60
+
61
+
62
+ # Patch seaborn kdeplot
63
+ wrapt.wrap_function_wrapper("seaborn", "kdeplot", kde)
@@ -6,6 +6,7 @@ from matplotlib.lines import Line2D
6
6
 
7
7
  from maidr.core.enum import PlotType
8
8
  from maidr.patch.common import common
9
+ from maidr.core.enum.smooth_keywords import SMOOTH_KEYWORDS
9
10
 
10
11
 
11
12
  def line(wrapped, instance, args, kwargs) -> Axes | list[Line2D]:
@@ -0,0 +1,81 @@
1
+ from __future__ import annotations
2
+
3
+ import wrapt
4
+ from matplotlib.axes import Axes
5
+ from matplotlib.lines import Line2D
6
+ from maidr.core.enum import PlotType
7
+ from maidr.patch.common import common
8
+ import numpy as np
9
+ from maidr.core.context_manager import ContextManager
10
+ import uuid
11
+ from maidr.core.enum.smooth_keywords import SMOOTH_KEYWORDS
12
+ from maidr.util.regression_line_utils import find_regression_line
13
+
14
+
15
+ def regplot(wrapped, instance, args, kwargs) -> Axes:
16
+ """
17
+ Patch seaborn.regplot to register SCATTER and SMOOTH layers for MAIDR.
18
+ """
19
+ scatter = kwargs.get("scatter", True)
20
+ if scatter:
21
+ ax = common(PlotType.SCATTER, wrapped, instance, args, kwargs)
22
+ else:
23
+ # Prevent any MAIDR layer registration during plotting when scatter=False
24
+ with ContextManager.set_internal_context():
25
+ ax = wrapped(*args, **kwargs)
26
+ axes = ax if isinstance(ax, Axes) else ax.axes if hasattr(ax, "axes") else None
27
+ if axes is not None:
28
+ regression_line = find_regression_line(axes)
29
+ if regression_line is not None:
30
+ # ---
31
+ # Assign a unique gid to the regression line if not already set.
32
+ # This is necessary because the SVG output may contain many <g> and <path> tags,
33
+ # and only the regression line should be uniquely selectable for accessibility and highlighting.
34
+ # By setting a unique gid, we ensure the backend and frontend can generate a reliable selector
35
+ # (e.g., g[id='maidr-...'] path) that matches only the intended regression line.
36
+ # ---
37
+ if regression_line.get_gid() is None:
38
+ new_gid = f"maidr-{uuid.uuid4()}"
39
+ regression_line.set_gid(new_gid)
40
+ common(
41
+ PlotType.SMOOTH,
42
+ lambda *a, **k: ax,
43
+ instance,
44
+ args,
45
+ dict(kwargs, regression_line=regression_line),
46
+ )
47
+ return ax
48
+
49
+
50
+ def patched_plot(wrapped, instance, args, kwargs):
51
+ """
52
+ Patch matplotlib Axes.plot to register SMOOTH layers for MAIDR if the label matches SMOOTH_KEYWORDS.
53
+ """
54
+ # Call the original plot function
55
+ lines = wrapped(*args, **kwargs)
56
+ # lines can be a list of Line2D objects
57
+ for line in lines:
58
+ if isinstance(line, Line2D):
59
+ label = line.get_label() or ""
60
+ label_str = str(label)
61
+ # Detect if this is a smooth/regression line by label
62
+ if any(key in label_str.lower() for key in SMOOTH_KEYWORDS):
63
+ # Assign a unique gid if not already set
64
+ if line.get_gid() is None:
65
+ new_gid = f"maidr-{uuid.uuid4()}"
66
+ line.set_gid(new_gid)
67
+ # Register as a smooth layer
68
+ common(
69
+ PlotType.SMOOTH,
70
+ lambda *a, **k: instance,
71
+ instance,
72
+ args,
73
+ dict(kwargs, regression_line=line),
74
+ )
75
+ return lines
76
+
77
+
78
+ # Patch seaborn function.
79
+ wrapt.wrap_function_wrapper("seaborn", "regplot", regplot)
80
+ # Patch matplotlib Axes.plot for smooth line detection/registration
81
+ wrapt.wrap_function_wrapper(Axes, "plot", patched_plot)
@@ -0,0 +1,12 @@
1
+ from maidr.core.enum.plot_type import PlotType
2
+ from typing import List
3
+
4
+
5
+ def deduplicate_smooth_and_line(plots: List[object]) -> List[object]:
6
+ """
7
+ If any SMOOTH plots exist, remove all LINE plots from the list.
8
+ """
9
+ has_smooth = any(getattr(plot, "type", None) == PlotType.SMOOTH for plot in plots)
10
+ if has_smooth:
11
+ return [plot for plot in plots if getattr(plot, "type", None) != PlotType.LINE]
12
+ return plots
@@ -0,0 +1,19 @@
1
+ from matplotlib.lines import Line2D
2
+ import numpy as np
3
+
4
+
5
+ def find_regression_line(axes):
6
+ """
7
+ Helper to find the regression line (Line2D) in the given axes.
8
+ """
9
+ return next(
10
+ (
11
+ artist
12
+ for artist in axes.get_children()
13
+ if isinstance(artist, Line2D)
14
+ and artist.get_label() not in (None, "", "_nolegend_")
15
+ and artist.get_xydata() is not None
16
+ and np.asarray(artist.get_xydata()).size > 0
17
+ ),
18
+ None,
19
+ )
@@ -0,0 +1,46 @@
1
+ import numpy as np
2
+ from matplotlib.axes import Axes
3
+ from matplotlib.lines import Line2D
4
+ from typing import List, Tuple
5
+
6
+
7
+ def data_to_svg_coords(
8
+ ax: Axes, x_data: np.ndarray, y_data: np.ndarray
9
+ ) -> Tuple[np.ndarray, np.ndarray]:
10
+ """
11
+ Convert data coordinates to SVG coordinates using matplotlib transforms.
12
+ Returns x_svg, y_svg arrays in SVG points.
13
+ """
14
+ fig = getattr(ax, "figure", None)
15
+ if fig is None:
16
+ import matplotlib.pyplot as plt
17
+
18
+ fig = plt.gcf()
19
+ try:
20
+ fig.tight_layout()
21
+ except Exception:
22
+ pass
23
+ xy_disp = ax.transData.transform(np.column_stack([x_data, y_data]))
24
+ xy_figpix = fig.transFigure.inverted().transform(xy_disp)
25
+ fig_width_pts = fig.get_size_inches()[0] * 72
26
+ fig_height_pts = fig.get_size_inches()[1] * 72
27
+ x_svg = xy_figpix[:, 0] * fig_width_pts
28
+ y_svg = (1 - xy_figpix[:, 1]) * fig_height_pts
29
+ return x_svg, y_svg
30
+
31
+
32
+ def unique_lines_by_xy(lines: List[Line2D]) -> List[Line2D]:
33
+ """
34
+ Deduplicate lines by rounded xy data (8 decimals). Only lines with >0 points are kept.
35
+ """
36
+ seen_xy = set()
37
+ unique_lines = []
38
+ for line in lines:
39
+ xy = np.asarray(line.get_xydata())
40
+ if xy.shape[0] == 0:
41
+ continue
42
+ xy_rounded = tuple(map(tuple, np.round(xy, 8)))
43
+ if xy_rounded not in seen_xy:
44
+ seen_xy.add(xy_rounded)
45
+ unique_lines.append(line)
46
+ return unique_lines
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [tool.poetry]
6
6
  name = "maidr"
7
- version = "1.0.0"
7
+ version = "1.2.0"
8
8
  description = "Multimodal Access and Interactive Data Representations"
9
9
  authors = [
10
10
  "JooYoung Seo <jseo1005@illinois.edu>",
@@ -43,6 +43,8 @@ htmltools = ">=0.5"
43
43
  jupyter = "^1.0.0"
44
44
  wrapt = "^1.16.0"
45
45
  virtualenv = "^20.26.6"
46
+ mplfinance = "^0.12.10b0"
47
+ statsmodels = "^0.14.4"
46
48
 
47
49
  [tool.poetry.group.dev.dependencies]
48
50
  black = "23.3.0"
@@ -71,7 +73,7 @@ match = "(main|master)"
71
73
  prerelease_token = "rc"
72
74
  prerelease = false
73
75
 
74
- [tool.semantic_release.changelog]
76
+ [tool.semantic_release.changelog.default_templates]
75
77
  template_dir = "templates"
76
78
  changelog_file = "CHANGELOG.md"
77
79
  exclude_commit_patterns = [
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes