@@ -2,18 +2,28 @@ use bevy::prelude::*;
22
33pub struct Plugin ;
44
5+ #[ derive( Resource ) ]
6+ struct GridFont ( Handle < Font > ) ;
7+
58impl bevy:: app:: Plugin for Plugin {
69 fn build ( & self , app : & mut App ) {
7- app. add_systems ( Startup , spawn_grid) ;
8- app. add_systems ( PostUpdate , ( update_grid, update_grid_labels) . chain ( ) ) ;
10+ app. add_systems ( Startup , ( spawn_grid, load_grid_font) ) ;
11+ app. add_systems ( PostUpdate , update_grid) ;
12+ app. add_systems ( Update , update_grid_labels) ;
913 }
1014}
1115
16+ fn load_grid_font ( mut commands : Commands , asset_server : Res < AssetServer > ) {
17+ let font = asset_server. load ( "fonts/RobotoCondensed-VariableFont_wght.ttf" ) ;
18+ commands. insert_resource ( GridFont ( font) ) ;
19+ }
20+
1221// ── Degree-friendly intervals ────────────────────────────────────────────────
1322
1423/// Degree-friendly intervals sorted largest → smallest.
1524/// Whole degrees, then arc-minutes, then arc-seconds.
1625const DEGREE_INTERVALS : & [ f32 ] = & [
26+ 180.0 ,
1727 90.0 ,
1828 45.0 ,
1929 30.0 ,
@@ -141,11 +151,11 @@ fn format_degree(value: f64, is_latitude: bool) -> String {
141151 let sec = ( rem - min as f64 ) * 60.0 ;
142152
143153 if deg == 0 && min == 0 && sec. abs ( ) > 0.01 {
144- format ! ( "{sec:.0}\" {suffix}" )
154+ format ! ( "{sec:.0}\u{2033} {suffix}" )
145155 } else if sec. abs ( ) > 0.01 {
146- format ! ( "{deg}\u{00b0} {min}' { sec:.0}\" {suffix}" )
156+ format ! ( "{deg}\u{00b0} {min}\u{2032} { sec:.0}\u{2033} {suffix}" )
147157 } else if min > 0 {
148- format ! ( "{deg}\u{00b0} {min}' {suffix}" )
158+ format ! ( "{deg}\u{00b0} {min}\u{2032} {suffix}" )
149159 } else {
150160 format ! ( "{deg}\u{00b0} {suffix}" )
151161 }
@@ -166,7 +176,6 @@ fn format_value(value: f32) -> String {
166176
167177const MIN_LINE_SPACING_PX : f32 = 80.0 ;
168178const GRID_Z : f32 = -0.01 ;
169- const LABEL_Z : f32 = -0.005 ;
170179const LABEL_FONT_SIZE : f32 = 11.0 ;
171180const LABEL_MARGIN_PX : f32 = 8.0 ;
172181
@@ -352,15 +361,21 @@ fn update_grid(
352361 let lat_top = y_to_lat ( vp. world_top ) ;
353362
354363 let deg_per_px_lon = ( lon_right - lon_left) as f32 / vp. win_w ;
355- let deg_per_px_lat = ( lat_top - lat_bottom) as f32 / vp. win_h ;
356364
357- let lon_interval = nice_degree_interval ( deg_per_px_lon, MIN_LINE_SPACING_PX ) ;
358- let lat_interval = nice_degree_interval ( deg_per_px_lat, MIN_LINE_SPACING_PX ) ;
365+ // Use the same degree interval for both axes. Mercator's non-linear
366+ // y-axis makes per-pixel latitude density meaningless, and geographic
367+ // grids conventionally use uniform degree spacing.
368+ let interval = nice_degree_interval ( deg_per_px_lon, MIN_LINE_SPACING_PX ) ;
369+ let lon_interval = interval;
370+ let lat_interval = interval;
359371
360372 let first_lon = ( lon_left / lon_interval as f64 ) . floor ( ) as i64 ;
361373 let last_lon = ( lon_right / lon_interval as f64 ) . ceil ( ) as i64 ;
362374 for i in first_lon..=last_lon {
363375 let lon = i as f64 * lon_interval as f64 ;
376+ if lon. abs ( ) > 180.0 {
377+ continue ;
378+ }
364379 let x = lon_to_x ( lon) ;
365380 add_rect ( & mut positions, & mut indices, x, center_y, thickness, height) ;
366381 }
@@ -402,7 +417,9 @@ fn update_grid(
402417 }
403418}
404419
405- // ── Text2d label rendering ──────────────────────────────────────────────────
420+ // ── Grid label rendering (world-space Text2d) ──────────────────────────────
421+
422+ const LABEL_Z : f32 = -0.005 ;
406423
407424/// Collected label data: world position + text + anchor.
408425struct LabelSpec {
@@ -419,9 +436,36 @@ fn update_grid_labels(
419436 windows : Query < & Window , With < bevy:: window:: PrimaryWindow > > ,
420437 clear_color : Res < ClearColor > ,
421438 target_crs : Option < Res < rgis_crs:: TargetCrs > > ,
439+ grid_font : Option < Res < GridFont > > ,
422440 side_panel_width : Res < rgis_units:: SidePanelWidth > ,
423441 bottom_panel_height : Res < rgis_units:: BottomPanelHeight > ,
442+ mut last_state : Local < LastCameraState > ,
424443) {
444+ // Wait for the font to be available before spawning labels.
445+ let Some ( ref font_res) = grid_font else {
446+ return ;
447+ } ;
448+
449+ let Ok ( transform) = camera_query. single ( ) else {
450+ return ;
451+ } ;
452+ let Ok ( window) = windows. single ( ) else {
453+ return ;
454+ } ;
455+
456+ let window_size = Vec2 :: new ( window. width ( ) , window. height ( ) ) ;
457+ if transform. translation == last_state. translation
458+ && transform. scale == last_state. scale
459+ && window_size == last_state. window_size
460+ && !label_query. is_empty ( )
461+ {
462+ return ;
463+ }
464+
465+ last_state. translation = transform. translation ;
466+ last_state. scale = transform. scale ;
467+ last_state. window_size = window_size;
468+
425469 // Despawn all previous labels.
426470 for entity in label_query. iter ( ) {
427471 commands. entity ( entity) . despawn ( ) ;
@@ -486,15 +530,18 @@ fn update_grid_labels(
486530 let lat_top = y_to_lat ( vp. world_top ) ;
487531
488532 let deg_per_px_lon = ( lon_right - lon_left) as f32 / vp. win_w ;
489- let deg_per_px_lat = ( lat_top - lat_bottom) as f32 / vp. win_h ;
490533
491- let lon_interval = nice_degree_interval ( deg_per_px_lon, MIN_LINE_SPACING_PX ) ;
492- let lat_interval = nice_degree_interval ( deg_per_px_lat, MIN_LINE_SPACING_PX ) ;
534+ let interval = nice_degree_interval ( deg_per_px_lon, MIN_LINE_SPACING_PX ) ;
535+ let lon_interval = interval;
536+ let lat_interval = interval;
493537
494538 let first_lon = ( lon_left / lon_interval as f64 ) . floor ( ) as i64 ;
495539 let last_lon = ( lon_right / lon_interval as f64 ) . ceil ( ) as i64 ;
496540 for i in first_lon..=last_lon {
497541 let lon = i as f64 * lon_interval as f64 ;
542+ if lon. abs ( ) > 180.0 {
543+ continue ;
544+ }
498545 let x = lon_to_x ( lon) ;
499546 labels. push ( LabelSpec {
500547 world_x : x,
@@ -555,6 +602,7 @@ fn update_grid_labels(
555602 commands. spawn ( (
556603 Text2d :: new ( label. text ) ,
557604 TextFont {
605+ font : font_res. 0 . clone ( ) ,
558606 font_size : LABEL_FONT_SIZE ,
559607 ..default ( )
560608 } ,
@@ -593,3 +641,138 @@ fn add_rect(
593641 indices. push ( base + 2 ) ;
594642 indices. push ( base + 3 ) ;
595643}
644+
645+ #[ cfg( test) ]
646+ mod tests {
647+ use super :: * ;
648+
649+ // ── nice_degree_interval ────────────────────────────────────────────
650+
651+ #[ test]
652+ fn degree_interval_zoomed_out_picks_large_interval ( ) {
653+ // ~0.28 deg/px (full world across 1280px)
654+ let interval = nice_degree_interval ( 360.0 / 1280.0 , 80.0 ) ;
655+ assert ! ( interval >= 15.0 , "expected >=15°, got {interval}" ) ;
656+ }
657+
658+ #[ test]
659+ fn degree_interval_zoomed_in_picks_small_interval ( ) {
660+ // ~0.001 deg/px (city-level zoom)
661+ let interval = nice_degree_interval ( 0.001 , 80.0 ) ;
662+ assert ! ( interval <= 1.0 , "expected <=1°, got {interval}" ) ;
663+ }
664+
665+ #[ test]
666+ fn degree_interval_returns_value_from_list ( ) {
667+ let interval = nice_degree_interval ( 0.05 , 80.0 ) ;
668+ assert ! (
669+ DEGREE_INTERVALS . contains( & interval) ,
670+ "interval {interval} not in DEGREE_INTERVALS"
671+ ) ;
672+ }
673+
674+ #[ test]
675+ fn degree_interval_never_below_smallest ( ) {
676+ let smallest = * DEGREE_INTERVALS . last ( ) . unwrap ( ) ;
677+ let interval = nice_degree_interval ( 0.0000001 , 80.0 ) ;
678+ assert ! ( interval >= smallest) ;
679+ }
680+
681+ // ── nice_interval (1-2-5 generic) ───────────────────────────────────
682+
683+ #[ test]
684+ fn nice_interval_picks_round_values ( ) {
685+ let interval = nice_interval ( 1.0 , 80.0 ) ;
686+ // 80 units min spacing → should pick 100
687+ assert_eq ! ( interval, 100.0 ) ;
688+ }
689+
690+ #[ test]
691+ fn nice_interval_scales_with_camera ( ) {
692+ let a = nice_interval ( 1.0 , 80.0 ) ;
693+ let b = nice_interval ( 10.0 , 80.0 ) ;
694+ assert ! ( b > a, "larger camera scale should give larger interval" ) ;
695+ }
696+
697+ // ── Mercator round-trip ─────────────────────────────────────────────
698+
699+ #[ test]
700+ fn lon_x_round_trip ( ) {
701+ for lon in [ -180.0 , -90.0 , 0.0 , 45.0 , 180.0 ] {
702+ let x = lon_to_x ( lon) ;
703+ let back = x_to_lon ( x) ;
704+ assert ! ( ( back - lon) . abs( ) < 1e-4 , "lon {lon} -> x {x} -> {back}" ) ;
705+ }
706+ }
707+
708+ #[ test]
709+ fn lat_y_round_trip ( ) {
710+ for lat in [ -85.0 , -45.0 , 0.0 , 45.0 , 85.0 ] {
711+ let y = lat_to_y ( lat) ;
712+ let back = y_to_lat ( y) ;
713+ assert ! ( ( back - lat) . abs( ) < 1e-6 , "lat {lat} -> y {y} -> {back}" ) ;
714+ }
715+ }
716+
717+ #[ test]
718+ fn equator_maps_to_near_zero ( ) {
719+ assert ! ( lat_to_y( 0.0 ) . abs( ) < 1e-6 ) ;
720+ assert ! ( lon_to_x( 0.0 ) . abs( ) < 1e-6 ) ;
721+ }
722+
723+ #[ test]
724+ fn mercator_y_increases_with_latitude ( ) {
725+ assert ! ( lat_to_y( 45.0 ) > lat_to_y( 0.0 ) ) ;
726+ assert ! ( lat_to_y( 0.0 ) > lat_to_y( -45.0 ) ) ;
727+ }
728+
729+ // ── format_degree ───────────────────────────────────────────────────
730+
731+ #[ test]
732+ fn format_degree_zero_latitude ( ) {
733+ let s = format_degree ( 0.0 , true ) ;
734+ assert_eq ! ( s, "0\u{00b0} N" ) ;
735+ }
736+
737+ #[ test]
738+ fn format_degree_zero_longitude ( ) {
739+ let s = format_degree ( 0.0 , false ) ;
740+ assert_eq ! ( s, "0\u{00b0} E" ) ;
741+ }
742+
743+ #[ test]
744+ fn format_degree_negative_latitude ( ) {
745+ let s = format_degree ( -45.0 , true ) ;
746+ assert ! ( s. ends_with( "S" ) , "expected S suffix, got {s}" ) ;
747+ assert ! ( s. contains( "45" ) , "expected 45 in {s}" ) ;
748+ }
749+
750+ #[ test]
751+ fn format_degree_with_minutes ( ) {
752+ let s = format_degree ( 45.5 , true ) ;
753+ assert ! ( s. contains( "30\u{2032} " ) , "expected 30′ in {s}" ) ;
754+ }
755+
756+ #[ test]
757+ fn format_degree_west_longitude ( ) {
758+ let s = format_degree ( -90.0 , false ) ;
759+ assert ! ( s. ends_with( "W" ) , "expected W suffix, got {s}" ) ;
760+ }
761+
762+ // ── format_value ────────────────────────────────────────────────────
763+
764+ #[ test]
765+ fn format_value_large ( ) {
766+ assert_eq ! ( format_value( 1_500_000.0 ) , "1500000" ) ;
767+ }
768+
769+ #[ test]
770+ fn format_value_medium ( ) {
771+ assert_eq ! ( format_value( 123.4 ) , "123.4" ) ;
772+ }
773+
774+ #[ test]
775+ fn format_value_small ( ) {
776+ assert_eq ! ( format_value( 0.0012 ) , "0.0012" ) ;
777+ }
778+ }
0 commit comments