Compare commits

...

21 Commits

Author SHA1 Message Date
isark d3ef66e498 Updated base plan for 3.25 fellshrine ruins change
11 months ago
isark fa8c21e16c Fixed 'resume' (start) on non-played plans
1 year ago
isark 66a738389c Prevents autoresume on a manually paused stopwatch
1 year ago
isark 3bbb50123a Editor bugfix missed cleanup before importing. Bugfix drag&drop at last position without doubleclick/drop-box
1 year ago
isark 84695203ea Reworked to reduce some unnecessary re-rendering but still laggy on linux!!
1 year ago
isark e1cdbd566a Fixed issue with missing path on first plan load.
1 year ago
isark cc96e42af0 Minor correction for improved bind handling when game loses focus and overlay isn't active
1 year ago
isark 67f43d4b7f empty notes save fix. correct default value if no current zone aggregate first entry found.
1 year ago
isark 5730861acf 1.8.0 release
1 year ago
isark 9cf16ab3df More refactoring, some more aggregate stats
1 year ago
isark 5c2d03272f Ability to detach notes. Generic "window" component since I'm now at 3 windows and it is getting cumbersome duplicating all that code all over the place.
1 year ago
isark 7c3e739f8d Mildly improved support for livesplit inspired functionality. Also initial beginnings of supporting an "aggregated" live stat for your ongoing run compared to another one.
1 year ago
isark a8443964bc Scuffed display, scuffed uuid based checkpoint (for livesplit inspired functionality)
1 year ago
isark cd042d99ca Some attempts at supporting some kind of livesplit functionality.
1 year ago
isark 3272c3bda2 Added run post run statistics view in the non-overlay window. Provides some stats for a run in aggregation on the zone id and then also a toggle to see an unaggregated run
1 year ago
isark 5db8663819 Improved time tracking support practically 'recording' your whole playthrough during a plan.
1 year ago
isark fda12b2f90 Made plan loading touch the file to sort on last modified for the previous plan view
1 year ago
isark b1550d68e5 release build compilation fix..
1 year ago
isark 32a2424783 Allow stopwatch and resuming runs, low resolution on saves, to not write to disk too often...
1 year ago
isark 54fd9ca62e Initial support for synchronizing url-imported plans based on the etag http header. This obviously requires the server hosting the plan files to correct enough etags
1 year ago
isark 9b22ad942a Fixed some oddities and made sure recent plans are 'updated' appropriately.
1 year ago

