├── Trunk.toml ├── src ├── math │ ├── mod.rs │ ├── newton.rs │ ├── bezier.rs │ └── affine.rs ├── update.rs ├── lib.rs ├── graph │ ├── mod.rs │ ├── base.rs │ ├── simulator.rs │ ├── visualizer.rs │ └── structures.rs ├── components │ ├── mod.rs │ ├── top_panel.rs │ ├── footer.rs │ ├── error_modal.rs │ ├── transition_and_scale.rs │ ├── graph_io.rs │ ├── edit_menu.rs │ └── central_panel.rs ├── mode.rs ├── main.rs ├── config.rs └── app.rs ├── assets ├── card.png ├── favicon.ico ├── icon-1024.png ├── icon-256.png ├── icon_ios_touch_192.png ├── maskable_icon_x512.png ├── manifest.json └── sw.js ├── images └── graph-editor-demo-v3.gif ├── .typos.toml ├── .gitignore ├── check.sh ├── .github └── workflows │ ├── typos.yml │ ├── pages.yml │ └── rust.yml ├── rust-toolchain.toml ├── fill_template.sh ├── memo ├── intersection_of_bezier_and_circle.py └── graph_visualization.md ├── fill_template.ps1 ├── LICENSE-MIT ├── flake.nix ├── Cargo.toml ├── README.md ├── index.html └── LICENSE-APACHE /Trunk.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | filehash = false 3 | -------------------------------------------------------------------------------- /src/math/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod affine; 2 | pub mod bezier; 3 | pub mod newton; 4 | -------------------------------------------------------------------------------- /assets/card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentakom1213/graph-editor/HEAD/assets/card.png -------------------------------------------------------------------------------- /assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentakom1213/graph-editor/HEAD/assets/favicon.ico -------------------------------------------------------------------------------- /assets/icon-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentakom1213/graph-editor/HEAD/assets/icon-1024.png -------------------------------------------------------------------------------- /assets/icon-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentakom1213/graph-editor/HEAD/assets/icon-256.png -------------------------------------------------------------------------------- /assets/icon_ios_touch_192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentakom1213/graph-editor/HEAD/assets/icon_ios_touch_192.png -------------------------------------------------------------------------------- /assets/maskable_icon_x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentakom1213/graph-editor/HEAD/assets/maskable_icon_x512.png -------------------------------------------------------------------------------- /images/graph-editor-demo-v3.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentakom1213/graph-editor/HEAD/images/graph-editor-demo-v3.gif -------------------------------------------------------------------------------- /.typos.toml: -------------------------------------------------------------------------------- 1 | # https://github.com/crate-ci/typos 2 | # install: cargo install typos-cli 3 | # run: typos 4 | 5 | [default.extend-words] 6 | egui = "egui" # Example for how to ignore a false positive 7 | -------------------------------------------------------------------------------- /src/update.rs: -------------------------------------------------------------------------------- 1 | use crate::GraphEditorApp; 2 | 3 | pub fn request_repaint(app: &mut GraphEditorApp, ctx: &egui::Context) { 4 | if app.is_animated { 5 | ctx.request_repaint(); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![warn(clippy::all, rust_2018_idioms)] 2 | 3 | mod app; 4 | mod components; 5 | mod config; 6 | mod graph; 7 | mod math; 8 | mod mode; 9 | mod update; 10 | 11 | pub use app::GraphEditorApp; 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Mac stuff: 2 | .DS_Store 3 | 4 | # trunk output folder 5 | dist 6 | 7 | # Rust compile target directories: 8 | target 9 | target_ra 10 | target_wasm 11 | 12 | # https://github.com/lycheeverse/lychee 13 | .lycheecache 14 | -------------------------------------------------------------------------------- /src/graph/mod.rs: -------------------------------------------------------------------------------- 1 | mod base; 2 | mod simulator; 3 | mod structures; 4 | mod visualizer; 5 | 6 | pub use base::BaseGraph; 7 | pub use simulator::{simulation_methods, Simulator}; 8 | pub use structures::Graph; 9 | pub use visualizer::{visualize_methods, Visualizer}; 10 | -------------------------------------------------------------------------------- /src/components/mod.rs: -------------------------------------------------------------------------------- 1 | mod central_panel; 2 | mod edit_menu; 3 | mod error_modal; 4 | mod footer; 5 | mod graph_io; 6 | mod top_panel; 7 | mod transition_and_scale; 8 | 9 | pub use central_panel::draw_central_panel; 10 | pub use edit_menu::draw_edit_menu; 11 | pub use error_modal::draw_error_modal; 12 | pub use footer::draw_footer; 13 | pub use graph_io::draw_graph_io; 14 | pub use top_panel::{draw_top_panel, PanelTabState}; 15 | -------------------------------------------------------------------------------- /check.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # This scripts runs various CI-like checks in a convenient way. 3 | set -eux 4 | 5 | cargo check --quiet --workspace --all-targets 6 | cargo check --quiet --workspace --all-features --lib --target wasm32-unknown-unknown 7 | cargo fmt --all -- --check 8 | cargo clippy --quiet --workspace --all-targets --all-features -- -D warnings -W clippy::all 9 | cargo test --quiet --workspace --all-targets --all-features 10 | cargo test --quiet --workspace --doc 11 | trunk build 12 | -------------------------------------------------------------------------------- /.github/workflows/typos.yml: -------------------------------------------------------------------------------- 1 | # Copied from https://github.com/rerun-io/rerun_template 2 | 3 | # https://github.com/crate-ci/typos 4 | # Add exceptions to `.typos.toml` 5 | # install and run locally: cargo install typos-cli && typos 6 | 7 | name: Spell Check 8 | on: [pull_request] 9 | 10 | jobs: 11 | run: 12 | name: Spell Check 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout Actions Repository 16 | uses: actions/checkout@v4 17 | 18 | - name: Check spelling of entire workspace 19 | uses: crate-ci/typos@master 20 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | # If you see this, run "rustup self update" to get rustup 1.23 or newer. 2 | 3 | # NOTE: above comment is for older `rustup` (before TOML support was added), 4 | # which will treat the first line as the toolchain name, and therefore show it 5 | # to the user in the error, instead of "error: invalid channel name '[toolchain]'". 6 | 7 | [toolchain] 8 | channel = "1.88" # Avoid specifying a patch version here; see https://github.com/emilk/eframe_template/issues/145 9 | components = [ "rustfmt", "clippy" ] 10 | targets = [ "wasm32-unknown-unknown" ] 11 | -------------------------------------------------------------------------------- /fill_template.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | set -e 4 | 5 | echo "To fill the template tell me your egui project crate name: " 6 | 7 | read crate 8 | 9 | echo "To fill the template tell me your name (for author in Cargo.toml): " 10 | 11 | read name 12 | 13 | echo "To fill the template tell me your e-mail address (also for Cargo.toml): " 14 | 15 | read email 16 | 17 | echo "Patching files..." 18 | 19 | sed -i "s/eframe_template/$crate/g" Cargo.toml 20 | sed -i "s/eframe_template/$crate/g" src/main.rs 21 | sed -i "s/eframe template/$crate/g" index.html 22 | sed -i "s/eframe_template/$crate/g" assets/sw.js 23 | sed -i "s/Emil Ernerfeldt/$name/g" Cargo.toml 24 | sed -i "s/emil.ernerfeldt@gmail.com/$email/g" Cargo.toml 25 | 26 | echo "Done." 27 | -------------------------------------------------------------------------------- /memo/intersection_of_bezier_and_circle.py: -------------------------------------------------------------------------------- 1 | # %% ライブラリのインポート 2 | import sympy as sy 3 | 4 | # %% 変数の定義 5 | # sy.var("x0 y0 x1 y1 x2 y2 x3 y3 xc yc r t x y") 6 | 7 | x0, y0 = sy.symbols("x0 y0") 8 | x1, y1 = sy.symbols("x1 y1") 9 | x2, y2 = sy.symbols("x2 y2") 10 | xc, yc = sy.symbols("xc yc") 11 | r = sy.symbols("r") 12 | t = sy.symbols("t") 13 | x, y = sy.symbols("x y") 14 | 15 | # %% ベジエ曲線の定義 16 | x_bezier = (1 - t) ** 2 * x0 + 2 * (1 - t) * t * x1 + t**2 * x2 17 | y_bezier = (1 - t) ** 2 * y0 + 2 * (1 - t) * t * y1 + t**2 * y2 18 | 19 | # %% 円の定義 20 | circle = (x - xc) ** 2 + (y - yc) ** 2 - r**2 21 | 22 | # %% ベジエ曲線と円の交点を求める 23 | f = circle.subs({x: x_bezier, y: y_bezier}) 24 | df = sy.diff(f, t) 25 | 26 | print("f = ", f) 27 | print("df = ", df) 28 | -------------------------------------------------------------------------------- /src/math/newton.rs: -------------------------------------------------------------------------------- 1 | /// ニュートン法で方程式 f(x) = 0 の解を求める 2 | /// f: 関数, df: f の導関数, x0: 初期値, tol: 許容誤差, max_iter: 最大反復回数 3 | pub fn newton_method( 4 | f: impl Fn(f32) -> f32, 5 | df: impl Fn(f32) -> f32, 6 | x0: f32, 7 | tol: f32, 8 | max_iter: usize, 9 | ) -> Option { 10 | let mut x = x0; 11 | 12 | for _ in 0..max_iter { 13 | let fx = f(x); 14 | let dfx = df(x); 15 | 16 | if dfx.abs() < 1e-6 { 17 | // 導関数が 0 に近いときは収束しないため終了 18 | return None; 19 | } 20 | 21 | let x_next = x - fx / dfx; 22 | 23 | if (x_next - x).abs() < tol { 24 | return Some(x_next); // 収束したら解を返す 25 | } 26 | 27 | x = x_next; 28 | } 29 | 30 | None // 最大反復回数に達した場合は解なし 31 | } 32 | -------------------------------------------------------------------------------- /assets/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Graph Editor", 3 | "short_name": "graph-editor", 4 | "icons": [ 5 | { 6 | "src": "./assets/icon-256.png", 7 | "sizes": "256x256", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "./assets/maskable_icon_x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png", 14 | "purpose": "any maskable" 15 | }, 16 | { 17 | "src": "./assets/icon-1024.png", 18 | "sizes": "1024x1024", 19 | "type": "image/png" 20 | }, 21 | { 22 | "src": "./assets/card.png", 23 | "sizes": "1234x817", 24 | "type": "image/png" 25 | } 26 | ], 27 | "lang": "en-US", 28 | "id": "/index.html", 29 | "start_url": "./index.html", 30 | "display": "standalone", 31 | "background_color": "white", 32 | "theme_color": "white" 33 | } 34 | -------------------------------------------------------------------------------- /fill_template.ps1: -------------------------------------------------------------------------------- 1 | $crate = Read-Host "To fill the template, tell me your egui project crate name: " 2 | $name = Read-Host "To fill the template, tell me your name (for author in Cargo.toml): " 3 | $email = Read-Host "To fill the template, tell me your e-mail address (also for Cargo.toml): " 4 | 5 | Write-Host "Patching files..." 6 | 7 | (Get-Content "Cargo.toml") -replace "eframe_template", $crate | Set-Content "Cargo.toml" 8 | (Get-Content "src\main.rs") -replace "eframe_template", $crate | Set-Content "src\main.rs" 9 | (Get-Content "index.html") -replace "eframe template", $crate -replace "eframe_template", $crate | Set-Content "index.html" 10 | (Get-Content "assets\sw.js") -replace "eframe_template", $crate | Set-Content "assets\sw.js" 11 | (Get-Content "Cargo.toml") -replace "Emil Ernerfeldt", $name -replace "emil.ernerfeldt@gmail.com", $email | Set-Content "Cargo.toml" 12 | 13 | Write-Host "Done." -------------------------------------------------------------------------------- /assets/sw.js: -------------------------------------------------------------------------------- 1 | self.addEventListener('install', function (e) { 2 | self.skipWaiting(); 3 | }); 4 | 5 | self.addEventListener('activate', function (e) { 6 | e.waitUntil( 7 | caches.keys().then(function (cacheNames) { 8 | return Promise.all( 9 | cacheNames.map(function (cache) { 10 | return caches.delete(cache); 11 | }) 12 | ); 13 | }).then(() => self.clients.claim()) 14 | ); 15 | }); 16 | 17 | // ネットワーク接続が回復したらキャッシュを削除 18 | self.addEventListener('message', function (event) { 19 | if (event.data === 'clearCache') { 20 | caches.keys().then(function (cacheNames) { 21 | return Promise.all( 22 | cacheNames.map(function (cache) { 23 | return caches.delete(cache); 24 | }) 25 | ); 26 | }); 27 | } 28 | }); 29 | 30 | // すべてのリクエストをネットワークから取得し、キャッシュは使用しない 31 | self.addEventListener('fetch', function (e) { 32 | e.respondWith(fetch(e.request)); 33 | }); 34 | -------------------------------------------------------------------------------- /src/mode.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, PartialEq, Clone)] 2 | pub enum EditMode { 3 | Normal, 4 | AddVertex, 5 | AddEdge { 6 | from_vertex: Option, 7 | confirmed: bool, 8 | }, 9 | Delete, 10 | } 11 | 12 | impl EditMode { 13 | pub fn default_normal() -> Self { 14 | Self::Normal 15 | } 16 | 17 | pub fn default_add_vertex() -> Self { 18 | Self::AddVertex 19 | } 20 | 21 | pub fn default_add_edge() -> Self { 22 | Self::AddEdge { 23 | from_vertex: None, 24 | confirmed: false, 25 | } 26 | } 27 | 28 | pub fn default_delete() -> Self { 29 | Self::Delete 30 | } 31 | 32 | pub fn is_add_vertex(&self) -> bool { 33 | matches!(self, Self::AddVertex) 34 | } 35 | 36 | pub fn is_add_edge(&self) -> bool { 37 | matches!(self, Self::AddEdge { .. }) 38 | } 39 | 40 | pub fn is_delete(&self) -> bool { 41 | matches!(self, Self::Delete) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any 2 | person obtaining a copy of this software and associated 3 | documentation files (the "Software"), to deal in the 4 | Software without restriction, including without 5 | limitation the rights to use, copy, modify, merge, 6 | publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software 8 | is furnished to do so, subject to the following 9 | conditions: 10 | 11 | The above copyright notice and this permission notice 12 | shall be included in all copies or substantial portions 13 | of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 22 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 23 | DEALINGS IN THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /src/components/top_panel.rs: -------------------------------------------------------------------------------- 1 | // src/components/top_panel.rs 2 | use egui::{Context, TopBottomPanel}; 3 | 4 | use crate::GraphEditorApp; 5 | 6 | pub struct PanelTabState { 7 | pub edit_menu: bool, 8 | pub graph_io: bool, 9 | } 10 | 11 | impl Default for PanelTabState { 12 | fn default() -> Self { 13 | PanelTabState { 14 | edit_menu: true, 15 | graph_io: true, 16 | } 17 | } 18 | } 19 | 20 | pub fn draw_top_panel(app: &mut GraphEditorApp, ctx: &Context) { 21 | TopBottomPanel::top("top_panel").show(ctx, |ui| { 22 | // カーソルがあるか判定 23 | app.hovered_on_top_panel = ui.rect_contains_pointer(ui.max_rect()); 24 | 25 | egui::menu::bar(ui, |ui| { 26 | ui.toggle_value( 27 | &mut app.panel_tab.edit_menu, 28 | egui::RichText::new("Menu").size(app.config.menu_font_size_normal), 29 | ); 30 | ui.toggle_value( 31 | &mut app.panel_tab.graph_io, 32 | egui::RichText::new("Input").size(app.config.menu_font_size_normal), 33 | ); 34 | }); 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /src/components/footer.rs: -------------------------------------------------------------------------------- 1 | use egui::Context; 2 | 3 | use crate::{config::APP_VERSION, GraphEditorApp}; 4 | 5 | /// フッターを描画する 6 | pub fn draw_footer(app: &mut GraphEditorApp, ctx: &Context) { 7 | // 画面下部にフッターを追加 8 | egui::Area::new("Footer".into()) 9 | .anchor(egui::Align2::RIGHT_BOTTOM, egui::vec2(-20.0, -10.0)) 10 | .show(ctx, |ui| { 11 | egui::Frame::default().show(ui, |ui| { 12 | ui.horizontal_centered(|ui| { 13 | ui.label( 14 | egui::RichText::new(format!( 15 | "Graph Editor v{APP_VERSION} © 2025 kentakom1213" 16 | )) 17 | .size(app.config.footer_font_size), 18 | ); 19 | 20 | if ui 21 | .hyperlink_to( 22 | egui::RichText::new("GitHub").size(app.config.footer_font_size), 23 | "https://github.com/kentakom1213/graph-editor", 24 | ) 25 | .clicked() 26 | {} 27 | }); 28 | }); 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "eframe devShell"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 6 | rust-overlay.url = "github:oxalica/rust-overlay"; 7 | flake-utils.url = "github:numtide/flake-utils"; 8 | }; 9 | 10 | outputs = { self, nixpkgs, rust-overlay, flake-utils, ... }: 11 | flake-utils.lib.eachDefaultSystem (system: 12 | let 13 | overlays = [ (import rust-overlay) ]; 14 | pkgs = import nixpkgs { inherit system overlays; }; 15 | in with pkgs; { 16 | devShells.default = mkShell rec { 17 | buildInputs = [ 18 | # Rust 19 | rust-bin.stable.latest.default 20 | trunk 21 | 22 | # misc. libraries 23 | openssl 24 | pkg-config 25 | 26 | # GUI libs 27 | libxkbcommon 28 | libGL 29 | fontconfig 30 | 31 | # wayland libraries 32 | wayland 33 | 34 | # x11 libraries 35 | xorg.libXcursor 36 | xorg.libXrandr 37 | xorg.libXi 38 | xorg.libX11 39 | 40 | ]; 41 | 42 | LD_LIBRARY_PATH = "${lib.makeLibraryPath buildInputs}"; 43 | }; 44 | }); 45 | } 46 | -------------------------------------------------------------------------------- /src/components/error_modal.rs: -------------------------------------------------------------------------------- 1 | use egui::Context; 2 | 3 | use crate::GraphEditorApp; 4 | 5 | /// エラー表示を行うモーダル画面 6 | pub fn draw_error_modal(app: &mut GraphEditorApp, ctx: &Context) { 7 | if let Some(message) = app.error_message.to_owned() { 8 | // 背景を暗くする 9 | let screen_rect = ctx.screen_rect(); 10 | let dark_color = egui::Color32::from_black_alpha(160); 11 | let painter = ctx.layer_painter(egui::LayerId::new( 12 | egui::Order::Background, 13 | egui::Id::new("modal_bg"), 14 | )); 15 | painter.rect_filled(screen_rect, 0.0, dark_color); 16 | 17 | if ctx.input(|i| { 18 | i.key_pressed(egui::Key::Escape) 19 | || i.pointer.any_released() && !app.hovered_on_input_window 20 | }) { 21 | app.error_message = None; 22 | return; 23 | } 24 | 25 | // エラーモーダル本体 26 | egui::Window::new( 27 | egui::RichText::new("Error") 28 | .strong() 29 | .size(app.config.menu_font_size_normal) 30 | .color(egui::Color32::from_rgb(255, 100, 80)), 31 | ) 32 | .collapsible(false) 33 | .resizable(false) 34 | .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) 35 | .frame(egui::Frame::popup(ctx.style().as_ref()).inner_margin(10.0)) 36 | .show(ctx, |ui| { 37 | ui.label(egui::RichText::new(message).size(app.config.menu_font_size_normal)); 38 | }); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /memo/graph_visualization.md: -------------------------------------------------------------------------------- 1 | # グラフ描画アルゴリズム 2 | 3 | ## 資料 4 | 5 | - [力学モデル (グラフ描画アルゴリズム) - wikipedia](https://ja.wikipedia.org/wiki/%E5%8A%9B%E5%AD%A6%E3%83%A2%E3%83%87%E3%83%AB_(%E3%82%B0%E3%83%A9%E3%83%95%E6%8F%8F%E7%94%BB%E3%82%A2%E3%83%AB%E3%82%B4%E3%83%AA%E3%82%BA%E3%83%A0)) 6 | 7 | ## 定義 8 | 9 | - グラフ $G = (V, E)$ 10 | - 頂点集合 $V$ ( $N := |V|$ ) 11 | - 頂点 $v\in V$ について, 12 | - $v.\!\boldsymbol{x}$ :頂点 $v$ の座標 13 | - $v.\!\boldsymbol{\dot{x}}$ :頂点 $v$​ の速度ベクトル 14 | - $v.\!m$ :頂点 $v$ の重さ 15 | - 辺集合 $E \subseteq \binom{V}{2}$ ( $M := |E|$ ) 16 | - 定数 17 | - $c$ :クーロン定数 18 | - $k$​ :ばね定数 19 | - $l$​ :ばねの自然長 20 | - $h$ :減衰定数( $0 < h < 1$​ ) 21 | - $\Delta t$ :微小時間(フレームレートを取得) 22 | 23 | 24 | ## アルゴリズム 25 | 26 | 以下のようなアルゴリズムで描画を行う. 27 | 28 | 1. 全ての頂点 $v\in V$ について, $v.\!\mathit{dx} := 0,~v.\!\mathit{dy} := 0$ とする. 29 | 30 | 2. 全ての頂点 $v\in V$ について, 31 | 32 | 1. 頂点 $v$ に加わる力を $\boldsymbol{f}_v := (0, 0)$​ とする. 33 | 34 | 1. 全ての頂点 $w\in V$ について,$v.\!\boldsymbol{x}$ から $w.\!\boldsymbol{x}$ へ向かう単位ベクトルを $\hat{r}$ とするとき, 35 | $$ 36 | \boldsymbol{f}_v := \boldsymbol{f}_v + \frac{c}{\|v.\!\boldsymbol{x} - w.\!\boldsymbol{x}\|^2}\cdot \hat{r}. 37 | $$ 38 | 39 | 2. 頂点 $v$ の隣接頂点 $w\in N(v)$ について, 40 | $$ 41 | \boldsymbol{f}_v := \boldsymbol{f}_v + k \cdot (\|v.\!\boldsymbol{x} - w.\!\boldsymbol{x}\| - l) \cdot \hat{r}. 42 | $$ 43 | 44 | 3. 振動の減衰を加味して速度を更新する. 45 | $$ 46 | v.\!\boldsymbol{\dot{x}} := h \cdot \left(v.\!\boldsymbol{\dot{x}} + \Delta t \cdot \frac{\boldsymbol{f}_v}{v.\!m} \right). 47 | $$ 48 | 49 | 4. 頂点の位置を更新する. 50 | $$ 51 | v.\!\boldsymbol{x} := v.\!\boldsymbol{x} + \Delta t \cdot v.\!\boldsymbol{\dot{x}}. 52 | $$ 53 | -------------------------------------------------------------------------------- /src/graph/base.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug)] 2 | pub struct BaseGraph { 3 | pub n: usize, 4 | pub edges: Vec<(usize, usize)>, 5 | } 6 | 7 | impl BaseGraph { 8 | /// 文字列からグラフの基本構造を生成する. 9 | /// ```text 10 | /// N M 11 | /// u_1 v_1 12 | /// ... 13 | /// u_M v_M 14 | /// ``` 15 | pub fn parse(input_text: &str, zero_indexed: bool) -> anyhow::Result { 16 | let mut source = input_text 17 | .split_ascii_whitespace() 18 | .map(|s| s.parse::()); 19 | 20 | let n = source 21 | .next() 22 | .ok_or_else(|| anyhow::anyhow!("Insufficient input"))??; 23 | let m = source 24 | .next() 25 | .ok_or_else(|| anyhow::anyhow!("Insufficient input"))??; 26 | 27 | let edges = (0..m) 28 | .map(|_| { 29 | let mut from = source 30 | .next() 31 | .ok_or_else(|| anyhow::anyhow!("Insufficient input"))??; 32 | let mut to = source 33 | .next() 34 | .ok_or_else(|| anyhow::anyhow!("Insufficient input"))??; 35 | 36 | if !zero_indexed { 37 | from = from 38 | .checked_sub(1) 39 | .ok_or_else(|| anyhow::anyhow!("Invalid edge: {} {}", from, to))?; 40 | to = to 41 | .checked_sub(1) 42 | .ok_or_else(|| anyhow::anyhow!("Invalid edge: {} {}", from, to))?; 43 | } 44 | 45 | if from > n || to > n { 46 | return Err(anyhow::anyhow!("Invalid edge: {} {}", from, to)); 47 | } 48 | 49 | anyhow::Ok((from, to)) 50 | }) 51 | .collect::>()?; 52 | 53 | if source.next().is_some() { 54 | return Err(anyhow::anyhow!("Excessive input")); 55 | } 56 | 57 | Ok(Self { n, edges }) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "graph-editor" 3 | version = "0.4.4" 4 | authors = ["Kenta KOMOTO"] 5 | edition = "2021" 6 | include = ["LICENSE-APACHE", "LICENSE-MIT", "**/*.rs", "Cargo.toml"] 7 | rust-version = "1.88" 8 | 9 | [package.metadata.docs.rs] 10 | all-features = true 11 | targets = ["x86_64-unknown-linux-gnu", "wasm32-unknown-unknown"] 12 | 13 | [dependencies] 14 | egui = "0.31" 15 | eframe = { version = "0.31", default-features = false, features = [ 16 | "accesskit", # Make egui compatible with screen readers. NOTE: adds a lot of dependencies. 17 | "default_fonts", # Embed the default egui fonts. 18 | "glow", # Use the glow rendering backend. Alternative: "wgpu". 19 | "persistence", # Enable restoring app state when restarting the app. 20 | "wayland", # To support Linux (and CI) 21 | ] } 22 | log = "0.4" 23 | 24 | # You only need serde if you want app persistence: 25 | serde = { version = "1", features = ["derive"] } 26 | itertools = "0.14.0" 27 | epaint = "0.31.1" 28 | anyhow = "1.0.97" 29 | rand = { version = "0.8" } 30 | getrandom = { version = "0.2.2", features = ["js"] } 31 | num-traits = "0.2.19" 32 | 33 | # native: 34 | [target.'cfg(not(target_arch = "wasm32"))'.dependencies] 35 | env_logger = "0.11" 36 | 37 | # web: 38 | [target.'cfg(target_arch = "wasm32")'.dependencies] 39 | wasm-bindgen-futures = "0.4" 40 | web-sys = "0.3.70" # to access the DOM (to hide the loading text) 41 | 42 | [profile.release] 43 | opt-level = 2 # fast and small wasm 44 | 45 | # Optimize all dependencies even in debug builds: 46 | [profile.dev.package."*"] 47 | opt-level = 2 48 | 49 | 50 | [patch.crates-io] 51 | 52 | # If you want to use the bleeding edge version of egui and eframe: 53 | # egui = { git = "https://github.com/emilk/egui", branch = "master" } 54 | # eframe = { git = "https://github.com/emilk/egui", branch = "master" } 55 | 56 | # If you fork https://github.com/emilk/egui you can test with: 57 | # egui = { path = "../egui/crates/egui" } 58 | # eframe = { path = "../egui/crates/eframe" } 59 | -------------------------------------------------------------------------------- /.github/workflows/pages.yml: -------------------------------------------------------------------------------- 1 | name: Github Pages 2 | 3 | # By default, runs if you push to main. keeps your deployed app in sync with main branch. 4 | on: 5 | push: 6 | branches: 7 | - main 8 | # to only run when you do a new github release, comment out above part and uncomment the below trigger. 9 | # on: 10 | # release: 11 | # types: 12 | # - published 13 | 14 | permissions: 15 | contents: write # for committing to gh-pages branch. 16 | 17 | jobs: 18 | build-github-pages: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v4 # repo checkout 22 | - name: Setup toolchain for wasm 23 | run: | 24 | rustup update stable 25 | rustup default stable 26 | rustup set profile minimal 27 | rustup target add wasm32-unknown-unknown 28 | - name: Rust Cache # cache the rust build artefacts 29 | uses: Swatinem/rust-cache@v2 30 | - name: Download and install Trunk binary 31 | run: wget -qO- https://github.com/thedodd/trunk/releases/latest/download/trunk-x86_64-unknown-linux-gnu.tar.gz | tar -xzf- 32 | - name: Build # build 33 | # Environment $public_url resolves to the github project page. 34 | # If using a user/organization page, remove the `${{ github.event.repository.name }}` part. 35 | # using --public-url something will allow trunk to modify all the href paths like from favicon.ico to repo_name/favicon.ico . 36 | # this is necessary for github pages where the site is deployed to username.github.io/repo_name and all files must be requested 37 | # relatively as eframe_template/favicon.ico. if we skip public-url option, the href paths will instead request username.github.io/favicon.ico which 38 | # will obviously return error 404 not found. 39 | run: ./trunk build --release --public-url $public_url 40 | env: 41 | public_url: "https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}" 42 | - name: Deploy 43 | uses: JamesIves/github-pages-deploy-action@v4 44 | with: 45 | folder: dist 46 | # this option will not maintain any history of your previous pages deployment 47 | # set to false if you want all page build to be committed to your gh-pages branch history 48 | single-commit: true 49 | -------------------------------------------------------------------------------- /src/graph/simulator.rs: -------------------------------------------------------------------------------- 1 | use crate::graph::Graph; 2 | 3 | /// シミュレーションを行う 4 | pub trait Simulator { 5 | /// 1ステップ分シミュレートする 6 | fn simulate_step(&self, graph: &mut Graph); 7 | } 8 | 9 | pub mod simulation_methods { 10 | use crate::{ 11 | config::SimulateConfig, 12 | graph::{Graph, Simulator}, 13 | }; 14 | 15 | const DISTANCE_EPS: f32 = 1e-5; 16 | 17 | /// 力学モデル 18 | pub struct ForceDirectedModel { 19 | pub config: SimulateConfig, 20 | } 21 | 22 | impl Simulator for ForceDirectedModel { 23 | fn simulate_step(&self, graph: &mut Graph) { 24 | let &SimulateConfig { 25 | c, 26 | k, 27 | l, 28 | h, 29 | m, 30 | max_v, 31 | dt, 32 | } = &self.config; 33 | 34 | // ドラッグ差分を解消 35 | graph 36 | .vertices_mut() 37 | .iter_mut() 38 | .for_each(|v| v.solve_drag_offset()); 39 | 40 | let n = graph.vertices.len(); 41 | 42 | for i in 0..n { 43 | let v = graph.vertices[i].clone(); 44 | 45 | // vからxへ向かう単位ベクトル 46 | let r = |x: egui::Pos2| -> egui::Vec2 { (x - v.position).normalized() }; 47 | 48 | // 頂点vに働く力 49 | let fv = graph 50 | .vertices 51 | .iter() 52 | .filter(|w| w.position.distance(v.position) > DISTANCE_EPS) 53 | // 頂点間の斥力 54 | .map(|w| -r(w.position) * c / v.position.distance_sq(w.position)) 55 | // 辺による引力 56 | .chain( 57 | graph 58 | .neighbor_vertices(v.id) 59 | .map(|w| r(w.position) * (v.position.distance(w.position) - l) * k), 60 | ) 61 | .fold(egui::Vec2::ZERO, |acc, f| acc + f); 62 | 63 | // 速度を更新 64 | let mut next_velocity = (v.velocity + fv * dt / m) * h; 65 | 66 | if next_velocity.length() > max_v { 67 | next_velocity = next_velocity.normalized() * max_v; 68 | } 69 | 70 | // 位置を更新 71 | let next_position = v.position + v.velocity * dt; 72 | 73 | graph.vertices[i].velocity = next_velocity; 74 | graph.vertices[i].position = next_position; 75 | } 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Graph Editor 2 | 3 | [![Build Status](https://github.com/kentakom1213/graph-editor/workflows/CI/badge.svg)](https://github.com/kentakom1213/graph-editor/actions?workflow=CI) 4 | 5 | Graph Editor は [eframe](https://github.com/emilk/egui/tree/master/crates/eframe) と [egui](https://github.com/emilk/egui/) によるグラフ編集アプリです. 6 | 7 | ![demo](./images/graph-editor-demo-v3.gif) 8 | 9 | ## 📌 操作 10 | 11 | ### Edit Mode 12 | 13 | | モード | コマンド | 説明 | 14 | | :--------------------------- | :------: | :-------------------------------- | 15 | | Normal モード | N | 頂点の移動などを行う | 16 | | Add Vertex (頂点追加) モード | V | クリックした位置に頂点を追加する | 17 | | Add Edge (辺追加) モード | E | 選択した 2 つの頂点の間に辺を張る | 18 | | Delete Edge (辺削除) モード | D | クリックした頂点/辺を削除する | 19 | 20 | ### Indexing 21 | 22 | 頂点の表示方法を変更する. 23 | 24 | | Indexing | コマンド | 説明 | 25 | | :-------- | :--------: | :-------------------------- | 26 | | 0-indexed | 1 (toggle) | 頂点を `0` 始まりで表示する | 27 | | 1-indexed | 1 (toggle) | 頂点を `1` 始まりで表示する | 28 | 29 | ### Direction 30 | 31 | | Direction | コマンド | 説明 | 32 | | :--------- | :----------------: | :----------------------- | 33 | | Undirected | Shift + D (toggle) | 無向グラフとして描画する | 34 | | Directed | Shift + D (toggle) | 有向グラフとして描画する | 35 | 36 | ### Animation 37 | 38 | | Animation | コマンド | 説明 | 39 | | :-------- | :--------: | :--------------- | 40 | | On | A (toggle) | ノードを動かす | 41 | | Off | A (toggle) | ノードを固定する | 42 | 43 | ### 共通 44 | 45 | - ドラッグ,または 2 本指でのスクロールでグラフ全体を移動する 46 | 47 | --- 48 | 49 | ## ローカル環境での実行方法 50 | 51 | ```bash 52 | # リポジトリをクローン 53 | git clone https://github.com/powell/graph-editor.git 54 | cd graph-editor 55 | 56 | # アプリケーションをビルドして実行 57 | cargo run --release 58 | ``` 59 | 60 | --- 61 | 62 | ## Web 版をローカルでプレビューする方法 63 | 64 | ### インストール 65 | 66 | - Rust と Trunk のインストールが必要です。 67 | 68 | ```bash 69 | # WebAssemblyターゲットを追加 70 | rustup target add wasm32-unknown-unknown 71 | 72 | # Trunk をインストール 73 | cargo install --locked trunk 74 | ``` 75 | 76 | ### ローカルサーバーで実行する場合 77 | 78 | ```bash 79 | # ローカルサーバーを起動 80 | trunk serve 81 | ``` 82 | 83 | ブラウザで `http://127.0.0.1:8080` を開いて確認してください。 84 | 85 | ## コントリビューション 86 | 87 | バグ報告、機能追加の提案、プルリクエストなど歓迎いたします。 88 | 89 | ## ライセンス 90 | 91 | このプロジェクトは MIT ライセンス、APACHE ライセンスの下で提供されています。詳細は [LICENSE-APACHE](https://github.com/kentakom1213/graph-editor/blob/main/LICENSE-APACHE)、[LICENSE-MIT](https://github.com/kentakom1213/graph-editor/blob/main/LICENSE-MIT) ファイルを参照してください。 92 | -------------------------------------------------------------------------------- /src/components/transition_and_scale.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | math::affine::{Affine2D, ApplyAffine}, 3 | GraphEditorApp, 4 | }; 5 | 6 | /// 右クリックでドラッグを行う 7 | pub fn drag_central_panel(app: &mut GraphEditorApp, ui: &mut egui::Ui) { 8 | let response = ui.allocate_response(ui.available_size(), egui::Sense::drag()); 9 | 10 | // マウス入力の処理 11 | if response.dragged_by(egui::PointerButton::Primary) { 12 | if let Some(mouse_pos) = ui.input(|i| i.pointer.hover_pos()) { 13 | if let Some(last_pos) = app.last_mouse_pos { 14 | let cur_scale = app.graph.affine.borrow().scale_x(); 15 | let delta = mouse_pos - last_pos; 16 | *app.graph.affine.borrow_mut() *= Affine2D::from_transition(delta / cur_scale); 17 | } 18 | app.last_mouse_pos = Some(mouse_pos); 19 | } 20 | } else { 21 | app.last_mouse_pos = None; 22 | } 23 | } 24 | 25 | /// グラフのスケールを行う 26 | pub fn scale_central_panel(app: &mut GraphEditorApp, ui: &mut egui::Ui) { 27 | let input = ui.input(|i| i.clone()); 28 | 29 | // スクロールに対応 30 | if let Some(pos) = input.pointer.hover_pos() { 31 | let scroll_delta = input.smooth_scroll_delta.y; 32 | 33 | // 現在のscaleの逆数倍で変化させる 34 | let cur_affine = app.graph.affine.borrow().to_owned(); 35 | 36 | let cur_scale = cur_affine.scale_x(); 37 | let scale = 1.0 + app.config.scale_delta * scroll_delta / cur_scale; 38 | 39 | if let Some(inv) = cur_affine.inverse() { 40 | // 中心の調整 41 | let center = pos.applied(&inv); 42 | 43 | // アフィン変換の生成 44 | let affine = Affine2D::from_center_and_scale(center, scale); 45 | 46 | if let Some(res) = 47 | cur_affine.try_compose(&affine, app.config.scale_min, app.config.scale_max) 48 | { 49 | *app.graph.affine.borrow_mut() = res; 50 | } 51 | } 52 | } 53 | 54 | // 2本指ジェスチャーに対応 55 | if let Some(multitouch) = input.multi_touch() { 56 | let scale = multitouch.zoom_delta; 57 | 58 | let cur_affine = app.graph.affine.borrow().to_owned(); 59 | 60 | if let Some(inv) = cur_affine.inverse() { 61 | // 中心の調整 62 | let center = multitouch.center_pos.applied(&inv); 63 | 64 | // アフィン変換の生成 65 | let affine = Affine2D::from_center_and_scale(center, scale); 66 | 67 | if let Some(res) = 68 | cur_affine.try_compose(&affine, app.config.scale_min, app.config.scale_max) 69 | { 70 | *app.graph.affine.borrow_mut() = res; 71 | } 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/components/graph_io.rs: -------------------------------------------------------------------------------- 1 | use egui::Context; 2 | 3 | use crate::{graph::BaseGraph, GraphEditorApp}; 4 | 5 | /// グラフのエンコードを表示する 6 | pub fn draw_graph_io(app: &mut GraphEditorApp, ctx: &Context) { 7 | if !app.hovered_on_input_window { 8 | app.input_text = app.graph.encode(app.zero_indexed) 9 | } 10 | 11 | // テキストの表示 12 | egui::Window::new("Graph Input") 13 | .collapsible(false) 14 | .default_width(150.0) 15 | .show(ctx, |ui| { 16 | // カーソルがあるか判定 17 | app.hovered_on_input_window = ui.rect_contains_pointer(ui.max_rect()); 18 | 19 | egui::Frame::default() 20 | .inner_margin(egui::Margin::same(10)) 21 | .show(ui, |ui| { 22 | ui.horizontal(|ui| { 23 | if ui 24 | .button( 25 | egui::RichText::new("Copy").size(app.config.menu_font_size_normal), 26 | ) 27 | .clicked() 28 | { 29 | ctx.copy_text(app.input_text.clone()); 30 | } 31 | 32 | if ui 33 | .button( 34 | egui::RichText::new("Apply").size(app.config.menu_font_size_normal), 35 | ) 36 | .clicked() 37 | { 38 | let new_graph = BaseGraph::parse(&app.input_text, app.zero_indexed) 39 | .and_then(|base| { 40 | app.graph.rebuild_from_basegraph( 41 | app.config.visualizer.as_ref(), 42 | base, 43 | ctx.used_size(), 44 | ) 45 | }); 46 | 47 | match new_graph { 48 | Ok(_) => { 49 | app.is_animated = true; 50 | } 51 | Err(err) => { 52 | app.error_message = Some(err.to_string()); 53 | } 54 | } 55 | } 56 | }); 57 | ui.separator(); 58 | 59 | // コード形式で表示 60 | if ui.code_editor(&mut app.input_text).has_focus() { 61 | app.hovered_on_input_window = true; 62 | } 63 | }); 64 | }); 65 | } 66 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![warn(clippy::all, rust_2018_idioms)] 2 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release 3 | 4 | // When compiling natively: 5 | #[cfg(not(target_arch = "wasm32"))] 6 | fn main() -> eframe::Result { 7 | env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`). 8 | 9 | let native_options = eframe::NativeOptions { 10 | viewport: egui::ViewportBuilder::default() 11 | .with_inner_size([400.0, 300.0]) 12 | .with_min_inner_size([300.0, 220.0]) 13 | .with_icon( 14 | // NOTE: Adding an icon is optional 15 | eframe::icon_data::from_png_bytes(&include_bytes!("../assets/icon-256.png")[..]) 16 | .expect("Failed to load icon"), 17 | ), 18 | ..Default::default() 19 | }; 20 | eframe::run_native( 21 | "Graph Editor", 22 | native_options, 23 | Box::new(|_cc| Ok(Box::new(graph_editor::GraphEditorApp::default()))), 24 | ) 25 | } 26 | 27 | // When compiling to web using trunk: 28 | #[cfg(target_arch = "wasm32")] 29 | fn main() { 30 | use eframe::wasm_bindgen::JsCast as _; 31 | 32 | // Redirect `log` message to `console.log` and friends: 33 | eframe::WebLogger::init(log::LevelFilter::Debug).ok(); 34 | 35 | let web_options = eframe::WebOptions::default(); 36 | 37 | wasm_bindgen_futures::spawn_local(async { 38 | let document = web_sys::window() 39 | .expect("No window") 40 | .document() 41 | .expect("No document"); 42 | 43 | let canvas = document 44 | .get_element_by_id("the_canvas_id") 45 | .expect("Failed to find the_canvas_id") 46 | .dyn_into::() 47 | .expect("the_canvas_id was not a HtmlCanvasElement"); 48 | 49 | let start_result = eframe::WebRunner::new() 50 | .start( 51 | canvas, 52 | web_options, 53 | Box::new(|_cc| Ok(Box::new(graph_editor::GraphEditorApp::default()))), 54 | ) 55 | .await; 56 | 57 | // Remove the loading text and spinner: 58 | if let Some(loading_text) = document.get_element_by_id("loading_text") { 59 | match start_result { 60 | Ok(_) => { 61 | loading_text.remove(); 62 | } 63 | Err(e) => { 64 | loading_text.set_inner_html( 65 | "

