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