From a6426bebe21ea1fe85f5cf31a711134b06447788 Mon Sep 17 00:00:00 2001 From: Jarrett Ye Date: Thu, 27 Feb 2025 11:53:01 +0800 Subject: [PATCH] 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 * 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 --- Cargo.lock | 7 +- Cargo.toml | 6 +- cargo/licenses.json | 2 +- proto/anki/scheduler.proto | 4 + rslib/src/scheduler/fsrs/retention.rs | 26 ++ rslib/src/scheduler/fsrs/simulator.rs | 143 ++++++- rslib/src/scheduler/states/load_balancer.rs | 37 +- ts/routes/deck-options/DeckOptionsPage.svelte | 2 +- ts/routes/deck-options/FsrsOptions.svelte | 241 +---------- .../deck-options/FsrsOptionsOuter.svelte | 2 + ts/routes/deck-options/SimulatorModal.svelte | 375 ++++++++++++++++++ ts/routes/graphs/simulator.ts | 1 + 12 files changed, 597 insertions(+), 249 deletions(-) create mode 100644 ts/routes/deck-options/SimulatorModal.svelte diff --git a/Cargo.lock b/Cargo.lock index f95c5c700..63078a334 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] diff --git a/Cargo.toml b/Cargo.toml index 5d8a5494c..6bd0c35e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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] diff --git a/cargo/licenses.json b/cargo/licenses.json index ca3eacd6a..e4d849463 100644 --- a/cargo/licenses.json +++ b/cargo/licenses.json @@ -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", diff --git a/proto/anki/scheduler.proto b/proto/anki/scheduler.proto index 94efd42d7..b657ff3fa 100644 --- a/proto/anki/scheduler.proto +++ b/proto/anki/scheduler.proto @@ -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 { diff --git a/rslib/src/scheduler/fsrs/retention.rs b/rslib/src/scheduler/fsrs/retention.rs index 39b2e6f5d..c89c08aab 100644 --- a/rslib/src/scheduler/fsrs/retention.rs +++ b/rslib/src/scheduler/fsrs/retention.rs @@ -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 = + 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| { diff --git a/rslib/src/scheduler/fsrs/simulator.rs b/rslib/src/scheduler/fsrs/simulator.rs index c9d3e699e..1cfc2e959 100644 --- a/rslib/src/scheduler/fsrs/simulator.rs +++ b/rslib/src/scheduler/fsrs/simulator.rs @@ -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 = (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::>(); + 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 { + // 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 = + 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, diff --git a/rslib/src/scheduler/states/load_balancer.rs b/rslib/src/scheduler/states/load_balancer.rs index 1436549f8..5ae32d900 100644 --- a/rslib/src/scheduler/states/load_balancer.rs +++ b/rslib/src/scheduler/states/load_balancer.rs @@ -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) -> 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, ) -> Result> { 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() diff --git a/ts/routes/deck-options/DeckOptionsPage.svelte b/ts/routes/deck-options/DeckOptionsPage.svelte index 772b2a20a..2201493b2 100644 --- a/ts/routes/deck-options/DeckOptionsPage.svelte +++ b/ts/routes/deck-options/DeckOptionsPage.svelte @@ -90,7 +90,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html - + diff --git a/ts/routes/deck-options/FsrsOptions.svelte b/ts/routes/deck-options/FsrsOptions.svelte index fe3db3677..82c822a66 100644 --- a/ts/routes/deck-options/FsrsOptions.svelte +++ b/ts/routes/deck-options/FsrsOptions.svelte @@ -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 { - 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;
-
- {tr.deckConfigFsrsSimulatorExperimental()} - - - openHelpModal("simulateFsrsReview")}> - {tr.deckConfigDaysToSimulate()} - - - - - openHelpModal("simulateFsrsReview")}> - {tr.deckConfigAdditionalNewCardsToSimulate()} - - - - - openHelpModal("simulateFsrsReview")}> - {tr.schedulingNewCardsday()} - - - - - openHelpModal("simulateFsrsReview")}> - {tr.schedulingMaximumReviewsday()} - - - - - openHelpModal("simulateFsrsReview")}> - {tr.schedulingMaximumInterval()} - - - - - - - {#if simulating} - {tr.qtMiscProcessing()} - {/if} - - -
- - - - - -
- - - - - - - - - -
-
+
- diff --git a/ts/routes/graphs/simulator.ts b/ts/routes/graphs/simulator.ts index c24c8f0bb..09a7da5a0 100644 --- a/ts/routes/graphs/simulator.ts +++ b/ts/routes/graphs/simulator.ts @@ -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]);