diff --git a/.github/workflows/pullrequest.yml b/.github/workflows/pullrequest.yml index 1e5b48139..710eefbe9 100644 --- a/.github/workflows/pullrequest.yml +++ b/.github/workflows/pullrequest.yml @@ -125,3 +125,49 @@ jobs: with: name: Chart-Lint-report path: ./**/build/reports/lint-results*.html + KotlinMultiplatform: + name: Kotlin Multiplatform (iOS/Desktop) + # iOS simulator targets (chartLibCore/chartLibComposeMultiplatform) require a macOS runner + # with Xcode; on the ubuntu "Check" job these tasks are silently skipped + # (kotlin.native.ignoreDisabledTargets=true), so this job is the only place they actually run. + runs-on: macos-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + submodules: true + - name: Install JDK + uses: actions/setup-java@v6 + with: + distribution: "temurin" + java-version: 17 + - name: Install Android SDK + uses: hannesa2/action-android/install-sdk@0.17.0 + - name: Run chartLibCore/chartLibComposeMultiplatform tests (Android host, Desktop, iOS simulator) + run: ./gradlew :chartLibCore:allTests :chartLibComposeMultiplatform:allTests + - name: Build demoKmp (all targets) and demoKmpAndroid + run: ./gradlew :demoKmp:build :demoKmpAndroid:assembleDebug + - name: Desktop smoke-test (demoKmp:run starts without crashing) + run: | + ./gradlew :demoKmp:run --console=plain & + run_pid=$! + sleep 25 + if kill -0 "$run_pid" 2>/dev/null; then + echo "demoKmp:run is still running after 25s -> desktop app launched successfully" + kill "$run_pid" + wait "$run_pid" 2>/dev/null || true + else + wait "$run_pid" + exit_code=$? + echo "demoKmp:run exited early with code $exit_code" + exit 1 + fi + - name: Archive Kotlin Multiplatform test reports + uses: actions/upload-artifact@v7 + if: ${{ always() }} + with: + name: Chart-KotlinMultiplatform-test-report + path: | + chartLibCore/build/reports/tests + chartLibComposeMultiplatform/build/reports/tests + if-no-files-found: warn diff --git a/KotlinMultiplatform.md b/KotlinMultiplatform.md new file mode 100644 index 000000000..55af38d66 --- /dev/null +++ b/KotlinMultiplatform.md @@ -0,0 +1,1414 @@ +# Kotlin Multiplatform (KMP) Plan for AndroidChart + +This document tracks the plan to add a Kotlin Multiplatform library and demo app to +AndroidChart, alongside the existing `chartLib` (View-based) and `chartLibCompose` +(Android-only Compose wrapper) modules. + +## Why this is not a small config change + +`chartLib` and `chartLibCompose` are deeply coupled to Android APIs: + +- `chartLib` uses `android.graphics.Canvas/Paint/Bitmap/Matrix/RectF/Typeface/DashPathEffect/ + Drawable`, `android.view.View/MotionEvent/ViewGroup`, `android.content.Context`, + `android.os.Parcelable/Handler` throughout renderers, data classes, and components. +- `chartLibCompose` does **not** draw with Compose `Canvas`/`DrawScope`. Every + `*Composable.kt` wraps the existing View-based chart via `AndroidView` interop + (`androidx.compose.ui.viewinterop.AndroidView`). It is Android-only end to end. + +A scan of `data/`, `components/`, `utils/`, `interfaces/` shows Android types baked +directly into field/property signatures (not just implementation details), e.g.: + +| Android type | Where used | Occurrences | +|----------------------------|-----------------------------------------------|-------------| +| `android.graphics.drawable.Drawable` | `BaseEntry.icon`, `MarkerImage` | 17 | +| `android.annotation.SuppressLint` | misc | 14 | +| `android.graphics.DashPathEffect` | `IDataSet`, axis/line dash styling | 6 | +| `android.graphics.Color` | `ColorTemplate`, components | 6 | +| `android.os.Build` | version checks | 4 | +| `android.graphics.Typeface` | `ComponentBase`, `IDataSet` | 3 | +| `android.os.Parcelable`/`Parcel` | `EntryFloat`, `PointF`, etc. | 4 | +| `android.graphics.Paint` | `AxisBase`, renderers | 2 | +| `android.content.Context` | `AssetManagerUtils`, `ContextUtils` | 2 | + +Because of this, there is no small "first slice" that avoids introducing common +(platform-agnostic) replacement abstractions — nearly every core class in `data/`, +`components/`, and `utils/` needs at least one Android type removed from its public +API before it can move into a `commonMain` source set. + +## Two possible strategies (recap) + +**A. Compose Multiplatform rewrite (recommended)** — rewrite `chartLibCompose` (or a +new sibling module) to draw with `androidx.compose.foundation.Canvas` / `DrawScope` +instead of wrapping `AndroidView`, and target Kotlin Multiplatform + JetBrains Compose +Multiplatform (Android, iOS, Desktop, Wasm/JS). This reuses Compose's own drawing, +gesture, and text APIs instead of reinventing Canvas/Paint/Matrix abstractions. + +**B. Full KMP for `chartLib` itself** — keep the View-based renderer but introduce +`expect`/`actual` for `Canvas`, `Paint`, `Bitmap`, `Context`, gesture input, etc. Far +larger in scope (touches ~180 files, all 22+ renderers, all touch listeners, save/export +utils) and duplicates what Compose Multiplatform already provides. Not recommended +unless non-Compose consumers on other platforms are a hard requirement. + +This plan follows **Strategy A**, built up incrementally by first extracting a +platform-agnostic core data/logic module (`chartLibCore`) that both the existing +Android `chartLib` and a future Compose Multiplatform renderer can share. + +## Module layout (target end state) + +``` +chartLib Android View-based chart library (unchanged, Android-only) +chartLibCore NEW: Kotlin Multiplatform pure-Kotlin data model & logic + (commonMain + androidMain + iosMain + desktop jvm) +chartLibCompose Android-only Compose wrapper around chartLib (kept as-is for + AndroidView-based consumers, or deprecated once + chartLibComposeMultiplatform matures) +chartLibComposeMultiplatform NEW: Compose Multiplatform renderer built on + chartLibCore + Compose Canvas/DrawScope + (commonMain + androidMain + iosMain + desktop + wasmJs) +app Existing Android demo app (unchanged) +demoKmp NEW: Compose Multiplatform demo app consuming + chartLibComposeMultiplatform on Android/iOS/Desktop/Wasm +lint Existing custom lint rules (unchanged) +``` + +## Status + +- [x] **Step 0 — Feasibility scan.** Confirmed Xcode toolchain is available locally; + AGP 9.2.1 requires the new `com.android.kotlin.multiplatform.library` plugin + (not `com.android.library`) for KMP modules with an Android target. +- [x] **Step A.1 — Module scaffold + first portable-class extraction.** + Created `chartLibCore` (KMP: `androidTarget`, `jvm("desktop")`, `iosX64`, + `iosArm64`, `iosSimulatorArm64`). Moved the 5 classes with zero true Android + coupling into `commonMain`, preserving package names so no call sites changed: + - `utils/ObjectPool.kt` (removed JVM-only `@Synchronized`, see note below) + - `utils/FSize.kt` + - `utils/PointD.kt` + - `utils/ColorTemplate.kt` (replaced `android.graphics.Color.rgb(...)` with a + common `argb()` helper) + - `highlight/Range.kt` + - `highlight/RangeDouble.kt` + - Moved `ObjectPoolTest` into `commonTest`, ported JUnit → `kotlin.test`. + - `chartLib` now depends on `chartLibCore` via `api(project(":chartLibCore"))`. + - Verified: `chartLibCore` builds/tests green on Android, desktop-JVM, and all + 3 iOS targets; `chartLib:test`, `chartLibCompose:assembleDebug`, and the full + `./gradlew test` all still pass unchanged. + - **Known trade-off:** `ObjectPool` lost `@Synchronized` (JVM-only annotation, + unavailable in `commonMain`). Revisit with `kotlinx-atomicfu` or a + platform-specific `expect`/`actual` lock if thread-safety across pool + instances becomes a real requirement on non-JVM targets. +- [x] **Step A.2 (partial) + first half of A.3 — `ChartIcon` abstraction + full + Entry family migration.** + - Added `expect abstract class ChartIcon` in `chartLibCore` commonMain, with + `actual typealias ChartIcon = android.graphics.drawable.Drawable` on Android + (zero source/behavior change for Android consumers — `entry.icon` is still + literally a `Drawable`) and empty placeholder `actual abstract class ChartIcon` + on iOS/desktop until Compose Multiplatform icon rendering (e.g. `Painter`) is + designed. Required adding `-Xexpect-actual-classes` to `chartLibCore`'s + `compilerOptions.freeCompilerArgs` (expect/actual classes are still Beta). + - Moved the entire `data/` entry hierarchy into `chartLibCore` commonMain, + unchanged in package/API shape: + `BaseEntry`, `EntryFloat`, `EntryDouble`, the deprecated `Entry` bridge class, + and all `Bar/Pie/Radar/Bubble/Candle` `*Entry`/`*EntryFloat`/`*EntryDouble` + variants (19 files total). + - Replaced `android.graphics.drawable.Drawable?` fields/params with `ChartIcon?` + throughout (transparent on Android via the typealias). + - Removed `android.annotation.SuppressLint`/`@TargetApi` (build-tool-only, + meaningless outside Android — safe to drop). + - **Breaking change (flagged, not silently made):** `EntryFloat` (and therefore + `Entry`) no longer implements `android.os.Parcelable` or `java.io.Serializable` + — both are JVM/Android-only types with no multiplatform equivalent. A repo-wide + search found **no internal usage** of `EntryFloat.CREATOR`, `is Parcelable`, + or entry serialization, so this is believed safe, but it **is** a public API + removal for any external consumer relying on putting entries into a `Bundle`/ + `Intent` via `Parcelable`/`Serializable`. If needed later, this can be restored + Android-only via the `expect class` + additional-supertypes-on-`actual` pattern + (an `actual` declaration is allowed to implement extra platform-specific + interfaces beyond what `expect` declares). + - Replaced the JVM-only `Build.VERSION.SDK_INT` branch in `toString()` + implementations with `this::class.simpleName` (works identically on all + Kotlin targets). + - Replaced `Utils.FLOAT_EPSILON`/`Utils.DOUBLE_EPSILON` (which pulled in the + still-Android-only `Utils` object) with local common constants + `ENTRY_FLOAT_EPSILON`/`ENTRY_DOUBLE_EPSILON` in `EntryFloat.kt`, built with the + common-Kotlin-stdlib `Float.fromBits(1)`/`Double.fromBits(1L)`. + - Removed two `timber.log.Timber.i(...)` informational log calls from + `PieEntryFloat`'s deprecated `x` accessor (Timber is Android-only; logging + facade design deferred — see the `timber.log.Timber` row in the abstractions + table below). + - `PieEntryDouble.kt` was moved as-is (it was already an empty file pre-existing + in `chartLib` — a gap in the deprecated-class migration table unrelated to + this KMP effort; left untouched, out of scope here). + - Verified: `chartLibCore` builds clean on Android/iOS×3/desktop-JVM; + `chartLib:compileDebugKotlin` succeeds **with zero source changes needed** in + `chartLib` itself (proof the package-preserving move is fully transparent to + existing consumers); full `./gradlew test :chartLibCore:allTests` and + `chartLibCompose:assembleDebug`/`app:assembleDebug` all pass. +- [x] **CI fix — `chartLibCore` KMP module breaks the Linux "Check" CI job.** + The `com.android.kotlin.multiplatform.library` plugin doesn't enable JVM-based + unit tests for the `android` target by default, and Gradle errors out (rather + than just warning) when it hits *disabled* Kotlin/Native targets — like the + iOS targets on a non-macOS CI runner — unless explicitly told those are okay + to skip. Fixed both: + - Added `withHostTest {}` to `chartLibCore/build.gradle.kts`'s `android { }` + block, which enables a `testAndroidHostTest` task that runs `commonTest` + (currently just `ObjectPoolTest`) on the JVM, no emulator/device needed — + this is what makes `chartLibCore`'s tests actually participate in `./gradlew + test` at all. + - Added `kotlin.native.ignoreDisabledTargets=true` to the root + `gradle.properties`, so Gradle treats disabled iOS Kotlin/Native targets + (unbuildable on Linux CI runners, which lack Xcode) as a no-op instead of a + hard failure. iOS targets still build/test normally on macOS (verified + locally on this machine, which has Xcode). + - Verified locally: `./gradlew test`, `:chartLibCore:build`, + `:chartLibCore:allTests`, `:chartLibCore:testAndroidHostTest`, + `:chartLibCompose:assembleDebug`, `:app:assembleDebug` all still pass on + macOS with Xcode present (all iOS/android/desktop targets still compile and + test normally; nothing is being silently skipped here — the flag only + matters on hosts that can't build iOS at all). +- [x] **Step A.3 (remainder, partial) — DataSet-family groundwork: common + enums/abstractions landed; full `IDataSet`/`BaseDataSet`/`DataSet`/`ChartData` + move is BLOCKED, see below.** + - **Landed in `chartLibCore` (new, reusable common types):** + - `components/AxisDependency.kt` (top-level enum: `LEFT`, `RIGHT`) — + extracted from `YAxis`'s nested enum. `YAxis` keeps a nested + `typealias AxisDependency = info.appdev.charting.components.AxisDependency` + so `YAxis.AxisDependency.LEFT` and all existing imports/call sites keep + working unchanged (nested type aliases are a real, if lesser-known, + Kotlin feature — see kotlinlang.org's type-alias docs). + - `components/LegendForm.kt` (top-level enum: `NONE`, `EMPTY`, `DEFAULT`, + `SQUARE`, `CIRCLE`, `LINE`) — same nested-typealias treatment applied to + `Legend.LegendForm`. + - `utils/PaintStyle.kt` (top-level enum: `FILL`, `STROKE`, `FILL_AND_STROKE`) + — platform-independent replacement for `android.graphics.Paint.Style`, + used by `ICandleDataSet`/`CandleDataSet`. A new Android-only converter + `PaintStyle?.toAndroidPaintStyle()` (in `chartLib`'s + `utils/PaintStyleAndroid.kt`) bridges it back to `Paint.Style` at the two + `CandleStickChartRenderer` draw call sites; the demo app's + `CandleStickChartActivity` was updated to set `PaintStyle.FILL`/`STROKE` + instead of `Paint.Style.FILL`/`STROKE`. + - `utils/ChartTypeface.kt` (+ android/iOS/desktop actuals) — same + `expect`/`actual` pattern as `ChartIcon`. `expect open class ChartTypeface` + (must be `open`, not `abstract`, to match `android.graphics.Typeface`'s + modality) with `actual typealias ChartTypeface = android.graphics.Typeface` + on Android (zero-cost) and placeholder `actual open class ChartTypeface` on + iOS/desktop. Applied to `IDataSet.valueTypeface`, `BaseDataSet.mValueTypeface`, + and `ChartData.setValueTypeface(tf: ChartTypeface?)` — these three files + **stay in `chartLib`** for now (see blocker below) but their public API + surface is now common-type-ready. + - `utils/DashEffect.kt` — a common `class DashEffect(intervals: FloatArray, + phase: Float)` (manual `equals`/`hashCode` via `contentEquals`, since + Kotlin data classes don't do array content-equality automatically) designed + to eventually replace `android.graphics.DashPathEffect` in `IDataSet`/ + `BaseDataSet`/`ILineDataSet`/etc. **Not yet wired up** — the DashPathEffect + usages in `BaseDataSet.formLineDashEffect`, `LineDataSet.dashPathEffect`, + and `LineScatterCandleRadarDataSet.dashPathEffectHighlight` are consumed + directly as `android.graphics.DashPathEffect` at several renderer call + sites (`LegendRenderer`, `LineChartRenderer`, `CandleStickChartRenderer`, + etc.); swapping the property types requires updating every one of those + render call sites with an Android-side conversion helper (mirroring what + was just done for `PaintStyle`). Left as a distinct, separately-scoped + follow-up sub-slice ("A.3c") rather than folding it into an already-large + change. + - `utils/DisplayMetrics.kt` — a common `var chartDensity: Float = 1f` + + `Float.convertDpToPixel()` extension, replacing the old Android-only + `Float.convertDpToPixel()` in `chartLib`'s `NumberUtils.kt` (which read a + nullable `android.util.DisplayMetrics` global and logged a Timber warning + if uninitialized). `chartLib`'s `Context.initUtils()` now sets + `chartDensity = this.resources.displayMetrics.density` directly. This is a + **minor documented behavior change**: the old "Utils NOT INITIALIZED" + Timber warning is gone — `chartDensity` simply defaults to `1f` (no + scaling) until a platform sets it, rather than warning and passing the + value through unscaled. + - `utils/PointF.kt` moved from `chartLib` to `chartLibCore` commonMain, + dropping `android.os.Parcelable`/`Parcel` support (same rationale/breaking- + change disclosure as `EntryFloat` in the previous slice — no internal usage + of `PointF.CREATOR` found repo-wide) and replacing the JVM-only + `Math.toRadians(...)` call in `changePosition(...)` with a manual + `degrees * PI / 180.0` conversion using `kotlin.math.PI`. + - `ColorTemplate.argb(r, g, b)` made `public` (was `private`) and two new + public constants `ColorTemplate.WHITE`/`ColorTemplate.BLACK` added, as + reusable platform-independent replacements for + `android.graphics.Color.rgb(...)`/`Color.WHITE`/`Color.BLACK` — not yet + applied inside `LineDataSet`/`RadarDataSet`/`BarLineScatterCandleBubbleDataSet` + (deferred along with the rest of those classes, see blocker below). + - **Key blocker discovered (documents a correction to the migration order in + "Migration order rationale" below):** `IDataSet.valueFormatter` is typed + `IValueFormatter`, and `IValueFormatter.getFormattedValue(...)` takes a + `ViewPortHandler?` parameter; `ILineDataSet.fillFormatter` is typed + `IFillFormatter`, whose `getFillLinePosition(...)` takes a + `LineDataProvider` (a chart-view-facing `interfaces/dataprovider` type). + `ViewPortHandler` is a 595-line `Matrix`/`RectF`/`View`-coupled geometry + engine (planned Step A.6), and `LineDataProvider` is transitively coupled to + the whole View-based rendering pipeline (planned to move alongside/after + Compose Multiplatform rendering work, not before). Because `IDataSet` and + `ILineDataSet` reference these types directly in their member signatures, + **`IDataSet`/`BaseDataSet`/`DataSet`/`ChartData` and every concrete + `*DataSet` subclass cannot be moved to `chartLibCore` until Step A.6 + (`ViewPortHandler` abstraction) lands**, and a common formatter/dataprovider + story is designed. This revises the previously-assumed ordering (A.3 fully + before A.4/A.6) — **A.6 must now at least partially precede full A.3 + completion.** `interfaces/dataprovider/*` was also confirmed to stay + Android-only in general (it exposes `RectF`, `Transformer`, and other + View-pipeline types) rather than move to `chartLibCore`. + - Also confirmed `interfaces/datasets/IBarDataSet` (depends on `utils/Fill.kt`, + a `Shader`/`LinearGradient`/`Drawable`-heavy gradient-fill abstraction) and + `IScatterDataSet` (depends on `renderer/scatter/IShapeRenderer`, which takes + `Canvas`/`Paint` directly) have their own separate Android-only blockers + beyond the `IValueFormatter`/`IFillFormatter` one — `BarDataSet` and + `ScatterDataSet` will need dedicated `Fill`/`IShapeRenderer` abstraction + work even after Step A.6 unblocks the rest of the family. + - Verified: `chartLibCore` builds/tests green on Android/iOS×3/desktop-JVM; + `chartLib:compileDebugKotlin`, full `./gradlew test`, + `chartLibCompose:assembleDebug`, and `app:assembleDebug` all pass with the + `YAxis`/`Legend`/`CandleDataSet`/`ICandleDataSet`/`IDataSet`/`BaseDataSet`/ + `ChartData`/`CandleStickChartRenderer`/`CandleStickChartActivity` changes + described above. +- [x] **Step A.3 (completion) — `IDataSet`/`BaseDataSet`/`DataSet`/`ChartData` + fully migrated to `chartLibCore` commonMain, unblocked by Step A.6.** + - **Moved to `chartLibCore` commonMain** (same package/class names, so + zero import churn for any downstream consumer): + `interfaces/datasets/IDataSet.kt`, `data/BaseDataSet.kt`, `data/DataSet.kt` + (including its nested `enum class Rounding { UP, DOWN, CLOSEST }`, which + simply moved with the file — no separate top-level extraction needed since + the whole class relocated), `data/ChartData.kt`. + - **`DashEffect` finally wired up**: `IDataSet.formLineDashEffect`, + `BaseDataSet.mFormLineDashEffect`/`formLineDashEffect`, + `components/Legend.formLineDashEffect`, and + `components/LegendEntry.formLineDashEffect` all switched from + `android.graphics.DashPathEffect?` to the common `utils/DashEffect.kt` + (landed unwired in the previous slice). `Legend`/`LegendEntry` themselves + **stay in `chartLib`** (still blocked by `android.graphics.Paint` usage + elsewhere in `Legend`), so a new Android-only + `chartLib/utils/DashEffectAndroid.kt` (`DashEffect.toAndroidDashPathEffect()`) + converts at the one real Canvas boundary, + `LegendRenderer.kt`'s `formPaint.pathEffect = ...` assignment. Two demo-app + call sites (`DataTools.kt`, `SpecificPositionsLineChartActivity.kt`) that + constructed `DashPathEffect(...)` for `formLineDashEffect` were updated to + construct `DashEffect(...)` instead — a small source-level (not behavioral) + breaking change for any external code doing the same. + - **`IDataSet.kt`'s nested-typealias imports replaced**: now imports the + top-level common `components/AxisDependency`/`components/LegendForm` + directly instead of importing `components/YAxis`/`components/Legend` just + to reach their nested type aliases (same pattern used for `Highlight` in + Step A.4). + - **`BaseDataSet.kt` Android-coupling removed**: + - `@ColorInt`/`@Transient` annotations dropped (lint/serialization hints + with no multiplatform equivalent; `@Transient` was already meaningless + once `Serializable` was dropped from `DataSet`). + - `android.graphics.Color.rgb/argb/red/green/blue(...)` calls replaced with + new platform-independent equivalents added to `chartLibCore`'s + `utils/ColorTemplate.kt`: `argb(alpha, r, g, b)` (4-arg overload, the + existing 3-arg `argb(r, g, b)` stayed for the fully-opaque case), + `alpha(color)`, `red(color)`, `green(color)`, `blue(color)`. + - The one `Context`-taking overload, `setColors(colors: IntArray, context: + Context)` (resolves Android color resources via `ContextCompat`), was + extracted out of the class body into a new Android-only extension + function in `chartLib/data/BaseDataSetAndroid.kt`, implemented purely in + terms of `BaseDataSet`'s existing public `resetColors()`/`addColor(...)` + API (no `protected` member access needed). Call-site syntax + (`dataSet.setColors(colors, context)`) is unchanged since Kotlin extension + functions resolve identically to member functions at call sites; a + repo-wide grep found zero existing callers of this overload. + - `Utils.defaultValueFormatter` (an Android-only `object Utils` singleton + that can't be referenced from commonMain, since dependencies only flow + `chartLibCore` → `chartLib`, never the reverse) replaced with a private + `companion object { private val defaultValueFormatter: IValueFormatter by + lazy { DefaultValueFormatter(1) } }` directly on `BaseDataSet`. + - **`DataSet.kt`/`ChartData.kt` cleanup**: dropped `@SuppressLint(...)` + (meaningless outside `chartLib`, which is the only module with the custom + `:lint` checks applied via `lintChecks(project(":lint"))`), dropped + `java.io.Serializable` (same disclosed-breaking-change rationale as + `EntryFloat`/`PointF`/`Highlight` — no internal usage of `DataSet`/ + `ChartData` serialization found repo-wide), and replaced the two + `Timber.e(...)` calls with `println(...)` (same rare-error-log rationale as + `ViewPortHandler`'s `Timber.i(...)` → `println(...)` in Step A.6). + - **Formatter cluster partly unblocked and moved as a natural follow-on**, + since `IDataSet.valueFormatter: IValueFormatter` now lives in + `chartLibCore`: `formatter/IValueFormatter.kt` (no changes needed) and + `formatter/DefaultValueFormatter.kt`/`formatter/StackedValueFormatter.kt` + (both previously used `java.text.DecimalFormat`, which has no multiplatform + equivalent — rewritten in terms of a new shared common helper, + `utils/DecimalFormatting.kt`'s `formatGroupedDecimal(value, digits)`, + implementing the same rounding + thousands-grouping behavior as the + `"###,###,###,##0.00"`-style `DecimalFormat` patterns using only + `kotlin.math`). `formatter/ColorFormatter.kt` also moved (only depended on + `EntryFloat`/`IDataSet`, both already common). Added + `chartLibCore/commonTest/formatter/DefaultValueFormatterTest.kt` (5 new + unit tests covering zero/nonzero decimal digits, thousands grouping, + negative values, and negative-zero rounding) since this rewrite had no + prior test coverage. + - **Still blocked / explicitly out of scope for this slice:** + `IAxisValueFormatter`, `DefaultAxisValueFormatter`, + `IndexAxisValueFormatter`, `LargeValueFormatter`, `PercentFormatter` + (blocked by `components/AxisBase`, Step A.5); `IFillFormatter`, + `DefaultFillFormatter` (blocked by `interfaces/dataprovider/LineDataProvider` + and `ILineDataSet`); every concrete `*DataSet` subclass + (`LineDataSet`, `BarDataSet`, `ScatterDataSet`, etc. — most still use + `android.graphics.Color`/`Paint`/`Shader` extensively, and `IBarDataSet`/ + `IScatterDataSet` have their own separate `Fill`/`IShapeRenderer` blockers + noted in the prior Step A.3 entry); `LineDataSet.dashPathEffect`/ + `LineScatterCandleRadarDataSet.dashPathEffectHighlight` (a *different* + dash-effect property from `formLineDashEffect`, tied to `ILineDataSet`, + left as `android.graphics.DashPathEffect` since `ILineDataSet` itself isn't + moving yet). + - Verified: `:chartLibCore:build` (Android host + desktop-JVM + iOS×3 + simulator/device targets all compile and pass tests, including the 5 new + `DefaultValueFormatterTest` cases), `chartLib:compileDebugKotlin` + (zero warnings/errors beyond pre-existing unrelated ones), full + `./gradlew test`, `chartLibCompose:assembleDebug`, and `app:assembleDebug` + all pass. + - **Now unblocked:** highlighter classes in Step A.4 that depend on + `IDataSet` (though most still also depend on `interfaces/dataprovider/*`, + so remain blocked for that separate reason); `AxisBase`/Step A.5 can now + freely reference `IDataSet`-family types if needed. +- [ ] **Step A.4 (partial) — `Highlight`/`IHighlighter` moved; the rest of + `formatter/` and `highlight/` is BLOCKED by the same Step A.6 dependency + found while investigating Step A.3.** + - **Landed:** `highlight/Highlight.kt` and `highlight/IHighlighter.kt` moved + to `chartLibCore` commonMain. `Highlight` dropped `java.io.Serializable` + (same disclosed-breaking-change rationale as `EntryFloat`/`PointF` — no + internal usage of Highlight serialization found repo-wide) and now imports + the top-level common `info.appdev.charting.components.AxisDependency` + instead of the nested `YAxis.AxisDependency` type alias (which itself still + resolves to the exact same type — this is purely so `Highlight` doesn't + need to reference the Android-only `YAxis` class to get at the enum). + `IHighlighter` needed no changes at all (it only referenced `Highlight`). + - **Everything else in `formatter/` is blocked:** every one of the 10 files in + `formatter/` (`IValueFormatter`, `DefaultValueFormatter`, + `IAxisValueFormatter`, `DefaultAxisValueFormatter`, `IndexAxisValueFormatter`, + `LargeValueFormatter`, `PercentFormatter`, `StackedValueFormatter`, + `IFillFormatter`, `DefaultFillFormatter`, `ColorFormatter`) imports at least + one of: `utils/ViewPortHandler` (Step A.6, not yet done), + `components/AxisBase` (Step A.5, not yet done), + `interfaces/datasets/IDataSet`/`ILineDataSet` (blocked per the Step A.3 + finding), or `interfaces/dataprovider/LineDataProvider` (Android-only, tied + to the View rendering pipeline). None can move yet. + - **Everything else in `highlight/` is blocked** for the same reason: + `ChartHighlighter`, `BarHighlighter`, `HorizontalBarHighlighter`, + `CombinedHighlighter` all import `interfaces/datasets/IDataSet` and/or + `interfaces/dataprovider/*`; `PieHighlighter`/`RadarHighlighter`/ + `PieRadarHighlighter` additionally import concrete `charts/PieChart`, + `charts/RadarChart`, `charts/PieRadarChartBase` `View` subclasses directly. + - **Conclusion:** Step A.4 cannot meaningfully proceed further until Step A.6 + (`ViewPortHandler`/`Transformer` common abstraction) and enough of Step A.5 + (`AxisBase` at minimum) land, exactly as flagged in the Step A.3 changelog + entry above. Recommend doing **Step A.6 next**, since it's the single + blocker unblocking the largest amount of remaining work (rest of A.3, all + of A.4, most of A.5). + - Verified: `chartLibCore` build/tests, `chartLib:compileDebugKotlin` (zero + source changes needed), full `./gradlew test`, + `chartLibCompose:assembleDebug`, `app:assembleDebug` all pass. +- [x] **Step A.6 — Migrate `utils/` geometry & viewport math + (`Matrix`/`RectF` value types, `ViewPortHandler`, `Transformer`).** + - **Common `Matrix`/`RectF` foundation** (landed first, verified in isolation): + `chartLibCore/utils/Matrix.kt` — a full custom 3×3 affine/perspective matrix + implementation, row-major and index-compatible with `android.graphics.Matrix` + (`MSCALE_X=0` … `MPERSP_2=8`), with `reset`, `set`, `getValues`/`setValues`, + `setTranslate`/`setScale` (with pivot), `postTranslate`/`postScale` (with + pivot), `postConcat`, `mapPoints`, `mapRect`, and `invert` (cofactor/adjugate, + returns `false` below a `1e-12f` determinant threshold instead of throwing). + `chartLibCore/utils/RectF.kt` — a common rectangle type with `width`/`height`/ + `centerX`/`centerY`, `set(...)` overloads (including an `operator fun set` + supporting the `rect[l,t,r] = b` indexed-assignment idiom `ViewPortHandler` + relies on), and custom `equals`/`hashCode`/`toString`. 11 unit tests in + `MatrixTest.kt` cover identity/reset, translate/scale (origin and pivot), + composition order, `getValues`/`setValues` round-trips, `invert` correctness, + and `mapRect` bounding-box computation — all pass on JVM (Android host), + desktop, and iOS simulator. + - **`ViewPortHandler` moved to `chartLibCore` commonMain**, rebuilt on the + common `Matrix`/`RectF` instead of `android.graphics.Matrix`/`RectF`. Its + only two Android touches were removed: the `android.view.View?` parameter on + `refresh(...)`/`centerViewPort(...)` became a platform-agnostic + `invalidate: (() -> Unit)?` callback lambda, and the single `Timber.i(...)` + debug log became a plain `println(...)` (this is a rarely-hit path gated + behind a `logging: Boolean = false` default, not worth a full logging + facade for one call site). + - **`Transformer` split into a common base + Android subclass**, since its + `generateTransformedValues{Line,Bubble,Candle,Scatter}` methods take + `ILineDataSet`/`IBubbleDataSet`/`ICandleDataSet`/`IScatterDataSet` params + (still Android-only, blocked per the Step A.3 finding) and its + `pathValueToPixel`/`pathValuesToPixel` methods use `android.graphics.Path` + (no multiplatform equivalent yet): + - `chartLibCore/utils/TransformerCore.kt` (new, common, open class) — holds + every matrix/rect operation that doesn't depend on the dataset family or + `Path`: `prepareMatrixValuePx`, `prepareMatrixOffset` (open, overridden by + `TransformerHorizontalBarChart`), `pointValuesToPixel`, `rectValueToPixel` + (+ phase/horizontal variants), `pixelsToValue`, `getValuesByTouchPoint`, + `getPixelForValues`, `valueToPixelMatrix`/`pixelToValueMatrix`. The + `FloatArray?`/`RectF?` nullable parameter types from the original + Android-`Matrix`-flavored signatures were tightened to non-null (the + common `Matrix.mapPoints`/`mapRect` API is non-null, and a repo-wide + check confirmed no caller ever passes `null` here). + - `chartLib/utils/Transformer.kt` (rewritten) — now `open class Transformer + : TransformerCore(viewPortHandler)`, keeping only the + `generateTransformedValues*` and `pathValueToPixel`/`pathValuesToPixel` + methods. The two `Path` methods convert the common `Matrix` to + `android.graphics.Matrix` via a new `toAndroidMatrix()` extension before + calling `Path.transform(...)`, since `Path` itself can't move. + - `TransformerHorizontalBarChart` needed no changes — its override of + `prepareMatrixOffset` only touches matrices/`ViewPortHandler` accessors, + all already common. + - **New Android-side conversion helpers** (`chartLib/utils/MatrixAndroid.kt`, + `RectFAndroid.kt`) bridge the common `Matrix`/`RectF` back to + `android.graphics.Matrix`/`RectF` at the handful of places that must still + call native Canvas/Path APIs directly: `toAndroidMatrix()`/`toCommonMatrix()`, + `toAndroidRectF()`/`toCommonRectF()`, and `copyInto(target)` overloads in + both directions (for buffer-style rects that get mutated in place, e.g. + `mGridClippingRect`, `mBarShadowRectBuffer`, `barRect`). + - **~20 files updated at the Canvas-drawing boundary** to route through these + converters — the actual blast radius anticipated in the Step A.6 planning + note: `Chart.kt` (public `contentRect: RectF` property now converts once at + the getter), `BarChart.kt`/`HorizontalBarChart.kt` (`getBarBounds` converts + the caller-supplied `outputRect` round-trip), `BarLineChartBase.kt` (all + `canvas.clipRect`/`c.drawRect(contentRect, ...)` calls, all `viewPortHandler + .refresh(...)`/`.centerViewPort(...)` calls swapped from `this`/`View` to + `{ invalidate() }`), `BarLineChartTouchListener.kt` (switched its `Matrix` + import to the common type; `refresh(...)` calls now wrap `chart` in + `{ chart.invalidate() }`), the 4 zoom/pan `jobs/*.kt` files (same `View` → + lambda swap, common `Matrix` import), and 8 renderer files + (`XAxisRenderer(HorizontalBarChart)`, `YAxisRenderer(HorizontalBarChart)`, + `BarChartRenderer`, `HorizontalBarChartRenderer`, + `Rounded(Horizontal)BarChartRenderer`) whose clipping-rect buffers now + `copyInto`/`toCommonRectF()` at the `viewPortHandler.contentRect`/ + `rectValueToPixel(...)` boundary instead of relying on same-type `.set(...)`. + - **Not touched / explicitly out of scope for this slice:** `RadarChart.kt`, + `PieRadarChartBase.kt` read `viewPortHandler.contentRect.width()`/`.height()`/ + `.left` etc. only — already source-compatible with the common `RectF`, no + changes needed. `CombinedChart.kt`'s `contentRect` overrides delegate to the + (already-converting) `Chart.contentRect` property, also untouched. + - Verified: `chartLibCore` builds/tests green (Android host, desktop-JVM, + iOS simulator — `Matrix`/`RectF`/`ViewPortHandler`/`TransformerCore` all + compile with zero platform-specific code), `chartLib:compileDebugKotlin`, + full `./gradlew test`, `chartLibCompose:assembleDebug`, and + `app:assembleDebug` all pass. + - **Now unblocked:** the rest of Step A.3 (`IDataSet`/`BaseDataSet`/ + `DataSet`/`ChartData`/concrete `*DataSet` subclasses, since + `IValueFormatter`/`IFillFormatter` no longer need an Android-only + `ViewPortHandler`/`LineDataProvider`), most of Step A.4 (`formatter/` + package, most of `highlight/`), and Step A.5 groundwork. +- [x] **Step A.5 (axes + limit lines) — `components/` + (`ComponentBase`/`AxisBase`/`XAxis`/`YAxis`/`LimitLine`/`LimitRange`) + + the `AxisBase`-coupled formatter cluster + (`IAxisValueFormatter`/`DefaultAxisValueFormatter`/`IndexAxisValueFormatter`/ + `LargeValueFormatter`/`PercentFormatter`) fully migrated to `chartLibCore` + commonMain. `Legend`/`LegendEntry`/`Description`/`IMarker`/`MarkerImage`/ + `MarkerView` remain in `chartLib` for a follow-up slice.** + - **Circular-dependency slice:** `AxisBase.valueFormatter` references + `DefaultAxisValueFormatter` directly, and `IAxisValueFormatter`/ + `DefaultAxisValueFormatter` both take an `axis: AxisBase?` parameter — + so `AxisBase` + `IAxisValueFormatter` + `DefaultAxisValueFormatter` had to + move together as one atomic unit (can't be split across separate commits). + - `ComponentBase.kt`: `Typeface?` → common `ChartTypeface?` (same + zero-cost-typealias pattern as `IDataSet.valueTypeface` from Step A.3, + so every existing `paintX.typeface = someComponent.typeface` call site at + chartLib render boundaries stays source-compatible unchanged); + `Color.BLACK` → `ColorTemplate.BLACK`; dropped `@ColorInt`. + - `AxisBase.kt`: `Color.GRAY` → `ColorTemplate.GRAY` (new constant added to + `chartLibCore/utils/ColorTemplate.kt`, `argb(128, 128, 128)`); + `axisLineDashPathEffect`/`gridDashPathEffect` switched from + `android.graphics.DashPathEffect?` to common `DashEffect?` (same pattern as + Step A.3's `IDataSet.formLineDashEffect`); `Timber.e(...)` → `println(...)` + in `addLimitLine`/`addLimitRange`; dropped `@ColorInt`. The one + `Paint`-dependent method, `getLongestLabel(p: Paint?)`, was extracted out of + the class body into a new Android-only extension function in + `chartLib/components/AxisBaseAndroid.kt` (`fun AxisBase.getLongestLabel(p: + Paint?): String`) since `Paint.measureText` has no multiplatform + equivalent; the no-arg `longestLabel` property (pure string-length + comparison, no `Paint`) stayed as a member on the common class. + - `YAxis.kt`: `Color.GRAY` → `ColorTemplate.GRAY`; dropped `@ColorInt`. The + two `Paint`-dependent methods, `getRequiredWidthSpace(p: Paint)`/ + `getRequiredHeightSpace(p: Paint)`, were extracted the same way into a new + `chartLib/components/YAxisAndroid.kt` (calling the also-Android-only + `PaintUtils.kt` `calcTextWidth`/`calcTextHeight` extensions and the new + `AxisBase.getLongestLabel(p)` extension). `XAxis.kt` needed zero changes + (already fully portable — only used the already-common + `convertDpToPixel`). + - `LimitLine.kt`/`LimitRange.kt`: `Color.rgb(...)` → + `ColorTemplate.argb(...)`; `Paint.Style?` → the already-existing common + `PaintStyle?` enum (from the earlier `ICandleDataSet`/`CandleDataSet` + slice); `DashPathEffect?` → common `DashEffect?`; dropped `@ColorInt`. + - Renderer call-site updates for the `DashEffect`→`DashPathEffect` and + `PaintStyle`→`Paint.Style` conversions (via the existing + `toAndroidDashPathEffect()`/`toAndroidPaintStyle()` converters): + `XAxisRenderer.kt`, `YAxisRenderer.kt`, + `XAxisRendererHorizontalBarChart.kt`, `YAxisRendererHorizontalBarChart.kt`, + `YAxisRendererRadarChart.kt` — all at their + `paintGrid.pathEffect =`/`paintAxisLine.pathEffect =`/ + `limitLinePaint.pathEffect =`/`limitRangePaint.pathEffect =`/ + `limitLinePaint.style =`/`limitRangePaint.style =` call sites. Also added + the new `getRequiredWidthSpace`/`getRequiredHeightSpace` extension-function + imports at their two call sites, `BarLineChartBase.kt` and + `HorizontalBarChart.kt`. + - **Formatter cluster** (now unblocked once `AxisBase` is common): + - `IAxisValueFormatter.kt`/`IndexAxisValueFormatter.kt`: moved unchanged + (no Android coupling). + - `DefaultAxisValueFormatter.kt`: dropped the `java.text.DecimalFormat` + field, rewritten using the existing shared + `chartLibCore/utils/DecimalFormatting.kt` helper + `formatGroupedDecimal(value, digits)` (same helper already used by + `DefaultValueFormatter`/`StackedValueFormatter` in Step A.3). + - `PercentFormatter.kt`: same `formatGroupedDecimal(value, 1)` rewrite; + the unused `PercentFormatter(format: DecimalFormat)` constructor overload + (zero callers repo-wide, confirmed via grep) was replaced with + `PercentFormatter(decimalDigits: Int)` — a disclosed breaking change, + consistent with this migration's established pattern of trading + Java-only APIs (`Serializable`, `Parcelable`, now `DecimalFormat`) for + KMP-portable equivalents. + - `LargeValueFormatter.kt`: the most involved rewrite — replaced + `DecimalFormat("###E00")`-based engineering-notation formatting with a + manual Kotlin algorithm: compute `floor(log10(|value|))` (self-corrected + for floating-point imprecision by checking neighbouring powers of ten), + floor-divide by 3 and re-multiply to get the engineering exponent + (nearest multiple of 3 ≤ the true exponent), compute the mantissa, + determine how many fractional digits are still needed to reach exactly + 3 significant digits total, round with carry-over handling (mantissa + rounding up to ≥1000 bumps the exponent bracket and recomputes), then + format and strip trailing zeros/the decimal point. Validated against a + real JDK `DecimalFormat("###E00")` (`/tmp/DFTest.java`) to empirically + confirm the exact rounding/formatting behavior before writing the + Kotlin port. **Caught and fixed one bug during verification:** the + initial port forgot to re-multiply the floor-divided exponent by 3 + (`floorDiv3(exponent)` instead of `floorDiv3(exponent) * 3`), which + surfaced immediately as a test failure (`1100f` → `"110"` instead of + `"1.1k"`) and was fixed before landing. + - **Test migration:** `chartLib/src/test/.../LargeValueFormatterTest.kt` + (JUnit4, ~25 assertions) moved to + `chartLibCore/src/commonTest/.../LargeValueFormatterTest.kt` and ported to + `kotlin.test` (`Test`/`assertEquals`) — now runs on every `chartLibCore` + target (Android host, desktop JVM, iOS simulator) instead of only the + Android JVM unit-test target. All ~25 assertions pass unchanged after the + rewrite (this was the regression check for the manual engineering-notation + algorithm). + - Verified: `chartLibCore:allTests` green (Android host + desktop JVM + iOS + simulator, including the ported `LargeValueFormatterTest`), + `chartLib:compileDebugKotlin`, full `./gradlew test`, + `chartLibCompose:assembleDebug`, `app:assembleDebug` all pass. + - **Now unblocked:** Step A.4's dataprovider-independent formatter cluster + (`IAxisValueFormatter` family) is fully migrated as part of this slice; + the `Legend`/`LegendEntry` components (Step A.5 continuation) are now + migrated too, see below. + +- [x] **Step A.5 (continuation) — `Legend`/`LegendEntry`/`Description` fully + migrated to `chartLibCore` commonMain; `IMarker`/`MarkerImage`/`MarkerView` + confirmed permanently Android-only (Canvas/Context/Drawable/RelativeLayout + coupling with no separable portable core) and left in `chartLib`.** + - `LegendEntry.kt`: trivial move, only dropped `@ColorInt`. + - `Legend.kt`: dropped `@ColorInt` (n/a — none present besides the + already-common enums); the bulk of the class (alignment/orientation + enums, spacing properties, `entries`/`extraEntries`, `setCustom`/ + `setExtra`/`resetCustom`) needed no changes at all. Three methods were + `Paint`-dependent and were extracted into a new Android-only extension + file, `chartLib/components/LegendAndroid.kt`: + `Legend.getMaximumEntryWidth(p: Paint)`, `Legend.getMaximumEntryHeight(p: + Paint)`, and — the largest and most involved extraction in this + migration so far — `Legend.calculateDimensions(labelPaint: Paint, + viewPortHandler: ViewPortHandler)` (the ~110-line horizontal/vertical + legend-layout algorithm). Unlike the smaller single/double-line + extractions in Step A.3/A.5 (`getLongestLabel`, `getRequiredWidthSpace`), + this shows the extension-function pattern scales to large, + multi-branch business logic as long as every field/property it touches + is `public` on the common class — required switching the two internal + offset reads at the end of `calculateDimensions` from the `protected` + `mXOffset`/`mYOffset` fields to the existing public `xOffset`/`yOffset` + accessors on `ComponentBase` (extension functions cannot see `protected` + members from outside the class hierarchy, same constraint documented in + Step A.3's `BaseDataSetAndroid.kt`). + - `Description.kt`: `textAlign: android.graphics.Paint.Align?` replaced + with a new common `chartLibCore/utils/TextAlign.kt` enum + (`LEFT`/`CENTER`/`RIGHT`) plus a new `chartLib/utils/TextAlignAndroid.kt` + converter (`TextAlign?.toAndroidAlign(default)`), following the same + common-enum-plus-Android-converter pattern already established for + `PaintStyle`/`DashEffect`. Only one render call site needed updating, + `Chart.kt`'s `drawDescription()` (`mDescPaint.textAlign = + description.textAlign.toAndroidAlign()`); grepped for other + `description.textAlign`/`Description(...)` usages repo-wide and found + none that set a custom alignment (only the no-op default). + - `LegendRenderer.kt` updated to import the new + `info.appdev.charting.components.calculateDimensions` extension + function at its one call site (member-call syntax unchanged, resolves + identically as an extension call). + - Verified: `chartLibCore:allTests` green, `chartLib:compileDebugKotlin`, + full `./gradlew test`, `chartLibCompose:assembleDebug`, + `app:assembleDebug` all pass. + - **Step A.5 is now considered fully complete**: all of `components/` + that has a genuinely portable core (axes, limit lines, legend, + description) lives in `chartLibCore`; the remainder + (`IMarker`/`MarkerImage`/`MarkerView`) is permanently Android-only by + design, matching the same category of decision already made for + `highlight/`'s dataprovider-coupled classes in Step A.4. + +- [ ] **Step A.7 — New `chartLibComposeMultiplatform` module**: Compose + Multiplatform renderers built on `chartLibCore` using `DrawScope`. + Given the plan doc already flags this as "the single largest remaining + chunk of work (~32 renderer files)", it is being sub-sliced: + - [x] **Step A.7 (module scaffold)**: created the new + `chartLibComposeMultiplatform` module and verified the whole Gradle/ + Kotlin/Compose Multiplatform toolchain resolves and builds across + every target *before* investing in any renderer port. + - `chartLibComposeMultiplatform/build.gradle.kts` closely mirrors + `chartLibCore/build.gradle.kts` (`com.android.kotlin.multiplatform.library` + + `org.jetbrains.kotlin.multiplatform`), adding two more plugins: + `org.jetbrains.compose` (the Compose Multiplatform Gradle plugin, which + supplies the `compose.runtime`/`compose.foundation`/`compose.ui` + dependency aliases) and `org.jetbrains.kotlin.plugin.compose` (the + Compose compiler, version-pinned to the project's Kotlin `2.4.10`, same + as `chartLibCompose`). `commonMain` depends on `chartLibCore` (`api`) + plus `compose.runtime`/`compose.foundation`/`compose.ui`. + - No Gradle version catalog (`libs.versions.toml`) exists in this repo; + plugin/dependency versions are declared inline per-module, so the new + Compose Multiplatform plugin version is pinned directly in this + module's `build.gradle.kts`. + - **Compose Multiplatform plugin version required raising this module's + `compileSdk` to 37** (kept at `36` everywhere else in the repo): + Compose Multiplatform `1.12.1`'s Android artifacts + (`androidx.compose.ui:ui-android`, `foundation-android`, + `runtime-saveable-android`, etc.) declare an AAR metadata minimum of + API 37, which fails `checkAndroidMainAarMetadata` at `compileSdk=36`. + An older plugin version (`1.8.2`) avoids the compileSdk bump but is + incompatible with AGP 9.2.1's newer + `KotlinMultiplatformAndroidComponentsExtension` API + (`NoSuchMethodError` on `onVariant`) — confirmed by testing both. + `compileSdk = 37` is therefore scoped to only this one module for now; + the rest of the repo (`chartLib`, `chartLibCore`, `chartLibCompose`, + `app`) intentionally stays on `36` until there's a reason to bump + everything at once. + - **Dropped `iosX64` for this module only** (unlike `chartLibCore`, + which still targets it): Compose Multiplatform `1.12.x` no longer + publishes `compose.ui`/`compose.foundation`/`compose.runtime` + artifacts for the Intel iOS simulator target, so `iosX64` dependency + resolution fails outright. Only `iosArm64()`/`iosSimulatorArm64()` are + configured, matching upstream Compose Multiplatform's own supported + iOS target set. + - Added a placeholder `chartLibComposeMultiplatform/.../Placeholder.kt` + (a single documented constant) purely to validate the module compiles + end-to-end on every target — no real renderer code yet. + - `settings.gradle.kts` updated with + `include(":chartLibComposeMultiplatform")`. + - Verified: `:chartLibComposeMultiplatform:build` succeeds (Android AAR, + desktop jar, and both iOS arm64 klibs/frameworks all compile/link), + plus the full existing chain (`chartLibCore:allTests`, + `chartLib:compileDebugKotlin`, full `./gradlew test`, + `chartLibCompose:assembleDebug`, `app:assembleDebug`) still green. + - **Not yet done** (remaining sub-slices of Step A.7): the actual + `DrawScope` port of the ~32 renderer files, and deciding/implementing + a slice order for them (proof-of-concept with one simple renderer + first, per the plan, before the rest). + - [x] **Step A.7 (grid line proof-of-concept)**: ported the vertical/ + horizontal axis grid line drawing (`XAxisRenderer.renderGridLines`/ + `drawGridLine` and `YAxisRenderer.renderGridLines`/`linePath`/ + `transformedPositions`) to Compose's `DrawScope`, as the first real + renderer slice, chosen because it needs no concrete chart-type + `DataSet` (`LineDataSet`/`BarDataSet`/etc. are still Android-coupled + and haven't moved to `chartLibCore` yet — only the axis/viewport/ + transform types from Step A.5/A.6 are needed), making it fully + self-contained. + - New file `chartLibComposeMultiplatform/.../renderer/AxisGridRenderer.kt` + with `DrawScope.drawXAxisGridLines(xAxis, viewPortHandler, transformer)` + and `DrawScope.drawYAxisGridLines(yAxis, viewPortHandler, transformer)`, + operating purely on `chartLibCore` types (`XAxis`/`YAxis`, + `ViewPortHandler`, `TransformerCore`) — no dependency on chartLib's + `Renderer`/`DataRenderer`/`AxisRenderer` Android base-class hierarchy, + confirming the intended Step A.8 direction: Compose renderers are + fresh idiomatic functions operating on the portable model, not a + line-for-line port of the View-based OOP renderer classes. + - The axis-value-to-pixel math (`xAxisGridPixelPositions`/ + `yAxisGridPixelPositions`) is deliberately factored out as plain + functions returning a `FloatArray`, separate from the actual + `drawLine`/`clipRect` calls, specifically so it can be unit-tested + without a Compose UI test harness. + - New test `chartLibComposeMultiplatform/commonTest/.../ + AxisGridRendererTest.kt`: sets up a `ViewPortHandler`/`TransformerCore` + for a 100x100 content rect mapping axis values `[0, 10]`, and asserts + axis value `0`/`10` map to the expected content-rect edges (left/right + for X, bottom/top for Y, confirming the Y-pixel-axis inversion). + - Removed the earlier scaffold placeholder file now that real renderer + code exists in the module. + - Verified: this is the **first slice where `chartLibComposeMultiplatform` + tests actually ran and passed on three targets** — + `testAndroidHostTest`, `desktopTest`, and (this session's sandbox + happened to have a usable iOS toolchain) `iosSimulatorArm64Test` — all + 2/2 tests green on each. Full existing chain + (`chartLibCore:allTests`, `chartLib:compileDebugKotlin`, + `./gradlew test`, `chartLibCompose:assembleDebug`, + `app:assembleDebug`) also still green. + - **Not yet done**: the remaining ~30 renderer files (data renderers for + each chart type, legend renderer, limit line renderer, marker + rendering, etc.), most of which *do* need the concrete `DataSet` + classes ported to `chartLibCore` first (a prerequisite not yet + started) before they can be tackled the same way. +- [x] **Step A.3 (continuation, part 2) — concrete `DataSet` family (Bar/Line/ + Scatter/Candle/Bubble/Radar/Pie) prerequisite for Step A.7's renderer + port.** The Step A.7 grid-line slice above surfaced that most of the + remaining ~30 renderer files need concrete chart-type `DataSet` classes + (`LineDataSet`/`BarDataSet`/etc.), which hadn't moved to `chartLibCore` + yet even though the abstract `DataSet`/`BaseDataSet`/`IDataSet` base + already had (Step A.3 completion). This slice migrates every concrete + `DataSet` class that has *no* remaining Android-only blocker, and + leaves the ones that genuinely do (documented below) as Android-only + leaves extending the now-common parent classes — same pattern as + `IMarker`/highlighters in Steps A.4/A.5. + - **Moved to `chartLibCore` commonMain** (same package/class names): + `data/BarLineScatterCandleBubbleDataSet.kt`, + `data/LineScatterCandleRadarDataSet.kt`, `data/LineRadarDataSet.kt`, + `data/BubbleDataSet.kt`, `data/CandleDataSet.kt`, `data/PieDataSet.kt`, + `data/RadarDataSet.kt`, and their interfaces + `interfaces/datasets/IBarLineScatterCandleBubbleDataSet.kt`, + `ILineScatterCandleRadarDataSet.kt`, `ILineRadarDataSet.kt`, + `IBubbleDataSet.kt`, `ICandleDataSet.kt`, `IPieDataSet.kt`, + `IRadarDataSet.kt`. `BubbleDataSet`/`PieDataSet`/`CandleDataSet`/ + `RadarDataSet` are now fully portable leaf classes; the abstract + parents unblock any future common data renderer for those chart + types. + - Trivial Android cleanup applied throughout: `@ColorInt` annotations + dropped (non-functional at runtime); `android.graphics.Color.rgb(...)`/ + `Color.WHITE` literals replaced with the existing common + `ColorTemplate.argb(r, g, b)` helper (already used elsewhere in + `chartLibCore`); the `convertDpToPixel()` calls in these files already + resolved to the common `Float.convertDpToPixel()` extension + established in Step A.5 (backed by the global `chartDensity` + set via `Context.initUtils()`), so no changes were needed there. + - `ILineScatterCandleRadarDataSet.dashPathEffectHighlight`/ + `LineScatterCandleRadarDataSet.enableDashedHighlightLine(...)`: + `android.graphics.DashPathEffect` replaced with the common `DashEffect` + type (Step A.5's `DashEffect`/`toAndroidDashPathEffect()` pattern, + reused as-is). One render call site updated, + `LineScatterCandleRadarRenderer.drawHighlightLines(...)` + (`paintHighlight.pathEffect = set.dashPathEffectHighlight` → + `...?.toAndroidDashPathEffect()`). + - **`fillDrawable` (on `ILineRadarDataSet`/`LineRadarDataSet`) removed + from common code entirely** and replaced with a new Android-only + side-channel: `chartLib/data/LineRadarDataSetAndroid.kt` defines + `var ILineRadarDataSet<*>.fillDrawable: Drawable?` as an extension + property backed by a `WeakHashMap, Drawable?>` + keyed by dataset identity. `android.graphics.drawable.Drawable` has no + portable equivalent (unlike `DashPathEffect`/`Paint.Style`, which + already had common replacements), so — matching the established + extension-property pattern from Step A.3's `BaseDataSet.setColors` — + this keeps `dataSet.fillDrawable = ...`/`dataSet.fillDrawable` + call-site syntax completely unchanged for both `LineDataSet` (still + Android-only, stays in `chartLib`) and `RadarDataSet` (now common), + at the cost of adding an explicit + `import info.appdev.charting.data.fillDrawable` at each call site + (`LineChartRenderer.kt`, `RadarChartRenderer.kt`, and three `app` + example activities that set a custom fill drawable). + - **Two small disclosed behavior changes**, documented in + `LineRadarDataSet`'s class doc, both around the removed `fillDrawable` + stored property: setting `fillColor` no longer implicitly clears a + previously set `fillDrawable` (previously an automatic side effect of + the old stored-property setter), and `.copy()` no longer propagates + `fillDrawable` to the copy. Callers relying on either behavior should + set `fillDrawable` explicitly afterward — same category of + already-disclosed minor breaking change as `PercentFormatter`'s + constructor change in Step A.5. + - **Left as Android-only leaves** (extending the now-common abstract + parents, same as `IMarker`/highlighters in Steps A.4/A.5): `BarDataSet` + (needs the heavily Canvas/Paint/Drawable/LinearGradient-coupled `Fill` + class, which itself would need the same common-type-plus-Android- + renderer split as `DashEffect`/`PaintStyle` — out of scope for this + slice), `LineDataSet` (needs `Fill`, `Context`/`ContextCompat`-based + drawable-resource fill loading, and `IFillFormatter`, which in turn + depends on the permanently-Android-only `LineDataProvider` from Step + A.4), and `ScatterDataSet`/`IScatterDataSet` (needs the + Canvas/Paint-coupled `IShapeRenderer` family in + `renderer/scatter/`). + - Verified: `chartLibCore:compileKotlinDesktop` (chartLibCore's own + compile), full `chartLibCore:allTests`, `chartLib:compileDebugKotlin`, + full `./gradlew test`, `chartLibCompose:assembleDebug`, + `app:assembleDebug`, and `chartLibComposeMultiplatform:build` (all + targets, since it depends on `chartLibCore`) all pass. + - **Not yet done**: `Fill` splitting (common data + Android drawing + extension) to unblock `BarDataSet`/`LineDataSet`; `IFillFormatter`/ + `LineDataProvider` decoupling; `IShapeRenderer`/`ScatterDataSet`. Any + of these would be reasonable next slices before resuming the Step A.7 + renderer port for Bar/Line/Scatter charts (Candle/Radar/Bubble/Pie + renderers are unblocked already on the data-model side). +- [x] **Step A.3 (continuation, part 3) — split the `Fill` class, migrate + `BarDataSet`.** Tackles the first of the three blockers flagged above. + `Fill` (`utils/Fill.kt`) mixed a small amount of portable state + (`type`/`color`/`alpha`/`gradientColors`/`gradientPositions`/the derived + `finalColor`) with Canvas/Paint/Drawable/LinearGradient-based drawing + logic (`fillRect(...)`/`fillPath(...)`) and a `Drawable` payload for + `Type.DRAWABLE` — none of which have a portable KMP equivalent. + - **`chartLibCore/utils/Fill.kt`** now holds only the portable state: + `Type`/`Direction` enums, `type`, `color`, `alpha`, + `gradientColors`/`gradientPositions`, the `startColor,endColor` + gradient constructor, and `setGradientColors(...)`. The previously + `private var mFinalColor` is exposed as a new public read-only + `val finalColor: Int?` property (was only accessible internally to + `fillRect`/`fillPath` before) so the Android-only drawing extensions + can read it. `@ColorInt` dropped (non-functional at runtime, same as + prior slices). + - **New `chartLib/utils/FillAndroid.kt`** holds everything + Android-only: `fun Fill.fillRect(...)`/`fun Fill.fillPath(...)` as + extension functions (ported verbatim from the old member functions, + just reading `finalColor` instead of the private `mFinalColor`), plus + `var Fill.drawable: Drawable?` as a `WeakHashMap`-backed + extension property for the `Type.DRAWABLE` payload — same side-channel + pattern as `ILineRadarDataSet.fillDrawable` from continuation part 2. + (Note: `Type.DRAWABLE`/`drawable` were already dead code with no + public setter anywhere in the codebase before this slice; the + side-channel exists for API completeness/future use, not because + anything currently exercises it.) + - Two call sites (`BarChartRenderer.kt`, `HorizontalBarChartRenderer.kt`, + both calling `dataSet.getFill(pos)?.fillRect(...)`) needed only an + added `import info.appdev.charting.utils.fillRect` — no call-site + syntax changes, since Kotlin resolves member-call syntax + (`fill.fillRect(...)`) identically whether `fillRect` is a member or + an extension function. + - **Moved to `chartLibCore` commonMain**: `data/BarDataSet.kt`, + `interfaces/datasets/IBarDataSet.kt` — `IBarDataSet` needed no changes + (it was already portable, referencing only `Fill`/`BarEntryFloat`); + `BarDataSet` had `android.graphics.Color.rgb(...)`/`Color.BLACK` + replaced with `ColorTemplate.argb(...)`/`ColorTemplate.BLACK` (the + latter a pre-existing common constant) and `@ColorInt` dropped. + `BarDataSet` is now a fully portable leaf class — first of the three + remaining Android-only `DataSet` leaves to move. + - **`LineDataSet`/`ScatterDataSet` remain Android-only** — `LineDataSet` + still needs `IFillFormatter`/`LineDataProvider` decoupling and + `Context`/`ContextCompat`-based drawable-resource fill loading; + `ScatterDataSet` still needs the `IShapeRenderer` family split. + - No behavior changes disclosed for this slice — `finalColor` was + already computed identically before (just via a private property), + and the dead `Type.DRAWABLE` path behaves the same (returns early + when no drawable is set, same as before when `drawable` was `null`). + - Verified: `chartLibCore:compileKotlinDesktop`, + `chartLib:compileDebugKotlin`, full `chartLibCore:allTests`, + `chartLibCompose:assembleDebug`, `app:assembleDebug`, full + `./gradlew test`, and `chartLibComposeMultiplatform:build` (all + targets) all pass. + - **Not yet done**: `IFillFormatter`/`LineDataProvider` decoupling and + `IShapeRenderer`/`ScatterDataSet` splitting remain as candidate next + slices to fully unblock `LineDataSet`/`ScatterDataSet`; resuming the + Step A.7 renderer port itself (e.g. a Bar/Candle/Radar/Bubble/Pie data + renderer proof-of-concept) is also now viable given the expanded set + of portable `DataSet` leaves. +- [x] **Step A.3 (continuation, part 4) — decouple `IFillFormatter`, migrate + `LineDataSet`.** Tackles the second blocker flagged above. `LineDataSet` + had three remaining Android couplings: `android.graphics.DashPathEffect`, + the `IFillFormatter?` stored property (whose interface method takes a + `LineDataProvider`, permanently Android-only per Step A.4), and a + `Context`/`ContextCompat`-based `setCircleColors(colors, context)` + overload. + - `DashPathEffect` → common `DashEffect` (Step A.5's type, already reused + in continuation part 2): `dashPathEffect` property, private backing + field, and `enableDashedLine(...)`'s construction all now use + `DashEffect(floatArrayOf(lineLength, spaceLength), phase)`. One + renderer call site updated, `LineChartRenderer.drawCubicFill`-adjacent + line-paint setup (`paintRender.pathEffect = dataSet.dashPathEffect` → + `...?.toAndroidDashPathEffect()`, reusing the existing conversion + extension). + - **`fillFormatter` (on `ILineDataSet`/`LineDataSet`) removed from + common code entirely**, same side-channel pattern as `fillDrawable` + from continuation part 2: new + `chartLib/data/LineDataSetAndroid.kt` defines + `var ILineDataSet<*>.fillFormatter: IFillFormatter?` backed by a + `WeakHashMap, IFillFormatter>`, with the getter + lazily materializing a `DefaultFillFormatter()` default (matching the + old stored-property's non-null default) and the setter mapping + `null` back to a fresh `DefaultFillFormatter()` (matching the old + setter's behavior). Six call sites updated with an added + `import info.appdev.charting.data.fillFormatter`: + `LineChartRenderer.kt` (2 reads) and five `app` example activities + that set a custom `fillFormatter`. + - **`setCircleColors(colors: IntArray, context: Context)`** (the + `ContextCompat.getColor(...)`-resolving overload) moved to the same + `LineDataSetAndroid.kt` as a generic extension function + (`fun > LineDataSet.setCircleColors(...)`); + call-site syntax is unchanged (no existing call sites in this repo + use this overload, but the public API shape is preserved for + external consumers). + - Trivial cleanup: `@ColorInt` dropped; `Color.WHITE`/ + `Color.rgb(140, 234, 255)` → `ColorTemplate.WHITE`/ + `ColorTemplate.argb(140, 234, 255)`; two `Timber.e(...)` validation + warnings (circle/circle-hole radius) → `println(...)`, matching the + `println`-for-warnings pattern already used elsewhere in + `chartLibCore` (`AxisBase`, `ViewPortHandler`, `DataSet`, + `ChartData`); the `@SuppressLint("RawTypeDataSet")` annotation on the + internal `copy(lineDataSet: LineDataSet<*>)` helper was dropped + (Android-only annotation type, and the custom `RawTypeDataSet` lint + check only runs against the `chartLib` module, not `chartLibCore`). + - **Moved to `chartLibCore` commonMain**: `data/LineDataSet.kt`, + `interfaces/datasets/ILineDataSet.kt`. `LineDataSet` is now a fully + portable leaf class (generic over `BaseEntry`, same as before). + - **Disclosed behavior change**: `.copy()` no longer propagates a + custom `fillFormatter` to the copy (previously copied via the old + `copy(LineDataSet)` helper's `mFillFormatter` assignment) — same + category as the `fillDrawable` copy-non-propagation disclosed in + continuation part 2. Documented in `LineDataSet`'s class doc. + - **`ScatterDataSet`/`IScatterDataSet` still Android-only** — needs the + `IShapeRenderer` family split (`renderer/scatter/*`, Canvas/Paint- + coupled) plus extracting the `ScatterShape` enum (currently nested + inside the Android-only `ScatterChart`) to a portable location. + - Verified: `chartLibCore:compileKotlinDesktop`, + `chartLib:compileDebugKotlin`, full `chartLibCore:allTests`, + `chartLibCompose:assembleDebug`, `app:assembleDebug`, full + `./gradlew test`, and `chartLibComposeMultiplatform:build` (all + targets) all pass. + - **Not yet done**: `IShapeRenderer`/`ScatterDataSet` splitting (the + last remaining Android-only `DataSet` leaf); resuming the Step A.7 + renderer port itself is now viable for every chart type except + Scatter. +- [x] **Step A.3 (continuation, part 5) — split `IShapeRenderer`, migrate + `ScatterDataSet`.** Tackles the last of the three blockers flagged + above, completing the concrete `DataSet` family migration: every chart + type's `DataSet` is now portable. + - **`ScatterShape` extracted out of the Android-only `ScatterChart` + class** into a new top-level, fully portable + `chartLibCore/charts/ScatterShape.kt` (same package, + `info.appdev.charting.charts`, so `chartLib`'s `ScatterChart.kt` + needed no import change — just deletion of the nested `enum class + ScatterShape { ... }` block). This is a source-breaking change for + callers: `ScatterChart.ScatterShape.SQUARE` → `ScatterShape.SQUARE` + (5 call sites updated: `ScatterDataSetAndroid.kt`, + `ScatterChartActivity.kt`, `SimpleFragment.kt`, + `ChartExamples.kt`, `ChartState.kt`). + - **`shapeRenderer` (on `IScatterDataSet`/`ScatterDataSet`) removed + from common code entirely**, same side-channel pattern as + `fillDrawable`/`fillFormatter`: new + `chartLib/data/ScatterDataSetAndroid.kt` defines + `var IScatterDataSet.shapeRenderer: IShapeRenderer?` backed by a + `WeakHashMap`. Unlike + `fillFormatter`'s "null means reset to default" semantics, + `shapeRenderer` can be legitimately `null` (`ScatterChartRenderer` + treats a `null` renderer as "skip drawing"), so the getter uses + `containsKey` rather than `getOrPut` to distinguish "never set" + (lazily materializes the default `SquareShapeRenderer()`) from + "explicitly set to null" — `getOrPut` cannot tell those apart when + the map's value type is itself nullable. + - **`setScatterShape(shape)`** moved to the same file as an extension + function on `ScatterDataSet`; **`getRendererForShape(shape)`** moved + from `ScatterDataSet`'s companion object to a top-level function in + `chartLib` (source-breaking: `ScatterDataSet.getRendererForShape(...)` + → `getRendererForShape(...)`, unused elsewhere in this repo). + - The `renderer/scatter/*` shape-drawing classes themselves + (`IShapeRenderer` + 7 concrete renderers) stay exactly where they + are, unchanged — they're Canvas/Paint-coupled drawing logic, same + permanently-Android-only category as the `Renderer`/`DataRenderer` + hierarchy from Step A.4, not something to "split". + - **Moved to `chartLibCore` commonMain**: `data/ScatterDataSet.kt`, + `interfaces/datasets/IScatterDataSet.kt`. `ScatterDataSet` is now a + fully portable leaf class — the last of the seven concrete `DataSet` + classes (Bar/Line/Scatter/Candle/Bubble/Radar/Pie) to move. **The + concrete `DataSet` family migration that started as a Step A.7 + prerequisite is now complete.** + - **Disclosed behavior change**: `.copy()` no longer propagates a + custom `shapeRenderer` to the copy — same category as the + `fillDrawable`/`fillFormatter` copy-non-propagation disclosed in + continuation parts 2 and 4. Documented in `ScatterDataSet`'s class + doc. + - Verified: `chartLibCore:compileKotlinDesktop`, + `chartLib:compileDebugKotlin`, full `chartLibCore:allTests`, + `chartLibCompose:assembleDebug`, `app:assembleDebug`, full + `./gradlew test`, and `chartLibComposeMultiplatform:build` (all + targets) all pass. + - **Next up**: resuming the Step A.7 Compose Multiplatform renderer + port itself is now viable for every chart type's data model + (Bar/Line/Scatter/Candle/Bubble/Radar/Pie all have portable + `DataSet`s); alternatively Step A.8 (gesture handling), A.9 + (`demoKmp`), A.10 (CI), or A.11 (Publishing). +- [x] **Step A.7 (continuation) — first data renderer: non-stacked + `IBarDataSet` proof-of-concept.** Resumes the Compose Multiplatform + renderer port now that the concrete `DataSet` family is portable, using + the same "pure pixel-math function + thin `DrawScope` extension" + pattern established by the grid-line renderer, applied to a chart + *data* renderer for the first time. + - New `chartLibComposeMultiplatform/commonMain/.../renderer/ + BarChartRenderer.kt`: `barPixelRects(dataSet, barWidth, transformer)` + computes one `[left, top, right, bottom]` value-space rect per + non-stacked entry (mirroring the non-stacked branch of `chartLib`'s + `buffer.BarBuffer.feed`) and maps it to pixel space via + `TransformerCore.pointValuesToPixel` (reusing the point-pair transform + already used by the grid-line renderer, since a rect's two corners + are just two (x, y) points); `DrawScope.drawBarChartDataSet(...)` + draws each rect with `drawRect`, cycling through the dataset's colors + via `getColorByIndex(index)` the same way + `BarChartRenderer.drawDataSet` does. + - **Deliberately out of scope for this slice** (same scoping choice as + the grid-line proof-of-concept): stacked bars, animation phases + (`phaseX`/`phaseY`), inverted axis, bar borders/shadows, rounded + bars, and the `BarData`/`ChartData` container classes (still + Android-only in `chartLib`) — this renderer operates directly on a + single `IBarDataSet` plus a caller-supplied `barWidth`, matching how + `buffer.BarBuffer` itself is parameterized, without requiring + `BarData` to be ported first. + - New test `chartLibComposeMultiplatform/commonTest/.../ + BarChartRendererTest.kt`: same 100x100 content-rect setup as + `AxisGridRendererTest`; asserts a positive-value entry's rect maps to + the expected content-rect pixel positions, and that a negative-value + entry's rect keeps pixel-space `top < bottom` (i.e. the Y-axis + inversion is handled correctly regardless of value sign) — both + without a Compose UI test harness. + - Verified: `chartLibComposeMultiplatform:testAndroidHostTest`/ + `desktopTest` first (new tests green: 2/2 on each), then the full + chain — `chartLibComposeMultiplatform:build` (all targets, including + `iosSimulatorArm64Test`), `chartLibCore:allTests`, + `chartLib:compileDebugKotlin`, `chartLibCompose:assembleDebug`, + `app:assembleDebug`, full `./gradlew test` — all pass. + - **Not yet done**: stacked/grouped bars and the remaining ~29 renderer + files for every other chart type (Line/Scatter/Candle/Bubble/Radar/ + Pie data renderers, legend renderer, limit lines, markers, combined + chart compositing). +- [x] **Step A.8 — Gesture handling proof-of-concept** with + `pointerInput`/`detectTransformGestures` replacing `ChartTouchListener`/`MotionEvent`. + Turned out to need very little new common code: `ViewPortHandler`'s + `zoom`/`translate`/`refresh`/`limitTransAndScale` matrix math (bounds-clamped pan/zoom) + already lived in `chartLibCore` from earlier steps, so this slice only needed to combine + Compose's per-frame pan+zoom+centroid gesture callback into a single matrix update. + - New `chartLibComposeMultiplatform/commonMain/.../gesture/GestureHandling.kt`: + `chartGestureMatrix(viewPortHandler, pan, zoom, centroid, scaleXEnabled, scaleYEnabled, + dragXEnabled, dragYEnabled)` copies the current `matrixTouch`, applies + `postTranslate(pan)` then `postScale(zoom, zoom, centroid)` (mirrors `chartLib`'s + `BarLineChartTouchListener.performDrag`/`performZoom` combined into one step, since + Compose reports incremental deltas per frame rather than cumulative-since-gesture-start + deltas like `MotionEvent`); `Modifier.chartTransformGestures(viewPortHandler, ..., + onGesture)` wires `detectTransformGestures` to it and calls + `ViewPortHandler.refresh(matrix, onGesture, true)` (which also applies the existing + `limitTransAndScale` bounds clamping). + - **Deliberately out of scope for this proof-of-concept** (same scoping choice as the + earlier axis/data renderer slices): rotation gestures, independent X/Y-only zoom modes + (`X_ZOOM`/`Y_ZOOM`), highlight-on-drag, double-tap zoom, fling/deceleration, and + `OnChartGestureListener` callbacks — `chartLib`'s `BarLineChartTouchListener` handles + all of these but they're not needed to prove the Compose gesture wiring works. + - New test `chartLibComposeMultiplatform/commonTest/.../GestureHandlingTest.kt`: verifies + `chartGestureMatrix`'s pure matrix math directly (pan-only, zoom-about-centroid-only, + and disabled-axis cases), reading back `MTRANS_X`/`MTRANS_Y`/`MSCALE_X`/`MSCALE_Y` via + `Matrix.getValues` — no `ViewPortHandler.refresh`/clamping involved, since the clamp + bounds depend on chart state in ways that would make the pure-math assertions less + direct (same "test the pure function before the stateful wiring" approach as the + renderer tests). + - Verified: `chartLibComposeMultiplatform:testAndroidHostTest`/`desktopTest` first (new + tests green: 3/3 on each), then the full chain — + `chartLibComposeMultiplatform:build` (all targets, including `iosSimulatorArm64Test`), + `chartLibCore:allTests`, `chartLib:compileDebugKotlin`, `chartLibCompose:assembleDebug`, + `app:assembleDebug`, full `./gradlew test` — all pass. + - **Not yet done**: wiring `chartTransformGestures` into an actual composable chart + (there is no interactive Compose Multiplatform chart screen yet, since Step A.9's + `demoKmp` app doesn't exist), and the out-of-scope gesture features listed above. +- [x] **Step A.9 — `demoKmp` Compose Multiplatform demo app** (Android, iOS, Desktop). + Ties together every Compose Multiplatform proof-of-concept from Steps A.7/A.8 into one + actually-runnable `@Composable` shown on all three platforms: axis grid lines + (`drawXAxisGridLines`/`drawYAxisGridLines`), a bar chart data renderer + (`drawBarChartDataSet`), and pan/pinch-zoom gestures (`chartTransformGestures`) — all from + `chartLibComposeMultiplatform`, none from the Android-only `chartLib`/`chartLibCompose`. + - **Two Gradle modules instead of one**, discovered while implementing this slice: AGP + 9's new KMP DSL forbids combining `com.android.application` with + `org.jetbrains.kotlin.multiplatform`'s `androidTarget()` in the same project (a hard + error, not just a warning), so the plan's original single-module sketch needed + splitting: + - **`demoKmp`** (new, `com.android.kotlin.multiplatform.library` — same shape as + `chartLibComposeMultiplatform`): `commonMain/DemoKmpApp.kt` holds the shared + `@Composable` (a `BarDataSet` of 5 entries, rendered via the Step A.7 grid-line + + bar-chart renderers inside a `Canvas`, with `Modifier.chartTransformGestures(...)` + from Step A.8 attached for interactivity); `desktopMain/Main.kt` (`fun main() = + application { Window(...) { DemoKmpApp() } }`, also exposed as `:demoKmp:run`); + `iosMain/MainViewController.kt` (`ComposeUIViewController { DemoKmpApp() }`, for a + future thin Xcode wrapper to embed — see "not yet done" below). + - **`demoKmpAndroid`** (new, plain `com.android.application`, *not* multiplatform): + just `MainActivity`/`AndroidManifest.xml`/`applicationId`/launcher-activity + boilerplate, depending on `project(":demoKmp")` for `DemoKmpApp()` and the shared + renderer/gesture code. + - Both modules registered in `settings.gradle.kts` + (`include(":demoKmp")`/`include(":demoKmpAndroid")`). + - `demoKmp`'s `compileSdk` had to be `37` (not `36`, matching + `chartLibComposeMultiplatform`): Compose Multiplatform 1.12.1's Android artifacts + require it; `demoKmpAndroid` needed the same bump plus its own distinct + `namespace` (`info.appdev.charting.demokmp.androidapp`, vs. `demoKmp`'s + `info.appdev.charting.demokmp`) since AGP's manifest merger rejects two modules in the + same dependency graph sharing one namespace. + - Verified: `:demoKmpAndroid:assembleDebug` (Android APK), `:demoKmp:compileKotlinDesktop` + and a manual `:demoKmp:run` smoke-test (desktop app launches without exceptions), + `:demoKmp:compileKotlinIosSimulatorArm64`, and the full `:demoKmp:build` (all targets, + including linking both debug and release `iosArm64`/`iosSimulatorArm64` frameworks) all + pass. Full existing chain (`chartLibComposeMultiplatform:build`, `chartLibCore:allTests`, + `chartLib:compileDebugKotlin`, `chartLibCompose:assembleDebug`, `app:assembleDebug`, + `./gradlew test`) also still green. + - **Not yet done**: an actual Xcode wrapper project (`.xcodeproj`) embedding + `MainViewController()` on a real iOS device/simulator screen (only the Gradle-buildable + `iosArm64`/`iosSimulatorArm64` framework artifact exists so far); a `wasmJsMain` target; + richer demo content (multiple chart types/screens, matching `chartLibCompose`'s + `examples/ChartExamples.kt` breadth) — intentionally deferred until more renderers exist + per Step A.7's remaining ~29-file scope. +- [x] **Step A.10 — CI.** Extended `.github/workflows/pullrequest.yml` with a new + `KotlinMultiplatform` job on a `macos-latest` runner (Xcode/iOS simulators are only + available there, unlike the existing Ubuntu-based `Check` job). + - **Why a new job was needed**: the existing `Check` job already runs `./gradlew test` + and `./gradlew check` on `ubuntu-latest`, which *does* touch `chartLibCore`/ + `chartLibComposeMultiplatform`, but their iOS simulator test tasks + (`iosSimulatorArm64Test`, etc.) are silently skipped there via the + `kotlin.native.ignoreDisabledTargets=true` `gradle.properties` flag added earlier to + keep the Ubuntu build from hard-failing on unsupported native targets (see the CI-fix + session before Step A.4). That means the iOS test coverage added throughout Steps + A.3–A.9 had **never actually executed in CI** until this job. + - New `KotlinMultiplatform` job steps: checkout, JDK 17, Android SDK install (still needed + — `chartLibCore`/`chartLibComposeMultiplatform`/`demoKmp` all have an `android` target + too), then `./gradlew :chartLibCore:allTests :chartLibComposeMultiplatform:allTests` + (now genuinely running the Android host, Desktop, *and* iOS simulator test suites on a + capable runner), `./gradlew :demoKmp:build :demoKmpAndroid:assembleDebug` (builds every + `demoKmp` target, including linking the iOS frameworks, plus the separate Android app + module), and a desktop smoke-test step that launches `:demoKmp:run` in the background, + waits 25s, and asserts the process is *still running* (i.e. the Compose Desktop window + launched without an early crash) before killing it — a lightweight substitute for a + full screenshot job, deferred as explicitly optional in the plan ("if feasible"). + - Test reports (`chartLibCore`/`chartLibComposeMultiplatform` `build/reports/tests`) + archived as a build artifact (`if-no-files-found: warn`, matching the existing Lint + report archive step's leniency), for inspecting individual test results if the job + fails. + - Verified locally (this sandbox has Xcode installed): `./gradlew :chartLibCore:allTests + :chartLibComposeMultiplatform:allTests` passes; the `:demoKmp:run`-then-check-still-alive + shell logic was manually confirmed to correctly detect a successfully-launched long-lived + process (the `run` task keeps the daemon/process alive as expected). The workflow YAML + itself was validated with `python3 -c "import yaml; yaml.safe_load(...)"` for syntax + correctness (GitHub's own runners couldn't be exercised directly from this environment). + - **Not yet done**: an actual Wasm/JS target (neither `chartLibCore` nor + `chartLibComposeMultiplatform`/`demoKmp` target `wasmJs` yet — no CI job needed until + one exists); a real screenshot-diff job for `demoKmp` (the existing Android + Espresso-based screenshot comparison in the `buildTest` job doesn't apply to a Compose + Desktop/iOS window). +- [x] **Step A.11 — Publishing.** Added `com.vanniktech.maven.publish` to + `chartLibCore` and `chartLibComposeMultiplatform`, mirroring the existing + `chartLib`/`chartLibCompose` `mavenPublishing { coordinates(...); pom {}; repositories { + GitHubPackages } }` pattern. No `configure(KotlinMultiplatform(...))` call was needed — + per vanniktech's docs, `com.android.kotlin.multiplatform.library` projects (which both + modules already use) are auto-detected and configured with sensible defaults (sources + jars per source set, empty javadoc jars per target, Maven Central + GitHub Packages + + MavenLocal publications for every target: `android`, `desktop`, `iosArm64`, + `iosSimulatorArm64`, plus `iosX64` for `chartLibCore` only, and a root `kotlinMultiplatform` + metadata publication). + - Hit and fixed two Gradle/plugin interaction bugs discovered while wiring this up (both + pre-existing footguns of this repo's setup, not new problems in the vanniktech plugin + itself): + 1. **Root `gradle.properties`' `POM_ARTIFACT_ID=chart` raced with our own + `coordinates(artifactId = ...)` call for KMP projects.** The plugin's simple + `com.vanniktech.maven.publish` entry point auto-applies `pomFromGradleProperties()` + at plugin-apply time, which (for KMP projects) *asynchronously* renames every + per-target publication's artifactId from `"$projectName-$target"` to + `"$POM_ARTIFACT_ID-$target"` in an `afterEvaluate` block. Our own `coordinates(...)` + call in each module's `mavenPublishing {}` block queues a *second* `afterEvaluate` + rename, which failed with `"The plugin can't handle the publication ... artifactId + chart-android"` because by the time it ran, the artifactId no longer matched the + expected `"$projectName-"` prefix (the first rename had already consumed it). Fixed + by adding a per-module `gradle.properties` (`chartLibCore/gradle.properties`, + `chartLibComposeMultiplatform/gradle.properties`) overriding `POM_ARTIFACT_ID` to the + correct per-module value, so both renames agree and the second is a no-op. + 2. **Applying `id("com.vanniktech.maven.publish") version "0.37.0"` independently in + 4 sibling subprojects' `plugins {}` blocks** (previously only 2: + `chartLib`/`chartLibCompose`) made Gradle load the plugin under different + classloader instances for at least one pair of projects, breaking the plugin's + shared Maven Central deployment `BuildService` (`"Cannot set the value of task + ':chartLibComposeMultiplatform:prepareMavenCentralPublishing' property + 'buildService' ... loaded with InstrumentingVisitableURLClassLoader..."`). Fixed by + declaring `id("com.vanniktech.maven.publish") version "0.37.0" apply false` once in + the root `build.gradle.kts`'s new `plugins {}` block, and dropping the version from + all four subprojects' own `id("com.vanniktech.maven.publish")` declarations, so every + module resolves the exact same plugin classloader. + - `demoKmp`/`demoKmpAndroid` (demo apps, not libraries) intentionally do **not** get any + publishing plugin — nothing in the plan calls for publishing them, matching the existing + `app` module's precedent. + - Verified: `./gradlew :chartLib:publishToMavenLocal :chartLibCompose:publishToMavenLocal + :chartLibCore:publishToMavenLocal :chartLibComposeMultiplatform:publishToMavenLocal + -PRELEASE_SIGNING_ENABLED=false` succeeds for all four modules and produces the expected + per-target artifacts (poms, `.module` Gradle metadata, sources/javadoc jars, `.aar` for + `-android`, `.klib` for the iOS targets, `.jar` for `-desktop` and the root multiplatform + metadata publication) under the local Maven repo, with artifact IDs matching the existing + `chartLib`/`chartLibCompose` naming convention (e.g. `chartLibCore`, `chartLibCore- + android`, `chartLibCore-iosarm64`, `chartLibComposeMultiplatform-desktop`, ...). Also + re-ran the full existing verification chain (`chartLibCore:allTests`, + `chartLibComposeMultiplatform:allTests`, `chartLib:compileDebugKotlin`, + `chartLibCompose:assembleDebug`, `app:assembleDebug`, `demoKmp:build`, + `demoKmpAndroid:assembleDebug`, full `./gradlew test`) — all pass, no regressions. + - **Not yet done**: no CI step actually runs `publishToMavenLocal` for the two new modules + (the existing `Check` job does this for `chartLib`/`chartLibCompose` only); this could be + added to either the `Check` job or the new `KotlinMultiplatform` job as a lightweight + follow-up smoke test, but wasn't required by the plan text and is deferred. + +## Step A.2 — Common replacement abstractions needed before further migration + +These must exist in `chartLibCore` (or be designed) before the corresponding +Android-coupled classes can move: + +| Android type | Common replacement | Consumers to update | +|-----------------------------------|-----------------------------------------------------------------------------|----------------------| +| `android.graphics.Color` (Int) | Already just an `Int` ARGB value at the API boundary — no wrapper needed, just remove the `android.graphics.Color` import/usages (done for `ColorTemplate`; remaining in `components/`). | `ComponentBase`, `AxisBase` | +| `android.graphics.DashPathEffect` | `data class DashEffect(val intervals: FloatArray, val phase: Float)` in `chartLibCore`; Android renderer maps it to a real `DashPathEffect` at draw time. | `IDataSet`, `LineDataSet`, `BarLineScatterCandleBubbleDataSet`, renderers | +| `android.graphics.Typeface` | `expect class ChartTypeface` (or drop entirely from the data model and let each renderer own font/typeface as a rendering concern, not a dataset concern). | `ComponentBase`, `IDataSet` | +| `android.graphics.drawable.Drawable` (`icon`) | `expect class ChartIcon` wrapping a platform image handle (`Drawable` on Android, `UIImage`/`Painter`-friendly type on Compose Multiplatform), or make icon rendering a Compose-only concern (`painterResource` at the composable layer, not baked into `EntryFloat`). | `BaseEntry`, `EntryFloat`, `MarkerImage` | +| `android.os.Parcelable` | Drop from common data classes. Parcelable is an Android IPC/state-restoration detail, not core chart data. If Android call sites rely on `Parcelable` (e.g. `EntryFloat` in a `Bundle`), add an Android-only wrapper/extension in `chartLib`'s `androidMain`. | `EntryFloat`, `PointF` | +| `android.graphics.Matrix` / `RectF` | Pure-Kotlin `Matrix3x2`/`Rect` value classes for the shared viewport math in `ViewPortHandler`/`Transformer`; Android renderer converts to/from `android.graphics.Matrix`/`RectF` only at the drawing boundary. | `ViewPortHandler`, `Transformer`, `TransformerHorizontalBarChart` | +| `android.view.View` (in `ViewPortHandler`) | Remove the `View` reference from shared viewport math; pass width/height as plain values instead. | `ViewPortHandler` | +| `android.animation.TimeInterpolator` (`Easing`) | Plain Kotlin `fun interface Easing { fun getInterpolation(input: Float): Float }`; Android animator wraps it in a `TimeInterpolator` adapter at the call site. | `ChartAnimator`, `jobs/*` | +| `timber.log.Timber` (Android-only logging) | Introduce a tiny common logging `expect`/`actual` facade (`ChartLog.d(...)`), or simply drop debug logging from common code and keep it Android-only via `androidMain`. | `NumberUtils`, `ViewPortHandler`, `AxisBase` | +| `android.annotation.SuppressLint` / `@TargetApi` | Just remove — these are Android lint/build-tool annotations with no multiplatform meaning. | `ChartHighlighter`, `HorizontalBarHighlighter`, others | +| `android.content.Context` (`AssetManagerUtils`, `ContextUtils`) | Keep these Android-only; they stay in `chartLib`'s `androidMain`, not `commonMain`. | `AssetManagerUtils`, `ContextUtils` | + +## Migration order rationale (Steps A.3–A.6) + +The dependency graph forces a specific order — nothing in `formatter/` or +`highlight/` can move until `data/EntryFloat`, `interfaces/datasets/IDataSet`, and +`utils/ViewPortHandler` are portable, because every formatter/highlighter references +at least one of them: + +1. **`data/` + `interfaces/`** — apply the `DashEffect`/`ChartTypeface`/`ChartIcon`/ + dropped-`Parcelable` abstractions from Step A.2 to `BaseEntry`, `EntryFloat`, + `EntryDouble`, all `*Entry*` subclasses, `BaseDataSet`, `DataSet`, all + `*DataSet` subclasses, `ChartData` and subclasses, and all `interfaces/datasets/*` + / `interfaces/dataprovider/*`. +2. **`formatter/`** — zero Android imports already; only blocked by `EntryFloat`, + `IDataSet`, `ViewPortHandler`, `AxisBase`. Once `data/`+`interfaces/` move, only + `ViewPortHandler`/`AxisBase` block the formatters that reference them + (`DefaultValueFormatter`, `IValueFormatter`, `LargeValueFormatter`, + `PercentFormatter`, `StackedValueFormatter`) — see Step A.6. +3. **`highlight/`** — `Range`/`RangeDouble` already moved. The rest + (`Highlight`, `IHighlighter`, `BarHighlighter`, `CombinedHighlighter`, + `PieHighlighter`, `PieRadarHighlighter`, `RadarHighlighter`, `ChartHighlighter`, + `HorizontalBarHighlighter`) only need `@SuppressLint` removed plus the `data/` + and `interfaces/` migration from step 1. Note: `PieHighlighter`/`RadarHighlighter`/ + `PieRadarHighlighter` reference `charts/PieChart`, `charts/RadarChart`, + `charts/PieRadarChartBase` directly — these need to depend on new common + *provider interfaces* instead of concrete `View`-based chart classes (introduce + `interfaces/dataprovider` equivalents if not already sufficient), so the + highlighter logic doesn't require the Android `View` subclasses at all. +4. **`components/`** — `ComponentBase`/`AxisBase` need `Typeface`/`Paint`/`Color` + removed per Step A.2. `Legend`, `LegendEntry`, `XAxis`, `YAxis`, `LimitLine`, + `LimitRange`, `Description` mostly extend/compose these, so they follow + naturally once the base classes are portable. `MarkerView`/`MarkerImage` + (Android `View` subclasses) stay in `chartLib`'s `androidMain` — they are + inherently platform UI, not shared logic. +5. **`utils/` geometry** — `ViewPortHandler`, `Transformer`, + `TransformerHorizontalBarChart` need the `Matrix`/`RectF`/`View` replacement + from Step A.2. `PointF` needs `Parcelable` dropped from the common variant + (keep an Android-only `Parcelable` extension if needed for `Bundle` interop). + `CanvasUtils`, `SaveUtils`, `PaintUtils`, `AssetManagerUtils`, `ContextUtils` + stay Android-only (`androidMain`) — they are fundamentally platform IO/graphics + concerns, not shared chart logic. + +> **Correction found while executing step 1 (see the Status changelog above):** +> `interfaces/datasets/IDataSet.valueFormatter` is typed `IValueFormatter`, whose +> `getFormattedValue(...)` takes a `ViewPortHandler?`, and +> `ILineDataSet.fillFormatter` is typed `IFillFormatter`, whose +> `getFillLinePosition(...)` takes a `LineDataProvider` (`interfaces/dataprovider`). +> This means step 1 (`data/`+`interfaces/`) **cannot fully complete before** step 5 +> (`ViewPortHandler`/`Transformer`) at least partially lands, and before a +> common-friendly design exists for `interfaces/dataprovider` (which today exposes +> `RectF`/`Transformer` and is tightly coupled to the concrete `View`-based chart +> classes). The enums/typedefs that *don't* transitively depend on these +> (`AxisDependency`, `LegendForm`, `PaintStyle`, `ChartTypeface`, `DashEffect`, +> `PointF`, `chartDensity`/`convertDpToPixel`) were still landed in `chartLibCore` +> as groundwork, and applied to their Android-only call sites in `chartLib` where +> that was safe to do without moving the whole class. `IBarDataSet`/`BarDataSet` +> (via `utils/Fill.kt`) and `IScatterDataSet`/`ScatterDataSet` (via +> `renderer/scatter/IShapeRenderer`) have their own independent blockers on top of +> this and will need dedicated abstraction work regardless of when steps 1/5 +> otherwise complete. + +## Step A.7+ — Compose Multiplatform renderer + +Once `chartLibCore` covers data/formatters/highlighters/components/viewport math, +create `chartLibComposeMultiplatform`: + +- Apply `org.jetbrains.kotlin.multiplatform` + `org.jetbrains.compose` (JetBrains + Compose Multiplatform gradle plugin, not just `org.jetbrains.kotlin.plugin.compose` + which only enables the compiler plugin for Android/JVM-only Compose). +- Reimplement each renderer (`LineChartRenderer`, `BarChartRenderer`, + `PieChartRenderer`, etc.) as `DrawScope` extension functions using + `drawLine`/`drawPath`/`drawCircle`/`drawText` instead of `Canvas.drawX(Paint)`. + This is the single largest remaining chunk of work (~32 renderer files). +- Replace touch/gesture handling (`ChartTouchListener`, `BarLineChartTouchListener`, + `PieRadarChartTouchListener`) with Compose `pointerInput` + + `detectTransformGestures`/`detectDragGestures`. +- Target `androidTarget()`, `iosX64/iosArm64/iosSimulatorArm64()`, + `jvm("desktop")`, and optionally `wasmJs { browser() }`. +- Structure source sets as `commonMain` (chart composables + draw logic), + `androidMain`, `iosMain`, `desktopMain`, `wasmJsMain` only for platform-specific + bits (e.g., font loading, bitmap export). + +## Step A.9 — `demoKmp` app + +- Two Gradle modules (split forced by AGP 9's new KMP DSL, which forbids + `com.android.application` + `androidTarget()` in the same project): + - `demoKmp` (`com.android.kotlin.multiplatform.library`, same shape as + `chartLibComposeMultiplatform`): `commonMain` (shared `DemoKmpApp()` composable + using the Step A.7/A.8 renderers/gestures), `desktopMain` (`main()` launching a + `ComposeWindow`, also runnable via `:demoKmp:run`), `iosMain` + (`MainViewController()` via `ComposeUIViewController`). + - `demoKmpAndroid` (plain `com.android.application`): `MainActivity` + + `AndroidManifest.xml` + `applicationId`, depending on `project(":demoKmp")`. +- Registered in `settings.gradle.kts` as `include(":demoKmp")` and + `include(":demoKmpAndroid")`. +- Not yet done: an actual Xcode wrapper project embedding `MainViewController()`, + a `wasmJsMain` target, and richer demo content (multiple chart types/screens). + +## Step A.10 — CI + +- New `KotlinMultiplatform` job in `.github/workflows/pullrequest.yml`, on + `macos-latest` (required for iOS simulator targets): installs JDK 17 + Android + SDK, then runs `./gradlew :chartLibCore:allTests :chartLibComposeMultiplatform:allTests` + (Android host + Desktop + iOS simulator tests — previously never actually + executed in CI, since the Ubuntu `Check` job skips disabled iOS native targets + via `kotlin.native.ignoreDisabledTargets=true`), then + `./gradlew :demoKmp:build :demoKmpAndroid:assembleDebug`, then a desktop + smoke-test step (launches `:demoKmp:run` in the background, waits 25s, asserts + the process is still alive, then kills it). +- Test reports archived as a build artifact for failure diagnosis. +- Not yet done: a `wasmJs` target (none of the KMP modules target it yet) and a + real screenshot-diff job for `demoKmp` (Compose Desktop/iOS, unlike the + existing Android Espresso-based screenshot comparison). + +## Step A.11 — Publishing + +- Added `id("com.vanniktech.maven.publish")` (declared with a version once, as + `apply false`, in the root `build.gradle.kts`'s new `plugins {}` block — see + below for why) plus a `mavenPublishing { coordinates(...); pom {}; repositories + { GitHubPackages } }` block to `chartLibCore/build.gradle.kts` and + `chartLibComposeMultiplatform/build.gradle.kts`, mirroring the existing + `chartLib`/`chartLibCompose` pattern exactly. No `configure(KotlinMultiplatform + (...))` call needed — the plugin auto-detects `com.android.kotlin.multiplatform + .library` projects and configures per-target publications automatically. +- Resulting publications per module: a root `kotlinMultiplatform` metadata + publication (artifact ID = the module name, e.g. `chartLibCore`), plus one + per target: `chartLibCore-android`, `chartLibCore-desktop`, + `chartLibCore-iosarm64`, `chartLibCore-iossimulatorarm64`, + `chartLibCore-iosx64` (`chartLibComposeMultiplatform` has the same set minus + `-iosx64`, since Compose Multiplatform 1.12.x doesn't publish iOS-Intel- + simulator artifacts — see Step A.7's iOS target note). Each publishes sources + + (empty) javadoc jars, plus `.aar`/`.klib`/`.jar` as appropriate for the + target. +- Two Gradle/plugin interaction bugs fixed along the way: + - Root `gradle.properties`' `POM_ARTIFACT_ID=chart` conflicted with per-module + `coordinates(artifactId = ...)` calls for KMP projects specifically (the + plugin's automatic `pomFromGradleProperties()` and our explicit + `coordinates()` call both rename every per-target publication's artifactId + in `afterEvaluate`, and for KMP projects the rename logic requires the + *current* artifactId to still start with `"$projectName-"`, which broke + once the first (automatic) rename ran first). Fixed with a per-module + `chartLibCore/gradle.properties` / `chartLibComposeMultiplatform/gradle. + properties` overriding `POM_ARTIFACT_ID` to the correct value, so both + renames agree. + - Applying `id("com.vanniktech.maven.publish") version "0.37.0"` independently + in 4 sibling subprojects' `plugins {}` blocks (previously only 2) made + Gradle load the plugin under different classloaders for some project pairs, + breaking its shared Maven Central `BuildService`. Fixed by declaring the + versioned plugin once (`apply false`) in the root `build.gradle.kts` and + referencing it without a version in all four subprojects. +- `demoKmp`/`demoKmpAndroid` are demo apps and intentionally have no publishing + plugin, matching the existing `app` module's precedent. +- Verified via `./gradlew :chartLib:publishToMavenLocal :chartLibCompose: + publishToMavenLocal :chartLibCore:publishToMavenLocal :chartLibComposeMultiplatform: + publishToMavenLocal -PRELEASE_SIGNING_ENABLED=false` (all four succeed, + artifacts inspected under the local Maven repo) plus the full existing + verification chain. +- Not yet done: no CI step runs `publishToMavenLocal` for the two new modules + yet (only `chartLib`/`chartLibCompose` currently get that smoke test in the + `Check` job); updating `jitpack.yml` / README to document the new KMP + coordinates once this branch is actually released. + +## Notes / open questions for maintainers + +- This roughly doubles the surface area to keep in sync with the existing + View-based `chartLib`, which is explicitly in **maintenance mode**. Confirm + there's real demand/maintainer bandwidth before committing to Steps A.7–A.11. +- Decide whether `chartLibCompose` (AndroidView-wrapper) stays long-term as a + lighter-weight Android-only option, or is deprecated once + `chartLibComposeMultiplatform` reaches parity. +- Decide on `wasmJs`/JS support scope now vs. later — it adds meaningful + complexity (font rendering, canvas API differences) for comparatively niche + demand relative to Android/iOS/Desktop. diff --git a/app/src/main/kotlin/info/appdev/chartexample/CandleStickChartActivity.kt b/app/src/main/kotlin/info/appdev/chartexample/CandleStickChartActivity.kt index 04760f6a6..8f21c4395 100644 --- a/app/src/main/kotlin/info/appdev/chartexample/CandleStickChartActivity.kt +++ b/app/src/main/kotlin/info/appdev/chartexample/CandleStickChartActivity.kt @@ -4,7 +4,6 @@ import android.Manifest import android.content.Intent import android.content.pm.PackageManager import android.graphics.Color -import android.graphics.Paint import android.os.Bundle import android.view.Menu import android.view.MenuItem @@ -21,6 +20,7 @@ import info.appdev.charting.components.YAxis.AxisDependency import info.appdev.charting.data.CandleData import info.appdev.charting.data.CandleDataSet import info.appdev.charting.data.CandleEntryFloat +import info.appdev.charting.utils.PaintStyle class CandleStickChartActivity : DemoBase(), OnSeekBarChangeListener { @@ -108,9 +108,9 @@ class CandleStickChartActivity : DemoBase(), OnSeekBarChangeListener { set1.shadowColor = Color.DKGRAY set1.shadowWidth = 0.7f set1.decreasingColor = Color.BLUE - set1.decreasingPaintStyle = Paint.Style.FILL + set1.decreasingPaintStyle = PaintStyle.FILL set1.increasingColor = Color.rgb(122, 242, 84) - set1.increasingPaintStyle = Paint.Style.STROKE + set1.increasingPaintStyle = PaintStyle.STROKE set1.neutralColor = Color.BLUE //set1.setHighlightLineWidth(1f); diff --git a/app/src/main/kotlin/info/appdev/chartexample/CubicLineChartActivity.kt b/app/src/main/kotlin/info/appdev/chartexample/CubicLineChartActivity.kt index d67111a48..f1a6727a8 100644 --- a/app/src/main/kotlin/info/appdev/chartexample/CubicLineChartActivity.kt +++ b/app/src/main/kotlin/info/appdev/chartexample/CubicLineChartActivity.kt @@ -19,6 +19,7 @@ import info.appdev.charting.components.YAxis import info.appdev.charting.data.EntryFloat import info.appdev.charting.data.LineData import info.appdev.charting.data.LineDataSet +import info.appdev.charting.data.fillFormatter import info.appdev.charting.formatter.IFillFormatter import info.appdev.charting.interfaces.dataprovider.LineDataProvider import info.appdev.charting.interfaces.datasets.ILineDataSet diff --git a/app/src/main/kotlin/info/appdev/chartexample/DataTools.kt b/app/src/main/kotlin/info/appdev/chartexample/DataTools.kt index 0ae78e982..78d574ad1 100644 --- a/app/src/main/kotlin/info/appdev/chartexample/DataTools.kt +++ b/app/src/main/kotlin/info/appdev/chartexample/DataTools.kt @@ -2,12 +2,14 @@ package info.appdev.chartexample import android.content.Context import android.graphics.Color -import android.graphics.DashPathEffect +import info.appdev.charting.utils.DashEffect import androidx.core.content.ContextCompat import info.appdev.charting.charts.LineChart import info.appdev.charting.data.EntryFloat import info.appdev.charting.data.LineData import info.appdev.charting.data.LineDataSet +import info.appdev.charting.data.fillDrawable +import info.appdev.charting.data.fillFormatter import info.appdev.charting.formatter.IFillFormatter import info.appdev.charting.interfaces.dataprovider.LineDataProvider import info.appdev.charting.interfaces.datasets.ILineDataSet @@ -214,7 +216,7 @@ class DataTools { // customize legend entry lineDataSet01.formLineWidth = 1f - lineDataSet01.formLineDashEffect = DashPathEffect(floatArrayOf(10f, 5f), 0f) + lineDataSet01.formLineDashEffect = DashEffect(floatArrayOf(10f, 5f), 0f) lineDataSet01.formSize = 15f // text size of values diff --git a/app/src/main/kotlin/info/appdev/chartexample/FilledLineActivity.kt b/app/src/main/kotlin/info/appdev/chartexample/FilledLineActivity.kt index f8e082637..30be05551 100644 --- a/app/src/main/kotlin/info/appdev/chartexample/FilledLineActivity.kt +++ b/app/src/main/kotlin/info/appdev/chartexample/FilledLineActivity.kt @@ -13,6 +13,7 @@ import info.appdev.charting.components.YAxis import info.appdev.charting.data.EntryFloat import info.appdev.charting.data.LineData import info.appdev.charting.data.LineDataSet +import info.appdev.charting.data.fillFormatter import info.appdev.charting.formatter.IFillFormatter import info.appdev.charting.interfaces.dataprovider.LineDataProvider import info.appdev.charting.interfaces.datasets.ILineDataSet diff --git a/app/src/main/kotlin/info/appdev/chartexample/GradientActivity.kt b/app/src/main/kotlin/info/appdev/chartexample/GradientActivity.kt index c3fda989b..677e11ca8 100644 --- a/app/src/main/kotlin/info/appdev/chartexample/GradientActivity.kt +++ b/app/src/main/kotlin/info/appdev/chartexample/GradientActivity.kt @@ -9,6 +9,8 @@ import info.appdev.chartexample.notimportant.DemoBase import info.appdev.charting.data.EntryFloat import info.appdev.charting.data.LineData import info.appdev.charting.data.LineDataSet +import info.appdev.charting.data.fillDrawable +import info.appdev.charting.data.fillFormatter import info.appdev.charting.formatter.IFillFormatter import info.appdev.charting.interfaces.dataprovider.LineDataProvider import info.appdev.charting.interfaces.datasets.ILineDataSet diff --git a/app/src/main/kotlin/info/appdev/chartexample/ScatterChartActivity.kt b/app/src/main/kotlin/info/appdev/chartexample/ScatterChartActivity.kt index e78b5e8fd..d50d8cda4 100644 --- a/app/src/main/kotlin/info/appdev/chartexample/ScatterChartActivity.kt +++ b/app/src/main/kotlin/info/appdev/chartexample/ScatterChartActivity.kt @@ -14,11 +14,13 @@ import info.appdev.chartexample.DataTools.Companion.getValues import info.appdev.chartexample.custom.CustomScatterShapeRenderer import info.appdev.chartexample.databinding.ActivityScatterchartBinding import info.appdev.chartexample.notimportant.DemoBase -import info.appdev.charting.charts.ScatterChart +import info.appdev.charting.charts.ScatterShape import info.appdev.charting.components.Legend import info.appdev.charting.data.EntryFloat import info.appdev.charting.data.ScatterData import info.appdev.charting.data.ScatterDataSet +import info.appdev.charting.data.setScatterShape +import info.appdev.charting.data.shapeRenderer import info.appdev.charting.highlight.Highlight import info.appdev.charting.interfaces.datasets.IScatterDataSet import info.appdev.charting.listener.OnChartValueSelectedListener @@ -99,10 +101,10 @@ class ScatterChartActivity : DemoBase(), OnSeekBarChangeListener, OnChartValueSe // create a dataset and give it a type val set1 = ScatterDataSet(values1, "DS 1") - set1.setScatterShape(ScatterChart.ScatterShape.SQUARE) + set1.setScatterShape(ScatterShape.SQUARE) set1.color = ColorTemplate.COLORFUL_COLORS[0] val set2 = ScatterDataSet(values2, "DS 2") - set2.setScatterShape(ScatterChart.ScatterShape.CIRCLE) + set2.setScatterShape(ScatterShape.CIRCLE) set2.scatterShapeHoleColor = ColorTemplate.COLORFUL_COLORS[3] set2.scatterShapeHoleRadius = 3f set2.color = ColorTemplate.COLORFUL_COLORS[1] diff --git a/app/src/main/kotlin/info/appdev/chartexample/SpecificPositionsLineChartActivity.kt b/app/src/main/kotlin/info/appdev/chartexample/SpecificPositionsLineChartActivity.kt index 212d96084..ee1380218 100644 --- a/app/src/main/kotlin/info/appdev/chartexample/SpecificPositionsLineChartActivity.kt +++ b/app/src/main/kotlin/info/appdev/chartexample/SpecificPositionsLineChartActivity.kt @@ -2,7 +2,7 @@ package info.appdev.chartexample import android.annotation.SuppressLint import android.graphics.Color -import android.graphics.DashPathEffect +import info.appdev.charting.utils.DashEffect import android.graphics.Typeface import android.os.Bundle import android.view.Menu @@ -23,6 +23,7 @@ import info.appdev.charting.components.LimitLine.LimitLabelPosition import info.appdev.charting.data.EntryFloat import info.appdev.charting.data.LineData import info.appdev.charting.data.LineDataSet +import info.appdev.charting.data.fillDrawable import info.appdev.charting.highlight.Highlight import info.appdev.charting.interfaces.datasets.ILineDataSet import info.appdev.charting.listener.ChartTouchListener.ChartGesture @@ -272,7 +273,7 @@ class SpecificPositionsLineChartActivity : DemoBase(), OnSeekBarChangeListener, set11.valueTextSize = 9f set11.isDrawFilled = true set11.formLineWidth = 1f - set11.formLineDashEffect = DashPathEffect(floatArrayOf(10f, 5f), 0f) + set11.formLineDashEffect = DashEffect(floatArrayOf(10f, 5f), 0f) set11.formSize = 15f if (getSDKInt() >= 18) { // fill drawable only supported on api level 18 and above diff --git a/app/src/main/kotlin/info/appdev/chartexample/TimeLineActivity.kt b/app/src/main/kotlin/info/appdev/chartexample/TimeLineActivity.kt index ba3908ac9..4014e068a 100644 --- a/app/src/main/kotlin/info/appdev/chartexample/TimeLineActivity.kt +++ b/app/src/main/kotlin/info/appdev/chartexample/TimeLineActivity.kt @@ -21,6 +21,7 @@ import info.appdev.charting.data.EntryDouble import info.appdev.charting.data.EntryFloat import info.appdev.charting.data.LineData import info.appdev.charting.data.LineDataSet +import info.appdev.charting.data.fillFormatter import info.appdev.charting.formatter.IFillFormatter import info.appdev.charting.interfaces.dataprovider.LineDataProvider import info.appdev.charting.interfaces.datasets.ILineDataSet diff --git a/app/src/main/kotlin/info/appdev/chartexample/fragments/SimpleFragment.kt b/app/src/main/kotlin/info/appdev/chartexample/fragments/SimpleFragment.kt index 5907041a9..8e359c50f 100644 --- a/app/src/main/kotlin/info/appdev/chartexample/fragments/SimpleFragment.kt +++ b/app/src/main/kotlin/info/appdev/chartexample/fragments/SimpleFragment.kt @@ -8,7 +8,7 @@ import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment import info.appdev.chartexample.DataTools.Companion.getValues -import info.appdev.charting.charts.ScatterChart.ScatterShape +import info.appdev.charting.charts.ScatterShape import info.appdev.charting.data.BarData import info.appdev.charting.data.BarDataSet import info.appdev.charting.data.BarEntryFloat @@ -20,6 +20,7 @@ import info.appdev.charting.data.PieDataSet import info.appdev.charting.data.PieEntryFloat import info.appdev.charting.data.ScatterData import info.appdev.charting.data.ScatterDataSet +import info.appdev.charting.data.setScatterShape import info.appdev.charting.interfaces.datasets.IBarDataSet import info.appdev.charting.interfaces.datasets.ILineDataSet import info.appdev.charting.interfaces.datasets.IScatterDataSet diff --git a/build.gradle.kts b/build.gradle.kts index d82a4438a..a9d8f93e6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -13,6 +13,17 @@ buildscript { } } +plugins { + // Declared once here (without applying) so every module that publishes + // (chartLib, chartLibCompose, chartLibCore, chartLibComposeMultiplatform) resolves + // the exact same plugin classloader instance. Applying the same versioned plugin + // id independently in 3+ sibling subprojects can otherwise make Gradle load it under + // different classloaders, which breaks the plugin's shared Maven Central build service + // ("Cannot set the value of task ... property 'buildService' ... loaded with + // InstrumentingVisitableURLClassLoader..."). + id("com.vanniktech.maven.publish") version "0.37.0" apply false +} + allprojects { repositories { google() diff --git a/chartLib/build.gradle.kts b/chartLib/build.gradle.kts index cdc1e7ce3..fffacccb9 100644 --- a/chartLib/build.gradle.kts +++ b/chartLib/build.gradle.kts @@ -5,7 +5,7 @@ import java.net.URI plugins { id("com.android.library") id("maven-publish") - id("com.vanniktech.maven.publish") version "0.37.0" + id("com.vanniktech.maven.publish") } android { @@ -55,6 +55,7 @@ dependencies { lintChecks(project(":lint")) // applies locally lintPublish(project(path = ":lint", configuration = "lintJar")) // embeds in published AAR + api(project(":chartLibCore")) implementation("androidx.annotation:annotation:1.10.0") implementation("androidx.core:core:1.16.0") implementation("androidx.activity:activity-ktx:1.10.1") diff --git a/chartLib/src/main/kotlin/info/appdev/charting/charts/BarChart.kt b/chartLib/src/main/kotlin/info/appdev/charting/charts/BarChart.kt index 617571756..22fbb3275 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/charts/BarChart.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/charts/BarChart.kt @@ -10,6 +10,8 @@ import info.appdev.charting.highlight.BarHighlighter import info.appdev.charting.highlight.Highlight import info.appdev.charting.interfaces.dataprovider.BarDataProvider import info.appdev.charting.renderer.BarChartRenderer +import info.appdev.charting.utils.copyInto +import info.appdev.charting.utils.toCommonRectF import timber.log.Timber import java.util.Locale @@ -178,7 +180,9 @@ open class BarChart : BarLineChartBase, BarDataProvider { outputRect.set(left, top, right, bottom) - getTransformer(set.axisDependency).rectValueToPixel(outputRect) + val commonRect = outputRect.toCommonRectF() + getTransformer(set.axisDependency).rectValueToPixel(commonRect) + commonRect.copyInto(outputRect) } } diff --git a/chartLib/src/main/kotlin/info/appdev/charting/charts/BarLineChartBase.kt b/chartLib/src/main/kotlin/info/appdev/charting/charts/BarLineChartBase.kt index f29cf15ff..9f6969ed0 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/charts/BarLineChartBase.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/charts/BarLineChartBase.kt @@ -4,7 +4,6 @@ import android.annotation.SuppressLint import android.content.Context import android.graphics.Canvas import android.graphics.Color -import android.graphics.Matrix import android.graphics.Paint import android.graphics.RectF import android.util.AttributeSet @@ -15,6 +14,8 @@ import info.appdev.charting.components.Legend.LegendVerticalAlignment import info.appdev.charting.components.XAxis.XAxisPosition import info.appdev.charting.components.YAxis import info.appdev.charting.components.YAxis.AxisDependency +import info.appdev.charting.components.getRequiredHeightSpace +import info.appdev.charting.components.getRequiredWidthSpace import info.appdev.charting.data.BarLineScatterCandleBubbleData import info.appdev.charting.data.BaseEntry import info.appdev.charting.data.EntryFloat @@ -37,6 +38,8 @@ import info.appdev.charting.utils.PointF.Companion.getInstance import info.appdev.charting.utils.PointF.Companion.recycleInstance import info.appdev.charting.utils.Transformer import info.appdev.charting.utils.convertDpToPixel +import info.appdev.charting.utils.toAndroidRectF +import info.appdev.charting.utils.Matrix import timber.log.Timber import kotlin.math.abs import kotlin.math.max @@ -242,7 +245,7 @@ abstract class BarLineChartBase>> : ViewGroup, I mDescPaint.typeface = description.typeface mDescPaint.textSize = description.textSize mDescPaint.color = description.textColor - mDescPaint.textAlign = description.textAlign + mDescPaint.textAlign = description.textAlign.toAndroidAlign() val x: Float val y: Float @@ -924,7 +926,7 @@ abstract class Chart>> : ViewGroup, I * (into which the actual values are drawn). */ override val contentRect: RectF - get() = viewPortHandler.contentRect + get() = viewPortHandler.contentRect.toAndroidRectF() /** * disables intercept touch events diff --git a/chartLib/src/main/kotlin/info/appdev/charting/charts/HorizontalBarChart.kt b/chartLib/src/main/kotlin/info/appdev/charting/charts/HorizontalBarChart.kt index 33506ae2f..44ce166a0 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/charts/HorizontalBarChart.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/charts/HorizontalBarChart.kt @@ -8,6 +8,7 @@ import info.appdev.charting.components.Legend.LegendOrientation import info.appdev.charting.components.Legend.LegendVerticalAlignment import info.appdev.charting.components.XAxis.XAxisPosition import info.appdev.charting.components.YAxis.AxisDependency +import info.appdev.charting.components.getRequiredHeightSpace import info.appdev.charting.data.BarEntryFloat import info.appdev.charting.data.EntryFloat import info.appdev.charting.highlight.Highlight @@ -20,6 +21,8 @@ import info.appdev.charting.utils.PointF import info.appdev.charting.utils.PointF.Companion.getInstance import info.appdev.charting.utils.TransformerHorizontalBarChart import info.appdev.charting.utils.convertDpToPixel +import info.appdev.charting.utils.copyInto +import info.appdev.charting.utils.toCommonRectF import timber.log.Timber import kotlin.math.max import kotlin.math.min @@ -222,7 +225,9 @@ open class HorizontalBarChart : BarChart { outputRect.set(left, top, right, bottom) - getTransformer(set!!.axisDependency).rectValueToPixel(outputRect) + val commonRect = outputRect.toCommonRectF() + getTransformer(set!!.axisDependency).rectValueToPixel(commonRect) + commonRect.copyInto(outputRect) } } diff --git a/chartLib/src/main/kotlin/info/appdev/charting/charts/ScatterChart.kt b/chartLib/src/main/kotlin/info/appdev/charting/charts/ScatterChart.kt index 4efae3569..ce7598146 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/charts/ScatterChart.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/charts/ScatterChart.kt @@ -31,37 +31,6 @@ open class ScatterChart : BarLineChartBase, ScatterDataProvider { override val scatterData: ScatterData? get() = mData - /** - * Predefined ScatterShapes that allow the specification of a shape a ScatterDataSet should be drawn with. - * If a ScatterShape is specified for a ScatterDataSet, the required renderer is set. - */ - enum class ScatterShape(private val shapeIdentifier: String) { - SQUARE("SQUARE"), - CIRCLE("CIRCLE"), - TRIANGLE("TRIANGLE"), - CROSS("CROSS"), - X("X"), - CHEVRON_UP("CHEVRON_UP"), - CHEVRON_DOWN("CHEVRON_DOWN"); - - override fun toString(): String { - return shapeIdentifier - } - - companion object { - val allDefaultShapes: Array - get() = arrayOf( - SQUARE, - CIRCLE, - TRIANGLE, - CROSS, - X, - CHEVRON_UP, - CHEVRON_DOWN - ) - } - } - override val accessibilityDescription: String get() = "This is scatter chart" } diff --git a/chartLib/src/main/kotlin/info/appdev/charting/components/AxisBaseAndroid.kt b/chartLib/src/main/kotlin/info/appdev/charting/components/AxisBaseAndroid.kt new file mode 100644 index 000000000..7883d278c --- /dev/null +++ b/chartLib/src/main/kotlin/info/appdev/charting/components/AxisBaseAndroid.kt @@ -0,0 +1,31 @@ +package info.appdev.charting.components + +import android.graphics.Paint + +/** + * Returns the longest formatted label (in terms of px), this axis + * contains. If paint is null, then returns the longest formatted label + * (in terms of characters), this axis contains. + * + * Android-only extension since text width measurement requires + * `android.graphics.Paint`. + */ +fun AxisBase.getLongestLabel(p: Paint?): String { + if (p == null) { + return this.longestLabel + } + var longest: String? = "" + val max = 0f + + for (i in entries.indices) { + val text = getFormattedLabel(i) + if (text != null) { + val width = p.measureText(text) + if (max < width) { + longest = text + } + } + } + + return longest!! +} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/components/Legend.kt b/chartLib/src/main/kotlin/info/appdev/charting/components/Legend.kt deleted file mode 100644 index 1c94e9fcd..000000000 --- a/chartLib/src/main/kotlin/info/appdev/charting/components/Legend.kt +++ /dev/null @@ -1,518 +0,0 @@ -package info.appdev.charting.components - -import android.graphics.DashPathEffect -import android.graphics.Paint -import info.appdev.charting.utils.ColorTemplate -import info.appdev.charting.utils.FSize -import info.appdev.charting.utils.ViewPortHandler -import info.appdev.charting.utils.calcTextHeight -import info.appdev.charting.utils.calcTextSize -import info.appdev.charting.utils.calcTextWidth -import info.appdev.charting.utils.convertDpToPixel -import info.appdev.charting.utils.getLineHeight -import info.appdev.charting.utils.getLineSpacing -import kotlin.math.max -import kotlin.math.min - -/** - * Class representing the legend of the chart. The legend will contain one entry - * per color and DataSet. Multiple colors in one DataSet are grouped together. - * The legend object is NOT available before setting data to the chart. - */ -class Legend() : ComponentBase() { - enum class LegendForm { - /** - * Avoid drawing a form - */ - NONE, - - /** - * Do not draw the form, but leave space for it - */ - EMPTY, - - /** - * Use default (default dataset's form to the legend's form) - */ - DEFAULT, - - /** - * Draw a square - */ - SQUARE, - - /** - * Draw a circle - */ - CIRCLE, - - /** - * Draw a horizontal line - */ - LINE - } - - enum class LegendHorizontalAlignment { - LEFT, CENTER, RIGHT - } - - enum class LegendVerticalAlignment { - TOP, CENTER, BOTTOM - } - - enum class LegendOrientation { - HORIZONTAL, VERTICAL - } - - enum class LegendDirection { - LEFT_TO_RIGHT, RIGHT_TO_LEFT - } - - /** - * The legend entries array - */ - var entries: Array = arrayOf() - private set - - /** - * Entries that will be appended to the end of the auto calculated entries after calculating the legend. - * (if the legend has already been calculated, you will need to call notifyDataSetChanged() to let the changes take effect) - */ - var extraEntries: Array = arrayOf() - private set - - /** - * Are the legend labels/colors a custom value or auto calculated? If false, - * then it's auto, if true, then custom. default false (automatic legend) - */ - var isLegendCustom: Boolean = false - private set - - /** - * sets the horizontal alignment of the legend - */ - var horizontalAlignment: LegendHorizontalAlignment = LegendHorizontalAlignment.LEFT - - /** - * sets the vertical alignment of the legend - */ - var verticalAlignment: LegendVerticalAlignment = LegendVerticalAlignment.BOTTOM - - /** - * sets the orientation of the legend - */ - var orientation: LegendOrientation = LegendOrientation.HORIZONTAL - - /** - * returns whether the legend will draw inside the chart or outside - */ - var isDrawInsideEnabled: Boolean = false - private set - - /** - * the text direction for the legend - */ - var direction: LegendDirection = LegendDirection.LEFT_TO_RIGHT - - /** - * the shape/form the legend colors are drawn in - */ - var form: LegendForm = LegendForm.SQUARE - - /** - * the size of the legend forms/shapes - */ - var formSize: Float = 8f - - /** - * the size of the legend forms/shapes - */ - var formLineWidth: Float = 3f - - /** - * Line dash path effect used for shapes that consist of lines. - */ - var formLineDashEffect: DashPathEffect? = null - - /** - * the space between the legend entries on a horizontal axis, default 6f - */ - var xEntrySpace: Float = 6f - - /** - * the space between the legend entries on a vertical axis, default 5f - */ - var yEntrySpace: Float = 0f - - /** - * the space between the legend entries on a vertical axis, default 2f - * private float mYEntrySpace = 2f; / ** the space between the form and the - * actual label/text - */ - var formToTextSpace: Float = 5f - - /** - * the space that should be left between stacked forms - */ - var stackSpace: Float = 3f - - /** - * The maximum relative size out of the whole chart view. / If the legend is - * to the right/left of the chart, then this affects the width of the - * legend. / If the legend is to the top/bottom of the chart, then this - * affects the height of the legend. / If the legend is the center of the - * piechart, then this defines the size of the rectangular bounds out of the - * size of the "hole". / default: 0.95f (95%) - */ - var maxSizePercent: Float = 0.95f - - /** - * Constructor. Provide entries for the legend. - */ - constructor(entries: Array) : this() { - this.entries = entries - } - - /** - * This method sets the automatically computed colors for the legend. Use setCustom(...) to set custom colors. - */ - fun setEntries(entries: MutableList) { - this.entries = entries.toTypedArray() - } - - /** - * returns the maximum length in pixels across all legend labels + formsize - * + formtotextspace - * - * @param p the paint object used for rendering the text - */ - fun getMaximumEntryWidth(p: Paint): Float { - var max = 0f - var maxFormSize = 0f - val formToTextSpace = formToTextSpace.convertDpToPixel() - - for (entry in this.entries) { - if (entry == null) continue - val formSize = (if (entry.formSize.isNaN()) - this.formSize - else - entry.formSize).convertDpToPixel() - if (formSize > maxFormSize) maxFormSize = formSize - - val label = entry.label ?: continue - - val length = p.calcTextWidth(label).toFloat() - - if (length > max) max = length - } - - return max + maxFormSize + formToTextSpace - } - - /** - * returns the maximum height in pixels across all legend labels - * - * @param p the paint object used for rendering the text - */ - fun getMaximumEntryHeight(p: Paint): Float { - var max = 0f - - for (entry in this.entries) { - if (entry == null) continue - val label = entry.label ?: continue - - val length = p.calcTextHeight(label).toFloat() - - if (length > max) max = length - } - - return max - } - - fun setExtra(entries: MutableList) { - this.extraEntries = entries.toTypedArray() - } - - fun setExtra(entries: Array) { - this.extraEntries = entries - } - - /** - * Entries that will be appended to the end of the auto calculated - * entries after calculating the legend. - * (if the legend has already been calculated, you will need to call notifyDataSetChanged() - * to let the changes take effect) - */ - fun setExtra(colors: IntArray, labels: Array) { - val entries: MutableList = ArrayList() - - for (i in 0..() - } - - /** - * Sets a custom legend's entries array. - * * A null label will start a group. - * This will disable the feature that automatically calculates the legend - * entries from the datasets. - * Call resetCustom() to re-enable automatic calculation (and then - * notifyDataSetChanged() is needed to auto-calculate the legend again) - */ - fun setCustom(entries: Array) { - this.entries = entries - this.isLegendCustom = true - } - - /** - * Sets a custom legend's entries array. - * * A null label will start a group. - * This will disable the feature that automatically calculates the legend - * entries from the datasets. - * Call resetCustom() to re-enable automatic calculation (and then - * notifyDataSetChanged() is needed to auto-calculate the legend again) - */ - fun setCustom(entries: MutableList) { - this.entries = entries.toTypedArray() - this.isLegendCustom = true - } - - /** - * Calling this will disable the custom legend entries (set by - * setCustom(...)). Instead, the entries will again be calculated - * automatically (after notifyDataSetChanged() is called). - */ - fun resetCustom() { - this.isLegendCustom = false - } - - /** - * sets whether the legend will draw inside the chart or outside - */ - fun setDrawInside(value: Boolean) { - this.isDrawInsideEnabled = value - } - - /** - * the total width of the legend (needed width space) - */ - var neededWidth: Float = 0f - - /** - * the total height of the legend (needed height space) - */ - var neededHeight: Float = 0f - - var mTextHeightMax: Float = 0f - - var mTextWidthMax: Float = 0f - - /** - * Should the legend word wrap? / this is currently supported only for: - * BelowChartLeft, BelowChartRight, BelowChartCenter. / note that word - * wrapping a legend takes a toll on performance. / you may want to set - * maxSizePercent when word wrapping, to set the point where the text wraps. - * / default: false - */ - var isWordWrapEnabled: Boolean = false - - val calculatedLabelSizes: MutableList = mutableListOf() - val calculatedLabelBreakPoints: MutableList = mutableListOf() - val calculatedLineSizes: MutableList = mutableListOf() - - init { - this.mTextSize = 10f.convertDpToPixel() - this.mXOffset = 5f.convertDpToPixel() - this.mYOffset = 3f.convertDpToPixel() // 2 - } - - /** - * Calculates the dimensions of the Legend. This includes the maximum width - * and height of a single entry, as well as the total width and height of - * the Legend. - */ - fun calculateDimensions(labelPaint: Paint, viewPortHandler: ViewPortHandler) { - val defaultFormSize = formSize.convertDpToPixel() - val stackSpace = stackSpace.convertDpToPixel() - val formToTextSpace = formToTextSpace.convertDpToPixel() - val xEntrySpace = xEntrySpace.convertDpToPixel() - val yEntrySpace = yEntrySpace.convertDpToPixel() - val wordWrapEnabled = this.isWordWrapEnabled - val entries = this.entries - val entryCount = entries.size - - mTextWidthMax = getMaximumEntryWidth(labelPaint) - mTextHeightMax = getMaximumEntryHeight(labelPaint) - - when (this.orientation) { - LegendOrientation.VERTICAL -> { - var maxWidth = 0f - var maxHeight = 0f - var width = 0f - val labelLineHeight = labelPaint.getLineHeight() - var wasStacked = false - - var i = 0 - while (i < entryCount) { - val e = entries[i] - if (e == null) { - i++; continue - } - val drawingForm = e.form != LegendForm.NONE - val formSize = if (e.formSize.isNaN()) - defaultFormSize - else - e.formSize.convertDpToPixel() - val label = e.label - - if (!wasStacked) width = 0f - - if (drawingForm) { - if (wasStacked) width += stackSpace - width += formSize - } - - // grouped forms have null labels - if (label != null) { - // make a step to the left - if (drawingForm && !wasStacked) width += formToTextSpace - else if (wasStacked) { - maxWidth = max(maxWidth, width) - maxHeight += labelLineHeight + yEntrySpace - width = 0f - wasStacked = false - } - - width += labelPaint.calcTextWidth(label).toFloat() - - maxHeight += labelLineHeight + yEntrySpace - } else { - wasStacked = true - width += formSize - if (i < entryCount - 1) width += stackSpace - } - - maxWidth = max(maxWidth, width) - i++ - } - - neededWidth = maxWidth - neededHeight = maxHeight - } - - LegendOrientation.HORIZONTAL -> { - val labelLineHeight = labelPaint.getLineHeight() - val labelLineSpacing = labelPaint.getLineSpacing() + yEntrySpace - val contentWidth = viewPortHandler.contentWidth() * this.maxSizePercent - - // Start calculating layout - var maxLineWidth = 0f - var currentLineWidth = 0f - var requiredWidth = 0f - var stackedStartIndex = -1 - - calculatedLabelBreakPoints.clear() - calculatedLabelSizes.clear() - calculatedLineSizes.clear() - - var i = 0 - while (i < entryCount) { - val legendEntry = entries[i] - if (legendEntry == null) { - i++; continue - } - val drawingForm = legendEntry.form != LegendForm.NONE - val formSize = if (legendEntry.formSize.isNaN()) - defaultFormSize - else - legendEntry.formSize.convertDpToPixel() - val label = legendEntry.label - - calculatedLabelBreakPoints.add(false) - - if (stackedStartIndex == -1) { - // we are not stacking, so required width is for this label only - requiredWidth = 0f - } else { - // add the spacing appropriate for stacked labels/forms - requiredWidth += stackSpace - } - - // grouped forms have null labels - if (label != null) { - val fSize = labelPaint.calcTextSize(label) - calculatedLabelSizes.add(fSize) - requiredWidth += if (drawingForm) formToTextSpace + formSize else 0f - requiredWidth += fSize.width - } else { - calculatedLabelSizes.add(FSize.getInstance(0f, 0f)) - requiredWidth += if (drawingForm) formSize else 0f - - if (stackedStartIndex == -1) { - // mark this index as we might want to break here later - stackedStartIndex = i - } - } - - if (label != null || i == entryCount - 1) { - val requiredSpacing = if (currentLineWidth == 0f) 0f else xEntrySpace - - if (!wordWrapEnabled // No word wrapping, it must fit. - // The line is empty, it must fit - || currentLineWidth == 0f // It simply fits - || (contentWidth - currentLineWidth >= - requiredSpacing + requiredWidth) - ) { - // Expand current line - currentLineWidth += requiredSpacing + requiredWidth - } else { // It doesn't fit, we need to wrap a line - - // Add current line size to array - - calculatedLineSizes.add(FSize.getInstance(currentLineWidth, labelLineHeight)) - maxLineWidth = max(maxLineWidth, currentLineWidth) - - // Start a new line - calculatedLabelBreakPoints[if (stackedStartIndex > -1) - stackedStartIndex - else - i] = true - currentLineWidth = requiredWidth - } - - if (i == entryCount - 1) { - // Add last line size to array - calculatedLineSizes.add(FSize.getInstance(currentLineWidth, labelLineHeight)) - maxLineWidth = max(maxLineWidth, currentLineWidth) - } - } - - stackedStartIndex = if (label != null) -1 else stackedStartIndex - i++ - } - - neededWidth = maxLineWidth - neededHeight = (labelLineHeight - * (calculatedLineSizes.size).toFloat() - + labelLineSpacing * (if (calculatedLineSizes.isEmpty()) - 0 - else - (calculatedLineSizes.size - 1)).toFloat()) - } - } - - neededHeight += mYOffset - neededWidth += mXOffset - } -} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/components/LegendAndroid.kt b/chartLib/src/main/kotlin/info/appdev/charting/components/LegendAndroid.kt new file mode 100644 index 000000000..213854986 --- /dev/null +++ b/chartLib/src/main/kotlin/info/appdev/charting/components/LegendAndroid.kt @@ -0,0 +1,246 @@ +package info.appdev.charting.components + +import android.graphics.Paint +import info.appdev.charting.utils.FSize +import info.appdev.charting.utils.ViewPortHandler +import info.appdev.charting.utils.calcTextHeight +import info.appdev.charting.utils.calcTextSize +import info.appdev.charting.utils.calcTextWidth +import info.appdev.charting.utils.convertDpToPixel +import info.appdev.charting.utils.getLineHeight +import info.appdev.charting.utils.getLineSpacing +import kotlin.math.max + +/** + * Returns the maximum length in pixels across all legend labels + formsize + * + formtotextspace. + * + * Android-only extension since text measurement requires `android.graphics.Paint`. + * + * @param p the paint object used for rendering the text + */ +fun Legend.getMaximumEntryWidth(p: Paint): Float { + var max = 0f + var maxFormSize = 0f + val formToTextSpace = formToTextSpace.convertDpToPixel() + + for (entry in this.entries) { + if (entry == null) continue + val formSize = (if (entry.formSize.isNaN()) + this.formSize + else + entry.formSize).convertDpToPixel() + if (formSize > maxFormSize) maxFormSize = formSize + + val label = entry.label ?: continue + + val length = p.calcTextWidth(label).toFloat() + + if (length > max) max = length + } + + return max + maxFormSize + formToTextSpace +} + +/** + * Returns the maximum height in pixels across all legend labels. + * + * Android-only extension since text measurement requires `android.graphics.Paint`. + * + * @param p the paint object used for rendering the text + */ +fun Legend.getMaximumEntryHeight(p: Paint): Float { + var max = 0f + + for (entry in this.entries) { + if (entry == null) continue + val label = entry.label ?: continue + + val length = p.calcTextHeight(label).toFloat() + + if (length > max) max = length + } + + return max +} + +/** + * Calculates the dimensions of the Legend. This includes the maximum width + * and height of a single entry, as well as the total width and height of + * the Legend. + * + * Android-only extension since text measurement requires `android.graphics.Paint`. + */ +fun Legend.calculateDimensions(labelPaint: Paint, viewPortHandler: ViewPortHandler) { + val defaultFormSize = formSize.convertDpToPixel() + val stackSpace = stackSpace.convertDpToPixel() + val formToTextSpace = formToTextSpace.convertDpToPixel() + val xEntrySpace = xEntrySpace.convertDpToPixel() + val yEntrySpace = yEntrySpace.convertDpToPixel() + val wordWrapEnabled = this.isWordWrapEnabled + val entries = this.entries + val entryCount = entries.size + + mTextWidthMax = getMaximumEntryWidth(labelPaint) + mTextHeightMax = getMaximumEntryHeight(labelPaint) + + when (this.orientation) { + Legend.LegendOrientation.VERTICAL -> { + var maxWidth = 0f + var maxHeight = 0f + var width = 0f + val labelLineHeight = labelPaint.getLineHeight() + var wasStacked = false + + var i = 0 + while (i < entryCount) { + val e = entries[i] + if (e == null) { + i++; continue + } + val drawingForm = e.form != Legend.LegendForm.NONE + val formSize = if (e.formSize.isNaN()) + defaultFormSize + else + e.formSize.convertDpToPixel() + val label = e.label + + if (!wasStacked) width = 0f + + if (drawingForm) { + if (wasStacked) width += stackSpace + width += formSize + } + + // grouped forms have null labels + if (label != null) { + // make a step to the left + if (drawingForm && !wasStacked) width += formToTextSpace + else if (wasStacked) { + maxWidth = max(maxWidth, width) + maxHeight += labelLineHeight + yEntrySpace + width = 0f + wasStacked = false + } + + width += labelPaint.calcTextWidth(label).toFloat() + + maxHeight += labelLineHeight + yEntrySpace + } else { + wasStacked = true + width += formSize + if (i < entryCount - 1) width += stackSpace + } + + maxWidth = max(maxWidth, width) + i++ + } + + neededWidth = maxWidth + neededHeight = maxHeight + } + + Legend.LegendOrientation.HORIZONTAL -> { + val labelLineHeight = labelPaint.getLineHeight() + val labelLineSpacing = labelPaint.getLineSpacing() + yEntrySpace + val contentWidth = viewPortHandler.contentWidth() * this.maxSizePercent + + // Start calculating layout + var maxLineWidth = 0f + var currentLineWidth = 0f + var requiredWidth = 0f + var stackedStartIndex = -1 + + calculatedLabelBreakPoints.clear() + calculatedLabelSizes.clear() + calculatedLineSizes.clear() + + var i = 0 + while (i < entryCount) { + val legendEntry = entries[i] + if (legendEntry == null) { + i++; continue + } + val drawingForm = legendEntry.form != Legend.LegendForm.NONE + val formSize = if (legendEntry.formSize.isNaN()) + defaultFormSize + else + legendEntry.formSize.convertDpToPixel() + val label = legendEntry.label + + calculatedLabelBreakPoints.add(false) + + if (stackedStartIndex == -1) { + // we are not stacking, so required width is for this label only + requiredWidth = 0f + } else { + // add the spacing appropriate for stacked labels/forms + requiredWidth += stackSpace + } + + // grouped forms have null labels + if (label != null) { + val fSize = labelPaint.calcTextSize(label) + calculatedLabelSizes.add(fSize) + requiredWidth += if (drawingForm) formToTextSpace + formSize else 0f + requiredWidth += fSize.width + } else { + calculatedLabelSizes.add(FSize.getInstance(0f, 0f)) + requiredWidth += if (drawingForm) formSize else 0f + + if (stackedStartIndex == -1) { + // mark this index as we might want to break here later + stackedStartIndex = i + } + } + + if (label != null || i == entryCount - 1) { + val requiredSpacing = if (currentLineWidth == 0f) 0f else xEntrySpace + + if (!wordWrapEnabled // No word wrapping, it must fit. + // The line is empty, it must fit + || currentLineWidth == 0f // It simply fits + || (contentWidth - currentLineWidth >= + requiredSpacing + requiredWidth) + ) { + // Expand current line + currentLineWidth += requiredSpacing + requiredWidth + } else { // It doesn't fit, we need to wrap a line + + // Add current line size to array + + calculatedLineSizes.add(FSize.getInstance(currentLineWidth, labelLineHeight)) + maxLineWidth = max(maxLineWidth, currentLineWidth) + + // Start a new line + calculatedLabelBreakPoints[if (stackedStartIndex > -1) + stackedStartIndex + else + i] = true + currentLineWidth = requiredWidth + } + + if (i == entryCount - 1) { + // Add last line size to array + calculatedLineSizes.add(FSize.getInstance(currentLineWidth, labelLineHeight)) + maxLineWidth = max(maxLineWidth, currentLineWidth) + } + } + + stackedStartIndex = if (label != null) -1 else stackedStartIndex + i++ + } + + neededWidth = maxLineWidth + neededHeight = (labelLineHeight + * (calculatedLineSizes.size).toFloat() + + labelLineSpacing * (if (calculatedLineSizes.isEmpty()) + 0 + else + (calculatedLineSizes.size - 1)).toFloat()) + } + } + + neededHeight += yOffset + neededWidth += xOffset +} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/components/YAxisAndroid.kt b/chartLib/src/main/kotlin/info/appdev/charting/components/YAxisAndroid.kt new file mode 100644 index 000000000..f9294603c --- /dev/null +++ b/chartLib/src/main/kotlin/info/appdev/charting/components/YAxisAndroid.kt @@ -0,0 +1,43 @@ +package info.appdev.charting.components + +import android.graphics.Paint +import info.appdev.charting.utils.calcTextHeight +import info.appdev.charting.utils.calcTextWidth +import info.appdev.charting.utils.convertDpToPixel +import kotlin.math.max +import kotlin.math.min + +/** + * This is for normal (not horizontal) charts horizontal spacing. + * + * Android-only extension since text measurement requires `android.graphics.Paint`. + */ +fun YAxis.getRequiredWidthSpace(p: Paint): Float { + p.textSize = textSize + + val label = getLongestLabel(p) + var width = p.calcTextWidth(label).toFloat() + xOffset * 2f + + var minWidth = this.minWidth + var maxWidth = this.maxWidth + + if (minWidth > 0f) minWidth = minWidth.convertDpToPixel() + + if (maxWidth > 0f && maxWidth != Float.POSITIVE_INFINITY) maxWidth = maxWidth.convertDpToPixel() + + width = max(minWidth, min(width, if (maxWidth > 0.0) maxWidth else width)) + + return width +} + +/** + * This is for HorizontalBarChart vertical spacing. + * + * Android-only extension since text measurement requires `android.graphics.Paint`. + */ +fun YAxis.getRequiredHeightSpace(p: Paint): Float { + p.textSize = textSize + + val label = getLongestLabel(p) + return p.calcTextHeight(label).toFloat() + yOffset * 2f +} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/BaseDataSetAndroid.kt b/chartLib/src/main/kotlin/info/appdev/charting/data/BaseDataSetAndroid.kt new file mode 100644 index 000000000..58c0af197 --- /dev/null +++ b/chartLib/src/main/kotlin/info/appdev/charting/data/BaseDataSetAndroid.kt @@ -0,0 +1,21 @@ +package info.appdev.charting.data + +import android.content.Context +import androidx.core.content.ContextCompat + +/** + * Sets the colors that should be used for this DataSet. Colors are reused + * as soon as the number of Entries the DataSet represents is higher than + * the size of the colors array. You can use + * "new int[] { R.color.red, R.color.green, ... }" to provide colors for + * this method. Internally, the colors are resolved using + * ContextCompat.getColor(context,...) + * + * Android-only extension since it needs a `Context` to resolve color resources. + */ +fun BaseDataSet<*>.setColors(colors: IntArray, context: Context) { + resetColors() + for (color in colors) { + addColor(ContextCompat.getColor(context, color)) + } +} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/EntryFloat.kt b/chartLib/src/main/kotlin/info/appdev/charting/data/EntryFloat.kt deleted file mode 100644 index f25202e98..000000000 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/EntryFloat.kt +++ /dev/null @@ -1,144 +0,0 @@ -package info.appdev.charting.data - -import android.graphics.drawable.Drawable -import android.os.Build -import android.os.Parcel -import android.os.ParcelFormatException -import android.os.Parcelable -import info.appdev.charting.utils.Utils -import java.io.Serializable -import kotlin.math.abs - -/** - * Class representing one entry in the chart. Might contain multiple values. - * Might only contain a single value depending on the used constructor. - */ -open class EntryFloat : BaseEntry, Parcelable, Serializable { - - constructor() - - /** - * An EntryFloat represents one single entry in the chart. - * - * @param x the x value - * @param y the y value (the actual value of the entry) - */ - constructor(x: Float, y: Float) : super(x = x, y = y) - - /** - * An EntryFloat represents one single entry in the chart. - * - * @param x the x value - * @param y the y value (the actual value of the entry) - * @param data Spot for additional data this Entry represents. - */ - constructor(x: Float, y: Float, data: Any?) : super(x = x, y = y, data = data) - - /** - * An EntryFloat represents one single entry in the chart. - * - * @param x the x value - * @param y the y value (the actual value of the entry) - * @param icon icon image - */ - constructor(x: Float, y: Float, icon: Drawable?) : super(x = x, y = y, icon = icon) - - /** - * An EntryFloat represents one single entry in the chart. - * - * @param x the x value - * @param y the y value (the actual value of the entry) - * @param icon icon image - * @param data Spot for additional data this EntryFloat represents. - */ - constructor(x: Float, y: Float, icon: Drawable?, data: Any?) : super(x = x, y = y, icon = icon, data = data) - - /** - * returns an exact copy of the entry - */ - open fun copy(): EntryFloat { - val e = EntryFloat( - x = x, - y = y, - data = data - ) - return e - } - - /** - * Compares value, xIndex and data of the entries. Returns true if entries - * are equal in those points, false if not. Does not check by hash-code like - * it's done by the "equals" method. - */ - fun equalTo(entryFloat: EntryFloat?): Boolean { - if (entryFloat == null) - return false - - if (entryFloat.data !== this.data) - return false - - if (abs((entryFloat.x - this.x).toDouble()) > Utils.FLOAT_EPSILON) - return false - - if (abs((entryFloat.y - this.y).toDouble()) > Utils.FLOAT_EPSILON) - return false - - return true - } - - /** - * returns a string representation of the entry containing x-index and value - */ - override fun toString(): String { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - "${this.javaClass.typeName.substringAfterLast(".")} x=$x y=$y" - } else { - "EntryFloat x=$x y=$y" - } - } - - override fun describeContents(): Int { - return 0 - } - - override fun writeToParcel(dest: Parcel, flags: Int) { - dest.writeFloat(this.x) - dest.writeFloat(this.y) - if (data != null) { - if (data is Parcelable) { - dest.writeInt(1) - dest.writeParcelable(data as Parcelable?, flags) - } else { - throw ParcelFormatException("Cannot parcel an EntryFloat with non-parcelable data") - } - } else { - dest.writeInt(0) - } - } - - protected constructor(`in`: Parcel) { - this.x = `in`.readFloat() - this.yBase = `in`.readFloat() - if (`in`.readInt() == 1) { - this.data = if (Build.VERSION.SDK_INT >= 33) { - `in`.readParcelable(Any::class.java.classLoader, Any::class.java) - } else { - @Suppress("DEPRECATION") - `in`.readParcelable(Any::class.java.classLoader) - } - } - } - - companion object { - @JvmField - val CREATOR: Parcelable.Creator = object : Parcelable.Creator { - override fun createFromParcel(source: Parcel): EntryFloat { - return EntryFloat(source) - } - - override fun newArray(size: Int): Array { - return arrayOfNulls(size) - } - } - } -} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/LineDataSetAndroid.kt b/chartLib/src/main/kotlin/info/appdev/charting/data/LineDataSetAndroid.kt new file mode 100644 index 000000000..f43a6f16f --- /dev/null +++ b/chartLib/src/main/kotlin/info/appdev/charting/data/LineDataSetAndroid.kt @@ -0,0 +1,48 @@ +package info.appdev.charting.data + +import android.content.Context +import androidx.annotation.ColorInt +import androidx.core.content.ContextCompat +import info.appdev.charting.formatter.DefaultFillFormatter +import info.appdev.charting.formatter.IFillFormatter +import info.appdev.charting.interfaces.datasets.ILineDataSet +import java.util.WeakHashMap + +/** + * Android-only side channels for [ILineDataSet]/[LineDataSet], mirroring the pattern already + * established for `ILineRadarDataSet.fillDrawable` in `LineRadarDataSetAndroid.kt`. + * + * [IFillFormatter] depends on `LineDataProvider` (permanently Android-only, see Step A.4), so it + * has no portable equivalent and is kept as a `WeakHashMap`-backed extension property here instead + * of a stored property on the common class. + */ +private val fillFormatters = WeakHashMap, IFillFormatter>() + +/** + * Sets a custom IFillFormatter to the chart that handles the position of the + * filled-line for each DataSet. Set this to null to use the default logic. + */ +var ILineDataSet<*>.fillFormatter: IFillFormatter? + get() = fillFormatters.getOrPut(this) { DefaultFillFormatter() } + set(value) { + fillFormatters[this] = value ?: DefaultFillFormatter() + } + +/** + * Sets the colors that should be used for the circles of this DataSet. + * Colors are reused as soon as the number of Entries the DataSet represents + * is higher than the size of the colors array. You can use + * "new String[] { R.color.red, R.color.green, ... }" to provide colors for + * this method. Internally, the colors are resolved using + * getResources().getColor(...) + */ +fun > LineDataSet.setCircleColors(@ColorInt colors: IntArray, context: Context) { + val clrs = this.circleColors + clrs.clear() + + for (color in colors) { + clrs.add(ContextCompat.getColor(context, color)) + } + + this.circleColors = clrs +} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/LineRadarDataSetAndroid.kt b/chartLib/src/main/kotlin/info/appdev/charting/data/LineRadarDataSetAndroid.kt new file mode 100644 index 000000000..9117132da --- /dev/null +++ b/chartLib/src/main/kotlin/info/appdev/charting/data/LineRadarDataSetAndroid.kt @@ -0,0 +1,30 @@ +package info.appdev.charting.data + +import android.graphics.drawable.Drawable +import info.appdev.charting.interfaces.datasets.ILineRadarDataSet +import java.util.WeakHashMap + +/** + * Android-only side-channel storage for the `fillDrawable` property that used to live directly + * on `ILineRadarDataSet`/`LineRadarDataSet`. `android.graphics.drawable.Drawable` has no + * portable equivalent, so it can no longer be a stored property on the now-common + * `LineRadarDataSet` (used by both the Android-only `LineDataSet` and the fully portable + * `RadarDataSet`). Backed by a [WeakHashMap] keyed by dataset identity so it doesn't leak + * datasets that are no longer referenced elsewhere. + * + * Note: unlike the old stored property, setting [ILineRadarDataSet.fillColor] no longer + * implicitly clears a previously set `fillDrawable`, and `.copy()` no longer propagates + * `fillDrawable` to the copy (see `LineRadarDataSet`'s class doc in `chartLibCore`). + */ +private val fillDrawables = WeakHashMap, Drawable?>() + +/** + * The drawable used for filling the area below the line (line/radar charts), as an + * alternative to a solid [ILineRadarDataSet.fillColor]. Android-only, since it operates on + * `android.graphics.drawable.Drawable`. + */ +var ILineRadarDataSet<*>.fillDrawable: Drawable? + get() = fillDrawables[this] + set(value) { + fillDrawables[this] = value + } diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/ScatterDataSet.kt b/chartLib/src/main/kotlin/info/appdev/charting/data/ScatterDataSet.kt deleted file mode 100644 index aadb8848e..000000000 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/ScatterDataSet.kt +++ /dev/null @@ -1,110 +0,0 @@ -package info.appdev.charting.data - -import androidx.annotation.ColorInt -import info.appdev.charting.charts.ScatterChart.ScatterShape -import info.appdev.charting.interfaces.datasets.IScatterDataSet -import info.appdev.charting.renderer.scatter.ChevronDownShapeRenderer -import info.appdev.charting.renderer.scatter.ChevronUpShapeRenderer -import info.appdev.charting.renderer.scatter.CircleShapeRenderer -import info.appdev.charting.renderer.scatter.CrossShapeRenderer -import info.appdev.charting.renderer.scatter.IShapeRenderer -import info.appdev.charting.renderer.scatter.SquareShapeRenderer -import info.appdev.charting.renderer.scatter.TriangleShapeRenderer -import info.appdev.charting.renderer.scatter.XShapeRenderer -import info.appdev.charting.utils.ColorTemplate - -open class ScatterDataSet(yVals: MutableList, label: String = "") : LineScatterCandleRadarDataSet(yVals, label), IScatterDataSet { - /** - * the size the scatterShape will have, in density pixels - */ - private var shapeSize = 15f - - /** - * Renderer responsible for rendering this DataSet, default: square - */ - protected var mShapeRenderer: IShapeRenderer? = SquareShapeRenderer() - - /** - * The radius of the hole in the shape (applies to Square, Circle and Triangle) - * - default: 0.0 - */ - private var mScatterShapeHoleRadius = 0f - - /** - * Color for the hole in the shape. - * Setting to `ColorTemplate.COLOR_NONE` will behave as transparent. - * - default: ColorTemplate.COLOR_NONE - */ - @ColorInt - private var mScatterShapeHoleColor = ColorTemplate.COLOR_NONE - - override fun copy(): DataSet { - val entries: MutableList = mutableListOf() - for (i in entriesInternal.indices) { - entries.add(entriesInternal[i].copy()) - } - val copied = ScatterDataSet(entries, label) - copy(copied) - return copied - } - - protected fun copy(scatterDataSet: ScatterDataSet) { - super.copy((scatterDataSet as BaseDataSet<*>?)!!) - scatterDataSet.shapeSize = shapeSize - scatterDataSet.mShapeRenderer = mShapeRenderer - scatterDataSet.mScatterShapeHoleRadius = mScatterShapeHoleRadius - scatterDataSet.mScatterShapeHoleColor = mScatterShapeHoleColor - } - - /** - * Sets the ScatterShape this DataSet should be drawn with. This will search for an available IShapeRenderer and set this - * renderer for the DataSet. - */ - fun setScatterShape(shape: ScatterShape) { - mShapeRenderer = getRendererForShape(shape) - } - - /** - * Sets the size in density pixels the drawn scatterShape will have. This - * only applies for non custom shapes. - */ - override var scatterShapeSize: Float - get() = shapeSize - set(value) { - shapeSize = value - } - override var scatterShapeHoleRadius: Float - get() = mScatterShapeHoleRadius - set(value) { - mScatterShapeHoleRadius = value - } - override var scatterShapeHoleColor: Int - get() = mScatterShapeHoleColor - set(value) { - mScatterShapeHoleColor = value - } - - /** - * Sets a new IShapeRenderer responsible for drawing this DataSet. - * This can also be used to set a custom IShapeRenderer aside from the default ones. - */ - override var shapeRenderer: IShapeRenderer? - get() = mShapeRenderer - set(value) { - mShapeRenderer = value - } - - companion object { - fun getRendererForShape(shape: ScatterShape): IShapeRenderer? { - return when (shape) { - ScatterShape.SQUARE -> SquareShapeRenderer() - ScatterShape.CIRCLE -> CircleShapeRenderer() - ScatterShape.TRIANGLE -> TriangleShapeRenderer() - ScatterShape.CROSS -> CrossShapeRenderer() - ScatterShape.X -> XShapeRenderer() - ScatterShape.CHEVRON_UP -> ChevronUpShapeRenderer() - ScatterShape.CHEVRON_DOWN -> ChevronDownShapeRenderer() - } - } - } -} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/ScatterDataSetAndroid.kt b/chartLib/src/main/kotlin/info/appdev/charting/data/ScatterDataSetAndroid.kt new file mode 100644 index 000000000..60eaea2df --- /dev/null +++ b/chartLib/src/main/kotlin/info/appdev/charting/data/ScatterDataSetAndroid.kt @@ -0,0 +1,62 @@ +package info.appdev.charting.data + +import info.appdev.charting.charts.ScatterShape +import info.appdev.charting.interfaces.datasets.IScatterDataSet +import info.appdev.charting.renderer.scatter.ChevronDownShapeRenderer +import info.appdev.charting.renderer.scatter.ChevronUpShapeRenderer +import info.appdev.charting.renderer.scatter.CircleShapeRenderer +import info.appdev.charting.renderer.scatter.CrossShapeRenderer +import info.appdev.charting.renderer.scatter.IShapeRenderer +import info.appdev.charting.renderer.scatter.SquareShapeRenderer +import info.appdev.charting.renderer.scatter.TriangleShapeRenderer +import info.appdev.charting.renderer.scatter.XShapeRenderer +import java.util.WeakHashMap + +/** + * Android-only side channels for [IScatterDataSet]/[ScatterDataSet], mirroring the pattern already + * established for `ILineRadarDataSet.fillDrawable`/`ILineDataSet.fillFormatter`. + * + * [IShapeRenderer] is fundamentally Canvas/Paint-coupled and has no portable equivalent, so it is + * kept as a `WeakHashMap`-backed extension property here instead of a stored property on the + * common class. Unlike `fillFormatter`'s "null means default" semantics, `shapeRenderer` can be + * legitimately `null` (`ScatterChartRenderer` treats a `null` renderer as "don't draw"), so the map + * distinguishes "never set" (falls back to the default `SquareShapeRenderer()`) from "explicitly + * set to null" via [MutableMap.containsKey] rather than [getOrPut][kotlin.collections.getOrPut] + * (which cannot tell a stored `null` apart from an absent key). + */ +private val shapeRenderers = WeakHashMap() + +/** + * The [IShapeRenderer] responsible for rendering this DataSet. Default: [SquareShapeRenderer]. + * Can be set to a custom [IShapeRenderer] aside from the default ones, or to `null` to skip + * drawing entirely. + */ +var IScatterDataSet.shapeRenderer: IShapeRenderer? + get() = if (shapeRenderers.containsKey(this)) { + shapeRenderers[this] + } else { + SquareShapeRenderer().also { shapeRenderers[this] = it } + } + set(value) { + shapeRenderers[this] = value + } + +/** + * Sets the ScatterShape this DataSet should be drawn with. This will search for an available + * IShapeRenderer and set this renderer for the DataSet. + */ +fun ScatterDataSet.setScatterShape(shape: ScatterShape) { + this.shapeRenderer = getRendererForShape(shape) +} + +fun getRendererForShape(shape: ScatterShape): IShapeRenderer? { + return when (shape) { + ScatterShape.SQUARE -> SquareShapeRenderer() + ScatterShape.CIRCLE -> CircleShapeRenderer() + ScatterShape.TRIANGLE -> TriangleShapeRenderer() + ScatterShape.CROSS -> CrossShapeRenderer() + ScatterShape.X -> XShapeRenderer() + ScatterShape.CHEVRON_UP -> ChevronUpShapeRenderer() + ScatterShape.CHEVRON_DOWN -> ChevronDownShapeRenderer() + } +} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/formatter/DefaultAxisValueFormatter.kt b/chartLib/src/main/kotlin/info/appdev/charting/formatter/DefaultAxisValueFormatter.kt deleted file mode 100644 index 06b3837df..000000000 --- a/chartLib/src/main/kotlin/info/appdev/charting/formatter/DefaultAxisValueFormatter.kt +++ /dev/null @@ -1,32 +0,0 @@ -package info.appdev.charting.formatter - -import info.appdev.charting.components.AxisBase -import java.text.DecimalFormat - -open class DefaultAxisValueFormatter(digits: Int) : IAxisValueFormatter { - /** - * decimal format for formatting - */ - protected var decimalFormat: DecimalFormat - /** - * The number of decimal digits this formatter uses or -1, if unspecified. - */ - var decimalDigits = 0 - protected set - - init { - decimalDigits = digits - val stringBuffer = StringBuffer() - for (i in 0 until digits) { - if (i == 0) - stringBuffer.append(".") - stringBuffer.append("0") - } - decimalFormat = DecimalFormat("###,###,###,##0$stringBuffer") - } - - override fun getFormattedValue(value: Float, axis: AxisBase?): String { - // avoid memory allocations here (for performance) - return decimalFormat.format(value.toDouble()) - } -} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/formatter/LargeValueFormatter.kt b/chartLib/src/main/kotlin/info/appdev/charting/formatter/LargeValueFormatter.kt deleted file mode 100644 index 4bdc96900..000000000 --- a/chartLib/src/main/kotlin/info/appdev/charting/formatter/LargeValueFormatter.kt +++ /dev/null @@ -1,81 +0,0 @@ -package info.appdev.charting.formatter - -import info.appdev.charting.components.AxisBase -import info.appdev.charting.data.EntryFloat -import info.appdev.charting.utils.ViewPortHandler -import java.text.DecimalFormat - -/** - * Predefined value-formatter that formats large numbers in a pretty way. - * Outputs: 856 = 856; 1000 = 1k; 5821 = 5.8k; 10500 = 10k; 101800 = 102k; - * 2000000 = 2m; 7800000 = 7.8m; 92150000 = 92m; 123200000 = 123m; 9999999 = - * 10m; 1000000000 = 1b; - * Special thanks to Roman Gromov - * (https://github.com/romangromov) for this piece of code. - */ -open class LargeValueFormatter() : IValueFormatter, IAxisValueFormatter { - - private var suffix = arrayOf( - "", "k", "m", "b", "t" - ) - private var maxLength = 5 - private val decimalFormat: DecimalFormat = DecimalFormat("###E00") - private var text = "" - - /** - * Creates a formatter that appends a specified text to the result string - * - * @param appendix a text that will be appended - */ - constructor(appendix: String) : this() { - text = appendix - } - - // IValueFormatter - override fun getFormattedValue(value: Float, entryFloat: EntryFloat?, dataSetIndex: Int, viewPortHandler: ViewPortHandler?): String { - return makePretty(value.toDouble()) + text - } - - // IAxisValueFormatter - override fun getFormattedValue(value: Float, axis: AxisBase?): String { - return makePretty(value.toDouble()) + text - } - - /** - * Set an appendix text to be added at the end of the formatted value. - */ - fun setAppendix(appendix: String) { - text = appendix - } - - /** - * Set custom suffix to be appended after the values. - * Default suffix: ["", "k", "m", "b", "t"] - * - * @param suffixArray new suffix - */ - fun setSuffix(suffixArray: Array) { - suffix = suffixArray - } - - fun setMaxLength(max: Int) { - maxLength = max - } - - /** - * Formats each number properly. Special thanks to Roman Gromov - * (https://github.com/romangromov) for this piece of code. - */ - private fun makePretty(number: Double): String { - var decimalFormat = decimalFormat.format(number) - val numericValue1 = Character.getNumericValue(decimalFormat[decimalFormat.length - 1]) - val numericValue2 = Character.getNumericValue(decimalFormat[decimalFormat.length - 2]) - val combined = Integer.valueOf(numericValue2.toString() + "" + numericValue1) - decimalFormat = decimalFormat.replace("E[0-9][0-9]".toRegex(), suffix[combined / 3]) - while (decimalFormat.length > maxLength || decimalFormat.matches("[0-9]+\\.[a-z]".toRegex())) { - decimalFormat = decimalFormat.substring(0, decimalFormat.length - 2) + decimalFormat.substring(decimalFormat.length - 1) - } - return decimalFormat - } - -} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/jobs/AnimatedMoveViewJob.kt b/chartLib/src/main/kotlin/info/appdev/charting/jobs/AnimatedMoveViewJob.kt index 38b004235..070034d50 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/jobs/AnimatedMoveViewJob.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/jobs/AnimatedMoveViewJob.kt @@ -23,7 +23,7 @@ class AnimatedMoveViewJob( pts[1] = yOrigin + (yValue - yOrigin) * phase mTrans?.pointValuesToPixel(pts) - mViewPortHandler.centerViewPort(pts, view) + mViewPortHandler.centerViewPort(pts) { view?.invalidate() } } override fun recycleSelf() { diff --git a/chartLib/src/main/kotlin/info/appdev/charting/jobs/AnimatedZoomJob.kt b/chartLib/src/main/kotlin/info/appdev/charting/jobs/AnimatedZoomJob.kt index 946532f36..1b94c6d9d 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/jobs/AnimatedZoomJob.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/jobs/AnimatedZoomJob.kt @@ -3,7 +3,7 @@ package info.appdev.charting.jobs import android.animation.Animator import android.animation.ValueAnimator import android.annotation.SuppressLint -import android.graphics.Matrix +import info.appdev.charting.utils.Matrix import android.view.View import info.appdev.charting.charts.BarLineChartBase import info.appdev.charting.components.YAxis @@ -46,7 +46,7 @@ open class AnimatedZoomJob @SuppressLint("NewApi") constructor( val save = mOnAnimationUpdateMatrixBuffer mViewPortHandler.setZoom(scaleX, scaleY, save) - mViewPortHandler.refresh(save, view, false) + mViewPortHandler.refresh(save, { view?.invalidate() }, false) val valsInView = yAxis.axisRange / mViewPortHandler.scaleY val xsInView = xAxisRange / mViewPortHandler.scaleX @@ -57,7 +57,7 @@ open class AnimatedZoomJob @SuppressLint("NewApi") constructor( mTrans?.pointValuesToPixel(pts) mViewPortHandler.translate(pts, save) - mViewPortHandler.refresh(save, view, true) + mViewPortHandler.refresh(save, { view?.invalidate() }, true) } override fun onAnimationEnd(animation: Animator) { diff --git a/chartLib/src/main/kotlin/info/appdev/charting/jobs/MoveViewJob.kt b/chartLib/src/main/kotlin/info/appdev/charting/jobs/MoveViewJob.kt index df1ca7eac..a093e7c1d 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/jobs/MoveViewJob.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/jobs/MoveViewJob.kt @@ -12,7 +12,7 @@ open class MoveViewJob(viewPortHandler: ViewPortHandler, xValue: Float, yValue: pts[1] = yValue mTrans?.pointValuesToPixel(pts) - mViewPortHandler.centerViewPort(pts, view) + mViewPortHandler.centerViewPort(pts) { view?.invalidate() } recycleInstance(this) } diff --git a/chartLib/src/main/kotlin/info/appdev/charting/jobs/ZoomJob.kt b/chartLib/src/main/kotlin/info/appdev/charting/jobs/ZoomJob.kt index 8714ecaec..9eea9049c 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/jobs/ZoomJob.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/jobs/ZoomJob.kt @@ -1,6 +1,6 @@ package info.appdev.charting.jobs -import android.graphics.Matrix +import info.appdev.charting.utils.Matrix import android.view.View import info.appdev.charting.charts.BarLineChartBase import info.appdev.charting.components.YAxis.AxisDependency @@ -17,7 +17,7 @@ open class ZoomJob( override fun run() { val save = mRunMatrixBuffer mViewPortHandler.zoom(scaleX, scaleY, save) - mViewPortHandler.refresh(save, view, false) + mViewPortHandler.refresh(save, { view?.invalidate() }, false) (view as? BarLineChartBase<*>)?.let { view -> val yValsInView = view.getAxis(axisDependency).axisRange / mViewPortHandler.scaleY @@ -29,7 +29,7 @@ open class ZoomJob( mTrans?.pointValuesToPixel(pts) mViewPortHandler.translate(pts, save) - mViewPortHandler.refresh(save, view, false) + mViewPortHandler.refresh(save, { view.invalidate() }, false) view.calculateOffsets() view.postInvalidate() diff --git a/chartLib/src/main/kotlin/info/appdev/charting/listener/BarLineChartTouchListener.kt b/chartLib/src/main/kotlin/info/appdev/charting/listener/BarLineChartTouchListener.kt index faaaeca8d..31a010f99 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/listener/BarLineChartTouchListener.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/listener/BarLineChartTouchListener.kt @@ -1,7 +1,6 @@ package info.appdev.charting.listener import android.annotation.SuppressLint -import android.graphics.Matrix import android.view.MotionEvent import android.view.VelocityTracker import android.view.View @@ -15,6 +14,7 @@ import info.appdev.charting.interfaces.datasets.IDataSet import info.appdev.charting.utils.PointF import info.appdev.charting.utils.Utils import info.appdev.charting.utils.convertDpToPixel +import info.appdev.charting.utils.Matrix import timber.log.Timber import kotlin.math.abs import kotlin.math.sqrt @@ -259,7 +259,7 @@ class BarLineChartTouchListener( } // perform the transformation, update the chart - matrix = chart.viewPortHandler.refresh(matrix, chart, true) + matrix = chart.viewPortHandler.refresh(matrix, { chart.invalidate() }, true) return true // indicate event was handled } @@ -582,7 +582,7 @@ class BarLineChartTouchListener( performDrag(event, dragDistanceX, dragDistanceY) event.recycle() - matrix = chart.viewPortHandler.refresh(matrix, chart, false) + matrix = chart.viewPortHandler.refresh(matrix, { chart.invalidate() }, false) decelerationLastTime = currentTime diff --git a/chartLib/src/main/kotlin/info/appdev/charting/renderer/BarChartRenderer.kt b/chartLib/src/main/kotlin/info/appdev/charting/renderer/BarChartRenderer.kt index 1eab91213..d1ab347b4 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/renderer/BarChartRenderer.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/renderer/BarChartRenderer.kt @@ -10,12 +10,15 @@ import info.appdev.charting.highlight.Highlight import info.appdev.charting.interfaces.dataprovider.BarDataProvider import info.appdev.charting.interfaces.datasets.IBarDataSet import info.appdev.charting.utils.Fill +import info.appdev.charting.utils.fillRect import info.appdev.charting.utils.PointF import info.appdev.charting.utils.Transformer import info.appdev.charting.utils.ViewPortHandler import info.appdev.charting.utils.calcTextHeight import info.appdev.charting.utils.convertDpToPixel +import info.appdev.charting.utils.copyInto import info.appdev.charting.utils.drawImage +import info.appdev.charting.utils.toCommonRectF import kotlin.math.ceil import kotlin.math.min @@ -133,7 +136,9 @@ open class BarChartRenderer( barShadowRectBuffer.left = x - barWidthHalf barShadowRectBuffer.right = x + barWidthHalf - trans!!.rectValueToPixel(barShadowRectBuffer) + val commonShadowRect = barShadowRectBuffer.toCommonRectF() + trans!!.rectValueToPixel(commonShadowRect) + commonShadowRect.copyInto(barShadowRectBuffer) } if (!viewPortHandler.isInBoundsLeft(barShadowRectBuffer.right)) { i++ @@ -245,7 +250,9 @@ open class BarChartRenderer( barRect[left, y1, right] = y2 - trans.rectToPixelPhase(barRect, animator.phaseY) + val commonBarRect = barRect.toCommonRectF() + trans.rectToPixelPhase(commonBarRect, animator.phaseY) + commonBarRect.copyInto(barRect) } override fun drawValues(canvas: Canvas) { diff --git a/chartLib/src/main/kotlin/info/appdev/charting/renderer/CandleStickChartRenderer.kt b/chartLib/src/main/kotlin/info/appdev/charting/renderer/CandleStickChartRenderer.kt index 261c138b9..0b29101f6 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/renderer/CandleStickChartRenderer.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/renderer/CandleStickChartRenderer.kt @@ -11,6 +11,7 @@ import info.appdev.charting.utils.PointF import info.appdev.charting.utils.ViewPortHandler import info.appdev.charting.utils.convertDpToPixel import info.appdev.charting.utils.drawImage +import info.appdev.charting.utils.toAndroidPaintStyle open class CandleStickChartRenderer( var dataProvider: CandleDataProvider, @@ -117,7 +118,7 @@ open class CandleStickChartRenderer( paintRender.color = dataSet.decreasingColor } - paintRender.style = dataSet.decreasingPaintStyle + paintRender.style = dataSet.decreasingPaintStyle.toAndroidPaintStyle() canvas.drawRect( bodyBuffers[0], bodyBuffers[3], @@ -131,7 +132,7 @@ open class CandleStickChartRenderer( paintRender.color = dataSet.increasingColor } - paintRender.style = dataSet.increasingPaintStyle + paintRender.style = dataSet.increasingPaintStyle.toAndroidPaintStyle() canvas.drawRect( bodyBuffers[0], bodyBuffers[1], diff --git a/chartLib/src/main/kotlin/info/appdev/charting/renderer/HorizontalBarChartRenderer.kt b/chartLib/src/main/kotlin/info/appdev/charting/renderer/HorizontalBarChartRenderer.kt index 870796ffb..04be4724e 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/renderer/HorizontalBarChartRenderer.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/renderer/HorizontalBarChartRenderer.kt @@ -10,13 +10,16 @@ import info.appdev.charting.interfaces.dataprovider.BarDataProvider import info.appdev.charting.interfaces.dataprovider.base.IBaseProvider import info.appdev.charting.interfaces.datasets.IBarDataSet import info.appdev.charting.utils.Fill +import info.appdev.charting.utils.fillRect import info.appdev.charting.utils.PointF import info.appdev.charting.utils.Transformer import info.appdev.charting.utils.ViewPortHandler import info.appdev.charting.utils.calcTextHeight import info.appdev.charting.utils.calcTextWidth import info.appdev.charting.utils.convertDpToPixel +import info.appdev.charting.utils.copyInto import info.appdev.charting.utils.drawImage +import info.appdev.charting.utils.toCommonRectF import kotlin.math.ceil import kotlin.math.min @@ -78,7 +81,9 @@ open class HorizontalBarChartRenderer( mBarShadowRectBuffer.top = x - barWidthHalf mBarShadowRectBuffer.bottom = x + barWidthHalf - trans!!.rectValueToPixel(mBarShadowRectBuffer) + val commonShadowRect = mBarShadowRectBuffer.toCommonRectF() + trans!!.rectValueToPixel(commonShadowRect) + commonShadowRect.copyInto(mBarShadowRectBuffer) if (!viewPortHandler.isInBoundsTop(mBarShadowRectBuffer.bottom)) { i++ @@ -455,7 +460,9 @@ open class HorizontalBarChartRenderer( barRect[y1, top, y2] = bottom - trans.rectToPixelPhaseHorizontal(barRect, animator.phaseY) + val commonBarRect = barRect.toCommonRectF() + trans.rectToPixelPhaseHorizontal(commonBarRect, animator.phaseY) + commonBarRect.copyInto(barRect) } override fun setHighlightDrawPos(high: Highlight, bar: RectF) { diff --git a/chartLib/src/main/kotlin/info/appdev/charting/renderer/LegendRenderer.kt b/chartLib/src/main/kotlin/info/appdev/charting/renderer/LegendRenderer.kt index 408f57dbb..053f0792e 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/renderer/LegendRenderer.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/renderer/LegendRenderer.kt @@ -12,6 +12,7 @@ import info.appdev.charting.components.Legend.LegendHorizontalAlignment import info.appdev.charting.components.Legend.LegendOrientation import info.appdev.charting.components.Legend.LegendVerticalAlignment import info.appdev.charting.components.LegendEntry +import info.appdev.charting.components.calculateDimensions import info.appdev.charting.data.ChartData import info.appdev.charting.interfaces.datasets.IBarDataSet import info.appdev.charting.interfaces.datasets.ICandleDataSet @@ -23,6 +24,7 @@ import info.appdev.charting.utils.calcTextWidth import info.appdev.charting.utils.convertDpToPixel import info.appdev.charting.utils.getLineHeight import info.appdev.charting.utils.getLineSpacing +import info.appdev.charting.utils.toAndroidDashPathEffect import java.util.Collections import kotlin.math.min @@ -486,7 +488,7 @@ open class LegendRenderer( entry.formLineDashEffect formPaint.style = Paint.Style.STROKE formPaint.strokeWidth = formLineWidth - formPaint.pathEffect = formLineDashEffect + formPaint.pathEffect = formLineDashEffect?.toAndroidDashPathEffect() mLineFormPath.reset() mLineFormPath.moveTo(x, y) diff --git a/chartLib/src/main/kotlin/info/appdev/charting/renderer/LineChartRenderer.kt b/chartLib/src/main/kotlin/info/appdev/charting/renderer/LineChartRenderer.kt index 38bb2fcee..0bc5a545c 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/renderer/LineChartRenderer.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/renderer/LineChartRenderer.kt @@ -10,6 +10,8 @@ import android.graphics.Path import info.appdev.charting.animation.ChartAnimator import info.appdev.charting.data.BaseEntry import info.appdev.charting.data.LineDataSet +import info.appdev.charting.data.fillDrawable +import info.appdev.charting.data.fillFormatter import info.appdev.charting.highlight.Highlight import info.appdev.charting.interfaces.dataprovider.LineDataProvider import info.appdev.charting.interfaces.datasets.IBarLineScatterCandleBubbleDataSet @@ -21,6 +23,7 @@ import info.appdev.charting.utils.Transformer import info.appdev.charting.utils.ViewPortHandler import info.appdev.charting.utils.convertDpToPixel import info.appdev.charting.utils.drawImage +import info.appdev.charting.utils.toAndroidDashPathEffect import java.lang.ref.WeakReference import kotlin.math.max import kotlin.math.min @@ -95,7 +98,7 @@ open class LineChartRenderer( return paintRender.strokeWidth = dataSet.lineWidth - paintRender.pathEffect = dataSet.dashPathEffect + paintRender.pathEffect = dataSet.dashPathEffect?.toAndroidDashPathEffect() when (dataSet.lineMode) { LineDataSet.Mode.LINEAR, LineDataSet.Mode.STEPPED -> drawLinear(canvas, dataSet) diff --git a/chartLib/src/main/kotlin/info/appdev/charting/renderer/LineScatterCandleRadarRenderer.kt b/chartLib/src/main/kotlin/info/appdev/charting/renderer/LineScatterCandleRadarRenderer.kt index 13f8ad48e..27cbb7522 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/renderer/LineScatterCandleRadarRenderer.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/renderer/LineScatterCandleRadarRenderer.kt @@ -6,6 +6,7 @@ import android.graphics.Path import info.appdev.charting.animation.ChartAnimator import info.appdev.charting.interfaces.datasets.ILineScatterCandleRadarDataSet import info.appdev.charting.utils.ViewPortHandler +import info.appdev.charting.utils.toAndroidDashPathEffect abstract class LineScatterCandleRadarRenderer( animator: ChartAnimator, @@ -31,7 +32,7 @@ abstract class LineScatterCandleRadarRenderer( paintHighlight.strokeWidth = set.highlightLineWidth // draw highlighted lines (if enabled) - paintHighlight.pathEffect = set.dashPathEffectHighlight + paintHighlight.pathEffect = set.dashPathEffectHighlight?.toAndroidDashPathEffect() // draw vertical highlight lines if (set.isVerticalHighlightIndicator) { diff --git a/chartLib/src/main/kotlin/info/appdev/charting/renderer/RadarChartRenderer.kt b/chartLib/src/main/kotlin/info/appdev/charting/renderer/RadarChartRenderer.kt index 5c0c8ba01..90c543cb1 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/renderer/RadarChartRenderer.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/renderer/RadarChartRenderer.kt @@ -8,6 +8,7 @@ import androidx.annotation.ColorInt import androidx.core.graphics.withSave import info.appdev.charting.animation.ChartAnimator import info.appdev.charting.charts.RadarChart +import info.appdev.charting.data.fillDrawable import info.appdev.charting.highlight.Highlight import info.appdev.charting.interfaces.datasets.IRadarDataSet import info.appdev.charting.utils.ColorTemplate diff --git a/chartLib/src/main/kotlin/info/appdev/charting/renderer/RoundedBarChartRenderer.kt b/chartLib/src/main/kotlin/info/appdev/charting/renderer/RoundedBarChartRenderer.kt index 8e8e28516..0b2fe5c66 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/renderer/RoundedBarChartRenderer.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/renderer/RoundedBarChartRenderer.kt @@ -11,6 +11,8 @@ import info.appdev.charting.interfaces.dataprovider.BarDataProvider import info.appdev.charting.interfaces.datasets.IBarDataSet import info.appdev.charting.utils.ViewPortHandler import info.appdev.charting.utils.convertDpToPixel +import info.appdev.charting.utils.copyInto +import info.appdev.charting.utils.toCommonRectF import kotlin.math.min class RoundedBarChartRenderer( @@ -47,7 +49,9 @@ class RoundedBarChartRenderer( mBarShadowRectBuffer.left = x - barWidthHalf mBarShadowRectBuffer.right = x + barWidthHalf } - trans!!.rectValueToPixel(mBarShadowRectBuffer) + val commonShadowRect = mBarShadowRectBuffer.toCommonRectF() + trans!!.rectValueToPixel(commonShadowRect) + commonShadowRect.copyInto(mBarShadowRectBuffer) if (!viewPortHandler.isInBoundsLeft(mBarShadowRectBuffer.right)) { i++ continue diff --git a/chartLib/src/main/kotlin/info/appdev/charting/renderer/RoundedHorizontalBarChartRenderer.kt b/chartLib/src/main/kotlin/info/appdev/charting/renderer/RoundedHorizontalBarChartRenderer.kt index 5c6d2cacb..d1103946d 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/renderer/RoundedHorizontalBarChartRenderer.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/renderer/RoundedHorizontalBarChartRenderer.kt @@ -9,6 +9,8 @@ import info.appdev.charting.interfaces.dataprovider.BarDataProvider import info.appdev.charting.interfaces.datasets.IBarDataSet import info.appdev.charting.utils.ViewPortHandler import info.appdev.charting.utils.convertDpToPixel +import info.appdev.charting.utils.copyInto +import info.appdev.charting.utils.toCommonRectF import kotlin.math.min class RoundedHorizontalBarChartRenderer( @@ -48,7 +50,9 @@ class RoundedHorizontalBarChartRenderer( mBarShadowRectBuffer.top = x - barWidthHalf mBarShadowRectBuffer.bottom = x + barWidthHalf } - trans!!.rectValueToPixel(mBarShadowRectBuffer) + val commonShadowRect = mBarShadowRectBuffer.toCommonRectF() + trans!!.rectValueToPixel(commonShadowRect) + commonShadowRect.copyInto(mBarShadowRectBuffer) if (!viewPortHandler.isInBoundsTop(mBarShadowRectBuffer.bottom)) { i++ continue diff --git a/chartLib/src/main/kotlin/info/appdev/charting/renderer/ScatterChartRenderer.kt b/chartLib/src/main/kotlin/info/appdev/charting/renderer/ScatterChartRenderer.kt index cf4a1c10d..da621ac54 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/renderer/ScatterChartRenderer.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/renderer/ScatterChartRenderer.kt @@ -2,6 +2,7 @@ package info.appdev.charting.renderer import android.graphics.Canvas import info.appdev.charting.animation.ChartAnimator +import info.appdev.charting.data.shapeRenderer import info.appdev.charting.highlight.Highlight import info.appdev.charting.interfaces.dataprovider.ScatterDataProvider import info.appdev.charting.interfaces.datasets.IScatterDataSet diff --git a/chartLib/src/main/kotlin/info/appdev/charting/renderer/XAxisRenderer.kt b/chartLib/src/main/kotlin/info/appdev/charting/renderer/XAxisRenderer.kt index dd93481c9..860e6a205 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/renderer/XAxisRenderer.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/renderer/XAxisRenderer.kt @@ -22,6 +22,9 @@ import info.appdev.charting.utils.calcTextHeight import info.appdev.charting.utils.calcTextSize import info.appdev.charting.utils.calcTextWidth import info.appdev.charting.utils.convertDpToPixel +import info.appdev.charting.utils.copyInto +import info.appdev.charting.utils.toAndroidDashPathEffect +import info.appdev.charting.utils.toAndroidPaintStyle import info.appdev.charting.utils.drawXAxisValue import kotlin.math.roundToInt @@ -33,7 +36,7 @@ open class XAxisRenderer( protected fun setupGridPaint() { paintGrid.color = xAxis.gridColor paintGrid.strokeWidth = xAxis.gridLineWidth - paintGrid.pathEffect = xAxis.gridDashPathEffect + paintGrid.pathEffect = xAxis.gridDashPathEffect?.toAndroidDashPathEffect() } override fun computeAxis(min: Float, max: Float, inverted: Boolean) { @@ -143,7 +146,7 @@ open class XAxisRenderer( paintAxisLine.color = xAxis.axisLineColor paintAxisLine.strokeWidth = xAxis.axisLineWidth - paintAxisLine.pathEffect = xAxis.axisLineDashPathEffect + paintAxisLine.pathEffect = xAxis.axisLineDashPathEffect?.toAndroidDashPathEffect() if (xAxis.position == XAxisPosition.TOP || xAxis.position == XAxisPosition.TOP_INSIDE || xAxis.position == XAxisPosition.BOTH_SIDED) { canvas.drawLine( @@ -285,7 +288,7 @@ open class XAxisRenderer( open val gridClippingRect: RectF? get() { - mGridClippingRect.set(viewPortHandler.contentRect) + viewPortHandler.contentRect.copyInto(mGridClippingRect) mGridClippingRect.inset(-axis.gridLineWidth, 0f) return mGridClippingRect } @@ -334,7 +337,7 @@ open class XAxisRenderer( limitLinePaint.style = Paint.Style.STROKE limitLinePaint.color = limitLine.lineColor limitLinePaint.strokeWidth = limitLine.lineWidth - limitLinePaint.pathEffect = limitLine.dashPathEffect + limitLinePaint.pathEffect = limitLine.dashPathEffect?.toAndroidDashPathEffect() canvas.drawPath(mLimitLinePath, limitLinePaint) } @@ -357,7 +360,7 @@ open class XAxisRenderer( limitLines.forEach { limitLine -> if (limitLine.isEnabled) { canvas.withSave { - mLimitLineClippingRect.set(viewPortHandler.contentRect) + viewPortHandler.contentRect.copyInto(mLimitLineClippingRect) mLimitLineClippingRect.inset(-limitLine.lineWidth, 0f) canvas.clipRect(mLimitLineClippingRect) @@ -377,7 +380,7 @@ open class XAxisRenderer( // if drawing the limit-value label is enabled limitLine.label?.let { label -> if (label.isNotEmpty() && limitLine.isEnabled) { - limitLinePaint.style = limitLine.textStyle + limitLinePaint.style = limitLine.textStyle.toAndroidPaintStyle() limitLinePaint.pathEffect = null limitLinePaint.color = limitLine.textColor limitLinePaint.strokeWidth = 0.5f diff --git a/chartLib/src/main/kotlin/info/appdev/charting/renderer/XAxisRendererHorizontalBarChart.kt b/chartLib/src/main/kotlin/info/appdev/charting/renderer/XAxisRendererHorizontalBarChart.kt index 02edf3666..cb90e539e 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/renderer/XAxisRendererHorizontalBarChart.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/renderer/XAxisRendererHorizontalBarChart.kt @@ -18,6 +18,9 @@ import info.appdev.charting.utils.ViewPortHandler import info.appdev.charting.utils.calcTextHeight import info.appdev.charting.utils.calcTextSize import info.appdev.charting.utils.convertDpToPixel +import info.appdev.charting.utils.copyInto +import info.appdev.charting.utils.toAndroidDashPathEffect +import info.appdev.charting.utils.toAndroidPaintStyle import kotlin.math.roundToInt @Suppress("MemberVisibilityCanBePrivate") @@ -162,7 +165,7 @@ open class XAxisRendererHorizontalBarChart( override val gridClippingRect: RectF get() { - mGridClippingRect.set(viewPortHandler.contentRect) + viewPortHandler.contentRect.copyInto(mGridClippingRect) mGridClippingRect.inset(0f, -axis.gridLineWidth) return mGridClippingRect } @@ -226,14 +229,14 @@ open class XAxisRendererHorizontalBarChart( if (!limitLine.isEnabled) continue canvas.withSave { - mLimitLineClippingRect.set(viewPortHandler.contentRect) + viewPortHandler.contentRect.copyInto(mLimitLineClippingRect) mLimitLineClippingRect.inset(0f, -limitLine.lineWidth) canvas.clipRect(mLimitLineClippingRect) limitLinePaint.style = Paint.Style.STROKE limitLinePaint.color = limitLine.lineColor limitLinePaint.strokeWidth = limitLine.lineWidth - limitLinePaint.pathEffect = limitLine.dashPathEffect + limitLinePaint.pathEffect = limitLine.dashPathEffect?.toAndroidDashPathEffect() pts[1] = limitLine.limit @@ -250,7 +253,7 @@ open class XAxisRendererHorizontalBarChart( // if drawing the limit-value label is enabled if (label != null && label != "") { - limitLinePaint.style = limitLine.textStyle + limitLinePaint.style = limitLine.textStyle.toAndroidPaintStyle() limitLinePaint.pathEffect = null limitLinePaint.color = limitLine.textColor limitLinePaint.strokeWidth = 0.5f diff --git a/chartLib/src/main/kotlin/info/appdev/charting/renderer/YAxisRenderer.kt b/chartLib/src/main/kotlin/info/appdev/charting/renderer/YAxisRenderer.kt index 5ab918c41..6668277f8 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/renderer/YAxisRenderer.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/renderer/YAxisRenderer.kt @@ -16,6 +16,9 @@ import info.appdev.charting.utils.Transformer import info.appdev.charting.utils.ViewPortHandler import info.appdev.charting.utils.calcTextHeight import info.appdev.charting.utils.convertDpToPixel +import info.appdev.charting.utils.copyInto +import info.appdev.charting.utils.toAndroidDashPathEffect +import info.appdev.charting.utils.toAndroidPaintStyle open class YAxisRenderer( viewPortHandler: ViewPortHandler, @@ -147,7 +150,7 @@ open class YAxisRenderer( paintGrid.color = yAxis.gridColor paintGrid.strokeWidth = yAxis.gridLineWidth - paintGrid.pathEffect = yAxis.gridDashPathEffect + paintGrid.pathEffect = yAxis.gridDashPathEffect?.toAndroidDashPathEffect() val gridLinePath = renderGridLinesPath gridLinePath.reset() @@ -172,7 +175,7 @@ open class YAxisRenderer( open val gridClippingRect: RectF? get() { - mGridClippingRect.set(viewPortHandler.contentRect) + viewPortHandler.contentRect.copyInto(mGridClippingRect) mGridClippingRect.inset(0f, -axis.gridLineWidth) return mGridClippingRect } @@ -229,7 +232,7 @@ open class YAxisRenderer( */ protected open fun drawZeroLine(canvas: Canvas) { canvas.withSave { - zeroLineClippingRect.set(viewPortHandler.contentRect) + viewPortHandler.contentRect.copyInto(zeroLineClippingRect) zeroLineClippingRect.inset(0f, -yAxis.zeroLineWidth) canvas.clipRect(zeroLineClippingRect) @@ -284,14 +287,14 @@ open class YAxisRenderer( continue canvas.withSave { - limitLineClippingRect.set(viewPortHandler.contentRect) + viewPortHandler.contentRect.copyInto(limitLineClippingRect) limitLineClippingRect.inset(0f, -limitLine.lineWidth) canvas.clipRect(limitLineClippingRect) limitLinePaint.style = Paint.Style.STROKE limitLinePaint.color = limitLine.lineColor limitLinePaint.strokeWidth = limitLine.lineWidth - limitLinePaint.pathEffect = limitLine.dashPathEffect + limitLinePaint.pathEffect = limitLine.dashPathEffect?.toAndroidDashPathEffect() pts[1] = limitLine.limit @@ -308,7 +311,7 @@ open class YAxisRenderer( // if drawing the limit-value label is enabled if (label != null && label != "") { - limitLinePaint.style = limitLine.textStyle + limitLinePaint.style = limitLine.textStyle.toAndroidPaintStyle() limitLinePaint.pathEffect = null limitLinePaint.color = limitLine.textColor limitLinePaint.typeface = limitLine.typeface @@ -385,14 +388,14 @@ open class YAxisRenderer( continue canvas.withSave { - limitLineClippingRect.set(viewPortHandler.contentRect) + viewPortHandler.contentRect.copyInto(limitLineClippingRect) limitLineClippingRect.inset(0f, -limitRange.lineWidth) canvas.clipRect(limitLineClippingRect) limitRangePaint.style = Paint.Style.STROKE limitRangePaint.color = limitRange.lineColor limitRangePaint.strokeWidth = limitRange.lineWidth - limitRangePaint.pathEffect = limitRange.dashPathEffect + limitRangePaint.pathEffect = limitRange.dashPathEffect?.toAndroidDashPathEffect() limitRangePaintFill.style = Paint.Style.FILL limitRangePaintFill.color = limitRange.rangeColor @@ -430,7 +433,7 @@ open class YAxisRenderer( // if drawing the limit-value label is enabled if (label != null && label != "") { - limitRangePaint.style = limitRange.textStyle + limitRangePaint.style = limitRange.textStyle.toAndroidPaintStyle() limitRangePaint.pathEffect = null limitRangePaint.color = limitRange.textColor limitRangePaint.typeface = limitRange.typeface diff --git a/chartLib/src/main/kotlin/info/appdev/charting/renderer/YAxisRendererHorizontalBarChart.kt b/chartLib/src/main/kotlin/info/appdev/charting/renderer/YAxisRendererHorizontalBarChart.kt index 3ecaa402b..0f5237305 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/renderer/YAxisRendererHorizontalBarChart.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/renderer/YAxisRendererHorizontalBarChart.kt @@ -15,6 +15,9 @@ import info.appdev.charting.utils.Transformer import info.appdev.charting.utils.ViewPortHandler import info.appdev.charting.utils.calcTextHeight import info.appdev.charting.utils.convertDpToPixel +import info.appdev.charting.utils.copyInto +import info.appdev.charting.utils.toAndroidDashPathEffect +import info.appdev.charting.utils.toAndroidPaintStyle @Suppress("MemberVisibilityCanBePrivate") open class YAxisRendererHorizontalBarChart( @@ -174,7 +177,7 @@ open class YAxisRendererHorizontalBarChart( override val gridClippingRect: RectF get() { - mGridClippingRect.set(viewPortHandler.contentRect) + viewPortHandler.contentRect.copyInto(mGridClippingRect) mGridClippingRect.inset(-axis.gridLineWidth, 0f) return mGridClippingRect } @@ -188,7 +191,7 @@ open class YAxisRendererHorizontalBarChart( override fun drawZeroLine(canvas: Canvas) { canvas.withSave { - zeroLineClippingRect.set(viewPortHandler.contentRect) + viewPortHandler.contentRect.copyInto(zeroLineClippingRect) zeroLineClippingRect.inset(-yAxis.zeroLineWidth, 0f) canvas.clipRect(limitLineClippingRect) @@ -243,7 +246,7 @@ open class YAxisRendererHorizontalBarChart( if (!limitLine.isEnabled) continue canvas.withSave { - limitLineClippingRect.set(viewPortHandler.contentRect) + viewPortHandler.contentRect.copyInto(limitLineClippingRect) limitLineClippingRect.inset(-limitLine.lineWidth, 0f) canvas.clipRect(limitLineClippingRect) @@ -260,7 +263,7 @@ open class YAxisRendererHorizontalBarChart( limitLinePaint.style = Paint.Style.STROKE limitLinePaint.color = limitLine.lineColor - limitLinePaint.pathEffect = limitLine.dashPathEffect + limitLinePaint.pathEffect = limitLine.dashPathEffect?.toAndroidDashPathEffect() limitLinePaint.strokeWidth = limitLine.lineWidth canvas.drawPath(limitLinePath, limitLinePaint) @@ -270,7 +273,7 @@ open class YAxisRendererHorizontalBarChart( // if drawing the limit-value label is enabled if (label != null && label != "") { - limitLinePaint.style = limitLine.textStyle + limitLinePaint.style = limitLine.textStyle.toAndroidPaintStyle() limitLinePaint.pathEffect = null limitLinePaint.color = limitLine.textColor limitLinePaint.typeface = limitLine.typeface diff --git a/chartLib/src/main/kotlin/info/appdev/charting/renderer/YAxisRendererRadarChart.kt b/chartLib/src/main/kotlin/info/appdev/charting/renderer/YAxisRendererRadarChart.kt index 19be59882..fa6e8314f 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/renderer/YAxisRendererRadarChart.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/renderer/YAxisRendererRadarChart.kt @@ -14,6 +14,7 @@ import kotlin.math.floor import kotlin.math.log10 import kotlin.math.nextUp import kotlin.math.pow +import info.appdev.charting.utils.toAndroidDashPathEffect class YAxisRendererRadarChart( viewPortHandler: ViewPortHandler, @@ -190,7 +191,7 @@ class YAxisRendererRadarChart( if (!limitLine.isEnabled) continue limitLinePaint.color = limitLine.lineColor - limitLinePaint.pathEffect = limitLine.dashPathEffect + limitLinePaint.pathEffect = limitLine.dashPathEffect?.toAndroidDashPathEffect() limitLinePaint.strokeWidth = limitLine.lineWidth val r = (limitLine.limit - chart.yChartMin) * factor diff --git a/chartLib/src/main/kotlin/info/appdev/charting/utils/ColorTemplate.kt b/chartLib/src/main/kotlin/info/appdev/charting/utils/ColorTemplate.kt deleted file mode 100644 index 43c2b6dd6..000000000 --- a/chartLib/src/main/kotlin/info/appdev/charting/utils/ColorTemplate.kt +++ /dev/null @@ -1,102 +0,0 @@ -package info.appdev.charting.utils - -import android.graphics.Color - -/** - * Class that holds predefined color integer arrays (e.g. ColorTemplate.VORDIPLOM_COLORS) - * and convenience methods for loading colors from resources. - */ -object ColorTemplate { - /** - * an "invalid" color that indicates that no color is set - */ - const val COLOR_NONE: Int = 0x00112233 - - /** - * this "color" is used for the Legend creation and indicates that the next - * form should be skipped - */ - const val COLOR_SKIP: Int = 0x00112234 - - /** - * THE COLOR THEMES ARE PREDEFINED (predefined color integer arrays), FEEL - * FREE TO CREATE YOUR OWN WITH AS MANY DIFFERENT COLORS AS YOU WANT - */ - val LIBERTY_COLORS: IntArray = intArrayOf( - Color.rgb(207, 248, 246), - Color.rgb(148, 212, 212), - Color.rgb(136, 180, 187), - Color.rgb(118, 174, 175), - Color.rgb(42, 109, 130) - ) - val JOYFUL_COLORS: IntArray = intArrayOf( - Color.rgb(217, 80, 138), - Color.rgb(254, 149, 7), - Color.rgb(254, 247, 120), - Color.rgb(106, 167, 134), - Color.rgb(53, 194, 209) - ) - val PASTEL_COLORS: IntArray = intArrayOf( - Color.rgb(64, 89, 128), - Color.rgb(149, 165, 124), - Color.rgb(217, 184, 162), - Color.rgb(191, 134, 134), - Color.rgb(179, 48, 80) - ) - val COLORFUL_COLORS: IntArray = intArrayOf( - Color.rgb(193, 37, 82), - Color.rgb(255, 102, 0), - Color.rgb(245, 199, 0), - Color.rgb(106, 150, 31), - Color.rgb(179, 100, 53) - ) - val VORDIPLOM_COLORS: IntArray = intArrayOf( - Color.rgb(192, 255, 140), - Color.rgb(255, 247, 140), - Color.rgb(255, 208, 140), - Color.rgb(140, 234, 255), - Color.rgb(255, 140, 157) - ) - val MATERIAL_COLORS: IntArray = intArrayOf( - rgb("#2ecc71"), - rgb("#f1c40f"), - rgb("#e74c3c"), - rgb("#3498db") - ) - - /** - * Converts the given hex-color-string to rgb. - */ - fun rgb(hex: String): Int { - val color = hex.replace("#", "").toLong(16).toInt() - val r = (color shr 16) and 0xFF - val g = (color shr 8) and 0xFF - val b = (color) and 0xFF - return Color.rgb(r, g, b) - } - - /** - * Returns the Android ICS holo blue light color. - */ - val holoBlue: Int - get() = Color.rgb(51, 181, 229) - - /** - * Sets the alpha component of the given color. - * @param alpha 0 - 255 - */ - fun colorWithAlpha(color: Int, alpha: Int): Int { - return (color and 0xffffff) or ((alpha and 0xff) shl 24) - } - - /** - * Turns an array of colors (integer color values) into an ArrayList of colors. - */ - fun createColors(colors: IntArray): MutableList { - val result: MutableList = ArrayList() - for (i in colors) { - result.add(i) - } - return result - } -} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/utils/ContextUtils.kt b/chartLib/src/main/kotlin/info/appdev/charting/utils/ContextUtils.kt index 060a84102..c005d5628 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/utils/ContextUtils.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/utils/ContextUtils.kt @@ -2,10 +2,8 @@ package info.appdev.charting.utils import android.content.Context import android.os.Build -import android.util.DisplayMetrics import android.view.ViewConfiguration -var metrics: DisplayMetrics? = null var minimumFlingVelocity = 0 var maximumFlingVelocity = 0 @@ -14,7 +12,7 @@ fun Context.initUtils() { minimumFlingVelocity = viewConfiguration.scaledMinimumFlingVelocity maximumFlingVelocity = viewConfiguration.scaledMaximumFlingVelocity - metrics = this.resources.displayMetrics + chartDensity = this.resources.displayMetrics.density } fun getSDKInt() = Build.VERSION.SDK_INT diff --git a/chartLib/src/main/kotlin/info/appdev/charting/utils/DashEffectAndroid.kt b/chartLib/src/main/kotlin/info/appdev/charting/utils/DashEffectAndroid.kt new file mode 100644 index 000000000..6ee4bf1b1 --- /dev/null +++ b/chartLib/src/main/kotlin/info/appdev/charting/utils/DashEffectAndroid.kt @@ -0,0 +1,9 @@ +package info.appdev.charting.utils + +import android.graphics.DashPathEffect + +/** + * Converts a platform-independent [DashEffect] into an Android [DashPathEffect], + * for use at Canvas/Paint drawing boundaries (e.g. `Paint.pathEffect`). + */ +fun DashEffect.toAndroidDashPathEffect(): DashPathEffect = DashPathEffect(intervals, phase) diff --git a/chartLib/src/main/kotlin/info/appdev/charting/utils/Fill.kt b/chartLib/src/main/kotlin/info/appdev/charting/utils/Fill.kt deleted file mode 100644 index bd3c5a921..000000000 --- a/chartLib/src/main/kotlin/info/appdev/charting/utils/Fill.kt +++ /dev/null @@ -1,248 +0,0 @@ -package info.appdev.charting.utils - -import android.R.attr.endColor -import android.R.attr.startColor -import android.graphics.Canvas -import android.graphics.LinearGradient -import android.graphics.Paint -import android.graphics.Path -import android.graphics.RectF -import android.graphics.Shader -import android.graphics.drawable.Drawable -import androidx.annotation.ColorInt -import kotlin.math.floor -import androidx.core.graphics.withClip - -open class Fill { - enum class Type { - EMPTY, COLOR, LINEAR_GRADIENT, DRAWABLE - } - - enum class Direction { - DOWN, UP, RIGHT, LEFT - } - - /** - * the type of fill - */ - var type: Type = Type.EMPTY - - /** - * the color that is used for filling - */ - @ColorInt - private var mColor: Int? = null - - @ColorInt - private var mFinalColor: Int? = null - - /** - * the drawable to be used for filling - */ - protected var drawable: Drawable? = null - - var gradientColors: IntArray? = null - - var gradientPositions: FloatArray? = null - - /** - * transparency used for filling - */ - private var mAlpha = 255 - - constructor(@ColorInt startColor: Int, @ColorInt endColor: Int) { - this.type = Type.LINEAR_GRADIENT - this.gradientColors = intArrayOf(startColor, endColor) - } - - var color: Int? - get() = mColor - set(color) { - this.mColor = color - calculateFinalColor() - } - - fun setGradientColors(@ColorInt startColor: Int, @ColorInt endColor: Int) { - this.gradientColors = intArrayOf(startColor, endColor) - } - - var alpha: Int - get() = mAlpha - set(alpha) { - this.mAlpha = alpha - calculateFinalColor() - } - - private fun calculateFinalColor() { - if (mColor == null) { - mFinalColor = null - } else { - val alpha = floor(((mColor!! shr 24) / 255.0) * (mAlpha / 255.0) * 255.0).toInt() - mFinalColor = (alpha shl 24) or (mColor!! and 0xffffff) - } - } - - fun fillRect( - canvas: Canvas, paint: Paint, - left: Float, top: Float, right: Float, bottom: Float, - gradientDirection: Direction?, mRoundedBarRadius: Float - ) { - when (this.type) { - Type.EMPTY -> return - - Type.COLOR -> { - if (mFinalColor == null) { - return - } - - if (this.isClipPathSupported) { - canvas.withClip(left, top, right, bottom) { - - canvas.drawColor(mFinalColor!!) - - } - } else { - // save - val previous = paint.style - val previousColor = paint.color - - // set - paint.style = Paint.Style.FILL - paint.color = mFinalColor!! - - canvas.drawRoundRect(RectF(left, top, right, bottom), mRoundedBarRadius, mRoundedBarRadius, paint) - - // restore - paint.color = previousColor - paint.style = previous - } - } - - Type.LINEAR_GRADIENT -> { - val gradient = LinearGradient( - (if (gradientDirection == Direction.RIGHT) - right - else - left) - .toInt().toFloat(), - (if (gradientDirection == Direction.UP) - bottom - else - top) - .toInt().toFloat(), - (when (gradientDirection) { - Direction.RIGHT -> left - Direction.LEFT -> right - else -> left - }).toInt().toFloat(), - (when (gradientDirection) { - Direction.UP -> top - Direction.DOWN -> bottom - else -> top - }).toInt().toFloat(), - this.gradientColors!!, - this.gradientPositions, - Shader.TileMode.MIRROR - ) - - paint.shader = gradient - - canvas.drawRoundRect(RectF(left, top, right, bottom), mRoundedBarRadius, mRoundedBarRadius, paint) - } - - Type.DRAWABLE -> { - if (drawable == null) { - return - } - - drawable!!.setBounds(left.toInt(), top.toInt(), right.toInt(), bottom.toInt()) - drawable!!.draw(canvas) - } - } - } - - fun fillPath( - canvas: Canvas, - path: Path, - paint: Paint, - clipRect: RectF? - ) { - when (this.type) { - Type.EMPTY -> return - - Type.COLOR -> { - if (mFinalColor == null) { - return - } - - if (clipRect != null && this.isClipPathSupported) { - canvas.withClip(path) { - - canvas.drawColor(mFinalColor!!) - - } - } else { - // save - val previous = paint.style - val previousColor = paint.color - - // set - paint.style = Paint.Style.FILL - paint.color = mFinalColor!! - - canvas.drawPath(path, paint) - - // restore - paint.color = previousColor - paint.style = previous - } - } - - Type.LINEAR_GRADIENT -> { - val gradient = LinearGradient( - 0f, - 0f, - canvas.width.toFloat(), - canvas.height.toFloat(), - this.gradientColors!!, - this.gradientPositions, - Shader.TileMode.MIRROR - ) - - paint.shader = gradient - - canvas.drawPath(path, paint) - } - - Type.DRAWABLE -> { - if (drawable == null) { - return - } - - ensureClipPathSupported() - - val save = canvas.save() - canvas.clipPath(path) - - drawable!!.setBounds( - if (clipRect == null) 0 else clipRect.left.toInt(), - if (clipRect == null) 0 else clipRect.top.toInt(), - if (clipRect == null) canvas.width else clipRect.right.toInt(), - if (clipRect == null) canvas.height else clipRect.bottom.toInt() - ) - drawable!!.draw(canvas) - - canvas.restoreToCount(save) - } - } - } - - private val isClipPathSupported: Boolean - get() = getSDKInt() >= 18 - - private fun ensureClipPathSupported() { - if (getSDKInt() < 18) { - throw RuntimeException("Fill-drawables not (yet) supported below API level 18, this code was run on API level \${getSDKInt()}") - } - } -} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/utils/FillAndroid.kt b/chartLib/src/main/kotlin/info/appdev/charting/utils/FillAndroid.kt new file mode 100644 index 000000000..5dd89f21a --- /dev/null +++ b/chartLib/src/main/kotlin/info/appdev/charting/utils/FillAndroid.kt @@ -0,0 +1,185 @@ +package info.appdev.charting.utils + +import android.graphics.Canvas +import android.graphics.LinearGradient +import android.graphics.Paint +import android.graphics.Path +import android.graphics.RectF +import android.graphics.Shader +import android.graphics.drawable.Drawable +import androidx.core.graphics.withClip +import java.util.WeakHashMap + +/** + * Android-only drawing logic and [Drawable] payload for the portable [Fill] class. + * + * `Canvas`/`Paint`/`Drawable`/`LinearGradient` have no portable KMP equivalent, so this stays + * Android-only, mirroring the [Fill] side-channel pattern already used for + * `ILineRadarDataSet.fillDrawable` (see `LineRadarDataSetAndroid.kt`). + */ +private val fillDrawables = WeakHashMap() + +/** + * the drawable to be used for filling when [Fill.type] is [Fill.Type.DRAWABLE] + */ +var Fill.drawable: Drawable? + get() = fillDrawables[this] + set(value) { + fillDrawables[this] = value + } + +private val Fill.isClipPathSupported: Boolean + get() = getSDKInt() >= 18 + +private fun Fill.ensureClipPathSupported() { + if (getSDKInt() < 18) { + throw RuntimeException("Fill-drawables not (yet) supported below API level 18, this code was run on API level \${getSDKInt()}") + } +} + +fun Fill.fillRect( + canvas: Canvas, paint: Paint, + left: Float, top: Float, right: Float, bottom: Float, + gradientDirection: Fill.Direction?, mRoundedBarRadius: Float +) { + when (this.type) { + Fill.Type.EMPTY -> return + + Fill.Type.COLOR -> { + val finalColor = this.finalColor ?: return + + if (this.isClipPathSupported) { + canvas.withClip(left, top, right, bottom) { + + canvas.drawColor(finalColor) + + } + } else { + // save + val previous = paint.style + val previousColor = paint.color + + // set + paint.style = Paint.Style.FILL + paint.color = finalColor + + canvas.drawRoundRect(RectF(left, top, right, bottom), mRoundedBarRadius, mRoundedBarRadius, paint) + + // restore + paint.color = previousColor + paint.style = previous + } + } + + Fill.Type.LINEAR_GRADIENT -> { + val gradient = LinearGradient( + (if (gradientDirection == Fill.Direction.RIGHT) + right + else + left) + .toInt().toFloat(), + (if (gradientDirection == Fill.Direction.UP) + bottom + else + top) + .toInt().toFloat(), + (when (gradientDirection) { + Fill.Direction.RIGHT -> left + Fill.Direction.LEFT -> right + else -> left + }).toInt().toFloat(), + (when (gradientDirection) { + Fill.Direction.UP -> top + Fill.Direction.DOWN -> bottom + else -> top + }).toInt().toFloat(), + this.gradientColors!!, + this.gradientPositions, + Shader.TileMode.MIRROR + ) + + paint.shader = gradient + + canvas.drawRoundRect(RectF(left, top, right, bottom), mRoundedBarRadius, mRoundedBarRadius, paint) + } + + Fill.Type.DRAWABLE -> { + val drawable = this.drawable ?: return + + drawable.setBounds(left.toInt(), top.toInt(), right.toInt(), bottom.toInt()) + drawable.draw(canvas) + } + } +} + +fun Fill.fillPath( + canvas: Canvas, + path: Path, + paint: Paint, + clipRect: RectF? +) { + when (this.type) { + Fill.Type.EMPTY -> return + + Fill.Type.COLOR -> { + val finalColor = this.finalColor ?: return + + if (clipRect != null && this.isClipPathSupported) { + canvas.withClip(path) { + + canvas.drawColor(finalColor) + + } + } else { + // save + val previous = paint.style + val previousColor = paint.color + + // set + paint.style = Paint.Style.FILL + paint.color = finalColor + + canvas.drawPath(path, paint) + + // restore + paint.color = previousColor + paint.style = previous + } + } + + Fill.Type.LINEAR_GRADIENT -> { + val gradient = LinearGradient( + 0f, + 0f, + canvas.width.toFloat(), + canvas.height.toFloat(), + this.gradientColors!!, + this.gradientPositions, + Shader.TileMode.MIRROR + ) + + paint.shader = gradient + + canvas.drawPath(path, paint) + } + + Fill.Type.DRAWABLE -> { + val drawable = this.drawable ?: return + + ensureClipPathSupported() + + val save = canvas.save() + canvas.clipPath(path) + + drawable.setBounds( + if (clipRect == null) 0 else clipRect.left.toInt(), + if (clipRect == null) 0 else clipRect.top.toInt(), + if (clipRect == null) canvas.width else clipRect.right.toInt(), + if (clipRect == null) canvas.height else clipRect.bottom.toInt() + ) + drawable.draw(canvas) + + canvas.restoreToCount(save) + } + } +} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/utils/MatrixAndroid.kt b/chartLib/src/main/kotlin/info/appdev/charting/utils/MatrixAndroid.kt new file mode 100644 index 000000000..e73757c83 --- /dev/null +++ b/chartLib/src/main/kotlin/info/appdev/charting/utils/MatrixAndroid.kt @@ -0,0 +1,24 @@ +package info.appdev.charting.utils + +/** + * Converts the platform-independent [Matrix] to an `android.graphics.Matrix`, for the few call + * sites that must hand a matrix to native Android APIs (e.g. `Path.transform(Matrix)`). + */ +fun Matrix.toAndroidMatrix(): android.graphics.Matrix { + val values = FloatArray(9) + getValues(values) + val androidMatrix = android.graphics.Matrix() + androidMatrix.setValues(values) + return androidMatrix +} + +/** + * Converts an `android.graphics.Matrix` to the platform-independent [Matrix]. + */ +fun android.graphics.Matrix.toCommonMatrix(): Matrix { + val values = FloatArray(9) + getValues(values) + val commonMatrix = Matrix() + commonMatrix.setValues(values) + return commonMatrix +} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/utils/NumberUtils.kt b/chartLib/src/main/kotlin/info/appdev/charting/utils/NumberUtils.kt index 3eeaaa1ae..d210eb8de 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/utils/NumberUtils.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/utils/NumberUtils.kt @@ -1,6 +1,5 @@ package info.appdev.charting.utils -import timber.log.Timber import kotlin.math.ceil import kotlin.math.log10 import kotlin.math.pow @@ -36,23 +35,6 @@ fun Double.roundToNextSignificant(): Float { return shifted / magnitude } -/** - * This method converts dp unit to equivalent pixels, depending on device - * density. NEEDS UTILS TO BE INITIALIZED BEFORE USAGE. - * - * dp A value in dp (density independent pixels) unit. Which we need - * to convert into pixels - * @return A float value to represent px equivalent to dp depending on - * device density - */ -fun Float.convertDpToPixel(): Float { - if (metrics == null) { - Timber.e("Utils NOT INITIALIZED. You need to call Utils.init(...) at least once before calling Utils.convertDpToPixel(...). Otherwise conversion does not take place.") - return this - } else - return this * metrics!!.density -} - fun Float.formatNumber(digitCount: Int, separateThousands: Boolean, separateChar: Char = '.'): String { var number = this var digitCount = digitCount diff --git a/chartLib/src/main/kotlin/info/appdev/charting/utils/PaintStyleAndroid.kt b/chartLib/src/main/kotlin/info/appdev/charting/utils/PaintStyleAndroid.kt new file mode 100644 index 000000000..23ce79d4b --- /dev/null +++ b/chartLib/src/main/kotlin/info/appdev/charting/utils/PaintStyleAndroid.kt @@ -0,0 +1,15 @@ +package info.appdev.charting.utils + +import android.graphics.Paint + +/** + * Converts the platform-independent [PaintStyle] used by the data model (e.g. + * [info.appdev.charting.interfaces.datasets.ICandleDataSet]) to the `android.graphics.Paint.Style` + * expected by the Android rendering pipeline. + */ +fun PaintStyle?.toAndroidPaintStyle(default: Paint.Style = Paint.Style.FILL): Paint.Style = when (this) { + PaintStyle.FILL -> Paint.Style.FILL + PaintStyle.STROKE -> Paint.Style.STROKE + PaintStyle.FILL_AND_STROKE -> Paint.Style.FILL_AND_STROKE + null -> default +} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/utils/RectFAndroid.kt b/chartLib/src/main/kotlin/info/appdev/charting/utils/RectFAndroid.kt new file mode 100644 index 000000000..790c88d33 --- /dev/null +++ b/chartLib/src/main/kotlin/info/appdev/charting/utils/RectFAndroid.kt @@ -0,0 +1,26 @@ +package info.appdev.charting.utils + +/** + * Converts the platform-independent [RectF] to an `android.graphics.RectF`, for the call sites + * that must hand a rect to native Android Canvas/Path APIs. + */ +fun RectF.toAndroidRectF(): android.graphics.RectF = android.graphics.RectF(left, top, right, bottom) + +/** + * Copies the values of this platform-independent [RectF] into an existing `android.graphics.RectF`. + */ +fun RectF.copyInto(target: android.graphics.RectF) { + target.set(left, top, right, bottom) +} + +/** + * Converts an `android.graphics.RectF` to the platform-independent [RectF]. + */ +fun android.graphics.RectF.toCommonRectF(): RectF = RectF(left, top, right, bottom) + +/** + * Copies the values of this `android.graphics.RectF` into an existing platform-independent [RectF]. + */ +fun android.graphics.RectF.copyInto(target: RectF) { + target.set(left, top, right, bottom) +} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/utils/TextAlignAndroid.kt b/chartLib/src/main/kotlin/info/appdev/charting/utils/TextAlignAndroid.kt new file mode 100644 index 000000000..ab1975a19 --- /dev/null +++ b/chartLib/src/main/kotlin/info/appdev/charting/utils/TextAlignAndroid.kt @@ -0,0 +1,15 @@ +package info.appdev.charting.utils + +import android.graphics.Paint + +/** + * Converts the platform-independent [TextAlign] used by the data model (e.g. + * [info.appdev.charting.components.Description]) to the `android.graphics.Paint.Align` + * expected by the Android rendering pipeline. + */ +fun TextAlign?.toAndroidAlign(default: Paint.Align = Paint.Align.LEFT): Paint.Align = when (this) { + TextAlign.LEFT -> Paint.Align.LEFT + TextAlign.CENTER -> Paint.Align.CENTER + TextAlign.RIGHT -> Paint.Align.RIGHT + null -> default +} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/utils/Transformer.kt b/chartLib/src/main/kotlin/info/appdev/charting/utils/Transformer.kt index 9d5831ed2..add59f1aa 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/utils/Transformer.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/utils/Transformer.kt @@ -1,72 +1,22 @@ package info.appdev.charting.utils import android.annotation.SuppressLint -import android.graphics.Matrix import android.graphics.Path -import android.graphics.RectF import info.appdev.charting.data.EntryFloat import info.appdev.charting.interfaces.datasets.IBubbleDataSet import info.appdev.charting.interfaces.datasets.ICandleDataSet import info.appdev.charting.interfaces.datasets.ILineDataSet import info.appdev.charting.interfaces.datasets.IScatterDataSet -import info.appdev.charting.utils.PointD.Companion.getInstance /** * Transformer class that contains all matrices and is responsible for * transforming values into pixels on the screen and backwards. + * + * The platform-agnostic matrix/rect logic lives in the common [TransformerCore] base class + * (`chartLibCore`). This subclass adds the pieces that still depend on Android-only types + * (`android.graphics.Path`) or on the dataset family (not yet available in `commonMain`). */ -open class Transformer(protected var viewPortHandler: ViewPortHandler) { - /** - * matrix to map the values to the screen pixels - */ - var valueMatrix: Matrix = Matrix() - protected set - - /** - * matrix for handling the different offsets of the chart - */ - var offsetMatrix: Matrix = Matrix() - protected set - - /** - * Prepares the matrix that transforms values to pixels. Calculates the - * scale factors from the charts size and offsets. - */ - fun prepareMatrixValuePx(xChartMin: Float, deltaX: Float, deltaY: Float, yChartMin: Float) { - var scaleX = viewPortHandler.contentWidth() / deltaX - var scaleY = viewPortHandler.contentHeight() / deltaY - - if (scaleX.isInfinite()) { - scaleX = 0f - } - if (scaleY.isInfinite()) { - scaleY = 0f - } - - // setup all matrices - valueMatrix.reset() - valueMatrix.postTranslate(-xChartMin, -yChartMin) - valueMatrix.postScale(scaleX, -scaleY) - } - - /** - * Prepares the matrix that contains all offsets. - */ - open fun prepareMatrixOffset(inverted: Boolean) { - offsetMatrix.reset() - - // offset.postTranslate(mOffsetLeft, getHeight() - mOffsetBottom); - if (!inverted) offsetMatrix.postTranslate( - viewPortHandler.offsetLeft(), - viewPortHandler.chartHeight - viewPortHandler.offsetBottom() - ) - else { - offsetMatrix - .setTranslate(viewPortHandler.offsetLeft(), -viewPortHandler.offsetTop()) - offsetMatrix.postScale(1.0f, -1.0f) - } - } - +open class Transformer(viewPortHandler: ViewPortHandler) : TransformerCore(viewPortHandler) { protected var valuePointsForGenerateTransformedValuesScatter: FloatArray = FloatArray(1) /** @@ -215,9 +165,9 @@ open class Transformer(protected var viewPortHandler: ViewPortHandler) { * to value-touch-offset */ fun pathValueToPixel(path: Path) { - path.transform(this.valueMatrix) - path.transform(viewPortHandler.matrixTouch) - path.transform(this.offsetMatrix) + path.transform(this.valueMatrix.toAndroidMatrix()) + path.transform(viewPortHandler.matrixTouch.toAndroidMatrix()) + path.transform(this.offsetMatrix.toAndroidMatrix()) } /** @@ -225,166 +175,7 @@ open class Transformer(protected var viewPortHandler: ViewPortHandler) { */ fun pathValuesToPixel(paths: MutableList) { for (i in paths.indices) { - pathValueToPixel(paths.get(i)!!) + pathValueToPixel(paths[i]!!) } } - - /** - * Transform an array of points with all matrices. VERY IMPORTANT: Keep - * matrix order "value-touch-offset" when transforming. - */ - fun pointValuesToPixel(pts: FloatArray?) { - valueMatrix.mapPoints(pts) - viewPortHandler.matrixTouch.mapPoints(pts) - offsetMatrix.mapPoints(pts) - } - - /** - * Transform a rectangle with all matrices. - */ - fun rectValueToPixel(r: RectF?) { - valueMatrix.mapRect(r) - viewPortHandler.matrixTouch.mapRect(r) - offsetMatrix.mapRect(r) - } - - /** - * Transform a rectangle with all matrices with potential animation phases. - */ - fun rectToPixelPhase(r: RectF, phaseY: Float) { - // multiply the height of the rect with the phase - - r.top *= phaseY - r.bottom *= phaseY - - valueMatrix.mapRect(r) - viewPortHandler.matrixTouch.mapRect(r) - offsetMatrix.mapRect(r) - } - - fun rectToPixelPhaseHorizontal(r: RectF, phaseY: Float) { - // multiply the height of the rect with the phase - - r.left *= phaseY - r.right *= phaseY - - valueMatrix.mapRect(r) - viewPortHandler.matrixTouch.mapRect(r) - offsetMatrix.mapRect(r) - } - - /** - * Transform a rectangle with all matrices with potential animation phases. - */ - fun rectValueToPixelHorizontal(r: RectF?) { - valueMatrix.mapRect(r) - viewPortHandler.matrixTouch.mapRect(r) - offsetMatrix.mapRect(r) - } - - /** - * Transform a rectangle with all matrices with potential animation phases. - */ - fun rectValueToPixelHorizontal(r: RectF, phaseY: Float) { - // multiply the height of the rect with the phase - - r.left *= phaseY - r.right *= phaseY - - valueMatrix.mapRect(r) - viewPortHandler.matrixTouch.mapRect(r) - offsetMatrix.mapRect(r) - } - - /** - * transforms multiple rects with all matrices - */ - fun rectValuesToPixel(rects: MutableList) { - val m = this.valueToPixelMatrix - - for (i in rects.indices) m.mapRect(rects.get(i)) - } - - protected var mPixelToValueMatrixBuffer: Matrix = Matrix() - - /** - * Transforms the given array of touch positions (pixels) (x, y, x, y, ...) - * into values on the chart. - */ - fun pixelsToValue(pixels: FloatArray?) { - val tmp = mPixelToValueMatrixBuffer - tmp.reset() - - // invert all matrixes to convert back to the original value - offsetMatrix.invert(tmp) - tmp.mapPoints(pixels) - - viewPortHandler.matrixTouch.invert(tmp) - tmp.mapPoints(pixels) - - valueMatrix.invert(tmp) - tmp.mapPoints(pixels) - } - - /** - * buffer for performance - */ - var ptsBuffer: FloatArray = FloatArray(2) - - /** - * Returns a recyclable PointD instance. - * returns the x and y values in the chart at the given touch point - * (encapsulated in a PointD). This method transforms pixel coordinates to - * coordinates / values in the chart. This is the opposite method to - * getPixelForValues(...). - */ - fun getValuesByTouchPoint(x: Float, y: Float): PointD { - val result = getInstance(0.0, 0.0) - getValuesByTouchPoint(x, y, result) - return result - } - - fun getValuesByTouchPoint(x: Float, y: Float, outputPoint: PointD) { - ptsBuffer[0] = x - ptsBuffer[1] = y - - pixelsToValue(ptsBuffer) - - outputPoint.x = ptsBuffer[0].toDouble() - outputPoint.y = ptsBuffer[1].toDouble() - } - - /** - * Returns a recyclable PointD instance. - * Returns the x and y coordinates (pixels) for a given x and y value in the chart. - */ - fun getPixelForValues(x: Float, y: Float): PointD { - ptsBuffer[0] = x - ptsBuffer[1] = y - - pointValuesToPixel(ptsBuffer) - - val xPx = ptsBuffer[0].toDouble() - val yPx = ptsBuffer[1].toDouble() - - return getInstance(xPx, yPx) - } - - private val mMBuffer1 = Matrix() - - val valueToPixelMatrix: Matrix - get() { - mMBuffer1.set(this.valueMatrix) - mMBuffer1.postConcat(viewPortHandler.matrixTouch) - mMBuffer1.postConcat(this.offsetMatrix) - return mMBuffer1 - } - - private val mMBuffer2 = Matrix() - - val pixelToValueMatrix: Matrix - get() { - this.valueToPixelMatrix.invert(mMBuffer2) - return mMBuffer2 - } } diff --git a/chartLibCompose/build.gradle.kts b/chartLibCompose/build.gradle.kts index 04938c0a3..5146c5c2a 100644 --- a/chartLibCompose/build.gradle.kts +++ b/chartLibCompose/build.gradle.kts @@ -6,7 +6,7 @@ plugins { id("com.android.library") id("maven-publish") id("org.jetbrains.kotlin.plugin.compose") version "2.4.10" - id("com.vanniktech.maven.publish") version "0.37.0" + id("com.vanniktech.maven.publish") } android { diff --git a/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/ChartState.kt b/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/ChartState.kt index 89c93d0c1..f2421a7e5 100644 --- a/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/ChartState.kt +++ b/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/ChartState.kt @@ -7,7 +7,7 @@ import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.setValue import info.appdev.charting.charts.CombinedChart -import info.appdev.charting.charts.ScatterChart +import info.appdev.charting.charts.ScatterShape import info.appdev.charting.data.* import info.appdev.charting.highlight.Highlight @@ -164,7 +164,7 @@ class RadarChartState : ChartState() { */ @Stable class ScatterChartState : ChartState() { - var scaleType by mutableStateOf(ScatterChart.ScatterShape.CIRCLE) + var scaleType by mutableStateOf(ScatterShape.CIRCLE) } /** diff --git a/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/examples/ChartExamples.kt b/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/examples/ChartExamples.kt index 7575aece6..49857dd76 100644 --- a/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/examples/ChartExamples.kt +++ b/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/examples/ChartExamples.kt @@ -22,7 +22,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import info.appdev.charting.charts.ScatterChart +import info.appdev.charting.charts.ScatterShape import info.appdev.charting.components.Legend import info.appdev.charting.components.XAxis import info.appdev.charting.compose.BarChart @@ -45,6 +45,7 @@ import info.appdev.charting.data.RadarDataSet import info.appdev.charting.data.RadarEntryFloat import info.appdev.charting.data.ScatterData import info.appdev.charting.data.ScatterDataSet +import info.appdev.charting.data.setScatterShape /** * Example composable demonstrating all chart types in Compose. @@ -255,7 +256,7 @@ fun ScatterChartExample() { val dataSet = remember(entries) { ScatterDataSet(entries.toMutableList(), "Data Points").apply { color = android.graphics.Color.rgb(255, 87, 34) - setScatterShape(ScatterChart.ScatterShape.CIRCLE) + setScatterShape(ScatterShape.CIRCLE) scatterShapeSize = 12f isDrawValues = false } diff --git a/chartLibComposeMultiplatform/build.gradle.kts b/chartLibComposeMultiplatform/build.gradle.kts new file mode 100644 index 000000000..39e9472b6 --- /dev/null +++ b/chartLibComposeMultiplatform/build.gradle.kts @@ -0,0 +1,106 @@ +import info.git.versionHelper.getVersionText +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import java.net.URI + +plugins { + id("com.android.kotlin.multiplatform.library") + id("org.jetbrains.kotlin.multiplatform") + id("org.jetbrains.compose") version "1.12.1" + id("org.jetbrains.kotlin.plugin.compose") version "2.4.10" + id("maven-publish") + id("com.vanniktech.maven.publish") +} + +kotlin { + compilerOptions { + freeCompilerArgs.add("-Xexpect-actual-classes") + } + + android { + namespace = "info.appdev.charting.compose.multiplatform" + compileSdk = 37 + minSdk = 23 + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + withHostTest {} + } + + jvm("desktop") + + // Compose Multiplatform (as of 1.12.x) no longer publishes iosX64 (Intel simulator) + // artifacts for compose.ui/compose.foundation/compose.runtime, so unlike chartLibCore + // this module only targets the two arm64 iOS targets. + val xcfName = "chartLibComposeMultiplatformKit" + listOf( + iosArm64(), + iosSimulatorArm64() + ).forEach { iosTarget -> + iosTarget.binaries.framework { + baseName = xcfName + } + } + + sourceSets { + commonMain.dependencies { + api(project(":chartLibCore")) + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.ui) + } + commonTest.dependencies { + implementation(kotlin("test")) + } + } +} + +group = project.findProperty("group")?.toString() ?: "info.AppDevNext" +var versionVersion = getVersionText() +println("Build version $versionVersion") + +mavenPublishing { + coordinates( + groupId = project.findProperty("group")?.toString() ?: "info.AppDevNext", + artifactId = "chartLibComposeMultiplatform", + version = "$versionVersion" + ) + pom { + name = "Android Chart compose multiplatform" + description = + "Kotlin/Compose Multiplatform chart renderers/gesture-handling for AndroidChart, built on the portable chartLibCore data model, shared across Android, iOS and Desktop" + inceptionYear = "2022" + url = "https://github.com/AppDevNext/AndroidChart/" + licenses { + license { + name = "The Apache License, Version 2.0" + url = "http://www.apache.org/licenses/LICENSE-2.0.txt" + distribution = "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + } + developers { + developer { + id = "AppDevNext" + name = "AppDevNext" + url = "https://github.com/AppDevNext/" + } + } + scm { + url = "https://github.com/AppDevNext/AndroidChart/" + connection = "scm:git:git://github.com/AppDevNext/AndroidChart.git" + developerConnection = "scm:git:ssh://git@github.com/AppDevNext/AndroidChart.git" + } + } + + // Github packages + repositories { + maven { + version = "$versionVersion-SNAPSHOT" + name = "GitHubPackages" + url = URI("https://maven.pkg.github.com/AppDevNext/AndroidChart") + credentials { + username = System.getenv("GITHUBACTOR") + password = System.getenv("GITHUBTOKEN") + } + } + } +} diff --git a/chartLibComposeMultiplatform/gradle.properties b/chartLibComposeMultiplatform/gradle.properties new file mode 100644 index 000000000..12f98d6fc --- /dev/null +++ b/chartLibComposeMultiplatform/gradle.properties @@ -0,0 +1,6 @@ +# Overrides the root-level POM_ARTIFACT_ID=chart for this Kotlin Multiplatform module. +# +# See chartLibCore/gradle.properties for the detailed explanation: the vanniktech plugin's +# automatic pomFromGradleProperties() must agree with our mavenPublishing { coordinates(...) } +# call in build.gradle.kts, otherwise the second per-target artifactId rename fails. +POM_ARTIFACT_ID=chartLibComposeMultiplatform diff --git a/chartLibComposeMultiplatform/src/commonMain/kotlin/info/appdev/charting/compose/multiplatform/gesture/GestureHandling.kt b/chartLibComposeMultiplatform/src/commonMain/kotlin/info/appdev/charting/compose/multiplatform/gesture/GestureHandling.kt new file mode 100644 index 000000000..87a059cf9 --- /dev/null +++ b/chartLibComposeMultiplatform/src/commonMain/kotlin/info/appdev/charting/compose/multiplatform/gesture/GestureHandling.kt @@ -0,0 +1,93 @@ +package info.appdev.charting.compose.multiplatform.gesture + +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.pointerInput +import info.appdev.charting.utils.Matrix +import info.appdev.charting.utils.ViewPortHandler + +/** + * Proof-of-concept Compose Multiplatform gesture handling (Step A.8): drives + * [ViewPortHandler]'s existing pan/zoom matrix math from Compose's + * `detectTransformGestures`, replacing `chartLib`'s `MotionEvent`-based + * `ChartTouchListener`/`BarLineChartTouchListener`. + * + * `ViewPortHandler.zoom`/`translate`/`refresh` (and the bounds-clamping in + * `limitTransAndScale`) already live in `chartLibCore` and needed no changes for this + * proof-of-concept: Compose's `detectTransformGestures` conveniently reports one combined + * pan+zoom+centroid callback per frame (unlike the touch listener's separate + * `performDrag`/`performZoom`), so this module only needs to combine the two into a single + * matrix update and feed it to [ViewPortHandler.refresh]. + * + * **Deliberately out of scope for this proof-of-concept** (same scoping choice as the earlier + * axis/data renderer slices): rotation gestures, independent X/Y-only zoom modes + * (`X_ZOOM`/`Y_ZOOM`), highlight-on-drag, double-tap zoom, fling/deceleration, and + * `OnChartGestureListener` callbacks. + */ + +/** + * Computes the pixel-space touch matrix that results from applying [pan] (in pixels) and + * [zoom] (a uniform scale factor, pivoting on [centroid]) to [viewPortHandler]'s current + * touch matrix. Extracted as a pure function, separate from the gesture-detection wiring + * itself, so the matrix math can be unit-tested without needing a Compose UI test harness. + * + * Mirrors `chartLib`'s `BarLineChartTouchListener.performDrag`/`performZoom` combined into a + * single step: `matrix.postTranslate(pan.x, pan.y)` then + * `matrix.postScale(zoom, zoom, centroid.x, centroid.y)`, applied on top of the current + * `matrixTouch` (not a "saved" matrix from gesture-start, since Compose already reports + * incremental per-frame pan/zoom deltas rather than cumulative ones since gesture-start). + */ +fun chartGestureMatrix( + viewPortHandler: ViewPortHandler, + pan: Offset, + zoom: Float, + centroid: Offset, + scaleXEnabled: Boolean = true, + scaleYEnabled: Boolean = true, + dragXEnabled: Boolean = true, + dragYEnabled: Boolean = true +): Matrix { + val matrix = Matrix() + matrix.set(viewPortHandler.matrixTouch) + + val dx = if (dragXEnabled) pan.x else 0f + val dy = if (dragYEnabled) pan.y else 0f + matrix.postTranslate(dx, dy) + + val scaleX = if (scaleXEnabled) zoom else 1f + val scaleY = if (scaleYEnabled) zoom else 1f + if (scaleX != 1f || scaleY != 1f) { + matrix.postScale(scaleX, scaleY, centroid.x, centroid.y) + } + + return matrix +} + +/** + * [Modifier] that recognizes pan/pinch-zoom gestures and applies them to [viewPortHandler] + * via [chartGestureMatrix] + [ViewPortHandler.refresh], invoking [onGesture] afterwards + * (typically to trigger a Compose recomposition/redraw of the chart). + */ +fun Modifier.chartTransformGestures( + viewPortHandler: ViewPortHandler, + scaleXEnabled: Boolean = true, + scaleYEnabled: Boolean = true, + dragXEnabled: Boolean = true, + dragYEnabled: Boolean = true, + onGesture: () -> Unit +): Modifier = this.pointerInput(viewPortHandler) { + detectTransformGestures { centroid, pan, zoom, _ -> + val matrix = chartGestureMatrix( + viewPortHandler = viewPortHandler, + pan = pan, + zoom = zoom, + centroid = centroid, + scaleXEnabled = scaleXEnabled, + scaleYEnabled = scaleYEnabled, + dragXEnabled = dragXEnabled, + dragYEnabled = dragYEnabled + ) + viewPortHandler.refresh(matrix, onGesture, true) + } +} diff --git a/chartLibComposeMultiplatform/src/commonMain/kotlin/info/appdev/charting/compose/multiplatform/renderer/AxisGridRenderer.kt b/chartLibComposeMultiplatform/src/commonMain/kotlin/info/appdev/charting/compose/multiplatform/renderer/AxisGridRenderer.kt new file mode 100644 index 000000000..048a02fcf --- /dev/null +++ b/chartLibComposeMultiplatform/src/commonMain/kotlin/info/appdev/charting/compose/multiplatform/renderer/AxisGridRenderer.kt @@ -0,0 +1,111 @@ +package info.appdev.charting.compose.multiplatform.renderer + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.clipRect +import info.appdev.charting.components.XAxis +import info.appdev.charting.components.YAxis +import info.appdev.charting.utils.TransformerCore +import info.appdev.charting.utils.ViewPortHandler + +/** + * Proof-of-concept Compose Multiplatform renderer (Step A.7): draws the X-axis vertical + * grid lines using only portable `chartLibCore` types ([XAxis], [ViewPortHandler], + * [TransformerCore]), rendering through Compose's [DrawScope] instead of + * `android.graphics.Canvas`. + * + * Mirrors the pixel math of `chartLib`'s `XAxisRenderer.renderGridLines`/`drawGridLine`: + * one vertical line per axis entry (or specific position), transformed from axis-value + * space to pixel space and clipped to the viewport's content rect. + */ +/** + * Computes the pixel-space vertical grid line x-positions for [xAxis] via [transformer] + * (axis-value space -> pixel space). Extracted as a pure function, separate from the + * [DrawScope] drawing itself, so the coordinate math can be unit-tested without needing + * a Compose UI test harness. + */ +fun xAxisGridPixelPositions(xAxis: XAxis, transformer: TransformerCore): FloatArray { + val values = if (xAxis.isShowSpecificPositions) xAxis.specificPositions else xAxis.entries + val positions = FloatArray(values.size * 2) + for (i in values.indices) { + positions[i * 2] = values[i] + positions[i * 2 + 1] = values[i] + } + transformer.pointValuesToPixel(positions) + return positions +} + +fun DrawScope.drawXAxisGridLines( + xAxis: XAxis, + viewPortHandler: ViewPortHandler, + transformer: TransformerCore +) { + if (!xAxis.isDrawGridLines || !xAxis.isEnabled) return + + val positions = xAxisGridPixelPositions(xAxis, transformer) + + val clip = viewPortHandler.contentRect + val color = Color(xAxis.gridColor) + clipRect(clip.left, clip.top, clip.right, clip.bottom) { + var i = 0 + while (i < positions.size) { + val x = positions[i] + drawLine( + color = color, + start = Offset(x, viewPortHandler.contentBottom()), + end = Offset(x, viewPortHandler.contentTop()), + strokeWidth = xAxis.gridLineWidth + ) + i += 2 + } + } +} + +/** + * Computes the pixel-space horizontal grid line y-positions for [yAxis] via [transformer] + * (axis-value space -> pixel space). Extracted as a pure function for the same testability + * reason as [xAxisGridPixelPositions]. + */ +fun yAxisGridPixelPositions(yAxis: YAxis, transformer: TransformerCore): FloatArray { + val values = if (yAxis.isShowSpecificPositions) yAxis.specificPositions else yAxis.entries + val positions = FloatArray(values.size * 2) + for (i in values.indices) { + // only fill y values, x values are not needed for y grid lines + positions[i * 2 + 1] = values[i] + } + transformer.pointValuesToPixel(positions) + return positions +} + +/** + * Draws the Y-axis horizontal grid lines, mirroring `chartLib`'s + * `YAxisRenderer.renderGridLines`/`linePath`/`transformedPositions`: one horizontal line + * per axis entry (or specific position), transformed from axis-value space to pixel space + * and clipped to the viewport's content rect. + */ +fun DrawScope.drawYAxisGridLines( + yAxis: YAxis, + viewPortHandler: ViewPortHandler, + transformer: TransformerCore +) { + if (!yAxis.isEnabled || !yAxis.isDrawGridLines) return + + val positions = yAxisGridPixelPositions(yAxis, transformer) + + val clip = viewPortHandler.contentRect + val color = Color(yAxis.gridColor) + clipRect(clip.left, clip.top, clip.right, clip.bottom) { + var i = 0 + while (i < positions.size) { + val y = positions[i + 1] + drawLine( + color = color, + start = Offset(viewPortHandler.offsetLeft(), y), + end = Offset(viewPortHandler.contentRight(), y), + strokeWidth = yAxis.gridLineWidth + ) + i += 2 + } + } +} diff --git a/chartLibComposeMultiplatform/src/commonMain/kotlin/info/appdev/charting/compose/multiplatform/renderer/BarChartRenderer.kt b/chartLibComposeMultiplatform/src/commonMain/kotlin/info/appdev/charting/compose/multiplatform/renderer/BarChartRenderer.kt new file mode 100644 index 000000000..2f2cc7ec3 --- /dev/null +++ b/chartLibComposeMultiplatform/src/commonMain/kotlin/info/appdev/charting/compose/multiplatform/renderer/BarChartRenderer.kt @@ -0,0 +1,83 @@ +package info.appdev.charting.compose.multiplatform.renderer + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import info.appdev.charting.interfaces.datasets.IBarDataSet +import info.appdev.charting.utils.TransformerCore + +/** + * Proof-of-concept Compose Multiplatform data renderer (Step A.7): draws a single, non-stacked + * [IBarDataSet] using only portable `chartLibCore` types, rendering through Compose's + * [DrawScope] instead of `android.graphics.Canvas`/`BarBuffer`. + * + * Mirrors the non-stacked-bar branch of `chartLib`'s `buffer.BarBuffer.feed`: for every entry, + * a `[left, top, right, bottom]` value-space rect is built from the entry's `x`/`y` and the + * dataset's bar width, then mapped to pixel space via [TransformerCore.pointValuesToPixel]. + * Stacked bars, animation phases, and bar borders/shadows are intentionally out of scope for + * this proof-of-concept slice (same scoping choice as the earlier grid-line renderer). + */ + +/** + * Computes the pixel-space bar rects (four floats per entry: left, top, right, bottom) for + * every non-stacked entry of [dataSet]. Extracted as a pure function, separate from the + * [DrawScope] drawing itself, so the coordinate math can be unit-tested without needing a + * Compose UI test harness. + */ +fun barPixelRects(dataSet: IBarDataSet, barWidth: Float, transformer: TransformerCore): FloatArray { + val barWidthHalf = barWidth / 2f + val positions = FloatArray(dataSet.entryCount * 4) + + for (i in 0 until dataSet.entryCount) { + val entry = dataSet.getEntryForIndex(i) ?: continue + val x = entry.x + val y = entry.y + + val top = if (y >= 0) y else 0f + val bottom = if (y <= 0) y else 0f + + val offset = i * 4 + positions[offset] = x - barWidthHalf + positions[offset + 1] = top + positions[offset + 2] = x + barWidthHalf + positions[offset + 3] = bottom + } + + // pointValuesToPixel maps (x, y) pairs, so each [left, top, right, bottom] rect is + // transformed as its two corner points: (left, top) and (right, bottom). + transformer.pointValuesToPixel(positions) + return positions +} + +/** + * Draws every non-stacked entry of [dataSet] as a filled rectangle, cycling through the + * dataset's colors the same way `chartLib`'s `BarChartRenderer.drawDataSet` does via + * `dataSet.getColorByIndex(index)`. + */ +fun DrawScope.drawBarChartDataSet( + dataSet: IBarDataSet, + barWidth: Float, + transformer: TransformerCore +) { + val positions = barPixelRects(dataSet, barWidth, transformer) + + var entryIndex = 0 + var i = 0 + while (i < positions.size) { + val left = positions[i] + val top = positions[i + 1] + val right = positions[i + 2] + val bottom = positions[i + 3] + + val color = Color(dataSet.getColorByIndex(entryIndex)) + drawRect( + color = color, + topLeft = Offset(left, top), + size = Size(right - left, bottom - top) + ) + + i += 4 + entryIndex++ + } +} diff --git a/chartLibComposeMultiplatform/src/commonTest/kotlin/info/appdev/charting/compose/multiplatform/gesture/GestureHandlingTest.kt b/chartLibComposeMultiplatform/src/commonTest/kotlin/info/appdev/charting/compose/multiplatform/gesture/GestureHandlingTest.kt new file mode 100644 index 000000000..f7f6f1f27 --- /dev/null +++ b/chartLibComposeMultiplatform/src/commonTest/kotlin/info/appdev/charting/compose/multiplatform/gesture/GestureHandlingTest.kt @@ -0,0 +1,84 @@ +package info.appdev.charting.compose.multiplatform.gesture + +import androidx.compose.ui.geometry.Offset +import info.appdev.charting.utils.Matrix +import info.appdev.charting.utils.ViewPortHandler +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Verifies the pure matrix-math helper behind the Step A.8 proof-of-concept gesture handling, + * without needing a Compose UI test harness (no real pointer/gesture input is simulated here, + * only [chartGestureMatrix]'s pan/zoom-to-matrix math). + */ +class GestureHandlingTest { + + private fun setUpViewPortHandler(): ViewPortHandler { + val viewPortHandler = ViewPortHandler() + viewPortHandler.setChartDimens(100f, 100f) + viewPortHandler.restrainViewPort(0f, 0f, 0f, 0f) + return viewPortHandler + } + + @Test + fun chartGestureMatrix_appliesPanTranslationOnTopOfIdentityMatrix() { + val viewPortHandler = setUpViewPortHandler() + + val matrix = chartGestureMatrix( + viewPortHandler = viewPortHandler, + pan = Offset(10f, 5f), + zoom = 1f, + centroid = Offset(50f, 50f) + ) + + val values = FloatArray(9) + matrix.getValues(values) + assertEquals(10f, values[Matrix.MTRANS_X], absoluteTolerance = 0.001f) + assertEquals(5f, values[Matrix.MTRANS_Y], absoluteTolerance = 0.001f) + assertEquals(1f, values[Matrix.MSCALE_X], absoluteTolerance = 0.001f) + assertEquals(1f, values[Matrix.MSCALE_Y], absoluteTolerance = 0.001f) + } + + @Test + fun chartGestureMatrix_appliesZoomAboutCentroidOnTopOfIdentityMatrix() { + val viewPortHandler = setUpViewPortHandler() + + val matrix = chartGestureMatrix( + viewPortHandler = viewPortHandler, + pan = Offset.Zero, + zoom = 2f, + centroid = Offset(50f, 50f) + ) + + val values = FloatArray(9) + matrix.getValues(values) + assertEquals(2f, values[Matrix.MSCALE_X], absoluteTolerance = 0.001f) + assertEquals(2f, values[Matrix.MSCALE_Y], absoluteTolerance = 0.001f) + // pivot (50, 50) scaled by 2 -> translation = pivot * (1 - scale) + assertEquals(-50f, values[Matrix.MTRANS_X], absoluteTolerance = 0.001f) + assertEquals(-50f, values[Matrix.MTRANS_Y], absoluteTolerance = 0.001f) + } + + @Test + fun chartGestureMatrix_disabledAxesIgnorePanAndZoomOnThatAxis() { + val viewPortHandler = setUpViewPortHandler() + + val matrix = chartGestureMatrix( + viewPortHandler = viewPortHandler, + pan = Offset(10f, 5f), + zoom = 2f, + centroid = Offset(50f, 50f), + scaleXEnabled = false, + dragYEnabled = false + ) + + val values = FloatArray(9) + matrix.getValues(values) + // X scale disabled -> stays 1; Y drag disabled -> stays 0 + assertEquals(1f, values[Matrix.MSCALE_X], absoluteTolerance = 0.001f) + assertEquals(2f, values[Matrix.MSCALE_Y], absoluteTolerance = 0.001f) + assertEquals(10f, values[Matrix.MTRANS_X], absoluteTolerance = 0.001f) + // y-translation comes only from the Y-scale pivot, since dragY is disabled + assertEquals(-50f, values[Matrix.MTRANS_Y], absoluteTolerance = 0.001f) + } +} diff --git a/chartLibComposeMultiplatform/src/commonTest/kotlin/info/appdev/charting/compose/multiplatform/renderer/AxisGridRendererTest.kt b/chartLibComposeMultiplatform/src/commonTest/kotlin/info/appdev/charting/compose/multiplatform/renderer/AxisGridRendererTest.kt new file mode 100644 index 000000000..c30c9c491 --- /dev/null +++ b/chartLibComposeMultiplatform/src/commonTest/kotlin/info/appdev/charting/compose/multiplatform/renderer/AxisGridRendererTest.kt @@ -0,0 +1,67 @@ +package info.appdev.charting.compose.multiplatform.renderer + +import info.appdev.charting.components.XAxis +import info.appdev.charting.components.YAxis +import info.appdev.charting.utils.TransformerCore +import info.appdev.charting.utils.ViewPortHandler +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Verifies the pure pixel-math helpers behind the Step A.7 proof-of-concept grid line + * renderer, without needing a Compose UI test harness: a 100x100 content rect mapping the + * axis-value range [0, 10] should place value 0 at the left/bottom edge and value 10 at the + * right/top edge, matching `chartLib`'s `XAxisRenderer`/`YAxisRenderer` pixel math. + */ +class AxisGridRendererTest { + + private fun setUpViewPortAndTransformer(): Pair { + val viewPortHandler = ViewPortHandler() + viewPortHandler.setChartDimens(100f, 100f) + viewPortHandler.restrainViewPort(0f, 0f, 0f, 0f) + + val transformer = TransformerCore(viewPortHandler) + transformer.prepareMatrixValuePx(xChartMin = 0f, deltaX = 10f, deltaY = 10f, yChartMin = 0f) + transformer.prepareMatrixOffset(inverted = false) + return viewPortHandler to transformer + } + + @Test + fun xAxisGridPixelPositions_mapsAxisValuesToContentRectPixels() { + val (viewPortHandler, transformer) = setUpViewPortAndTransformer() + + val xAxis = XAxis().apply { + entries = floatArrayOf(0f, 10f) + } + + val positions = xAxisGridPixelPositions(xAxis, transformer) + + assertEquals(4, positions.size) + assertEquals(viewPortHandler.contentLeft(), positions[0], absoluteTolerance = 0.001f) + assertEquals(viewPortHandler.contentRight(), positions[2], absoluteTolerance = 0.001f) + } + + @Test + fun yAxisGridPixelPositions_mapsAxisValuesToContentRectPixels() { + val (viewPortHandler, transformer) = setUpViewPortAndTransformer() + + val yAxis = YAxis().apply { + entries = floatArrayOf(0f, 10f) + } + + val positions = yAxisGridPixelPositions(yAxis, transformer) + + assertEquals(4, positions.size) + // axis value 0 maps to the bottom of the content rect, value 10 to the top + // (y-pixel axis is inverted relative to chart-value axis). + assertEquals(viewPortHandler.contentBottom(), positions[1], absoluteTolerance = 0.001f) + assertEquals(viewPortHandler.contentTop(), positions[3], absoluteTolerance = 0.001f) + } + + private fun assertEquals(expected: Float, actual: Float, absoluteTolerance: Float) { + kotlin.test.assertTrue( + kotlin.math.abs(expected - actual) <= absoluteTolerance, + "expected=$expected actual=$actual" + ) + } +} diff --git a/chartLibComposeMultiplatform/src/commonTest/kotlin/info/appdev/charting/compose/multiplatform/renderer/BarChartRendererTest.kt b/chartLibComposeMultiplatform/src/commonTest/kotlin/info/appdev/charting/compose/multiplatform/renderer/BarChartRendererTest.kt new file mode 100644 index 000000000..2eb95bc2a --- /dev/null +++ b/chartLibComposeMultiplatform/src/commonTest/kotlin/info/appdev/charting/compose/multiplatform/renderer/BarChartRendererTest.kt @@ -0,0 +1,61 @@ +package info.appdev.charting.compose.multiplatform.renderer + +import info.appdev.charting.data.BarDataSet +import info.appdev.charting.data.BarEntryFloat +import info.appdev.charting.utils.TransformerCore +import info.appdev.charting.utils.ViewPortHandler +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Verifies the pure pixel-math helper behind the Step A.7 proof-of-concept bar chart data + * renderer, without needing a Compose UI test harness: a 100x100 content rect mapping the + * axis-value range [0, 10] on both axes should place a bar's value-space rect at the expected + * pixel-space rect, matching the non-stacked branch of `chartLib`'s `buffer.BarBuffer.feed`. + */ +class BarChartRendererTest { + + private fun setUpViewPortAndTransformer(): Pair { + val viewPortHandler = ViewPortHandler() + viewPortHandler.setChartDimens(100f, 100f) + viewPortHandler.restrainViewPort(0f, 0f, 0f, 0f) + + val transformer = TransformerCore(viewPortHandler) + transformer.prepareMatrixValuePx(xChartMin = 0f, deltaX = 10f, deltaY = 10f, yChartMin = 0f) + transformer.prepareMatrixOffset(inverted = false) + return viewPortHandler to transformer + } + + @Test + fun barPixelRects_mapsPositiveEntryValueRectToContentRectPixels() { + val (viewPortHandler, transformer) = setUpViewPortAndTransformer() + + val dataSet = BarDataSet(mutableListOf(BarEntryFloat(5f, 10f)), "positive") + + val positions = barPixelRects(dataSet, barWidth = 2f, transformer = transformer) + + assertEquals(4, positions.size) + // value-space rect: left=4, top=10, right=6, bottom=0 + assertEquals(viewPortHandler.contentLeft() + 40f, positions[0], absoluteTolerance = 0.001f) // left + assertEquals(viewPortHandler.contentTop(), positions[1], absoluteTolerance = 0.001f) // top (value 10 -> chart top) + assertEquals(viewPortHandler.contentLeft() + 60f, positions[2], absoluteTolerance = 0.001f) // right + assertEquals(viewPortHandler.contentBottom(), positions[3], absoluteTolerance = 0.001f) // bottom (value 0 -> chart bottom) + } + + @Test + fun barPixelRects_mapsNegativeEntryValueRectToContentRectPixels() { + val (viewPortHandler, transformer) = setUpViewPortAndTransformer() + + // Prepare a matrix spanning [-10, 10] so a negative entry stays within the viewport. + transformer.prepareMatrixValuePx(xChartMin = -10f, deltaX = 20f, deltaY = 20f, yChartMin = -10f) + transformer.prepareMatrixOffset(inverted = false) + + val dataSet = BarDataSet(mutableListOf(BarEntryFloat(0f, -5f)), "negative") + + val positions = barPixelRects(dataSet, barWidth = 2f, transformer = transformer) + + assertEquals(4, positions.size) + // value-space rect: top=0, bottom=-5 -> pixel top must stay above (numerically less than) pixel bottom + assertEquals(true, positions[1] < positions[3]) + } +} diff --git a/chartLibCore/build.gradle.kts b/chartLibCore/build.gradle.kts new file mode 100644 index 000000000..e811e86e2 --- /dev/null +++ b/chartLibCore/build.gradle.kts @@ -0,0 +1,96 @@ +import info.git.versionHelper.getVersionText +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import java.net.URI + +plugins { + id("com.android.kotlin.multiplatform.library") + id("org.jetbrains.kotlin.multiplatform") + id("maven-publish") + id("com.vanniktech.maven.publish") +} + +kotlin { + compilerOptions { + freeCompilerArgs.add("-Xexpect-actual-classes") + } + + android { + namespace = "info.appdev.charting.core" + compileSdk = 36 + minSdk = 23 + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + withHostTest {} + } + + jvm("desktop") + + val xcfName = "chartLibCoreKit" + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64() + ).forEach { iosTarget -> + iosTarget.binaries.framework { + baseName = xcfName + } + } + + sourceSets { + commonTest.dependencies { + implementation(kotlin("test")) + } + } +} + +group = project.findProperty("group")?.toString() ?: "info.AppDevNext" +var versionVersion = getVersionText() +println("Build version $versionVersion") + +mavenPublishing { + coordinates( + groupId = project.findProperty("group")?.toString() ?: "info.AppDevNext", + artifactId = "chartLibCore", + version = "$versionVersion" + ) + pom { + name = "Android Chart core" + description = + "Kotlin Multiplatform core module of AndroidChart: platform-agnostic chart data model, math and formatters shared across chartLib (Android View), chartLibComposeMultiplatform (Compose Multiplatform), iOS and Desktop" + inceptionYear = "2022" + url = "https://github.com/AppDevNext/AndroidChart/" + licenses { + license { + name = "The Apache License, Version 2.0" + url = "http://www.apache.org/licenses/LICENSE-2.0.txt" + distribution = "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + } + developers { + developer { + id = "AppDevNext" + name = "AppDevNext" + url = "https://github.com/AppDevNext/" + } + } + scm { + url = "https://github.com/AppDevNext/AndroidChart/" + connection = "scm:git:git://github.com/AppDevNext/AndroidChart.git" + developerConnection = "scm:git:ssh://git@github.com/AppDevNext/AndroidChart.git" + } + } + + // Github packages + repositories { + maven { + version = "$versionVersion-SNAPSHOT" + name = "GitHubPackages" + url = URI("https://maven.pkg.github.com/AppDevNext/AndroidChart") + credentials { + username = System.getenv("GITHUBACTOR") + password = System.getenv("GITHUBTOKEN") + } + } + } +} diff --git a/chartLibCore/gradle.properties b/chartLibCore/gradle.properties new file mode 100644 index 000000000..752ae4ae7 --- /dev/null +++ b/chartLibCore/gradle.properties @@ -0,0 +1,10 @@ +# Overrides the root-level POM_ARTIFACT_ID=chart for this Kotlin Multiplatform module. +# +# com.vanniktech.maven.publish's MavenPublishPlugin automatically calls +# pomFromGradleProperties() at apply-time, which eagerly renames every per-target KMP +# publication (android/desktop/iosArm64/...) using the root POM_ARTIFACT_ID. Our own +# mavenPublishing { coordinates(...) } call in build.gradle.kts then runs afterwards and +# tries to rename the *already renamed* publications, which fails because they no longer +# start with the expected "chartLibCore-" prefix. Overriding POM_ARTIFACT_ID here makes +# both passes agree, so the second (redundant) rename is a no-op. +POM_ARTIFACT_ID=chartLibCore diff --git a/chartLibCore/src/androidMain/kotlin/info/appdev/charting/utils/ChartIcon.android.kt b/chartLibCore/src/androidMain/kotlin/info/appdev/charting/utils/ChartIcon.android.kt new file mode 100644 index 000000000..6c4b02991 --- /dev/null +++ b/chartLibCore/src/androidMain/kotlin/info/appdev/charting/utils/ChartIcon.android.kt @@ -0,0 +1,8 @@ +package info.appdev.charting.utils + +/** + * On Android, [ChartIcon] is literally an `android.graphics.drawable.Drawable`, so all + * existing renderer/consumer code using `entry.icon` as a `Drawable` continues to compile + * and behave exactly as before this abstraction was introduced. + */ +actual typealias ChartIcon = android.graphics.drawable.Drawable diff --git a/chartLibCore/src/androidMain/kotlin/info/appdev/charting/utils/ChartTypeface.android.kt b/chartLibCore/src/androidMain/kotlin/info/appdev/charting/utils/ChartTypeface.android.kt new file mode 100644 index 000000000..80d2735b7 --- /dev/null +++ b/chartLibCore/src/androidMain/kotlin/info/appdev/charting/utils/ChartTypeface.android.kt @@ -0,0 +1,3 @@ +package info.appdev.charting.utils + +actual typealias ChartTypeface = android.graphics.Typeface diff --git a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/charts/ScatterShape.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/charts/ScatterShape.kt new file mode 100644 index 000000000..cf837071e --- /dev/null +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/charts/ScatterShape.kt @@ -0,0 +1,36 @@ +package info.appdev.charting.charts + +/** + * Predefined ScatterShapes that allow the specification of a shape a ScatterDataSet should be drawn with. + * If a ScatterShape is specified for a ScatterDataSet, the required renderer is set. + * + * Extracted out of the (Android-only) `ScatterChart` class so that `ScatterDataSet.setScatterShape(...)` + * can reference it from portable code; previously accessed as the nested `ScatterChart.ScatterShape`, + * now a top-level type in the same package, so call sites use `ScatterShape.SQUARE` etc. directly. + */ +enum class ScatterShape(private val shapeIdentifier: String) { + SQUARE("SQUARE"), + CIRCLE("CIRCLE"), + TRIANGLE("TRIANGLE"), + CROSS("CROSS"), + X("X"), + CHEVRON_UP("CHEVRON_UP"), + CHEVRON_DOWN("CHEVRON_DOWN"); + + override fun toString(): String { + return shapeIdentifier + } + + companion object { + val allDefaultShapes: Array + get() = arrayOf( + SQUARE, + CIRCLE, + TRIANGLE, + CROSS, + X, + CHEVRON_UP, + CHEVRON_DOWN + ) + } +} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/components/AxisBase.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/AxisBase.kt similarity index 91% rename from chartLib/src/main/kotlin/info/appdev/charting/components/AxisBase.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/AxisBase.kt index 19e7f2b1d..ff2e78c01 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/components/AxisBase.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/AxisBase.kt @@ -1,13 +1,10 @@ package info.appdev.charting.components -import android.graphics.Color -import android.graphics.DashPathEffect -import android.graphics.Paint -import androidx.annotation.ColorInt import info.appdev.charting.formatter.DefaultAxisValueFormatter import info.appdev.charting.formatter.IAxisValueFormatter +import info.appdev.charting.utils.ColorTemplate +import info.appdev.charting.utils.DashEffect import info.appdev.charting.utils.convertDpToPixel -import timber.log.Timber import kotlin.math.abs /** @@ -23,16 +20,14 @@ abstract class AxisBase : ComponentBase() { * The color of the grid lines for this axis (the horizontal lines * coming from each label). */ - @ColorInt - var gridColor = Color.GRAY + var gridColor = ColorTemplate.GRAY private var mGridLineWidth = 1f /** * Returns the color of the axis line (line alongside the axis). */ - @ColorInt - var axisLineColor = Color.GRAY + var axisLineColor = ColorTemplate.GRAY private var mAxisLineWidth = 1f @@ -105,13 +100,13 @@ abstract class AxisBase : ComponentBase() { /** * the path effect of the axis line that makes dashed lines possible */ - var axisLineDashPathEffect: DashPathEffect? = null + var axisLineDashPathEffect: DashEffect? = null private set /** * the path effect of the grid lines that makes dashed lines possible */ - var gridDashPathEffect: DashPathEffect? = null + var gridDashPathEffect: DashEffect? = null private set /** @@ -297,7 +292,7 @@ abstract class AxisBase : ComponentBase() { limitLines.add(limitLine) if (limitLines.size > 6) { - Timber.e("Warning! You have more than 6 LimitLines on your axis, do you really want that?") + println("Warning! You have more than 6 LimitLines on your axis, do you really want that?") } } @@ -308,7 +303,7 @@ abstract class AxisBase : ComponentBase() { limitRanges.add(l) if (limitRanges.size > 6) { - Timber.e("Warning! You have more than 6 LimitLines on your axis, do you really want that?") + println("Warning! You have more than 6 LimitLines on your axis, do you really want that?") } } @@ -357,31 +352,6 @@ abstract class AxisBase : ComponentBase() { return longest!! } - /** - * Returns the longest formatted label (in terms of px), this axis - * contains. - * If paint is null, then returns the longest formatted label (in terms of characters), this axis contains. - */ - fun getLongestLabel(p: Paint?): String { - if (p == null) { - return this.longestLabel - } - var longest: String? = "" - val max = 0f - - for (i in entries.indices) { - val text = getFormattedLabel(i) - if (text != null) { - val width = p.measureText(text) - if (max < width) { - longest = text - } - } - } - - return longest!! - } - fun getFormattedLabel(index: Int): String? { return if (index < 0 || index >= entries.size) "" @@ -421,7 +391,7 @@ abstract class AxisBase : ComponentBase() { * @param phase offset, in degrees (normally, use 0) */ fun enableGridDashedLine(lineLength: Float, spaceLength: Float, phase: Float) { - this.gridDashPathEffect = DashPathEffect( + this.gridDashPathEffect = DashEffect( floatArrayOf( lineLength, spaceLength ), phase @@ -435,7 +405,7 @@ abstract class AxisBase : ComponentBase() { * * @param effect the DashPathEffect */ - fun setGridDashedLine(effect: DashPathEffect?) { + fun setGridDashedLine(effect: DashEffect?) { this.gridDashPathEffect = effect } @@ -462,7 +432,7 @@ abstract class AxisBase : ComponentBase() { * @param phase offset, in degrees (normally, use 0) */ fun enableAxisLineDashedLine(lineLength: Float, spaceLength: Float, phase: Float) { - this.axisLineDashPathEffect = DashPathEffect(floatArrayOf(lineLength, spaceLength), phase) + this.axisLineDashPathEffect = DashEffect(floatArrayOf(lineLength, spaceLength), phase) } /** @@ -472,7 +442,7 @@ abstract class AxisBase : ComponentBase() { * * @param effect the DashPathEffect */ - fun setAxisLineDashedLine(effect: DashPathEffect?) { + fun setAxisLineDashedLine(effect: DashEffect?) { this.axisLineDashPathEffect = effect } diff --git a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/AxisDependency.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/AxisDependency.kt new file mode 100644 index 000000000..104a4a3fb --- /dev/null +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/AxisDependency.kt @@ -0,0 +1,13 @@ +package info.appdev.charting.components + +/** + * Specifies which y-axis (LEFT or RIGHT) a DataSet should be plotted against. + * + * Platform-independent home for this enum; [info.appdev.charting.components.YAxis] + * (Android-only for now) exposes it as a nested `YAxis.AxisDependency` type alias + * to preserve the existing public API. + */ +enum class AxisDependency { + LEFT, + RIGHT +} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/components/ComponentBase.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/ComponentBase.kt similarity index 90% rename from chartLib/src/main/kotlin/info/appdev/charting/components/ComponentBase.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/ComponentBase.kt index c83b5e926..0b1894faa 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/components/ComponentBase.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/ComponentBase.kt @@ -1,8 +1,7 @@ package info.appdev.charting.components -import android.graphics.Color -import android.graphics.Typeface -import androidx.annotation.ColorInt +import info.appdev.charting.utils.ChartTypeface +import info.appdev.charting.utils.ColorTemplate import info.appdev.charting.utils.convertDpToPixel /** @@ -28,7 +27,7 @@ abstract class ComponentBase { /** * the typeface used for the labels */ - var typeface: Typeface? = null + var typeface: ChartTypeface? = null /** * the text size of the labels @@ -38,8 +37,7 @@ abstract class ComponentBase { /** * the text color to use for the labels */ - @ColorInt - open var textColor: Int = Color.BLACK + open var textColor: Int = ColorTemplate.BLACK /** * Returns the used offset on the x-axis for drawing the axis or legend diff --git a/chartLib/src/main/kotlin/info/appdev/charting/components/Description.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/Description.kt similarity index 93% rename from chartLib/src/main/kotlin/info/appdev/charting/components/Description.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/Description.kt index 91a16cf87..e928c9047 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/components/Description.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/Description.kt @@ -1,7 +1,7 @@ package info.appdev.charting.components -import android.graphics.Paint.Align import info.appdev.charting.utils.PointF +import info.appdev.charting.utils.TextAlign import info.appdev.charting.utils.convertDpToPixel class Description : ComponentBase() { @@ -26,7 +26,7 @@ class Description : ComponentBase() { /** * the alignment of the description text */ - var textAlign: Align? = Align.RIGHT + var textAlign: TextAlign? = TextAlign.RIGHT init { // default size diff --git a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/Legend.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/Legend.kt new file mode 100644 index 000000000..58fefb378 --- /dev/null +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/Legend.kt @@ -0,0 +1,260 @@ +package info.appdev.charting.components + +import info.appdev.charting.utils.ColorTemplate +import info.appdev.charting.utils.DashEffect +import info.appdev.charting.utils.FSize +import info.appdev.charting.utils.ViewPortHandler +import info.appdev.charting.utils.convertDpToPixel +import kotlin.math.max +import kotlin.math.min + +/** + * Class representing the legend of the chart. The legend will contain one entry + * per color and DataSet. Multiple colors in one DataSet are grouped together. + * The legend object is NOT available before setting data to the chart. + */ +class Legend() : ComponentBase() { + /** + * Kept as a nested type alias (rather than a nested enum) so that the actual enum can live + * in the platform-independent `chartLibCore` module while `Legend.LegendForm` keeps + * working unchanged for existing callers. + */ + typealias LegendForm = info.appdev.charting.components.LegendForm + + enum class LegendHorizontalAlignment { + LEFT, CENTER, RIGHT + } + + enum class LegendVerticalAlignment { + TOP, CENTER, BOTTOM + } + + enum class LegendOrientation { + HORIZONTAL, VERTICAL + } + + enum class LegendDirection { + LEFT_TO_RIGHT, RIGHT_TO_LEFT + } + + /** + * The legend entries array + */ + var entries: Array = arrayOf() + private set + + /** + * Entries that will be appended to the end of the auto calculated entries after calculating the legend. + * (if the legend has already been calculated, you will need to call notifyDataSetChanged() to let the changes take effect) + */ + var extraEntries: Array = arrayOf() + private set + + /** + * Are the legend labels/colors a custom value or auto calculated? If false, + * then it's auto, if true, then custom. default false (automatic legend) + */ + var isLegendCustom: Boolean = false + private set + + /** + * sets the horizontal alignment of the legend + */ + var horizontalAlignment: LegendHorizontalAlignment = LegendHorizontalAlignment.LEFT + + /** + * sets the vertical alignment of the legend + */ + var verticalAlignment: LegendVerticalAlignment = LegendVerticalAlignment.BOTTOM + + /** + * sets the orientation of the legend + */ + var orientation: LegendOrientation = LegendOrientation.HORIZONTAL + + /** + * returns whether the legend will draw inside the chart or outside + */ + var isDrawInsideEnabled: Boolean = false + private set + + /** + * the text direction for the legend + */ + var direction: LegendDirection = LegendDirection.LEFT_TO_RIGHT + + /** + * the shape/form the legend colors are drawn in + */ + var form: LegendForm = LegendForm.SQUARE + + /** + * the size of the legend forms/shapes + */ + var formSize: Float = 8f + + /** + * the size of the legend forms/shapes + */ + var formLineWidth: Float = 3f + + /** + * Line dash path effect used for shapes that consist of lines. + */ + var formLineDashEffect: DashEffect? = null + + /** + * the space between the legend entries on a horizontal axis, default 6f + */ + var xEntrySpace: Float = 6f + + /** + * the space between the legend entries on a vertical axis, default 5f + */ + var yEntrySpace: Float = 0f + + /** + * the space between the legend entries on a vertical axis, default 2f + * private float mYEntrySpace = 2f; / ** the space between the form and the + * actual label/text + */ + var formToTextSpace: Float = 5f + + /** + * the space that should be left between stacked forms + */ + var stackSpace: Float = 3f + + /** + * The maximum relative size out of the whole chart view. / If the legend is + * to the right/left of the chart, then this affects the width of the + * legend. / If the legend is to the top/bottom of the chart, then this + * affects the height of the legend. / If the legend is the center of the + * piechart, then this defines the size of the rectangular bounds out of the + * size of the "hole". / default: 0.95f (95%) + */ + var maxSizePercent: Float = 0.95f + + /** + * Constructor. Provide entries for the legend. + */ + constructor(entries: Array) : this() { + this.entries = entries + } + + /** + * This method sets the automatically computed colors for the legend. Use setCustom(...) to set custom colors. + */ + fun setEntries(entries: MutableList) { + this.entries = entries.toTypedArray() + } + + fun setExtra(entries: MutableList) { + this.extraEntries = entries.toTypedArray() + } + + fun setExtra(entries: Array) { + this.extraEntries = entries + } + + /** + * Entries that will be appended to the end of the auto calculated + * entries after calculating the legend. + * (if the legend has already been calculated, you will need to call notifyDataSetChanged() + * to let the changes take effect) + */ + fun setExtra(colors: IntArray, labels: Array) { + val entries: MutableList = ArrayList() + + for (i in 0..() + } + + /** + * Sets a custom legend's entries array. + * * A null label will start a group. + * This will disable the feature that automatically calculates the legend + * entries from the datasets. + * Call resetCustom() to re-enable automatic calculation (and then + * notifyDataSetChanged() is needed to auto-calculate the legend again) + */ + fun setCustom(entries: Array) { + this.entries = entries + this.isLegendCustom = true + } + + /** + * Sets a custom legend's entries array. + * * A null label will start a group. + * This will disable the feature that automatically calculates the legend + * entries from the datasets. + * Call resetCustom() to re-enable automatic calculation (and then + * notifyDataSetChanged() is needed to auto-calculate the legend again) + */ + fun setCustom(entries: MutableList) { + this.entries = entries.toTypedArray() + this.isLegendCustom = true + } + + /** + * Calling this will disable the custom legend entries (set by + * setCustom(...)). Instead, the entries will again be calculated + * automatically (after notifyDataSetChanged() is called). + */ + fun resetCustom() { + this.isLegendCustom = false + } + + /** + * sets whether the legend will draw inside the chart or outside + */ + fun setDrawInside(value: Boolean) { + this.isDrawInsideEnabled = value + } + + /** + * the total width of the legend (needed width space) + */ + var neededWidth: Float = 0f + + /** + * the total height of the legend (needed height space) + */ + var neededHeight: Float = 0f + + var mTextHeightMax: Float = 0f + + var mTextWidthMax: Float = 0f + + /** + * Should the legend word wrap? / this is currently supported only for: + * BelowChartLeft, BelowChartRight, BelowChartCenter. / note that word + * wrapping a legend takes a toll on performance. / you may want to set + * maxSizePercent when word wrapping, to set the point where the text wraps. + * / default: false + */ + var isWordWrapEnabled: Boolean = false + + val calculatedLabelSizes: MutableList = mutableListOf() + val calculatedLabelBreakPoints: MutableList = mutableListOf() + val calculatedLineSizes: MutableList = mutableListOf() + + init { + this.mTextSize = 10f.convertDpToPixel() + this.mXOffset = 5f.convertDpToPixel() + this.mYOffset = 3f.convertDpToPixel() // 2 + } + +} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/components/LegendEntry.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/LegendEntry.kt similarity index 89% rename from chartLib/src/main/kotlin/info/appdev/charting/components/LegendEntry.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/LegendEntry.kt index 1f6401ded..7ef028d26 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/components/LegendEntry.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/LegendEntry.kt @@ -1,7 +1,6 @@ package info.appdev.charting.components -import android.graphics.DashPathEffect -import androidx.annotation.ColorInt +import info.appdev.charting.utils.DashEffect import info.appdev.charting.components.Legend.LegendForm import info.appdev.charting.utils.ColorTemplate @@ -23,8 +22,8 @@ class LegendEntry { form: LegendForm, formSize: Float, formLineWidth: Float, - formLineDashEffect: DashPathEffect?, - @ColorInt formColor: Int + formLineDashEffect: DashEffect?, + formColor: Int ) { this.label = label this.form = form @@ -68,11 +67,10 @@ class LegendEntry { * * Set to null to use the legend's default */ - var formLineDashEffect: DashPathEffect? = null + var formLineDashEffect: DashEffect? = null /** * The color for drawing the form */ - @ColorInt var formColor: Int = ColorTemplate.COLOR_NONE } \ No newline at end of file diff --git a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/LegendForm.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/LegendForm.kt new file mode 100644 index 000000000..0d5a68634 --- /dev/null +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/LegendForm.kt @@ -0,0 +1,40 @@ +package info.appdev.charting.components + +/** + * Describes how a DataSet's legend entry ("form") should be drawn. + * + * Platform-independent home for this enum; [info.appdev.charting.components.Legend] + * (Android-only for now) exposes it as a nested `Legend.LegendForm` type alias + * to preserve the existing public API. + */ +enum class LegendForm { + /** + * Avoid drawing a form + */ + NONE, + + /** + * Do not draw the form, but leave space for it + */ + EMPTY, + + /** + * Use default (default dataset's form to the legend's form) + */ + DEFAULT, + + /** + * Draw a square + */ + SQUARE, + + /** + * Draw a circle + */ + CIRCLE, + + /** + * Draw a horizontal line + */ + LINE +} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/components/LimitLine.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/LimitLine.kt similarity index 89% rename from chartLib/src/main/kotlin/info/appdev/charting/components/LimitLine.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/LimitLine.kt index 1a24589b0..210948742 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/components/LimitLine.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/LimitLine.kt @@ -1,9 +1,8 @@ package info.appdev.charting.components -import android.graphics.Color -import android.graphics.DashPathEffect -import android.graphics.Paint -import androidx.annotation.ColorInt +import info.appdev.charting.utils.ColorTemplate +import info.appdev.charting.utils.DashEffect +import info.appdev.charting.utils.PaintStyle import info.appdev.charting.utils.convertDpToPixel /** @@ -19,17 +18,16 @@ class LimitLine : ComponentBase { private var mLineWidth = 2f /** the color of the limit line */ - @ColorInt - var lineColor: Int = Color.rgb(237, 91, 91) + var lineColor: Int = ColorTemplate.argb(237, 91, 91) /** the style of the label text */ - var textStyle: Paint.Style? = Paint.Style.FILL_AND_STROKE + var textStyle: PaintStyle? = PaintStyle.FILL_AND_STROKE /** label string that is drawn next to the limit line */ var label: String? = "" /** the path effect of this LimitLine that makes dashed lines possible */ - var dashPathEffect: DashPathEffect? = null + var dashPathEffect: DashEffect? = null private set /** @@ -86,7 +84,7 @@ class LimitLine : ComponentBase { * @param phase offset, in degrees (normally, use 0) */ fun enableDashedLine(lineLength: Float, spaceLength: Float, phase: Float) { - this.dashPathEffect = DashPathEffect( + this.dashPathEffect = DashEffect( floatArrayOf( lineLength, spaceLength ), phase diff --git a/chartLib/src/main/kotlin/info/appdev/charting/components/LimitRange.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/LimitRange.kt similarity index 90% rename from chartLib/src/main/kotlin/info/appdev/charting/components/LimitRange.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/LimitRange.kt index e69f15c4e..e38b652eb 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/components/LimitRange.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/LimitRange.kt @@ -1,10 +1,9 @@ package info.appdev.charting.components -import android.graphics.Color -import android.graphics.DashPathEffect -import android.graphics.Paint -import androidx.annotation.ColorInt import info.appdev.charting.components.LimitLine.LimitLabelPosition +import info.appdev.charting.utils.ColorTemplate +import info.appdev.charting.utils.DashEffect +import info.appdev.charting.utils.PaintStyle import info.appdev.charting.utils.convertDpToPixel /** @@ -41,20 +40,18 @@ class LimitRange : ComponentBase { /** * the color of the limit line */ - @ColorInt - var lineColor: Int = Color.rgb(237, 91, 91) + var lineColor: Int = ColorTemplate.argb(237, 91, 91) /** * the color of the Range */ - @ColorInt - var rangeColor: Int = Color.rgb(128, 128, 128) + var rangeColor: Int = ColorTemplate.argb(128, 128, 128) /** * Sets the color of the value-text that is drawn next to the LimitLine. * Default: Paint.Style.FILL_AND_STROKE */ - var textStyle: Paint.Style? = Paint.Style.FILL + var textStyle: PaintStyle? = PaintStyle.FILL /** * Sets the label that is drawn next to the limit line. Provide "" if no @@ -65,7 +62,7 @@ class LimitRange : ComponentBase { /** * the path effect of this LimitLine that makes dashed lines possible */ - var dashPathEffect: DashPathEffect? = null + var dashPathEffect: DashEffect? = null private set /** @@ -122,7 +119,7 @@ class LimitRange : ComponentBase { * @param phase offset, in degrees (normally, use 0) */ fun enableDashedLine(lineLength: Float, spaceLength: Float, phase: Float) { - this.dashPathEffect = DashPathEffect( + this.dashPathEffect = DashEffect( floatArrayOf( lineLength, spaceLength ), phase diff --git a/chartLib/src/main/kotlin/info/appdev/charting/components/XAxis.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/XAxis.kt similarity index 100% rename from chartLib/src/main/kotlin/info/appdev/charting/components/XAxis.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/XAxis.kt diff --git a/chartLib/src/main/kotlin/info/appdev/charting/components/YAxis.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/YAxis.kt similarity index 78% rename from chartLib/src/main/kotlin/info/appdev/charting/components/YAxis.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/YAxis.kt index 16ad70347..e08c68c45 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/components/YAxis.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/YAxis.kt @@ -1,10 +1,6 @@ package info.appdev.charting.components -import android.graphics.Color -import android.graphics.Paint -import androidx.annotation.ColorInt -import info.appdev.charting.utils.calcTextHeight -import info.appdev.charting.utils.calcTextWidth +import info.appdev.charting.utils.ColorTemplate import info.appdev.charting.utils.convertDpToPixel import kotlin.math.abs import kotlin.math.max @@ -17,6 +13,13 @@ import kotlin.math.min * setting data for the chart. */ open class YAxis : AxisBase { + /** + * Kept as a nested type alias (rather than a nested enum) so that the actual enum can live + * in the platform-independent `chartLibCore` module while `YAxis.AxisDependency` keeps + * working unchanged for existing callers. + */ + typealias AxisDependency = info.appdev.charting.components.AxisDependency + /** * indicates if the bottom y-label entry is drawn or not */ @@ -43,8 +46,7 @@ open class YAxis : AxisBase { /** * Color of the zero line */ - @ColorInt - var zeroLineColor: Int = Color.GRAY + var zeroLineColor: Int = ColorTemplate.GRAY /** * Width of the zero line in pixels @@ -96,13 +98,6 @@ open class YAxis : AxisBase { */ var maxWidth: Float = Float.POSITIVE_INFINITY - /** - * Enum that specifies the axis a DataSet should be plotted against, either LEFT or RIGHT. - */ - enum class AxisDependency { - LEFT, RIGHT - } - constructor() : super() { this.axisDependency = AxisDependency.LEFT this.mYOffset = 0f @@ -132,37 +127,6 @@ open class YAxis : AxisBase { this.mZeroLineWidth = width.convertDpToPixel() } - /** - * This is for normal (not horizontal) charts horizontal spacing. - */ - fun getRequiredWidthSpace(p: Paint): Float { - p.textSize = mTextSize - - val label = getLongestLabel(p) - var width = p.calcTextWidth(label).toFloat() + xOffset * 2f - - var minWidth = this.minWidth - var maxWidth = this.maxWidth - - if (minWidth > 0f) minWidth = minWidth.convertDpToPixel() - - if (maxWidth > 0f && maxWidth != Float.POSITIVE_INFINITY) maxWidth = maxWidth.convertDpToPixel() - - width = max(minWidth, min(width, if (maxWidth > 0.0) maxWidth else width)) - - return width - } - - /** - * This is for HorizontalBarChart vertical spacing. - */ - fun getRequiredHeightSpace(p: Paint): Float { - p.textSize = mTextSize - - val label = getLongestLabel(p) - return p.calcTextHeight(label).toFloat() + yOffset * 2f - } - /** * Returns true if this axis needs horizontal offset, false if no offset is needed. */ diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/BarDataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BarDataSet.kt similarity index 91% rename from chartLib/src/main/kotlin/info/appdev/charting/data/BarDataSet.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BarDataSet.kt index 1e94ef0ca..5be7ee009 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/BarDataSet.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BarDataSet.kt @@ -1,8 +1,7 @@ package info.appdev.charting.data -import android.graphics.Color -import androidx.annotation.ColorInt import info.appdev.charting.interfaces.datasets.IBarDataSet +import info.appdev.charting.utils.ColorTemplate import info.appdev.charting.utils.Fill open class BarDataSet(yVals: MutableList, label: String) : BarLineScatterCandleBubbleDataSet(yVals, label), IBarDataSet { @@ -15,13 +14,11 @@ open class BarDataSet(yVals: MutableList, label: String) : BarLin /** * the color used for drawing the bar shadows */ - @ColorInt - private var mBarShadowColor = Color.rgb(215, 215, 215) + private var mBarShadowColor = ColorTemplate.argb(215, 215, 215) private var mBarBorderWidth = 0.0f - @ColorInt - private var mBarBorderColor = Color.BLACK + private var mBarBorderColor = ColorTemplate.BLACK /** * the alpha value used to draw the highlight indicator bar @@ -44,7 +41,7 @@ open class BarDataSet(yVals: MutableList, label: String) : BarLin protected set init { - highLightColor = Color.rgb(0, 0, 0) + highLightColor = ColorTemplate.BLACK calcStackSize(yVals) calcEntryCountIncludingStacks(yVals) @@ -87,7 +84,7 @@ open class BarDataSet(yVals: MutableList, label: String) : BarLin /** * Sets the start and end color for gradient color, ONLY color that should be used for this DataSet. */ - fun setGradientColor(@ColorInt startColor: Int, @ColorInt endColor: Int) { + fun setGradientColor(startColor: Int, endColor: Int) { gradients.clear() gradients.add(Fill(startColor, endColor)) } @@ -150,8 +147,7 @@ open class BarDataSet(yVals: MutableList, label: String) : BarLin /** * Sets the color used for drawing the bar-shadows. The bar shadows is a - * surface behind the bar that indicates the maximum value. Don't for get to - * use getResources().getColor(...) to set this. Or Color.rgb(...). + * surface behind the bar that indicates the maximum value. */ override var barShadowColor: Int get() = mBarShadowColor diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/BarEntry.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BarEntry.kt similarity index 83% rename from chartLib/src/main/kotlin/info/appdev/charting/data/BarEntry.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BarEntry.kt index eedc9b38d..3b2353158 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/BarEntry.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BarEntry.kt @@ -1,6 +1,6 @@ package info.appdev.charting.data -import android.graphics.drawable.Drawable +import info.appdev.charting.utils.ChartIcon @Deprecated( @@ -30,7 +30,7 @@ open class BarEntry : BarEntryFloat { * @param y * @param icon - icon image */ - constructor(x: Float, y: Float, icon: Drawable?) : super(x, y, icon) + constructor(x: Float, y: Float, icon: ChartIcon?) : super(x, y, icon) /** * Constructor for normal bars (not stacked). @@ -40,6 +40,6 @@ open class BarEntry : BarEntryFloat { * @param icon - icon image * @param data - Spot for additional data this Entry represents. */ - constructor(x: Float, y: Float, icon: Drawable?, data: Any?) : super(x, y, icon, data) + constructor(x: Float, y: Float, icon: ChartIcon?, data: Any?) : super(x, y, icon, data) } diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/BarEntryDouble.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BarEntryDouble.kt similarity index 92% rename from chartLib/src/main/kotlin/info/appdev/charting/data/BarEntryDouble.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BarEntryDouble.kt index 6560d5d26..6a621737d 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/BarEntryDouble.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BarEntryDouble.kt @@ -1,6 +1,6 @@ package info.appdev.charting.data -import android.graphics.drawable.Drawable +import info.appdev.charting.utils.ChartIcon import info.appdev.charting.highlight.RangeDouble import kotlin.math.abs @@ -47,11 +47,11 @@ open class BarEntryDouble : BarEntryFloat { xDouble = x; yDouble = y } - constructor(x: Double, y: Double, icon: Drawable?) : super(x.toFloat(), y.toFloat(), icon) { + constructor(x: Double, y: Double, icon: ChartIcon?) : super(x.toFloat(), y.toFloat(), icon) { xDouble = x; yDouble = y } - constructor(x: Double, y: Double, icon: Drawable?, data: Any?) : super(x.toFloat(), y.toFloat(), icon, data) { + constructor(x: Double, y: Double, icon: ChartIcon?, data: Any?) : super(x.toFloat(), y.toFloat(), icon, data) { xDouble = x; yDouble = y } @@ -73,7 +73,7 @@ open class BarEntryDouble : BarEntryFloat { calcDoubleRanges() } - constructor(x: Double, vals: DoubleArray?, icon: Drawable?) : super(x.toFloat(), calcDoubleSum(vals).toFloat(), icon) { + constructor(x: Double, vals: DoubleArray?, icon: ChartIcon?) : super(x.toFloat(), calcDoubleSum(vals).toFloat(), icon) { xDouble = x yDouble = calcDoubleSum(vals) yValsDouble = vals diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/BarEntryFloat.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BarEntryFloat.kt similarity index 92% rename from chartLib/src/main/kotlin/info/appdev/charting/data/BarEntryFloat.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BarEntryFloat.kt index 1ce910a01..1c51c8868 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/BarEntryFloat.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BarEntryFloat.kt @@ -1,14 +1,12 @@ package info.appdev.charting.data -import android.annotation.SuppressLint -import android.graphics.drawable.Drawable +import info.appdev.charting.utils.ChartIcon import info.appdev.charting.highlight.Range import kotlin.math.abs /** * Entry class for the BarChart. (especially stacked bars) */ -@SuppressLint("ParcelCreator") open class BarEntryFloat : EntryFloat { /** * Returns the stacked values this BarEntry represents, or null, if only a single value is represented (then, use @@ -56,7 +54,7 @@ open class BarEntryFloat : EntryFloat { * @param y * @param icon - icon image */ - constructor(x: Float, y: Float, icon: Drawable?) : super(x, y, icon) + constructor(x: Float, y: Float, icon: ChartIcon?) : super(x, y, icon) /** * Constructor for normal bars (not stacked). @@ -66,7 +64,7 @@ open class BarEntryFloat : EntryFloat { * @param icon - icon image * @param data - Spot for additional data this Entry represents. */ - constructor(x: Float, y: Float, icon: Drawable?, data: Any?) : super(x, y, icon, data) + constructor(x: Float, y: Float, icon: ChartIcon?, data: Any?) : super(x, y, icon, data) /** * Constructor for stacked bar entries. One data object for whole stack @@ -100,7 +98,7 @@ open class BarEntryFloat : EntryFloat { * @param vals - the stack values, use at least 2 * @param icon - icon image */ - constructor(x: Float, vals: FloatArray?, icon: Drawable?) : super(x, calcSum(vals), icon) { + constructor(x: Float, vals: FloatArray?, icon: ChartIcon?) : super(x, calcSum(vals), icon) { this.yVals = vals calcPosNegSum() calcRanges() @@ -114,7 +112,7 @@ open class BarEntryFloat : EntryFloat { * @param icon - icon image * @param data - Spot for additional data this Entry represents. */ - constructor(x: Float, vals: FloatArray?, icon: Drawable?, data: Any?) : super(x, calcSum(vals), icon, data) { + constructor(x: Float, vals: FloatArray?, icon: ChartIcon?, data: Any?) : super(x, calcSum(vals), icon, data) { this.yVals = vals calcPosNegSum() calcRanges() diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/BarLineScatterCandleBubbleDataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BarLineScatterCandleBubbleDataSet.kt similarity index 83% rename from chartLib/src/main/kotlin/info/appdev/charting/data/BarLineScatterCandleBubbleDataSet.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BarLineScatterCandleBubbleDataSet.kt index 2751e4703..f0bf6bfb5 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/BarLineScatterCandleBubbleDataSet.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BarLineScatterCandleBubbleDataSet.kt @@ -1,8 +1,7 @@ package info.appdev.charting.data -import android.graphics.Color -import androidx.annotation.ColorInt import info.appdev.charting.interfaces.datasets.IBarLineScatterCandleBubbleDataSet +import info.appdev.charting.utils.ColorTemplate /** * Baseclass of all DataSets for Bar-, Line-, Scatter- and CandleStickChart. @@ -12,8 +11,7 @@ abstract class BarLineScatterCandleBubbleDataSet>(yVals: Mu /** * Sets the color that is used for drawing the highlight indicators. */ - @ColorInt - override var highLightColor: Int = Color.rgb(255, 187, 115) + override var highLightColor: Int = ColorTemplate.argb(255, 187, 115) protected fun copy(barLineScatterCandleBubbleDataSet: BarLineScatterCandleBubbleDataSet<*>) { super.copy((barLineScatterCandleBubbleDataSet as BaseDataSet<*>?)!!) diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/BaseDataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BaseDataSet.kt similarity index 87% rename from chartLib/src/main/kotlin/info/appdev/charting/data/BaseDataSet.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BaseDataSet.kt index 110ae26cb..7ea758423 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/BaseDataSet.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BaseDataSet.kt @@ -1,18 +1,14 @@ package info.appdev.charting.data -import android.content.Context -import android.graphics.Color -import android.graphics.DashPathEffect -import android.graphics.Typeface -import androidx.annotation.ColorInt -import androidx.core.content.ContextCompat -import info.appdev.charting.components.Legend.LegendForm -import info.appdev.charting.components.YAxis.AxisDependency +import info.appdev.charting.components.AxisDependency +import info.appdev.charting.components.LegendForm +import info.appdev.charting.formatter.DefaultValueFormatter import info.appdev.charting.formatter.IValueFormatter import info.appdev.charting.interfaces.datasets.IDataSet +import info.appdev.charting.utils.ChartTypeface import info.appdev.charting.utils.ColorTemplate +import info.appdev.charting.utils.DashEffect import info.appdev.charting.utils.PointF -import info.appdev.charting.utils.Utils import info.appdev.charting.utils.convertDpToPixel /** @@ -23,13 +19,11 @@ abstract class BaseDataSet>() : IDataSet { /** * List representing all colors that are used for this DataSet */ - @ColorInt protected var mColors: MutableList /** * List representing all colors that are used for drawing the actual values for this DataSet */ - @ColorInt protected var mValueColors: MutableList /** @@ -50,18 +44,17 @@ abstract class BaseDataSet>() : IDataSet { /** * custom formatter that is used instead of the auto-formatter if set */ - @Transient protected var mValueFormatter: IValueFormatter? = null /** * the typeface used for the value text */ - protected var mValueTypeface: Typeface? = null + protected var mValueTypeface: ChartTypeface? = null private var mForm = LegendForm.DEFAULT private var mFormSize = Float.NaN private var mFormLineWidth = Float.NaN - private var mFormLineDashEffect: DashPathEffect? = null + private var mFormLineDashEffect: DashEffect? = null private var mIsVisible = true private var mIsDrawValues = true private var mIsDrawIcons = true @@ -96,8 +89,8 @@ abstract class BaseDataSet>() : IDataSet { mValueColors = ArrayList() // default color - mColors.add(Color.rgb(140, 234, 255)) - mValueColors.add(Color.BLACK) + mColors.add(ColorTemplate.argb(140, 234, 255)) + mValueColors.add(ColorTemplate.BLACK) } constructor(label: String) : this() { @@ -117,7 +110,7 @@ abstract class BaseDataSet>() : IDataSet { */ fun setColor(color: Int, alpha: Int) { mColors.clear() - mColors.add(Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color))) + mColors.add(ColorTemplate.argb(alpha, ColorTemplate.red(color), ColorTemplate.green(color), ColorTemplate.blue(color))) } override var color: Int @@ -134,7 +127,7 @@ abstract class BaseDataSet>() : IDataSet { return mColors[index % mColors.size] } - override var formLineDashEffect: DashPathEffect? + override var formLineDashEffect: DashEffect? get() = mFormLineDashEffect set(value) { mFormLineDashEffect = value @@ -197,14 +190,11 @@ abstract class BaseDataSet>() : IDataSet { * "new int[] { R.color.red, R.color.green, ... }" to provide colors for * this method. Internally, the colors are resolved using * ContextCompat.getColor(context,...) + * + * Note: this is provided as an Android-only extension function, see + * `BaseDataSetAndroid.kt`, since it needs an Android `Context` to resolve + * color resources. */ - fun setColors(colors: IntArray, context: Context) { - mColors.clear() - - for (color in colors) { - mColors.add(ContextCompat.getColor(context, color)) - } - } /** * Adds a new color to the colors array of the DataSet. @@ -219,7 +209,7 @@ abstract class BaseDataSet>() : IDataSet { fun setColors(colors: IntArray, alpha: Int) { resetColors() for (color in colors) { - addColor(Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color))) + addColor(ColorTemplate.argb(alpha, ColorTemplate.red(color), ColorTemplate.green(color), ColorTemplate.blue(color))) } } @@ -249,7 +239,7 @@ abstract class BaseDataSet>() : IDataSet { override var valueFormatter: IValueFormatter get() = if (needsFormatter()) - Utils.defaultValueFormatter + defaultValueFormatter else mValueFormatter!! set(value) { @@ -280,7 +270,7 @@ abstract class BaseDataSet>() : IDataSet { return mValueColors[value % mValueColors.size] } - override var valueTypeface: Typeface? + override var valueTypeface: ChartTypeface? get() = mValueTypeface set(value) { mValueTypeface = value @@ -367,4 +357,12 @@ abstract class BaseDataSet>() : IDataSet { baseDataSet.mValueTextSize = mValueTextSize baseDataSet.mVisible = mVisible } + + companion object { + /** + * The default value formatter used when no custom formatter is set, + * platform-independent replacement for the former `Utils.defaultValueFormatter`. + */ + private val defaultValueFormatter: IValueFormatter by lazy { DefaultValueFormatter(1) } + } } diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/BaseEntry.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BaseEntry.kt similarity index 72% rename from chartLib/src/main/kotlin/info/appdev/charting/data/BaseEntry.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BaseEntry.kt index fcc4bf3f1..ceffb0547 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/BaseEntry.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BaseEntry.kt @@ -1,6 +1,6 @@ package info.appdev.charting.data -import android.graphics.drawable.Drawable +import info.appdev.charting.utils.ChartIcon abstract class BaseEntry where T : Number, T : Comparable { @@ -20,7 +20,7 @@ abstract class BaseEntry where T : Number, T : Comparable { var data: Any? = null - var icon: Drawable? = null + var icon: ChartIcon? = null constructor() @@ -37,11 +37,11 @@ abstract class BaseEntry where T : Number, T : Comparable { this.data = data } - constructor(y: T, icon: Drawable?) : this(y) { + constructor(y: T, icon: ChartIcon?) : this(y) { this.icon = icon } - constructor(y: T, icon: Drawable?, data: Any?) : this(y = y) { + constructor(y: T, icon: ChartIcon?, data: Any?) : this(y = y) { this.icon = icon this.data = data } @@ -50,11 +50,11 @@ abstract class BaseEntry where T : Number, T : Comparable { this.data = data } - constructor(x: T, y: T, icon: Drawable?) : this(x = x, y = y) { + constructor(x: T, y: T, icon: ChartIcon?) : this(x = x, y = y) { this.icon = icon } - constructor(x: T, y: T, icon: Drawable?, data: Any?) : this(x = x, y = y) { + constructor(x: T, y: T, icon: ChartIcon?, data: Any?) : this(x = x, y = y) { this.icon = icon this.data = data } diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/BubbleDataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BubbleDataSet.kt similarity index 100% rename from chartLib/src/main/kotlin/info/appdev/charting/data/BubbleDataSet.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BubbleDataSet.kt diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/BubbleEntry.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BubbleEntry.kt similarity index 87% rename from chartLib/src/main/kotlin/info/appdev/charting/data/BubbleEntry.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BubbleEntry.kt index af0986e7e..ae4367f44 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/BubbleEntry.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BubbleEntry.kt @@ -1,7 +1,6 @@ package info.appdev.charting.data -import android.annotation.SuppressLint -import android.graphics.drawable.Drawable +import info.appdev.charting.utils.ChartIcon @Deprecated( message = "The replacement is BubbleEntryFloat, or use BubbleEntryDouble for higher precision. BubbleEntry is retained for backward compatibility but will be removed in a future version.", @@ -40,7 +39,7 @@ class BubbleEntry : BubbleEntryFloat { * @param size The size of the bubble. * @param icon Icon image */ - constructor(x: Float, y: Float, size: Float, icon: Drawable?) : super(x, y, size, icon) { + constructor(x: Float, y: Float, size: Float, icon: ChartIcon?) : super(x, y, size, icon) { this.size = size } } diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/BubbleEntryDouble.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BubbleEntryDouble.kt similarity index 85% rename from chartLib/src/main/kotlin/info/appdev/charting/data/BubbleEntryDouble.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BubbleEntryDouble.kt index 2fd3c3332..446574be5 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/BubbleEntryDouble.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BubbleEntryDouble.kt @@ -1,7 +1,6 @@ package info.appdev.charting.data -import android.annotation.SuppressLint -import android.graphics.drawable.Drawable +import info.appdev.charting.utils.ChartIcon import kotlin.math.abs /** @@ -9,7 +8,6 @@ import kotlin.math.abs * so it works seamlessly in the existing bubble chart rendering pipeline. * Use [xDouble], [yDouble] and [sizeDouble] for full-precision access. */ -@SuppressLint("ParcelCreator") open class BubbleEntryDouble : BubbleEntryFloat { var xDouble: Double = 0.0 @@ -34,7 +32,7 @@ open class BubbleEntryDouble : BubbleEntryFloat { this.size = size.toFloat() } - constructor(x: Double, y: Double, size: Double, icon: Drawable?) : super(x.toFloat(), y.toFloat(), size.toFloat(), icon) { + constructor(x: Double, y: Double, size: Double, icon: ChartIcon?) : super(x.toFloat(), y.toFloat(), size.toFloat(), icon) { xDouble = x; yDouble = y; sizeDouble = size this.size = size.toFloat() } diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/BubbleEntryFloat.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BubbleEntryFloat.kt similarity index 86% rename from chartLib/src/main/kotlin/info/appdev/charting/data/BubbleEntryFloat.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BubbleEntryFloat.kt index f9676aabf..73ec7f653 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/BubbleEntryFloat.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BubbleEntryFloat.kt @@ -1,13 +1,11 @@ package info.appdev.charting.data -import android.annotation.SuppressLint -import android.graphics.drawable.Drawable +import info.appdev.charting.utils.ChartIcon /** * Subclass of Entry that holds a value for one entry in a BubbleChart. Bubble * chart implementation: Copyright 2015 Pierre-Marc Airoldi Licensed under Apache License 2.0 */ -@SuppressLint("ParcelCreator") open class BubbleEntryFloat : EntryFloat { /** * Returns the size of this entry (the size of the bubble). @@ -45,7 +43,7 @@ open class BubbleEntryFloat : EntryFloat { * @param size The size of the bubble. * @param icon Icon image */ - constructor(x: Float, y: Float, size: Float, icon: Drawable?) : super(x, y, icon) { + constructor(x: Float, y: Float, size: Float, icon: ChartIcon?) : super(x, y, icon) { this.size = size } diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/CandleDataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/CandleDataSet.kt similarity index 93% rename from chartLib/src/main/kotlin/info/appdev/charting/data/CandleDataSet.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/CandleDataSet.kt index 79e4d142d..48545466b 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/CandleDataSet.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/CandleDataSet.kt @@ -1,9 +1,8 @@ package info.appdev.charting.data -import android.graphics.Paint -import androidx.annotation.ColorInt import info.appdev.charting.interfaces.datasets.ICandleDataSet import info.appdev.charting.utils.ColorTemplate +import info.appdev.charting.utils.PaintStyle import info.appdev.charting.utils.convertDpToPixel /** @@ -37,36 +36,32 @@ open class CandleDataSet(yVals: MutableList, label: String = " * paint style when open < close * increasing candlesticks are traditionally hollow */ - protected var mIncreasingPaintStyle: Paint.Style? = Paint.Style.STROKE + protected var mIncreasingPaintStyle: PaintStyle? = PaintStyle.STROKE /** * paint style when open > close * decreasing candlesticks are traditionally filled */ - protected var mDecreasingPaintStyle: Paint.Style? = Paint.Style.FILL + protected var mDecreasingPaintStyle: PaintStyle? = PaintStyle.FILL /** * color for open == close */ - @ColorInt protected var mNeutralColor: Int = ColorTemplate.COLOR_SKIP /** * color for open < close */ - @ColorInt protected var mIncreasingColor: Int = ColorTemplate.COLOR_SKIP /** * color for open > close */ - @ColorInt protected var mDecreasingColor: Int = ColorTemplate.COLOR_SKIP /** * shadow line color, set -1 for backward compatibility and uses default color */ - @ColorInt protected var mShadowColor: Int = ColorTemplate.COLOR_SKIP override fun copy(): DataSet { @@ -172,7 +167,7 @@ open class CandleDataSet(yVals: MutableList, label: String = " */ mDecreasingColor = value } - override var increasingPaintStyle: Paint.Style? + override var increasingPaintStyle: PaintStyle? get() = mIncreasingPaintStyle set(value) { /** @@ -180,7 +175,7 @@ open class CandleDataSet(yVals: MutableList, label: String = " */ mIncreasingPaintStyle = value } - override var decreasingPaintStyle: Paint.Style? + override var decreasingPaintStyle: PaintStyle? get() = mDecreasingPaintStyle set(value) { /** diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/CandleEntry.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/CandleEntry.kt similarity index 95% rename from chartLib/src/main/kotlin/info/appdev/charting/data/CandleEntry.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/CandleEntry.kt index e7d9811bf..b050bcb5f 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/CandleEntry.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/CandleEntry.kt @@ -1,6 +1,6 @@ package info.appdev.charting.data -import android.graphics.drawable.Drawable +import info.appdev.charting.utils.ChartIcon @Deprecated( message = "The replacement is CandleEntryFloat, or use CandleEntryDouble for higher precision. CandleEntry is retained for backward compatibility but will be removed in a future version.", @@ -52,7 +52,7 @@ class CandleEntry : CandleEntryFloat { */ constructor( x: Float, shadowH: Float, shadowL: Float, open: Float, close: Float, - icon: Drawable? + icon: ChartIcon? ) : super(x, shadowH, shadowL, open, close) { this.high = shadowH this.low = shadowL @@ -71,7 +71,7 @@ class CandleEntry : CandleEntryFloat { */ constructor( x: Float, shadowH: Float, shadowL: Float, open: Float, close: Float, - icon: Drawable?, data: Any? + icon: ChartIcon?, data: Any? ) : super(x, shadowH, shadowL, open, close) { this.high = shadowH this.low = shadowL diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/CandleEntryDouble.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/CandleEntryDouble.kt similarity index 94% rename from chartLib/src/main/kotlin/info/appdev/charting/data/CandleEntryDouble.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/CandleEntryDouble.kt index 0ec9ccf05..72d5ad134 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/CandleEntryDouble.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/CandleEntryDouble.kt @@ -1,7 +1,6 @@ package info.appdev.charting.data -import android.annotation.SuppressLint -import android.graphics.drawable.Drawable +import info.appdev.charting.utils.ChartIcon import kotlin.math.abs /** @@ -9,7 +8,6 @@ import kotlin.math.abs * so it works seamlessly in the existing candlestick chart rendering pipeline. * Use [xDouble], [highDouble], [lowDouble], [openDouble], [closeDouble] for full-precision access. */ -@SuppressLint("ParcelCreator") open class CandleEntryDouble : CandleEntryFloat { var xDouble: Double = 0.0 @@ -39,7 +37,7 @@ open class CandleEntryDouble : CandleEntryFloat { } constructor( - x: Double, shadowH: Double, shadowL: Double, open: Double, close: Double, icon: Drawable? + x: Double, shadowH: Double, shadowL: Double, open: Double, close: Double, icon: ChartIcon? ) : super(x.toFloat(), shadowH.toFloat(), shadowL.toFloat(), open.toFloat(), close.toFloat(), icon) { xDouble = x; highDouble = shadowH; lowDouble = shadowL; openDouble = open; closeDouble = close this.high = shadowH.toFloat(); this.low = shadowL.toFloat() @@ -47,7 +45,7 @@ open class CandleEntryDouble : CandleEntryFloat { } constructor( - x: Double, shadowH: Double, shadowL: Double, open: Double, close: Double, icon: Drawable?, data: Any? + x: Double, shadowH: Double, shadowL: Double, open: Double, close: Double, icon: ChartIcon?, data: Any? ) : super(x.toFloat(), shadowH.toFloat(), shadowL.toFloat(), open.toFloat(), close.toFloat(), icon, data) { xDouble = x; highDouble = shadowH; lowDouble = shadowL; openDouble = open; closeDouble = close this.high = shadowH.toFloat(); this.low = shadowL.toFloat() diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/CandleEntryFloat.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/CandleEntryFloat.kt similarity index 94% rename from chartLib/src/main/kotlin/info/appdev/charting/data/CandleEntryFloat.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/CandleEntryFloat.kt index d6659c82d..d331ae41d 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/CandleEntryFloat.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/CandleEntryFloat.kt @@ -1,13 +1,11 @@ package info.appdev.charting.data -import android.annotation.SuppressLint -import android.graphics.drawable.Drawable +import info.appdev.charting.utils.ChartIcon import kotlin.math.abs /** * Subclass of Entry that holds all values for one entry in a CandleStickChart. */ -@SuppressLint("ParcelCreator") open class CandleEntryFloat : EntryFloat { /** * Returns the upper shadows highest value. @@ -73,7 +71,7 @@ open class CandleEntryFloat : EntryFloat { */ constructor( x: Float, shadowH: Float, shadowL: Float, open: Float, close: Float, - icon: Drawable? + icon: ChartIcon? ) : super(x, (shadowH + shadowL) / 2f, icon) { this.high = shadowH this.low = shadowL @@ -92,7 +90,7 @@ open class CandleEntryFloat : EntryFloat { */ constructor( x: Float, shadowH: Float, shadowL: Float, open: Float, close: Float, - icon: Drawable?, data: Any? + icon: ChartIcon?, data: Any? ) : super(x, (shadowH + shadowL) / 2f, icon, data) { this.high = shadowH this.low = shadowL diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/ChartData.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/ChartData.kt similarity index 97% rename from chartLib/src/main/kotlin/info/appdev/charting/data/ChartData.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/ChartData.kt index dc247286d..769e45a3d 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/ChartData.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/ChartData.kt @@ -1,19 +1,16 @@ package info.appdev.charting.data -import android.annotation.SuppressLint -import android.graphics.Typeface -import info.appdev.charting.components.YAxis.AxisDependency +import info.appdev.charting.components.AxisDependency import info.appdev.charting.formatter.IValueFormatter import info.appdev.charting.highlight.Highlight import info.appdev.charting.interfaces.datasets.IDataSet -import timber.log.Timber -import java.io.Serializable +import info.appdev.charting.utils.ChartTypeface /** * Class that holds all relevant data that represents the chart. That involves at least one (or more) DataSets, and an array of x-values. */ @Suppress("unused") -abstract class ChartData> : Serializable { +abstract class ChartData> { /** * maximum y-value in the value array across all axes */ @@ -339,7 +336,7 @@ abstract class ChartData> : Serializable { calcMinMax(entryFloat, set.axisDependency) } else { - Timber.e("Cannot add Entry because dataSetIndex too high or too low.") + println("Cannot add Entry because dataSetIndex too high or too low.") } } @@ -441,7 +438,6 @@ abstract class ChartData> : Serializable { * specified index. Returns true if an Entry was removed, false if no Entry * was found that meets the specified requirements. */ - @SuppressLint("RawTypeDataSet") open fun removeEntry(xValue: Float, dataSetIndex: Int): Boolean { if (dataSetIndex >= dataSets.size) { return false @@ -560,7 +556,7 @@ abstract class ChartData> : Serializable { /** * Sets the Typeface for all value-labels for all DataSets this data object contains. */ - fun setValueTypeface(tf: Typeface?) { + fun setValueTypeface(tf: ChartTypeface?) { for (set in this.dataSets) { set.valueTypeface = tf } diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/DataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/DataSet.kt similarity index 95% rename from chartLib/src/main/kotlin/info/appdev/charting/data/DataSet.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/DataSet.kt index 4073809f3..2ab25b69e 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/DataSet.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/DataSet.kt @@ -1,8 +1,5 @@ package info.appdev.charting.data -import android.annotation.SuppressLint -import timber.log.Timber -import java.io.Serializable import kotlin.math.abs /** @@ -14,7 +11,7 @@ import kotlin.math.abs abstract class DataSet>( protected var entriesInternal: MutableList, label: String = "" -) : BaseDataSet(label), Serializable { +) : BaseDataSet(label) { /** * maximum y-value in the value array */ @@ -131,7 +128,7 @@ abstract class DataSet>( */ abstract fun copy(): DataSet? - protected fun copy(@SuppressLint("RawTypeDataSet") dataSet: DataSet<*>) { + protected fun copy(dataSet: DataSet<*>) { super.copy(dataSet) } @@ -199,10 +196,10 @@ abstract class DataSet>( override fun getEntryForIndex(index: Int): T? { if (index < 0) { - Timber.e("index $index is < 0 for getEntryForIndex") + println("index $index is < 0 for getEntryForIndex") return null } else if (index >= entriesInternal.size) { - Timber.e("index $index / ${entriesInternal.size} is out of range for getEntryForIndex") + println("index $index / ${entriesInternal.size} is out of range for getEntryForIndex") return null } return entriesInternal[index] diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/Entry.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/Entry.kt similarity index 74% rename from chartLib/src/main/kotlin/info/appdev/charting/data/Entry.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/Entry.kt index 9bc6dd11b..584fcb61c 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/Entry.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/Entry.kt @@ -1,16 +1,6 @@ package info.appdev.charting.data -import android.R.attr.data -import android.R.attr.x -import android.R.attr.y -import android.graphics.drawable.Drawable -import android.os.Build -import android.os.Parcel -import android.os.ParcelFormatException -import android.os.Parcelable -import info.appdev.charting.utils.Utils -import java.io.Serializable -import kotlin.math.abs +import info.appdev.charting.utils.ChartIcon /** * Class representing one entry in the chart. Might contain multiple values. @@ -48,7 +38,7 @@ class Entry : EntryFloat { * @param y the y value (the actual value of the entry) * @param icon icon image */ - constructor(x: Float, y: Float, icon: Drawable?) : super(x = x, y = y, icon = icon) + constructor(x: Float, y: Float, icon: ChartIcon?) : super(x = x, y = y, icon = icon) /** * An EntryFloat represents one single entry in the chart. @@ -58,6 +48,6 @@ class Entry : EntryFloat { * @param icon icon image * @param data Spot for additional data this EntryFloat represents. */ - constructor(x: Float, y: Float, icon: Drawable?, data: Any?) : super(x = x, y = y, icon = icon, data = data) + constructor(x: Float, y: Float, icon: ChartIcon?, data: Any?) : super(x = x, y = y, icon = icon, data = data) } diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/EntryDouble.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/EntryDouble.kt similarity index 70% rename from chartLib/src/main/kotlin/info/appdev/charting/data/EntryDouble.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/EntryDouble.kt index 31b3138a9..63298550e 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/EntryDouble.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/EntryDouble.kt @@ -1,17 +1,14 @@ package info.appdev.charting.data -import android.graphics.drawable.Drawable -import android.os.Build -import java.io.Serializable +import info.appdev.charting.utils.ChartIcon import kotlin.math.abs -import info.appdev.charting.utils.Utils /** * High-precision entry that stores x and y as Double, but extends EntryFloat * so it works seamlessly in the existing chart rendering pipeline. * Use [xDouble] and [yDouble] to access the full-precision values. */ -open class EntryDouble : EntryFloat, Serializable { +open class EntryDouble : EntryFloat { var xDouble: Double = 0.0 var yDouble: Double = 0.0 @@ -49,13 +46,13 @@ open class EntryDouble : EntryFloat, Serializable { this.data = data } - constructor(x: Double, y: Double, icon: Drawable?) : super() { + constructor(x: Double, y: Double, icon: ChartIcon?) : super() { this.xDouble = x this.yDouble = y this.icon = icon } - constructor(x: Double, y: Double, icon: Drawable?, data: Any?) : super() { + constructor(x: Double, y: Double, icon: ChartIcon?, data: Any?) : super() { this.xDouble = x this.yDouble = y this.icon = icon @@ -67,16 +64,12 @@ open class EntryDouble : EntryFloat, Serializable { fun equalTo(other: EntryDouble?): Boolean { if (other == null) return false if (other.data !== this.data) return false - if (abs(other.xDouble - this.xDouble) > Utils.DOUBLE_EPSILON) return false - if (abs(other.yDouble - this.yDouble) > Utils.DOUBLE_EPSILON) return false + if (abs(other.xDouble - this.xDouble) > ENTRY_DOUBLE_EPSILON) return false + if (abs(other.yDouble - this.yDouble) > ENTRY_DOUBLE_EPSILON) return false return true } override fun toString(): String { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - "${this.javaClass.typeName.substringAfterLast(".")} xDouble=$xDouble yDouble=$yDouble" - } else { - "EntryDouble xDouble=$xDouble yDouble=$yDouble" - } + return "${this::class.simpleName} xDouble=$xDouble yDouble=$yDouble" } } diff --git a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/EntryFloat.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/EntryFloat.kt new file mode 100644 index 000000000..fafa6b2f9 --- /dev/null +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/EntryFloat.kt @@ -0,0 +1,95 @@ +package info.appdev.charting.data + +import info.appdev.charting.utils.ChartIcon +import kotlin.math.abs + +/** Smallest positive Float greater than zero, used for approximate equality checks. */ +internal val ENTRY_FLOAT_EPSILON: Float = Float.fromBits(1) + +/** Smallest positive Double greater than zero, used for approximate equality checks. */ +internal val ENTRY_DOUBLE_EPSILON: Double = Double.fromBits(1L) + +/** + * Class representing one entry in the chart. Might contain multiple values. + * Might only contain a single value depending on the used constructor. + */ +open class EntryFloat : BaseEntry { + + constructor() + + /** + * An EntryFloat represents one single entry in the chart. + * + * @param x the x value + * @param y the y value (the actual value of the entry) + */ + constructor(x: Float, y: Float) : super(x = x, y = y) + + /** + * An EntryFloat represents one single entry in the chart. + * + * @param x the x value + * @param y the y value (the actual value of the entry) + * @param data Spot for additional data this Entry represents. + */ + constructor(x: Float, y: Float, data: Any?) : super(x = x, y = y, data = data) + + /** + * An EntryFloat represents one single entry in the chart. + * + * @param x the x value + * @param y the y value (the actual value of the entry) + * @param icon icon image + */ + constructor(x: Float, y: Float, icon: ChartIcon?) : super(x = x, y = y, icon = icon) + + /** + * An EntryFloat represents one single entry in the chart. + * + * @param x the x value + * @param y the y value (the actual value of the entry) + * @param icon icon image + * @param data Spot for additional data this EntryFloat represents. + */ + constructor(x: Float, y: Float, icon: ChartIcon?, data: Any?) : super(x = x, y = y, icon = icon, data = data) + + /** + * returns an exact copy of the entry + */ + open fun copy(): EntryFloat { + val e = EntryFloat( + x = x, + y = y, + data = data + ) + return e + } + + /** + * Compares value, xIndex and data of the entries. Returns true if entries + * are equal in those points, false if not. Does not check by hash-code like + * it's done by the "equals" method. + */ + fun equalTo(entryFloat: EntryFloat?): Boolean { + if (entryFloat == null) + return false + + if (entryFloat.data !== this.data) + return false + + if (abs((entryFloat.x - this.x).toDouble()) > ENTRY_FLOAT_EPSILON) + return false + + if (abs((entryFloat.y - this.y).toDouble()) > ENTRY_FLOAT_EPSILON) + return false + + return true + } + + /** + * returns a string representation of the entry containing x-index and value + */ + override fun toString(): String { + return "${this::class.simpleName} x=$x y=$y" + } +} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/LineDataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/LineDataSet.kt similarity index 76% rename from chartLib/src/main/kotlin/info/appdev/charting/data/LineDataSet.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/LineDataSet.kt index 3c369112d..21c4bd3bd 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/LineDataSet.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/LineDataSet.kt @@ -1,18 +1,24 @@ package info.appdev.charting.data -import android.annotation.SuppressLint -import android.content.Context -import android.graphics.Color -import android.graphics.DashPathEffect -import androidx.annotation.ColorInt -import androidx.core.content.ContextCompat -import info.appdev.charting.formatter.DefaultFillFormatter -import info.appdev.charting.formatter.IFillFormatter import info.appdev.charting.interfaces.datasets.ILineDataSet import info.appdev.charting.utils.ColorTemplate +import info.appdev.charting.utils.DashEffect import info.appdev.charting.utils.convertDpToPixel -import timber.log.Timber +/** + * LineDataSet describes a line-chart-able DataSet. + * + * Two Android-only members were removed in favor of side-channels defined in + * `chartLib` (see `LineDataSetAndroid.kt`), since neither has a portable KMP + * equivalent: + * - `fillFormatter` (an [info.appdev.charting.formatter.IFillFormatter]) is now + * an extension property. As a result, `.copy()` no longer propagates a + * custom `fillFormatter` to the copy (it did before, along with the other + * fields in the old `copy(LineDataSet)` helper). + * - `setCircleColors(colors: IntArray, context: Context)`, the + * `ContextCompat.getColor(...)`-resolving overload, is now an extension + * function taking a `LineDataSet<*>` receiver. + */ open class LineDataSet>(yVals: MutableList = mutableListOf(), label: String = "") : LineRadarDataSet(yVals, label), ILineDataSet { /** * Drawing mode for this line dataset @@ -26,14 +32,12 @@ open class LineDataSet>(yVals: MutableList = mutableList * are already prepared (by calling getResources().getColor(...)) before * adding them to the DataSet. */ - @ColorInt var circleColors: MutableList = mutableListOf() /** * the color of the inner circles */ - @ColorInt - private var mCircleHoleColor = Color.WHITE + private var mCircleHoleColor = ColorTemplate.WHITE /** * the radius of the circle-shaped value indicators @@ -53,12 +57,7 @@ open class LineDataSet>(yVals: MutableList = mutableList /** * the path effect of this DataSet that makes dashed lines possible */ - private var mDashPathEffect: DashPathEffect? = null - - /** - * formatter for customizing the position of the fill-line - */ - private var mFillFormatter: IFillFormatter? = DefaultFillFormatter() + private var mDashPathEffect: DashEffect? = null /** * if true, drawing circles is enabled @@ -71,7 +70,7 @@ open class LineDataSet>(yVals: MutableList = mutableList // default colors // mColors.add(Color.rgb(192, 255, 140)); // mColors.add(Color.rgb(255, 247, 140)); - circleColors.add(Color.rgb(140, 234, 255)) + circleColors.add(ColorTemplate.argb(140, 234, 255)) } @Suppress("UNCHECKED_CAST") @@ -85,7 +84,7 @@ open class LineDataSet>(yVals: MutableList = mutableList return copied as DataSet } - protected fun copy(@SuppressLint("RawTypeDataSet") lineDataSet: LineDataSet<*>) { + protected fun copy(lineDataSet: LineDataSet<*>) { super.copy((lineDataSet as BaseDataSet<*>?)!!) lineDataSet.circleColors = this.circleColors lineDataSet.mCircleHoleColor = mCircleHoleColor @@ -95,7 +94,6 @@ open class LineDataSet>(yVals: MutableList = mutableList lineDataSet.mDashPathEffect = mDashPathEffect lineDataSet.mDrawCircleHole = mDrawCircleHole lineDataSet.mDrawCircles = mDrawCircleHole - lineDataSet.mFillFormatter = mFillFormatter lineDataSet.mLineDataSetMode = mLineDataSetMode } @@ -109,7 +107,7 @@ open class LineDataSet>(yVals: MutableList = mutableList * @param phase offset, in degrees (normally, use 0) */ fun enableDashedLine(lineLength: Float, spaceLength: Float, phase: Float) { - mDashPathEffect = DashPathEffect( + mDashPathEffect = DashEffect( floatArrayOf( lineLength, spaceLength ), phase @@ -156,7 +154,7 @@ open class LineDataSet>(yVals: MutableList = mutableList if (value >= 1f) { mCircleRadius = value.convertDpToPixel() } else { - Timber.e("Circle radius cannot be < 1") + println("Circle radius cannot be < 1") } } @@ -170,7 +168,7 @@ open class LineDataSet>(yVals: MutableList = mutableList if (value >= 0.5f) { mCircleHoleRadius = value.convertDpToPixel() } else { - Timber.e("Circle radius cannot be < 0.5") + println("Circle radius cannot be < 0.5") } } @@ -196,7 +194,7 @@ open class LineDataSet>(yVals: MutableList = mutableList set(value) { mDrawCircleHole = value } - override var dashPathEffect: DashPathEffect? + override var dashPathEffect: DashEffect? get() = mDashPathEffect set(value) { mDashPathEffect = value @@ -211,16 +209,6 @@ open class LineDataSet>(yVals: MutableList = mutableList mDashPathEffect = null } - /** - * Sets a custom IFillFormatter to the chart that handles the position of the - * filled-line for each DataSet. Set this to null to use the default logic. - */ - override var fillFormatter: IFillFormatter? - get() = mFillFormatter - set(value) { - mFillFormatter = value ?: DefaultFillFormatter() - } - /** * Sets the colors that should be used for the circles of this DataSet. * Colors are reused as soon as the number of Entries the DataSet represents @@ -232,25 +220,6 @@ open class LineDataSet>(yVals: MutableList = mutableList this.circleColors = ColorTemplate.createColors(colors) } - /** - * ets the colors that should be used for the circles of this DataSet. - * Colors are reused as soon as the number of Entries the DataSet represents - * is higher than the size of the colors array. You can use - * "new String[] { R.color.red, R.color.green, ... }" to provide colors for - * this method. Internally, the colors are resolved using - * getResources().getColor(...) - */ - fun setCircleColors(@ColorInt colors: IntArray, context: Context) { - val clrs = this.circleColors - clrs.clear() - - for (color in colors) { - clrs.add(ContextCompat.getColor(context, color)) - } - - this.circleColors = clrs - } - /** * Sets the one and ONLY color that should be used for this DataSet. * Internally, this recreates the colors array and adds the specified color. diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/LineRadarDataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/LineRadarDataSet.kt similarity index 74% rename from chartLib/src/main/kotlin/info/appdev/charting/data/LineRadarDataSet.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/LineRadarDataSet.kt index c1b270955..fb28bd264 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/LineRadarDataSet.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/LineRadarDataSet.kt @@ -1,29 +1,25 @@ package info.appdev.charting.data -import android.graphics.Color -import android.graphics.drawable.Drawable -import androidx.annotation.ColorInt import info.appdev.charting.interfaces.datasets.ILineRadarDataSet +import info.appdev.charting.utils.ColorTemplate import info.appdev.charting.utils.convertDpToPixel /** * Base dataset for line and radar DataSets. + * + * Note: the Android-only `fillDrawable` property (drawable-based line/area fill, as opposed + * to a solid [fillColor]) lives outside this common class, as an extension property in + * `chartLib`'s `LineRadarDataSetAndroid.kt` (`android.graphics.drawable.Drawable` has no + * portable equivalent). As a result, setting [fillColor] here no longer implicitly clears a + * previously set `fillDrawable`, and [copy] no longer propagates `fillDrawable` to the copy; + * callers relying on either behavior should set `fillDrawable` explicitly. */ abstract class LineRadarDataSet>(yVals: MutableList, label: String) : LineScatterCandleRadarDataSet(yVals, label), ILineRadarDataSet { // TODO: Move to using `Fill` class /** * the color that is used for filling the line surface */ - @ColorInt - private var mFillColor = Color.rgb(140, 234, 255) - - /** - * Sets the drawable to be used to fill the area below the line. - */ - /** - * the drawable to be used for filling the line surface - */ - override var fillDrawable: Drawable? = null + private var mFillColor = ColorTemplate.argb(140, 234, 255) /** * sets the alpha value (transparency) that is used for filling the line @@ -48,11 +44,9 @@ abstract class LineRadarDataSet>(yVals: MutableList, lab get() = mFillColor /** * Sets the color that is used for filling the area below the line. - * Resets an eventually set "fillDrawable". */ set(color) { mFillColor = color - this.fillDrawable = null } override var lineWidth: Float @@ -73,7 +67,6 @@ abstract class LineRadarDataSet>(yVals: MutableList, lab lineRadarDataSet.isDrawFilled = this.isDrawFilled lineRadarDataSet.fillAlpha = this.fillAlpha lineRadarDataSet.mFillColor = mFillColor - lineRadarDataSet.fillDrawable = this.fillDrawable lineRadarDataSet.mLineWidth = mLineWidth } } diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/LineScatterCandleRadarDataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/LineScatterCandleRadarDataSet.kt similarity index 93% rename from chartLib/src/main/kotlin/info/appdev/charting/data/LineScatterCandleRadarDataSet.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/LineScatterCandleRadarDataSet.kt index 5a2a9a811..81d46bb6d 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/LineScatterCandleRadarDataSet.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/LineScatterCandleRadarDataSet.kt @@ -1,7 +1,7 @@ package info.appdev.charting.data -import android.graphics.DashPathEffect import info.appdev.charting.interfaces.datasets.ILineScatterCandleRadarDataSet +import info.appdev.charting.utils.DashEffect import info.appdev.charting.utils.convertDpToPixel abstract class LineScatterCandleRadarDataSet>(yVals: MutableList, label: String) : BarLineScatterCandleBubbleDataSet(yVals, label), @@ -12,8 +12,8 @@ abstract class LineScatterCandleRadarDataSet>(yVals: Mutabl /** the width of the highlight indicator lines */ protected var mHighlightLineWidth: Float - /** the path effect for dashed highlight-lines */ - override var dashPathEffectHighlight: DashPathEffect? = null + /** the dash effect for dashed highlight-lines */ + override var dashPathEffectHighlight: DashEffect? = null protected set init { @@ -58,7 +58,7 @@ abstract class LineScatterCandleRadarDataSet>(yVals: Mutabl * @param phase offset, in degrees (normally, use 0) */ fun enableDashedHighlightLine(lineLength: Float, spaceLength: Float, phase: Float) { - this.dashPathEffectHighlight = DashPathEffect( + this.dashPathEffectHighlight = DashEffect( floatArrayOf(lineLength, spaceLength), phase ) diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/PieDataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/PieDataSet.kt similarity index 98% rename from chartLib/src/main/kotlin/info/appdev/charting/data/PieDataSet.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/PieDataSet.kt index aa0161ee4..83fe37c7a 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/PieDataSet.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/PieDataSet.kt @@ -1,6 +1,5 @@ package info.appdev.charting.data -import androidx.annotation.ColorInt import info.appdev.charting.interfaces.datasets.IPieDataSet import info.appdev.charting.utils.convertDpToPixel @@ -26,7 +25,6 @@ open class PieDataSet(yVals: MutableList, label: String) : DataSe private var mValueLinePart2Length = 0.4f private var mIsValueLineVariableLength = true - @ColorInt private var mHighlightColor: Int? = null override fun copy(): DataSet { diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/PieEntry.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/PieEntry.kt similarity index 65% rename from chartLib/src/main/kotlin/info/appdev/charting/data/PieEntry.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/PieEntry.kt index dafa6e378..45e1a95c8 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/PieEntry.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/PieEntry.kt @@ -1,6 +1,6 @@ package info.appdev.charting.data -import android.graphics.drawable.Drawable +import info.appdev.charting.utils.ChartIcon @Deprecated( message = "The replacement is PieEntryFloat, or use PieEntryDouble for higher precision. PieEntry is retained for backward compatibility but will be removed in a future version.", @@ -12,9 +12,9 @@ class PieEntry : PieEntryFloat { constructor(value: Float, data: Any?) : super(value, data) - constructor(value: Float, icon: Drawable?) : super(value, icon) + constructor(value: Float, icon: ChartIcon?) : super(value, icon) - constructor(value: Float, icon: Drawable?, data: Any?) : super(value, icon, data) + constructor(value: Float, icon: ChartIcon?, data: Any?) : super(value, icon, data) constructor(value: Float, label: String?) : super(value) { this.label = label @@ -24,11 +24,11 @@ class PieEntry : PieEntryFloat { this.label = label } - constructor(value: Float, label: String?, icon: Drawable?) : super(value, icon) { + constructor(value: Float, label: String?, icon: ChartIcon?) : super(value, icon) { this.label = label } - constructor(value: Float, label: String?, icon: Drawable?, data: Any?) : super(value, icon, data) { + constructor(value: Float, label: String?, icon: ChartIcon?, data: Any?) : super(value, icon, data) { this.label = label } diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/PieEntryDouble.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/PieEntryDouble.kt similarity index 100% rename from chartLib/src/main/kotlin/info/appdev/charting/data/PieEntryDouble.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/PieEntryDouble.kt diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/PieEntryFloat.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/PieEntryFloat.kt similarity index 56% rename from chartLib/src/main/kotlin/info/appdev/charting/data/PieEntryFloat.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/PieEntryFloat.kt index 2789fac5a..ec9147b45 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/PieEntryFloat.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/PieEntryFloat.kt @@ -1,10 +1,7 @@ package info.appdev.charting.data -import android.annotation.SuppressLint -import android.graphics.drawable.Drawable -import timber.log.Timber +import info.appdev.charting.utils.ChartIcon -@SuppressLint("ParcelCreator") open class PieEntryFloat : EntryFloat { var label: String? = null @@ -12,9 +9,9 @@ open class PieEntryFloat : EntryFloat { constructor(value: Float, data: Any?) : super(0f, value, data) - constructor(value: Float, icon: Drawable?) : super(0f, value, icon) + constructor(value: Float, icon: ChartIcon?) : super(0f, value, icon) - constructor(value: Float, icon: Drawable?, data: Any?) : super(0f, value, icon, data) + constructor(value: Float, icon: ChartIcon?, data: Any?) : super(0f, value, icon, data) constructor(value: Float, label: String?) : super(0f, value) { this.label = label @@ -24,11 +21,11 @@ open class PieEntryFloat : EntryFloat { this.label = label } - constructor(value: Float, label: String?, icon: Drawable?) : super(0f, value, icon) { + constructor(value: Float, label: String?, icon: ChartIcon?) : super(0f, value, icon) { this.label = label } - constructor(value: Float, label: String?, icon: Drawable?, data: Any?) : super(0f, value, icon, data) { + constructor(value: Float, label: String?, icon: ChartIcon?, data: Any?) : super(0f, value, icon, data) { this.label = label } @@ -41,13 +38,9 @@ open class PieEntryFloat : EntryFloat { @get:Deprecated("") @set:Deprecated("") override var x: Float - get() { - Timber.i("Pie entries do not have x values") - return super.x - } + get() = super.x set(x) { super.x = x - Timber.i("Pie entries do not have x values") } override fun copy(): PieEntryFloat { diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/RadarDataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/RadarDataSet.kt similarity index 94% rename from chartLib/src/main/kotlin/info/appdev/charting/data/RadarDataSet.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/RadarDataSet.kt index d4d3ce261..8108a22c5 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/RadarDataSet.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/RadarDataSet.kt @@ -1,7 +1,5 @@ package info.appdev.charting.data -import android.graphics.Color -import androidx.annotation.ColorInt import info.appdev.charting.interfaces.datasets.IRadarDataSet import info.appdev.charting.utils.ColorTemplate @@ -9,12 +7,10 @@ open class RadarDataSet(yVals: MutableList, label: String = "") /** flag indicating whether highlight circle should be drawn or not */ protected var mIsDrawHighlightCircle: Boolean = false - @ColorInt - protected var mHighlightCircleFillColor: Int = Color.WHITE + protected var mHighlightCircleFillColor: Int = ColorTemplate.argb(255, 255, 255) /** The stroke color for highlight circle. * If Utils.COLOR_NONE, the color of the dataset is taken. */ - @ColorInt protected var mHighlightCircleStrokeColor: Int = ColorTemplate.COLOR_NONE protected var mHighlightCircleStrokeAlpha: Int = (0.3 * 255).toInt() diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/RadarEntry.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/RadarEntry.kt similarity index 100% rename from chartLib/src/main/kotlin/info/appdev/charting/data/RadarEntry.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/RadarEntry.kt diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/RadarEntryDouble.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/RadarEntryDouble.kt similarity index 94% rename from chartLib/src/main/kotlin/info/appdev/charting/data/RadarEntryDouble.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/RadarEntryDouble.kt index 03080dfee..913c17457 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/RadarEntryDouble.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/RadarEntryDouble.kt @@ -1,6 +1,5 @@ package info.appdev.charting.data -import android.annotation.SuppressLint /** * High-precision radar entry that stores the value as Double, extending [RadarEntryFloat] @@ -9,7 +8,6 @@ import android.annotation.SuppressLint * Radar entries have no meaningful x-axis value. */ @Suppress("DEPRECATION") -@SuppressLint("ParcelCreator") open class RadarEntryDouble : RadarEntryFloat { var valueDouble: Double = 0.0 diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/RadarEntryFloat.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/RadarEntryFloat.kt similarity index 88% rename from chartLib/src/main/kotlin/info/appdev/charting/data/RadarEntryFloat.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/RadarEntryFloat.kt index ad232d94e..29ca4c097 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/RadarEntryFloat.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/RadarEntryFloat.kt @@ -1,8 +1,6 @@ package info.appdev.charting.data -import android.annotation.SuppressLint -@SuppressLint("ParcelCreator") open class RadarEntryFloat : EntryFloat { constructor(value: Float) : super(0f, value) diff --git a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/ScatterDataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/ScatterDataSet.kt new file mode 100644 index 000000000..329e93df2 --- /dev/null +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/ScatterDataSet.kt @@ -0,0 +1,76 @@ +package info.appdev.charting.data + +import info.appdev.charting.interfaces.datasets.IScatterDataSet +import info.appdev.charting.utils.ColorTemplate + +/** + * ScatterDataSet describes a scatter-chart-able DataSet. + * + * `setScatterShape(shape)`/`shapeRenderer`/`getRendererForShape(shape)` were removed from common + * code entirely and replaced with Android-only side-channels in `chartLib`'s + * `ScatterDataSetAndroid.kt`, since `IShapeRenderer` (the renderer interface these deal with) is + * fundamentally Canvas/Paint-coupled and has no portable equivalent (same category of drawing + * logic as the `Renderer`/`DataRenderer` hierarchy, permanently Android-only per Step A.4): + * - `shapeRenderer` is now a `WeakHashMap`-backed extension property on `IScatterDataSet`, same + * side-channel pattern as `ILineRadarDataSet.fillDrawable`/`ILineDataSet.fillFormatter`. As a + * result, `.copy()` no longer propagates a custom `shapeRenderer` to the copy. + * - `setScatterShape(shape: ScatterShape)` is now an extension function on `ScatterDataSet`. + * - `getRendererForShape(shape: ScatterShape)` is now a top-level function in `chartLib` (was + * previously `ScatterDataSet.getRendererForShape(shape)` on the companion object). + */ +open class ScatterDataSet(yVals: MutableList, label: String = "") : LineScatterCandleRadarDataSet(yVals, label), IScatterDataSet { + /** + * the size the scatterShape will have, in density pixels + */ + private var shapeSize = 15f + + /** + * The radius of the hole in the shape (applies to Square, Circle and Triangle) + * - default: 0.0 + */ + private var mScatterShapeHoleRadius = 0f + + /** + * Color for the hole in the shape. + * Setting to `ColorTemplate.COLOR_NONE` will behave as transparent. + * - default: ColorTemplate.COLOR_NONE + */ + private var mScatterShapeHoleColor = ColorTemplate.COLOR_NONE + + override fun copy(): DataSet { + val entries: MutableList = mutableListOf() + for (i in entriesInternal.indices) { + entries.add(entriesInternal[i].copy()) + } + val copied = ScatterDataSet(entries, label) + copy(copied) + return copied + } + + protected fun copy(scatterDataSet: ScatterDataSet) { + super.copy((scatterDataSet as BaseDataSet<*>?)!!) + scatterDataSet.shapeSize = shapeSize + scatterDataSet.mScatterShapeHoleRadius = mScatterShapeHoleRadius + scatterDataSet.mScatterShapeHoleColor = mScatterShapeHoleColor + } + + /** + * Sets the size in density pixels the drawn scatterShape will have. This + * only applies for non custom shapes. + */ + override var scatterShapeSize: Float + get() = shapeSize + set(value) { + shapeSize = value + } + override var scatterShapeHoleRadius: Float + get() = mScatterShapeHoleRadius + set(value) { + mScatterShapeHoleRadius = value + } + override var scatterShapeHoleColor: Int + get() = mScatterShapeHoleColor + set(value) { + mScatterShapeHoleColor = value + } +} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/formatter/ColorFormatter.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/ColorFormatter.kt similarity index 100% rename from chartLib/src/main/kotlin/info/appdev/charting/formatter/ColorFormatter.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/ColorFormatter.kt diff --git a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/DefaultAxisValueFormatter.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/DefaultAxisValueFormatter.kt new file mode 100644 index 000000000..b14a367ad --- /dev/null +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/DefaultAxisValueFormatter.kt @@ -0,0 +1,18 @@ +package info.appdev.charting.formatter + +import info.appdev.charting.components.AxisBase +import info.appdev.charting.utils.formatGroupedDecimal + +open class DefaultAxisValueFormatter(digits: Int) : IAxisValueFormatter { + /** + * The number of decimal digits this formatter uses or -1, if unspecified. + */ + var decimalDigits = digits + protected set + + override fun getFormattedValue(value: Float, axis: AxisBase?): String { + // avoid memory allocations here (for performance) + return formatGroupedDecimal(value.toDouble(), decimalDigits) + } +} + diff --git a/chartLib/src/main/kotlin/info/appdev/charting/formatter/DefaultValueFormatter.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/DefaultValueFormatter.kt similarity index 65% rename from chartLib/src/main/kotlin/info/appdev/charting/formatter/DefaultValueFormatter.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/DefaultValueFormatter.kt index 1164c220a..58a581426 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/formatter/DefaultValueFormatter.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/DefaultValueFormatter.kt @@ -2,18 +2,15 @@ package info.appdev.charting.formatter import info.appdev.charting.data.EntryFloat import info.appdev.charting.utils.ViewPortHandler -import java.text.DecimalFormat +import info.appdev.charting.utils.formatGroupedDecimal /** - * Default formatter used for formatting values inside the chart. Uses a DecimalFormat with - * pre-calculated number of digits (depending on max and min value). + * Default formatter used for formatting values inside the chart. Formats values with a + * pre-calculated number of decimal digits and groups the integer part with thousands + * separators, platform-independent replacement for the former `java.text.DecimalFormat`-based + * implementation (e.g. pattern "###,###,###,##0.00"). */ open class DefaultValueFormatter(digits: Int) : IValueFormatter { - /** - * DecimalFormat for formatting - */ - protected var decimalFormat: DecimalFormat? = null - /** * Returns the number of decimal digits this formatter uses. */ @@ -34,18 +31,11 @@ open class DefaultValueFormatter(digits: Int) : IValueFormatter { */ fun setup(digits: Int) { decimalDigits = digits - val b = StringBuffer() - for (i in 0 until digits) { - if (i == 0) b.append(".") - b.append("0") - } - decimalFormat = DecimalFormat("###,###,###,##0$b") } - override fun getFormattedValue(value: Float, entryFloat: EntryFloat?, dataSetIndex: Int, viewPortHandler: ViewPortHandler?): String? { - + override fun getFormattedValue(value: Float, entryFloat: EntryFloat?, dataSetIndex: Int, viewPortHandler: ViewPortHandler?): String { // put more logic here ... // avoid memory allocations here (for performance reasons) - return decimalFormat!!.format(value.toDouble()) + return formatGroupedDecimal(value.toDouble(), decimalDigits) } -} \ No newline at end of file +} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/formatter/IAxisValueFormatter.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/IAxisValueFormatter.kt similarity index 100% rename from chartLib/src/main/kotlin/info/appdev/charting/formatter/IAxisValueFormatter.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/IAxisValueFormatter.kt diff --git a/chartLib/src/main/kotlin/info/appdev/charting/formatter/IValueFormatter.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/IValueFormatter.kt similarity index 100% rename from chartLib/src/main/kotlin/info/appdev/charting/formatter/IValueFormatter.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/IValueFormatter.kt diff --git a/chartLib/src/main/kotlin/info/appdev/charting/formatter/IndexAxisValueFormatter.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/IndexAxisValueFormatter.kt similarity index 100% rename from chartLib/src/main/kotlin/info/appdev/charting/formatter/IndexAxisValueFormatter.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/IndexAxisValueFormatter.kt diff --git a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/LargeValueFormatter.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/LargeValueFormatter.kt new file mode 100644 index 000000000..4d49a092e --- /dev/null +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/LargeValueFormatter.kt @@ -0,0 +1,149 @@ +package info.appdev.charting.formatter + +import info.appdev.charting.components.AxisBase +import info.appdev.charting.data.EntryFloat +import info.appdev.charting.utils.ViewPortHandler +import kotlin.math.abs +import kotlin.math.floor +import kotlin.math.log10 +import kotlin.math.pow +import kotlin.math.roundToLong + +/** + * Predefined value-formatter that formats large numbers in a pretty way. + * Outputs: 856 = 856; 1000 = 1k; 5821 = 5.8k; 10500 = 10k; 101800 = 102k; + * 2000000 = 2m; 7800000 = 7.8m; 92150000 = 92m; 123200000 = 123m; 9999999 = + * 10m; 1000000000 = 1b; + * Special thanks to Roman Gromov + * (https://github.com/romangromov) for this piece of code. + */ +open class LargeValueFormatter() : IValueFormatter, IAxisValueFormatter { + + private var suffix = arrayOf( + "", "k", "m", "b", "t" + ) + private var maxLength = 5 + private var text = "" + + /** + * Creates a formatter that appends a specified text to the result string + * + * @param appendix a text that will be appended + */ + constructor(appendix: String) : this() { + text = appendix + } + + // IValueFormatter + override fun getFormattedValue(value: Float, entryFloat: EntryFloat?, dataSetIndex: Int, viewPortHandler: ViewPortHandler?): String { + return makePretty(value.toDouble()) + text + } + + // IAxisValueFormatter + override fun getFormattedValue(value: Float, axis: AxisBase?): String { + return makePretty(value.toDouble()) + text + } + + /** + * Set an appendix text to be added at the end of the formatted value. + */ + fun setAppendix(appendix: String) { + text = appendix + } + + /** + * Set custom suffix to be appended after the values. + * Default suffix: ["", "k", "m", "b", "t"] + * + * @param suffixArray new suffix + */ + fun setSuffix(suffixArray: Array) { + suffix = suffixArray + } + + fun setMaxLength(max: Int) { + maxLength = max + } + + /** + * Formats the number using engineering notation (exponent forced to a multiple of 3) with + * the mantissa rounded to 3 significant digits, then replaces the exponent with the + * corresponding suffix (k, m, b, t, ...). Platform-independent replacement of the previous + * `java.text.DecimalFormat("###E00")`-based implementation. + */ + private fun makePretty(number: Double): String { + if (number == 0.0) return "0" + + val negative = number < 0 + val absNumber = abs(number) + + // floor(log10(absNumber)), self-corrected for floating point imprecision + var exponent = floor(log10(absNumber)).toInt() + if (10.0.pow(exponent) > absNumber) exponent-- + if (10.0.pow(exponent + 1) <= absNumber) exponent++ + + // round down to the nearest (lower or equal) multiple of 3, engineering-notation style + var engineeringExponent = floorDiv3(exponent) * 3 + var mantissa = absNumber / 10.0.pow(engineeringExponent) + + // number of integer digits in the mantissa (1..3), used to know how many fractional + // digits are still needed to reach 3 significant digits in total + var integerDigits = digitCount(mantissa) + var fractionDigits = (3 - integerDigits).coerceAtLeast(0) + mantissa = roundTo(mantissa, fractionDigits) + + // rounding may have pushed the mantissa to 1000 or beyond (e.g. 999.999 -> 1000.0), + // in which case the exponent bracket needs to shift up by one step + if (mantissa >= 1000.0) { + engineeringExponent += 3 + mantissa /= 1000.0 + integerDigits = digitCount(mantissa) + fractionDigits = (3 - integerDigits).coerceAtLeast(0) + mantissa = roundTo(mantissa, fractionDigits) + } + + val suffixIndex = engineeringExponent / 3 + val suffixText = if (suffixIndex in suffix.indices) suffix[suffixIndex] else "" + + val mantissaString = stripTrailingZeros(formatFixed(mantissa, fractionDigits)) + + return (if (negative) "-" else "") + mantissaString + suffixText + } + + private fun floorDiv3(value: Int): Int { + val quotient = value / 3 + return if (value % 3 != 0 && value < 0) quotient - 1 else quotient + } + + private fun digitCount(mantissa: Double): Int { + var digits = 1 + var threshold = 10.0 + while (mantissa >= threshold) { + digits++ + threshold *= 10.0 + } + return digits + } + + private fun roundTo(value: Double, digits: Int): Double { + val factor = 10.0.pow(digits) + return (value * factor).roundToLong() / factor + } + + private fun formatFixed(value: Double, digits: Int): String { + if (digits <= 0) return value.roundToLong().toString() + + val factor = 10.0.pow(digits) + val scaled = (value * factor).roundToLong() + val intPart = scaled / factor.toLong() + val fracPart = scaled % factor.toLong() + return "$intPart." + fracPart.toString().padStart(digits, '0') + } + + private fun stripTrailingZeros(value: String): String { + if (!value.contains('.')) return value + var result = value.trimEnd('0') + if (result.endsWith('.')) result = result.dropLast(1) + return result + } +} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/formatter/PercentFormatter.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/PercentFormatter.kt similarity index 58% rename from chartLib/src/main/kotlin/info/appdev/charting/formatter/PercentFormatter.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/PercentFormatter.kt index e785c9833..bd5a668e3 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/formatter/PercentFormatter.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/PercentFormatter.kt @@ -3,35 +3,34 @@ package info.appdev.charting.formatter import info.appdev.charting.components.AxisBase import info.appdev.charting.data.EntryFloat import info.appdev.charting.utils.ViewPortHandler -import java.text.DecimalFormat +import info.appdev.charting.utils.formatGroupedDecimal /** * This IValueFormatter is just for convenience and simply puts a "%" sign after each value. (Recommended for PieChart) */ open class PercentFormatter : IValueFormatter, IAxisValueFormatter { - protected var decimalFormat: DecimalFormat + /** + * The number of decimal digits used when formatting the percentage value. + */ + protected var decimalDigits: Int = 1 - constructor() { - decimalFormat = DecimalFormat("###,###,##0.0") - } + constructor() /** - * Allow a custom decimal format + * Allow a custom number of decimal digits. */ - constructor(format: DecimalFormat) { - decimalFormat = format + constructor(decimalDigits: Int) { + this.decimalDigits = decimalDigits } // IValueFormatter override fun getFormattedValue(value: Float, entryFloat: EntryFloat?, dataSetIndex: Int, viewPortHandler: ViewPortHandler?): String? { - return decimalFormat.format(value.toDouble()) + " %" + return formatGroupedDecimal(value.toDouble(), decimalDigits) + " %" } // IAxisValueFormatter override fun getFormattedValue(value: Float, axis: AxisBase?): String { - return decimalFormat.format(value.toDouble()) + " %" + return formatGroupedDecimal(value.toDouble(), decimalDigits) + " %" } - - val decimalDigits: Int - get() = 1 } + diff --git a/chartLib/src/main/kotlin/info/appdev/charting/formatter/StackedValueFormatter.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/StackedValueFormatter.kt similarity index 72% rename from chartLib/src/main/kotlin/info/appdev/charting/formatter/StackedValueFormatter.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/StackedValueFormatter.kt index 606bfeb0c..17e6e807e 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/formatter/StackedValueFormatter.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/StackedValueFormatter.kt @@ -3,7 +3,7 @@ package info.appdev.charting.formatter import info.appdev.charting.data.BarEntryFloat import info.appdev.charting.data.EntryFloat import info.appdev.charting.utils.ViewPortHandler -import java.text.DecimalFormat +import info.appdev.charting.utils.formatGroupedDecimal /** * A formatter specifically for stacked BarChart that allows to specify whether the all stack values @@ -16,18 +16,7 @@ import java.text.DecimalFormat * @param appendix a string that should be appended behind the value * @param decimals the number of decimal digits to use */ -open class StackedValueFormatter(private val drawWholeStack: Boolean, private val appendix: String, decimals: Int) : IValueFormatter { - private val decimalFormat: DecimalFormat - - init { - val b = StringBuffer() - for (i in 0 until decimals) { - if (i == 0) b.append(".") - b.append("0") - } - - this.decimalFormat = DecimalFormat("###,###,###,##0$b") - } +open class StackedValueFormatter(private val drawWholeStack: Boolean, private val appendix: String, private val decimals: Int) : IValueFormatter { override fun getFormattedValue(value: Float, entryFloat: EntryFloat?, dataSetIndex: Int, viewPortHandler: ViewPortHandler?): String { if (!drawWholeStack && entryFloat is BarEntryFloat) { @@ -40,7 +29,7 @@ open class StackedValueFormatter(private val drawWholeStack: Boolean, private va return if (vals[vals.size - 1] == value) { // return the "sum" across all stack values - decimalFormat.format(barEntry.y.toDouble()) + appendix + formatGroupedDecimal(barEntry.y.toDouble(), decimals) + appendix } else { "" // return empty } @@ -48,6 +37,6 @@ open class StackedValueFormatter(private val drawWholeStack: Boolean, private va } // return the "proposed" value - return decimalFormat.format(value.toDouble()) + appendix + return formatGroupedDecimal(value.toDouble(), decimals) + appendix } } diff --git a/chartLib/src/main/kotlin/info/appdev/charting/highlight/Highlight.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/highlight/Highlight.kt similarity index 96% rename from chartLib/src/main/kotlin/info/appdev/charting/highlight/Highlight.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/highlight/Highlight.kt index 902fc840c..045c5f2d4 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/highlight/Highlight.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/highlight/Highlight.kt @@ -1,12 +1,11 @@ package info.appdev.charting.highlight -import info.appdev.charting.components.YAxis.AxisDependency -import java.io.Serializable +import info.appdev.charting.components.AxisDependency /** * Contains information needed to determine the highlighted value. */ -class Highlight : Serializable { +class Highlight { /** * the x-value of the highlighted value */ diff --git a/chartLib/src/main/kotlin/info/appdev/charting/highlight/IHighlighter.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/highlight/IHighlighter.kt similarity index 100% rename from chartLib/src/main/kotlin/info/appdev/charting/highlight/IHighlighter.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/highlight/IHighlighter.kt diff --git a/chartLib/src/main/kotlin/info/appdev/charting/highlight/Range.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/highlight/Range.kt similarity index 100% rename from chartLib/src/main/kotlin/info/appdev/charting/highlight/Range.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/highlight/Range.kt diff --git a/chartLib/src/main/kotlin/info/appdev/charting/highlight/RangeDouble.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/highlight/RangeDouble.kt similarity index 100% rename from chartLib/src/main/kotlin/info/appdev/charting/highlight/RangeDouble.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/highlight/RangeDouble.kt diff --git a/chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/IBarDataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/IBarDataSet.kt similarity index 100% rename from chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/IBarDataSet.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/IBarDataSet.kt diff --git a/chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/IBarLineScatterCandleBubbleDataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/IBarLineScatterCandleBubbleDataSet.kt similarity index 100% rename from chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/IBarLineScatterCandleBubbleDataSet.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/IBarLineScatterCandleBubbleDataSet.kt diff --git a/chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/IBubbleDataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/IBubbleDataSet.kt similarity index 100% rename from chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/IBubbleDataSet.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/IBubbleDataSet.kt diff --git a/chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/ICandleDataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/ICandleDataSet.kt similarity index 90% rename from chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/ICandleDataSet.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/ICandleDataSet.kt index ce86fccf2..749ef03c6 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/ICandleDataSet.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/ICandleDataSet.kt @@ -1,6 +1,6 @@ package info.appdev.charting.interfaces.datasets -import android.graphics.Paint +import info.appdev.charting.utils.PaintStyle import info.appdev.charting.data.CandleEntryFloat interface ICandleDataSet : ILineScatterCandleRadarDataSet { @@ -44,12 +44,12 @@ interface ICandleDataSet : ILineScatterCandleRadarDataSet { /** * Returns paint style when open < close */ - val increasingPaintStyle: Paint.Style? + val increasingPaintStyle: PaintStyle? /** * Returns paint style when open > close */ - val decreasingPaintStyle: Paint.Style? + val decreasingPaintStyle: PaintStyle? /** * Is the shadow color same as the candle color? diff --git a/chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/IDataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/IDataSet.kt similarity index 96% rename from chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/IDataSet.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/IDataSet.kt index d0f0ceef2..7b907ab1a 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/IDataSet.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/IDataSet.kt @@ -1,12 +1,12 @@ package info.appdev.charting.interfaces.datasets -import android.graphics.DashPathEffect -import android.graphics.Typeface -import info.appdev.charting.components.Legend -import info.appdev.charting.components.YAxis -import info.appdev.charting.data.DataSet +import info.appdev.charting.components.AxisDependency +import info.appdev.charting.components.LegendForm import info.appdev.charting.data.BaseEntry +import info.appdev.charting.data.DataSet import info.appdev.charting.formatter.IValueFormatter +import info.appdev.charting.utils.ChartTypeface +import info.appdev.charting.utils.DashEffect import info.appdev.charting.utils.PointF interface IDataSet> { @@ -186,7 +186,7 @@ interface IDataSet> { /** * Set the y-axis this DataSet should be plotted against (either LEFT or RIGHT). Default: LEFT */ - var axisDependency: YAxis.AxisDependency + var axisDependency: AxisDependency /** * returns all the colors that are set for this DataSet @@ -238,7 +238,7 @@ interface IDataSet> { /** * Sets a Typeface for the value-labels of this DataSet. */ - var valueTypeface: Typeface? + var valueTypeface: ChartTypeface? /** * Sets the text-size of the value-labels of this DataSet in dp. @@ -250,7 +250,7 @@ interface IDataSet> { * * Return `DEFAULT` to use the default legend form. */ - val form: Legend.LegendForm + val form: LegendForm /** * The form size to draw for this dataset in the legend. @@ -271,7 +271,7 @@ interface IDataSet> { * * Return `null` to use the default legend form line dash effect. */ - val formLineDashEffect: DashPathEffect? + val formLineDashEffect: DashEffect? /** * Returns true if y-value drawing is enabled, false if not diff --git a/chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/ILineDataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/ILineDataSet.kt similarity index 80% rename from chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/ILineDataSet.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/ILineDataSet.kt index e1465aa8c..85233c0e9 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/ILineDataSet.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/ILineDataSet.kt @@ -1,9 +1,8 @@ package info.appdev.charting.interfaces.datasets -import android.graphics.DashPathEffect import info.appdev.charting.data.BaseEntry import info.appdev.charting.data.LineDataSet -import info.appdev.charting.formatter.IFillFormatter +import info.appdev.charting.utils.DashEffect interface ILineDataSet> : ILineRadarDataSet { /** @@ -58,18 +57,13 @@ interface ILineDataSet> : ILineRadarDataSet { var isDrawCircleHoleEnabled: Boolean /** - * Returns the DashPathEffect that is used for drawing the lines. + * Returns the DashEffect that is used for drawing the lines. */ - var dashPathEffect: DashPathEffect? + var dashPathEffect: DashEffect? /** * Returns true if the dashed-line effect is enabled, false if not. - * If the DashPathEffect object is null, also return false here. + * If the DashEffect object is null, also return false here. */ var isDashedLineEnabled: Boolean - - /** - * Returns the IFillFormatter that is set for this DataSet. - */ - var fillFormatter: IFillFormatter? } \ No newline at end of file diff --git a/chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/ILineRadarDataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/ILineRadarDataSet.kt similarity index 79% rename from chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/ILineRadarDataSet.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/ILineRadarDataSet.kt index 31ebc9986..d6287e1d5 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/ILineRadarDataSet.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/ILineRadarDataSet.kt @@ -1,6 +1,5 @@ package info.appdev.charting.interfaces.datasets -import android.graphics.drawable.Drawable import info.appdev.charting.data.BaseEntry interface ILineRadarDataSet> : ILineScatterCandleRadarDataSet { @@ -9,11 +8,6 @@ interface ILineRadarDataSet> : ILineScatterCandleRadarDataS */ val fillColor: Int - /** - * Returns the drawable used for filling the area below the line. - */ - val fillDrawable: Drawable? - /** * Returns the alpha value that is used for filling the line surface, * default: 85 diff --git a/chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/ILineScatterCandleRadarDataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/ILineScatterCandleRadarDataSet.kt similarity index 80% rename from chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/ILineScatterCandleRadarDataSet.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/ILineScatterCandleRadarDataSet.kt index 8e1f2868e..975465df9 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/ILineScatterCandleRadarDataSet.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/ILineScatterCandleRadarDataSet.kt @@ -1,7 +1,7 @@ package info.appdev.charting.interfaces.datasets -import android.graphics.DashPathEffect import info.appdev.charting.data.BaseEntry +import info.appdev.charting.utils.DashEffect interface ILineScatterCandleRadarDataSet> : IBarLineScatterCandleBubbleDataSet { /** @@ -20,7 +20,7 @@ interface ILineScatterCandleRadarDataSet> : IBarLineScatter val highlightLineWidth: Float /** - * Returns the DashPathEffect that is used for highlighting. + * Returns the dash effect that is used for highlighting. */ - val dashPathEffectHighlight: DashPathEffect? + val dashPathEffectHighlight: DashEffect? } diff --git a/chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/IPieDataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/IPieDataSet.kt similarity index 100% rename from chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/IPieDataSet.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/IPieDataSet.kt diff --git a/chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/IRadarDataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/IRadarDataSet.kt similarity index 100% rename from chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/IRadarDataSet.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/IRadarDataSet.kt diff --git a/chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/IScatterDataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/IScatterDataSet.kt similarity index 71% rename from chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/IScatterDataSet.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/IScatterDataSet.kt index 796808a2e..07a0d0baa 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/IScatterDataSet.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/IScatterDataSet.kt @@ -1,7 +1,6 @@ package info.appdev.charting.interfaces.datasets import info.appdev.charting.data.EntryFloat -import info.appdev.charting.renderer.scatter.IShapeRenderer interface IScatterDataSet : ILineScatterCandleRadarDataSet { /** @@ -18,9 +17,4 @@ interface IScatterDataSet : ILineScatterCandleRadarDataSet { * the color for the hole in the shape */ val scatterShapeHoleColor: Int - - /** - * the IShapeRenderer responsible for rendering this DataSet. - */ - val shapeRenderer: IShapeRenderer? } diff --git a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/ChartIcon.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/ChartIcon.kt new file mode 100644 index 000000000..4b9ab41f0 --- /dev/null +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/ChartIcon.kt @@ -0,0 +1,12 @@ +package info.appdev.charting.utils + +/** + * Opaque, platform-specific icon/image handle that can be attached to a chart entry + * (e.g. [BaseEntry.icon][info.appdev.charting.data.BaseEntry]). + * + * On Android this is a type alias for `android.graphics.drawable.Drawable`, so existing + * Android renderer code that treats `entry.icon` as a `Drawable` keeps compiling and + * behaving unchanged. Other platforms currently declare an empty placeholder until a + * Compose Multiplatform icon representation (e.g. a `Painter`) is designed. + */ +expect abstract class ChartIcon diff --git a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/ChartTypeface.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/ChartTypeface.kt new file mode 100644 index 000000000..cafd373d9 --- /dev/null +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/ChartTypeface.kt @@ -0,0 +1,9 @@ +package info.appdev.charting.utils + +/** + * Platform-independent placeholder for a font/typeface handle used to draw value labels. + * + * On Android this is a transparent type alias for [android.graphics.Typeface], so existing + * Android code that reads/writes `Typeface` values via the data model keeps working unchanged. + */ +expect open class ChartTypeface diff --git a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/ColorTemplate.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/ColorTemplate.kt new file mode 100644 index 000000000..0c24896ab --- /dev/null +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/ColorTemplate.kt @@ -0,0 +1,155 @@ +package info.appdev.charting.utils + +/** + * Class that holds predefined color integer arrays (e.g. ColorTemplate.VORDIPLOM_COLORS) + * and convenience methods for loading colors from resources. + */ +object ColorTemplate { + /** + * an "invalid" color that indicates that no color is set + */ + const val COLOR_NONE: Int = 0x00112233 + + /** + * this "color" is used for the Legend creation and indicates that the next + * form should be skipped + */ + const val COLOR_SKIP: Int = 0x00112234 + + /** + * THE COLOR THEMES ARE PREDEFINED (predefined color integer arrays), FEEL + * FREE TO CREATE YOUR OWN WITH AS MANY DIFFERENT COLORS AS YOU WANT + */ + val LIBERTY_COLORS: IntArray = intArrayOf( + argb(207, 248, 246), + argb(148, 212, 212), + argb(136, 180, 187), + argb(118, 174, 175), + argb(42, 109, 130) + ) + val JOYFUL_COLORS: IntArray = intArrayOf( + argb(217, 80, 138), + argb(254, 149, 7), + argb(254, 247, 120), + argb(106, 167, 134), + argb(53, 194, 209) + ) + val PASTEL_COLORS: IntArray = intArrayOf( + argb(64, 89, 128), + argb(149, 165, 124), + argb(217, 184, 162), + argb(191, 134, 134), + argb(179, 48, 80) + ) + val COLORFUL_COLORS: IntArray = intArrayOf( + argb(193, 37, 82), + argb(255, 102, 0), + argb(245, 199, 0), + argb(106, 150, 31), + argb(179, 100, 53) + ) + val VORDIPLOM_COLORS: IntArray = intArrayOf( + argb(192, 255, 140), + argb(255, 247, 140), + argb(255, 208, 140), + argb(140, 234, 255), + argb(255, 140, 157) + ) + val MATERIAL_COLORS: IntArray = intArrayOf( + rgb("#2ecc71"), + rgb("#f1c40f"), + rgb("#e74c3c"), + rgb("#3498db") + ) + + /** + * Converts the given hex-color-string to rgb. + */ + fun rgb(hex: String): Int { + val color = hex.replace("#", "").toLong(16).toInt() + val r = (color shr 16) and 0xFF + val g = (color shr 8) and 0xFF + val b = (color) and 0xFF + return argb(r, g, b) + } + + /** + * Returns the Android ICS holo blue light color. + */ + val holoBlue: Int + get() = argb(51, 181, 229) + + /** + * Sets the alpha component of the given color. + * @param alpha 0 - 255 + */ + fun colorWithAlpha(color: Int, alpha: Int): Int { + return (color and 0xffffff) or ((alpha and 0xff) shl 24) + } + + /** + * Turns an array of colors (integer color values) into an ArrayList of colors. + */ + fun createColors(colors: IntArray): MutableList { + val result: MutableList = ArrayList() + for (i in colors) { + result.add(i) + } + return result + } + + /** + * Fully opaque white, platform-independent replacement for android.graphics.Color.WHITE. + */ + val WHITE: Int = argb(255, 255, 255) + + /** + * Fully opaque black, platform-independent replacement for android.graphics.Color.BLACK. + */ + val BLACK: Int = argb(0, 0, 0) + + /** + * Fully opaque gray, platform-independent replacement for android.graphics.Color.GRAY. + */ + val GRAY: Int = argb(128, 128, 128) + + /** + * Builds a fully opaque ARGB color int from the given red/green/blue components (0-255), + * platform-independent replacement for android.graphics.Color.rgb(r, g, b). + */ + fun argb(red: Int, green: Int, blue: Int): Int { + return (0xFF shl 24) or ((red and 0xFF) shl 16) or ((green and 0xFF) shl 8) or (blue and 0xFF) + } + + /** + * Builds an ARGB color int from the given alpha/red/green/blue components (0-255), + * platform-independent replacement for android.graphics.Color.argb(a, r, g, b). + */ + fun argb(alpha: Int, red: Int, green: Int, blue: Int): Int { + return ((alpha and 0xFF) shl 24) or ((red and 0xFF) shl 16) or ((green and 0xFF) shl 8) or (blue and 0xFF) + } + + /** + * Extracts the alpha component (0-255) of the given ARGB color int, + * platform-independent replacement for android.graphics.Color.alpha(color). + */ + fun alpha(color: Int): Int = (color shr 24) and 0xFF + + /** + * Extracts the red component (0-255) of the given ARGB color int, + * platform-independent replacement for android.graphics.Color.red(color). + */ + fun red(color: Int): Int = (color shr 16) and 0xFF + + /** + * Extracts the green component (0-255) of the given ARGB color int, + * platform-independent replacement for android.graphics.Color.green(color). + */ + fun green(color: Int): Int = (color shr 8) and 0xFF + + /** + * Extracts the blue component (0-255) of the given ARGB color int, + * platform-independent replacement for android.graphics.Color.blue(color). + */ + fun blue(color: Int): Int = color and 0xFF +} diff --git a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/DashEffect.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/DashEffect.kt new file mode 100644 index 000000000..556276315 --- /dev/null +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/DashEffect.kt @@ -0,0 +1,27 @@ +package info.appdev.charting.utils + +/** + * Platform-independent replacement for `android.graphics.DashPathEffect`, describing a + * dashed-line pattern for lines drawn by the chart (legend forms, highlight lines, etc.). + * + * @param intervals alternating "on" and "off" lengths, in pixels; must have an even size >= 2 + * @param phase offset into the intervals array at which drawing begins + */ +class DashEffect(val intervals: FloatArray, val phase: Float) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is DashEffect) return false + return intervals.contentEquals(other.intervals) && phase == other.phase + } + + override fun hashCode(): Int { + var result = intervals.contentHashCode() + result = 31 * result + phase.hashCode() + return result + } + + override fun toString(): String { + return "DashEffect(intervals=${intervals.contentToString()}, phase=$phase)" + } +} diff --git a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/DecimalFormatting.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/DecimalFormatting.kt new file mode 100644 index 000000000..a24241990 --- /dev/null +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/DecimalFormatting.kt @@ -0,0 +1,37 @@ +package info.appdev.charting.utils + +import kotlin.math.abs +import kotlin.math.pow +import kotlin.math.roundToLong + +/** + * Formats [value] rounded to the given number of decimal [digits], grouping the integer part + * with thousands separators. Platform-independent replacement for the + * `java.text.DecimalFormat`-based formatting previously used by value formatters + * (e.g. pattern "###,###,###,##0.00"). + */ +fun formatGroupedDecimal(value: Double, digits: Int): String { + val factor = 10.0.pow(digits) + val roundedTotal = (abs(value) * factor).roundToLong() + val intPart = if (digits == 0) roundedTotal else roundedTotal / factor.toLong() + val fracPart = if (digits == 0) 0L else roundedTotal % factor.toLong() + + val sb = StringBuilder() + if (value < 0 && roundedTotal != 0L) sb.append("-") + sb.append(groupThousands(intPart.toString())) + if (digits > 0) { + sb.append(".") + sb.append(fracPart.toString().padStart(digits, '0')) + } + return sb.toString() +} + +private fun groupThousands(digitsString: String): String { + val length = digitsString.length + val sb = StringBuilder() + for (i in digitsString.indices) { + if (i > 0 && (length - i) % 3 == 0) sb.append(",") + sb.append(digitsString[i]) + } + return sb.toString() +} diff --git a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/DisplayMetrics.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/DisplayMetrics.kt new file mode 100644 index 000000000..d8da64b6e --- /dev/null +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/DisplayMetrics.kt @@ -0,0 +1,14 @@ +package info.appdev.charting.utils + +/** + * Current display density scale factor (pixels per dp), used by [convertDpToPixel] to convert + * dp values to pixels. Must be set once per platform before dp-based sizes are used + * (e.g. via `Context.initUtils()` on Android). Defaults to 1f (no scaling) so that non-Android + * targets still produce a sane (if unscaled) result before an explicit density is provided. + */ +var chartDensity: Float = 1f + +/** + * Converts a dp (density-independent pixels) value to pixels, depending on [chartDensity]. + */ +fun Float.convertDpToPixel(): Float = this * chartDensity diff --git a/chartLib/src/main/kotlin/info/appdev/charting/utils/FSize.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/FSize.kt similarity index 100% rename from chartLib/src/main/kotlin/info/appdev/charting/utils/FSize.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/FSize.kt diff --git a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/Fill.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/Fill.kt new file mode 100644 index 000000000..4d62c73a4 --- /dev/null +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/Fill.kt @@ -0,0 +1,81 @@ +package info.appdev.charting.utils + +import kotlin.math.floor + +/** + * Portable, data-only description of a fill (solid color, linear gradient, or drawable-based). + * + * The actual Canvas/Paint/Drawable/LinearGradient-based drawing logic (`fillRect`/`fillPath`) + * has no portable KMP equivalent, so it lives as Android-only extension functions in + * `chartLib`'s `FillAndroid.kt`. The same applies to the `drawable` payload used by + * [Type.DRAWABLE], which is exposed there as an extension property. + */ +open class Fill { + enum class Type { + EMPTY, COLOR, LINEAR_GRADIENT, DRAWABLE + } + + enum class Direction { + DOWN, UP, RIGHT, LEFT + } + + /** + * the type of fill + */ + var type: Type = Type.EMPTY + + /** + * the color that is used for filling + */ + private var mColor: Int? = null + + private var mFinalColor: Int? = null + + var gradientColors: IntArray? = null + + var gradientPositions: FloatArray? = null + + /** + * transparency used for filling + */ + private var mAlpha = 255 + + constructor(startColor: Int, endColor: Int) { + this.type = Type.LINEAR_GRADIENT + this.gradientColors = intArrayOf(startColor, endColor) + } + + var color: Int? + get() = mColor + set(color) { + this.mColor = color + calculateFinalColor() + } + + fun setGradientColors(startColor: Int, endColor: Int) { + this.gradientColors = intArrayOf(startColor, endColor) + } + + var alpha: Int + get() = mAlpha + set(alpha) { + this.mAlpha = alpha + calculateFinalColor() + } + + /** + * The final ARGB color to use for filling, combining [color] and [alpha]. + * Exposed so that the Android-only drawing extensions (`fillRect`/`fillPath`) can use it. + */ + val finalColor: Int? + get() = mFinalColor + + private fun calculateFinalColor() { + if (mColor == null) { + mFinalColor = null + } else { + val alpha = floor(((mColor!! shr 24) / 255.0) * (mAlpha / 255.0) * 255.0).toInt() + mFinalColor = (alpha shl 24) or (mColor!! and 0xffffff) + } + } +} diff --git a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/Matrix.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/Matrix.kt new file mode 100644 index 000000000..9d496ba10 --- /dev/null +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/Matrix.kt @@ -0,0 +1,203 @@ +package info.appdev.charting.utils + +import kotlin.math.abs + +/** + * Platform-independent 3x3 affine/perspective transform matrix, API-compatible (in spirit) with + * `android.graphics.Matrix`'s affine subset that the chart rendering pipeline relies on + * (`postTranslate`, `postScale`, `setScale`, `setTranslate`, `postConcat`, `invert`, `mapPoints`, + * `mapRect`, `getValues`/`setValues`). + * + * Values are stored row-major, matching Android's layout, so [getValues]/[setValues] round-trip + * the same way existing `ViewPortHandler` buffer-manipulation code expects: + * ``` + * [ MSCALE_X MSKEW_X MTRANS_X ] + * [ MSKEW_Y MSCALE_Y MTRANS_Y ] + * [ MPERSP_0 MPERSP_1 MPERSP_2 ] + * ``` + */ +class Matrix { + private val values = FloatArray(9) + + init { + reset() + } + + fun reset() { + for (i in 0..8) values[i] = if (i == MSCALE_X || i == MSCALE_Y || i == MPERSP_2) 1f else 0f + } + + fun set(src: Matrix) { + src.values.copyInto(values) + } + + fun getValues(dst: FloatArray) { + values.copyInto(dst) + } + + fun setValues(src: FloatArray) { + src.copyInto(values) + } + + /** + * Replaces this matrix with a pure translation. + */ + fun setTranslate(dx: Float, dy: Float) { + reset() + values[MTRANS_X] = dx + values[MTRANS_Y] = dy + } + + /** + * Replaces this matrix with a scale about the given pivot point. + */ + fun setScale(sx: Float, sy: Float, px: Float = 0f, py: Float = 0f) { + reset() + values[MSCALE_X] = sx + values[MSCALE_Y] = sy + values[MTRANS_X] = px - sx * px + values[MTRANS_Y] = py - sy * py + } + + /** + * Post-concatenates a translation onto this matrix: `this = translate(dx, dy) * this`. + */ + fun postTranslate(dx: Float, dy: Float) { + val t = Matrix() + t.setTranslate(dx, dy) + postConcat(t) + } + + /** + * Post-concatenates a scale about the given pivot onto this matrix. + */ + fun postScale(sx: Float, sy: Float, px: Float = 0f, py: Float = 0f) { + val s = Matrix() + s.setScale(sx, sy, px, py) + postConcat(s) + } + + /** + * Post-concatenates [other] onto this matrix: `this = other * this`, i.e. [other] is applied + * *after* this matrix's existing transform when mapping a point. + */ + fun postConcat(other: Matrix) { + val result = multiply(other.values, this.values) + result.copyInto(values) + } + + /** + * Transforms an array of points in place: `(x0, y0, x1, y1, ...)`. + */ + fun mapPoints(pts: FloatArray) { + var i = 0 + while (i + 1 < pts.size) { + val x = pts[i] + val y = pts[i + 1] + + val nx = values[MSCALE_X] * x + values[MSKEW_X] * y + values[MTRANS_X] + val ny = values[MSKEW_Y] * x + values[MSCALE_Y] * y + values[MTRANS_Y] + val w = values[MPERSP_0] * x + values[MPERSP_1] * y + values[MPERSP_2] + + if (w == 1f || w == 0f) { + pts[i] = nx + pts[i + 1] = ny + } else { + pts[i] = nx / w + pts[i + 1] = ny / w + } + i += 2 + } + } + + /** + * Transforms [r] to the bounding box of its four transformed corners. + */ + fun mapRect(r: RectF) { + val pts = floatArrayOf( + r.left, r.top, + r.right, r.top, + r.right, r.bottom, + r.left, r.bottom + ) + mapPoints(pts) + + var minX = pts[0] + var maxX = pts[0] + var minY = pts[1] + var maxY = pts[1] + var i = 2 + while (i < pts.size) { + if (pts[i] < minX) minX = pts[i] + if (pts[i] > maxX) maxX = pts[i] + if (pts[i + 1] < minY) minY = pts[i + 1] + if (pts[i + 1] > maxY) maxY = pts[i + 1] + i += 2 + } + r.left = minX + r.top = minY + r.right = maxX + r.bottom = maxY + } + + /** + * Computes the inverse of this matrix into [dst]. Returns false (leaving [dst] unmodified) + * if this matrix is not invertible. + */ + fun invert(dst: Matrix): Boolean { + val m = values + val a = m[0]; val b = m[1]; val c = m[2] + val d = m[3]; val e = m[4]; val f = m[5] + val g = m[6]; val h = m[7]; val i = m[8] + + val det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g) + if (abs(det) < 1e-12f) return false + + val invDet = 1f / det + + val r = FloatArray(9) + r[0] = (e * i - f * h) * invDet + r[1] = (c * h - b * i) * invDet + r[2] = (b * f - c * e) * invDet + r[3] = (f * g - d * i) * invDet + r[4] = (a * i - c * g) * invDet + r[5] = (c * d - a * f) * invDet + r[6] = (d * h - e * g) * invDet + r[7] = (b * g - a * h) * invDet + r[8] = (a * e - b * d) * invDet + + r.copyInto(dst.values) + return true + } + + override fun toString(): String = "Matrix" + values.joinToString(prefix = "[", postfix = "]") + + companion object { + const val MSCALE_X = 0 + const val MSKEW_X = 1 + const val MTRANS_X = 2 + const val MSKEW_Y = 3 + const val MSCALE_Y = 4 + const val MTRANS_Y = 5 + const val MPERSP_0 = 6 + const val MPERSP_1 = 7 + const val MPERSP_2 = 8 + + /** + * Standard row-major 3x3 matrix multiplication: `result = left * right`. + */ + private fun multiply(left: FloatArray, right: FloatArray): FloatArray { + val r = FloatArray(9) + for (row in 0..2) { + for (col in 0..2) { + var sum = 0f + for (k in 0..2) { + sum += left[row * 3 + k] * right[k * 3 + col] + } + r[row * 3 + col] = sum + } + } + return r + } + } +} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/utils/ObjectPool.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/ObjectPool.kt similarity index 98% rename from chartLib/src/main/kotlin/info/appdev/charting/utils/ObjectPool.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/ObjectPool.kt index 4386d78db..11bf71fe4 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/utils/ObjectPool.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/ObjectPool.kt @@ -66,7 +66,6 @@ class ObjectPool> private constructor(withCapacity: Int, poolObj * * @return An instance of Poolable object T */ - @Synchronized fun get(): T { if (objects.isEmpty() && this.replenishPercentage > 0.0f) { this.refillPool() @@ -84,7 +83,6 @@ class ObjectPool> private constructor(withCapacity: Int, poolObj * * @param object An object of type T to recycle */ - @Synchronized fun recycle(`object`: T?) { if (`object` == null) return @@ -108,7 +106,6 @@ class ObjectPool> private constructor(withCapacity: Int, poolObj * * @param objects A list of objects of type T to recycle */ - @Synchronized fun recycle(objects: List) { val objectsListSize = objects.size @@ -171,7 +168,6 @@ class ObjectPool> private constructor(withCapacity: Int, poolObj * @param object An instance of the object that the pool should recycle. * @return */ - @Synchronized fun > create(withCapacity: Int, `object`: T): ObjectPool { val result: ObjectPool = ObjectPool(withCapacity, `object`) result.poolId = ids diff --git a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/PaintStyle.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/PaintStyle.kt new file mode 100644 index 000000000..c72e7c663 --- /dev/null +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/PaintStyle.kt @@ -0,0 +1,12 @@ +package info.appdev.charting.utils + +/** + * Platform-independent replacement for `android.graphics.Paint.Style`. + * Used by the data model (e.g. [info.appdev.charting.interfaces.datasets.ICandleDataSet]) + * to describe how a shape should be painted, without depending on `android.graphics.Paint`. + */ +enum class PaintStyle { + FILL, + STROKE, + FILL_AND_STROKE +} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/utils/PointD.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/PointD.kt similarity index 100% rename from chartLib/src/main/kotlin/info/appdev/charting/utils/PointD.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/PointD.kt diff --git a/chartLib/src/main/kotlin/info/appdev/charting/utils/PointF.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/PointF.kt similarity index 59% rename from chartLib/src/main/kotlin/info/appdev/charting/utils/PointF.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/PointF.kt index e6f60fc8d..c92f80f48 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/utils/PointF.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/PointF.kt @@ -1,8 +1,7 @@ package info.appdev.charting.utils -import android.os.Parcel -import android.os.Parcelable import info.appdev.charting.utils.ObjectPool.Poolable +import kotlin.math.PI import kotlin.math.cos import kotlin.math.sin @@ -17,18 +16,6 @@ class PointF : Poolable { this.y = y } - /** - * Set the point's coordinates from the data stored in the specified - * parcel. To write a point to a parcel, call writeToParcel(). - * Provided to support older Android devices. - * - * @param in The parcel to read the point's coordinates from - */ - fun my_readFromParcel(`in`: Parcel) { - x = `in`.readFloat() - y = `in`.readFloat() - } - override fun instantiate(): PointF { return PointF(0f, 0f) } @@ -68,24 +55,6 @@ class PointF : Poolable { fun recycleInstances(instances: MutableList) { pool.recycle(instances) } - - val CREATOR: Parcelable.Creator = object : Parcelable.Creator { - /** - * Return a new point from the data in the specified parcel. - */ - override fun createFromParcel(`in`: Parcel): PointF { - val r = PointF(0f, 0f) - r.my_readFromParcel(`in`) - return r - } - - /** - * Return an array of rectangles of the specified size. - */ - override fun newArray(size: Int): Array { - return arrayOfNulls(size) - } - } } } @@ -103,6 +72,7 @@ fun PointF.getPosition(dist: Float, angle: Float): PointF { } fun PointF.changePosition(dist: Float, angle: Float, outputPoint: PointF) { - outputPoint.x = (this.x + dist * cos(Math.toRadians(angle.toDouble()))).toFloat() - outputPoint.y = (this.y + dist * sin(Math.toRadians(angle.toDouble()))).toFloat() + val radians = angle.toDouble() * PI / 180.0 + outputPoint.x = (this.x + dist * cos(radians)).toFloat() + outputPoint.y = (this.y + dist * sin(radians)).toFloat() } \ No newline at end of file diff --git a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/RectF.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/RectF.kt new file mode 100644 index 000000000..2e83b8d93 --- /dev/null +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/RectF.kt @@ -0,0 +1,55 @@ +package info.appdev.charting.utils + +/** + * Platform-independent replacement for `android.graphics.RectF`, covering the subset of the API + * the chart rendering pipeline relies on. + */ +class RectF( + var left: Float = 0f, + var top: Float = 0f, + var right: Float = 0f, + var bottom: Float = 0f +) { + fun width(): Float = right - left + + fun height(): Float = bottom - top + + fun centerX(): Float = (left + right) / 2f + + fun centerY(): Float = (top + bottom) / 2f + + /** + * Also enables the `rect[left, top, right] = bottom` indexed-assignment syntax used by + * `ViewPortHandler`, since `operator fun set` supports both call styles. + */ + operator fun set(left: Float, top: Float, right: Float, bottom: Float) { + this.left = left + this.top = top + this.right = right + this.bottom = bottom + } + + fun set(other: RectF) { + set(other.left, other.top, other.right, other.bottom) + } + + fun setEmpty() { + set(0f, 0f, 0f, 0f) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is RectF) return false + return left == other.left && top == other.top && right == other.right && bottom == other.bottom + } + + override fun hashCode(): Int { + var result = left.hashCode() + result = 31 * result + top.hashCode() + result = 31 * result + right.hashCode() + result = 31 * result + bottom.hashCode() + return result + } + + override fun toString(): String = "RectF($left, $top, $right, $bottom)" +} diff --git a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/TextAlign.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/TextAlign.kt new file mode 100644 index 000000000..636764ad6 --- /dev/null +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/TextAlign.kt @@ -0,0 +1,12 @@ +package info.appdev.charting.utils + +/** + * Platform-independent replacement for `android.graphics.Paint.Align`. + * Used by components (e.g. [info.appdev.charting.components.Description]) to describe + * how text should be aligned, without depending on `android.graphics.Paint`. + */ +enum class TextAlign { + LEFT, + CENTER, + RIGHT +} diff --git a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/TransformerCore.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/TransformerCore.kt new file mode 100644 index 000000000..3145c21c0 --- /dev/null +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/TransformerCore.kt @@ -0,0 +1,223 @@ +package info.appdev.charting.utils + +/** + * Transformer class that contains all matrices and is responsible for + * transforming values into pixels on the screen and backwards. + * + * This common base holds every matrix operation that does not depend on Android-only types + * (`Path`) or on the dataset family (`ILineDataSet`, `IBubbleDataSet`, `ICandleDataSet`, + * `IScatterDataSet`), which are not yet available in `commonMain`. The Android-specific pieces + * live in `info.appdev.charting.utils.Transformer` in the `chartLib` module, which extends this + * class. + */ +open class TransformerCore(protected var viewPortHandler: ViewPortHandler) { + /** + * matrix to map the values to the screen pixels + */ + var valueMatrix: Matrix = Matrix() + protected set + + /** + * matrix for handling the different offsets of the chart + */ + var offsetMatrix: Matrix = Matrix() + protected set + + /** + * Prepares the matrix that transforms values to pixels. Calculates the + * scale factors from the charts size and offsets. + */ + fun prepareMatrixValuePx(xChartMin: Float, deltaX: Float, deltaY: Float, yChartMin: Float) { + var scaleX = viewPortHandler.contentWidth() / deltaX + var scaleY = viewPortHandler.contentHeight() / deltaY + + if (scaleX.isInfinite()) { + scaleX = 0f + } + if (scaleY.isInfinite()) { + scaleY = 0f + } + + // setup all matrices + valueMatrix.reset() + valueMatrix.postTranslate(-xChartMin, -yChartMin) + valueMatrix.postScale(scaleX, -scaleY) + } + + /** + * Prepares the matrix that contains all offsets. + */ + open fun prepareMatrixOffset(inverted: Boolean) { + offsetMatrix.reset() + + // offset.postTranslate(mOffsetLeft, getHeight() - mOffsetBottom); + if (!inverted) offsetMatrix.postTranslate( + viewPortHandler.offsetLeft(), + viewPortHandler.chartHeight - viewPortHandler.offsetBottom() + ) + else { + offsetMatrix + .setTranslate(viewPortHandler.offsetLeft(), -viewPortHandler.offsetTop()) + offsetMatrix.postScale(1.0f, -1.0f) + } + } + + /** + * Transform an array of points with all matrices. VERY IMPORTANT: Keep + * matrix order "value-touch-offset" when transforming. + */ + fun pointValuesToPixel(pts: FloatArray) { + valueMatrix.mapPoints(pts) + viewPortHandler.matrixTouch.mapPoints(pts) + offsetMatrix.mapPoints(pts) + } + + /** + * Transform a rectangle with all matrices. + */ + fun rectValueToPixel(r: RectF) { + valueMatrix.mapRect(r) + viewPortHandler.matrixTouch.mapRect(r) + offsetMatrix.mapRect(r) + } + + /** + * Transform a rectangle with all matrices with potential animation phases. + */ + fun rectToPixelPhase(r: RectF, phaseY: Float) { + // multiply the height of the rect with the phase + + r.top *= phaseY + r.bottom *= phaseY + + valueMatrix.mapRect(r) + viewPortHandler.matrixTouch.mapRect(r) + offsetMatrix.mapRect(r) + } + + fun rectToPixelPhaseHorizontal(r: RectF, phaseY: Float) { + // multiply the height of the rect with the phase + + r.left *= phaseY + r.right *= phaseY + + valueMatrix.mapRect(r) + viewPortHandler.matrixTouch.mapRect(r) + offsetMatrix.mapRect(r) + } + + /** + * Transform a rectangle with all matrices with potential animation phases. + */ + fun rectValueToPixelHorizontal(r: RectF) { + valueMatrix.mapRect(r) + viewPortHandler.matrixTouch.mapRect(r) + offsetMatrix.mapRect(r) + } + + /** + * Transform a rectangle with all matrices with potential animation phases. + */ + fun rectValueToPixelHorizontal(r: RectF, phaseY: Float) { + // multiply the height of the rect with the phase + + r.left *= phaseY + r.right *= phaseY + + valueMatrix.mapRect(r) + viewPortHandler.matrixTouch.mapRect(r) + offsetMatrix.mapRect(r) + } + + /** + * transforms multiple rects with all matrices + */ + fun rectValuesToPixel(rects: MutableList) { + val m = this.valueToPixelMatrix + + for (i in rects.indices) m.mapRect(rects[i]) + } + + protected var mPixelToValueMatrixBuffer: Matrix = Matrix() + + /** + * Transforms the given array of touch positions (pixels) (x, y, x, y, ...) + * into values on the chart. + */ + fun pixelsToValue(pixels: FloatArray) { + val tmp = mPixelToValueMatrixBuffer + tmp.reset() + + // invert all matrixes to convert back to the original value + offsetMatrix.invert(tmp) + tmp.mapPoints(pixels) + + viewPortHandler.matrixTouch.invert(tmp) + tmp.mapPoints(pixels) + + valueMatrix.invert(tmp) + tmp.mapPoints(pixels) + } + + /** + * buffer for performance + */ + var ptsBuffer: FloatArray = FloatArray(2) + + /** + * Returns a recyclable PointD instance. + * returns the x and y values in the chart at the given touch point + * (encapsulated in a PointD). This method transforms pixel coordinates to + * coordinates / values in the chart. This is the opposite method to + * getPixelForValues(...). + */ + fun getValuesByTouchPoint(x: Float, y: Float): PointD { + val result = PointD.getInstance(0.0, 0.0) + getValuesByTouchPoint(x, y, result) + return result + } + + fun getValuesByTouchPoint(x: Float, y: Float, outputPoint: PointD) { + ptsBuffer[0] = x + ptsBuffer[1] = y + + pixelsToValue(ptsBuffer) + + outputPoint.x = ptsBuffer[0].toDouble() + outputPoint.y = ptsBuffer[1].toDouble() + } + + /** + * Returns a recyclable PointD instance. + * Returns the x and y coordinates (pixels) for a given x and y value in the chart. + */ + fun getPixelForValues(x: Float, y: Float): PointD { + ptsBuffer[0] = x + ptsBuffer[1] = y + + pointValuesToPixel(ptsBuffer) + + val xPx = ptsBuffer[0].toDouble() + val yPx = ptsBuffer[1].toDouble() + + return PointD.getInstance(xPx, yPx) + } + + private val mMBuffer1 = Matrix() + + val valueToPixelMatrix: Matrix + get() { + mMBuffer1.set(this.valueMatrix) + mMBuffer1.postConcat(viewPortHandler.matrixTouch) + mMBuffer1.postConcat(this.offsetMatrix) + return mMBuffer1 + } + + private val mMBuffer2 = Matrix() + + val pixelToValueMatrix: Matrix + get() { + this.valueToPixelMatrix.invert(mMBuffer2) + return mMBuffer2 + } +} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/utils/ViewPortHandler.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/ViewPortHandler.kt similarity index 96% rename from chartLib/src/main/kotlin/info/appdev/charting/utils/ViewPortHandler.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/ViewPortHandler.kt index c6ec5006a..a7942a532 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/utils/ViewPortHandler.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/ViewPortHandler.kt @@ -1,9 +1,5 @@ package info.appdev.charting.utils -import android.graphics.Matrix -import android.graphics.RectF -import android.view.View -import timber.log.Timber import kotlin.math.max import kotlin.math.min @@ -112,8 +108,9 @@ open class ViewPortHandler { preserveViewPortOnResize(previousWidth, previousHeight) - if (logging) - Timber.i(contentRect.toString()) + if (logging) { + println(contentRect.toString()) + } } /** @@ -348,8 +345,9 @@ open class ViewPortHandler { * setScaleMinima(...) method. * * @param transformedPts the position to center view viewport to + * @param invalidate optional callback invoked when the host view should be redrawn */ - fun centerViewPort(transformedPts: FloatArray, view: View?) { + fun centerViewPort(transformedPts: FloatArray, invalidate: (() -> Unit)? = null) { val save = mCenterViewPortMatrixBuffer save.reset() save.set(matrixTouch) @@ -359,7 +357,7 @@ open class ViewPortHandler { save.postTranslate(-x, -y) - refresh(save, view, true) + refresh(save, invalidate, true) } /** @@ -369,15 +367,18 @@ open class ViewPortHandler { /** * call this method to refresh the graph with a given matrix + * + * @param invalidate optional callback invoked when the host view should be redrawn */ - fun refresh(newMatrix: Matrix, view: View?, invalidate: Boolean): Matrix { + fun refresh(newMatrix: Matrix, invalidate: (() -> Unit)?, doInvalidate: Boolean): Matrix { matrixTouch.set(newMatrix) // make sure scale and translation are within their bounds limitTransAndScale(matrixTouch, contentRect) - if (invalidate) - view?.invalidate() + if (doInvalidate) { + invalidate?.invoke() + } newMatrix.set(matrixTouch) return newMatrix diff --git a/chartLibCore/src/commonTest/kotlin/info/appdev/charting/formatter/DefaultValueFormatterTest.kt b/chartLibCore/src/commonTest/kotlin/info/appdev/charting/formatter/DefaultValueFormatterTest.kt new file mode 100644 index 000000000..517024238 --- /dev/null +++ b/chartLibCore/src/commonTest/kotlin/info/appdev/charting/formatter/DefaultValueFormatterTest.kt @@ -0,0 +1,44 @@ +package info.appdev.charting.formatter + +import kotlin.test.Test +import kotlin.test.assertEquals + +class DefaultValueFormatterTest { + + @Test + fun formatsWithZeroDigits() { + val formatter = DefaultValueFormatter(0) + assertEquals("42", formatter.getFormattedValue(42f, null, 0, null)) + assertEquals("0", formatter.getFormattedValue(0f, null, 0, null)) + assertEquals("-7", formatter.getFormattedValue(-7f, null, 0, null)) + } + + @Test + fun formatsWithDecimalDigits() { + val formatter = DefaultValueFormatter(2) + assertEquals("1.50", formatter.getFormattedValue(1.5f, null, 0, null)) + assertEquals("0.10", formatter.getFormattedValue(0.1f, null, 0, null)) + assertEquals("-3.25", formatter.getFormattedValue(-3.25f, null, 0, null)) + } + + @Test + fun groupsThousands() { + val formatter = DefaultValueFormatter(1) + assertEquals("1,234.5", formatter.getFormattedValue(1234.5f, null, 0, null)) + assertEquals("1,000,000.0", formatter.getFormattedValue(1_000_000f, null, 0, null)) + } + + @Test + fun negativeZeroDoesNotKeepMinusSign() { + val formatter = DefaultValueFormatter(0) + assertEquals("0", formatter.getFormattedValue(-0.1f, null, 0, null)) + } + + @Test + fun setupUpdatesDecimalDigits() { + val formatter = DefaultValueFormatter(1) + formatter.setup(3) + assertEquals(3, formatter.decimalDigits) + assertEquals("2.000", formatter.getFormattedValue(2f, null, 0, null)) + } +} diff --git a/chartLib/src/test/kotlin/info/appdev/charting/test/LargeValueFormatterTest.kt b/chartLibCore/src/commonTest/kotlin/info/appdev/charting/formatter/LargeValueFormatterTest.kt similarity index 59% rename from chartLib/src/test/kotlin/info/appdev/charting/test/LargeValueFormatterTest.kt rename to chartLibCore/src/commonTest/kotlin/info/appdev/charting/formatter/LargeValueFormatterTest.kt index 48fd5dc43..d0b7f9be5 100644 --- a/chartLib/src/test/kotlin/info/appdev/charting/test/LargeValueFormatterTest.kt +++ b/chartLibCore/src/commonTest/kotlin/info/appdev/charting/formatter/LargeValueFormatterTest.kt @@ -1,8 +1,7 @@ -package info.appdev.charting.test +package info.appdev.charting.formatter -import info.appdev.charting.formatter.LargeValueFormatter -import org.junit.Assert -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals class LargeValueFormatterTest { @Test @@ -10,82 +9,82 @@ class LargeValueFormatterTest { val formatter = LargeValueFormatter() val resultDouble = formatter.getFormattedValue(5.0.toFloat(), null) - Assert.assertEquals("5", resultDouble) + assertEquals("5", resultDouble) var result = formatter.getFormattedValue(5f, null) - Assert.assertEquals("5", result) + assertEquals("5", result) result = formatter.getFormattedValue(5.5f, null) - Assert.assertEquals("5.5", result) + assertEquals("5.5", result) result = formatter.getFormattedValue(50f, null) - Assert.assertEquals("50", result) + assertEquals("50", result) result = formatter.getFormattedValue(50.5f, null) - Assert.assertEquals("50.5", result) + assertEquals("50.5", result) result = formatter.getFormattedValue(500f, null) - Assert.assertEquals("500", result) + assertEquals("500", result) result = formatter.getFormattedValue(1100f, null) - Assert.assertEquals("1.1k", result) + assertEquals("1.1k", result) result = formatter.getFormattedValue(10000f, null) - Assert.assertEquals("10k", result) + assertEquals("10k", result) result = formatter.getFormattedValue(10500f, null) - Assert.assertEquals("10.5k", result) + assertEquals("10.5k", result) result = formatter.getFormattedValue(100000f, null) - Assert.assertEquals("100k", result) + assertEquals("100k", result) result = formatter.getFormattedValue(1000000f, null) - Assert.assertEquals("1m", result) + assertEquals("1m", result) result = formatter.getFormattedValue(1500000f, null) - Assert.assertEquals("1.5m", result) + assertEquals("1.5m", result) result = formatter.getFormattedValue(9500000f, null) - Assert.assertEquals("9.5m", result) + assertEquals("9.5m", result) result = formatter.getFormattedValue(22200000f, null) - Assert.assertEquals("22.2m", result) + assertEquals("22.2m", result) result = formatter.getFormattedValue(222000000f, null) - Assert.assertEquals("222m", result) + assertEquals("222m", result) result = formatter.getFormattedValue(1000000000f, null) - Assert.assertEquals("1b", result) + assertEquals("1b", result) result = formatter.getFormattedValue(9900000000f, null) - Assert.assertEquals("9.9b", result) + assertEquals("9.9b", result) result = formatter.getFormattedValue(99000000000f, null) - Assert.assertEquals("99b", result) + assertEquals("99b", result) result = formatter.getFormattedValue(99500000000f, null) - Assert.assertEquals("99.5b", result) + assertEquals("99.5b", result) result = formatter.getFormattedValue(999000000000f, null) - Assert.assertEquals("999b", result) + assertEquals("999b", result) result = formatter.getFormattedValue(1000000000000f, null) - Assert.assertEquals("1t", result) + assertEquals("1t", result) formatter.setSuffix(arrayOf("", "k", "m", "b", "t", "q")) // quadrillion support result = formatter.getFormattedValue(1000000000000000f, null) - Assert.assertEquals("1q", result) + assertEquals("1q", result) result = formatter.getFormattedValue(1100000000000000f, null) - Assert.assertEquals("1.1q", result) + assertEquals("1.1q", result) result = formatter.getFormattedValue(10000000000000000f, null) - Assert.assertEquals("10q", result) + assertEquals("10q", result) result = formatter.getFormattedValue(13300000000000000f, null) - Assert.assertEquals("13.3q", result) + assertEquals("13.3q", result) result = formatter.getFormattedValue(100000000000000000f, null) - Assert.assertEquals("100q", result) + assertEquals("100q", result) } } diff --git a/chartLib/src/test/kotlin/info/appdev/charting/test/ObjectPoolTest.kt b/chartLibCore/src/commonTest/kotlin/info/appdev/charting/test/ObjectPoolTest.kt similarity index 60% rename from chartLib/src/test/kotlin/info/appdev/charting/test/ObjectPoolTest.kt rename to chartLibCore/src/commonTest/kotlin/info/appdev/charting/test/ObjectPoolTest.kt index 6e16a06d6..4e11d5fbe 100644 --- a/chartLib/src/test/kotlin/info/appdev/charting/test/ObjectPoolTest.kt +++ b/chartLibCore/src/commonTest/kotlin/info/appdev/charting/test/ObjectPoolTest.kt @@ -2,8 +2,8 @@ package info.appdev.charting.test import info.appdev.charting.utils.ObjectPool import info.appdev.charting.utils.ObjectPool.Poolable -import org.junit.Assert -import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.Test class ObjectPoolTest { internal class TestPoolable private constructor(var foo: Int, var bar: Int) : Poolable() { @@ -41,37 +41,37 @@ class ObjectPoolTest { var poolCount = TestPoolable.getPool().poolCount val testPoolables = ArrayList() - Assert.assertEquals(4, poolCapacity) - Assert.assertEquals(4, poolCount) + assertEquals(4, poolCapacity) + assertEquals(4, poolCount) var testPoolable = TestPoolable.getInstance(6, 7) - Assert.assertEquals(6, testPoolable.foo) - Assert.assertEquals(7, testPoolable.bar) + assertEquals(6, testPoolable.foo) + assertEquals(7, testPoolable.bar) poolCapacity = TestPoolable.getPool().poolCapacity poolCount = TestPoolable.getPool().poolCount - Assert.assertEquals(4, poolCapacity) - Assert.assertEquals(3, poolCount) + assertEquals(4, poolCapacity) + assertEquals(3, poolCount) TestPoolable.recycleInstance(testPoolable) poolCapacity = TestPoolable.getPool().poolCapacity poolCount = TestPoolable.getPool().poolCount - Assert.assertEquals(4, poolCapacity) - Assert.assertEquals(4, poolCount) + assertEquals(4, poolCapacity) + assertEquals(4, poolCount) testPoolable = TestPoolable.getInstance(20, 30) - Assert.assertEquals(20, testPoolable.foo) - Assert.assertEquals(30, testPoolable.bar) + assertEquals(20, testPoolable.foo) + assertEquals(30, testPoolable.bar) TestPoolable.recycleInstance(testPoolable) poolCapacity = TestPoolable.getPool().poolCapacity poolCount = TestPoolable.getPool().poolCount - Assert.assertEquals(4, poolCapacity) - Assert.assertEquals(4, poolCount) + assertEquals(4, poolCapacity) + assertEquals(4, poolCount) testPoolables.add(TestPoolable.getInstance(12, 24)) testPoolables.add(TestPoolable.getInstance(1, 2)) @@ -80,15 +80,15 @@ class ObjectPoolTest { poolCapacity = TestPoolable.getPool().poolCapacity poolCount = TestPoolable.getPool().poolCount - Assert.assertEquals(4, poolCapacity) - Assert.assertEquals(0, poolCount) + assertEquals(4, poolCapacity) + assertEquals(0, poolCount) TestPoolable.recycleInstances(testPoolables) poolCapacity = TestPoolable.getPool().poolCapacity poolCount = TestPoolable.getPool().poolCount - Assert.assertEquals(4, poolCapacity) - Assert.assertEquals(4, poolCount) + assertEquals(4, poolCapacity) + assertEquals(4, poolCount) testPoolables.clear() @@ -98,28 +98,28 @@ class ObjectPoolTest { testPoolables.add(TestPoolable.getInstance(3, 5)) testPoolables.add(TestPoolable.getInstance(6, 8)) testPoolables.add(TestPoolable.getInstance(8, 9)) - Assert.assertEquals(12, testPoolables[0].foo) - Assert.assertEquals(24, testPoolables[0].bar) - Assert.assertEquals(1, testPoolables[1].foo) - Assert.assertEquals(2, testPoolables[1].bar) - Assert.assertEquals(3, testPoolables[2].foo) - Assert.assertEquals(5, testPoolables[2].bar) - Assert.assertEquals(6, testPoolables[3].foo) - Assert.assertEquals(8, testPoolables[3].bar) - Assert.assertEquals(8, testPoolables[4].foo) - Assert.assertEquals(9, testPoolables[4].bar) + assertEquals(12, testPoolables[0].foo) + assertEquals(24, testPoolables[0].bar) + assertEquals(1, testPoolables[1].foo) + assertEquals(2, testPoolables[1].bar) + assertEquals(3, testPoolables[2].foo) + assertEquals(5, testPoolables[2].bar) + assertEquals(6, testPoolables[3].foo) + assertEquals(8, testPoolables[3].bar) + assertEquals(8, testPoolables[4].foo) + assertEquals(9, testPoolables[4].bar) poolCapacity = TestPoolable.getPool().poolCapacity poolCount = TestPoolable.getPool().poolCount - Assert.assertEquals(4, poolCapacity) - Assert.assertEquals(3, poolCount) + assertEquals(4, poolCapacity) + assertEquals(3, poolCount) TestPoolable.recycleInstances(testPoolables) poolCapacity = TestPoolable.getPool().poolCapacity poolCount = TestPoolable.getPool().poolCount - Assert.assertEquals(8, poolCapacity) - Assert.assertEquals(8, poolCount) + assertEquals(8, poolCapacity) + assertEquals(8, poolCount) testPoolables.clear() @@ -134,26 +134,26 @@ class ObjectPoolTest { testPoolables.add(TestPoolable.getInstance(12, 24)) testPoolables.add(TestPoolable.getInstance(6, 8)) testPoolables.add(TestPoolable.getInstance(6, 8)) - Assert.assertEquals(0, testPoolables[0].foo) - Assert.assertEquals(0, testPoolables[0].bar) - Assert.assertEquals(6, testPoolables[1].foo) - Assert.assertEquals(8, testPoolables[1].bar) - Assert.assertEquals(1, testPoolables[2].foo) - Assert.assertEquals(2, testPoolables[2].bar) - Assert.assertEquals(3, testPoolables[3].foo) - Assert.assertEquals(5, testPoolables[3].bar) - Assert.assertEquals(8, testPoolables[4].foo) - Assert.assertEquals(9, testPoolables[4].bar) - Assert.assertEquals(12, testPoolables[5].foo) - Assert.assertEquals(24, testPoolables[5].bar) - Assert.assertEquals(12, testPoolables[6].foo) - Assert.assertEquals(24, testPoolables[6].bar) - Assert.assertEquals(12, testPoolables[7].foo) - Assert.assertEquals(24, testPoolables[7].bar) - Assert.assertEquals(6, testPoolables[8].foo) - Assert.assertEquals(8, testPoolables[8].bar) - Assert.assertEquals(6, testPoolables[9].foo) - Assert.assertEquals(8, testPoolables[9].bar) + assertEquals(0, testPoolables[0].foo) + assertEquals(0, testPoolables[0].bar) + assertEquals(6, testPoolables[1].foo) + assertEquals(8, testPoolables[1].bar) + assertEquals(1, testPoolables[2].foo) + assertEquals(2, testPoolables[2].bar) + assertEquals(3, testPoolables[3].foo) + assertEquals(5, testPoolables[3].bar) + assertEquals(8, testPoolables[4].foo) + assertEquals(9, testPoolables[4].bar) + assertEquals(12, testPoolables[5].foo) + assertEquals(24, testPoolables[5].bar) + assertEquals(12, testPoolables[6].foo) + assertEquals(24, testPoolables[6].bar) + assertEquals(12, testPoolables[7].foo) + assertEquals(24, testPoolables[7].bar) + assertEquals(6, testPoolables[8].foo) + assertEquals(8, testPoolables[8].bar) + assertEquals(6, testPoolables[9].foo) + assertEquals(8, testPoolables[9].bar) for (p in testPoolables) { TestPoolable.recycleInstance(p) @@ -161,25 +161,25 @@ class ObjectPoolTest { poolCapacity = TestPoolable.getPool().poolCapacity poolCount = TestPoolable.getPool().poolCount - Assert.assertEquals(16, poolCapacity) - Assert.assertEquals(16, poolCount) + assertEquals(16, poolCapacity) + assertEquals(16, poolCount) testPoolable = TestPoolable.getInstance(9001, 9001) - Assert.assertEquals(9001, testPoolable.foo) - Assert.assertEquals(9001, testPoolable.bar) + assertEquals(9001, testPoolable.foo) + assertEquals(9001, testPoolable.bar) poolCapacity = TestPoolable.getPool().poolCapacity poolCount = TestPoolable.getPool().poolCount - Assert.assertEquals(16, poolCapacity) - Assert.assertEquals(15, poolCount) + assertEquals(16, poolCapacity) + assertEquals(15, poolCount) TestPoolable.recycleInstance(testPoolable) poolCapacity = TestPoolable.getPool().poolCapacity poolCount = TestPoolable.getPool().poolCount - Assert.assertEquals(16, poolCapacity) - Assert.assertEquals(16, poolCount) + assertEquals(16, poolCapacity) + assertEquals(16, poolCount) var e: Exception? = null try { @@ -188,7 +188,7 @@ class ObjectPoolTest { } catch (ex: Exception) { e = ex } finally { - Assert.assertEquals(e!!.message, true, true) + assertEquals(true, true, e!!.message) } testPoolables.clear() @@ -202,14 +202,14 @@ class ObjectPoolTest { poolCapacity = TestPoolable.getPool().poolCapacity poolCount = TestPoolable.getPool().poolCount - Assert.assertEquals(16, poolCapacity) - Assert.assertEquals(0, poolCount) + assertEquals(16, poolCapacity) + assertEquals(0, poolCount) testPoolables.add(TestPoolable.getInstance(0, 0)) poolCapacity = TestPoolable.getPool().poolCapacity poolCount = TestPoolable.getPool().poolCount - Assert.assertEquals(16, poolCapacity) - Assert.assertEquals(7, poolCount) + assertEquals(16, poolCapacity) + assertEquals(7, poolCount) } } \ No newline at end of file diff --git a/chartLibCore/src/commonTest/kotlin/info/appdev/charting/utils/MatrixTest.kt b/chartLibCore/src/commonTest/kotlin/info/appdev/charting/utils/MatrixTest.kt new file mode 100644 index 000000000..44d9ca595 --- /dev/null +++ b/chartLibCore/src/commonTest/kotlin/info/appdev/charting/utils/MatrixTest.kt @@ -0,0 +1,156 @@ +package info.appdev.charting.utils + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class MatrixTest { + + private fun assertClose(expected: Float, actual: Float, tolerance: Float = 1e-4f) { + assertTrue(kotlin.math.abs(expected - actual) < tolerance, "expected $expected but was $actual") + } + + @Test + fun reset_isIdentity() { + val m = Matrix() + val pts = floatArrayOf(3f, 4f) + m.mapPoints(pts) + assertClose(3f, pts[0]) + assertClose(4f, pts[1]) + } + + @Test + fun postTranslate_shiftsPoints() { + val m = Matrix() + m.postTranslate(10f, -5f) + val pts = floatArrayOf(1f, 1f) + m.mapPoints(pts) + assertClose(11f, pts[0]) + assertClose(-4f, pts[1]) + } + + @Test + fun postScale_scalesAboutOrigin() { + val m = Matrix() + m.postScale(2f, 3f) + val pts = floatArrayOf(2f, 2f) + m.mapPoints(pts) + assertClose(4f, pts[0]) + assertClose(6f, pts[1]) + } + + @Test + fun postScale_scalesAboutPivot() { + val m = Matrix() + m.postScale(2f, 2f, 10f, 10f) + val pts = floatArrayOf(10f, 10f) + m.mapPoints(pts) + // pivot point should be invariant under a scale about itself + assertClose(10f, pts[0]) + assertClose(10f, pts[1]) + } + + @Test + fun postTranslateThenPostScale_appliesInOrder() { + // translate(+10,0) then scale(2,2): scaling is "post", so it applies to the + // already-translated result -> point (0,0) -> (10,0) -> (20,0) + val m = Matrix() + m.postTranslate(10f, 0f) + m.postScale(2f, 2f) + val pts = floatArrayOf(0f, 0f) + m.mapPoints(pts) + assertClose(20f, pts[0]) + assertClose(0f, pts[1]) + } + + @Test + fun setValuesAndGetValues_roundTrip() { + val m = Matrix() + m.postTranslate(3f, 4f) + val buffer = FloatArray(9) + m.getValues(buffer) + + val m2 = Matrix() + m2.setValues(buffer) + + val pts = floatArrayOf(1f, 1f) + m2.mapPoints(pts) + assertClose(4f, pts[0]) + assertClose(5f, pts[1]) + } + + @Test + fun invert_undoesTransform() { + val m = Matrix() + m.postScale(2f, 4f) + m.postTranslate(3f, -1f) + + val inverse = Matrix() + val success = m.invert(inverse) + assertTrue(success) + + val original = floatArrayOf(5f, 7f) + val pts = original.copyOf() + m.mapPoints(pts) + inverse.mapPoints(pts) + + assertClose(original[0], pts[0]) + assertClose(original[1], pts[1]) + } + + @Test + fun mapRect_computesBoundingBoxAfterTransform() { + val m = Matrix() + m.postScale(2f, 3f) + m.postTranslate(1f, 1f) + + val r = RectF(0f, 0f, 10f, 20f) + m.mapRect(r) + + assertClose(1f, r.left) + assertClose(1f, r.top) + assertClose(21f, r.right) + assertClose(61f, r.bottom) + } + + @Test + fun postConcat_composesInExpectedOrder() { + val a = Matrix() + a.postTranslate(5f, 0f) + + val b = Matrix() + b.postScale(2f, 2f) + + // a.postConcat(b) => new = b * a, i.e. b applied after a + a.postConcat(b) + + val pts = floatArrayOf(0f, 0f) + a.mapPoints(pts) + assertEquals(10f, pts[0]) + assertEquals(0f, pts[1]) + } + + @Test + fun setScale_replacesExistingTransform() { + val m = Matrix() + m.postTranslate(100f, 100f) + m.setScale(2f, 2f) + + val pts = floatArrayOf(3f, 3f) + m.mapPoints(pts) + assertClose(6f, pts[0]) + assertClose(6f, pts[1]) + } + + @Test + fun setTranslate_replacesExistingTransform() { + val m = Matrix() + m.postScale(5f, 5f) + m.setTranslate(2f, 3f) + + val pts = floatArrayOf(1f, 1f) + m.mapPoints(pts) + assertClose(3f, pts[0]) + assertClose(4f, pts[1]) + } +} diff --git a/chartLibCore/src/desktopMain/kotlin/info/appdev/charting/utils/ChartIcon.desktop.kt b/chartLibCore/src/desktopMain/kotlin/info/appdev/charting/utils/ChartIcon.desktop.kt new file mode 100644 index 000000000..693aadc2f --- /dev/null +++ b/chartLibCore/src/desktopMain/kotlin/info/appdev/charting/utils/ChartIcon.desktop.kt @@ -0,0 +1,8 @@ +package info.appdev.charting.utils + +/** + * Placeholder [ChartIcon] implementation for the desktop (JVM) target until a Compose + * Multiplatform icon representation is designed; entry icons are not yet rendered on this + * platform. + */ +actual abstract class ChartIcon diff --git a/chartLibCore/src/desktopMain/kotlin/info/appdev/charting/utils/ChartTypeface.desktop.kt b/chartLibCore/src/desktopMain/kotlin/info/appdev/charting/utils/ChartTypeface.desktop.kt new file mode 100644 index 000000000..c8c53e73f --- /dev/null +++ b/chartLibCore/src/desktopMain/kotlin/info/appdev/charting/utils/ChartTypeface.desktop.kt @@ -0,0 +1,6 @@ +package info.appdev.charting.utils + +/** + * Placeholder implementation until a native (java.awt.Font-based) typeface abstraction is designed. + */ +actual open class ChartTypeface diff --git a/chartLibCore/src/iosMain/kotlin/info/appdev/charting/utils/ChartIcon.ios.kt b/chartLibCore/src/iosMain/kotlin/info/appdev/charting/utils/ChartIcon.ios.kt new file mode 100644 index 000000000..8aba87d4c --- /dev/null +++ b/chartLibCore/src/iosMain/kotlin/info/appdev/charting/utils/ChartIcon.ios.kt @@ -0,0 +1,7 @@ +package info.appdev.charting.utils + +/** + * Placeholder [ChartIcon] implementation for iOS until a Compose Multiplatform icon + * representation is designed; entry icons are not yet rendered on this platform. + */ +actual abstract class ChartIcon diff --git a/chartLibCore/src/iosMain/kotlin/info/appdev/charting/utils/ChartTypeface.ios.kt b/chartLibCore/src/iosMain/kotlin/info/appdev/charting/utils/ChartTypeface.ios.kt new file mode 100644 index 000000000..bdb0aa5be --- /dev/null +++ b/chartLibCore/src/iosMain/kotlin/info/appdev/charting/utils/ChartTypeface.ios.kt @@ -0,0 +1,6 @@ +package info.appdev.charting.utils + +/** + * Placeholder implementation until a native (CoreText-based) typeface abstraction is designed. + */ +actual open class ChartTypeface diff --git a/demoKmp/build.gradle.kts b/demoKmp/build.gradle.kts new file mode 100644 index 000000000..e2843a478 --- /dev/null +++ b/demoKmp/build.gradle.kts @@ -0,0 +1,60 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id("com.android.kotlin.multiplatform.library") + id("org.jetbrains.kotlin.multiplatform") + id("org.jetbrains.compose") version "1.12.1" + id("org.jetbrains.kotlin.plugin.compose") version "2.4.10" +} + +kotlin { + compilerOptions { + freeCompilerArgs.add("-Xexpect-actual-classes") + } + + android { + namespace = "info.appdev.charting.demokmp" + compileSdk = 37 + minSdk = 23 + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + } + + jvm("desktop") + + // Same iOS target scoping as chartLibComposeMultiplatform (Step A.7): Compose Multiplatform + // (as of 1.12.x) no longer publishes iosX64 (Intel simulator) artifacts. + val xcfName = "demoKmpKit" + listOf( + iosArm64(), + iosSimulatorArm64() + ).forEach { iosTarget -> + iosTarget.binaries.framework { + baseName = xcfName + } + } + + sourceSets { + commonMain.dependencies { + implementation(project(":chartLibComposeMultiplatform")) + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.ui) + } + val desktopMain by getting { + dependencies { + implementation(compose.desktop.currentOs) + } + } + } +} + +// Exposes `:demoKmp:run` to launch the desktop entry point (`desktopMain/Main.kt`) directly, +// without needing the separate `demoKmpAndroid` application module. +compose.desktop { + application { + mainClass = "info.appdev.charting.demokmp.MainKt" + } +} diff --git a/demoKmp/src/commonMain/kotlin/info/appdev/charting/demokmp/DemoKmpApp.kt b/demoKmp/src/commonMain/kotlin/info/appdev/charting/demokmp/DemoKmpApp.kt new file mode 100644 index 000000000..7501a2220 --- /dev/null +++ b/demoKmp/src/commonMain/kotlin/info/appdev/charting/demokmp/DemoKmpApp.kt @@ -0,0 +1,98 @@ +package info.appdev.charting.demokmp + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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 info.appdev.charting.compose.multiplatform.gesture.chartTransformGestures +import info.appdev.charting.compose.multiplatform.renderer.drawBarChartDataSet +import info.appdev.charting.compose.multiplatform.renderer.drawXAxisGridLines +import info.appdev.charting.compose.multiplatform.renderer.drawYAxisGridLines +import info.appdev.charting.components.XAxis +import info.appdev.charting.components.YAxis +import info.appdev.charting.data.BarDataSet +import info.appdev.charting.data.BarEntryFloat +import info.appdev.charting.utils.TransformerCore +import info.appdev.charting.utils.ViewPortHandler + +/** + * `demoKmp` — Step A.9's shared Compose Multiplatform demo screen, built entirely on the + * proof-of-concept renderers/gesture handling added in Steps A.7/A.8 + * (`chartLibComposeMultiplatform`), rather than `chartLib`/`chartLibCompose` (Android-only). + * + * This intentionally mirrors the `app`/`chartLibCompose` demo apps' spirit (show a chart with + * a couple of example datasets, backed by `androidx.compose.material3`) while only using + * portable (`chartLibCore`) chart types under the hood, so the exact same `@Composable` + * renders identically on Android, Desktop, and iOS. + */ +@Composable +fun DemoKmpApp() { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + BarChartDemo() + } + } +} + +@Composable +private fun BarChartDemo() { + val dataSet = remember { + BarDataSet( + mutableListOf( + BarEntryFloat(0f, 4f), + BarEntryFloat(1f, 8f), + BarEntryFloat(2f, 6f), + BarEntryFloat(3f, 10f), + BarEntryFloat(4f, 3f) + ), + "demoKmp bar dataset" + ) + } + + val viewPortHandler = remember { ViewPortHandler() } + val transformer = remember { TransformerCore(viewPortHandler) } + val xAxis = remember { XAxis().apply { entries = floatArrayOf(0f, 1f, 2f, 3f, 4f) } } + val yAxis = remember { YAxis().apply { entries = floatArrayOf(0f, 2f, 4f, 6f, 8f, 10f) } } + + // Bumped after every gesture to trigger a redraw (Canvas isn't itself observing + // ViewPortHandler's mutable internal matrix state). + var redrawTick by remember { mutableIntStateOf(0) } + + Box(modifier = Modifier.fillMaxSize()) { + Canvas( + modifier = Modifier + .fillMaxSize() + .chartTransformGestures(viewPortHandler) { redrawTick++ } + ) { + redrawTick // read to establish a recomposition dependency on gesture updates + + viewPortHandler.setChartDimens(size.width, size.height) + viewPortHandler.restrainViewPort(40f, 20f, 20f, 40f) + transformer.prepareMatrixValuePx(xChartMin = -0.5f, deltaX = 5f, deltaY = 11f, yChartMin = -0.5f) + transformer.prepareMatrixOffset(inverted = false) + + drawXAxisGridLines(xAxis, viewPortHandler, transformer) + drawYAxisGridLines(yAxis, viewPortHandler, transformer) + drawBarChartDataSet(dataSet, barWidth = 0.6f, transformer = transformer) + } + + Text( + text = "demoKmp: BarDataSet + axis grid lines rendered via chartLibComposeMultiplatform. " + + "Pinch/drag to pan & zoom.", + modifier = Modifier + .align(Alignment.TopStart) + .padding(8.dp) + ) + } +} diff --git a/demoKmp/src/desktopMain/kotlin/info/appdev/charting/demokmp/Main.kt b/demoKmp/src/desktopMain/kotlin/info/appdev/charting/demokmp/Main.kt new file mode 100644 index 000000000..41d4e17ef --- /dev/null +++ b/demoKmp/src/desktopMain/kotlin/info/appdev/charting/demokmp/Main.kt @@ -0,0 +1,13 @@ +package info.appdev.charting.demokmp + +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application + +/** + * Desktop (JVM) entry point for the `demoKmp` Compose Multiplatform demo (Step A.9). + */ +fun main() = application { + Window(onCloseRequest = ::exitApplication, title = "demoKmp") { + DemoKmpApp() + } +} diff --git a/demoKmp/src/iosMain/kotlin/info/appdev/charting/demokmp/MainViewController.kt b/demoKmp/src/iosMain/kotlin/info/appdev/charting/demokmp/MainViewController.kt new file mode 100644 index 000000000..b69c14df1 --- /dev/null +++ b/demoKmp/src/iosMain/kotlin/info/appdev/charting/demokmp/MainViewController.kt @@ -0,0 +1,20 @@ +package info.appdev.charting.demokmp + +import androidx.compose.ui.window.ComposeUIViewController +import platform.UIKit.UIViewController + +/** + * iOS entry point for the `demoKmp` Compose Multiplatform demo (Step A.9), to be called from a + * thin Swift/Xcode wrapper app's `UIHostingController`-equivalent (typically + * `ComposeUIViewController`-based `UIViewController` embedding, e.g. + * `UIApplication.shared.windows.first?.rootViewController = MainViewController()`). + * + * **Not yet done**: the actual Xcode wrapper project (`.xcodeproj`/`.xcworkspace`) that embeds + * this framework's `MainViewController()` isn't part of this Gradle-buildable slice -- only the + * `iosArm64`/`iosSimulatorArm64` Kotlin framework artifact is. Building/running it standalone + * from Gradle is possible via `:demoKmp:linkDebugFrameworkIosSimulatorArm64`. + */ +@Suppress("unused") +fun MainViewController(): UIViewController = ComposeUIViewController { + DemoKmpApp() +} diff --git a/demoKmpAndroid/build.gradle.kts b/demoKmpAndroid/build.gradle.kts new file mode 100644 index 000000000..ecd902f3b --- /dev/null +++ b/demoKmpAndroid/build.gradle.kts @@ -0,0 +1,44 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.plugin.compose") version "2.4.10" +} + +// Plain (non-multiplatform) Android application module: AGP 9's new KMP DSL forbids combining +// `com.android.application` with `org.jetbrains.kotlin.multiplatform`'s `androidTarget()` in the +// same Gradle project, so the actual `applicationId`/launcher-activity/manifest live here, while +// `:demoKmp` (a `com.android.kotlin.multiplatform.library` module) supplies the shared +// `DemoKmpApp()` composable and the Desktop/iOS entry points. +android { + namespace = "info.appdev.charting.demokmp.androidapp" + compileSdk = 37 + + defaultConfig { + applicationId = "info.appdev.charting.demokmp" + minSdk = 23 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_17 + } + } + + buildFeatures { + compose = true + } +} + +dependencies { + implementation(project(":demoKmp")) + implementation("androidx.activity:activity-compose:1.10.1") +} diff --git a/demoKmpAndroid/src/main/AndroidManifest.xml b/demoKmpAndroid/src/main/AndroidManifest.xml new file mode 100644 index 000000000..c4eb598bd --- /dev/null +++ b/demoKmpAndroid/src/main/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + diff --git a/demoKmpAndroid/src/main/kotlin/info/appdev/charting/demokmp/MainActivity.kt b/demoKmpAndroid/src/main/kotlin/info/appdev/charting/demokmp/MainActivity.kt new file mode 100644 index 000000000..d26623f26 --- /dev/null +++ b/demoKmpAndroid/src/main/kotlin/info/appdev/charting/demokmp/MainActivity.kt @@ -0,0 +1,20 @@ +package info.appdev.charting.demokmp + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent + +/** + * Android entry point for the `demoKmp` Compose Multiplatform demo (Step A.9). Lives in its own + * plain `com.android.application` module (`demoKmpAndroid`), separate from the KMP `demoKmp` + * library module, because AGP 9's new KMP DSL forbids combining `com.android.application` with + * `org.jetbrains.kotlin.multiplatform`'s `androidTarget()` in the same Gradle project. + */ +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + DemoKmpApp() + } + } +} diff --git a/demoKmpAndroid/src/main/res/values/strings.xml b/demoKmpAndroid/src/main/res/values/strings.xml new file mode 100644 index 000000000..becf407c3 --- /dev/null +++ b/demoKmpAndroid/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + demoKmp + diff --git a/gradle.properties b/gradle.properties index 2f17915e1..085e5e2e0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,3 +7,8 @@ SONATYPE_HOST=CENTRAL_PORTAL RELEASE_SIGNING_ENABLED=true POM_ARTIFACT_ID=chart android.nonTransitiveRClass=false + +# chartLibCore is a Kotlin Multiplatform module with iOS targets, which cannot be +# compiled/tested on non-macOS CI runners (e.g. the Linux "Check" job). Without this, +# Gradle fails the build instead of just skipping those disabled native tasks. +kotlin.native.ignoreDisabledTargets=true diff --git a/settings.gradle.kts b/settings.gradle.kts index 2e108c247..f01f50b81 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,4 +1,8 @@ include("chartLib") +include("chartLibCore") include("chartLibCompose") +include("chartLibComposeMultiplatform") +include("demoKmp") +include("demoKmpAndroid") include("app") include("lint")