@@ -131,6 +131,260 @@ pub struct UiHistogramView {
131131 pub show_grid : bool ,
132132 /// Flag to trigger plot bounds reset (auto-fit to data).
133133 pub needs_plot_reset : bool ,
134+ /// Current histogram view transform.
135+ pub transform : ViewTransform ,
136+ }
137+
138+ #[ derive( Clone , Copy , Debug , PartialEq , Eq , Default ) ]
139+ pub enum Rotation {
140+ #[ default]
141+ R0 ,
142+ R90 ,
143+ R180 ,
144+ R270 ,
145+ }
146+
147+ #[ derive( Clone , Copy , Debug , PartialEq , Eq ) ]
148+ pub struct ViewTransform {
149+ pub rotation : Rotation ,
150+ pub flip_h : bool ,
151+ pub flip_v : bool ,
152+ }
153+
154+ impl Default for ViewTransform {
155+ fn default ( ) -> Self {
156+ Self {
157+ rotation : Rotation :: R0 ,
158+ flip_h : false ,
159+ flip_v : false ,
160+ }
161+ }
162+ }
163+
164+ impl ViewTransform {
165+ #[ must_use]
166+ pub fn is_identity ( self ) -> bool {
167+ self . rotation == Rotation :: R0 && !self . flip_h && !self . flip_v
168+ }
169+
170+ pub fn rotate_cw ( & mut self ) {
171+ self . rotation = match self . rotation {
172+ Rotation :: R0 => Rotation :: R90 ,
173+ Rotation :: R90 => Rotation :: R180 ,
174+ Rotation :: R180 => Rotation :: R270 ,
175+ Rotation :: R270 => Rotation :: R0 ,
176+ } ;
177+ }
178+
179+ pub fn rotate_ccw ( & mut self ) {
180+ self . rotation = match self . rotation {
181+ Rotation :: R0 => Rotation :: R270 ,
182+ Rotation :: R90 => Rotation :: R0 ,
183+ Rotation :: R180 => Rotation :: R90 ,
184+ Rotation :: R270 => Rotation :: R180 ,
185+ } ;
186+ }
187+
188+ pub fn flip_horizontal ( & mut self ) {
189+ self . flip_h = !self . flip_h ;
190+ }
191+
192+ pub fn flip_vertical ( & mut self ) {
193+ self . flip_v = !self . flip_v ;
194+ }
195+
196+ pub fn reset ( & mut self ) {
197+ * self = Self :: default ( ) ;
198+ }
199+
200+ #[ must_use]
201+ pub fn display_size ( self , width : usize , height : usize ) -> ( usize , usize ) {
202+ match self . rotation {
203+ Rotation :: R90 | Rotation :: R270 => ( height, width) ,
204+ _ => ( width, height) ,
205+ }
206+ }
207+
208+ #[ must_use]
209+ pub fn apply_inverse (
210+ self ,
211+ x : usize ,
212+ y : usize ,
213+ width : usize ,
214+ height : usize ,
215+ ) -> Option < ( usize , usize ) > {
216+ if width == 0 || height == 0 {
217+ return None ;
218+ }
219+ let ( disp_w, disp_h) = self . display_size ( width, height) ;
220+ if x >= disp_w || y >= disp_h {
221+ return None ;
222+ }
223+ let mut x = x;
224+ let mut y = y;
225+ if self . flip_h {
226+ x = disp_w. saturating_sub ( 1 ) . saturating_sub ( x) ;
227+ }
228+ if self . flip_v {
229+ y = disp_h. saturating_sub ( 1 ) . saturating_sub ( y) ;
230+ }
231+ let ( src_x, src_y) = match self . rotation {
232+ Rotation :: R0 => ( x, y) ,
233+ Rotation :: R90 => ( y, height. saturating_sub ( 1 ) . saturating_sub ( x) ) ,
234+ Rotation :: R180 => (
235+ width. saturating_sub ( 1 ) . saturating_sub ( x) ,
236+ height. saturating_sub ( 1 ) . saturating_sub ( y) ,
237+ ) ,
238+ Rotation :: R270 => ( width. saturating_sub ( 1 ) . saturating_sub ( y) , x) ,
239+ } ;
240+ if src_x >= width || src_y >= height {
241+ return None ;
242+ }
243+ Some ( ( src_x, src_y) )
244+ }
245+
246+ #[ must_use]
247+ pub fn apply_f64 ( self , x : f64 , y : f64 , width : f64 , height : f64 ) -> Option < ( f64 , f64 ) > {
248+ if !width. is_finite ( ) || !height. is_finite ( ) || width <= 0.0 || height <= 0.0 {
249+ return None ;
250+ }
251+ let ( mut out_x, mut out_y) = match self . rotation {
252+ Rotation :: R0 => ( x, y) ,
253+ Rotation :: R90 => ( height - y, x) ,
254+ Rotation :: R180 => ( width - x, height - y) ,
255+ Rotation :: R270 => ( y, width - x) ,
256+ } ;
257+ let ( disp_w, disp_h) = match self . rotation {
258+ Rotation :: R90 | Rotation :: R270 => ( height, width) ,
259+ _ => ( width, height) ,
260+ } ;
261+ if self . flip_h {
262+ out_x = disp_w - out_x;
263+ }
264+ if self . flip_v {
265+ out_y = disp_h - out_y;
266+ }
267+ Some ( ( out_x, out_y) )
268+ }
269+
270+ #[ must_use]
271+ pub fn apply_inverse_f64 ( self , x : f64 , y : f64 , width : f64 , height : f64 ) -> Option < ( f64 , f64 ) > {
272+ if !width. is_finite ( ) || !height. is_finite ( ) || width <= 0.0 || height <= 0.0 {
273+ return None ;
274+ }
275+ let ( disp_w, disp_h) = match self . rotation {
276+ Rotation :: R90 | Rotation :: R270 => ( height, width) ,
277+ _ => ( width, height) ,
278+ } ;
279+ let mut x = x;
280+ let mut y = y;
281+ if self . flip_h {
282+ x = disp_w - x;
283+ }
284+ if self . flip_v {
285+ y = disp_h - y;
286+ }
287+ let ( src_x, src_y) = match self . rotation {
288+ Rotation :: R0 => ( x, y) ,
289+ Rotation :: R90 => ( y, height - x) ,
290+ Rotation :: R180 => ( width - x, height - y) ,
291+ Rotation :: R270 => ( width - y, x) ,
292+ } ;
293+ Some ( ( src_x, src_y) )
294+ }
295+
296+ #[ must_use]
297+ pub fn status_label ( self ) -> Option < String > {
298+ if self . is_identity ( ) {
299+ return None ;
300+ }
301+ let mut parts = Vec :: new ( ) ;
302+ match self . rotation {
303+ Rotation :: R0 => { }
304+ Rotation :: R90 => parts. push ( "Rot 90° CW" . to_string ( ) ) ,
305+ Rotation :: R180 => parts. push ( "Rot 180°" . to_string ( ) ) ,
306+ Rotation :: R270 => parts. push ( "Rot 90° CCW" . to_string ( ) ) ,
307+ }
308+ match ( self . flip_h , self . flip_v ) {
309+ ( true , true ) => parts. push ( "Flip H+V" . to_string ( ) ) ,
310+ ( true , false ) => parts. push ( "Flip H" . to_string ( ) ) ,
311+ ( false , true ) => parts. push ( "Flip V" . to_string ( ) ) ,
312+ ( false , false ) => { }
313+ }
314+ Some ( parts. join ( ", " ) )
315+ }
316+ }
317+
318+ #[ cfg( test) ]
319+ mod tests {
320+ use super :: { Rotation , ViewTransform } ;
321+ use std:: collections:: HashSet ;
322+
323+ fn assert_close ( a : f64 , b : f64 ) {
324+ assert ! ( ( a - b) . abs( ) < 1e-9 , "expected {a} ≈ {b}" ) ;
325+ }
326+
327+ #[ test]
328+ fn view_transform_apply_inverse_is_bijection ( ) {
329+ let width = 4usize ;
330+ let height = 3usize ;
331+ let rotations = [ Rotation :: R0 , Rotation :: R90 , Rotation :: R180 , Rotation :: R270 ] ;
332+ for rotation in rotations {
333+ for flip_h in [ false , true ] {
334+ for flip_v in [ false , true ] {
335+ let transform = ViewTransform {
336+ rotation,
337+ flip_h,
338+ flip_v,
339+ } ;
340+ let ( disp_w, disp_h) = transform. display_size ( width, height) ;
341+ let mut seen = HashSet :: with_capacity ( width * height) ;
342+ for y in 0 ..disp_h {
343+ for x in 0 ..disp_w {
344+ let ( sx, sy) = transform
345+ . apply_inverse ( x, y, width, height)
346+ . expect ( "in-bounds coords must map" ) ;
347+ assert ! ( sx < width && sy < height) ;
348+ let idx = sy * width + sx;
349+ assert ! ( seen. insert( idx) , "duplicate mapping for {idx}" ) ;
350+ }
351+ }
352+ assert_eq ! ( seen. len( ) , width * height) ;
353+ assert ! ( transform. apply_inverse( disp_w, 0 , width, height) . is_none( ) ) ;
354+ assert ! ( transform. apply_inverse( 0 , disp_h, width, height) . is_none( ) ) ;
355+ }
356+ }
357+ }
358+ }
359+
360+ #[ test]
361+ fn view_transform_f64_round_trip ( ) {
362+ let width = 5.0 ;
363+ let height = 3.0 ;
364+ let points = [ ( 0.0 , 0.0 ) , ( 0.5 , 0.5 ) , ( 1.25 , 2.75 ) , ( 4.2 , 0.1 ) , ( 5.0 , 3.0 ) ] ;
365+ let rotations = [ Rotation :: R0 , Rotation :: R90 , Rotation :: R180 , Rotation :: R270 ] ;
366+ for rotation in rotations {
367+ for flip_h in [ false , true ] {
368+ for flip_v in [ false , true ] {
369+ let transform = ViewTransform {
370+ rotation,
371+ flip_h,
372+ flip_v,
373+ } ;
374+ for ( x, y) in points {
375+ let ( dx, dy) = transform
376+ . apply_f64 ( x, y, width, height)
377+ . expect ( "valid dims" ) ;
378+ let ( sx, sy) = transform
379+ . apply_inverse_f64 ( dx, dy, width, height)
380+ . expect ( "valid dims" ) ;
381+ assert_close ( sx, x) ;
382+ assert_close ( sy, y) ;
383+ }
384+ }
385+ }
386+ }
387+ }
134388}
135389
136390#[ derive( Clone , Copy , Default ) ]
0 commit comments