diff --git a/.env b/.env index d7e8024..0402c00 100644 --- a/.env +++ b/.env @@ -1 +1 @@ -API_VERSION=1.0.0 +API_VERSION=1.1.0 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a1f2c64..2a399b6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -46,6 +46,9 @@ jobs: needs: [changes, set-up-scp] if: ${{ needs.changes.outputs.frontend == 'true' }} + env: + LEAFLET_ACCESS_TOKEN: ${{ secrets.LEAFLET_ACCESS_TOKEN }} + steps: - name: Checkout repository uses: actions/checkout@v4 diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index b3299fd..7386ef5 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -24,6 +24,9 @@ jobs: clippy: runs-on: ubuntu-latest + env: + LEAFLET_ACCESS_TOKEN: ${{ secrets.LEAFLET_ACCESS_TOKEN }} + steps: - uses: actions/checkout@v4 diff --git a/README.md b/README.md index 8e910d7..aebd052 100644 --- a/README.md +++ b/README.md @@ -15,27 +15,61 @@ [![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/JosephLai241/stacc/prettier.yml?logo=prettier&label=Prettier)](https://github.com/JosephLai241/stacc/actions/workflows/prettier.yml) [![Docker Image Version (latest semver)](https://img.shields.io/docker/v/jlai241/stacc-api?logo=docker&label=Docker%20version)](https://hub.docker.com/repository/docker/jlai241/stacc-api/general) +# 👉👉👉 [josephlai.dev][josephlai.dev] 👈👈👈 + # Table of Contents - [What Is This?](#what-is-this) +- [Where Does the Violence Data Come From?](#where-does-the-violence-data-come-from) - [The Stack](#the-stack) # What Is This? -This is my full-stack portfolio site written entirely in Rust to prove the haters wrong -- Rust _is_ production ready. +This is my full-stack portfolio site written entirely in Rust to prove the haters wrong -- Rust _is_ production ready. This is both a place to blog about computer science and a form of artistic expression. + +![crt tv gif][crt tv gif] + +I implemented a retro, CRT TV theme. The background GIF changes on each page refresh and a static GIF loads between each GIF change, simulating flipping through TV channels. All GIFs are miscellaneous screen captures from many different animes -- some are mellow, while others are packed with action. There are many more GIFs I would have liked to include in the rotation, but I have restricted the GIFs to anime to keep consistent with a single theme. This list of GIFs will continue to grow over the years as I find more GIFs that suit the aesthetics of the site. Enjoy cycling through the GIFs! + +The site consists of many little easter eggs, including but not limited to: + +- A dynamic GIF background that changes on each page refresh. +- 404 page stories about a digital nomad getting lost, generated by ChatGPT. +- [Click around and find out] + +# Where Does the Violence Data Come From? + +My site pulls data from the City of Chicago's Socrata API. Here are links to the documentation: + +- [Violence Reduction - Shotspotter Alerts][violence reduction - shotspotter alerts] +- [Violence Reduction - Victims of Homicides and Non-Fatal Shootings][violence reduction - victims of homicides and non-fatal shootings] -I thought it would be cool to implement a dynamic background for the site. A new background GIF is loaded each time you refresh the homepage. This list of GIFs will continue to grow over the years as I find more GIFs that embody the right vibes. Enjoy cycling through the GIFs! +The City of Chicago's Uniform Crime Reporting (UCR) codes and descriptions may be found [at this page][city of chicago ucr codes]. # The Stack This project uses the following stack: -| | | -| -------- | ------------------------------------------------------------------------------------------------------------------------- | -| Frontend | [`Yew`][yew] | -| Backend | [`Actix Web`][actix web] | -| Database | [![MongoDB Badge](https://img.shields.io/badge/MongoDB-4EA94B?style=for-the-badge&logo=mongodb&logoColor=white)][mongodb] | +### Frontend + +[`Yew`][yew] + +### API + +[`Actix Web`][actix web] + +[![docker badge](https://img.shields.io/badge/docker-2ca5e0?style=for-the-badge&logo=docker&logocolor=white)][docker] + +### Database + +[![MongoDB Badge](https://img.shields.io/badge/MongoDB-4EA94B?style=for-the-badge&logo=mongodb&logoColor=white)][mongodb] -[yew]: https://yew.rs/ [actix web]: https://actix.rs/ +[city of chicago ucr codes]: https://gis.chicagopolice.org/pages/crime_details +[crt tv gif]: https://i.imgur.com/8F4N34p.gif +[docker]: https://www.docker.com/ +[josephlai.dev]: https://josephlai.dev/ [mongodb]: https://www.mongodb.com/ +[violence reduction - shotspotter alerts]: https://dev.socrata.com/foundry/data.cityofchicago.org/3h7q-7mdb +[violence reduction - victims of homicides and non-fatal shootings]: https://dev.socrata.com/foundry/data.cityofchicago.org/gumc-mgzr +[yew]: https://yew.rs/ diff --git a/api/Cargo.toml b/api/Cargo.toml index 46f9e87..ba3f4fd 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -2,7 +2,7 @@ authors = ["Joseph Lai"] edition = "2021" name = "api" -version = "1.0.0" +version = "1.1.0" [[bin]] name = "api" @@ -23,5 +23,6 @@ mongodb = "2.5.0" rand = "0.8.5" reqwest = { version = "0.11.18", features = ["json"] } serde = { version = "1.0.163", features = ["derive"] } +serde_json = "1.0.111" thiserror = "1.0.40" tokio = { version = "1.28.2", features = ["full"] } diff --git a/api/src/errors.rs b/api/src/errors.rs index dd9b893..9096ee6 100644 --- a/api/src/errors.rs +++ b/api/src/errors.rs @@ -13,6 +13,10 @@ use crate::models::data::Response; /// Contains all error variants for errors that may be raised by Actix Web endpoints. #[derive(Debug, Display, derive_more::Error)] pub enum StaccResponseError { + /// Something fucked up when pinging the Chicago map APIs. + #[display(fmt = "Chicago API error: {error}")] + ChicagoAPIError { error: String }, + /// A generic error variant for MongoDB. #[display(fmt = "MongoDB error: {error}")] MongoDBError { error: String }, @@ -34,6 +38,9 @@ impl ResponseError for StaccResponseError { fn status_code(&self) -> StatusCode { match *self { + StaccResponseError::ChicagoAPIError { .. } => { + StatusCode::from_u16(500).unwrap_or(StatusCode::BAD_REQUEST) + } StaccResponseError::MongoDBError { .. } => { StatusCode::from_u16(500).unwrap_or(StatusCode::BAD_REQUEST) } @@ -57,4 +64,8 @@ pub enum StaccError { /// Something fucked up while making a request with `reqwest`. #[error("Reqwest error: {0}")] Reqwest(#[from] reqwest::Error), + + /// Something fucked up with `serde_json`. + #[error("Serde JSON error: {0}")] + SerdeJSONError(#[from] serde_json::Error), } diff --git a/api/src/main.rs b/api/src/main.rs index 8c081c5..61c7773 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -1,4 +1,5 @@ //! The API for the `stacc`. +#![allow(clippy::enum_variant_names)] use std::env; @@ -49,6 +50,7 @@ async fn main() { .app_data(mongo.clone()) .service( web::scope("api") + .service(routes::misc::chiraq) .service(routes::misc::get_background_gif) .service(routes::misc::story) .service( diff --git a/api/src/models/data.rs b/api/src/models/data.rs index 06caff2..f801f43 100644 --- a/api/src/models/data.rs +++ b/api/src/models/data.rs @@ -1,6 +1,7 @@ //! Contains models for miscellaneous data. use serde::{Deserialize, Serialize}; +use serde_json::Value; /// Contains the Imgur link to the background GIF. #[derive(Debug, Deserialize, Serialize)] @@ -9,6 +10,15 @@ pub struct BackgroundGIF { pub link: String, } +/// Contains JSON data returned from Chicago map-related APIs. +#[derive(Debug, Deserialize, Serialize)] +pub struct ChicagoMapData { + /// Data returned from the Shotspotter Alert API endpoint. + pub shotspotter_data: Value, + /// Data returned from the Victims of Homicides and Non-Fatal Shootings API endpoint. + pub violence_data: Value, +} + /// Contains the story for the 404 page. #[derive(Debug, Deserialize, Serialize)] pub struct Story { diff --git a/api/src/routes/misc.rs b/api/src/routes/misc.rs index 0df5948..277185b 100644 --- a/api/src/routes/misc.rs +++ b/api/src/routes/misc.rs @@ -18,7 +18,7 @@ use crate::{ errors::StaccResponseError, middleware, models::data::{BackgroundGIF, Story}, - utils::{environment::EnvironmentVariables, mongo::Mongo}, + utils::{chicago::get_vhnfs_shotspotter_data, environment::EnvironmentVariables, mongo::Mongo}, }; lazy_static! { @@ -28,6 +28,11 @@ lazy_static! { static ref FALLBACK_GIF: &'static str = "https://imgur.com/FgJDNsx.gif"; /// The default fallback story if selecting a random story from MongoDB fails. static ref FALLBACK_STORY: &'static str = "If you don’t like the road you’re walking, pave another one. Except for this one."; + + /// The API endpoint for the ShotSpotter Alerts data. + static ref SHOTSPOTTER_ENDPOINT: &'static str = "https://data.cityofchicago.org/resource/3h7q-7mdb.json"; + /// The API endpoint for the Victims of Homicides and Non-Fatal Shootings data. + static ref VHNFS_ENDPOINT: &'static str = "https://data.cityofchicago.org/resource/gumc-mgzr.json"; } /// Create a cookie that stores the background GIF link. Returns `Some(Cookie)` if able to retrieve @@ -98,6 +103,24 @@ pub async fn get_background_gif( } } +/// Get the data that will be plotted on the Chicago map on the `violence` page. +#[get("/chiraq")] +pub async fn chiraq( + mongo: Data, + request: HttpRequest, +) -> Result { + if let Err(error) = middleware::log_visitor_data(&mongo, &request).await { + error!("{}", error); + } + + get_vhnfs_shotspotter_data() + .await + .map(|chicago_map_data| HttpResponse::Ok().json(chicago_map_data)) + .map_err(|error| StaccResponseError::ChicagoAPIError { + error: error.to_string(), + }) +} + /// Get a 404 page story by choosing a random story stored in the stories collection. #[get("/story")] pub async fn story( diff --git a/api/src/utils/checks.rs b/api/src/utils/checks.rs index d40bbb6..c0606e6 100644 --- a/api/src/utils/checks.rs +++ b/api/src/utils/checks.rs @@ -23,6 +23,7 @@ lazy_static! { "STACC_POSTS_COLLECTION_NAME", "STACC_STORIES_COLLECTION_NAME", "STACC_VISITORS_COLLECTION_NAME", + "SOCRATA_APP_TOKEN" ]; } diff --git a/api/src/utils/chicago.rs b/api/src/utils/chicago.rs new file mode 100644 index 0000000..336e5d8 --- /dev/null +++ b/api/src/utils/chicago.rs @@ -0,0 +1,54 @@ +//! Contains miscellaneous utilities for Chicago-related functionality. + +use lazy_static::lazy_static; +use reqwest::Client; +use serde_json::Value; + +use crate::{errors::StaccError, models::data::ChicagoMapData}; + +use super::environment::EnvironmentVariables; + +lazy_static! { + /// A `reqwest` `Client` that is reused for Chicago API requests. + static ref REQUEST_CLIENT: Client = Client::new(); + /// The API endpoint for the ShotSpotter Alerts data. + static ref SHOTSPOTTER_ENDPOINT: &'static str = "https://data.cityofchicago.org/resource/3h7q-7mdb.json"; + /// The API endpoint for the Victims of Homicides and Non-Fatal Shootings data. + static ref VHNFS_ENDPOINT: &'static str = "https://data.cityofchicago.org/resource/gumc-mgzr.json"; +} + +/// Get data for Victims of Homicides and Non-Fatal Shootings and Shotspotter Alert data from the +/// Chicago APIs. +pub async fn get_vhnfs_shotspotter_data() -> Result { + let violence_data: Value = serde_json::from_str( + &REQUEST_CLIENT + .get(VHNFS_ENDPOINT.to_string()) + .header( + "X-App-Token", + EnvironmentVariables::SocrataAppToken.env_var()?, + ) + .send() + .await? + .text() + .await?, + )?; + let shotspotter_data: Value = serde_json::from_str( + &REQUEST_CLIENT + .get(SHOTSPOTTER_ENDPOINT.to_string()) + .header( + "X-App-Token", + EnvironmentVariables::SocrataAppToken.env_var()?, + ) + .send() + .await? + .text() + .await?, + )?; + + let chicago_map_data = ChicagoMapData { + shotspotter_data, + violence_data, + }; + + Ok(chicago_map_data) +} diff --git a/api/src/utils/environment.rs b/api/src/utils/environment.rs index bd213f6..5e9bd0e 100644 --- a/api/src/utils/environment.rs +++ b/api/src/utils/environment.rs @@ -12,6 +12,8 @@ pub enum EnvironmentVariables { MongoDBURI, /// The MongoDB user. MongoDBUser, + /// The Socrata app token for Chicago map-related data. + SocrataAppToken, /// The port number the API runs on. StaccAPIPortNumber, /// The name of the collection that contains all backgrounds. @@ -35,6 +37,7 @@ impl EnvironmentVariables { Self::MongoDBPassword => Ok(env::var("MONGO_PASSWORD")?), Self::MongoDBURI => Ok(env::var("MONGO_URI")?), Self::MongoDBUser => Ok(env::var("MONGO_USER")?), + Self::SocrataAppToken => Ok(env::var("SOCRATA_APP_TOKEN")?), Self::StaccAPIPortNumber => Ok(env::var("STACC_API_PORT_NUMBER")?), Self::StaccBackgroundsCollectionName => { Ok(env::var("STACC_BACKGROUNDS_COLLECTION_NAME")?) diff --git a/api/src/utils/mod.rs b/api/src/utils/mod.rs index 41a8046..26eda8f 100644 --- a/api/src/utils/mod.rs +++ b/api/src/utils/mod.rs @@ -1,5 +1,6 @@ //! Contains miscellaneous utilities for `stacc`. pub mod checks; +pub mod chicago; pub mod environment; pub mod mongo; diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml index 3933092..1d4bdb6 100644 --- a/frontend/Cargo.toml +++ b/frontend/Cargo.toml @@ -2,7 +2,7 @@ authors = ["Joseph Lai"] edition = "2021" name = "frontend" -version = "1.0.1" +version = "1.1.0" [dependencies] chrono = "0.4.24" @@ -12,14 +12,29 @@ gloo-console = "0.2.3" gloo-net = { version = "0.2.6", features = ["http"] } gloo-timers = { version = "0.2.6", features = ["futures"] } gloo-utils = "0.1.6" +hex = "0.4.3" js-sys = "0.3.63" lazy_static = "1.4.0" +leaflet = "0.4.0" pulldown-cmark = "0.9.3" serde = { version = "1.0.163", features = ["derive"] } +serde_json = "1.0.111" +sha2 = "0.10.8" thiserror = "1.0.40" wasm-bindgen = "0.2.86" wasm-bindgen-futures = "0.4.36" wasm-cookies = "0.1.0" -web-sys = { version = "0.3.63", features = ["CssStyleDeclaration", "Document", "HtmlElement", "MutationObserver", "MutationObserverInit", "Node"] } +web-sys = { version = "0.3.63", features = [ + "CssStyleDeclaration", + "Document", + "HtmlElement", + "HtmlInputElement", + "HtmlTableCellElement", + "HtmlTableElement", + "HtmlTableRowElement", + "MutationObserver", + "MutationObserverInit", + "Node", +] } yew = { version = "0.20.0", features = ["csr"] } yew-router = "0.17.0" diff --git a/frontend/index.html b/frontend/index.html index 8ab66ac..fb0cccf 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -51,6 +51,19 @@ rel="stylesheet" /> + + + + for StaccError { + fn from(element: Element) -> Self { + StaccError::ElementError(element) + } +} + +impl From for StaccError { + fn from(html_table_element: HtmlTableElement) -> Self { + StaccError::HtmlTableElementError(html_table_element) + } } impl From for StaccError { fn from(js_value: JsValue) -> Self { - StaccError::SetBackgroundError(js_value) + StaccError::JsValueError(js_value) } } diff --git a/frontend/src/main.rs b/frontend/src/main.rs index 1fd6771..adecbc3 100644 --- a/frontend/src/main.rs +++ b/frontend/src/main.rs @@ -1,16 +1,21 @@ //! `stacc` -- A Rust web frontend. +#![allow(clippy::enum_variant_names)] use lazy_static::lazy_static; use yew::prelude::*; use yew_router::prelude::*; -use pages::{about::About, blog::Blog, not_found::NotFound, post_view::PostView, root::Root}; +use pages::{ + about::About, blog::Blog, not_found::NotFound, post_view::PostView, root::Root, + violence::Violence, +}; use router::Route; mod errors; mod models; mod pages; mod router; +mod traits; mod utils; lazy_static! { @@ -50,6 +55,7 @@ fn switch(route: Route) -> Html { Route::NotFound => html! { }, Route::PostView { post_id } => html! { }, Route::Root => html! { }, + Route::Violence => html! { }, } } diff --git a/frontend/src/models/chicago.rs b/frontend/src/models/chicago.rs new file mode 100644 index 0000000..c6c2979 --- /dev/null +++ b/frontend/src/models/chicago.rs @@ -0,0 +1,566 @@ +//! Contains structs for deserializing responses returned from the Chicago API. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Holds the Chicago ShotSpotter Alert and Victims of Homicide and Non-Fatal Shootings data. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct ChicagoMapData { + /// Data returned from the Shotspotter Alert API endpoint. + pub shotspotter_data: Value, + /// Data returned from the Victims of Homicides and Non-Fatal Shootings API endpoint. + pub violence_data: Value, +} + +impl Default for ChicagoMapData { + fn default() -> Self { + Self { + shotspotter_data: Value::Array(vec![]), + violence_data: Value::Array(vec![]), + } + } +} + +/// Contains cleaned/summarized data returned from the Shotspotter API. +#[derive(Debug)] +pub struct CleanedShotData { + /// Most to least common blocks on which shots were detected (`ShotData.block`). + pub sorted_blocks: HashMap, + /// Most to least common community areas in which shots were detected (`ShotData.community_area`). + pub sorted_community_areas: HashMap, + /// Most to least common dates of occurrence (formatted date extracted from `ShotData.date`). + pub sorted_dates: HashMap, + /// Most to least common incident types (`ShotData.incident_type_description`). + pub sorted_incident_types: HashMap, + /// Most to least common number of rounds fired (`ShotData.rounds`). + pub sorted_rounds: HashMap, + /// Most to least common zip codes in which shots were detected (`ShotData.zip_code`). + pub sorted_zip_codes: HashMap, + /// The earliest to latest dates that have been plotted. + pub time_range: (String, String), +} + +impl CleanedShotData { + /// Create a new instance of `CleanedShotData`. + pub fn new() -> Self { + Self { + sorted_blocks: HashMap::new(), + sorted_community_areas: HashMap::new(), + sorted_dates: HashMap::new(), + sorted_incident_types: HashMap::new(), + sorted_rounds: HashMap::new(), + sorted_zip_codes: HashMap::new(), + time_range: ("".to_string(), "".to_string()), + } + } +} + +/// Contains cleaned/summarized data returned from the violence data. +#[derive(Debug)] +pub struct CleanedViolenceData { + /// Most to least common age ranges (`ViolenceData.age`). + pub sorted_ages: HashMap, + /// Most to least common community areas (`ViolenceData.community_area`). + pub sorted_community_areas: HashMap, + /// Most to least common dates of occurrence (formatted date extracted from + /// `ViolenceData.community_area`). + pub sorted_dates: HashMap, + /// Count of yes or no gun injuries (`ViolenceData.gunshot_injury_i`). + pub sorted_gun_injury_count: HashMap, + /// Most to least common incident types. The keys correspond to the value returned from the + /// match statement which matches the UCR code to its description + /// (`ViolenceData.incident_iucr_cd`). + pub sorted_incident_types: HashMap, + /// Most to least common location descriptions (`ViolenceData.location_description`). + pub sorted_location_descriptions: HashMap, + /// Most to least common victim races (`ViolenceData.race`). + pub sorted_victim_races: HashMap, + /// Most to least common victim sexes (`ViolenceData.sex`). + pub sorted_victim_sexes: HashMap, + /// Most to least common zip codes (`ViolenceData.zip_code`). + pub sorted_zip_codes: HashMap, + /// The earliest to latest dates that have been plotted. + pub time_range: (String, String), +} + +impl CleanedViolenceData { + /// Create a new instance of `CleanedViolenceData`. + pub fn new() -> Self { + Self { + sorted_ages: HashMap::new(), + sorted_community_areas: HashMap::new(), + sorted_dates: HashMap::new(), + sorted_gun_injury_count: HashMap::new(), + sorted_incident_types: HashMap::new(), + sorted_location_descriptions: HashMap::new(), + sorted_victim_races: HashMap::new(), + sorted_victim_sexes: HashMap::new(), + sorted_zip_codes: HashMap::new(), + time_range: ("".to_string(), "".to_string()), + } + } +} + +/// Holds the fields contained in the `location` key. +#[derive(Debug, Deserialize)] +pub struct Location { + /// The coordinates (longitude followed by latitude) at which the event occurred. + pub coordinates: Vec, +} + +/// Holds the most interesting data from the Shotspotter data. +/// Information for these fields reference the Socrata API documentation here: +/// [Socrata Shotspotter API Documentation](https://dev.socrata.com/foundry/data.cityofchicago.org/3h7q-7mdb) +#[derive(Debug, Deserialize)] +pub struct ShotData { + /// The block on which the shots occurred. + pub block: String, + /// The name of the community in which shots were fired. + pub community_area: String, + /// The date (sometimes a rough estimation) on which the shots were fired. + pub date: String, + /// A short description of the incident. + /// Alert types are “Single Gunshot,” “Multiple Gunshot,” and “Gunshot or Firecracker.” + pub incident_type_description: String, + /// The location of the shooting. + pub location: Location, + /// The number of shots/rounds detected. + pub rounds: String, + /// The zip code in which the shots occurred. + pub zip_code: String, +} + +/// Holds the most interesting data from the violence data. +/// Information for these fields reference the Socrata API documentation here: +/// [Socrata Violence API Documentation](https://dev.socrata.com/foundry/data.cityofchicago.org/gumc-mgzr) +#[derive(Debug, Deserialize)] +pub struct ViolenceData { + /// The age range of the victim (ie. `0-19`, `20-29`, `30-39`). + pub age: String, + /// The name of the community in which the violence occurred (ie. `AUBURN GRESHAM`, `AUSTIN`, + /// `BELMONT CRAGIN`). + pub community_area: String, + /// The date (sometimes a rough estimation) on which the violence occurred. + pub date: String, + /// Whether there was a gunshot injury ("YES" or "NO"). + pub gunshot_injury_i: String, + /// Based on the Illinois Uniform Crime Reporting code. This is directly linked to the Primary + /// Type and Description. + pub incident_iucr_cd: String, + /// The name of the incident. + pub incident_primary: String, + /// The location of the shooting. + pub location: Location, + /// A short description of the location at which the violence occurred. + pub location_description: String, + /// An abbreviation of the race of the victim (ie. `API`, `BLK`, `WHI`). + pub race: String, + /// A single letter denoting the sex of the victim (ie. `F`, `M`). + pub sex: String, + /// Crime classification as outlined in the FBI's Uniform Crime Reporting (UCR). See the Chicago + /// Police Department listing of these classifications at: http://gis.chicagopolice.org/clearmapcrimesums/crime_type. + pub victimization_fbi_cd: String, + /// The FBI's text description of the incident. + /// FBI Description connects a text description of the category to FBI Code. + pub victimization_fbi_descr: String, + /// The zip code in which the violence occurred. + pub zip_code: String, +} + +impl ViolenceData { + /// Get the crime description that matches the `self.incident_iucr_cd` code. + /// Codes were found at this [Chicago Police Crime Details page](https://gis.chicagopolice.org/pages/crime_details). + /// + /// Holy fucking shit Vim/Neovim's macros saved me so much fucking time. 🅱️esus 🅱️UCCing 🅱️hrist + /// Neovim is the best. + pub fn get_crime_description(&self) -> String { + match self.incident_iucr_cd.as_str() { + "0110" => "HOMICIDE FIRST DEGREE MURDER".to_string(), + "0130" => "HOMICIDE SECOND DEGREE MURDER".to_string(), + "0141" => "HOMICIDE INVOLUNTARY MANSLAUGHTER".to_string(), + "0142" => "HOMICIDE RECKLESS HOMICIDE".to_string(), + "0261" => "CRIM SEXUAL ASSAULT AGGRAVATED: HANDGUN".to_string(), + "0262" => "CRIM SEXUAL ASSAULT AGGRAVATED: OTHER FIREARM".to_string(), + "0263" => "CRIM SEXUAL ASSAULT AGGRAVATED: KNIFE/CUT INSTR".to_string(), + "0264" => "CRIM SEXUAL ASSAULT AGGRAVATED: OTHER DANG WEAPON".to_string(), + "0265" => "CRIM SEXUAL ASSAULT AGGRAVATED: OTHER".to_string(), + "0266" => "CRIM SEXUAL ASSAULT PREDATORY".to_string(), + "0271" => "CRIM SEXUAL ASSAULT ATTEMPT AGG: HANDGUN".to_string(), + "0272" => "CRIM SEXUAL ASSAULT ATTEMPT AGG: OTHER FIREARM".to_string(), + "0273" => "CRIM SEXUAL ASSAULT ATTEMPT AGG: KNIFE/CUT INSTR".to_string(), + "0274" => "CRIM SEXUAL ASSAULT ATTEMPT AGG: OTHER DANG WEAPON".to_string(), + "0275" => "CRIM SEXUAL ASSAULT ATTEMPT AGG: OTHER".to_string(), + "0281" => "CRIM SEXUAL ASSAULT NON-AGGRAVATED".to_string(), + "0291" => "CRIM SEXUAL ASSAULT ATTEMPT NON-AGGRAVATED".to_string(), + "1753" => "OFFENSE INVOLVING CHILDREN SEX ASSLT OF CHILD BY FAM MBR".to_string(), + "1754" => "OFFENSE INVOLVING CHILDREN AGG SEX ASSLT OF CHILD FAM MBR".to_string(), + "0312" => "ROBBERY ARMED:KNIFE/CUTTING INSTRUMENT".to_string(), + "0313" => "ROBBERY ARMED: OTHER DANGEROUS WEAPON".to_string(), + "031A" => "ROBBERY ARMED: HANDGUN".to_string(), + "031B" => "ROBBERY ARMED: OTHER FIREARM".to_string(), + "0320" => "ROBBERY STRONGARM - NO WEAPON".to_string(), + "0325" => "ROBBERY VEHICULAR HIJACKING".to_string(), + "0326" => "ROBBERY AGGRAVATED VEHICULAR HIJACKING".to_string(), + "0330" => "ROBBERY AGGRAVATED".to_string(), + "0331" => "ROBBERY ATTEMPT: AGGRAVATED".to_string(), + "0334" => "ROBBERY ATTEMPT: ARMED-KNIFE/CUT INSTR".to_string(), + "0337" => "ROBBERY ATTEMPT: ARMED-OTHER DANG WEAP".to_string(), + "033A" => "ROBBERY ATTEMPT: ARMED-HANDGUN".to_string(), + "033B" => "ROBBERY ATTEMPT: ARMED-OTHER FIREARM".to_string(), + "0340" => "ROBBERY ATTEMPT: STRONGARM-NO WEAPON".to_string(), + "051A" => "ASSAULT AGGRAVATED: HANDGUN".to_string(), + "051B" => "ASSAULT AGGRAVATED: OTHER FIREARM".to_string(), + "0520" => "ASSAULT AGGRAVATED:KNIFE/CUTTING INSTR".to_string(), + "0530" => "ASSAULT AGGRAVATED: OTHER DANG WEAPON".to_string(), + "0550" => "ASSAULT AGGRAVATED PO: HANDGUN".to_string(), + "0551" => "ASSAULT AGGRAVATED PO: OTHER FIREARM".to_string(), + "0552" => "ASSAULT AGGRAVATED PO:KNIFE/CUT INSTR".to_string(), + "0553" => "ASSAULT AGGRAVATED PO: OTHER DANG WEAP".to_string(), + "0555" => "ASSAULT AGG PRO.EMP: HANDGUN".to_string(), + "0556" => "ASSAULT AGG PRO.EMP: OTHER FIREARM".to_string(), + "0557" => "ASSAULT AGG PRO.EMP:KNIFE/CUTTING INST".to_string(), + "0558" => "ASSAULT AGG PRO.EMP: OTHER DANG WEAPON".to_string(), + "041A" => "BATTERY AGGRAVATED: HANDGUN".to_string(), + "041B" => "BATTERY AGGRAVATED: OTHER FIREARM".to_string(), + "0420" => "BATTERY AGGRAVATED:KNIFE/CUTTING INSTR".to_string(), + "0430" => "BATTERY AGGRAVATED: OTHER DANG WEAPON".to_string(), + "0450" => "BATTERY AGGRAVATED PO: HANDGUN".to_string(), + "0451" => "BATTERY AGGRAVATED PO: OTHER FIREARM".to_string(), + "0452" => "BATTERY AGGRAVATED PO: KNIFE/CUT INSTR".to_string(), + "0453" => "BATTERY AGGRAVATED PO: OTHER DANG WEAP".to_string(), + "0461" => "BATTERY AGG PO HANDS ETC SERIOUS INJ".to_string(), + "0462" => "BATTERY AGG PRO EMP HANDS SERIOUS INJ".to_string(), + "0479" => "BATTERY AGG: HANDS/FIST/FEET SERIOUS INJURY".to_string(), + "0480" => "BATTERY AGG PRO.EMP: HANDGUN".to_string(), + "0481" => "BATTERY AGG PRO.EMP: OTHER FIREARM".to_string(), + "0482" => "BATTERY AGG PRO.EMP:KNIFE/CUTTING INST".to_string(), + "0483" => "BATTERY AGG PRO.EMP: OTHER DANG WEAPON".to_string(), + "0485" => "BATTERY AGGRAVATED OF A CHILD".to_string(), + "0488" => "BATTERY AGGRAVATED DOMESTIC BATTERY: HANDGUN".to_string(), + "0489" => "BATTERY AGGRAVATED DOMESTIC BATTERY: OTHER FIREARM".to_string(), + "0490" => "RITUALISM AGG RITUAL MUT:HANDGUN".to_string(), + "0491" => "RITUALISM AGG RITUAL MUT:OTHER FIREARM".to_string(), + "0492" => "RITUALISM AGG RITUAL MUT:KNIFE/CUTTING I".to_string(), + "0493" => "RITUALISM AGG RITUAL MUT:OTH DANG WEAPON".to_string(), + "0495" => "BATTERY AGGRAVATED OF A SENIOR CITIZEN".to_string(), + "0496" => "BATTERY AGGRAVATED DOMESTIC BATTERY: KNIFE/CUTTING INST".to_string(), + "0497" => "BATTERY AGGRAVATED DOMESTIC BATTERY: OTHER DANG WEAPON".to_string(), + "0498" => { + "BATTERY AGGRAVATED DOMESTIC BATTERY: HANDS/FIST/FEET SERIOUS INJURY".to_string() + } + "0510" => "RITUALISM AGG RIT MUT: HANDS/FIST/FEET SERIOUS INJURY".to_string(), + "0610" => "BURGLARY FORCIBLE ENTRY".to_string(), + "0620" => "BURGLARY UNLAWFUL ENTRY".to_string(), + "0630" => "BURGLARY ATTEMPT FORCIBLE ENTRY".to_string(), + "0650" => "BURGLARY HOME INVASION".to_string(), + "0810" => "THEFT OVER $300".to_string(), + "0820" => "THEFT $300 AND UNDER".to_string(), + "0840" => "THEFT FINANCIAL ID THEFT: OVER $300".to_string(), + "0841" => "THEFT FINANCIAL ID THEFT:$300 &UNDER".to_string(), + "0842" => "THEFT AGG: FINANCIAL ID THEFT".to_string(), + "0843" => "THEFT ATTEMPT FINANCIAL IDENTITY THEFT".to_string(), + "0850" => "THEFT ATTEMPT THEFT".to_string(), + "0860" => "THEFT RETAIL THEFT".to_string(), + "0865" => "THEFT DELIVERY CONTAINER THEFT".to_string(), + "0870" => "THEFT POCKET-PICKING".to_string(), + "0880" => "THEFT PURSE-SNATCHING".to_string(), + "0890" => "THEFT FROM BUILDING".to_string(), + "0895" => "THEFT FROM COIN-OP MACHINE/DEVICE".to_string(), + "0910" => "MOTOR VEHICLE THEFT AUTOMOBILE".to_string(), + "0915" => "MOTOR VEHICLE THEFT TRUCK, BUS, MOTOR HOME".to_string(), + "0917" => "MOTOR VEHICLE THEFT CYCLE, SCOOTER, BIKE W-VIN".to_string(), + "0918" => "MOTOR VEHICLE THEFT CYCLE, SCOOTER, BIKE NO VIN".to_string(), + "0920" => "MOTOR VEHICLE THEFT ATT: AUTOMOBILE".to_string(), + "0925" => "MOTOR VEHICLE THEFT ATT: TRUCK, BUS, MOTOR HOME".to_string(), + "0927" => "MOTOR VEHICLE THEFT ATTEMPT: CYCLE, SCOOTER, BIKE W-VIN".to_string(), + "0928" => "MOTOR VEHICLE THEFT ATTEMPT: CYCLE, SCOOTER, BIKE NO VIN".to_string(), + "0930" => "MOTOR VEHICLE THEFT THEFT/RECOVERY: AUTOMOBILE".to_string(), + "0935" => "MOTOR VEHICLE THEFT THEFT/RECOVERY: TRUCK,BUS,MHOME".to_string(), + "0937" => "MOTOR VEHICLE THEFT THEFT/RECOVERY: CYCLE, SCOOTER, BIKE W-VIN".to_string(), + "0938" => "MOTOR VEHICLE THEFT THEFT/RECOVERY: CYCLE, SCOOTER, BIKE NO VIN".to_string(), + "0545" => "ASSAULT PRO EMP HANDS NO/MIN INJURY".to_string(), + "0554" => "ASSAULT AGG PO HANDS NO/MIN INJURY".to_string(), + "0560" => "ASSAULT SIMPLE".to_string(), + "0580" => "STALKING SIMPLE".to_string(), + "0581" => "STALKING AGGRAVATED".to_string(), + "0583" => "STALKING CYBERSTALKING".to_string(), + "0440" => "BATTERY AGG: HANDS/FIST/FEET NO/MINOR INJURY".to_string(), + "0454" => "BATTERY AGG PO HANDS NO/MIN INJURY".to_string(), + "0460" => "BATTERY SIMPLE".to_string(), + "0475" => "BATTERY OF UNBORN CHILD".to_string(), + "0484" => "BATTERY PRO EMP HANDS NO/MIN INJURY".to_string(), + "0486" => "BATTERY DOMESTIC BATTERY SIMPLE".to_string(), + "0487" => "BATTERY AGGRAVATED OF A UNBORN CHILD".to_string(), + "0494" => "RITUALISM AGG RIT MUT: HANDS/FIST/FEET NO/MINOR INJURY".to_string(), + "1010" => "ARSON BY EXPLOSIVE".to_string(), + "1020" => "ARSON BY FIRE".to_string(), + "1025" => "ARSON AGGRAVATED".to_string(), + "1090" => "ARSON ATTEMPT ARSON".to_string(), + "1120" => "DECEPTIVE PRACTICE FORGERY".to_string(), + "1121" => "DECEPTIVE PRACTICE COUNTERFEITING DOCUMENT".to_string(), + "1122" => "DECEPTIVE PRACTICE COUNTERFEIT CHECK".to_string(), + "1110" => "DECEPTIVE PRACTICE BOGUS CHECK".to_string(), + "1130" => "DECEPTIVE PRACTICE FRAUD OR CONFIDENCE GAME".to_string(), + "1135" => "DECEPTIVE PRACTICE INSURANCE FRAUD".to_string(), + "1150" => "DECEPTIVE PRACTICE CREDIT CARD FRAUD".to_string(), + "1151" => "DECEPTIVE PRACTICE ILLEGAL POSSESSION CASH CARD".to_string(), + "1152" => "DECEPTIVE PRACTICE ILLEGAL USE CASH CARD".to_string(), + "1160" => "DECEPTIVE PRACTICE ALTER COINS".to_string(), + "1170" => "DECEPTIVE PRACTICE IMPERSONATION".to_string(), + "1185" => "DECEPTIVE PRACTICE DECEPTIVE COLLECTION PRACTICES".to_string(), + "1195" => "DECEPTIVE PRACTICE FINAN EXPLOIT-ELDERLY/DISABLED".to_string(), + "1205" => "DECEPTIVE PRACTICE THEFT BY LESSEE,NON-VEH".to_string(), + "1206" => "DECEPTIVE PRACTICE THEFT BY LESSEE,MOTOR VEH".to_string(), + "1210" => "DECEPTIVE PRACTICE THEFT OF LABOR/SERVICES".to_string(), + "1220" => "DECEPTIVE PRACTICE THEFT OF LOST/MISLAID PROP".to_string(), + "1230" => "DECEPTIVE PRACTICE POSS. KEYS OR DEV.TO COIN MACH".to_string(), + "1235" => "DECEPTIVE PRACTICE UNLAWFUL USE OF RECORDED SOUND".to_string(), + "1240" => "DECEPTIVE PRACTICE UNLAWFUL USE OF A COMPUTER".to_string(), + "1241" => "DECEPTIVE PRACTICE AGGRAVATED COMPUTER TAMPERING".to_string(), + "1242" => "DECEPTIVE PRACTICE COMPUTER FRAUD".to_string(), + "1245" => "DECEPTIVE PRACTICE PAY TV SERVICE OFFENSES".to_string(), + "1255" => "DECEPTIVE PRACTICE UNIDENTIFIABLE RECORDING SOUND".to_string(), + "1260" => "DECEPTIVE PRACTICE LIBRARY THEFT".to_string(), + "1261" => "DECEPTIVE PRACTICE UNAUTHORIZED VIDEOTAPING".to_string(), + "1265" => "CRIMINAL DAMAGE LIBRARY VANDALISM".to_string(), + "1305" => "CRIMINAL DAMAGE CRIMINAL DEFACEMENT".to_string(), + "1140" => "DECEPTIVE PRACTICE EMBEZZLEMENT".to_string(), + "1200" => "DECEPTIVE PRACTICE STOLEN PROP: BUY/RECEIVE/POS.".to_string(), + "1310" => "CRIMINAL DAMAGE TO PROPERTY".to_string(), + "1320" => "CRIMINAL DAMAGE TO VEHICLE".to_string(), + "1340" => "CRIMINAL DAMAGE TO STATE SUP PROP".to_string(), + "1345" => "CRIMINAL DAMAGE TO CITY OF CHICAGO PROPERTY".to_string(), + "1370" => "CRIMINAL DAMAGE TO FIRE FIGHT.APP.EQUIP".to_string(), + "1375" => "CRIMINAL DAMAGE INSTITUTIONAL VANDALISM".to_string(), + "141A" => "WEAPONS VIOLATION UNLAWFUL USE HANDGUN".to_string(), + "141B" => "WEAPONS VIOLATION UNLAWFUL USE OTHER FIREARM".to_string(), + "141C" => "WEAPONS VIOLATION UNLAWFUL USE OTHER DANG WEAPON".to_string(), + "142A" => "WEAPONS VIOLATION UNLAWFUL SALE HANDGUN".to_string(), + "142B" => "WEAPONS VIOLATION UNLAWFUL SALE OTHER FIREARM".to_string(), + "1435" => "WEAPONS VIOLATION POS: FIREARM AT SCHOOL".to_string(), + "143A" => "WEAPONS VIOLATION UNLAWFUL POSS OF HANDGUN".to_string(), + "143B" => "WEAPONS VIOLATION UNLAWFUL POSS OTHER FIREARM".to_string(), + "143C" => "WEAPONS VIOLATION UNLAWFUL POSS AMMUNITION".to_string(), + "1440" => "WEAPONS VIOLATION REGISTER OF SALES BY DEALER".to_string(), + "1450" => "WEAPONS VIOLATION DEFACE IDENT MARKS OF FIREARM".to_string(), + "1460" => "WEAPONS VIOLATION POSS FIREARM/AMMO:NO FOID CARD".to_string(), + "1475" => "WEAPONS VIOLATION SALE OF METAL PIERCING BULLETS".to_string(), + "1476" => "WEAPONS VIOLATION USE OF METAL PIERCING BULLETS".to_string(), + "1477" => "WEAPONS VIOLATION RECKLESS FIREARM DISCHARGE".to_string(), + "2900" => "WEAPONS VIOLATION UNLAWFUL USE/SALE AIR RIFLE".to_string(), + "1505" => "PROSTITUTION CALL OPERATION".to_string(), + "1506" => "PROSTITUTION SOLICIT ON PUBLIC WAY".to_string(), + "1507" => "PROSTITUTION SOLICIT OFF PUBLIC WAY".to_string(), + "1510" => "PROSTITUTION CAB OPERATION".to_string(), + "1511" => "PROSTITUTION IN TAVERN".to_string(), + "1512" => "PROSTITUTION SOLICIT FOR PROSTITUTE".to_string(), + "1513" => "PROSTITUTION SOLICIT FOR BUSINESS".to_string(), + "1515" => "PROSTITUTION PANDERING".to_string(), + "1520" => "PROSTITUTION KEEP PLACE OF PROSTITUTION".to_string(), + "1521" => "PROSTITUTION KEEP PLACE OF JUV PROSTITUTION".to_string(), + "1525" => "PROSTITUTION PATRONIZING A PROSTITUTE".to_string(), + "1526" => "PROSTITUTION PATRONIZE JUVENILE PROSTITUTE".to_string(), + "1530" => "PROSTITUTION PIMPING".to_string(), + "1531" => "PROSTITUTION JUVENILE PIMPING".to_string(), + "1537" => "OFFENSE INVOLVING CHILDREN POS: PORNOGRAPHIC PRINT".to_string(), + "1542" => "OBSCENITY SALE OF OBSCENE MATERIALS".to_string(), + "1544" => "SEX OFFENSE SEXUAL EXPLOITATION OF A CHILD".to_string(), + "1549" => "PROSTITUTION OTHER PROSTITUTION OFFENSE".to_string(), + "1535" => "OBSCENITY OBSCENITY".to_string(), + "1536" => "PUBLIC INDECENCY LICENSED PREMISE".to_string(), + "1540" => "OBSCENITY OBSCENE MATTER".to_string(), + "1541" => "OBSCENITY SALE/DIST OBSCENE MAT TO MINOR".to_string(), + "1562" => "SEX OFFENSE AGG CRIMINAL SEXUAL ABUSE".to_string(), + "1563" => "SEX OFFENSE CRIMINAL SEXUAL ABUSE".to_string(), + "1564" => "SEX OFFENSE CRIMINAL TRANSMISSION OF HIV".to_string(), + "1565" => "SEX OFFENSE INDECENT SOLICITATION/CHILD".to_string(), + "1566" => "SEX OFFENSE INDECENT SOLICITATION/ADULT".to_string(), + "1570" => "SEX OFFENSE PUBLIC INDECENCY".to_string(), + "1572" => "SEX OFFENSE ADULTRY".to_string(), + "1574" => "SEX OFFENSE FORNICATION".to_string(), + "1576" => "SEX OFFENSE BIGAMY".to_string(), + "1578" => "SEX OFFENSE MARRYING A BIGAMIST".to_string(), + "1580" => "SEX OFFENSE SEX RELATION IN FAMILY".to_string(), + "1582" => "OFFENSE INVOLVING CHILDREN CHILD PORNOGRAPHY".to_string(), + "1585" => "SEX OFFENSE OTHER".to_string(), + "1590" => "SEX OFFENSE ATT AGG CRIMINAL SEXUAL ABUSE".to_string(), + "2830" => "OTHER OFFENSE OBSCENE TELEPHONE CALLS".to_string(), + "5004" => "SEX OFFENSE ATT CRIM SEXUAL ABUSE".to_string(), + "1811" => "NARCOTICS POSS: CANNABIS 30GMS OR LESS".to_string(), + "1812" => "NARCOTICS POSS: CANNABIS MORE THAN 30GMS".to_string(), + "1821" => "NARCOTICS MANU/DEL:CANNABIS 10GM OR LESS".to_string(), + "1822" => "NARCOTICS MANU/DEL:CANNABIS OVER 10 GMS".to_string(), + "1840" => "NARCOTICS DELIVER CANNABIS TO PERSON <18".to_string(), + "1850" => "NARCOTICS CANNABIS PLANT".to_string(), + "1860" => "NARCOTICS CALCULATED CANNABIS CONSPIRACY".to_string(), + "1900" => "OTHER NARCOTIC VIOLATION INTOXICATING COMPOUNDS".to_string(), + "2010" => "NARCOTICS MANU/DELIVER:AMPHETAMINES".to_string(), + "2011" => "NARCOTICS MANU/DELIVER:BARBITUATES".to_string(), + "2012" => "NARCOTICS MANU/DELIVER:COCAINE".to_string(), + "2013" => "NARCOTICS MANU/DELIVER: HEROIN(BRN/TAN)".to_string(), + "2014" => "NARCOTICS MANU/DELIVER: HEROIN (WHITE)".to_string(), + "2015" => "NARCOTICS MANU/DELIVER: HALLUCINOGEN".to_string(), + "2016" => "NARCOTICS MANU/DELIVER:PCP".to_string(), + "2017" => "NARCOTICS MANU/DELIVER:CRACK".to_string(), + "2018" => "NARCOTICS MANU/DELIVER:SYNTHETIC DRUGS".to_string(), + "2019" => "NARCOTICS MANU/DELIVER:HEROIN(BLACK TAR)".to_string(), + "2020" => "NARCOTICS POSS: AMPHETAMINES".to_string(), + "2021" => "NARCOTICS POSS: BARBITUATES".to_string(), + "2022" => "NARCOTICS POSS: COCAINE".to_string(), + "2023" => "NARCOTICS POSS: HEROIN(BRN/TAN)".to_string(), + "2024" => "NARCOTICS POSS: HEROIN(WHITE)".to_string(), + "2025" => "NARCOTICS POSS: HALLUCINOGENS".to_string(), + "2026" => "NARCOTICS POSS: PCP".to_string(), + "2027" => "NARCOTICS POSS: CRACK".to_string(), + "2028" => "NARCOTICS POSS: SYNTHETIC DRUGS".to_string(), + "2029" => "NARCOTICS POSS: HEROIN(BLACK TAR)".to_string(), + "2030" => "NARCOTICS MANU/DELIVER:LOOK-ALIKE DRUG".to_string(), + "2031" => "NARCOTICS POSS: METHAMPHETAMINES".to_string(), + "2032" => "NARCOTICS MANU/DELIVER: METHAMPHETAMINES".to_string(), + "2040" => "NARCOTICS POSS: LOOK-ALIKE DRUGS".to_string(), + "2050" => "NARCOTICS CRIMINAL DRUG CONSPIRACY".to_string(), + "2060" => "NARCOTICS FAIL REGISTER LIC:CONT SUBS".to_string(), + "2070" => "NARCOTICS DEL CONT SUBS TO PERSON <18".to_string(), + "2080" => "NARCOTICS CONT SUBS:FAIL TO MAINT RECORD".to_string(), + "2090" => "NARCOTICS ALTER/FORGE PRESCRIPTION".to_string(), + "2094" => "NARCOTICS ATTEMPT POSSESSION CANNABIS".to_string(), + "2095" => "NARCOTICS ATTEMPT POSSESSION NARCOTICS".to_string(), + "2110" => "NARCOTICS POS: HYPODERMIC NEEDLE".to_string(), + "2170" => "NARCOTICS POSSESSION OF DRUG EQUIPMENT".to_string(), + "1610" => "GAMBLING BOOKMAKING/HORSES".to_string(), + "1611" => "GAMBLING BOOKMAKING/SPORTS".to_string(), + "1620" => "GAMBLING BOLITA OR BOLI PUL/OFFICE".to_string(), + "1621" => "GAMBLING BOLITA OR BOLI PUL/RUNNER".to_string(), + "1622" => "GAMBLING BOLITA OR BOLI PUL/WRITER".to_string(), + "1623" => "GAMBLING BOLITA OR BOLI PUL/STATION".to_string(), + "1624" => "GAMBLING LOTTERY/PARI-MUTUEL".to_string(), + "1625" => "GAMBLING NATIONAL LOTTERY".to_string(), + "1626" => "GAMBLING ILLEGAL ILL LOTTERY".to_string(), + "1627" => "GAMBLING LOTTERY/OTHER".to_string(), + "1630" => "GAMBLING WIREROOM/HORSES".to_string(), + "1631" => "GAMBLING WIREROOM/SPORTS".to_string(), + "1632" => "GAMBLING WIREROOM/NUMBERS".to_string(), + "1633" => "GAMBLING SPORTS TAMPERING".to_string(), + "1640" => "GAMBLING REGISTER FED GAMBLING STAMP".to_string(), + "1650" => "GAMBLING VIOL CHARITABLE GAME ACT".to_string(), + "1651" => "GAMBLING GAME/CARDS".to_string(), + "1661" => "GAMBLING GAME/DICE".to_string(), + "1670" => "GAMBLING GAME/AMUSEMENT DEVICE".to_string(), + "1680" => "GAMBLING OTHER".to_string(), + "1681" => "GAMBLING LOTTERY/PARLAY CARDS".to_string(), + "1682" => "OTHER OFFENSE ANIMAL FIGHTING".to_string(), + "1690" => "GAMBLING POLICY/HOUSEBOOK".to_string(), + "1691" => "GAMBLING POLICY/STATION".to_string(), + "1692" => "GAMBLING POLICY/RUNNER".to_string(), + "1693" => "GAMBLING POLICY/TURN-IN".to_string(), + "1694" => "GAMBLING POLICY/OFFICE".to_string(), + "1695" => "GAMBLING POLICY/PRESS".to_string(), + "1696" => "GAMBLING POLICY/WHEEL".to_string(), + "1697" => "GAMBLING POLICY/OTHER".to_string(), + "1720" => "OFFENSE INVOLVING CHILDREN CONTRIBUTE DELINQUENCY OF A CHILD".to_string(), + "1750" => "OFFENSE INVOLVING CHILDREN CHILD ABUSE".to_string(), + "1751" => "OFFENSE INVOLVING CHILDREN CRIM SEX ABUSE BY FAM MEMBER".to_string(), + "1752" => "OFFENSE INVOLVING CHILDREN AGG CRIM SEX ABUSE FAM MEMBER".to_string(), + "1790" => "OFFENSE INVOLVING CHILDREN CHILD ABDUCTION".to_string(), + "1791" => "OFFENSE INVOLVING CHILDREN HARBOR RUNAWAY".to_string(), + "1792" => "KIDNAPPING CHILD ABDUCTION/STRANGER".to_string(), + "2210" => "LIQUOR LAW VIOLATION SELL/GIVE/DEL LIQUOR TO MINOR".to_string(), + "2220" => "LIQUOR LAW VIOLATION ILLEGAL POSSESSION BY MINOR".to_string(), + "2230" => "LIQUOR LAW VIOLATION ILLEGAL CONSUMPTION BY MINOR".to_string(), + "2240" => "LIQUOR LAW VIOLATION MINOR MISREPRESENT AGE".to_string(), + "2250" => "LIQUOR LAW VIOLATION LIQUOR LICENSE VIOLATION".to_string(), + "2251" => "LIQUOR LAW VIOLATION EMPLOY MINOR".to_string(), + "0470" => "PUBLIC PEACE VIOLATION RECKLESS CONDUCT".to_string(), + "2840" => "PUBLIC PEACE VIOLATION FALSE FIRE ALARM".to_string(), + "2860" => "PUBLIC PEACE VIOLATION FALSE POLICE REPORT".to_string(), + "2870" => "PUBLIC PEACE VIOLATION PEEPING TOM".to_string(), + "3100" => "PUBLIC PEACE VIOLATION MOB ACTION".to_string(), + "3610" => "OTHER OFFENSE INTERFERE W/ HIGHER EDUCATION".to_string(), + "3710" => "INTERFERE WITH PUBLIC OFFICER RESIST/OBSTRUCT/DISARM OFFICER".to_string(), + "3720" => "INTERFERE WITH PUBLIC OFFICER REFUSING TO AID AN OFFICER".to_string(), + "3730" => "INTERFERE WITH PUBLIC OFFICER OBSTRUCTING JUSTICE".to_string(), + "3740" => "INTERFERE WITH PUBLIC OFFICER CONCEALING/AIDING A FUGITIVE".to_string(), + "3750" => "INTERFERE WITH PUBLIC OFFICER ESCAPE".to_string(), + "3751" => "INTERFERE WITH PUBLIC OFFICER AIDING ARRESTEE ESCAPE".to_string(), + "3760" => "INTERFERE WITH PUBLIC OFFICER OBSTRUCTING SERVICE".to_string(), + "1030" => "ARSON POS: EXPLOSIVE/INCENDIARY DEV".to_string(), + "1035" => "ARSON POS: CHEMICAL/DRY-ICE DEVICE".to_string(), + "1330" => "CRIMINAL TRESPASS TO LAND".to_string(), + "1335" => "CRIMINAL TRESPASS TO AIRPORT".to_string(), + "1350" => "CRIMINAL TRESPASS TO STATE SUP LAND".to_string(), + "1360" => "CRIMINAL TRESPASS TO VEHICLE".to_string(), + "1365" => "CRIMINAL TRESPASS TO RESIDENCE".to_string(), + "1710" => "OFFENSE INVOLVING CHILDREN ENDANGERING LIFE/HEALTH CHILD".to_string(), + "1715" => "OFFENSES INVOLVING CHILDREN SALE TOBACCO PRODUCTS TOMINOR".to_string(), + "1725" => "OFFENSES INVOLVING CHILDREN CONTRIBUTE CRIM DELINQUENCYJUVENILE".to_string(), + "1755" => "OFFENSES INVOLVING CHILDREN CHILD ABANDONMENT".to_string(), + "1775" => "OFFENSES INVOLVING CHILDREN SALE OF TRAVEL TICKET TO MINOR".to_string(), + "1780" => "OFFENSE INVOLVING CHILDREN OTHER OFFENSE".to_string(), + "2091" => "NARCOTICS FORFEIT PROPERTY".to_string(), + "2092" => "NARCOTICS SOLICIT NARCOTICS ON PUBLICWAY".to_string(), + "2093" => "NARCOTICS FOUND SUSPECT NARCOTICS".to_string(), + "2111" => "NARCOTICS SALE/DEL HYPODERMIC NEEDLE".to_string(), + "2120" => "NARCOTICS FAILURE TO KEEP HYPO RECORDS".to_string(), + "2160" => "NARCOTICS SALE/DEL DRUG PARAPHERNALIA".to_string(), + "2500" => "CRIMINAL ABORTION CRIMINAL ABORTION".to_string(), + "2820" => "OTHER OFFENSE TELEPHONE THREAT".to_string(), + "2825" => "OTHER OFFENSE HARASSMENT BY TELEPHONE".to_string(), + "2826" => "OTHER OFFENSE HARASSMENT BY ELECTRONIC MEANS".to_string(), + "2850" => "PUBLIC PEACE VIOLATION BOMB THREAT".to_string(), + "2851" => "PUBLIC PEACE VIOLATION ARSON THREAT".to_string(), + "2890" => "PUBLIC PEACE VIOLATION OTHER VIOLATION".to_string(), + "2895" => "PUBLIC PEACE VIOLATION INTERFERE W/ EMERGENCY EQUIP".to_string(), + "3000" => "PUBLIC PEACE VIOLATION SELL/ADVERTISE FIREWORKS".to_string(), + "3200" => "PUBLIC PEACE VIOLATION ARMED VIOLENCE".to_string(), + "3300" => "PUBLIC PEACE VIOLATION PUBLIC DEMONSTRATION".to_string(), + "3400" => "PUBLIC PEACE VIOLATION LOOTING".to_string(), + "3770" => "INTERFERE WITH PUBLIC OFFICER CONTRABAND IN PRISON".to_string(), + "3800" => "INTERFERE WITH PUBLIC OFFICER INTERFERENCE JUDICIAL PROCESS".to_string(), + "3910" => "INTERFERE WITH PUBLIC OFFICER BRIBERY".to_string(), + "3920" => "INTERFERE WITH PUBLIC OFFICER OFFICIAL MISCONDUCT".to_string(), + "3960" => "INTIMIDATION INTIMIDATION".to_string(), + "3966" => "INTIMIDATION EDUCATIONAL INTIMIDAITON".to_string(), + "3970" => "INTIMIDATION EXTORTION".to_string(), + "3975" => "INTIMIDATION COMPELLING ORG MEMBERSHIP".to_string(), + "3980" => "INTIMIDATION COMPELLING CONFESSION".to_string(), + "4210" => "KIDNAPPING KIDNAPPING".to_string(), + "4220" => "KIDNAPPING AGGRAVATED".to_string(), + "4230" => "KIDNAPPING UNLAWFUL RESTRAINT".to_string(), + "4240" => "KIDNAPPING FORCIBLE DETENTION".to_string(), + "4255" => "KIDNAPPING UNLAWFUL INTERFERE/VISITATION".to_string(), + "4310" => "OTHER OFFENSE POSSESSION OF BURGLARY TOOLS".to_string(), + "4387" => "OTHER OFFENSE VIOLATE ORDER OF PROTECTION".to_string(), + "4388" => "OTHER OFFENSE VIO BAIL BOND: DOM VIOLENCE".to_string(), + "4410" => "OTHER OFFENSE DESTRUCTION OF DRAFT CARD".to_string(), + "4420" => "OTHER OFFENSE CRIMINAL FORTIFICATION".to_string(), + "4510" => "OTHER OFFENSE PROBATION VIOLATION".to_string(), + "4625" => "OTHER OFFENSE PAROLE VIOLATION".to_string(), + "4650" => "OTHER OFFENSE SEX OFFENDER: FAIL TO REGISTER".to_string(), + "4651" => "OTHER OFFENSE SEX OFFENDER: FAIL REG NEW ADD".to_string(), + "4652" => "OTHER OFFENSE SEX OFFENDER: PROHIBITED ZONE".to_string(), + "4740" => "OTHER OFFENSE UNLAWFUL USE OF BODY ARMOR".to_string(), + "4750" => "OTHER OFFENSE DISCLOSE DV VICTIM LOCATION".to_string(), + "4800" => "OTHER OFFENSE MONEY LAUNDERING".to_string(), + "4810" => "OTHER OFFENSE COMPOUNDING A CRIME".to_string(), + "4860" => "OTHER OFFENSE BOARD PLANE WITH WEAPON".to_string(), + "5000" => "OTHER OFFENSE OTHER CRIME AGAINST PERSON".to_string(), + "5001" => "OTHER OFFENSE OTHER CRIME INVOLVING PROPERTY".to_string(), + "5002" => "OTHER OFFENSE OTHER VEHICLE OFFENSE".to_string(), + "5003" => "OTHER OFFENSE OTHER ARSON/EXPLOSIVE INCIDENT".to_string(), + "5007" => "OTHER OFFENSE OTHER WEAPONS VIOLATION".to_string(), + "5008" => "OTHER OFFENSE FIREARM REGISTRATION VIOLATION".to_string(), + "500E" => "OTHER OFFENSE EAVESDROPPING".to_string(), + "500N" => "OTHER OFFENSE ABUSE/NEGLECT: CARE FACILITY".to_string(), + "5011" => "OTHER OFFENSE LICENSE VIOLATION".to_string(), + "501A" => "OTHER OFFENSE ANIMAL ABUSE/NEGLECT".to_string(), + "501H" => "OTHER OFFENSE HAZARDOUS MATERIALS VIOLATION".to_string(), + "502P" => "OTHER OFFENSE FALSE/STOLEN/ALTERED TRP".to_string(), + "502R" => "OTHER OFFENSE VEHICLE TITLE/REG OFFENSE".to_string(), + "502T" => "OTHER OFFENSE TAMPER WITH MOTOR VEHICLE".to_string(), + + _ => "UNKNOWN".to_string(), + } + } +} diff --git a/frontend/src/models/mod.rs b/frontend/src/models/mod.rs index c1314fa..e6bd468 100644 --- a/frontend/src/models/mod.rs +++ b/frontend/src/models/mod.rs @@ -1,5 +1,6 @@ //! Contains all models used throughout the frontend. pub mod blog; +pub mod chicago; pub mod response; pub mod story; diff --git a/frontend/src/models/response.rs b/frontend/src/models/response.rs index 0cc4796..387ee65 100644 --- a/frontend/src/models/response.rs +++ b/frontend/src/models/response.rs @@ -3,7 +3,7 @@ use serde::Deserialize; /// Holds the standard message + status code response sent from the API. -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] pub struct Response { /// The message associated with this response. pub message: String, diff --git a/frontend/src/pages/about.rs b/frontend/src/pages/about.rs index 2fc4cd1..1b2f9ca 100644 --- a/frontend/src/pages/about.rs +++ b/frontend/src/pages/about.rs @@ -11,7 +11,7 @@ use crate::FAVICON_GIF; /// The about component. #[function_component(About)] pub fn about() -> Html { - background::set_background(false); + background::set_background(true); gloo_utils::document().set_title("jl | about"); open_graph::set_open_graph_tag(OpenGraphTag::Description("about me".to_string())) diff --git a/frontend/src/pages/blog.rs b/frontend/src/pages/blog.rs index d5063bc..9a1c87d 100644 --- a/frontend/src/pages/blog.rs +++ b/frontend/src/pages/blog.rs @@ -18,7 +18,7 @@ use crate::FAVICON_GIF; /// The blog page. #[function_component(Blog)] pub fn blog() -> Html { - background::set_background(false); + background::set_background(true); gloo_utils::document().set_title("jl | blog"); let is_loading = use_state(|| true); diff --git a/frontend/src/pages/mod.rs b/frontend/src/pages/mod.rs index 6c1810f..3736e48 100644 --- a/frontend/src/pages/mod.rs +++ b/frontend/src/pages/mod.rs @@ -6,3 +6,4 @@ pub mod not_found; pub mod post_view; pub mod root; pub mod utils; +pub mod violence; diff --git a/frontend/src/pages/not_found.rs b/frontend/src/pages/not_found.rs index 57e8ba8..98d07f6 100644 --- a/frontend/src/pages/not_found.rs +++ b/frontend/src/pages/not_found.rs @@ -17,7 +17,7 @@ use crate::{ /// The 404 Not Found page. #[function_component(NotFound)] pub fn not_found() -> Html { - background::set_background(false); + background::set_background(true); gloo_utils::document().set_title("jl | 404"); let is_loading = use_state(|| true); diff --git a/frontend/src/pages/post_view.rs b/frontend/src/pages/post_view.rs index 7e24ecd..d9383c4 100644 --- a/frontend/src/pages/post_view.rs +++ b/frontend/src/pages/post_view.rs @@ -24,7 +24,7 @@ pub struct PostViewProps { /// The post view page. #[function_component(PostView)] pub fn post_view(props: &PostViewProps) -> Html { - background::set_background(false); + background::set_background(true); let post_id = props.post_id.clone(); diff --git a/frontend/src/pages/root.rs b/frontend/src/pages/root.rs index 68349b2..d760fde 100644 --- a/frontend/src/pages/root.rs +++ b/frontend/src/pages/root.rs @@ -59,6 +59,7 @@ pub fn root() -> Html {

{"blog"}

+

{"violence"}

{"about"}

} diff --git a/frontend/src/pages/utils.rs b/frontend/src/pages/utils.rs index 0ec8702..0818eb3 100644 --- a/frontend/src/pages/utils.rs +++ b/frontend/src/pages/utils.rs @@ -3,9 +3,15 @@ use gloo_console::error; use js_sys::Function; use pulldown_cmark::{html, Options, Parser}; -use web_sys::{MutationObserver, MutationObserverInit}; +use wasm_bindgen::JsCast; +use web_sys::{ + HtmlTableCellElement, HtmlTableElement, HtmlTableRowElement, MutationObserver, + MutationObserverInit, +}; use yew::prelude::*; +use crate::errors::StaccError; + /// Convert the post's body from Markdown to HTML. fn create_post_body(post_body: &str) -> String { let mut parser_options = Options::empty(); @@ -120,3 +126,64 @@ pub fn loading() -> Html { } } + +/// Create a table from the data in a given `Vec<(String, i32)>`. +pub fn create_table_from_data( + table_header: (&str, &str), + data: &[(String, i32)], +) -> Result { + let document = gloo_utils::document(); + + let table = document + .create_element("table")? + .dyn_into::()?; + table.set_class_name("data-table"); + + let header_row = document + .create_element("tr")? + .dyn_into::()?; + header_row.set_class_name("data-table-header-row"); + + let key_header = document + .create_element("th")? + .dyn_into::()?; + key_header.set_inner_text(table_header.0); + key_header.set_class_name("data-table-header-cell"); + + let value_header = document + .create_element("th")? + .dyn_into::()?; + value_header.set_inner_text(table_header.1); + value_header.set_class_name("data-table-header-cell"); + + let _ = header_row.append_child(&key_header); + let _ = header_row.append_child(&value_header); + + let _ = table.append_child(&header_row); + + for key_value in data.iter() { + let row = document + .create_element("tr")? + .dyn_into::()?; + row.set_class_name("data-table-row"); + + let key = document + .create_element("td")? + .dyn_into::()?; + key.set_inner_text(&key_value.0); + key.set_class_name("data-table-left-cell"); + + let value = document + .create_element("td")? + .dyn_into::()?; + value.set_inner_text(&key_value.1.to_string()); + value.set_class_name("data-table-right-cell-left-border"); + + let _ = row.append_child(&key); + let _ = row.append_child(&value); + + let _ = table.append_child(&row); + } + + Ok(table) +} diff --git a/frontend/src/pages/violence.rs b/frontend/src/pages/violence.rs new file mode 100644 index 0000000..a8facd9 --- /dev/null +++ b/frontend/src/pages/violence.rs @@ -0,0 +1,724 @@ +//! The page containing a map of pins indicating Chicago ShotSpotter alert locations. + +use std::env; + +use gloo_console::error; +use gloo_net::http::Request; +use lazy_static::lazy_static; +use leaflet::{ + Icon, IconOptions, LatLng, LayerGroup, Map, MapOptions, Marker, MarkerOptions, Point, Popup, + PopupOptions, TileLayer, TileLayerOptions, +}; +use serde_json::Value; +use wasm_bindgen::{JsCast, JsValue}; +use web_sys::{HtmlElement, Node}; +use yew::{prelude::*, virtual_dom::VNode}; + +use crate::{ + errors::StaccError, + models::{ + chicago::{ChicagoMapData, CleanedShotData, CleanedViolenceData, ShotData, ViolenceData}, + response::Response, + }, + pages::utils::Loading, + traits::{abstractable_hashmap::AbstractableHashMap, popup::Popup as PopupTrait}, + utils::{ + background, + date::format_date, + open_graph::{self, OpenGraphTag, PageType}, + }, + FAVICON_GIF, +}; + +use super::utils::{self, create_table_from_data}; + +lazy_static! { + /// City of Chicago data use disclaimer message. It is required to include this disclaimer on + /// the site (https://www.chicago.gov/city/en/narr/foia/data_disclaimer.html). + static ref CHICAGO_DATA_DISCLAIMER: &'static str = "This site provides applications using data that has been modified for use from its original source, www.cityofchicago.org, the official website of the City of Chicago. The City of Chicago makes no claims as to the content, accuracy, timeliness, or completeness of any of the data provided at this site. The data provided at this site is subject to change at any time. It is understood that the data provided at this site is being used at one’s own risk."; + + /// Jawg.io maps attribution required to use the Jawg map API. + static ref JAWG_MAPS_ATTRIBUTION: &'static str = r#"© JawgMaps © OSM contributors"#; + /// The Leaflet attribution required to use the map API. + static ref LEAFLET_ATTRIBUTION: &'static str = "© Jawg - © OpenStreetMap contributors"; + /// The URL template for the Leaflet map. + static ref LEAFLET_URL_TEMPLATE: String = { + let leaflet_access_token: &'static str = env!( + "LEAFLET_ACCESS_TOKEN", + "THE LEAFLET_ACCESS_TOKEN IS NOT SET IN THE CURRENT ENVIRONMENT! CANNOT BUILD FRONTEND." + ); + + let mut url_template = + "https://tile.jawg.io/f6a80ab7-56ec-4b34-bc1c-3caec4328a77/{z}/{x}/{y}{r}.png?access-token=".to_string(); + url_template.push_str(leaflet_access_token); + + url_template + }; + + /// The icon for mapping gunshot or firecracker alerts. + static ref GUNSHOT_OR_FIRECRACKER_ICON: &'static str = "https://i.imgur.com/UalxwUV.png"; + /// The icon for mapping ShotSpotter alerts (single shot). + static ref SINGLE_SHOTSPOTTER_MARKER_ICON: &'static str = "https://i.imgur.com/muz4yax.png"; + /// The icon for mapping ShotSpotter alerts (multiple shots). + static ref MULTIPLE_SHOTSPOTTER_MARKER_ICON: &'static str = "https://i.imgur.com/3auFaRW.png"; + /// The icon for mapping violence alerts. + static ref VIOLENCE_MARKER_ICON: &'static str = "https://i.imgur.com/5TV33u5.png"; + + /// The City of Chicago Socrata API endpoint for data pertaining to victims of homicide and + /// non-fatal shootings. + static ref SOCRATA_VICTIMS_ENDPOINT: &'static str = "https://data.cityofchicago.org/resource/gumc-mgzr.json"; + /// The City of Chicago Socrata API endpoint for ShotSpotter alerts. + static ref SOCRATA_SHOTSPOTTER_ENDPOINT: &'static str = "https://data.cityofchicago.org/resource/3h7q-7mdb.json"; +} + +/// The Chicago ShotSpotter map page. +#[function_component(Violence)] +pub fn violence() -> Html { + background::set_background(true); + gloo_utils::document().set_title("jl | violence"); + + let is_loading = use_state(|| true); + let get_chiraq_response = use_state(|| None); + { + let is_loading = is_loading.clone(); + let get_chiraq_response = get_chiraq_response.clone(); + + use_effect_with_deps( + move |_| { + wasm_bindgen_futures::spawn_local(async move { + open_graph::set_open_graph_tag(OpenGraphTag::Description( + "Visualizing violence in Chicago".to_string(), + )) + .unwrap_or_else(|error| error!(error.to_string())); + open_graph::set_open_graph_tag(OpenGraphTag::ImageLink( + FAVICON_GIF.to_string(), + )) + .unwrap_or_else(|error| error!(error.to_string())); + open_graph::set_open_graph_tag(OpenGraphTag::PageType(PageType::Website)) + .unwrap_or_else(|error| error!(error.to_string())); + open_graph::set_open_graph_tag(OpenGraphTag::Title( + "jl | violence".to_string(), + )) + .unwrap_or_else(|error| error!(error.to_string())); + + match Request::get("/api/chiraq").send().await { + Ok(response) => match response.status() { + 200 => { + is_loading.set(false); + response.json::().await.map_or_else( + |error| { + error!("FAILED TO PARSE CHIRAQ DATA TO THE CHICAGOMAPDATA STRUCT!"); + error!(error.to_string()); + + is_loading.set(false); + get_chiraq_response.set(Some(Err( + Response::status_500_with_message(format!( + "UNABLE TO PARSE CHIRAQ DATA TO JSON: {error}" + )), + ))) + }, + |shotspotter_data| { + is_loading.set(false); + get_chiraq_response.set(Some(Ok(shotspotter_data))) + }, + ) + } + _ => { + error!(format!("{:?}", response)); + + is_loading.set(false); + get_chiraq_response.set(Some(Err( + Response::status_500_with_message( + "No API response".to_string(), + ), + ))); + } + }, + Err(error) => { + error!(format!("{:?}", error)); + + is_loading.set(false); + get_chiraq_response.set(Some(Err(Response::status_500_with_message( + format!("UNABLE TO GET CHIRAQ DATA FROM THE API: {error}"), + )))); + } + } + }); + }, + (), + ) + } + + let chiraq_response = get_chiraq_response + .as_ref() + .unwrap_or(&Ok(ChicagoMapData::default())) + .to_owned(); + + let (dates, map, tables) = match chiraq_response { + Ok(chicago_map_data) => { + let shotspotter_data_array = chicago_map_data.shotspotter_data.as_array(); + let violence_data_array = chicago_map_data.violence_data.as_array(); + + if let (Some(shotspotter_data), Some(violence_data)) = + (shotspotter_data_array, violence_data_array) + { + if !shotspotter_data.is_empty() && !violence_data.is_empty() { + let (date_html, map_html, chart_html) = render_map(chicago_map_data) + .unwrap_or_else(|error| { + ( + html! { +
+

