Skip to main content

tzf_rs/
lib.rs

1#![doc = include_str!("../README.md")]
2#![cfg_attr(docsrs, feature(doc_cfg))]
3
4use geometry_rs::{
5    CoordStorage, I32Point, I32Polygon, I32RaycastMode, Point, Polygon, PolygonBuildOptions,
6};
7#[cfg(feature = "export-geojson")]
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::f64::consts::PI;
11use std::vec;
12#[cfg(all(feature = "bundled", feature = "full"))]
13compile_error!(
14    "features `bundled` and `full` are mutually exclusive; \
15     add `default-features = false` when enabling `full`"
16);
17
18#[cfg(feature = "bundled")]
19use tzf_dist::{load_preindex, load_topology_compress_topo};
20#[cfg(feature = "full")]
21use tzf_dist_git::{load_compress_topo, load_preindex, load_topology_compress_topo};
22pub mod pbgen;
23
24struct Item<T: CoordStorage> {
25    polys: Vec<Polygon<T>>,
26    name: String,
27}
28
29impl<T: CoordStorage> Item<T> {
30    fn contains_point(&self, p: &Point) -> bool {
31        for poly in &self.polys {
32            // Timezone polygons tile the globe, so a query that lands exactly
33            // on a shared border must belong to both neighbours rather than to
34            // neither. The nautical zones make this easy to hit: their borders
35            // sit on whole meridians (7.5°, 22.5°, …), which is exactly the
36            // kind of coordinate people type by hand.
37            if poly.contains_point_allow_on_edge(*p) {
38                return true;
39            }
40        }
41        false
42    }
43}
44
45/// Monomorphized finder internals. `T` is the polygon coordinate storage:
46/// `i32` (1e5-scaled) for compressed topo data, `f64` for user-supplied
47/// protobuf data.
48struct FinderCore<T: CoordStorage> {
49    all: Vec<Item<T>>,
50    data_version: String,
51    // grid maps (floor(lng), floor(lat)) → candidate item indices.
52    // Populated automatically when loading CompressedTopoTimezones that
53    // contains an embedded GridIndex.
54    grid: Option<HashMap<(i16, i16), Vec<u32>>>,
55}
56
57enum FinderKind {
58    Float(FinderCore<f64>),
59    Scaled(FinderCore<i32>),
60}
61
62/// Dispatch once at the top of each query; everything below the dispatch is
63/// monomorphized over the storage type, avoiding a per-polygon enum match.
64macro_rules! with_core {
65    ($finder:expr, $core:ident => $body:expr) => {
66        match &$finder.inner {
67            FinderKind::Float($core) => $body,
68            FinderKind::Scaled($core) => $body,
69        }
70    };
71}
72
73/// Finder works anywhere.
74///
75/// Finder use a fine tuned Ray casting algorithm implement [geometry-rs]
76/// which is Rust port of [geometry] by [Josh Baker].
77///
78/// [geometry-rs]: https://github.com/ringsaturn/geometry-rs
79/// [geometry]: https://github.com/tidwall/geometry
80/// [Josh Baker]: https://github.com/tidwall
81pub struct Finder {
82    inner: FinderKind,
83}
84
85/// Minimum ring segment count for building a polygon acceleration index.
86///
87/// Rings below this are scanned linearly. Only [`FinderOptions::YStripes`]
88/// consults this value; the `NoIndex*` variants build no index at all.
89///
90/// 32 matches the Go implementation (`internal/geom.minIndexSegments`) rather
91/// than the geometry-rs default of 64, which tzf-rs previously inherited from
92/// geometry-rs's earlier RTree work. Measured on the bundled dataset
93/// (Apple M3 Max, `benches/edges.rs`): 32 costs +0.64 MiB live heap
94/// (32.69 MB -> 33.36 MB) and is 1-5% faster on edge-city lookups. The latency
95/// edge is within run-to-run noise here; Go's benchmark harness, which reports
96/// p50 over many iterations, resolves it more clearly (+14% p50 at 64).
97const DEFAULT_RTREE_MIN_SEGMENTS: usize = 32;
98
99/// Finder build options for polygon acceleration indexes.
100///
101/// Compressed topo data (the tzf-dist default) always stores polygons as
102/// 1e5-scaled integer coordinates; the options only choose the acceleration
103/// index and the raycast flavor. The indexes operate directly in the scaled
104/// integer storage space, so [`FinderOptions::YStripes`] keeps the full
105/// memory savings of integer storage.
106///
107/// Default:
108/// - [`FinderOptions::NoIndex`]
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
110#[non_exhaustive]
111pub enum FinderOptions {
112    /// Disable polygon acceleration indexes.
113    ///
114    /// For compressed topo data this is equivalent to
115    /// [`FinderOptions::NoIndexFloatRaycast`].
116    #[default]
117    NoIndex,
118    /// Use Y stripes index (recommended).
119    YStripes,
120    /// Disable polygon acceleration indexes; segment endpoints are converted
121    /// to `f64` in registers during raycasting.
122    NoIndexFloatRaycast,
123    /// Disable polygon acceleration indexes and use an integer cross-product
124    /// raycast, which snaps the query point to the 1e-5 grid (a semantic
125    /// difference near polygon edges). Opt-in.
126    NoIndexIntegerRaycast,
127}
128
129impl FinderOptions {
130    /// Disable polygon acceleration indexes.
131    #[must_use]
132    pub fn no_index() -> Self {
133        Self::NoIndex
134    }
135
136    /// Use Y stripes index.
137    #[must_use]
138    pub fn y_stripes() -> Self {
139        Self::YStripes
140    }
141
142    #[must_use]
143    pub fn no_index_float_raycast() -> Self {
144        Self::NoIndexFloatRaycast
145    }
146
147    #[must_use]
148    pub fn no_index_integer_raycast() -> Self {
149        Self::NoIndexIntegerRaycast
150    }
151
152    fn to_polygon_build_options(self) -> PolygonBuildOptions {
153        match self {
154            Self::YStripes => PolygonBuildOptions {
155                enable_rtree: false,
156                enable_compressed_quad: false,
157                enable_y_stripes: true,
158                rtree_min_segments: DEFAULT_RTREE_MIN_SEGMENTS,
159            },
160            Self::NoIndex | Self::NoIndexFloatRaycast | Self::NoIndexIntegerRaycast => {
161                PolygonBuildOptions {
162                    enable_rtree: false,
163                    enable_compressed_quad: false,
164                    enable_y_stripes: false,
165                    rtree_min_segments: DEFAULT_RTREE_MIN_SEGMENTS,
166                }
167            }
168        }
169    }
170
171    fn i32_raycast_mode(self) -> I32RaycastMode {
172        match self {
173            Self::NoIndexIntegerRaycast => I32RaycastMode::Integer,
174            Self::NoIndex | Self::NoIndexFloatRaycast | Self::YStripes => I32RaycastMode::Float,
175        }
176    }
177}
178
179/// Decode a Google Polyline encoded byte slice into a list of Points.
180///
181/// The go-polyline library encodes coordinates as [lng, lat] pairs with 1e5 precision.
182#[allow(clippy::cast_possible_truncation)]
183fn decode_polyline(encoded: &[u8]) -> Vec<I32Point> {
184    let mut points = Vec::new();
185    let mut index = 0;
186    let mut lng: i64 = 0;
187    let mut lat: i64 = 0;
188
189    while index < encoded.len() {
190        let (dlng, next) = polyline_decode_value(encoded, index);
191        index = next;
192        let (dlat, next) = polyline_decode_value(encoded, index);
193        index = next;
194        lng += dlng;
195        lat += dlat;
196        points.push(I32Point {
197            x: i32::try_from(lng).expect("polyline longitude exceeds i32"),
198            y: i32::try_from(lat).expect("polyline latitude exceeds i32"),
199        });
200    }
201    points
202}
203
204fn polyline_decode_value(encoded: &[u8], start: usize) -> (i64, usize) {
205    let mut result: i64 = 0;
206    let mut shift = 0;
207    let mut index = start;
208
209    loop {
210        let byte = (encoded[index] as i64) - 63;
211        index += 1;
212        result |= (byte & 0x1F) << shift;
213        shift += 5;
214        if byte < 0x20 {
215            break;
216        }
217    }
218
219    let value = if result & 1 != 0 {
220        !(result >> 1)
221    } else {
222        result >> 1
223    };
224    (value, index)
225}
226
227fn expand_compressed_ring(
228    segs: &[pbgen::CompressedRingSegment],
229    edges: &[Vec<I32Point>],
230) -> Vec<I32Point> {
231    let mut pts = Vec::new();
232    for seg in segs {
233        match &seg.content {
234            Some(pbgen::compressed_ring_segment::Content::Inline(inline)) => {
235                pts.extend(decode_polyline(&inline.points));
236            }
237            Some(pbgen::compressed_ring_segment::Content::EdgeForward(idx)) => {
238                pts.extend_from_slice(&edges[*idx as usize]);
239            }
240            Some(pbgen::compressed_ring_segment::Content::EdgeReversed(idx)) => {
241                pts.extend(edges[*idx as usize].iter().rev().copied());
242            }
243            None => {}
244        }
245    }
246    pts
247}
248
249impl<T: CoordStorage> FinderCore<T> {
250    fn get_tz_name(&self, lng: f64, lat: f64) -> &str {
251        if let Some(ref grid) = self.grid {
252            let key = (lng.floor() as i16, lat.floor() as i16);
253            let indices = match grid.get(&key) {
254                Some(v) => v,
255                None => return "",
256            };
257            // Single-candidate short-circuit: skip PIP when there is only one
258            // candidate and we are away from antimeridian / pole edges.
259            if indices.len() == 1 && (-179.0..179.0).contains(&lng) && (-89.0..89.0).contains(&lat)
260            {
261                return &self.all[indices[0] as usize].name;
262            }
263            let p = geometry_rs::Point { x: lng, y: lat };
264            for &idx in indices {
265                if self.all[idx as usize].contains_point(&p) {
266                    return &self.all[idx as usize].name;
267                }
268            }
269            return "";
270        }
271        let p = geometry_rs::Point { x: lng, y: lat };
272        for item in &self.all {
273            if item.contains_point(&p) {
274                return &item.name;
275            }
276        }
277        ""
278    }
279
280    fn get_tz_names(&self, lng: f64, lat: f64) -> Vec<&str> {
281        let mut ret: Vec<&str> = vec![];
282        if let Some(ref grid) = self.grid {
283            let key = (lng.floor() as i16, lat.floor() as i16);
284            if let Some(indices) = grid.get(&key) {
285                let p = geometry_rs::Point { x: lng, y: lat };
286                for &idx in indices {
287                    if self.all[idx as usize].contains_point(&p) {
288                        ret.push(&self.all[idx as usize].name);
289                    }
290                }
291            }
292            return ret;
293        }
294        let p = geometry_rs::Point { x: lng, y: lat };
295        for item in &self.all {
296            if item.contains_point(&p) {
297                ret.push(&item.name);
298            }
299        }
300        ret
301    }
302
303    fn timezonenames(&self) -> Vec<&str> {
304        let mut ret: Vec<&str> = vec![];
305        for item in &self.all {
306            ret.push(&item.name);
307        }
308        ret
309    }
310}
311
312impl Finder {
313    fn from_pb_with_polygon_options(tzs: pbgen::Timezones, options: PolygonBuildOptions) -> Self {
314        let mut all: Vec<Item<f64>> = vec![];
315        for tz in &tzs.timezones {
316            let mut polys: Vec<Polygon> = vec![];
317
318            for pbpoly in &tz.polygons {
319                let mut exterior: Vec<Point> = vec![];
320                for pbpoint in &pbpoly.points {
321                    exterior.push(Point {
322                        x: f64::from(pbpoint.lng),
323                        y: f64::from(pbpoint.lat),
324                    });
325                }
326
327                let mut interior: Vec<Vec<Point>> = vec![];
328
329                for holepoly in &pbpoly.holes {
330                    let mut holeextr: Vec<Point> = vec![];
331                    for holepoint in &holepoly.points {
332                        holeextr.push(Point {
333                            x: f64::from(holepoint.lng),
334                            y: f64::from(holepoint.lat),
335                        });
336                    }
337                    interior.push(holeextr);
338                }
339
340                polys.push(geometry_rs::Polygon::new(exterior, interior, Some(options)));
341            }
342
343            all.push(Item {
344                name: tz.name.to_string(),
345                polys,
346            });
347        }
348        Self {
349            inner: FinderKind::Float(FinderCore {
350                all,
351                data_version: tzs.version,
352                grid: None,
353            }),
354        }
355    }
356
357    fn from_compressed_topo_with_polygon_options(
358        tzs: pbgen::CompressedTopoTimezones,
359        options: PolygonBuildOptions,
360        raycast_mode: I32RaycastMode,
361    ) -> Self {
362        let mut edges: Vec<Vec<I32Point>> = vec![Vec::new(); tzs.shared_edges.len()];
363        for edge in &tzs.shared_edges {
364            edges[edge.id as usize] = decode_polyline(&edge.points);
365        }
366
367        let grid = tzs.grid_index.map(|gi| {
368            let mut m = HashMap::with_capacity(gi.cells.len());
369            for cell in gi.cells {
370                m.insert((cell.lng as i16, cell.lat as i16), cell.tz_indices);
371            }
372            m
373        });
374
375        let mut all: Vec<Item<i32>> = vec![];
376        for tz in &tzs.timezones {
377            let mut polys: Vec<I32Polygon> = vec![];
378            for poly in &tz.polygons {
379                let exterior = expand_compressed_ring(&poly.exterior, &edges);
380                let interior: Vec<Vec<I32Point>> = poly
381                    .holes
382                    .iter()
383                    .map(|hole| expand_compressed_ring(&hole.exterior, &edges))
384                    .collect();
385                // The acceleration indexes operate directly in the 1e5-scaled
386                // integer storage space, so enabling them no longer requires
387                // falling back to float storage.
388                polys.push(I32Polygon::new_with_options(
389                    exterior,
390                    interior,
391                    1e5,
392                    raycast_mode,
393                    Some(options),
394                ));
395            }
396            all.push(Item {
397                name: tz.name.clone(),
398                polys,
399            });
400        }
401        Self {
402            inner: FinderKind::Scaled(FinderCore {
403                all,
404                data_version: tzs.version,
405                grid,
406            }),
407        }
408    }
409
410    /// Create a Finder from `CompressedTopoTimezones` protobuf data.
411    ///
412    /// This is the preferred constructor when using tzf-dist data.
413    #[must_use]
414    pub fn from_compressed_topo(tzs: pbgen::CompressedTopoTimezones) -> Self {
415        Self::from_compressed_topo_with_options(tzs, FinderOptions::default())
416    }
417
418    /// Create a Finder from `CompressedTopoTimezones` with explicit polygon build options.
419    #[must_use]
420    pub fn from_compressed_topo_with_options(
421        tzs: pbgen::CompressedTopoTimezones,
422        options: FinderOptions,
423    ) -> Self {
424        Self::from_compressed_topo_with_polygon_options(
425            tzs,
426            options.to_polygon_build_options(),
427            options.i32_raycast_mode(),
428        )
429    }
430
431    /// `from_pb` is used when you can use your own timezone data, as long as
432    /// it's compatible with Proto's desc.
433    ///
434    /// # Arguments
435    ///
436    /// * `tzs` - Timezones data.
437    ///
438    /// # Returns
439    ///
440    /// * `Finder` - A Finder instance.
441    #[must_use]
442    pub fn from_pb(tzs: pbgen::Timezones) -> Self {
443        Self::from_pb_with_options(tzs, FinderOptions::default())
444    }
445
446    /// Create a finder from protobuf data with explicit polygon build options.
447    #[must_use]
448    pub fn from_pb_with_options(tzs: pbgen::Timezones, options: FinderOptions) -> Self {
449        Self::from_pb_with_polygon_options(tzs, options.to_polygon_build_options())
450    }
451
452    /// Example:
453    ///
454    /// ```rust
455    /// use tzf_rs::Finder;
456    ///
457    /// let finder = Finder::new();
458    /// assert_eq!("Asia/Shanghai", finder.get_tz_name(116.3883, 39.9289));
459    /// ```
460    #[must_use]
461    pub fn get_tz_name(&self, lng: f64, lat: f64) -> &str {
462        with_core!(self, core => core.get_tz_name(lng, lat))
463    }
464
465    /// ```rust
466    /// use tzf_rs::Finder;
467    /// let finder = Finder::new();
468    /// println!("{:?}", finder.get_tz_names(116.3883, 39.9289));
469    /// ```
470    #[must_use]
471    pub fn get_tz_names(&self, lng: f64, lat: f64) -> Vec<&str> {
472        with_core!(self, core => core.get_tz_names(lng, lat))
473    }
474
475    /// Example:
476    ///
477    /// ```rust
478    /// use tzf_rs::Finder;
479    ///
480    /// let finder = Finder::new();
481    /// println!("{:?}", finder.timezonenames());
482    /// ```
483    #[must_use]
484    pub fn timezonenames(&self) -> Vec<&str> {
485        with_core!(self, core => core.timezonenames())
486    }
487
488    /// Example:
489    ///
490    /// ```rust
491    /// use tzf_rs::Finder;
492    ///
493    /// let finder = Finder::new();
494    /// println!("{:?}", finder.data_version());
495    /// ```
496    #[must_use]
497    pub fn data_version(&self) -> &str {
498        with_core!(self, core => &core.data_version)
499    }
500
501    /// Creates a new, empty `Finder`.
502    ///
503    /// Example:
504    ///
505    /// ```rust
506    /// use tzf_rs::Finder;
507    ///
508    /// let finder = Finder::new();
509    /// ```
510    #[must_use]
511    pub fn new() -> Self {
512        Self::default()
513    }
514
515    /// Convert the Finder's data to GeoJSON format.
516    ///
517    /// Returns a `BoundaryFile` (FeatureCollection) containing all timezone polygons.
518    ///
519    /// # Example
520    ///
521    /// ```rust
522    /// use tzf_rs::Finder;
523    ///
524    /// let finder = Finder::new();
525    /// let geojson = finder.to_geojson();
526    /// let json_string = geojson.to_string();
527    /// ```
528    #[must_use]
529    #[cfg(feature = "export-geojson")]
530    pub fn to_geojson(&self) -> BoundaryFile {
531        with_core!(self, core => core.to_geojson())
532    }
533
534    /// Convert a specific timezone to GeoJSON format.
535    ///
536    /// Returns `Some(BoundaryFile)` containing a FeatureCollection with all features
537    /// for the timezone if found, `None` otherwise. The returned FeatureCollection
538    /// may contain multiple features if the timezone has multiple geographic boundaries.
539    ///
540    /// # Arguments
541    ///
542    /// * `timezone_name` - The timezone name to export (e.g., "Asia/Tokyo")
543    ///
544    /// # Example
545    ///
546    /// ```rust
547    /// use tzf_rs::Finder;
548    ///
549    /// let finder = Finder::new();
550    /// if let Some(collection) = finder.get_tz_geojson("Asia/Tokyo") {
551    ///     let json_string = collection.to_string();
552    ///     println!("Found {} feature(s)", collection.features.len());
553    ///     if let Some(first_feature) = collection.features.first() {
554    ///         println!("Timezone ID: {}", first_feature.properties.tzid);
555    ///     }
556    /// }
557    /// ```
558    #[must_use]
559    #[cfg(feature = "export-geojson")]
560    pub fn get_tz_geojson(&self, timezone_name: &str) -> Option<BoundaryFile> {
561        with_core!(self, core => core.get_tz_geojson(timezone_name))
562    }
563}
564
565#[cfg(feature = "export-geojson")]
566impl<T: CoordStorage> FinderCore<T> {
567    /// Helper method to convert an Item to a FeatureItem.
568    fn item_to_feature(&self, item: &Item<T>) -> FeatureItem {
569        // Convert internal Item to pbgen::Timezone format
570        let mut pbpolys = Vec::new();
571        for poly in &item.polys {
572            // Storage space → degrees; `scale` is 1.0 for float storage.
573            let scale = poly.scale();
574            let mut pbpoly = pbgen::Polygon {
575                points: Vec::new(),
576                holes: Vec::new(),
577            };
578
579            pbpoly
580                .points
581                .extend(poly.exterior().iter().map(|point| pbgen::Point {
582                    lng: (point.x.to_f64() / scale) as f32,
583                    lat: (point.y.to_f64() / scale) as f32,
584                }));
585            for hole in poly.holes() {
586                pbpoly.holes.push(pbgen::Polygon {
587                    points: hole
588                        .iter()
589                        .map(|point| pbgen::Point {
590                            lng: (point.x.to_f64() / scale) as f32,
591                            lat: (point.y.to_f64() / scale) as f32,
592                        })
593                        .collect(),
594                    holes: Vec::new(),
595                });
596            }
597
598            pbpolys.push(pbpoly);
599        }
600
601        let pbtz = pbgen::Timezone {
602            polygons: pbpolys,
603            name: item.name.clone(),
604        };
605
606        revert_item(&pbtz)
607    }
608
609    fn to_geojson(&self) -> BoundaryFile {
610        let mut output = BoundaryFile {
611            collection_type: "FeatureCollection".to_string(),
612            features: Vec::new(),
613        };
614
615        for item in &self.all {
616            output.features.push(self.item_to_feature(item));
617        }
618
619        output
620    }
621
622    fn get_tz_geojson(&self, timezone_name: &str) -> Option<BoundaryFile> {
623        let mut output = BoundaryFile {
624            collection_type: "FeatureCollection".to_string(),
625            features: Vec::new(),
626        };
627        for item in &self.all {
628            if item.name == timezone_name {
629                output.features.push(self.item_to_feature(item));
630            }
631        }
632
633        if output.features.is_empty() {
634            None
635        } else {
636            Some(output)
637        }
638    }
639}
640
641/// Creates a new, empty `Finder`.
642///
643/// Example:
644///
645/// ```rust
646/// use tzf_rs::Finder;
647///
648/// let finder = Finder::default();
649/// ```
650impl Default for Finder {
651    fn default() -> Self {
652        let file_bytes = load_topology_compress_topo();
653        Self::from_compressed_topo(
654            pbgen::CompressedTopoTimezones::try_from(file_bytes).unwrap_or_default(),
655        )
656    }
657}
658
659/// deg2num is used to convert longitude, latitude to [Slippy map tilenames]
660/// under specific zoom level.
661///
662/// [Slippy map tilenames]: https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames
663///
664/// Example:
665///
666/// ```rust
667/// use tzf_rs::deg2num;
668/// let ret = deg2num(116.3883, 39.9289, 7);
669/// assert_eq!((105, 48), ret);
670/// ```
671#[must_use]
672#[allow(
673    clippy::cast_precision_loss,
674    clippy::cast_possible_truncation,
675    clippy::similar_names
676)]
677pub fn deg2num(lng: f64, lat: f64, zoom: i64) -> (i64, i64) {
678    let n = (1i64 << zoom) as f64;
679    let lat_rad = lat.to_radians();
680    let xtile = (lng / 360.0 + 0.5) * n;
681    let ytile = (1.0 - lat_rad.tan().asinh() / PI) / 2.0 * n;
682
683    // Possible precision loss here
684    (xtile as i64, ytile as i64)
685}
686
687/// GeoJSON type definitions for conversion
688#[cfg(feature = "export-geojson")]
689pub type PolygonCoordinates = Vec<Vec<[f64; 2]>>;
690#[cfg(feature = "export-geojson")]
691pub type MultiPolygonCoordinates = Vec<PolygonCoordinates>;
692
693#[cfg(feature = "export-geojson")]
694#[derive(Debug, Clone, Serialize, Deserialize)]
695pub struct GeometryDefine {
696    #[serde(rename = "type")]
697    pub geometry_type: String,
698    pub coordinates: MultiPolygonCoordinates,
699}
700
701#[cfg(feature = "export-geojson")]
702#[derive(Debug, Clone, Serialize, Deserialize)]
703pub struct PropertiesDefine {
704    pub tzid: String,
705}
706
707#[cfg(feature = "export-geojson")]
708#[derive(Debug, Clone, Serialize, Deserialize)]
709pub struct FeatureItem {
710    #[serde(rename = "type")]
711    pub feature_type: String,
712    pub properties: PropertiesDefine,
713    pub geometry: GeometryDefine,
714}
715
716#[cfg(feature = "export-geojson")]
717impl FeatureItem {
718    pub fn to_string(&self) -> String {
719        serde_json::to_string(self).unwrap_or_default()
720    }
721
722    pub fn to_string_pretty(&self) -> String {
723        serde_json::to_string_pretty(self).unwrap_or_default()
724    }
725}
726
727#[cfg(feature = "export-geojson")]
728#[derive(Debug, Clone, Serialize, Deserialize)]
729pub struct BoundaryFile {
730    #[serde(rename = "type")]
731    pub collection_type: String,
732    pub features: Vec<FeatureItem>,
733}
734
735#[cfg(feature = "export-geojson")]
736impl BoundaryFile {
737    pub fn to_string(&self) -> String {
738        serde_json::to_string(self).unwrap_or_default()
739    }
740
741    pub fn to_string_pretty(&self) -> String {
742        serde_json::to_string_pretty(self).unwrap_or_default()
743    }
744}
745
746/// Convert protobuf Polygon array to GeoJSON MultiPolygon coordinates
747#[cfg(feature = "export-geojson")]
748fn from_pb_polygon_to_geo_multipolygon(pbpoly: &[pbgen::Polygon]) -> MultiPolygonCoordinates {
749    let mut res = MultiPolygonCoordinates::new();
750    for poly in pbpoly {
751        let mut new_geo_poly = PolygonCoordinates::new();
752
753        // Main polygon (exterior ring)
754        let mut mainpoly = Vec::new();
755        for point in &poly.points {
756            mainpoly.push([f64::from(point.lng), f64::from(point.lat)]);
757        }
758        new_geo_poly.push(mainpoly);
759
760        // Holes (interior rings)
761        for holepoly in &poly.holes {
762            let mut holepoly_coords = Vec::new();
763            for point in &holepoly.points {
764                holepoly_coords.push([f64::from(point.lng), f64::from(point.lat)]);
765            }
766            new_geo_poly.push(holepoly_coords);
767        }
768        res.push(new_geo_poly);
769    }
770    res
771}
772
773/// Convert a protobuf Timezone to a GeoJSON FeatureItem
774#[cfg(feature = "export-geojson")]
775fn revert_item(input: &pbgen::Timezone) -> FeatureItem {
776    FeatureItem {
777        feature_type: "Feature".to_string(),
778        properties: PropertiesDefine {
779            tzid: input.name.clone(),
780        },
781        geometry: GeometryDefine {
782            geometry_type: "MultiPolygon".to_string(),
783            coordinates: from_pb_polygon_to_geo_multipolygon(&input.polygons),
784        },
785    }
786}
787
788/// Convert protobuf Timezones to GeoJSON BoundaryFile (FeatureCollection)
789#[cfg(feature = "export-geojson")]
790pub fn revert_timezones(input: &pbgen::Timezones) -> BoundaryFile {
791    let mut output = BoundaryFile {
792        collection_type: "FeatureCollection".to_string(),
793        features: Vec::new(),
794    };
795    for timezone in &input.timezones {
796        let item = revert_item(timezone);
797        output.features.push(item);
798    }
799    output
800}
801
802// Packs (x, y, z) into a single u64 tile key, mirroring the Go
803// implementation's TileID layout:
804// bits 56-63 = zoom (0-255), bits 28-55 = x (up to 2^28), bits 0-27 = y (up to 2^28).
805// This covers all OSM zoom levels (0-28) without collision. Out-of-range
806// lookup coordinates are masked; the masked values exceed any real tile
807// index so they simply never match.
808const TILE_COORD_BITS: u32 = 28;
809const TILE_COORD_MASK: u64 = (1 << TILE_COORD_BITS) - 1;
810
811#[inline]
812#[allow(clippy::cast_sign_loss)]
813fn pack_tile_key(x: i64, y: i64, z: i64) -> u64 {
814    ((z as u64) << (2 * TILE_COORD_BITS))
815        | ((x as u64 & TILE_COORD_MASK) << TILE_COORD_BITS)
816        | (y as u64 & TILE_COORD_MASK)
817}
818
819#[cfg(feature = "export-geojson")]
820#[allow(clippy::cast_possible_wrap)]
821fn unpack_tile_key(key: u64) -> (i64, i64, i64) {
822    let x = ((key >> TILE_COORD_BITS) & TILE_COORD_MASK) as i64;
823    let y = (key & TILE_COORD_MASK) as i64;
824    let z = (key >> (2 * TILE_COORD_BITS)) as i64;
825    (x, y, z)
826}
827
828/// Most tiles belong to exactly one timezone, so store that index inline and
829/// only heap-allocate for boundary tiles that straddle multiple timezones.
830enum TileEntry {
831    One(u16),
832    Many(Box<[u16]>),
833}
834
835impl TileEntry {
836    fn indices(&self) -> &[u16] {
837        match self {
838            Self::One(idx) => std::slice::from_ref(idx),
839            Self::Many(idxs) => idxs,
840        }
841    }
842}
843
844/// `FuzzyFinder` blazing fast for most places on earth, use a preindex data.
845/// Not work for places around borders.
846///
847/// `FuzzyFinder` store all preindex's tiles data in a `HashMap`,
848/// It iterate all zoom levels for input's longitude and latitude to build
849/// map key to to check if in map.
850///
851/// It's is very fast and use about 400ns to check if has preindex.
852/// It work for most places on earth and here is a quick loop of preindex data:
853/// ![](https://user-images.githubusercontent.com/13536789/200174943-7d40661e-bda5-4b79-a867-ec637e245a49.png)
854pub struct FuzzyFinder {
855    min_zoom: i64,
856    max_zoom: i64,
857    // Sorted timezone name table; tiles reference names by index, so index
858    // order matches lexical order.
859    names: Vec<String>,
860    all: HashMap<u64, TileEntry>, // K: packed <x,y,z>
861    data_version: String,
862}
863
864impl Default for FuzzyFinder {
865    /// Creates a new, empty `FuzzyFinder`.
866    ///
867    /// ```rust
868    /// use tzf_rs::FuzzyFinder;
869    ///
870    /// let finder = FuzzyFinder::default();
871    /// ```
872    fn default() -> Self {
873        let file_bytes = load_preindex();
874        Self::from_pb(pbgen::PreindexTimezones::try_from(file_bytes.to_vec()).unwrap_or_default())
875    }
876}
877
878impl FuzzyFinder {
879    /// # Panics
880    ///
881    /// Panics if the input contains more than `u16::MAX` distinct timezone names.
882    #[must_use]
883    pub fn from_pb(tzs: pbgen::PreindexTimezones) -> Self {
884        // First pass: build a sorted name table so indices compare in the
885        // same order as the names themselves.
886        let mut names: Vec<String> = tzs.keys.iter().map(|item| item.name.clone()).collect();
887        names.sort();
888        names.dedup();
889        let name_idx: HashMap<&str, u16> = names
890            .iter()
891            .enumerate()
892            .map(|(i, name)| {
893                (
894                    name.as_str(),
895                    u16::try_from(i).expect("more than u16::MAX timezone names"),
896                )
897            })
898            .collect();
899
900        // Second pass: populate tiles with name indices.
901        let mut all: HashMap<u64, TileEntry> = HashMap::new();
902        for item in &tzs.keys {
903            let idx = name_idx[item.name.as_str()];
904            let key = pack_tile_key(i64::from(item.x), i64::from(item.y), i64::from(item.z));
905            match all.entry(key) {
906                std::collections::hash_map::Entry::Vacant(entry) => {
907                    entry.insert(TileEntry::One(idx));
908                }
909                std::collections::hash_map::Entry::Occupied(mut entry) => {
910                    let mut idxs = entry.get().indices().to_vec();
911                    idxs.push(idx);
912                    idxs.sort_unstable();
913                    *entry.get_mut() = TileEntry::Many(idxs.into_boxed_slice());
914                }
915            }
916        }
917
918        Self {
919            min_zoom: i64::from(tzs.agg_zoom),
920            max_zoom: i64::from(tzs.idx_zoom),
921            names,
922            all,
923            data_version: tzs.version,
924        }
925    }
926
927    /// Retrieves the time zone name for the given longitude and latitude.
928    ///
929    /// # Arguments
930    ///
931    /// * `lng` - Longitude
932    /// * `lat` - Latitude
933    ///
934    /// # Example:
935    ///
936    /// ```rust
937    /// use tzf_rs::FuzzyFinder;
938    ///
939    /// let finder = FuzzyFinder::new();
940    /// assert_eq!("Asia/Shanghai", finder.get_tz_name(116.3883, 39.9289));
941    /// ```
942    ///
943    /// # Panics
944    ///
945    /// - Panics if `lng` or `lat` is out of range.
946    /// - Panics if `lng` or `lat` is not a number.
947    #[must_use]
948    pub fn get_tz_name(&self, lng: f64, lat: f64) -> &str {
949        if self.max_zoom <= self.min_zoom {
950            return "";
951        }
952        // Compute tile coords once at the highest zoom, then right-shift for coarser levels.
953        let top_zoom = self.max_zoom - 1;
954        let (high_x, high_y) = deg2num(lng, lat, top_zoom);
955        for zoom in self.min_zoom..self.max_zoom {
956            let shift = (top_zoom - zoom) as u32;
957            if let Some(&idx) = self
958                .all
959                .get(&pack_tile_key(high_x >> shift, high_y >> shift, zoom))
960                .and_then(|entry| entry.indices().first())
961            {
962                return &self.names[usize::from(idx)];
963            }
964        }
965        ""
966    }
967
968    pub fn get_tz_names(&self, lng: f64, lat: f64) -> Vec<&str> {
969        let mut names: Vec<&str> = vec![];
970        if self.max_zoom <= self.min_zoom {
971            return names;
972        }
973        let top_zoom = self.max_zoom - 1;
974        let (high_x, high_y) = deg2num(lng, lat, top_zoom);
975        for zoom in self.min_zoom..self.max_zoom {
976            let shift = (top_zoom - zoom) as u32;
977            if let Some(entry) =
978                self.all
979                    .get(&pack_tile_key(high_x >> shift, high_y >> shift, zoom))
980            {
981                for &idx in entry.indices() {
982                    names.push(self.names[usize::from(idx)].as_str());
983                }
984            }
985        }
986        names
987    }
988
989    /// Gets the version of the data used by this `FuzzyFinder`.
990    ///
991    /// # Returns
992    ///
993    /// The version of the data used by this `FuzzyFinder` as a `&str`.
994    ///
995    /// # Example:
996    ///
997    /// ```rust
998    /// use tzf_rs::FuzzyFinder;
999    ///
1000    /// let finder = FuzzyFinder::new();
1001    /// println!("{:?}", finder.data_version());
1002    /// ```
1003    #[must_use]
1004    pub fn data_version(&self) -> &str {
1005        &self.data_version
1006    }
1007
1008    /// Creates a new, empty `FuzzyFinder`.
1009    ///
1010    /// ```rust
1011    /// use tzf_rs::FuzzyFinder;
1012    ///
1013    /// let finder = FuzzyFinder::default();
1014    /// ```
1015    #[must_use]
1016    pub fn new() -> Self {
1017        Self::default()
1018    }
1019
1020    /// Convert the FuzzyFinder's preindex data to GeoJSON format.
1021    ///
1022    /// This method generates polygons for each tile in the preindex,
1023    /// representing the geographic bounds of each tile.
1024    ///
1025    /// Returns a `BoundaryFile` (FeatureCollection) containing all timezone tile polygons.
1026    ///
1027    /// # Example
1028    ///
1029    /// ```rust
1030    /// use tzf_rs::FuzzyFinder;
1031    ///
1032    /// let finder = FuzzyFinder::new();
1033    /// let geojson = finder.to_geojson();
1034    /// let json_string = geojson.to_string();
1035    /// ```
1036    #[must_use]
1037    #[cfg(feature = "export-geojson")]
1038    pub fn to_geojson(&self) -> BoundaryFile {
1039        let mut name_to_keys: HashMap<u16, Vec<(i64, i64, i64)>> = HashMap::new();
1040
1041        // Group tiles by timezone name index
1042        for (key, entry) in &self.all {
1043            for &idx in entry.indices() {
1044                name_to_keys
1045                    .entry(idx)
1046                    .or_default()
1047                    .push(unpack_tile_key(*key));
1048            }
1049        }
1050
1051        let mut features = Vec::new();
1052
1053        for (idx, keys) in name_to_keys {
1054            let mut multi_polygon_coords = MultiPolygonCoordinates::new();
1055
1056            for (x, y, z) in keys {
1057                // Convert tile coordinates to lat/lng bounds
1058                let tile_poly = tile_to_polygon(x, y, z);
1059                multi_polygon_coords.push(vec![tile_poly]);
1060            }
1061
1062            let feature = FeatureItem {
1063                feature_type: "Feature".to_string(),
1064                properties: PropertiesDefine {
1065                    tzid: self.names[usize::from(idx)].clone(),
1066                },
1067                geometry: GeometryDefine {
1068                    geometry_type: "MultiPolygon".to_string(),
1069                    coordinates: multi_polygon_coords,
1070                },
1071            };
1072
1073            features.push(feature);
1074        }
1075
1076        BoundaryFile {
1077            collection_type: "FeatureCollection".to_string(),
1078            features,
1079        }
1080    }
1081
1082    /// Convert a specific timezone's preindex data to GeoJSON format.
1083    ///
1084    /// Returns `Some(FeatureItem)` if the timezone is found in the preindex, `None` otherwise.
1085    ///
1086    /// # Arguments
1087    ///
1088    /// * `timezone_name` - The timezone name to export (e.g., "Asia/Tokyo")
1089    ///
1090    /// # Example
1091    ///
1092    /// ```rust
1093    /// use tzf_rs::FuzzyFinder;
1094    ///
1095    /// let finder = FuzzyFinder::new();
1096    /// if let Some(feature) = finder.get_tz_geojson("Asia/Tokyo") {
1097    ///     let json_string = feature.to_string();
1098    ///     println!("Found {} tiles for timezone", feature.geometry.coordinates.len());
1099    /// }
1100    /// ```
1101    #[must_use]
1102    #[cfg(feature = "export-geojson")]
1103    pub fn get_tz_geojson(&self, timezone_name: &str) -> Option<FeatureItem> {
1104        // The name table is sorted, so binary search for the index.
1105        let target = u16::try_from(
1106            self.names
1107                .binary_search_by(|name| name.as_str().cmp(timezone_name))
1108                .ok()?,
1109        )
1110        .ok()?;
1111
1112        let mut keys = Vec::new();
1113
1114        // Find all tiles that contain this timezone
1115        for (key, entry) in &self.all {
1116            if entry.indices().contains(&target) {
1117                keys.push(unpack_tile_key(*key));
1118            }
1119        }
1120
1121        if keys.is_empty() {
1122            return None;
1123        }
1124
1125        let mut multi_polygon_coords = MultiPolygonCoordinates::new();
1126
1127        for (x, y, z) in keys {
1128            // Convert tile coordinates to lat/lng bounds
1129            let tile_poly = tile_to_polygon(x, y, z);
1130            multi_polygon_coords.push(vec![tile_poly]);
1131        }
1132
1133        Some(FeatureItem {
1134            feature_type: "Feature".to_string(),
1135            properties: PropertiesDefine {
1136                tzid: timezone_name.to_string(),
1137            },
1138            geometry: GeometryDefine {
1139                geometry_type: "MultiPolygon".to_string(),
1140                coordinates: multi_polygon_coords,
1141            },
1142        })
1143    }
1144}
1145
1146/// Convert tile coordinates (x, y, z) to a polygon representing the tile bounds.
1147#[cfg(feature = "export-geojson")]
1148#[allow(clippy::cast_precision_loss)]
1149fn tile_to_polygon(x: i64, y: i64, z: i64) -> Vec<[f64; 2]> {
1150    let n = f64::powf(2.0, z as f64);
1151
1152    // Calculate min (west, south) corner
1153    let lng_min = (x as f64) / n * 360.0 - 180.0;
1154    let lat_min_rad = ((1.0 - ((y + 1) as f64) / n * 2.0) * PI).sinh().atan();
1155    let lat_min = lat_min_rad.to_degrees();
1156
1157    // Calculate max (east, north) corner
1158    let lng_max = ((x + 1) as f64) / n * 360.0 - 180.0;
1159    let lat_max_rad = ((1.0 - (y as f64) / n * 2.0) * PI).sinh().atan();
1160    let lat_max = lat_max_rad.to_degrees();
1161
1162    // Create a closed polygon (5 points, first == last)
1163    vec![
1164        [lng_min, lat_min],
1165        [lng_max, lat_min],
1166        [lng_max, lat_max],
1167        [lng_min, lat_max],
1168        [lng_min, lat_min],
1169    ]
1170}
1171
1172/// It's most recommend to use, combine both [`Finder`] and [`FuzzyFinder`],
1173/// if [`FuzzyFinder`] got no data, then use [`Finder`].
1174pub struct DefaultFinder {
1175    pub finder: Finder,
1176    pub fuzzy_finder: FuzzyFinder,
1177}
1178
1179impl Default for DefaultFinder {
1180    /// Creates a new, empty `DefaultFinder`.
1181    ///
1182    /// # Example
1183    ///
1184    /// ```rust
1185    /// use tzf_rs::DefaultFinder;
1186    /// let finder = DefaultFinder::new();
1187    /// ```
1188    fn default() -> Self {
1189        let options = FinderOptions::y_stripes();
1190        let topo_bytes = load_topology_compress_topo();
1191        let tzs = pbgen::CompressedTopoTimezones::try_from(topo_bytes).unwrap_or_default();
1192        let finder = Finder::from_compressed_topo_with_options(tzs, options);
1193
1194        let fuzzy_finder = FuzzyFinder::default();
1195
1196        Self {
1197            finder,
1198            fuzzy_finder,
1199        }
1200    }
1201}
1202
1203impl DefaultFinder {
1204    /// Creates a new `DefaultFinder` with explicit polygon build options.
1205    ///
1206    /// The selected options are applied to the internal `Finder`.
1207    #[must_use]
1208    pub fn new_with_options(options: FinderOptions) -> Self {
1209        let topo_bytes = load_topology_compress_topo();
1210        let tzs = pbgen::CompressedTopoTimezones::try_from(topo_bytes).unwrap_or_default();
1211        Self {
1212            finder: Finder::from_compressed_topo_with_options(tzs, options),
1213            fuzzy_finder: FuzzyFinder::default(),
1214        }
1215    }
1216
1217    /// Use lossless data to create a new `DefaultFinder`.
1218    ///
1219    /// Similar to [`DefaultFinder::new`], but the internal [`Finder`] uses
1220    /// `combined-with-oceans.compress.topo.bin` (~17 MB, no topology simplification)
1221    /// instead of the default topology-simplified dataset (~5.4 MB). Higher precision, ~1 GB memory usage.
1222    ///
1223    /// Requires the `full` feature to be enabled and must use a git dependency:
1224    /// ```toml
1225    /// tzf-rs = { git = "https://github.com/ringsaturn/tzf-rs", features = ["full"], default-features = false }
1226    /// ```
1227    ///
1228    /// # Example
1229    ///
1230    /// ```rust
1231    /// # #[cfg(feature = "full")]
1232    /// # {
1233    /// use tzf_rs::DefaultFinder;
1234    /// let finder = DefaultFinder::new_full();
1235    /// assert_eq!("Asia/Shanghai", finder.get_tz_name(116.3883, 39.9289));
1236    /// # }
1237    /// ```
1238    #[must_use]
1239    #[cfg(feature = "full")]
1240    #[cfg_attr(docsrs, doc(cfg(feature = "full")))]
1241    pub fn new_full() -> Self {
1242        Self::new_full_with_options(FinderOptions::y_stripes())
1243    }
1244
1245    /// Creates a `DefaultFinder` using full-precision data with explicit polygon build options.
1246    #[must_use]
1247    #[cfg(feature = "full")]
1248    #[cfg_attr(docsrs, doc(cfg(feature = "full")))]
1249    pub fn new_full_with_options(options: FinderOptions) -> Self {
1250        let tzs =
1251            pbgen::CompressedTopoTimezones::try_from(load_compress_topo()).unwrap_or_default();
1252        Self {
1253            finder: Finder::from_compressed_topo_with_options(tzs, options),
1254            fuzzy_finder: FuzzyFinder::default(),
1255        }
1256    }
1257
1258    /// ```rust
1259    /// use tzf_rs::DefaultFinder;
1260    /// let finder = DefaultFinder::new();
1261    /// assert_eq!("Asia/Shanghai", finder.get_tz_name(116.3883, 39.9289));
1262    /// ```
1263    #[must_use]
1264    pub fn get_tz_name(&self, lng: f64, lat: f64) -> &str {
1265        let fuzzy = self.fuzzy_finder.get_tz_name(lng, lat);
1266        if !fuzzy.is_empty() {
1267            return fuzzy;
1268        }
1269        self.finder.get_tz_name(lng, lat)
1270    }
1271
1272    /// ```rust
1273    /// use tzf_rs::DefaultFinder;
1274    /// let finder = DefaultFinder::new();
1275    /// println!("{:?}", finder.get_tz_names(116.3883, 39.9289));
1276    /// ```
1277    #[must_use]
1278    pub fn get_tz_names(&self, lng: f64, lat: f64) -> Vec<&str> {
1279        self.finder.get_tz_names(lng, lat)
1280    }
1281
1282    /// Returns all time zone names as a `Vec<&str>`.
1283    ///
1284    /// ```rust
1285    /// use tzf_rs::DefaultFinder;
1286    /// let finder = DefaultFinder::new();
1287    /// println!("{:?}", finder.timezonenames());
1288    /// ```
1289    #[must_use]
1290    pub fn timezonenames(&self) -> Vec<&str> {
1291        self.finder.timezonenames()
1292    }
1293
1294    /// Returns the version of the data used by this `DefaultFinder` as a `&str`.
1295    ///
1296    /// Example:
1297    ///
1298    /// ```rust
1299    /// use tzf_rs::DefaultFinder;
1300    ///
1301    /// let finder = DefaultFinder::new();
1302    /// println!("{:?}", finder.data_version());
1303    /// ```
1304    #[must_use]
1305    pub fn data_version(&self) -> &str {
1306        self.finder.data_version()
1307    }
1308
1309    /// Creates a new instance of `DefaultFinder`.
1310    ///
1311    /// ```rust
1312    /// use tzf_rs::DefaultFinder;
1313    /// let finder = DefaultFinder::new();
1314    /// ```
1315    #[must_use]
1316    pub fn new() -> Self {
1317        Self::default()
1318    }
1319
1320    /// Convert the DefaultFinder's data to GeoJSON format.
1321    ///
1322    /// This uses the underlying `Finder`'s data for the GeoJSON conversion.
1323    ///
1324    /// Returns a `BoundaryFile` (FeatureCollection) containing all timezone polygons.
1325    ///
1326    /// # Example
1327    ///
1328    /// ```rust
1329    /// use tzf_rs::DefaultFinder;
1330    ///
1331    /// let finder = DefaultFinder::new();
1332    /// let geojson = finder.to_geojson();
1333    /// let json_string = geojson.to_string();
1334    /// ```
1335    #[must_use]
1336    #[cfg(feature = "export-geojson")]
1337    pub fn to_geojson(&self) -> BoundaryFile {
1338        self.finder.to_geojson()
1339    }
1340
1341    /// Convert a specific timezone to GeoJSON format.
1342    ///
1343    /// This uses the underlying `Finder`'s data for the GeoJSON conversion.
1344    ///
1345    /// Returns `Some(BoundaryFile)` containing a FeatureCollection with all features
1346    /// for the timezone if found, `None` otherwise. The returned FeatureCollection
1347    /// may contain multiple features if the timezone has multiple geographic boundaries.
1348    ///
1349    /// # Arguments
1350    ///
1351    /// * `timezone_name` - The timezone name to export (e.g., "Asia/Tokyo")
1352    ///
1353    /// # Example
1354    ///
1355    /// ```rust
1356    /// use tzf_rs::DefaultFinder;
1357    ///
1358    /// let finder = DefaultFinder::new();
1359    /// if let Some(collection) = finder.get_tz_geojson("Asia/Tokyo") {
1360    ///     let json_string = collection.to_string();
1361    ///     println!("Found {} feature(s)", collection.features.len());
1362    ///     if let Some(first_feature) = collection.features.first() {
1363    ///         println!("Timezone ID: {}", first_feature.properties.tzid);
1364    ///     }
1365    /// }
1366    /// ```
1367    #[must_use]
1368    #[cfg(feature = "export-geojson")]
1369    pub fn get_tz_geojson(&self, timezone_name: &str) -> Option<BoundaryFile> {
1370        self.finder.get_tz_geojson(timezone_name)
1371    }
1372}