The app has crashed. See the developer console for details.

", 66 | ); 67 | panic!("Failed to start eframe: {e:?}"); 68 | } 69 | } 70 | } 71 | }); 72 | } 73 | -------------------------------------------------------------------------------- /src/math/bezier.rs: -------------------------------------------------------------------------------- 1 | use super::newton::newton_method; 2 | 3 | pub fn calc_bezier_control_point( 4 | start: egui::Pos2, 5 | end: egui::Pos2, 6 | distance: f32, 7 | is_clockwise: bool, 8 | ) -> egui::Pos2 { 9 | let mid = start + (end - start) / 2.0; 10 | let dir = (end - start).normalized().rot90(); 11 | 12 | mid + dir * if is_clockwise { distance } else { -distance } 13 | } 14 | 15 | /// ベジェ曲線 16 | pub fn bezier_curve(start: egui::Pos2, control: egui::Pos2, end: egui::Pos2, t: f32) -> egui::Pos2 { 17 | let x = (1.0 - t).powf(2.0) * start.x + 2.0 * t * (1.0 - t) * control.x + t.powf(2.0) * end.x; 18 | let y = (1.0 - t).powf(2.0) * start.y + 2.0 * t * (1.0 - t) * control.y + t.powf(2.0) * end.y; 19 | egui::Pos2::new(x, y) 20 | } 21 | 22 | /// ベジェ曲線のパラメータ`t`における微分 23 | pub fn d_bezier_dt(start: egui::Pos2, control: egui::Pos2, end: egui::Pos2, t: f32) -> egui::Vec2 { 24 | let mt = 1.0 - t; 25 | let dx = 2.0 * mt * (control.x - start.x) + 2.0 * t * (end.x - control.x); 26 | let dy = 2.0 * mt * (control.y - start.y) + 2.0 * t * (end.y - control.y); 27 | egui::Vec2::new(dx, dy) 28 | } 29 | 30 | /// ベジェ曲線の2階微分 31 | pub fn d2_bezier_dt2(start: egui::Pos2, control: egui::Pos2, end: egui::Pos2) -> egui::Vec2 { 32 | control.to_vec2() - 2.0 * start.to_vec2() + end.to_vec2() 33 | } 34 | 35 | /// ベジェ曲線と円の交点を計算 36 | /// 37 | /// ### Parameters 38 | /// - `bezier_start`: ベジェ曲線の始点 39 | /// - `bezier_control`: ベジェ曲線の制御点 40 | /// - `bezier_end`: ベジェ曲線の終点 41 | /// - `circle_center`: 円の中心 42 | /// - `circle_radius`: 円の半径 43 | /// 44 | /// ### Returns 45 | /// 交点が存在する場合はその座標と向き,存在しない場合は `None` 46 | pub fn calc_intersection_of_bezier_and_circle( 47 | bezier_start: egui::Pos2, 48 | bezier_control: egui::Pos2, 49 | bezier_end: egui::Pos2, 50 | circle_center: egui::Pos2, 51 | circle_radius: f32, 52 | ) -> Option<(egui::Pos2, egui::Vec2)> { 53 | let (x0, y0) = (bezier_start.x, bezier_start.y); 54 | let (x1, y1) = (bezier_control.x, bezier_control.y); 55 | let (x2, y2) = (bezier_end.x, bezier_end.y); 56 | let (xc, yc) = (circle_center.x, circle_center.y); 57 | let r = circle_radius; 58 | 59 | let bezier = 60 | |t: f32| -> egui::Pos2 { bezier_curve(bezier_start, bezier_control, bezier_end, t) }; 61 | 62 | let f = |t: f32| -> f32 { 63 | let pos = bezier(t); 64 | -r.powf(2.0) + (pos.x - xc).powf(2.0) + (pos.y - yc).powf(2.0) 65 | }; 66 | 67 | // df/dt (project://memo/intersection_of_bezier_and_circle.py で導出) 68 | let df = |t: f32| -> f32 { 69 | (-4.0 * t * x1 + 4.0 * t * x2 + 2.0 * x0 * (2.0 * t - 2.0) + 2.0 * x1 * (2.0 - 2.0 * t)) 70 | * (t.powf(2.0) * x2 + t * x1 * (2.0 - 2.0 * t) + x0 * (1.0 - t).powf(2.0) - xc) 71 | + (-4.0 * t * y1 72 | + 4.0 * t * y2 73 | + 2.0 * y0 * (2.0 * t - 2.0) 74 | + 2.0 * y1 * (2.0 - 2.0 * t)) 75 | * (t.powf(2.0) * y2 + t * y1 * (2.0 - 2.0 * t) + y0 * (1.0 - t).powf(2.0) - yc) 76 | }; 77 | 78 | let t = newton_method(f, df, 0.5, 1e-6, 10)?; 79 | 80 | (0.0..1.0).contains(&t).then(|| { 81 | ( 82 | bezier(t), 83 | d_bezier_dt(bezier_start, bezier_control, bezier_end, t), 84 | ) 85 | }) 86 | } 87 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use egui::Color32; 2 | 3 | use crate::graph::{simulation_methods, visualize_methods, Simulator, Visualizer}; 4 | 5 | /// バージョン情報 6 | pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); 7 | 8 | /// 全体の設定 9 | pub struct AppConfig { 10 | pub bg_color: Color32, 11 | pub vertex_radius: f32, 12 | pub vertex_stroke: f32, 13 | pub vertex_color_outline: Color32, 14 | pub vertex_color_normal: Color32, 15 | pub vertex_color_dragged: Color32, 16 | pub vertex_color_selected: Color32, 17 | pub vertex_font_size: f32, 18 | pub vertex_font_color: Color32, 19 | pub edge_color_normal: Color32, 20 | pub edge_color_hover: Color32, 21 | pub edge_arrow_length: f32, 22 | pub edge_arrow_width: f32, 23 | pub edge_bezier_distance: f32, 24 | pub edge_stroke: f32, 25 | pub menu_font_size_normal: f32, 26 | pub menu_font_size_mini: f32, 27 | pub footer_font_size: f32, 28 | /// 最大倍率 29 | pub scale_max: f32, 30 | /// 最小倍率 31 | pub scale_min: f32, 32 | /// 倍率の刻み 33 | pub scale_delta: f32, 34 | /// 可視化アルゴリズム 35 | pub visualizer: Box, 36 | /// シミュレーションアルゴリズム 37 | pub simulator: Box, 38 | } 39 | 40 | impl Default for AppConfig { 41 | fn default() -> Self { 42 | Self { 43 | bg_color: Color32::from_rgb(230, 230, 230), 44 | vertex_radius: 36.0, 45 | vertex_stroke: 3.0, 46 | vertex_color_outline: Color32::from_rgb(150, 150, 150), 47 | vertex_color_normal: Color32::WHITE, 48 | vertex_color_dragged: Color32::from_rgb(200, 100, 100), 49 | vertex_color_selected: Color32::from_rgb(100, 200, 100), 50 | vertex_font_size: 40.0, 51 | vertex_font_color: Color32::BLACK, 52 | edge_color_normal: Color32::from_rgb(100, 100, 100), 53 | edge_color_hover: Color32::from_rgb(200, 100, 100), 54 | edge_stroke: 6.0, 55 | edge_arrow_length: 18.0, 56 | edge_arrow_width: 9.0, 57 | edge_bezier_distance: 50.0, 58 | menu_font_size_normal: 20.0, 59 | menu_font_size_mini: 15.0, 60 | footer_font_size: 13.0, 61 | scale_max: 3.0, 62 | scale_min: 0.1, 63 | scale_delta: 0.002, 64 | visualizer: Box::new(visualize_methods::HillClimbing(1_000)), 65 | simulator: Box::new(simulation_methods::ForceDirectedModel { 66 | config: SimulateConfig::default(), 67 | }), 68 | } 69 | } 70 | } 71 | 72 | /// シミュレーションの設定 73 | pub struct SimulateConfig { 74 | /// クーロン定数 75 | pub c: f32, 76 | /// ばね定数 77 | pub k: f32, 78 | /// ばねの自然長 79 | pub l: f32, 80 | /// 減衰定数 81 | pub h: f32, 82 | /// 頂点の重さ 83 | pub m: f32, 84 | /// 最大速度 85 | pub max_v: f32, 86 | /// 微小時間 87 | pub dt: f32, 88 | } 89 | 90 | impl Default for SimulateConfig { 91 | fn default() -> Self { 92 | Self { 93 | c: 2e5, 94 | k: 7.0, 95 | l: 180.0, 96 | h: 0.73, 97 | m: 10.0, 98 | max_v: 100.0, 99 | dt: 0.2, 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/graph/visualizer.rs: -------------------------------------------------------------------------------- 1 | /// 可視化を行う 2 | pub trait Visualizer { 3 | /// グラフ G = (V,E) が与えられたとき, 4 | /// 頂点から 2 次元平面への写像 f: V → (0,1)^2 を構成する. 5 | fn resolve_vertex_position(&self, n: usize, edges: &[(usize, usize)]) -> Vec; 6 | } 7 | 8 | pub mod visualize_methods { 9 | #![allow(dead_code)] 10 | 11 | /// [0,1]^2 から一様ランダムにサンプリングする 12 | fn sample_point() -> egui::Vec2 { 13 | egui::vec2(rand::random::(), rand::random::()) 14 | } 15 | 16 | /// 2 点の外積を計算する 17 | fn cross(a: egui::Vec2, b: egui::Vec2) -> f32 { 18 | a.x * b.y - a.y * b.x 19 | } 20 | 21 | /// 線分同士の交差判定 22 | fn is_crossing((p1, q1): (egui::Vec2, egui::Vec2), (p2, q2): (egui::Vec2, egui::Vec2)) -> bool { 23 | let d1 = cross(q1 - p1, p2 - p1); 24 | let d2 = cross(q1 - p1, q2 - p1); 25 | let d3 = cross(q2 - p2, p1 - p2); 26 | let d4 = cross(q2 - p2, q1 - p2); 27 | 28 | d1 * d2 < 0.0 && d3 * d4 < 0.0 29 | } 30 | 31 | /// 辺の重なりの回数を数える 32 | /// - 計算量: O(m^2) 33 | fn count_edge_crossing(positions: &[egui::Vec2], edges: &[(usize, usize)]) -> usize { 34 | let m = edges.len(); 35 | 36 | let mut count = 0; 37 | 38 | for i in 0..m { 39 | for j in i + 1..m { 40 | let (u, v) = edges[i]; 41 | let (w, x) = edges[j]; 42 | 43 | let p1 = positions[u]; 44 | let q1 = positions[v]; 45 | let p2 = positions[w]; 46 | let q2 = positions[x]; 47 | 48 | if is_crossing((p1, q1), (p2, q2)) { 49 | count += 1; 50 | } 51 | } 52 | } 53 | 54 | count 55 | } 56 | 57 | // -------------------- Visualizer Methods -------------------- 58 | /// 一様ランダムに各頂点の座標を選択する. 59 | pub struct Naive; 60 | 61 | impl super::Visualizer for Naive { 62 | fn resolve_vertex_position(&self, n: usize, _edges: &[(usize, usize)]) -> Vec { 63 | (0..n).map(|_| sample_point()).collect() 64 | } 65 | } 66 | 67 | /// 辺の重なりが減るように山登り法を用いて配置する. 68 | /// - `max_iter`: 最大反復回数 69 | pub struct HillClimbing(pub usize); 70 | 71 | impl super::Visualizer for HillClimbing { 72 | fn resolve_vertex_position(&self, n: usize, edges: &[(usize, usize)]) -> Vec { 73 | if n == 0 { 74 | return vec![]; 75 | } 76 | 77 | let initial_positions = (0..n).map(|_| sample_point()).collect::>(); 78 | 79 | let mut best_positions = initial_positions.clone(); 80 | let mut best_crossing = count_edge_crossing(&best_positions, edges); 81 | 82 | for _ in 0..self.0 { 83 | let mut new_positions = best_positions.clone(); 84 | 85 | // 1 つの頂点をランダムに選択して座標を変更する 86 | let i = rand::random::() % n; 87 | new_positions[i] = sample_point(); 88 | 89 | // 辺の重なりが減るような配置を選択する 90 | let new_crossing = count_edge_crossing(&new_positions, edges); 91 | 92 | if new_crossing < best_crossing { 93 | best_positions = new_positions; 94 | best_crossing = new_crossing; 95 | } 96 | } 97 | 98 | best_positions 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/app.rs: -------------------------------------------------------------------------------- 1 | use eframe::egui; 2 | 3 | use crate::components::{ 4 | draw_central_panel, draw_edit_menu, draw_error_modal, draw_footer, draw_graph_io, 5 | draw_top_panel, PanelTabState, 6 | }; 7 | use crate::config::AppConfig; 8 | use crate::graph::Graph; 9 | use crate::mode::EditMode; 10 | use crate::update::request_repaint; 11 | 12 | pub struct GraphEditorApp { 13 | pub graph: Graph, 14 | pub is_animated: bool, 15 | pub last_mouse_pos: Option, 16 | pub next_z_index: u32, 17 | pub edit_mode: EditMode, 18 | pub zero_indexed: bool, 19 | pub hovered_on_top_panel: bool, 20 | pub hovered_on_menu_window: bool, 21 | pub hovered_on_input_window: bool, 22 | pub config: AppConfig, 23 | pub input_text: String, 24 | pub error_message: Option, 25 | pub panel_tab: PanelTabState, 26 | } 27 | 28 | impl GraphEditorApp { 29 | pub fn new() -> Self { 30 | Self::default() 31 | } 32 | 33 | pub fn deselect_all_vertices_edges(&mut self) { 34 | for vertex in self.graph.vertices_mut() { 35 | vertex.is_pressed = false; 36 | vertex.is_selected = false; 37 | } 38 | for edge in self.graph.edges_mut() { 39 | edge.is_pressed = false; 40 | } 41 | } 42 | 43 | pub fn switch_normal_mode(&mut self) { 44 | self.deselect_all_vertices_edges(); 45 | self.edit_mode = EditMode::default_normal(); 46 | } 47 | 48 | pub fn switch_add_vertex_mode(&mut self) { 49 | self.deselect_all_vertices_edges(); 50 | self.edit_mode = EditMode::default_add_vertex(); 51 | } 52 | 53 | pub fn switch_add_edge_mode(&mut self) { 54 | self.deselect_all_vertices_edges(); 55 | self.edit_mode = EditMode::default_add_edge(); 56 | } 57 | 58 | pub fn switch_delete_mode(&mut self) { 59 | self.deselect_all_vertices_edges(); 60 | self.edit_mode = EditMode::default_delete(); 61 | } 62 | } 63 | 64 | impl Default for GraphEditorApp { 65 | fn default() -> Self { 66 | Self { 67 | graph: Graph::default(), 68 | is_animated: true, 69 | last_mouse_pos: None, 70 | next_z_index: 2, 71 | edit_mode: EditMode::default_normal(), 72 | zero_indexed: false, 73 | hovered_on_top_panel: false, 74 | hovered_on_menu_window: false, 75 | hovered_on_input_window: false, 76 | config: AppConfig::default(), 77 | input_text: String::new(), 78 | error_message: None, 79 | panel_tab: PanelTabState::default(), 80 | } 81 | } 82 | } 83 | 84 | impl eframe::App for GraphEditorApp { 85 | fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { 86 | // トップパネル(タブバー)を描画 87 | draw_top_panel(self, ctx); // ★ 追加 88 | 89 | // メイン領域を描画 90 | draw_central_panel(self, ctx); 91 | 92 | // 現在選択されているタブに応じてサイドパネルの内容を切り替える 93 | if self.panel_tab.edit_menu { 94 | // 編集メニューを描画 95 | draw_edit_menu(self, ctx); 96 | } 97 | if self.panel_tab.graph_io { 98 | // グラフの入力を描画 99 | draw_graph_io(self, ctx); 100 | } 101 | 102 | // フッターを描画 103 | draw_footer(self, ctx); 104 | 105 | // エラーメッセージを描画 106 | draw_error_modal(self, ctx); 107 | 108 | // 再描画 109 | request_repaint(self, ctx); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/math/affine.rs: -------------------------------------------------------------------------------- 1 | //! 2次元Affine変換 2 | //! 3 | //! 2次元平面上の点の拡大,縮小,平行移動 4 | 5 | #![allow(clippy::needless_range_loop)] 6 | 7 | use std::ops::{Mul, MulAssign}; 8 | 9 | use num_traits::One; 10 | 11 | /// 2次元アフィン変換の0元行列 12 | const AFFINE2D_ZERO: [[f32; 3]; 3] = [[0.0; 3]; 3]; 13 | 14 | /// 2次元アフィン変換の1元行列 15 | const AFFINE2D_ONE: [[f32; 3]; 3] = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; 16 | 17 | /// アフィン変換 18 | #[derive(Debug, Clone, Copy)] 19 | pub struct Affine2D(pub [[f32; 3]; 3]); 20 | 21 | impl Affine2D { 22 | /// アフィン変換の平行移動 23 | pub fn from_transition(vec2: egui::Vec2) -> Self { 24 | Self([[1.0, 0.0, vec2.x], [0.0, 1.0, vec2.y], [0.0, 0.0, 1.0]]) 25 | } 26 | 27 | /// アフィン変換の拡大縮小 28 | /// - `center`: 拡大縮小の中心 29 | /// - `scale`: 拡大縮小の倍率 30 | pub fn from_center_and_scale(center: egui::Pos2, scale: f32) -> Self { 31 | Self([ 32 | [scale, 0.0, center.x * (1.0 - scale)], 33 | [0.0, scale, center.y * (1.0 - scale)], 34 | [0.0, 0.0, 1.0], 35 | ]) 36 | } 37 | 38 | /// 並行移動成分を取得 39 | pub fn translation(&self) -> egui::Vec2 { 40 | egui::vec2(self.0[0][2], self.0[1][2]) 41 | } 42 | 43 | /// スケールを取得 44 | pub fn scale_x(&self) -> f32 { 45 | self.0[0][0] 46 | } 47 | 48 | /// アフィン変換を合成する 49 | /// - scale の最小値,最大値の範囲を超えない操作のみ行う 50 | pub fn try_compose(&self, rhs: &Affine2D, scale_min: f32, scale_max: f32) -> Option { 51 | let composed = *self * *rhs; 52 | let scale = composed.scale_x(); 53 | 54 | (scale_min <= scale && scale <= scale_max).then_some(composed) 55 | } 56 | 57 | /// アフィン変換の逆元 58 | pub fn inverse(&self) -> Option { 59 | let (a, b, tx) = (self.0[0][0], self.0[0][1], self.0[0][2]); 60 | let (c, d, ty) = (self.0[1][0], self.0[1][1], self.0[1][2]); 61 | 62 | // 行列式を計算 63 | let det = a * d - b * c; 64 | if det.abs() < 1e-6 { 65 | // 正則でない場合 66 | return None; 67 | } 68 | 69 | let inv_det = 1.0 / det; 70 | 71 | // 線形部分の逆行列 72 | let a_inv = d * inv_det; 73 | let b_inv = -b * inv_det; 74 | let c_inv = -c * inv_det; 75 | let d_inv = a * inv_det; 76 | 77 | // 平行移動の逆変換 78 | let tx_inv = -(a_inv * tx + b_inv * ty); 79 | let ty_inv = -(c_inv * tx + d_inv * ty); 80 | 81 | Some(Self([ 82 | [a_inv, b_inv, tx_inv], 83 | [c_inv, d_inv, ty_inv], 84 | [0.0, 0.0, 1.0], 85 | ])) 86 | } 87 | } 88 | 89 | impl Mul for Affine2D { 90 | type Output = Self; 91 | fn mul(self, rhs: Self) -> Self::Output { 92 | let mut res = AFFINE2D_ZERO; 93 | for i in 0..3 { 94 | for j in 0..3 { 95 | for k in 0..3 { 96 | res[i][j] += self.0[i][k] * rhs.0[k][j]; 97 | } 98 | } 99 | } 100 | Self(res) 101 | } 102 | } 103 | 104 | impl MulAssign for Affine2D { 105 | fn mul_assign(&mut self, rhs: Self) { 106 | *self = *self * rhs; 107 | } 108 | } 109 | 110 | impl One for Affine2D { 111 | fn one() -> Self { 112 | Self(AFFINE2D_ONE) 113 | } 114 | } 115 | 116 | /// アフィン変換を適用する 117 | pub trait ApplyAffine: Sized { 118 | /// アフィン変換を適用した結果を取得する 119 | fn applied(&self, affine: &Affine2D) -> Self; 120 | /// アフィン変換を適用する 121 | fn apply(&mut self, affine: &Affine2D) { 122 | *self = self.applied(affine); 123 | } 124 | } 125 | 126 | impl ApplyAffine for egui::Vec2 { 127 | fn applied(&self, affine: &Affine2D) -> Self { 128 | let new_x = affine.0[0][0] * self.x + affine.0[0][1] * self.y + affine.0[0][2]; 129 | let new_y = affine.0[1][0] * self.x + affine.0[1][1] * self.y + affine.0[1][2]; 130 | egui::vec2(new_x, new_y) 131 | } 132 | } 133 | 134 | impl ApplyAffine for egui::Pos2 { 135 | fn applied(&self, affine: &Affine2D) -> Self { 136 | let new_x = affine.0[0][0] * self.x + affine.0[0][1] * self.y + affine.0[0][2]; 137 | let new_y = affine.0[1][0] * self.x + affine.0[1][1] * self.y + affine.0[1][2]; 138 | egui::pos2(new_x, new_y) 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | paths: 4 | - "src/**" 5 | - "Cargo.toml" 6 | - "Cargo.lock" 7 | pull_request: 8 | paths: 9 | - "src/**" 10 | - "Cargo.toml" 11 | - "Cargo.lock" 12 | workflow_dispatch: 13 | 14 | name: CI 15 | 16 | env: 17 | RUSTFLAGS: -D warnings 18 | RUSTDOCFLAGS: -D warnings 19 | 20 | jobs: 21 | check: 22 | name: Check 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v4 26 | - uses: actions-rs/toolchain@v1 27 | with: 28 | profile: minimal 29 | toolchain: stable 30 | override: true 31 | - uses: actions-rs/cargo@v1 32 | with: 33 | command: check 34 | args: --all-features 35 | 36 | check_wasm: 37 | name: Check wasm32 38 | runs-on: ubuntu-latest 39 | steps: 40 | - uses: actions/checkout@v4 41 | - uses: actions-rs/toolchain@v1 42 | with: 43 | profile: minimal 44 | toolchain: stable 45 | target: wasm32-unknown-unknown 46 | override: true 47 | - uses: actions-rs/cargo@v1 48 | with: 49 | command: check 50 | args: --all-features --lib --target wasm32-unknown-unknown 51 | 52 | test: 53 | name: Test Suite 54 | runs-on: ubuntu-latest 55 | steps: 56 | - uses: actions/checkout@v4 57 | - uses: actions-rs/toolchain@v1 58 | with: 59 | profile: minimal 60 | toolchain: stable 61 | override: true 62 | - run: sudo apt-get install libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev libssl-dev 63 | - uses: actions-rs/cargo@v1 64 | with: 65 | command: test 66 | args: --lib 67 | 68 | fmt: 69 | name: Rustfmt 70 | runs-on: ubuntu-latest 71 | steps: 72 | - uses: actions/checkout@v4 73 | - uses: actions-rs/toolchain@v1 74 | with: 75 | profile: minimal 76 | toolchain: stable 77 | override: true 78 | components: rustfmt 79 | - uses: actions-rs/cargo@v1 80 | with: 81 | command: fmt 82 | args: --all -- --check 83 | 84 | clippy: 85 | name: Clippy 86 | runs-on: ubuntu-latest 87 | steps: 88 | - uses: actions/checkout@v4 89 | - uses: actions-rs/toolchain@v1 90 | with: 91 | profile: minimal 92 | toolchain: stable 93 | override: true 94 | components: clippy 95 | - uses: actions-rs/cargo@v1 96 | with: 97 | command: clippy 98 | args: -- -D warnings 99 | 100 | trunk: 101 | name: trunk 102 | runs-on: ubuntu-latest 103 | steps: 104 | - uses: actions/checkout@v4 105 | - uses: actions-rs/toolchain@v1 106 | with: 107 | profile: minimal 108 | toolchain: 1.82.0 109 | target: wasm32-unknown-unknown 110 | override: true 111 | - name: Download and install Trunk binary 112 | run: wget -qO- https://github.com/thedodd/trunk/releases/latest/download/trunk-x86_64-unknown-linux-gnu.tar.gz | tar -xzf- 113 | - name: Build 114 | run: ./trunk build 115 | 116 | # build: 117 | # runs-on: ${{ matrix.os }} 118 | # strategy: 119 | # fail-fast: false 120 | # matrix: 121 | # include: 122 | # - os: macos-latest 123 | # TARGET: aarch64-apple-darwin 124 | 125 | # - os: ubuntu-latest 126 | # TARGET: aarch64-unknown-linux-gnu 127 | 128 | # - os: ubuntu-latest 129 | # TARGET: armv7-unknown-linux-gnueabihf 130 | 131 | # - os: ubuntu-latest 132 | # TARGET: x86_64-unknown-linux-gnu 133 | 134 | # - os: windows-latest 135 | # TARGET: x86_64-pc-windows-msvc 136 | # EXTENSION: .exe 137 | 138 | # steps: 139 | # - name: Building ${{ matrix.TARGET }} 140 | # run: echo "${{ matrix.TARGET }}" 141 | 142 | # - uses: actions/checkout@master 143 | # - name: Install build dependencies - Rustup 144 | # run: | 145 | # curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- --default-toolchain stable --profile default --target ${{ matrix.TARGET }} -y 146 | # echo "$HOME/.cargo/bin" >> $GITHUB_PATH 147 | 148 | # # For linux, it's necessary to use cross from the git repository to avoid glibc problems 149 | # # Ref: https://github.com/cross-rs/cross/issues/1510 150 | # - name: Install cross for linux 151 | # if: contains(matrix.TARGET, 'linux') 152 | # run: | 153 | # cargo install cross --git https://github.com/cross-rs/cross --rev 1b8cf50d20180c1a394099e608141480f934b7f7 154 | 155 | # - name: Install cross for mac and windows 156 | # if: ${{ !contains(matrix.TARGET, 'linux') }} 157 | # run: | 158 | # cargo install cross 159 | 160 | # - name: Build 161 | # run: | 162 | # cross build --verbose --release --target=${{ matrix.TARGET }} 163 | 164 | # - name: Rename 165 | # run: cp target/${{ matrix.TARGET }}/release/graph-editor${{ matrix.EXTENSION }} graph-editor-${{ matrix.TARGET }}${{ matrix.EXTENSION }} 166 | 167 | # - uses: actions/upload-artifact@master 168 | # with: 169 | # name: graph-editor-${{ matrix.TARGET }}${{ matrix.EXTENSION }} 170 | # path: graph-editor-${{ matrix.TARGET }}${{ matrix.EXTENSION }} 171 | 172 | # - uses: svenstaro/upload-release-action@v2 173 | # name: Upload binaries to release 174 | # if: ${{ github.event_name == 'push' }} 175 | # with: 176 | # repo_token: ${{ secrets.GITHUB_TOKEN }} 177 | # file: graph-editor-${{ matrix.TARGET }}${{ matrix.EXTENSION }} 178 | # asset_name: graph-editor-${{ matrix.TARGET }}${{ matrix.EXTENSION }} 179 | # tag: ${{ github.ref }} 180 | # prerelease: ${{ !startsWith(github.ref, 'refs/tags/') }} 181 | # overwrite: true 182 | -------------------------------------------------------------------------------- /src/components/edit_menu.rs: -------------------------------------------------------------------------------- 1 | use egui::Context; 2 | 3 | use crate::{mode::EditMode, GraphEditorApp}; 4 | 5 | /// 編集メニューを表示する 6 | pub fn draw_edit_menu(app: &mut GraphEditorApp, ctx: &Context) { 7 | egui::SidePanel::left("Menu") 8 | .min_width(200.0) 9 | .show(ctx, |ui| { 10 | // カーソルがあるか判定 11 | app.hovered_on_menu_window = ui.rect_contains_pointer(ui.max_rect()); 12 | 13 | egui::Frame::new() 14 | .inner_margin(egui::Margin::same(10)) 15 | .show(ui, |ui| { 16 | ui.vertical(|ui| { 17 | // モード切替 18 | ui.label( 19 | egui::RichText::new("Edit Mode").size(app.config.menu_font_size_mini), 20 | ); 21 | 22 | ui.radio_value( 23 | &mut app.edit_mode, 24 | EditMode::default_normal(), 25 | egui::RichText::new("Normal [Esc]") 26 | .size(app.config.menu_font_size_normal), 27 | ); 28 | ui.radio_value( 29 | &mut app.edit_mode, 30 | EditMode::default_add_vertex(), 31 | egui::RichText::new("Add Vertex [V]") 32 | .size(app.config.menu_font_size_normal), 33 | ); 34 | ui.radio_value( 35 | &mut app.edit_mode, 36 | EditMode::default_add_edge(), 37 | egui::RichText::new("Add Edge [E]") 38 | .size(app.config.menu_font_size_normal), 39 | ); 40 | ui.radio_value( 41 | &mut app.edit_mode, 42 | EditMode::default_delete(), 43 | egui::RichText::new("Delete [D]") 44 | .size(app.config.menu_font_size_normal), 45 | ); 46 | 47 | ui.separator(); 48 | 49 | // 0-indexed / 1-indexed の選択 50 | ui.label( 51 | egui::RichText::new("Indexing [1]") 52 | .size(app.config.menu_font_size_mini), 53 | ); 54 | 55 | ui.radio_value( 56 | &mut app.zero_indexed, 57 | true, 58 | egui::RichText::new("0-indexed").size(app.config.menu_font_size_normal), 59 | ); 60 | ui.radio_value( 61 | &mut app.zero_indexed, 62 | false, 63 | egui::RichText::new("1-indexed").size(app.config.menu_font_size_normal), 64 | ); 65 | 66 | ui.separator(); 67 | 68 | ui.label( 69 | egui::RichText::new("Direction [Shift + D]") 70 | .size(app.config.menu_font_size_mini), 71 | ); 72 | ui.radio_value( 73 | &mut app.graph.is_directed, 74 | false, 75 | egui::RichText::new("Undirected") 76 | .size(app.config.menu_font_size_normal), 77 | ); 78 | ui.radio_value( 79 | &mut app.graph.is_directed, 80 | true, 81 | egui::RichText::new("Directed").size(app.config.menu_font_size_normal), 82 | ); 83 | 84 | ui.separator(); 85 | 86 | ui.checkbox( 87 | &mut app.is_animated, 88 | egui::RichText::new("Animate [A]") 89 | .size(app.config.menu_font_size_normal), 90 | ); 91 | 92 | ui.separator(); 93 | 94 | // 補グラフを取る 95 | let complement_button = egui::Button::new( 96 | egui::RichText::new("Complement") 97 | .size(app.config.menu_font_size_normal), 98 | ); 99 | let complement_response = 100 | ui.add_enabled(!app.graph.is_directed, complement_button); 101 | 102 | if complement_response.clicked() { 103 | let complement = app.graph.calc_complement(); 104 | let new_graph_result = app.graph.rebuild_from_basegraph( 105 | app.config.visualizer.as_ref(), 106 | complement, 107 | ctx.used_size(), 108 | ); 109 | match new_graph_result { 110 | Ok(_) => { 111 | app.is_animated = true; 112 | } 113 | Err(err) => { 114 | app.error_message = Some(err.to_string()); 115 | } 116 | } 117 | } 118 | 119 | // 逆辺を張る 120 | let revert_button = egui::Button::new( 121 | egui::RichText::new("Revert Edge") 122 | .size(app.config.menu_font_size_normal), 123 | ); 124 | let revert_response = ui.add_enabled(app.graph.is_directed, revert_button); 125 | 126 | if revert_response.clicked() { 127 | let reverted = app.graph.calc_reverted(); 128 | let new_graph_result = app.graph.rebuild_from_basegraph( 129 | app.config.visualizer.as_ref(), 130 | reverted, 131 | ctx.used_size(), 132 | ); 133 | match new_graph_result { 134 | Ok(_) => { 135 | app.is_animated = true; 136 | } 137 | Err(err) => { 138 | app.error_message = Some(err.to_string()); 139 | } 140 | } 141 | } 142 | 143 | ui.separator(); 144 | 145 | // グラフのクリア 146 | if ui 147 | .button( 148 | egui::RichText::new("Clear All") 149 | .size(app.config.menu_font_size_normal), 150 | ) 151 | .clicked() 152 | { 153 | app.graph.clear(); 154 | app.next_z_index = 0; 155 | } 156 | }); 157 | }); 158 | }); 159 | } 160 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 12 | 13 | Graph Editor 14 | 18 | 22 | 23 | 24 | 25 | 26 | 27 | 31 | 35 | 39 | 40 | 41 | 42 | 43 | 47 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 67 | 73 | 79 | 85 | 91 | 92 | 93 | 94 | 99 | 104 | 105 | 191 | 192 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 |
205 |

Loading…

206 |
207 |
208 | 209 | 210 | 211 | 233 | 234 | 235 | 236 | 237 | -------------------------------------------------------------------------------- /src/graph/structures.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | cell::{Ref, RefCell}, 3 | collections::{HashMap, HashSet}, 4 | rc::Rc, 5 | }; 6 | 7 | use egui::Vec2; 8 | use num_traits::One; 9 | 10 | use crate::math::affine::{Affine2D, ApplyAffine}; 11 | 12 | use super::{BaseGraph, Visualizer}; 13 | 14 | #[derive(Debug, Clone)] 15 | pub struct Vertex { 16 | pub id: usize, 17 | pub position: egui::Pos2, 18 | pub velocity: egui::Vec2, 19 | pub drag: Affine2D, 20 | pub is_pressed: bool, 21 | pub is_selected: bool, 22 | pub z_index: u32, 23 | pub is_deleted: bool, 24 | pub affine: Rc>, 25 | } 26 | 27 | impl Vertex { 28 | pub fn get_position(&self) -> egui::Pos2 { 29 | self.position.applied(&self.affine.borrow()) 30 | } 31 | 32 | pub fn affine(&self) -> Ref<'_, Affine2D> { 33 | self.affine.borrow() 34 | } 35 | 36 | pub fn update_position(&mut self, new_position: egui::Pos2) { 37 | if let Some(inv) = self.affine.borrow().inverse() { 38 | self.position = new_position.applied(&inv); 39 | } 40 | } 41 | 42 | pub fn solve_drag_offset(&mut self) { 43 | self.position.apply(&self.drag); 44 | self.drag = Affine2D::one(); 45 | } 46 | } 47 | 48 | #[derive(Debug, Clone)] 49 | pub struct Edge { 50 | pub from: usize, 51 | pub to: usize, 52 | pub is_pressed: bool, 53 | pub is_deleted: bool, 54 | } 55 | 56 | impl Edge { 57 | pub fn new(from: usize, to: usize) -> Self { 58 | Self { 59 | from, 60 | to, 61 | is_pressed: false, 62 | is_deleted: false, 63 | } 64 | } 65 | } 66 | 67 | #[derive(Debug)] 68 | pub struct Graph { 69 | /// 有向グラフ / 無向グラフ 70 | pub is_directed: bool, 71 | /// 頂点集合に対するアフィン変換 72 | pub affine: Rc>, 73 | /// 頂点集合 74 | pub vertices: Vec, 75 | /// 辺集合 76 | pub edges: Vec, 77 | } 78 | 79 | impl Graph { 80 | /// 削除済みフラグが立っている頂点,辺を削除する 81 | pub fn restore_graph(&mut self) { 82 | // 削除済みフラグが立っている頂点を削除 83 | self.vertices.retain(|vertex| !vertex.is_deleted); 84 | 85 | // 頂点番号を振り直す 86 | let mut new_vertex_id = HashMap::new(); 87 | 88 | for (i, vertex) in self.vertices.iter_mut().enumerate() { 89 | new_vertex_id.insert(vertex.id, i); 90 | vertex.id = i; 91 | } 92 | 93 | // 辺の頂点番号を振り直す 94 | for edge in &mut self.edges { 95 | if let Some((new_from, new_to)) = new_vertex_id 96 | .get(&edge.from) 97 | .zip(new_vertex_id.get(&edge.to)) 98 | { 99 | edge.from = *new_from; 100 | edge.to = *new_to; 101 | } else { 102 | // 辺を削除 103 | edge.is_deleted = true; 104 | } 105 | } 106 | 107 | // 削除済みフラグが立っている辺を削除 108 | self.edges.retain(|edge| !edge.is_deleted); 109 | } 110 | 111 | pub fn edges(&self) -> &Vec { 112 | &self.edges 113 | } 114 | 115 | pub fn vertices_mut(&mut self) -> &mut Vec { 116 | &mut self.vertices 117 | } 118 | 119 | pub fn edges_mut(&mut self) -> &mut Vec { 120 | &mut self.edges 121 | } 122 | 123 | pub fn vertices_edges_mut(&mut self) -> (&mut Vec, &mut Vec) { 124 | (&mut self.vertices, &mut self.edges) 125 | } 126 | 127 | pub fn add_vertex(&mut self, position: egui::Pos2, z_index: u32) { 128 | let position = position - self.affine.borrow().translation(); 129 | 130 | self.vertices.push(Vertex { 131 | id: self.vertices.len(), 132 | position, 133 | velocity: Vec2::ZERO, 134 | is_pressed: false, 135 | drag: Affine2D::one(), 136 | is_selected: false, 137 | z_index, 138 | is_deleted: false, 139 | affine: self.affine.clone(), 140 | }); 141 | } 142 | 143 | /// 始点と終点が同じ辺が存在するか 144 | fn has_same_edge(is_directed: bool, edges: &[Edge], from: usize, to: usize) -> bool { 145 | edges.iter().any(|edge| { 146 | (edge.from, edge.to) == (from, to) || !is_directed && (edge.from, edge.to) == (to, from) 147 | }) 148 | } 149 | 150 | /// ユニークな辺を追加する.正常に追加された場合`true`を返す. 151 | pub fn add_unique_edge( 152 | is_directed: bool, 153 | edges: &mut Vec, 154 | from: usize, 155 | to: usize, 156 | ) -> bool { 157 | if Self::has_same_edge(is_directed, edges, from, to) { 158 | false 159 | } else { 160 | edges.push(Edge::new(from, to)); 161 | true 162 | } 163 | } 164 | 165 | /// 隣接頂点のidを列挙する 166 | pub fn neighbor_vertices(&self, id: usize) -> impl Iterator { 167 | self.edges 168 | .iter() 169 | .filter_map(move |v| { 170 | if v.from == id { 171 | Some(v.to) 172 | } else if v.to == id { 173 | Some(v.from) 174 | } else { 175 | None 176 | } 177 | }) 178 | .filter_map(|id| self.vertices.iter().find(|v| v.id == id)) 179 | } 180 | 181 | pub fn clear(&mut self) { 182 | self.vertices.clear(); 183 | self.edges.clear(); 184 | } 185 | 186 | pub fn list_unique_edges(&self) -> Vec<(usize, usize)> { 187 | let mut seen = HashSet::new(); 188 | 189 | self.edges 190 | .iter() 191 | .filter_map(|e| { 192 | if !self.is_directed && seen.contains(&(e.to, e.from)) { 193 | None 194 | } else { 195 | seen.insert((e.from, e.to)); 196 | Some((e.from, e.to)) 197 | } 198 | }) 199 | .collect() 200 | } 201 | 202 | pub fn encode(&mut self, zero_indexed: bool) -> String { 203 | // 削除済み頂点を削除 204 | self.restore_graph(); 205 | 206 | let unique_edges = self.list_unique_edges(); 207 | let mut res = format!("{} {}", self.vertices.len(), unique_edges.len()); 208 | 209 | for (from, to) in unique_edges { 210 | res.push_str(&format!( 211 | "\n{} {}", 212 | if zero_indexed { from } else { from + 1 }, 213 | if zero_indexed { to } else { to + 1 } 214 | )); 215 | } 216 | 217 | res 218 | } 219 | 220 | /// グラフの補グラフを求める(無向グラフの場合のみ) 221 | pub fn calc_complement(&self) -> BaseGraph { 222 | debug_assert!(!self.is_directed); 223 | 224 | let n = self.vertices.len(); 225 | 226 | // 辺の存在性を反転 227 | let mut edge_existence = vec![vec![true; n]; n]; 228 | 229 | for edge in &self.edges { 230 | let u = edge.from; 231 | let v = edge.to; 232 | edge_existence[u][v] = false; 233 | edge_existence[v][u] = false; 234 | } 235 | 236 | BaseGraph { 237 | n, 238 | edges: (0..n) 239 | .flat_map(move |i| (i + 1..n).map(move |j| (i, j))) 240 | .filter(|&(u, v)| edge_existence[u][v]) 241 | .collect(), 242 | } 243 | } 244 | 245 | /// 逆辺を張ったグラフを求める(有向グラフの場合のみ) 246 | pub fn calc_reverted(&self) -> BaseGraph { 247 | debug_assert!(self.is_directed); 248 | 249 | BaseGraph { 250 | n: self.vertices.len(), 251 | edges: self.edges.iter().map(|e| (e.to, e.from)).collect(), 252 | } 253 | } 254 | 255 | /// グラフの入力からグラフを生成する 256 | pub fn rebuild_from_basegraph( 257 | &mut self, 258 | visualizer: &dyn Visualizer, 259 | BaseGraph { n, edges }: BaseGraph, 260 | window_size: egui::Vec2, 261 | ) -> anyhow::Result<()> { 262 | // グラフの初期化 263 | self.clear(); 264 | *self.affine.borrow_mut() = Affine2D::one(); 265 | 266 | // 頂点座標を適切な位置に(上下左右 10% の余白をもたせる) 267 | let adjust_to_window = |pos: egui::Vec2| -> egui::Pos2 { 268 | (pos * window_size * 0.8 + window_size * 0.1).to_pos2() 269 | }; 270 | 271 | // グラフの構築 272 | let new_vertices = visualizer 273 | .resolve_vertex_position(n, &edges) 274 | .into_iter() 275 | .enumerate() 276 | .map(|(id, pos)| Vertex { 277 | id, 278 | position: adjust_to_window(pos), 279 | velocity: egui::Vec2::ZERO, 280 | is_pressed: false, 281 | drag: Affine2D::one(), 282 | is_selected: false, 283 | z_index: 0, 284 | is_deleted: false, 285 | affine: self.affine.clone(), 286 | }); 287 | 288 | self.vertices.extend(new_vertices); 289 | 290 | let new_edges = edges.into_iter().map(|(from, to)| Edge::new(from, to)); 291 | 292 | self.edges.extend(new_edges); 293 | 294 | Ok(()) 295 | } 296 | } 297 | 298 | impl Default for Graph { 299 | fn default() -> Self { 300 | let affine = Rc::new(RefCell::new(Affine2D::one())); 301 | 302 | Self { 303 | is_directed: false, 304 | vertices: vec![ 305 | Vertex { 306 | id: 0, 307 | position: egui::pos2(400.0, 400.0), 308 | velocity: egui::Vec2::ZERO, 309 | is_pressed: false, 310 | drag: Affine2D::one(), 311 | is_selected: false, 312 | z_index: 0, 313 | is_deleted: false, 314 | affine: affine.clone(), 315 | }, 316 | Vertex { 317 | id: 1, 318 | position: egui::pos2(600.0, 400.0), 319 | velocity: egui::Vec2::ZERO, 320 | is_pressed: false, 321 | drag: Affine2D::one(), 322 | is_selected: false, 323 | z_index: 1, 324 | is_deleted: false, 325 | affine: affine.clone(), 326 | }, 327 | ], 328 | edges: vec![Edge { 329 | from: 0, 330 | to: 1, 331 | is_pressed: false, 332 | is_deleted: false, 333 | }], 334 | affine, 335 | } 336 | } 337 | } 338 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /src/components/central_panel.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use itertools::Itertools; 4 | 5 | use super::transition_and_scale::{drag_central_panel, scale_central_panel}; 6 | use crate::{ 7 | config::AppConfig, 8 | graph::Graph, 9 | math::{ 10 | affine::{Affine2D, ApplyAffine}, 11 | bezier::{ 12 | bezier_curve, calc_bezier_control_point, calc_intersection_of_bezier_and_circle, 13 | d2_bezier_dt2, d_bezier_dt, 14 | }, 15 | newton::newton_method, 16 | }, 17 | mode::EditMode, 18 | GraphEditorApp, 19 | }; 20 | 21 | /// メイン領域を描画 22 | pub fn draw_central_panel(app: &mut GraphEditorApp, ctx: &egui::Context) { 23 | egui::CentralPanel::default() 24 | .frame(egui::Frame::new().fill(app.config.bg_color)) 25 | .show(ctx, |ui| { 26 | // モード切替を行う 27 | change_edit_mode(app, ui); 28 | 29 | // ドラッグを行う 30 | drag_central_panel(app, ui); 31 | 32 | // スケールを行う 33 | scale_central_panel(app, ui); 34 | 35 | // クリックした位置に頂点を追加 36 | add_vertex(app, ui); 37 | 38 | // ペインターを取得 39 | let painter = ui.painter(); 40 | 41 | // 辺の描画 42 | draw_edges(app, ui, painter); 43 | 44 | // 頂点の描画 45 | draw_vertices(app, ui, painter); 46 | }); 47 | } 48 | 49 | /// モード切替の処理 50 | fn change_edit_mode(app: &mut GraphEditorApp, ui: &egui::Ui) { 51 | // 入力中はモード切替を行わない 52 | if app.hovered_on_input_window { 53 | return; 54 | } 55 | 56 | if ui.input(|i| i.key_pressed(egui::Key::Escape)) { 57 | // AddEdgeモードで,片方の頂点が選択済みの場合,選択状態を解除 58 | if let EditMode::AddEdge { 59 | from_vertex: ref mut from_vertex @ Some(from_vertex_id), 60 | .. 61 | } = app.edit_mode 62 | { 63 | if let Some(from_vertex) = app 64 | .graph 65 | .vertices_mut() 66 | .iter_mut() 67 | .find(|v| v.id == from_vertex_id) 68 | { 69 | from_vertex.is_selected = false; 70 | } 71 | *from_vertex = None; 72 | } else { 73 | app.switch_normal_mode(); 74 | } 75 | } 76 | if ui.input(|i| i.key_pressed(egui::Key::V)) { 77 | app.switch_add_vertex_mode(); 78 | } 79 | if ui.input(|i| i.key_pressed(egui::Key::E)) { 80 | app.switch_add_edge_mode(); 81 | } 82 | if ui.input(|i| i.key_pressed(egui::Key::D)) { 83 | // Shift + D で無向グラフ/有向グラフを切り替え 84 | if ui.input(|i| i.modifiers.shift) { 85 | app.graph.is_directed ^= true; 86 | } else { 87 | app.switch_delete_mode(); 88 | } 89 | } 90 | if ui.input(|i| i.key_pressed(egui::Key::Num1)) { 91 | app.zero_indexed ^= true; 92 | } 93 | if ui.input(|i| i.key_pressed(egui::Key::A)) { 94 | // A でグラフのシミュレーションを切り替え 95 | app.is_animated ^= true; 96 | } 97 | } 98 | 99 | /// クリックした位置に頂点を追加する 100 | fn add_vertex(app: &mut GraphEditorApp, ui: &egui::Ui) { 101 | // クリックした位置に頂点を追加する 102 | if app.edit_mode.is_add_vertex() 103 | && ui.input(|i| i.pointer.any_click()) 104 | && !app.hovered_on_top_panel 105 | && !app.hovered_on_menu_window 106 | && !app.hovered_on_input_window 107 | { 108 | if let Some(mouse_pos) = ui.input(|i| i.pointer.hover_pos()) { 109 | let affine = app.graph.affine.borrow().to_owned(); 110 | if let Some(inv) = affine.inverse() { 111 | let scaled_pos = mouse_pos.applied(&inv); 112 | let pos = scaled_pos + affine.translation(); 113 | 114 | app.graph.add_vertex(pos, app.next_z_index); 115 | app.next_z_index += 1; 116 | } 117 | } 118 | } 119 | } 120 | 121 | /// central_panel に辺を描画する 122 | fn draw_edges(app: &mut GraphEditorApp, ui: &egui::Ui, painter: &egui::Painter) { 123 | app.graph.restore_graph(); 124 | 125 | // 辺をカウントする 126 | let edge_count = app 127 | .graph 128 | .edges() 129 | .iter() 130 | .fold(HashMap::new(), |mut map, edge| { 131 | *map.entry((edge.from, edge.to)).or_insert(0) += 1; 132 | *map.entry((edge.to, edge.from)).or_insert(0) += 1; 133 | map 134 | }); 135 | 136 | let is_directed = app.graph.is_directed; 137 | let (vertices_mut, edges_mut) = app.graph.vertices_edges_mut(); 138 | 139 | for edge in edges_mut.iter_mut() { 140 | if let (Some(from_vertex), Some(to_vertex)) = ( 141 | vertices_mut.iter().find(|v| v.id == edge.from), 142 | vertices_mut.iter().find(|v| v.id == edge.to), 143 | ) { 144 | // ノーマルモードの場合,エッジの選択判定を行う 145 | if app.edit_mode.is_delete() { 146 | let mouse_pos = ui.input(|i| i.pointer.hover_pos()).unwrap_or_default(); 147 | 148 | // 端点との距離 149 | let distance_from_vertex = (mouse_pos - from_vertex.get_position()) 150 | .length() 151 | .min((mouse_pos - to_vertex.get_position()).length()); 152 | 153 | // カーソルが頂点上にあるかどうか 154 | let is_on_vertex = distance_from_vertex < app.config.vertex_radius; 155 | 156 | // マウスと辺の最近接点の距離 157 | let distance = if !is_directed || edge_count.get(&(edge.from, edge.to)) == Some(&1) 158 | { 159 | distance_from_edge_line( 160 | from_vertex.get_position(), 161 | to_vertex.get_position(), 162 | mouse_pos, 163 | ) 164 | } else { 165 | distance_from_edge_bezier( 166 | from_vertex.get_position(), 167 | to_vertex.get_position(), 168 | app.config.edge_bezier_distance, 169 | mouse_pos, 170 | ) 171 | }; 172 | 173 | // 当たり判定の閾値 (線の太さ + 余裕分) 174 | let threshold = 10.0; 175 | 176 | // カーソルが辺上にあるかどうか 177 | let is_on_edge = distance < threshold; 178 | 179 | if is_on_edge && !is_on_vertex { 180 | edge.is_pressed = true; 181 | 182 | if ui.input(|i| i.pointer.any_click()) { 183 | edge.is_deleted = true; 184 | } 185 | } else { 186 | edge.is_pressed = false; 187 | } 188 | } 189 | 190 | let edge_color = if edge.is_pressed { 191 | app.config.edge_color_hover 192 | } else { 193 | app.config.edge_color_normal 194 | }; 195 | 196 | if is_directed { 197 | if edge_count.get(&(edge.from, edge.to)) == Some(&1) { 198 | draw_edge_directed( 199 | painter, 200 | from_vertex.get_position(), 201 | to_vertex.get_position(), 202 | edge_color, 203 | &app.config, 204 | ); 205 | } else { 206 | draw_edge_directed_curved( 207 | painter, 208 | from_vertex.get_position(), 209 | to_vertex.get_position(), 210 | edge_color, 211 | &app.config, 212 | ); 213 | } 214 | } else { 215 | draw_edge_undirected( 216 | painter, 217 | from_vertex.get_position(), 218 | to_vertex.get_position(), 219 | app.config.edge_stroke, 220 | edge_color, 221 | ); 222 | } 223 | } 224 | } 225 | } 226 | 227 | fn distance_from_edge_line(from_pos: egui::Pos2, to_pos: egui::Pos2, mouse_pos: egui::Pos2) -> f32 { 228 | let edge_vector = to_pos - from_pos; 229 | let mouse_vector = mouse_pos - from_pos; 230 | let edge_length = edge_vector.length(); 231 | 232 | let t_ast = (edge_vector.dot(mouse_vector) / edge_length.powi(2)).clamp(0.0, 1.0); 233 | let nearest_point = from_pos + t_ast * edge_vector; 234 | 235 | (mouse_pos - nearest_point).length() 236 | } 237 | 238 | fn distance_from_edge_bezier( 239 | from_pos: egui::Pos2, 240 | to_pos: egui::Pos2, 241 | bezier_distance: f32, 242 | mouse_pos: egui::Pos2, 243 | ) -> f32 { 244 | let control = calc_bezier_control_point(from_pos, to_pos, bezier_distance, false); 245 | 246 | let bezier = |t: f32| -> egui::Pos2 { bezier_curve(from_pos, control, to_pos, t) }; 247 | let d_bezier = |t: f32| -> egui::Vec2 { d_bezier_dt(from_pos, control, to_pos, t) }; 248 | let dd_bezier = d2_bezier_dt2(from_pos, control, to_pos); 249 | 250 | let d_dist_sq_dt = |t: f32| -> f32 { 251 | let pt = bezier(t); 252 | let d_pos = d_bezier(t); 253 | 2.0 * (pt - mouse_pos).dot(d_pos) 254 | }; 255 | let d2_sqr_dist_dt2 = |t: f32| -> f32 { 256 | let pt = bezier(t); // (x, y) 257 | let dp = d_bezier(t); // (dx/dt, dy/dt) 258 | let ddp = dd_bezier; // (d^2x/dt^2, d^2y/dt^2) for quadratic is constant 259 | 260 | // 2( (dx/dt)^2 + (dy/dt)^2 ) + 2( (x - Mx)*d^2x/dt^2 + (y - My)*d^2y/dt^2 ) 261 | 2.0 * dp.length_sq() + 2.0 * (pt - mouse_pos).dot(ddp) 262 | }; 263 | 264 | let t_ast = newton_method(d_dist_sq_dt, d2_sqr_dist_dt2, 0.5, 1e-6, 10); 265 | 266 | t_ast 267 | .filter(|&t| (0.0..=1.0).contains(&t)) 268 | .map(|t| bezier(t).distance(mouse_pos)) 269 | .unwrap_or(f32::INFINITY) 270 | } 271 | 272 | fn draw_edge_undirected( 273 | painter: &egui::Painter, 274 | from_pos: egui::Pos2, 275 | to_pos: egui::Pos2, 276 | stroke: f32, 277 | color: egui::Color32, 278 | ) { 279 | painter.line_segment([from_pos, to_pos], egui::Stroke::new(stroke, color)); 280 | } 281 | 282 | fn draw_edge_directed( 283 | painter: &egui::Painter, 284 | from_pos: egui::Pos2, 285 | to_pos: egui::Pos2, 286 | color: egui::Color32, 287 | config: &AppConfig, 288 | ) { 289 | // 矢印の方向を取得 290 | let dir = (to_pos - from_pos).normalized(); 291 | let arrowhead = to_pos - dir * config.vertex_radius; 292 | let endpoint = arrowhead - dir * config.edge_arrow_length; 293 | 294 | // 矢印のヘッド(三角形)の3つの頂点を計算 295 | let dir = dir * config.edge_arrow_length; 296 | let left = egui::Pos2::new( 297 | arrowhead.x - dir.x - dir.y * (config.edge_arrow_width / config.edge_arrow_length), 298 | arrowhead.y - dir.y + dir.x * (config.edge_arrow_width / config.edge_arrow_length), 299 | ); 300 | let right = egui::Pos2::new( 301 | arrowhead.x - dir.x + dir.y * (config.edge_arrow_width / config.edge_arrow_length), 302 | arrowhead.y - dir.y - dir.x * (config.edge_arrow_width / config.edge_arrow_length), 303 | ); 304 | 305 | // 三角形を描画 306 | painter.add(egui::Shape::convex_polygon( 307 | vec![arrowhead, left, right], 308 | color, 309 | egui::Stroke::NONE, 310 | )); 311 | 312 | // 線を描画 313 | painter.line_segment( 314 | [from_pos, endpoint], 315 | egui::Stroke::new(config.edge_stroke, color), 316 | ); 317 | } 318 | 319 | /// 曲線付きの矢印を描画する関数 320 | fn draw_edge_directed_curved( 321 | painter: &egui::Painter, 322 | from_pos: egui::Pos2, 323 | to_pos: egui::Pos2, 324 | color: egui::Color32, 325 | config: &AppConfig, 326 | ) -> Option<()> { 327 | let control = calc_bezier_control_point(from_pos, to_pos, config.edge_bezier_distance, false); 328 | 329 | // ベジェ曲線と円の交点を計算 330 | let (arrowhead, dir) = calc_intersection_of_bezier_and_circle( 331 | from_pos, 332 | control, 333 | to_pos, 334 | to_pos, 335 | config.vertex_radius, 336 | )?; 337 | 338 | // 2次ベジェ曲線を描画 339 | let bezier = epaint::QuadraticBezierShape { 340 | points: [from_pos, control, to_pos], // 始点・制御点・終点 341 | closed: false, 342 | fill: egui::Color32::TRANSPARENT, 343 | stroke: epaint::PathStroke::new(config.edge_stroke, color), 344 | }; 345 | painter.add(bezier); 346 | 347 | // 矢印のヘッドに曲線が重ならないよう,マスクを作成 348 | painter.line_segment( 349 | [ 350 | arrowhead - dir.normalized() * config.edge_arrow_length / 2.0, 351 | arrowhead, 352 | ], 353 | egui::Stroke::new(config.edge_stroke, config.bg_color), 354 | ); 355 | 356 | // 矢印のヘッド(三角形)の3つの頂点を計算 357 | let dir = dir.normalized() * config.edge_arrow_length; 358 | let left = egui::Pos2::new( 359 | arrowhead.x - dir.x - dir.y * (config.edge_arrow_width / config.edge_arrow_length), 360 | arrowhead.y - dir.y + dir.x * (config.edge_arrow_width / config.edge_arrow_length), 361 | ); 362 | let right = egui::Pos2::new( 363 | arrowhead.x - dir.x + dir.y * (config.edge_arrow_width / config.edge_arrow_length), 364 | arrowhead.y - dir.y - dir.x * (config.edge_arrow_width / config.edge_arrow_length), 365 | ); 366 | 367 | // 三角形を描画 368 | painter.add(egui::Shape::convex_polygon( 369 | vec![arrowhead, left, right], 370 | color, 371 | egui::Stroke::NONE, 372 | )); 373 | 374 | Some(()) 375 | } 376 | 377 | /// central_panel に頂点を描画する 378 | fn draw_vertices(app: &mut GraphEditorApp, ui: &egui::Ui, painter: &egui::Painter) { 379 | // グラフの更新 380 | app.graph.restore_graph(); 381 | 382 | // シミュレーションがonの場合,位置を更新 383 | if app.is_animated { 384 | app.config.simulator.simulate_step(&mut app.graph); 385 | } 386 | 387 | let is_directed = app.graph.is_directed; 388 | let (vertices_mut, edges_mut) = app.graph.vertices_edges_mut(); 389 | 390 | for vertex in vertices_mut.iter_mut().sorted_by_key(|v| v.z_index) { 391 | let rect = egui::Rect::from_center_size( 392 | vertex.get_position(), 393 | egui::vec2( 394 | app.config.vertex_radius * 2.0, 395 | app.config.vertex_radius * 2.0, 396 | ), 397 | ); 398 | let response = ui.interact( 399 | rect, 400 | egui::Id::new(vertex.id), 401 | egui::Sense::click_and_drag(), 402 | ); 403 | 404 | // ドラッグ開始時の処理 405 | if response.drag_started() { 406 | vertex.is_pressed = true; 407 | vertex.z_index = app.next_z_index; 408 | app.next_z_index += 1; 409 | if let Some(mouse_pos) = response.hover_pos() { 410 | let delta = Affine2D::from_transition(mouse_pos - vertex.get_position()); 411 | vertex.drag = delta; 412 | } 413 | } else if response.dragged() { 414 | // ドラッグ中の位置更新 415 | if let Some(mouse_pos) = response.hover_pos() { 416 | vertex.update_position(mouse_pos.applied(&vertex.drag)); 417 | } 418 | } else { 419 | vertex.is_pressed = false; 420 | } 421 | 422 | // ホバー時 423 | if app.edit_mode.is_add_edge() || app.edit_mode.is_delete() { 424 | vertex.is_pressed = response.hovered(); 425 | } 426 | 427 | // 選択時 428 | if response.clicked() && !response.dragged() { 429 | // 最前面に配置 430 | vertex.z_index = app.next_z_index; 431 | app.next_z_index += 1; 432 | 433 | match app.edit_mode { 434 | EditMode::AddEdge { 435 | ref mut from_vertex, 436 | ref mut confirmed, 437 | } => { 438 | if let Some(from_vertex_inner) = from_vertex { 439 | if *from_vertex_inner == vertex.id { 440 | // 自分だった場合,選択を解除 441 | vertex.is_selected = false; 442 | *from_vertex = None; 443 | } else { 444 | // クリックした頂点をto_vertexに設定(すでに追加されている場合は無視) 445 | Graph::add_unique_edge( 446 | is_directed, 447 | edges_mut, 448 | *from_vertex_inner, 449 | vertex.id, 450 | ); 451 | *confirmed = true; 452 | } 453 | } else { 454 | vertex.is_selected = true; 455 | vertex.z_index = app.next_z_index; 456 | app.next_z_index += 1; 457 | // クリックした頂点をfrom_vertexに設定 458 | *from_vertex = Some(vertex.id); 459 | } 460 | } 461 | EditMode::Delete => { 462 | vertex.is_deleted = true; 463 | } 464 | _ => {} 465 | } 466 | } 467 | 468 | match app.edit_mode { 469 | // 始点のみ選択状態の場合,辺を描画 470 | EditMode::AddEdge { 471 | from_vertex: Some(from_vertex_inner), 472 | confirmed: false, 473 | } => { 474 | if vertex.id == from_vertex_inner { 475 | if let Some(mouse_pos) = ui.input(|i| i.pointer.hover_pos()) { 476 | painter.line_segment( 477 | [vertex.get_position(), mouse_pos], 478 | egui::Stroke::new(app.config.edge_stroke, app.config.edge_color_normal), 479 | ); 480 | } 481 | } 482 | } 483 | // 辺を選択し終えた場合,状態をリセット 484 | EditMode::AddEdge { 485 | from_vertex: ref mut from_vertex @ Some(from_vertex_inner), 486 | confirmed: ref mut confirmed @ true, 487 | } => { 488 | if vertex.id == from_vertex_inner { 489 | vertex.is_selected = false; 490 | *from_vertex = None; 491 | *confirmed = false; 492 | } 493 | } 494 | _ => {} 495 | } 496 | 497 | // 頂点の色 498 | let color = if vertex.is_selected { 499 | app.config.vertex_color_selected 500 | } else if vertex.is_pressed { 501 | app.config.vertex_color_dragged 502 | } else { 503 | app.config.vertex_color_normal 504 | }; 505 | 506 | // 0-indexed / 1-indexed の選択によってIDを変更 507 | let vertex_show_id = if app.zero_indexed { 508 | vertex.id 509 | } else { 510 | vertex.id + 1 511 | } 512 | .to_string(); 513 | 514 | painter.circle_filled(vertex.get_position(), app.config.vertex_radius, color); 515 | painter.circle_stroke( 516 | vertex.get_position(), 517 | app.config.vertex_radius, 518 | egui::Stroke::new(app.config.vertex_stroke, app.config.vertex_color_outline), 519 | ); 520 | painter.text( 521 | vertex.get_position(), 522 | egui::Align2::CENTER_CENTER, 523 | vertex_show_id, 524 | egui::FontId::proportional(app.config.vertex_font_size), 525 | app.config.vertex_font_color, 526 | ); 527 | } 528 | } 529 | --------------------------------------------------------------------------------