Skip to main content

tzf_rs/
lib.rs

1//! Fast timezone finder for Rust: convert (longitude, latitude) coordinates
2//! to timezone names, offline.
3//!
4//! Version 2 is protobuf-free: the data source is the TZF embedded binary
5//! format (`.tzb`) shipped by [tzf-dist], and two finder mechanisms consume
6//! it:
7//!
8//! - [`DefaultFinder`] — the recommended general-purpose finder. Expands the
9//!   file's geometry into materialized polygons at load time and answers most
10//!   queries from the FUZZY preindex tiles, falling back to exact
11//!   point-in-polygon for boundary cases.
12//! - [`EmbeddedFinder`] — the low-memory finder. Queries the `.tzb` bytes in
13//!   place (no expansion; roughly the file size plus a small open-time index
14//!   of chunk skip blocks and per-group latitude stripes, ~100 KB on lite),
15//!   with the same FUZZY fast path. Queries are slower than [`DefaultFinder`];
16//!   results are identical.
17//!
18//! ```rust
19//! use tzf_rs::DefaultFinder;
20//!
21//! let finder = DefaultFinder::new();
22//! assert_eq!("Asia/Shanghai", finder.get_tz_name(116.3883, 39.9289));
23//! ```
24//!
25//! Creating a finder is expensive — build one and share it (e.g. via
26//! `std::sync::LazyLock`).
27//!
28//! [tzf-dist]: https://github.com/ringsaturn/tzf-dist
29#![cfg_attr(docsrs, feature(doc_cfg))]
30
31/// Compiles and runs every ```` ```rust ```` block in `README.md` as a doctest,
32/// so the README's samples cannot drift from the API. Gated on the features
33/// those samples need; `cargo test --features bundled,export-geojson` (the
34/// `cargo test-all` alias used by `make ci`) covers it.
35#[cfg(all(doctest, feature = "bundled", feature = "export-geojson"))]
36#[doc = include_str!("../README.md")]
37struct ReadmeDoctests;
38
39use std::borrow::Cow;
40use std::f64::consts::PI;
41
42#[cfg(all(feature = "bundled", feature = "full"))]
43compile_error!(
44    "feature `bundled` is mutually exclusive with the git-only data feature \
45     `full`; add `default-features = false` when enabling it"
46);
47
48mod finder;
49mod tzb;
50
51#[cfg(feature = "export-geojson")]
52mod geojson;
53#[cfg(feature = "export-geojson")]
54pub use geojson::{
55    BoundaryFile, FeatureItem, GeometryDefine, MultiPolygonCoordinates, PolygonCoordinates,
56    PropertiesDefine,
57};
58
59pub use tzb::Error;
60
61use finder::{DenseGrid, FuzzyIndex, PolyFinder, assemble_items};
62use tzb::Reader;
63
64#[cfg(feature = "bundled")]
65use tzf_dist::load_lite_tzb;
66#[cfg(all(not(feature = "bundled"), feature = "full"))]
67use tzf_dist_git::load_lite_tzb;
68
69/// The recommended finder: FUZZY preindex fast path over materialized
70/// polygon geometry, loaded from a `.tzb` file.
71///
72/// `get_tz_name` answers from the preindex tile when one covers the point
73/// (the vast majority of queries) and falls back to exact point-in-polygon
74/// otherwise; `get_tz_names` always uses the polygon scan, as the
75/// polygon-exact escape hatch. Files without a FUZZY section get the plain
76/// polygon finder.
77pub struct DefaultFinder {
78    fuzzy: Option<FuzzyIndex>,
79    finder: PolyFinder,
80}
81
82impl DefaultFinder {
83    /// Creates the finder from the bundled tzf-dist lite `.tzb` data.
84    ///
85    /// # Panics
86    ///
87    /// Panics when the embedded dataset is malformed — the release pipeline
88    /// validates it, so this only fires on a broken build.
89    ///
90    /// ```rust
91    /// use tzf_rs::DefaultFinder;
92    /// let finder = DefaultFinder::new();
93    /// assert_eq!("Asia/Shanghai", finder.get_tz_name(116.3883, 39.9289));
94    /// ```
95    #[cfg(any(feature = "bundled", feature = "full"))]
96    #[must_use]
97    pub fn new() -> Self {
98        Self::from_tzb(load_lite_tzb()).expect("tzf-dist lite.tzb is validated at release")
99    }
100
101    /// Creates the finder from the full-precision `.tzb` dataset (~14 MB,
102    /// no topology simplification). Higher fidelity, larger memory footprint.
103    ///
104    /// Requires the `full` feature, which is git-only:
105    /// ```toml
106    /// tzf-rs = { git = "https://github.com/ringsaturn/tzf-rs", features = ["full"], default-features = false }
107    /// ```
108    ///
109    /// # Panics
110    ///
111    /// Panics when the embedded dataset is malformed (release-validated).
112    #[cfg(feature = "full")]
113    #[cfg_attr(docsrs, doc(cfg(feature = "full")))]
114    #[must_use]
115    pub fn new_full() -> Self {
116        Self::from_tzb(tzf_dist_git::load_full_tzb())
117            .expect("tzf-dist full.tzb is validated at release")
118    }
119
120    /// Builds a finder from TZF embedded binary (`.tzb`) bytes by expanding
121    /// the geometry into the materialized polygon engine at load time. `data`
122    /// is only read during loading. When the file carries a FUZZY section it
123    /// becomes the `get_tz_name` fast path.
124    ///
125    /// # Errors
126    ///
127    /// Returns [`Error`] when the bytes are not a structurally valid
128    /// E-profile (`.tzb`) file.
129    pub fn from_tzb(data: &[u8]) -> Result<Self, Error> {
130        let reader = Reader::open(Cow::Borrowed(data))?;
131        let fuzzy = FuzzyIndex::from_reader(&reader)?;
132        let grid = DenseGrid::from_reader(&reader);
133        let expanded = reader.expand()?;
134        Ok(Self {
135            fuzzy,
136            finder: PolyFinder {
137                items: assemble_items(expanded.names, expanded.polygons),
138                grid,
139                version: expanded.version,
140            },
141        })
142    }
143
144    /// Returns the first matching timezone name, or `""` when no timezone
145    /// covers the point.
146    ///
147    /// ```rust
148    /// use tzf_rs::DefaultFinder;
149    /// let finder = DefaultFinder::new();
150    /// assert_eq!("Asia/Shanghai", finder.get_tz_name(116.3883, 39.9289));
151    /// ```
152    #[must_use]
153    pub fn get_tz_name(&self, lng: f64, lat: f64) -> &str {
154        if let Some(fuzzy) = &self.fuzzy
155            && let Some(idx) = fuzzy.get(lng, lat)
156        {
157            return &self.finder.items[usize::from(idx)].name;
158        }
159        self.finder.get_tz_name(lng, lat)
160    }
161
162    /// Returns all matching timezone names (overlapping areas produce more
163    /// than one), sorted lexicographically. Always polygon-exact.
164    ///
165    /// ```rust
166    /// use tzf_rs::DefaultFinder;
167    /// let finder = DefaultFinder::new();
168    /// println!("{:?}", finder.get_tz_names(116.3883, 39.9289));
169    /// ```
170    #[must_use]
171    pub fn get_tz_names(&self, lng: f64, lat: f64) -> Vec<&str> {
172        self.finder.get_tz_names(lng, lat)
173    }
174
175    /// Returns all timezone names in the dataset.
176    ///
177    /// ```rust
178    /// use tzf_rs::DefaultFinder;
179    /// let finder = DefaultFinder::new();
180    /// println!("{:?}", finder.timezonenames());
181    /// ```
182    #[must_use]
183    pub fn timezonenames(&self) -> Vec<&str> {
184        self.finder.timezonenames()
185    }
186
187    /// Returns the dataset release this finder was built from (e.g. `2026c`).
188    ///
189    /// ```rust
190    /// use tzf_rs::DefaultFinder;
191    /// let finder = DefaultFinder::new();
192    /// println!("{:?}", finder.data_version());
193    /// ```
194    #[must_use]
195    pub fn data_version(&self) -> &str {
196        &self.finder.version
197    }
198
199    /// Converts all timezone boundaries to a GeoJSON FeatureCollection.
200    #[cfg(feature = "export-geojson")]
201    #[cfg_attr(docsrs, doc(cfg(feature = "export-geojson")))]
202    #[must_use]
203    pub fn to_geojson(&self) -> BoundaryFile {
204        geojson::collection(
205            self.finder
206                .items
207                .iter()
208                .map(geojson::feature_from_item)
209                .collect(),
210        )
211    }
212
213    /// Converts one timezone's boundaries to a GeoJSON FeatureCollection.
214    /// Returns `None` when the dataset does not contain the name.
215    #[cfg(feature = "export-geojson")]
216    #[cfg_attr(docsrs, doc(cfg(feature = "export-geojson")))]
217    #[must_use]
218    pub fn get_tz_geojson(&self, timezone_name: &str) -> Option<BoundaryFile> {
219        let features: Vec<_> = self
220            .finder
221            .items
222            .iter()
223            .filter(|item| item.name == timezone_name)
224            .map(geojson::feature_from_item)
225            .collect();
226        if features.is_empty() {
227            None
228        } else {
229            Some(geojson::collection(features))
230        }
231    }
232
233    /// Converts one timezone's FUZZY preindex tiles to a GeoJSON
234    /// FeatureCollection: one Feature whose MultiPolygon holds each tile's
235    /// bounding rectangle — the area where `get_tz_name` answers from the
236    /// preindex fast path instead of exact point-in-polygon. Tiles are
237    /// ordered coarsest zoom first.
238    ///
239    /// Returns `None` when the file carries no FUZZY section, the dataset
240    /// does not contain the name, or no preindex tile names it.
241    #[cfg(feature = "export-geojson")]
242    #[cfg_attr(docsrs, doc(cfg(feature = "export-geojson")))]
243    #[must_use]
244    pub fn get_tz_preindex_geojson(&self, timezone_name: &str) -> Option<BoundaryFile> {
245        let fuzzy = self.fuzzy.as_ref()?;
246        // The same name may map to more than one directory item; a preindex
247        // tile may name any of them.
248        let indices: Vec<u16> = self
249            .finder
250            .items
251            .iter()
252            .enumerate()
253            .filter(|(_, item)| item.name == timezone_name)
254            .filter_map(|(i, _)| u16::try_from(i).ok())
255            .collect();
256        if indices.is_empty() {
257            return None;
258        }
259        let keys = fuzzy.tile_keys_for(&indices);
260        if keys.is_empty() {
261            return None;
262        }
263        Some(geojson::collection(vec![geojson::feature_from_tile_keys(
264            timezone_name.to_string(),
265            &keys,
266        )]))
267    }
268
269    /// Converts the whole FUZZY preindex to a GeoJSON FeatureCollection: one
270    /// Feature per timezone that owns at least one tile, in dataset order; a
271    /// boundary tile appears in every timezone it names. Returns `None` when
272    /// the file carries no FUZZY section.
273    #[cfg(feature = "export-geojson")]
274    #[cfg_attr(docsrs, doc(cfg(feature = "export-geojson")))]
275    #[must_use]
276    pub fn to_preindex_geojson(&self) -> Option<BoundaryFile> {
277        let fuzzy = self.fuzzy.as_ref()?;
278        let mut grouped = fuzzy.tile_keys_grouped();
279        let features = self
280            .finder
281            .items
282            .iter()
283            .enumerate()
284            .filter_map(|(i, item)| {
285                let keys = grouped.remove(&u16::try_from(i).ok()?)?;
286                Some(geojson::feature_from_tile_keys(item.name.clone(), &keys))
287            })
288            .collect();
289        Some(geojson::collection(features))
290    }
291}
292
293#[cfg(any(feature = "bundled", feature = "full"))]
294impl Default for DefaultFinder {
295    fn default() -> Self {
296        Self::new()
297    }
298}
299
300/// The low-memory finder: queries TZF embedded binary (`.tzb`) bytes in
301/// place, without expanding the geometry. Total footprint is roughly the file
302/// itself (the bundled lite data is ~4 MB) plus the timezone names and a
303/// 16-bytes-per-16-chunks skip table built while validating the file at open
304/// (~6 KB for lite, ~20 KB for full at the encoder's default 256-point
305/// chunks).
306///
307/// `get_tz_name` consults the file's FUZZY preindex first and falls back to
308/// the compressed-geometry scan; results match [`DefaultFinder`] over the
309/// same file, only slower (microseconds instead of hundreds of nanoseconds
310/// on boundary queries).
311pub struct EmbeddedFinder {
312    reader: Reader<'static>,
313    names: Vec<String>,
314}
315
316impl EmbeddedFinder {
317    /// Creates the finder over the bundled tzf-dist lite `.tzb` data,
318    /// borrowed in place from the executable's read-only data segment.
319    ///
320    /// # Panics
321    ///
322    /// Panics when the embedded dataset is malformed (release-validated).
323    #[cfg(any(feature = "bundled", feature = "full"))]
324    #[must_use]
325    pub fn new() -> Self {
326        Self::from_tzb(load_lite_tzb()).expect("tzf-dist lite.tzb is validated at release")
327    }
328
329    /// Builds a finder that queries `data` in place. Accepts borrowed
330    /// `&'static [u8]` (e.g. `include_bytes!`) without copying, or an owned
331    /// `Vec<u8>` (e.g. a file read at startup).
332    ///
333    /// # Errors
334    ///
335    /// Returns [`Error`] when the bytes are not a structurally valid
336    /// E-profile (`.tzb`) file. Memory images (`.tzm`) are rejected with
337    /// [`Error::Profile`] — they exist for the Go runtime and offer no
338    /// benefit here.
339    pub fn from_tzb(data: impl Into<Cow<'static, [u8]>>) -> Result<Self, Error> {
340        let reader = Reader::open(data.into())?;
341        let names = (0..reader.timezone_count())
342            .map(|i| reader.name(i).map(str::to_string))
343            .collect::<Result<Vec<_>, _>>()?;
344        Ok(Self { reader, names })
345    }
346
347    /// Returns the first matching timezone name, or `""` when no timezone
348    /// covers the point.
349    ///
350    /// ```rust
351    /// use tzf_rs::EmbeddedFinder;
352    /// let finder = EmbeddedFinder::new();
353    /// assert_eq!("Asia/Shanghai", finder.get_tz_name(116.3883, 39.9289));
354    /// ```
355    #[must_use]
356    pub fn get_tz_name(&self, lng: f64, lat: f64) -> &str {
357        if self.reader.has_fuzzy()
358            && let Ok(Some(idx)) = self.reader.fuzzy_lookup(lng, lat)
359        {
360            return &self.names[idx as usize];
361        }
362        match self.reader.lookup(lng, lat) {
363            Ok(Some(idx)) => &self.names[idx as usize],
364            _ => "",
365        }
366    }
367
368    /// Returns all matching timezone names, sorted lexicographically. Always
369    /// polygon-exact.
370    #[must_use]
371    pub fn get_tz_names(&self, lng: f64, lat: f64) -> Vec<&str> {
372        let mut indices = Vec::with_capacity(self.reader.lookup_buffer_size());
373        if self.reader.lookup_into(lng, lat, &mut indices).is_err() {
374            return Vec::new();
375        }
376        indices
377            .into_iter()
378            .map(|idx| self.names[idx as usize].as_str())
379            .collect()
380    }
381
382    /// Returns all timezone names in the dataset.
383    #[must_use]
384    pub fn timezonenames(&self) -> Vec<&str> {
385        self.names.iter().map(String::as_str).collect()
386    }
387
388    /// Returns the dataset release this finder was built from (e.g. `2026c`).
389    #[must_use]
390    pub fn data_version(&self) -> &str {
391        self.reader.data_version()
392    }
393
394    /// Converts all timezone boundaries to a GeoJSON FeatureCollection.
395    ///
396    /// Unlike [`DefaultFinder`], which exports polygons it already holds,
397    /// this decodes the whole file's geometry on demand — roughly the cost of
398    /// loading an expanded finder. A timezone that fails to decode is
399    /// omitted; use [`get_tz_geojson`] when the error matters.
400    ///
401    /// [`get_tz_geojson`]: EmbeddedFinder::get_tz_geojson
402    #[cfg(feature = "export-geojson")]
403    #[cfg_attr(docsrs, doc(cfg(feature = "export-geojson")))]
404    #[must_use]
405    pub fn to_geojson(&self) -> BoundaryFile {
406        let features = (0..self.names.len())
407            .filter_map(|i| {
408                let polys = self.reader.expand_timezone(i as u32).ok()?;
409                Some(geojson::feature_from_expanded(
410                    self.names[i].clone(),
411                    &polys,
412                ))
413            })
414            .collect();
415        geojson::collection(features)
416    }
417
418    /// Converts one timezone's boundaries to a GeoJSON FeatureCollection,
419    /// decoding only that timezone's rings. Returns `None` when the dataset
420    /// does not contain the name or its geometry fails to decode.
421    #[cfg(feature = "export-geojson")]
422    #[cfg_attr(docsrs, doc(cfg(feature = "export-geojson")))]
423    #[must_use]
424    pub fn get_tz_geojson(&self, timezone_name: &str) -> Option<BoundaryFile> {
425        let mut features = Vec::new();
426        for (i, name) in self.names.iter().enumerate() {
427            if name != timezone_name {
428                continue;
429            }
430            let polys = self.reader.expand_timezone(i as u32).ok()?;
431            features.push(geojson::feature_from_expanded(name.clone(), &polys));
432        }
433        if features.is_empty() {
434            None
435        } else {
436            Some(geojson::collection(features))
437        }
438    }
439
440    /// Converts one timezone's FUZZY preindex tiles to a GeoJSON
441    /// FeatureCollection; see [`DefaultFinder::get_tz_preindex_geojson`].
442    /// Results match [`DefaultFinder`] over the same file.
443    #[cfg(feature = "export-geojson")]
444    #[cfg_attr(docsrs, doc(cfg(feature = "export-geojson")))]
445    #[must_use]
446    pub fn get_tz_preindex_geojson(&self, timezone_name: &str) -> Option<BoundaryFile> {
447        if !self.reader.has_fuzzy() {
448            return None;
449        }
450        let indices: Vec<u16> = self
451            .names
452            .iter()
453            .enumerate()
454            .filter(|(_, name)| name.as_str() == timezone_name)
455            .filter_map(|(i, _)| u16::try_from(i).ok())
456            .collect();
457        if indices.is_empty() {
458            return None;
459        }
460        // The FUZZY key array is stored sorted, so the filtered keys keep the
461        // coarsest-zoom-first order DefaultFinder produces by sorting.
462        let entries = self.reader.fuzzy_entries().ok()?;
463        let keys: Vec<u64> = entries
464            .iter()
465            .filter(|(_, idxs)| idxs.iter().any(|i| indices.contains(i)))
466            .map(|(key, _)| *key)
467            .collect();
468        if keys.is_empty() {
469            return None;
470        }
471        Some(geojson::collection(vec![geojson::feature_from_tile_keys(
472            timezone_name.to_string(),
473            &keys,
474        )]))
475    }
476
477    /// Converts the whole FUZZY preindex to a GeoJSON FeatureCollection; see
478    /// [`DefaultFinder::to_preindex_geojson`]. Results match
479    /// [`DefaultFinder`] over the same file.
480    #[cfg(feature = "export-geojson")]
481    #[cfg_attr(docsrs, doc(cfg(feature = "export-geojson")))]
482    #[must_use]
483    pub fn to_preindex_geojson(&self) -> Option<BoundaryFile> {
484        if !self.reader.has_fuzzy() {
485            return None;
486        }
487        let entries = self.reader.fuzzy_entries().ok()?;
488        let mut grouped: std::collections::HashMap<u16, Vec<u64>> =
489            std::collections::HashMap::new();
490        for (key, idxs) in &entries {
491            for &idx in idxs {
492                grouped.entry(idx).or_default().push(*key);
493            }
494        }
495        let features = (0..self.names.len())
496            .filter_map(|i| {
497                let keys = grouped.remove(&u16::try_from(i).ok()?)?;
498                Some(geojson::feature_from_tile_keys(
499                    self.names[i].clone(),
500                    &keys,
501                ))
502            })
503            .collect();
504        Some(geojson::collection(features))
505    }
506}
507
508#[cfg(any(feature = "bundled", feature = "full"))]
509impl Default for EmbeddedFinder {
510    fn default() -> Self {
511        Self::new()
512    }
513}
514
515/// deg2num is used to convert longitude, latitude to [Slippy map tilenames]
516/// under specific zoom level.
517///
518/// [Slippy map tilenames]: https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames
519///
520/// Example:
521///
522/// ```rust
523/// use tzf_rs::deg2num;
524/// let ret = deg2num(116.3883, 39.9289, 7);
525/// assert_eq!((105, 48), ret);
526/// ```
527#[must_use]
528#[allow(
529    clippy::cast_precision_loss,
530    clippy::cast_possible_truncation,
531    clippy::similar_names
532)]
533pub fn deg2num(lng: f64, lat: f64, zoom: i64) -> (i64, i64) {
534    let n = (1i64 << zoom) as f64;
535    let lat_rad = lat.to_radians();
536    let xtile = (lng / 360.0 + 0.5) * n;
537    let ytile = (1.0 - lat_rad.tan().asinh() / PI) / 2.0 * n;
538
539    // Possible precision loss here
540    (xtile as i64, ytile as i64)
541}