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

1import logging 

2import numbers 

3 

4import numpy as np 

5from prefixed import Float 

6 

7logger = logging.getLogger(__name__) 

8 

9 

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 } 

16 

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 

27 

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 

76 

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 

85 

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 

103 

104 def calculate(self): 

105 rev = self.axis_layout["_range"][0] >= self.axis_layout["_range"][-1] 

106 self.prepare() 

107 

108 if "tickmode" in self.axis_layout and self.axis_layout["tickmode"] == "array": 

109 return self.array_ticks() 

110 

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 

153 

154 return self.axis_layout["_tickvals"] 

155 

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"] 

161 

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 ) 

169 

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) 

180 

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 

185 

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))) 

189 

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 ) 

225 

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) 

234 

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"]