@ -0,0 +1,47 @@
{
"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,6 +45,7 @@ Thumbs.db
package-lock.json package-lock.json
processed_world_areas.json processed_world_areas.json
processed_world_areas_full.json
releases releases
.env .env
releaser_key releaser_key

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

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

10
src-tauri/Cargo.lock generated

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

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

@ -37,9 +37,6 @@ struct UnprocessedArea {
#[serde(rename = "IsVaalArea")] #[serde(rename = "IsVaalArea")]
pub is_vaal_area: bool, pub is_vaal_area: bool,
#[serde(rename = "Unknown64")]
pub unknown64: bool,
#[serde(rename = "Unknown9")] #[serde(rename = "Unknown9")]
pub unknown9: i64, pub unknown9: i64,
} }
@ -61,8 +58,7 @@ pub type AreaId = usize;
pub type WorldAreasMap = HashMap<String, WorldArea>; pub type WorldAreasMap = HashMap<String, WorldArea>;
#[allow(dead_code)] #[allow(dead_code)]
pub fn load_world_areas_map(content: &'static str) -> WorldAreasMap { pub fn load_world_areas_map(content: &'static str) -> WorldAreasMap {
serde_json::from_str::<WorldAreasMap>(content) serde_json::from_str::<WorldAreasMap>(content).expect("Could not load world areas json")
.expect("Could not load world areas json")
} }
#[macro_export] #[macro_export]
@ -206,7 +202,6 @@ pub fn repack(content: &str, out_path: &str) {
if w_a.act < 11 if w_a.act < 11
&& !w_a.is_vaal_area && !w_a.is_vaal_area
&& !w_a.connections_world_areas_keys.is_empty() && !w_a.connections_world_areas_keys.is_empty()
&& !w_a.unknown64
&& !w_a.named_id.starts_with("Map") && !w_a.named_id.starts_with("Map")
&& !w_a.named_id.starts_with("Descent") && !w_a.named_id.starts_with("Descent")
&& !w_a.named_id.starts_with("EndlessLedge") && !w_a.named_id.starts_with("EndlessLedge")
@ -234,3 +229,31 @@ pub fn repack(content: &str, out_path: &str) {
std::fs::write(out_path, &new_json).expect("Could not write to file"); 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,8 +6,10 @@ fn main() {
tauri_build::build(); tauri_build::build();
const OUT_PROCESSED: &'static str = "../data/processed_world_areas.json"; 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"); 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(&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... //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"); config::Rect::export_to("../src/app/_models/generated/Rect.ts").expect("Could not generate config struct");

@ -26,6 +26,10 @@ pub struct Rect {
pub struct Config { pub struct Config {
#[serde(default = "Config::default_initial_plan_window_position")] #[serde(default = "Config::default_initial_plan_window_position")]
pub initial_plan_window_position: Rect, 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")] #[serde(default = "Config::default_hide_on_unfocus")]
pub hide_on_unfocus: bool, pub hide_on_unfocus: bool,
#[serde(default = "Config::default_toggle_overlay")] #[serde(default = "Config::default_toggle_overlay")]
@ -47,12 +51,29 @@ pub struct Config {
pub num_visible: u16, pub num_visible: u16,
#[serde(default = "Config::default_plan_offset")] #[serde(default = "Config::default_plan_offset")]
pub offset: i16, 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 { impl Default for Config {
fn default() -> Self { fn default() -> Self {
Self { Self {
initial_plan_window_position: Self::default_initial_plan_window_position(), 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(), hide_on_unfocus: Self::default_hide_on_unfocus(),
toggle_overlay: Self::default_toggle_overlay(), toggle_overlay: Self::default_toggle_overlay(),
prev: Self::default_prev(), prev: Self::default_prev(),
@ -66,6 +87,12 @@ impl Default for Config {
note_default_fg: Self::default_note_default_fg(), note_default_fg: Self::default_note_default_fg(),
num_visible: Self::default_plan_num_visible(), num_visible: Self::default_plan_num_visible(),
offset: Self::default_plan_offset(), 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(),
} }
} }
} }
@ -75,6 +102,14 @@ impl Config {
Default::default() 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 { fn default_hide_on_unfocus() -> bool {
true true
} }
@ -118,4 +153,23 @@ impl Config {
fn default_plan_offset() -> i16 { fn default_plan_offset() -> i16 {
1 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,6 +1,7 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!! // Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::collections::HashMap;
use std::sync::mpsc::Receiver; use std::sync::mpsc::Receiver;
use std::{path::PathBuf, sync::Mutex}; use std::{path::PathBuf, sync::Mutex};
@ -25,12 +26,14 @@ use tauri::SystemTrayMenuItem;
use tauri::Window; use tauri::Window;
use lazy_static::lazy_static; use lazy_static::lazy_static;
use time::{RunHistory, RunHistoryMetadata};
mod config; mod config;
mod overlay; mod overlay;
mod plan; mod plan;
mod poe_reader; mod poe_reader;
mod storage; mod storage;
mod time;
lazy_static! { lazy_static! {
static ref WORLD_AREAS_MAP: WorldAreasMap = poe_data::world_area::load_world_areas_map( static ref WORLD_AREAS_MAP: WorldAreasMap = poe_data::world_area::load_world_areas_map(
@ -38,6 +41,12 @@ lazy_static! {
); );
} }
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] #[tauri::command]
fn set_interactable(interactable: bool, state: tauri::State<Sender<Event>>) { fn set_interactable(interactable: bool, state: tauri::State<Sender<Event>>) {
if interactable { if interactable {
@ -53,6 +62,13 @@ fn load_world_areas() -> WorldAreasMap {
WORLD_AREAS_MAP.clone() WORLD_AREAS_MAP.clone()
} }
#[tauri::command]
fn load_full_world_areas() -> WorldAreasMap {
log::info!("Loading world areas");
FULL_WORLD_AREAS_MAP.clone()
}
#[tauri::command] #[tauri::command]
fn load_config(state: tauri::State<Mutex<Storage>>) -> Option<Config> { fn load_config(state: tauri::State<Mutex<Storage>>) -> Option<Config> {
Some(state.lock().ok()?.config.clone()) Some(state.lock().ok()?.config.clone())
@ -72,6 +88,21 @@ fn enumerate_stored_plans() -> Vec<PlanMetadata> {
Storage::enumerate_plans() Storage::enumerate_plans()
} }
#[tauri::command]
fn save_history(current_run_history: RunHistory) {
Storage::save_history(current_run_history);
}
#[tauri::command]
fn load_history_at_uuid(uuid: String) -> Option<RunHistory> {
Storage::load_history_at_uuid(uuid)
}
#[tauri::command]
fn load_cache() -> Option<HashMap<String, RunHistoryMetadata>> {
Storage::load_cache()
}
#[tauri::command] #[tauri::command]
fn load_plan_at_path( fn load_plan_at_path(
path: PathBuf, path: PathBuf,
@ -103,8 +134,8 @@ fn save_plan_at_path(path: PathBuf, plan: Plan) -> bool {
} }
#[tauri::command] #[tauri::command]
fn save_plan_at_store(name: String, plan: Plan) -> Option<PathBuf> { fn save_plan_at_store(name: String, plan: Plan, allow_overwrite: bool) -> Option<PathBuf> {
Storage::save_plan_at_store_path(&name, plan).ok() Storage::save_plan_at_store_path(&name, plan, allow_overwrite).ok()
} }
#[tauri::command] #[tauri::command]
@ -122,11 +153,13 @@ fn main() {
let settings = CustomMenuItem::new("settings".to_string(), "Settings"); let settings = CustomMenuItem::new("settings".to_string(), "Settings");
let editor = CustomMenuItem::new("editor".to_string(), "Plan Editor"); 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 force_show = CustomMenuItem::new("force_show".to_string(), "Force show");
let exit = CustomMenuItem::new("exit".to_string(), "Exit"); let exit = CustomMenuItem::new("exit".to_string(), "Exit");
let tray_menu = SystemTrayMenu::new() let tray_menu = SystemTrayMenu::new()
.add_item(settings) .add_item(settings)
.add_item(editor) .add_item(editor)
.add_item(runstats)
.add_item(force_show) .add_item(force_show)
.add_native_item(SystemTrayMenuItem::Separator) .add_native_item(SystemTrayMenuItem::Separator)
.add_item(exit); .add_item(exit);
@ -149,7 +182,7 @@ fn main() {
.expect("Could not get main overlay window"), .expect("Could not get main overlay window"),
); );
} }
// app.get_window("Overlay") // app.get_window("Overlay")
// .expect("Could not get main overlay window") // .expect("Could not get main overlay window")
// .open_devtools(); // .open_devtools();
@ -159,6 +192,7 @@ fn main() {
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
set_interactable, set_interactable,
load_world_areas, load_world_areas,
load_full_world_areas,
load_config, load_config,
update_config, update_config,
enumerate_stored_plans, enumerate_stored_plans,
@ -166,6 +200,9 @@ fn main() {
save_plan_at_path, save_plan_at_path,
save_plan_at_store, save_plan_at_store,
base_plan, base_plan,
save_history,
load_history_at_uuid,
load_cache,
]) ])
.system_tray(system_tray) .system_tray(system_tray)
.on_system_tray_event(|app, event| match event { .on_system_tray_event(|app, event| match event {
@ -173,7 +210,7 @@ fn main() {
"exit" => { "exit" => {
std::process::exit(0); std::process::exit(0);
} }
"editor" | "settings" => { "editor" | "settings" | "runstats" => {
if let Some(window) = app.get_window("Normal") { if let Some(window) = app.get_window("Normal") {
window.show().ok(); window.show().ok();
window.emit_to("Normal", "loadTab", id).ok(); window.emit_to("Normal", "loadTab", id).ok();
@ -203,12 +240,11 @@ fn listen_for_zone_changes(poe_client_log_path: Option<PathBuf>, window: Window)
std::thread::spawn(move || { std::thread::spawn(move || {
// need _watcher // need _watcher
if let Some((enter_area_receiver, _watcher)) = receiver_from_path(&poe_client_log_path) { 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) { for area in blocking_area_filtered_rx(&enter_area_receiver) {
if let Some(area) = filter_func(area) { if let Some(area) = filter_func(area) {
if let Some(entered) = world_areas.get(&area) { // if let Some(entered) = world_areas.get(&area) {
window.emit_to("Overlay", "entered", &entered.named_id).ok(); window.emit_to("Overlay", "entered", &area).ok();
} // }
} }
} }
} }

@ -58,7 +58,7 @@ impl Overlay {
previous: State::hidden(), previous: State::hidden(),
}; };
window.manage(Mutex::new(OverlayData { })); window.manage(Mutex::new(OverlayData {}));
let mut fsm = Overlay::uninitialized_state_machine(overlay).init(); let mut fsm = Overlay::uninitialized_state_machine(overlay).init();
@ -126,19 +126,22 @@ impl Overlay {
UnderlayEvent::MoveResize(bounds) => fsm.handle(&Event::Bounds(bounds)), UnderlayEvent::MoveResize(bounds) => fsm.handle(&Event::Bounds(bounds)),
UnderlayEvent::Detach => fsm.handle(&State::hidden().into()), UnderlayEvent::Detach => fsm.handle(&State::hidden().into()),
UnderlayEvent::Focus => { UnderlayEvent::Focus => {
fsm.window.emit("overlay_or_target_focus", true).ok();
if let State::Hidden {} = fsm.state() { if let State::Hidden {} = fsm.state() {
fsm.handle(&State::Visible {}.into()); fsm.handle(&State::Visible {}.into());
} }
} }
UnderlayEvent::Blur => { UnderlayEvent::Blur => {
if !fsm.window.is_focused().unwrap() if !fsm.window.is_focused().unwrap() {
&& fsm fsm.window.emit("overlay_or_target_focus", false).ok();
if fsm
.window .window
.state::<Mutex<Storage>>() .state::<Mutex<Storage>>()
.lock() .lock()
.is_ok_and(|s| s.config.hide_on_unfocus) .is_ok_and(|s| s.config.hide_on_unfocus)
{ {
fsm.handle(&State::Hidden {}.into()) fsm.handle(&State::Hidden {}.into())
}
} }
} }
UnderlayEvent::X11FullscreenEvent { is_fullscreen: _ } => {} UnderlayEvent::X11FullscreenEvent { is_fullscreen: _ } => {}

@ -1,10 +1,10 @@
use std::{collections::HashMap, path::PathBuf}; use std::{collections::HashMap, path::PathBuf};
use serde::{ser::SerializeStruct, Deserialize, Serialize}; use serde::{ser::SerializeStruct, Deserialize, Deserializer, Serialize};
use serde_json::Value;
#[derive(Serialize, Deserialize, Debug, Clone)] #[derive(Serialize, Deserialize, Debug, Clone)]
pub struct pub struct Plan {
Plan {
plan: Vec<PlanElement>, plan: Vec<PlanElement>,
current: usize, current: usize,
#[serde(flatten)] #[serde(flatten)]
@ -15,6 +15,11 @@ Plan {
pub struct PlanMetadata { pub struct PlanMetadata {
stored_path: Option<PathBuf>, stored_path: Option<PathBuf>,
update_url: Option<String>, 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 { impl PlanMetadata {
@ -26,23 +31,39 @@ impl PlanMetadata {
impl Serialize for PlanMetadata { impl Serialize for PlanMetadata {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where where
S: serde::Serializer, S: serde::Serializer,
{ {
let mut state = serializer.serialize_struct("PlanMetadata", 3)?; let mut state = serializer.serialize_struct("PlanMetadata", 6)?;
state.serialize_field("update_url", &self.update_url)?; state.serialize_field("update_url", &self.update_url)?;
state.serialize_field("stored_path", &self.stored_path)?; 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(path) = &self.stored_path {
if let Some(name) = path.file_name() { if let Some(name) = path.file_name() {
state.serialize_field("name", &name.to_str())?; state.serialize_field("name", &name.to_str())?;
} }
} }
state.end() 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> { impl From<PlanMetadata> for Option<Plan> {
fn from(metadata: PlanMetadata) -> Self { fn from(metadata: PlanMetadata) -> Self {
Some(serde_json::from_slice(&std::fs::read(metadata.stored_path?).ok()?).ok()?) Some(serde_json::from_slice(&std::fs::read(metadata.stored_path?).ok()?).ok()?)
@ -67,6 +88,13 @@ pub struct PlanElement {
edited: bool, edited: bool,
anchor_act: Option<u8>, anchor_act: Option<u8>,
#[serde(default, skip_serializing_if = "is_false")]
checkpoint: bool,
}
fn is_false(flag: &bool) -> bool {
!flag
} }
impl PlanElement { impl PlanElement {
@ -123,11 +151,15 @@ pub fn convert_old(path: PathBuf) -> Option<Plan> {
uuid: PlanElement::generate_uuid(), uuid: PlanElement::generate_uuid(),
edited: PlanElement::edited(), edited: PlanElement::edited(),
anchor_act: None, anchor_act: None,
checkpoint: false,
}) })
.collect::<Vec<PlanElement>>(), .collect::<Vec<PlanElement>>(),
metadata: PlanMetadata { metadata: PlanMetadata {
stored_path: None, stored_path: None,
update_url: None, update_url: None,
latest_server_etag: None,
identifier: None,
last_stored_time: None,
}, },
}) })
} }

@ -1,12 +1,14 @@
use std::collections::HashMap;
use std::error::Error; use std::error::Error;
use std::fs::DirEntry;
use std::path::PathBuf; use std::path::PathBuf;
use directories::ProjectDirs; use directories::ProjectDirs;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::config::Config; use crate::config::Config;
use crate::plan::{convert_old, Plan, PlanMetadata}; use crate::plan::{convert_old, Plan, PlanMetadata};
use crate::time::{RunHistory, RunHistoryMetadata};
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
pub struct Storage { pub struct Storage {
@ -18,7 +20,9 @@ const QUALIFIER: &'static str = "me";
const ORGANIZATION: &'static str = "isark.poe"; const ORGANIZATION: &'static str = "isark.poe";
const APPLICATION: &'static str = "Nothing"; const APPLICATION: &'static str = "Nothing";
const CONFIG_FILE: &'static str = "configuration.json"; const CONFIG_FILE: &'static str = "configuration.json";
const HISTORY_CACHE_FILE: &'static str = "history_cache.json";
const SAVED_PLANS: &'static str = "plans"; const SAVED_PLANS: &'static str = "plans";
const SAVED_HISTORIES: &'static str = "histories";
fn mkdir_for_storage() { fn mkdir_for_storage() {
let dir_structure = Storage::plan_dir(); let dir_structure = Storage::plan_dir();
@ -27,6 +31,13 @@ fn mkdir_for_storage() {
.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(); .ok();
} }
let dir_structure: Option<PathBuf> = Storage::history_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();
}
} }
impl Default for Storage { impl Default for Storage {
@ -52,10 +63,18 @@ impl Storage {
Some(Self::proj_dir()?.data_dir().join(SAVED_PLANS)) Some(Self::proj_dir()?.data_dir().join(SAVED_PLANS))
} }
pub fn history_dir() -> Option<PathBuf> {
Some(Self::proj_dir()?.data_dir().join(SAVED_HISTORIES))
}
pub fn config_file() -> Option<PathBuf> { pub fn config_file() -> Option<PathBuf> {
Some(Self::proj_dir()?.data_dir().join(CONFIG_FILE)) Some(Self::proj_dir()?.data_dir().join(CONFIG_FILE))
} }
pub fn history_cache_file() -> Option<PathBuf> {
Some(Self::proj_dir()?.data_dir().join(HISTORY_CACHE_FILE))
}
pub fn save_config(&self) { pub fn save_config(&self) {
let content = match serde_json::to_string_pretty(&self) { let content = match serde_json::to_string_pretty(&self) {
Ok(content) => content, Ok(content) => content,
@ -84,25 +103,36 @@ impl Storage {
} }
pub fn load_plan_at_path(path: PathBuf, save_local: bool) -> Option<Plan> { pub fn load_plan_at_path(path: PathBuf, save_local: bool) -> Option<Plan> {
log::trace!("Loading plan: {path:?}"); let mut plan: Plan = match serde_json::from_str(&std::fs::read_to_string(&path).ok()?).ok() {
let plan: Plan = match serde_json::from_str(&std::fs::read_to_string(&path).ok()?).ok() {
Some(plan) => plan, Some(plan) => plan,
None => convert_old(path.clone())?, None => convert_old(path.clone())?,
}; };
if save_local { if save_local {
match Self::save_plan_at_store_path(path.file_name()?.to_str()?, plan.clone()) { match Self::save_plan_at_store_path(path.file_name()?.to_str()?, plan.clone(), false) {
Ok(_) => (), Ok(path) => plan.set_stored_path(Some(path)),
Err(_e) => { Err(_e) => {
log::error!("Could not save plan at store path during load"); log::error!("Could not save plan at store path during load");
} }
} }
} }
//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) Some(plan)
} }
pub fn save_plan_at_store_path(file_name: &str, mut plan: Plan) -> Result<PathBuf, Box<dyn Error>> { 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() { let plan_dir = match Self::plan_dir() {
Some(dir) => dir, Some(dir) => dir,
None => return Err("No plan dir".into()), None => return Err("No plan dir".into()),
@ -111,12 +141,13 @@ impl Storage {
let file_path = plan_dir.join(file_name).with_extension("json"); let file_path = plan_dir.join(file_name).with_extension("json");
//Disallow overwriting. //Disallow overwriting.
if file_path.exists() { if !allow_overwrite && file_path.exists() {
return Err("File already exists".into()); return Err("File already exists".into());
} }
//TODO: Determine necessity if allow_overwrite && file_path.exists() {
plan.set_stored_path(Some(file_path.clone())); log::info!("Overwriting plan : {file_path:?}");
}
std::fs::write(&file_path, serde_json::to_string(&plan)?)?; std::fs::write(&file_path, serde_json::to_string(&plan)?)?;
@ -129,14 +160,21 @@ impl Storage {
None => return vec![], None => return vec![],
}; };
let read_dir = match plan_dir.read_dir() { let mut read_dir: Vec<DirEntry> = match plan_dir.read_dir() {
Ok(read_dir) => read_dir, Ok(read_dir) => read_dir.filter_map(|v| {
log::trace!("Read dir: {:?}", v);
v.ok()
}).collect(),
Err(_) => return vec![], Err(_) => return vec![],
}; };
read_dir.sort_by_key(|v| v.metadata().ok()?.modified().ok());
read_dir.reverse();
read_dir read_dir
.iter()
.filter_map(|entry| { .filter_map(|entry| {
let path = entry.ok()?.path(); let path = entry.path();
if path.extension()? != "json" { if path.extension()? != "json" {
return None; return None;
@ -166,9 +204,80 @@ impl Storage {
} }
fn load_metadata_at_path(path: PathBuf) -> Option<PlanMetadata> { fn load_metadata_at_path(path: PathBuf) -> Option<PlanMetadata> {
let mut plan: PlanMetadata =
serde_json::from_str(&std::fs::read_to_string(&path).ok()?).ok()?; 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;
}
};
plan.set_stored_path(Some(path)); plan.set_stored_path(Some(path));
Some(plan) 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();
}
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();
}
} }

@ -0,0 +1,52 @@
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": { "package": {
"productName": "Nothing", "productName": "Nothing",
"version": "1.4.0" "version": "1.8.9"
}, },
"tauri": { "tauri": {
"systemTray": { "systemTray": {
@ -18,7 +18,8 @@
"allowlist": { "allowlist": {
"dialog": { "dialog": {
"open": true, "open": true,
"save": true "save": true,
"message": true
}, },
"globalShortcut": { "globalShortcut": {
"all": true "all": true

@ -7,12 +7,18 @@ export interface PlanInterface {
stored_path?: string; stored_path?: string;
update_url?: string; update_url?: string;
name?: string; name?: string;
latest_server_etag?: string;
identifier?: string,
last_stored_time?: string;
} }
export interface PlanMetadata { export interface PlanMetadata {
stored_path?: string; stored_path?: string;
update_url?: string; update_url?: string;
name: string; name: string;
latest_server_etag?: string;
identifier?: string;
last_stored_time?: string;
} }
export class Plan { export class Plan {
@ -20,16 +26,31 @@ export class Plan {
current: number; current: number;
update_url?: string; update_url?: string;
name?: string; name?: string;
latest_server_etag?: string;
private path?: string; identifier?: string;
last_stored_time?: string;
public path?: string;
private selfSaveSubject: Subject<void> = new Subject<void>(); private selfSaveSubject: Subject<void> = new Subject<void>();
constructor(plan: PlanInterface) {
constructor(plan?: PlanInterface) {
if(!plan) {
this.plan = [];
this.current = 0;
return;
};
this.plan = plan.plan; this.plan = plan.plan;
this.current = plan.current; this.current = plan.current;
if (plan.stored_path) { if (plan.stored_path) {
this.path = 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.selfSaveSubject.pipe(debounceTime(500)).subscribe(() => this.directSelfSave());
} }
@ -37,6 +58,10 @@ export class Plan {
this.path = path; this.path = path;
} }
isNext(zoneId: string, current = this.current) {
return current + 1 < this.plan.length && zoneId === this.plan[current + 1].area_key;
}
next() { next() {
if (this.current + 1 < this.plan!.length) { if (this.current + 1 < this.plan!.length) {
this.current++; this.current++;
@ -57,17 +82,20 @@ export class Plan {
current: this.current, current: this.current,
stored_path: this.path, stored_path: this.path,
update_url: this.update_url, update_url: this.update_url,
latest_server_etag: this.latest_server_etag,
identifier: this.identifier,
last_stored_time: this.last_stored_time,
}; };
} }
private requestSelfSave() { public requestSelfSave() {
if (this.path) { if (this.path) {
this.selfSaveSubject.next(); this.selfSaveSubject.next();
} }
} }
private directSelfSave() { private directSelfSave() {
invoke('save_plan_at_path', {path: this.path, plan: this.toInterface()}); invoke('save_plan_at_path', { path: this.path, plan: this.toInterface() });
} }
} }
@ -78,4 +106,7 @@ export interface PlanElement {
uuid?: string; uuid?: string;
edited: boolean; edited: boolean;
anchor_act?: number; anchor_act?: number;
checkpoint?: boolean;
checkpoint_millis?: number;
checkpoint_your_millis?: number;
} }

@ -32,12 +32,18 @@ export class OverlayService {
this.isOverlay = appWindow.label === "Overlay"; this.isOverlay = appWindow.label === "Overlay";
} }
registerInitialBinds() { registerInitialBinds(retry: boolean = true) {
this.shortcuts.register(this.configService.config.toggleOverlay).subscribe((_shortcut) => { this.shortcuts.register(this.configService.config.toggleOverlay).subscribe({
this.onToggleOverlay() 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>) { onOverlayStateChange(event: Event<StateEvent>) {
this.interactable = event.payload.Interactable != null; this.interactable = event.payload.Interactable != null;
if (event.payload.Hidden) { this.visible = false } else { this.visible = true }; if (event.payload.Hidden) { this.visible = false } else { this.visible = true };

@ -1,10 +1,20 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { invoke } from '@tauri-apps/api'; import { invoke } from '@tauri-apps/api';
import { Observable, ReplaySubject, Subject, from, map, switchMap, tap } from 'rxjs'; import { EMPTY, Observable, ReplaySubject, Subject, from, map, switchMap, tap } from 'rxjs';
import { Plan, PlanInterface, PlanMetadata } from '../_models/plan'; import { Plan, PlanInterface, PlanMetadata } from '../_models/plan';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { UrlDialog } from '../plan-display/url-dialog.component'; import { UrlDialog } from '../plan-display/url-dialog.component';
import { fetch } from '@tauri-apps/api/http'; 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;
}
}
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -13,9 +23,11 @@ export class PlanService {
private _currentPlanSubject: Subject<Plan> = new ReplaySubject<Plan>(1); private _currentPlanSubject: Subject<Plan> = new ReplaySubject<Plan>(1);
private _basePlanSubject: Subject<Plan> = new ReplaySubject<Plan>(1); private _basePlanSubject: Subject<Plan> = new ReplaySubject<Plan>(1);
private _storedPlansSubject: Subject<PlanMetadata[]> = new ReplaySubject<PlanMetadata[]>(1);
constructor(private dialog: MatDialog) { constructor(private dialog: MatDialog) {
this.loadBasePlan(); this.loadBasePlan();
this.loadStoredPlans();
} }
public getBasePlan(): Observable<Plan> { public getBasePlan(): Observable<Plan> {
@ -30,15 +42,19 @@ export class PlanService {
this._currentPlanSubject.next(plan); this._currentPlanSubject.next(plan);
} }
public enumerateStoredPlans(): Observable<PlanMetadata[]> { public getStoredPlans(): Observable<PlanMetadata[]> {
return from(invoke<PlanMetadata[]>('enumerate_stored_plans')); return this._storedPlansSubject.asObservable();
} }
public loadPlanFromPath(path: string, save_local: boolean = true): Observable<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) })); 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): Observable<Plan> { public loadFromUrl(url?: string, name?: string, save_local: boolean = false): Observable<Plan> {
if (!url || !name) { if (!url || !name) {
const dialogRef = this.dialog.open(UrlDialog, { const dialogRef = this.dialog.open(UrlDialog, {
data: { data: {
@ -49,14 +65,14 @@ export class PlanService {
return dialogRef.afterClosed().pipe(switchMap(data => { return dialogRef.afterClosed().pipe(switchMap(data => {
if (data.url) { if (data.url) {
return this._loadFromUrl(data.url, data.name); return this._loadFromUrl(data.url, data.name, save_local);
} }
return new Observable<Plan>((s) => s.complete()); return new Observable<Plan>((s) => s.complete());
})); }));
} else { } else {
return this._loadFromUrl(url, name); return this._loadFromUrl(url, name, save_local);
} }
} }
@ -68,11 +84,32 @@ export class PlanService {
return from(invoke('save_plan_at_path', { path, plan: plan.toInterface() })); return from(invoke('save_plan_at_path', { path, plan: plan.toInterface() }));
} }
public savePlanAtStore(name: string, plan: Plan) { public checkForPlanUpdate(plan: Plan | PlanMetadata): Observable<Plan> {
return from(invoke<string>('save_plan_at_store', { name, plan: plan.toInterface() })); 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);
}));
} }
private _loadFromUrl(url: string, name: string): Observable<Plan> { 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();
}));
}
private _loadFromUrl(url: string, name: string, save_local: boolean): Observable<Plan> {
//Tauri fetch //Tauri fetch
return from(fetch( return from(fetch(
url, url,
@ -80,17 +117,46 @@ export class PlanService {
method: 'GET', method: 'GET',
timeout: 10 timeout: 10
})).pipe(map(response => { })).pipe(map(response => {
return new Plan(response.data as PlanInterface); 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 => { })).pipe(tap(plan => {
plan.update_url = url; plan.update_url = url;
plan.name = name; 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);
}
}
private loadBasePlan() { private loadBasePlan() {
from(invoke<PlanInterface>('base_plan')).subscribe(plan => { from(invoke<PlanInterface>('base_plan')).subscribe(plan => {
plan.plan.forEach(elem => { elem.edited = false; }); plan.plan.forEach(elem => { elem.edited = false; });
plan.current = 0;
plan.name = "Base Plan";
this._basePlanSubject?.next(new Plan(plan)); this._basePlanSubject?.next(new Plan(plan));
}); });
} }
private loadStoredPlans() {
from(invoke<PlanMetadata[]>('enumerate_stored_plans')).subscribe(plans => {
this._storedPlansSubject.next(plans);
})
}
} }

@ -0,0 +1,111 @@
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,6 +1,6 @@
import { Injectable, NgZone } from '@angular/core'; import { Injectable, NgZone } from '@angular/core';
import { ShortcutHandler, register, unregister } from '@tauri-apps/api/globalShortcut'; import { ShortcutHandler, register, unregister } from '@tauri-apps/api/globalShortcut';
import { Observable, Subscriber } from 'rxjs'; import { Observable, Subscriber, from } from 'rxjs';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -8,7 +8,7 @@ import { Observable, Subscriber } from 'rxjs';
export class ShortcutService { export class ShortcutService {
private internalHandlers: Map<string, [ShortcutHandler, Subscriber<string>, () => void]> = new Map<string, [ShortcutHandler, Subscriber<string>, () => void]>(); private internalHandlers: Map<string, [ShortcutHandler, Subscriber<string>, () => void]> = new Map<string, [ShortcutHandler, Subscriber<string>, () => void]>();
constructor(private zone: NgZone) {} constructor(private zone: NgZone) { }
register(shortcut: string) { register(shortcut: string) {
return new Observable<string>((subscriber) => { return new Observable<string>((subscriber) => {
@ -21,12 +21,12 @@ export class ShortcutService {
}; };
this.internalHandlers.set(shortcut, [originalHandler, subscriber, teardown]); this.internalHandlers.set(shortcut, [originalHandler, subscriber, teardown]);
register(shortcut, originalHandler).catch(e => subscriber.error(e));
register(shortcut, originalHandler)
return teardown; return teardown;
}); });
} }
rebind_from_to(previousShortcut: string, nextShortcut: string) { rebind_from_to(previousShortcut: string, nextShortcut: string) {
let [oldHandler, subscriber, teardown] = this.internalHandlers.get(previousShortcut)!; let [oldHandler, subscriber, teardown] = this.internalHandlers.get(previousShortcut)!;
@ -43,4 +43,10 @@ export class ShortcutService {
this.internalHandlers.set(nextShortcut, [oldHandler, subscriber, teardown]); this.internalHandlers.set(nextShortcut, [oldHandler, subscriber, teardown]);
} }
///No safety checks
force_unbind(shortcut: string) {
return from(unregister(shortcut));
}
} }

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

@ -0,0 +1,51 @@
<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>

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

@ -0,0 +1,21 @@
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();
});
});

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

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

@ -16,6 +16,9 @@ import { MatTabsModule } from '@angular/material/tabs';
import { MAT_DIALOG_DEFAULT_OPTIONS } from "@angular/material/dialog"; import { MAT_DIALOG_DEFAULT_OPTIONS } from "@angular/material/dialog";
import { TooltipComponent } from "./tooltip/tooltip.component"; import { TooltipComponent } from "./tooltip/tooltip.component";
import { HttpClientModule } from "@angular/common/http"; 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"; // import { GemFinderComponent } from "./gem-finder/gem-finder.component";
export function initializeApp(configService: ConfigService) { export function initializeApp(configService: ConfigService) {
@ -26,7 +29,7 @@ export function initializeApp(configService: ConfigService) {
@NgModule({ @NgModule({
declarations: [ declarations: [
AppComponent, AppComponent
], ],
imports: [ imports: [
BrowserModule, BrowserModule,
@ -40,7 +43,9 @@ export function initializeApp(configService: ConfigService) {
SettingsComponent, SettingsComponent,
MatTabsModule, MatTabsModule,
TooltipComponent, TooltipComponent,
HttpClientModule HttpClientModule,
AngularSvgIconModule.forRoot(),
RunStatsComponent
], ],
providers: [ providers: [
{ {

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

@ -0,0 +1,15 @@
<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>

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

@ -0,0 +1,21 @@
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();
});
});

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

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

@ -1,4 +1,4 @@
import { ChangeDetectorRef, Component, ElementRef, OnInit, ViewChild } from '@angular/core'; import { AfterViewInit, ChangeDetectorRef, Component, ElementRef, OnChanges, OnInit, QueryList, SimpleChanges, ViewChild, ViewChildren } from '@angular/core';
import { import {
CdkDrag, CdkDrag,
@ -14,7 +14,7 @@ import { Plan, PlanElement } from '../_models/plan';
import { WorldAreaService } from '../_services/world-area.service'; import { WorldAreaService } from '../_services/world-area.service';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { Fuzzr } from '../fuzzr/fuzzr'; import { Fuzzr } from '../fuzzr/fuzzr';
import { from } from 'rxjs'; import { BehaviorSubject, first, from, skip } from 'rxjs';
import { save } from '@tauri-apps/api/dialog'; import { save } from '@tauri-apps/api/dialog';
import { PlanService } from '../_services/plan.service'; import { PlanService } from '../_services/plan.service';
import { MatDialog, MatDialogModule } from '@angular/material/dialog'; import { MatDialog, MatDialogModule } from '@angular/material/dialog';
@ -47,12 +47,11 @@ interface Act {
MatInputModule, MatInputModule,
MatSelectModule, MatSelectModule,
MatButtonModule, MatButtonModule,
MatSlideToggleModule MatSlideToggleModule,
], ],
providers: []
}) })
export class EditorComponent implements OnInit { export class EditorComponent implements OnInit {
planInEditing: Plan;
areas?: WorldArea[]; areas?: WorldArea[];
planAreas: WorldArea[]; planAreas: WorldArea[];
areasMap?: Map<String, WorldArea>; areasMap?: Map<String, WorldArea>;
@ -62,22 +61,22 @@ export class EditorComponent implements OnInit {
filterAct: Act; filterAct: Act;
planFilterAct: Act; planFilterAct: Act;
acts: Act[]; acts: Act[];
@ViewChild('planList') planListElement!: ElementRef; @ViewChild('planListElement') planListElement!: ElementRef;
autoScrollToEnd: boolean; autoScrollToEnd: boolean;
reverseDisplay: boolean; reverseDisplay: boolean;
disabledPlanDD: boolean; disabledPlanDD: boolean;
original: PlanElement[] = [];
latestList: BehaviorSubject<PlanElement[]> = new BehaviorSubject<PlanElement[]>([]);
constructor(public worldAreaService: WorldAreaService, private cdr: ChangeDetectorRef, private planService: PlanService, public dialog: MatDialog) { constructor(public worldAreaService: WorldAreaService, private cdr: ChangeDetectorRef, private planService: PlanService, public dialog: MatDialog) {
this.planInEditing = new Plan({
plan: [],
current: 0
});
this.disabledPlanDD = false; this.disabledPlanDD = false;
this.autoScrollToEnd = false; this.autoScrollToEnd = false;
this.planFuzzer = new Fuzzr(this.planInEditing.plan, { this.latestList = new BehaviorSubject<any[]>([]);
this.planFuzzer = new Fuzzr(this.original, {
toString: (e: PlanElement) => { toString: (e: PlanElement) => {
return this.areasMap?.get(e.area_key)?.name; return this.areasMap?.get(e.area_key)?.name;
} }
@ -100,6 +99,19 @@ export class EditorComponent implements OnInit {
this.planFilterAct = this.acts[0]; 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 { ngOnInit(): void {
this.worldAreaService.getWorldAreas().subscribe(worldAreas => { this.worldAreaService.getWorldAreas().subscribe(worldAreas => {
this.areas = [...worldAreas.values()]; this.areas = [...worldAreas.values()];
@ -113,11 +125,11 @@ export class EditorComponent implements OnInit {
dropHandler(event: CdkDragDrop<WorldArea[]> | CdkDragDrop<PlanElement[]>) { dropHandler(event: CdkDragDrop<WorldArea[]> | CdkDragDrop<PlanElement[]>) {
if (event.previousContainer === event.container && !isWorldAreaEvent(event)) { if (event.previousContainer === event.container && !isWorldAreaEvent(event)) {
const realCurrent = this.planInEditing.plan.indexOf(event.previousContainer.data[event.currentIndex]); const realCurrent = this.original.indexOf(event.previousContainer.data[event.currentIndex]);
const realPrev = this.planInEditing.plan.indexOf(event.previousContainer.data[event.previousIndex]); const realPrev = this.original.indexOf(event.previousContainer.data[event.previousIndex]);
moveItemInArray(this.planInEditing.plan, realPrev, realCurrent); moveItemInArray(this.original, realPrev, realCurrent);
} else } else {
if (this.planInEditing && this.areas && isWorldAreaEvent(event)) { if (this.areas && isWorldAreaEvent(event)) {
if (event.container.data.length > 0 && 'connections_world_areas_keys' in event.container.data[0]) { if (event.container.data.length > 0 && 'connections_world_areas_keys' in event.container.data[0]) {
return; return;
} }
@ -127,18 +139,31 @@ export class EditorComponent implements OnInit {
if (bounds) { if (bounds) {
index += bounds[0]; index += bounds[0];
} }
this.planInEditing.plan.splice(index, 0, this.planItemFromArea(event.previousContainer.data[event.previousIndex])); this.original.splice(index, 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[]>) { dropEndHandler(event: CdkDragDrop<WorldArea[]> | CdkDragDrop<PlanElement[]> | null) {
if (event == null) return;
if (isWorldAreaEvent(event) && this.areas) { if (isWorldAreaEvent(event) && this.areas) {
this.planInEditing.plan.splice(this.getEnd(), 0, this.planItemFromArea(event.previousContainer.data[event.previousIndex])); this.original.splice(this.getEnd(), 0, this.planItemFromArea(event.previousContainer.data[event.previousIndex]));
} else { } else {
moveItemInArray(this.planInEditing.plan, event.previousIndex, this.getEnd()); moveItemInArray(this.original, event.previousIndex, this.getEnd());
} }
this.scrollToEnd(); this.latestList.pipe(skip(1)).pipe(first()).subscribe(() => {
this.scrollToEnd();
});
this.filterPlanElements();
} }
getEnd() { getEnd() {
@ -146,12 +171,14 @@ export class EditorComponent implements OnInit {
if (bounds) { if (bounds) {
return bounds[1]; return bounds[1];
} else { } else {
return this.planInEditing.plan.length; return this.original.length;
} }
} }
remove(item: PlanElement) { remove(item: PlanElement) {
this.planInEditing.plan.splice(this.planIndexOf(item), 1); this.original.splice(this.planIndexOf(item), 1);
this.filterPlanElements();
} }
canDrop = () => { canDrop = () => {
@ -181,28 +208,36 @@ export class EditorComponent implements OnInit {
if (!this.autoScrollToEnd) { if (!this.autoScrollToEnd) {
return; return;
} }
this.cdr.detectChanges(); this.cdr.detectChanges();
if (!this.reverseDisplay) { if (!this.reverseDisplay) {
this.planListElement.nativeElement.scrollTop = this.planListElement.nativeElement.scrollHeight; this.planListElement.nativeElement.scrollTop = this.planListElement.nativeElement.scrollHeight;
} else { } else {
this.planListElement.nativeElement.scrollTop = 0; this.planListElement.nativeElement.scrollTop = 0;
} }
} }
doubleClickArea(item: WorldArea) { doubleClickArea(item: WorldArea) {
this.planInEditing.plan.splice(this.planInEditing.plan.length, 0, this.planItemFromArea(item)); this.original.splice(this.original.length, 0, this.planItemFromArea(item));
this.scrollToEnd();
this.latestList.pipe(skip(1)).pipe(first()).subscribe((_) => {
this.scrollToEnd();
});
this.filterPlanElements();
} }
planElemFilterBounds() { planElemFilterBounds() {
if (this.planFilterAct.value !== 0) { if (this.planFilterAct.value !== 0) {
let bounds = this.planInEditing.plan.filter(item => item.anchor_act === this.planFilterAct.value || this.planFilterAct.value + 1 === item.anchor_act).map((value) => this.planIndexOf(value)); 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) { if (bounds.length == 2) {
return bounds; return bounds;
} }
if (bounds.length == 1 && this.planFilterAct.value == 10) { if (bounds.length == 1 && this.planFilterAct.value == 10) {
bounds[1] = this.planInEditing.plan.length; bounds[1] = this.original.length;
return bounds; return bounds;
} }
} }
@ -218,7 +253,6 @@ export class EditorComponent implements OnInit {
this.disabledPlanDD = false; this.disabledPlanDD = false;
} }
if (this.planSearchString !== "" || this.planFilterAct.value != 0) { if (this.planSearchString !== "" || this.planFilterAct.value != 0) {
let bounds = this.planElemFilterBounds(); let bounds = this.planElemFilterBounds();
@ -237,24 +271,22 @@ export class EditorComponent implements OnInit {
} }
} else { } else {
return this.planInEditing.plan; return this.original;
} }
} }
if (this.reverseDisplay) { this.latestList.next([... (this.reverseDisplay ? value().slice().reverse() : value())]);
return value().slice().reverse();
} else {
return value();
}
} }
planIndexOf(planElement: PlanElement) { planIndexOf(planElement: PlanElement) {
const index = this.planInEditing.plan.indexOf(planElement); return this.original.indexOf(planElement);
return index;
} }
clearPlan() { clearPlan() {
this.planInEditing.plan.length = 0; while (this.original.length > 0) {
this.original.pop();
}
this.filterPlanElements();
this.cdr.detectChanges(); this.cdr.detectChanges();
} }
@ -266,7 +298,9 @@ export class EditorComponent implements OnInit {
}] }]
})).subscribe(file => { })).subscribe(file => {
if (file) { if (file) {
this.planService.savePlanAtPath(file, this.planInEditing).subscribe(); const plan = new Plan();
plan.plan = [...this.original];
this.planService.savePlanAtPath(file, plan).subscribe();
} }
}); });
} }
@ -284,8 +318,11 @@ export class EditorComponent implements OnInit {
if (file) { if (file) {
// We disallow multiple but interface still says it can be multiple, thus the cast. // We disallow multiple but interface still says it can be multiple, thus the cast.
this.planService.loadPlanFromPath(file as string, false).subscribe(plan => { this.planService.loadPlanFromPath(file as string, false).subscribe(plan => {
this.planInEditing.plan.length = 0; while (this.original.length > 0) {
plan.plan.forEach(p => this.planInEditing.plan.push(p)); this.original.pop();
}
plan.plan.forEach(item => this.original.push(item));
this.filterPlanElements();
}); });
} }
}); });
@ -293,8 +330,11 @@ export class EditorComponent implements OnInit {
loadBasePlan() { loadBasePlan() {
this.planService.getBasePlan().subscribe(plan => { this.planService.getBasePlan().subscribe(plan => {
this.planInEditing.plan.length = 0; while (this.original.length > 0) {
plan.plan.forEach(p => this.planInEditing.plan.push(p)); this.original.pop();
}
plan.plan.forEach(item => this.original.push(item));
this.filterPlanElements();
}) })
} }
@ -304,11 +344,12 @@ export class EditorComponent implements OnInit {
const dialogRef = this.dialog.open(EditNotesComponentDialog, { const dialogRef = this.dialog.open(EditNotesComponentDialog, {
data: { data: {
note: item.notes note: item.notes
} },
}) disableClose: true
},)
dialogRef.afterClosed().subscribe(note => { dialogRef.afterClosed().subscribe(note => {
if (note) { if (note != undefined && note != null) {
if (item.notes !== note) { if (item.notes !== note) {
item.edited = true; item.edited = true;
} }

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

@ -0,0 +1,24 @@
.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%;
}

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

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

@ -1,4 +1,4 @@
import { AfterViewInit, Component, ElementRef, Inject, Input, ViewChild, ViewEncapsulation } from '@angular/core'; import { Component, ElementRef, Inject, Input, ViewChild, } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
@ -19,32 +19,33 @@ interface DialogData {
styleUrls: ['./notes.component.scss'], styleUrls: ['./notes.component.scss'],
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, MatButtonModule], imports: [CommonModule, FormsModule, MatButtonModule],
encapsulation: ViewEncapsulation.None,
}) })
export class NotesComponent implements AfterViewInit { export class NotesComponent {
@Input() @Input()
note?: string; note?: string;
@ViewChild("ref") @ViewChild("ref")
ref?: ElementRef ref?: ElementRef
constructor(public md: MarkdownService) {} realValue: any;
ngAfterViewInit(): void {
} constructor(public md: MarkdownService) {}
} }
@Component({ @Component({
selector: 'notes-editor', selector: 'notes-editor',
templateUrl: 'edit-notes.component.html', templateUrl: 'edit-notes.component.html',
styleUrls: ['./edit-notes.component.scss'],
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, MatDialogModule, MatFormFieldModule, MatInputModule, FormsModule, MatButtonModule, NotesComponent, ScalableComponent], imports: [CommonModule, FormsModule, MatDialogModule, MatFormFieldModule, MatInputModule, FormsModule, MatButtonModule, NotesComponent, ScalableComponent],
encapsulation: ViewEncapsulation.None,
}) })
export class EditNotesComponentDialog { export class EditNotesComponentDialog {
note: string; note?: string;
constructor( constructor(
public dialogRef: MatDialogRef<EditNotesComponentDialog>, public dialogRef: MatDialogRef<EditNotesComponentDialog>,
@Inject(MAT_DIALOG_DATA) public data: DialogData, @Inject(MAT_DIALOG_DATA) public data: DialogData,
public md: MarkdownService
) { ) {
if (data.note) { if (data.note) {
this.note = `${data.note}`; this.note = `${data.note}`;
@ -54,6 +55,10 @@ export class EditNotesComponentDialog {
} }
cancel() { cancel() {
this.dialogRef.close(); this.dialogRef.close(undefined);
}
save() {
this.dialogRef.close(this.note);
} }
} }

@ -0,0 +1,12 @@
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);
}
}

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

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

@ -1,40 +1,33 @@
import { AfterViewInit, ChangeDetectorRef, Component, Input, NgZone, OnInit, ViewChild } from '@angular/core'; import { Component, Input, NgZone, OnInit } from '@angular/core';
import { NgxMoveableComponent, OnDragEnd, OnResize, OnResizeEnd } from 'ngx-moveable';
import { OnDrag } from 'ngx-moveable';
import { ConfigService } from '../_services/config.service'; import { ConfigService } from '../_services/config.service';
import { Rect } from '../_models/generated/Rect';
import { ShortcutService } from '../_services/shortcut.service'; import { ShortcutService } from '../_services/shortcut.service';
import { CarouselComponent } from '../carousel/carousel.component'; import { CarouselComponent } from '../carousel/carousel.component';
import { PlanService } from '../_services/plan.service'; import { PlanService } from '../_services/plan.service';
import { Plan, PlanElement, PlanMetadata } from '../_models/plan'; import { Plan, PlanElement } from '../_models/plan';
import { WorldAreaService } from '../_services/world-area.service'; import { WorldAreaService } from '../_services/world-area.service';
import { WorldArea } from '../_models/world-area'; import { WorldArea } from '../_models/world-area';
import { Subscription, from } from 'rxjs'; import { Subscription, from } from 'rxjs';
import { open } from '@tauri-apps/api/dialog';
import { OverlayService, StateEvent } from '../_services/overlay.service'; import { OverlayService, StateEvent } from '../_services/overlay.service';
import { appWindow } from '@tauri-apps/api/window'; import { appWindow } from '@tauri-apps/api/window';
import { EventsService } from '../_services/events.service'; import { EventsService } from '../_services/events.service';
import { Event } from '@tauri-apps/api/event'; 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({ @Component({
selector: 'plan-display', selector: 'plan-display',
templateUrl: './plan-display.component.html', templateUrl: './plan-display.component.html',
styleUrls: ['./plan-display.component.scss'] styleUrls: ['./plan-display.component.scss']
}) })
export class PlanDisplayComponent implements AfterViewInit, OnInit { export class PlanDisplayComponent implements OnInit {
@Input() backgroundColor?: string;
@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; slideIndex: number = 0;
zoneSlides?: CarouselComponent<PlanElement>; zoneSlides?: CarouselComponent<PlanElement>;
currentSlides?: CarouselComponent<PlanElement>; currentSlides?: CarouselComponent<PlanElement>;
worldAreaMap?: Map<String, WorldArea>; worldAreaMap?: Map<String, WorldArea>;
settingsOpen: boolean = false; settingsOpen: boolean = false;
init: boolean = false;
hasAttachedOnce: boolean = false; hasAttachedOnce: boolean = false;
overlayStateChangeHandle?: Subscription; overlayStateChangeHandle?: Subscription;
@ -43,52 +36,73 @@ export class PlanDisplayComponent implements AfterViewInit, OnInit {
prevBind?: Subscription; prevBind?: Subscription;
currentPlan?: Plan; currentPlan?: Plan;
previousPlans: PlanMetadata[] = [];
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()
})
});
constructor(
public configService: ConfigService,
public planService: PlanService,
public worldAreaService: WorldAreaService,
public overlayService: OverlayService,
public dialog: MatDialog,
public timeTrackerService: TimeTrackerService,
this.planService.enumerateStoredPlans().subscribe(plans => { private events: EventsService,
this.previousPlans = plans; private shortcut: ShortcutService,
}) private zone: NgZone,
private runStatService: RunStatService,
) {
this.planService.getCurrentPlan().subscribe(plan => { this.planService.getCurrentPlan().subscribe(plan => {
this.currentPlan = plan; this.currentPlan = plan;
if (this.configService.config.enableStopwatch) {
this.loadComparisonData(this.currentPlan);
}
this.timeTrackerService.onNewRun(plan);
//Close settings anytime we get a new current plan.
this.settingsOpen = false;
setTimeout(() => this.setIndex(plan.current), 0); setTimeout(() => this.setIndex(plan.current), 0);
}) })
this.registerOnZoneEnter(); this.registerOnZoneEnter();
} }
registerOnZoneEnter() { loadComparisonData(plan: Plan) {
appWindow.listen("entered", (entered) => { if (!this.configService.config.runCompareHistory) {
if (this.currentPlan) { return;
const current = this.currentPlan.current; }
const length = this.currentPlan.plan.length;
if (current + 1 < length) { this.timeTrackerService.loadHistory(this.configService.config.runCompareHistory).subscribe(history => {
if (entered.payload === this.currentPlan.plan[current + 1].area_key) { if (history) {
this.zone.run(() => this.next()); this.runStatService.insertTimesAtCheckpoints(history, plan);
}
}
} }
}); });
} }
windowInitHandler() {
if (window.innerWidth > 0) { registerOnZoneEnter() {
this.ngAfterViewInit(); appWindow.listen("entered", (entered) => {
} if (this.currentPlan && typeof entered.payload == "string") {
if (this.currentPlan.isNext(entered.payload)) {
this.zone.run(() => this.next());
}
}
});
} }
ngOnInit() { ngOnInit() {
this.worldAreaService.getWorldAreas().subscribe(a => this.worldAreaMap = a); this.worldAreaService.getFullWorldAreas().subscribe(a => this.worldAreaMap = a);
this.overlayStateChangeHandle = this.events.listen<StateEvent>("OverlayStateChange").subscribe(this.onOverlayStateChange.bind(this)); 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();
}
});
} }
onOverlayStateChange(event: Event<StateEvent>) { onOverlayStateChange(event: Event<StateEvent>) {
@ -111,18 +125,6 @@ export class PlanDisplayComponent implements AfterViewInit, OnInit {
return Math.abs(v); return Math.abs(v);
} }
transform() {
return `translate(${this.rect!.x}px, ${this.rect!.y}px)`;
}
width() {
return `${this.rect!.width}px`;
}
height() {
return `${this.rect!.height}px`;
}
hasWaypoint(key?: string): boolean { hasWaypoint(key?: string): boolean {
if (!key) { if (!key) {
key = this.currentPlan!.plan[this.currentPlan!.current].area_key; key = this.currentPlan!.plan[this.currentPlan!.current].area_key;
@ -139,52 +141,6 @@ export class PlanDisplayComponent implements AfterViewInit, OnInit {
return this.worldAreaService.hasTrial(key); return this.worldAreaService.hasTrial(key);
} }
ngAfterViewInit(): void {
if (window.innerWidth > 0) {
const cfgRect = this.configService.config.initialPlanWindowPosition;
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;
}
}
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() {
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,
}
}
registerZoneSlides(carousel: CarouselComponent<PlanElement>) { registerZoneSlides(carousel: CarouselComponent<PlanElement>) {
this.zoneSlides = carousel; this.zoneSlides = carousel;
this.zoneSlides.setIndex(this.slideIndex); this.zoneSlides.setIndex(this.slideIndex);
@ -192,8 +148,23 @@ export class PlanDisplayComponent implements AfterViewInit, OnInit {
setupBinds() { setupBinds() {
if (this.currentSlides && !this.bindsAreSetup) { if (this.currentSlides && !this.bindsAreSetup) {
this.nextBind = this.shortcut.register(this.configService.config.prev).subscribe((_shortcut) => this.prev());
this.prevBind = this.shortcut.register(this.configService.config.next).subscribe((_shortcut) => this.next()); 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.bindsAreSetup = true; this.bindsAreSetup = true;
} }
} }
@ -208,11 +179,54 @@ export class PlanDisplayComponent implements AfterViewInit, OnInit {
next() { next() {
if (this.overlayService.visible) { if (this.overlayService.visible) {
this.currentPlan!.next(); this.currentPlan!.next();
this.checkCheckpoint();
this.currentSlides?.next(); this.currentSlides?.next();
this.zoneSlides?.next(); this.zoneSlides?.next();
} }
} }
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!);
}
}
yourDiff(element: PlanElement) {
if (!element.checkpoint || !element.checkpoint_your_millis || !element.checkpoint_millis) return "";
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)}`;
}
}
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";
}
showDiff(element: PlanElement) {
return element.checkpoint && element.checkpoint_your_millis && element.checkpoint_millis;
}
cpMillis(element: PlanElement) {
if (!element.checkpoint) return "";
if (!element.checkpoint_millis) return "N/A";
return this.timeTrackerService.hmsTimestamp(element.checkpoint_millis);
}
prev() { prev() {
if (this.overlayService.visible) { if (this.overlayService.visible) {
this.currentPlan!.prev(); this.currentPlan!.prev();
@ -231,55 +245,22 @@ export class PlanDisplayComponent implements AfterViewInit, OnInit {
} }
} }
loadPrevious(path: string) {
this.planService.loadPlanFromPath(path, false).subscribe(plan => {
this.planService.setCurrentPlan(plan);
});
}
settingsClick(event: any) { settingsClick(event: any) {
this.settingsOpen = !this.settingsOpen; this.settingsOpen = !this.settingsOpen;
event.stopPropagation(); event.stopPropagation();
} }
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);
this.settingsOpen = false;
}
});
}
});
}
loadBasePlan() {
this.planService.getBasePlan().subscribe(plan => {
this.currentPlan = new Plan(plan);
if (this.zoneSlides) {
this.zoneSlides.setIndex(0);
}
if (this.currentSlides) {
this.currentSlides.setIndex(0);
}
})
}
onScroll(event: WheelEvent) { onScroll(event: WheelEvent) {
if (event.deltaY < 0) { if (event.deltaY < 0) {
this.prev(); this.prev();
this.timeTrackerService.onForcePrev(this.currentPlan!.plan[this.currentPlan!.current].area_key);
this.checkCheckpoint();
} else { } else {
this.next(); this.next();
this.timeTrackerService.onForceNext(this.currentPlan!.plan[this.currentPlan!.current].area_key);
this.checkCheckpoint();
} }
} }
@ -300,18 +281,33 @@ export class PlanDisplayComponent implements AfterViewInit, OnInit {
} }
} }
loadFromUrl() { shouldDisplayTimer(): boolean {
this.planService.loadFromUrl().subscribe(plan => { if (!this.configService.config.enableStopwatch) return false;
console.log("plan", plan);
if (plan) { return this.timeTrackerService.isActive;
this.planService.savePlanAtStore(plan.name!, plan).subscribe((path) => { }
console.log("path", path);
if(path) {
plan.setPath(path); displayZoneName(zoneName: string) {
} if (this.configService.config.shortenZoneNames) {
}); return this.trim(this.trimUnneccesaryWords(zoneName));
this.planService.setCurrentPlan(plan); } 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,11 +12,19 @@ import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatListModule } from '@angular/material/list'; import { MatListModule } from '@angular/material/list';
import { ScalableComponent } from '../Scalable/scalable.component'; 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 { 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({ @NgModule({
declarations: [ declarations: [
PlanDisplayComponent PlanDisplayComponent,
], ],
imports: [ imports: [
CommonModule, CommonModule,
@ -33,7 +41,15 @@ import { TooltipComponent } from '../tooltip/tooltip.component';
MatListModule, MatListModule,
ScalableComponent, ScalableComponent,
MatTooltipModule, MatTooltipModule,
TooltipComponent TooltipComponent,
AngularSvgIconModule,
ScrollingModule,
MatDialogModule,
ResumeDialog,
AggregateDisplayComponent,
DraggableWindowComponent,
StopwatchControlsComponent,
PlanSelectionComponent,
], ],
exports: [ exports: [
PlanDisplayComponent PlanDisplayComponent

@ -0,0 +1,37 @@
<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>

@ -0,0 +1,34 @@
.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;
}

@ -0,0 +1,21 @@
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();
});
});

@ -0,0 +1,143 @@
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;
}
}

@ -0,0 +1,6 @@
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>

@ -0,0 +1,33 @@
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>) {}
}

@ -0,0 +1,5 @@
<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>

@ -0,0 +1,21 @@
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();
});
});

@ -0,0 +1,15 @@
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) { }
}

@ -0,0 +1,60 @@
<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>

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

@ -0,0 +1,21 @@
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();
});
});

@ -0,0 +1,155 @@
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,4 +59,37 @@
[max]="configService.config.numVisible - 1" step="1"> [max]="configService.config.numVisible - 1" step="1">
</mat-form-field> </mat-form-field>
</div> </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> </div>

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

@ -0,0 +1,21 @@
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();
});
});

@ -0,0 +1,14 @@
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>;
}

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 159 B

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 388 B

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 295 B

@ -80,4 +80,16 @@ div.picker_wrapper.popup {
.mdc-notched-outline__notch { .mdc-notched-outline__notch {
clip-path: none !important; 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