Coverage for src/plotly_gtk/utils/ticks.py: 14%
131 statements
« prev ^ index » next coverage.py v7.6.4, created at 2024-11-08 21:22 +0000
« prev ^ index » next coverage.py v7.6.4, created at 2024-11-08 21:22 +0000
1import logging
2import numbers
4import numpy as np
5from prefixed import Float
7logger = logging.getLogger(__name__)
10class Ticks:
11 ROUND_SET = {
12 10: [2, 5, 10],
13 "LOG1": [-0.046, 0, 0.301, 0.477, 0.602, 0.699, 0.778, 0.845, 0.903, 0.954, 1],
14 "LOG2": [-0.301, 0, 0.301, 0.699, 1],
15 }
17 def __init__(self, layout, axis, length):
18 logger.debug(
19 f"Ticks.__init__(layout: {layout}, axis: {axis}, length: {length})"
20 )
21 self.layout = layout
22 self.axis = axis
23 self.axis_layout = self.layout[axis]
24 self.axis_layout["_range"][0] = self.axis_layout["_range"][0]
25 self.axis_layout["_range"][-1] = self.axis_layout["_range"][-1]
26 self.length = length
28 def tick_first(self):
29 logger.debug("Calling Ticks.tick_first")
30 axrev = self.axis_layout["_range"][-1] < self.axis_layout["_range"][0]
31 round_func = np.floor if axrev else np.ceil
32 logger.debug(f"_dtick: {self.axis_layout["_dtick"]}")
33 if isinstance(self.axis_layout["_dtick"], numbers.Number):
34 tmin = (
35 round_func(
36 (self.axis_layout["_range"][0] - self.axis_layout["_tick0"])
37 / self.axis_layout["_dtick"]
38 )
39 * self.axis_layout["_dtick"]
40 + self.axis_layout["_tick0"]
41 )
42 else:
43 ttype = self.axis_layout["_dtick"][0]
44 dtnum = int(self.axis_layout["_dtick"][1:])
45 if ttype == "M":
46 # TODO: Implement M type ticks
47 raise NotImplementedError("M type ticks not implemented yet")
48 elif ttype == "L":
49 tmin = np.log10(
50 round_func(
51 (
52 np.power(10, self.axis_layout["_range"][0])
53 - self.axis_layout["_tick0"]
54 )
55 / dtnum
56 )
57 * dtnum
58 + self.axis_layout["_tick0"]
59 )
60 elif ttype == "D":
61 # TODO: Finish
62 tickset = (
63 self.ROUND_SET["LOG2"] if dtnum == 2 else self.ROUND_SET["LOG1"]
64 )
65 tickset = self.ROUND_SET[
66 "LOG2"
67 ] # FIXME: temp fix - demo log_1 is returning wrong dtnum
68 frac = self.round_up(self.axis_layout["_range"][0] % 1, tickset, axrev)
69 tmin = np.floor(self.axis_layout["_range"][0]) + np.log(
70 np.round(np.power(10, frac), 1)
71 ) / np.log(10)
72 else:
73 raise ValueError(f"Unknown dtick: {self.axis_layout['_dtick']}")
74 logger.debug(f"Returning {tmin} from Ticks.tick_first")
75 return tmin
77 def tick_increment(self, x: float, dtick: str, rev: bool) -> float:
78 logger.debug(
79 f"Calling Ticks.tick_increment(x: {x}, dtick: {dtick}, rev: {rev})"
80 )
81 sign = -1 if rev else 1
82 ttype = dtick[0]
83 dtnum = int(dtick[1:])
84 # TODO: Do this: dtsigned = sign * dtnum
86 if ttype == "M":
87 raise NotImplementedError
88 elif ttype == "L":
89 raise NotImplementedError
90 elif ttype == "D":
91 tickset = self.ROUND_SET["LOG2"] if dtnum == 2 else self.ROUND_SET["LOG1"]
92 tickset = self.ROUND_SET[
93 "LOG2"
94 ] # FIXME: temp fix - demo log_1 is returning wrong dtnum
95 x2 = x + sign * 0.01
96 frac = self.round_up(x2 % 1, tickset, rev)
97 inc = np.floor(x2) + np.log(np.round(np.power(10, frac), 1)) / np.log(10)
98 else:
99 raise ValueError(f"Unknown dtick: {self.axis_layout['_dtick']}")
100 logger.debug(f"Returning {inc} from Ticks.tick_increment")
101 assert isinstance(inc, float)
102 return inc
104 def calculate(self):
105 rev = self.axis_layout["_range"][0] >= self.axis_layout["_range"][-1]
106 self.prepare()
108 if "tickmode" in self.axis_layout and self.axis_layout["tickmode"] == "array":
109 return self.array_ticks()
111 if isinstance(self.axis_layout["_dtick"], numbers.Number):
112 logger.debug("Numeric dtick")
113 self.axis_layout["_tickvals"] = np.arange(
114 self.tick_first(),
115 self.axis_layout["_range"][-1],
116 self.axis_layout["_dtick"],
117 )
118 else:
119 logger.debug("Text dtick")
120 x = self.tick_first()
121 _tickvals = np.array([x])
122 logger.debug(f"_range: {self.axis_layout["_range"]}")
123 while True:
124 x = self.tick_increment(x, self.axis_layout["_dtick"], rev)
125 if x >= (
126 self.axis_layout["_range"][-1]
127 if not rev
128 else self.axis_layout["_range"][0]
129 ):
130 break
131 _tickvals = np.append(_tickvals, [x])
132 logger.debug(f"Original _tickvals: {_tickvals}")
133 self.axis_layout["_tickvals"] = np.power(10, _tickvals)
134 logger.debug(f"Corrected _tickvals: {self.axis_layout["_tickvals"]}")
135 if isinstance(self.axis_layout["_dtick"], numbers.Number):
136 self.axis_layout["_ticktext"] = np.char.mod(
137 "%g", self.axis_layout["_tickvals"]
138 )
139 self.axis_layout["_ticktext"] = [
140 f"{Float(val):%1000.3H}" for val in self.axis_layout["_tickvals"]
141 ]
142 else:
143 _text = []
144 for _tick in self.axis_layout["_tickvals"]:
145 logval = np.log10(_tick)
146 if logval == np.floor(logval):
147 _text.append(f"{_tick:g}")
148 else:
149 _text.append(
150 f"<sup>{_tick / np.power(10, np.floor(logval)):g}</sup>"
151 )
152 self.axis_layout["_ticktext"] = _text
154 return self.axis_layout["_tickvals"]
156 def prepare(self):
157 if (
158 "tickmode" in self.axis_layout and self.axis_layout["tickmode"] == "auto"
159 ) or "dtick" not in self.axis_layout:
160 nt = self.axis_layout["nticks"]
162 if nt == 0:
163 min_px = 40 if self.axis.startswith("y") else 80
164 nt = round(self.length / min_px)
165 nt = min(10, max(5, nt))
166 self.auto_ticks(
167 (self.axis_layout["_range"][-1] - self.axis_layout["_range"][0]) / nt
168 )
170 @staticmethod
171 def round_up(
172 value: float, rounding_set: list[float], rev: bool = False
173 ) -> float | int:
174 # TODO: rev
175 if value <= np.max(rounding_set):
176 rtn = rounding_set[np.argwhere(np.array(rounding_set) > value)[0][0]]
177 assert isinstance(rtn, float) or isinstance(rtn, int)
178 return rtn
179 return max(rounding_set)
181 @staticmethod
182 def round_dtick(rough_dtick, base, rounding_set):
183 rounded_val = Ticks.round_up(rough_dtick / base, rounding_set)
184 return base * rounded_val
186 def auto_ticks(self, rough_dtick):
187 def get_base(v):
188 return np.power(v, np.floor(np.log(rough_dtick) / np.log(10)))
190 if self.axis_layout["_type"] == "log":
191 self.axis_layout["_tick0"] = 0
192 # FIXME: make this work
193 if False: # rough_dtick > 0.7:
194 self.axis_layout["_dtick"] = np.ceil(rough_dtick)
195 elif (
196 np.abs(self.axis_layout["_range"][-1] - self.axis_layout["_range"][0])
197 < 1
198 ):
199 nt = (
200 1.5
201 * np.abs(
202 self.axis_layout["_range"][-1] - self.axis_layout["_range"][0]
203 )
204 / rough_dtick
205 )
206 rough_dtick = (
207 np.abs(
208 np.power(10, self.axis_layout["_range"][-1])
209 - np.power(10, self.axis_layout["_range"][0])
210 )
211 / nt
212 )
213 base = get_base(10)
214 self.axis_layout["_dtick"] = "L" + str(
215 self.round_dtick(rough_dtick, base, self.ROUND_SET[10])
216 )
217 else:
218 self.axis_layout["_dtick"] = "D2" if rough_dtick > 0.3 else "D1"
219 else:
220 self.axis_layout["_tick0"] = 0
221 base = get_base(10)
222 self.axis_layout["_dtick"] = self.round_dtick(
223 rough_dtick, base, self.ROUND_SET[10]
224 )
226 def array_ticks(self):
227 vals = self.axis_layout["tickvals"]
228 if self.axis_layout["_type"] == "log":
229 vals = np.log10(vals)
230 range = self.axis_layout["_range"]
231 idx_min = np.argwhere(vals >= range[0])[0][0]
232 idx_max = np.argwhere(vals <= range[1])[-1][0]
233 idx_slice = slice(idx_min, idx_max + 1)
235 self.axis_layout["_tickvals"] = self.axis_layout["tickvals"][idx_slice]
236 self.axis_layout["_ticktext"] = self.axis_layout["ticktext"][idx_slice]
237 return self.axis_layout["_tickvals"]