├── public ├── icon.ico ├── icon.png ├── screenshot.png ├── index.html ├── icon.svg └── icon.dev.svg ├── Makefile ├── README.md ├── .gitignore ├── src ├── main.rs ├── web.rs └── lib.rs ├── .github └── workflows │ └── main.yml ├── Cargo.toml └── LICENSE /public/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grantshandy/grapher/HEAD/public/icon.ico -------------------------------------------------------------------------------- /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grantshandy/grapher/HEAD/public/icon.png -------------------------------------------------------------------------------- /public/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grantshandy/grapher/HEAD/public/screenshot.png -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: 2 | cargo fmt 3 | wasm-pack build --release --target web --out-dir public/wasm 4 | rm public/wasm/.gitignore 5 | 6 | serve: 7 | python3 -m http.server -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # grapher 2 | A simple graphing calculator written in Rust. 3 | 4 | [Visit online](https://grantshandy.github.io/grapher/#(4*sin(x/4))+4,4*sin(x/4),(4*sin(x/4))+(2*cos(5*x))+2) 5 | 6 | ![screenshot](./public/screenshot.png) 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | /public/wasm/ 4 | public/wasm/grapher_bg.wasm 5 | public/wasm/README.md 6 | public/wasm/grapher_bg.wasm.d.ts 7 | public/wasm/grapher.d.ts 8 | public/wasm/grapher.js 9 | public/wasm/package.json 10 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release 2 | 3 | fn main() { 4 | eframe::run_native( 5 | Box::new(grapher::Grapher::new()), 6 | eframe::NativeOptions::default(), 7 | ); 8 | } 9 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v2 17 | 18 | - uses: actions/cache@v2 19 | with: 20 | path: | 21 | ~/.cargo/registry 22 | ~/.cargo/git 23 | target 24 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 25 | 26 | - name: Install wasm-pack 27 | run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh 28 | 29 | - name: Build 30 | run: make 31 | 32 | - name: Deploy to gh-pages 33 | uses: peaceiris/actions-gh-pages@v3 34 | with: 35 | github_token: ${{ secrets.GITHUB_TOKEN }} 36 | publish_dir: ./public -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "grapher" 3 | version = "1.0.0" 4 | edition = "2021" 5 | authors = ["Grant Handy "] 6 | description = "A simple graphing calculator written in Rust." 7 | license = "MIT" 8 | readme = "README.md" 9 | repository = "https://github.com/grantshandy/grapher" 10 | categories = ["gui"] 11 | keywords = ["gui", "graph", "math"] 12 | build = "build.rs" 13 | 14 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 15 | 16 | [lib] 17 | crate-type = ["cdylib", "rlib"] 18 | 19 | [dependencies] 20 | eframe = "0.17" 21 | exmex = "0.15" 22 | cfg-if = "1" 23 | 24 | [target.'cfg(target_arch = "wasm32")'.dependencies] 25 | web-sys = { version = "0.3", features = ["Window", "Location", "History"] } 26 | console_error_panic_hook = "0.1" 27 | 28 | [target.'cfg(target_os = "windows")'.build-dependencies] 29 | winres = "0.1" 30 | 31 | [package.metadata.winres] 32 | OriginalFilename = "grapher.exe" 33 | LegalCopyright = "Copyright © 2022 Grant Handy" -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Grapher 7 | 30 | 31 | 32 | 33 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Grant Handy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /public/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 14 | 16 | 22 | 26 | 30 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/web.rs: -------------------------------------------------------------------------------- 1 | use crate::FunctionEntry; 2 | 3 | #[cfg(target_arch = "wasm32")] 4 | use crate::Grapher; 5 | 6 | #[cfg(target_arch = "wasm32")] 7 | use eframe::wasm_bindgen::{self, prelude::*}; 8 | 9 | #[cfg(target_arch = "wasm32")] 10 | #[wasm_bindgen] 11 | pub fn start_web(canvas_id: &str) -> Result<(), eframe::wasm_bindgen::JsValue> { 12 | console_error_panic_hook::set_once(); 13 | 14 | eframe::start_web(canvas_id, Box::new(Grapher::new())) 15 | } 16 | 17 | #[cfg(target_arch = "wasm32")] 18 | pub fn update_url(data: &Vec) { 19 | let history = web_sys::window() 20 | .expect("Couldn't get window") 21 | .history() 22 | .expect("Couldn't get window.history"); 23 | 24 | let info_str = url_string_from_data(data); 25 | 26 | history 27 | .push_state_with_url(&JsValue::NULL, "", Some(&info_str)) 28 | .unwrap(); 29 | } 30 | 31 | pub fn url_string_from_data(data: &Vec) -> String { 32 | let mut info_str = String::from("#"); 33 | 34 | for entry in data { 35 | info_str.push_str(format!("{},", entry.text).as_str()); 36 | } 37 | 38 | info_str.pop(); 39 | 40 | info_str 41 | } 42 | 43 | #[cfg(target_arch = "wasm32")] 44 | pub fn get_data_from_url(data: &mut Vec) -> Option { 45 | let href = web_sys::window() 46 | .expect("Couldn't get window") 47 | .document() 48 | .expect("Couldn't get document") 49 | .location() 50 | .expect("Couldn't get location") 51 | .href() 52 | .expect("Couldn't get href"); 53 | 54 | if !href.contains('#') { 55 | return None; 56 | } 57 | 58 | let func_string = match href.split('#').last() { 59 | Some(x) => x, 60 | None => return None, 61 | }; 62 | 63 | if func_string == "" { 64 | return None; 65 | } 66 | 67 | let mut error: Option = None; 68 | 69 | for entry in func_string.split(',') { 70 | let func = match exmex::parse::(&entry.replace("e", crate::EULER)) { 71 | Ok(func) => Some(func), 72 | Err(e) => { 73 | error = Some(e.to_string()); 74 | None 75 | } 76 | }; 77 | 78 | data.push(FunctionEntry { 79 | text: entry.to_string(), 80 | func, 81 | }); 82 | } 83 | 84 | error 85 | } 86 | -------------------------------------------------------------------------------- /public/icon.dev.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 38 | 40 | 44 | 50 | 55 | 60 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | use eframe::{ 2 | egui::{ 3 | self, 4 | plot::{Legend, Line, Plot, Values}, 5 | CollapsingHeader, Frame, RichText, ScrollArea, SidePanel, Slider, Style, TextEdit, 6 | TextStyle, Visuals, 7 | }, 8 | epaint::{Color32, Vec2}, 9 | epi::{self}, 10 | }; 11 | use exmex::{Express, FlatEx}; 12 | 13 | mod web; 14 | 15 | #[cfg(target_arch = "wasm32")] 16 | pub use web::start_web; 17 | 18 | pub const EULER: &'static str = "2.7182818284590452353602874713527"; 19 | 20 | const COLORS: &'static [Color32; 18] = &[ 21 | Color32::RED, 22 | Color32::GREEN, 23 | Color32::YELLOW, 24 | Color32::BLUE, 25 | Color32::BROWN, 26 | Color32::GOLD, 27 | Color32::GRAY, 28 | Color32::WHITE, 29 | Color32::LIGHT_YELLOW, 30 | Color32::LIGHT_GREEN, 31 | Color32::LIGHT_BLUE, 32 | Color32::LIGHT_GRAY, 33 | Color32::LIGHT_RED, 34 | Color32::DARK_GRAY, 35 | Color32::DARK_RED, 36 | Color32::KHAKI, 37 | Color32::DARK_GREEN, 38 | Color32::DARK_BLUE, 39 | ]; 40 | 41 | #[derive(Clone, Debug)] 42 | pub struct Grapher { 43 | data: Vec, 44 | error: Option, 45 | points: usize, 46 | } 47 | 48 | impl Grapher { 49 | pub fn new() -> Self { 50 | let mut data = Vec::new(); 51 | 52 | cfg_if::cfg_if! { 53 | if #[cfg(target_arch = "wasm32")] { 54 | let error: Option = web::get_data_from_url(&mut data); 55 | } else { 56 | let error: Option = None; 57 | } 58 | } 59 | 60 | if data.is_empty() { 61 | data.push(FunctionEntry::new()); 62 | } 63 | 64 | Self { 65 | data, 66 | error, 67 | points: 500, 68 | } 69 | } 70 | 71 | fn side_panel(&mut self, ctx: &egui::Context) { 72 | SidePanel::left("left_panel").show(ctx, |ui| { 73 | ScrollArea::vertical().show(ui, |ui| { 74 | ui.add_space(6.0); 75 | ui.heading("Grapher"); 76 | ui.small("© 2022 Grant Handy"); 77 | 78 | ui.separator(); 79 | 80 | let mut outer_changed = false; 81 | 82 | ui.horizontal_top(|ui| { 83 | if self.data.len() < 18 && ui.button("Add").clicked() { 84 | self.data.push(FunctionEntry::new()); 85 | outer_changed = true; 86 | } 87 | 88 | if self.data.len() > 1 && ui.button("Delete").clicked() { 89 | self.data.pop(); 90 | outer_changed = true; 91 | } 92 | }); 93 | 94 | ui.add_space(4.5); 95 | 96 | for (n, entry) in self.data.iter_mut().enumerate() { 97 | let mut inner_changed = false; 98 | 99 | let hint_text = match n { 100 | 0 => "x^2", 101 | 1 => "sin(x)", 102 | 2 => "x+2", 103 | 3 => "x*3", 104 | 4 => "abs(x)", 105 | 5 => "cos(x)", 106 | // most people won't go past 5 so i'll be lazy 107 | _ => "", 108 | }; 109 | 110 | ui.horizontal(|ui| { 111 | ui.label(RichText::new(" ").strong().background_color(COLORS[n])); 112 | 113 | if ui.add(TextEdit::singleline(&mut entry.text).hint_text(hint_text)).changed() { 114 | if entry.text != "" { 115 | inner_changed = true; 116 | } else { 117 | entry.func = None; 118 | } 119 | 120 | outer_changed = true; 121 | } 122 | }); 123 | 124 | if inner_changed { 125 | self.error = None; 126 | 127 | // for nathan 128 | entry.func = match exmex::parse::(&entry.text.replace("e", EULER)) { 129 | Ok(func) => Some(func), 130 | Err(e) => { 131 | self.error = Some(e.to_string()); 132 | continue; 133 | } 134 | }; 135 | } 136 | } 137 | 138 | #[cfg(target_arch = "wasm32")] 139 | if outer_changed { 140 | web::update_url(&self.data); 141 | } 142 | 143 | ui.separator(); 144 | ui.label("Grapher is a free and open source graphing calculator available online. Add functions on the left and they'll appear on the right in the graph."); 145 | ui.label("Hold control and scroll to zoom and drag to move around the graph."); 146 | ui.hyperlink_to("Source Code ", "https://github.com/grantshandy/grapher"); 147 | #[cfg(not(target_arch = "wasm32"))] 148 | ui.hyperlink_to("View Graph Online", { 149 | let mut base_url = "https://grantshandy.github.io/grapher/".to_string(); 150 | base_url.push_str(&web::url_string_from_data(&self.data)); 151 | 152 | base_url 153 | }); 154 | #[cfg(target_arch = "wasm32")] 155 | ui.hyperlink_to("Download for Desktop", "https://github.com/grantshandy/grapher/releases"); 156 | ui.separator(); 157 | 158 | CollapsingHeader::new("Settings").show(ui, |ui| { 159 | ui.add(Slider::new(&mut self.points, 10..=1000).text("Resolution")); 160 | ui.label("Set to a lower resolution for better performance and a higher resolution for more accuracy. It's also pretty funny if you bring it down ridiculously low."); 161 | }); 162 | }); 163 | }); 164 | } 165 | 166 | fn graph(&mut self, ctx: &egui::Context) { 167 | let mut lines: Vec = Vec::new(); 168 | 169 | for (n, entry) in self.data.clone().into_iter().enumerate() { 170 | if let Some(func) = entry.func { 171 | let name = format!("y = {}", entry.text.clone()); 172 | let values = Values::from_explicit_callback( 173 | move |x| match func.eval(&[x]) { 174 | Ok(y) => y, 175 | Err(e) => { 176 | // DIRTY HACK THEY DON'T WANT YOU TO KNOW ABOUT! 177 | if e.to_string() == "parsed expression contains 0 vars but passed slice has 1 elements" { 178 | entry.text.parse().unwrap_or(0.0) 179 | } else { 180 | 0.0 181 | } 182 | } 183 | }, 184 | .., 185 | self.points, 186 | ); 187 | 188 | let line = Line::new(values).name(name).color(COLORS[n]); 189 | 190 | lines.push(line); 191 | } 192 | } 193 | 194 | let frame = Frame::window(&Style::default()).margin(Vec2 { x: 0.0, y: 0.0 }); 195 | 196 | egui::CentralPanel::default().frame(frame).show(ctx, |ui| { 197 | if let Some(error) = &self.error { 198 | ui.centered_and_justified(|ui| { 199 | ui.heading(format!("Error: {}", error)); 200 | }); 201 | } else { 202 | Plot::new("grapher") 203 | .legend(Legend::default().text_style(TextStyle::Body)) 204 | .data_aspect(1.0) 205 | .show(ui, |plot_ui| { 206 | for line in lines { 207 | plot_ui.line(line); 208 | } 209 | }); 210 | } 211 | }); 212 | } 213 | } 214 | 215 | impl Default for Grapher { 216 | fn default() -> Self { 217 | Self::new() 218 | } 219 | } 220 | 221 | impl epi::App for Grapher { 222 | fn name(&self) -> &str { 223 | "Grapher" 224 | } 225 | 226 | // imma assume you aren't this cool 227 | fn max_size_points(&self) -> Vec2 { 228 | Vec2 { 229 | x: 4096.0, 230 | y: 2160.0, 231 | } 232 | } 233 | 234 | fn update(&mut self, ctx: &egui::Context, _frame: &epi::Frame) { 235 | ctx.set_visuals(Visuals::dark()); 236 | 237 | self.side_panel(ctx); 238 | self.graph(ctx); 239 | } 240 | } 241 | 242 | /// An entry in the sidebar 243 | #[derive(Clone, Debug)] 244 | pub struct FunctionEntry { 245 | pub text: String, 246 | pub func: Option>, 247 | } 248 | 249 | impl FunctionEntry { 250 | pub fn new() -> Self { 251 | Self { 252 | text: String::new(), 253 | func: None, 254 | } 255 | } 256 | } 257 | --------------------------------------------------------------------------------