diff --git a/Koraph-threat-model.md b/Koraph-threat-model.md deleted file mode 100644 index 38b97bc..0000000 --- a/Koraph-threat-model.md +++ /dev/null @@ -1,170 +0,0 @@ -## Context confirmation (2026-02-18) -- 이 저장소는 샘플 앱 + 그래프 시각화 라이브러리이며, 보안 영향은 라이브러리 소비자 앱의 데이터 경로에 크게 의존합니다. -- `GraphVisualizer` 입력은 라이브러리 사용 개발자 구현에 따라 내부/외부 입력 모두 가능하므로, DoS 리스크는 조건부로 유지합니다 (`graph-visualizer/src/commonMain/kotlin/com/rootachieve/koraph/graphvisualizer/GraphVisualizer.kt:67`). -- 현재 샘플 앱은 계정/토큰/개인정보 저장 계획이 없으므로 백업 관련 위험은 낮은 우선순위로 조정합니다 (`composeApp/src/androidMain/AndroidManifest.xml:5`). -- 웹은 GitHub Pages 정적 배포 계획으로 확인되어, 웹 공급망 위험은 존재하나 샘플 성격을 반영해 우선순위를 낮게 조정합니다 (`composeApp/src/webMain/resources/index.html:18`). -- `firebase-debug.log`에는 실제 OAuth 토큰은 없고 scope 메타데이터만 존재합니다 (`firebase-debug.log:1`, `firebase-debug.log:7`). - -## Executive summary -현재 컨텍스트에서 최고 위험군은 라이브러리 소비자가 외부/대용량 그래프를 무제한 입력할 때 발생할 수 있는 가용성 저하입니다. 샘플 앱 자체는 민감 데이터를 다루지 않으므로 백업 설정, 웹 정적 배포, 로그 메타데이터 이슈는 주로 운영 위생 관점의 낮은 우선순위 위험으로 재분류했습니다. - -## Scope and assumptions -- In-scope paths: - - `composeApp/` - - `graph-visualizer/` - - `gradle/` - - `firebase-debug.log` -- Out-of-scope: - - 외부 백엔드/API 서버 (저장소 내 미존재) - - 인프라/클라우드 런타임 정책 (저장소 외부) -- Assumptions: - - 앱은 멀티플랫폼 클라이언트 중심이며 서버 인증 계층이 없다. - - 그래프 입력 신뢰수준은 라이브러리 소비자 구현에 따라 달라진다. - - CI 파이프라인은 별도 구성되어 있으나 현재 저장소에서 직접 확인되지 않는다. -- Open questions that can change ranking: - - 소비자 앱이 허용하는 입력 크기 상한(노드/간선 제한) - - 소비자 앱이 민감 데이터를 저장하는지 여부 - - 소비자 웹 배포에서 CSP/무결성 정책을 어디서 강제하는지 여부 - -## System model -### Primary components -- `composeApp`: Android/iOS/JS/Wasm 앱 진입점 (`composeApp/src/androidMain/kotlin/com/rootachieve/koraph/MainActivity.kt`, `composeApp/src/webMain/kotlin/com/rootachieve/koraph/main.kt`) -- `graph-visualizer`: adjacency 입력을 렌더링하는 공용 라이브러리 (`graph-visualizer/src/commonMain/kotlin/com/rootachieve/koraph/graphvisualizer/GraphVisualizer.kt`) -- Force layout 엔진: 그래프 계산 핵심 루프 (`graph-visualizer/src/commonMain/kotlin/com/rootachieve/koraph/graphvisualizer/ForceLayoutEngine.kt`) -- Android 앱 매니페스트/빌드 설정 (`composeApp/src/androidMain/AndroidManifest.xml`, `composeApp/build.gradle.kts`) - -### Data flows and trust boundaries -- End User -> App UI - - Data: 탭/줌/팬 이벤트 - - Channel: 로컬 UI 이벤트 - - Security guarantees: 플랫폼 입력 모델 의존, 별도 인증 없음 - - Validation: 좌표 기반 hit-test만 수행 (`GraphVisualizer.kt:241`) -- Host App Data Source -> GraphVisualizer API - - Data: `Map>` adjacency, 노드 라벨 - - Channel: 인메모리 함수 호출 - - Security guarantees: 호출자 신뢰 가정 - - Validation: 구조 변환은 있으나 크기 제한 없음 (`GraphModel.kt:39`, `ForceLayoutEngine.kt:91`) -- App Runtime -> Android Backup channel - - Data: 앱 로컬 저장 데이터(향후 포함 가능) - - Channel: OS 백업/복원 - - Security guarantees: OS 정책 의존 - - Validation: 앱 차원 제한 없음 (`AndroidManifest.xml:5`) -- Browser/Web Host -> `composeApp.js` - - Data: 스크립트 리소스 - - Channel: HTTP(S) - - Security guarantees: 배포 인프라 설정 의존 - - Validation: CSP/SRI 표기 없음 (`index.html:18`) -- Developer tooling -> Repo logs - - Data: Firebase CLI 디버그 로그 - - Channel: 파일 커밋/배포 - - Security guarantees: Git 운영 정책 의존 - - Validation: 현재 토큰은 없으나 scope 정보 존재 (`firebase-debug.log:1`) - -#### Diagram -```mermaid -flowchart LR -A["End User"] --> B["Compose App"] -C["Host Data Source"] --> B -B --> D["Graph Visualizer"] -D --> E["Force Layout"] -B --> F["Android Backup"] -G["Web Host"] --> B -H["Developer Tooling"] --> I["Repository"] -I --> B -``` - -## Assets and security objectives -| Asset | Why it matters | Security objective (C/I/A) | -|---|---|---| -| 그래프 데이터(노드/간선) | 시각화 결과 무결성 및 앱 안정성에 직접 영향 | I, A | -| 앱 로컬 저장 데이터(미래 확장 포함) | 계정/설정/토큰 저장 시 유출 리스크 | C, I | -| 웹 배포 산출물(`composeApp.js`) | 변조 시 클라이언트 코드 실행권 획득 가능 | I | -| 소스 저장소/로그 파일 | 내부 운영정보 및 잠재 비밀 노출 경로 | C | -| UI 가용성(렌더링 프레임) | 대규모 입력 시 사용자 기능 마비 가능 | A | - -## Attacker model -### Capabilities -- 공개 배포된 클라이언트 앱에 임의 입력(대형 그래프)을 전달할 수 있는 외부 사용자. -- 저장소를 읽을 수 있는 제3자(오픈소스 소비자 포함). -- 웹 호스팅/전달 경로를 노리는 공급망 공격자(배포 환경 약할 경우). - -### Non-capabilities -- 저장소에 없는 백엔드 DB/관리자 API 직접 공격은 본 범위에서 불가. -- 운영체제 루트 권한 전제 공격은 기본 시나리오에서 제외(필요 시 별도 모델링). - -## Entry points and attack surfaces -| Surface | How reached | Trust boundary | Notes | Evidence (repo path / symbol) | -|---|---|---|---|---| -| Android launcher activity | 앱 실행 | User -> App | 단일 exported 액티비티 | `composeApp/src/androidMain/AndroidManifest.xml:11` | -| Web bootstrap script | 브라우저 로딩 | Web Host -> App | CSP/SRI 명시 없음 | `composeApp/src/webMain/resources/index.html:18` | -| Graph input API | 라이브러리 호출 | Data Source -> Visualizer | 입력 크기 제한 부재 | `graph-visualizer/src/commonMain/kotlin/com/rootachieve/koraph/graphvisualizer/GraphVisualizer.kt:67` | -| Force layout loop | GraphVisualizer 내부 | Visualizer -> Compute engine | O(n^2 * iterations) 구조 | `graph-visualizer/src/commonMain/kotlin/com/rootachieve/koraph/graphvisualizer/ForceLayoutEngine.kt:91` | -| Android backup policy | OS 백업 수행 | App -> OS Backup | `allowBackup=true` | `composeApp/src/androidMain/AndroidManifest.xml:5` | -| Repo-tracked debug log | Git clone/공개 | Tooling -> Repository | OAuth scope 메타데이터 노출 | `firebase-debug.log:1` | - -## Top abuse paths -1. 공격자 목표: 앱 가용성 저하 - 1) 외부 입력 소스에 매우 큰 adjacency 주입 - 2) `computeForceLayout` 이중 루프가 반복 수행 - 3) UI 스레드/렌더링 지연으로 앱 사용 불가 -2. 공격자 목표: 백업 경로를 통한 데이터 획득 - 1) 사용자 단말 백업 데이터 접근 시도 - 2) 앱 데이터가 백업 세트에 포함 - 3) 민감정보 저장 시 기밀성 손상 -3. 공격자 목표: 저장소 메타데이터 기반 정찰 - 1) 커밋된 로그에서 인증 스코프/도구 사용 패턴 수집 - 2) 사회공학/표적 공격 정교화 - 3) 후속 자격증명 탈취 시도 확률 증가 -4. 공격자 목표: 웹 산출물 변조를 통한 코드 실행 - 1) 정적 파일 배포 경로/CDN 변조 - 2) 브라우저가 변조된 `composeApp.js` 로딩 - 3) 클라이언트 무결성 손상 -5. 공격자 목표: 리버스엔지니어링 비용 절감 - 1) release 난독화 비활성 APK 분석 - 2) 내부 로직/상수 추적 - 3) 앱 변조/복제 시도 비용 감소 - -## Threat model table -| Threat ID | Threat source | Prerequisites | Threat action | Impact | Impacted assets | Existing controls (evidence) | Gaps | Recommended mitigations | Detection ideas | Likelihood | Impact severity | Priority | -|---|---|---|---|---|---|---|---|---|---|---|---|---| -| TM-001 | 원격/외부 입력 공급자 | 그래프 데이터가 외부 입력과 연결되어야 함 | 대형/비정상 그래프로 레이아웃 계산량 폭증 유도 | 앱 프리즈/응답 지연 | UI 가용성, 그래프 데이터 무결성 | 최소 스케일/반경 보정 등 일부 안정화 (`GraphVisualizerApi.kt`, `ForceLayoutEngine.kt`) | 노드/간선/반복 상한, 타임아웃, 백그라운드 연산 제한 부재 | 노드/간선 최대치 검증, 초과 입력 거절, 계산을 백그라운드 디스패처로 이동, 작업 시간 제한 도입 | 프레임 타임/ANR 지표, 입력 크기 메트릭, 계산 시간 히스토그램 경보 | medium | medium | medium | -| TM-002 | 로컬 공격자 또는 백업 접근자 | 샘플 앱이 향후 민감 데이터 저장으로 확장되어야 함 | 백업 경로에서 앱 데이터 획득 | 데이터 유출 가능성(현재는 제한적) | 앱 로컬 저장 데이터 | 현재 샘플 앱은 민감 데이터를 저장하지 않음(사용자 확인) | `allowBackup=true` 기본값이 남아 있어 소비자/확장 시 재노출 가능 (`AndroidManifest.xml:5`) | 샘플에는 현상 유지 가능, 배포 앱/소비자 문서에 `allowBackup` 정책 가이드 명시 | 릴리즈 체크리스트에 백업정책 점검 추가 | low | low | low | -| TM-003 | 저장소 관찰자/공급망 정찰자 | 로그 파일이 버전관리로 배포되어야 함 | 로그에서 OAuth scope/운영 습관 수집 | 정찰 품질 향상(직접 침해는 아님) | 저장소 메타데이터 | 로그 내 실제 OAuth 토큰은 없음 (`firebase-debug.log:7`) | 로그 커밋 습관이 남아 재발 시 실제 비밀 노출 가능 | 로그 파일 저장소 추적 해제, pre-commit secret scan(gitleaks 등), CI 비밀탐지 | PR 단계 비밀 스캔 실패 알림, 로그 파일 커밋 감지 룰 | low | low | low | -| TM-004 | 웹 공급망 공격자 | GitHub Pages 정적 배포 경로가 공격받아야 함 | `composeApp.js` 변조 후 사용자에게 전달 | 샘플 웹 무결성 손상 | 웹 배포 산출물, 사용자 신뢰 | 단순 정적 로더만 존재 (`index.html:18`) | 저장소 내 CSP/SRI 가드레일 부재 | 가능하면 메타 CSP 추가, 배포 파이프라인에서 아티팩트 해시 검증 | 배포 후 해시 검증, 무결성 모니터링 | low | medium | low | -| TM-005 | 리버스엔지니어링 공격자 | APK 확보 가능(일반적) | 난독화 없는 release 바이너리 분석 | 공격 자동화/변조 비용 감소 | 앱 로직 무결성 | 최신 SDK 타겟 및 기본 플랫폼 보호 | `isMinifyEnabled=false` (`composeApp/build.gradle.kts:80`) | release에서 R8/난독화 활성화, 디버그 심볼 관리, 무결성 점검 | 변조 APK 탐지(서명 검증), 비정상 클라이언트 비율 모니터링 | high | low | low | - -## Criticality calibration -- critical: - - 인증 우회로 사용자 데이터 대량 탈취 가능 시 - - 서명 키/실제 토큰이 저장소에 노출된 경우 - - 원격 코드 실행 체인이 재현 가능한 경우 -- high: - - 공개 웹 배포에서 무결성 검증 부재 + 실제 변조 가능성이 높은 경우 - - 민감정보 저장 앱에서 백업 정책 오설정으로 대규모 유출 가능 시 - - 테넌트/사용자 간 데이터 경계 붕괴 시 -- medium: - - 가용성 저하가 재현 가능하나 복구가 가능한 경우(대형 그래프 DoS) - - 운영 메타데이터 노출로 표적 공격 가능성이 증가하는 경우 - - 의존성 노후로 잠재 CVE 노출 가능성이 있으나 즉시 악용 경로 미확정인 경우 -- low: - - 보안 직접영향이 작고 주로 분석 난이도만 낮추는 설정(난독화 비활성) - - UI/아이콘/빌드 경고 수준 이슈 - - 공격 전제조건이 비현실적으로 높은 경우 - -## Focus paths for security review -| Path | Why it matters | Related Threat IDs | -|---|---|---| -| `composeApp/src/androidMain/AndroidManifest.xml` | 백업 정책/컴포넌트 노출 등 플랫폼 보안 기본선 | TM-002 | -| `composeApp/build.gradle.kts` | release 보안 옵션(난독화/축소) | TM-005 | -| `composeApp/src/webMain/resources/index.html` | 웹 산출물 로딩 신뢰경계(CSP/SRI) | TM-004 | -| `graph-visualizer/src/commonMain/kotlin/com/rootachieve/koraph/graphvisualizer/GraphVisualizer.kt` | 외부 데이터 진입점 및 계산 트리거 | TM-001 | -| `graph-visualizer/src/commonMain/kotlin/com/rootachieve/koraph/graphvisualizer/ForceLayoutEngine.kt` | 계산 복잡도 기반 DoS 핵심 위치 | TM-001 | -| `firebase-debug.log` | 로그/메타데이터 노출 및 운영 위생 점검 | TM-003 | -| `gradle/libs.versions.toml` | 의존성 최신화 및 CVE 관리 출발점 | TM-003 | - -## Quality check -- [x] 발견된 엔트리포인트(Android launcher, web bootstrap, graph input API)를 모두 다뤘습니다. -- [x] 각 신뢰경계를 위협 항목에 최소 1회 이상 연결했습니다. -- [x] 런타임 코드와 개발/도구 산출물(`firebase-debug.log`, Gradle 설정)을 분리해 기술했습니다. -- [x] 사용자 컨텍스트 답변(라이브러리 소비자 종속, 샘플앱 비민감, GitHub Pages 배포)을 우선순위에 반영했습니다. -- [x] 결론은 소비자 앱 맥락에 따라 조건부로 변할 수 있음을 표시했습니다. diff --git a/composeApp/src/commonMain/kotlin/com/rootachieve/koraph/VisualizerControlPanel.kt b/composeApp/src/commonMain/kotlin/com/rootachieve/koraph/VisualizerControlPanel.kt new file mode 100644 index 0000000..7baae66 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/rootachieve/koraph/VisualizerControlPanel.kt @@ -0,0 +1,814 @@ +package com.rootachieve.koraph + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AssistChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.rootachieve.koraph.graphvisualizer.ForceLayoutConfig +import com.rootachieve.koraph.graphvisualizer.GraphAnimationFlags +import com.rootachieve.koraph.graphvisualizer.GraphInteractionConfig +import com.rootachieve.koraph.graphvisualizer.GraphLabelConfig +import com.rootachieve.koraph.graphvisualizer.GraphVisualizerOptions + +private object SampleInteractionDefaults { + const val minScale: Float = 0.4f + const val maxScale: Float = 6f + const val tapSelectionPadding: Float = 12f +} + +private object SampleLabelDefaults { + const val widthDp: Float = 96f + const val fontSizeSp: Float = 12f + const val verticalPaddingDp: Float = 6f +} + +private object SampleLayoutDefaults { + const val iterations: Int = 520 + const val nodeRepulsion: Float = 1450f + const val repulsionExponent: Float = 1.2f + const val edgeTension: Float = 0.018f + const val centerTension: Float = 0.04f + const val baseEdgeLength: Float = 96f + const val edgeDistanceScale: Float = 1f + const val damping: Float = 0.9f + const val convergenceThreshold: Float = 0.14f + const val collisionPadding: Float = 14f + const val collisionStrength: Float = 0.85f + const val maxVelocity: Float = 11f +} + +private object ControlPanelDimensions { + val cornerRadius = 18.dp + val panelPadding = 14.dp + val sectionSpacing = 14.dp + val itemSpacing = 10.dp + val fieldSpacing = 10.dp +} + +internal enum class VisualizerPreset( + val label: String, +) { + Sample("Sample"), + Default("Default"), + Performance("Performance"), + Presentation("Presentation"), +} + +internal data class VisualizerControlState( + val preset: VisualizerPreset, + val scaleNodeSizeByDegree: Boolean, + val emphasizeEdgeWeight: Boolean, + val directed: Boolean, + val showArrows: Boolean, + val enablePanZoom: Boolean, + val enableTapSelection: Boolean, + val enableNodeDrag: Boolean, + val fitToViewport: Boolean, + val clearSelectionOnInit: Boolean, + val animateColors: Boolean, + val animateLabelFade: Boolean, + val animateInitialRender: Boolean, + val animateLayout: Boolean, + val viewportPadding: String, + val colorAnimationDurationMillis: String, + val labelFadeAnimationDurationMillis: String, + val layoutAnimationDurationMillis: String, + val labelFadeZoomThreshold: String, + val initialRenderAnimationDurationMillis: String, + val minScale: String, + val maxScale: String, + val tapSelectionPadding: String, + val clearSelectionOnBackgroundTap: Boolean, + val keepLayoutPhysicsOnNodeDrag: Boolean, + val dragPhysicsIterationsPerStep: String, + val labelWidthDp: String, + val labelFontSizeSp: String, + val labelVerticalPaddingDp: String, + val iterations: String, + val nodeRepulsion: String, + val repulsionExponent: String, + val edgeTension: String, + val degreeAwareEdgeTension: Boolean, + val centerTension: String, + val baseEdgeLength: String, + val edgeDistanceScale: String, + val damping: String, + val convergenceThreshold: String, + val randomSeed: String, + val collisionPadding: String, + val collisionStrength: String, + val maxVelocity: String, +) + +internal fun buildVisualizerControlState( + preset: VisualizerPreset = VisualizerPreset.Sample, + directed: Boolean = false, +): VisualizerControlState { + val options = presetBaseOptions( + preset = preset, + directed = directed, + ) + + return VisualizerControlState( + preset = preset, + scaleNodeSizeByDegree = false, + emphasizeEdgeWeight = false, + directed = options.directed, + showArrows = options.showArrows, + enablePanZoom = options.enablePanZoom, + enableTapSelection = options.enableTapSelection, + enableNodeDrag = options.enableNodeDrag, + fitToViewport = options.fitToViewport, + clearSelectionOnInit = options.clearSelectionOnInit, + animateColors = options.animationFlags.hasFlag(GraphAnimationFlags.COLOR_TRANSITION), + animateLabelFade = options.animationFlags.hasFlag(GraphAnimationFlags.LABEL_VISIBILITY_FADE), + animateInitialRender = options.animationFlags.hasFlag(GraphAnimationFlags.INITIAL_RENDER), + animateLayout = options.animationFlags.hasFlag(GraphAnimationFlags.LAYOUT_TRANSITION), + viewportPadding = options.viewportPadding.toFieldValue(), + colorAnimationDurationMillis = options.colorAnimationDurationMillis.toString(), + labelFadeAnimationDurationMillis = options.labelFadeAnimationDurationMillis.toString(), + layoutAnimationDurationMillis = options.layoutAnimationDurationMillis.toString(), + labelFadeZoomThreshold = options.labelFadeZoomThreshold.toFieldValue(), + initialRenderAnimationDurationMillis = options.initialRenderAnimationDurationMillis.toString(), + minScale = options.interaction.minScale.toFieldValue(), + maxScale = options.interaction.maxScale.toFieldValue(), + tapSelectionPadding = options.interaction.tapSelectionPadding.toFieldValue(), + clearSelectionOnBackgroundTap = options.interaction.clearSelectionOnBackgroundTap, + keepLayoutPhysicsOnNodeDrag = options.interaction.keepLayoutPhysicsOnNodeDrag, + dragPhysicsIterationsPerStep = options.interaction.dragPhysicsIterationsPerStep.toString(), + labelWidthDp = options.label.widthDp.toFieldValue(), + labelFontSizeSp = options.label.fontSizeSp.toFieldValue(), + labelVerticalPaddingDp = options.label.verticalPaddingDp.toFieldValue(), + iterations = options.layout.iterations.toString(), + nodeRepulsion = options.layout.nodeRepulsion.toFieldValue(), + repulsionExponent = options.layout.repulsionExponent.toFieldValue(), + edgeTension = options.layout.edgeTension.toFieldValue(), + degreeAwareEdgeTension = options.layout.degreeAwareEdgeTension, + centerTension = options.layout.centerTension.toFieldValue(), + baseEdgeLength = options.layout.baseEdgeLength.toFieldValue(), + edgeDistanceScale = options.layout.edgeDistanceScale.toFieldValue(), + damping = options.layout.damping.toFieldValue(), + convergenceThreshold = options.layout.convergenceThreshold.toFieldValue(), + randomSeed = options.layout.randomSeed.toString(), + collisionPadding = options.layout.collisionPadding.toFieldValue(), + collisionStrength = options.layout.collisionStrength.toFieldValue(), + maxVelocity = options.layout.maxVelocity.toFieldValue(), + ) +} + +internal fun VisualizerControlState.toGraphVisualizerOptions(): GraphVisualizerOptions { + val fallback = presetBaseOptions( + preset = preset, + directed = directed, + ) + + return fallback.copy( + directed = directed, + showArrows = showArrows, + enablePanZoom = enablePanZoom, + enableTapSelection = enableTapSelection, + enableNodeDrag = enableNodeDrag, + fitToViewport = fitToViewport, + viewportPadding = viewportPadding.toFloatOr(fallback.viewportPadding), + clearSelectionOnInit = clearSelectionOnInit, + animationFlags = animationFlags(), + colorAnimationDurationMillis = colorAnimationDurationMillis.toIntOr(fallback.colorAnimationDurationMillis), + labelFadeAnimationDurationMillis = labelFadeAnimationDurationMillis.toIntOr( + fallback.labelFadeAnimationDurationMillis, + ), + layoutAnimationDurationMillis = layoutAnimationDurationMillis.toIntOr( + fallback.layoutAnimationDurationMillis, + ), + labelFadeZoomThreshold = labelFadeZoomThreshold.toFloatOr(fallback.labelFadeZoomThreshold), + initialRenderAnimationDurationMillis = initialRenderAnimationDurationMillis.toIntOr( + fallback.initialRenderAnimationDurationMillis, + ), + interaction = GraphInteractionConfig( + minScale = minScale.toFloatOr(fallback.interaction.minScale), + maxScale = maxScale.toFloatOr(fallback.interaction.maxScale), + tapSelectionPadding = tapSelectionPadding.toFloatOr(fallback.interaction.tapSelectionPadding), + clearSelectionOnBackgroundTap = clearSelectionOnBackgroundTap, + keepLayoutPhysicsOnNodeDrag = keepLayoutPhysicsOnNodeDrag, + dragPhysicsIterationsPerStep = dragPhysicsIterationsPerStep.toIntOr( + fallback.interaction.dragPhysicsIterationsPerStep, + ), + ), + label = GraphLabelConfig( + widthDp = labelWidthDp.toFloatOr(fallback.label.widthDp), + fontSizeSp = labelFontSizeSp.toFloatOr(fallback.label.fontSizeSp), + verticalPaddingDp = labelVerticalPaddingDp.toFloatOr(fallback.label.verticalPaddingDp), + ), + layout = ForceLayoutConfig( + iterations = iterations.toIntOr(fallback.layout.iterations), + nodeRepulsion = nodeRepulsion.toFloatOr(fallback.layout.nodeRepulsion), + repulsionExponent = repulsionExponent.toFloatOr(fallback.layout.repulsionExponent), + edgeTension = edgeTension.toFloatOr(fallback.layout.edgeTension), + degreeAwareEdgeTension = degreeAwareEdgeTension, + centerTension = centerTension.toFloatOr(fallback.layout.centerTension), + baseEdgeLength = baseEdgeLength.toFloatOr(fallback.layout.baseEdgeLength), + edgeDistanceScale = edgeDistanceScale.toFloatOr(fallback.layout.edgeDistanceScale), + damping = damping.toFloatOr(fallback.layout.damping), + convergenceThreshold = convergenceThreshold.toFloatOr(fallback.layout.convergenceThreshold), + randomSeed = randomSeed.toIntOr(fallback.layout.randomSeed), + collisionPadding = collisionPadding.toFloatOr(fallback.layout.collisionPadding), + collisionStrength = collisionStrength.toFloatOr(fallback.layout.collisionStrength), + maxVelocity = maxVelocity.toFloatOr(fallback.layout.maxVelocity), + ), + ) +} + +@Composable +internal fun VisualizerControlPanel( + controls: VisualizerControlState, + onControlsChange: (VisualizerControlState) -> Unit, + onApplyPreset: (VisualizerPreset) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .background( + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(ControlPanelDimensions.cornerRadius), + ) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant, + shape = RoundedCornerShape(ControlPanelDimensions.cornerRadius), + ) + .padding(ControlPanelDimensions.panelPadding) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(ControlPanelDimensions.sectionSpacing), + ) { + SettingsSection(title = "Engine Presets") { + Text( + text = "Preset defaults load into the fields below. Invalid input falls back to the selected preset.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(ControlPanelDimensions.fieldSpacing), + ) { + PresetChip( + preset = VisualizerPreset.Sample, + currentPreset = controls.preset, + onApplyPreset = onApplyPreset, + modifier = Modifier.weight(1f), + ) + PresetChip( + preset = VisualizerPreset.Default, + currentPreset = controls.preset, + onApplyPreset = onApplyPreset, + modifier = Modifier.weight(1f), + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(ControlPanelDimensions.fieldSpacing), + ) { + PresetChip( + preset = VisualizerPreset.Performance, + currentPreset = controls.preset, + onApplyPreset = onApplyPreset, + modifier = Modifier.weight(1f), + ) + PresetChip( + preset = VisualizerPreset.Presentation, + currentPreset = controls.preset, + onApplyPreset = onApplyPreset, + modifier = Modifier.weight(1f), + ) + } + } + + SettingsSection(title = "Sample Style") { + ToggleRow( + firstLabel = "Scale node size by degree", + firstValue = controls.scaleNodeSizeByDegree, + onFirstChange = { + onControlsChange(controls.copy(scaleNodeSizeByDegree = it)) + }, + secondLabel = "Emphasize selected edge weight", + secondValue = controls.emphasizeEdgeWeight, + onSecondChange = { + onControlsChange(controls.copy(emphasizeEdgeWeight = it)) + }, + ) + } + + SettingsSection(title = "Behavior") { + ToggleRow( + firstLabel = "Directed graph", + firstValue = controls.directed, + onFirstChange = { + onControlsChange(controls.copy(directed = it)) + }, + secondLabel = "Show arrows", + secondValue = controls.showArrows, + onSecondChange = { + onControlsChange(controls.copy(showArrows = it)) + }, + ) + ToggleRow( + firstLabel = "Pan and zoom", + firstValue = controls.enablePanZoom, + onFirstChange = { + onControlsChange(controls.copy(enablePanZoom = it)) + }, + secondLabel = "Tap selection", + secondValue = controls.enableTapSelection, + onSecondChange = { + onControlsChange(controls.copy(enableTapSelection = it)) + }, + ) + ToggleRow( + firstLabel = "Node drag", + firstValue = controls.enableNodeDrag, + onFirstChange = { + onControlsChange(controls.copy(enableNodeDrag = it)) + }, + secondLabel = "Fit to viewport", + secondValue = controls.fitToViewport, + onSecondChange = { + onControlsChange(controls.copy(fitToViewport = it)) + }, + ) + ToggleRow( + firstLabel = "Clear selection on init", + firstValue = controls.clearSelectionOnInit, + onFirstChange = { + onControlsChange(controls.copy(clearSelectionOnInit = it)) + }, + secondLabel = "Clear selection on background tap", + secondValue = controls.clearSelectionOnBackgroundTap, + onSecondChange = { + onControlsChange(controls.copy(clearSelectionOnBackgroundTap = it)) + }, + ) + ToggleRow( + firstLabel = "Keep physics while dragging", + firstValue = controls.keepLayoutPhysicsOnNodeDrag, + onFirstChange = { + onControlsChange(controls.copy(keepLayoutPhysicsOnNodeDrag = it)) + }, + ) + } + + SettingsSection(title = "Animation Flags") { + ToggleRow( + firstLabel = "Color transition", + firstValue = controls.animateColors, + onFirstChange = { + onControlsChange(controls.copy(animateColors = it)) + }, + secondLabel = "Label fade", + secondValue = controls.animateLabelFade, + onSecondChange = { + onControlsChange(controls.copy(animateLabelFade = it)) + }, + ) + ToggleRow( + firstLabel = "Initial render", + firstValue = controls.animateInitialRender, + onFirstChange = { + onControlsChange(controls.copy(animateInitialRender = it)) + }, + secondLabel = "Layout transition", + secondValue = controls.animateLayout, + onSecondChange = { + onControlsChange(controls.copy(animateLayout = it)) + }, + ) + } + + SettingsSection(title = "Engine Values (Text)") { + Text( + text = "These engine values are adjusted with text input only.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + FieldRow( + firstLabel = "Viewport padding", + firstValue = controls.viewportPadding, + onFirstChange = { + onControlsChange(controls.copy(viewportPadding = it)) + }, + secondLabel = "Iterations", + secondValue = controls.iterations, + onSecondChange = { + onControlsChange(controls.copy(iterations = it)) + }, + ) + FieldRow( + firstLabel = "Node repulsion", + firstValue = controls.nodeRepulsion, + onFirstChange = { + onControlsChange(controls.copy(nodeRepulsion = it)) + }, + secondLabel = "Repulsion exponent", + secondValue = controls.repulsionExponent, + onSecondChange = { + onControlsChange(controls.copy(repulsionExponent = it)) + }, + ) + FieldRow( + firstLabel = "Edge tension", + firstValue = controls.edgeTension, + onFirstChange = { + onControlsChange(controls.copy(edgeTension = it)) + }, + secondLabel = "Center tension", + secondValue = controls.centerTension, + onSecondChange = { + onControlsChange(controls.copy(centerTension = it)) + }, + ) + FieldRow( + firstLabel = "Base edge length", + firstValue = controls.baseEdgeLength, + onFirstChange = { + onControlsChange(controls.copy(baseEdgeLength = it)) + }, + secondLabel = "Edge distance scale", + secondValue = controls.edgeDistanceScale, + onSecondChange = { + onControlsChange(controls.copy(edgeDistanceScale = it)) + }, + ) + FieldRow( + firstLabel = "Damping", + firstValue = controls.damping, + onFirstChange = { + onControlsChange(controls.copy(damping = it)) + }, + secondLabel = "Convergence threshold", + secondValue = controls.convergenceThreshold, + onSecondChange = { + onControlsChange(controls.copy(convergenceThreshold = it)) + }, + ) + FieldRow( + firstLabel = "Collision padding", + firstValue = controls.collisionPadding, + onFirstChange = { + onControlsChange(controls.copy(collisionPadding = it)) + }, + secondLabel = "Collision strength", + secondValue = controls.collisionStrength, + onSecondChange = { + onControlsChange(controls.copy(collisionStrength = it)) + }, + ) + FieldRow( + firstLabel = "Maximum velocity", + firstValue = controls.maxVelocity, + onFirstChange = { + onControlsChange(controls.copy(maxVelocity = it)) + }, + ) + } + + SettingsSection(title = "Animation Timing") { + FieldRow( + firstLabel = "Label fade zoom threshold", + firstValue = controls.labelFadeZoomThreshold, + onFirstChange = { + onControlsChange(controls.copy(labelFadeZoomThreshold = it)) + }, + secondLabel = "Color duration (ms)", + secondValue = controls.colorAnimationDurationMillis, + onSecondChange = { + onControlsChange(controls.copy(colorAnimationDurationMillis = it)) + }, + ) + FieldRow( + firstLabel = "Label fade duration (ms)", + firstValue = controls.labelFadeAnimationDurationMillis, + onFirstChange = { + onControlsChange(controls.copy(labelFadeAnimationDurationMillis = it)) + }, + secondLabel = "Layout duration (ms)", + secondValue = controls.layoutAnimationDurationMillis, + onSecondChange = { + onControlsChange(controls.copy(layoutAnimationDurationMillis = it)) + }, + ) + FieldRow( + firstLabel = "Initial render duration (ms)", + firstValue = controls.initialRenderAnimationDurationMillis, + onFirstChange = { + onControlsChange(controls.copy(initialRenderAnimationDurationMillis = it)) + }, + ) + } + + SettingsSection(title = "Interaction") { + FieldRow( + firstLabel = "Minimum scale", + firstValue = controls.minScale, + onFirstChange = { + onControlsChange(controls.copy(minScale = it)) + }, + secondLabel = "Maximum scale", + secondValue = controls.maxScale, + onSecondChange = { + onControlsChange(controls.copy(maxScale = it)) + }, + ) + FieldRow( + firstLabel = "Tap selection padding", + firstValue = controls.tapSelectionPadding, + onFirstChange = { + onControlsChange(controls.copy(tapSelectionPadding = it)) + }, + secondLabel = "Drag physics iterations", + secondValue = controls.dragPhysicsIterationsPerStep, + onSecondChange = { + onControlsChange(controls.copy(dragPhysicsIterationsPerStep = it)) + }, + ) + } + + SettingsSection(title = "Labels") { + FieldRow( + firstLabel = "Label width (dp)", + firstValue = controls.labelWidthDp, + onFirstChange = { + onControlsChange(controls.copy(labelWidthDp = it)) + }, + secondLabel = "Font size (sp)", + secondValue = controls.labelFontSizeSp, + onSecondChange = { + onControlsChange(controls.copy(labelFontSizeSp = it)) + }, + ) + FieldRow( + firstLabel = "Vertical padding (dp)", + firstValue = controls.labelVerticalPaddingDp, + onFirstChange = { + onControlsChange(controls.copy(labelVerticalPaddingDp = it)) + }, + ) + } + + SettingsSection(title = "Layout Engine Flags") { + ToggleRow( + firstLabel = "Degree-aware edge tension", + firstValue = controls.degreeAwareEdgeTension, + onFirstChange = { + onControlsChange(controls.copy(degreeAwareEdgeTension = it)) + }, + ) + FieldRow( + firstLabel = "Random seed", + firstValue = controls.randomSeed, + onFirstChange = { + onControlsChange(controls.copy(randomSeed = it)) + }, + ) + } + } +} + +@Composable +private fun SettingsSection( + title: String, + content: @Composable ColumnScope.() -> Unit, +) { + Column( + verticalArrangement = Arrangement.spacedBy(ControlPanelDimensions.itemSpacing), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + content() + } +} + +@Composable +private fun PresetChip( + preset: VisualizerPreset, + currentPreset: VisualizerPreset, + onApplyPreset: (VisualizerPreset) -> Unit, + modifier: Modifier = Modifier, +) { + AssistChip( + onClick = { + onApplyPreset(preset) + }, + modifier = modifier, + label = { + Text( + if (preset == currentPreset) { + "${preset.label} active" + } else { + preset.label + }, + ) + }, + ) +} + +@Composable +private fun ToggleRow( + firstLabel: String, + firstValue: Boolean, + onFirstChange: (Boolean) -> Unit, + secondLabel: String? = null, + secondValue: Boolean = false, + onSecondChange: ((Boolean) -> Unit)? = null, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(ControlPanelDimensions.fieldSpacing), + ) { + ToggleCell( + label = firstLabel, + value = firstValue, + onValueChange = onFirstChange, + modifier = Modifier.weight(1f), + ) + if (secondLabel != null && onSecondChange != null) { + ToggleCell( + label = secondLabel, + value = secondValue, + onValueChange = onSecondChange, + modifier = Modifier.weight(1f), + ) + } else { + Spacer(modifier = Modifier.weight(1f)) + } + } +} + +@Composable +private fun ToggleCell( + label: String, + value: Boolean, + onValueChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall, + ) + Spacer(modifier = Modifier.width(8.dp)) + Switch( + checked = value, + onCheckedChange = onValueChange, + ) + } +} + +@Composable +private fun FieldRow( + firstLabel: String, + firstValue: String, + onFirstChange: (String) -> Unit, + secondLabel: String? = null, + secondValue: String = "", + onSecondChange: ((String) -> Unit)? = null, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(ControlPanelDimensions.fieldSpacing), + ) { + SettingField( + label = firstLabel, + value = firstValue, + onValueChange = onFirstChange, + modifier = Modifier.weight(1f), + ) + if (secondLabel != null && onSecondChange != null) { + SettingField( + label = secondLabel, + value = secondValue, + onValueChange = onSecondChange, + modifier = Modifier.weight(1f), + ) + } else { + Spacer(modifier = Modifier.weight(1f)) + } + } +} + +@Composable +private fun SettingField( + label: String, + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier, + singleLine = true, + label = { + Text(label) + }, + ) +} + +private fun presetBaseOptions( + preset: VisualizerPreset, + directed: Boolean, +): GraphVisualizerOptions { + return when (preset) { + VisualizerPreset.Sample -> sampleGraphVisualizerOptions(directed = directed) + VisualizerPreset.Default -> GraphVisualizerOptions.default().copy( + directed = directed, + showArrows = directed, + ) + VisualizerPreset.Performance -> GraphVisualizerOptions.performance(directed = directed) + VisualizerPreset.Presentation -> GraphVisualizerOptions.presentation(directed = directed) + } +} + +private fun sampleGraphVisualizerOptions( + directed: Boolean, +): GraphVisualizerOptions { + return GraphVisualizerOptions.presentation(directed = directed).copy( + fitToViewport = true, + interaction = GraphInteractionConfig( + minScale = SampleInteractionDefaults.minScale, + maxScale = SampleInteractionDefaults.maxScale, + tapSelectionPadding = SampleInteractionDefaults.tapSelectionPadding, + ), + label = GraphLabelConfig( + widthDp = SampleLabelDefaults.widthDp, + fontSizeSp = SampleLabelDefaults.fontSizeSp, + verticalPaddingDp = SampleLabelDefaults.verticalPaddingDp, + ), + layout = ForceLayoutConfig( + iterations = SampleLayoutDefaults.iterations, + nodeRepulsion = SampleLayoutDefaults.nodeRepulsion, + repulsionExponent = SampleLayoutDefaults.repulsionExponent, + edgeTension = SampleLayoutDefaults.edgeTension, + centerTension = SampleLayoutDefaults.centerTension, + baseEdgeLength = SampleLayoutDefaults.baseEdgeLength, + edgeDistanceScale = SampleLayoutDefaults.edgeDistanceScale, + damping = SampleLayoutDefaults.damping, + convergenceThreshold = SampleLayoutDefaults.convergenceThreshold, + collisionPadding = SampleLayoutDefaults.collisionPadding, + collisionStrength = SampleLayoutDefaults.collisionStrength, + maxVelocity = SampleLayoutDefaults.maxVelocity, + ), + ) +} + +private fun VisualizerControlState.animationFlags(): Int { + var flags = GraphAnimationFlags.NONE + if (animateColors) { + flags = flags or GraphAnimationFlags.COLOR_TRANSITION + } + if (animateLabelFade) { + flags = flags or GraphAnimationFlags.LABEL_VISIBILITY_FADE + } + if (animateInitialRender) { + flags = flags or GraphAnimationFlags.INITIAL_RENDER + } + if (animateLayout) { + flags = flags or GraphAnimationFlags.LAYOUT_TRANSITION + } + return flags +} + +private fun Int.hasFlag( + flag: Int, +): Boolean = (this and flag) == flag + +private fun Float.toFieldValue(): String = toString() + +private fun String.toFloatOr( + fallback: Float, +): Float = toFloatOrNull() ?: fallback + +private fun String.toIntOr( + fallback: Int, +): Int = toIntOrNull() ?: fallback diff --git a/composeApp/src/commonMain/kotlin/com/rootachieve/koraph/VisualizerSampleScreen.kt b/composeApp/src/commonMain/kotlin/com/rootachieve/koraph/VisualizerSampleScreen.kt index b71cc8c..8f96e97 100644 --- a/composeApp/src/commonMain/kotlin/com/rootachieve/koraph/VisualizerSampleScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/rootachieve/koraph/VisualizerSampleScreen.kt @@ -7,7 +7,9 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeContentPadding import androidx.compose.foundation.layout.width @@ -23,56 +25,22 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.rootachieve.koraph.graphvisualizer.ForceLayoutConfig -import com.rootachieve.koraph.graphvisualizer.GraphInteractionConfig -import com.rootachieve.koraph.graphvisualizer.GraphLabelConfig import com.rootachieve.koraph.graphvisualizer.GraphVisualizer -import com.rootachieve.koraph.graphvisualizer.GraphVisualizerOptions import com.rootachieve.koraph.graphvisualizer.NodeStyleInput import com.rootachieve.koraph.graphvisualizer.rememberGraphVisualizerState private object SampleScreenDimensions { val containerPadding = 16.dp val headerSpacing = 8.dp - val selectionRowSpacing = 10.dp - val controlsSpacing = 8.dp - val graphSpacing = 12.dp + val actionRowSpacing = 10.dp + val panelSpacing = 12.dp val chipSpacing = 8.dp + val controlPanelMaxHeight = 320.dp val graphCornerRadius = 20.dp val graphBorderWidth = 1.dp val graphContentPadding = 10.dp } -private object SampleInteractionDefaults { - const val minScale: Float = 0.4f - const val maxScale: Float = 6f - const val tapSelectionPadding: Float = 12f -} - -private object SampleLabelDefaults { - const val compactWidthDp: Float = 96f - const val expandedWidthDp: Float = 128f - const val compactFontSizeSp: Float = 12f - const val expandedFontSizeSp: Float = 14f - const val compactVerticalPaddingDp: Float = 6f - const val expandedVerticalPaddingDp: Float = 9f -} - -private object SampleLayoutDefaults { - const val iterations: Int = 520 - const val nodeRepulsion: Float = 1450f - const val repulsionExponent: Float = 1.2f - const val edgeTension: Float = 0.018f - const val centerTension: Float = 0.04f - const val baseEdgeLength: Float = 96f - const val edgeDistanceScale: Float = 1f - const val damping: Float = 0.9f - const val convergenceThreshold: Float = 0.14f - const val collisionPadding: Float = 14f - const val collisionStrength: Float = 0.85f - const val maxVelocity: Float = 11f -} - private const val defaultWeightFallback: Float = 1f @Composable @@ -81,10 +49,12 @@ internal fun VisualizerSampleScreen() { val graph = remember { buildVisualizerSampleGraphData() } var selectedNode by remember { mutableStateOf(null) } - var scaleNodeSizeByDegree by remember { mutableStateOf(false) } - var emphasizeEdgeWeight by remember { mutableStateOf(false) } - var largeLabelMode by remember { mutableStateOf(false) } + var showControls by remember { mutableStateOf(true) } + var controls by remember { mutableStateOf(buildVisualizerControlState()) } + val visualizerOptions = remember(controls) { + controls.toGraphVisualizerOptions() + } val selectedEdgeWeightRange = remember( selectedNode, graph.neighborsByNode, @@ -124,7 +94,7 @@ internal fun VisualizerSampleScreen() { text = "Selected node: ${selectedNode?.let { graph.nodeInfo[it]?.name } ?: "None"}", style = MaterialTheme.typography.bodyMedium, ) - Spacer(modifier = Modifier.height(SampleScreenDimensions.selectionRowSpacing)) + Spacer(modifier = Modifier.height(SampleScreenDimensions.actionRowSpacing)) Row(verticalAlignment = Alignment.CenterVertically) { AssistChip( @@ -137,33 +107,32 @@ internal fun VisualizerSampleScreen() { Spacer(modifier = Modifier.width(SampleScreenDimensions.chipSpacing)) AssistChip( onClick = { - scaleNodeSizeByDegree = !scaleNodeSizeByDegree - }, - label = { - Text( - if (scaleNodeSizeByDegree) { - "Scale Nodes: ON" - } else { - "Scale Nodes: OFF" - }, + val preservedStyles = controls + controls = buildVisualizerControlState( + preset = controls.preset, + directed = controls.directed, + ).copy( + scaleNodeSizeByDegree = preservedStyles.scaleNodeSizeByDegree, + emphasizeEdgeWeight = preservedStyles.emphasizeEdgeWeight, ) }, + label = { Text("Reset Inputs") }, ) } - Spacer(modifier = Modifier.height(SampleScreenDimensions.controlsSpacing)) + Spacer(modifier = Modifier.height(SampleScreenDimensions.chipSpacing)) Row(verticalAlignment = Alignment.CenterVertically) { AssistChip( onClick = { - emphasizeEdgeWeight = !emphasizeEdgeWeight + showControls = !showControls }, label = { Text( - if (emphasizeEdgeWeight) { - "Weighted Edges: ON" + if (showControls) { + "Hide Controls" } else { - "Weighted Edges: OFF" + "Show Controls" }, ) }, @@ -171,25 +140,47 @@ internal fun VisualizerSampleScreen() { Spacer(modifier = Modifier.width(SampleScreenDimensions.chipSpacing)) AssistChip( onClick = { - largeLabelMode = !largeLabelMode + controls = buildVisualizerControlState( + preset = VisualizerPreset.Sample, + directed = controls.directed, + ).copy( + scaleNodeSizeByDegree = controls.scaleNodeSizeByDegree, + emphasizeEdgeWeight = controls.emphasizeEdgeWeight, + ) }, - label = { - Text( - if (largeLabelMode) { - "Large Labels: ON" - } else { - "Large Labels: OFF" - }, + label = { Text("Load Sample Preset") }, + ) + } + + if (showControls) { + Spacer(modifier = Modifier.height(SampleScreenDimensions.panelSpacing)) + VisualizerControlPanel( + controls = controls, + onControlsChange = { updated -> + controls = updated + }, + onApplyPreset = { preset -> + val preservedStyles = controls + controls = buildVisualizerControlState( + preset = preset, + directed = controls.directed, + ).copy( + scaleNodeSizeByDegree = preservedStyles.scaleNodeSizeByDegree, + emphasizeEdgeWeight = preservedStyles.emphasizeEdgeWeight, ) }, + modifier = Modifier + .fillMaxWidth() + .heightIn(max = SampleScreenDimensions.controlPanelMaxHeight), ) } - Spacer(modifier = Modifier.height(SampleScreenDimensions.graphSpacing)) + Spacer(modifier = Modifier.height(SampleScreenDimensions.panelSpacing)) Box( modifier = Modifier - .fillMaxSize() + .fillMaxWidth() + .weight(1f) .background( color = MaterialTheme.colorScheme.surface, shape = RoundedCornerShape(SampleScreenDimensions.graphCornerRadius), @@ -205,51 +196,13 @@ internal fun VisualizerSampleScreen() { adjacency = graph.adjacency, nodeInfo = graph.nodeInfo, state = state, - options = GraphVisualizerOptions.presentation(directed = false).copy( - fitToViewport = true, - interaction = GraphInteractionConfig( - minScale = SampleInteractionDefaults.minScale, - maxScale = SampleInteractionDefaults.maxScale, - tapSelectionPadding = SampleInteractionDefaults.tapSelectionPadding, - ), - label = GraphLabelConfig( - widthDp = if (largeLabelMode) { - SampleLabelDefaults.expandedWidthDp - } else { - SampleLabelDefaults.compactWidthDp - }, - fontSizeSp = if (largeLabelMode) { - SampleLabelDefaults.expandedFontSizeSp - } else { - SampleLabelDefaults.compactFontSizeSp - }, - verticalPaddingDp = if (largeLabelMode) { - SampleLabelDefaults.expandedVerticalPaddingDp - } else { - SampleLabelDefaults.compactVerticalPaddingDp - }, - ), - layout = ForceLayoutConfig( - iterations = SampleLayoutDefaults.iterations, - nodeRepulsion = SampleLayoutDefaults.nodeRepulsion, - repulsionExponent = SampleLayoutDefaults.repulsionExponent, - edgeTension = SampleLayoutDefaults.edgeTension, - centerTension = SampleLayoutDefaults.centerTension, - baseEdgeLength = SampleLayoutDefaults.baseEdgeLength, - edgeDistanceScale = SampleLayoutDefaults.edgeDistanceScale, - damping = SampleLayoutDefaults.damping, - convergenceThreshold = SampleLayoutDefaults.convergenceThreshold, - collisionPadding = SampleLayoutDefaults.collisionPadding, - collisionStrength = SampleLayoutDefaults.collisionStrength, - maxVelocity = SampleLayoutDefaults.maxVelocity, - ), - ), + options = visualizerOptions, nodeStyle = { input: NodeStyleInput -> visualizerSampleNodeStyle( input = input, selectedNode = selectedNode, graph = graph, - scaleNodeSizeByDegree = scaleNodeSizeByDegree, + scaleNodeSizeByDegree = controls.scaleNodeSizeByDegree, ) }, edgeStyle = { input -> @@ -257,7 +210,7 @@ internal fun VisualizerSampleScreen() { input = input, edgeTypes = graph.edgeTypeByKey, edgeWeights = graph.edgeWeightByKey, - emphasizeEdgeWeight = emphasizeEdgeWeight, + emphasizeEdgeWeight = controls.emphasizeEdgeWeight, selectedEdgeWeightRange = selectedEdgeWeightRange, ) }, diff --git a/graph-visualizer/src/commonMain/kotlin/com/rootachieve/koraph/graphvisualizer/ForceLayoutEngine.kt b/graph-visualizer/src/commonMain/kotlin/com/rootachieve/koraph/graphvisualizer/ForceLayoutEngine.kt index c128c8b..f92ca92 100644 --- a/graph-visualizer/src/commonMain/kotlin/com/rootachieve/koraph/graphvisualizer/ForceLayoutEngine.kt +++ b/graph-visualizer/src/commonMain/kotlin/com/rootachieve/koraph/graphvisualizer/ForceLayoutEngine.kt @@ -194,6 +194,167 @@ internal fun computeForceLayout( ) } +internal fun relaxLayoutFromCurrentPositions( + positions: List, + edges: List, + config: ForceLayoutConfig, + nodeRadii: List = List(positions.size) { ForceLayoutDefaults.defaultNodeRadius }, + pinnedNodeIds: Set = emptySet(), + iterations: Int = 8, +): List { + val nodeCount = positions.size + if (nodeCount <= 0) { + return emptyList() + } + val edgeDistanceScale = config.edgeDistanceScale.coerceAtLeast(ForceLayoutDefaults.minimumEdgeDistanceScale) + val x = FloatArray(nodeCount) { index -> + positions[index].x / edgeDistanceScale + } + val y = FloatArray(nodeCount) { index -> + positions[index].y / edgeDistanceScale + } + val vx = FloatArray(nodeCount) + val vy = FloatArray(nodeCount) + val fx = FloatArray(nodeCount) + val fy = FloatArray(nodeCount) + + val centerTension = config.centerTension.coerceAtLeast(0f) + val repulsionExponent = config.repulsionExponent.coerceIn(0.5f, 4f) + val idealEdgeLength = config.baseEdgeLength.coerceAtLeast(ForceLayoutDefaults.minimumBaseEdgeLength) + val collisionPadding = config.collisionPadding.coerceAtLeast(0f) + val collisionStrength = config.collisionStrength.coerceAtLeast(0f) + val maxVelocity = config.maxVelocity.coerceAtLeast(ForceLayoutDefaults.minimumVelocityMagnitude) + val degreeAwareEdgeTension = config.degreeAwareEdgeTension + val resolvedRadii = FloatArray(nodeCount) { nodeId -> + nodeRadii.getOrNull(nodeId)?.coerceAtLeast(ForceLayoutDefaults.minimumNodeRadius) + ?: ForceLayoutDefaults.defaultNodeRadius + } + val degree = IntArray(nodeCount) + for (edge in edges) { + if ( + edge.fromId !in 0 until nodeCount || + edge.toId !in 0 until nodeCount || + edge.fromId == edge.toId + ) { + continue + } + degree[edge.fromId] += 1 + degree[edge.toId] += 1 + } + + repeat(iterations.coerceAtLeast(1)) { + fx.fill(0f) + fy.fill(0f) + + for (i in 0 until nodeCount) { + for (j in i + 1 until nodeCount) { + var dx = x[j] - x[i] + var dy = y[j] - y[i] + var distSq = dx * dx + dy * dy + if (distSq < ForceLayoutDefaults.minimumDistanceEpsilon) { + dx = ForceLayoutDefaults.minimumDistanceEpsilon + dy = 0f + distSq = dx * dx + } + + val distance = sqrt(distSq) + val nx = dx / distance + val ny = dy / distance + val repulsionDenominator = distance + .toDouble() + .pow(repulsionExponent.toDouble()) + .toFloat() + .coerceAtLeast(ForceLayoutDefaults.minimumDistanceEpsilon) + val repulsiveForce = config.nodeRepulsion / repulsionDenominator + + fx[i] -= nx * repulsiveForce + fy[i] -= ny * repulsiveForce + fx[j] += nx * repulsiveForce + fy[j] += ny * repulsiveForce + + val minimumDistance = resolvedRadii[i] + resolvedRadii[j] + collisionPadding + if (distance < minimumDistance) { + val overlap = (minimumDistance - distance).coerceAtLeast(0f) + val collisionForce = overlap * collisionStrength + fx[i] -= nx * collisionForce + fy[i] -= ny * collisionForce + fx[j] += nx * collisionForce + fy[j] += ny * collisionForce + } + } + } + + for (edge in edges) { + val from = edge.fromId + val to = edge.toId + if (from !in 0 until nodeCount || to !in 0 until nodeCount || from == to) { + continue + } + + val dx = x[to] - x[from] + val dy = y[to] - y[from] + val distance = sqrt(max(ForceLayoutDefaults.minimumDistanceEpsilon, dx * dx + dy * dy)) + val nx = dx / distance + val ny = dy / distance + val hubDamp = if (degreeAwareEdgeTension) { + sqrt(max(degree[from], degree[to]).toFloat()).coerceAtLeast(1f) + } else { + 1f + } + val attractiveForce = config.edgeTension * (distance - idealEdgeLength) / hubDamp + + fx[from] += nx * attractiveForce + fy[from] += ny * attractiveForce + fx[to] -= nx * attractiveForce + fy[to] -= ny * attractiveForce + } + + if (centerTension > 0f) { + for (nodeId in 0 until nodeCount) { + fx[nodeId] -= x[nodeId] * centerTension + fy[nodeId] -= y[nodeId] * centerTension + } + } + + var maxDelta = 0f + for (nodeId in 0 until nodeCount) { + if (nodeId in pinnedNodeIds) { + vx[nodeId] = 0f + vy[nodeId] = 0f + continue + } + vx[nodeId] = ((vx[nodeId] + fx[nodeId]) * config.damping) + .coerceIn(-maxVelocity, maxVelocity) + vy[nodeId] = ((vy[nodeId] + fy[nodeId]) * config.damping) + .coerceIn(-maxVelocity, maxVelocity) + + x[nodeId] += vx[nodeId] + y[nodeId] += vy[nodeId] + + val delta = sqrt(vx[nodeId] * vx[nodeId] + vy[nodeId] * vy[nodeId]) + if (delta > maxDelta) { + maxDelta = delta + } + } + + if (maxDelta < config.convergenceThreshold) { + return List(nodeCount) { index -> + Offset( + x = x[index] * edgeDistanceScale, + y = y[index] * edgeDistanceScale, + ) + } + } + } + + return List(nodeCount) { index -> + Offset( + x = x[index] * edgeDistanceScale, + y = y[index] * edgeDistanceScale, + ) + } +} + internal fun calculateBaseTransform( positions: List, canvasSize: IntSize, diff --git a/graph-visualizer/src/commonMain/kotlin/com/rootachieve/koraph/graphvisualizer/GraphVisualizer.kt b/graph-visualizer/src/commonMain/kotlin/com/rootachieve/koraph/graphvisualizer/GraphVisualizer.kt index e0afe5c..478509c 100644 --- a/graph-visualizer/src/commonMain/kotlin/com/rootachieve/koraph/graphvisualizer/GraphVisualizer.kt +++ b/graph-visualizer/src/commonMain/kotlin/com/rootachieve/koraph/graphvisualizer/GraphVisualizer.kt @@ -6,6 +6,10 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.awaitLongPressOrCancellation +import androidx.compose.foundation.gestures.drag import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.layout.Box @@ -16,12 +20,19 @@ import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.input.pointer.PointerType +import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity @@ -192,9 +203,14 @@ fun GraphVisualizer( fitToBounds = options.fitToViewport, ) } - val projectedPositions = remember(animatedWorldPositions, baseTransform, state.scale, state.offset) { + val nodeWorldDragOffsets = remember(signature) { mutableStateMapOf() } + val draggedWorldPositions = applyWorldDragOffsets( + basePositions = animatedWorldPositions, + dragOffsets = nodeWorldDragOffsets, + ) + val projectedPositions = remember(draggedWorldPositions, baseTransform, state.scale, state.offset) { projectPositions( - positions = animatedWorldPositions, + positions = draggedWorldPositions, baseTransform = baseTransform, state = state, ) @@ -215,6 +231,19 @@ fun GraphVisualizer( val appearance = nodeAppearanceScale(entryProgress) nodeRadii.map { radius -> radius * appearance } } + val dragProjectionScale = remember(baseTransform.baseScale, state.scale) { + (baseTransform.baseScale * state.scale).coerceAtLeast(minimumDragProjectionScale) + } + val dragProjectedPositionsState = rememberUpdatedState(renderedPositions) + val dragNodeRadiiState = rememberUpdatedState(renderedNodeRadii) + val dragProjectionScaleState = rememberUpdatedState(dragProjectionScale) + val nodeKeysState = rememberUpdatedState(graphModel.nodeKeys) + val dragBaseWorldPositionsState = rememberUpdatedState(animatedWorldPositions) + val dragLayoutEdgesState = rememberUpdatedState(layoutEdges) + val dragLayoutConfigState = rememberUpdatedState(options.layout) + val dragLayoutNodeRadiiState = rememberUpdatedState(layoutNodeRadii) + val dragKeepPhysicsState = rememberUpdatedState(interaction.keepLayoutPhysicsOnNodeDrag) + val dragPhysicsIterationsState = rememberUpdatedState(interaction.resolvedDragPhysicsIterationsPerStep) var interactionModifier: Modifier = Modifier if (options.enablePanZoom) { @@ -230,6 +259,65 @@ fun GraphVisualizer( } } } + if (options.enableNodeDrag) { + val hitPadding = interaction.resolvedTapSelectionPadding + interactionModifier = interactionModifier.pointerInput( + options.enableNodeDrag, + signature, + interaction, + ) { + detectNodeDragGestures( + findDraggableNode = { pointerOffset -> + val projectedPositions = dragProjectedPositionsState.value + val nodeRadii = dragNodeRadiiState.value + findNodeAt( + pointerPosition = pointerOffset, + projectedPositions = projectedPositions, + radiusProvider = { nodeId -> + nodeRadii.getOrElse(nodeId) { 12f } + hitPadding + }, + ) + }, + onNodeDragStart = { nodeId -> + state.updateSelectedNodeId(nodeId) + onSelectionChange(nodeKeysState.value.getOrNull(nodeId)) + }, + onNodeDrag = { nodeId, dragAmount -> + val projectionScale = dragProjectionScaleState.value + val worldDelta = Offset( + x = dragAmount.x / projectionScale, + y = dragAmount.y / projectionScale, + ) + if (!dragKeepPhysicsState.value) { + val currentOffset = nodeWorldDragOffsets[nodeId] ?: Offset.Zero + nodeWorldDragOffsets[nodeId] = currentOffset + worldDelta + } else { + val basePositions = dragBaseWorldPositionsState.value + val currentWorldPositions = applyWorldDragOffsets( + basePositions = basePositions, + dragOffsets = nodeWorldDragOffsets, + ) + if (nodeId in currentWorldPositions.indices) { + val draggedPositions = currentWorldPositions.toMutableList() + draggedPositions[nodeId] = draggedPositions[nodeId] + worldDelta + val relaxedPositions = relaxLayoutFromCurrentPositions( + positions = draggedPositions, + edges = dragLayoutEdgesState.value, + config = dragLayoutConfigState.value, + nodeRadii = dragLayoutNodeRadiiState.value, + pinnedNodeIds = setOf(nodeId), + iterations = dragPhysicsIterationsState.value, + ) + nodeWorldDragOffsets.clear() + relaxedPositions.forEachIndexed { index, relaxedPosition -> + nodeWorldDragOffsets[index] = relaxedPosition - basePositions[index] + } + } + } + }, + ) + } + } if (options.enableTapSelection) { val hitPadding = interaction.resolvedTapSelectionPadding interactionModifier = interactionModifier.pointerInput( @@ -323,6 +411,20 @@ fun GraphVisualizer( } } +private const val minimumDragProjectionScale: Float = 0.01f + +private fun applyWorldDragOffsets( + basePositions: List, + dragOffsets: Map, +): List { + if (dragOffsets.isEmpty()) { + return basePositions + } + return basePositions.mapIndexed { nodeId, position -> + position + (dragOffsets[nodeId] ?: Offset.Zero) + } +} + private fun collectAllNodeKeys(adjacency: Map>): List { val ordered = linkedSetOf() adjacency.forEach { (from, neighbors) -> @@ -383,6 +485,48 @@ private fun buildRenderPriority( ) } +private suspend fun PointerInputScope.detectNodeDragGestures( + findDraggableNode: (Offset) -> Int?, + onNodeDragStart: (Int) -> Unit = {}, + onNodeDrag: (nodeId: Int, dragAmount: Offset) -> Unit, +) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + val nodeId = findDraggableNode(down.position) ?: return@awaitEachGesture + val dragStart = if (down.type == PointerType.Mouse) { + down + } else { + awaitLongPressOrCancellation(down.id) ?: return@awaitEachGesture + } + + var hasDragged = false + val endedByUp = drag(dragStart.id) { change -> + val dragAmount = change.positionChange() + if (hasDragged) { + if (dragAmount != Offset.Zero) { + onNodeDrag(nodeId, dragAmount) + } + change.consume() + return@drag + } + if (dragAmount != Offset.Zero) { + hasDragged = true + onNodeDragStart(nodeId) + onNodeDrag(nodeId, dragAmount) + change.consume() + } + } + + if (hasDragged && endedByUp) { + currentEvent.changes.forEach { change -> + if (change.changedToUp()) { + change.consume() + } + } + } + } +} + @Composable private fun animatedNodeStyles( styles: List, @@ -624,13 +768,18 @@ private fun GraphLabels( if (labelText.isBlank()) { return@forEachIndexed } - BasicText( - text = labelText, - style = TextStyle( + val resolvedTextStyle = config.textStyle.merge( + TextStyle( color = style.labelColor, fontSize = fontSize, textAlign = TextAlign.Center, ), + ) + BasicText( + text = labelText, + style = resolvedTextStyle, + maxLines = config.maxLines, + overflow = config.overflow, modifier = Modifier .alpha(alpha) .offset { diff --git a/graph-visualizer/src/commonMain/kotlin/com/rootachieve/koraph/graphvisualizer/GraphVisualizerApi.kt b/graph-visualizer/src/commonMain/kotlin/com/rootachieve/koraph/graphvisualizer/GraphVisualizerApi.kt index 01baa70..5e5e155 100644 --- a/graph-visualizer/src/commonMain/kotlin/com/rootachieve/koraph/graphvisualizer/GraphVisualizerApi.kt +++ b/graph-visualizer/src/commonMain/kotlin/com/rootachieve/koraph/graphvisualizer/GraphVisualizerApi.kt @@ -8,6 +8,8 @@ import androidx.compose.runtime.remember import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextOverflow @Stable data class GraphVisualizerOptions( @@ -15,6 +17,7 @@ data class GraphVisualizerOptions( val showArrows: Boolean = true, val enablePanZoom: Boolean = true, val enableTapSelection: Boolean = true, + val enableNodeDrag: Boolean = true, val fitToViewport: Boolean = true, val viewportPadding: Float = 48f, val clearSelectionOnInit: Boolean = true, @@ -90,6 +93,8 @@ data class GraphInteractionConfig( val maxScale: Float = 4.5f, val tapSelectionPadding: Float = 8f, val clearSelectionOnBackgroundTap: Boolean = true, + val keepLayoutPhysicsOnNodeDrag: Boolean = false, + val dragPhysicsIterationsPerStep: Int = 8, ) { val resolvedMinScale: Float get() = minScale.coerceAtLeast(0.01f) @@ -99,6 +104,9 @@ data class GraphInteractionConfig( val resolvedTapSelectionPadding: Float get() = tapSelectionPadding.coerceAtLeast(0f) + + val resolvedDragPhysicsIterationsPerStep: Int + get() = dragPhysicsIterationsPerStep.coerceIn(1, 60) } @Stable @@ -106,6 +114,9 @@ data class GraphLabelConfig( val widthDp: Float = 96f, val fontSizeSp: Float = 12f, val verticalPaddingDp: Float = 6f, + val textStyle: TextStyle = TextStyle.Default, + val maxLines: Int = 1, + val overflow: TextOverflow = TextOverflow.Clip, ) { val resolvedWidthDp: Float get() = widthDp.coerceAtLeast(24f) diff --git a/graph-visualizer/src/commonTest/kotlin/com/rootachieve/koraph/graphvisualizer/ForceLayoutEngineTest.kt b/graph-visualizer/src/commonTest/kotlin/com/rootachieve/koraph/graphvisualizer/ForceLayoutEngineTest.kt index ba44dcf..eb54b9c 100644 --- a/graph-visualizer/src/commonTest/kotlin/com/rootachieve/koraph/graphvisualizer/ForceLayoutEngineTest.kt +++ b/graph-visualizer/src/commonTest/kotlin/com/rootachieve/koraph/graphvisualizer/ForceLayoutEngineTest.kt @@ -261,6 +261,68 @@ class ForceLayoutEngineTest { assertEquals(1f, transform.baseScale) } + @Test + fun calculateBaseTransform_returnsSafeDefaultsForEmptyPositionsOrInvalidCanvas() { + // when + val emptyPositions = calculateBaseTransform( + positions = emptyList(), + canvasSize = androidx.compose.ui.unit.IntSize(640, 480), + ) + val invalidCanvas = calculateBaseTransform( + positions = listOf(androidx.compose.ui.geometry.Offset(10f, 20f)), + canvasSize = androidx.compose.ui.unit.IntSize(0, 480), + ) + + // then + assertEquals(androidx.compose.ui.geometry.Offset.Zero, emptyPositions.worldCenter) + assertEquals(androidx.compose.ui.geometry.Offset(320f, 240f), emptyPositions.canvasCenter) + assertEquals(1f, emptyPositions.baseScale) + + assertEquals(androidx.compose.ui.geometry.Offset.Zero, invalidCanvas.worldCenter) + assertEquals(androidx.compose.ui.geometry.Offset(0f, 240f), invalidCanvas.canvasCenter) + assertEquals(1f, invalidCanvas.baseScale) + } + + @Test + fun relaxLayoutFromCurrentPositions_keepsPinnedNodeAndMovesLinkedNodes() { + // given + val start = listOf( + androidx.compose.ui.geometry.Offset(0f, 0f), + androidx.compose.ui.geometry.Offset(420f, 0f), + ) + val edges = listOf( + LayoutEdge(0, 1), + ) + val config = ForceLayoutConfig( + iterations = 300, + nodeRepulsion = 0f, + edgeTension = 0.08f, + degreeAwareEdgeTension = false, + centerTension = 0f, + baseEdgeLength = 80f, + damping = 0.9f, + convergenceThreshold = 0.0001f, + edgeDistanceScale = 1f, + collisionPadding = 0f, + collisionStrength = 0f, + randomSeed = 42, + ) + + // when + val relaxed = relaxLayoutFromCurrentPositions( + positions = start, + edges = edges, + config = config, + pinnedNodeIds = setOf(0), + iterations = 24, + ) + + // then + assertClose(start[0].x, relaxed[0].x) + assertClose(start[0].y, relaxed[0].y) + assertTrue(relaxed[1].x < start[1].x) + } + private fun assertClose(expected: Float, actual: Float, tolerance: Float = 0.0001f) { assertTrue( abs(expected - actual) <= tolerance, diff --git a/graph-visualizer/src/commonTest/kotlin/com/rootachieve/koraph/graphvisualizer/StyleAndInteractionTest.kt b/graph-visualizer/src/commonTest/kotlin/com/rootachieve/koraph/graphvisualizer/StyleAndInteractionTest.kt index 79c29a0..c26135c 100644 --- a/graph-visualizer/src/commonTest/kotlin/com/rootachieve/koraph/graphvisualizer/StyleAndInteractionTest.kt +++ b/graph-visualizer/src/commonTest/kotlin/com/rootachieve/koraph/graphvisualizer/StyleAndInteractionTest.kt @@ -4,6 +4,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextOverflow import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -262,6 +264,28 @@ class StyleAndInteractionTest { assertNull(notSelected) } + @Test + fun findNodeAt_clampsMinimumHitRadiusWhenRadiusProviderReturnsNonPositiveValue() { + // given + val nodes = listOf(Offset(10f, 10f)) + + // when + val selected = findNodeAt( + pointerPosition = Offset(10.9f, 10f), + projectedPositions = nodes, + radiusProvider = { -100f }, + ) + val notSelected = findNodeAt( + pointerPosition = Offset(11.1f, 10f), + projectedPositions = nodes, + radiusProvider = { 0f }, + ) + + // then + assertEquals(0, selected) + assertNull(notSelected) + } + @Test fun graphVisualizerState_applyTransformUpdatesScaleAndOffset() { // given @@ -363,6 +387,7 @@ class StyleAndInteractionTest { val minScale = -3f val maxScale = 0f val tapSelectionPadding = -2f + val dragPhysicsIterationsPerStep = -7 // when val config = GraphInteractionConfig( @@ -370,6 +395,8 @@ class StyleAndInteractionTest { maxScale = maxScale, tapSelectionPadding = tapSelectionPadding, clearSelectionOnBackgroundTap = false, + keepLayoutPhysicsOnNodeDrag = true, + dragPhysicsIterationsPerStep = dragPhysicsIterationsPerStep, ) // then @@ -377,6 +404,8 @@ class StyleAndInteractionTest { assertEquals(0.01f, config.resolvedMaxScale) assertEquals(0f, config.resolvedTapSelectionPadding) assertFalse(config.clearSelectionOnBackgroundTap) + assertTrue(config.keepLayoutPhysicsOnNodeDrag) + assertEquals(1, config.resolvedDragPhysicsIterationsPerStep) } @Test @@ -397,5 +426,8 @@ class StyleAndInteractionTest { assertEquals(24f, config.resolvedWidthDp) assertEquals(8f, config.resolvedFontSizeSp) assertEquals(0f, config.resolvedVerticalPaddingDp) + assertEquals(TextStyle.Default, config.textStyle) + assertEquals(1, config.maxLines) + assertEquals(TextOverflow.Clip, config.overflow) } }