{ "FUCK!" }

+

{ "Shit done fucked up with the dates." }

+

{ format!("{error:#?}") }

+
+ }, + html! { +
+

{ "FUCK!" }

+

{ "Shit done fucked up with the map." }

+

{ format!("{error:#?}") }

+
+ }, + html! { +
+

{ "FUCK!" }

+

{ "Shit done fucked up with the charts." }

+

{ format!("{error:#?}") }

+
+ }, + ) + }); + + (date_html, map_html, chart_html) + } else { + ( + html! { + + }, + html! { + + }, + html! { + + }, + ) + } + } else { + ( + html! { + + }, + html! { + + }, + html! { + + }, + ) + } + } + Err(error) => { + error!("FAILED TO GET CHIRAQ DATA FROM THE API!"); + error!(format!("{error:#?}")); + + ( + html! { +
+

{ "FUCK!" }

+

{ "Shit done fucked up with the dates." }

+

{ format!("{error:#?}") }

+
+ }, + html! { +
+

{ "FUCK!" }

+

{ "Shit done fucked up with the map." }

+

{ format!("{error:#?}") }

+
+ }, + html! { +
+

{ "FUCK!" }

+

{ "Shit done fucked up with the data charts." }

+

{ format!("{error:#?}") }

+
+ }, + ) + } + }; + + let page_view = html! { +
+
+ { render_about_section() } +
+
+ { dates } +
+
+ { map } +
+
+ { tables } +
+ +
+ }; + + utils::create_page_with_nav( + None, + if *is_loading { + html! { } + } else { + // NOTE: + // This block is necessary because the Leaflet container does not automatically detect + // window resizing if the map container has an initial size state that eventually + // changes, ie. the map is hidden at first, but is later revealed when an event occurs. + // Leaflet tiles will only partially load until the user manually resizes the window. We + // circumvent this by dispatching a resize `Event`, tricking Leaflet into recalculating + // the tiles due to window resizing. + let resize_event = Event::new("resize").ok(); + if let Some(resize_event) = resize_event { + let _ = gloo_utils::window() + .dispatch_event(&resize_event) + .map_err(|error| { + error!("FAILED TO CALL RESIZE EVENT!"); + error!(error); + }); + } + + page_view + }, + ) +} + +/// Render the about this page section describing what's displayed here. +fn render_about_section() -> Html { + html! { +
+

{ "chicago violence" }

+

{ "This map marks locations where Shotspotter alerts as well as victims of homicides and non-fatal shootings have been recorded." }

+
+ } +} + +/// Render the Shotspotter and violence map via Leaflet. +fn render_map(chicago_map_data: ChicagoMapData) -> Result<(VNode, VNode, VNode), StaccError> { + let date_container = gloo_utils::document() + .create_element("div")? + .dyn_into::()?; + date_container.set_class_name("date-range-container"); + + let map_container = gloo_utils::document() + .create_element("div")? + .dyn_into::()?; + map_container.set_class_name("map"); + + let tables_container = gloo_utils::document() + .create_element("div")? + .dyn_into::()?; + tables_container.set_class_name("tables-container"); + + let map = create_map(&map_container); + + if let (Some(shotspotter_data), Some(vhnfs_data)) = ( + chicago_map_data.shotspotter_data.as_array(), + chicago_map_data.violence_data.as_array(), + ) { + if !shotspotter_data.is_empty() { + let cleaned_shot_data = plot_shotspotter_data(&map, shotspotter_data)?; + + create_date_range_labels( + &date_container, + "shotspotter data range", + &cleaned_shot_data.time_range, + )?; + + let tables = gloo_utils::document() + .create_element("div")? + .dyn_into::()?; + + let tables_header_container = gloo_utils::document() + .create_element("div")? + .dyn_into::()?; + tables_header_container.set_class_name("tables-container-header-container"); + tables_header_container + .set_inner_html("

