Compare commits

...

31 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
isark 7b1fbe01df Fixed missed function call after extracting out some code into a separate method
1 year ago
isark 4f30d8b0ba Should basically be back to working as it did before the refactoring.
1 year ago
isark a1249b46ab Initial first version post cleanup, need to test it out
1 year ago
isark 6282f12ce9 Some more backend api improvements. TODO: Adapt frontend usage to this new interface
1 year ago
isark 033b061fb6 Broken but initial cleanup code...
1 year ago
isark 48d1dda124 Lots of work around importing plans from urls, also keeping track of the url to allow updating the plan later on if there's been a change.
1 year ago
isark 21333908ec (Unreleased) Should now ignore next/prev hotkeys if the overlay isn't visible. Also improved binding/unbinding to be more rxjs-ish
1 year ago
isark a8334cbd03 Patched base plan to correct wrong zone in note
2 years ago
isakfredriksson b10619182e Minor tweaks to dropEndHandler, gonna reuse the end code for double click handler
2 years ago
isark d1d64b7960 Added section anchor points and support for using these in the filter
2 years 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
processed_world_areas.json
processed_world_areas_full.json
releases
.env
releaser_key

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

10
src-tauri/Cargo.lock generated

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

@ -20,7 +20,7 @@ ts-rs = "6.2.1"
[dependencies]
steamlocate = "1.2.1"
tauri = { version = "1.2", features = [ "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_json = "1.0"
Underlayer = { git = "https://git.isark.me/isark/Underlay.git" }
@ -39,6 +39,7 @@ notify = "6.0.1"
regex = "1.9.3"
lazy_static = "1.4.0"
uuid = { version = "1.6.1", features = ["v4", "serde"] }
serde_with = "3.7.0"
[features]
# this feature is used for production builds or when `devPath` points to the filesystem

@ -37,9 +37,6 @@ struct UnprocessedArea {
#[serde(rename = "IsVaalArea")]
pub is_vaal_area: bool,
#[serde(rename = "Unknown64")]
pub unknown64: bool,
#[serde(rename = "Unknown9")]
pub unknown9: i64,
}
@ -61,8 +58,7 @@ pub type AreaId = usize;
pub type WorldAreasMap = HashMap<String, WorldArea>;
#[allow(dead_code)]
pub fn load_world_areas_map(content: &'static str) -> WorldAreasMap {
serde_json::from_str::<WorldAreasMap>(content)
.expect("Could not load world areas json")
serde_json::from_str::<WorldAreasMap>(content).expect("Could not load world areas json")
}
#[macro_export]
@ -206,7 +202,6 @@ pub fn repack(content: &str, out_path: &str) {
if w_a.act < 11
&& !w_a.is_vaal_area
&& !w_a.connections_world_areas_keys.is_empty()
&& !w_a.unknown64
&& !w_a.named_id.starts_with("Map")
&& !w_a.named_id.starts_with("Descent")
&& !w_a.named_id.starts_with("EndlessLedge")
@ -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");
}
#[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();
const OUT_PROCESSED: &'static str = "../data/processed_world_areas.json";
const OUT_PROCESSED_FULL: &'static str = "../data/processed_world_areas_full.json";
println!("cargo:rerun-if-changed=../data/WorldAreas.json");
poe_data::world_area::repack(&include_str!("../../data/WorldAreas.json"), OUT_PROCESSED);
poe_data::world_area::repack_full(&include_str!("../../data/WorldAreas.json"), OUT_PROCESSED_FULL);
//Let's go with uppercase names, allows automatic import to work...
config::Rect::export_to("../src/app/_models/generated/Rect.ts").expect("Could not generate config struct");

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

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

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

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

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

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

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

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

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

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

@ -1,90 +1,162 @@
import { Injectable } from '@angular/core';
import { invoke } from '@tauri-apps/api';
import { Observable, ReplaySubject, Subject, from, map, tap } from 'rxjs';
import { Plan, PlanInterface } from '../_models/plan';
import { EMPTY, Observable, ReplaySubject, Subject, from, map, switchMap, tap } from 'rxjs';
import { Plan, PlanInterface, PlanMetadata } from '../_models/plan';
import { MatDialog } from '@angular/material/dialog';
import { UrlDialog } from '../plan-display/url-dialog.component';
import { fetch } from '@tauri-apps/api/http';
import { Response } from '@tauri-apps/api/http';
export class UrlError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
@Injectable({
providedIn: 'root'
})
export class PlanService {
currentPlan?: Plan;
planStore: string[] = [];
basePlan?: Plan;
private basePlanSubj: Subject<Plan> = new ReplaySubject<Plan>(1);
private _currentPlanSubject: Subject<Plan> = new ReplaySubject<Plan>(1);
private _basePlanSubject: Subject<Plan> = new ReplaySubject<Plan>(1);
private _storedPlansSubject: Subject<PlanMetadata[]> = new ReplaySubject<PlanMetadata[]>(1);
constructor() {
this.getPreviousPlans();
constructor(private dialog: MatDialog) {
this.loadBasePlan();
this.loadStoredPlans();
}
public getBasePlan(): Observable<Plan> {
return this._basePlanSubject.asObservable();
}
loadPlan(path: string) {
return from(invoke<PlanInterface>('load_plan', { path })).pipe(
map(plan => {
this.currentPlan = new Plan(plan);
return plan
})
);
public getCurrentPlan(): Observable<Plan> {
return this._currentPlanSubject.asObservable();
}
loadPlanNoSave(path: string) {
return from(invoke<PlanInterface>('load_plan', { path })).pipe(
map(plan => {
this.currentPlan = new Plan(plan);
this.currentPlan.setPath(path);
return plan
})
);
public setCurrentPlan(plan: Plan) {
this._currentPlanSubject.next(plan);
}
public getStoredPlans(): Observable<PlanMetadata[]> {
return this._storedPlansSubject.asObservable();
}
loadBasePlan() {
if(!this.basePlan) {
from(invoke<PlanInterface>('base_plan')).subscribe(plan => {
plan.plan.forEach(elem => {elem.edited = false;});
this.basePlan = new Plan(plan);
this.basePlanSubj?.next(this.basePlan);
public loadPlanFromPath(path: string, save_local: boolean = true): Observable<Plan> {
return from(invoke<PlanInterface>('load_plan_at_path', { path, saveLocal: save_local })).pipe(map(plan => {
return new Plan(plan)
})).pipe(tap(() => {
this.loadStoredPlans();
}));
}
public loadFromUrl(url?: string, name?: string, save_local: boolean = false): Observable<Plan> {
if (!url || !name) {
const dialogRef = this.dialog.open(UrlDialog, {
data: {
url: url,
name: name
}
});
}
return this.basePlanSubj!.asObservable();
return dialogRef.afterClosed().pipe(switchMap(data => {
if (data.url) {
return this._loadFromUrl(data.url, data.name, save_local);
}
return new Observable<Plan>((s) => s.complete());
}));
} else {
return this._loadFromUrl(url, name, save_local);
}
}
savePlan(path: string, plan: Plan) {
public savePlanAtPath(path: string, plan: Plan) {
plan.plan.forEach(elem => {
if (!elem.notes) { elem.notes = "" }
});
return from(invoke<boolean>('save_plan', {
path,
plan: {
plan: plan.plan,
current: plan.current
},
})).subscribe(status => {
});
return from(invoke('save_plan_at_path', { path, plan: plan.toInterface() }));
}
getPreviousPlans() {
from(invoke<string[]>('load_stored_plans')).subscribe(plans => this.planStore = plans);
public checkForPlanUpdate(plan: Plan | PlanMetadata): Observable<Plan> {
if (!plan.update_url) return EMPTY;
return from(fetch(
plan.update_url,
{
method: 'HEAD',
timeout: 10
})).pipe(switchMap(response => {
this.validateResponse(response);
if (response.headers['etag'] === plan.latest_server_etag) {
return EMPTY;
}
return this._loadFromUrl(plan.update_url!, plan.name!, false);
}));
}
loadPrevious(name: string) {
return from(invoke<PlanInterface>('load_previous', { name })).pipe(tap(plan => {
console.log("previous loaded: ", plan);
this.currentPlan = new Plan(plan);
this.currentPlan.setPrevious(name);
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();
}));
}
zoneFromUuid(uuid: string) {
if (!this.basePlan) {
return undefined;
private _loadFromUrl(url: string, name: string, save_local: boolean): Observable<Plan> {
//Tauri fetch
return from(fetch(
url,
{
method: 'GET',
timeout: 10
})).pipe(map(response => {
this.validateResponse(response);
const plan = new Plan(response.data as PlanInterface);
const etag = response.headers['etag'];
if (etag) {
plan.latest_server_etag = etag;
console.log("got etag: ", etag);
}
return plan;
})).pipe(tap(plan => {
plan.update_url = url;
plan.name = name;
if (save_local) {
this.savePlanAtStore(name, plan).subscribe();
}
this.loadStoredPlans();
}));
}
private validateResponse(response: Response<unknown>) {
if (!response.ok) {
throw new UrlError(response.status, "Error fetching plan from URL, status " + response.status + ", URL: " + response.url + " with body:" + response.data);
}
}
private loadBasePlan() {
from(invoke<PlanInterface>('base_plan')).subscribe(plan => {
plan.plan.forEach(elem => { elem.edited = false; });
plan.current = 0;
plan.name = "Base Plan";
this._basePlanSubject?.next(new Plan(plan));
});
}
return this.basePlan.plan.find(elem => elem.uuid === uuid);
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,45 +1,52 @@
import { Injectable, NgZone } from '@angular/core';
import { ShortcutHandler, register, unregister } from '@tauri-apps/api/globalShortcut';
import { EMPTY, from } from 'rxjs';
import { Observable, Subscriber, from } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class ShortcutService {
bound: Map<ShortcutHandler, string> = new Map<ShortcutHandler, string>();
private internalHandlers: Map<string, [ShortcutHandler, Subscriber<string>, () => void]> = new Map<string, [ShortcutHandler, Subscriber<string>, () => void]>();
constructor(private zone: NgZone) {
}
register(shortcut: string, handler: ShortcutHandler) {
this.bound.set(handler, shortcut);
constructor(private zone: NgZone) { }
return from(register(shortcut, (s) => {
this.zone.run(() => handler(s));
}));
}
register(shortcut: string) {
return new Observable<string>((subscriber) => {
unregister(handler: ShortcutHandler) {
const shortcut = this.bound.get(handler);
this.bound.delete(handler);
let originalHandler: ShortcutHandler = (s) => this.zone.run(() => subscriber.next(s));
return shortcut ? from(unregister(shortcut)) : EMPTY;
}
const teardown = () => {
unregister(shortcut);
this.internalHandlers.delete(shortcut);
};
rebind(shortcut: string, handler: ShortcutHandler) {
const prevShortcut = this.bound.get(handler);
this.register(shortcut, handler).subscribe(
{
error: (_err) => {
if (prevShortcut) {
this.register(prevShortcut, handler);
}
return EMPTY;
}
});
this.internalHandlers.set(shortcut, [originalHandler, subscriber, teardown]);
register(shortcut, originalHandler).catch(e => subscriber.error(e));
return teardown;
});
}
rebind_from_to(previousShortcut: string, nextShortcut: string) {
const oldHandler = [...this.bound.entries()].find((entry: [ShortcutHandler, string]) => entry[1] === previousShortcut)?.[0];
this.rebind(nextShortcut, oldHandler!);
let [oldHandler, subscriber, teardown] = this.internalHandlers.get(previousShortcut)!;
subscriber.remove(teardown);
teardown();
teardown = () => {
unregister(nextShortcut);
this.internalHandlers.delete(nextShortcut);
};
register(nextShortcut, oldHandler);
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;
private worldAreasSubject = new ReplaySubject<Map<string, WorldArea>>();
private fullWorldAreasSubject = new ReplaySubject<Map<string, WorldArea>>();
constructor(private zone: NgZone) {
from(invoke<Map<string, WorldArea>>('load_world_areas')).subscribe((data) => {
@ -23,6 +24,12 @@ export class WorldAreaService {
toString: (e: [string, WorldArea]) => e[1].name
})
});
from(invoke<Map<string, WorldArea>>('load_full_world_areas')).subscribe((data) => {
const entries = Object.entries(data).sort((a, b) => a[1].key_id - b[1].key_id);
this.worldAreas = new Map(entries);
this.zone.run(() => this.fullWorldAreasSubject.next(this.worldAreas!));
});
}
getWorldAreas(): Observable<Map<string, WorldArea>> {
@ -31,6 +38,12 @@ export class WorldAreaService {
);
}
getFullWorldAreas(): Observable<Map<string, WorldArea>> {
return this.fullWorldAreasSubject.asObservable().pipe(
filter(worldAreas => !!worldAreas),
);
}
hasTrial(key: string): boolean {
switch (key) {
case "1_1_7_1": return true;

@ -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">
<plan-editor class="content"></plan-editor>
</mat-tab>
<mat-tab label="Previous runs" *ngIf="configService.config.enableStopwatch">
<app-run-stats class="content"></app-run-stats>
</mat-tab>
</mat-tab-group>
<tooltip class="tooltip">

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

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

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

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

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

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

@ -1,17 +1,15 @@
<div class="NOTES_COMPONENT">
<div class="container">
<span>Edit note </span><span style="color: grey; font-size: 0.9em;">(supports markdown)</span>
<div class="left">
<textarea [(ngModel)]="note" cols="50" rows="10"></textarea>
</div>
<span><span>Preview </span><span style="color: grey; font-size: 0.9em;">(Unscaled)</span></span>
<div class="right">
<notes [note]="note"></notes>
</div>
<div class="container">
<span>Edit note </span><span style="color: grey; font-size: 0.9em;">(supports markdown)</span>
<div>
<textarea [(ngModel)]="note" cols="50" rows="10"></textarea>
</div>
<div mat-dialog-actions>
<button mat-button color="warn" (click)="cancel()">Cancel</button>
<button mat-button [mat-dialog-close]="note" cdkFocusInitial>Save</button>
<span><span>Preview </span><span style="color: grey; font-size: 0.9em;">(Unscaled)</span></span>
<div class="w-100">
<div class="display-component" [innerHTML]="md.render(note ?? '')"></div>
</div>
</div>
<div mat-dialog-actions>
<button mat-button color="warn" (click)="cancel()">Cancel</button>
<button mat-button (click)="save()" cdkFocusInitial>Save</button>
</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 {
display: flex;
flex-direction: column;
}
img {
display: block;
width: 100%;
height: 100%;
max-height: 100%;
object-fit: contain;
}
.container {
display: flex;
flex-direction: column;
height: 800px;
width: 1200px;
}
&.display-component {
max-height: 100%;
height: 100%;
display: grid;
grid-auto-flow: row;
grid-template-rows: repeat(auto-fit, minmax(50px, 1fr));
}
img {
display: block;
width: 100%;
height: 100%;
max-height: 100%;
object-fit: contain;
}
& {
font-size: 1.3em;
}
.display-component {
max-height: 100%;
height: 100%;
display: grid;
grid-auto-flow: row;
grid-template-rows: repeat(auto-fit, minmax(50px, 1fr));
font-size: 1.3em;
}
.note-preview {
min-height: 400px;
}
.note-preview {
min-height: 400px;
max-height: 400px;
min-width: 600px;
max-width: 600px;
}

@ -1,7 +1,7 @@
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 { CommonModule, NgIf } from '@angular/common';
import { MatButton, MatButtonModule } from '@angular/material/button';
import { CommonModule } from '@angular/common';
import { MatButtonModule } from '@angular/material/button';
import { FormsModule } from '@angular/forms';
import { MatInputModule } from '@angular/material/input';
import { MatFormFieldModule } from '@angular/material/form-field';
@ -19,32 +19,33 @@ interface DialogData {
styleUrls: ['./notes.component.scss'],
standalone: true,
imports: [CommonModule, FormsModule, MatButtonModule],
encapsulation: ViewEncapsulation.None,
})
export class NotesComponent implements AfterViewInit {
export class NotesComponent {
@Input()
note?: string;
@ViewChild("ref")
ref?: ElementRef
constructor(public md: MarkdownService) {}
ngAfterViewInit(): void {
}
realValue: any;
constructor(public md: MarkdownService) {}
}
@Component({
selector: 'notes-editor',
templateUrl: 'edit-notes.component.html',
styleUrls: ['./edit-notes.component.scss'],
standalone: true,
imports: [CommonModule, FormsModule, MatDialogModule, MatFormFieldModule, MatInputModule, FormsModule, MatButtonModule, NotesComponent, ScalableComponent],
encapsulation: ViewEncapsulation.None,
})
export class EditNotesComponentDialog {
note: string;
note?: string;
constructor(
public dialogRef: MatDialogRef<EditNotesComponentDialog>,
@Inject(MAT_DIALOG_DATA) public data: DialogData,
public md: MarkdownService
) {
if (data.note) {
this.note = `${data.note}`;
@ -54,6 +55,10 @@ export class EditNotesComponentDialog {
}
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,4 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Rect } from "./Rect";
export interface Config { initialPlanWindowPosition: Rect, hideOnUnfocus: boolean, toggleOverlay: string, prev: string, next: string, planBg: string, backdropBg: string, noteDefaultFg: string, poeClientLogPath: string | null, }

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

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

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

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

@ -12,11 +12,19 @@ import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatListModule } from '@angular/material/list';
import { ScalableComponent } from '../Scalable/scalable.component';
import {MatTooltipModule} from '@angular/material/tooltip';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TooltipComponent } from '../tooltip/tooltip.component';
import { AngularSvgIconModule } from 'angular-svg-icon';
import { ScrollingModule } from '@angular/cdk/scrolling';
import { MatDialogModule } from '@angular/material/dialog';
import { ResumeDialog } from './resume-dialog.component';
import { AggregateDisplayComponent } from '../aggregate-display/aggregate-display.component';
import { DraggableWindowComponent } from '../draggable-window/draggable-window.component';
import { StopwatchControlsComponent } from './stopwatch-controls/stopwatch-controls.component';
import { PlanSelectionComponent } from './plan-selection/plan-selection.component';
@NgModule({
declarations: [
PlanDisplayComponent
PlanDisplayComponent,
],
imports: [
CommonModule,
@ -33,7 +41,15 @@ import { TooltipComponent } from '../tooltip/tooltip.component';
MatListModule,
ScalableComponent,
MatTooltipModule,
TooltipComponent
TooltipComponent,
AngularSvgIconModule,
ScrollingModule,
MatDialogModule,
ResumeDialog,
AggregateDisplayComponent,
DraggableWindowComponent,
StopwatchControlsComponent,
PlanSelectionComponent,
],
exports: [
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,7 @@
<label>Plan name</label><input matInput type="text" [(ngModel)]="data2.name"><br>
<label>Url</label><input matInput type="text" [(ngModel)]="data2.url">
<div mat-dialog-actions>
<button mat-button (click)="cancel()">Cancel</button>
<button mat-button [mat-dialog-close]="data2" cdkFocusInitial>Save</button>
</div>

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

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

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

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

After

Width:  |  Height:  |  Size: 457 B

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

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