Compare commits

..

1 Commits

@ -1,47 +0,0 @@
{
"root": true,
"ignorePatterns": [
"projects/**/*"
],
"overrides": [
{
"files": [
"*.ts"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@angular-eslint/recommended",
"plugin:@angular-eslint/template/process-inline-templates"
],
"rules": {
"@angular-eslint/directive-selector": [
"error",
{
"type": "attribute",
"prefix": "app",
"style": "camelCase"
}
],
"@angular-eslint/component-selector": [
"error",
{
"type": "element",
"prefix": "app",
"style": "kebab-case"
}
]
}
},
{
"files": [
"*.html"
],
"extends": [
"plugin:@angular-eslint/template/recommended",
"plugin:@angular-eslint/template/accessibility"
],
"rules": {}
}
]
}

1
.gitignore vendored

@ -45,7 +45,6 @@ Thumbs.db
package-lock.json
processed_world_areas.json
processed_world_areas_full.json
releases
.env
releaser_key

@ -31,7 +31,8 @@
"styles": [
"src/styles.scss"
],
"scripts": []
"scripts": [
]
},
"configurations": {
"production": {
@ -71,15 +72,6 @@
}
},
"defaultConfiguration": "development"
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": [
"src/**/*.ts",
"src/**/*.html"
]
}
}
}
}
@ -90,9 +82,6 @@
}
},
"cli": {
"analytics": false,
"schematicCollections": [
"@angular-eslint/schematics"
]
"analytics": false
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

@ -6,8 +6,7 @@
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"lint": "ng lint"
"watch": "ng build --watch --configuration development"
},
"private": true,
"dependencies": {
@ -23,9 +22,7 @@
"@tauri-apps/api": "^1.2.0",
"@types/markdown-it": "^13.0.0",
"@types/natural-compare": "^1.4.1",
"@types/uuid": "^9.0.8",
"angular-resize-event": "^3.2.0",
"angular-svg-icon": "^17.0.0",
"bootstrap": "^5.3.2",
"fuzzr": "https://github.com/isark2/fuzzr#v0.3.1",
"markdown-it": "^13.0.1",
@ -35,24 +32,15 @@
"ngx-moveable": "^0.48.1",
"rxjs": "~7.8.1",
"tslib": "^2.6.0",
"uuid": "^9.0.1",
"vanilla-picker": "^2.12.1",
"zone.js": "^0.13.1"
},
"devDependencies": {
"@angular-devkit/build-angular": "^16.1.4",
"@angular-eslint/builder": "17.3.0",
"@angular-eslint/eslint-plugin": "17.3.0",
"@angular-eslint/eslint-plugin-template": "17.3.0",
"@angular-eslint/schematics": "17.3.0",
"@angular-eslint/template-parser": "17.3.0",
"@angular/cli": "~16.1.4",
"@angular/compiler-cli": "^16.1.4",
"@tauri-apps/cli": "^1.4.0",
"@types/jasmine": "~4.3.0",
"@typescript-eslint/eslint-plugin": "7.2.0",
"@typescript-eslint/parser": "7.2.0",
"eslint": "^8.57.0",
"jasmine-core": "~5.0.1",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
@ -61,4 +49,4 @@
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.1.6"
}
}
}

10
src-tauri/Cargo.lock generated

