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