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::{Point, Polygon, PolygonBuildOptions};
5#[cfg(feature = "export-geojson")]
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::f64::consts::PI;
9use std::vec;
10#[cfg(all(feature = "bundled", feature = "full"))]
11compile_error!(
12    "features `bundled` and `full` are mutually exclusive; \
13     add `default-features = false` when enabling `full`"
14);
15
16#[cfg(feature = "bundled")]
17use tzf_dist::{load_preindex, load_topology_compress_topo};
18#[cfg(feature = "full")]
19use tzf_dist_git::{load_compress_topo, load_preindex, load_topology_compress_topo};
20pub mod pbgen;
21
22struct Item {
23    polys: Vec<Polygon>,
24    name: String,
25}
26
27impl Item {
28    fn contains_point(&self, p: &Point) -> bool {
29        for poly in &self.polys {
30            if poly.contains_point(*p) {
31                return true;
32            }
33        }
34        false
35    }
36}
37
38/// Finder works anywhere.
39///
40/// Finder use a fine tuned Ray casting algorithm implement [geometry-rs]
41/// which is Rust port of [geometry] by [Josh Baker].
42///
43/// [geometry-rs]: https://github.com/ringsaturn/geometry-rs
44/// [geometry]: https://github.com/tidwall/geometry
45/// [Josh Baker]: https://github.com/tidwall
46pub struct Finder {
47    all: Vec<Item>,
48    data_version: String,
49    // grid maps (floor(lng), floor(lat)) → candidate item indices.
50    // Populated automatically when loading CompressedTopoTimezones that
51    // contains an embedded GridIndex.
52    grid: Option<HashMap<(i16, i16), Vec<u32>>>,
53}
54
55const DEFAULT_RTREE_MIN_SEGMENTS: usize = 64;
56
57/// Finder build options for polygon acceleration indexes.
58///
59/// Default:
60/// - [`FinderOptions::NoIndex`]
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
62#[non_exhaustive]
63pub enum FinderOptions {
64    /// Disable polygon acceleration indexes.
65    #[default]
66    NoIndex,
67    /// Use Y stripes index.
68    YStripes,
69}
70
71impl FinderOptions {
72    /// Disable polygon acceleration indexes.
73    #[must_use]
74    pub fn no_index() -> Self {
75        Self::NoIndex
76    }
77
78    /// Use Y stripes index.
79    #[must_use]
80    pub fn y_stripes() -> Self {
81        Self::YStripes
82    }
83
84    fn to_polygon_build_options(self) -> PolygonBuildOptions {
85        match self {
86            Self::YStripes => PolygonBuildOptions {
87                enable_rtree: false,
88                enable_compressed_quad: false,
89                enable_y_stripes: true,
90                rtree_min_segments: DEFAULT_RTREE_MIN_SEGMENTS,
91            },
92            Self::NoIndex => PolygonBuildOptions {
93                enable_rtree: false,
94                enable_compressed_quad: false,
95                enable_y_stripes: false,
96                rtree_min_segments: DEFAULT_RTREE_MIN_SEGMENTS,
97            },
98        }
99    }
100}
101
102/// Decode a Google Polyline encoded byte slice into a list of Points.
103///
104/// The go-polyline library encodes coordinates as [lng, lat] pairs with 1e5 precision.
105#[allow(clippy::cast_possible_truncation)]
106fn decode_polyline(encoded: &[u8]) -> Vec<Point> {
107    let mut points = Vec::new();
108    let mut index = 0;
109    let mut lng: i64 = 0;
110    let mut lat: i64 = 0;
111
112    while index < encoded.len() {
113        let (dlng, next) = polyline_decode_value(encoded, index);
114        index = next;
115        let (dlat, next) = polyline_decode_value(encoded, index);
116        index = next;
117        lng += dlng;
118        lat += dlat;
119        points.push(Point {
120            x: lng as f64 / 1e5,
121            y: lat as f64 / 1e5,
122        });
123    }
124    points
125}
126
127fn polyline_decode_value(encoded: &[u8], start: usize) -> (i64, usize) {
128    let mut result: i64 = 0;
129    let mut shift = 0;
130    let mut index = start;
131
132    loop {
133        let byte = (encoded[index] as i64) - 63;
134        index += 1;
135        result |= (byte & 0x1F) << shift;
136        shift += 5;
137        if byte < 0x20 {
138            break;
139        }
140    }
141
142    let value = if result & 1 != 0 {
143        !(result >> 1)
144    } else {
145        result >> 1
146    };
147    (value, index)
148}
149
150fn expand_compressed_ring(
151    segs: &[pbgen::CompressedRingSegment],
152    edges: &[Vec<Point>],
153) -> Vec<Point> {
154    let mut pts = Vec::new();
155    for seg in segs {
156        match &seg.content {
157            Some(pbgen::compressed_ring_segment::Content::Inline(inline)) => {
158                pts.extend(decode_polyline(&inline.points));
159            }
160            Some(pbgen::compressed_ring_segment::Content::EdgeForward(idx)) => {
161                pts.extend_from_slice(&edges[*idx as usize]);
162            }
163            Some(pbgen::compressed_ring_segment::Content::EdgeReversed(idx)) => {
164                pts.extend(edges[*idx as usize].iter().rev().copied());
165            }
166            None => {}
167        }
168    }
169    pts
170}
171
172impl Finder {
173    fn from_pb_with_polygon_options(tzs: pbgen::Timezones, options: PolygonBuildOptions) -> Self {
174        let mut f = Self {
175            all: vec![],
176            data_version: tzs.version,
177            grid: None,
178        };
179        for tz in &tzs.timezones {
180            let mut polys: Vec<Polygon> = vec![];
181
182            for pbpoly in &tz.polygons {
183                let mut exterior: Vec<Point> = vec![];
184                for pbpoint in &pbpoly.points {
185                    exterior.push(Point {
186                        x: f64::from(pbpoint.lng),
187                        y: f64::from(pbpoint.lat),
188                    });
189                }
190
191                let mut interior: Vec<Vec<Point>> = vec![];
192
193                for holepoly in &pbpoly.holes {
194                    let mut holeextr: Vec<Point> = vec![];
195                    for holepoint in &holepoly.points {
196                        holeextr.push(Point {
197                            x: f64::from(holepoint.lng),
198                            y: f64::from(holepoint.lat),
199                        });
200                    }
201                    interior.push(holeextr);
202                }
203
204                let geopoly = geometry_rs::Polygon::new(exterior, interior, Some(options));
205                polys.push(geopoly);
206            }
207
208            let item: Item = Item {
209                name: tz.name.to_string(),
210                polys,
211            };
212
213            f.all.push(item);
214        }
215        f
216    }
217
218    fn from_compressed_topo_with_polygon_options(
219        tzs: pbgen::CompressedTopoTimezones,
220        options: PolygonBuildOptions,
221    ) -> Self {
222        let mut edges: Vec<Vec<Point>> = vec![Vec::new(); tzs.shared_edges.len()];
223        for edge in &tzs.shared_edges {
224            edges[edge.id as usize] = decode_polyline(&edge.points);
225        }
226
227        let grid = tzs.grid_index.map(|gi| {
228            let mut m = HashMap::with_capacity(gi.cells.len());
229            for cell in gi.cells {
230                m.insert((cell.lng as i16, cell.lat as i16), cell.tz_indices);
231            }
232            m
233        });
234
235        let mut f = Self {
236            all: vec![],
237            data_version: tzs.version,
238            grid,
239        };
240
241        for tz in &tzs.timezones {
242            let mut polys: Vec<Polygon> = vec![];
243            for poly in &tz.polygons {
244                let exterior = expand_compressed_ring(&poly.exterior, &edges);
245                let interior: Vec<Vec<Point>> = poly
246                    .holes
247                    .iter()
248                    .map(|hole| expand_compressed_ring(&hole.exterior, &edges))
249                    .collect();
250                polys.push(geometry_rs::Polygon::new(exterior, interior, Some(options)));
251            }
252            f.all.push(Item {
253                name: tz.name.clone(),
254                polys,
255            });
256        }
257        f
258    }
259
260    /// Create a Finder from `CompressedTopoTimezones` protobuf data.
261    ///
262    /// This is the preferred constructor when using tzf-dist data.
263    #[must_use]
264    pub fn from_compressed_topo(tzs: pbgen::CompressedTopoTimezones) -> Self {
265        Self::from_compressed_topo_with_options(tzs, FinderOptions::default())
266    }
267
268    /// Create a Finder from `CompressedTopoTimezones` with explicit polygon build options.
269    #[must_use]
270    pub fn from_compressed_topo_with_options(
271        tzs: pbgen::CompressedTopoTimezones,
272        options: FinderOptions,
273    ) -> Self {
274        Self::from_compressed_topo_with_polygon_options(tzs, options.to_polygon_build_options())
275    }
276
277    /// `from_pb` is used when you can use your own timezone data, as long as
278    /// it's compatible with Proto's desc.
279    ///
280    /// # Arguments
281    ///
282    /// * `tzs` - Timezones data.
283    ///
284    /// # Returns
285    ///
286    /// * `Finder` - A Finder instance.
287    #[must_use]
288    pub fn from_pb(tzs: pbgen::Timezones) -> Self {
289        Self::from_pb_with_options(tzs, FinderOptions::default())
290    }
291
292    /// Create a finder from protobuf data with explicit polygon build options.
293    #[must_use]
294    pub fn from_pb_with_options(tzs: pbgen::Timezones, options: FinderOptions) -> Self {
295        Self::from_pb_with_polygon_options(tzs, options.to_polygon_build_options())
296    }
297
298    /// Example:
299    ///
300    /// ```rust
301    /// use tzf_rs::Finder;
302    ///
303    /// let finder = Finder::new();
304    /// assert_eq!("Asia/Shanghai", finder.get_tz_name(116.3883, 39.9289));
305    /// ```
306    #[must_use]
307    pub fn get_tz_name(&self, lng: f64, lat: f64) -> &str {
308        if let Some(ref grid) = self.grid {
309            let key = (lng.floor() as i16, lat.floor() as i16);
310            let indices = match grid.get(&key) {
311                Some(v) => v,
312                None => return "",
313            };
314            // Single-candidate short-circuit: skip PIP when there is only one
315            // candidate and we are away from antimeridian / pole edges.
316            if indices.len() == 1 && (-179.0..179.0).contains(&lng) && (-89.0..89.0).contains(&lat)
317            {
318                return &self.all[indices[0] as usize].name;
319            }
320            let p = geometry_rs::Point { x: lng, y: lat };
321            for &idx in indices {
322                if self.all[idx as usize].contains_point(&p) {
323                    return &self.all[idx as usize].name;
324                }
325            }
326            return "";
327        }
328        let p = geometry_rs::Point { x: lng, y: lat };
329        for item in &self.all {
330            if item.contains_point(&p) {
331                return &item.name;
332            }
333        }
334        ""
335    }
336
337    /// ```rust
338    /// use tzf_rs::Finder;
339    /// let finder = Finder::new();
340    /// println!("{:?}", finder.get_tz_names(116.3883, 39.9289));
341    /// ```
342    #[must_use]
343    pub fn get_tz_names(&self, lng: f64, lat: f64) -> Vec<&str> {
344        let mut ret: Vec<&str> = vec![];
345        if let Some(ref grid) = self.grid {
346            let key = (lng.floor() as i16, lat.floor() as i16);
347            if let Some(indices) = grid.get(&key) {
348                let p = geometry_rs::Point { x: lng, y: lat };
349                for &idx in indices {
350                    if self.all[idx as usize].contains_point(&p) {
351                        ret.push(&self.all[idx as usize].name);
352                    }
353                }
354            }
355            return ret;
356        }
357        let p = geometry_rs::Point { x: lng, y: lat };
358        for item in &self.all {
359            if item.contains_point(&p) {
360                ret.push(&item.name);
361            }
362        }
363        ret
364    }
365
366    /// Example:
367    ///
368    /// ```rust
369    /// use tzf_rs::Finder;
370    ///
371    /// let finder = Finder::new();
372    /// println!("{:?}", finder.timezonenames());
373    /// ```
374    #[must_use]
375    pub fn timezonenames(&self) -> Vec<&str> {
376        let mut ret: Vec<&str> = vec![];
377        for item in &self.all {
378            ret.push(&item.name);
379        }
380        ret
381    }
382
383    /// Example:
384    ///
385    /// ```rust
386    /// use tzf_rs::Finder;
387    ///
388    /// let finder = Finder::new();
389    /// println!("{:?}", finder.data_version());
390    /// ```
391    #[must_use]
392    pub fn data_version(&self) -> &str {
393        &self.data_version
394    }
395
396    /// Creates a new, empty `Finder`.
397    ///
398    /// Example:
399    ///
400    /// ```rust
401    /// use tzf_rs::Finder;
402    ///
403    /// let finder = Finder::new();
404    /// ```
405    #[must_use]
406    pub fn new() -> Self {
407        Self::default()
408    }
409
410    /// Helper method to convert an Item to a FeatureItem.
411    #[cfg(feature = "export-geojson")]
412    fn item_to_feature(&self, item: &Item) -> FeatureItem {
413        // Convert internal Item to pbgen::Timezone format
414        let mut pbpolys = Vec::new();
415        for poly in &item.polys {
416            let mut pbpoly = pbgen::Polygon {
417                points: Vec::new(),
418                holes: Vec::new(),
419            };
420
421            // Convert exterior points
422            for point in poly.exterior() {
423                pbpoly.points.push(pbgen::Point {
424                    lng: point.x as f32,
425                    lat: point.y as f32,
426                });
427            }
428
429            // Convert holes
430            for hole in poly.holes() {
431                let mut hole_poly = pbgen::Polygon {
432                    points: Vec::new(),
433                    holes: Vec::new(),
434                };
435                for point in hole {
436                    hole_poly.points.push(pbgen::Point {
437                        lng: point.x as f32,
438                        lat: point.y as f32,
439                    });
440                }
441                pbpoly.holes.push(hole_poly);
442            }
443
444            pbpolys.push(pbpoly);
445        }
446
447        let pbtz = pbgen::Timezone {
448            polygons: pbpolys,
449            name: item.name.clone(),
450        };
451
452        revert_item(&pbtz)
453    }
454
455    /// Convert the Finder's data to GeoJSON format.
456    ///
457    /// Returns a `BoundaryFile` (FeatureCollection) containing all timezone polygons.
458    ///
459    /// # Example
460    ///
461    /// ```rust
462    /// use tzf_rs::Finder;
463    ///
464    /// let finder = Finder::new();
465    /// let geojson = finder.to_geojson();
466    /// let json_string = geojson.to_string();
467    /// ```
468    #[must_use]
469    #[cfg(feature = "export-geojson")]
470    pub fn to_geojson(&self) -> BoundaryFile {
471        let mut output = BoundaryFile {
472            collection_type: "FeatureCollection".to_string(),
473            features: Vec::new(),
474        };
475
476        for item in &self.all {
477            output.features.push(self.item_to_feature(item));
478        }
479
480        output
481    }
482
483    /// Convert a specific timezone to GeoJSON format.
484    ///
485    /// Returns `Some(BoundaryFile)` containing a FeatureCollection with all features
486    /// for the timezone if found, `None` otherwise. The returned FeatureCollection
487    /// may contain multiple features if the timezone has multiple geographic boundaries.
488    ///
489    /// # Arguments
490    ///
491    /// * `timezone_name` - The timezone name to export (e.g., "Asia/Tokyo")
492    ///
493    /// # Example
494    ///
495    /// ```rust
496    /// use tzf_rs::Finder;
497    ///
498    /// let finder = Finder::new();
499    /// if let Some(collection) = finder.get_tz_geojson("Asia/Tokyo") {
500    ///     let json_string = collection.to_string();
501    ///     println!("Found {} feature(s)", collection.features.len());
502    ///     if let Some(first_feature) = collection.features.first() {
503    ///         println!("Timezone ID: {}", first_feature.properties.tzid);
504    ///     }
505    /// }
506    /// ```
507    #[must_use]
508    #[cfg(feature = "export-geojson")]
509    pub fn get_tz_geojson(&self, timezone_name: &str) -> Option<BoundaryFile> {
510        let mut output = BoundaryFile {
511            collection_type: "FeatureCollection".to_string(),
512            features: Vec::new(),
513        };
514        for item in &self.all {
515            if item.name == timezone_name {
516                output.features.push(self.item_to_feature(item));
517            }
518        }
519
520        if output.features.is_empty() {
521            None
522        } else {
523            Some(output)
524        }
525    }
526}
527
528/// Creates a new, empty `Finder`.
529///
530/// Example:
531///
532/// ```rust
533/// use tzf_rs::Finder;
534///
535/// let finder = Finder::default();
536/// ```
537impl Default for Finder {
538    fn default() -> Self {
539        let file_bytes = load_topology_compress_topo();
540        Self::from_compressed_topo(
541            pbgen::CompressedTopoTimezones::try_from(file_bytes).unwrap_or_default(),
542        )
543    }
544}
545
546/// deg2num is used to convert longitude, latitude to [Slippy map tilenames]
547/// under specific zoom level.
548///
549/// [Slippy map tilenames]: https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames
550///
551/// Example:
552///
553/// ```rust
554/// use tzf_rs::deg2num;
555/// let ret = deg2num(116.3883, 39.9289, 7);
556/// assert_eq!((105, 48), ret);
557/// ```
558#[must_use]
559#[allow(
560    clippy::cast_precision_loss,
561    clippy::cast_possible_truncation,
562    clippy::similar_names
563)]
564pub fn deg2num(lng: f64, lat: f64, zoom: i64) -> (i64, i64) {
565    let n = (1i64 << zoom) as f64;
566    let lat_rad = lat.to_radians();
567    let xtile = (lng / 360.0 + 0.5) * n;
568    let ytile = (1.0 - lat_rad.tan().asinh() / PI) / 2.0 * n;
569
570    // Possible precision loss here
571    (xtile as i64, ytile as i64)
572}
573
574/// GeoJSON type definitions for conversion
575#[cfg(feature = "export-geojson")]
576pub type PolygonCoordinates = Vec<Vec<[f64; 2]>>;
577#[cfg(feature = "export-geojson")]
578pub type MultiPolygonCoordinates = Vec<PolygonCoordinates>;
579
580#[cfg(feature = "export-geojson")]
581#[derive(Debug, Clone, Serialize, Deserialize)]
582pub struct GeometryDefine {
583    #[serde(rename = "type")]
584    pub geometry_type: String,
585    pub coordinates: MultiPolygonCoordinates,
586}
587
588#[cfg(feature = "export-geojson")]
589#[derive(Debug, Clone, Serialize, Deserialize)]
590pub struct PropertiesDefine {
591    pub tzid: String,
592}
593
594#[cfg(feature = "export-geojson")]
595#[derive(Debug, Clone, Serialize, Deserialize)]
596pub struct FeatureItem {
597    #[serde(rename = "type")]
598    pub feature_type: String,
599    pub properties: PropertiesDefine,
600    pub geometry: GeometryDefine,
601}
602
603#[cfg(feature = "export-geojson")]
604impl FeatureItem {
605    pub fn to_string(&self) -> String {
606        serde_json::to_string(self).unwrap_or_default()
607    }
608
609    pub fn to_string_pretty(&self) -> String {
610        serde_json::to_string_pretty(self).unwrap_or_default()
611    }
612}
613
614#[cfg(feature = "export-geojson")]
615#[derive(Debug, Clone, Serialize, Deserialize)]
616pub struct BoundaryFile {
617    #[serde(rename = "type")]
618    pub collection_type: String,
619    pub features: Vec<FeatureItem>,
620}
621
622#[cfg(feature = "export-geojson")]
623impl BoundaryFile {
624    pub fn to_string(&self) -> String {
625        serde_json::to_string(self).unwrap_or_default()
626    }
627
628    pub fn to_string_pretty(&self) -> String {
629        serde_json::to_string_pretty(self).unwrap_or_default()
630    }
631}
632
633/// Convert protobuf Polygon array to GeoJSON MultiPolygon coordinates
634#[cfg(feature = "export-geojson")]
635fn from_pb_polygon_to_geo_multipolygon(pbpoly: &[pbgen::Polygon]) -> MultiPolygonCoordinates {
636    let mut res = MultiPolygonCoordinates::new();
637    for poly in pbpoly {
638        let mut new_geo_poly = PolygonCoordinates::new();
639
640        // Main polygon (exterior ring)
641        let mut mainpoly = Vec::new();
642        for point in &poly.points {
643            mainpoly.push([f64::from(point.lng), f64::from(point.lat)]);
644        }
645        new_geo_poly.push(mainpoly);
646
647        // Holes (interior rings)
648        for holepoly in &poly.holes {
649            let mut holepoly_coords = Vec::new();
650            for point in &holepoly.points {
651                holepoly_coords.push([f64::from(point.lng), f64::from(point.lat)]);
652            }
653            new_geo_poly.push(holepoly_coords);
654        }
655        res.push(new_geo_poly);
656    }
657    res
658}
659
660/// Convert a protobuf Timezone to a GeoJSON FeatureItem
661#[cfg(feature = "export-geojson")]
662fn revert_item(input: &pbgen::Timezone) -> FeatureItem {
663    FeatureItem {
664        feature_type: "Feature".to_string(),
665        properties: PropertiesDefine {
666            tzid: input.name.clone(),
667        },
668        geometry: GeometryDefine {
669            geometry_type: "MultiPolygon".to_string(),
670            coordinates: from_pb_polygon_to_geo_multipolygon(&input.polygons),
671        },
672    }
673}
674
675/// Convert protobuf Timezones to GeoJSON BoundaryFile (FeatureCollection)
676#[cfg(feature = "export-geojson")]
677pub fn revert_timezones(input: &pbgen::Timezones) -> BoundaryFile {
678    let mut output = BoundaryFile {
679        collection_type: "FeatureCollection".to_string(),
680        features: Vec::new(),
681    };
682    for timezone in &input.timezones {
683        let item = revert_item(timezone);
684        output.features.push(item);
685    }
686    output
687}
688
689/// `FuzzyFinder` blazing fast for most places on earth, use a preindex data.
690/// Not work for places around borders.
691///
692/// `FuzzyFinder` store all preindex's tiles data in a `HashMap`,
693/// It iterate all zoom levels for input's longitude and latitude to build
694/// map key to to check if in map.
695///
696/// It's is very fast and use about 400ns to check if has preindex.
697/// It work for most places on earth and here is a quick loop of preindex data:
698/// ![](https://user-images.githubusercontent.com/13536789/200174943-7d40661e-bda5-4b79-a867-ec637e245a49.png)
699pub struct FuzzyFinder {
700    min_zoom: i64,
701    max_zoom: i64,
702    all: HashMap<(i64, i64, i64), Vec<String>>, // K: <x,y,z>
703    data_version: String,
704}
705
706impl Default for FuzzyFinder {
707    /// Creates a new, empty `FuzzyFinder`.
708    ///
709    /// ```rust
710    /// use tzf_rs::FuzzyFinder;
711    ///
712    /// let finder = FuzzyFinder::default();
713    /// ```
714    fn default() -> Self {
715        let file_bytes = load_preindex();
716        Self::from_pb(pbgen::PreindexTimezones::try_from(file_bytes.to_vec()).unwrap_or_default())
717    }
718}
719
720impl FuzzyFinder {
721    #[must_use]
722    pub fn from_pb(tzs: pbgen::PreindexTimezones) -> Self {
723        let mut f = Self {
724            min_zoom: i64::from(tzs.agg_zoom),
725            max_zoom: i64::from(tzs.idx_zoom),
726            all: HashMap::new(),
727            data_version: tzs.version,
728        };
729        for item in &tzs.keys {
730            let key = (i64::from(item.x), i64::from(item.y), i64::from(item.z));
731            let names = f.all.entry(key).or_default();
732            names.push(item.name.to_string());
733            names.sort();
734        }
735        f
736    }
737
738    /// Retrieves the time zone name for the given longitude and latitude.
739    ///
740    /// # Arguments
741    ///
742    /// * `lng` - Longitude
743    /// * `lat` - Latitude
744    ///
745    /// # Example:
746    ///
747    /// ```rust
748    /// use tzf_rs::FuzzyFinder;
749    ///
750    /// let finder = FuzzyFinder::new();
751    /// assert_eq!("Asia/Shanghai", finder.get_tz_name(116.3883, 39.9289));
752    /// ```
753    ///
754    /// # Panics
755    ///
756    /// - Panics if `lng` or `lat` is out of range.
757    /// - Panics if `lng` or `lat` is not a number.
758    #[must_use]
759    pub fn get_tz_name(&self, lng: f64, lat: f64) -> &str {
760        if self.max_zoom <= self.min_zoom {
761            return "";
762        }
763        // Compute tile coords once at the highest zoom, then right-shift for coarser levels.
764        let top_zoom = self.max_zoom - 1;
765        let (high_x, high_y) = deg2num(lng, lat, top_zoom);
766        for zoom in self.min_zoom..self.max_zoom {
767            let shift = (top_zoom - zoom) as u32;
768            if let Some(names) = self.all.get(&(high_x >> shift, high_y >> shift, zoom)) {
769                if let Some(name) = names.first() {
770                    return name;
771                }
772            }
773        }
774        ""
775    }
776
777    pub fn get_tz_names(&self, lng: f64, lat: f64) -> Vec<&str> {
778        let mut names: Vec<&str> = vec![];
779        if self.max_zoom <= self.min_zoom {
780            return names;
781        }
782        let top_zoom = self.max_zoom - 1;
783        let (high_x, high_y) = deg2num(lng, lat, top_zoom);
784        for zoom in self.min_zoom..self.max_zoom {
785            let shift = (top_zoom - zoom) as u32;
786            if let Some(entries) = self.all.get(&(high_x >> shift, high_y >> shift, zoom)) {
787                for item in entries {
788                    names.push(item);
789                }
790            }
791        }
792        names
793    }
794
795    /// Gets the version of the data used by this `FuzzyFinder`.
796    ///
797    /// # Returns
798    ///
799    /// The version of the data used by this `FuzzyFinder` as a `&str`.
800    ///
801    /// # Example:
802    ///
803    /// ```rust
804    /// use tzf_rs::FuzzyFinder;
805    ///
806    /// let finder = FuzzyFinder::new();
807    /// println!("{:?}", finder.data_version());
808    /// ```
809    #[must_use]
810    pub fn data_version(&self) -> &str {
811        &self.data_version
812    }
813
814    /// Creates a new, empty `FuzzyFinder`.
815    ///
816    /// ```rust
817    /// use tzf_rs::FuzzyFinder;
818    ///
819    /// let finder = FuzzyFinder::default();
820    /// ```
821    #[must_use]
822    pub fn new() -> Self {
823        Self::default()
824    }
825
826    /// Convert the FuzzyFinder's preindex data to GeoJSON format.
827    ///
828    /// This method generates polygons for each tile in the preindex,
829    /// representing the geographic bounds of each tile.
830    ///
831    /// Returns a `BoundaryFile` (FeatureCollection) containing all timezone tile polygons.
832    ///
833    /// # Example
834    ///
835    /// ```rust
836    /// use tzf_rs::FuzzyFinder;
837    ///
838    /// let finder = FuzzyFinder::new();
839    /// let geojson = finder.to_geojson();
840    /// let json_string = geojson.to_string();
841    /// ```
842    #[must_use]
843    #[cfg(feature = "export-geojson")]
844    pub fn to_geojson(&self) -> BoundaryFile {
845        let mut name_to_keys: HashMap<&String, Vec<(i64, i64, i64)>> = HashMap::new();
846
847        // Group tiles by timezone name
848        for (key, names) in &self.all {
849            for name in names {
850                name_to_keys.entry(name).or_insert_with(Vec::new).push(*key);
851            }
852        }
853
854        let mut features = Vec::new();
855
856        for (name, keys) in name_to_keys {
857            let mut multi_polygon_coords = MultiPolygonCoordinates::new();
858
859            for (x, y, z) in keys {
860                // Convert tile coordinates to lat/lng bounds
861                let tile_poly = tile_to_polygon(x, y, z);
862                multi_polygon_coords.push(vec![tile_poly]);
863            }
864
865            let feature = FeatureItem {
866                feature_type: "Feature".to_string(),
867                properties: PropertiesDefine { tzid: name.clone() },
868                geometry: GeometryDefine {
869                    geometry_type: "MultiPolygon".to_string(),
870                    coordinates: multi_polygon_coords,
871                },
872            };
873
874            features.push(feature);
875        }
876
877        BoundaryFile {
878            collection_type: "FeatureCollection".to_string(),
879            features,
880        }
881    }
882
883    /// Convert a specific timezone's preindex data to GeoJSON format.
884    ///
885    /// Returns `Some(FeatureItem)` if the timezone is found in the preindex, `None` otherwise.
886    ///
887    /// # Arguments
888    ///
889    /// * `timezone_name` - The timezone name to export (e.g., "Asia/Tokyo")
890    ///
891    /// # Example
892    ///
893    /// ```rust
894    /// use tzf_rs::FuzzyFinder;
895    ///
896    /// let finder = FuzzyFinder::new();
897    /// if let Some(feature) = finder.get_tz_geojson("Asia/Tokyo") {
898    ///     let json_string = feature.to_string();
899    ///     println!("Found {} tiles for timezone", feature.geometry.coordinates.len());
900    /// }
901    /// ```
902    #[must_use]
903    #[cfg(feature = "export-geojson")]
904    pub fn get_tz_geojson(&self, timezone_name: &str) -> Option<FeatureItem> {
905        let mut keys = Vec::new();
906
907        // Find all tiles that contain this timezone
908        for (key, names) in &self.all {
909            if names.iter().any(|n| n == timezone_name) {
910                keys.push(*key);
911            }
912        }
913
914        if keys.is_empty() {
915            return None;
916        }
917
918        let mut multi_polygon_coords = MultiPolygonCoordinates::new();
919
920        for (x, y, z) in keys {
921            // Convert tile coordinates to lat/lng bounds
922            let tile_poly = tile_to_polygon(x, y, z);
923            multi_polygon_coords.push(vec![tile_poly]);
924        }
925
926        Some(FeatureItem {
927            feature_type: "Feature".to_string(),
928            properties: PropertiesDefine {
929                tzid: timezone_name.to_string(),
930            },
931            geometry: GeometryDefine {
932                geometry_type: "MultiPolygon".to_string(),
933                coordinates: multi_polygon_coords,
934            },
935        })
936    }
937}
938
939/// Convert tile coordinates (x, y, z) to a polygon representing the tile bounds.
940#[cfg(feature = "export-geojson")]
941#[allow(clippy::cast_precision_loss)]
942fn tile_to_polygon(x: i64, y: i64, z: i64) -> Vec<[f64; 2]> {
943    let n = f64::powf(2.0, z as f64);
944
945    // Calculate min (west, south) corner
946    let lng_min = (x as f64) / n * 360.0 - 180.0;
947    let lat_min_rad = ((1.0 - ((y + 1) as f64) / n * 2.0) * PI).sinh().atan();
948    let lat_min = lat_min_rad.to_degrees();
949
950    // Calculate max (east, north) corner
951    let lng_max = ((x + 1) as f64) / n * 360.0 - 180.0;
952    let lat_max_rad = ((1.0 - (y as f64) / n * 2.0) * PI).sinh().atan();
953    let lat_max = lat_max_rad.to_degrees();
954
955    // Create a closed polygon (5 points, first == last)
956    vec![
957        [lng_min, lat_min],
958        [lng_max, lat_min],
959        [lng_max, lat_max],
960        [lng_min, lat_max],
961        [lng_min, lat_min],
962    ]
963}
964
965/// It's most recommend to use, combine both [`Finder`] and [`FuzzyFinder`],
966/// if [`FuzzyFinder`] got no data, then use [`Finder`].
967pub struct DefaultFinder {
968    pub finder: Finder,
969    pub fuzzy_finder: FuzzyFinder,
970}
971
972impl Default for DefaultFinder {
973    /// Creates a new, empty `DefaultFinder`.
974    ///
975    /// # Example
976    ///
977    /// ```rust
978    /// use tzf_rs::DefaultFinder;
979    /// let finder = DefaultFinder::new();
980    /// ```
981    fn default() -> Self {
982        let options = FinderOptions::y_stripes();
983        let topo_bytes = load_topology_compress_topo();
984        let tzs = pbgen::CompressedTopoTimezones::try_from(topo_bytes).unwrap_or_default();
985        let finder = Finder::from_compressed_topo_with_options(tzs, options);
986
987        let fuzzy_finder = FuzzyFinder::default();
988
989        Self {
990            finder,
991            fuzzy_finder,
992        }
993    }
994}
995
996impl DefaultFinder {
997    /// Creates a new `DefaultFinder` with explicit polygon build options.
998    ///
999    /// The selected options are applied to the internal `Finder`.
1000    #[must_use]
1001    pub fn new_with_options(options: FinderOptions) -> Self {
1002        let topo_bytes = load_topology_compress_topo();
1003        let tzs = pbgen::CompressedTopoTimezones::try_from(topo_bytes).unwrap_or_default();
1004        Self {
1005            finder: Finder::from_compressed_topo_with_options(tzs, options),
1006            fuzzy_finder: FuzzyFinder::default(),
1007        }
1008    }
1009
1010    /// Use lossless data to create a new `DefaultFinder`.
1011    ///
1012    /// Similar to [`DefaultFinder::new`], but the internal [`Finder`] uses
1013    /// `combined-with-oceans.compress.topo.bin` (~17 MB, no topology simplification)
1014    /// instead of the default topology-simplified dataset (~5.4 MB). Higher precision, ~1 GB memory usage.
1015    ///
1016    /// Requires the `full` feature to be enabled and must use a git dependency:
1017    /// ```toml
1018    /// tzf-rs = { git = "https://github.com/ringsaturn/tzf-rs", features = ["full"], default-features = false }
1019    /// ```
1020    ///
1021    /// # Example
1022    ///
1023    /// ```rust
1024    /// # #[cfg(feature = "full")]
1025    /// # {
1026    /// use tzf_rs::DefaultFinder;
1027    /// let finder = DefaultFinder::new_full();
1028    /// assert_eq!("Asia/Shanghai", finder.get_tz_name(116.3883, 39.9289));
1029    /// # }
1030    /// ```
1031    #[must_use]
1032    #[cfg(feature = "full")]
1033    #[cfg_attr(docsrs, doc(cfg(feature = "full")))]
1034    pub fn new_full() -> Self {
1035        Self::new_full_with_options(FinderOptions::y_stripes())
1036    }
1037
1038    /// Creates a `DefaultFinder` using full-precision data with explicit polygon build options.
1039    #[must_use]
1040    #[cfg(feature = "full")]
1041    #[cfg_attr(docsrs, doc(cfg(feature = "full")))]
1042    pub fn new_full_with_options(options: FinderOptions) -> Self {
1043        let tzs =
1044            pbgen::CompressedTopoTimezones::try_from(load_compress_topo()).unwrap_or_default();
1045        Self {
1046            finder: Finder::from_compressed_topo_with_options(tzs, options),
1047            fuzzy_finder: FuzzyFinder::default(),
1048        }
1049    }
1050
1051    /// ```rust
1052    /// use tzf_rs::DefaultFinder;
1053    /// let finder = DefaultFinder::new();
1054    /// assert_eq!("Asia/Shanghai", finder.get_tz_name(116.3883, 39.9289));
1055    /// ```
1056    #[must_use]
1057    pub fn get_tz_name(&self, lng: f64, lat: f64) -> &str {
1058        let fuzzy = self.fuzzy_finder.get_tz_name(lng, lat);
1059        if !fuzzy.is_empty() {
1060            return fuzzy;
1061        }
1062        self.finder.get_tz_name(lng, lat)
1063    }
1064
1065    /// ```rust
1066    /// use tzf_rs::DefaultFinder;
1067    /// let finder = DefaultFinder::new();
1068    /// println!("{:?}", finder.get_tz_names(116.3883, 39.9289));
1069    /// ```
1070    #[must_use]
1071    pub fn get_tz_names(&self, lng: f64, lat: f64) -> Vec<&str> {
1072        let fuzzy_names = self.fuzzy_finder.get_tz_names(lng, lat);
1073        if !fuzzy_names.is_empty() {
1074            return fuzzy_names;
1075        }
1076        let names = self.finder.get_tz_names(lng, lat);
1077        if !names.is_empty() {
1078            return names;
1079        }
1080        Vec::new() // Return empty vector if no timezone is found
1081    }
1082
1083    /// Returns all time zone names as a `Vec<&str>`.
1084    ///
1085    /// ```rust
1086    /// use tzf_rs::DefaultFinder;
1087    /// let finder = DefaultFinder::new();
1088    /// println!("{:?}", finder.timezonenames());
1089    /// ```
1090    #[must_use]
1091    pub fn timezonenames(&self) -> Vec<&str> {
1092        self.finder.timezonenames()
1093    }
1094
1095    /// Returns the version of the data used by this `DefaultFinder` as a `&str`.
1096    ///
1097    /// Example:
1098    ///
1099    /// ```rust
1100    /// use tzf_rs::DefaultFinder;
1101    ///
1102    /// let finder = DefaultFinder::new();
1103    /// println!("{:?}", finder.data_version());
1104    /// ```
1105    #[must_use]
1106    pub fn data_version(&self) -> &str {
1107        &self.finder.data_version
1108    }
1109
1110    /// Creates a new instance of `DefaultFinder`.
1111    ///
1112    /// ```rust
1113    /// use tzf_rs::DefaultFinder;
1114    /// let finder = DefaultFinder::new();
1115    /// ```
1116    #[must_use]
1117    pub fn new() -> Self {
1118        Self::default()
1119    }
1120
1121    /// Convert the DefaultFinder's data to GeoJSON format.
1122    ///
1123    /// This uses the underlying `Finder`'s data for the GeoJSON conversion.
1124    ///
1125    /// Returns a `BoundaryFile` (FeatureCollection) containing all timezone polygons.
1126    ///
1127    /// # Example
1128    ///
1129    /// ```rust
1130    /// use tzf_rs::DefaultFinder;
1131    ///
1132    /// let finder = DefaultFinder::new();
1133    /// let geojson = finder.to_geojson();
1134    /// let json_string = geojson.to_string();
1135    /// ```
1136    #[must_use]
1137    #[cfg(feature = "export-geojson")]
1138    pub fn to_geojson(&self) -> BoundaryFile {
1139        self.finder.to_geojson()
1140    }
1141
1142    /// Convert a specific timezone to GeoJSON format.
1143    ///
1144    /// This uses the underlying `Finder`'s data for the GeoJSON conversion.
1145    ///
1146    /// Returns `Some(BoundaryFile)` containing a FeatureCollection with all features
1147    /// for the timezone if found, `None` otherwise. The returned FeatureCollection
1148    /// may contain multiple features if the timezone has multiple geographic boundaries.
1149    ///
1150    /// # Arguments
1151    ///
1152    /// * `timezone_name` - The timezone name to export (e.g., "Asia/Tokyo")
1153    ///
1154    /// # Example
1155    ///
1156    /// ```rust
1157    /// use tzf_rs::DefaultFinder;
1158    ///
1159    /// let finder = DefaultFinder::new();
1160    /// if let Some(collection) = finder.get_tz_geojson("Asia/Tokyo") {
1161    ///     let json_string = collection.to_string();
1162    ///     println!("Found {} feature(s)", collection.features.len());
1163    ///     if let Some(first_feature) = collection.features.first() {
1164    ///         println!("Timezone ID: {}", first_feature.properties.tzid);
1165    ///     }
1166    /// }
1167    /// ```
1168    #[must_use]
1169    #[cfg(feature = "export-geojson")]
1170    pub fn get_tz_geojson(&self, timezone_name: &str) -> Option<BoundaryFile> {
1171        self.finder.get_tz_geojson(timezone_name)
1172    }
1173}