mirror of
https://github.com/spacedriveapp/spacedrive
synced 2024-07-02 11:13:29 +00:00
[ENG-1068] Plus codes / Open Location Code support (#1324)
* bring in rand deps * SUPER crude pluscode attempt * working pluscodes! * some cleanup * `encodeURIComponent` for URL escaping --------- Co-authored-by: Oscar Beaumont <oscar@otbeaumont.me>
This commit is contained in:
parent
8d69f3f289
commit
e2a0878e7a
2
Cargo.lock
generated
2
Cargo.lock
generated
|
@ -7296,6 +7296,8 @@ dependencies = [
|
|||
"chrono",
|
||||
"image",
|
||||
"kamadak-exif",
|
||||
"rand 0.8.5",
|
||||
"rand_chacha 0.3.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
|
|
|
@ -12,5 +12,7 @@ serde = { version = "1.0.183", features = ["derive"] }
|
|||
serde_json = { version = "1.0.104" }
|
||||
specta = { workspace = true, features = ["chrono"] }
|
||||
chrono = { version = "0.4.26", features = ["serde"] }
|
||||
rand = "0.8.5"
|
||||
rand_chacha = "0.3.1"
|
||||
|
||||
# symphonia crate looks great for audio metadata
|
||||
|
|
|
@ -29,3 +29,28 @@ pub const OFFSET_TAGS: [Tag; 3] = [
|
|||
Tag::OffsetTimeOriginal,
|
||||
Tag::OffsetTimeDigitized,
|
||||
];
|
||||
|
||||
/// The Earth's maximum latitude (can also be negative, depending on if you're North or South of the Equator).
|
||||
pub const LAT_MAX_POS: f64 = 90_f64;
|
||||
|
||||
/// The Earth's maximum longitude (can also be negative depending on if you're East or West of the Prime meridian).
|
||||
///
|
||||
/// The negative value of this is known as the anti-meridian, and when combined they make a 360 degree circle around the Earth.
|
||||
pub const LONG_MAX_POS: f64 = 180_f64;
|
||||
|
||||
/// 125km. This is the Kármán line + a 25km additional padding just to be safe.
|
||||
pub const ALT_MAX_HEIGHT: i32 = 125_000_i32;
|
||||
|
||||
/// -1km. This should be adequate for even the Dead Sea on the Israeli border,
|
||||
/// the lowest point on land (and much deeper).
|
||||
pub const ALT_MIN_HEIGHT: i32 = -1000_i32;
|
||||
|
||||
/// The maximum degrees that a direction can be (as a bearing, starting from 0 degrees)
|
||||
pub const DIRECTION_MAX: i32 = 360;
|
||||
|
||||
pub const PLUSCODE_DIGITS: [char; 20] = [
|
||||
'2', '3', '4', '5', '6', '7', '8', '9', 'C', 'F', 'G', 'H', 'J', 'M', 'P', 'Q', 'R', 'V', 'W',
|
||||
'X',
|
||||
];
|
||||
|
||||
pub const PLUSCODE_GRID_SIZE: f64 = 20.0;
|
||||
|
|
|
@ -1,31 +1,28 @@
|
|||
use super::{
|
||||
consts::{DECIMAL_SF, DMS_DIVISION},
|
||||
ExifReader,
|
||||
use crate::{
|
||||
image::{
|
||||
consts::{
|
||||
ALT_MAX_HEIGHT, ALT_MIN_HEIGHT, DECIMAL_SF, DIRECTION_MAX, DMS_DIVISION, LAT_MAX_POS,
|
||||
LONG_MAX_POS,
|
||||
},
|
||||
ExifReader, PlusCode,
|
||||
},
|
||||
Error, Result,
|
||||
};
|
||||
use crate::{Error, Result};
|
||||
use exif::Tag;
|
||||
use std::{fmt::Display, ops::Neg};
|
||||
use rand::{Rng, SeedableRng};
|
||||
use rand_chacha::ChaCha20Rng;
|
||||
use std::ops::Neg;
|
||||
|
||||
#[derive(Default, Clone, PartialEq, Debug, serde::Serialize, serde::Deserialize, specta::Type)]
|
||||
pub struct MediaLocation {
|
||||
latitude: f64,
|
||||
longitude: f64,
|
||||
pluscode: PlusCode,
|
||||
altitude: Option<i32>,
|
||||
direction: Option<i32>, // the direction that the image was taken in, as a bearing (should always be <= 0 && <= 360)
|
||||
}
|
||||
|
||||
const LAT_MAX_POS: f64 = 90_f64;
|
||||
const LONG_MAX_POS: f64 = 180_f64;
|
||||
|
||||
impl MediaLocation {
|
||||
/// This is used to clamp and format coordinates. They are rounded to 8 significant figures after the decimal point.
|
||||
///
|
||||
/// `max` must be a positive `f64`, and it should be the maximum distance allowed (e.g. 90 or 180 degrees)
|
||||
#[must_use]
|
||||
fn format_coordinate(v: f64, max: f64) -> f64 {
|
||||
(v.clamp(max.neg(), max) * DECIMAL_SF).round() / DECIMAL_SF
|
||||
}
|
||||
|
||||
/// Create a new [`MediaLocation`] from a latitude and longitude pair.
|
||||
///
|
||||
/// Both of the provided values will be rounded to 8 digits after the decimal point ([`DECIMAL_SF`]),
|
||||
|
@ -41,10 +38,14 @@ impl MediaLocation {
|
|||
pub fn new(lat: f64, long: f64, altitude: Option<i32>, direction: Option<i32>) -> Self {
|
||||
let latitude = Self::format_coordinate(lat, LAT_MAX_POS);
|
||||
let longitude = Self::format_coordinate(long, LONG_MAX_POS);
|
||||
let altitude = altitude.map(Self::format_altitude);
|
||||
let direction = direction.map(Self::format_direction);
|
||||
let pluscode = PlusCode::new(latitude, longitude);
|
||||
|
||||
Self {
|
||||
latitude,
|
||||
longitude,
|
||||
pluscode,
|
||||
altitude,
|
||||
direction,
|
||||
}
|
||||
|
@ -79,9 +80,7 @@ impl MediaLocation {
|
|||
.filter_map(|(item, reference)| {
|
||||
let mut item: String = item.unwrap_or_default();
|
||||
let reference: String = reference.unwrap_or_default();
|
||||
item.retain(|x| {
|
||||
x.is_numeric() || x.is_whitespace() || x == '.' || x == '/' || x == '-'
|
||||
});
|
||||
item.retain(|x| x.is_numeric() || x.is_whitespace() || x == '.');
|
||||
let i = item
|
||||
.split_whitespace()
|
||||
.filter_map(|x| x.parse::<f64>().ok());
|
||||
|
@ -100,15 +99,69 @@ impl MediaLocation {
|
|||
Self::new(
|
||||
Self::format_coordinate(res[0], LAT_MAX_POS),
|
||||
Self::format_coordinate(res[1], LONG_MAX_POS),
|
||||
reader.get_tag(Tag::GPSAltitude),
|
||||
reader.get_tag(Tag::GPSAltitude).map(Self::format_altitude),
|
||||
reader
|
||||
.get_tag(Tag::GPSImgDirection)
|
||||
.map(|x: i32| x.clamp(0, 360)),
|
||||
.map(Self::format_direction),
|
||||
)
|
||||
})
|
||||
.ok_or(Error::MediaLocationParse)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn generate() -> Self {
|
||||
let mut rng = ChaCha20Rng::from_entropy();
|
||||
let latitude = rng.gen_range(-LAT_MAX_POS..=LAT_MAX_POS);
|
||||
let longitude = rng.gen_range(-LONG_MAX_POS..=LONG_MAX_POS);
|
||||
|
||||
let pluscode = PlusCode::new(latitude, longitude);
|
||||
|
||||
let altitude = Some(rng.gen_range(ALT_MIN_HEIGHT..=ALT_MAX_HEIGHT));
|
||||
let direction = Some(rng.gen_range(0..=DIRECTION_MAX));
|
||||
|
||||
Self {
|
||||
latitude,
|
||||
longitude,
|
||||
pluscode,
|
||||
altitude,
|
||||
direction,
|
||||
}
|
||||
}
|
||||
|
||||
/// This returns the contained coordinates as `(latitude, longitude)`
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use sd_media_metadata::image::MediaLocation;
|
||||
///
|
||||
/// let mut home = MediaLocation::new(38.89767633, -7.36560353, Some(32), Some(20));
|
||||
/// assert_eq!(home.coordinates(), (38.89767633, -7.36560353));
|
||||
/// ```
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub const fn coordinates(&self) -> (f64, f64) {
|
||||
(self.latitude, self.longitude)
|
||||
}
|
||||
|
||||
/// This returns the contained Plus Code/Open Location Code
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use sd_media_metadata::image::MediaLocation;
|
||||
///
|
||||
/// let mut home = MediaLocation::new(38.89767633, -7.36560353, Some(32), Some(20));
|
||||
/// assert_eq!(home.pluscode().to_string(), "894HFGG5+82".to_string());
|
||||
/// ```
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn pluscode(&self) -> PlusCode {
|
||||
self.pluscode.clone()
|
||||
}
|
||||
|
||||
/// This also re-generates the Plus Code for your coordinates
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
|
@ -117,10 +170,14 @@ impl MediaLocation {
|
|||
/// let mut home = MediaLocation::new(38.89767633, -7.36560353, Some(32), Some(20));
|
||||
/// home.update_latitude(60_f64);
|
||||
/// ```
|
||||
#[inline]
|
||||
pub fn update_latitude(&mut self, lat: f64) {
|
||||
self.latitude = Self::format_coordinate(lat, LAT_MAX_POS);
|
||||
self.pluscode = PlusCode::new(self.latitude, self.longitude);
|
||||
}
|
||||
|
||||
/// This also re-generates the Plus Code for your coordinates
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
|
@ -129,8 +186,10 @@ impl MediaLocation {
|
|||
/// let mut home = MediaLocation::new(38.89767633, -7.36560353, Some(32), Some(20));
|
||||
/// home.update_longitude(20_f64);
|
||||
/// ```
|
||||
#[inline]
|
||||
pub fn update_longitude(&mut self, long: f64) {
|
||||
self.longitude = Self::format_coordinate(long, LONG_MAX_POS);
|
||||
self.pluscode = PlusCode::new(self.latitude, self.longitude);
|
||||
}
|
||||
|
||||
/// # Examples
|
||||
|
@ -141,8 +200,9 @@ impl MediaLocation {
|
|||
/// let mut home = MediaLocation::new(38.89767633, -7.36560353, Some(32), Some(20));
|
||||
/// home.update_altitude(20);
|
||||
/// ```
|
||||
#[inline]
|
||||
pub fn update_altitude(&mut self, altitude: i32) {
|
||||
self.altitude = Some(altitude);
|
||||
self.altitude = Some(Self::format_altitude(altitude));
|
||||
}
|
||||
|
||||
/// # Examples
|
||||
|
@ -153,8 +213,32 @@ impl MediaLocation {
|
|||
/// let mut home = MediaLocation::new(38.89767633, -7.36560353, Some(32), Some(20));
|
||||
/// home.update_direction(233);
|
||||
/// ```
|
||||
pub fn update_direction(&mut self, bearing: i32) {
|
||||
self.direction = Some(bearing.clamp(0, 360));
|
||||
#[inline]
|
||||
pub fn update_direction(&mut self, direction: i32) {
|
||||
self.direction = Some(Self::format_direction(direction));
|
||||
}
|
||||
|
||||
/// This is used to clamp and format coordinates. They are rounded to 8 significant figures after the decimal point.
|
||||
///
|
||||
/// `max` must positive, and it should be the maximum distance allowed (e.g. 180 degrees)
|
||||
#[inline]
|
||||
#[must_use]
|
||||
fn format_coordinate(v: f64, max: f64) -> f64 {
|
||||
(v.clamp(max.neg(), max) * DECIMAL_SF).round() / DECIMAL_SF
|
||||
}
|
||||
|
||||
/// This is used to clamp altitudes to appropriate values.
|
||||
#[inline]
|
||||
#[must_use]
|
||||
fn format_altitude(v: i32) -> i32 {
|
||||
v.clamp(ALT_MIN_HEIGHT, ALT_MAX_HEIGHT)
|
||||
}
|
||||
|
||||
/// This is used to ensure an image direction/bearing is a valid bearing (anywhere from 0-360 degrees).
|
||||
#[inline]
|
||||
#[must_use]
|
||||
fn format_direction(v: i32) -> i32 {
|
||||
v.clamp(0, DIRECTION_MAX)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -169,13 +253,12 @@ impl TryFrom<String> for MediaLocation {
|
|||
/// use sd_media_metadata::image::MediaLocation;
|
||||
///
|
||||
/// let s = String::from("32.47583923, -28.49238495");
|
||||
/// MediaLocation::try_from(s).unwrap();
|
||||
///
|
||||
/// let location = MediaLocation::try_from(s).unwrap();
|
||||
/// assert_eq!(location.to_string(), "32.47583923, -28.49238495".to_string());
|
||||
/// ```
|
||||
fn try_from(value: String) -> std::result::Result<Self, Self::Error> {
|
||||
let iter = value
|
||||
.split_terminator(", ")
|
||||
.filter_map(|x| x.parse::<f64>().ok());
|
||||
fn try_from(mut value: String) -> std::result::Result<Self, Self::Error> {
|
||||
value.retain(|c| !c.is_whitespace() || c.is_numeric() || c == '-' || c == '.');
|
||||
let iter = value.split(',').filter_map(|x| x.parse::<f64>().ok());
|
||||
if iter.clone().count() == 2 {
|
||||
let items = iter.collect::<Vec<_>>();
|
||||
Ok(Self::new(
|
||||
|
@ -189,9 +272,3 @@ impl TryFrom<String> for MediaLocation {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for MediaLocation {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_fmt(format_args!("{}, {}", self.latitude, self.longitude))
|
||||
}
|
||||
}
|
5
crates/media-metadata/src/image/geographic/mod.rs
Normal file
5
crates/media-metadata/src/image/geographic/mod.rs
Normal file
|
@ -0,0 +1,5 @@
|
|||
mod location;
|
||||
mod pluscodes;
|
||||
|
||||
pub use location::MediaLocation;
|
||||
pub use pluscodes::PlusCode;
|
116
crates/media-metadata/src/image/geographic/pluscodes.rs
Normal file
116
crates/media-metadata/src/image/geographic/pluscodes.rs
Normal file
|
@ -0,0 +1,116 @@
|
|||
use crate::{
|
||||
image::consts::{PLUSCODE_DIGITS, PLUSCODE_GRID_SIZE},
|
||||
Error,
|
||||
};
|
||||
use std::{
|
||||
fmt::Display,
|
||||
ops::{DivAssign, SubAssign},
|
||||
};
|
||||
|
||||
#[derive(
|
||||
Default, Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize, specta::Type,
|
||||
)]
|
||||
pub struct PlusCode(String);
|
||||
|
||||
impl Display for PlusCode {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
struct PlusCodeState {
|
||||
coord_state: f64,
|
||||
grid_size: f64,
|
||||
result: [char; 5],
|
||||
}
|
||||
|
||||
impl PlusCodeState {
|
||||
#[inline]
|
||||
#[must_use]
|
||||
fn new(coord_state: f64) -> Self {
|
||||
Self {
|
||||
coord_state,
|
||||
grid_size: PLUSCODE_GRID_SIZE,
|
||||
result: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[must_use]
|
||||
fn iterate(mut self, x: f64) -> Self {
|
||||
self.coord_state.sub_assign(x * self.grid_size);
|
||||
self.grid_size.div_assign(PLUSCODE_GRID_SIZE); // this shrinks on each iteration
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl PlusCode {
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn new(lat: f64, long: f64) -> Self {
|
||||
let mut output = Self::encode_coordinates(Self::normalize_lat(lat))
|
||||
.into_iter()
|
||||
.zip(Self::encode_coordinates(Self::normalize_long(long)))
|
||||
.flat_map(<[char; 2]>::from)
|
||||
.collect::<String>();
|
||||
output.insert(8, '+');
|
||||
|
||||
Self(output)
|
||||
}
|
||||
|
||||
#[allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::as_conversions
|
||||
)]
|
||||
#[inline]
|
||||
#[must_use]
|
||||
fn encode_coordinates(coordinates: f64) -> [char; 5] {
|
||||
(0..5)
|
||||
.fold(PlusCodeState::new(coordinates), |mut pcs, i| {
|
||||
let x = (pcs.coord_state / pcs.grid_size).floor();
|
||||
pcs.result[i] = PLUSCODE_DIGITS[x as usize];
|
||||
pcs.iterate(x)
|
||||
})
|
||||
.result
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[must_use]
|
||||
fn normalize_lat(lat: f64) -> f64 {
|
||||
if 180.0 < (if 0.0 > lat + 90.0 { 0.0 } else { lat + 90.0 }) {
|
||||
180.0
|
||||
} else {
|
||||
lat + 90.0
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[must_use]
|
||||
fn normalize_long(long: f64) -> f64 {
|
||||
if (long + 180.0) > 360.0 {
|
||||
return long - 180.0;
|
||||
}
|
||||
long + 180.0
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for PlusCode {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(mut value: String) -> Result<Self, Self::Error> {
|
||||
value.retain(|c| !c.is_whitespace());
|
||||
|
||||
if value.len() > 11
|
||||
|| value.len() < 2
|
||||
|| (value.len() < 8 && !value.contains('+'))
|
||||
|| PLUSCODE_DIGITS
|
||||
.iter()
|
||||
.any(|x| value.chars().any(|y| y != '+' && x != &y))
|
||||
{
|
||||
return Err(Error::Conversion);
|
||||
}
|
||||
|
||||
Ok(Self(value))
|
||||
}
|
||||
}
|
|
@ -5,7 +5,7 @@ mod composite;
|
|||
mod consts;
|
||||
mod dimensions;
|
||||
mod flash;
|
||||
mod location;
|
||||
mod geographic;
|
||||
mod orientation;
|
||||
mod profile;
|
||||
mod reader;
|
||||
|
@ -15,7 +15,7 @@ pub use composite::Composite;
|
|||
pub use consts::DMS_DIVISION;
|
||||
pub use dimensions::Dimensions;
|
||||
pub use flash::{Flash, FlashMode, FlashValue};
|
||||
pub use location::MediaLocation;
|
||||
pub use geographic::{MediaLocation, PlusCode};
|
||||
pub use orientation::Orientation;
|
||||
pub use profile::ColorProfile;
|
||||
pub use reader::ExifReader;
|
||||
|
|
|
@ -49,9 +49,12 @@ function MediaData({ data }: Props) {
|
|||
<a
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
platform.openLink(
|
||||
`https://www.google.com/maps/search/?api=1&query=${data.location?.latitude}%2c${data.location?.longitude}`
|
||||
);
|
||||
if (data.location)
|
||||
platform.openLink(
|
||||
`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(
|
||||
data.location.latitude
|
||||
)}%2c${encodeURIComponent(data.location.longitude)}`
|
||||
);
|
||||
}}
|
||||
>
|
||||
{formatLocation(data.location, 3)}
|
||||
|
@ -61,6 +64,28 @@ function MediaData({ data }: Props) {
|
|||
)
|
||||
}
|
||||
/>
|
||||
<MetaData
|
||||
label="Plus Code"
|
||||
value={
|
||||
data.location?.pluscode ? (
|
||||
<a
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (data.location)
|
||||
platform.openLink(
|
||||
`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(
|
||||
data.location.pluscode
|
||||
)}`
|
||||
);
|
||||
}}
|
||||
>
|
||||
{data.location?.pluscode}
|
||||
</a>
|
||||
) : (
|
||||
'--'
|
||||
)
|
||||
}
|
||||
/>
|
||||
<MetaData
|
||||
label="Dimensions"
|
||||
value={
|
||||
|
@ -78,7 +103,7 @@ function MediaData({ data }: Props) {
|
|||
<MetaData label="Color profile" value={data.camera_data.color_profile} />
|
||||
<MetaData label="Color space" value={data.camera_data.color_space} />
|
||||
<MetaData label="Flash" value={data.camera_data.flash?.mode} />
|
||||
<MetaData label="Zoom" value={data.camera_data.zoom} />
|
||||
<MetaData label="Zoom" value={data.camera_data.zoom?.toFixed(2)} />
|
||||
<MetaData label="Iso" value={data.camera_data.iso} />
|
||||
<MetaData label="Software" value={data.camera_data.software} />
|
||||
</Accordion>
|
||||
|
|
|
@ -275,7 +275,7 @@ export type MaybeNot<T> = T | { not: T }
|
|||
|
||||
export type MaybeUndefined<T> = null | null | T
|
||||
|
||||
export type MediaLocation = { latitude: number; longitude: number; altitude: number | null; direction: number | null }
|
||||
export type MediaLocation = { latitude: number; longitude: number; pluscode: PlusCode; altitude: number | null; direction: number | null }
|
||||
|
||||
export type MediaMetadata = ({ type: "Image" } & ImageMetadata) | ({ type: "Video" } & VideoMetadata) | ({ type: "Audio" } & AudioMetadata)
|
||||
|
||||
|
@ -346,6 +346,8 @@ export type PeerId = string
|
|||
|
||||
export type PeerMetadata = { name: string; operating_system: OperatingSystem | null; version: string | null; email: string | null; img_url: string | null }
|
||||
|
||||
export type PlusCode = string
|
||||
|
||||
export type RelationOperation = { relation_item: any; relation_group: any; relation: string; data: RelationOperationData }
|
||||
|
||||
export type RelationOperationData = "c" | { u: { field: string; value: any } } | "d"
|
||||
|
|
Loading…
Reference in a new issue