shotspotter data

"); + + let incident_types_table = create_table_from_data( + ("incident type", "occurrences"), + &cleaned_shot_data.to_vec("sorted_incident_types")?, + )?; + let blocks_table = create_table_from_data( + ("block", "occurrences"), + &cleaned_shot_data.to_vec("sorted_blocks")?, + )?; + let community_areas_table = create_table_from_data( + ("community area", "occurrences"), + &cleaned_shot_data.to_vec("sorted_community_areas")?, + )?; + let zip_codes_table = create_table_from_data( + ("zip code", "occurrences"), + &cleaned_shot_data.to_vec("sorted_zip_codes")?, + )?; + + let _ = tables.append_child(&tables_header_container); + let _ = tables.append_child(&incident_types_table); + let _ = tables.append_child(&blocks_table); + let _ = tables.append_child(&community_areas_table); + let _ = tables.append_child(&zip_codes_table); + + let _ = tables_container.append_child(&tables); + } + + if !vhnfs_data.is_empty() { + let table_separator = gloo_utils::document() + .create_element("div")? + .dyn_into::()?; + table_separator.set_class_name("tables-container-thicc-separator"); + let _ = tables_container.append_child(&table_separator); + + let cleaned_violence_data = plot_violence_data(&map, vhnfs_data)?; + + create_date_range_labels( + &date_container, + "violence data range", + &cleaned_violence_data.time_range, + )?; + + let tables = gloo_utils::document() + .create_element("div")? + .dyn_into::()?; + + let tables_header_container = gloo_utils::document() + .create_element("div")? + .dyn_into::()?; + tables_header_container.set_class_name("tables-container-header-container"); + tables_header_container + .set_inner_html("

