diff --git a/.github/workflows/pages-web.yml b/.github/workflows/pages-web.yml new file mode 100644 index 0000000..74fbe26 --- /dev/null +++ b/.github/workflows/pages-web.yml @@ -0,0 +1,73 @@ +name: Deploy Web (GitHub Pages) + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: github-pages + cancel-in-progress: true + +jobs: + build: + name: Build web artifact + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: Set up Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Build web distribution (JS + Wasm fallback) + run: ./gradlew --no-daemon --stacktrace :composeApp:composeCompatibilityBrowserDistribution + + - name: Prepare Pages artifact + run: | + set -euo pipefail + mkdir -p site + cp -R composeApp/build/dist/composeWebCompatibility/productionExecutable/. site/ + touch site/.nojekyll + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: site + + deploy: + name: Deploy to GitHub Pages + runs-on: ubuntu-latest + needs: build + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + permissions: + pages: write + id-token: write + + steps: + - name: Deploy + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml new file mode 100644 index 0000000..c0c9203 --- /dev/null +++ b/.github/workflows/pr-ci.yml @@ -0,0 +1,71 @@ +name: PR Library CI + +on: + pull_request: + branches: + - main + types: + - opened + - synchronize + - reopened + - ready_for_review + +permissions: + contents: read + +concurrency: + group: ci-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + library-ci: + name: library-ci + runs-on: ubuntu-latest + + steps: + # 전체 히스토리까지 checkout + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # gradle 빌드에 사용할 JDK 버전 고정 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + # JS/Wasm를 위한 Node 런타임 준비 + - name: Set up Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: "20" + + # Android SDK 설치 + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + + # Gradle 캐시 + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + # 테스트, 컴파일 검증 + - name: Run library verification + run: | + ./gradlew --no-daemon --stacktrace \ + :graph-visualizer:testDebugUnitTest \ + :graph-visualizer:compileKotlinMetadata \ + :graph-visualizer:compileKotlinJs \ + :graph-visualizer:compileKotlinWasmJs + + # 실패 시 테스트리포트 업로드 + - name: Upload test reports on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: graph-visualizer-test-reports + if-no-files-found: ignore + path: | + graph-visualizer/build/reports/tests/** + graph-visualizer/build/test-results/** diff --git a/.github/workflows/release-cd.yml b/.github/workflows/release-cd.yml new file mode 100644 index 0000000..115838d --- /dev/null +++ b/.github/workflows/release-cd.yml @@ -0,0 +1,109 @@ +name: Release Please + CD + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: write + issues: write + pull-requests: write + +concurrency: + group: release-please-main + cancel-in-progress: false + +jobs: + # release-please -> main push 시 변경분을 반영해 release PR을 생성/업데이트하고, 릴리즈 커밋이면 태그/릴리즈를 생성 + release-please: + name: release-please + runs-on: ubuntu-latest + outputs: + release_created: ${{ steps.release.outputs.release_created }} + tag_name: ${{ steps.release.outputs.tag_name }} + version: ${{ steps.release.outputs.version }} + + steps: + - name: Run release-please + id: release + uses: googleapis/release-please-action@v4 + with: + release-type: simple + target-branch: main + include-v-in-tag: true + token: ${{ github.token }} + + release-cd: + name: release-cd + runs-on: ubuntu-latest + needs: release-please + if: ${{ needs.release-please.outputs.release_created == 'true' }} + + steps: + # 이번 릴리즈 태그 기준으로 코드를 checkout해서 정확히 아티팩트 생성 + - name: Checkout release tag + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ needs.release-please.outputs.tag_name }} + + # JDK 버전 고정 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + # JS/Wasm를 위한 Node 런타임 준비 + - name: Set up Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: "20" + + # Android SDK 설치 + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + + # Gradle 캐시 + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + # 테스트, 컴파일 검증 및 aar 생성 + - name: Verify and build release AAR + run: | + ./gradlew --no-daemon --stacktrace \ + :graph-visualizer:testDebugUnitTest \ + :graph-visualizer:compileKotlinMetadata \ + :graph-visualizer:compileKotlinJs \ + :graph-visualizer:compileKotlinWasmJs \ + :graph-visualizer:assembleRelease + + # 생성된 release AAR를 릴리즈 업로드용 이름으로 정리 + - name: Prepare release asset + env: + VERSION: ${{ needs.release-please.outputs.version }} + run: | + set -euo pipefail + + input_aar="graph-visualizer/build/outputs/aar/graph-visualizer-release.aar" + if [ ! -f "${input_aar}" ]; then + echo "Missing release AAR: ${input_aar}" >&2 + exit 1 + fi + + mkdir -p dist + output_aar="dist/graph-visualizer-${VERSION}.aar" + cp "${input_aar}" "${output_aar}" + + # 생성된 GitHub Release에 AAR 파일 첨부 + - name: Upload assets to GitHub Release + env: + GITHUB_TOKEN: ${{ github.token }} + TAG: ${{ needs.release-please.outputs.tag_name }} + VERSION: ${{ needs.release-please.outputs.version }} + run: | + gh release upload "${TAG}" \ + "dist/graph-visualizer-${VERSION}.aar" \ + --clobber diff --git a/.gitignore b/.gitignore index edc01d5..697e952 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ captures !*.xcworkspace/contents.xcworkspacedata **/xcshareddata/WorkspaceSettings.xcsettings node_modules/ +kotlin-js-store/ # Created by https://www.toptal.com/developers/gitignore/api/kotlin,android,androidstudio @@ -205,4 +206,4 @@ fabric.properties !/gradle/wrapper/gradle-wrapper.jar -# End of https://www.toptal.com/developers/gitignore/api/kotlin,android,androidstudio \ No newline at end of file +# End of https://www.toptal.com/developers/gitignore/api/kotlin,android,androidstudio diff --git a/Koraph-threat-model.md b/Koraph-threat-model.md new file mode 100644 index 0000000..38b97bc --- /dev/null +++ b/Koraph-threat-model.md @@ -0,0 +1,170 @@ +## 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/LICENSE b/LICENSE new file mode 100644 index 0000000..7a4a3ea --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/README.md b/README.md index 410b495..1d2e9c3 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,147 @@ -This is a Kotlin Multiplatform project targeting Android, iOS, Web. - -* [/composeApp](./composeApp/src) is for code that will be shared across your Compose Multiplatform applications. - It contains several subfolders: - - [commonMain](./composeApp/src/commonMain/kotlin) is for code that’s common for all targets. - - Other folders are for Kotlin code that will be compiled for only the platform indicated in the folder name. - For example, if you want to use Apple’s CoreCrypto for the iOS part of your Kotlin app, - the [iosMain](./composeApp/src/iosMain/kotlin) folder would be the right place for such calls. - Similarly, if you want to edit the Desktop (JVM) specific part, the [jvmMain](./composeApp/src/jvmMain/kotlin) - folder is the appropriate location. - -* [/iosApp](./iosApp/iosApp) contains iOS applications. Even if you’re sharing your UI with Compose Multiplatform, - you need this entry point for your iOS app. This is also where you should add SwiftUI code for your project. - -### Build and Run Android Application - -To build and run the development version of the Android app, use the run configuration from the run widget -in your IDE’s toolbar or build it directly from the terminal: -- on macOS/Linux - ```shell - ./gradlew :composeApp:assembleDebug - ``` -- on Windows - ```shell - .\gradlew.bat :composeApp:assembleDebug - ``` - -### Build and Run Web Application - -To build and run the development version of the web app, use the run configuration from the run widget -in your IDE's toolbar or run it directly from the terminal: -- for the Wasm target (faster, modern browsers): - - on macOS/Linux - ```shell - ./gradlew :composeApp:wasmJsBrowserDevelopmentRun - ``` - - on Windows - ```shell - .\gradlew.bat :composeApp:wasmJsBrowserDevelopmentRun - ``` -- for the JS target (slower, supports older browsers): - - on macOS/Linux - ```shell - ./gradlew :composeApp:jsBrowserDevelopmentRun - ``` - - on Windows - ```shell - .\gradlew.bat :composeApp:jsBrowserDevelopmentRun - ``` - -### Build and Run iOS Application - -To build and run the development version of the iOS app, use the run configuration from the run widget -in your IDE’s toolbar or open the [/iosApp](./iosApp) directory in Xcode and run it from there. - ---- - -Learn more about [Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html), -[Compose Multiplatform](https://github.com/JetBrains/compose-multiplatform/#compose-multiplatform), -[Kotlin/Wasm](https://kotl.in/wasm/)… - -We would appreciate your feedback on Compose/Web and Kotlin/Wasm in the public Slack channel [#compose-web](https://slack-chats.kotlinlang.org/c/compose-web). -If you face any issues, please report them on [YouTrack](https://youtrack.jetbrains.com/newIssue?project=CMP). \ No newline at end of file +# Koraph + +[![](https://jitpack.io/v/rootachieve/Koraph.svg)](https://jitpack.io/#rootachieve/Koraph) + +> Koraph is a Compose Multiplatform graph visualization library for turning adjacency maps into interactive node-link diagrams. + +Koraph is a Compose Multiplatform project that includes: + +- `composeApp`: demo app for Android, iOS, JS, and Wasm +- `graph-visualizer`: reusable graph visualization library (`Map>` input) + +## Sample + +You can try the web sample app at: +[https://rootachieve.github.io/Koraph/](https://rootachieve.github.io/Koraph/) + +## Installation + +### Prerequisites + +- JDK 17+ +- Android Studio (or IntelliJ IDEA with Kotlin/Compose support) +- Xcode (for iOS target) + +### Gradle + +Assuming a release tag is already published to JitPack: + +```kotlin +// settings.gradle.kts +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + maven("https://jitpack.io") + } +} +``` + +```kotlin +// build.gradle.kts +dependencies { + // all published modules (recommended for simple onboarding) + implementation("com.github.rootachieve:Koraph:") +} +``` +### Build + +```bash +./gradlew :graph-visualizer:allTests +./gradlew :composeApp:assembleDebug +``` + +## Basic Usage + +Use `SimpleGraphVisualizer` for a quick start without manually creating full node metadata. + +```kotlin +enum class NodeKey { Gateway, Search, Users } + +val adjacency = mapOf( + NodeKey.Gateway to listOf(NodeKey.Search, NodeKey.Users), + NodeKey.Search to listOf(NodeKey.Users), +) + +SimpleGraphVisualizer( + adjacency = adjacency, + onSelectionChange = { selected -> + println("Selected node: ${selected?.name ?: "none"}") + }, +) +``` + +You can also apply lightweight per-node customization with `nodeInfoFactory`. + +```kotlin +SimpleGraphVisualizer( + adjacency = adjacency, + nodeInfoFactory = { key -> + NodeInfo( + name = key.name, + size = if (key == NodeKey.Gateway) 24f else 18f, + ) + }, +) +``` + +## Advanced Usage + +For full control, provide `nodeInfo`, tuned options, and custom style rules. + +```kotlin +enum class NodeKey { Gateway, Search, Users, Alerts, Custom } + +val adjacency: Map> = mapOf( + NodeKey.Gateway to listOf(NodeKey.Search, NodeKey.Alerts), + NodeKey.Search to listOf(NodeKey.Users), + NodeKey.Users to listOf(NodeKey.Gateway), +) + +val nodeInfo: Map = mapOf( + NodeKey.Gateway to NodeInfo(name = "Gateway", style = NodeShape.RoundedRect, size = 24f), + NodeKey.Search to NodeInfo(name = "Search", style = NodeShape.Circle, size = 18f), + NodeKey.Users to NodeInfo(name = "Users", style = NodeShape.Hexagon, size = 20f), + NodeKey.Alerts to NodeInfo(name = "Alerts", style = NodeShape.Diamond, size = 22f), +) + +GraphVisualizer( + adjacency = adjacency, + nodeInfo = nodeInfo, + options = GraphVisualizerOptions.presentation().copy( + interaction = GraphInteractionConfig( + minScale = 0.4f, + maxScale = 6f, + tapSelectionPadding = 12f, + clearSelectionOnBackgroundTap = false, + ), + label = GraphLabelConfig(widthDp = 120f, fontSizeSp = 13f), + layout = ForceLayoutConfig(centerTension = 0.035f), + ), + nodeStyle = { input -> + val info = input.nodeInfo + NodeStyle( + shape = info.style, + fillColor = when (input.selectionState) { + SelectionState.Selected -> info.selectedColor + SelectionState.OtherSelected -> Color(0xFFD1D5DB) + SelectionState.NoneSelected -> info.color + }, + strokeColor = when (input.selectionState) { + SelectionState.Selected -> info.selectedStrokeColor + SelectionState.OtherSelected -> Color(0xFF6B7280) + SelectionState.NoneSelected -> info.strokeColor + }, + radius = info.size, + labelColor = info.labelColor, + ) + }, +) +``` + +More detailed advanced usage documentation will be added in a future docs update. + +## License + +Licensed under Apache-2.0. See [LICENSE](LICENSE). diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 3d16601..fa13b44 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -43,6 +43,7 @@ kotlin { implementation(libs.androidx.activity.compose) } commonMain.dependencies { + implementation(projects.graphVisualizer) implementation(libs.compose.runtime) implementation(libs.compose.foundation) implementation(libs.compose.material3) @@ -88,4 +89,3 @@ android { dependencies { debugImplementation(libs.compose.uiTooling) } - diff --git a/composeApp/src/commonMain/kotlin/com/rootachieve/koraph/App.kt b/composeApp/src/commonMain/kotlin/com/rootachieve/koraph/App.kt index f32184a..7d1d555 100644 --- a/composeApp/src/commonMain/kotlin/com/rootachieve/koraph/App.kt +++ b/composeApp/src/commonMain/kotlin/com/rootachieve/koraph/App.kt @@ -1,49 +1,13 @@ package com.rootachieve.koraph -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.safeContentPadding -import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier +import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview -import org.jetbrains.compose.resources.painterResource - -import koraph.composeapp.generated.resources.Res -import koraph.composeapp.generated.resources.compose_multiplatform @Composable @Preview fun App() { MaterialTheme { - var showContent by remember { mutableStateOf(false) } - Column( - modifier = Modifier - .background(MaterialTheme.colorScheme.primaryContainer) - .safeContentPadding() - .fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Button(onClick = { showContent = !showContent }) { - Text("Click me!") - } - AnimatedVisibility(showContent) { - val greeting = remember { Greeting().greet() } - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Image(painterResource(Res.drawable.compose_multiplatform), null) - Text("Compose: $greeting") - } - } - } + VisualizerSampleScreen() } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/com/rootachieve/koraph/VisualizerSampleData.kt b/composeApp/src/commonMain/kotlin/com/rootachieve/koraph/VisualizerSampleData.kt new file mode 100644 index 0000000..8b33239 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/rootachieve/koraph/VisualizerSampleData.kt @@ -0,0 +1,147 @@ +package com.rootachieve.koraph + +import androidx.compose.ui.graphics.Color +import com.rootachieve.koraph.graphvisualizer.NodeInfo +import com.rootachieve.koraph.graphvisualizer.NodeShape + +internal data class VisualizerSampleGraphData( + val adjacency: Map>, + val nodeInfo: Map, + val groupByNode: Map, + val edgeTypeByKey: Map, + val edgeWeightByKey: Map, + val neighborsByNode: Map>, +) + +internal data class SampleEdgeDef( + val from: SampleNode, + val to: SampleNode, + val type: SampleEdgeType, + val weight: Float, +) + +internal data class SampleEdgeKey( + val first: SampleNode, + val second: SampleNode, +) + +internal data class EdgeWeightRange( + val min: Float, + val max: Float, +) + +internal enum class SampleGroup( + val baseColor: Color, +) { + Core(baseColor = Color(0xFFE9D5FF)), + Feature(baseColor = Color(0xFFBBF7D0)), + Infra(baseColor = Color(0xFFBFDBFE)), + Support(baseColor = Color(0xFFFDE68A)), +} + +internal enum class SampleEdgeType { + Related, + Critical, + DataFlow, +} + +internal enum class SampleNode { + Gateway, + Auth, + Profile, + Feed, + Search, + Messaging, + Payments, + Notifications, + Analytics, + Storage, +} + +private object SampleDataDefaults { + const val minEdgeWeight: Float = 1f + const val maxEdgeWeight: Float = 3f + const val nodeSize: Float = 18f +} + +internal fun buildVisualizerSampleGraphData(): VisualizerSampleGraphData { + val edgeDefs = listOf( + SampleEdgeDef(SampleNode.Gateway, SampleNode.Auth, SampleEdgeType.Critical, 2.8f), + SampleEdgeDef(SampleNode.Gateway, SampleNode.Feed, SampleEdgeType.Related, 1.6f), + SampleEdgeDef(SampleNode.Gateway, SampleNode.Search, SampleEdgeType.Related, 1.5f), + SampleEdgeDef(SampleNode.Auth, SampleNode.Profile, SampleEdgeType.Critical, 2.4f), + SampleEdgeDef(SampleNode.Profile, SampleNode.Feed, SampleEdgeType.Related, 1.4f), + SampleEdgeDef(SampleNode.Feed, SampleNode.Notifications, SampleEdgeType.DataFlow, 1.9f), + SampleEdgeDef(SampleNode.Search, SampleNode.Analytics, SampleEdgeType.DataFlow, 1.8f), + SampleEdgeDef(SampleNode.Search, SampleNode.Storage, SampleEdgeType.DataFlow, 2.2f), + SampleEdgeDef(SampleNode.Payments, SampleNode.Auth, SampleEdgeType.Critical, 2.6f), + SampleEdgeDef(SampleNode.Payments, SampleNode.Storage, SampleEdgeType.DataFlow, 2.5f), + SampleEdgeDef(SampleNode.Messaging, SampleNode.Notifications, SampleEdgeType.Related, 1.3f), + SampleEdgeDef(SampleNode.Messaging, SampleNode.Profile, SampleEdgeType.Related, 1.2f), + SampleEdgeDef(SampleNode.Analytics, SampleNode.Storage, SampleEdgeType.DataFlow, 1.7f), + ) + + val groups = mapOf( + SampleNode.Gateway to SampleGroup.Core, + SampleNode.Auth to SampleGroup.Core, + SampleNode.Profile to SampleGroup.Feature, + SampleNode.Feed to SampleGroup.Feature, + SampleNode.Search to SampleGroup.Feature, + SampleNode.Messaging to SampleGroup.Feature, + SampleNode.Payments to SampleGroup.Feature, + SampleNode.Notifications to SampleGroup.Support, + SampleNode.Analytics to SampleGroup.Support, + SampleNode.Storage to SampleGroup.Infra, + ) + + val names = SampleNode.entries.associateWith { node -> node.name } + + val adjacencyMutable = SampleNode.entries + .associateWith { linkedSetOf() } + .toMutableMap() + + val edgeTypes = mutableMapOf() + val edgeWeights = mutableMapOf() + + edgeDefs.forEach { edge -> + adjacencyMutable.getValue(edge.from).add(edge.to) + adjacencyMutable.getValue(edge.to).add(edge.from) + + val edgeKey = sampleEdgeKey(edge.from, edge.to) + edgeTypes[edgeKey] = edge.type + + val resolvedWeight = edge.weight.coerceIn( + minimumValue = SampleDataDefaults.minEdgeWeight, + maximumValue = SampleDataDefaults.maxEdgeWeight, + ) + + edgeWeights[edgeKey] = maxOf(edgeWeights[edgeKey] ?: resolvedWeight, resolvedWeight) + } + + val adjacency = adjacencyMutable.mapValues { (_, neighbors) -> neighbors.toList() } + val nodeInfo = SampleNode.entries.associateWith { node -> + NodeInfo( + name = names.getValue(node), + style = NodeShape.Circle, + size = SampleDataDefaults.nodeSize, + ) + } + val neighbors = adjacency.mapValues { (_, list) -> list.toSet() } + + return VisualizerSampleGraphData( + adjacency = adjacency, + nodeInfo = nodeInfo, + groupByNode = groups, + edgeTypeByKey = edgeTypes, + edgeWeightByKey = edgeWeights, + neighborsByNode = neighbors, + ) +} + +internal fun sampleEdgeKey(a: SampleNode, b: SampleNode): SampleEdgeKey { + return if (a.ordinal <= b.ordinal) { + SampleEdgeKey(a, b) + } else { + SampleEdgeKey(b, a) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/rootachieve/koraph/VisualizerSampleScreen.kt b/composeApp/src/commonMain/kotlin/com/rootachieve/koraph/VisualizerSampleScreen.kt new file mode 100644 index 0000000..b71cc8c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/rootachieve/koraph/VisualizerSampleScreen.kt @@ -0,0 +1,270 @@ +package com.rootachieve.koraph + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeContentPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AssistChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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 chipSpacing = 8.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 +internal fun VisualizerSampleScreen() { + val state = rememberGraphVisualizerState() + 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) } + + val selectedEdgeWeightRange = remember( + selectedNode, + graph.neighborsByNode, + graph.edgeWeightByKey, + ) { + val node = selectedNode ?: return@remember null + val neighborWeights = graph.neighborsByNode[node] + ?.mapNotNull { neighbor -> + graph.edgeWeightByKey[sampleEdgeKey(node, neighbor)] + } + .orEmpty() + + if (neighborWeights.isEmpty()) { + null + } else { + EdgeWeightRange( + min = neighborWeights.minOrNull() ?: defaultWeightFallback, + max = neighborWeights.maxOrNull() ?: defaultWeightFallback, + ) + } + } + + Column( + modifier = Modifier + .background(MaterialTheme.colorScheme.surfaceContainerLowest) + .safeContentPadding() + .fillMaxSize() + .padding(SampleScreenDimensions.containerPadding), + ) { + Text( + text = "Visualizer Sample", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.height(SampleScreenDimensions.headerSpacing)) + Text( + text = "Selected node: ${selectedNode?.let { graph.nodeInfo[it]?.name } ?: "None"}", + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(modifier = Modifier.height(SampleScreenDimensions.selectionRowSpacing)) + + Row(verticalAlignment = Alignment.CenterVertically) { + AssistChip( + onClick = { + state.resetView() + selectedNode = null + }, + label = { Text("Reset View") }, + ) + Spacer(modifier = Modifier.width(SampleScreenDimensions.chipSpacing)) + AssistChip( + onClick = { + scaleNodeSizeByDegree = !scaleNodeSizeByDegree + }, + label = { + Text( + if (scaleNodeSizeByDegree) { + "Scale Nodes: ON" + } else { + "Scale Nodes: OFF" + }, + ) + }, + ) + } + + Spacer(modifier = Modifier.height(SampleScreenDimensions.controlsSpacing)) + + Row(verticalAlignment = Alignment.CenterVertically) { + AssistChip( + onClick = { + emphasizeEdgeWeight = !emphasizeEdgeWeight + }, + label = { + Text( + if (emphasizeEdgeWeight) { + "Weighted Edges: ON" + } else { + "Weighted Edges: OFF" + }, + ) + }, + ) + Spacer(modifier = Modifier.width(SampleScreenDimensions.chipSpacing)) + AssistChip( + onClick = { + largeLabelMode = !largeLabelMode + }, + label = { + Text( + if (largeLabelMode) { + "Large Labels: ON" + } else { + "Large Labels: OFF" + }, + ) + }, + ) + } + + Spacer(modifier = Modifier.height(SampleScreenDimensions.graphSpacing)) + + Box( + modifier = Modifier + .fillMaxSize() + .background( + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(SampleScreenDimensions.graphCornerRadius), + ) + .border( + width = SampleScreenDimensions.graphBorderWidth, + color = MaterialTheme.colorScheme.outlineVariant, + shape = RoundedCornerShape(SampleScreenDimensions.graphCornerRadius), + ) + .padding(SampleScreenDimensions.graphContentPadding), + ) { + GraphVisualizer( + 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, + ), + ), + nodeStyle = { input: NodeStyleInput -> + visualizerSampleNodeStyle( + input = input, + selectedNode = selectedNode, + graph = graph, + scaleNodeSizeByDegree = scaleNodeSizeByDegree, + ) + }, + edgeStyle = { input -> + visualizerSampleEdgeStyle( + input = input, + edgeTypes = graph.edgeTypeByKey, + edgeWeights = graph.edgeWeightByKey, + emphasizeEdgeWeight = emphasizeEdgeWeight, + selectedEdgeWeightRange = selectedEdgeWeightRange, + ) + }, + onSelectionChange = { selected -> + selectedNode = selected + }, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/rootachieve/koraph/VisualizerSampleStyles.kt b/composeApp/src/commonMain/kotlin/com/rootachieve/koraph/VisualizerSampleStyles.kt new file mode 100644 index 0000000..316ce3d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/rootachieve/koraph/VisualizerSampleStyles.kt @@ -0,0 +1,146 @@ +package com.rootachieve.koraph + +import androidx.compose.ui.graphics.Color +import com.rootachieve.koraph.graphvisualizer.EdgeStyle +import com.rootachieve.koraph.graphvisualizer.EdgeStyleInput +import com.rootachieve.koraph.graphvisualizer.NodeShape +import com.rootachieve.koraph.graphvisualizer.NodeStyle +import com.rootachieve.koraph.graphvisualizer.NodeStyleInput +import com.rootachieve.koraph.graphvisualizer.SelectionState + +private val disconnectedNodeGray = Color(0xFFE5E7EB) +private val edgeNoneSelectedGray = Color(0xFFD1D5DB) +private val edgeUnselectedGray = Color(0xFFE5E7EB) +private val edgeRelatedGreen = Color(0xFFA7F3D0) +private val edgeCriticalRed = Color(0xFFFCA5A5) +private val edgeDataFlowBlue = Color(0xFF93C5FD) + +private object SampleStyleDefaults { + const val strokeLightenRatio: Float = 0.35f + const val selectedEdgeBaseWidth: Float = 3.2f + const val noneSelectedEdgeBaseWidth: Float = 1.7f + const val otherSelectedEdgeBaseWidth: Float = 1.4f + const val defaultEdgeWeight: Float = 1f + const val minNodeDegreeForScale: Int = 0 + const val maxNodeDegreeForScale: Int = 8 + const val nodeRadiusGrowthPerDegree: Float = 0.09f + const val maxNodeRadius: Float = 36f + const val minimumWeightedEdgeWidth: Float = 0.9f + const val weightNormalizationEpsilon: Float = 0.0001f + const val sameWeightMultiplier: Float = 3f + const val weightScalingRange: Float = 2f +} + +internal fun visualizerSampleNodeStyle( + input: NodeStyleInput, + selectedNode: SampleNode?, + graph: VisualizerSampleGraphData, + scaleNodeSizeByDegree: Boolean, +): NodeStyle { + val group = graph.groupByNode[input.key] ?: SampleGroup.Support + val groupColor = group.baseColor + val nodeColor = when { + selectedNode == null -> groupColor + input.key == selectedNode -> groupColor + graph.neighborsByNode[selectedNode]?.contains(input.key) == true -> groupColor + else -> disconnectedNodeGray + } + val borderColor = lightenColor(nodeColor, SampleStyleDefaults.strokeLightenRatio) + val degree = graph.neighborsByNode[input.key]?.size ?: SampleStyleDefaults.minNodeDegreeForScale + val radius = if (scaleNodeSizeByDegree) { + radiusByDegree( + baseRadius = input.nodeInfo.size, + degree = degree, + ) + } else { + input.nodeInfo.size + } + + return NodeStyle( + shape = NodeShape.Circle, + fillColor = nodeColor, + strokeColor = borderColor, + radius = radius, + labelColor = Color(0xFF1F2937), + ) +} + +internal fun visualizerSampleEdgeStyle( + input: EdgeStyleInput, + edgeTypes: Map, + edgeWeights: Map, + emphasizeEdgeWeight: Boolean, + selectedEdgeWeightRange: EdgeWeightRange?, +): EdgeStyle { + val edgeKey = sampleEdgeKey(input.from, input.to) + val color = when (input.selectionState) { + SelectionState.NoneSelected -> edgeNoneSelectedGray + SelectionState.OtherSelected -> edgeUnselectedGray + SelectionState.Selected -> { + when (edgeTypes[edgeKey] ?: SampleEdgeType.Related) { + SampleEdgeType.Related -> edgeRelatedGreen + SampleEdgeType.Critical -> edgeCriticalRed + SampleEdgeType.DataFlow -> edgeDataFlowBlue + } + } + } + + val baseWidth = when (input.selectionState) { + SelectionState.Selected -> SampleStyleDefaults.selectedEdgeBaseWidth + SelectionState.NoneSelected -> SampleStyleDefaults.noneSelectedEdgeBaseWidth + SelectionState.OtherSelected -> SampleStyleDefaults.otherSelectedEdgeBaseWidth + } + + val weightedWidth = if (emphasizeEdgeWeight && input.selectionState == SelectionState.Selected) { + selectedEdgeWidthByWeight( + baseWidth = baseWidth, + weight = edgeWeights[edgeKey] ?: SampleStyleDefaults.defaultEdgeWeight, + range = selectedEdgeWeightRange, + ) + } else { + baseWidth + } + + return EdgeStyle( + color = color, + width = weightedWidth, + dashed = false, + ) +} + +private fun lightenColor(color: Color, amount: Float): Color { + val ratio = amount.coerceIn(0f, 1f) + return Color( + red = color.red + (1f - color.red) * ratio, + green = color.green + (1f - color.green) * ratio, + blue = color.blue + (1f - color.blue) * ratio, + alpha = color.alpha, + ) +} + +private fun radiusByDegree(baseRadius: Float, degree: Int): Float { + val growth = 1f + ( + degree.coerceAtMost(SampleStyleDefaults.maxNodeDegreeForScale) * + SampleStyleDefaults.nodeRadiusGrowthPerDegree + ) + return (baseRadius * growth).coerceIn(baseRadius, SampleStyleDefaults.maxNodeRadius) +} + +private fun selectedEdgeWidthByWeight( + baseWidth: Float, + weight: Float, + range: EdgeWeightRange?, +): Float { + val multiplier = when { + range == null -> 1f + (range.max - range.min) <= SampleStyleDefaults.weightNormalizationEpsilon -> { + SampleStyleDefaults.sameWeightMultiplier + } + else -> { + val normalized = ((weight - range.min) / (range.max - range.min)) + .coerceIn(0f, 1f) + 1f + (normalized * SampleStyleDefaults.weightScalingRange) + } + } + return (baseWidth * multiplier).coerceAtLeast(SampleStyleDefaults.minimumWeightedEdgeWidth) +} diff --git a/composeApp/src/commonTest/kotlin/com/rootachieve/koraph/ComposeAppCommonTest.kt b/composeApp/src/commonTest/kotlin/com/rootachieve/koraph/ComposeAppCommonTest.kt index 2a6a06b..9ffd9c4 100644 --- a/composeApp/src/commonTest/kotlin/com/rootachieve/koraph/ComposeAppCommonTest.kt +++ b/composeApp/src/commonTest/kotlin/com/rootachieve/koraph/ComposeAppCommonTest.kt @@ -7,6 +7,14 @@ class ComposeAppCommonTest { @Test fun example() { - assertEquals(3, 1 + 2) + // given + val left = 1 + val right = 2 + + // when + val result = left + right + + // then + assertEquals(3, result) } -} \ No newline at end of file +} diff --git a/graph-visualizer/build.gradle.kts b/graph-visualizer/build.gradle.kts new file mode 100644 index 0000000..0a58c4d --- /dev/null +++ b/graph-visualizer/build.gradle.kts @@ -0,0 +1,77 @@ +import org.gradle.api.publish.maven.MavenPublication +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidLibrary) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.composeCompiler) + id("maven-publish") +} + +group = "com.rootachieve.koraph" +version = "0.1.0-SNAPSHOT" + +kotlin { + androidTarget { + publishLibraryVariants("release") + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + } + } + + iosArm64() + iosSimulatorArm64() + + js { + browser() + } + + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + browser() + } + + sourceSets { + commonMain.dependencies { + api(libs.compose.runtime) + api(libs.compose.foundation) + api(libs.compose.ui) + } + commonTest.dependencies { + implementation(libs.kotlin.test) + } + } +} + +android { + namespace = "com.rootachieve.koraph.graphvisualizer" + compileSdk = libs.versions.android.compileSdk.get().toInt() + + defaultConfig { + minSdk = libs.versions.android.minSdk.get().toInt() + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} + +publishing { + publications.withType().configureEach { + pom { + name.set("Koraph Graph Visualizer") + description.set("Compose Multiplatform graph visualization library for adjacency-list inputs.") + url.set("https://github.com/rootachieve/Koraph") + + licenses { + license { + name.set("Apache License 2.0") + url.set("https://www.apache.org/licenses/LICENSE-2.0") + } + } + } + } +} diff --git a/graph-visualizer/src/androidMain/AndroidManifest.xml b/graph-visualizer/src/androidMain/AndroidManifest.xml new file mode 100644 index 0000000..cc947c5 --- /dev/null +++ b/graph-visualizer/src/androidMain/AndroidManifest.xml @@ -0,0 +1 @@ + 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 new file mode 100644 index 0000000..c128c8b --- /dev/null +++ b/graph-visualizer/src/commonMain/kotlin/com/rootachieve/koraph/graphvisualizer/ForceLayoutEngine.kt @@ -0,0 +1,307 @@ +package com.rootachieve.koraph.graphvisualizer + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.IntSize +import kotlin.math.max +import kotlin.math.min +import kotlin.math.pow +import kotlin.math.sqrt + +private object ForceLayoutDefaults { + const val initialPositionRange: Float = 100f + const val defaultNodeRadius: Float = 18f + const val minimumNodeRadius: Float = 4f + const val minimumBaseEdgeLength: Float = 8f + const val minimumEdgeDistanceScale: Float = 0.1f + const val minimumDistanceEpsilon: Float = 0.001f + const val minimumVelocityMagnitude: Float = 1f + const val defaultViewportPadding: Float = 48f + const val minimumWorldExtent: Float = 1f + const val minimumProjectedScale: Float = 0.01f + const val minimumHitRadius: Float = 1f + const val minimumRenderedNodeRadius: Float = 8f + const val maximumRenderedNodeRadius: Float = 44f +} + +internal data class ForceLayoutResult( + val positions: List, + val iterationsPerformed: Int, +) + +internal data class BaseTransform( + val worldCenter: Offset, + val canvasCenter: Offset, + val baseScale: Float, +) + +internal fun computeForceLayout( + nodeCount: Int, + edges: List, + config: ForceLayoutConfig, + nodeRadii: List = List(nodeCount) { ForceLayoutDefaults.defaultNodeRadius }, +): ForceLayoutResult { + if (nodeCount <= 0) { + return ForceLayoutResult(emptyList(), 0) + } + + val rng = SeededRandom(config.randomSeed) + val x = FloatArray(nodeCount) { + rng.nextFloat(-ForceLayoutDefaults.initialPositionRange, ForceLayoutDefaults.initialPositionRange) + } + val y = FloatArray(nodeCount) { + rng.nextFloat(-ForceLayoutDefaults.initialPositionRange, ForceLayoutDefaults.initialPositionRange) + } + 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 baseEdgeLength = config.baseEdgeLength.coerceAtLeast(ForceLayoutDefaults.minimumBaseEdgeLength) + val edgeDistanceScale = config.edgeDistanceScale.coerceAtLeast(ForceLayoutDefaults.minimumEdgeDistanceScale) + val idealEdgeLength = baseEdgeLength + 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 + } + + var iterationsPerformed = 0 + + for (iteration in 0 until config.iterations.coerceAtLeast(1)) { + iterationsPerformed = iteration + 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 = rng.nextFloat(-1f, 1f) + dy = rng.nextFloat(-1f, 1f) + distSq = dx * dx + dy * dy + ForceLayoutDefaults.minimumDistanceEpsilon + } + + 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) { + // Keeps disconnected components from drifting too far apart. + fx[nodeId] -= x[nodeId] * centerTension + fy[nodeId] -= y[nodeId] * centerTension + } + } + + var maxDelta = 0f + for (nodeId in 0 until nodeCount) { + 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) { + break + } + } + + val positions = List(nodeCount) { index -> + Offset( + x = x[index] * edgeDistanceScale, + y = y[index] * edgeDistanceScale, + ) + } + + return ForceLayoutResult( + positions = positions, + iterationsPerformed = iterationsPerformed, + ) +} + +internal fun calculateBaseTransform( + positions: List, + canvasSize: IntSize, + padding: Float = ForceLayoutDefaults.defaultViewportPadding, + fitToBounds: Boolean = true, +): BaseTransform { + if (positions.isEmpty() || canvasSize.width <= 0 || canvasSize.height <= 0) { + return BaseTransform( + worldCenter = Offset.Zero, + canvasCenter = Offset( + x = canvasSize.width * 0.5f, + y = canvasSize.height * 0.5f, + ), + baseScale = 1f, + ) + } + + var minX = Float.POSITIVE_INFINITY + var maxX = Float.NEGATIVE_INFINITY + var minY = Float.POSITIVE_INFINITY + var maxY = Float.NEGATIVE_INFINITY + + for (position in positions) { + minX = min(minX, position.x) + maxX = max(maxX, position.x) + minY = min(minY, position.y) + maxY = max(maxY, position.y) + } + + val scale = if (fitToBounds) { + val worldWidth = (maxX - minX).coerceAtLeast(ForceLayoutDefaults.minimumWorldExtent) + val worldHeight = (maxY - minY).coerceAtLeast(ForceLayoutDefaults.minimumWorldExtent) + val usableWidth = (canvasSize.width - (padding * 2f)).coerceAtLeast(ForceLayoutDefaults.minimumWorldExtent) + val usableHeight = (canvasSize.height - (padding * 2f)).coerceAtLeast(ForceLayoutDefaults.minimumWorldExtent) + min(usableWidth / worldWidth, usableHeight / worldHeight) + .takeIf { it.isFinite() && it > 0f } + ?: 1f + } else { + 1f + } + + return BaseTransform( + worldCenter = Offset((minX + maxX) * 0.5f, (minY + maxY) * 0.5f), + canvasCenter = Offset(canvasSize.width * 0.5f, canvasSize.height * 0.5f), + baseScale = scale, + ) +} + +internal fun projectPositions( + positions: List, + baseTransform: BaseTransform, + state: GraphVisualizerState, +): List { + val scaled = (baseTransform.baseScale * state.scale).coerceAtLeast(ForceLayoutDefaults.minimumProjectedScale) + return positions.map { world -> + val dx = (world.x - baseTransform.worldCenter.x) * scaled + val dy = (world.y - baseTransform.worldCenter.y) * scaled + Offset( + x = baseTransform.canvasCenter.x + state.offset.x + dx, + y = baseTransform.canvasCenter.y + state.offset.y + dy, + ) + } +} + +internal fun findNodeAt( + pointerPosition: Offset, + projectedPositions: List, + radiusProvider: (nodeId: Int) -> Float, +): Int? { + var selectedNode: Int? = null + var selectedDistanceSq = Float.POSITIVE_INFINITY + + for (nodeId in projectedPositions.indices) { + val nodePosition = projectedPositions[nodeId] + val dx = pointerPosition.x - nodePosition.x + val dy = pointerPosition.y - nodePosition.y + val distanceSq = dx * dx + dy * dy + val hitRadius = radiusProvider(nodeId).coerceAtLeast(ForceLayoutDefaults.minimumHitRadius) + + if (distanceSq <= hitRadius * hitRadius && distanceSq < selectedDistanceSq) { + selectedDistanceSq = distanceSq + selectedNode = nodeId + } + } + + return selectedNode +} + +internal fun scaledNodeRadius(baseRadius: Float, currentScale: Float): Float { + return (baseRadius * currentScale).coerceIn( + ForceLayoutDefaults.minimumRenderedNodeRadius, + ForceLayoutDefaults.maximumRenderedNodeRadius, + ) +} + +private class SeededRandom(seed: Int) { + private var state = seed.toLong() and Mask + + fun nextFloat(minValue: Float, maxValue: Float): Float { + state = (LcgMultiplier * state + LcgIncrement) and Mask + val normalized = (state.toDouble() / Mask.toDouble()).toFloat() + return minValue + (maxValue - minValue) * normalized + } + + private companion object { + // Numerical Recipes LCG parameters (32-bit). + const val LcgMultiplier: Long = 1_664_525L + const val LcgIncrement: Long = 1_013_904_223L + const val Mask: Long = 0xFFFF_FFFFL + } +} diff --git a/graph-visualizer/src/commonMain/kotlin/com/rootachieve/koraph/graphvisualizer/GraphCanvasRenderer.kt b/graph-visualizer/src/commonMain/kotlin/com/rootachieve/koraph/graphvisualizer/GraphCanvasRenderer.kt new file mode 100644 index 0000000..40e7d19 --- /dev/null +++ b/graph-visualizer/src/commonMain/kotlin/com/rootachieve/koraph/graphvisualizer/GraphCanvasRenderer.kt @@ -0,0 +1,221 @@ +package com.rootachieve.koraph.graphvisualizer + +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.DrawStyle +import androidx.compose.ui.graphics.drawscope.Fill +import androidx.compose.ui.graphics.drawscope.Stroke +import kotlin.math.max +import kotlin.math.sqrt + +internal fun DrawScope.drawGraphEdges( + renderableEdges: List>, + edgeStyles: List, + projectedPositions: List, + nodeRadii: List, + drawProgress: Float = 1f, + edgeIndices: List? = null, +) { + val progress = drawProgress.coerceIn(0f, 1f) + if (progress <= 0f) { + return + } + + val indices = edgeIndices ?: renderableEdges.indices + indices.forEach { index -> + val edge = renderableEdges.getOrNull(index) ?: return@forEach + if (edge.fromId !in projectedPositions.indices || edge.toId !in projectedPositions.indices) { + return@forEach + } + + val style = edgeStyles.getOrElse(index) { defaultEdgeStyle(isSelected = false) } + val from = projectedPositions[edge.fromId] + val to = projectedPositions[edge.toId] + val radiusFrom = nodeRadii.getOrElse(edge.fromId) { 12f } + val radiusTo = nodeRadii.getOrElse(edge.toId) { 12f } + + val dx = to.x - from.x + val dy = to.y - from.y + val distance = sqrt(dx * dx + dy * dy) + if (distance < 0.001f) { + return@forEach + } + + val direction = Offset(dx / distance, dy / distance) + val start = Offset( + x = from.x + direction.x * radiusFrom, + y = from.y + direction.y * radiusFrom, + ) + val end = Offset( + x = to.x - direction.x * radiusTo, + y = to.y - direction.y * radiusTo, + ) + val animatedEnd = Offset( + x = start.x + (end.x - start.x) * progress, + y = start.y + (end.y - start.y) * progress, + ) + + drawLine( + color = style.color, + start = start, + end = animatedEnd, + strokeWidth = style.width.coerceAtLeast(1f), + cap = StrokeCap.Round, + pathEffect = if (style.dashed) { + PathEffect.dashPathEffect(floatArrayOf(12f, 8f), 0f) + } else { + null + }, + ) + + if (edge.drawArrow && progress >= 0.96f) { + drawArrowHead( + tip = animatedEnd, + direction = direction, + color = style.color, + strokeWidth = style.width, + ) + } + } +} + +internal fun DrawScope.drawGraphNodes( + projectedPositions: List, + nodeStyles: List, + nodeRadii: List, + drawProgress: Float = 1f, + nodeIndices: List? = null, +) { + val progress = drawProgress.coerceIn(0f, 1f) + if (progress <= 0f) { + return + } + + val indices = nodeIndices ?: projectedPositions.indices + indices.forEach { nodeId -> + val center = projectedPositions.getOrNull(nodeId) ?: return@forEach + val style = nodeStyles.getOrElse(nodeId) { + nodeStyleFromInfo(defaultNodeInfo(name = ""), isSelected = false) + } + val radius = nodeRadii.getOrElse(nodeId) { style.radius } + val strokeWidth = max(1.25f, radius * 0.1f) + val fillColor = style.fillColor.copy(alpha = style.fillColor.alpha * progress) + val strokeColor = style.strokeColor.copy(alpha = style.strokeColor.alpha * progress) + + drawNodeShape( + center = center, + radius = radius, + shape = style.shape, + color = fillColor, + drawStyle = Fill, + ) + + drawNodeShape( + center = center, + radius = radius, + shape = style.shape, + color = strokeColor, + drawStyle = Stroke(strokeWidth), + ) + } +} + +private fun DrawScope.drawNodeShape( + center: Offset, + radius: Float, + shape: NodeShape, + color: Color, + drawStyle: DrawStyle, +) { + when (shape) { + NodeShape.Circle -> { + drawCircle( + color = color, + radius = radius, + center = center, + style = drawStyle, + ) + } + + NodeShape.RoundedRect -> { + drawRoundRect( + color = color, + topLeft = Offset(center.x - radius, center.y - radius), + size = Size(radius * 2f, radius * 2f), + cornerRadius = CornerRadius(radius * 0.35f, radius * 0.35f), + style = drawStyle, + ) + } + + NodeShape.Diamond -> { + val path = Path().apply { + moveTo(center.x, center.y - radius) + lineTo(center.x + radius, center.y) + lineTo(center.x, center.y + radius) + lineTo(center.x - radius, center.y) + close() + } + drawPath(path = path, color = color, style = drawStyle) + } + + NodeShape.Hexagon -> { + val half = radius * 0.5f + val path = Path().apply { + moveTo(center.x - half, center.y - radius) + lineTo(center.x + half, center.y - radius) + lineTo(center.x + radius, center.y) + lineTo(center.x + half, center.y + radius) + lineTo(center.x - half, center.y + radius) + lineTo(center.x - radius, center.y) + close() + } + drawPath(path = path, color = color, style = drawStyle) + } + + is NodeShape.Custom -> { + drawPath( + path = shape.pathBuilder(center, radius), + color = color, + style = drawStyle, + ) + } + } +} + +private fun DrawScope.drawArrowHead( + tip: Offset, + direction: Offset, + color: Color, + strokeWidth: Float, +) { + val arrowLength = max(10f, strokeWidth * 3f) + val arrowWidth = max(6f, strokeWidth * 2.4f) + val base = Offset( + x = tip.x - direction.x * arrowLength, + y = tip.y - direction.y * arrowLength, + ) + val perpendicular = Offset(-direction.y, direction.x) + + val left = Offset( + x = base.x + perpendicular.x * arrowWidth * 0.5f, + y = base.y + perpendicular.y * arrowWidth * 0.5f, + ) + val right = Offset( + x = base.x - perpendicular.x * arrowWidth * 0.5f, + y = base.y - perpendicular.y * arrowWidth * 0.5f, + ) + + val arrowPath = Path().apply { + moveTo(tip.x, tip.y) + lineTo(left.x, left.y) + lineTo(right.x, right.y) + close() + } + drawPath(path = arrowPath, color = color, style = Fill) +} diff --git a/graph-visualizer/src/commonMain/kotlin/com/rootachieve/koraph/graphvisualizer/GraphModel.kt b/graph-visualizer/src/commonMain/kotlin/com/rootachieve/koraph/graphvisualizer/GraphModel.kt new file mode 100644 index 0000000..335e1dc --- /dev/null +++ b/graph-visualizer/src/commonMain/kotlin/com/rootachieve/koraph/graphvisualizer/GraphModel.kt @@ -0,0 +1,238 @@ +package com.rootachieve.koraph.graphvisualizer + +internal data class GraphEdge( + val fromId: Int, + val toId: Int, + val from: K, + val to: K, +) + +internal data class GraphModel( + val nodeKeys: List, + val nodeInfoByKey: Map, + val edges: List>, +) { + val nodeCount: Int + get() = nodeKeys.size +} + +internal data class LayoutEdge( + val fromId: Int, + val toId: Int, +) + +internal data class RenderableEdge( + val fromId: Int, + val toId: Int, + val from: K, + val to: K, + val drawArrow: Boolean, +) + +internal fun adjacencySignature( + adjacency: Map>, + nodeInfo: Map, +): Int { + return (adjacency.hashCode() * 31) + nodeInfo.hashCode() +} + +internal fun buildGraphModel( + adjacency: Map>, + nodeInfo: Map, + fallbackNodeInfo: (K) -> NodeInfo = { key -> defaultNodeInfo(name = key.toString()) }, +): GraphModel { + val orderedKeys = linkedSetOf() + adjacency.forEach { (from, neighbors) -> + orderedKeys += from + neighbors.forEach { to -> orderedKeys += to } + } + nodeInfo.keys.forEach { key -> orderedKeys += key } + + val nodeKeys = orderedKeys.toList() + val nodeInfoByKey = nodeKeys.associateWith { key -> + nodeInfo[key] ?: fallbackNodeInfo(key) + } + val idByKey = nodeKeys.withIndex().associate { (index, key) -> key to index } + + val edges = buildList { + adjacency.forEach { (from, neighbors) -> + val fromId = idByKey[from] ?: return@forEach + neighbors.forEach { to -> + val toId = idByKey[to] ?: return@forEach + add( + GraphEdge( + fromId = fromId, + toId = toId, + from = from, + to = to, + ), + ) + } + } + } + + return GraphModel( + nodeKeys = nodeKeys, + nodeInfoByKey = nodeInfoByKey, + edges = edges, + ) +} + +internal fun buildLayoutEdges( + edges: List>, +): List { + val seenPairs = mutableSetOf() + val result = mutableListOf() + + for (edge in edges) { + if (edge.fromId == edge.toId) { + continue + } + val first = minOf(edge.fromId, edge.toId) + val second = maxOf(edge.fromId, edge.toId) + val key = edgePairKey(first, second) + if (seenPairs.add(key)) { + result += LayoutEdge(first, second) + } + } + + return result +} + +internal fun buildRenderableEdges( + edges: List>, + directed: Boolean, + showArrows: Boolean, +): List> { + if (directed) { + return edges.map { edge -> + RenderableEdge( + fromId = edge.fromId, + toId = edge.toId, + from = edge.from, + to = edge.to, + drawArrow = showArrows, + ) + } + } + + val seenPairs = mutableSetOf() + val result = mutableListOf>() + + for (edge in edges) { + val smaller = minOf(edge.fromId, edge.toId) + val larger = maxOf(edge.fromId, edge.toId) + val key = edgePairKey(smaller, larger) + if (seenPairs.add(key)) { + val canonicalFrom = if (edge.fromId == smaller) { + edge.from + } else { + edge.to + } + val canonicalTo = if (edge.toId == larger) { + edge.to + } else { + edge.from + } + result += RenderableEdge( + fromId = smaller, + toId = larger, + from = canonicalFrom, + to = canonicalTo, + drawArrow = false, + ) + } + } + + return result +} + +internal fun resolveNodeStyles( + graphModel: GraphModel, + selectedNodeId: Int?, + styleProvider: (NodeStyleInput) -> NodeStyle, +): List { + return graphModel.nodeKeys.mapIndexed { nodeId, key -> + val info = graphModel.nodeInfoByKey[key] ?: defaultNodeInfo(name = key.toString()) + val selectionState = when { + selectedNodeId == null -> SelectionState.NoneSelected + selectedNodeId == nodeId -> SelectionState.Selected + else -> SelectionState.OtherSelected + } + styleProvider( + NodeStyleInput( + nodeId = nodeId, + key = key, + nodeInfo = info, + isSelected = selectedNodeId == nodeId, + selectionState = selectionState, + ), + ) + } +} + +internal fun resolveNodeLabels( + graphModel: GraphModel, +): List { + return graphModel.nodeKeys.map { key -> + graphModel.nodeInfoByKey[key]?.name ?: key.toString() + } +} + +internal fun resolveNodeLabels( + graphModel: GraphModel, + selectedNodeId: Int?, + labelProvider: ((NodeStyleInput) -> String?)?, +): List { + if (labelProvider == null) { + return resolveNodeLabels(graphModel) + } + + return graphModel.nodeKeys.mapIndexed { nodeId, key -> + val info = graphModel.nodeInfoByKey[key] ?: defaultNodeInfo(name = key.toString()) + val selectionState = when { + selectedNodeId == null -> SelectionState.NoneSelected + selectedNodeId == nodeId -> SelectionState.Selected + else -> SelectionState.OtherSelected + } + labelProvider( + NodeStyleInput( + nodeId = nodeId, + key = key, + nodeInfo = info, + isSelected = selectedNodeId == nodeId, + selectionState = selectionState, + ), + ) ?: "" + } +} + +internal fun resolveEdgeStyles( + renderableEdges: List>, + selectedNodeId: Int?, + styleProvider: (EdgeStyleInput) -> EdgeStyle, +): List { + return renderableEdges.map { edge -> + val isSelected = selectedNodeId != null && + (selectedNodeId == edge.fromId || selectedNodeId == edge.toId) + val selectionState = when { + selectedNodeId == null -> SelectionState.NoneSelected + isSelected -> SelectionState.Selected + else -> SelectionState.OtherSelected + } + styleProvider( + EdgeStyleInput( + fromId = edge.fromId, + toId = edge.toId, + from = edge.from, + to = edge.to, + isSelected = isSelected, + selectionState = selectionState, + ), + ) + } +} + +private fun edgePairKey(first: Int, second: Int): Long { + return (first.toLong() shl 32) or (second.toLong() and 0xFFFFFFFFL) +} 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 new file mode 100644 index 0000000..e0afe5c --- /dev/null +++ b/graph-visualizer/src/commonMain/kotlin/com/rootachieve/koraph/graphvisualizer/GraphVisualizer.kt @@ -0,0 +1,645 @@ +package com.rootachieve.koraph.graphvisualizer + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Animatable +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.detectTapGestures +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +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.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlin.math.min +import kotlin.math.roundToInt + +@Composable +fun SimpleGraphVisualizer( + adjacency: Map>, + modifier: Modifier = Modifier, + options: GraphVisualizerOptions = GraphVisualizerOptions.default(), + state: GraphVisualizerState = rememberGraphVisualizerState(), + nodeLabel: (K) -> String = { it.toString() }, + nodeInfoFactory: (K) -> NodeInfo = { key -> defaultNodeInfo(name = nodeLabel(key)) }, + onSelectionChange: (K?) -> Unit = {}, + onNodeClick: (K) -> Unit = {}, +) { + val generatedNodeInfo = remember(adjacency, nodeLabel, nodeInfoFactory) { + collectAllNodeKeys(adjacency).associateWith { key -> + nodeInfoFactory(key) + } + } + + GraphVisualizer( + adjacency = adjacency, + nodeInfo = generatedNodeInfo, + modifier = modifier, + options = options, + state = state, + onSelectionChange = onSelectionChange, + onNodeClick = onNodeClick, + ) +} + +@Composable +fun GraphVisualizer( + adjacency: Map>, + nodeInfo: Map = emptyMap(), + fallbackNodeInfo: (K) -> NodeInfo = { key -> defaultNodeInfo(name = key.toString()) }, + modifier: Modifier = Modifier, + options: GraphVisualizerOptions = GraphVisualizerOptions.default(), + state: GraphVisualizerState = rememberGraphVisualizerState(), + nodeStyle: ((NodeStyleInput) -> NodeStyle)? = null, + labelText: ((NodeStyleInput) -> String?)? = null, + edgeStyle: ((EdgeStyleInput) -> EdgeStyle)? = null, + onSelectionChange: (K?) -> Unit = {}, + onNodeClick: (K) -> Unit = {}, +) { + val signature = adjacencySignature( + adjacency = adjacency, + nodeInfo = nodeInfo, + ) + val graphModel = remember(signature, fallbackNodeInfo) { + buildGraphModel( + adjacency = adjacency, + nodeInfo = nodeInfo, + fallbackNodeInfo = fallbackNodeInfo, + ) + } + LaunchedEffect(signature, options.clearSelectionOnInit) { + if (options.clearSelectionOnInit) { + state.updateSelectedNodeId(null) + onSelectionChange(null) + } + } + + val layoutEdges = remember(graphModel.edges) { + buildLayoutEdges(graphModel.edges) + } + val layoutNodeRadii = remember(graphModel.nodeKeys, graphModel.nodeInfoByKey) { + graphModel.nodeKeys.map { key -> + graphModel.nodeInfoByKey[key]?.size ?: 18f + } + } + val layoutResult = remember(graphModel.nodeCount, layoutEdges, layoutNodeRadii, options.layout) { + computeForceLayout( + nodeCount = graphModel.nodeCount, + edges = layoutEdges, + config = options.layout, + nodeRadii = layoutNodeRadii, + ) + } + val animatedWorldPositions = animatedLayoutPositions( + targetPositions = layoutResult.positions, + enabled = options.animationFlags.hasAnimationFlag(GraphAnimationFlags.LAYOUT_TRANSITION), + durationMillis = options.layoutAnimationDurationMillis, + ) + + val renderableEdges = remember(graphModel.edges, options.directed, options.showArrows) { + buildRenderableEdges( + edges = graphModel.edges, + directed = options.directed, + showArrows = options.showArrows, + ) + } + val interaction = options.interaction + val label = options.label + + val resolvedNodeStyle = nodeStyle ?: { input: NodeStyleInput -> + defaultNodeStyle( + input = input, + selectionColors = options.selectionColors, + ) + } + val resolvedEdgeStyle = edgeStyle ?: { input: EdgeStyleInput -> + defaultEdgeStyle( + input = input, + selectionColors = options.selectionColors, + ) + } + + val nodeStyles = resolveNodeStyles( + graphModel = graphModel, + selectedNodeId = state.selectedNodeId, + styleProvider = resolvedNodeStyle, + ) + val nodeLabels = resolveNodeLabels( + graphModel = graphModel, + selectedNodeId = state.selectedNodeId, + labelProvider = labelText, + ) + val edgeStyles = resolveEdgeStyles( + renderableEdges = renderableEdges, + selectedNodeId = state.selectedNodeId, + styleProvider = resolvedEdgeStyle, + ) + + val animatedNodeStyles = animatedNodeStyles( + styles = nodeStyles, + enabled = options.animationFlags.hasAnimationFlag(GraphAnimationFlags.COLOR_TRANSITION), + durationMillis = options.colorAnimationDurationMillis, + ) + val animatedEdgeStyles = animatedEdgeStyles( + styles = edgeStyles, + enabled = options.animationFlags.hasAnimationFlag(GraphAnimationFlags.COLOR_TRANSITION), + durationMillis = options.colorAnimationDurationMillis, + ) + val labelAlpha = animatedLabelAlpha( + scale = state.scale, + enabled = options.animationFlags.hasAnimationFlag(GraphAnimationFlags.LABEL_VISIBILITY_FADE), + threshold = options.labelFadeZoomThreshold, + durationMillis = options.labelFadeAnimationDurationMillis, + ) + val entryProgress = initialRenderProgress( + enabled = options.animationFlags.hasAnimationFlag(GraphAnimationFlags.INITIAL_RENDER), + signature = signature, + durationMillis = options.initialRenderAnimationDurationMillis, + ) + + var canvasSize by remember { mutableStateOf(IntSize.Zero) } + + val baseTransform = remember( + animatedWorldPositions, + canvasSize, + options.fitToViewport, + options.viewportPadding, + ) { + calculateBaseTransform( + positions = animatedWorldPositions, + canvasSize = canvasSize, + padding = options.viewportPadding, + fitToBounds = options.fitToViewport, + ) + } + val projectedPositions = remember(animatedWorldPositions, baseTransform, state.scale, state.offset) { + projectPositions( + positions = animatedWorldPositions, + baseTransform = baseTransform, + state = state, + ) + } + val renderedPositions = remember(projectedPositions, baseTransform, state.offset, entryProgress) { + interpolateEntryPositions( + finalPositions = projectedPositions, + center = baseTransform.canvasCenter + state.offset, + progress = entryProgress, + ) + } + val nodeRadii = remember(animatedNodeStyles, state.scale) { + animatedNodeStyles.map { style -> + scaledNodeRadius(style.radius, state.scale) + } + } + val renderedNodeRadii = remember(nodeRadii, entryProgress) { + val appearance = nodeAppearanceScale(entryProgress) + nodeRadii.map { radius -> radius * appearance } + } + + var interactionModifier: Modifier = Modifier + if (options.enablePanZoom) { + interactionModifier = interactionModifier.pointerInput(options.enablePanZoom, interaction) { + detectTransformGestures { centroid, pan, zoom, _ -> + state.applyTransform( + centroid = centroid, + pan = pan, + zoom = zoom, + minScale = interaction.resolvedMinScale, + maxScale = interaction.resolvedMaxScale, + ) + } + } + } + if (options.enableTapSelection) { + val hitPadding = interaction.resolvedTapSelectionPadding + interactionModifier = interactionModifier.pointerInput( + options.enableTapSelection, + renderedPositions, + renderedNodeRadii, + interaction, + ) { + detectTapGestures { tapOffset -> + val selectedNodeId = findNodeAt( + pointerPosition = tapOffset, + projectedPositions = renderedPositions, + radiusProvider = { nodeId -> + renderedNodeRadii.getOrElse(nodeId) { 12f } + hitPadding + }, + ) + if (selectedNodeId == null && !interaction.clearSelectionOnBackgroundTap) { + return@detectTapGestures + } + state.updateSelectedNodeId(selectedNodeId) + val selectedKey = selectedNodeId?.let { nodeId -> + graphModel.nodeKeys.getOrNull(nodeId) + } + onSelectionChange(selectedKey) + if (selectedKey != null) { + onNodeClick(selectedKey) + } + } + } + } + + val renderPriority = remember(renderableEdges, graphModel.nodeCount, state.selectedNodeId) { + buildRenderPriority( + renderableEdges = renderableEdges, + nodeCount = graphModel.nodeCount, + selectedNodeId = state.selectedNodeId, + ) + } + + Box( + modifier = modifier + .clipToBounds() + .then(interactionModifier) + .onSizeChanged { canvasSize = it }, + ) { + Canvas(modifier = Modifier.fillMaxSize()) { + drawGraphEdges( + renderableEdges = renderableEdges, + edgeStyles = animatedEdgeStyles, + projectedPositions = renderedPositions, + nodeRadii = renderedNodeRadii, + drawProgress = entryProgress, + edgeIndices = renderPriority.backgroundEdgeIndices, + ) + drawGraphNodes( + projectedPositions = renderedPositions, + nodeStyles = animatedNodeStyles, + nodeRadii = renderedNodeRadii, + drawProgress = entryProgress, + nodeIndices = renderPriority.backgroundNodeIndices, + ) + if (renderPriority.foregroundEdgeIndices.isNotEmpty()) { + drawGraphEdges( + renderableEdges = renderableEdges, + edgeStyles = animatedEdgeStyles, + projectedPositions = renderedPositions, + nodeRadii = renderedNodeRadii, + drawProgress = entryProgress, + edgeIndices = renderPriority.foregroundEdgeIndices, + ) + } + if (renderPriority.foregroundNodeIndices.isNotEmpty()) { + drawGraphNodes( + projectedPositions = renderedPositions, + nodeStyles = animatedNodeStyles, + nodeRadii = renderedNodeRadii, + drawProgress = entryProgress, + nodeIndices = renderPriority.foregroundNodeIndices, + ) + } + } + + GraphLabels( + projectedPositions = renderedPositions, + nodeStyles = animatedNodeStyles, + nodeRadii = renderedNodeRadii, + labels = nodeLabels, + alpha = (labelAlpha * entryProgress).coerceIn(0f, 1f), + config = label, + ) + } +} + +private fun collectAllNodeKeys(adjacency: Map>): List { + val ordered = linkedSetOf() + adjacency.forEach { (from, neighbors) -> + ordered += from + neighbors.forEach { to -> ordered += to } + } + return ordered.toList() +} + +private data class RenderPriority( + val backgroundEdgeIndices: List, + val foregroundEdgeIndices: List, + val backgroundNodeIndices: List, + val foregroundNodeIndices: List, +) + +private fun buildRenderPriority( + renderableEdges: List>, + nodeCount: Int, + selectedNodeId: Int?, +): RenderPriority { + if (selectedNodeId == null || selectedNodeId !in 0 until nodeCount) { + return RenderPriority( + backgroundEdgeIndices = renderableEdges.indices.toList(), + foregroundEdgeIndices = emptyList(), + backgroundNodeIndices = (0 until nodeCount).toList(), + foregroundNodeIndices = emptyList(), + ) + } + + val backgroundEdgeIndices = mutableListOf() + val foregroundEdgeIndices = mutableListOf() + val highlightedNodeIds = linkedSetOf(selectedNodeId) + + renderableEdges.forEachIndexed { index, edge -> + val isHighlighted = edge.fromId == selectedNodeId || edge.toId == selectedNodeId + if (isHighlighted) { + foregroundEdgeIndices += index + highlightedNodeIds += edge.fromId + highlightedNodeIds += edge.toId + } else { + backgroundEdgeIndices += index + } + } + + val foregroundNodeIndices = highlightedNodeIds + .filter { it in 0 until nodeCount } + .sorted() + val highlightedNodeSet = foregroundNodeIndices.toSet() + val backgroundNodeIndices = (0 until nodeCount) + .filter { it !in highlightedNodeSet } + + return RenderPriority( + backgroundEdgeIndices = backgroundEdgeIndices, + foregroundEdgeIndices = foregroundEdgeIndices, + backgroundNodeIndices = backgroundNodeIndices, + foregroundNodeIndices = foregroundNodeIndices, + ) +} + +@Composable +private fun animatedNodeStyles( + styles: List, + enabled: Boolean, + durationMillis: Int, +): List { + if (!enabled) { + return styles + } + + return styles.mapIndexed { index, style -> + val fillColor by animateColorAsState( + targetValue = style.fillColor, + animationSpec = tween(durationMillis), + label = "gv-node-fill-$index", + ) + val strokeColor by animateColorAsState( + targetValue = style.strokeColor, + animationSpec = tween(durationMillis), + label = "gv-node-stroke-$index", + ) + val labelColor by animateColorAsState( + targetValue = style.labelColor, + animationSpec = tween(durationMillis), + label = "gv-node-label-$index", + ) + val radius by animateFloatAsState( + targetValue = style.radius, + animationSpec = tween(durationMillis), + label = "gv-node-radius-$index", + ) + + style.copy( + fillColor = fillColor, + strokeColor = strokeColor, + labelColor = labelColor, + radius = radius, + ) + } +} + +@Composable +private fun animatedEdgeStyles( + styles: List, + enabled: Boolean, + durationMillis: Int, +): List { + if (!enabled) { + return styles + } + + return styles.mapIndexed { index, style -> + val color by animateColorAsState( + targetValue = style.color, + animationSpec = tween(durationMillis), + label = "gv-edge-color-$index", + ) + val width by animateFloatAsState( + targetValue = style.width, + animationSpec = tween(durationMillis), + label = "gv-edge-width-$index", + ) + style.copy( + color = color, + width = width, + ) + } +} + +@Composable +private fun animatedLayoutPositions( + targetPositions: List, + enabled: Boolean, + durationMillis: Int, +): List { + var startPositions by remember { mutableStateOf(targetPositions) } + var endPositions by remember { mutableStateOf(targetPositions) } + val transitionProgress = remember { Animatable(1f) } + + LaunchedEffect(targetPositions, enabled, durationMillis) { + if (!enabled) { + startPositions = targetPositions + endPositions = targetPositions + transitionProgress.snapTo(1f) + return@LaunchedEffect + } + if ( + startPositions.size != targetPositions.size || + endPositions.size != targetPositions.size + ) { + startPositions = targetPositions + endPositions = targetPositions + transitionProgress.snapTo(1f) + return@LaunchedEffect + } + + startPositions = interpolateLayoutPositions( + from = startPositions, + to = endPositions, + progress = transitionProgress.value, + ) + endPositions = targetPositions + transitionProgress.snapTo(0f) + transitionProgress.animateTo( + targetValue = 1f, + animationSpec = tween( + durationMillis = durationMillis.coerceAtLeast(1), + easing = FastOutSlowInEasing, + ), + ) + } + + return if (!enabled) { + targetPositions + } else { + interpolateLayoutPositions( + from = startPositions, + to = endPositions, + progress = transitionProgress.value, + ) + } +} + +private fun interpolateLayoutPositions( + from: List, + to: List, + progress: Float, +): List { + if (from.size != to.size) { + return to + } + val t = progress.coerceIn(0f, 1f) + if (t >= 0.999f) { + return to + } + if (t <= 0.001f) { + return from + } + val size = min(from.size, to.size) + return List(size) { index -> + val start = from[index] + val end = to[index] + androidx.compose.ui.geometry.Offset( + x = start.x + (end.x - start.x) * t, + y = start.y + (end.y - start.y) * t, + ) + } +} + +@Composable +private fun animatedLabelAlpha( + scale: Float, + enabled: Boolean, + threshold: Float, + durationMillis: Int, +): Float { + if (!enabled) { + return 1f + } + + val targetAlpha = if (scale < threshold) 0f else 1f + val alpha by animateFloatAsState( + targetValue = targetAlpha, + animationSpec = tween(durationMillis), + label = "gv-label-alpha", + ) + return alpha +} + +@Composable +private fun initialRenderProgress( + enabled: Boolean, + signature: Int, + durationMillis: Int, +): Float { + val animatable = remember(signature, enabled) { + Animatable(if (enabled) 0f else 1f) + } + + LaunchedEffect(signature, enabled, durationMillis) { + if (!enabled) { + animatable.snapTo(1f) + return@LaunchedEffect + } + animatable.snapTo(0f) + animatable.animateTo( + targetValue = 1f, + animationSpec = tween( + durationMillis = durationMillis.coerceAtLeast(1), + easing = FastOutSlowInEasing, + ), + ) + } + + return animatable.value +} + +private fun interpolateEntryPositions( + finalPositions: List, + center: androidx.compose.ui.geometry.Offset, + progress: Float, +): List { + val t = progress.coerceIn(0f, 1f) + if (t >= 0.999f) { + return finalPositions + } + + return finalPositions.map { target -> + androidx.compose.ui.geometry.Offset( + x = center.x + (target.x - center.x) * t, + y = center.y + (target.y - center.y) * t, + ) + } +} + +private fun nodeAppearanceScale(progress: Float): Float { + val clamped = progress.coerceIn(0f, 1f) + return 0.4f + (0.6f * clamped) +} + +@Composable +private fun GraphLabels( + projectedPositions: List, + nodeStyles: List, + nodeRadii: List, + labels: List, + alpha: Float, + config: GraphLabelConfig, +) { + val labelWidth = config.resolvedWidthDp.dp + val fontSize = config.resolvedFontSizeSp.sp + val verticalPaddingPx = with(LocalDensity.current) { config.resolvedVerticalPaddingDp.dp.toPx() } + val labelWidthPx = with(LocalDensity.current) { labelWidth.toPx() } + + projectedPositions.forEachIndexed { nodeId, position -> + val style = nodeStyles.getOrElse(nodeId) { nodeStyleFromInfo(defaultNodeInfo(""), isSelected = false) } + val radius = nodeRadii.getOrElse(nodeId) { style.radius } + val labelText = labels.getOrElse(nodeId) { "Node $nodeId" } + if (labelText.isBlank()) { + return@forEachIndexed + } + BasicText( + text = labelText, + style = TextStyle( + color = style.labelColor, + fontSize = fontSize, + textAlign = TextAlign.Center, + ), + modifier = Modifier + .alpha(alpha) + .offset { + IntOffset( + x = (position.x - (labelWidthPx * 0.5f)).roundToInt(), + y = (position.y + radius + verticalPaddingPx).roundToInt(), + ) + } + .width(labelWidth), + ) + } +} 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 new file mode 100644 index 0000000..01baa70 --- /dev/null +++ b/graph-visualizer/src/commonMain/kotlin/com/rootachieve/koraph/graphvisualizer/GraphVisualizerApi.kt @@ -0,0 +1,384 @@ +package com.rootachieve.koraph.graphvisualizer + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path + +@Stable +data class GraphVisualizerOptions( + val directed: Boolean = true, + val showArrows: Boolean = true, + val enablePanZoom: Boolean = true, + val enableTapSelection: Boolean = true, + val fitToViewport: Boolean = true, + val viewportPadding: Float = 48f, + val clearSelectionOnInit: Boolean = true, + val selectionColors: GraphSelectionColors = GraphSelectionColors(), + val animationFlags: Int = GraphAnimationFlags.COLOR_TRANSITION or + GraphAnimationFlags.LABEL_VISIBILITY_FADE or + GraphAnimationFlags.LAYOUT_TRANSITION, + val colorAnimationDurationMillis: Int = 220, + val labelFadeAnimationDurationMillis: Int = 220, + val layoutAnimationDurationMillis: Int = 280, + val labelFadeZoomThreshold: Float = 0.75f, + val initialRenderAnimationDurationMillis: Int = 900, + val layout: ForceLayoutConfig = ForceLayoutConfig(), + val interaction: GraphInteractionConfig = GraphInteractionConfig(), + val label: GraphLabelConfig = GraphLabelConfig(), +) { + companion object { + fun default(): GraphVisualizerOptions = GraphVisualizerOptions() + + fun performance( + directed: Boolean = true, + ): GraphVisualizerOptions { + return GraphVisualizerOptions( + directed = directed, + showArrows = directed, + animationFlags = GraphAnimationFlags.NONE, + layout = ForceLayoutConfig( + iterations = 180, + nodeRepulsion = 900f, + edgeTension = 0.018f, + centerTension = 0.03f, + damping = 0.92f, + convergenceThreshold = 0.9f, + baseEdgeLength = 78f, + collisionPadding = 6f, + collisionStrength = 0.5f, + maxVelocity = 16f, + ), + ) + } + + fun presentation( + directed: Boolean = true, + ): GraphVisualizerOptions { + return GraphVisualizerOptions( + directed = directed, + showArrows = directed, + animationFlags = GraphAnimationFlags.COLOR_TRANSITION or + GraphAnimationFlags.LABEL_VISIBILITY_FADE or + GraphAnimationFlags.LAYOUT_TRANSITION or + GraphAnimationFlags.INITIAL_RENDER, + colorAnimationDurationMillis = 260, + labelFadeAnimationDurationMillis = 260, + layoutAnimationDurationMillis = 300, + labelFadeZoomThreshold = 0.85f, + initialRenderAnimationDurationMillis = 1000, + layout = ForceLayoutConfig( + iterations = 320, + centerTension = 0.03f, + baseEdgeLength = 88f, + collisionPadding = 10f, + collisionStrength = 0.75f, + maxVelocity = 12f, + ), + ) + } + } +} + +@Stable +data class GraphInteractionConfig( + val minScale: Float = 0.35f, + val maxScale: Float = 4.5f, + val tapSelectionPadding: Float = 8f, + val clearSelectionOnBackgroundTap: Boolean = true, +) { + val resolvedMinScale: Float + get() = minScale.coerceAtLeast(0.01f) + + val resolvedMaxScale: Float + get() = maxScale.coerceAtLeast(resolvedMinScale) + + val resolvedTapSelectionPadding: Float + get() = tapSelectionPadding.coerceAtLeast(0f) +} + +@Stable +data class GraphLabelConfig( + val widthDp: Float = 96f, + val fontSizeSp: Float = 12f, + val verticalPaddingDp: Float = 6f, +) { + val resolvedWidthDp: Float + get() = widthDp.coerceAtLeast(24f) + + val resolvedFontSizeSp: Float + get() = fontSizeSp.coerceAtLeast(8f) + + val resolvedVerticalPaddingDp: Float + get() = verticalPaddingDp.coerceAtLeast(0f) +} + +object GraphAnimationFlags { + const val NONE: Int = 0 + const val COLOR_TRANSITION: Int = 1 + const val LABEL_VISIBILITY_FADE: Int = 1 shl 1 + const val INITIAL_RENDER: Int = 1 shl 2 + const val LAYOUT_TRANSITION: Int = 1 shl 3 +} + +fun Int.hasAnimationFlag(flag: Int): Boolean = (this and flag) == flag + +fun graphAnimationFlagsOf(vararg flags: Int): Int { + var merged = GraphAnimationFlags.NONE + for (flag in flags) { + merged = merged or flag + } + return merged +} + +@Stable +data class ForceLayoutConfig( + val iterations: Int = 300, + val nodeRepulsion: Float = 1200f, + val repulsionExponent: Float = 2f, + val edgeTension: Float = 0.02f, + val degreeAwareEdgeTension: Boolean = true, + val centerTension: Float = 0.02f, + val baseEdgeLength: Float = 84f, + val edgeDistanceScale: Float = 1f, + val damping: Float = 0.9f, + val convergenceThreshold: Float = 0.5f, + val randomSeed: Int = 42, + val collisionPadding: Float = 8f, + val collisionStrength: Float = 0.65f, + val maxVelocity: Float = 14f, +) + +sealed interface NodeShape { + data object Circle : NodeShape + data object RoundedRect : NodeShape + data object Diamond : NodeShape + data object Hexagon : NodeShape + + /** + * Return a closed [Path] in canvas coordinates. + * The path will be used for both fill and stroke drawing. + */ + data class Custom( + val pathBuilder: (center: Offset, radius: Float) -> Path, + ) : NodeShape +} + +@Stable +data class NodeInfo( + val name: String, + val style: NodeShape = NodeShape.Circle, + val size: Float = 18f, + val color: Color = Color(0xFF2563EB), + val selectedColor: Color = Color(0xFFF59E0B), + val strokeColor: Color = Color(0xFF1E3A8A), + val selectedStrokeColor: Color = Color(0xFF7C2D12), + val labelColor: Color = Color(0xFF111827), +) + +@Stable +data class GraphStateColor( + val nodeColor: Color? = null, + val borderColor: Color? = null, + val edgeColor: Color? = null, +) + +@Stable +data class GraphSelectionColors( + val selected: GraphStateColor = GraphStateColor(), + val otherSelected: GraphStateColor = GraphStateColor(), + val noneSelected: GraphStateColor = GraphStateColor(), +) + +enum class SelectionState { + Selected, + OtherSelected, + NoneSelected, +} + +@Stable +data class NodeStyle( + val shape: NodeShape, + val fillColor: Color, + val strokeColor: Color, + val radius: Float, + val labelColor: Color, +) + +@Stable +data class EdgeStyle( + val color: Color, + val width: Float, + val dashed: Boolean = false, +) + +@Stable +data class NodeStyleInput( + val nodeId: Int, + val key: K, + val nodeInfo: NodeInfo, + val isSelected: Boolean, + val selectionState: SelectionState, +) + +@Stable +data class EdgeStyleInput( + val fromId: Int, + val toId: Int, + val from: K, + val to: K, + val isSelected: Boolean, + val selectionState: SelectionState, +) + +@Stable +data class GraphVisualizerState( + private val scaleState: MutableState, + private val offsetState: MutableState, + private val selectedNodeIdState: MutableState, +) { + val scale: Float + get() = scaleState.value + + val offset: Offset + get() = offsetState.value + + val selectedNodeId: Int? + get() = selectedNodeIdState.value + + fun updateScale(value: Float) { + scaleState.value = value + } + + fun updateOffset(value: Offset) { + offsetState.value = value + } + + fun updateSelectedNodeId(value: Int?) { + selectedNodeIdState.value = value + } + + fun resetView( + scale: Float = 1f, + offset: Offset = Offset.Zero, + clearSelection: Boolean = true, + ) { + updateScale(scale.coerceAtLeast(0.01f)) + updateOffset(offset) + if (clearSelection) { + updateSelectedNodeId(null) + } + } + + fun applyTransform( + centroid: Offset, + pan: Offset, + zoom: Float, + minScale: Float = 0.35f, + maxScale: Float = 4.5f, + ) { + val previousScale = scale.coerceAtLeast(0.01f) + val nextScale = (previousScale * zoom).coerceIn(minScale, maxScale) + val scaleFactor = nextScale / previousScale + + val shiftedOffset = offset - centroid + val scaledShiftedOffset = Offset( + x = shiftedOffset.x * scaleFactor, + y = shiftedOffset.y * scaleFactor, + ) + + updateOffset(centroid + scaledShiftedOffset + pan) + updateScale(nextScale) + } +} + +@Composable +fun rememberGraphVisualizerState( + initialScale: Float = 1f, + initialOffset: Offset = Offset.Zero, + initialSelectedNodeId: Int? = null, +): GraphVisualizerState { + val scaleState = remember { mutableStateOf(initialScale.coerceAtLeast(0.01f)) } + val offsetState = remember { mutableStateOf(initialOffset) } + val selectedNodeIdState = remember { mutableStateOf(initialSelectedNodeId) } + + return remember { + GraphVisualizerState( + scaleState = scaleState, + offsetState = offsetState, + selectedNodeIdState = selectedNodeIdState, + ) + } +} + +fun defaultNodeInfo(name: String): NodeInfo { + return NodeInfo(name = name) +} + +fun nodeStyleFromInfo(nodeInfo: NodeInfo, isSelected: Boolean): NodeStyle { + return NodeStyle( + shape = nodeInfo.style, + fillColor = if (isSelected) nodeInfo.selectedColor else nodeInfo.color, + strokeColor = if (isSelected) nodeInfo.selectedStrokeColor else nodeInfo.strokeColor, + radius = nodeInfo.size, + labelColor = nodeInfo.labelColor, + ) +} + +fun defaultNodeStyle( + input: NodeStyleInput, + selectionColors: GraphSelectionColors, +): NodeStyle { + val stateColor = when (input.selectionState) { + SelectionState.Selected -> selectionColors.selected + SelectionState.OtherSelected -> selectionColors.otherSelected + SelectionState.NoneSelected -> selectionColors.noneSelected + } + + val defaultNodeColor = when (input.selectionState) { + SelectionState.Selected -> input.nodeInfo.selectedColor + SelectionState.OtherSelected -> input.nodeInfo.color + SelectionState.NoneSelected -> input.nodeInfo.color + } + val defaultBorderColor = when (input.selectionState) { + SelectionState.Selected -> input.nodeInfo.selectedStrokeColor + SelectionState.OtherSelected -> input.nodeInfo.strokeColor + SelectionState.NoneSelected -> input.nodeInfo.strokeColor + } + + return NodeStyle( + shape = input.nodeInfo.style, + fillColor = stateColor.nodeColor ?: defaultNodeColor, + strokeColor = stateColor.borderColor ?: defaultBorderColor, + radius = input.nodeInfo.size, + labelColor = input.nodeInfo.labelColor, + ) +} + +fun defaultEdgeStyle(isSelected: Boolean): EdgeStyle { + return EdgeStyle( + color = if (isSelected) Color(0xFFF59E0B) else Color(0xFF64748B), + width = if (isSelected) 3.5f else 2f, + dashed = false, + ) +} + +fun defaultEdgeStyle( + input: EdgeStyleInput, + selectionColors: GraphSelectionColors, +): EdgeStyle { + val stateColor = when (input.selectionState) { + SelectionState.Selected -> selectionColors.selected + SelectionState.OtherSelected -> selectionColors.otherSelected + SelectionState.NoneSelected -> selectionColors.noneSelected + } + val fallback = defaultEdgeStyle(isSelected = input.isSelected) + + return fallback.copy( + color = stateColor.edgeColor ?: fallback.color, + ) +} 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 new file mode 100644 index 0000000..ba44dcf --- /dev/null +++ b/graph-visualizer/src/commonTest/kotlin/com/rootachieve/koraph/graphvisualizer/ForceLayoutEngineTest.kt @@ -0,0 +1,301 @@ +package com.rootachieve.koraph.graphvisualizer + +import kotlin.math.abs +import kotlin.math.min +import kotlin.math.sqrt +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ForceLayoutEngineTest { + + @Test + fun computeForceLayout_isDeterministicForSameSeed() { + // given + val edges = listOf( + LayoutEdge(0, 1), + LayoutEdge(1, 2), + LayoutEdge(2, 3), + LayoutEdge(3, 0), + ) + val config = ForceLayoutConfig( + iterations = 120, + randomSeed = 2026, + ) + + // when + val first = computeForceLayout(nodeCount = 4, edges = edges, config = config) + val second = computeForceLayout(nodeCount = 4, edges = edges, config = config) + + // then + assertEquals(first.positions.size, second.positions.size) + assertEquals(first.iterationsPerformed, second.iterationsPerformed) + + first.positions.indices.forEach { index -> + assertClose(first.positions[index].x, second.positions[index].x) + assertClose(first.positions[index].y, second.positions[index].y) + } + } + + @Test + fun computeForceLayout_stopsEarlyWhenConvergenceThresholdIsHigh() { + // given + val edges = listOf( + LayoutEdge(0, 1), + LayoutEdge(1, 2), + ) + val config = ForceLayoutConfig( + iterations = 300, + convergenceThreshold = 9999f, + randomSeed = 123, + ) + + // when + val result = computeForceLayout(nodeCount = 3, edges = edges, config = config) + + // then + assertEquals(1, result.iterationsPerformed) + } + + @Test + fun computeForceLayout_centerTensionCompactsDisconnectedComponents() { + // given + val disconnectedEdges = listOf( + LayoutEdge(0, 1), + LayoutEdge(1, 2), + LayoutEdge(2, 3), + LayoutEdge(4, 5), + LayoutEdge(5, 6), + LayoutEdge(6, 7), + ) + val baseConfig = ForceLayoutConfig( + iterations = 180, + centerTension = 0f, + randomSeed = 77, + ) + val compactConfig = ForceLayoutConfig( + iterations = 180, + centerTension = 0.06f, + randomSeed = 77, + ) + + // when + val base = computeForceLayout( + nodeCount = 8, + edges = disconnectedEdges, + config = baseConfig, + ) + val compact = computeForceLayout( + nodeCount = 8, + edges = disconnectedEdges, + config = compactConfig, + ) + + val baseMaxRadius = base.positions.maxOf { position -> + sqrt(position.x * position.x + position.y * position.y) + } + val compactMaxRadius = compact.positions.maxOf { position -> + sqrt(position.x * position.x + position.y * position.y) + } + + // then + assertTrue(compactMaxRadius < baseMaxRadius) + } + + @Test + fun computeForceLayout_collisionAvoidanceIncreasesMinimumSpacing() { + // given + val edges = listOf( + LayoutEdge(0, 1), + LayoutEdge(1, 2), + LayoutEdge(2, 3), + LayoutEdge(3, 4), + ) + val nodeRadii = listOf(20f, 20f, 20f, 20f, 20f) + val withoutCollisionConfig = ForceLayoutConfig( + iterations = 180, + nodeRepulsion = 250f, + edgeTension = 0.075f, + baseEdgeLength = 24f, + collisionPadding = 0f, + collisionStrength = 0f, + randomSeed = 99, + ) + val withCollisionConfig = ForceLayoutConfig( + iterations = 180, + nodeRepulsion = 250f, + edgeTension = 0.075f, + baseEdgeLength = 24f, + collisionPadding = 10f, + collisionStrength = 0.9f, + randomSeed = 99, + ) + + // when + val withoutCollision = computeForceLayout( + nodeCount = 5, + edges = edges, + config = withoutCollisionConfig, + nodeRadii = nodeRadii, + ) + val withCollision = computeForceLayout( + nodeCount = 5, + edges = edges, + config = withCollisionConfig, + nodeRadii = nodeRadii, + ) + + val noCollisionMinDistance = minimumPairDistance(withoutCollision.positions) + val withCollisionMinDistance = minimumPairDistance(withCollision.positions) + + // then + assertTrue(withCollisionMinDistance > noCollisionMinDistance) + } + + @Test + fun computeForceLayout_edgeDistanceScaleExpandsEdgeLength() { + // given + val edges = listOf( + LayoutEdge(0, 1), + LayoutEdge(1, 2), + LayoutEdge(2, 3), + LayoutEdge(3, 0), + ) + val baseConfig = ForceLayoutConfig( + iterations = 220, + randomSeed = 314, + edgeDistanceScale = 1f, + ) + val compactConfig = baseConfig.copy(edgeDistanceScale = 0.8f) + val expandedConfig = baseConfig.copy(edgeDistanceScale = 1.8f) + + // when + val compact = computeForceLayout( + nodeCount = 4, + edges = edges, + config = compactConfig, + ) + val expanded = computeForceLayout( + nodeCount = 4, + edges = edges, + config = expandedConfig, + ) + + val compactAverage = averageEdgeLength(compact.positions, edges) + val expandedAverage = averageEdgeLength(expanded.positions, edges) + + // then + assertTrue(expandedAverage > compactAverage * 1.8f) + } + + @Test + fun buildLayoutEdges_deduplicatesBidirectionalAndSelfEdges() { + // given + val edges = listOf( + GraphEdge(fromId = 0, toId = 1, from = "a", to = "b"), + GraphEdge(fromId = 1, toId = 0, from = "b", to = "a"), + GraphEdge(fromId = 0, toId = 1, from = "a", to = "b"), + GraphEdge(fromId = 2, toId = 2, from = "c", to = "c"), + ) + + // when + val layoutEdges = buildLayoutEdges(edges) + + // then + assertEquals(listOf(LayoutEdge(0, 1)), layoutEdges) + } + + @Test + fun scaledNodeRadius_clampsToSafeRange() { + // given + val tooSmallBaseRadius = 2f + val normalBaseRadius = 18f + val tooLargeBaseRadius = 30f + + // when + val clampedSmall = scaledNodeRadius(baseRadius = tooSmallBaseRadius, currentScale = 0.1f) + val unchangedNormal = scaledNodeRadius(baseRadius = normalBaseRadius, currentScale = 1f) + val clampedLarge = scaledNodeRadius(baseRadius = tooLargeBaseRadius, currentScale = 2f) + + // then + assertEquals(8f, clampedSmall) + assertEquals(44f, clampedLarge) + assertEquals(18f, unchangedNormal) + } + + @Test + fun calculateBaseTransform_returnsFiniteScale() { + // given + val positions = listOf( + androidx.compose.ui.geometry.Offset(-10f, -20f), + androidx.compose.ui.geometry.Offset(40f, 30f), + ) + + // when + val transform = calculateBaseTransform( + positions = positions, + canvasSize = androidx.compose.ui.unit.IntSize(1080, 720), + ) + + // then + assertTrue(transform.baseScale.isFinite()) + assertTrue(transform.baseScale > 0f) + } + + @Test + fun calculateBaseTransform_canDisableAutoFit() { + // given + val positions = listOf( + androidx.compose.ui.geometry.Offset(-200f, -200f), + androidx.compose.ui.geometry.Offset(300f, 300f), + ) + + // when + val transform = calculateBaseTransform( + positions = positions, + canvasSize = androidx.compose.ui.unit.IntSize(1080, 720), + fitToBounds = false, + ) + + // then + assertEquals(1f, transform.baseScale) + } + + private fun assertClose(expected: Float, actual: Float, tolerance: Float = 0.0001f) { + assertTrue( + abs(expected - actual) <= tolerance, + "Expected $expected but was $actual (tol=$tolerance)", + ) + } + + private fun minimumPairDistance(positions: List): Float { + var minDistance = Float.POSITIVE_INFINITY + for (i in positions.indices) { + for (j in i + 1 until positions.size) { + val dx = positions[j].x - positions[i].x + val dy = positions[j].y - positions[i].y + val distance = sqrt(dx * dx + dy * dy) + minDistance = min(minDistance, distance) + } + } + return minDistance + } + + private fun averageEdgeLength( + positions: List, + edges: List, + ): Float { + if (edges.isEmpty()) { + return 0f + } + var total = 0f + edges.forEach { edge -> + val from = positions[edge.fromId] + val to = positions[edge.toId] + val dx = to.x - from.x + val dy = to.y - from.y + total += sqrt(dx * dx + dy * dy) + } + return total / edges.size + } +} diff --git a/graph-visualizer/src/commonTest/kotlin/com/rootachieve/koraph/graphvisualizer/GraphModelTest.kt b/graph-visualizer/src/commonTest/kotlin/com/rootachieve/koraph/graphvisualizer/GraphModelTest.kt new file mode 100644 index 0000000..11f55a7 --- /dev/null +++ b/graph-visualizer/src/commonTest/kotlin/com/rootachieve/koraph/graphvisualizer/GraphModelTest.kt @@ -0,0 +1,156 @@ +package com.rootachieve.koraph.graphvisualizer + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class GraphModelTest { + + @Test + fun buildGraphModel_includesNodesFromAdjacencyAndNodeInfo() { + // given + val adjacency = mapOf( + "gateway" to listOf("search", "alerts"), + "search" to listOf("users"), + ) + val nodeInfo = mapOf( + "gateway" to NodeInfo(name = "Gateway", size = 24f), + "archive" to NodeInfo(name = "Archive", size = 15f), + ) + + // when + val model = buildGraphModel( + adjacency = adjacency, + nodeInfo = nodeInfo, + ) + + // then + assertEquals(5, model.nodeCount) + assertTrue(model.nodeKeys.contains("archive")) + assertEquals(3, model.edges.size) + assertTrue(model.edges.any { it.from == "gateway" && it.to == "alerts" }) + } + + @Test + fun buildRenderableEdges_undirectedModeDeduplicatesPairs() { + // given + val edges = listOf( + GraphEdge(fromId = 0, toId = 1, from = "a", to = "b"), + GraphEdge(fromId = 1, toId = 0, from = "b", to = "a"), + GraphEdge(fromId = 1, toId = 2, from = "b", to = "c"), + ) + + // when + val renderable = buildRenderableEdges( + edges = edges, + directed = false, + showArrows = true, + ) + + // then + assertEquals(2, renderable.size) + assertTrue(renderable.all { !it.drawArrow }) + assertTrue(renderable.any { it.fromId == 0 && it.toId == 1 }) + assertTrue(renderable.any { it.fromId == 1 && it.toId == 2 }) + } + + @Test + fun buildRenderableEdges_undirectedMode_alignsKeysWithCanonicalIds() { + // given + val edges = listOf( + GraphEdge(fromId = 3, toId = 1, from = "payments", to = "auth"), + ) + + // when + val renderable = buildRenderableEdges( + edges = edges, + directed = false, + showArrows = true, + ) + + // then + assertEquals(1, renderable.size) + assertEquals(1, renderable[0].fromId) + assertEquals(3, renderable[0].toId) + assertEquals("auth", renderable[0].from) + assertEquals("payments", renderable[0].to) + } + + @Test + fun buildRenderableEdges_directedModeRespectsArrowOption() { + // given + val edges = listOf( + GraphEdge(fromId = 0, toId = 1, from = "a", to = "b"), + GraphEdge(fromId = 1, toId = 2, from = "b", to = "c"), + ) + + // when + val withArrows = buildRenderableEdges( + edges = edges, + directed = true, + showArrows = true, + ) + val withoutArrows = buildRenderableEdges( + edges = edges, + directed = true, + showArrows = false, + ) + + // then + assertTrue(withArrows.all { it.drawArrow }) + assertFalse(withoutArrows.any { it.drawArrow }) + } + + @Test + fun adjacencySignature_changesWhenAdjacencyOrNodeInfoChanges() { + // given + val adjacency = mutableMapOf( + "a" to listOf("b"), + ) + val nodeInfo = mutableMapOf( + "a" to NodeInfo(name = "A"), + ) + + // when + val first = adjacencySignature(adjacency, nodeInfo) + adjacency["b"] = listOf("a") + val second = adjacencySignature(adjacency, nodeInfo) + nodeInfo["b"] = NodeInfo(name = "B") + val third = adjacencySignature(adjacency, nodeInfo) + + // then + assertNotEquals(first, second) + assertNotEquals(second, third) + } + + @Test + fun buildGraphModel_usesFallbackNodeInfoForMissingEntries() { + // given + val adjacency = mapOf( + "gateway" to listOf("search"), + ) + val nodeInfo = mapOf( + "gateway" to NodeInfo(name = "Gateway"), + ) + val fallbackNodeInfo: (String) -> NodeInfo = { key -> + NodeInfo( + name = "Auto-$key", + size = 22f, + ) + } + + // when + val model = buildGraphModel( + adjacency = adjacency, + nodeInfo = nodeInfo, + fallbackNodeInfo = fallbackNodeInfo, + ) + + // then + assertEquals("Gateway", model.nodeInfoByKey.getValue("gateway").name) + assertEquals("Auto-search", model.nodeInfoByKey.getValue("search").name) + assertEquals(22f, model.nodeInfoByKey.getValue("search").size) + } +} 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 new file mode 100644 index 0000000..79c29a0 --- /dev/null +++ b/graph-visualizer/src/commonTest/kotlin/com/rootachieve/koraph/graphvisualizer/StyleAndInteractionTest.kt @@ -0,0 +1,401 @@ +package com.rootachieve.koraph.graphvisualizer + +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class StyleAndInteractionTest { + + @Test + fun resolveNodeStyles_usesNodeInfoNameShapeSizeAndColor() { + // given + val model = buildGraphModel( + adjacency = mapOf( + "gateway" to listOf("search"), + ), + nodeInfo = mapOf( + "gateway" to NodeInfo( + name = "Gateway", + style = NodeShape.Custom { center, radius -> + Path().apply { + moveTo(center.x, center.y - radius) + lineTo(center.x + radius, center.y) + lineTo(center.x, center.y + radius) + lineTo(center.x - radius, center.y) + close() + } + }, + size = 24f, + color = Color(0xFF2563EB), + selectedColor = Color(0xFF93C5FD), + ), + "search" to NodeInfo( + name = "Search", + style = NodeShape.Hexagon, + size = 20f, + color = Color(0xFF10B981), + ), + ), + ) + + // when + val nodeStyles = resolveNodeStyles( + graphModel = model, + selectedNodeId = 0, + styleProvider = { input -> + defaultNodeStyle( + input = input, + selectionColors = GraphSelectionColors( + selected = GraphStateColor(nodeColor = Color(0xFFFF0000)), + otherSelected = GraphStateColor(nodeColor = Color(0xFF00FF00)), + noneSelected = GraphStateColor(nodeColor = Color(0xFF0000FF)), + ), + ) + }, + ) + val labels = resolveNodeLabels(model) + + // then + assertEquals("Gateway", labels[0]) + assertTrue(nodeStyles[0].shape is NodeShape.Custom) + assertEquals(24f, nodeStyles[0].radius) + assertEquals(Color(0xFFFF0000), nodeStyles[0].fillColor) + assertEquals(NodeShape.Hexagon, nodeStyles[1].shape) + assertEquals(20f, nodeStyles[1].radius) + assertEquals(Color(0xFF00FF00), nodeStyles[1].fillColor) + } + + @Test + fun resolveEdgeStyles_marksEdgesConnectedToSelectedNode() { + // given + val renderableEdges = listOf( + RenderableEdge(fromId = 0, toId = 1, from = "a", to = "b", drawArrow = true), + RenderableEdge(fromId = 2, toId = 3, from = "c", to = "d", drawArrow = true), + ) + + // when + val edgeStyles = resolveEdgeStyles( + renderableEdges = renderableEdges, + selectedNodeId = 1, + styleProvider = { input -> + defaultEdgeStyle( + input = input, + selectionColors = GraphSelectionColors( + selected = GraphStateColor(edgeColor = Color.Yellow), + otherSelected = GraphStateColor(edgeColor = Color.Gray), + noneSelected = GraphStateColor(edgeColor = Color.Black), + ), + ) + }, + ) + + // then + assertEquals(Color.Yellow, edgeStyles[0].color) + assertEquals(Color.Gray, edgeStyles[1].color) + } + + @Test + fun resolveNodeLabels_canHideNodesByLabelProvider() { + // given + val model = buildGraphModel( + adjacency = mapOf( + "selected" to listOf("linked"), + "other" to emptyList(), + ), + nodeInfo = mapOf( + "selected" to NodeInfo(name = "Selected"), + "linked" to NodeInfo(name = "Linked"), + "other" to NodeInfo(name = "Other"), + ), + ) + + // when + val labels = resolveNodeLabels( + graphModel = model, + selectedNodeId = 0, + labelProvider = { input -> + when (input.key) { + "selected", + "linked", + -> input.nodeInfo.name + else -> null + } + }, + ) + + // then + assertEquals("Selected", labels[0]) + assertEquals("Linked", labels[1]) + assertEquals("", labels[2]) + } + + @Test + fun defaultStyles_supportThreeSelectionStates() { + // given + val nodeInfo = NodeInfo(name = "N", color = Color(0xFF123456), strokeColor = Color(0xFF654321)) + val colors = GraphSelectionColors( + selected = GraphStateColor( + nodeColor = Color(0xFFAAAAAA), + borderColor = Color(0xFF111111), + edgeColor = Color(0xFF222222), + ), + otherSelected = GraphStateColor( + nodeColor = Color(0xFFBBBBBB), + borderColor = Color(0xFF333333), + edgeColor = Color(0xFF444444), + ), + noneSelected = GraphStateColor( + nodeColor = Color(0xFFCCCCCC), + borderColor = Color(0xFF555555), + edgeColor = Color(0xFF666666), + ), + ) + + // when + val selectedNode = defaultNodeStyle( + input = NodeStyleInput( + nodeId = 0, + key = "a", + nodeInfo = nodeInfo, + isSelected = true, + selectionState = SelectionState.Selected, + ), + selectionColors = colors, + ) + val otherNode = defaultNodeStyle( + input = NodeStyleInput( + nodeId = 1, + key = "b", + nodeInfo = nodeInfo, + isSelected = false, + selectionState = SelectionState.OtherSelected, + ), + selectionColors = colors, + ) + val noneNode = defaultNodeStyle( + input = NodeStyleInput( + nodeId = 2, + key = "c", + nodeInfo = nodeInfo, + isSelected = false, + selectionState = SelectionState.NoneSelected, + ), + selectionColors = colors, + ) + + assertEquals(Color(0xFFAAAAAA), selectedNode.fillColor) + assertEquals(Color(0xFF111111), selectedNode.strokeColor) + assertEquals(Color(0xFFBBBBBB), otherNode.fillColor) + assertEquals(Color(0xFF333333), otherNode.strokeColor) + assertEquals(Color(0xFFCCCCCC), noneNode.fillColor) + assertEquals(Color(0xFF555555), noneNode.strokeColor) + + val selectedEdge = defaultEdgeStyle( + input = EdgeStyleInput( + fromId = 0, + toId = 1, + from = "a", + to = "b", + isSelected = true, + selectionState = SelectionState.Selected, + ), + selectionColors = colors, + ) + val otherEdge = defaultEdgeStyle( + input = EdgeStyleInput( + fromId = 1, + toId = 2, + from = "b", + to = "c", + isSelected = false, + selectionState = SelectionState.OtherSelected, + ), + selectionColors = colors, + ) + val noneEdge = defaultEdgeStyle( + input = EdgeStyleInput( + fromId = 2, + toId = 3, + from = "c", + to = "d", + isSelected = false, + selectionState = SelectionState.NoneSelected, + ), + selectionColors = colors, + ) + + // then + assertEquals(Color(0xFF222222), selectedEdge.color) + assertEquals(Color(0xFF444444), otherEdge.color) + assertEquals(Color(0xFF666666), noneEdge.color) + } + + @Test + fun findNodeAt_returnsClosestNodeWithinRadius() { + // given + val nodes = listOf( + Offset(10f, 10f), + Offset(40f, 10f), + Offset(80f, 10f), + ) + + // when + val selected = findNodeAt( + pointerPosition = Offset(37f, 12f), + projectedPositions = nodes, + radiusProvider = { 10f }, + ) + val notSelected = findNodeAt( + pointerPosition = Offset(500f, 500f), + projectedPositions = nodes, + radiusProvider = { 10f }, + ) + + // then + assertEquals(1, selected) + assertNull(notSelected) + } + + @Test + fun graphVisualizerState_applyTransformUpdatesScaleAndOffset() { + // given + val state = GraphVisualizerState( + scaleState = mutableStateOf(1f), + offsetState = mutableStateOf(Offset.Zero), + selectedNodeIdState = mutableStateOf(null), + ) + + // when + state.applyTransform( + centroid = Offset(100f, 100f), + pan = Offset(20f, -8f), + zoom = 1.5f, + ) + + // then + assertTrue(state.scale > 1f) + assertTrue(state.offset != Offset.Zero) + } + + @Test + fun graphVisualizerState_resetViewClearsSelection() { + // given + val state = GraphVisualizerState( + scaleState = mutableStateOf(2.2f), + offsetState = mutableStateOf(Offset(80f, -30f)), + selectedNodeIdState = mutableStateOf(3), + ) + + // when + state.resetView() + + // then + assertEquals(1f, state.scale) + assertEquals(Offset.Zero, state.offset) + assertNull(state.selectedNodeId) + } + + @Test + fun animationFlags_allowBitwiseCombinationWithPlusOrAnd() { + // given + val unknownFlag = GraphAnimationFlags.NONE + (1 shl 5) + + // when + val flags = GraphAnimationFlags.COLOR_TRANSITION + + GraphAnimationFlags.LABEL_VISIBILITY_FADE + + GraphAnimationFlags.INITIAL_RENDER + + // then + assertTrue(flags.hasAnimationFlag(GraphAnimationFlags.COLOR_TRANSITION)) + assertTrue(flags.hasAnimationFlag(GraphAnimationFlags.LABEL_VISIBILITY_FADE)) + assertTrue(flags.hasAnimationFlag(GraphAnimationFlags.INITIAL_RENDER)) + assertFalse(flags.hasAnimationFlag(unknownFlag)) + } + + @Test + fun graphAnimationFlagsOf_mergesFlagsConveniently() { + // given + val targetFlags = intArrayOf( + GraphAnimationFlags.COLOR_TRANSITION, + GraphAnimationFlags.LABEL_VISIBILITY_FADE, + GraphAnimationFlags.INITIAL_RENDER, + ) + + // when + val flags = graphAnimationFlagsOf( + *targetFlags, + ) + + // then + assertTrue(flags.hasAnimationFlag(GraphAnimationFlags.COLOR_TRANSITION)) + assertTrue(flags.hasAnimationFlag(GraphAnimationFlags.LABEL_VISIBILITY_FADE)) + assertTrue(flags.hasAnimationFlag(GraphAnimationFlags.INITIAL_RENDER)) + } + + @Test + fun graphVisualizerOptions_presets_areConvenientAndConsistent() { + // given + val defaultOptions = GraphVisualizerOptions() + + // when + val defaults = GraphVisualizerOptions.default() + val performance = GraphVisualizerOptions.performance() + val presentation = GraphVisualizerOptions.presentation() + + // then + assertEquals(defaultOptions, defaults) + assertEquals(GraphAnimationFlags.NONE, performance.animationFlags) + assertTrue(performance.layout.iterations < defaults.layout.iterations) + assertTrue(presentation.animationFlags.hasAnimationFlag(GraphAnimationFlags.COLOR_TRANSITION)) + assertTrue(presentation.animationFlags.hasAnimationFlag(GraphAnimationFlags.LABEL_VISIBILITY_FADE)) + assertTrue(presentation.animationFlags.hasAnimationFlag(GraphAnimationFlags.INITIAL_RENDER)) + } + + @Test + fun graphInteractionConfig_clampsUnsafeValues() { + // given + val minScale = -3f + val maxScale = 0f + val tapSelectionPadding = -2f + + // when + val config = GraphInteractionConfig( + minScale = minScale, + maxScale = maxScale, + tapSelectionPadding = tapSelectionPadding, + clearSelectionOnBackgroundTap = false, + ) + + // then + assertEquals(0.01f, config.resolvedMinScale) + assertEquals(0.01f, config.resolvedMaxScale) + assertEquals(0f, config.resolvedTapSelectionPadding) + assertFalse(config.clearSelectionOnBackgroundTap) + } + + @Test + fun graphLabelConfig_clampsUnsafeValues() { + // given + val widthDp = 0f + val fontSizeSp = 2f + val verticalPaddingDp = -4f + + // when + val config = GraphLabelConfig( + widthDp = widthDp, + fontSizeSp = fontSizeSp, + verticalPaddingDp = verticalPaddingDp, + ) + + // then + assertEquals(24f, config.resolvedWidthDp) + assertEquals(8f, config.resolvedFontSizeSp) + assertEquals(0f, config.resolvedVerticalPaddingDp) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index a6e0b4e..2ee0d08 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -28,4 +28,5 @@ dependencyResolutionManagement { } } -include(":composeApp") \ No newline at end of file +include(":composeApp") +include(":graph-visualizer")