a rust tui to view amtrak train status
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Greedy label collision detection for city names

City labels were getting overwritten by braille-rendered map features
(station markers, route lines, train circles). Fixed with two changes:

1. Greedy collision detection: compute approximate bounding boxes for
each label in geo-coordinate space, sorted by population priority.
Skip labels that overlap any previously placed label, train marker,
or station marker. Uses AABB intersection testing.

2. Render city labels as the topmost layer (layer 12) so text characters
aren't overwritten by braille dots drawn in later layers.

Labels are placed offset-right of the city coordinate with collision-
aware spacing. Dense areas (like the NEC corridor) will show only the
highest-population cities that fit without overlap.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+91 -36
+91 -36
src/ui.rs
··· 188 188 .map(|g| segments_to_lines(&g.roads, Color::Rgb(45, 48, 58))) 189 189 .unwrap_or_default(); 190 190 191 - // Cities visible based on zoom level and population 192 - let city_labels: Vec<(f64, f64, String)> = app 193 - .geo 194 - .as_ref() 195 - .map(|g| { 196 - let min_pop = if vp_width > 40.0 { 197 - 2_000_000 // zoomed out: only major cities 198 - } else if vp_width > 20.0 { 199 - 500_000 200 - } else if vp_width > 10.0 { 201 - 100_000 202 - } else { 203 - 30_000 // zoomed in: most cities 204 - }; 205 - g.cities 191 + // City labels with greedy collision detection. 192 + // We approximate each label's bounding box in geo-coordinates and skip any 193 + // that would overlap a previously placed label or a train/station marker. 194 + let city_labels: Vec<(f64, f64, String)> = { 195 + // Approximate width of one character in geo-degrees. 196 + // Canvas area is (area.width - 2) chars wide (minus border), each braille 197 + // cell is 2 dots wide, but text chars occupy 1 full cell. 198 + let canvas_cols = (area.width.saturating_sub(2)) as f64; 199 + let char_w = if canvas_cols > 0.0 { 200 + vp_width / canvas_cols 201 + } else { 202 + 1.0 203 + }; 204 + let char_h = char_w * 2.0; // character cells are roughly 2x taller than wide in geo space 205 + 206 + let min_pop = if vp_width > 40.0 { 207 + 2_000_000 208 + } else if vp_width > 20.0 { 209 + 500_000 210 + } else if vp_width > 10.0 { 211 + 100_000 212 + } else { 213 + 30_000 214 + }; 215 + 216 + // Occupied bounding boxes: (x_min, y_min, x_max, y_max) 217 + let mut occupied: Vec<(f64, f64, f64, f64)> = Vec::new(); 218 + 219 + // Reserve space for train markers (each ~2 chars wide) 220 + for dot in &dots { 221 + let pad = char_w * 2.0; 222 + let pad_h = char_h; 223 + occupied.push(( 224 + dot.lon - pad, 225 + dot.lat - pad_h, 226 + dot.lon + pad, 227 + dot.lat + pad_h, 228 + )); 229 + } 230 + 231 + // Reserve space for station markers 232 + for s in &station_dots { 233 + let pad = char_w; 234 + let pad_h = char_h * 0.5; 235 + occupied.push((s.lon - pad, s.lat - pad_h, s.lon + pad, s.lat + pad_h)); 236 + } 237 + 238 + let candidates: Vec<_> = app 239 + .geo 240 + .as_ref() 241 + .map(|g| { 242 + g.cities 243 + .iter() 244 + .filter(|c| c.population >= min_pop) 245 + .filter(|c| vp.contains(c.lon, c.lat)) 246 + .collect::<Vec<_>>() 247 + }) 248 + .unwrap_or_default(); 249 + 250 + let mut result = Vec::new(); 251 + for city in candidates { 252 + let lx = city.lon + char_w; // offset right of the point 253 + let ly = city.lat; 254 + let label_w = city.name.len() as f64 * char_w; 255 + let label_h = char_h; 256 + let bbox = (lx, ly - label_h * 0.5, lx + label_w, ly + label_h * 0.5); 257 + 258 + // Check for collision with any occupied box 259 + let collides = occupied 206 260 .iter() 207 - .filter(|c| c.population >= min_pop) 208 - .filter(|c| vp.contains(c.lon, c.lat)) 209 - .map(|c| (c.lon, c.lat, c.name.clone())) 210 - .collect() 211 - }) 212 - .unwrap_or_default(); 261 + .any(|o| bbox.0 < o.2 && bbox.2 > o.0 && bbox.1 < o.3 && bbox.3 > o.1); 262 + 263 + if !collides { 264 + occupied.push(bbox); 265 + result.push((lx, ly, city.name.clone())); 266 + } 267 + } 268 + result 269 + }; 213 270 214 271 let canvas = Canvas::default() 215 272 .block( ··· 310 367 } 311 368 } 312 369 313 - // Layer 8: City labels — offset slightly right/up to avoid sitting on features 314 - if show_cities { 315 - for (lon, lat, ref name) in &city_labels { 316 - ctx.print( 317 - *lon + vp_width * 0.004, 318 - *lat + vp_width * 0.002, 319 - Span::styled( 320 - name.clone(), 321 - Style::default().fg(Color::Rgb(130, 125, 115)), 322 - ), 323 - ); 324 - } 325 - } 326 - 327 - // Layer 9: Amtrak routes 370 + // Layer 8: Amtrak routes 328 371 if show_routes { 329 372 for &(x1, y1, x2, y2, color) in &route_lines { 330 373 ctx.draw(&CanvasLine { ··· 390 433 ), 391 434 ); 392 435 } 436 + } 437 + } 438 + 439 + // Layer 12: City labels — rendered LAST so text isn't overwritten by braille 440 + // Collision detection already filtered out labels that overlap trains/stations 441 + if show_cities { 442 + for (lon, lat, ref name) in &city_labels { 443 + ctx.print( 444 + *lon, 445 + *lat, 446 + Span::styled(name.clone(), Style::default().fg(Color::Rgb(140, 135, 120))), 447 + ); 393 448 } 394 449 } 395 450 })