[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:
jake 2023-09-11 05:34:24 +01:00 committed by GitHub
parent 8d69f3f289
commit e2a0878e7a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 297 additions and 43 deletions

2
Cargo.lock generated
View file

@ -7296,6 +7296,8 @@ dependencies = [
"chrono",
"image",
"kamadak-exif",
"rand 0.8.5",
"rand_chacha 0.3.1",
"serde",
"serde_json",
"specta",

View file

@ -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

View file

@ -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;

View file

@ -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))
}
}

View file

@ -0,0 +1,5 @@
mod location;
mod pluscodes;
pub use location::MediaLocation;
pub use pluscodes::PlusCode;

View 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))
}
}

View file

@ -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;

View file

@ -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>

View file

@ -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"