mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 14:02:21 -04:00
Feat/support load balance and easy days in FSRS simulator (#3829)
* Feat/support load balance and easy days in FSRS simulator * format * consider LoadBalancerEnabled * use fsrs::PostSchedulingFn * add load balance and easy days to compute_optimal_retention * move simulator to a pop-over * fix incorrect simulationNumber when error 500 * Feat: Save to Preset Options * update tabs when update newPerDay & reviewsPerDay * don't reset deckSize & daysToSimulate when save options * fix missing easy days * plan to support review priority * Fix graph line rendering with non-scaling stroke * simplify review priority function with helper wrapper * fallback to default ReviewPriority for Added & ReverseAdded * Update ts/routes/deck-options/SimulatorModal.svelte Co-authored-by: Luc Mcgrady <lucmcgrady@gmail.com> * Wrap review priority function in Arc for thread-safe sharing * more granularity for R sorting * Add graph smoothing option to FSRS simulator * Improve graph resize handling in FSRS simulator * simplify review priority calculation * Add review order selection to FSRS simulator modal * Refactor review priority function using macro for conciseness * Add copyright and license header to SimulatorModal.svelte * cargo clippy * ./ninja fix:eslint * update fsrs-rs * Update FSRS dependencies and refactor load balancing functions - Update fsrs-rs dependency to latest commit - Modify retention and simulator modules to use Arc instead of Box - Update function signatures and imports in simulator module - Simplify review card order handling with direct enum usage * resolve reviewed changes * replace .unwrap() with ? * move simulating into SimulatorModal * add (crate) to interval_to_weekday * Update FsrsOptions.svelte * format --------- Co-authored-by: Luc Mcgrady <lucmcgrady@gmail.com>
This commit is contained in:
parent
96df6becfc
commit
a6426bebe2
12 changed files with 597 additions and 249 deletions
7
Cargo.lock
generated
7
Cargo.lock
generated
|
@ -2098,9 +2098,8 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fsrs"
|
||||
version = "2.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9174d1073f50c78ac1bb74ec9add329139f0bbc95747b1c1a490513ef5df50f"
|
||||
version = "3.0.0"
|
||||
source = "git+https://github.com/open-spaced-repetition/fsrs-rs.git?rev=96520531415e032781adfe212f8a5eed216006be#96520531415e032781adfe212f8a5eed216006be"
|
||||
dependencies = [
|
||||
"burn",
|
||||
"itertools 0.12.1",
|
||||
|
@ -2497,7 +2496,7 @@ dependencies = [
|
|||
"log",
|
||||
"presser",
|
||||
"thiserror 1.0.69",
|
||||
"windows 0.56.0",
|
||||
"windows 0.58.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
@ -35,9 +35,9 @@ git = "https://github.com/ankitects/linkcheck.git"
|
|||
rev = "184b2ca50ed39ca43da13f0b830a463861adb9ca"
|
||||
|
||||
[workspace.dependencies.fsrs]
|
||||
version = "=2.0.3"
|
||||
# git = "https://github.com/open-spaced-repetition/fsrs-rs.git"
|
||||
# rev = "58ca25ed2bc4bb1dc376208bbcaed7f5a501b941"
|
||||
# version = "=2.0.3"
|
||||
git = "https://github.com/open-spaced-repetition/fsrs-rs.git"
|
||||
rev = "96520531415e032781adfe212f8a5eed216006be"
|
||||
# path = "../open-spaced-repetition/fsrs-rs"
|
||||
|
||||
[workspace.dependencies]
|
||||
|
|
|
@ -1360,7 +1360,7 @@
|
|||
},
|
||||
{
|
||||
"name": "fsrs",
|
||||
"version": "2.0.3",
|
||||
"version": "3.0.0",
|
||||
"authors": "Open Spaced Repetition",
|
||||
"repository": "https://github.com/open-spaced-repetition/fsrs-rs",
|
||||
"license": "BSD-3-Clause",
|
||||
|
|
|
@ -12,6 +12,7 @@ import "anki/cards.proto";
|
|||
import "anki/decks.proto";
|
||||
import "anki/collection.proto";
|
||||
import "anki/config.proto";
|
||||
import "anki/deck_config.proto";
|
||||
|
||||
service SchedulerService {
|
||||
rpc GetQueuedCards(GetQueuedCardsRequest) returns (QueuedCards);
|
||||
|
@ -390,6 +391,8 @@ message SimulateFsrsReviewRequest {
|
|||
uint32 max_interval = 7;
|
||||
string search = 8;
|
||||
bool new_cards_ignore_review_limit = 9;
|
||||
repeated float easy_days_percentages = 10;
|
||||
deck_config.DeckConfig.Config.ReviewCardOrder review_order = 11;
|
||||
}
|
||||
|
||||
message SimulateFsrsReviewResponse {
|
||||
|
@ -405,6 +408,7 @@ message ComputeOptimalRetentionRequest {
|
|||
uint32 max_interval = 3;
|
||||
string search = 4;
|
||||
double loss_aversion = 5;
|
||||
repeated float easy_days_percentages = 6;
|
||||
}
|
||||
|
||||
message ComputeOptimalRetentionResponse {
|
||||
|
|
|
@ -1,13 +1,17 @@
|
|||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
use std::sync::Arc;
|
||||
|
||||
use anki_proto::scheduler::ComputeOptimalRetentionRequest;
|
||||
use fsrs::extract_simulator_config;
|
||||
use fsrs::PostSchedulingFn;
|
||||
use fsrs::SimulatorConfig;
|
||||
use fsrs::FSRS;
|
||||
|
||||
use super::simulator::apply_load_balance_and_easy_days;
|
||||
use crate::prelude::*;
|
||||
use crate::revlog::RevlogEntry;
|
||||
use crate::scheduler::states::load_balancer::parse_easy_days_percentages;
|
||||
use crate::search::SortMode;
|
||||
|
||||
#[derive(Default, Clone, Copy, Debug)]
|
||||
|
@ -35,6 +39,26 @@ impl Collection {
|
|||
let learn_span = req.days_to_simulate as usize;
|
||||
let learn_limit = 10;
|
||||
let deck_size = learn_span * learn_limit;
|
||||
let easy_days_percentages = parse_easy_days_percentages(req.easy_days_percentages)?;
|
||||
let next_day_at = self.timing_today()?.next_day_at;
|
||||
let post_scheduling_fn: Option<PostSchedulingFn> =
|
||||
if self.get_config_bool(BoolKey::LoadBalancerEnabled) {
|
||||
Some(PostSchedulingFn(Arc::new(
|
||||
move |interval, max_interval, today, due_cnt_per_day, rng| {
|
||||
apply_load_balance_and_easy_days(
|
||||
interval,
|
||||
max_interval,
|
||||
today,
|
||||
due_cnt_per_day,
|
||||
rng,
|
||||
next_day_at,
|
||||
&easy_days_percentages,
|
||||
)
|
||||
},
|
||||
)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(fsrs
|
||||
.optimal_retention(
|
||||
&SimulatorConfig {
|
||||
|
@ -54,6 +78,8 @@ impl Collection {
|
|||
learn_limit,
|
||||
review_limit: usize::MAX,
|
||||
new_cards_ignore_review_limit: true,
|
||||
post_scheduling_fn,
|
||||
review_priority_fn: None,
|
||||
},
|
||||
&req.params,
|
||||
|ip| {
|
||||
|
|
|
@ -1,16 +1,118 @@
|
|||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
use std::sync::Arc;
|
||||
|
||||
use anki_proto::deck_config::deck_config::config::ReviewCardOrder;
|
||||
use anki_proto::deck_config::deck_config::config::ReviewCardOrder::*;
|
||||
use anki_proto::scheduler::SimulateFsrsReviewRequest;
|
||||
use anki_proto::scheduler::SimulateFsrsReviewResponse;
|
||||
use fsrs::simulate;
|
||||
use fsrs::PostSchedulingFn;
|
||||
use fsrs::ReviewPriorityFn;
|
||||
use fsrs::SimulatorConfig;
|
||||
use itertools::Itertools;
|
||||
use rand::rngs::StdRng;
|
||||
use rand::Rng;
|
||||
|
||||
use crate::card::CardQueue;
|
||||
use crate::prelude::*;
|
||||
use crate::scheduler::states::fuzz::constrained_fuzz_bounds;
|
||||
use crate::scheduler::states::load_balancer::calculate_easy_days_modifiers;
|
||||
use crate::scheduler::states::load_balancer::interval_to_weekday;
|
||||
use crate::scheduler::states::load_balancer::parse_easy_days_percentages;
|
||||
use crate::scheduler::states::load_balancer::select_weighted_interval;
|
||||
use crate::scheduler::states::load_balancer::EasyDay;
|
||||
use crate::scheduler::states::load_balancer::LoadBalancerInterval;
|
||||
use crate::search::SortMode;
|
||||
|
||||
pub(crate) fn apply_load_balance_and_easy_days(
|
||||
interval: f32,
|
||||
max_interval: f32,
|
||||
day_elapsed: usize,
|
||||
due_cnt_per_day: &[usize],
|
||||
rng: &mut StdRng,
|
||||
next_day_at: TimestampSecs,
|
||||
easy_days_percentages: &[EasyDay; 7],
|
||||
) -> f32 {
|
||||
let (lower, upper) = constrained_fuzz_bounds(interval, 1, max_interval as u32);
|
||||
let mut review_counts = vec![0; upper as usize - lower as usize + 1];
|
||||
|
||||
// Fill review_counts with due counts for each interval
|
||||
let start = day_elapsed + lower as usize;
|
||||
let end = (day_elapsed + upper as usize + 1).min(due_cnt_per_day.len());
|
||||
if start < due_cnt_per_day.len() {
|
||||
let copy_len = (end - start).min(review_counts.len());
|
||||
review_counts[..copy_len].copy_from_slice(&due_cnt_per_day[start..start + copy_len]);
|
||||
}
|
||||
|
||||
let possible_intervals: Vec<u32> = (lower..=upper).collect();
|
||||
let weekdays = possible_intervals
|
||||
.iter()
|
||||
.map(|interval| {
|
||||
interval_to_weekday(
|
||||
*interval,
|
||||
next_day_at.adding_secs(day_elapsed as i64 * 86400),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let easy_days_modifier =
|
||||
calculate_easy_days_modifiers(easy_days_percentages, &weekdays, &review_counts);
|
||||
|
||||
let intervals =
|
||||
possible_intervals
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(interval_index, &target_interval)| LoadBalancerInterval {
|
||||
target_interval,
|
||||
review_count: review_counts[interval_index],
|
||||
sibling_modifier: 1.0,
|
||||
easy_days_modifier: easy_days_modifier[interval_index],
|
||||
});
|
||||
let fuzz_seed = rng.gen();
|
||||
select_weighted_interval(intervals, Some(fuzz_seed)).unwrap() as f32
|
||||
}
|
||||
|
||||
fn create_review_priority_fn(
|
||||
review_order: ReviewCardOrder,
|
||||
deck_size: usize,
|
||||
) -> Option<ReviewPriorityFn> {
|
||||
// Helper macro to wrap closure in ReviewPriorityFn
|
||||
macro_rules! wrap {
|
||||
($f:expr) => {
|
||||
Some(ReviewPriorityFn(std::sync::Arc::new($f)))
|
||||
};
|
||||
}
|
||||
|
||||
match review_order {
|
||||
// Ease-based ordering
|
||||
EaseAscending => wrap!(|c| -(c.difficulty * 100.0) as i32),
|
||||
EaseDescending => wrap!(|c| (c.difficulty * 100.0) as i32),
|
||||
|
||||
// Interval-based ordering
|
||||
IntervalsAscending => wrap!(|c| c.interval as i32),
|
||||
IntervalsDescending => wrap!(|c| -(c.interval as i32)),
|
||||
|
||||
// Retrievability-based ordering
|
||||
RetrievabilityAscending => wrap!(|c| (c.retrievability() * 1000.0) as i32),
|
||||
RetrievabilityDescending => {
|
||||
wrap!(|c| -(c.retrievability() * 1000.0) as i32)
|
||||
}
|
||||
|
||||
// Due date ordering
|
||||
Day | DayThenDeck | DeckThenDay => {
|
||||
wrap!(|c| c.scheduled_due() as i32)
|
||||
}
|
||||
|
||||
// Random ordering
|
||||
Random => {
|
||||
wrap!(move |_| rand::thread_rng().gen_range(0..deck_size) as i32)
|
||||
}
|
||||
|
||||
// Not implemented yet
|
||||
Added | ReverseAdded => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl Collection {
|
||||
pub fn simulate_review(
|
||||
&mut self,
|
||||
|
@ -44,12 +146,43 @@ impl Collection {
|
|||
stability: 1e-8, // Not filtered by fsrs-rs
|
||||
last_date: f32::NEG_INFINITY, // Treated as a new card in simulation
|
||||
due: ((introduced_today_count + i) / req.new_limit as usize) as f32,
|
||||
interval: f32::NEG_INFINITY,
|
||||
});
|
||||
converted_cards.extend(new_cards);
|
||||
}
|
||||
let deck_size = converted_cards.len();
|
||||
let p = self.get_optimal_retention_parameters(revlogs)?;
|
||||
|
||||
let easy_days_percentages = parse_easy_days_percentages(req.easy_days_percentages)?;
|
||||
let next_day_at = self.timing_today()?.next_day_at;
|
||||
|
||||
let post_scheduling_fn: Option<PostSchedulingFn> =
|
||||
if self.get_config_bool(BoolKey::LoadBalancerEnabled) {
|
||||
Some(PostSchedulingFn(Arc::new(
|
||||
move |interval, max_interval, today, due_cnt_per_day, rng| {
|
||||
apply_load_balance_and_easy_days(
|
||||
interval,
|
||||
max_interval,
|
||||
today,
|
||||
due_cnt_per_day,
|
||||
rng,
|
||||
next_day_at,
|
||||
&easy_days_percentages,
|
||||
)
|
||||
},
|
||||
)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let review_priority_fn = req
|
||||
.review_order
|
||||
.try_into()
|
||||
.ok()
|
||||
.and_then(|order| create_review_priority_fn(order, deck_size));
|
||||
|
||||
let config = SimulatorConfig {
|
||||
deck_size: converted_cards.len(),
|
||||
deck_size,
|
||||
learn_span: req.days_to_simulate as usize,
|
||||
max_cost_perday: f32::MAX,
|
||||
max_ivl: req.max_interval as f32,
|
||||
|
@ -65,6 +198,8 @@ impl Collection {
|
|||
learn_limit: req.new_limit as usize,
|
||||
review_limit: req.review_limit as usize,
|
||||
new_cards_ignore_review_limit: req.new_cards_ignore_review_limit,
|
||||
post_scheduling_fn,
|
||||
review_priority_fn,
|
||||
};
|
||||
let result = simulate(
|
||||
&config,
|
||||
|
@ -74,7 +209,7 @@ impl Collection {
|
|||
Some(converted_cards),
|
||||
)?;
|
||||
Ok(SimulateFsrsReviewResponse {
|
||||
accumulated_knowledge_acquisition: result.memorized_cnt_per_day.to_vec(),
|
||||
accumulated_knowledge_acquisition: result.memorized_cnt_per_day,
|
||||
daily_review_count: result
|
||||
.review_cnt_per_day
|
||||
.iter()
|
||||
|
@ -85,7 +220,7 @@ impl Collection {
|
|||
.iter()
|
||||
.map(|x| *x as u32)
|
||||
.collect_vec(),
|
||||
daily_time_cost: result.cost_per_day.to_vec(),
|
||||
daily_time_cost: result.cost_per_day,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -103,6 +238,7 @@ impl Card {
|
|||
stability: state.stability,
|
||||
last_date,
|
||||
due: relative_due as f32,
|
||||
interval: card.interval as f32,
|
||||
})
|
||||
}
|
||||
CardQueue::New => None,
|
||||
|
@ -112,6 +248,7 @@ impl Card {
|
|||
stability: state.stability,
|
||||
last_date: 0.0,
|
||||
due: 0.0,
|
||||
interval: card.interval as f32,
|
||||
})
|
||||
}
|
||||
CardQueue::PreviewRepeat => None,
|
||||
|
|
|
@ -277,29 +277,30 @@ impl LoadBalancer {
|
|||
}
|
||||
}
|
||||
|
||||
/// Build a mapping of deck config IDs to their easy days settings.
|
||||
/// For each deck config, maintains an array of 7 EasyDay values representing
|
||||
/// the load modifier for each day of the week.
|
||||
pub(crate) fn parse_easy_days_percentages(percentages: Vec<f32>) -> Result<[EasyDay; 7]> {
|
||||
if percentages.is_empty() {
|
||||
return Ok([EasyDay::Normal; 7]);
|
||||
}
|
||||
|
||||
Ok(TryInto::<[_; 7]>::try_into(percentages)
|
||||
.map_err(|_| {
|
||||
AnkiError::from(InvalidInputError {
|
||||
message: "expected 7 days".into(),
|
||||
source: None,
|
||||
backtrace: None,
|
||||
})
|
||||
})?
|
||||
.map(EasyDay::from))
|
||||
}
|
||||
|
||||
pub(crate) fn build_easy_days_percentages(
|
||||
configs: HashMap<DeckConfigId, DeckConfig>,
|
||||
) -> Result<HashMap<DeckConfigId, [EasyDay; 7]>> {
|
||||
configs
|
||||
.into_iter()
|
||||
.map(|(dcid, conf)| {
|
||||
let easy_days_percentages: [EasyDay; 7] = if conf.inner.easy_days_percentages.is_empty()
|
||||
{
|
||||
[EasyDay::Normal; 7]
|
||||
} else {
|
||||
TryInto::<[_; 7]>::try_into(conf.inner.easy_days_percentages)
|
||||
.map_err(|_| {
|
||||
AnkiError::from(InvalidInputError {
|
||||
message: "expected 7 days".into(),
|
||||
source: None,
|
||||
backtrace: None,
|
||||
})
|
||||
})?
|
||||
.map(EasyDay::from)
|
||||
};
|
||||
let easy_days_percentages =
|
||||
parse_easy_days_percentages(conf.inner.easy_days_percentages)?;
|
||||
Ok((dcid, easy_days_percentages))
|
||||
})
|
||||
.collect()
|
||||
|
@ -389,7 +390,7 @@ pub fn select_weighted_interval(
|
|||
Some(intervals_and_weights[selected_interval_index].0)
|
||||
}
|
||||
|
||||
fn interval_to_weekday(interval: u32, next_day_at: TimestampSecs) -> usize {
|
||||
pub(crate) fn interval_to_weekday(interval: u32, next_day_at: TimestampSecs) -> usize {
|
||||
let target_datetime = next_day_at
|
||||
.adding_secs((interval - 1) as i64 * 86400)
|
||||
.local_datetime()
|
||||
|
|
|
@ -90,7 +90,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
</Row>
|
||||
|
||||
<Row class="row-columns">
|
||||
<FsrsOptionsOuter {state} api={{}} />
|
||||
<FsrsOptionsOuter {state} api={{}} bind:onPresetChange />
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -10,12 +10,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
import {
|
||||
ComputeOptimalRetentionRequest,
|
||||
SimulateFsrsReviewRequest,
|
||||
type SimulateFsrsReviewResponse,
|
||||
} from "@generated/anki/scheduler_pb";
|
||||
import {
|
||||
computeFsrsParams,
|
||||
computeOptimalRetention,
|
||||
simulateFsrsReview,
|
||||
evaluateParams,
|
||||
setWantsAbort,
|
||||
} from "@generated/backend";
|
||||
|
@ -32,23 +30,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
import Warning from "./Warning.svelte";
|
||||
import ParamsInputRow from "./ParamsInputRow.svelte";
|
||||
import ParamsSearchRow from "./ParamsSearchRow.svelte";
|
||||
import {
|
||||
renderSimulationChart,
|
||||
SimulateSubgraph,
|
||||
type Point,
|
||||
} from "../graphs/simulator";
|
||||
import Graph from "../graphs/Graph.svelte";
|
||||
import HoverColumns from "../graphs/HoverColumns.svelte";
|
||||
import CumulativeOverlay from "../graphs/CumulativeOverlay.svelte";
|
||||
import AxisTicks from "../graphs/AxisTicks.svelte";
|
||||
import NoDataOverlay from "../graphs/NoDataOverlay.svelte";
|
||||
import TableData from "../graphs/TableData.svelte";
|
||||
import { defaultGraphBounds, type TableDatum } from "../graphs/graph-helpers";
|
||||
import InputBox from "../graphs/InputBox.svelte";
|
||||
import SimulatorModal from "./SimulatorModal.svelte";
|
||||
import { UpdateDeckConfigsMode } from "@generated/anki/deck_config_pb";
|
||||
|
||||
export let state: DeckOptionsState;
|
||||
export let openHelpModal: (String) => void;
|
||||
export let onPresetChange: () => void;
|
||||
|
||||
const presetName = state.currentPresetName;
|
||||
|
||||
|
@ -64,13 +51,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
let computingParams = false;
|
||||
let checkingParams = false;
|
||||
let computingRetention = false;
|
||||
let simulating = false;
|
||||
|
||||
let optimalRetention = 0;
|
||||
$: if ($presetName) {
|
||||
optimalRetention = 0;
|
||||
}
|
||||
$: computing =
|
||||
computingParams || checkingParams || computingRetention || simulating;
|
||||
$: computing = computingParams || checkingParams || computingRetention;
|
||||
$: defaultparamSearch = `preset:"${state.getCurrentNameForSearch()}" -is:suspended`;
|
||||
$: roundedRetention = Number($config.desiredRetention.toFixed(2));
|
||||
$: desiredRetentionWarning = getRetentionWarning(roundedRetention);
|
||||
|
@ -81,8 +67,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
| ComputeRetentionProgress
|
||||
| undefined;
|
||||
|
||||
let simulateSubgraph: SimulateSubgraph = SimulateSubgraph.count;
|
||||
|
||||
const optimalRetentionRequest = new ComputeOptimalRetentionRequest({
|
||||
daysToSimulate: 365,
|
||||
lossAversion: 2.5,
|
||||
|
@ -93,16 +77,16 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
|
||||
$: newCardsIgnoreReviewLimit = state.newCardsIgnoreReviewLimit;
|
||||
|
||||
const simulateFsrsRequest = new SimulateFsrsReviewRequest({
|
||||
$: simulateFsrsRequest = new SimulateFsrsReviewRequest({
|
||||
params: fsrsParams($config),
|
||||
desiredRetention: $config.desiredRetention,
|
||||
deckSize: 0,
|
||||
daysToSimulate: 365,
|
||||
newLimit: $config.newPerDay,
|
||||
reviewLimit: $config.reviewsPerDay,
|
||||
maxInterval: $config.maximumReviewInterval,
|
||||
search: `preset:"${state.getCurrentNameForSearch()}" -is:suspended`,
|
||||
newCardsIgnoreReviewLimit: $newCardsIgnoreReviewLimit,
|
||||
easyDaysPercentages: $config.easyDaysPercentages,
|
||||
reviewOrder: $config.reviewOrder,
|
||||
});
|
||||
|
||||
function getRetentionWarning(retention: number): string {
|
||||
|
@ -263,6 +247,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
optimalRetentionRequest.maxInterval = $config.maximumReviewInterval;
|
||||
optimalRetentionRequest.params = fsrsParams($config);
|
||||
optimalRetentionRequest.search = `preset:"${state.getCurrentNameForSearch()}" -is:suspended`;
|
||||
optimalRetentionRequest.easyDaysPercentages =
|
||||
$config.easyDaysPercentages;
|
||||
const resp = await computeOptimalRetention(optimalRetentionRequest);
|
||||
optimalRetention = resp.optimalRetention;
|
||||
computeRetentionProgress = undefined;
|
||||
|
@ -317,81 +303,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
state.save(UpdateDeckConfigsMode.COMPUTE_ALL_PARAMS);
|
||||
}
|
||||
|
||||
let tableData: TableDatum[] = [];
|
||||
const bounds = defaultGraphBounds();
|
||||
bounds.marginLeft += 8;
|
||||
let svg: HTMLElement | SVGElement | null = null;
|
||||
let simulationNumber = 0;
|
||||
|
||||
let points: Point[] = [];
|
||||
|
||||
function addArrays(arr1: number[], arr2: number[]): number[] {
|
||||
return arr1.map((value, index) => value + arr2[index]);
|
||||
}
|
||||
|
||||
async function simulateFsrs(): Promise<void> {
|
||||
let resp: SimulateFsrsReviewResponse | undefined;
|
||||
simulationNumber += 1;
|
||||
try {
|
||||
await runWithBackendProgress(
|
||||
async () => {
|
||||
simulateFsrsRequest.params = fsrsParams($config);
|
||||
simulateFsrsRequest.desiredRetention = $config.desiredRetention;
|
||||
simulateFsrsRequest.search = `preset:"${state.getCurrentNameForSearch()}" -is:suspended`;
|
||||
simulateFsrsRequest.newCardsIgnoreReviewLimit =
|
||||
$newCardsIgnoreReviewLimit;
|
||||
simulating = true;
|
||||
resp = await simulateFsrsReview(simulateFsrsRequest);
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
} finally {
|
||||
simulating = false;
|
||||
if (resp) {
|
||||
const dailyTotalCount = addArrays(
|
||||
resp.dailyReviewCount,
|
||||
resp.dailyNewCount,
|
||||
);
|
||||
|
||||
const dailyMemorizedCount = resp.accumulatedKnowledgeAcquisition;
|
||||
|
||||
points = points.concat(
|
||||
resp.dailyTimeCost.map((v, i) => ({
|
||||
x: i,
|
||||
timeCost: v,
|
||||
count: dailyTotalCount[i],
|
||||
memorized: dailyMemorizedCount[i],
|
||||
label: simulationNumber,
|
||||
})),
|
||||
);
|
||||
|
||||
tableData = renderSimulationChart(
|
||||
svg as SVGElement,
|
||||
bounds,
|
||||
points,
|
||||
simulateSubgraph,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$: tableData = renderSimulationChart(
|
||||
svg as SVGElement,
|
||||
bounds,
|
||||
points,
|
||||
simulateSubgraph,
|
||||
);
|
||||
|
||||
function clearSimulation(): void {
|
||||
points = points.filter((p) => p.label !== simulationNumber);
|
||||
simulationNumber = Math.max(0, simulationNumber - 1);
|
||||
tableData = renderSimulationChart(
|
||||
svg as SVGElement,
|
||||
bounds,
|
||||
points,
|
||||
simulateSubgraph,
|
||||
);
|
||||
}
|
||||
let showSimulator = false;
|
||||
</script>
|
||||
|
||||
<SpinBoxFloatRow
|
||||
|
@ -519,130 +431,21 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
</div>
|
||||
|
||||
<div class="m-2">
|
||||
<details>
|
||||
<summary>{tr.deckConfigFsrsSimulatorExperimental()}</summary>
|
||||
|
||||
<SpinBoxRow
|
||||
bind:value={simulateFsrsRequest.daysToSimulate}
|
||||
defaultValue={365}
|
||||
min={1}
|
||||
max={3650}
|
||||
>
|
||||
<SettingTitle on:click={() => openHelpModal("simulateFsrsReview")}>
|
||||
{tr.deckConfigDaysToSimulate()}
|
||||
</SettingTitle>
|
||||
</SpinBoxRow>
|
||||
|
||||
<SpinBoxRow
|
||||
bind:value={simulateFsrsRequest.deckSize}
|
||||
defaultValue={0}
|
||||
min={0}
|
||||
max={100000}
|
||||
>
|
||||
<SettingTitle on:click={() => openHelpModal("simulateFsrsReview")}>
|
||||
{tr.deckConfigAdditionalNewCardsToSimulate()}
|
||||
</SettingTitle>
|
||||
</SpinBoxRow>
|
||||
|
||||
<SpinBoxRow
|
||||
bind:value={simulateFsrsRequest.newLimit}
|
||||
defaultValue={$config.newPerDay}
|
||||
min={0}
|
||||
max={9999}
|
||||
>
|
||||
<SettingTitle on:click={() => openHelpModal("simulateFsrsReview")}>
|
||||
{tr.schedulingNewCardsday()}
|
||||
</SettingTitle>
|
||||
</SpinBoxRow>
|
||||
|
||||
<SpinBoxRow
|
||||
bind:value={simulateFsrsRequest.reviewLimit}
|
||||
defaultValue={$config.reviewsPerDay}
|
||||
min={0}
|
||||
max={9999}
|
||||
>
|
||||
<SettingTitle on:click={() => openHelpModal("simulateFsrsReview")}>
|
||||
{tr.schedulingMaximumReviewsday()}
|
||||
</SettingTitle>
|
||||
</SpinBoxRow>
|
||||
|
||||
<SpinBoxRow
|
||||
bind:value={simulateFsrsRequest.maxInterval}
|
||||
defaultValue={$config.maximumReviewInterval}
|
||||
min={1}
|
||||
max={36500}
|
||||
>
|
||||
<SettingTitle on:click={() => openHelpModal("simulateFsrsReview")}>
|
||||
{tr.schedulingMaximumInterval()}
|
||||
</SettingTitle>
|
||||
</SpinBoxRow>
|
||||
|
||||
<button
|
||||
class="btn {computing ? 'btn-warning' : 'btn-primary'}"
|
||||
disabled={computing}
|
||||
on:click={() => simulateFsrs()}
|
||||
>
|
||||
{tr.deckConfigSimulate()}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn {computing ? 'btn-warning' : 'btn-primary'}"
|
||||
disabled={computing}
|
||||
on:click={() => clearSimulation()}
|
||||
>
|
||||
{tr.deckConfigClearLastSimulate()}
|
||||
</button>
|
||||
{#if simulating}
|
||||
{tr.qtMiscProcessing()}
|
||||
{/if}
|
||||
|
||||
<Graph>
|
||||
<div class="radio-group">
|
||||
<InputBox>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
value={SimulateSubgraph.count}
|
||||
bind:group={simulateSubgraph}
|
||||
/>
|
||||
{tr.deckConfigFsrsSimulatorRadioCount()}
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
value={SimulateSubgraph.time}
|
||||
bind:group={simulateSubgraph}
|
||||
/>
|
||||
{tr.statisticsReviewsTimeCheckbox()}
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
value={SimulateSubgraph.memorized}
|
||||
bind:group={simulateSubgraph}
|
||||
/>
|
||||
{tr.deckConfigFsrsSimulatorRadioMemorized()}
|
||||
</label>
|
||||
</InputBox>
|
||||
</div>
|
||||
|
||||
<svg bind:this={svg} viewBox={`0 0 ${bounds.width} ${bounds.height}`}>
|
||||
<CumulativeOverlay />
|
||||
<HoverColumns />
|
||||
<AxisTicks {bounds} />
|
||||
<NoDataOverlay {bounds} />
|
||||
</svg>
|
||||
|
||||
<TableData {tableData} />
|
||||
</Graph>
|
||||
</details>
|
||||
<button class="btn btn-primary" on:click={() => (showSimulator = true)}>
|
||||
{tr.deckConfigFsrsSimulatorExperimental()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
div.radio-group {
|
||||
margin: 0.5em;
|
||||
}
|
||||
<SimulatorModal
|
||||
bind:shown={showSimulator}
|
||||
{state}
|
||||
{simulateFsrsRequest}
|
||||
{computing}
|
||||
{openHelpModal}
|
||||
{onPresetChange}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.btn {
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
|
|
@ -22,6 +22,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
|
||||
export let state: DeckOptionsState;
|
||||
export let api: Record<string, never>;
|
||||
export let onPresetChange: () => void;
|
||||
|
||||
const fsrs = state.fsrs;
|
||||
|
||||
|
@ -95,6 +96,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
{state}
|
||||
openHelpModal={(key) =>
|
||||
openHelpModal(Object.keys(settings).indexOf(key))}
|
||||
{onPresetChange}
|
||||
/>
|
||||
{/if}
|
||||
</DynamicallySlottable>
|
||||
|
|
375
ts/routes/deck-options/SimulatorModal.svelte
Normal file
375
ts/routes/deck-options/SimulatorModal.svelte
Normal file
|
@ -0,0 +1,375 @@
|
|||
<!--
|
||||
Copyright: Ankitects Pty Ltd and contributors
|
||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script lang="ts">
|
||||
import SpinBoxRow from "./SpinBoxRow.svelte";
|
||||
import SettingTitle from "$lib/components/SettingTitle.svelte";
|
||||
import Graph from "../graphs/Graph.svelte";
|
||||
import HoverColumns from "../graphs/HoverColumns.svelte";
|
||||
import CumulativeOverlay from "../graphs/CumulativeOverlay.svelte";
|
||||
import AxisTicks from "../graphs/AxisTicks.svelte";
|
||||
import NoDataOverlay from "../graphs/NoDataOverlay.svelte";
|
||||
import TableData from "../graphs/TableData.svelte";
|
||||
import InputBox from "../graphs/InputBox.svelte";
|
||||
import { defaultGraphBounds, type TableDatum } from "../graphs/graph-helpers";
|
||||
import { SimulateSubgraph, type Point } from "../graphs/simulator";
|
||||
import * as tr from "@generated/ftl";
|
||||
import { renderSimulationChart } from "../graphs/simulator";
|
||||
import { simulateFsrsReview } from "@generated/backend";
|
||||
import { runWithBackendProgress } from "@tslib/progress";
|
||||
import type {
|
||||
SimulateFsrsReviewRequest,
|
||||
SimulateFsrsReviewResponse,
|
||||
} from "@generated/anki/scheduler_pb";
|
||||
import type { DeckOptionsState } from "./lib";
|
||||
import SwitchRow from "$lib/components/SwitchRow.svelte";
|
||||
import GlobalLabel from "./GlobalLabel.svelte";
|
||||
import SpinBoxFloatRow from "./SpinBoxFloatRow.svelte";
|
||||
import { reviewOrderChoices } from "./choices";
|
||||
import EnumSelectorRow from "$lib/components/EnumSelectorRow.svelte";
|
||||
|
||||
export let shown = false;
|
||||
export let state: DeckOptionsState;
|
||||
export let simulateFsrsRequest: SimulateFsrsReviewRequest;
|
||||
export let computing: boolean;
|
||||
export let openHelpModal: (key: string) => void;
|
||||
export let onPresetChange: () => void;
|
||||
|
||||
const config = state.currentConfig;
|
||||
let simulateSubgraph: SimulateSubgraph = SimulateSubgraph.count;
|
||||
let tableData: TableDatum[] = [];
|
||||
let simulating: boolean = false;
|
||||
const fsrs = state.fsrs;
|
||||
const bounds = defaultGraphBounds();
|
||||
|
||||
let svg: HTMLElement | SVGElement | null = null;
|
||||
let simulationNumber = 0;
|
||||
let points: Point[] = [];
|
||||
const newCardsIgnoreReviewLimit = state.newCardsIgnoreReviewLimit;
|
||||
let smooth = true;
|
||||
|
||||
$: daysToSimulate = 365;
|
||||
$: deckSize = 0;
|
||||
$: windowSize = Math.ceil(daysToSimulate / 365);
|
||||
|
||||
function movingAverage(y: number[], windowSize: number): number[] {
|
||||
const result: number[] = [];
|
||||
for (let i = 0; i < y.length; i++) {
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
for (let j = Math.max(0, i - windowSize + 1); j <= i; j++) {
|
||||
sum += y[j];
|
||||
count++;
|
||||
}
|
||||
result.push(sum / count);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function addArrays(arr1: number[], arr2: number[]): number[] {
|
||||
return arr1.map((value, index) => value + arr2[index]);
|
||||
}
|
||||
|
||||
async function simulateFsrs(): Promise<void> {
|
||||
let resp: SimulateFsrsReviewResponse | undefined;
|
||||
simulateFsrsRequest.daysToSimulate = daysToSimulate;
|
||||
simulateFsrsRequest.deckSize = deckSize;
|
||||
try {
|
||||
await runWithBackendProgress(
|
||||
async () => {
|
||||
simulating = true;
|
||||
resp = await simulateFsrsReview(simulateFsrsRequest);
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
} finally {
|
||||
simulating = false;
|
||||
if (resp) {
|
||||
simulationNumber += 1;
|
||||
const dailyTotalCount = addArrays(
|
||||
resp.dailyReviewCount,
|
||||
resp.dailyNewCount,
|
||||
);
|
||||
|
||||
const dailyMemorizedCount = resp.accumulatedKnowledgeAcquisition;
|
||||
|
||||
points = points.concat(
|
||||
resp.dailyTimeCost.map((v, i) => ({
|
||||
x: i,
|
||||
timeCost: v,
|
||||
count: dailyTotalCount[i],
|
||||
memorized: dailyMemorizedCount[i],
|
||||
label: simulationNumber,
|
||||
})),
|
||||
);
|
||||
|
||||
tableData = renderSimulationChart(
|
||||
svg as SVGElement,
|
||||
bounds,
|
||||
points,
|
||||
simulateSubgraph,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearSimulation() {
|
||||
points = points.filter((p) => p.label !== simulationNumber);
|
||||
simulationNumber = Math.max(0, simulationNumber - 1);
|
||||
tableData = renderSimulationChart(
|
||||
svg as SVGElement,
|
||||
bounds,
|
||||
points,
|
||||
simulateSubgraph,
|
||||
);
|
||||
}
|
||||
|
||||
$: if (svg) {
|
||||
let pointsToRender = points;
|
||||
if (smooth) {
|
||||
// Group points by label (simulation number)
|
||||
const groupedPoints = points.reduce(
|
||||
(acc, point) => {
|
||||
acc[point.label] = acc[point.label] || [];
|
||||
acc[point.label].push(point);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<number, Point[]>,
|
||||
);
|
||||
|
||||
// Apply smoothing to each group separately
|
||||
pointsToRender = Object.values(groupedPoints).flatMap((group) => {
|
||||
const smoothedTimeCost = movingAverage(
|
||||
group.map((p) => p.timeCost),
|
||||
windowSize,
|
||||
);
|
||||
const smoothedCount = movingAverage(
|
||||
group.map((p) => p.count),
|
||||
windowSize,
|
||||
);
|
||||
const smoothedMemorized = movingAverage(
|
||||
group.map((p) => p.memorized),
|
||||
windowSize,
|
||||
);
|
||||
|
||||
return group.map((p, i) => ({
|
||||
...p,
|
||||
timeCost: smoothedTimeCost[i],
|
||||
count: smoothedCount[i],
|
||||
memorized: smoothedMemorized[i],
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
tableData = renderSimulationChart(
|
||||
svg as SVGElement,
|
||||
bounds,
|
||||
pointsToRender,
|
||||
simulateSubgraph,
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="modal" class:show={shown} class:d-block={shown} tabindex="-1">
|
||||
<div class="modal-dialog modal-xl">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{tr.deckConfigFsrsSimulatorExperimental()}</h5>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-close"
|
||||
aria-label="Close"
|
||||
on:click={() => (shown = false)}
|
||||
></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<SpinBoxRow
|
||||
bind:value={daysToSimulate}
|
||||
defaultValue={365}
|
||||
min={1}
|
||||
max={3650}
|
||||
>
|
||||
<SettingTitle on:click={() => openHelpModal("simulateFsrsReview")}>
|
||||
{tr.deckConfigDaysToSimulate()}
|
||||
</SettingTitle>
|
||||
</SpinBoxRow>
|
||||
|
||||
<SpinBoxRow bind:value={deckSize} defaultValue={0} min={0} max={100000}>
|
||||
<SettingTitle on:click={() => openHelpModal("simulateFsrsReview")}>
|
||||
{tr.deckConfigAdditionalNewCardsToSimulate()}
|
||||
</SettingTitle>
|
||||
</SpinBoxRow>
|
||||
|
||||
<SpinBoxFloatRow
|
||||
bind:value={simulateFsrsRequest.desiredRetention}
|
||||
defaultValue={$config.desiredRetention}
|
||||
min={0.7}
|
||||
max={0.99}
|
||||
percentage={true}
|
||||
>
|
||||
<SettingTitle on:click={() => openHelpModal("desiredRetention")}>
|
||||
{tr.deckConfigDesiredRetention()}
|
||||
</SettingTitle>
|
||||
</SpinBoxFloatRow>
|
||||
|
||||
<SpinBoxRow
|
||||
bind:value={simulateFsrsRequest.newLimit}
|
||||
defaultValue={$config.newPerDay}
|
||||
min={0}
|
||||
max={9999}
|
||||
>
|
||||
<SettingTitle on:click={() => openHelpModal("simulateFsrsReview")}>
|
||||
{tr.schedulingNewCardsday()}
|
||||
</SettingTitle>
|
||||
</SpinBoxRow>
|
||||
|
||||
<SpinBoxRow
|
||||
bind:value={simulateFsrsRequest.reviewLimit}
|
||||
defaultValue={$config.reviewsPerDay}
|
||||
min={0}
|
||||
max={9999}
|
||||
>
|
||||
<SettingTitle on:click={() => openHelpModal("simulateFsrsReview")}>
|
||||
{tr.schedulingMaximumReviewsday()}
|
||||
</SettingTitle>
|
||||
</SpinBoxRow>
|
||||
|
||||
<SpinBoxRow
|
||||
bind:value={simulateFsrsRequest.maxInterval}
|
||||
defaultValue={$config.maximumReviewInterval}
|
||||
min={1}
|
||||
max={36500}
|
||||
>
|
||||
<SettingTitle on:click={() => openHelpModal("simulateFsrsReview")}>
|
||||
{tr.schedulingMaximumInterval()}
|
||||
</SettingTitle>
|
||||
</SpinBoxRow>
|
||||
|
||||
<EnumSelectorRow
|
||||
bind:value={simulateFsrsRequest.reviewOrder}
|
||||
defaultValue={$config.reviewOrder}
|
||||
choices={reviewOrderChoices($fsrs)}
|
||||
>
|
||||
<SettingTitle on:click={() => openHelpModal("simulateFsrsReview")}>
|
||||
{tr.deckConfigReviewSortOrder()}
|
||||
</SettingTitle>
|
||||
</EnumSelectorRow>
|
||||
|
||||
<SwitchRow
|
||||
bind:value={simulateFsrsRequest.newCardsIgnoreReviewLimit}
|
||||
defaultValue={$newCardsIgnoreReviewLimit}
|
||||
>
|
||||
<SettingTitle on:click={() => openHelpModal("simulateFsrsReview")}>
|
||||
<GlobalLabel title={tr.deckConfigNewCardsIgnoreReviewLimit()} />
|
||||
</SettingTitle>
|
||||
</SwitchRow>
|
||||
|
||||
<SwitchRow bind:value={smooth} defaultValue={true}>
|
||||
<SettingTitle on:click={() => openHelpModal("simulateFsrsReview")}>
|
||||
{"Smooth Graph"}
|
||||
</SettingTitle>
|
||||
</SwitchRow>
|
||||
|
||||
<button
|
||||
class="btn {computing ? 'btn-warning' : 'btn-primary'}"
|
||||
disabled={computing}
|
||||
on:click={simulateFsrs}
|
||||
>
|
||||
{tr.deckConfigSimulate()}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn {computing ? 'btn-warning' : 'btn-primary'}"
|
||||
disabled={computing}
|
||||
on:click={clearSimulation}
|
||||
>
|
||||
{tr.deckConfigClearLastSimulate()}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn {computing ? 'btn-warning' : 'btn-primary'}"
|
||||
disabled={computing}
|
||||
on:click={() => {
|
||||
$config.newPerDay = simulateFsrsRequest.newLimit;
|
||||
$config.reviewsPerDay = simulateFsrsRequest.reviewLimit;
|
||||
$config.maximumReviewInterval = simulateFsrsRequest.maxInterval;
|
||||
$config.desiredRetention = simulateFsrsRequest.desiredRetention;
|
||||
$newCardsIgnoreReviewLimit =
|
||||
simulateFsrsRequest.newCardsIgnoreReviewLimit;
|
||||
$config.reviewOrder = simulateFsrsRequest.reviewOrder;
|
||||
onPresetChange();
|
||||
}}
|
||||
>
|
||||
<!-- {tr.deckConfigApplyChanges()} -->
|
||||
{"Save to Preset Options"}
|
||||
</button>
|
||||
|
||||
{#if simulating}
|
||||
{tr.actionsProcessing()}
|
||||
{/if}
|
||||
|
||||
<Graph>
|
||||
<div class="radio-group">
|
||||
<InputBox>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
value={SimulateSubgraph.count}
|
||||
bind:group={simulateSubgraph}
|
||||
/>
|
||||
{tr.deckConfigFsrsSimulatorRadioCount()}
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
value={SimulateSubgraph.time}
|
||||
bind:group={simulateSubgraph}
|
||||
/>
|
||||
{tr.statisticsReviewsTimeCheckbox()}
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
value={SimulateSubgraph.memorized}
|
||||
bind:group={simulateSubgraph}
|
||||
/>
|
||||
{tr.deckConfigFsrsSimulatorRadioMemorized()}
|
||||
</label>
|
||||
</InputBox>
|
||||
</div>
|
||||
|
||||
<svg
|
||||
bind:this={svg}
|
||||
viewBox={`0 0 ${bounds.width} ${bounds.height}`}
|
||||
>
|
||||
<CumulativeOverlay />
|
||||
<HoverColumns />
|
||||
<AxisTicks {bounds} />
|
||||
<NoDataOverlay {bounds} />
|
||||
</svg>
|
||||
|
||||
<TableData {tableData} />
|
||||
</Graph>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.modal {
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
:global(.modal-xl) {
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
div.radio-group {
|
||||
margin: 0.5em;
|
||||
}
|
||||
|
||||
.btn {
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
</style>
|
|
@ -124,6 +124,7 @@ export function renderSimulationChart(
|
|||
.selectAll("path")
|
||||
.data(Array.from(groups.entries()))
|
||||
.join("path")
|
||||
.attr("vector-effect", "non-scaling-stroke")
|
||||
.attr("stroke", (d, i) => color[i % color.length])
|
||||
.attr("d", d => line()(d[1].map(p => [p[0], p[1]])))
|
||||
.attr("data-group", d => d[0]);
|
||||
|
|
Loading…
Reference in a new issue