@ -1972,7 +1972,6 @@ dependencies = [
"regex",
"serde",
"serde_json",
"serde_with",
"simple_logger",
"statig",
"steamlocate",
@ -2944,9 +2943,9 @@ dependencies = [
[[package]]
name = "serde_with"
version = "3.7.0"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee80b0e361bbf88fd2f6e242ccd19cfda072cb0faa6ae694ecee08199938569a"
checksum = "1402f54f9a3b9e2efe71c1cea24e648acce55887983553eeb858cf3115acfd49"
dependencies = [
"base64 0.21.2",
"chrono",
@ -2954,7 +2953,6 @@ dependencies = [
"indexmap 1.9.3",
"indexmap 2.0.0",
"serde",
"serde_derive",
"serde_json",
"serde_with_macros",
"time",
@ -2962,9 +2960,9 @@ dependencies = [
[[package]]
name = "serde_with_macros"
version = "3.7.0"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6561dc161a9224638a31d876ccdfefbc1df91d3f3a8342eddb35f055d48c7655"
checksum = "9197f1ad0e3c173a0222d3c4404fb04c3afe87e962bcb327af73e8301fa203c7"
dependencies = [
"darling",
"proc-macro2",

@ -20,7 +20,7 @@ ts-rs = "6.2.1"
[dependencies]
steamlocate = "1.2.1"
tauri = { version = "1.2", features = [ "dialog-message", "http-request", "dialog-open", "global-shortcut-all", "dialog-save", "updater", "system-tray"] }
tauri = { version = "1.2", features = [ "dialog-open", "global-shortcut-all", "dialog-save", "updater", "system-tray"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
Underlayer = { git = "https://git.isark.me/isark/Underlay.git" }
@ -39,7 +39,6 @@ notify = "6.0.1"
regex = "1.9.3"
lazy_static = "1.4.0"
uuid = { version = "1.6.1", features = ["v4", "serde"] }
serde_with = "3.7.0"
[features]
# this feature is used for production builds or when `devPath` points to the filesystem

@ -37,6 +37,9 @@ struct UnprocessedArea {
#[serde(rename = "IsVaalArea")]
pub is_vaal_area: bool,
#[serde(rename = "Unknown64")]
pub unknown64: bool,
#[serde(rename = "Unknown9")]
pub unknown9: i64,
}
@ -58,7 +61,8 @@ pub type AreaId = usize;
pub type WorldAreasMap = HashMap<String, WorldArea>;
#[allow(dead_code)]
pub fn load_world_areas_map(content: &'static str) -> WorldAreasMap {
serde_json::from_str::<WorldAreasMap>(content).expect("Could not load world areas json")
serde_json::from_str::<WorldAreasMap>(content)
.expect("Could not load world areas json")
}
#[macro_export]
@ -202,6 +206,7 @@ pub fn repack(content: &str, out_path: &str) {
if w_a.act < 11
&& !w_a.is_vaal_area
&& !w_a.connections_world_areas_keys.is_empty()
&& !w_a.unknown64
&& !w_a.named_id.starts_with("Map")
&& !w_a.named_id.starts_with("Descent")
&& !w_a.named_id.starts_with("EndlessLedge")
@ -229,31 +234,3 @@ pub fn repack(content: &str, out_path: &str) {
std::fs::write(out_path, &new_json).expect("Could not write to file");
}
#[allow(dead_code)]
pub fn repack_full(content: &str, out_path: &str) {
let areas_json = serde_json::from_str::<UnprocessedAreas>(content);
let area_map = areas_json
.expect("Could not read world areas")
.into_iter()
.map(|w_a| {
(
w_a.named_id.clone(),
WorldArea {
name: w_a.name,
named_id: w_a.named_id,
act: w_a.act,
is_town: w_a.is_town,
has_waypoint: w_a.has_waypoint,
connections_world_areas_keys: w_a.connections_world_areas_keys,
key_id: w_a.key_id,
},
)
})
.collect::<WorldAreasMap>();
let new_json = serde_json::to_string(&area_map).unwrap();
std::fs::write(out_path, &new_json).expect("Could not write to file");
}

@ -6,10 +6,8 @@ fn main() {
tauri_build::build();
const OUT_PROCESSED: &'static str = "../data/processed_world_areas.json";
const OUT_PROCESSED_FULL: &'static str = "../data/processed_world_areas_full.json";
println!("cargo:rerun-if-changed=../data/WorldAreas.json");
poe_data::world_area::repack(&include_str!("../../data/WorldAreas.json"), OUT_PROCESSED);
poe_data::world_area::repack_full(&include_str!("../../data/WorldAreas.json"), OUT_PROCESSED_FULL);
//Let's go with uppercase names, allows automatic import to work...
config::Rect::export_to("../src/app/_models/generated/Rect.ts").expect("Could not generate config struct");

@ -26,10 +26,6 @@ pub struct Rect {
pub struct Config {
#[serde(default = "Config::default_initial_plan_window_position")]
pub initial_plan_window_position: Rect,
#[serde(default = "Config::default_initial_agg_window_position")]
pub initial_agg_window_position: Rect,
#[serde(default = "Config::default_initial_notes_window_position")]
pub initial_notes_window_position: Rect,
#[serde(default = "Config::default_hide_on_unfocus")]
pub hide_on_unfocus: bool,
#[serde(default = "Config::default_toggle_overlay")]
@ -51,29 +47,12 @@ pub struct Config {
pub num_visible: u16,
#[serde(default = "Config::default_plan_offset")]
pub offset: i16,
#[serde(default = "Config::default_enable_stopwatch")]
pub enable_stopwatch: bool,
#[serde(default = "Config::default_run_compare_history")]
pub run_compare_history: Option<String>,
#[serde(default = "Config::default_detach_notes")]
detach_notes: bool,
#[serde(default = "Config::default_show_livesplit")]
show_livesplit: bool,
#[serde(default = "Config::default_show_live_aggregate")]
show_live_aggregate: bool,
#[serde(default = "Config::default_shorten_zone_names")]
shorten_zone_names: bool,
}
impl Default for Config {
fn default() -> Self {
Self {
initial_plan_window_position: Self::default_initial_plan_window_position(),
initial_agg_window_position: Self::default_initial_agg_window_position(),
initial_notes_window_position: Self::default_initial_agg_window_position(),
hide_on_unfocus: Self::default_hide_on_unfocus(),
toggle_overlay: Self::default_toggle_overlay(),
prev: Self::default_prev(),
@ -87,12 +66,6 @@ impl Default for Config {
note_default_fg: Self::default_note_default_fg(),
num_visible: Self::default_plan_num_visible(),
offset: Self::default_plan_offset(),
run_compare_history: Self::default_run_compare_history(),
detach_notes: Self::default_detach_notes(),
enable_stopwatch: Self::default_enable_stopwatch(),
show_livesplit: Self::default_show_livesplit(),
show_live_aggregate: Self::default_show_live_aggregate(),
shorten_zone_names: Self::default_shorten_zone_names(),
}
}
}
@ -102,14 +75,6 @@ impl Config {
Default::default()
}
fn default_initial_agg_window_position() -> Rect {
Default::default()
}
fn default_initial_notes_window_position() -> Rect {
Default::default()
}
fn default_hide_on_unfocus() -> bool {
true
}
@ -153,23 +118,4 @@ impl Config {
fn default_plan_offset() -> i16 {
1
}
fn default_enable_stopwatch() -> bool {
false
}
fn default_run_compare_history() -> Option<String> {
None
}
fn default_show_livesplit() -> bool {
false
}
fn default_show_live_aggregate() -> bool {
false
}
fn default_shorten_zone_names() -> bool {
false
}
fn default_detach_notes() -> bool {
false
}
}

@ -1,7 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::collections::HashMap;
use std::sync::mpsc::Receiver;
use std::{path::PathBuf, sync::Mutex};
@ -10,7 +9,7 @@ use crossbeam::channel::Sender;
use notify::PollWatcher;
use overlay::{Event, Overlay};
use plan::{Plan, PlanMetadata};
use plan::Plan;
use poe_data::world_area::WorldAreasMap;
use poe_reader::filter_func;
use poe_reader::{blocking_area_filtered_rx, poe_client_log_receiver};
@ -26,26 +25,12 @@ use tauri::SystemTrayMenuItem;
use tauri::Window;
use lazy_static::lazy_static;
use time::{RunHistory, RunHistoryMetadata};
mod config;
mod overlay;
mod plan;
mod poe_reader;
mod storage;
mod time;
lazy_static! {
static ref WORLD_AREAS_MAP: WorldAreasMap = poe_data::world_area::load_world_areas_map(
include_str!("../../data/processed_world_areas.json")
);
}
lazy_static! {
static ref FULL_WORLD_AREAS_MAP: WorldAreasMap = poe_data::world_area::load_world_areas_map(
include_str!("../../data/processed_world_areas_full.json")
);
}
#[tauri::command]
fn set_interactable(interactable: bool, state: tauri::State<Sender<Event>>) {
@ -56,88 +41,78 @@ fn set_interactable(interactable: bool, state: tauri::State<Sender<Event>>) {
}
}
#[tauri::command]
fn load_world_areas() -> WorldAreasMap {
log::info!("Loading world areas");
WORLD_AREAS_MAP.clone()
lazy_static! {
static ref WORLD_AREAS_MAP: WorldAreasMap = poe_data::world_area::load_world_areas_map(include_str!(
"../../data/processed_world_areas.json"
));
}
#[tauri::command]
fn load_full_world_areas() -> WorldAreasMap {
fn load_world_areas() -> WorldAreasMap {
log::info!("Loading world areas");
FULL_WORLD_AREAS_MAP.clone()
WORLD_AREAS_MAP.clone()
}
#[tauri::command]
fn load_config(state: tauri::State<Mutex<Storage>>) -> Option<Config> {
Some(state.lock().ok()?.config.clone())
}
#[tauri::command]
fn update_config(config: Config, state: tauri::State<Mutex<Storage>>) {
fn set_config(
config: Config,
state: tauri::State<Mutex<Storage>>,
) {
log::info!("Saved config: {:?}", config);
if let Ok(mut storage) = state.lock() {
storage.config = config.clone();
storage.save_config();
storage.save();
}
}
#[tauri::command]
fn enumerate_stored_plans() -> Vec<PlanMetadata> {
Storage::enumerate_plans()
fn load_plan(path: PathBuf, state: tauri::State<Mutex<Storage>>) -> Option<Plan> {
if let Ok(mut storage) = state.lock() {
if let Some(path_string) = path.to_str() {
storage.last_plan = Some(path_string.to_string());
let plan = Storage::load_plan(storage.last_plan.as_ref()?);
log::info!("got plan: {plan:?}");
return plan;
}
}
None
}
#[tauri::command]
fn save_history(current_run_history: RunHistory) {
Storage::save_history(current_run_history);
}
fn save_plan(path: PathBuf, plan: Plan) -> bool {
if let Some(path_string) = path.with_extension("json").to_str() {
return Storage::save_plan(path_string, plan).is_ok();
}
#[tauri::command]
fn load_history_at_uuid(uuid: String) -> Option<RunHistory> {
Storage::load_history_at_uuid(uuid)
false
}
#[tauri::command]
fn load_cache() -> Option<HashMap<String, RunHistoryMetadata>> {
Storage::load_cache()
fn load_stored_plans() -> Vec<String> {
Storage::enumerate_plans()
}
#[tauri::command]
fn load_plan_at_path(
path: PathBuf,
save_local: Option<bool>,
state: tauri::State<Mutex<Storage>>,
) -> Option<Plan> {
if !path.exists() {
return None;
}
let mut storage = match state.lock() {
Ok(storage) => storage,
Err(_) => return None,
};
let save_local = save_local.unwrap_or(true);
storage.last_plan = Some(path.clone());
Storage::load_plan_at_path(path, save_local)
fn load_previous(name: String) -> Option<Plan> {
let plan = Storage::load_by_name(name);
log::info!("got plan: {plan:?}");
return plan;
}
#[tauri::command]
fn save_plan_at_path(path: PathBuf, plan: Plan) -> bool {
if let Some(path_string) = path.with_extension("json").to_str() {
return Storage::save_plan_at_path(path_string, plan).is_ok();
}
false
}
#[tauri::command]
fn save_plan_at_store(name: String, plan: Plan, allow_overwrite: bool) -> Option<PathBuf> {
Storage::save_plan_at_store_path(&name, plan, allow_overwrite).ok()
fn path_for_previous(prev: String) -> Option<String> {
let path = Storage::path_for_name(prev);
log::info!("got path: {path:?}");
return path;
}
#[tauri::command]
fn base_plan() -> Plan {
const BASE_PLAN_STRING: &str = include_str!("../../data/base_plan.json");
@ -153,13 +128,11 @@ fn main() {
let settings = CustomMenuItem::new("settings".to_string(), "Settings");
let editor = CustomMenuItem::new("editor".to_string(), "Plan Editor");
let runstats = CustomMenuItem::new("runstats".to_string(), "Run stats");
let force_show = CustomMenuItem::new("force_show".to_string(), "Force show");
let exit = CustomMenuItem::new("exit".to_string(), "Exit");
let tray_menu = SystemTrayMenu::new()
.add_item(settings)
.add_item(editor)
.add_item(runstats)
.add_item(force_show)
.add_native_item(SystemTrayMenuItem::Separator)
.add_item(exit);
@ -182,7 +155,7 @@ fn main() {
.expect("Could not get main overlay window"),
);
}
// app.get_window("Overlay")
// .expect("Could not get main overlay window")
// .open_devtools();
@ -192,17 +165,14 @@ fn main() {
.invoke_handler(tauri::generate_handler![
set_interactable,
load_world_areas,
load_full_world_areas,
load_config,
update_config,
enumerate_stored_plans,
load_plan_at_path,
save_plan_at_path,
save_plan_at_store,
set_config,
load_plan,
load_stored_plans,
save_plan,
base_plan,
save_history,
load_history_at_uuid,
load_cache,
load_previous,
path_for_previous,
])
.system_tray(system_tray)
.on_system_tray_event(|app, event| match event {
@ -210,16 +180,14 @@ fn main() {
"exit" => {
std::process::exit(0);
}
"editor" | "settings" | "runstats" => {
"editor" | "settings" => {
if let Some(window) = app.get_window("Normal") {
window.show().ok();
window.emit_to("Normal", "loadTab", id).ok();
}
}
"force_show" => {
app.state::<Sender<Event>>()
.send(overlay::State::Interactable {}.into())
.ok();
app.state::<Sender<Event>>().send(overlay::State::Interactable {}.into()).ok();
}
_ => {}
},
@ -240,11 +208,12 @@ fn listen_for_zone_changes(poe_client_log_path: Option<PathBuf>, window: Window)
std::thread::spawn(move || {
// need _watcher
if let Some((enter_area_receiver, _watcher)) = receiver_from_path(&poe_client_log_path) {
let world_areas: WorldAreasMap = load_world_areas();
for area in blocking_area_filtered_rx(&enter_area_receiver) {
if let Some(area) = filter_func(area) {
// if let Some(entered) = world_areas.get(&area) {
window.emit_to("Overlay", "entered", &area).ok();
// }
if let Some(entered) = world_areas.get(&area) {
window.emit_to("Overlay", "entered", &entered.named_id).ok();
}
}
}
}

@ -39,6 +39,7 @@ pub struct Overlay {
pub struct OverlayData {
// pub auto_hide: bool,
}
pub type LockedOverlayData = Mutex<OverlayData>;
fn wrap_underlay_rx(rx: MpscReceiver<UnderlayEvent>) -> Receiver<UnderlayEvent> {
let (cb_underlay_tx, cb_underlay_rx) = crossbeam::channel::unbounded();
@ -58,7 +59,7 @@ impl Overlay {
previous: State::hidden(),
};
window.manage(Mutex::new(OverlayData {}));
window.manage(Mutex::new(OverlayData { }));
let mut fsm = Overlay::uninitialized_state_machine(overlay).init();
@ -126,22 +127,19 @@ impl Overlay {
UnderlayEvent::MoveResize(bounds) => fsm.handle(&Event::Bounds(bounds)),
UnderlayEvent::Detach => fsm.handle(&State::hidden().into()),
UnderlayEvent::Focus => {
fsm.window.emit("overlay_or_target_focus", true).ok();
if let State::Hidden {} = fsm.state() {
fsm.handle(&State::Visible {}.into());
}
}
UnderlayEvent::Blur => {
if !fsm.window.is_focused().unwrap() {
fsm.window.emit("overlay_or_target_focus", false).ok();
if fsm
if !fsm.window.is_focused().unwrap()
&& fsm
.window
.state::<Mutex<Storage>>()
.lock()
.is_ok_and(|s| s.config.hide_on_unfocus)
{
fsm.handle(&State::Hidden {}.into())
}
{
fsm.handle(&State::Hidden {}.into())
}
}
UnderlayEvent::X11FullscreenEvent { is_fullscreen: _ } => {}

@ -1,100 +1,29 @@
use std::{collections::HashMap, path::PathBuf};
use std::collections::HashMap;
use serde::{ser::SerializeStruct, Deserialize, Deserializer, Serialize};
use serde_json::Value;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, Clone)]
#[derive(Serialize, Deserialize, Debug)]
pub struct Plan {
plan: Vec<PlanElement>,
current: usize,
#[serde(flatten)]
metadata: PlanMetadata,
stored_path: Option<String>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct PlanMetadata {
stored_path: Option<PathBuf>,
update_url: Option<String>,
latest_server_etag: Option<String>,
identifier: Option<String>,
#[serde(default)]
#[serde(deserialize_with = "deserialize_option_string")]
last_stored_time: Option<String>,
}
impl PlanMetadata {
pub fn set_stored_path(&mut self, file: Option<PathBuf>) {
self.stored_path = file;
}
}
impl Serialize for PlanMetadata {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let mut state = serializer.serialize_struct("PlanMetadata", 6)?;
state.serialize_field("update_url", &self.update_url)?;
state.serialize_field("stored_path", &self.stored_path)?;
state.serialize_field("latest_server_etag", &self.latest_server_etag)?;
state.serialize_field("identifier", &self.identifier)?;
state.serialize_field("last_stored_time", &self.last_stored_time)?;
if let Some(path) = &self.stored_path {
if let Some(name) = path.file_name() {
state.serialize_field("name", &name.to_str())?;
}
}
state.end()
}
}
// Custom deserializer that accepts both strings and legacy number for the stored_time field
fn deserialize_option_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
let v: Option<Value> = Option::deserialize(deserializer)?;
Ok(match v {
Some(Value::String(s)) => Some(s),
Some(Value::Number(n)) => Some(n.to_string()),
_ => None,
})
}
impl From<PlanMetadata> for Option<Plan> {
fn from(metadata: PlanMetadata) -> Self {
Some(serde_json::from_slice(&std::fs::read(metadata.stored_path?).ok()?).ok()?)
}
}
impl Plan {
pub fn set_stored_path(&mut self, file: Option<PathBuf>) {
self.metadata.stored_path = file;
pub fn set_stored_path(&mut self, file: String) {
self.stored_path = Some(file);
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
#[derive(Debug, Deserialize, Serialize)]
pub struct PlanElement {
area_key: String,
notes: String,
#[serde(default = "PlanElement::generate_uuid")]
uuid: uuid::Uuid,
#[serde(default = "PlanElement::edited")]
edited: bool,
anchor_act: Option<u8>,
#[serde(default, skip_serializing_if = "is_false")]
checkpoint: bool,
}
fn is_false(flag: &bool) -> bool {
!flag
}
impl PlanElement {
@ -107,14 +36,12 @@ impl PlanElement {
}
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
struct OldPlanElement {
area: OldArea,
note: String,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
struct OldPlan {
elements: Vec<OldPlanElement>,
@ -126,8 +53,7 @@ struct OldArea {
_rid: usize,
}
#[allow(dead_code)]
pub fn convert_old(path: PathBuf) -> Option<Plan> {
pub fn convert_old(path: &str) -> Option<Plan> {
let plan: OldPlan =
serde_json::from_str(&std::fs::read_to_string(path).expect("Could not convert old"))
.expect("could not convert old");
@ -150,16 +76,8 @@ pub fn convert_old(path: PathBuf) -> Option<Plan> {
notes: plan_element.note,
uuid: PlanElement::generate_uuid(),
edited: PlanElement::edited(),
anchor_act: None,
checkpoint: false,
})
.collect::<Vec<PlanElement>>(),
metadata: PlanMetadata {
stored_path: None,
update_url: None,
latest_server_etag: None,
identifier: None,
last_stored_time: None,
},
stored_path: None
})
}

@ -1,12 +1,12 @@
use std::{
fs::OpenOptions,
fs::{File, OpenOptions},
io::{Read, Seek, SeekFrom},
path::PathBuf,
sync::mpsc::{channel, Receiver, Sender},
time::Duration,
};
use notify::{Config, PollWatcher, RecursiveMode, Watcher, Event};
use notify::{event, Config, PollWatcher, RecursiveMode, Watcher, Event};
use regex::Regex;
#[cfg(target_os = "windows")]
@ -31,7 +31,7 @@ fn share_mode(options: &mut OpenOptions) {
}
#[cfg(not(target_os = "windows"))]
fn share_mode(_options: &mut OpenOptions) {}
fn share_mode(options: &mut OpenOptions) {}
impl LogFileReader {
fn new(file: &PathBuf, tx: Sender<String>) -> Result<Self, std::io::Error> {
@ -117,7 +117,6 @@ fn entered_filtered_rx<'a>(rx: &'a Receiver<String>) -> impl Iterator<Item = Str
})
}
#[allow(dead_code)]
/// Returns an iterator of strings with the id
pub fn area_filtered_rx<'a>(rx: &'a Receiver<String>) -> impl Iterator<Item = String> + 'a {
// Generating level 68 area "1_SideArea5_6" with seed 4103532853

@ -1,49 +1,41 @@
use std::collections::HashMap;
use std::error::Error;
use std::fs::DirEntry;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use crate::config::Config;
use crate::plan::{convert_old, Plan, PlanMetadata};
use crate::time::{RunHistory, RunHistoryMetadata};
use crate::plan::{convert_old, Plan};
#[derive(Serialize, Deserialize, Debug)]
pub struct Storage {
pub config: Config,
pub last_plan: Option<PathBuf>,
pub last_plan: Option<String>,
}
const QUALIFIER: &'static str = "me";
const ORGANIZATION: &'static str = "isark.poe";
const APPLICATION: &'static str = "Nothing";
const CONFIG_FILE: &'static str = "configuration.json";
const HISTORY_CACHE_FILE: &'static str = "history_cache.json";
const SAVED_PLANS: &'static str = "plans";
const SAVED_HISTORIES: &'static str = "histories";
fn mkdir_for_storage() {
let dir_structure = Storage::plan_dir();
if let Some(dir_structure) = dir_structure {
std::fs::create_dir_all(dir_structure)
.map_err(|_e| log::error!("Could not create directory for storing config and saves"))
.ok();
}
fn proj_dir() -> Option<ProjectDirs> {
ProjectDirs::from(QUALIFIER, ORGANIZATION, APPLICATION)
}
let dir_structure: Option<PathBuf> = Storage::history_dir();
if let Some(dir_structure) = dir_structure {
fn mkdir() {
if let Some(proj_dir) = proj_dir() {
let dir_structure = proj_dir.data_dir().join(SAVED_PLANS);
std::fs::create_dir_all(dir_structure)
.map_err(|_e| log::error!("Could not create directory for storing config and saves"))
.map_err(|e| log::error!("Could not create directory for storing config and saves"))
.ok();
}
}
impl Default for Storage {
fn default() -> Self {
mkdir_for_storage();
let storage = Self::load_storage();
mkdir();
let storage = Self::load();
match storage {
Some(storage) => storage,
None => Self {
@ -55,229 +47,107 @@ impl Default for Storage {
}
impl Storage {
pub fn proj_dir() -> Option<ProjectDirs> {
ProjectDirs::from(QUALIFIER, ORGANIZATION, APPLICATION)
}
pub fn plan_dir() -> Option<PathBuf> {
Some(Self::proj_dir()?.data_dir().join(SAVED_PLANS))
}
pub fn history_dir() -> Option<PathBuf> {
Some(Self::proj_dir()?.data_dir().join(SAVED_HISTORIES))
}
fn load() -> Option<Self> {
let storage: Option<Storage> = serde_json::from_str(
&std::fs::read_to_string(proj_dir()?.data_dir().join(CONFIG_FILE)).ok()?,
)
.ok();
pub fn config_file() -> Option<PathBuf> {
Some(Self::proj_dir()?.data_dir().join(CONFIG_FILE))
}
log::info!("Loaded storage: {:?}", storage);
pub fn history_cache_file() -> Option<PathBuf> {
Some(Self::proj_dir()?.data_dir().join(HISTORY_CACHE_FILE))
storage
}
pub fn save_config(&self) {
let content = match serde_json::to_string_pretty(&self) {
Ok(content) => content,
Err(_) => return,
};
let config_file = match Self::config_file() {
Some(config_file) => config_file,
None => return,
};
match std::fs::write(&config_file, content) {
Ok(_) => {
if let Some(c) = config_file.to_str() {
log::info!("Saved config to {c}");
pub fn save(&self) {
if let Ok(content) = serde_json::to_string_pretty(&self) {
if let Some(dir) = proj_dir() {
match std::fs::write(dir.data_dir().join(CONFIG_FILE), content) {
Ok(_) => {
if let Some(c) = dir.data_dir().join(CONFIG_FILE).to_str() {
log::info!("Saved config to {}", c)
}
}
Err(_) => log::error!("Could not write config"),
}
}
Err(_) => log::error!("Could not write config"),
}
}
pub fn save_plan_at_path<T: Into<String>>(file: T, plan: Plan) -> Result<(), Box<dyn Error>> {
pub fn save_plan<T: Into<String>>(file: T, plan: Plan) -> Result<(), Box<dyn Error>> {
std::fs::write(&file.into(), serde_json::to_string(&plan)?)?;
Ok(())
}
pub fn load_plan_at_path(path: PathBuf, save_local: bool) -> Option<Plan> {
let mut plan: Plan = match serde_json::from_str(&std::fs::read_to_string(&path).ok()?).ok() {
Some(plan) => plan,
None => convert_old(path.clone())?,
};
if save_local {
match Self::save_plan_at_store_path(path.file_name()?.to_str()?, plan.clone(), false) {
Ok(path) => plan.set_stored_path(Some(path)),
Err(_e) => {
log::error!("Could not save plan at store path during load");
pub fn load_plan<T: Into<String>>(file: T) -> Option<Plan> {
let mut file = file.into();
let plan_file = Path::new(&file);
//basically copy to our own dir to store if for reuse without contaminating or depending on the original file
if let Some(path) = plan_file.parent() {
if !path.ends_with(SAVED_PLANS) {
if let Some(proj_dir) = proj_dir() {
if let Some(file_name) = plan_file.file_name() {
let dest = proj_dir.data_dir().join(SAVED_PLANS).join(file_name);
let copy_result = std::fs::copy(&file, &dest);
match copy_result {
Ok(_) => {
file = dest.to_str()?.to_string();
}
Err(e) => log::error!("Could not store plan file {e:?}"),
}
}
}
}
}
//QoL touch file to put recent plans at top! :D
match std::fs::File::open(&path) {
Ok(file) => {
file.set_modified(std::time::SystemTime::now()).ok();
}
Err(_) => (),
}
Some(plan)
}
pub fn save_plan_at_store_path(
file_name: &str,
mut plan: Plan,
allow_overwrite: bool,
) -> Result<PathBuf, Box<dyn Error>> {
let plan_dir = match Self::plan_dir() {
Some(dir) => dir,
None => return Err("No plan dir".into()),
log::info!("Loading plan: {file:?}");
let mut plan = if let Some(plan) = serde_json::from_str(&std::fs::read_to_string(&file).ok()?).ok() {
plan
} else {
log::info!("Attempting to convert old");
let plan = convert_old(&file)?;
std::fs::write(
&file,
serde_json::to_string(&plan)
.map_err(|e| log::error!("Could not serialize converted plan to json {e:?}"))
.ok()?,
)
.map_err(|e| "Could not write converted plan to storage")
.ok();
Some(plan)
};
let file_path = plan_dir.join(file_name).with_extension("json");
//Disallow overwriting.
if !allow_overwrite && file_path.exists() {
return Err("File already exists".into());
if let Some(plan) = &mut plan {
plan.set_stored_path(file);
}
if allow_overwrite && file_path.exists() {
log::info!("Overwriting plan : {file_path:?}");
}
std::fs::write(&file_path, serde_json::to_string(&plan)?)?;
Ok(file_path.clone())
plan
}
pub fn enumerate_plans() -> Vec<PlanMetadata> {
let plan_dir = match Self::plan_dir() {
Some(dir) => dir,
None => return vec![],
};
let mut read_dir: Vec<DirEntry> = match plan_dir.read_dir() {
Ok(read_dir) => read_dir.filter_map(|v| {
log::trace!("Read dir: {:?}", v);
v.ok()
}).collect(),
Err(_) => return vec![],
};
pub fn load_by_name<T: Into<String>>(file: T) -> Option<Plan> {
let file = file.into();
let file = proj_dir()?.data_dir().join(SAVED_PLANS).join(file);
read_dir.sort_by_key(|v| v.metadata().ok()?.modified().ok());
read_dir.reverse();
log::info!("Loading plan: {file:?}");
read_dir
.iter()
.filter_map(|entry| {
let path = entry.path();
if path.extension()? != "json" {
return None;
}
Self::load_metadata_at_path(path)
})
.collect()
serde_json::from_str(&std::fs::read_to_string(&file).ok()?).ok()
}
//TODO: Remove if this turns out to be unnecessary.
// pub fn create_path_for_local_plan(name: &str, plan: &Plan) -> Option<String> {
// let file: PathBuf = Self::plan_dir()?.join(name).with_extension("json");
// Some(file.to_str()?.to_string())
// }
fn load_storage() -> Option<Self> {
let storage: Option<Storage> = serde_json::from_str(
&std::fs::read_to_string(Self::proj_dir()?.data_dir().join(CONFIG_FILE)).ok()?,
)
.ok();
log::trace!("Loaded storage: {:?}", storage);
storage
}
fn load_metadata_at_path(path: PathBuf) -> Option<PlanMetadata> {
let mut plan: PlanMetadata = match serde_json::from_str(&std::fs::read_to_string(&path).ok()?) {
Ok(plan) => plan,
Err(e) => {
log::error!("Could not load metadata at path: {path:?} : {e}");
return None;
pub fn enumerate_plans() -> Vec<String> {
if let Some(proj_dir) = proj_dir() {
if let Ok(read_dir) = proj_dir.data_dir().join(SAVED_PLANS).read_dir() {
return read_dir
.filter_map(|entry| Some(entry.ok()?.path().file_name()?.to_str()?.to_string()))
.collect::<Vec<String>>();
}
};
plan.set_stored_path(Some(path));
Some(plan)
}
pub fn save_history(history: RunHistory) {
Self::store_history_at_path(&history);
Self::store_history_at_cache(history);
}
pub fn load_history_at_uuid(uuid: String) -> Option<RunHistory> {
serde_json::from_str(&std::fs::read_to_string(Self::history_uuid_to_path(uuid)?).ok()?).ok()
}
pub fn load_cache() -> Option<HashMap<String, RunHistoryMetadata>> {
let path = match Self::history_cache_file() {
Some(path) => path,
None => { log::error!("Could not get path for history cache"); return None; },
};
serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
}
fn history_uuid_to_path(uuid: String) -> Option<PathBuf> {
let history_dir = match Self::history_dir() {
Some(dir) => dir,
None => return None,
};
Some(history_dir.join(uuid.to_string()).with_extension("json"))
}
fn store_history_at_path(history: &RunHistory) {
let path = match Self::history_uuid_to_path(history.uuid()) {
Some(path) => path,
None => { log::error!("Could not get path for history"); return; },
};
let serialization_result = match serde_json::to_string(&history) {
Ok(serialization_result) => serialization_result,
Err(e) => { log::error!("Could not serialize history: {}", e); return;},
};
}
std::fs::write(
path,
serialization_result,
).ok();
return vec![];
}
fn store_history_at_cache(history: RunHistory) {
let mut cache = match Self::load_cache() {
Some(cache) => cache,
None => HashMap::new(),
};
cache.insert(history.uuid(), history.into());
let path = match Self::history_cache_file() {
Some(path) => path,
None => { log::error!("Could not get path for history cache"); return; },
};
let serialization = match serde_json::to_string(&cache) {
Ok(serialization) => serialization,
Err(e) => { log::error!("Could not serialize history cache: {}", e); return; },
};
std::fs::write(path, serialization).ok();
pub fn path_for_name<T: Into<String>>(file: T) -> Option<String> {
let file = file.into();
let file = proj_dir()?.data_dir().join(SAVED_PLANS).join(file);
Some(file.to_str()?.to_string())
}
}

@ -1,52 +0,0 @@
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct RunHistory {
entries: Vec<TrackEntry>,
#[serde(flatten)]
metadata: RunHistoryMetadata,
}
impl RunHistory {
pub fn uuid(&self) -> String {
self.metadata.uuid.clone()
}
}
impl From<RunHistory> for RunHistoryMetadata {
fn from(history: RunHistory) -> Self {
history.metadata
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub enum EntryType {
PlanForceNext,
PlanForcePrev,
ZoneEnter,
CheckpointReached
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct TrackEntry {
#[serde(rename = "type")]
entry_type: EntryType,
zone: String,
current_elapsed_millis: u64,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct RunHistoryMetadata {
uuid: String,
current_elapsed_millis: u64,
associated_name: String,
#[serde(default = "default_last_updated_date_now")]
last_updated: u64,
}
fn default_last_updated_date_now() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis() as u64
}

@ -8,7 +8,7 @@
},
"package": {
"productName": "Nothing",
"version": "1.8.9"
"version": "1.4.0"
},
"tauri": {
"systemTray": {
@ -18,19 +18,10 @@
"allowlist": {
"dialog": {
"open": true,
"save": true,
"message": true
"save": true
},
"globalShortcut": {
"all": true
},
"http": {
"all": false,
"request": true,
"scope": [
"https://*",
"http://*"
]
}
},
"bundle": {

@ -5,99 +5,64 @@ export interface PlanInterface {
plan: PlanElement[];
current: number;
stored_path?: string;
update_url?: string;
name?: string;
latest_server_etag?: string;
identifier?: string,
last_stored_time?: string;
}
export interface PlanMetadata {
stored_path?: string;
update_url?: string;
name: string;
latest_server_etag?: string;
identifier?: string;
last_stored_time?: string;
}
export class Plan {
plan: PlanElement[];
current: number;
update_url?: string;
name?: string;
latest_server_etag?: string;
identifier?: string;
last_stored_time?: string;
public path?: string;
private selfSaveSubject: Subject<void> = new Subject<void>();
constructor(plan?: PlanInterface) {
if(!plan) {
this.plan = [];
this.current = 0;
return;
};
private path?: string;
private saveSubject: Subject<void> = new Subject<void>();
constructor(plan: PlanInterface) {
this.plan = plan.plan;
this.current = plan.current;
if (plan.stored_path) {
if(plan.stored_path) {
this.path = plan.stored_path;
}
this.update_url = plan.update_url;
this.name = plan.name;
this.latest_server_etag = plan.latest_server_etag;
this.identifier = plan.identifier;
this.last_stored_time = plan.last_stored_time;
this.selfSaveSubject.pipe(debounceTime(500)).subscribe(() => this.directSelfSave());
this.saveSubject.pipe(debounceTime(500)).subscribe(() => this.underlyingSave());
}
setPath(path: string) {
this.path = path;
}
isNext(zoneId: string, current = this.current) {
return current + 1 < this.plan.length && zoneId === this.plan[current + 1].area_key;
}
next() {
if (this.current + 1 < this.plan!.length) {
this.current++;
this.requestSelfSave();
this.save();
}
}
prev() {
if (this.current - 1 >= 0) {
this.current--;
this.requestSelfSave();
this.save();
}
}
toInterface(): PlanInterface {
return {
plan: this.plan,
current: this.current,
stored_path: this.path,
update_url: this.update_url,
latest_server_etag: this.latest_server_etag,
identifier: this.identifier,
last_stored_time: this.last_stored_time,
};
setPrevious(prev: string) {
from(invoke<string>('path_for_previous', { prev })).subscribe(path => {
if (path) {
this.setPath(path);
}
});
}
public requestSelfSave() {
private save() {
if (this.path) {
this.selfSaveSubject.next();
this.saveSubject.next();
}
}
private directSelfSave() {
invoke('save_plan_at_path', { path: this.path, plan: this.toInterface() });
private underlyingSave() {
console.log("Underlying save");
invoke('save_plan', {
plan: {
plan: this.plan,
current: this.current
}, path: this.path
});
}
}
export interface PlanElement {
@ -105,8 +70,4 @@ export interface PlanElement {
notes?: string;
uuid?: string;
edited: boolean;
anchor_act?: number;
checkpoint?: boolean;
checkpoint_millis?: number;
checkpoint_your_millis?: number;
}

@ -28,7 +28,7 @@ export class ConfigService {
console.error("Could not load config, should generally not happen :')");
return;
}
const _this = this;
this.cfg = cfg;
// Mostly to wrap setters so we can run the (debounced) sync function on any config changes!
@ -65,7 +65,7 @@ export class ConfigService {
}
private underlyingSync() {
invoke('update_config', { config: this.config });
invoke('set_config', { config: this.config });
}
}

@ -9,11 +9,9 @@ export class EventsService {
constructor(private zone: NgZone) { }
listen<T>(name: string,): Observable<Event<T>> {
return new Observable<Event<T>>((subscriber) => {
const unlisten = listen(name, (v: Event<T>) => {
this.zone.run(() => subscriber.next(v));
});
return async () => { (await unlisten)() };
});
}
@ -21,4 +19,4 @@ export class EventsService {
emit<T>(name: string, event: T): Observable<void> {
return from(emit(name, event));
}
}
}

@ -5,7 +5,6 @@ import { Event } from "@tauri-apps/api/event";
import { invoke } from '@tauri-apps/api';
import { ConfigService } from './config.service';
import { appWindow } from '@tauri-apps/api/window';
import { Subscription } from 'rxjs';
export class StateEvent {
Visible?: any;
@ -24,26 +23,13 @@ export class OverlayService {
constructor(private shortcuts: ShortcutService, private events: EventsService, private configService: ConfigService) {
if (appWindow.label == "Overlay") {
this.registerInitialBinds();
this.shortcuts.register(this.configService.config.toggleOverlay, this.onToggleOverlay.bind(this));
this.events.listen<StateEvent>("OverlayStateChange").subscribe(this.onOverlayStateChange.bind(this));
}
this.isOverlay = appWindow.label === "Overlay";
}
registerInitialBinds(retry: boolean = true) {
this.shortcuts.register(this.configService.config.toggleOverlay).subscribe({
next: (_shortcut) => {
this.onToggleOverlay()
},
error: (err) => {
console.error("error binding overlay toggle", err);
this.shortcuts.force_unbind(this.configService.config.toggleOverlay).subscribe(() => this.registerInitialBinds(false))
}
});
}
onOverlayStateChange(event: Event<StateEvent>) {
this.interactable = event.payload.Interactable != null;
if (event.payload.Hidden) { this.visible = false } else { this.visible = true };
@ -62,4 +48,16 @@ export class OverlayService {
invoke("set_interactable", { interactable: this.interactable }).then();
}
onBindToggleOverlayFinish(keys: string[]) {
this.isBinding = false;
let chord = keys.reduce((acc, curr) => {
if (acc === '') return curr;
return acc.concat('+').concat(curr);
}, '');
this.shortcuts.rebind(chord, this.onToggleOverlay);
this.configService.config.toggleOverlay = chord;
}
}

@ -1,162 +1,90 @@
import { Injectable } from '@angular/core';
import { invoke } from '@tauri-apps/api';
import { EMPTY, Observable, ReplaySubject, Subject, from, map, switchMap, tap } from 'rxjs';
import { Plan, PlanInterface, PlanMetadata } from '../_models/plan';
import { MatDialog } from '@angular/material/dialog';
import { UrlDialog } from '../plan-display/url-dialog.component';
import { fetch } from '@tauri-apps/api/http';
import { Response } from '@tauri-apps/api/http';
export class UrlError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
import { Observable, ReplaySubject, Subject, from, map, tap } from 'rxjs';
import { Plan, PlanInterface } from '../_models/plan';
@Injectable({
providedIn: 'root'
})
export class PlanService {
private _currentPlanSubject: Subject<Plan> = new ReplaySubject<Plan>(1);
private _basePlanSubject: Subject<Plan> = new ReplaySubject<Plan>(1);
private _storedPlansSubject: Subject<PlanMetadata[]> = new ReplaySubject<PlanMetadata[]>(1);
currentPlan?: Plan;
planStore: string[] = [];
basePlan?: Plan;
private basePlanSubj: Subject<Plan> = new ReplaySubject<Plan>(1);
constructor(private dialog: MatDialog) {
this.loadBasePlan();
this.loadStoredPlans();
}
public getBasePlan(): Observable<Plan> {
return this._basePlanSubject.asObservable();
}
public getCurrentPlan(): Observable<Plan> {
return this._currentPlanSubject.asObservable();
constructor() {
this.getPreviousPlans();
this.loadBasePlan();
}
public setCurrentPlan(plan: Plan) {
this._currentPlanSubject.next(plan);
loadPlan(path: string) {
return from(invoke<PlanInterface>('load_plan', { path })).pipe(
map(plan => {
this.currentPlan = new Plan(plan);
return plan
})
);
}
public getStoredPlans(): Observable<PlanMetadata[]> {
return this._storedPlansSubject.asObservable();
loadPlanNoSave(path: string) {
return from(invoke<PlanInterface>('load_plan', { path })).pipe(
map(plan => {
this.currentPlan = new Plan(plan);
this.currentPlan.setPath(path);
return plan
})
);
}
public loadPlanFromPath(path: string, save_local: boolean = true): Observable<Plan> {
return from(invoke<PlanInterface>('load_plan_at_path', { path, saveLocal: save_local })).pipe(map(plan => {
return new Plan(plan)
})).pipe(tap(() => {
this.loadStoredPlans();
}));
}
public loadFromUrl(url?: string, name?: string, save_local: boolean = false): Observable<Plan> {
if (!url || !name) {
const dialogRef = this.dialog.open(UrlDialog, {
data: {
url: url,
name: name
}
loadBasePlan() {
if(!this.basePlan) {
from(invoke<PlanInterface>('base_plan')).subscribe(plan => {
plan.plan.forEach(elem => {elem.edited = false;});
this.basePlan = new Plan(plan);
this.basePlanSubj?.next(this.basePlan);
});
return dialogRef.afterClosed().pipe(switchMap(data => {
if (data.url) {
return this._loadFromUrl(data.url, data.name, save_local);
}
return new Observable<Plan>((s) => s.complete());
}));
} else {
return this._loadFromUrl(url, name, save_local);
}
return this.basePlanSubj!.asObservable();
}
public savePlanAtPath(path: string, plan: Plan) {
savePlan(path: string, plan: Plan) {
plan.plan.forEach(elem => {
if (!elem.notes) { elem.notes = "" }
});
return from(invoke('save_plan_at_path', { path, plan: plan.toInterface() }));
return from(invoke<boolean>('save_plan', {
path,
plan: {
plan: plan.plan,
current: plan.current
},
})).subscribe(status => {
});
}
public checkForPlanUpdate(plan: Plan | PlanMetadata): Observable<Plan> {
if (!plan.update_url) return EMPTY;
return from(fetch(
plan.update_url,
{
method: 'HEAD',
timeout: 10
})).pipe(switchMap(response => {
this.validateResponse(response);
if (response.headers['etag'] === plan.latest_server_etag) {
return EMPTY;
}
return this._loadFromUrl(plan.update_url!, plan.name!, false);
}));
getPreviousPlans() {
from(invoke<string[]>('load_stored_plans')).subscribe(plans => this.planStore = plans);
}
public savePlanAtStore(name: string, plan: Plan, allowOverwrite: boolean = false) {
return from(invoke<string>('save_plan_at_store', { name, plan: plan.toInterface(), allowOverwrite })).pipe(tap(() => {
this.loadStoredPlans();
loadPrevious(name: string) {
return from(invoke<PlanInterface>('load_previous', { name })).pipe(tap(plan => {
console.log("previous loaded: ", plan);
this.currentPlan = new Plan(plan);
this.currentPlan.setPrevious(name);
}));
}
private _loadFromUrl(url: string, name: string, save_local: boolean): Observable<Plan> {
//Tauri fetch
return from(fetch(
url,
{
method: 'GET',
timeout: 10
})).pipe(map(response => {
this.validateResponse(response);
const plan = new Plan(response.data as PlanInterface);
const etag = response.headers['etag'];
if (etag) {
plan.latest_server_etag = etag;
console.log("got etag: ", etag);
}
return plan;
})).pipe(tap(plan => {
plan.update_url = url;
plan.name = name;
if (save_local) {
this.savePlanAtStore(name, plan).subscribe();
}
this.loadStoredPlans();
}));
}
private validateResponse(response: Response<unknown>) {
if (!response.ok) {
throw new UrlError(response.status, "Error fetching plan from URL, status " + response.status + ", URL: " + response.url + " with body:" + response.data);
zoneFromUuid(uuid: string) {
if (!this.basePlan) {
return undefined;
}
}
private loadBasePlan() {
from(invoke<PlanInterface>('base_plan')).subscribe(plan => {
plan.plan.forEach(elem => { elem.edited = false; });
plan.current = 0;
plan.name = "Base Plan";
this._basePlanSubject?.next(new Plan(plan));
});
}
private loadStoredPlans() {
from(invoke<PlanMetadata[]>('enumerate_stored_plans')).subscribe(plans => {
this._storedPlansSubject.next(plans);
})
return this.basePlan.plan.find(elem => elem.uuid === uuid);
}
}

@ -1,111 +0,0 @@
import { Injectable } from '@angular/core';
import { EntryType, RunHistory, TrackEntry } from './time-tracker.service';
import { Plan } from '../_models/plan';
export interface RunStat {
zoneName: string;
entryTime: string;
estimatedExit: string;
estimatedTimeSpent: string;
}
export interface AggregateRunStat {
zoneName: string;
aggregateFirstEntry: string;
aggregateLastExit: string;
aggregateTimeSpent: string;
aggregateNumEntries: string;
}
export interface UnformattedAggregateRunStat {
zoneId: string;
aggregateFirstEntry: number;
aggregateLastExit: number;
aggregateTimeSpent: number;
aggregateNumEntries: number;
}
export interface UnformattedAggregationData {
aggregation: UnformattedAggregateRunStat[];
aggregateNAId: string;
}
export interface UnformattedRunStat {
zoneId: string;
entryTime: number;
estimatedExit?: number;
estimatedTimeSpent?: number;
entryType: EntryType;
}
export type RunStatType = RunStat | AggregateRunStat;
@Injectable({
providedIn: 'root'
})
export class RunStatService {
constructor() {
}
calcAggregated(data: RunHistory): UnformattedAggregationData {
const aggregation = new Map<string, UnformattedAggregateRunStat>();
data.entries.forEach((entry, index) => {
const hasExit = !(data.entries.length - 1 === index);
let aggregate: UnformattedAggregateRunStat = {
zoneId: entry.zone,
aggregateFirstEntry: entry.current_elapsed_millis,
aggregateLastExit: hasExit ? data.entries[index + 1].current_elapsed_millis : 0,
aggregateTimeSpent: hasExit ? (data.entries[index + 1].current_elapsed_millis - data.entries[index].current_elapsed_millis) : 0,
aggregateNumEntries: 1,
}
const existing = aggregation.get(entry.zone);
if (existing) {
existing.aggregateLastExit = aggregate.aggregateLastExit;
existing.aggregateTimeSpent += aggregate.aggregateTimeSpent;
existing.aggregateNumEntries++;
}
aggregation.set(entry.zone, existing ?? aggregate);
});
return {
aggregation: Array.from(aggregation.values()),
aggregateNAId: data.entries.length > 0 ? data.entries[data.entries.length - 1].zone : "",
};
}
calcDirect(data: RunHistory): UnformattedRunStat[] {
return data.entries.map((entry, index) => {
const hasExit = !(data.entries.length - 1 === index);
return {
zoneId: entry.zone,
entryTime: entry.current_elapsed_millis,
estimatedExit: hasExit ? data.entries[index + 1].current_elapsed_millis : undefined,
estimatedTimeSpent: hasExit ? (data.entries[index + 1].current_elapsed_millis - data.entries[index].current_elapsed_millis) : undefined,
entryType: entry.type,
}
})
}
insertTimesAtCheckpoints(history: RunHistory, plan: Plan) {
const data = this.calcDirect(history);
const checkPointEntries = new Map(data.filter(entry => entry.entryType === EntryType.CheckpointReached).map(entry => [entry.zoneId, entry.entryTime]));
plan.plan.forEach(elem => {
if (checkPointEntries.has(elem.uuid!)) {
elem.checkpoint_millis = checkPointEntries.get(elem.uuid!);
}
});
}
}

@ -1,52 +1,45 @@
import { Injectable, NgZone } from '@angular/core';
import { ShortcutHandler, register, unregister } from '@tauri-apps/api/globalShortcut';
import { Observable, Subscriber, from } from 'rxjs';
import { EMPTY, from } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class ShortcutService {
private internalHandlers: Map<string, [ShortcutHandler, Subscriber<string>, () => void]> = new Map<string, [ShortcutHandler, Subscriber<string>, () => void]>();
bound: Map<ShortcutHandler, string> = new Map<ShortcutHandler, string>();
constructor(private zone: NgZone) { }
register(shortcut: string) {
return new Observable<string>((subscriber) => {
let originalHandler: ShortcutHandler = (s) => this.zone.run(() => subscriber.next(s));
const teardown = () => {
unregister(shortcut);
this.internalHandlers.delete(shortcut);
};
this.internalHandlers.set(shortcut, [originalHandler, subscriber, teardown]);
register(shortcut, originalHandler).catch(e => subscriber.error(e));
return teardown;
});
constructor(private zone: NgZone) {
}
register(shortcut: string, handler: ShortcutHandler) {
this.bound.set(handler, shortcut);
rebind_from_to(previousShortcut: string, nextShortcut: string) {
let [oldHandler, subscriber, teardown] = this.internalHandlers.get(previousShortcut)!;
subscriber.remove(teardown);
teardown();
teardown = () => {
unregister(nextShortcut);
this.internalHandlers.delete(nextShortcut);
};
return from(register(shortcut, (s) => {
this.zone.run(() => handler(s));
}));
}
register(nextShortcut, oldHandler);
unregister(handler: ShortcutHandler) {
const shortcut = this.bound.get(handler);
this.bound.delete(handler);
this.internalHandlers.set(nextShortcut, [oldHandler, subscriber, teardown]);
return shortcut ? from(unregister(shortcut)) : EMPTY;
}
rebind(shortcut: string, handler: ShortcutHandler) {
const prevShortcut = this.bound.get(handler);
this.register(shortcut, handler).subscribe(
{
error: (_err) => {
if (prevShortcut) {
this.register(prevShortcut, handler);
}
return EMPTY;
}
});
}
///No safety checks
force_unbind(shortcut: string) {
return from(unregister(shortcut));
rebind_from_to(previousShortcut: string, nextShortcut: string) {
const oldHandler = [...this.bound.entries()].find((entry: [ShortcutHandler, string]) => entry[1] === previousShortcut)?.[0];
this.rebind(nextShortcut, oldHandler!);
}
}

@ -1,382 +0,0 @@
import { Injectable, NgZone } from '@angular/core';
import { ConfigService } from './config.service';
import { Observable, ReplaySubject, Subject, Subscribable, Subscription, from, map, tap, timer } from 'rxjs';
import { Plan } from '../_models/plan';
import { MatDialog } from '@angular/material/dialog';
import { Resume, ResumeDialog } from '../plan-display/resume-dialog.component';
import { v4 as uuidv4 } from 'uuid';
import { invoke } from '@tauri-apps/api';
import { appWindow } from '@tauri-apps/api/window';
export enum EntryType {
PlanForceNext = "PlanForceNext",
PlanForcePrev = "PlanForcePrev",
ZoneEnter = "ZoneEnter",
CheckpointReached = "CheckpointReached",
}
export interface TrackEntry {
type: EntryType;
zone: string;
current_elapsed_millis: number;
}
export interface RunHistoryMetadata {
uuid: string;
currentElapsedMillis: number;
associatedName: string;
last_updated: number;
}
interface RunHistoryInterface {
uuid: string;
current_elapsed_millis: number;
associated_name: string;
last_updated: number;
entries: TrackEntry[];
}
export class RunHistory {
uuid: string;
currentElapsedMillis: number;
entries: TrackEntry[];
associatedName: string;
last_updated: number;
plan?: Plan;
constructor(data: RunHistoryInterface) {
this.uuid = data.uuid;
this.currentElapsedMillis = data.current_elapsed_millis;
this.entries = data.entries;
this.last_updated = data.last_updated;
this.associatedName = data.associated_name;
}
toInterface(): RunHistoryInterface {
return {
uuid: this.uuid,
current_elapsed_millis: this.currentElapsedMillis,
last_updated: this.last_updated,
entries: this.entries,
associated_name: this.associatedName,
}
}
}
@Injectable({
providedIn: 'root'
})
export class TimeTrackerService {
private currentRunHistory?: RunHistory;
private timerSubscription?: Subscription;
private debouncedSaveStopwatch?: Subscription;
resumeOnNext: boolean = false;
private start?: Date;
private latest?: Date;
private active: boolean = false;
private storedHistoriesSubject: Subject<Map<string, RunHistoryMetadata>> = new ReplaySubject<Map<string, RunHistoryMetadata>>(1);
private pushSubject: Subject<TrackEntry> = new ReplaySubject<TrackEntry>(1);
private newCurrentRunHistorySubject: Subject<RunHistory> = new ReplaySubject<RunHistory>(1);
constructor(private configService: ConfigService, public dialog: MatDialog, private zone: NgZone) {
this.loadCache();
appWindow.listen("entered", (entered) => {
if (entered.payload && typeof entered.payload === 'string')
this.onZoneEnter(entered.payload);
});
this.pushSubject.subscribe((entry) => {
this.currentRunHistory?.entries.push(entry);
});
}
public getCurrentRunHistory(): Observable<RunHistory> {
return this.newCurrentRunHistorySubject.asObservable();
}
getLatestEntry() {
return this.pushSubject.asObservable();
}
private setCurrentRunHistory(history: RunHistory) {
this.currentRunHistory = history;
this.newCurrentRunHistorySubject.next(history);
}
get elapsedTimeMillis() {
this.latest = new Date();
return this.latest!.valueOf() - this.start!.valueOf();
}
get isActive() {
return this.active;
}
get hasRunLoaded() {
return !!this.currentRunHistory;
}
public get storedHistories(): Observable<Map<string, RunHistoryMetadata>> {
return this.storedHistoriesSubject;
}
onNewRun(plan: Plan) {
if (this.timerSubscription && !this.timerSubscription.closed) this.timerSubscription.unsubscribe();
if (this.debouncedSaveStopwatch && !this.debouncedSaveStopwatch.closed) this.debouncedSaveStopwatch.unsubscribe();
this.start = undefined;
this.latest = undefined;
this.active = false;
if (plan.last_stored_time) {
this.loadHistory(plan.last_stored_time).subscribe(history => {
if (history) {
this.setCurrentRunHistory(history);
} else {
//Legacy or missing history, attempt to preserve elapsed time
this.setCurrentRunHistory(this.createNew(plan.name));
plan.last_stored_time = this.currentRunHistory!.uuid;
const old_time = parseInt(plan.last_stored_time, 10);
if (!isNaN(old_time) && old_time > 0) {
this.currentRunHistory!.currentElapsedMillis = old_time;
}
plan.requestSelfSave();
}
this.currentRunHistory!.plan = plan;
this.askResume(plan);
});
} else {
this.setCurrentRunHistory(this.createNew(plan.name));
this.currentRunHistory!.plan = plan;
this.resumeOnNext = true;
}
}
///Assumes currentPlan is set...
private startStopwatch() {
this.stop(); // Make sure we stop before starting again
if (this.currentRunHistory?.currentElapsedMillis) {
this.start = new Date(Date.now() - this.currentRunHistory.currentElapsedMillis);
} else {
this.start = new Date();
}
this.latest = new Date();
this.active = true;
//Make sure this is always cleared if e.g. force started! should be fine but just in case!!
this.resumeOnNext = false;
this.timerSubscription = timer(0, 1000).subscribe(() => {
this.zone.run(() => {
this.latest = new Date();
this.currentRunHistory!.currentElapsedMillis = this.elapsedTimeMillis;
});
});
this.debouncedSaveStopwatch = timer(0, 5000).subscribe(() => {
this.underlyingSaveStopwatch();
})
}
private underlyingSaveStopwatch() {
if (this.currentRunHistory && this.active && this.start && this.latest) {
this.currentRunHistory!.currentElapsedMillis = this.elapsedTimeMillis;
this.currentRunHistory!.last_updated = Date.now();
this.saveHistory(this.currentRunHistory!).subscribe(() => { });
this.loadCache();
if (this.currentRunHistory.plan) {
this.currentRunHistory.plan.last_stored_time = this.currentRunHistory.uuid;
this.currentRunHistory.plan.requestSelfSave();
}
}
}
public saveHistory(currentRunHistory: RunHistory) {
return from(invoke('save_history', { currentRunHistory: currentRunHistory.toInterface() })).pipe(tap(() => this.loadCache()));
}
public loadHistory(uuid: string) {
return from(invoke<RunHistoryInterface>('load_history_at_uuid', { uuid: uuid })).pipe(map(history => {
if (history) {
return new RunHistory(history);
}
return undefined;
}));
}
public onForceNext(forced_area: string) {
if (this.configService.config.enableStopwatch) {
if (this.isActive) {
this.pushSubject.next({
type: EntryType.PlanForceNext,
zone: forced_area,
current_elapsed_millis: this.elapsedTimeMillis
});
}
}
}
public onForcePrev(forced_area: string) {
if (this.configService.config.enableStopwatch) {
if (this.isActive) {
this.pushSubject.next({
type: EntryType.PlanForcePrev,
zone: forced_area,
current_elapsed_millis: this.elapsedTimeMillis
});
}
}
}
//Not perfect but good enough..
public reportCheckpoint(checkpoint: string) {
this.pushSubject.next({
type: EntryType.CheckpointReached,
zone: checkpoint,
current_elapsed_millis: this.elapsedTimeMillis
})
}
public stop() {
if (this.timerSubscription && !this.timerSubscription.closed) this.timerSubscription.unsubscribe();
if (this.debouncedSaveStopwatch && !this.debouncedSaveStopwatch.closed) this.debouncedSaveStopwatch.unsubscribe();
//Do a nice little save here as well!
this.underlyingSaveStopwatch();
this.start = undefined;
this.latest = undefined;
this.active = false;
}
public startLoaded() {
if (this.currentRunHistory) {
this.startStopwatch();
}
}
public hmsTimestamp(elapsed?: number): string {
return hmsTimestamp(elapsed);
}
public loadCache() {
from(invoke<Map<string, RunHistoryMetadata>>('load_cache')).subscribe(data => {
this.zone.run(() => {
const cache = new Map<string, RunHistoryMetadata>();
Object.values(data).forEach((value) => {
cache.set(value.uuid, {
uuid: value.uuid,
currentElapsedMillis: value.current_elapsed_millis,
associatedName: value.associated_name,
last_updated: value.last_updated,
});
});
console.log("sending new cache!");
this.storedHistoriesSubject.next(cache);
});
});
}
private onZoneEnter(zone: string) {
if (!this.currentRunHistory) return;
if (this.configService.config.enableStopwatch) {
if(!this.isActive && this.resumeOnNext) {
this.resumeOnNext = false;
this.startStopwatch();
}
if(!this.isActive && !this.resumeOnNext) {
//Don't start timer if not meant to auto resumes on next.
return;
}
this.pushSubject.next({
type: EntryType.ZoneEnter,
zone: zone,
current_elapsed_millis: this.elapsedTimeMillis
});
}
}
private askResume(plan: Plan) {
const dialogRef = this.dialog.open(ResumeDialog, { disableClose: true });
dialogRef.afterClosed().subscribe(resume => {
switch (resume) {
case Resume.Instant:
this.startStopwatch();
this.loadReachedCheckpoints();
break;
case Resume.Next:
this.resumeOnNext = true;
this.loadReachedCheckpoints();
break;
case Resume.Discard:
this.setCurrentRunHistory(this.createNew(plan.name));
this.loadReachedCheckpoints();
plan.last_stored_time = this.currentRunHistory!.uuid;
this.resumeOnNext = true;
plan.requestSelfSave();
break;
}
})
}
loadReachedCheckpoints() {
if (!this.currentRunHistory || !this.currentRunHistory.plan || !this.configService.config.runCompareHistory) return;
this.loadHistory(this.configService.config.runCompareHistory).subscribe(history => {
if (!history) return;
const checkpoints = new Map(history.entries.filter(entry => entry.type === EntryType.CheckpointReached).map(entry => [entry.zone, entry.current_elapsed_millis]));
const ourCheckpointValidZones = new Map(this.currentRunHistory?.plan?.plan.filter(entry => checkpoints.has(entry.uuid!)).map(entry => [entry.uuid!, entry]));
this.currentRunHistory?.entries.filter(entry => entry.type === EntryType.CheckpointReached).forEach(entry => {
if (ourCheckpointValidZones.has(entry.zone)) {
ourCheckpointValidZones.get(entry.zone)!.checkpoint_your_millis = entry.current_elapsed_millis;
}
})
});
}
private createNew(associatedName?: string) {
const uuid = uuidv4();
return new RunHistory({
uuid,
associated_name: associatedName || 'Unnamed',
current_elapsed_millis: 0,
last_updated: Date.now(),
entries: [],
});
}
}
export function hmsTimestamp(elapsed?: number): string {
if (elapsed == null || elapsed == undefined) return "N/A";
const h = String(Math.floor(elapsed / 3600000)).padStart(2, '0');
const m = String(Math.floor((elapsed % 3600000) / 60000)).padStart(2, '0');
const s = String(Math.floor((elapsed % 60000) / 1000)).padStart(2, '0');
return `${h}:${m}:${s}`;
}

@ -13,7 +13,6 @@ export class WorldAreaService {
public matcher?: Fuzzr;
private worldAreasSubject = new ReplaySubject<Map<string, WorldArea>>();
private fullWorldAreasSubject = new ReplaySubject<Map<string, WorldArea>>();
constructor(private zone: NgZone) {
from(invoke<Map<string, WorldArea>>('load_world_areas')).subscribe((data) => {
@ -24,12 +23,6 @@ export class WorldAreaService {
toString: (e: [string, WorldArea]) => e[1].name
})
});
from(invoke<Map<string, WorldArea>>('load_full_world_areas')).subscribe((data) => {
const entries = Object.entries(data).sort((a, b) => a[1].key_id - b[1].key_id);
this.worldAreas = new Map(entries);
this.zone.run(() => this.fullWorldAreasSubject.next(this.worldAreas!));
});
}
getWorldAreas(): Observable<Map<string, WorldArea>> {
@ -38,12 +31,6 @@ export class WorldAreaService {
);
}
getFullWorldAreas(): Observable<Map<string, WorldArea>> {
return this.fullWorldAreasSubject.asObservable().pipe(
filter(worldAreas => !!worldAreas),
);
}
hasTrial(key: string): boolean {
switch (key) {
case "1_1_7_1": return true;

@ -1,51 +0,0 @@
<div *ngIf="latestEntry && timeTrackerService.isActive" class="d-flex flex-column">
<div class="zone"><b>{{resolveZone(latestEntry.zone)}}</b></div>
<div class="d-flex flex-row">
Spent:
<app-wrap-value [value]="{current: currentSpent(), compare: compareSpent()}">
<ng-template let-v>
<span class="mx-2" [class]="classCurrentLowBetter(v.current, v.compare)">{{v.current | hms}}</span>
<span>{{v.compare | hms}}</span>
</ng-template>
</app-wrap-value>
</div>
<div class="d-flex flex-row">
First Entry:
<app-wrap-value [value]="{current: currentFirst(), compare: compareFirst()}">
<ng-template let-v>
<span class="mx-2" [class]="classCurrentLowBetter(v.current, v.compare)">{{v.current | hms}}</span>
<span>{{v.compare | hms}}</span>
</ng-template>
</app-wrap-value>
</div>
<div class="d-flex flex-row">
Last Entry:
<app-wrap-value [value]="{current: currentLast(), compare: compareLast()}">
<ng-template let-v>
<span class="mx-2" [class]="classCurrentLowBetter(v.current, v.compare)">{{v.current | hms}}</span>
<span>{{v.compare | hms}}</span>
</ng-template>
</app-wrap-value>
</div>
<div class="d-flex flex-row">
Number of Entries:
<app-wrap-value [value]="{current: currentEntries(), compare: compareEntries()}">
<ng-template let-v>
<span class="mx-2" [class]="classCurrentLowBetter(v.current, v.compare)">{{v.current}}</span>
<span>{{v.compare}}</span>
</ng-template>
</app-wrap-value>
</div>
</div>
<ng-container *ngIf="!latestEntry">
Awaiting zone entry to compare data
</ng-container>
<ng-container *ngIf="latestEntry && !timeTrackerService.isActive">
Awaiting stopwatch to start
</ng-container>

@ -1,9 +0,0 @@
.indeterminate {
color: orange;
}
.good-diff {
color: rgb(46, 179, 46);
}
.bad-diff {
color: rgb(199, 13, 13);
}

@ -1,21 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AggregateDisplayComponent } from './aggregate-display.component';
describe('AggregateDisplayComponent', () => {
let component: AggregateDisplayComponent;
let fixture: ComponentFixture<AggregateDisplayComponent>;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [AggregateDisplayComponent]
});
fixture = TestBed.createComponent(AggregateDisplayComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

@ -1,155 +0,0 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { PlanService } from '../_services/plan.service';
import { EntryType, RunHistory, TimeTrackerService, TrackEntry } from '../_services/time-tracker.service';
import { Plan } from '../_models/plan';
import { ConfigService } from '../_services/config.service';
import { RunStatService, UnformattedAggregateRunStat, UnformattedAggregationData } from '../_services/run-stat.service';
import { WorldArea } from '../_models/world-area';
import { WorldAreaService } from '../_services/world-area.service';
import { HmsPipe } from "../hms/hms.pipe";
import { WrapValueComponent } from '../wrap-value/wrap-value.component';
import { timer } from 'rxjs';
@Component({
selector: 'app-aggregate-display',
standalone: true,
templateUrl: './aggregate-display.component.html',
styleUrls: ['./aggregate-display.component.scss'],
imports: [CommonModule, HmsPipe, WrapValueComponent]
})
export class AggregateDisplayComponent {
latestEntry?: TrackEntry;
private worldAreaMap?: Map<string, WorldArea>;
private currentRunHistory?: RunHistory;
private compareAggregate?: Map<string, UnformattedAggregateRunStat>;
private currentAggregate?: Map<string, UnformattedAggregateRunStat>;
constructor(
public timeTrackerService: TimeTrackerService,
private configService: ConfigService,
private runStatService: RunStatService,
private worldAreaService: WorldAreaService,
) {
this.timeTrackerService.getCurrentRunHistory().subscribe(history => {
this.onNewCurrent(history);
});
this.worldAreaService.getFullWorldAreas().subscribe((data) => {
this.worldAreaMap = data;
})
this.timeTrackerService.getLatestEntry().subscribe(entry => {
if (entry.type != EntryType.ZoneEnter) return;
if (this.latestEntry) {
this.expandCurrentAggregated(this.latestEntry, entry);
}
this.latestEntry = entry;
});
}
private onNewCurrent(history: RunHistory) {
this.currentRunHistory = history;
this.loadComparisonData();
const aggregatedRunHistory = this.runStatService.calcAggregated(history);
this.currentAggregate = new Map(aggregatedRunHistory.aggregation.map(agg => [agg.zoneId, agg]));
if (history.entries.length > 0) {
this.latestEntry = history.entries[history.entries.length - 1];
} else {
this.latestEntry = undefined;
}
}
private loadComparisonData() {
if (!this.configService.config.runCompareHistory) {
return;
}
this.timeTrackerService.loadHistory(this.configService.config.runCompareHistory).subscribe(history => {
if (history) {
this.compareAggregate = new Map<string, UnformattedAggregateRunStat>(
this.runStatService.calcAggregated(history).aggregation.map(agg => [agg.zoneId, agg])
);
}
});
}
private expandCurrentAggregated(oldEntry: TrackEntry, newEntry: TrackEntry) {
if (!this.currentAggregate) return;
//Expand with old entry
let aggregate: UnformattedAggregateRunStat = {
zoneId: oldEntry.zone,
aggregateFirstEntry: oldEntry.current_elapsed_millis,
aggregateLastExit: newEntry.current_elapsed_millis,
aggregateTimeSpent: (newEntry.current_elapsed_millis - oldEntry.current_elapsed_millis),
aggregateNumEntries: 1,
}
const existing = this.currentAggregate.get(oldEntry.zone);
if (existing) {
existing.aggregateLastExit = aggregate.aggregateLastExit;
existing.aggregateTimeSpent += aggregate.aggregateTimeSpent;
existing.aggregateNumEntries++;
}
this.currentAggregate.set(aggregate.zoneId, existing ?? aggregate);
}
hms(value?: number) {
if (value) {
return this.timeTrackerService.hmsTimestamp(value);
} else {
return "N/A";
}
}
resolveZone(zoneId: string) {
const area = this.worldAreaMap?.get(zoneId);
if (!area) {
return "Unknown zone: " + zoneId;
}
// Act might not be very reasonable but it's the best we have it..
return area.name + " (A" + area.act + ")"
}
classCurrentLowBetter(current?: number, compare?: number) {
if (compare == undefined || compare == null || current == undefined || current == null) return "indeterminate";
return current <= compare ? "good-diff" : "bad-diff";
}
currentSpent() {
return (this.currentAggregate?.get(this.latestEntry!.zone)?.aggregateTimeSpent ?? 0) + (this.currentRunHistory!.currentElapsedMillis - this.latestEntry!.current_elapsed_millis);
}
compareSpent() {
return this.compareAggregate?.get(this.latestEntry!.zone)?.aggregateTimeSpent;
}
compareEntries() {
return this.compareAggregate?.get(this.latestEntry!.zone)?.aggregateNumEntries ?? 0;
}
currentEntries() {
return (this.currentAggregate?.get(this.latestEntry!.zone)?.aggregateNumEntries ?? 0) + 1;
}
compareLast() {
return this.compareAggregate?.get(this.latestEntry!.zone)?.aggregateLastExit;
}
currentLast() {
return this.latestEntry?.current_elapsed_millis ?? 0;
}
compareFirst() {
return (this.compareAggregate?.get(this.latestEntry!.zone)?.aggregateFirstEntry) ?? 0;
}
currentFirst() {
return (this.currentAggregate?.get(this.latestEntry!.zone)?.aggregateFirstEntry) ?? this.latestEntry!.current_elapsed_millis;
}
}

@ -11,9 +11,6 @@
<mat-tab label="Editor">
<plan-editor class="content"></plan-editor>
</mat-tab>
<mat-tab label="Previous runs" *ngIf="configService.config.enableStopwatch">
<app-run-stats class="content"></app-run-stats>
</mat-tab>
</mat-tab-group>
<tooltip class="tooltip">

@ -22,7 +22,7 @@ export class AppComponent implements OnInit {
isBinding: boolean = false;
overlayShowSettings: boolean = false;
hasAttachedOnce: boolean = false;
selected = new FormControl(0);
constructor(
@ -33,24 +33,15 @@ export class AppComponent implements OnInit {
private events: EventsService,
) {
this.events.listen<String>("loadTab").subscribe(event => {
switch (event.payload) {
case "settings":
this.selected.setValue(0);
break;
case "editor":
this.selected.setValue(1);
break;
case "runstats":
this.selected.setValue(2);
break;
default:
this.selected.setValue(0);
break;
if (event.payload === "editor") {
this.selected.setValue(1);
} else {
this.selected.setValue(0);
}
})
}
ngOnInit(): void {
}
}

@ -15,10 +15,6 @@ import { SettingsComponent } from "./settings/settings.component";
import { MatTabsModule } from '@angular/material/tabs';
import { MAT_DIALOG_DEFAULT_OPTIONS } from "@angular/material/dialog";
import { TooltipComponent } from "./tooltip/tooltip.component";
import { HttpClientModule } from "@angular/common/http";
import { AngularSvgIconModule } from "angular-svg-icon";
import { RunStatsComponent } from "./run-stats/run-stats.component";
import { WrapValueComponent } from './wrap-value/wrap-value.component';
// import { GemFinderComponent } from "./gem-finder/gem-finder.component";
export function initializeApp(configService: ConfigService) {
@ -29,7 +25,7 @@ export function initializeApp(configService: ConfigService) {
@NgModule({
declarations: [
AppComponent
AppComponent,
],
imports: [
BrowserModule,
@ -42,10 +38,7 @@ export function initializeApp(configService: ConfigService) {
OverlayModule,
SettingsComponent,
MatTabsModule,
TooltipComponent,
HttpClientModule,
AngularSvgIconModule.forRoot(),
RunStatsComponent
TooltipComponent
],
providers: [
{

@ -56,7 +56,7 @@ export class CarouselComponent<T> implements OnInit, AfterViewInit, OnChanges {
@Input() offset: number = 0;
containerDirectionLength: number = 0;
private debouncedOnchange: Subject<void> = new Subject<void>();
constructor(private cdr: ChangeDetectorRef) {
this.visibleSlides = [];
this.debouncedOnchange.pipe(debounceTime(500)).subscribe(() => this.realOnChange());
@ -65,26 +65,27 @@ export class CarouselComponent<T> implements OnInit, AfterViewInit, OnChanges {
}
}
ngOnInit(): void {
this.afterInitSelf.next(this);
this.intersectionObserver = new IntersectionObserver((entries, observer) => {
let changed = false;
entries.forEach(entry => {
const entryIndex = parseInt(entry.target.getAttribute('data-slideIndex')!);
if (!entryIndex && entryIndex != 0) {
return;
}
const entryIntersectingSlide = this.visibleSlides?.find(s => s.index == entryIndex);
if (!entryIntersectingSlide) {
return;
}
entryIntersectingSlide.currentlyIntersecting = entry.isIntersecting;
});
if (changed) {
const runIntersectionHandling = () => {
const entryIndex = parseInt(entry.target.getAttribute('data-slideIndex')!);
if (!entryIndex && entryIndex != 0) {
return;
}
const entryIntersectingSlide = this.visibleSlides?.find(s => s.index == entryIndex);
if (!entryIntersectingSlide) {
return;
}
entryIntersectingSlide.currentlyIntersecting = entry.isIntersecting;
};
runIntersectionHandling();
this.onChange();
}
})
})
}
@ -97,7 +98,7 @@ export class CarouselComponent<T> implements OnInit, AfterViewInit, OnChanges {
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['numVisible'] || changes['offset']) {
if(changes['numVisible'] || changes['offset']) {
this.reinitializeVisibleSlides();
}
}
@ -137,10 +138,10 @@ export class CarouselComponent<T> implements OnInit, AfterViewInit, OnChanges {
const start = Math.max(0, this.current - this.numExtraPrev());
const end = Math.min(this.current + this.numExtraNext(), this.slides!.length - 1);
for (let i = start; i <= end; i++) {
this.visibleSlides?.push({
index: i,
currentlyIntersecting: false,
});
this.visibleSlides?.push({
index: i,
currentlyIntersecting: false,
});
}
this.onChange();

@ -1,15 +0,0 @@
<ng-container *ngIf="init">
<ng-container *ngIf="rect">
<div class="target" [style.transform]="transform()" [style.width]="rect.width + 'px'"
[style.height]="rect.height + 'px'"
[style.background-color]="backgroundColor ? backgroundColor : 'rgba(0, 0, 0, 0.1)'" #targetRef>
<ng-content></ng-content>
</div>
<ngx-moveable #moveable [target]="targetRef" [draggable]="configurable" [resizable]="configurable"
(drag)="onDrag($event)" (resize)="onResize($event)" (dragEnd)="onDragEnd($event)"
(resizeEnd)="onResizeEnd($event)" [bounds]="bounds" [snappable]="true"
[style.visibility]="configurable ? 'visible' : 'hidden'">
</ngx-moveable>
</ng-container>
</ng-container>

@ -1,9 +0,0 @@
:host {
overflow: visible;
}
.target {
position: absolute;
min-width: 50px;
min-height: 50px;
}

@ -1,21 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DraggableWindowComponent } from './draggable-window.component';
describe('DraggableWindowComponent', () => {
let component: DraggableWindowComponent;
let fixture: ComponentFixture<DraggableWindowComponent>;
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [DraggableWindowComponent]
});
fixture = TestBed.createComponent(DraggableWindowComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

@ -1,88 +0,0 @@
import { AfterViewInit, ChangeDetectorRef, Component, EventEmitter, Input, NgZone, OnInit, Output, ViewChild } from '@angular/core';
import { Rect } from '../_models/generated/Rect';
import { NgxMoveableComponent, NgxMoveableModule, OnDrag, OnDragEnd, OnResize, OnResizeEnd } from 'ngx-moveable';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-draggable-window',
templateUrl: './draggable-window.component.html',
styleUrls: ['./draggable-window.component.scss'],
standalone: true,
imports: [CommonModule, NgxMoveableModule]
})
export class DraggableWindowComponent implements AfterViewInit {
rect?: Rect;
bounds: any = { "left": 0, "top": 0, "right": 0, "bottom": 0, "position": "css" };
@Input() public backgroundColor: string = "rgba(0, 0, 0, 0.1)";
@Input() public configurable: boolean = true;
@Input() public initialRect?: Rect;
@Output() savedRect: EventEmitter<Rect> = new EventEmitter();
@ViewChild("moveable") moveable?: NgxMoveableComponent;
init = false;
constructor(private cdr: ChangeDetectorRef, private zone: NgZone) {
window.addEventListener("resize", () => {
this.zone.run(() => {
this.windowInitHandler()
})
});
}
windowInitHandler() {
if (window.innerWidth > 0) {
const cfgRect = this.initialRect || { x: 0, y: 0, width: 0.1, height: 0.1 };
this.rect = {
x: cfgRect.x * window.innerWidth,
y: cfgRect.y * window.innerHeight,
width: cfgRect.width * window.innerWidth,
height: cfgRect.height * window.innerHeight,
}
this.moveable?.updateRect();
setTimeout(() => this.cdr.detectChanges(), 0);
this.init = true;
}
}
ngAfterViewInit(): void {
this.windowInitHandler();
}
transform() {
return `translate(${this.rect!.x}px, ${this.rect!.y}px)`;
}
onDrag(e: OnDrag) {
this.rect!.x = e.translate[0];
this.rect!.y = e.translate[1];
}
onDragEnd(_e: OnDragEnd) {
this.saveRect();
}
onResize(e: OnResize) {
this.rect!.width = e.width;
this.rect!.height = e.height;
this.onDrag(e.drag);
}
onResizeEnd(_e: OnResizeEnd) {
this.saveRect();
}
saveRect() {
if (this.rect) {
this.savedRect.emit({
x: this.rect.x / window.innerWidth,
y: this.rect.y / window.innerHeight,
width: this.rect.width / window.innerWidth,
height: this.rect.height / window.innerHeight,
});
}
}
}

@ -5,7 +5,7 @@
<button mat-raised-button color="primary" (click)="loadBasePlan()">Load base plan preset</button>
</div>
<div class="d-flex flex-column overflow-hidden p-4 grow">
<div class="d-flex flex-column overflow-hidden p-4">
<div class="row">
<div class="col-6">
<div class="row">
@ -25,30 +25,30 @@
</div>
<div class="col-6">
<div class="row">
<mat-form-field>
<mat-label>Area filter</mat-label>
<input matInput type="text" [ngModel]="planSearchString" (ngModelChange)="planSearchStringChange($event)">
<input matInput type="text" [(ngModel)]="planSearchString">
</mat-form-field>
</div>
<div class="row">
<mat-form-field>
<mat-label>Act filter</mat-label>
<mat-select [value]="planFilterAct" (valueChange)="planFilterActChange($event)">
<mat-select [(value)]="planFilterAct">
<mat-option *ngFor="let item of acts" [value]="item">{{item.name}}</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="row">
<mat-slide-toggle class="col-5" color="accent" [(ngModel)]="autoScrollToEnd">
Auto scroll to latest
</mat-slide-toggle>
<mat-slide-toggle class="col-5" color="accent" [(ngModel)]="autoScrollToEnd">
Auto scroll to latest
</mat-slide-toggle>
<mat-slide-toggle class="col-4" color="accent" [ngModel]="reverseDisplay"
(ngModelChange)="reverseDisplayChange($event)">
Reverse display
</mat-slide-toggle>
<mat-slide-toggle class="col-4" color="accent" [(ngModel)]="reverseDisplay">
Reverse display
</mat-slide-toggle>
<div class="col-3 d-flex justify-content-end ">
<button class="" mat-stroked-button color="warn" (click)="clearPlan()">Clear</button>
</div>
@ -57,50 +57,36 @@
</div>
<div cdkDropListGroup class="row h-100 overflow-hidden">
<div cdkDropListGroup *ngIf="areas" class="row overflow-hidden">
<div class="col-6 d-flex flex-column h-100 grow">
<div class="col-6 d-flex flex-column h-100">
<h2>Campaign zones</h2>
<div cdkDropList [cdkDropListData]="filterAreas()" class="list h-100 areas" cdkDropListSortingDisabled
<div cdkDropList [cdkDropListData]="filterAreas()" class="list areas" cdkDropListSortingDisabled
(cdkDropListDropped)="dropHandler($event)">
<div class="box" *ngFor="let item of filterAreas(); index as boxindex" cdkDrag
(dblclick)="doubleClickArea(item)">
<div class="box" *ngFor="let item of filterAreas()" cdkDrag (dblclick)="doubleClickArea(item)">
<div class="zone-name">{{item.name}}</div>
<div class="act">Act {{item.act}}</div>
</div>
</div>
</div>
<div class="col-6 d-flex flex-column h-100 grow">
<div class="col-6 d-flex flex-column h-100">
<h2>Plan</h2>
<div appendOnly #planListElement cdkDropList [cdkDropListData]="(this.latestList | async)!" class="list h-100"
<div cdkDropList #planList [cdkDropListData]="filterPlanElements()" class="list"
(cdkDropListDropped)="dropHandler($event)" [cdkDropListDisabled]="disabledPlanDD"
[cdkDropListEnterPredicate]="canDrop" [cdkDropListSortPredicate]="sortPredicate.bind(this)">
<div class="box" *ngFor="let item of latestList | async; index as boxIndex" cdkDrag
[cdkDragDisabled]="(!!this.planFilterAct.value) && boxIndex == 0" (contextmenu)="addNote($event, item)">
[cdkDropListEnterPredicate]="canDrop">
<div class="box" *ngFor="let item of filterPlanElements()" cdkDrag (contextmenu)="addNote($event, item)">
<div class="content">
<div class="content-left">
<div class="zone-name">{{areasMap?.get(item.area_key)?.name}}</div>
<div class="act">Act {{areasMap?.get(item.area_key)?.act}}
<mat-slide-toggle class="" color="accent" [(ngModel)]="item.checkpoint">
Checkpoint zone
</mat-slide-toggle>
</div>
</div>
<div class="zone-name">{{areasMap?.get(item.area_key)?.name}}</div>
<div class="act">Act {{areasMap?.get(item.area_key)?.act}}</div>
</div>
<div *ngIf="item.notes" class="notes">(Note)</div>
<div class="index">#{{planIndexOf(item)}}</div>
<div class="delete" (click)="remove(item)">+</div>
</div>
</div>
<div cdkDropList [cdkDropListData]="(this.latestList | async) !" class="list end"
<div cdkDropList [cdkDropListData]="plan.plan" class="list end"
style="position: relative;display: flex; flex-direction: column; justify-content: center;"
(cdkDropListDropped)="dropEndHandler($event)">
<span style="position: absolute; ">Place at end of list</span>
@ -108,4 +94,7 @@
</div>
</div>
</div>
</div>

@ -30,11 +30,6 @@
width: 100%;
}
.grow {
flex-grow: 1;
}
.box {
position: relative;
border-bottom: solid 1px map.get(palette.$nothing-dark-map, 50);
@ -49,12 +44,9 @@
padding: 20px 20px 20px 5px;
width: 100%;
overflow-x: hidden;
&:hover {
background-color: rgba(0, 0, 0, 0.1);
}
height: 80px;
}
.buttons {
@ -78,8 +70,8 @@
width: 15px;
height: 15px;
overflow: visible;
&:hover {
&:hover {
cursor: pointer;
}
}
@ -114,7 +106,6 @@
.list.cdk-drop-list-dragging .box:not(.cdk-drag-placeholder) {
transition: transform 125ms cubic-bezier(0, 0, 0.2, 1);
}
.right-settings {
gap: 8px;
}

@ -1,4 +1,4 @@
import { AfterViewInit, ChangeDetectorRef, Component, ElementRef, OnChanges, OnInit, QueryList, SimpleChanges, ViewChild, ViewChildren } from '@angular/core';
import { ChangeDetectorRef, Component, ElementRef, OnInit, ViewChild } from '@angular/core';
import {
CdkDrag,
@ -14,7 +14,7 @@ import { Plan, PlanElement } from '../_models/plan';
import { WorldAreaService } from '../_services/world-area.service';
import { FormsModule } from '@angular/forms';
import { Fuzzr } from '../fuzzr/fuzzr';
import { BehaviorSubject, first, from, skip } from 'rxjs';
import { from } from 'rxjs';
import { save } from '@tauri-apps/api/dialog';
import { PlanService } from '../_services/plan.service';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
@ -22,7 +22,7 @@ import { EditNotesComponentDialog } from './notes/notes.component';
import { open } from '@tauri-apps/api/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatSelectModule} from '@angular/material/select';
import { MatButtonModule } from '@angular/material/button';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
@ -47,13 +47,14 @@ interface Act {
MatInputModule,
MatSelectModule,
MatButtonModule,
MatSlideToggleModule,
MatSlideToggleModule
],
providers: []
})
export class EditorComponent implements OnInit {
areas?: WorldArea[];
planAreas: WorldArea[];
plan: Plan;
areasMap?: Map<String, WorldArea>;
areaSearchString: string = "";
planSearchString: string = "";
@ -61,22 +62,22 @@ export class EditorComponent implements OnInit {
filterAct: Act;
planFilterAct: Act;
acts: Act[];
@ViewChild('planListElement') planListElement!: ElementRef;
@ViewChild('planList') planListElement!: ElementRef;
autoScrollToEnd: boolean;
reverseDisplay: boolean;
disabledPlanDD: boolean;
original: PlanElement[] = [];
latestList: BehaviorSubject<PlanElement[]> = new BehaviorSubject<PlanElement[]>([]);
constructor(public worldAreaService: WorldAreaService, private cdr: ChangeDetectorRef, private planService: PlanService, public dialog: MatDialog) {
this.plan = new Plan({
plan: [],
current: 0
});
this.disabledPlanDD = false;
this.autoScrollToEnd = false;
this.latestList = new BehaviorSubject<any[]>([]);
this.planFuzzer = new Fuzzr(this.original, {
this.planFuzzer = new Fuzzr(this.plan.plan, {
toString: (e: PlanElement) => {
return this.areasMap?.get(e.area_key)?.name;
}
@ -99,19 +100,6 @@ export class EditorComponent implements OnInit {
this.planFilterAct = this.acts[0];
}
planSearchStringChange(value: string) {
this.planSearchString = value;
this.filterPlanElements();
}
planFilterActChange(value: Act) {
this.planFilterAct = value;
this.filterPlanElements();
}
reverseDisplayChange(value: boolean) {
this.reverseDisplay = value;
this.filterPlanElements();
}
ngOnInit(): void {
this.worldAreaService.getWorldAreas().subscribe(worldAreas => {
this.areas = [...worldAreas.values()];
@ -119,66 +107,32 @@ export class EditorComponent implements OnInit {
});
}
sortPredicate(index: number, _item: CdkDrag<WorldArea> | CdkDrag<PlanElement>) {
return !(this.planElemFilterBounds() && index == 0)
}
dropHandler(event: CdkDragDrop<WorldArea[]> | CdkDragDrop<PlanElement[]>) {
if (event.previousContainer === event.container && !isWorldAreaEvent(event)) {
const realCurrent = this.original.indexOf(event.previousContainer.data[event.currentIndex]);
const realPrev = this.original.indexOf(event.previousContainer.data[event.previousIndex]);
moveItemInArray(this.original, realPrev, realCurrent);
} else {
if (this.areas && isWorldAreaEvent(event)) {
const realCurrent = this.plan.plan.indexOf(event.previousContainer.data[event.currentIndex]);
const realPrev = this.plan.plan.indexOf(event.previousContainer.data[event.previousIndex]);
moveItemInArray(this.plan.plan, realPrev, realCurrent);
} else
if (this.plan && this.areas && isWorldAreaEvent(event)) {
if (event.container.data.length > 0 && 'connections_world_areas_keys' in event.container.data[0]) {
return;
}
const bounds = this.planElemFilterBounds();
let index = event.currentIndex;
if (bounds) {
index += bounds[0];
}
this.original.splice(index, 0, this.planItemFromArea(event.previousContainer.data[event.previousIndex]));
this.plan.plan.splice(event.currentIndex, 0, this.planItemFromArea(event.previousContainer.data[event.previousIndex]));
}
}
this.latestList.pipe(skip(1)).pipe(first()).subscribe(() => {
this.scrollToEnd();
});
this.filterPlanElements();
}
dropEndHandler(event: CdkDragDrop<WorldArea[]> | CdkDragDrop<PlanElement[]> | null) {
if (event == null) return;
dropEndHandler(event: CdkDragDrop<WorldArea[]> | CdkDragDrop<PlanElement[]>) {
if (isWorldAreaEvent(event) && this.areas) {
this.original.splice(this.getEnd(), 0, this.planItemFromArea(event.previousContainer.data[event.previousIndex]));
} else {
moveItemInArray(this.original, event.previousIndex, this.getEnd());
}
this.latestList.pipe(skip(1)).pipe(first()).subscribe(() => {
this.plan.plan.splice(this.plan.plan.length, 0, this.planItemFromArea(event.previousContainer.data[event.previousIndex]));
this.scrollToEnd();
});
this.filterPlanElements();
}
getEnd() {
let bounds = this.planElemFilterBounds();
if (bounds) {
return bounds[1];
} else {
return this.original.length;
moveItemInArray(this.plan.plan, event.previousIndex, this.plan.plan.length);
this.scrollToEnd();
}
}
remove(item: PlanElement) {
this.original.splice(this.planIndexOf(item), 1);
this.filterPlanElements();
this.plan.plan.splice(this.planIndexOf(item), 1);
}
canDrop = () => {
@ -208,85 +162,46 @@ export class EditorComponent implements OnInit {
if (!this.autoScrollToEnd) {
return;
}
this.cdr.detectChanges();
if (!this.reverseDisplay) {
this.planListElement.nativeElement.scrollTop = this.planListElement.nativeElement.scrollHeight;
} else {
this.planListElement.nativeElement.scrollTop = 0;
}
}
doubleClickArea(item: WorldArea) {
this.original.splice(this.original.length, 0, this.planItemFromArea(item));
this.latestList.pipe(skip(1)).pipe(first()).subscribe((_) => {
this.scrollToEnd();
});
this.filterPlanElements();
}
planElemFilterBounds() {
if (this.planFilterAct.value !== 0) {
let bounds = this.original.filter(item => item.anchor_act === this.planFilterAct.value || this.planFilterAct.value + 1 === item.anchor_act).map((value) => this.planIndexOf(value));
if (bounds.length == 2) {
return bounds;
}
if (bounds.length == 1 && this.planFilterAct.value == 10) {
bounds[1] = this.original.length;
return bounds;
}
}
return undefined;
this.plan.plan.splice(this.plan.plan.length, 0, this.planItemFromArea(item));
this.scrollToEnd();
}
filterPlanElements() {
const value = (): any[] => {
if (this.planSearchString !== "") {
if (this.planSearchString !== "" || this.planFilterAct.value != 0) {
this.disabledPlanDD = true;
return this.planFuzzer.search(this.planSearchString).map(({ item }) => item).filter(item => {
return this.areasMap?.get(item.area_key)?.act === this.planFilterAct.value || this.planFilterAct.value === 0;
});
} else {
this.disabledPlanDD = false;
}
if (this.planSearchString !== "" || this.planFilterAct.value != 0) {
let bounds = this.planElemFilterBounds();
const searched = this.planFuzzer.search(this.planSearchString).map(({ item }) => item);
if (bounds) {
const definedBounds: number[] = bounds;
return searched.filter(item => {
const index = this.planIndexOf(item);
return index >= definedBounds[0] && index < definedBounds[1];
});
} else {
return searched.filter(item => {
return this.areasMap?.get(item.area_key)?.act === this.planFilterAct.value || this.planFilterAct.value === 0;
});
}
} else {
return this.original;
return this.plan.plan;
}
}
this.latestList.next([... (this.reverseDisplay ? value().slice().reverse() : value())]);
if (this.reverseDisplay) {
return value().slice().reverse();
} else {
return value();
}
}
planIndexOf(planElement: PlanElement) {
return this.original.indexOf(planElement);
const index = this.plan.plan.indexOf(planElement);
return index;
}
clearPlan() {
while (this.original.length > 0) {
this.original.pop();
}
this.filterPlanElements();
this.plan.plan.length = 0;
this.cdr.detectChanges();
}
@ -298,9 +213,7 @@ export class EditorComponent implements OnInit {
}]
})).subscribe(file => {
if (file) {
const plan = new Plan();
plan.plan = [...this.original];
this.planService.savePlanAtPath(file, plan).subscribe();
this.planService.savePlan(file as string, this.plan);
}
});
}
@ -316,25 +229,18 @@ export class EditorComponent implements OnInit {
]
})).subscribe(file => {
if (file) {
// We disallow multiple but interface still says it can be multiple, thus the cast.
this.planService.loadPlanFromPath(file as string, false).subscribe(plan => {
while (this.original.length > 0) {
this.original.pop();
}
plan.plan.forEach(item => this.original.push(item));
this.filterPlanElements();
this.planService.loadPlanNoSave(file as string).subscribe(plan => {
this.plan.plan.length = 0;
plan.plan.forEach(p => this.plan.plan.push(p));
});
}
});
}
loadBasePlan() {
this.planService.getBasePlan().subscribe(plan => {
while (this.original.length > 0) {
this.original.pop();
}
plan.plan.forEach(item => this.original.push(item));
this.filterPlanElements();
this.planService.loadBasePlan().subscribe(plan => {
this.plan.plan.length = 0;
plan.plan.forEach(p => this.plan.plan.push(p));
})
}
@ -344,12 +250,11 @@ export class EditorComponent implements OnInit {
const dialogRef = this.dialog.open(EditNotesComponentDialog, {
data: {
note: item.notes
},
disableClose: true
},)
}
})
dialogRef.afterClosed().subscribe(note => {
if (note != undefined && note != null) {
if(note) {
if (item.notes !== note) {
item.edited = true;
}
@ -360,6 +265,7 @@ export class EditorComponent implements OnInit {
}
}
function isWorldAreaEvent(event: any): event is CdkDragDrop<WorldArea[]> {
return (event.previousContainer.data.length > 0 && 'connections_world_areas_keys' in event.previousContainer.data[0]);
}

@ -1,15 +1,17 @@
<div class="container">
<span>Edit note </span><span style="color: grey; font-size: 0.9em;">(supports markdown)</span>
<div>
<textarea [(ngModel)]="note" cols="50" rows="10"></textarea>
</div>
<div class="NOTES_COMPONENT">
<div class="container">
<span>Edit note </span><span style="color: grey; font-size: 0.9em;">(supports markdown)</span>
<div class="left">
<textarea [(ngModel)]="note" cols="50" rows="10"></textarea>
</div>
<span><span>Preview </span><span style="color: grey; font-size: 0.9em;">(Unscaled)</span></span>
<div class="w-100">
<div class="display-component" [innerHTML]="md.render(note ?? '')"></div>
<span><span>Preview </span><span style="color: grey; font-size: 0.9em;">(Unscaled)</span></span>
<div class="right">
<notes [note]="note"></notes>
</div>
</div>
<div mat-dialog-actions>
<button mat-button color="warn" (click)="cancel()">Cancel</button>
<button mat-button [mat-dialog-close]="note" cdkFocusInitial>Save</button>
</div>
</div>
<div mat-dialog-actions>
<button mat-button color="warn" (click)="cancel()">Cancel</button>
<button mat-button (click)="save()" cdkFocusInitial>Save</button>
</div>

@ -1,24 +0,0 @@
.container {
display: flex;
flex-direction: column;
height: 700px;
width: 500px;
align-items: center;
}
img {
display: block;
width: 100%;
height: 100%;
max-height: 100%;
object-fit: contain;
}
.display-component {
max-height: 100%;
height: 100%;
display: grid;
grid-auto-flow: row;
grid-template-rows: repeat(auto-fit, minmax(50px, 1fr));
width: 100%;
}

@ -0,0 +1,38 @@
<!-- <div class="NOTES_COMPONENT">
<div class="container">
<span>Edit note </span><span style="color: grey; font-size: 0.9em;">(supports markdown)</span>
<div class="left">
<textarea [(ngModel)]="note" cols="50" rows="10"></textarea>
</div>
<span><span>Preview </span><span style="color: grey; font-size: 0.9em;">(Unscaled)</span></span>
<div class="right">
<notes [note]="note"></notes>
</div>
</div>
<div mat-dialog-actions>
<button mat-button color="warn" (click)="cancel()">Cancel</button>
<button mat-button [mat-dialog-close]="note" cdkFocusInitial>Save</button>
</div>
</div> -->
<div class="NOTES_COMPONENT">
<div class="row">
<div class="col-6">
Original note
</div>
<div class="col-6">
Imported note
</div>
</div>
<div class="row">
<div class="col-6">
Note to save
</div>
<div class="col-6">
Preview
</div>
</div>
</div>

@ -1 +1 @@
<div #ref *ngIf="note" class="display-component" [innerHTML]="md.render(this.note)"></div>
<div #ref *ngIf="note" class="NOTES_COMPONENT display-component" [innerHTML]="md.render(note)"></div>

@ -1,30 +1,30 @@
.container {
display: flex;
flex-direction: column;
height: 800px;
width: 1200px;
}
.NOTES_COMPONENT {
.container {
display: flex;
flex-direction: column;
}
img {
display: block;
width: 100%;
height: 100%;
max-height: 100%;
object-fit: contain;
}
img {
display: block;
width: 100%;
height: 100%;
max-height: 100%;
object-fit: contain;
}
&.display-component {
max-height: 100%;
height: 100%;
display: grid;
grid-auto-flow: row;
grid-template-rows: repeat(auto-fit, minmax(50px, 1fr));
}
.display-component {
max-height: 100%;
height: 100%;
display: grid;
grid-auto-flow: row;
grid-template-rows: repeat(auto-fit, minmax(50px, 1fr));
font-size: 1.3em;
}
& {
font-size: 1.3em;
}
.note-preview {
min-height: 400px;
max-height: 400px;
min-width: 600px;
max-width: 600px;
.note-preview {
min-height: 400px;
}
}

@ -1,7 +1,7 @@
import { Component, ElementRef, Inject, Input, ViewChild, } from '@angular/core';
import { AfterViewInit, Component, ElementRef, Inject, Input, ViewChild, ViewEncapsulation } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
import { CommonModule } from '@angular/common';
import { MatButtonModule } from '@angular/material/button';
import { CommonModule, NgIf } from '@angular/common';
import { MatButton, MatButtonModule } from '@angular/material/button';
import { FormsModule } from '@angular/forms';
import { MatInputModule } from '@angular/material/input';
import { MatFormFieldModule } from '@angular/material/form-field';
@ -13,39 +13,43 @@ interface DialogData {
note?: string;
}
interface MergeDialogData {
originalNote: string;
importedNote: string;
}
@Component({
selector: 'notes',
templateUrl: './notes.component.html',
styleUrls: ['./notes.component.scss'],
standalone: true,
imports: [CommonModule, FormsModule, MatButtonModule],
encapsulation: ViewEncapsulation.None,
})
export class NotesComponent {
export class NotesComponent implements AfterViewInit {
@Input()
note?: string;
@ViewChild("ref")
ref?: ElementRef
realValue: any;
constructor(public md: MarkdownService) {}
constructor(public md: MarkdownService) {}
ngAfterViewInit(): void {
}
}
@Component({
selector: 'notes-editor',
templateUrl: 'edit-notes.component.html',
styleUrls: ['./edit-notes.component.scss'],
standalone: true,
imports: [CommonModule, FormsModule, MatDialogModule, MatFormFieldModule, MatInputModule, FormsModule, MatButtonModule, NotesComponent, ScalableComponent],
encapsulation: ViewEncapsulation.None,
})
export class EditNotesComponentDialog {
note?: string;
note: string;
constructor(
public dialogRef: MatDialogRef<EditNotesComponentDialog>,
@Inject(MAT_DIALOG_DATA) public data: DialogData,
public md: MarkdownService
) {
if (data.note) {
this.note = `${data.note}`;
@ -55,10 +59,32 @@ export class EditNotesComponentDialog {
}
cancel() {
this.dialogRef.close(undefined);
this.dialogRef.close();
}
}
@Component({
selector: 'notes-merger',
templateUrl: 'merge-notes.component.html',
standalone: true,
imports: [CommonModule, FormsModule, MatDialogModule, MatFormFieldModule, MatInputModule, FormsModule, MatButtonModule, NotesComponent, ScalableComponent],
encapsulation: ViewEncapsulation.None,
})
export class MergeNotesComponentDialog {
originalNote: string;
importedNote: string;
resultNote: string;
constructor(
public dialogRef: MatDialogRef<MergeNotesComponentDialog>,
@Inject(MAT_DIALOG_DATA) public data: MergeDialogData,
) {
this.originalNote = data.originalNote;
this.importedNote = data.importedNote;
this.resultNote = data.originalNote;
}
save() {
this.dialogRef.close(this.note);
cancel() {
this.dialogRef.close();
}
}

@ -1,12 +0,0 @@
import { Pipe, PipeTransform } from '@angular/core';
import { hmsTimestamp } from '../_services/time-tracker.service';
@Pipe({
name: 'hms',
standalone: true
})
export class HmsPipe implements PipeTransform {
transform(value?: number): string {
return hmsTimestamp(value);
}
}

@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Rect } from "./Rect";
export interface Config { initialPlanWindowPosition: Rect, hideOnUnfocus: boolean, toggleOverlay: string, prev: string, next: string, planBg: string, backdropBg: string, noteDefaultFg: string, poeClientLogPath: string | null, }

@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface Rect { x: number, y: number, width: number, height: number, }

@ -1,39 +1,30 @@
<div #globalTopLeft style="position: fixed; top: 0; left: 0; z-index: -1;"></div>
<div class=""></div>
<ng-container *ngIf="currentPlan">
<app-draggable-window [initialRect]="configService.config.initialPlanWindowPosition"
[configurable]="overlayService.interactable"
(savedRect)="configService.config.initialPlanWindowPosition = $event"
[backgroundColor]="backgroundColor ? backgroundColor : 'rgba(0, 0, 0, 0.1)'" (wheel)="onScroll($event)">
<div class="plan-window waypoint trial" [class]="specialClasses()">
<ng-container *ngIf="currentPlan">
<carousel class="zones" [initIndex]="currentPlan.current" [numVisible]="configService.config.numVisible"
[offset]="clampedOffset()" [slides]="currentPlan.plan" (afterInitSelf)="registerZoneSlides($event)"
<ng-container *ngIf="init">
<div #globalTopLeft style="position: fixed; top: 0; left: 0; z-index: -1;"></div>
<ng-container *ngIf="rect && planService.currentPlan">
<div class="target waypoint trial"
[style.background-color]="backgroundColor ? backgroundColor : 'rgba(0, 0, 0, 0.1)'"
[style.transform]="transform()" [style.width]="rect.width + 'px'" [style.height]="rect.height + 'px'"
[class]="specialClasses()" (wheel)="onScroll($event)" #targetRef>
<ng-container *ngIf="planService.currentPlan">
<carousel class="zones" [initIndex]="planService.currentPlan.current" [numVisible]="configService.config.numVisible"
[offset]="clampedOffset()" [slides]="planService.currentPlan.plan"
(afterInitSelf)="registerZoneSlides($event)"
[ngStyle]="zonesStyle()">
<ng-template let-slide let-index="index">
<div class="zone-slide" [style.color]="configService.config.noteDefaultFg"
[style.border]="index == currentPlan.current ? '1px white solid' : 'none'">
<div class="text-marker-left d-flex flex-row"
*ngIf="configService.config.showLivesplit && showDiff(slide)">
<div style="margin: 0 3px;" [class]="yourDiffClass(slide)">{{yourDiff(slide)}}
</div>
</div>
[style.border]="index == planService.currentPlan.current ? '1px white solid' : 'none'">
<div style="margin: 0 5px">
{{displayZoneName(worldAreaMap!.get(slide.area_key)!.name)}}
</div>
{{worldAreaMap!.get(slide.area_key)!.name}}
<div class="text-marker d-flex flex-row">
<div *ngIf="configService.config.showLivesplit" style="margin: 0 3px;">
{{cpMillis(slide)}}</div>
<div class="waypoint-text" *ngIf="hasWaypoint(slide.area_key)">(W)</div>
<div class="trial-text" *ngIf="hasTrial(slide.area_key)">(T)</div>
</div>
</div>
</ng-template>
</carousel>
<carousel *ngIf="!configService.config.detachNotes" [initIndex]="currentPlan.current"
[slides]="currentPlan.plan" (afterInitSelf)="registerCurrentSlides($event)">
<carousel [initIndex]="planService.currentPlan.current" [slides]="planService.currentPlan.plan"
(afterInitSelf)="registerCurrentSlides($event)">
<ng-template let-slide>
<scalable [clamp]="2">
<notes class="p-1" [note]="slide.notes" [style.color]="configService.config.noteDefaultFg"
@ -50,63 +41,51 @@
<div class="d-flex flex-column help-area">
<span><span class="waypoint-text">(W)</span> = Waypoint</span>
<span><span class="trial-text">(T)</span> = Trial</span>
<span>The plan window's will have a glow in the corresponding color(s) above to help
indicate if
the
current zone has any of those.</span>
<span>You can scroll in the plan window (while it is in 'interactable' mode) to quickly
switch
many
zones</span>
<span>The plan window's will have a glow in the corresponding color(s) above to help indicate if the current zone has any of those.</span>
<span>You can scroll in the plan window (while it is in 'interactable' mode) to quickly switch many zones</span>
</div>
</tooltip>
<span *ngIf="shouldDisplayTimer()"
class="timer">{{timeTrackerService.hmsTimestamp(timeTrackerService.elapsedTimeMillis)}}</span>
<!-- </div> -->
</div>
</app-draggable-window>
<app-draggable-window *ngIf="configService.config.showLiveAggregate"
[initialRect]="configService.config.initialAggWindowPosition" [configurable]="overlayService.interactable"
(savedRect)="configService.config.initialAggWindowPosition = $event"
[backgroundColor]="backgroundColor ? backgroundColor : 'rgba(0, 0, 0, 0.1)'">
<app-aggregate-display></app-aggregate-display>
</app-draggable-window>
<app-draggable-window [hidden]="!configService.config.detachNotes"
[initialRect]="configService.config.initialNotesWindowPosition" [configurable]="overlayService.interactable"
(savedRect)="configService.config.initialNotesWindowPosition = $event"
[backgroundColor]="backgroundColor ? backgroundColor : 'rgba(0, 0, 0, 0.1)'">
<div *ngIf="configService.config.detachNotes" class="standalone-notes">
<carousel [initIndex]="currentPlan.current" [slides]="currentPlan.plan"
(afterInitSelf)="registerCurrentSlides($event)">
<ng-template let-slide>
<scalable [clamp]="2">
<notes class="p-1" [note]="slide.notes" [style.color]="configService.config.noteDefaultFg"
#noteSlide></notes>
</scalable>
</ng-template>
</carousel>
</div>
<ngx-moveable #moveable [target]="targetRef" [draggable]="draggable && overlayService.interactable"
[resizable]="true && overlayService.interactable" (drag)="onDrag($event)" (resize)="onResize($event)"
(dragEnd)="onDragEnd($event)" (resizeEnd)="onResizeEnd($event)" [bounds]="bounds" [snappable]="true"
[style.visibility]="overlayService.interactable ? 'visible' : 'hidden'"></ngx-moveable>
</app-draggable-window>
</ng-container>
<ng-template cdkConnectedOverlay
[cdkConnectedOverlayOpen]="(settingsOpen || !planService.currentPlan) && overlayService.interactable"
[cdkConnectedOverlayOrigin]="globalTopLeft" (detach)="settingsOpen = false">
<div class="overlay container-fluid vw-100">
<div class="row row-cols-2">
<div class="planChooser col-xs-6 col-sm-6 col-md-4 col-lg-3 col-xl-3">
<div class="d-flex justify-content-evenly">
<div class="col-xs-6">
<button class="" mat-raised-button color="accent" (click)="openDialog()">Browse
Plans
</button>
</div>
<div class="col-xs-6">
<button class="" mat-raised-button color="accent" (click)="loadBasePlan()">
Load base plan
</button>
</div>
</div>
<div class="enumerated">
<mat-list role="list">
<mat-list-item class="d-flex flex-column" role="listitem"
*ngFor="let plan of planService.planStore"><button
(click)="setPrevious(plan)">{{plan}}</button></mat-list-item>
</mat-list>
</ng-container>
<ng-template cdkConnectedOverlay
[cdkConnectedOverlayOpen]="(settingsOpen || !currentPlan) && overlayService.interactable"
[cdkConnectedOverlayOrigin]="globalTopLeft" (detach)="settingsOpen = false">
<div class="overlay container-fluid vw-100">
<div class="row row-cols-2 h-100">
<app-plan-selection class="planChooser col-xs-6 col-sm-6 col-md-6 col-lg-4 col-xl-4 d-flex flex-column"></app-plan-selection>
<settings class="col-xs-6 col-sm-6 offset-md-1 col-md-5 offset-lg-4 col-lg-4 offset-xl-4 col-xl-4">
</settings>
</div>
</div>
<settings class="col-xs-6 col-sm-6 offset-md-3 col-md-5 offset-lg-5 col-lg-4 offset-xl-5 col-xl-4">
</settings>
</div>
<button mat-icon-button class="exit" *ngIf="planService.currentPlan"
(click)="settingsOpen = false"><span>+</span></button>
</div>
<button mat-icon-button class="exit" *ngIf="currentPlan" (click)="settingsOpen = false"><span>+</span></button>
</div>
</ng-template>
<ng-container *ngIf="overlayService.interactable">
<app-stopwatch-controls class="stop-stopwatch"></app-stopwatch-controls>
</ng-template>
</ng-container>

@ -7,39 +7,12 @@
}
}
.plan-window {
min-width: 50px;
min-height: 50px;
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
user-select: none;
&>* {
flex: 1 1 200px;
&:first-child {
flex: 1 1 15px;
min-height: 50px;
max-height: 120px;
}
}
}
.standalone-notes {
height: 100%;
width: 100%;
overflow: hidden;
}
.target-aggregate {
.target {
min-width: 50px;
min-height: 50px;
display: flex;
flex-direction: column;
user-select: none;
z-index: -1;
&>* {
flex: 1 1 200px;
@ -58,13 +31,6 @@
gap: 2px;
}
.text-marker-left {
position: absolute;
left: 10px;
bottom: 0px;
gap: 2px;
}
.waypoint-text {
color: rgba(25, 255, 255, 0.5);
}
@ -120,7 +86,7 @@
}
.zone-slide {
position: relative;
display: relative;
height: 100%;
display: flex;
align-items: center;
@ -151,12 +117,6 @@ notes {
left: 0;
}
.timer {
position: absolute;
top: 0;
left: 32px;
}
.help-area {
background-color: rgba(50, 50, 50, 1);
border: 1px solid black;
@ -176,6 +136,7 @@ notes {
font-size: 10rem;
& span {
display: flex;
align-items: center;
@ -199,16 +160,16 @@ notes {
'opsz' 48
}
.stop-stopwatch {
position: absolute;
bottom: 0;
right: 0;
.planChooser {
overflow: hidden;
}
.negative-diff {
color: green;
}
.positive-diff {
color: red;
.enumerated {
display: block;
overflow: hidden;
mat-list {
max-height: 100%;
overflow-y: scroll;
}
}

@ -1,133 +1,92 @@
import { Component, Input, NgZone, OnInit } from '@angular/core';
import { AfterViewInit, ChangeDetectorRef, Component, Input, NgZone, OnInit, ViewChild } from '@angular/core';
import { NgxMoveableComponent, OnDragEnd, OnResize, OnResizeEnd } from 'ngx-moveable';
import { OnDrag } from 'ngx-moveable';
import { ConfigService } from '../_services/config.service';
import { Rect } from '../_models/generated/Rect';
import { ShortcutService } from '../_services/shortcut.service';
import { CarouselComponent } from '../carousel/carousel.component';
import { PlanService } from '../_services/plan.service';
import { Plan, PlanElement } from '../_models/plan';
import { WorldAreaService } from '../_services/world-area.service';
import { WorldArea } from '../_models/world-area';
import { Subscription, from } from 'rxjs';
import { OverlayService, StateEvent } from '../_services/overlay.service';
import { from } from 'rxjs';
import { open } from '@tauri-apps/api/dialog';
import { OverlayService } from '../_services/overlay.service';
import { appWindow } from '@tauri-apps/api/window';
import { EventsService } from '../_services/events.service';
import { Event } from '@tauri-apps/api/event';
import { MatDialog } from '@angular/material/dialog';
import { TimeTrackerService } from '../_services/time-tracker.service';
import { RunStatService } from '../_services/run-stat.service';
@Component({
selector: 'plan-display',
templateUrl: './plan-display.component.html',
styleUrls: ['./plan-display.component.scss']
})
export class PlanDisplayComponent implements OnInit {
@Input() backgroundColor?: string;
export class PlanDisplayComponent implements AfterViewInit, OnInit {
@Input() backgroundColor?: String;
draggable: boolean = true;
rect?: Rect;
bounds: any = { "left": 0, "top": 0, "right": 0, "bottom": 0, "position": "css" };
@ViewChild("moveable") moveable?: NgxMoveableComponent;
slideIndex: number = 0;
zoneSlides?: CarouselComponent<PlanElement>;
currentSlides?: CarouselComponent<PlanElement>;
worldAreaMap?: Map<String, WorldArea>;
settingsOpen: boolean = false;
init: boolean = false;
hasAttachedOnce: boolean = false;
overlayStateChangeHandle?: Subscription;
bindsAreSetup: boolean = false;
nextBind?: Subscription;
prevBind?: Subscription;
currentPlan?: Plan;
constructor(
public configService: ConfigService,
public planService: PlanService,
public worldAreaService: WorldAreaService,
public overlayService: OverlayService,
public dialog: MatDialog,
public timeTrackerService: TimeTrackerService,
private events: EventsService,
private shortcut: ShortcutService,
private zone: NgZone,
private runStatService: RunStatService,
) {
this.planService.getCurrentPlan().subscribe(plan => {
this.currentPlan = plan;
constructor(private events: EventsService, public configService: ConfigService, private cdr: ChangeDetectorRef, private shortcut: ShortcutService, public planService: PlanService, public worldAreaService: WorldAreaService, public overlayService: OverlayService, private zone: NgZone) {
window.addEventListener("resize", () => {
this.zone.run(() => {
this.windowInitHandler()
})
});
if (this.configService.config.enableStopwatch) {
this.loadComparisonData(this.currentPlan);
appWindow.listen("entered", (entered) => {
if (this.planService.currentPlan) {
const current = this.planService.currentPlan.current;
const length = this.planService.currentPlan.plan.length;
if (current + 1 < length) {
if (entered.payload === this.planService.currentPlan.plan[current + 1].area_key) {
this.zone.run(() => this.next());
}
}
}
this.timeTrackerService.onNewRun(plan);
//Close settings anytime we get a new current plan.
this.settingsOpen = false;
setTimeout(() => this.setIndex(plan.current), 0);
})
});
this.registerOnZoneEnter();
this.planService.getPreviousPlans();
}
loadComparisonData(plan: Plan) {
if (!this.configService.config.runCompareHistory) {
return;
windowInitHandler() {
if (window.innerWidth > 0) {
this.ngAfterViewInit();
}
this.timeTrackerService.loadHistory(this.configService.config.runCompareHistory).subscribe(history => {
if (history) {
this.runStatService.insertTimesAtCheckpoints(history, plan);
}
});
}
ngOnInit() {
this.worldAreaService.getWorldAreas().subscribe(a => this.worldAreaMap = a);
registerOnZoneEnter() {
appWindow.listen("entered", (entered) => {
if (this.currentPlan && typeof entered.payload == "string") {
if (this.currentPlan.isNext(entered.payload)) {
this.zone.run(() => this.next());
}
}
});
}
ngOnInit() {
this.worldAreaService.getFullWorldAreas().subscribe(a => this.worldAreaMap = a);
this.overlayStateChangeHandle = this.events.listen<StateEvent>("OverlayStateChange").subscribe(this.onOverlayStateChange.bind(this));
this.events.listen<boolean>("overlay_or_target_focus").subscribe((event: Event<boolean>) => {
if (!event.payload) {
this.destroyBinds();
} else {
this.setupBinds();
}
});
abs(v: number) {
return Math.abs(v);
}
onOverlayStateChange(event: Event<StateEvent>) {
if (event.payload.Hidden) {
this.destroyBinds();
} else {
this.setupBinds();
}
transform() {
return `translate(${this.rect!.x}px, ${this.rect!.y}px)`;
}
destroyBinds() {
if (this.bindsAreSetup) {
this.nextBind?.unsubscribe();
this.prevBind?.unsubscribe();
this.bindsAreSetup = false;
}
width() {
return `${this.rect!.width}px`;
}
abs(v: number) {
return Math.abs(v);
height() {
return `${this.rect!.height}px`;
}
hasWaypoint(key?: string): boolean {
if (!key) {
key = this.currentPlan!.plan[this.currentPlan!.current].area_key;
key = this.planService.currentPlan!.plan[this.planService.currentPlan!.current].area_key;
}
const world_area = this.worldAreaMap?.get(key);
return world_area!.has_waypoint;
@ -135,104 +94,83 @@ export class PlanDisplayComponent implements OnInit {
hasTrial(key?: string): boolean {
if (!key) {
key = this.currentPlan!.plan[this.currentPlan!.current].area_key;
key = this.planService.currentPlan!.plan[this.planService.currentPlan!.current].area_key;
}
return this.worldAreaService.hasTrial(key);
}
registerZoneSlides(carousel: CarouselComponent<PlanElement>) {
this.zoneSlides = carousel;
this.zoneSlides.setIndex(this.slideIndex);
}
ngAfterViewInit(): void {
if (window.innerWidth > 0) {
const cfgRect = this.configService.config.initialPlanWindowPosition;
setupBinds() {
if (this.currentSlides && !this.bindsAreSetup) {
this.nextBind = this.shortcut.register(this.configService.config.prev).subscribe((_shortcut) => {
this.prev();
if (this.configService.config.enableStopwatch) {
this.timeTrackerService.onForcePrev(this.currentPlan!.plan[this.currentPlan!.current].area_key);
this.checkCheckpoint();
}
});
this.prevBind = this.shortcut.register(this.configService.config.next).subscribe((_shortcut) => {
this.next();
if (this.configService.config.enableStopwatch) {
this.timeTrackerService.onForceNext(this.currentPlan!.plan[this.currentPlan!.current].area_key);
this.checkCheckpoint();
}
});
this.rect = {
x: cfgRect.x * window.innerWidth,
y: cfgRect.y * window.innerHeight,
width: cfgRect.width * window.innerWidth,
height: cfgRect.height * window.innerHeight,
}
this.moveable?.updateRect();
this.bindsAreSetup = true;
setTimeout(() => this.cdr.detectChanges(), 0);
this.init = true;
}
}
registerCurrentSlides(carousel: CarouselComponent<PlanElement>) {
this.currentSlides = carousel;
this.currentSlides.setIndex(this.slideIndex);
this.setupBinds();
onDrag(e: OnDrag) {
this.rect!.x = e.translate[0];
this.rect!.y = e.translate[1];
}
next() {
if (this.overlayService.visible) {
this.currentPlan!.next();
this.checkCheckpoint();
this.currentSlides?.next();
this.zoneSlides?.next();
}
onDragEnd(e: OnDragEnd) {
this.saveRect();
}
checkCheckpoint() {
if (!this.currentPlan || !this.timeTrackerService.isActive) return;
const currentElem = this.currentPlan.plan[this.currentPlan.current];
if (currentElem.checkpoint && !currentElem.checkpoint_your_millis) {
currentElem.checkpoint_your_millis = this.timeTrackerService.elapsedTimeMillis;
this.timeTrackerService.reportCheckpoint(currentElem.uuid!);
}
onResize(e: OnResize) {
this.rect!.width = e.width;
this.rect!.height = e.height;
this.onDrag(e.drag);
}
yourDiff(element: PlanElement) {
if (!element.checkpoint || !element.checkpoint_your_millis || !element.checkpoint_millis) return "";
onResizeEnd(e: OnResizeEnd) {
this.saveRect();
}
const diff = element.checkpoint_your_millis - element.checkpoint_millis;
const neg = diff <= 0;
const abs = Math.abs(diff);
if (diff == 0) {
return `${neg ? "-" : "+"}00:00:00`;
} else {
return `${neg ? "-" : "+"}${this.timeTrackerService.hmsTimestamp(abs)}`;
saveRect() {
const toCfgRect = this.rect!;
this.configService.config.initialPlanWindowPosition = {
x: toCfgRect.x / window.innerWidth,
y: toCfgRect.y / window.innerHeight,
width: toCfgRect.width / window.innerWidth,
height: toCfgRect.height / window.innerHeight,
}
}
yourDiffClass(element: PlanElement): string {
if (!element.checkpoint || !element.checkpoint_your_millis || !element.checkpoint_millis) return "";
const diff = element.checkpoint_your_millis - element.checkpoint_millis;
const neg = diff <= 0;
return neg ? "negative-diff" : "positive-diff";
registerZoneSlides(carousel: CarouselComponent<PlanElement>) {
this.zoneSlides = carousel;
this.zoneSlides.setIndex(this.slideIndex);
}
showDiff(element: PlanElement) {
return element.checkpoint && element.checkpoint_your_millis && element.checkpoint_millis;
}
registerCurrentSlides(carousel: CarouselComponent<PlanElement>) {
this.currentSlides = carousel;
this.currentSlides.setIndex(this.slideIndex);
if (this.currentSlides) {
this.shortcut.register(this.configService.config.prev, this.prev.bind(this));
this.shortcut.register(this.configService.config.next, this.next.bind(this));
}
}
cpMillis(element: PlanElement) {
if (!element.checkpoint) return "";
if (!element.checkpoint_millis) return "N/A";
return this.timeTrackerService.hmsTimestamp(element.checkpoint_millis);
next() {
this.planService.currentPlan!.next();
this.currentSlides?.next();
this.zoneSlides?.next();
}
prev() {
if (this.overlayService.visible) {
this.currentPlan!.prev();
this.currentSlides?.prev();
this.zoneSlides?.prev();
}
this.planService.currentPlan!.prev();
this.currentSlides?.prev();
this.zoneSlides?.prev();
}
setIndex(index: number) {
@ -245,22 +183,54 @@ export class PlanDisplayComponent implements OnInit {
}
}
setPrevious(plan: string) {
this.planService.loadPrevious(plan).subscribe(plan => {
setTimeout(() => this.setIndex(plan.current), 0);
});
}
settingsClick(event: any) {
this.settingsOpen = !this.settingsOpen;
event.stopPropagation();
}
openDialog() {
from(open({
multiple: false,
filters: [
{
name: "JSON (.json)",
extensions: ['json']
}
]
})).subscribe(file => {
if (file) {
this.planService.loadPlan(file as string).subscribe(plan => {
if (plan) {
this.settingsOpen = false;
}
});
}
});
}
loadBasePlan() {
this.planService.loadBasePlan().subscribe(plan => {
this.planService.currentPlan = new Plan(plan);
if (this.zoneSlides) {
this.zoneSlides.setIndex(0);
}
if (this.currentSlides) {
this.currentSlides.setIndex(0);
}
})
}
onScroll(event: WheelEvent) {
if (event.deltaY < 0) {
this.prev();
this.timeTrackerService.onForcePrev(this.currentPlan!.plan[this.currentPlan!.current].area_key);
this.checkCheckpoint();
} else {
this.next();
this.timeTrackerService.onForceNext(this.currentPlan!.plan[this.currentPlan!.current].area_key);
this.checkCheckpoint();
}
}
@ -280,34 +250,4 @@ export class PlanDisplayComponent implements OnInit {
'max-height': `${this.configService.config.numVisible * 40}px`
}
}
shouldDisplayTimer(): boolean {
if (!this.configService.config.enableStopwatch) return false;
return this.timeTrackerService.isActive;
}
displayZoneName(zoneName: string) {
if (this.configService.config.shortenZoneNames) {
return this.trim(this.trimUnneccesaryWords(zoneName));
} else {
return zoneName;
}
}
trimUnneccesaryWords(zoneName: string) {
if (zoneName.toLowerCase().startsWith("the ")) {
return zoneName.substring(4);
} else {
return zoneName;
}
}
trim(zoneName: string, letters: number = 12) {
if (zoneName.length > letters + 3) {
return zoneName.substring(0, letters) + "...";
}
return zoneName;
}
}

@ -12,19 +12,11 @@ import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatListModule } from '@angular/material/list';
import { ScalableComponent } from '../Scalable/scalable.component';
import { MatTooltipModule } from '@angular/material/tooltip';
import {MatTooltipModule} from '@angular/material/tooltip';
import { TooltipComponent } from '../tooltip/tooltip.component';
import { AngularSvgIconModule } from 'angular-svg-icon';
import { ScrollingModule } from '@angular/cdk/scrolling';
import { MatDialogModule } from '@angular/material/dialog';
import { ResumeDialog } from './resume-dialog.component';
import { AggregateDisplayComponent } from '../aggregate-display/aggregate-display.component';
import { DraggableWindowComponent } from '../draggable-window/draggable-window.component';
import { StopwatchControlsComponent } from './stopwatch-controls/stopwatch-controls.component';
import { PlanSelectionComponent } from './plan-selection/plan-selection.component';
@NgModule({
declarations: [
PlanDisplayComponent,
PlanDisplayComponent
],
imports: [
CommonModule,
@ -41,15 +33,7 @@ import { PlanSelectionComponent } from './plan-selection/plan-selection.componen
MatListModule,
ScalableComponent,
MatTooltipModule,
TooltipComponent,
AngularSvgIconModule,
ScrollingModule,
MatDialogModule,
ResumeDialog,
AggregateDisplayComponent,
DraggableWindowComponent,
StopwatchControlsComponent,
PlanSelectionComponent,
TooltipComponent
],
exports: [
PlanDisplayComponent

@ -1,37 +0,0 @@
<div class="d-flex justify-content-evenly align-items-center mb-2 mt-2">
<div class="col-xs-4">
<button class="" mat-raised-button color="accent" (click)="openDialog()">Browse
Plans
</button>
</div>
<div class="col-xs-4">
<button class="" mat-raised-button color="accent" (click)="loadBasePlan()">
Load base plan
</button>
</div>
<div class="col-xs-4">
<button class="" mat-raised-button color="accent" (click)="loadFromUrl()">
Import from url
</button>
</div>
</div>
<div class="enumerated d-flex flex-column">
<mat-list role="list" class="d-flex flex-column h-100">
<cdk-virtual-scroll-viewport itemSize="10" class="h-100">
<mat-list-item class="d-flex flex-column" role="listitem" *cdkVirtualFor="let plan of plans">
<span>
<button [disabled]="disablePlans" (click)="loadPrevious(plan.stored_path!)">
<img *ngIf="plan.update_url" src="assets/public.svg">{{plan.name}}
</button><button *ngIf="plan.update_url" class="planActionButton" [disabled]="disablePlans"
(click)="checkForPlanUpdate(plan)" [title]="recentPlanTitle(plan)">
<svg-icon *ngIf="!hasUpdate(plan)" src="assets/material-sync.svg" />
<svg-icon *ngIf="hasUpdate(plan) && !isErr(plan)" src="assets/material-check.svg"
class="nice" />
<svg-icon *ngIf="hasUpdate(plan) && isErr(plan)" src="assets/material-warning.svg"
class="notnice" />
</button>
</span>
</mat-list-item>
</cdk-virtual-scroll-viewport>
</mat-list>
</div>

@ -1,34 +0,0 @@
.planChooser {
overflow: hidden;
}
.enumerated {
display: block;
overflow: hidden;
flex-grow: 1;
background-color: rgba(200, 200, 220, 0.15);
border: 1px solid rgba(255, 255, 255, 0.3);
mat-list {
max-height: 100%;
overflow-y: scroll;
}
}
.nice {
svg {
fill: green;
}
}
.notnice {
svg {
fill: red;
}
}
.planActionButton {
align-items: center;
justify-content: center;
padding-bottom: 1px;
}

@ -1,21 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PlanSelectionComponent } from './plan-selection.component';
describe('PlanSelectionComponent', () => {
let component: PlanSelectionComponent;
let fixture: ComponentFixture<PlanSelectionComponent>;
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [PlanSelectionComponent]
});
fixture = TestBed.createComponent(PlanSelectionComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

@ -1,143 +0,0 @@
import { Component } from '@angular/core';
import { Plan, PlanMetadata } from 'src/app/_models/plan';
import { PlanService, UrlError } from 'src/app/_services/plan.service';
import { open } from '@tauri-apps/api/dialog';
import { from } from 'rxjs';
import { CommonModule } from '@angular/common';
import { MatButtonModule } from '@angular/material/button';
import { ScrollingModule } from '@angular/cdk/scrolling';
import { MatListModule } from '@angular/material/list';
import { AngularSvgIconModule } from 'angular-svg-icon';
@Component({
selector: 'app-plan-selection',
templateUrl: './plan-selection.component.html',
styleUrls: ['./plan-selection.component.scss'],
standalone: true,
imports: [CommonModule, MatButtonModule, ScrollingModule, MatListModule, AngularSvgIconModule]
})
export class PlanSelectionComponent {
plans: PlanMetadata[] = [];
checkingPlanUpdate: boolean = false;
recentUpdateAttempts: Map<string, Date | UrlError> = new Map<string, Date | UrlError>();
constructor(
private planService: PlanService,
) {
this.planService.getStoredPlans().subscribe(plans => {
this.plans = plans;
})
}
get disablePlans(): boolean {
return this.checkingPlanUpdate;
}
openDialog() {
from(open({
multiple: false,
filters: [
{
name: "JSON (.json)",
extensions: ['json']
}
]
})).subscribe(file => {
if (file) {
this.planService.loadPlanFromPath(file as string).subscribe(plan => {
if (plan) {
this.planService.setCurrentPlan(plan);
}
});
}
});
}
loadBasePlan() {
this.planService.getBasePlan().subscribe(plan => {
plan.current = 0;
this.planService.setCurrentPlan(plan);
});
}
loadFromUrl() {
this.planService.loadFromUrl().subscribe(plan => {
if (plan) {
this.planService.savePlanAtStore(plan.name!, plan).subscribe((path) => {
if (path) {
plan.setPath(path);
}
});
this.planService.setCurrentPlan(plan);
}
});
}
loadPrevious(path: string) {
this.planService.loadPlanFromPath(path, false).subscribe(plan => {
this.planService.setCurrentPlan(plan);
});
}
checkForPlanUpdate(plan: PlanMetadata) {
this.checkingPlanUpdate = true;
this.planService.checkForPlanUpdate(plan).subscribe({
next: updatedPlan => {
console.log("check update: ", updatedPlan);
this.planService.loadPlanFromPath(plan.stored_path!, false).subscribe(oldPlan => {
console.log("oldPlan", oldPlan);
const newCurrent = this.commonUUIDToNewCurrent(oldPlan, updatedPlan);
updatedPlan.current = newCurrent;
//TODO: Interface to determine if we want to save the plan or not (allow not doing it in case user regrets... Also needs position error fix).
this.planService.savePlanAtStore(oldPlan.name!, updatedPlan, true).subscribe();
})
},
complete: () => {
this.checkingPlanUpdate = false
this.recentUpdateAttempts.set(plan.update_url!, new Date());
},
error: (err) => {
this.recentUpdateAttempts.set(plan.update_url!, err);
if (err instanceof UrlError) {
alert("Error retrieving plan update (" + err.status + "): " + err.message);
} else if (err instanceof Error) {
alert("Unexpected error: " + err.message);
}
this.checkingPlanUpdate = false;
}
});
}
recentPlanTitle(plan: PlanMetadata) {
if (!this.hasUpdate(plan)) return "Check for update";
if (this.hasUpdate(plan) && !this.isErr(plan)) return "Up to date";
if (this.hasUpdate(plan) && this.isErr(plan)) return "Error updating";
return "";
}
isErr(plan: PlanMetadata) {
return this.recentUpdateAttempts.get(plan.update_url!) instanceof UrlError;
}
hasUpdate(plan: PlanMetadata): any {
return this.recentUpdateAttempts.has(plan.update_url!);
}
private commonUUIDToNewCurrent(oldPlan: Plan, updatedPlan: Plan): number {
let bestOldCurrent = oldPlan.current;
let newIndex = 0;
//Attempt current plan element's UUID first, otherwise step backwards until we find a match
while (bestOldCurrent >= 0) {
const tempNewIndex = updatedPlan.plan.findIndex(e => e.uuid === oldPlan.plan[bestOldCurrent].uuid);
if (tempNewIndex !== -1) {
newIndex = tempNewIndex;
break;
}
bestOldCurrent--;
}
return newIndex;
}
}

@ -1,6 +0,0 @@
You have an ongoing run history <b>(time tracking)</b> going, would you like to resume it?
<div mat-dialog-actions>
<button mat-raised-button color="color-resume-instant" (click)="instant()">Resume instantaneously</button>
<button mat-raised-button color="color-resume-next" (click)="next()" cdkFocusInitial>Resume on entering next zone</button>
<button mat-raised-button color="color-resume-no" (click)="discard()" cdkFocusInitial>Start new</button>
</div>

@ -1,33 +0,0 @@
import { Component } from "@angular/core";
import { FormsModule } from "@angular/forms";
import { MatButtonModule } from "@angular/material/button";
import { MatDialogModule, MatDialogRef } from "@angular/material/dialog";
import { MatFormFieldModule } from "@angular/material/form-field";
import { MatInputModule } from "@angular/material/input";
export enum Resume {
Discard,
Next,
Instant
}
@Component({
selector: 'resume-dialog',
templateUrl: 'resume-dialog.component.html',
standalone: true,
imports: [MatDialogModule, MatFormFieldModule, MatInputModule, FormsModule, MatButtonModule],
})
export class ResumeDialog {
instant() {
this.dialogRef.close(Resume.Instant);
}
next() {
this.dialogRef.close(Resume.Next);
}
discard() {
this.dialogRef.close(Resume.Discard);
}
constructor(public dialogRef: MatDialogRef<ResumeDialog>) {}
}

@ -1,5 +0,0 @@
<button mat-raised-button color="warn" *ngIf="timeTrackerService.isActive"
(click)="timeTrackerService.stop()">Force stop stopwatch</button>
<button mat-raised-button color="warn"
*ngIf="!timeTrackerService.isActive && timeTrackerService.hasRunLoaded" (click)="timeTrackerService.startLoaded()">Start last
loaded</button>

@ -1,21 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { StopwatchControlsComponent } from './stopwatch-controls.component';
describe('StopwatchControlsComponent', () => {
let component: StopwatchControlsComponent;
let fixture: ComponentFixture<StopwatchControlsComponent>;
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [StopwatchControlsComponent]
});
fixture = TestBed.createComponent(StopwatchControlsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

@ -1,15 +0,0 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { TimeTrackerService } from 'src/app/_services/time-tracker.service';
@Component({
selector: 'app-stopwatch-controls',
templateUrl: './stopwatch-controls.component.html',
styleUrls: ['./stopwatch-controls.component.scss'],
imports: [CommonModule, MatButtonModule],
standalone: true
})
export class StopwatchControlsComponent {
constructor(public timeTrackerService: TimeTrackerService) { }
}

@ -1,7 +0,0 @@
<label>Plan name</label><input matInput type="text" [(ngModel)]="data2.name"><br>
<label>Url</label><input matInput type="text" [(ngModel)]="data2.url">
<div mat-dialog-actions>
<button mat-button (click)="cancel()">Cancel</button>
<button mat-button [mat-dialog-close]="data2" cdkFocusInitial>Save</button>
</div>

@ -1,31 +0,0 @@
import { Component, Inject } from "@angular/core";
import { FormsModule } from "@angular/forms";
import { MatButtonModule } from "@angular/material/button";
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from "@angular/material/dialog";
import { MatFormFieldModule } from "@angular/material/form-field";
import { MatInputModule } from "@angular/material/input";
interface UrlData {
url: string;
name: string;
}
@Component({
selector: 'url-dialog',
templateUrl: 'url-dialog.component.html',
standalone: true,
imports: [MatDialogModule, MatFormFieldModule, MatInputModule, FormsModule, MatButtonModule],
})
export class UrlDialog {
public data2: UrlData;
constructor(
public dialogRef: MatDialogRef<UrlDialog>,
@Inject(MAT_DIALOG_DATA) public data: UrlData,
) {
this.data2 = data;
}
cancel() {
this.dialogRef.close();
}
}

@ -1,60 +0,0 @@
<button mat-stroked-button color="warn" (click)="reset()">Reset view</button>
<ng-container *ngIf="!shouldShowTable && cache">
<div class="cache-viewport">
<div *ngFor="let item of this.cache | keyvalue: myOrder" class="cache-item d-flex flex-row p-4">
<div class="d-flex flex-column">
<span>Plan: {{item.value.associatedName}}</span>
<span>Run time: {{hms(item.value.currentElapsedMillis)}}</span>
<span>Last updated: {{dateFormat(item.value.last_updated)}}</span>
</div>
<div class="d-flex flex-column justify-content-center align-items-center">
<button mat-stroked-button color="accent" (click)="loadMain(item.key)">Load</button>
<button mat-stroked-button color="accent" (click)="setComparison(item.key)">Live compare</button>
</div>
</div>
</div>
</ng-container>
<ng-container *ngIf="shouldShowTable">
<mat-slide-toggle [(ngModel)]="this.aggregate">Show aggregated results</mat-slide-toggle>
<table mat-table [dataSource]="dataSource">
<ng-container matColumnDef="zoneName">
<th mat-header-cell *matHeaderCellDef> Zone </th>
<td mat-cell *matCellDef="let entry"> {{entry.zoneName}} </td>
</ng-container>
<ng-container matColumnDef="entryTime">
<th mat-header-cell *matHeaderCellDef> Time of entry </th>
<td mat-cell *matCellDef="let entry"> {{entry.entryTime}} </td>
</ng-container>
<ng-container matColumnDef="estimatedExit">
<th mat-header-cell *matHeaderCellDef> Time of estimated exit </th>
<td mat-cell *matCellDef="let entry"> {{entry.estimatedExit}} </td>
</ng-container>
<ng-container matColumnDef="estimatedTimeSpent">
<th mat-header-cell *matHeaderCellDef> Estimated time spent </th>
<td mat-cell *matCellDef="let entry"> {{entry.estimatedTimeSpent}} </td>
</ng-container>
<ng-container matColumnDef="aggregateFirstEntry">
<th mat-header-cell *matHeaderCellDef> Time of first entry </th>
<td mat-cell *matCellDef="let entry"> {{entry.aggregateFirstEntry}} </td>
</ng-container>
<ng-container matColumnDef="aggregateLastExit">
<th mat-header-cell *matHeaderCellDef> Time of estimated last exit </th>
<td mat-cell *matCellDef="let entry"> {{entry.aggregateLastExit}} </td>
</ng-container>
<ng-container matColumnDef="aggregateTimeSpent">
<th mat-header-cell *matHeaderCellDef> Estimated total time spent </th>
<td mat-cell *matCellDef="let entry"> {{entry.aggregateTimeSpent}} </td>
</ng-container>
<ng-container matColumnDef="aggregateNumEntries">
<th mat-header-cell *matHeaderCellDef> Number of entries </th>
<td mat-cell *matCellDef="let entry"> {{entry.aggregateNumEntries}} </td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns; sticky: true"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
</ng-container>

@ -1,3 +0,0 @@
.cache-item {
gap: 12px;
}

@ -1,21 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RunStatsComponent } from './run-stats.component';
describe('RunStatsComponent', () => {
let component: RunStatsComponent;
let fixture: ComponentFixture<RunStatsComponent>;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [RunStatsComponent]
});
fixture = TestBed.createComponent(RunStatsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

@ -1,155 +0,0 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule, KeyValue } from '@angular/common';
import { EntryType, RunHistory, RunHistoryMetadata, TimeTrackerService } from '../_services/time-tracker.service';
import { MatTableModule } from '@angular/material/table';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { FormsModule } from '@angular/forms';
import { ScrollingModule } from '@angular/cdk/scrolling';
import { MatButtonModule } from '@angular/material/button';
import { WorldAreaService } from '../_services/world-area.service';
import { WorldArea } from '../_models/world-area';
import { AggregateRunStat, RunStat, RunStatService, RunStatType, UnformattedAggregationData, UnformattedRunStat } from '../_services/run-stat.service';
import { ConfigService } from '../_services/config.service';
@Component({
selector: 'app-run-stats',
standalone: true,
imports: [CommonModule, MatTableModule, MatSlideToggleModule, FormsModule, ScrollingModule, MatButtonModule],
templateUrl: './run-stats.component.html',
styleUrls: ['./run-stats.component.scss']
})
export class RunStatsComponent implements OnInit {
aggregated?: AggregateRunStat[];
direct?: RunStat[];
aggregate: boolean = true;
cache?: Map<string, RunHistoryMetadata>;
worldAreaMap?: Map<string, WorldArea>;
constructor(
private timeTrackerService: TimeTrackerService,
private worldAreaService: WorldAreaService,
private runStatService: RunStatService,
private configService: ConfigService
) {
this.worldAreaService.getFullWorldAreas().subscribe((data) => {
this.worldAreaMap = data;
})
}
ngOnInit(): void {
this.timeTrackerService.storedHistories.subscribe((data) => {
this.cache = data;
})
}
get dataSource(): RunStatType[] {
return (this.aggregate ? this.aggregated : this.direct) ?? [];
}
get hasInitializedData() {
return this.aggregated || this.direct;
}
get shouldShowTable() {
return this.hasInitializedData;
}
loadMain(uuid: string) {
this.timeTrackerService.loadHistory(uuid).subscribe((data => {
if (data) {
this.onLoad(data);
}
}));
}
formatAggregate(data: UnformattedAggregationData) {
const { aggregation, aggregateNAId } = data;
return aggregation.map((entry) => {
let aggregateTimeSpent;
if (aggregateNAId === entry.zoneId) {
aggregateTimeSpent = this.timeTrackerService.hmsTimestamp(entry.aggregateTimeSpent) + " + N/A"
} else {
aggregateTimeSpent = this.timeTrackerService.hmsTimestamp(entry.aggregateTimeSpent);
}
return {
zoneName: this.resolveZone(entry.zoneId),
aggregateFirstEntry: this.timeTrackerService.hmsTimestamp(entry.aggregateFirstEntry),
aggregateLastExit: aggregateNAId === entry.zoneId ? "N/A" : this.timeTrackerService.hmsTimestamp(entry.aggregateLastExit),
aggregateTimeSpent: aggregateTimeSpent,
aggregateNumEntries: entry.aggregateNumEntries.toString(),
}
});
}
formatDirect(direct: UnformattedRunStat[]) {
return direct.filter(entry => entry.entryType == EntryType.ZoneEnter).map(entry => {
return {
zoneName: this.resolveZone(entry.zoneId),
entryTime: this.timeTrackerService.hmsTimestamp(entry.entryTime),
estimatedExit: entry.estimatedExit ? this.timeTrackerService.hmsTimestamp(entry.estimatedExit) : "N/A",
estimatedTimeSpent: entry.estimatedTimeSpent ? this.timeTrackerService.hmsTimestamp(entry.estimatedTimeSpent) : "N/A",
}
})
}
dateFormat(value: number) {
return new Date(value).toLocaleString();
}
onLoad(data: RunHistory) {
this.direct = this.formatDirect(this.runStatService.calcDirect(data));
this.aggregated = this.formatAggregate(this.runStatService.calcAggregated(data));
}
hms(time: number) {
return this.timeTrackerService.hmsTimestamp(time);
}
private resolveZone(zoneId: string) {
const area = this.worldAreaMap?.get(zoneId);
if (!area) {
return "Unknown zone: " + zoneId;
}
// Act might not be very reasonable but it's the best we have it..
return area.name + " (A" + area.act + ")"
}
reset() {
this.timeTrackerService.loadCache();
this.aggregated = undefined;
this.direct = undefined;
}
myOrder(a: KeyValue<string, RunHistoryMetadata>, b: KeyValue<string, RunHistoryMetadata>): number {
return b.value.last_updated - a.value.last_updated;
}
setComparison(id: string) {
this.configService.config.runCompareHistory = id;
}
get displayedColumns() {
if (this.aggregate) {
return [
"zoneName",
"aggregateFirstEntry",
"aggregateLastExit",
"aggregateTimeSpent",
"aggregateNumEntries",
]
} else {
return [
"zoneName",
"entryTime",
"estimatedExit",
"estimatedTimeSpent",
]
}
}
}

@ -59,37 +59,4 @@
[max]="configService.config.numVisible - 1" step="1">
</mat-form-field>
</div>
<div class="d-flex flex-row justify-content-between">
<span class="d-block">Shorten zone names</span>
<mat-slide-toggle [color]="overlayService.isOverlay ? 'primary-on-dark' : 'primary'"
[(ngModel)]="configService.config.shortenZoneNames"></mat-slide-toggle>
</div>
<div class="d-flex flex-row justify-content-between">
<span class="d-block">Detach zones</span>
<mat-slide-toggle [color]="overlayService.isOverlay ? 'primary-on-dark' : 'primary'"
[(ngModel)]="configService.config.detachNotes"></mat-slide-toggle>
</div>
<div class="d-flex flex-row justify-content-between">
<span class="d-block">Enable stopwatch</span>
<mat-slide-toggle [color]="overlayService.isOverlay ? 'primary-on-dark' : 'primary'"
[(ngModel)]="configService.config.enableStopwatch"></mat-slide-toggle>
</div>
<ng-container *ngIf="configService.config.enableStopwatch">
<div class="d-flex flex-row justify-content-between">
<span class="d-block">Enable displaying scuffed livesplit</span>
<mat-slide-toggle [color]="overlayService.isOverlay ? 'primary-on-dark' : 'primary'"
[(ngModel)]="configService.config.showLivesplit"></mat-slide-toggle>
</div>
<div class="d-flex flex-row justify-content-between">
<span class="d-block">Enable aggregate-stats live compare</span>
<mat-slide-toggle [color]="overlayService.isOverlay ? 'primary-on-dark' : 'primary'"
[(ngModel)]="configService.config.showLiveAggregate"></mat-slide-toggle>
</div>
</ng-container>
</div>

@ -1,2 +0,0 @@
<ng-container *ngTemplateOutlet="template!; context: { $implicit: value }">
</ng-container>

@ -1,21 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { WrapValueComponent } from './wrap-value.component';
describe('WrapValueComponent', () => {
let component: WrapValueComponent;
let fixture: ComponentFixture<WrapValueComponent>;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [WrapValueComponent]
});
fixture = TestBed.createComponent(WrapValueComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

@ -1,14 +0,0 @@
import { Component, ContentChild, Input, TemplateRef } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-wrap-value',
standalone: true,
imports: [CommonModule],
templateUrl: './wrap-value.component.html',
styleUrls: ['./wrap-value.component.scss']
})
export class WrapValueComponent<T> {
@Input() value?: T;
@ContentChild(TemplateRef) template?: TemplateRef<any>;
}

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M382-240 154-468l57-57 171 171 367-367 57 57-424 424Z"/></svg>

Before

Width:  |  Height:  |  Size: 159 B

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M160-160v-80h110l-16-14q-52-46-73-105t-21-119q0-111 66.5-197.5T400-790v84q-72 26-116 88.5T240-478q0 45 17 87.5t53 78.5l10 10v-98h80v240H160Zm400-10v-84q72-26 116-88.5T720-482q0-45-17-87.5T650-648l-10-10v98h-80v-240h240v80H690l16 14q49 49 71.5 106.5T800-482q0 111-66.5 197.5T560-170Z"/></svg>

Before

Width:  |  Height:  |  Size: 388 B

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m40-120 440-760 440 760H40Zm138-80h604L480-720 178-200Zm302-40q17 0 28.5-11.5T520-280q0-17-11.5-28.5T480-320q-17 0-28.5 11.5T440-280q0 17 11.5 28.5T480-240Zm-40-120h80v-200h-80v200Zm40-100Z"/></svg>

Before

Width:  |  Height:  |  Size: 295 B

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/></svg>

Before

Width:  |  Height:  |  Size: 457 B

@ -80,16 +80,4 @@ div.picker_wrapper.popup {
.mdc-notched-outline__notch {
clip-path: none !important;
}
.mat-color-resume-instant {
background-color: rgb(76, 146, 146);
}
.mat-color-resume-next {
background-color: rgb(120, 76, 146);
}
.mat-color-resume-no {
background-color: rgb(180, 41, 41);
}

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save