implement intesections + misc function options

This commit is contained in:
Simon Gardling 2025-12-02 23:10:28 -05:00
parent bccb19cecc
commit d95b29fb7f
Signed by: titaniumtown
GPG Key ID: 9AB28AC10ECE533D
7 changed files with 210 additions and 35 deletions

View File

@ -1,14 +1,8 @@
## TODO:
1. Function management
- Integrals between functions (too hard to implement, maybe will shelve)
- Display intersection between functions (would have to rewrite a lot of the function plotting handling)
- [Drag and drop support](https://github.com/emilk/egui/discussions/1530) in the UI to re-order functions
- Hide/disable functions
- Prevent user from making too many function entries
- Display function errors as tooltips or a warning box (not preventing the display of the graph)
- Clone functions
2. Smart display of graph
- Display of intersections between functions
3. Allow constants in min/max integral input (like pi or euler's number)
4. Sliding values for functions (like a user-interactable slider that adjusts a variable in the function, like desmos)
5. Fix integral display

View File

@ -131,7 +131,12 @@ fn main() {
"emoji-icon-font".to_owned(),
Arc::new(
FontData::from_owned(
font_stripper("emoji-icon-font.ttf", "emoji-icon.ttf", vec!['⚙']).unwrap(),
font_stripper(
"emoji-icon-font.ttf",
"emoji-icon.ttf",
vec!['⚙', '⎘', '👁', '○', '⬆', '⬇', '⚠'],
)
.unwrap(),
)
.tweak(FontTweak {
scale: 0.8,

View File

@ -45,6 +45,9 @@ pub struct FunctionEntry {
pub nth_derviative: bool,
/// If the function is visible on the graph
pub visible: bool,
pub back_data: Vec<PlotPoint>,
pub integral_data: Option<(Vec<Bar>, f64)>,
pub derivative_data: Vec<PlotPoint>,
@ -67,6 +70,7 @@ impl Hash for FunctionEntry {
self.nth_derviative.hash(state);
self.curr_nth.hash(state);
self.settings_opened.hash(state);
self.visible.hash(state);
}
}
@ -75,11 +79,12 @@ impl Serialize for FunctionEntry {
where
S: Serializer,
{
let mut s = serializer.serialize_struct("FunctionEntry", 4)?;
let mut s = serializer.serialize_struct("FunctionEntry", 5)?;
s.serialize_field("raw_func_str", &self.raw_func_str)?;
s.serialize_field("integral", &self.integral)?;
s.serialize_field("derivative", &self.derivative)?;
s.serialize_field("curr_nth", &self.curr_nth)?;
s.serialize_field("visible", &self.visible)?;
s.end()
}
@ -96,6 +101,12 @@ impl<'de> Deserialize<'de> for FunctionEntry {
integral: bool,
derivative: bool,
curr_nth: usize,
#[serde(default = "default_visible")]
visible: bool,
}
fn default_visible() -> bool {
true
}
let helper = Helper::deserialize(deserializer)?;
@ -115,6 +126,7 @@ impl<'de> Deserialize<'de> for FunctionEntry {
new_func_entry.integral = helper.integral;
new_func_entry.derivative = helper.derivative;
new_func_entry.curr_nth = helper.curr_nth;
new_func_entry.visible = helper.visible;
Ok(new_func_entry)
}
@ -129,6 +141,7 @@ impl Default for FunctionEntry {
integral: false,
derivative: false,
nth_derviative: false,
visible: true,
back_data: Vec::new(),
integral_data: None,
derivative_data: Vec::new(),
@ -374,7 +387,7 @@ impl FunctionEntry {
settings: &AppSettings,
main_plot_color: Color32,
) -> Option<f64> {
if self.test_result.is_some() | self.function.is_none() {
if self.test_result.is_some() | self.function.is_none() | !self.visible {
return None;
}

View File

@ -86,9 +86,14 @@ impl FunctionManager {
let initial_hash = self.get_hash();
let can_remove = self.functions.len() > 1;
let can_add = self.functions.len() < COLORS.len();
let num_functions = self.functions.len();
let available_width = ui.available_width();
let mut remove_i: Option<usize> = None;
let mut clone_i: Option<usize> = None;
let mut move_up_i: Option<usize> = None;
let mut move_down_i: Option<usize> = None;
let target_size = vec2(available_width, crate::consts::FONT_SIZE);
for (i, (te_id, function)) in self.functions.iter_mut().map(|(a, b)| (*a, b)).enumerate() {
let mut new_string = function.autocomplete.string.clone();
@ -116,6 +121,19 @@ impl FunctionManager {
// Only keep valid chars
new_string.retain(crate::misc::is_valid_char);
// Display error indicator with tooltip if there's a parsing error
if let Some(error) = function.get_test_result() {
ui.horizontal(|ui| {
ui.label(egui::RichText::new("").color(egui::Color32::YELLOW))
.on_hover_text(error);
ui.label(
egui::RichText::new(error)
.color(egui::Color32::LIGHT_RED)
.small(),
);
});
}
// If not fully open, return here as buttons cannot yet be displayed, therefore the user is inable to mark it for deletion
let animate_bool = ui.ctx().animate_bool(te_id, re.has_focus());
if animate_bool == 1.0 {
@ -197,6 +215,47 @@ impl FunctionManager {
remove_i = Some(i);
}
// Toggle visibility
function.visible.bitxor_assign(
ui.add(button_area_button(if function.visible {
"👁"
} else {
""
}))
.on_hover_text(match function.visible {
true => "Hide Function",
false => "Show Function",
})
.clicked(),
);
// Clone function
if ui
.add_enabled(can_add, button_area_button(""))
.on_hover_text("Clone Function")
.clicked()
{
clone_i = Some(i);
}
// Move up (only if not first)
if ui
.add_enabled(i > 0, button_area_button(""))
.on_hover_text("Move Up")
.clicked()
{
move_up_i = Some(i);
}
// Move down (only if not last)
if ui
.add_enabled(i < num_functions - 1, button_area_button(""))
.on_hover_text("Move Down")
.clicked()
{
move_down_i = Some(i);
}
ui.add_enabled_ui(function.is_some(), |ui| {
// Toggle integral being enabled or not
function.integral.bitxor_assign(
@ -240,6 +299,26 @@ impl FunctionManager {
self.functions.remove(remove_i_unwrap);
}
// Clone function if the user requests it
if let Some(clone_i_unwrap) = clone_i {
let cloned = self.functions[clone_i_unwrap].1.clone();
self.push_cloned(cloned);
}
// Move function up if the user requests it
if let Some(i) = move_up_i
&& i > 0
{
self.functions.swap(i, i - 1);
}
// Move function down if the user requests it
if let Some(i) = move_down_i
&& i < self.functions.len() - 1
{
self.functions.swap(i, i + 1);
}
let final_hash = self.get_hash();
initial_hash != final_hash
@ -253,6 +332,16 @@ impl FunctionManager {
));
}
/// Push a cloned function entry
pub fn push_cloned(&mut self, mut entry: FunctionEntry) {
// Reset settings_opened so the cloned function doesn't have settings open
entry.settings_opened = false;
self.functions.push((
create_id(random_u64().expect("unable to generate random id")),
entry,
));
}
/// Detect if any functions are using integrals
pub fn any_using_integral(&self) -> bool {
self.functions.iter().any(|(_, func)| func.integral)

View File

@ -2,7 +2,7 @@ use crate::{
consts::{BUILD_INFO, COLORS, DEFAULT_INTEGRAL_NUM, DEFAULT_MAX_X, DEFAULT_MIN_X, build},
function_entry::Riemann,
function_manager::FunctionManager,
misc::option_vec_printer,
misc::{EguiHelper, find_intersections, option_vec_printer},
};
use eframe::App;
use egui::{
@ -13,7 +13,7 @@ use egui_plot::Plot;
use emath::{Align, Align2};
use epaint::{CornerRadius, Margin};
use itertools::Itertools;
use std::{io::Read, ops::BitXorAssign};
use web_time::Instant;
@ -47,6 +47,9 @@ pub struct AppSettings {
/// Stores whether or not displaying roots is enabled
pub do_roots: bool,
/// Stores whether or not displaying intersections between functions is enabled
pub do_intersections: bool,
/// Stores current plot pixel width
pub plot_width: usize,
}
@ -64,6 +67,7 @@ impl Default for AppSettings {
integral_num: DEFAULT_INTEGRAL_NUM,
do_extrema: true,
do_roots: true,
do_intersections: true,
plot_width: 0,
}
}
@ -108,6 +112,9 @@ pub struct MathApp {
/// Stores settings (pretty self-explanatory)
settings: AppSettings,
/// Stores intersection points between functions
intersections: Vec<egui_plot::PlotPoint>,
}
#[cfg(target_arch = "wasm32")]
@ -246,6 +253,7 @@ impl MathApp {
last_info: (None, None),
opened: Opened::default(),
settings: AppSettings::default(),
intersections: Vec::new(),
}
}
@ -354,6 +362,15 @@ impl MathApp {
})
.clicked(),
);
self.settings.do_intersections.bitxor_assign(
ui.add(Button::new("Intersections"))
.on_hover_text(match self.settings.do_intersections {
true => "Disable Displaying Intersections",
false => "Display Intersections between functions",
})
.clicked(),
);
});
if self.functions.display_entries(ui) {
@ -530,7 +547,7 @@ impl App for MathApp {
self.side_panel(ctx);
}
// Central panel which contains the central plot (or an error created when parsing)
// Central panel which contains the central plot
CentralPanel::default()
.frame(Frame {
inner_margin: Margin::ZERO,
@ -540,29 +557,6 @@ impl App for MathApp {
..Frame::NONE
})
.show(ctx, |ui| {
// Display an error if it exists
let errors_formatted: String = self
.functions
.get_entries()
.iter()
.map(|(_, func)| func.get_test_result())
.enumerate()
.filter(|(_, error)| error.is_some())
.map(|(i, error)| {
// use unwrap_unchecked as None Errors are already filtered out
unsafe {
format!("(Function #{}) {}\n", i, error.as_ref().unwrap_unchecked())
}
})
.join("");
if !errors_formatted.is_empty() {
ui.centered_and_justified(|ui| {
ui.heading(errors_formatted);
});
return;
}
let available_width: usize = (ui.available_width() as usize) + 1; // Used in later logic
let width_changed = available_width != self.settings.plot_width;
self.settings.plot_width = available_width;
@ -607,6 +601,41 @@ impl App for MathApp {
})
.collect();
// Calculate and display intersections between functions
if self.settings.do_intersections {
let entries = self.functions.get_entries();
let visible_entries: Vec<_> = entries
.iter()
.filter(|(_, f)| f.visible && f.is_some())
.collect();
// Clear previous intersections
self.intersections.clear();
// Find intersections between all pairs of visible functions
for i in 0..visible_entries.len() {
for j in (i + 1)..visible_entries.len() {
let (_, func1) = visible_entries[i];
let (_, func2) = visible_entries[j];
let mut intersections =
find_intersections(&func1.back_data, &func2.back_data);
self.intersections.append(&mut intersections);
}
}
// Display intersection points
if !self.intersections.is_empty() {
plot_ui.points(
self.intersections
.clone()
.to_points()
.color(Color32::from_rgb(255, 105, 180)) // Hot pink for visibility
.radius(6.0),
);
}
}
self.last_info.0 = if area.iter().any(|e| e.is_some()) {
Some(format!("Area: {}", option_vec_printer(area.as_slice())))
} else {

View File

@ -214,3 +214,47 @@ include!(concat!(env!("OUT_DIR"), "/valid_chars.rs"));
pub fn is_valid_char(c: char) -> bool {
c.is_alphanumeric() | VALID_EXTRA_CHARS.contains(&c)
}
/// Find intersection points between two functions given their plotted data
/// Returns a vector of PlotPoints where the functions intersect
pub fn find_intersections(data1: &[PlotPoint], data2: &[PlotPoint]) -> Vec<PlotPoint> {
if data1.is_empty() || data2.is_empty() || data1.len() != data2.len() {
return Vec::new();
}
// Calculate difference between functions at each x point
let differences: Vec<(f64, f64)> = data1
.iter()
.zip(data2.iter())
.filter(|(p1, p2)| p1.y.is_finite() && p2.y.is_finite())
.map(|(p1, p2)| (p1.x, p1.y - p2.y))
.collect();
// Find where sign changes (intersection points)
differences
.iter()
.tuple_windows()
.filter(|((_, diff1), (_, diff2))| diff1.signum() != diff2.signum())
.map(|((x1, diff1), (x2, diff2))| {
// Linear interpolation to find approximate x of intersection
let t = diff1.abs() / (diff1.abs() + diff2.abs());
let x = x1 + t * (x2 - x1);
// Find corresponding y values and average them for the intersection point
// We need to interpolate y values from both functions
let y1_at_x1 = data1
.iter()
.find(|p| (p.x - x1).abs() < f64::EPSILON)
.map(|p| p.y)
.unwrap_or(0.0);
let y1_at_x2 = data1
.iter()
.find(|p| (p.x - x2).abs() < f64::EPSILON)
.map(|p| p.y)
.unwrap_or(0.0);
let y = y1_at_x1 + t * (y1_at_x2 - y1_at_x1);
PlotPoint::new(x, y)
})
.collect()
}

View File

@ -19,6 +19,7 @@ fn app_settings_constructor(
integral_num,
do_extrema: false,
do_roots: false,
do_intersections: false,
plot_width: pixel_width,
}
}