violence data

"); + + let incident_types_table = create_table_from_data( + ("incident type", "occurrences"), + &cleaned_violence_data.to_vec("sorted_incident_types")?, + )?; + let community_areas_table = create_table_from_data( + ("community area", "occurrences"), + &cleaned_violence_data.to_vec("sorted_community_areas")?, + )?; + let location_description_table = create_table_from_data( + ("location description", "occurrences"), + &cleaned_violence_data.to_vec("sorted_location_descriptions")?, + )?; + let victim_races_table = create_table_from_data( + ("victim race", "occurrences"), + &cleaned_violence_data.to_vec("sorted_victim_races")?, + )?; + let victim_sexes_table = create_table_from_data( + ("victim sex", "occurrences"), + &cleaned_violence_data.to_vec("sorted_victim_sexes")?, + )?; + let gun_injury_table = create_table_from_data( + ("gun injury?", "occurrences"), + &cleaned_violence_data.to_vec("sorted_gun_injury_count")?, + )?; + let zip_codes_table = create_table_from_data( + ("zip code", "occurrences"), + &cleaned_violence_data.to_vec("sorted_zip_codes")?, + )?; + + let _ = tables.append_child(&tables_header_container); + let _ = tables.append_child(&incident_types_table); + let _ = tables.append_child(&location_description_table); + let _ = tables.append_child(&victim_races_table); + let _ = tables.append_child(&victim_sexes_table); + let _ = tables.append_child(&gun_injury_table); + let _ = tables.append_child(&community_areas_table); + let _ = tables.append_child(&zip_codes_table); + + let _ = tables_container.append_child(&tables); + } + } else { + error!("FAILED TO GET SHOTSPOTTER AND VHNFS DATA FROM SHOTSPOTTER_DATA STRUCT"); + + let error_div = gloo_utils::document() + .create_element("div")? + .dyn_into::()?; + error_div.set_inner_html("no shotspotter or violence data available!"); + + let _ = date_container.append_child(&error_div); + let _ = tables_container.append_child(&error_div); + } + + let map_node: &Node = &date_container.clone().into(); + let date_html = Html::VRef(map_node.clone()); + + let map_node: &Node = &map_container.clone().into(); + let map_html = Html::VRef(map_node.clone()); + + let tables_node: &Node = &tables_container.clone().into(); + let tables_html = Html::VRef(tables_node.clone()); + + Ok((date_html, map_html, tables_html)) +} + +/// Create the date range labels for Shotspotter and violence data. +fn create_date_range_labels( + date_container: &HtmlElement, + label: &str, + time_range: &(String, String), +) -> Result<(), StaccError> { + let date_range_container = gloo_utils::document() + .create_element("div")? + .dyn_into::()?; + date_range_container.set_class_name("date-range-split-box"); + + let date_label = gloo_utils::document() + .create_element("div")? + .dyn_into::()?; + date_label.set_class_name("date-range-label-box"); + date_label.set_inner_html(&format!("{label}")); + + let date_value = gloo_utils::document() + .create_element("div")? + .dyn_into::()?; + date_value.set_class_name("date-range-value-box"); + date_value.set_inner_html(&format!("{} − {}", time_range.0, time_range.1)); + + let _ = date_range_container.append_child(&date_label)?; + let _ = date_range_container.append_child(&date_value)?; + + let _ = date_container.append_child(&date_range_container)?; + + Ok(()) +} + +/// Create the map and set its attribution layer. +fn create_map(container: &HtmlElement) -> Map { + let map = Map::new_with_element(container, &MapOptions::default()); + map.set_view(&LatLng::new(41.87708716842721, -87.62622819781514), 10.7); + + let tile_layer_options = TileLayerOptions::new(); + tile_layer_options.set_attribution(JAWG_MAPS_ATTRIBUTION.to_string()); + + TileLayer::new_options(&LEAFLET_URL_TEMPLATE, &tile_layer_options).add_to(&map); + + map +} + +/// Plot Shotspotter markers and their corresponding popups on the Leaflet map. +fn plot_shotspotter_data( + map: &Map, + shotspotter_data: &[Value], +) -> Result { + let shotspotter_layer = LayerGroup::new(); + + let mut earliest_date = "".to_string(); + let mut latest_date = "".to_string(); + + let mut cleaned_shot_data = CleanedShotData::new(); + + for shot in shotspotter_data.iter() { + let shot_data: Option = serde_json::from_value(shot.clone()).ok(); + + if let Some(shot_data) = shot_data { + let date = format_date(&shot_data.date); + + if earliest_date.is_empty() || date < earliest_date { + earliest_date = date.clone(); + } + + if latest_date.is_empty() || date > latest_date { + latest_date = date.clone(); + } + + if let (Some(longitude), Some(latitude)) = ( + shot_data.location.coordinates.first(), + shot_data.location.coordinates.last(), + ) { + let incident_type_description = shot_data.incident_type_description.clone(); + + let marker_icon = if &incident_type_description.to_lowercase() + == "multiple gunshots" + { + MULTIPLE_SHOTSPOTTER_MARKER_ICON.to_string() + } else if &incident_type_description.to_lowercase() == "gunshot or firecracker" { + GUNSHOT_OR_FIRECRACKER_ICON.to_string() + } else { + SINGLE_SHOTSPOTTER_MARKER_ICON.to_string() + }; + let icon = create_map_marker_icon(marker_icon); + let shot_marker = create_map_marker( + if &incident_type_description.to_lowercase() == "gunshot or firecracker" { + "🧨".to_string() + } else { + "🔫".to_string() + }, + &icon, + latitude, + longitude, + incident_type_description.clone().to_lowercase(), + ); + + shot_marker.add_to_layer_group(&shotspotter_layer); + + if let Ok(popup_content) = shot_data.to_popup() { + let popup = create_marker_popup(&popup_content); + shot_marker.bind_popup(&popup); + } + } + + cleaned_shot_data + .insert_or_increment("sorted_blocks", shot_data.block.trim_end_matches(','))?; + cleaned_shot_data + .insert_or_increment("sorted_community_areas", &shot_data.community_area)?; + cleaned_shot_data.insert_or_increment( + "sorted_incident_types", + &shot_data.incident_type_description, + )?; + cleaned_shot_data.insert_or_increment("sorted_rounds", &shot_data.rounds)?; + cleaned_shot_data.insert_or_increment("sorted_zip_codes", &shot_data.zip_code)?; + } + } + + shotspotter_layer.add_to(map); + + cleaned_shot_data.time_range = (earliest_date, latest_date); + + Ok(cleaned_shot_data) +} + +/// Plot violence markers and their corresponding popups on the Leaflet map. +fn plot_violence_data(map: &Map, vhnfs_data: &[Value]) -> Result { + let violence_layer = LayerGroup::new(); + + let mut earliest_date = "".to_string(); + let mut latest_date = "".to_string(); + + let mut cleaned_violence_data = CleanedViolenceData::new(); + + for violence in vhnfs_data.iter() { + let violence_data: Option = serde_json::from_value(violence.clone()).ok(); + + if let Some(violence_data) = violence_data { + let date = format_date(&violence_data.date); + + if earliest_date.is_empty() || date < earliest_date { + earliest_date = date.clone(); + } + + if latest_date.is_empty() || date > latest_date { + latest_date = date.clone(); + } + + if let (Some(longitude), Some(latitude)) = ( + violence_data.location.coordinates.first(), + violence_data.location.coordinates.last(), + ) { + let icon = create_map_marker_icon(VIOLENCE_MARKER_ICON.to_string()); + let violence_marker = create_map_marker( + "🤬".to_string(), + &icon, + latitude, + longitude, + "violence".to_string(), + ); + + violence_marker.add_to_layer_group(&violence_layer); + + if let Ok(popup_content) = violence_data.to_popup() { + let popup = create_marker_popup(&popup_content); + violence_marker.bind_popup(&popup); + } + } + + cleaned_violence_data.insert_or_increment("sorted_ages", &violence_data.age)?; + cleaned_violence_data + .insert_or_increment("sorted_community_areas", &violence_data.community_area)?; + cleaned_violence_data + .insert_or_increment("sorted_gun_injury_count", &violence_data.gunshot_injury_i)?; + cleaned_violence_data.insert_or_increment( + "sorted_incident_types", + &violence_data.get_crime_description(), + )?; + cleaned_violence_data.insert_or_increment( + "sorted_location_descriptions", + &violence_data.location_description, + )?; + cleaned_violence_data + .insert_or_increment("sorted_victim_races", &violence_data.race)?; + cleaned_violence_data.insert_or_increment("sorted_victim_sexes", &violence_data.sex)?; + cleaned_violence_data + .insert_or_increment("sorted_zip_codes", &violence_data.zip_code)?; + } + } + + violence_layer.add_to(map); + + cleaned_violence_data.time_range = (earliest_date, latest_date); + + Ok(cleaned_violence_data) +} + +/// Create an icon for the map marker. +fn create_map_marker_icon(icon_url: String) -> Icon { + let icon_options = IconOptions::new(); + icon_options.set_icon_url(icon_url); + icon_options.set_icon_size(Point::new(40.0, 40.0)); + + Icon::new(&icon_options) +} + +/// Create a map marker from the given `Icon`, alternate icon, and coordinates. +fn create_map_marker( + alt_icon: String, + icon: &Icon, + latitude: &f64, + longitude: &f64, + title: String, +) -> Marker { + let marker_options = MarkerOptions::new(); + marker_options.set_alt(alt_icon); + marker_options.set_icon(icon.clone()); + marker_options.set_title(title); + + Marker::new_with_options(&LatLng::new(*latitude, *longitude), &marker_options) +} + +/// Create the popup for the map marker. +fn create_marker_popup(popup_content: &JsValue) -> Popup { + let popup_options = PopupOptions::new(); + popup_options.set_class_name("marker-popup".to_string()); + popup_options.set_close_on_escape_key(true); + popup_options.set_keep_in_view(true); + + let popup = Popup::new(&popup_options, None); + popup.set_content(popup_content); + + popup +} diff --git a/frontend/src/router.rs b/frontend/src/router.rs index 078e693..4265d20 100644 --- a/frontend/src/router.rs +++ b/frontend/src/router.rs @@ -21,4 +21,6 @@ pub enum Route { /// Root page (landing page). #[at("/")] Root, + #[at("/violence")] + Violence, } diff --git a/frontend/src/traits/abstractable_hashmap.rs b/frontend/src/traits/abstractable_hashmap.rs new file mode 100644 index 0000000..99737cb --- /dev/null +++ b/frontend/src/traits/abstractable_hashmap.rs @@ -0,0 +1,100 @@ +//! Contains the trait and its implementations for sorting `HashMap`s by their values. + +use crate::{ + errors::StaccError, + models::chicago::{CleanedShotData, CleanedViolenceData}, +}; + +/// This trait enables a struct to sort all of its `HashMap` fields in descending order assuming its +/// `HashMap` field is of type `HashMap`. +/// Additionally, `HashMap`s may be converted into a sorted `Vec<(String, i32)>` representation. +/// This `Vec` is sorted based on the values in the `HashMap` before conversion. +pub trait AbstractableHashMap { + /// Insert a new key/value pair, or increment an existing value of a given key. + fn insert_or_increment(&mut self, hashmap_name: &str, key: &str) -> Result<(), StaccError>; + /// Convert a `HashMap` to a sorted `Vec<(String, i32)>` based on the values in + /// the `HashMap`. + fn to_vec(&self, hashmap_name: &str) -> Result, StaccError>; +} + +impl AbstractableHashMap for CleanedShotData { + fn insert_or_increment(&mut self, hashmap_name: &str, key: &str) -> Result<(), StaccError> { + let hashmap = match hashmap_name { + "sorted_blocks" => Ok(&mut self.sorted_blocks), + "sorted_community_areas" => Ok(&mut self.sorted_community_areas), + "sorted_incident_types" => Ok(&mut self.sorted_incident_types), + "sorted_rounds" => Ok(&mut self.sorted_rounds), + "sorted_zip_codes" => Ok(&mut self.sorted_zip_codes), + _ => Err(StaccError::InvalidHashMapError(hashmap_name.to_string())), + }?; + + let entry = hashmap.entry(key.to_string()).or_insert(0); + *entry += 1; + + Ok(()) + } + + fn to_vec(&self, hashmap_name: &str) -> Result, StaccError> { + let hashmap = match hashmap_name { + "sorted_blocks" => Ok(&self.sorted_blocks), + "sorted_community_areas" => Ok(&self.sorted_community_areas), + "sorted_incident_types" => Ok(&self.sorted_incident_types), + "sorted_rounds" => Ok(&self.sorted_rounds), + "sorted_zip_codes" => Ok(&self.sorted_zip_codes), + _ => Err(StaccError::InvalidHashMapError(hashmap_name.to_string())), + }?; + + let mut sorted_vec: Vec<(String, i32)> = hashmap + .iter() + .map(|(key, value)| (key.clone(), *value)) + .collect(); + sorted_vec.sort_by(|a, b| a.1.cmp(&b.1)); + sorted_vec.reverse(); + + Ok(sorted_vec) + } +} + +impl AbstractableHashMap for CleanedViolenceData { + fn insert_or_increment(&mut self, hashmap_name: &str, key: &str) -> Result<(), StaccError> { + let hashmap = match hashmap_name { + "sorted_ages" => Ok(&mut self.sorted_ages), + "sorted_community_areas" => Ok(&mut self.sorted_community_areas), + "sorted_gun_injury_count" => Ok(&mut self.sorted_gun_injury_count), + "sorted_incident_types" => Ok(&mut self.sorted_incident_types), + "sorted_location_descriptions" => Ok(&mut self.sorted_location_descriptions), + "sorted_victim_races" => Ok(&mut self.sorted_victim_races), + "sorted_victim_sexes" => Ok(&mut self.sorted_victim_sexes), + "sorted_zip_codes" => Ok(&mut self.sorted_zip_codes), + _ => Err(StaccError::InvalidHashMapError(hashmap_name.to_string())), + }?; + + let entry = hashmap.entry(key.to_string()).or_insert(0); + *entry += 1; + + Ok(()) + } + + fn to_vec(&self, hashmap_name: &str) -> Result, StaccError> { + let hashmap = match hashmap_name { + "sorted_ages" => Ok(&self.sorted_ages), + "sorted_community_areas" => Ok(&self.sorted_community_areas), + "sorted_gun_injury_count" => Ok(&self.sorted_gun_injury_count), + "sorted_incident_types" => Ok(&self.sorted_incident_types), + "sorted_location_descriptions" => Ok(&self.sorted_location_descriptions), + "sorted_victim_races" => Ok(&self.sorted_victim_races), + "sorted_victim_sexes" => Ok(&self.sorted_victim_sexes), + "sorted_zip_codes" => Ok(&self.sorted_zip_codes), + _ => Err(StaccError::InvalidHashMapError(hashmap_name.to_string())), + }?; + + let mut sorted_vec: Vec<(String, i32)> = hashmap + .iter() + .map(|(key, value)| (key.clone(), *value)) + .collect(); + sorted_vec.sort_by(|a, b| a.1.cmp(&b.1)); + sorted_vec.reverse(); + + Ok(sorted_vec) + } +} diff --git a/frontend/src/traits/mod.rs b/frontend/src/traits/mod.rs new file mode 100644 index 0000000..f0c1e99 --- /dev/null +++ b/frontend/src/traits/mod.rs @@ -0,0 +1,4 @@ +//! Contains miscellaneous traits used throughout the frontend. + +pub mod abstractable_hashmap; +pub mod popup; diff --git a/frontend/src/traits/popup.rs b/frontend/src/traits/popup.rs new file mode 100644 index 0000000..a322426 --- /dev/null +++ b/frontend/src/traits/popup.rs @@ -0,0 +1,267 @@ +//! Contains the trait and its implementations for creating map marker popups (the `violence` +//! page). + +use sha2::{Digest, Sha256}; +use wasm_bindgen::{JsCast, JsValue}; +use web_sys::{ + Document, Element, HtmlElement, HtmlTableCellElement, HtmlTableElement, HtmlTableRowElement, +}; + +use crate::{ + errors::StaccError, + models::chicago::{ShotData, ViolenceData}, + utils::date::format_date, +}; + +/// This trait enables the data for a given struct to be converted into an HTML popup by +/// constructing a `JsValue`. +pub trait Popup { + /// Neatly display the data found in the struct in a popup. + fn to_popup(&self) -> Result { + let hash_id = self.generate_id(); + let timestamp = self.get_date(); + + let document = gloo_utils::document(); + + let popup = document.create_element("div")?; + popup.set_id(&format!("shot-data-{hash_id}-popup")); + + let popup_content = document.create_element("div")?; + + self.create_popup_header(&document, &popup_content)?; + self.create_metadata_subtitle(&document, &popup_content, timestamp)?; + self.create_info_table(&document, &popup_content)?; + + popup.append_child(&popup_content)?; + + Ok(JsValue::from(popup)) + } + + /// Create the header for the popup. + fn create_popup_header( + &self, + document: &Document, + popup_content: &Element, + ) -> Result<(), StaccError>; + + /// Create the metadata subtitle/secondary header for the popup. + fn create_metadata_subtitle( + &self, + document: &Document, + popup_content: &Element, + timestamp: String, + ) -> Result<(), StaccError>; + + /// Create the info table containing miscellaneous incident information. + fn create_info_table( + &self, + document: &Document, + popup_content: &Element, + ) -> Result<(), StaccError>; + + /// Build a new row for the info table. + fn build_table_row( + &self, + document: &Document, + row_title: &str, + row_value: &str, + ) -> Result { + let row = document + .create_element("tr")? + .dyn_into::()?; + + let title = document + .create_element("td")? + .dyn_into::()?; + title.set_class_name("marker-popup-table-cell"); + title.set_inner_html(&format!("{row_title}")); + + let value = document + .create_element("td")? + .dyn_into::()?; + value.set_class_name("marker-popup-table-cell"); + value.set_inner_html(row_value); + + row.append_child(&title)?; + row.append_child(&value)?; + + Ok(row) + } + + /// Generate a SHA256 ID for this particular struct. This ID is referenced when constructing a + /// popup HTML element by referencing this value in the element's `id` attribute. + fn generate_id(&self) -> String; + + /// Get the human-readable date of this incident. + fn get_date(&self) -> String; +} + +impl Popup for ShotData { + fn create_popup_header( + &self, + document: &Document, + popup_content: &Element, + ) -> Result<(), StaccError> { + let header = document.create_element("h5")?; + header.set_inner_html(&self.incident_type_description); + + popup_content.append_child(&header)?; + + Ok(()) + } + + fn create_metadata_subtitle( + &self, + document: &Document, + popup_content: &Element, + timestamp: String, + ) -> Result<(), StaccError> { + let meta_subtitle = document.create_element("small")?; + meta_subtitle.set_inner_html(&format!( + "{} | {} {}", + timestamp, self.community_area, self.zip_code + )); + + popup_content.append_child(&meta_subtitle)?; + + Ok(()) + } + + fn create_info_table( + &self, + document: &Document, + popup_content: &Element, + ) -> Result<(), StaccError> { + let info_table = document + .create_element("table")? + .dyn_into::()?; + info_table.set_class_name("marker-popup-table"); + + let block_row = + self.build_table_row(document, "Block", self.block.trim_end_matches(','))?; + info_table.append_child(&block_row)?; + + if self.incident_type_description.to_lowercase() == "multiple gunshots" { + let rounds_row = self.build_table_row(document, "Rounds", &self.rounds)?; + info_table.append_child(&rounds_row)?; + } + + popup_content.append_child(&info_table.dyn_into::()?.into())?; + + Ok(()) + } + + fn generate_id(&self) -> String { + let mut hash_string = self.block.to_string(); + hash_string.push_str(&self.community_area); + hash_string.push_str(&self.date); + hash_string.push_str(&self.incident_type_description); + hash_string.push_str(&format!("{:?}", self.location)); + hash_string.push_str(&self.rounds); + hash_string.push_str(&self.zip_code); + + let mut hasher = Sha256::new(); + hasher.update(hash_string.as_bytes()); + + let hash_result = hasher.finalize(); + + hex::encode(hash_result) + } + + fn get_date(&self) -> String { + format_date(&self.date) + } +} + +impl Popup for ViolenceData { + fn create_popup_header( + &self, + document: &Document, + popup_content: &Element, + ) -> Result<(), StaccError> { + let crime_description = self.get_crime_description(); + + let header = document.create_element("h5")?; + header.set_inner_html(&crime_description); + + popup_content.append_child(&header)?; + + Ok(()) + } + + fn create_metadata_subtitle( + &self, + document: &Document, + popup_content: &Element, + timestamp: String, + ) -> Result<(), StaccError> { + let meta_subtitle = document.create_element("small")?; + meta_subtitle.set_inner_html(&format!( + "{} | {} {}", + timestamp, self.community_area, self.zip_code + )); + + popup_content.append_child(&meta_subtitle)?; + + Ok(()) + } + + fn create_info_table( + &self, + document: &Document, + popup_content: &Element, + ) -> Result<(), StaccError> { + let info_table = document + .create_element("table")? + .dyn_into::()?; + info_table.set_class_name("marker-popup-table"); + + let gunshot_injury_row = + self.build_table_row(document, "Gunshot injury?", &self.gunshot_injury_i)?; + info_table.append_child(&gunshot_injury_row)?; + + let location_description_row = + self.build_table_row(document, "Location", &self.location_description)?; + info_table.append_child(&location_description_row)?; + + let race_row = self.build_table_row(document, "Victim race", &self.race)?; + info_table.append_child(&race_row)?; + + let age_row = self.build_table_row(document, "Victim age", &self.age)?; + info_table.append_child(&age_row)?; + + let sex_row = self.build_table_row(document, "Victim sex", &self.sex)?; + info_table.append_child(&sex_row)?; + + popup_content.append_child(&info_table.dyn_into::()?.into())?; + + Ok(()) + } + + fn generate_id(&self) -> String { + let mut hash_string = self.age.to_string(); + hash_string.push_str(&self.community_area); + hash_string.push_str(&self.date); + hash_string.push_str(&self.gunshot_injury_i); + hash_string.push_str(&self.incident_iucr_cd); + hash_string.push_str(&self.incident_primary); + hash_string.push_str(&format!("{:?}", self.location)); + hash_string.push_str(&self.location_description); + hash_string.push_str(&self.race); + hash_string.push_str(&self.sex); + hash_string.push_str(&self.victimization_fbi_cd); + hash_string.push_str(&self.victimization_fbi_descr); + hash_string.push_str(&self.zip_code); + + let mut hasher = Sha256::new(); + hasher.update(hash_string.as_bytes()); + + let hash_result = hasher.finalize(); + + hex::encode(hash_result) + } + + fn get_date(&self) -> String { + format_date(&self.date) + } +} diff --git a/frontend/src/utils/date.rs b/frontend/src/utils/date.rs new file mode 100644 index 0000000..4cdea34 --- /dev/null +++ b/frontend/src/utils/date.rs @@ -0,0 +1,22 @@ +//! Contains miscellaneous utilities for formatting dates. + +use chrono::{DateTime, NaiveDateTime, Utc}; +use chrono_tz::America::Chicago; + +/// Format a raw date with the format `%Y-%m-%dT%H:%M:%S%.3f` into an more human-readable format. +pub fn format_date(raw_date: &str) -> String { + let parsed_datetime = NaiveDateTime::parse_from_str(raw_date, "%Y-%m-%dT%H:%M:%S%.3f") + .ok() + .map(|datetime| DateTime::::from_utc(datetime, Utc)); + + let chicago_datetime = parsed_datetime.map(|datetime| datetime.with_timezone(&Chicago)); + + let formatted_str = + chicago_datetime.map(|datetime| datetime.format("%Y/%m/%d %H:%M:%S %Z").to_string()); + + if let Some(formatted_date) = formatted_str { + formatted_date + } else { + raw_date.to_string() + } +} diff --git a/frontend/src/utils/mod.rs b/frontend/src/utils/mod.rs index e4538a8..b86db96 100644 --- a/frontend/src/utils/mod.rs +++ b/frontend/src/utils/mod.rs @@ -1,4 +1,5 @@ //! Contains miscellaneous utilities for the site. pub mod background; +pub mod date; pub mod open_graph; diff --git a/frontend/styles.css b/frontend/styles.css index 7493a45..5d8d881 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -486,6 +486,90 @@ code { } } +/* Style for the data table */ +.data-table { + border: solid 1px #7d310a; + display: block; + margin-bottom: 10px; + margin-top: 10px; + max-height: calc(30px * 10); + overflow-y: auto; + width: 100%; +} + +/* Style for the data table's header row cells. */ +.data-table-header-cell { + background: #7d310a; + padding: 5px; + padding-left: 10px; + padding-right: 10px; + /*position: sticky;*/ + /*top: 0;*/ + width: 50%; +} + +/* Style for the data table's header row. */ +.data-table-header-row { + display: flex; + position: sticky; + top: 0; +} + +/* Style for left side cells in the data table. */ +.data-table-left-cell { + padding: 5px; + padding-left: 10px; + padding-right: 10px; + width: 50%; +} + +/* Style for right side cells in the data table. */ +.data-table-right-cell-left-border { + border-left: solid 1px #7d310a; + padding: 5px; + padding-left: 10px; + padding-right: 10px; + width: 50%; +} + +/* Style for each row in the data table. */ +.data-table-row { + display: flex; + width: 100%; +} + +/* Style for the date ranges displayed above the violence map. */ +.date-range-container { + margin-bottom: 10px; + margin-top: 10px; +} + +/* Style for the date range box. */ +.date-range-split-box { + display: flex; + margin-bottom: 10px; + margin-top: 10px; +} + +/* Style for the left box/label. */ +.date-range-label-box { + background-color: #7d310a; + color: white; + flex: 1; + padding: 10px; + text-align: center; +} + +/* Style for the right box/value. */ +.date-range-value-box { + background: linear-gradient(rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 0.7)); + border: 1px solid #7d310a; + color: #7d310a; + flex: 2; + padding: 10px; + text-align: center; +} + /* Style for the error text. */ .error-text { animation: textShadow 1.6s infinite; @@ -537,11 +621,99 @@ code { width: 50px; } +.leaflet-container { + font-family: "Futura Md BT", sans-serif; + font-size: 12px; + font-size: 0.75rem; + line-height: 1.5; +} + +.leaflet-popup-content-wrapper { + background-color: #0b0d10; + border: #7d310a solid 1px; + box-shadow: 0 3px 14px rgba(0, 0, 0, 0.4); + color: #808080; +} + +.leaflet-popup-tip { + background-color: #0b0d10; + border: #7d310a solid 1px; +} + +/* The style for the top right map control container. */ +.leaflet-control-container .leaflet-right { + display: flex; + flex-direction: column; + font-size: smaller; + padding: 10px; + width: 275px; +} + /* A half-screen (left-oriented) container. */ .left-half-container { width: 50%; } +/* Set the height of the Leaflet map (otherwise it does not show up). */ +.map { + border: 1px solid #7d310a; + height: 90vh; +} + +/* Set the height of the Leaflet map (otherwise it does not show up). */ +.map-container { + height: 90vh; + margin-bottom: 1em; +} + +/* Set the style for each control group for the map. */ +.map-control { + display: flex; + width: 100%; +} + +/* Set the style for the checkbox in the `map-control` container */ +.map-control-checkbox { + align-items: center; + background-color: #7d310a; + box-sizing: border-box; + color: white; + display: flex; + flex: 0; + justify-content: center; + padding: 20px; +} + +.map-control-label { + border: 2px solid #7d310a; + box-sizing: border-box; + color: #7d310a; + cursor: pointer; + flex: 1; + padding: 20px; + text-align: center; +} + +/* The styling for the map markers' popups. */ +.marker-popup { + padding: 2px; +} + +/* The styling for the data table in the popup. */ +.marker-popup-table { + margin-top: 10px; + width: 100%; +} + +/* The styling for the data table's cells in the popup. */ +.marker-popup-table-cell { + border-collapse: collapse; + border-color: gray; + border-style: solid; + border-width: 1px; + padding: 4px; +} + /* Style for the container on the root page. */ .root-container { margin-left: 6rem !important; @@ -596,6 +768,34 @@ code { transform: translateY(-6px); } +/* The style for the container that will hold multiple different tables for + * Shotspotter and violence data. */ +.tables-container { + background: linear-gradient(rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 0.7)); + border: 1px solid #7d310a; + margin-bottom: 1em; + padding: 10px; +} + +/* The style for the container that will hold the header/title of the group of + * tables wrapped in a `tables-container` `
`. */ +.tables-container-header-container { + align-items: center; + background: #7d310a; + color: white; + display: flex; + justify-content: center; + padding: 10px; + text-align: center; +} + +/* Style for the extra T H I C C line divider in the tables container. */ +.tables-container-thicc-separator { + border-top: 10px solid #7d310a; + margin-bottom: 40px; + margin-top: 40px; +} + /* Sexy style for the site's title. */ .title-text { animation: @@ -659,6 +859,16 @@ code { margin-right: auto; } + .date-range-split-box { + flex-direction: column; + } + + .date-range-label-box, + .date-range-value-box { + flex: 1; + font-size: smaller; + } + .left-half-container { width: 100%; }