diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..edbedb7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# Test fixture JSON files: treat as binary for cleaner diffs and mark as +# linguist-generated so they don't count toward language statistics. +app/src/androidTest/assets/fixtures/*.json binary linguist-generated +app/src/test/resources/fixtures/*.json binary linguist-generated diff --git a/.gitignore b/.gitignore index c35773b..2d5e065 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,14 @@ local.properties # Local environment config (not committed — copy and adapt per machine) .envrc + +# Claude Code local artifacts +.claude/settings.local.json +.claude/reviews/ +.claude/plans/ + +# Codanna local config +.codannaignore + +# Temporary debugging artifacts (screenshots, bug reports) +/tmp/ diff --git a/CLAUDE.md b/CLAUDE.md index 085ed50..264d997 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,5 +14,65 @@ JAVA_HOME="/c/Program Files/Android/Android Studio/jbr" ./gradlew assembleDebug ### Install to tablet ```bash -"/c/Users/Durham/AppData/Local/Android/Sdk/platform-tools/adb.exe" install -r app/build/outputs/apk/debug/app-debug.apk +./gradlew installDebug ``` + +### Tests + +```bash +# Unit tests only (no device required) +./gradlew testDebugUnitTest + +# All tests (unit + instrumented, requires connected device) +./gradlew allTests +``` + +## Development Workflow + +Before starting any implementation, review [`docs/VISION.md`](docs/VISION.md) to ensure the new feature or UX aligns with the overall product vision and design principles. + +Break implementation tasks into small, independently testable pieces. For each piece, follow this cycle: + +1. **Write tests first**: Create unit tests that define the expected behavior before writing any implementation code. Run them to confirm they fail for the right reasons. +2. **Implement**: Write the minimum code to make the tests pass. +3. **Run all tests**: `./gradlew testDebugUnitTest` — ensure no regressions. +4. **Commit**: Commit the piece to the feature/fix/dev branch. + +Repeat steps 1-4 for each piece. After all pieces are complete: + +5. **Local review**: `REVIEW=$(./scripts/review-pr.sh --local --no-post)` — review the full diff and address all actionable items. +6. **Final test run**: `./gradlew testDebugUnitTest` — confirm everything still passes after review fixes. +7. **Push**: Push to both local and remote feature/fix/dev branch. + +## Device Debugging + +Screenshots and bug reports are saved to `tmp/` (gitignored). + +```bash +# Capture screenshot from device +adb exec-out screencap -p > tmp/screenshot.png + +# Find latest bug report path +adb logcat -d | grep "Bug report generated" | tail -1 + +# Pull bug report (use double quotes to prevent git bash path mangling) +adb exec-out "cat /storage/emulated/0/Android/data/com.writer.dev/files/Documents/bug-reports/bug-report-TIMESTAMP.json" > tmp/bug-report.json +``` + +The app generates bug reports via the menu (hamburger → Bug Report). Reports contain device info, recent stroke history, processing events, and document state. + +## PR Workflow + +Before creating a PR, run the self-review cycle locally: + +1. **Review local changes**: `REVIEW=$(./scripts/review-pr.sh --local --no-post)` — reviews the branch diff against master (use `--base ` to diff against a different branch), saves to `.claude/reviews/`. +2. **Read the review file** (`cat "$REVIEW"`) and address every actionable item by editing the code and committing. +3. **Verify fixes**: `./scripts/review-check.sh --local --no-post "$REVIEW"` — confirm all items are addressed. +4. If any items remain open, go back to step 2. +5. Once all items are resolved, **create the PR** and post the review: + - `gh pr create --draft` (or `gh pr create` if ready) + - `./scripts/review-pr.sh --post` (runs a fresh review and posts to the PR) + - `./scripts/review-check.sh --post "$REVIEW"` (posts the resolution checklist) + - `gh pr ready` (if created as draft) + +The `--local` flag works without a remote or PR. The `--no-post` / `--post` flags make scripts non-interactive for agent use. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6e1454f..dee06ec 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,6 +14,8 @@ android { targetSdk = 34 versionCode = 1 versionName = "0.1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunnerArguments["notAnnotation"] = "com.writer.recognition.DevTool" } buildTypes { @@ -39,8 +41,21 @@ android { jvmTarget = "17" } + testOptions { + unitTests { + isReturnDefaultValues = true + isIncludeAndroidResources = true + all { + it.jvmArgs( + "--add-opens", "java.base/jdk.internal.access=ALL-UNNAMED", + ) + } + } + } + buildFeatures { viewBinding = true + aidl = true } packaging { @@ -50,6 +65,40 @@ android { } } +tasks.register("captureFixture") { + description = "Capture handwriting fixture from device: -PfixtureName=hello -PexpectedText=\"hello\"" + dependsOn("installDebug", "installDebugAndroidTest") + doLast { + val name = project.property("fixtureName") as String + val text = project.property("expectedText") as String + val lang = project.findProperty("language") as? String ?: "en-US" + val line = project.findProperty("lineIndex") as? String ?: "0" + val adb = android.adbExecutable.absolutePath + val appId = "com.writer.dev" + + exec { + commandLine(adb, "shell", "am", "instrument", "-w", + "-e", "class", "com.writer.recognition.StrokeFixtureCapture", + "-e", "fixtureName", name, + "-e", "expectedText", text, + "-e", "language", lang, + "-e", "lineIndex", line, + "$appId.test/androidx.test.runner.AndroidJUnitRunner") + } + + exec { + commandLine(adb, "pull", + "/sdcard/Download/inkup-fixtures/$name.json", + "app/src/androidTest/assets/fixtures/$name.json") + } + + exec { + commandLine(adb, "shell", "rm", + "/sdcard/Download/inkup-fixtures/$name.json") + } + } +} + configurations.all { // Onyx SDK pulls in old pre-AndroidX support libraries that clash with AndroidX exclude(group = "com.android.support", module = "support-compat") @@ -86,4 +135,42 @@ dependencies { implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") implementation("androidx.recyclerview:recyclerview:1.3.2") implementation("com.google.android.material:material:1.12.0") + + // Testing + testImplementation("junit:junit:4.13.2") + testImplementation("org.json:json:20231013") + testImplementation("org.robolectric:robolectric:4.14.1") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1") + + androidTestImplementation("androidx.test:runner:1.6.2") + androidTestImplementation("androidx.test:rules:1.6.1") + androidTestImplementation("androidx.test.ext:junit:1.2.1") + androidTestImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1") +} + +tasks.withType { + testLogging { + events(org.gradle.api.tasks.testing.logging.TestLogEvent.FAILED) + showStandardStreams = false + } + addTestListener(object : TestListener { + override fun beforeSuite(suite: TestDescriptor) {} + override fun afterSuite(suite: TestDescriptor, result: TestResult) { + if (suite.parent == null) { + println("${result.resultType}: ${result.successfulTestCount} passed, ${result.failedTestCount} failed, ${result.skippedTestCount} skipped") + } + } + override fun beforeTest(testDescriptor: TestDescriptor) {} + override fun afterTest(testDescriptor: TestDescriptor, result: TestResult) {} + }) +} + +tasks.register("allTests") { + description = "Runs all tests: unit tests and instrumented tests on connected device" + group = "verification" + dependsOn("testDebugUnitTest", "connectedDebugAndroidTest") +} +// Run unit tests first — fail fast before slower device tests +tasks.matching { it.name == "connectedDebugAndroidTest" }.configureEach { + mustRunAfter("testDebugUnitTest") } diff --git a/app/src/androidTest/assets/fixtures/hello_test.json b/app/src/androidTest/assets/fixtures/hello_test.json new file mode 100644 index 0000000..8743dae --- /dev/null +++ b/app/src/androidTest/assets/fixtures/hello_test.json @@ -0,0 +1,245 @@ +{ + "expectedText": "hello test", + "language": "en-US", + "strokes": [ + { + "strokeId": "s0", + "points": [ + { "x": 59.63, "y": 30.94, "pressure": 1071.0, "timestamp": 1773472267298 }, + { "x": 59.63, "y": 30.94, "pressure": 2865.0, "timestamp": 1773472267366 }, + { "x": 59.83, "y": 34.91, "pressure": 3167.0, "timestamp": 1773472267396 }, + { "x": 58.84, "y": 46.20, "pressure": 3248.0, "timestamp": 1773472267405 }, + { "x": 57.26, "y": 56.10, "pressure": 3284.0, "timestamp": 1773472267416 }, + { "x": 55.28, "y": 65.81, "pressure": 3289.0, "timestamp": 1773472267427 }, + { "x": 53.30, "y": 74.53, "pressure": 3374.0, "timestamp": 1773472267435 }, + { "x": 51.91, "y": 81.46, "pressure": 3351.0, "timestamp": 1773472267447 }, + { "x": 51.31, "y": 87.80, "pressure": 3345.0, "timestamp": 1773472267455 }, + { "x": 51.31, "y": 91.96, "pressure": 3325.0, "timestamp": 1773472267466 }, + { "x": 51.31, "y": 93.54, "pressure": 3298.0, "timestamp": 1773472267484 }, + { "x": 51.31, "y": 93.54, "pressure": 3257.0, "timestamp": 1773472267557 }, + { "x": 54.68, "y": 90.77, "pressure": 3273.0, "timestamp": 1773472267566 }, + { "x": 58.45, "y": 86.41, "pressure": 3279.0, "timestamp": 1773472267576 }, + { "x": 62.41, "y": 82.45, "pressure": 3280.0, "timestamp": 1773472267587 }, + { "x": 65.58, "y": 78.49, "pressure": 3273.0, "timestamp": 1773472267596 }, + { "x": 68.55, "y": 75.52, "pressure": 3262.0, "timestamp": 1773472267609 }, + { "x": 70.53, "y": 74.33, "pressure": 3249.0, "timestamp": 1773472267623 }, + { "x": 72.12, "y": 73.34, "pressure": 3234.0, "timestamp": 1773472267639 }, + { "x": 73.70, "y": 72.74, "pressure": 3221.0, "timestamp": 1773472267655 }, + { "x": 75.29, "y": 72.35, "pressure": 3231.0, "timestamp": 1773472267669 }, + { "x": 77.27, "y": 73.14, "pressure": 3244.0, "timestamp": 1773472267678 }, + { "x": 79.05, "y": 75.52, "pressure": 3446.0, "timestamp": 1773472267690 }, + { "x": 80.44, "y": 79.48, "pressure": 3508.0, "timestamp": 1773472267701 }, + { "x": 81.43, "y": 84.83, "pressure": 3556.0, "timestamp": 1773472267710 }, + { "x": 81.43, "y": 89.19, "pressure": 3568.0, "timestamp": 1773472267721 }, + { "x": 81.43, "y": 93.15, "pressure": 3571.0, "timestamp": 1773472267732 }, + { "x": 81.03, "y": 96.32, "pressure": 3554.0, "timestamp": 1773472267744 }, + { "x": 81.03, "y": 99.09, "pressure": 3288.0, "timestamp": 1773472267755 }, + { "x": 81.23, "y": 101.07, "pressure": 3010.0, "timestamp": 1773472267765 }, + { "x": 81.23, "y": 101.86, "pressure": 2756.0, "timestamp": 1773472267774 }, + { "x": 81.63, "y": 103.05, "pressure": 1398.0, "timestamp": 1773472267785 }, + { "x": 82.62, "y": 104.24, "pressure": 1211.0, "timestamp": 1773472267796 }, + { "x": 82.62, "y": 104.24, "pressure": 1211.0, "timestamp": 1773472267798 } + ] + }, + { + "strokeId": "s1", + "points": [ + { "x": 94.11, "y": 84.83, "pressure": 374.0, "timestamp": 1773472268057 }, + { "x": 94.11, "y": 84.83, "pressure": 2510.0, "timestamp": 1773472268149 }, + { "x": 94.11, "y": 84.83, "pressure": 2659.0, "timestamp": 1773472268157 }, + { "x": 101.64, "y": 85.03, "pressure": 2744.0, "timestamp": 1773472268169 }, + { "x": 106.59, "y": 83.84, "pressure": 2770.0, "timestamp": 1773472268177 }, + { "x": 108.57, "y": 83.04, "pressure": 2792.0, "timestamp": 1773472268191 }, + { "x": 110.16, "y": 81.86, "pressure": 2851.0, "timestamp": 1773472268202 }, + { "x": 111.15, "y": 80.27, "pressure": 2979.0, "timestamp": 1773472268215 }, + { "x": 111.54, "y": 78.69, "pressure": 3094.0, "timestamp": 1773472268223 }, + { "x": 111.54, "y": 78.69, "pressure": 3256.0, "timestamp": 1773472268236 }, + { "x": 110.36, "y": 78.09, "pressure": 3288.0, "timestamp": 1773472268250 }, + { "x": 107.19, "y": 77.70, "pressure": 3294.0, "timestamp": 1773472268261 }, + { "x": 104.02, "y": 78.88, "pressure": 3279.0, "timestamp": 1773472268273 }, + { "x": 102.63, "y": 80.27, "pressure": 3235.0, "timestamp": 1773472268284 }, + { "x": 100.25, "y": 84.23, "pressure": 3209.0, "timestamp": 1773472268295 }, + { "x": 99.85, "y": 87.80, "pressure": 3185.0, "timestamp": 1773472268303 }, + { "x": 100.85, "y": 91.17, "pressure": 3145.0, "timestamp": 1773472268316 }, + { "x": 104.41, "y": 94.53, "pressure": 3126.0, "timestamp": 1773472268332 }, + { "x": 108.37, "y": 96.52, "pressure": 3117.0, "timestamp": 1773472268343 }, + { "x": 112.34, "y": 97.70, "pressure": 3111.0, "timestamp": 1773472268352 }, + { "x": 116.30, "y": 97.51, "pressure": 2270.0, "timestamp": 1773472268363 }, + { "x": 120.46, "y": 95.92, "pressure": 2153.0, "timestamp": 1773472268373 }, + { "x": 120.46, "y": 95.92, "pressure": 2153.0, "timestamp": 1773472268375 } + ] + }, + { + "strokeId": "s2", + "points": [ + { "x": 139.48, "y": 38.87, "pressure": 581.0, "timestamp": 1773472268650 }, + { "x": 139.48, "y": 38.87, "pressure": 2850.0, "timestamp": 1773472268737 }, + { "x": 136.51, "y": 46.79, "pressure": 2999.0, "timestamp": 1773472268751 }, + { "x": 132.94, "y": 57.29, "pressure": 3049.0, "timestamp": 1773472268760 }, + { "x": 130.76, "y": 67.79, "pressure": 3088.0, "timestamp": 1773472268769 }, + { "x": 128.98, "y": 77.10, "pressure": 3095.0, "timestamp": 1773472268780 }, + { "x": 128.78, "y": 84.63, "pressure": 3096.0, "timestamp": 1773472268790 }, + { "x": 129.18, "y": 90.57, "pressure": 3078.0, "timestamp": 1773472268799 }, + { "x": 129.97, "y": 93.54, "pressure": 2807.0, "timestamp": 1773472268810 }, + { "x": 130.76, "y": 96.91, "pressure": 2719.0, "timestamp": 1773472268819 }, + { "x": 130.76, "y": 96.91, "pressure": 1535.0, "timestamp": 1773472268831 }, + { "x": 132.74, "y": 98.30, "pressure": 1501.0, "timestamp": 1773472268840 } + ] + }, + { + "strokeId": "s3", + "points": [ + { "x": 155.73, "y": 41.84, "pressure": 705.0, "timestamp": 1773472269067 }, + { "x": 155.73, "y": 41.84, "pressure": 2923.0, "timestamp": 1773472269131 }, + { "x": 154.74, "y": 45.21, "pressure": 2990.0, "timestamp": 1773472269145 }, + { "x": 151.96, "y": 56.30, "pressure": 3030.0, "timestamp": 1773472269154 }, + { "x": 149.58, "y": 65.81, "pressure": 3043.0, "timestamp": 1773472269165 }, + { "x": 147.60, "y": 74.72, "pressure": 3046.0, "timestamp": 1773472269175 }, + { "x": 145.62, "y": 82.05, "pressure": 3043.0, "timestamp": 1773472269184 }, + { "x": 144.83, "y": 88.19, "pressure": 3023.0, "timestamp": 1773472269195 }, + { "x": 144.43, "y": 92.95, "pressure": 3018.0, "timestamp": 1773472269204 }, + { "x": 144.63, "y": 94.14, "pressure": 2258.0, "timestamp": 1773472269215 }, + { "x": 144.83, "y": 94.53, "pressure": 1438.0, "timestamp": 1773472269226 }, + { "x": 146.61, "y": 95.33, "pressure": 689.0, "timestamp": 1773472269234 }, + { "x": 148.40, "y": 95.33, "pressure": 671.0, "timestamp": 1773472269241 } + ] + }, + { + "strokeId": "s4", + "points": [ + { "x": 167.02, "y": 82.65, "pressure": 402.0, "timestamp": 1773472269373 }, + { "x": 167.02, "y": 82.65, "pressure": 1254.0, "timestamp": 1773472269417 }, + { "x": 168.21, "y": 88.99, "pressure": 1406.0, "timestamp": 1773472269425 }, + { "x": 169.40, "y": 93.54, "pressure": 1595.0, "timestamp": 1773472269436 }, + { "x": 170.19, "y": 94.73, "pressure": 1656.0, "timestamp": 1773472269448 }, + { "x": 172.57, "y": 95.92, "pressure": 1993.0, "timestamp": 1773472269457 }, + { "x": 175.34, "y": 95.72, "pressure": 2261.0, "timestamp": 1773472269468 }, + { "x": 177.72, "y": 94.93, "pressure": 2345.0, "timestamp": 1773472269476 }, + { "x": 179.30, "y": 93.74, "pressure": 2638.0, "timestamp": 1773472269489 }, + { "x": 180.89, "y": 91.36, "pressure": 2936.0, "timestamp": 1773472269500 }, + { "x": 181.48, "y": 88.59, "pressure": 3029.0, "timestamp": 1773472269509 }, + { "x": 181.28, "y": 86.21, "pressure": 3240.0, "timestamp": 1773472269520 }, + { "x": 180.69, "y": 85.03, "pressure": 3355.0, "timestamp": 1773472269532 }, + { "x": 177.12, "y": 83.04, "pressure": 3388.0, "timestamp": 1773472269541 }, + { "x": 174.15, "y": 82.25, "pressure": 3395.0, "timestamp": 1773472269553 }, + { "x": 171.77, "y": 82.05, "pressure": 3341.0, "timestamp": 1773472269566 }, + { "x": 169.40, "y": 82.85, "pressure": 2959.0, "timestamp": 1773472269579 }, + { "x": 169.40, "y": 82.85, "pressure": 2585.0, "timestamp": 1773472269594 } + ] + }, + { + "strokeId": "s5", + "points": [ + { "x": 231.01, "y": 75.71, "pressure": 48.0, "timestamp": 1773472271839 }, + { "x": 231.01, "y": 75.71, "pressure": 3000.0, "timestamp": 1773472271988 }, + { "x": 232.00, "y": 75.52, "pressure": 3064.0, "timestamp": 1773472272003 }, + { "x": 239.53, "y": 73.14, "pressure": 3080.0, "timestamp": 1773472272011 }, + { "x": 244.68, "y": 72.15, "pressure": 3085.0, "timestamp": 1773472272022 }, + { "x": 249.44, "y": 71.55, "pressure": 3089.0, "timestamp": 1773472272032 }, + { "x": 253.40, "y": 71.16, "pressure": 3092.0, "timestamp": 1773472272041 }, + { "x": 258.16, "y": 70.17, "pressure": 3096.0, "timestamp": 1773472272052 }, + { "x": 263.11, "y": 69.18, "pressure": 3094.0, "timestamp": 1773472272063 }, + { "x": 268.06, "y": 68.19, "pressure": 2333.0, "timestamp": 1773472272071 }, + { "x": 271.03, "y": 67.59, "pressure": 2312.0, "timestamp": 1773472272081 } + ] + }, + { + "strokeId": "s6", + "points": [ + { "x": 256.77, "y": 52.54, "pressure": 903.0, "timestamp": 1773472272259 }, + { "x": 256.77, "y": 52.54, "pressure": 2766.0, "timestamp": 1773472272316 }, + { "x": 254.59, "y": 58.08, "pressure": 2860.0, "timestamp": 1773472272328 }, + { "x": 251.62, "y": 67.39, "pressure": 2912.0, "timestamp": 1773472272340 }, + { "x": 249.64, "y": 75.32, "pressure": 2929.0, "timestamp": 1773472272348 }, + { "x": 248.05, "y": 81.86, "pressure": 2927.0, "timestamp": 1773472272359 }, + { "x": 247.85, "y": 87.80, "pressure": 2922.0, "timestamp": 1773472272370 }, + { "x": 248.05, "y": 92.55, "pressure": 2916.0, "timestamp": 1773472272378 }, + { "x": 248.65, "y": 94.93, "pressure": 2539.0, "timestamp": 1773472272389 }, + { "x": 249.24, "y": 97.11, "pressure": 1827.0, "timestamp": 1773472272401 }, + { "x": 252.81, "y": 99.88, "pressure": 1188.0, "timestamp": 1773472272410 }, + { "x": 254.59, "y": 100.68, "pressure": 1173.0, "timestamp": 1773472272419 } + ] + }, + { + "strokeId": "s7", + "points": [ + { "x": 278.17, "y": 92.16, "pressure": 601.0, "timestamp": 1773472272581 }, + { "x": 278.17, "y": 92.16, "pressure": 2719.0, "timestamp": 1773472272658 }, + { "x": 284.11, "y": 88.00, "pressure": 2794.0, "timestamp": 1773472272667 }, + { "x": 288.27, "y": 84.63, "pressure": 2888.0, "timestamp": 1773472272678 }, + { "x": 289.66, "y": 83.24, "pressure": 2941.0, "timestamp": 1773472272690 }, + { "x": 290.05, "y": 82.05, "pressure": 2977.0, "timestamp": 1773472272700 }, + { "x": 290.65, "y": 79.48, "pressure": 3037.0, "timestamp": 1773472272710 }, + { "x": 290.45, "y": 77.10, "pressure": 3095.0, "timestamp": 1773472272725 }, + { "x": 290.25, "y": 76.71, "pressure": 3142.0, "timestamp": 1773472272739 }, + { "x": 288.87, "y": 75.91, "pressure": 3171.0, "timestamp": 1773472272747 }, + { "x": 284.51, "y": 75.32, "pressure": 3128.0, "timestamp": 1773472272758 }, + { "x": 280.54, "y": 77.70, "pressure": 3123.0, "timestamp": 1773472272769 }, + { "x": 277.18, "y": 81.66, "pressure": 3101.0, "timestamp": 1773472272778 }, + { "x": 274.80, "y": 85.82, "pressure": 3074.0, "timestamp": 1773472272789 }, + { "x": 273.02, "y": 90.37, "pressure": 3068.0, "timestamp": 1773472272797 }, + { "x": 273.02, "y": 93.74, "pressure": 3019.0, "timestamp": 1773472272808 }, + { "x": 274.40, "y": 97.11, "pressure": 2985.0, "timestamp": 1773472272821 }, + { "x": 276.58, "y": 99.49, "pressure": 2859.0, "timestamp": 1773472272836 }, + { "x": 280.54, "y": 101.67, "pressure": 2746.0, "timestamp": 1773472272843 }, + { "x": 284.51, "y": 102.06, "pressure": 1545.0, "timestamp": 1773472272854 }, + { "x": 289.46, "y": 100.87, "pressure": 1379.0, "timestamp": 1773472272866 }, + { "x": 289.46, "y": 100.87, "pressure": 1379.0, "timestamp": 1773472272867 } + ] + }, + { + "strokeId": "s8", + "points": [ + { "x": 320.96, "y": 74.13, "pressure": 836.0, "timestamp": 1773472273014 }, + { "x": 320.96, "y": 74.13, "pressure": 2774.0, "timestamp": 1773472273079 }, + { "x": 316.01, "y": 75.71, "pressure": 2805.0, "timestamp": 1773472273111 }, + { "x": 311.06, "y": 77.89, "pressure": 2811.0, "timestamp": 1773472273123 }, + { "x": 309.67, "y": 79.28, "pressure": 2785.0, "timestamp": 1773472273132 }, + { "x": 308.88, "y": 81.26, "pressure": 2733.0, "timestamp": 1773472273145 }, + { "x": 309.27, "y": 84.03, "pressure": 2694.0, "timestamp": 1773472273159 }, + { "x": 310.46, "y": 86.41, "pressure": 2687.0, "timestamp": 1773472273172 }, + { "x": 314.03, "y": 89.58, "pressure": 2686.0, "timestamp": 1773472273186 }, + { "x": 317.99, "y": 91.96, "pressure": 2866.0, "timestamp": 1773472273195 }, + { "x": 319.97, "y": 92.95, "pressure": 2889.0, "timestamp": 1773472273206 }, + { "x": 322.35, "y": 94.14, "pressure": 2901.0, "timestamp": 1773472273220 }, + { "x": 322.35, "y": 94.14, "pressure": 3233.0, "timestamp": 1773472273252 }, + { "x": 320.76, "y": 96.91, "pressure": 3306.0, "timestamp": 1773472273260 }, + { "x": 315.02, "y": 99.88, "pressure": 3308.0, "timestamp": 1773472273271 }, + { "x": 308.08, "y": 101.86, "pressure": 3309.0, "timestamp": 1773472273283 }, + { "x": 301.15, "y": 103.65, "pressure": 3193.0, "timestamp": 1773472273291 }, + { "x": 295.60, "y": 104.04, "pressure": 2909.0, "timestamp": 1773472273302 }, + { "x": 293.62, "y": 104.04, "pressure": 2817.0, "timestamp": 1773472273310 }, + { "x": 293.62, "y": 104.04, "pressure": 1446.0, "timestamp": 1773472273332 } + ] + }, + { + "strokeId": "s9", + "points": [ + { "x": 331.26, "y": 74.72, "pressure": 633.0, "timestamp": 1773472273559 }, + { "x": 331.26, "y": 74.72, "pressure": 2992.0, "timestamp": 1773472273646 }, + { "x": 339.59, "y": 75.91, "pressure": 2999.0, "timestamp": 1773472273657 }, + { "x": 348.10, "y": 75.91, "pressure": 3000.0, "timestamp": 1773472273667 }, + { "x": 355.83, "y": 75.32, "pressure": 2993.0, "timestamp": 1773472273676 }, + { "x": 361.97, "y": 74.33, "pressure": 2072.0, "timestamp": 1773472273687 }, + { "x": 366.93, "y": 73.34, "pressure": 1774.0, "timestamp": 1773472273695 }, + { "x": 367.72, "y": 73.14, "pressure": 1769.0, "timestamp": 1773472273701 } + ] + }, + { + "strokeId": "s10", + "points": [ + { "x": 354.64, "y": 53.73, "pressure": 1142.0, "timestamp": 1773472273832 }, + { "x": 354.64, "y": 53.73, "pressure": 3160.0, "timestamp": 1773472273885 }, + { "x": 351.87, "y": 61.65, "pressure": 3318.0, "timestamp": 1773472273897 }, + { "x": 349.29, "y": 70.17, "pressure": 3339.0, "timestamp": 1773472273908 }, + { "x": 347.51, "y": 78.88, "pressure": 3356.0, "timestamp": 1773472273917 }, + { "x": 346.92, "y": 85.03, "pressure": 3335.0, "timestamp": 1773472273928 }, + { "x": 347.11, "y": 90.57, "pressure": 3330.0, "timestamp": 1773472273936 }, + { "x": 347.71, "y": 94.53, "pressure": 2685.0, "timestamp": 1773472273947 }, + { "x": 348.70, "y": 96.12, "pressure": 1491.0, "timestamp": 1773472273958 }, + { "x": 350.88, "y": 98.30, "pressure": 374.0, "timestamp": 1773472273966 }, + { "x": 351.27, "y": 98.50, "pressure": 347.0, "timestamp": 1773472273974 } + ] + } + ] +} diff --git a/app/src/androidTest/java/com/writer/recognition/DevTool.kt b/app/src/androidTest/java/com/writer/recognition/DevTool.kt new file mode 100644 index 0000000..5af8fd0 --- /dev/null +++ b/app/src/androidTest/java/com/writer/recognition/DevTool.kt @@ -0,0 +1,4 @@ +package com.writer.recognition + +/** Marker for dev-tool "tests" that should only run when explicitly invoked. */ +annotation class DevTool diff --git a/app/src/androidTest/java/com/writer/recognition/FixtureLoader.kt b/app/src/androidTest/java/com/writer/recognition/FixtureLoader.kt new file mode 100644 index 0000000..f95fca0 --- /dev/null +++ b/app/src/androidTest/java/com/writer/recognition/FixtureLoader.kt @@ -0,0 +1,55 @@ +package com.writer.recognition + +import androidx.test.platform.app.InstrumentationRegistry +import com.writer.model.InkLine +import com.writer.model.InkStroke +import com.writer.model.StrokePoint +import org.json.JSONObject + +object FixtureLoader { + + data class Fixture( + val expectedText: String, + val language: String, + val inkLine: InkLine + ) + + fun load(name: String): Fixture { + val context = InstrumentationRegistry.getInstrumentation().context + val jsonText = context.assets.open("fixtures/$name.json") + .bufferedReader().use { it.readText() } + val json = JSONObject(jsonText) + + val expectedText = json.getString("expectedText") + val language = json.optString("language", "en-US") + + val strokes = mutableListOf() + val strokesArr = json.getJSONArray("strokes") + for (i in 0 until strokesArr.length()) { + val strokeObj = strokesArr.getJSONObject(i) + val strokeId = strokeObj.getString("strokeId") + + val pointsArr = strokeObj.getJSONArray("points") + val points = mutableListOf() + for (j in 0 until pointsArr.length()) { + val ptObj = pointsArr.getJSONObject(j) + points.add( + StrokePoint( + x = ptObj.getDouble("x").toFloat(), + y = ptObj.getDouble("y").toFloat(), + pressure = ptObj.getDouble("pressure").toFloat(), + timestamp = ptObj.getLong("timestamp") + ) + ) + } + + strokes.add(InkStroke(strokeId = strokeId, points = points)) + } + + return Fixture( + expectedText = expectedText, + language = language, + inkLine = InkLine.build(strokes) + ) + } +} diff --git a/app/src/androidTest/java/com/writer/recognition/OnyxHwrTextRecognizerTest.kt b/app/src/androidTest/java/com/writer/recognition/OnyxHwrTextRecognizerTest.kt new file mode 100644 index 0000000..9ce839f --- /dev/null +++ b/app/src/androidTest/java/com/writer/recognition/OnyxHwrTextRecognizerTest.kt @@ -0,0 +1,79 @@ +package com.writer.recognition + +import android.content.ComponentName +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.RectF +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.writer.model.InkLine +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Connected test that exercises the full text recognition pipeline on a Boox device: + * service binding → initialization → protobuf encoding → SharedMemory IPC → result parsing. + * + * Skipped on non-Boox devices where KHwrService is unavailable. + */ +@RunWith(AndroidJUnit4::class) +class OnyxHwrTextRecognizerTest { + + private lateinit var recognizer: OnyxHwrTextRecognizer + private var hwrAvailable = false + + @Before + fun setUp() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + hwrAvailable = isHwrServiceAvailable(context) + assumeTrue("KHwrService not available — skipping on non-Boox device", hwrAvailable) + recognizer = OnyxHwrTextRecognizer(context) + } + + @After + fun tearDown() { + if (hwrAvailable) { + recognizer.close() + } + } + + @Test + fun initialize_bindsAndActivates() = runBlocking { + recognizer.initialize("en-US") + } + + @Test + fun recognizeLine_emptyStrokes_returnsEmpty() = runBlocking { + recognizer.initialize("en-US") + val line = InkLine(emptyList(), RectF()) + val result = recognizer.recognizeLine(line, "") + assertTrue("Empty strokes should return empty string", result.isEmpty()) + } + + @Test + fun recognizeLine_capturedHelloTest_recognizesCorrectly() = runBlocking { + recognizer.initialize("en-US") + val fixture = FixtureLoader.load("hello_test") + assertEquals(fixture.expectedText, recognizer.recognizeLine(fixture.inkLine, "")) + } + + // --- Helpers --- + + private fun isHwrServiceAvailable(context: android.content.Context): Boolean { + val intent = Intent().apply { + component = ComponentName( + "com.onyx.android.ksync", + "com.onyx.android.ksync.service.KHwrService" + ) + } + return context.packageManager.resolveService( + intent, PackageManager.ResolveInfoFlags.of(0) + ) != null + } +} diff --git a/app/src/androidTest/java/com/writer/recognition/StrokeFixtureCapture.kt b/app/src/androidTest/java/com/writer/recognition/StrokeFixtureCapture.kt new file mode 100644 index 0000000..5d0b849 --- /dev/null +++ b/app/src/androidTest/java/com/writer/recognition/StrokeFixtureCapture.kt @@ -0,0 +1,205 @@ +package com.writer.recognition + +import android.util.Log +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.writer.model.StrokePoint +import com.writer.storage.DocumentStorage +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assume.assumeTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File + +/** + * Instrumented test that captures handwriting from a document on device + * and writes a downsampled JSON fixture to /sdcard/Download/inkup-fixtures/. + * + * Two capture modes: + * + * **Single line** (original) — captures one line for recognition testing: + * ``` + * adb shell "am instrument -w -e class com.writer.recognition.StrokeFixtureCapture#captureFixture \ + * -e fixtureName hello_test -e expectedText 'hello test' \ + * com.writer.dev.test/androidx.test.runner.AndroidJUnitRunner" + * ``` + * + * **Full document** — captures all strokes, types, and diagram areas for integration testing: + * ``` + * adb shell "am instrument -w -e class com.writer.recognition.StrokeFixtureCapture#captureDocument \ + * -e fixtureName my_diagram \ + * com.writer.dev.test/androidx.test.runner.AndroidJUnitRunner" + * ``` + */ +@RunWith(AndroidJUnit4::class) +@DevTool +class StrokeFixtureCapture { + + @Test + fun captureFixture() { + val args = InstrumentationRegistry.getArguments() + val fixtureName = args.getString("fixtureName") + val expectedText = args.getString("expectedText") + assumeTrue("Skipped — run via captureFixture Gradle task", fixtureName != null && expectedText != null) + fixtureName!! + expectedText!! + require(!fixtureName.contains("..") && !fixtureName.contains('/') && !fixtureName.contains('\\')) { + "fixtureName must not contain path separators or '..': $fixtureName" + } + val language = args.getString("language") ?: "en-US" + val lineIndex = args.getString("lineIndex")?.toIntOrNull() ?: 0 + val documentName = args.getString("documentName") + + val context = InstrumentationRegistry.getInstrumentation().targetContext + + // Load the most recent document, or a named one + val docName = documentName ?: run { + val docs = DocumentStorage.listDocuments(context) + require(docs.isNotEmpty()) { "No documents found on device" } + docs.first().name + } + val data = requireNotNull(DocumentStorage.load(context, docName)) { + "Failed to load document: $docName" + } + require(data.strokes.isNotEmpty()) { "Document has no strokes" } + + // Segment strokes by line + val segmenter = LineSegmenter() + val lineStrokes = segmenter.getStrokesForLine(data.strokes, lineIndex) + require(lineStrokes.isNotEmpty()) { + "No strokes found on line $lineIndex. " + + "Available lines: ${segmenter.groupByLine(data.strokes).keys.sorted()}" + } + + // Build fixture JSON with downsampled strokes + val json = JSONObject().apply { + put("expectedText", expectedText) + put("language", language) + put("strokes", JSONArray().apply { + for ((i, stroke) in lineStrokes.sortedBy { it.points.first().x }.withIndex()) { + val downsampled = StrokeDownsampler.downsample(stroke) + put(JSONObject().apply { + put("strokeId", "s$i") + put("points", JSONArray().apply { + for (pt in downsampled.points) { + put(JSONObject().apply { + put("x", pt.x.toDouble()) + put("y", pt.y.toDouble()) + put("pressure", pt.pressure.toDouble()) + put("timestamp", pt.timestamp) + }) + } + }) + }) + } + }) + } + + // Write to /sdcard/Download/inkup-fixtures/ + val outDir = File("/sdcard/Download/inkup-fixtures") + if (!outDir.mkdirs() && !outDir.isDirectory) { + Log.w("StrokeFixtureCapture", "Failed to create fixture output dir: $outDir") + } + val outFile = File(outDir, "$fixtureName.json") + outFile.writeText(json.toString(2)) + } + + /** + * Capture the full document: all strokes with types, diagram areas, + * and recognized text cache. Suitable for integration tests that + * exercise line segmentation, scratch-out, and recognition flows. + */ + @Test + fun captureDocument() { + val args = InstrumentationRegistry.getArguments() + val fixtureName = args.getString("fixtureName") + assumeTrue("Skipped — run via adb instrument with fixtureName", fixtureName != null) + fixtureName!! + require(!fixtureName.contains("..") && !fixtureName.contains('/') && !fixtureName.contains('\\')) { + "fixtureName must not contain path separators or '..': $fixtureName" + } + val documentName = args.getString("documentName") + val context = InstrumentationRegistry.getInstrumentation().targetContext + + val docName = documentName ?: run { + val docs = DocumentStorage.listDocuments(context) + require(docs.isNotEmpty()) { "No documents found on device" } + docs.first().name + } + val data = requireNotNull(DocumentStorage.load(context, docName)) { + "Failed to load document: $docName" + } + require(data.strokes.isNotEmpty()) { "Document has no strokes" } + + val segmenter = LineSegmenter() + + val lineGroups = segmenter.groupByLine(data.strokes) + + val json = JSONObject().apply { + put("documentName", docName) + put("lineSpacing", com.writer.view.ScreenMetrics.lineSpacing.toDouble()) + put("topMargin", com.writer.view.ScreenMetrics.topMargin.toDouble()) + put("density", com.writer.view.ScreenMetrics.density.toDouble()) + + // All strokes with full metadata + put("strokes", JSONArray().apply { + for (stroke in data.strokes) { + val downsampled = StrokeDownsampler.downsample(stroke) + put(JSONObject().apply { + put("strokeId", stroke.strokeId) + put("strokeType", stroke.strokeType.name) + put("isGeometric", stroke.isGeometric) + put("lineIndex", segmenter.getStrokeLineIndex(stroke)) + put("points", JSONArray().apply { + for (pt in downsampled.points) { + put(JSONObject().apply { + put("x", pt.x.toDouble()) + put("y", pt.y.toDouble()) + put("pressure", pt.pressure.toDouble()) + put("timestamp", pt.timestamp) + }) + } + }) + }) + } + }) + + // Diagram areas + put("diagramAreas", JSONArray().apply { + for (area in data.diagramAreas) { + put(JSONObject().apply { + put("startLineIndex", area.startLineIndex) + put("heightInLines", area.heightInLines) + }) + } + }) + + // Recognized text cache + put("lineTextCache", JSONObject().apply { + for ((lineIdx, text) in data.lineTextCache) { + put(lineIdx.toString(), text) + } + }) + + // Line grouping summary + put("lineGroups", JSONObject().apply { + for ((lineIdx, strokes) in lineGroups.toSortedMap()) { + put(lineIdx.toString(), JSONArray().apply { + for (s in strokes) put(s.strokeId) + }) + } + }) + } + + val outDir = File("/sdcard/Download/inkup-fixtures") + if (!outDir.mkdirs() && !outDir.isDirectory) { + Log.w("StrokeFixtureCapture", "Failed to create fixture output dir: $outDir") + } + val outFile = File(outDir, "$fixtureName.json") + outFile.writeText(json.toString(2)) + Log.i("StrokeFixtureCapture", "Captured ${data.strokes.size} strokes, " + + "${data.diagramAreas.size} diagram areas, " + + "${lineGroups.size} lines → $outFile") + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 13952f2..569657a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,10 @@ + + + + - + + + diff --git a/app/src/main/aidl/com/onyx/android/sdk/hwr/service/HWRCommandArgs.aidl b/app/src/main/aidl/com/onyx/android/sdk/hwr/service/HWRCommandArgs.aidl new file mode 100644 index 0000000..008e543 --- /dev/null +++ b/app/src/main/aidl/com/onyx/android/sdk/hwr/service/HWRCommandArgs.aidl @@ -0,0 +1,3 @@ +package com.onyx.android.sdk.hwr.service; + +parcelable HWRCommandArgs; diff --git a/app/src/main/aidl/com/onyx/android/sdk/hwr/service/HWRInputArgs.aidl b/app/src/main/aidl/com/onyx/android/sdk/hwr/service/HWRInputArgs.aidl new file mode 100644 index 0000000..c47a0d8 --- /dev/null +++ b/app/src/main/aidl/com/onyx/android/sdk/hwr/service/HWRInputArgs.aidl @@ -0,0 +1,3 @@ +package com.onyx.android.sdk.hwr.service; + +parcelable HWRInputArgs; diff --git a/app/src/main/aidl/com/onyx/android/sdk/hwr/service/HWROutputArgs.aidl b/app/src/main/aidl/com/onyx/android/sdk/hwr/service/HWROutputArgs.aidl new file mode 100644 index 0000000..d325ae0 --- /dev/null +++ b/app/src/main/aidl/com/onyx/android/sdk/hwr/service/HWROutputArgs.aidl @@ -0,0 +1,3 @@ +package com.onyx.android.sdk.hwr.service; + +parcelable HWROutputArgs; diff --git a/app/src/main/aidl/com/onyx/android/sdk/hwr/service/HWROutputCallback.aidl b/app/src/main/aidl/com/onyx/android/sdk/hwr/service/HWROutputCallback.aidl new file mode 100644 index 0000000..9259259 --- /dev/null +++ b/app/src/main/aidl/com/onyx/android/sdk/hwr/service/HWROutputCallback.aidl @@ -0,0 +1,7 @@ +package com.onyx.android.sdk.hwr.service; + +import com.onyx.android.sdk.hwr.service.HWROutputArgs; + +interface HWROutputCallback { + void read(in HWROutputArgs args); +} diff --git a/app/src/main/aidl/com/onyx/android/sdk/hwr/service/IHWRService.aidl b/app/src/main/aidl/com/onyx/android/sdk/hwr/service/IHWRService.aidl new file mode 100644 index 0000000..b3d9dea --- /dev/null +++ b/app/src/main/aidl/com/onyx/android/sdk/hwr/service/IHWRService.aidl @@ -0,0 +1,16 @@ +package com.onyx.android.sdk.hwr.service; + +import android.os.ParcelFileDescriptor; +import com.onyx.android.sdk.hwr.service.HWROutputCallback; +import com.onyx.android.sdk.hwr.service.HWRInputArgs; +import com.onyx.android.sdk.hwr.service.HWRCommandArgs; + +// Method order determines transaction codes — must match the service exactly. +oneway interface IHWRService { + void init(in HWRInputArgs args, boolean forceReinit, HWROutputCallback callback); + void compileRecognizeText(String text, String language, HWROutputCallback callback); + void batchRecognize(in ParcelFileDescriptor pfd, HWROutputCallback callback); + void openIncrementalRecognizer(in HWRInputArgs args, HWROutputCallback callback); + void execCommand(in HWRInputArgs args, in HWRCommandArgs cmdArgs, HWROutputCallback callback); + void closeRecognizer(); +} diff --git a/app/src/main/java/com/onyx/android/sdk/hwr/service/HWRCommandArgs.kt b/app/src/main/java/com/onyx/android/sdk/hwr/service/HWRCommandArgs.kt new file mode 100644 index 0000000..ce6546f --- /dev/null +++ b/app/src/main/java/com/onyx/android/sdk/hwr/service/HWRCommandArgs.kt @@ -0,0 +1,17 @@ +package com.onyx.android.sdk.hwr.service + +import android.os.Parcel +import android.os.Parcelable + +/** Stub parcelable required by AIDL — not used in batchRecognize path. */ +class HWRCommandArgs() : Parcelable { + constructor(parcel: Parcel) : this() + + override fun writeToParcel(parcel: Parcel, flags: Int) {} + override fun describeContents(): Int = 0 + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel) = HWRCommandArgs(parcel) + override fun newArray(size: Int) = arrayOfNulls(size) + } +} diff --git a/app/src/main/java/com/onyx/android/sdk/hwr/service/HWRInputArgs.kt b/app/src/main/java/com/onyx/android/sdk/hwr/service/HWRInputArgs.kt new file mode 100644 index 0000000..5ea0045 --- /dev/null +++ b/app/src/main/java/com/onyx/android/sdk/hwr/service/HWRInputArgs.kt @@ -0,0 +1,86 @@ +package com.onyx.android.sdk.hwr.service + +import android.os.Parcel +import android.os.ParcelFileDescriptor +import android.os.Parcelable + +/** + * Parcelable matching the Boox ksync service's HWRInputArgs. + * Field order must match the service's classloader expectations exactly. + */ +class HWRInputArgs() : Parcelable { + var lang: String = "en_US" + var contentType: String = "Text" + var recognizerType: String = "Text" + var viewWidth: Float = 0f + var viewHeight: Float = 0f + var offsetX: Float = 0f + var offsetY: Float = 0f + var isGestureEnable: Boolean = false + var isTextEnable: Boolean = true + var isShapeEnable: Boolean = false + var isIncremental: Boolean = false + + var pfd: ParcelFileDescriptor? = null + var content: String? = null + + constructor(parcel: Parcel) : this() { + val className = parcel.readString() + if (className != null) { + lang = parcel.readString() ?: "en_US" + contentType = parcel.readString() ?: "Text" + recognizerType = parcel.readString() ?: "Text" + viewWidth = parcel.readFloat() + viewHeight = parcel.readFloat() + offsetX = parcel.readFloat() + offsetY = parcel.readFloat() + isGestureEnable = parcel.readByte() != 0.toByte() + isTextEnable = parcel.readByte() != 0.toByte() + isShapeEnable = parcel.readByte() != 0.toByte() + isIncremental = parcel.readByte() != 0.toByte() + pfd = parcel.readParcelable(ParcelFileDescriptor::class.java.classLoader, ParcelFileDescriptor::class.java) + content = parcel.readString() + } + } + + override fun writeToParcel(parcel: Parcel, flags: Int) { + // Write the class-name string that the Boox ksync service expects as a + // manual envelope. The service's own AIDL uses HWRInputData (not this class) + // and its unmarshalling code reads a leading class-name string before the + // fields. We cannot use writeParcelable() here because the receiving side + // deserializes with its own classloader keyed on this exact string, not on + // the Parcelable CREATOR of our class. The field order and class-name must + // match the service's expectations byte-for-byte. + parcel.writeString("com.onyx.android.sdk.hwr.bean.HWRInputData") + parcel.writeString(lang) + parcel.writeString(contentType) + parcel.writeString(recognizerType) + parcel.writeFloat(viewWidth) + parcel.writeFloat(viewHeight) + parcel.writeFloat(offsetX) + parcel.writeFloat(offsetY) + parcel.writeByte(if (isGestureEnable) 1 else 0) + parcel.writeByte(if (isTextEnable) 1 else 0) + parcel.writeByte(if (isShapeEnable) 1 else 0) + parcel.writeByte(if (isIncremental) 1 else 0) + val localPfd = pfd + if (localPfd != null) { + // Same manual envelope: the service reads a class-name string before + // calling ParcelFileDescriptor's own readFromParcel, so we must write + // the expected class name rather than using writeParcelable(). + parcel.writeString("android.os.ParcelFileDescriptor") + localPfd.writeToParcel(parcel, flags) + } else { + parcel.writeString(null) + } + parcel.writeString(content) + } + + override fun describeContents(): Int = + if (pfd != null) Parcelable.CONTENTS_FILE_DESCRIPTOR else 0 + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel) = HWRInputArgs(parcel) + override fun newArray(size: Int) = arrayOfNulls(size) + } +} diff --git a/app/src/main/java/com/onyx/android/sdk/hwr/service/HWROutputArgs.kt b/app/src/main/java/com/onyx/android/sdk/hwr/service/HWROutputArgs.kt new file mode 100644 index 0000000..8251f92 --- /dev/null +++ b/app/src/main/java/com/onyx/android/sdk/hwr/service/HWROutputArgs.kt @@ -0,0 +1,47 @@ +package com.onyx.android.sdk.hwr.service + +import android.os.Parcel +import android.os.ParcelFileDescriptor +import android.os.Parcelable + +/** + * Parcelable matching the Boox KHwrService output format. + * Field order must match the service's writeToParcel exactly. + */ +class HWROutputArgs() : Parcelable { + var pfd: ParcelFileDescriptor? = null + var recognizerActivated: Boolean = false + var compileSuccess: Boolean = false + var hwrResult: String? = null + var gesture: String? = null + var outputType: Int = 0 + var itemIdMap: String? = null + + constructor(parcel: Parcel) : this() { + pfd = parcel.readParcelable(ParcelFileDescriptor::class.java.classLoader, ParcelFileDescriptor::class.java) + recognizerActivated = parcel.readByte() != 0.toByte() + compileSuccess = parcel.readByte() != 0.toByte() + hwrResult = parcel.readString() + gesture = parcel.readString() + outputType = parcel.readInt() + itemIdMap = parcel.readString() + } + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeParcelable(pfd, flags) + parcel.writeByte(if (recognizerActivated) 1 else 0) + parcel.writeByte(if (compileSuccess) 1 else 0) + parcel.writeString(hwrResult) + parcel.writeString(gesture) + parcel.writeInt(outputType) + parcel.writeString(itemIdMap) + } + + override fun describeContents(): Int = + if (pfd != null) Parcelable.CONTENTS_FILE_DESCRIPTOR else 0 + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel) = HWROutputArgs(parcel) + override fun newArray(size: Int) = arrayOfNulls(size) + } +} diff --git a/app/src/main/java/com/writer/model/DiagramModel.kt b/app/src/main/java/com/writer/model/DiagramModel.kt new file mode 100644 index 0000000..4b696d0 --- /dev/null +++ b/app/src/main/java/com/writer/model/DiagramModel.kt @@ -0,0 +1,21 @@ +package com.writer.model + +import android.graphics.RectF + +data class DiagramNode( + val strokeId: String, + val shapeType: StrokeType, + val bounds: RectF, + var label: String = "" +) + +data class DiagramEdge( + val strokeId: String, + val fromNodeId: String?, // null = unconnected end + val toNodeId: String? +) + +class DiagramModel { + val nodes: MutableMap = mutableMapOf() // key = strokeId + val edges: MutableMap = mutableMapOf() // key = strokeId +} diff --git a/app/src/main/java/com/writer/model/DocumentModel.kt b/app/src/main/java/com/writer/model/DocumentModel.kt index ab38f84..7a62fac 100644 --- a/app/src/main/java/com/writer/model/DocumentModel.kt +++ b/app/src/main/java/com/writer/model/DocumentModel.kt @@ -5,4 +5,5 @@ class DocumentModel( ) { val activeStrokes: MutableList = mutableListOf() val diagramAreas: MutableList = mutableListOf() + val diagram: DiagramModel = DiagramModel() } diff --git a/app/src/main/java/com/writer/model/InkStroke.kt b/app/src/main/java/com/writer/model/InkStroke.kt index 71658f7..6f493eb 100644 --- a/app/src/main/java/com/writer/model/InkStroke.kt +++ b/app/src/main/java/com/writer/model/InkStroke.kt @@ -7,5 +7,9 @@ data class InkStroke( val points: List, val strokeWidth: Float = 3f, val startTime: Long = points.firstOrNull()?.timestamp ?: 0L, - val endTime: Long = points.lastOrNull()?.timestamp ?: 0L + val endTime: Long = points.lastOrNull()?.timestamp ?: 0L, + /** True for snapped geometric shapes (rectangle, triangle) rendered with sharp lineTo corners. */ + val isGeometric: Boolean = false, + /** Stroke type for diagram model and arrow rendering. */ + val strokeType: StrokeType = StrokeType.FREEHAND ) diff --git a/app/src/main/java/com/writer/model/StrokeExtensions.kt b/app/src/main/java/com/writer/model/StrokeExtensions.kt index cf8c354..edc1a3f 100644 --- a/app/src/main/java/com/writer/model/StrokeExtensions.kt +++ b/app/src/main/java/com/writer/model/StrokeExtensions.kt @@ -26,6 +26,8 @@ fun InkStroke.shiftY(dy: Float): InkStroke { return InkStroke( strokeId = strokeId, points = shiftedPoints, - strokeWidth = strokeWidth + strokeWidth = strokeWidth, + isGeometric = isGeometric, + strokeType = strokeType ) } diff --git a/app/src/main/java/com/writer/model/StrokeType.kt b/app/src/main/java/com/writer/model/StrokeType.kt new file mode 100644 index 0000000..ef95220 --- /dev/null +++ b/app/src/main/java/com/writer/model/StrokeType.kt @@ -0,0 +1,44 @@ +package com.writer.model + +enum class StrokeType { + FREEHAND, + LINE, + ARROW_HEAD, // arrowhead at end (→) + ARROW_TAIL, // arrowhead at start (←) + ARROW_BOTH, // bidirectional (↔) + ELBOW, + ELBOW_ARROW_HEAD, + ELBOW_ARROW_TAIL, + ELBOW_ARROW_BOTH, + ARC, + ARC_ARROW_HEAD, + ARC_ARROW_TAIL, + ARC_ARROW_BOTH, + ELLIPSE, + RECTANGLE, + ROUNDED_RECTANGLE, + TRIANGLE, + DIAMOND; + + /** True for LINE, ARROW_HEAD, ARROW_TAIL, ARROW_BOTH — strokes rendered as a single segment. */ + val isArrowOrLine: Boolean get() = this == LINE || this == ARROW_HEAD || this == ARROW_TAIL || this == ARROW_BOTH + + /** True for any connector type (line, elbow, arc) — strokes where segments between points should be checked for overlap. */ + val isConnector: Boolean get() = isArrowOrLine || isElbow || isArc + + /** True for ELBOW and its arrow variants. */ + val isElbow: Boolean get() = this == ELBOW || this == ELBOW_ARROW_HEAD || this == ELBOW_ARROW_TAIL || this == ELBOW_ARROW_BOTH + + /** True for ARC and its arrow variants. */ + val isArc: Boolean get() = this == ARC || this == ARC_ARROW_HEAD || this == ARC_ARROW_TAIL || this == ARC_ARROW_BOTH + + /** True if this stroke type has an arrowhead at the tip (end). */ + val hasArrowAtTip: Boolean get() = this == ARROW_HEAD || this == ARROW_BOTH || + this == ELBOW_ARROW_HEAD || this == ELBOW_ARROW_BOTH || + this == ARC_ARROW_HEAD || this == ARC_ARROW_BOTH + + /** True if this stroke type has an arrowhead at the tail (start). */ + val hasArrowAtTail: Boolean get() = this == ARROW_TAIL || this == ARROW_BOTH || + this == ELBOW_ARROW_TAIL || this == ELBOW_ARROW_BOTH || + this == ARC_ARROW_TAIL || this == ARC_ARROW_BOTH +} diff --git a/app/src/main/java/com/writer/recognition/HandwritingRecognizer.kt b/app/src/main/java/com/writer/recognition/GoogleMLKitTextRecognizer.kt similarity index 83% rename from app/src/main/java/com/writer/recognition/HandwritingRecognizer.kt rename to app/src/main/java/com/writer/recognition/GoogleMLKitTextRecognizer.kt index e5604b4..46f5950 100644 --- a/app/src/main/java/com/writer/recognition/HandwritingRecognizer.kt +++ b/app/src/main/java/com/writer/recognition/GoogleMLKitTextRecognizer.kt @@ -10,17 +10,21 @@ import com.google.mlkit.vision.digitalink.recognition.WritingArea import com.writer.model.InkLine import kotlinx.coroutines.tasks.await -class HandwritingRecognizer { +/** + * Google ML Kit Digital Ink recognition engine. + * Works on all Android devices. Downloads language models on first use. + */ +class GoogleMLKitTextRecognizer : TextRecognizer { companion object { - private const val TAG = "HandwritingRecognizer" + private const val TAG = "GoogleMLKitTextRecognizer" private const val PRE_CONTEXT_LENGTH = 20 } private var recognizer: DigitalInkRecognizer? = null private val modelManager = ModelManager() - suspend fun initialize(languageTag: String) { + override suspend fun initialize(languageTag: String) { val model = modelManager.ensureModelDownloaded(languageTag) recognizer = DigitalInkRecognition.getClient( DigitalInkRecognizerOptions.builder(model).build() @@ -28,7 +32,7 @@ class HandwritingRecognizer { Log.i(TAG, "Recognizer initialized for $languageTag") } - suspend fun recognizeLine(line: InkLine, preContext: String = ""): String { + override suspend fun recognizeLine(line: InkLine, preContext: String): String { val rec = recognizer ?: throw IllegalStateException("Recognizer not initialized") val inkBuilder = Ink.builder() @@ -54,7 +58,7 @@ class HandwritingRecognizer { return text } - fun close() { + override fun close() { recognizer?.close() recognizer = null } diff --git a/app/src/main/java/com/writer/recognition/HwrProtobuf.kt b/app/src/main/java/com/writer/recognition/HwrProtobuf.kt new file mode 100644 index 0000000..27323e2 --- /dev/null +++ b/app/src/main/java/com/writer/recognition/HwrProtobuf.kt @@ -0,0 +1,143 @@ +package com.writer.recognition + +import android.util.Log +import com.writer.model.InkLine +import org.json.JSONObject +import java.io.ByteArrayOutputStream + +/** + * Hand-rolled protobuf encoding for the Boox MyScript HWR service. + * No protobuf library dependency — encodes directly to the wire format + * expected by `com.onyx.android.ksync.service.KHwrService.batchRecognize()`. + * + * Extracted from the recognition engine for testability. + */ +object HwrProtobuf { + + /** + * Build the top-level HWRInputProto protobuf bytes from an [InkLine]. + * + * Field numbers (from HWRInputDataProto.HWRInputProto): + * 1: lang (string), 2: contentType (string), 4: recognizerType (string), + * 5: viewWidth (float), 6: viewHeight (float), + * 10: recognizeText (bool), 15: repeated pointerEvents + */ + fun buildProtobuf( + line: InkLine, viewWidth: Float, viewHeight: Float, lang: String = "en_US" + ): ByteArray { + // Pre-compute expected size: ~48 bytes per pointer event (tag + length-delimited + // sub-message with 4 fixed32 fields + 2 varints), plus ~60 bytes of header fields. + val totalPoints = line.strokes.sumOf { it.points.size } + val estimatedSize = 60 + totalPoints * 48 + val out = ByteArrayOutputStream(estimatedSize) + + writeTag(out, 1, 2); writeString(out, lang) + writeTag(out, 2, 2); writeString(out, "Text") + writeTag(out, 4, 2); writeString(out, "MS_ON_SCREEN") + writeTag(out, 5, 5); writeFixed32(out, viewWidth) + writeTag(out, 6, 5); writeFixed32(out, viewHeight) + writeTag(out, 10, 0); writeVarint(out, 1) // recognizeText = true + + val pointerBuf = ByteArrayOutputStream(64) + for (stroke in line.strokes) { + val points = stroke.points + if (points.isEmpty()) continue + + for ((i, point) in points.withIndex()) { + val isFirst = i == 0 + val isLast = i == points.size - 1 + val eventTypes = when { + isFirst && isLast -> listOf(0, 2) // single-point: DOWN then UP + isFirst -> listOf(0) // DOWN + isLast -> listOf(2) // UP + else -> listOf(1) // MOVE + } + for (eventType in eventTypes) { + val pointerBytes = encodePointerProto( + point.x, point.y, point.timestamp, point.pressure, + pointerId = 0, eventType = eventType, pointerType = 0, + reuse = pointerBuf + ) + writeTag(out, 15, 2) + writeBytes(out, pointerBytes) + } + } + } + return out.toByteArray() + } + + /** + * Encode a single HWRPointerProto message. + * Fields: float x(1), float y(2), sint64 t(3), float f(4), + * sint32 pointerId(5), enum eventType(6), enum pointerType(7) + */ + internal fun encodePointerProto( + x: Float, y: Float, t: Long, f: Float, + pointerId: Int, eventType: Int, pointerType: Int, + reuse: ByteArrayOutputStream? = null + ): ByteArray { + val out = reuse?.apply { reset() } ?: ByteArrayOutputStream(64) + writeTag(out, 1, 5); writeFixed32(out, x) + writeTag(out, 2, 5); writeFixed32(out, y) + writeTag(out, 3, 0); writeVarint(out, (t shl 1) xor (t shr 63)) + writeTag(out, 4, 5); writeFixed32(out, f) + writeTag(out, 5, 0); writeVarint(out, ((pointerId shl 1) xor (pointerId shr 31)).toLong()) + writeTag(out, 6, 0); writeVarint(out, eventType.toLong()) + writeTag(out, 7, 0); writeVarint(out, pointerType.toLong()) + return out.toByteArray() + } + + // --- Protobuf primitives --- + + internal fun writeTag(out: ByteArrayOutputStream, fieldNumber: Int, wireType: Int) { + writeVarint(out, ((fieldNumber shl 3) or wireType).toLong()) + } + + internal fun writeVarint(out: ByteArrayOutputStream, value: Long) { + var v = value + while (v and 0x7FL.inv() != 0L) { + out.write(((v.toInt() and 0x7F) or 0x80)) + v = v ushr 7 + } + out.write(v.toInt() and 0x7F) + } + + internal fun writeFixed32(out: ByteArrayOutputStream, value: Float) { + val bits = java.lang.Float.floatToIntBits(value) + out.write(bits and 0xFF) + out.write((bits shr 8) and 0xFF) + out.write((bits shr 16) and 0xFF) + out.write((bits shr 24) and 0xFF) + } + + internal fun writeString(out: ByteArrayOutputStream, value: String) { + val bytes = value.toByteArray(Charsets.UTF_8) + writeVarint(out, bytes.size.toLong()) + out.write(bytes) + } + + internal fun writeBytes(out: ByteArrayOutputStream, bytes: ByteArray) { + writeVarint(out, bytes.size.toLong()) + out.write(bytes) + } + + // --- Result parsing --- + + /** + * Parse the JSON result from the HWR service. + * Success: `{"result":{"label":"recognized text"}}` + * Error: `{"exception":{"cause":{"message":"..."}}}` → returns empty string + */ + fun parseHwrResult(json: String): String { + return try { + val obj = JSONObject(json) + if (obj.has("exception")) return "" + val result = obj.optJSONObject("result") + if (result != null) return result.optString("label", "") + obj.optString("label", "") + } catch (e: Exception) { + Log.w("HwrProtobuf", "Failed to parse HWR result: ${e.message}") + "" + } + } +} diff --git a/app/src/main/java/com/writer/recognition/LineSegmenter.kt b/app/src/main/java/com/writer/recognition/LineSegmenter.kt index db882e9..ef38301 100644 --- a/app/src/main/java/com/writer/recognition/LineSegmenter.kt +++ b/app/src/main/java/com/writer/recognition/LineSegmenter.kt @@ -7,8 +7,12 @@ import com.writer.view.HandwritingCanvasView /** * Assigns strokes to ruled line positions on the canvas. - * Uses the stroke's starting point Y to determine line assignment, - * so descenders (g, y, p, etc.) stay on the correct line. + * + * Uses the stroke's vertical centroid (midpoint of Y bounding box) to determine + * line assignment. This is more robust than using the first point because: + * - Cursive strokes may start above the baseline (e.g. "t" crossbar) + * - Descenders hang below but the letter body is on the line above + * - Words written at slightly different heights are assigned to the same line */ class LineSegmenter { @@ -19,9 +23,17 @@ class LineSegmenter { .coerceAtLeast(0) } - /** Returns the line index for a stroke, based on its starting point. */ + /** + * Returns the line index for a stroke, based on its vertical centroid. + * + * The centroid (midpoint of Y bounding box) is more stable than the first + * point because it represents where the bulk of the stroke sits, regardless + * of stroke direction or starting position. + */ fun getStrokeLineIndex(stroke: InkStroke): Int { - return getLineIndex(stroke.points.first().y) + val minY = stroke.points.minOf { it.y } + val maxY = stroke.points.maxOf { it.y } + return getLineIndex((minY + maxY) / 2f) } /** Returns the document-space Y for the top of a line index. */ @@ -29,7 +41,7 @@ class LineSegmenter { return HandwritingCanvasView.TOP_MARGIN + lineIndex * HandwritingCanvasView.LINE_SPACING } - /** Group strokes by their line index (using start point Y). */ + /** Group strokes by their line index (using Y centroid). */ fun groupByLine(strokes: List): Map> { val result = mutableMapOf>() for (stroke in strokes) { diff --git a/app/src/main/java/com/writer/recognition/OnyxHwrTextRecognizer.kt b/app/src/main/java/com/writer/recognition/OnyxHwrTextRecognizer.kt new file mode 100644 index 0000000..181a513 --- /dev/null +++ b/app/src/main/java/com/writer/recognition/OnyxHwrTextRecognizer.kt @@ -0,0 +1,230 @@ +package com.writer.recognition + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder +import android.os.ParcelFileDescriptor +import android.util.Log +import com.onyx.android.sdk.hwr.service.HWRInputArgs +import com.onyx.android.sdk.hwr.service.HWROutputArgs +import com.onyx.android.sdk.hwr.service.HWROutputCallback +import com.onyx.android.sdk.hwr.service.IHWRService +import com.writer.model.InkLine +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import java.io.FileInputStream +import java.io.FileOutputStream +import kotlin.coroutines.resume + +/** + * Handwriting recognition using the Boox firmware's built-in MyScript engine. + * Communicates via AIDL IPC with `com.onyx.android.ksync.service.KHwrService`. + * + * Each activity should create its own instance via [TextRecognizerFactory.create] + * and close it in `onDestroy`. + * + * Based on the approach used by [Notable](https://github.com/jshph/notable). + */ +class OnyxHwrTextRecognizer(private val context: Context) : TextRecognizer { + + companion object { + private const val TAG = "OnyxHwrTextRecognizer" + private const val SERVICE_PACKAGE = "com.onyx.android.ksync" + private const val SERVICE_CLASS = "com.onyx.android.ksync.service.KHwrService" + private const val BIND_TIMEOUT_MS = 3000L + private const val RECOGNIZE_TIMEOUT_MS = 10_000L + } + + @Volatile private var service: IHWRService? = null + @Volatile private var bound = false + @Volatile private var initialized = false + private var connectDeferred = CompletableDeferred() + private val initMutex = Mutex() + private var currentLang = "en_US" + private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + private val connection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { + service = IHWRService.Stub.asInterface(binder) + bound = true + Log.i(TAG, "HWR service connected") + connectDeferred.complete(Unit) + } + + override fun onServiceDisconnected(name: ComponentName?) { + service = null + bound = false + initialized = false + Log.w(TAG, "HWR service disconnected") + } + } + + override suspend fun initialize(languageTag: String) { + initMutex.withLock { + if (bound && service != null) return@withLock + + connectDeferred = CompletableDeferred() + currentLang = languageTag.replace("-", "_") + val intent = Intent().apply { + component = ComponentName(SERVICE_PACKAGE, SERVICE_CLASS) + } + + val bindStarted = try { + context.bindService(intent, connection, Context.BIND_AUTO_CREATE) + } catch (e: Exception) { + Log.w(TAG, "Failed to bind HWR service: ${e.message}") + throw IllegalStateException("Onyx HWR service not available", e) + } + + if (!bindStarted) { + throw IllegalStateException("Onyx HWR service not found — is this a Boox device?") + } + + val connected = try { + withTimeoutOrNull(BIND_TIMEOUT_MS) { + connectDeferred.await() + } != null + } catch (e: kotlinx.coroutines.CancellationException) { + context.unbindService(connection) + throw e + } + if (!connected || service == null) { + context.unbindService(connection) + throw IllegalStateException("Onyx HWR service bind timed out") + } + + val svc = service!! + val inputArgs = HWRInputArgs().apply { + lang = currentLang + contentType = "Text" + recognizerType = "MS_ON_SCREEN" + viewWidth = 1000f // arbitrary default; per-recognition dimensions are in the protobuf + viewHeight = 200f + isTextEnable = true + } + + suspendCancellableCoroutine { cont -> + svc.init(inputArgs, true, object : HWROutputCallback.Stub() { + override fun read(args: HWROutputArgs?) { + initialized = args?.recognizerActivated == true + Log.i(TAG, "HWR init: activated=$initialized") + if (cont.isActive) cont.resume(Unit) + } + }) + } + + if (!initialized) { + close() + throw IllegalStateException("MyScript recognizer failed to activate") + } + Log.i(TAG, "Recognizer initialized for $languageTag") + } + } + + // Note: preContext is accepted by the interface but not used here — + // MyScript context is set via HWRInputArgs on init, not per-recognition. + override suspend fun recognizeLine(line: InkLine, preContext: String): String { + val svc = service ?: throw IllegalStateException("Recognizer not initialized") + if (!initialized) throw IllegalStateException("Recognizer not initialized") + if (line.strokes.isEmpty()) return "" + + val bb = line.boundingBox + val viewWidth = if (bb.width() > 0) bb.width() else 1000f + val viewHeight = if (bb.height() > 0) bb.height() else 200f + + val protoBytes = HwrProtobuf.buildProtobuf(line, viewWidth, viewHeight, currentLang) + + val pipe = try { + ParcelFileDescriptor.createPipe() + } catch (e: Exception) { + Log.e(TAG, "Failed to create pipe: ${e.message}") + return "" + } + val readPfd = pipe[0] + val writePfd = pipe[1] + + // Write pipe data concurrently so the service can drain while we write. + // This prevents deadlock when protoBytes exceeds the kernel pipe buffer (~64KB). + val writeJob = ioScope.launch { + try { + FileOutputStream(writePfd.fileDescriptor).use { it.write(protoBytes) } + } catch (e: Exception) { + Log.e(TAG, "Pipe write failed: ${e.message}") + } finally { + writePfd.close() + } + } + + return try { + val result = withTimeoutOrNull(RECOGNIZE_TIMEOUT_MS) { + suspendCancellableCoroutine { cont -> + svc.batchRecognize(readPfd, object : HWROutputCallback.Stub() { + override fun read(args: HWROutputArgs?) { + if (!cont.isActive) return + try { + // Boox API: hwrResult is populated only on error; on success it's null and pfd carries the result + val errorJson = args?.hwrResult + if (!errorJson.isNullOrBlank()) { + Log.e(TAG, "HWR error: ${errorJson.take(300)}") + cont.resume("") + return + } + val resultPfd = args?.pfd + if (resultPfd == null) { + cont.resume("") + return + } + val json = readPfdAsString(resultPfd) + resultPfd.close() + val text = HwrProtobuf.parseHwrResult(json) + Log.d(TAG, "Recognized: \"$text\"") + cont.resume(text) + } catch (e: Exception) { + Log.e(TAG, "Error parsing HWR result: ${e.message}") + cont.resume("") + } + } + }) + } + } + result ?: "" + } finally { + writeJob.cancel() + readPfd.close() + } + } + + override fun close() { + ioScope.cancel() + if (!bound) return + try { + // closeRecognizer() is oneway (fire-and-forget); unbindService follows immediately + // so the remote recognizer may not process the close before the transport tears down + service?.closeRecognizer() + context.unbindService(connection) + } catch (e: Exception) { + Log.w(TAG, "Error closing HWR service: ${e.message}") + } + bound = false + service = null + initialized = false + } + + private fun readPfdAsString(pfd: ParcelFileDescriptor): String { + return FileInputStream(pfd.fileDescriptor).use { input -> + input.readBytes().toString(Charsets.UTF_8) + } + } + +} diff --git a/app/src/main/java/com/writer/recognition/StrokeDownsampler.kt b/app/src/main/java/com/writer/recognition/StrokeDownsampler.kt new file mode 100644 index 0000000..bff09db --- /dev/null +++ b/app/src/main/java/com/writer/recognition/StrokeDownsampler.kt @@ -0,0 +1,89 @@ +package com.writer.recognition + +import com.writer.model.InkStroke +import com.writer.model.StrokePoint +import kotlin.math.abs +import kotlin.math.sqrt + +object StrokeDownsampler { + + private const val DWELL_EPSILON = 0.5f // sub-pixel threshold for dwell collapse + + /** + * Collapse runs of nearly-identical (x,y) positions to first + last point. + * Handles pen-down dwell where the device reports many points at the same location. + */ + fun collapseIdenticalPositions(points: List): List { + if (points.size <= 1) return points + val result = mutableListOf() + var runStart = 0 + for (i in 1..points.size) { + val samePos = i < points.size && + abs(points[i].x - points[runStart].x) < DWELL_EPSILON && + abs(points[i].y - points[runStart].y) < DWELL_EPSILON + if (!samePos) { + result.add(points[runStart]) + if (i - 1 > runStart) { + result.add(points[i - 1]) + } + runStart = i + } + } + return result + } + + /** + * Ramer-Douglas-Peucker simplification on (x,y). + * Preserves pressure and timestamp of surviving points. + */ + fun rdp(points: List, epsilon: Float): List { + if (points.size <= 2) return points + + // Find the point with the maximum distance from the line (first, last) + val first = points.first() + val last = points.last() + var maxDist = 0f + var maxIdx = 0 + for (i in 1 until points.size - 1) { + val d = perpendicularDistance(points[i], first, last) + if (d > maxDist) { + maxDist = d + maxIdx = i + } + } + + return if (maxDist > epsilon) { + val left = rdp(points.subList(0, maxIdx + 1), epsilon) + val right = rdp(points.subList(maxIdx, points.size), epsilon) + left.dropLast(1) + right + } else { + listOf(first, last) + } + } + + /** + * Full downsampling pipeline: collapse dwells then RDP simplify. + */ + fun downsample(stroke: InkStroke, epsilon: Float = 0.5f): InkStroke { + val collapsed = collapseIdenticalPositions(stroke.points) + val simplified = rdp(collapsed, epsilon) + return stroke.copy(points = simplified) + } + + private fun perpendicularDistance( + point: StrokePoint, + lineStart: StrokePoint, + lineEnd: StrokePoint + ): Float { + val dx = lineEnd.x - lineStart.x + val dy = lineEnd.y - lineStart.y + val lengthSq = dx * dx + dy * dy + if (lengthSq == 0f) { + val px = point.x - lineStart.x + val py = point.y - lineStart.y + return sqrt(px * px + py * py) + } + val num = abs(dy * point.x - dx * point.y + lineEnd.x * lineStart.y - lineEnd.y * lineStart.x) + return num / sqrt(lengthSq) + } +} diff --git a/app/src/main/java/com/writer/recognition/TextRecognizer.kt b/app/src/main/java/com/writer/recognition/TextRecognizer.kt new file mode 100644 index 0000000..5236ccc --- /dev/null +++ b/app/src/main/java/com/writer/recognition/TextRecognizer.kt @@ -0,0 +1,35 @@ +package com.writer.recognition + +import com.writer.model.InkLine + +/** + * Abstraction for handwriting-to-text recognition engines. + * + * Implementations: + * - [GoogleMLKitTextRecognizer] — Google ML Kit Digital Ink (bundled model, works on all devices) + * - [OnyxHwrTextRecognizer] — Boox firmware's built-in MyScript engine via AIDL IPC + * (Onyx Boox devices only, significantly better accuracy) + */ +interface TextRecognizer { + + /** + * Initialize the recognition engine. Must be called before [recognizeLine]. + * May download models, bind to services, etc. + * + * @param languageTag BCP-47 language tag, e.g. "en-US" + * @throws Exception if initialization fails + */ + suspend fun initialize(languageTag: String) + + /** + * Recognize a single line of handwriting. + * + * @param line the strokes and bounding box for one line + * @param preContext up to 20 characters of preceding text for language model context + * @return recognized text (trimmed), or empty string if recognition fails + */ + suspend fun recognizeLine(line: InkLine, preContext: String = ""): String + + /** Release resources (models, service bindings, etc.). */ + fun close() +} diff --git a/app/src/main/java/com/writer/recognition/TextRecognizerFactory.kt b/app/src/main/java/com/writer/recognition/TextRecognizerFactory.kt new file mode 100644 index 0000000..800a064 --- /dev/null +++ b/app/src/main/java/com/writer/recognition/TextRecognizerFactory.kt @@ -0,0 +1,31 @@ +package com.writer.recognition + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build + +object TextRecognizerFactory { + fun create(context: Context): TextRecognizer { + if (isOnyxHwrAvailable(context)) return OnyxHwrTextRecognizer(context.applicationContext) + return GoogleMLKitTextRecognizer() + } + + @Suppress("DEPRECATION") + private fun isOnyxHwrAvailable(context: Context): Boolean { + val intent = Intent().apply { + component = ComponentName( + "com.onyx.android.ksync", + "com.onyx.android.ksync.service.KHwrService" + ) + } + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.packageManager.resolveService( + intent, PackageManager.ResolveInfoFlags.of(0) + ) + } else { + context.packageManager.resolveService(intent, 0) + } != null + } +} diff --git a/app/src/main/java/com/writer/storage/DocumentStorage.kt b/app/src/main/java/com/writer/storage/DocumentStorage.kt index f8ea4ed..539e94a 100644 --- a/app/src/main/java/com/writer/storage/DocumentStorage.kt +++ b/app/src/main/java/com/writer/storage/DocumentStorage.kt @@ -8,6 +8,7 @@ import com.writer.model.DiagramArea import com.writer.model.DocumentData import com.writer.model.InkStroke import com.writer.model.StrokePoint +import com.writer.model.StrokeType import org.json.JSONArray import org.json.JSONObject import java.io.File @@ -176,7 +177,7 @@ object DocumentStorage { // --- Serialization --- - private fun serializeToJson(data: DocumentData): JSONObject { + internal fun serializeToJson(data: DocumentData): JSONObject { val json = JSONObject() json.put("scrollOffsetY", data.scrollOffsetY.toDouble()) @@ -212,6 +213,12 @@ object DocumentStorage { pointsArr.put(ptObj) } strokeObj.put("points", pointsArr) + if (stroke.strokeType != StrokeType.FREEHAND) { + strokeObj.put("strokeType", stroke.strokeType.name) + } + if (stroke.isGeometric) { + strokeObj.put("isGeometric", true) + } strokesArr.put(strokeObj) } json.put("strokes", strokesArr) @@ -229,7 +236,7 @@ object DocumentStorage { return json } - private fun deserializeFromJson(text: String): DocumentData { + internal fun deserializeFromJson(text: String): DocumentData { val json = JSONObject(text) val scrollOffsetY = json.optDouble("scrollOffsetY", 0.0).toFloat() @@ -275,11 +282,19 @@ object DocumentStorage { ) } + val strokeTypeName = strokeObj.optString("strokeType", "") + val strokeType = try { + if (strokeTypeName.isNotEmpty()) StrokeType.valueOf(strokeTypeName) + else StrokeType.FREEHAND + } catch (_: IllegalArgumentException) { StrokeType.FREEHAND } + strokes.add( InkStroke( strokeId = strokeId, points = points, - strokeWidth = strokeWidth + strokeWidth = strokeWidth, + strokeType = strokeType, + isGeometric = strokeObj.optBoolean("isGeometric", false) ) ) } diff --git a/app/src/main/java/com/writer/ui/writing/BugReport.kt b/app/src/main/java/com/writer/ui/writing/BugReport.kt new file mode 100644 index 0000000..836ba9a --- /dev/null +++ b/app/src/main/java/com/writer/ui/writing/BugReport.kt @@ -0,0 +1,108 @@ +package com.writer.ui.writing + +import com.writer.model.DiagramArea +import com.writer.model.InkStroke +import com.writer.model.StrokePoint +import com.writer.view.ScreenMetrics +import org.json.JSONArray +import org.json.JSONObject + +/** + * Serializes a bug report containing device info, document state, + * recent stroke history, and processing decisions. + * + * The report is a self-contained JSON file that can be loaded in + * unit tests to replay the stroke sequence through the pipeline. + */ +object BugReport { + + fun serialize( + eventSnapshot: StrokeEventLog.Snapshot, + activeStrokes: List, + diagramAreas: List, + lineTextCache: Map, + userDescription: String = "" + ): JSONObject { + return JSONObject().apply { + put("version", 1) + put("timestampMs", System.currentTimeMillis()) + put("userDescription", userDescription) + + // Device / screen metrics + put("device", JSONObject().apply { + put("model", android.os.Build.MODEL) + put("manufacturer", android.os.Build.MANUFACTURER) + put("osVersion", android.os.Build.VERSION.SDK_INT) + put("density", ScreenMetrics.density.toDouble()) + put("lineSpacing", ScreenMetrics.lineSpacing.toDouble()) + put("topMargin", ScreenMetrics.topMargin.toDouble()) + put("isCompact", ScreenMetrics.isCompact) + }) + + // Recent raw strokes from ring buffer + put("recentStrokes", JSONArray().apply { + for (entry in eventSnapshot.strokes) { + put(JSONObject().apply { + put("index", entry.index) + put("timestampMs", entry.timestampMs) + put("points", serializePoints(entry.points)) + }) + } + }) + + // Processing events + put("processingEvents", JSONArray().apply { + for (event in eventSnapshot.events) { + put(JSONObject().apply { + put("strokeIndex", event.strokeIndex) + put("type", event.type.name) + put("detail", event.detail) + put("timestampMs", event.timestampMs) + }) + } + }) + + // Current document state + put("documentState", JSONObject().apply { + put("strokeCount", activeStrokes.size) + put("strokes", JSONArray().apply { + for (stroke in activeStrokes) { + put(JSONObject().apply { + put("strokeId", stroke.strokeId) + put("strokeType", stroke.strokeType.name) + put("isGeometric", stroke.isGeometric) + put("pointCount", stroke.points.size) + put("points", serializePoints(stroke.points)) + }) + } + }) + put("diagramAreas", JSONArray().apply { + for (area in diagramAreas) { + put(JSONObject().apply { + put("startLineIndex", area.startLineIndex) + put("heightInLines", area.heightInLines) + }) + } + }) + put("lineTextCache", JSONObject().apply { + for ((lineIdx, text) in lineTextCache) { + put(lineIdx.toString(), text) + } + }) + }) + } + } + + private fun serializePoints(points: List): JSONArray { + return JSONArray().apply { + for (pt in points) { + put(JSONObject().apply { + put("x", pt.x.toDouble()) + put("y", pt.y.toDouble()) + put("pressure", pt.pressure.toDouble()) + put("timestamp", pt.timestamp) + }) + } + } + } +} diff --git a/app/src/main/java/com/writer/ui/writing/DiagramCanvas.kt b/app/src/main/java/com/writer/ui/writing/DiagramCanvas.kt new file mode 100644 index 0000000..d28f3cb --- /dev/null +++ b/app/src/main/java/com/writer/ui/writing/DiagramCanvas.kt @@ -0,0 +1,13 @@ +package com.writer.ui.writing + +import com.writer.model.DiagramArea +import com.writer.model.InkStroke + +/** Abstraction for canvas operations needed by diagram management. */ +interface DiagramCanvas { + var diagramAreas: List + var scrollOffsetY: Float + fun loadStrokes(strokes: List) + /** Pause SDK overlay, redraw surface, resume SDK — for e-ink visibility. */ + fun pauseAndRedraw() +} diff --git a/app/src/main/java/com/writer/ui/writing/DiagramManager.kt b/app/src/main/java/com/writer/ui/writing/DiagramManager.kt new file mode 100644 index 0000000..be3c738 --- /dev/null +++ b/app/src/main/java/com/writer/ui/writing/DiagramManager.kt @@ -0,0 +1,507 @@ +package com.writer.ui.writing + +import android.util.Log +import com.writer.model.DiagramArea +import com.writer.model.DocumentModel +import com.writer.model.InkLine +import com.writer.model.InkStroke +import com.writer.model.StrokeType +import com.writer.model.minX +import com.writer.model.minY +import com.writer.model.maxX +import com.writer.model.maxY +import com.writer.model.shiftY +import com.writer.recognition.LineSegmenter +import com.writer.recognition.TextRecognizer +import com.writer.view.HandwritingCanvasView +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** Callback interface for DiagramManager to communicate with its host. */ +interface DiagramManagerHost { + fun onDiagramAreasChanged() + fun getLineTextCache(): MutableMap +} + +class DiagramManager( + val documentModel: DocumentModel, + val lineSegmenter: LineSegmenter, + private val recognizer: TextRecognizer, + private val canvas: DiagramCanvas, + private val host: DiagramManagerHost, + private val scope: CoroutineScope +) { + companion object { + private const val TAG = "DiagramManager" + // Delay before running diagram text recognition after a stroke + private const val DIAGRAM_RECOGNIZE_DELAY_MS = 600L + } + + /** Recognized text groups keyed by diagram area start line. */ + data class DiagramTextGroup( + val text: String, + val centerX: Float, + val centerY: Float, + val strokeIds: Set + ) + + // Debounced diagram recognition jobs, keyed by area startLineIndex + private val diagramRecognizeJobs = mutableMapOf() + // Diagram text: recognized text groups keyed by diagram area start line + private val diagramTextCache = mutableMapOf>() + + /** Check if a line index falls inside any diagram area. */ + fun isDiagramLine(lineIdx: Int): Boolean = + documentModel.diagramAreas.any { it.containsLine(lineIdx) } + + /** Check if a line has pre-existing strokes (not inside any diagram area). */ + fun hasTextStrokesOnLine(lineIdx: Int, excluding: InkStroke? = null): Boolean = + documentModel.activeStrokes.any { + it !== excluding && + !isDiagramLine(lineSegmenter.getStrokeLineIndex(it)) && + lineSegmenter.getStrokeLineIndex(it) == lineIdx + } + + /** + * After strokes are erased from a diagram, shrink the area to fit remaining + * content and shift text below up to reclaim freed space. + */ + fun shrinkDiagramAfterErase(area: DiagramArea) { + val ls = HandwritingCanvasView.LINE_SPACING + val lineTextCache = host.getLineTextCache() + + // Find all remaining strokes in this diagram area + val remainingStrokes = documentModel.activeStrokes.filter { stroke -> + area.containsLine(lineSegmenter.getStrokeLineIndex(stroke)) + } + + if (remainingStrokes.isEmpty()) { + // Diagram is empty — remove it and shift everything below up + val linesFreed = area.heightInLines + val shiftUpPx = linesFreed * ls + val oldEnd = area.endLineIndex + + documentModel.diagramAreas.remove(area) + diagramTextCache.remove(area.startLineIndex) + + // Shift strokes below the old diagram up + val shifted = documentModel.activeStrokes.map { stroke -> + val strokeLine = lineSegmenter.getStrokeLineIndex(stroke) + if (strokeLine > oldEnd) stroke.shiftY(-shiftUpPx) else stroke + } + documentModel.activeStrokes.clear() + documentModel.activeStrokes.addAll(shifted) + + // Shift other diagram areas below + val shiftedAreas = documentModel.diagramAreas.map { other -> + if (other.startLineIndex > oldEnd) + other.copy(startLineIndex = other.startLineIndex - linesFreed) + else other + } + documentModel.diagramAreas.clear() + documentModel.diagramAreas.addAll(shiftedAreas) + + // Shift text cache + val shiftedCache = lineTextCache.entries.associate { (line, text) -> + (if (line > oldEnd) line - linesFreed else line) to text + } + lineTextCache.clear() + lineTextCache.putAll(shiftedCache) + + canvas.loadStrokes(documentModel.activeStrokes.toList()) + canvas.diagramAreas = documentModel.diagramAreas.toList() + canvas.pauseAndRedraw() + host.onDiagramAreasChanged() + Log.i(TAG, "Removed empty diagram area ${area.startLineIndex}-${area.endLineIndex}, shifted up $linesFreed lines") + return + } + + // Compute tight bounds from remaining strokes + 1-line padding + val minLine = remainingStrokes.minOf { lineSegmenter.getLineIndex(it.minY) } + val maxLine = remainingStrokes.maxOf { lineSegmenter.getLineIndex(it.maxY) } + val newStart = (minLine - 1).coerceAtLeast(area.startLineIndex) + val newEnd = (maxLine + 1).coerceAtMost(area.endLineIndex) + + val linesFreedBelow = area.endLineIndex - newEnd + val linesFreedAbove = newStart - area.startLineIndex + val totalFreed = linesFreedAbove + linesFreedBelow + + if (totalFreed == 0) return // no change + + // Step 1: Shrink the diagram area first + documentModel.diagramAreas.remove(area) + val shrunk = DiagramArea( + startLineIndex = newStart, + heightInLines = newEnd - newStart + 1 + ) + documentModel.diagramAreas.add(shrunk) + + // Step 2: Shift content below the old diagram end UP by linesFreedBelow + if (linesFreedBelow > 0) { + val shiftUpPx = linesFreedBelow * ls + val shifted = documentModel.activeStrokes.map { stroke -> + val strokeLine = lineSegmenter.getStrokeLineIndex(stroke) + if (strokeLine > area.endLineIndex) stroke.shiftY(-shiftUpPx) else stroke + } + documentModel.activeStrokes.clear() + documentModel.activeStrokes.addAll(shifted) + + val shiftedAreas = documentModel.diagramAreas.map { other -> + if (other.startLineIndex > area.endLineIndex) + other.copy(startLineIndex = other.startLineIndex - linesFreedBelow) + else other + } + documentModel.diagramAreas.clear() + documentModel.diagramAreas.addAll(shiftedAreas) + + val shiftedCache = lineTextCache.entries.associate { (line, text) -> + (if (line > area.endLineIndex) line - linesFreedBelow else line) to text + } + lineTextCache.clear() + lineTextCache.putAll(shiftedCache) + } + + // Step 3: Shrink from top — shift diagram content + everything below UP + if (linesFreedAbove > 0) { + val shiftUpPx = linesFreedAbove * ls + val shifted = documentModel.activeStrokes.map { stroke -> + val strokeLine = lineSegmenter.getStrokeLineIndex(stroke) + if (strokeLine >= newStart) stroke.shiftY(-shiftUpPx) else stroke + } + documentModel.activeStrokes.clear() + documentModel.activeStrokes.addAll(shifted) + + val shiftedAreas = documentModel.diagramAreas.map { other -> + if (other.startLineIndex >= newStart) + other.copy(startLineIndex = other.startLineIndex - linesFreedAbove) + else other + } + documentModel.diagramAreas.clear() + documentModel.diagramAreas.addAll(shiftedAreas) + + val shiftedCache = lineTextCache.entries.associate { (line, text) -> + (if (line >= newStart) line - linesFreedAbove else line) to text + } + lineTextCache.clear() + lineTextCache.putAll(shiftedCache) + + canvas.scrollOffsetY = (canvas.scrollOffsetY - shiftUpPx).coerceAtLeast(0f) + } + + canvas.loadStrokes(documentModel.activeStrokes.toList()) + canvas.diagramAreas = documentModel.diagramAreas.toList() + canvas.pauseAndRedraw() + host.onDiagramAreasChanged() + + for (line in 0..area.endLineIndex) { lineTextCache.remove(line) } + + val finalArea = documentModel.diagramAreas.find { + it.heightInLines == shrunk.heightInLines + } + Log.i(TAG, "Shrunk diagram: ${area.startLineIndex}-${area.endLineIndex} -> " + + "${finalArea?.startLineIndex}-${finalArea?.endLineIndex} (freed: up=$linesFreedAbove down=$linesFreedBelow)") + } + + /** + * Schedule diagram text recognition for [area], debounced by default. + * Rapid calls cancel the previous pending job — only the last one runs. + * Pass [immediate] = true for initial load (no delay). + */ + fun recognizeDiagramArea(area: DiagramArea, immediate: Boolean = false) { + diagramRecognizeJobs[area.startLineIndex]?.cancel() + if (immediate) { + recognizeDiagramAreaNow(area) + return + } + diagramRecognizeJobs[area.startLineIndex] = scope.launch { + delay(DIAGRAM_RECOGNIZE_DELAY_MS) + recognizeDiagramAreaNow(area) + } + } + + private fun recognizeDiagramAreaNow(area: DiagramArea) { + val lineTextCache = host.getLineTextCache() + + // Collect all freehand strokes in the diagram area + val freehandStrokes = documentModel.activeStrokes.filter { stroke -> + stroke.strokeType == StrokeType.FREEHAND && + area.containsLine(lineSegmenter.getStrokeLineIndex(stroke)) + } + if (freehandStrokes.isEmpty()) { + diagramTextCache.remove(area.startLineIndex) + return + } + + val ls = HandwritingCanvasView.LINE_SPACING + + // Collect geometric (shape) strokes for classification context + val shapeStrokes = documentModel.activeStrokes.filter { stroke -> + stroke.strokeType != StrokeType.FREEHAND && + !stroke.strokeType.isConnector && + area.containsLine(lineSegmenter.getStrokeLineIndex(stroke)) + } + + // Classify freehand strokes as text candidates or drawing strokes. + // Uses per-stroke heuristics (height, complexity, size, closure) and + // group-level contagion (freehand near drawings/shapes -> drawing). + val (textStrokes, _) = DiagramStrokeClassifier.partition( + freehandStrokes, shapeStrokes, ls + ) + if (textStrokes.isEmpty()) { + diagramTextCache.remove(area.startLineIndex) + return + } + + val groups = DiagramTextGrouping.groupByProximity(textStrokes, ls, shapeStrokes) + + scope.launch { + val results = mutableListOf() + for (group in groups) { + val yRows = DiagramTextGrouping.splitIntoRowsAdaptive(group, ls * 0.25f) + val groupIds = group.map { it.strokeId }.toSet() + for (row in yRows) { + val inkLine = InkLine.build(row) + try { + val preContext = buildPreContext(area.startLineIndex, lineTextCache) + val text = withContext(Dispatchers.IO) { + recognizer.recognizeLine(inkLine, preContext) + }.trim() + if (text.isNotEmpty() && text != "[?]") { + val cx = (inkLine.boundingBox.left + inkLine.boundingBox.right) / 2f + val cy = (inkLine.boundingBox.top + inkLine.boundingBox.bottom) / 2f + results.add(DiagramTextGroup( + text = text, + centerX = cx, + centerY = cy, + strokeIds = groupIds + )) + } + } catch (e: Exception) { + Log.e(TAG, "Diagram group recognition failed", e) + } + } + } + diagramTextCache[area.startLineIndex] = results + Log.d(TAG, "Diagram area ${area.startLineIndex}: recognized ${results.size} groups: ${results.map { it.text }}") + host.onDiagramAreasChanged() + } + } + + private fun buildPreContext(lineIndex: Int, lineTextCache: Map): String = + com.writer.ui.writing.buildPreContext(lineTextCache, lineIndex) + + /** + * Called when a shape is detected outside a diagram area. + * Creates a diagram area around the shape's bounding box with 1 line padding, + * merging with adjacent diagram areas. + * Returns the diagram bounds (topY, bottomY) or null. + */ + fun onShapeDetected(stroke: InkStroke): Pair? { + val lineTextCache = host.getLineTextCache() + val minY = stroke.minY + val maxY = stroke.maxY + val topLine = lineSegmenter.getLineIndex(minY) + val bottomLine = lineSegmenter.getLineIndex(maxY) + + // Exact bounding box — no forced padding. Overflow expansion will + // add space as needed when strokes cross the boundary. + // Don't extend into lines with pre-existing text strokes. + var mergeStart = topLine + var mergeHeight = bottomLine - mergeStart + 1 + + // Merge with adjacent diagram area above + val above = documentModel.diagramAreas.find { it.endLineIndex + 1 >= mergeStart && it.endLineIndex < mergeStart + mergeHeight } + if (above != null && above.endLineIndex + 1 >= mergeStart) { + if (above.startLineIndex < mergeStart) { + mergeHeight += mergeStart - above.startLineIndex + mergeStart = above.startLineIndex + } + documentModel.diagramAreas.remove(above) + } + + // Merge with adjacent diagram area below + val below = documentModel.diagramAreas.find { it.startLineIndex <= mergeStart + mergeHeight && it.startLineIndex >= mergeStart } + if (below != null && below != above) { + val belowEnd = below.startLineIndex + below.heightInLines + if (belowEnd > mergeStart + mergeHeight) { + mergeHeight = belowEnd - mergeStart + } + documentModel.diagramAreas.remove(below) + } + + val newArea = DiagramArea( + startLineIndex = mergeStart, + heightInLines = mergeHeight + ) + documentModel.diagramAreas.add(newArea) + canvas.diagramAreas = documentModel.diagramAreas.toList() + + // Invalidate text cache for affected lines + for (line in mergeStart until mergeStart + mergeHeight) { + lineTextCache.remove(line) + } + + val topY = lineSegmenter.getLineY(mergeStart) + val bottomY = lineSegmenter.getLineY(mergeStart + mergeHeight) + Log.i(TAG, "Auto-created diagram area: lines $mergeStart-${mergeStart + mergeHeight - 1}") + return Pair(topY, bottomY) + } + + /** + * Called when a stroke inside a diagram area extends beyond its bounds. + * Expands the diagram area to cover the stroke's Y range. + */ + fun onStrokeOverflow(overflowStrokeId: String, strokeMinY: Float, strokeMaxY: Float) { + val lineTextCache = host.getLineTextCache() + val strokeTopLine = lineSegmenter.getLineIndex(strokeMinY) + val strokeBottomLine = lineSegmenter.getLineIndex(strokeMaxY) + + // Find the diagram area that the stroke started in (closest match) + val area = documentModel.diagramAreas.find { + // The stroke overlaps this area + strokeTopLine <= it.endLineIndex && strokeBottomLine >= it.startLineIndex + } ?: return + + val ls = HandwritingCanvasView.LINE_SPACING + val linesAbove = maxOf(0, area.startLineIndex - strokeTopLine + 1) // +1 for padding + val linesBelow = maxOf(0, strokeBottomLine - area.endLineIndex + 1) + + if (linesAbove == 0 && linesBelow == 0) return + + // Upward expansion: shift diagram strokes and the overflow stroke DOWN. + // Text above stays in place. + // Use centroid (getStrokeLineIndex) to classify — avoids catching text + // descenders that barely cross the diagram boundary. + // The overflow stroke is identified by matching its Y bounds. + if (linesAbove > 0) { + val shiftPx = linesAbove * ls + val shifted = documentModel.activeStrokes.map { stroke -> + val strokeLine = lineSegmenter.getStrokeLineIndex(stroke) + val isInsideDiagram = strokeLine >= area.startLineIndex + val isOverflowStroke = stroke.strokeId == overflowStrokeId + if (isInsideDiagram || isOverflowStroke) stroke.shiftY(shiftPx) else stroke + } + documentModel.activeStrokes.clear() + documentModel.activeStrokes.addAll(shifted) + + // Shift diagram areas at or below + val shiftedAreas = documentModel.diagramAreas.map { other -> + if (other.startLineIndex >= area.startLineIndex) + other.copy(startLineIndex = other.startLineIndex + linesAbove) + else other + } + documentModel.diagramAreas.clear() + documentModel.diagramAreas.addAll(shiftedAreas) + + // Shift text cache + val shiftedCache = lineTextCache.entries.associate { (line, text) -> + (if (line >= area.startLineIndex) line + linesAbove else line) to text + } + lineTextCache.clear() + lineTextCache.putAll(shiftedCache) + + // Scroll so diagram appears at same screen position + canvas.scrollOffsetY += shiftPx + } + + // Downward expansion: shift strokes below the diagram DOWN, + // but NOT the overflow stroke (it stays — it's part of the diagram). + // Use centroid to classify. Overflow stroke identified by Y bounds. + if (linesBelow > 0) { + val shiftPx = linesBelow * ls + val postEndLine = area.endLineIndex + linesAbove // adjusted for upward shift + val shifted = documentModel.activeStrokes.map { stroke -> + val strokeLine = lineSegmenter.getStrokeLineIndex(stroke) + val isOverflowStroke = stroke.strokeId == overflowStrokeId + if (strokeLine > postEndLine && !isOverflowStroke) + stroke.shiftY(shiftPx) else stroke + } + documentModel.activeStrokes.clear() + documentModel.activeStrokes.addAll(shifted) + + val shiftedAreas = documentModel.diagramAreas.map { other -> + if (other.startLineIndex > postEndLine) + other.copy(startLineIndex = other.startLineIndex + linesBelow) + else other + } + documentModel.diagramAreas.clear() + documentModel.diagramAreas.addAll(shiftedAreas) + + val shiftedCache = lineTextCache.entries.associate { (line, text) -> + (if (line > postEndLine) line + linesBelow else line) to text + } + lineTextCache.clear() + lineTextCache.putAll(shiftedCache) + } + + // Expand diagram: keep original startLineIndex, grow height. + // After upward shift, the area moved to startLineIndex + linesAbove. + // We expand it back to the original startLineIndex. + val shiftedArea = documentModel.diagramAreas.find { + it.startLineIndex == area.startLineIndex + linesAbove + } ?: return + documentModel.diagramAreas.remove(shiftedArea) + val expanded = DiagramArea( + startLineIndex = area.startLineIndex, + heightInLines = area.heightInLines + linesAbove + linesBelow + ) + documentModel.diagramAreas.add(expanded) + + // Update canvas and force redraw so expanded bounds are immediately visible + canvas.loadStrokes(documentModel.activeStrokes.toList()) + canvas.diagramAreas = documentModel.diagramAreas.toList() + canvas.pauseAndRedraw() + for (line in expanded.startLineIndex..expanded.endLineIndex) { + lineTextCache.remove(line) + } + Log.i(TAG, "Expanded diagram: ${area.startLineIndex}-${area.endLineIndex} -> " + + "${expanded.startLineIndex}-${expanded.endLineIndex} (above=$linesAbove below=$linesBelow)") + } + + /** + * Handle strokes being erased (from scratch-out): invalidate diagram text cache + * for affected areas and shrink diagram areas that contained erased strokes. + */ + fun onStrokesErased(idsToRemove: Set, erasedStrokes: List) { + // Invalidate diagram text cache for any affected diagram areas + for (area in documentModel.diagramAreas) { + val cachedGroups = diagramTextCache[area.startLineIndex] ?: continue + if (cachedGroups.any { group -> group.strokeIds.any { it in idsToRemove } }) { + recognizeDiagramArea(area) + } + } + + // Shrink diagram areas that contained erased strokes + val affectedStartLines = erasedStrokes.map { lineSegmenter.getStrokeLineIndex(it) }.toSet() + for (startLine in affectedStartLines) { + val area = documentModel.diagramAreas.find { it.containsLine(startLine) } ?: continue + shrinkDiagramAfterErase(area) + } + } + + /** Get recognized text groups for a diagram area by start line index. */ + fun getTextGroups(startLineIndex: Int): List = + diagramTextCache[startLineIndex] ?: emptyList() + + /** Cancel pending jobs and clear diagram recognition state. */ + fun stop() { + diagramRecognizeJobs.values.forEach { it.cancel() } + diagramRecognizeJobs.clear() + } + + /** Reset all diagram state. */ + fun reset() { + diagramRecognizeJobs.values.forEach { it.cancel() } + diagramRecognizeJobs.clear() + diagramTextCache.clear() + } + + /** Clear the diagram text cache (used during undo/redo). */ + fun clearTextCache() { + diagramTextCache.clear() + } +} diff --git a/app/src/main/java/com/writer/ui/writing/DiagramStrokeClassifier.kt b/app/src/main/java/com/writer/ui/writing/DiagramStrokeClassifier.kt new file mode 100644 index 0000000..cc024e8 --- /dev/null +++ b/app/src/main/java/com/writer/ui/writing/DiagramStrokeClassifier.kt @@ -0,0 +1,186 @@ +package com.writer.ui.writing + +import com.writer.model.InkStroke +import com.writer.model.StrokeType +import com.writer.model.minX +import com.writer.model.minY +import com.writer.model.maxX +import com.writer.model.maxY +import com.writer.model.pathLength +import com.writer.model.diagonal +import com.writer.model.xRange +import com.writer.model.yRange +import kotlin.math.hypot +import kotlin.math.max + +/** + * Classifies freehand strokes inside diagram areas as text candidates or drawing strokes. + * + * Uses a scored heuristic approach: each stroke gets a "drawing score" from 0.0 to 1.0 + * based on height, path complexity, size, and closure. A score ≥ 0.5 means drawing. + * + * After per-stroke classification, group-level heuristics catch multi-line connected + * components and "drawing contagion" (freehand strokes adjacent to drawings/shapes). + */ +object DiagramStrokeClassifier { + + // Per-stroke thresholds (multiples of LINE_SPACING) + private const val HEIGHT_STRONG = 1.5f // yRange > 1.5×LS → strong drawing signal + private const val HEIGHT_MODERATE = 1.0f // yRange > 1.0×LS → moderate signal + private const val COMPLEXITY_HIGH = 3.5f // pathLength/diagonal ratio + private const val COMPLEXITY_MODERATE = 2.5f + private const val SIZE_THRESHOLD = 2.5f // max(xRange,yRange) > 2.5×LS + private const val CLOSE_FRACTION = 0.2f // start-end distance / diagonal for closure + private const val CLOSE_MIN_DIAGONAL = 0.5f // minimum diagonal for closure check (×LS) + private const val CONNECTOR_WIDTH = 2.0f // width > 2×LS for connector filter + private const val CONNECTOR_HEIGHT_RATIO = 0.3f // height < 30% of width + + // Score threshold + private const val DRAWING_THRESHOLD = 0.5f + + // Group-level thresholds + private const val GROUP_MAX_LINE_SPAN = 2.0f // group yRange > 2×LS → drawing + + /** + * Compute a drawing score for a single stroke (0.0 = text, 1.0 = drawing). + * @param includeConnector whether to include the connector heuristic (wide+flat). + * Set to false for dwell disambiguation — cursive text is wide+flat but not a connector. + */ + fun classifyStroke(stroke: InkStroke, lineSpacing: Float, includeConnector: Boolean = true): Float { + if (stroke.points.size < 2) return 0f + + val w = stroke.xRange + val h = stroke.yRange + val pl = stroke.pathLength + val diag = stroke.diagonal + + var score = 0f + + // A: Height relative to line spacing + when { + h > HEIGHT_STRONG * lineSpacing -> score += 0.6f + h > HEIGHT_MODERATE * lineSpacing -> score += 0.3f + } + + // B: Path complexity + if (diag > 0f) { + val complexity = pl / diag + when { + complexity > COMPLEXITY_HIGH -> score += 0.4f + complexity > COMPLEXITY_MODERATE -> score += 0.2f + } + } + + // C: Large vertical size (height matters, not width — text is naturally wide) + if (h > SIZE_THRESHOLD * lineSpacing) { + score += 0.3f + } + + // D: Closed loop + if (diag > CLOSE_MIN_DIAGONAL * lineSpacing && stroke.points.size >= 3) { + val first = stroke.points.first() + val last = stroke.points.last() + val closeDist = hypot((last.x - first.x).toDouble(), (last.y - first.y).toDouble()).toFloat() + if (closeDist < CLOSE_FRACTION * diag) { + score += 0.3f + } + } + + // E: Connector (wide + flat → auto-drawing). Skipped for dwell disambiguation + // because cursive text is also wide+flat. + if (includeConnector && w > CONNECTOR_WIDTH * lineSpacing && h < CONNECTOR_HEIGHT_RATIO * w) { + score = 1.0f + } + + return score.coerceAtMost(1.0f) + } + + /** + * Partition freehand strokes into text candidates and drawing strokes + * using per-stroke heuristics only. + */ + fun partitionByStroke( + strokes: List, + lineSpacing: Float + ): Pair, List> { + val text = mutableListOf() + val drawing = mutableListOf() + for (stroke in strokes) { + if (classifyStroke(stroke, lineSpacing) >= DRAWING_THRESHOLD) { + drawing.add(stroke) + } else { + text.add(stroke) + } + } + return Pair(text, drawing) + } + + /** + * Full classification pipeline: per-stroke scoring + group-level contagion. + * + * Groups freehand strokes by proximity. If any freehand stroke in a group + * is drawing-classified (per-stroke heuristics), the entire group becomes + * drawing. Geometric shapes do NOT trigger contagion — text labels are + * often inside or adjacent to shapes and must remain as text. + * Also applies multi-line span filter. + * + * @return (textCandidates, drawingStrokes) + */ + fun partition( + freehandStrokes: List, + geometricStrokes: List, + lineSpacing: Float + ): Pair, List> { + if (freehandStrokes.isEmpty()) return Pair(emptyList(), emptyList()) + + // Step 1: Per-stroke classification + val drawingScores = freehandStrokes.associateWith { classifyStroke(it, lineSpacing) } + val perStrokeDrawingIds = drawingScores.filter { it.value >= DRAWING_THRESHOLD }.keys.map { it.strokeId }.toSet() + + // Step 2: Proximity-group FREEHAND strokes only for contagion. + // Geometric shapes are NOT included — text labels near shapes must stay text. + val groups = groupByProximity(freehandStrokes, lineSpacing) + + // Step 3: Drawing contagion — if any freehand stroke in a group is + // drawing-classified, all freehand strokes in the group become drawing. + val contagionDrawingIds = mutableSetOf() + for (group in groups) { + val hasDrawing = group.any { stroke -> + stroke.strokeId in perStrokeDrawingIds + } + // Multi-line span check + val groupYRange = group.maxOf { it.maxY } - + group.minOf { it.minY } + val isMultiLine = groupYRange > GROUP_MAX_LINE_SPAN * lineSpacing + + if (hasDrawing || isMultiLine) { + for (stroke in group) { + if (stroke.strokeType == StrokeType.FREEHAND) { + contagionDrawingIds.add(stroke.strokeId) + } + } + } + } + + // Step 4: Partition + val allDrawingIds = perStrokeDrawingIds + contagionDrawingIds + val text = freehandStrokes.filter { it.strokeId !in allDrawingIds } + val drawing = freehandStrokes.filter { it.strokeId in allDrawingIds } + return Pair(text, drawing) + } + + /** + * Check if a group of strokes should be classified as text. + * Returns false (= drawing) if the group spans more than 2 line heights. + */ + fun isTextGroup(group: List, lineSpacing: Float): Boolean { + if (group.isEmpty()) return true + val minY = group.minOf { it.minY } + val maxY = group.maxOf { it.maxY } + return (maxY - minY) <= GROUP_MAX_LINE_SPAN * lineSpacing + } + + // Grid-based proximity grouping — O(n) average instead of O(n²). + private fun groupByProximity(strokes: List, maxGap: Float): List> = + SpatialGrouping.groupByProximity(strokes, maxGap) { s -> BBox(s.minX, s.minY, s.maxX, s.maxY) } +} diff --git a/app/src/main/java/com/writer/ui/writing/DiagramTextGrouping.kt b/app/src/main/java/com/writer/ui/writing/DiagramTextGrouping.kt new file mode 100644 index 0000000..6aa0861 --- /dev/null +++ b/app/src/main/java/com/writer/ui/writing/DiagramTextGrouping.kt @@ -0,0 +1,156 @@ +package com.writer.ui.writing + +import com.writer.model.InkStroke +import com.writer.model.minX +import com.writer.model.minY +import com.writer.model.maxX +import com.writer.model.maxY +import com.writer.model.yRange + +/** + * Groups freehand diagram strokes by 2D spatial proximity, then splits + * groups into horizontal rows for line-based recognition. + * + * Extracted from [WritingCoordinator.recognizeDiagramArea] for testability. + */ +object DiagramTextGrouping { + + /** + * Group strokes by 2D spatial proximity. Strokes whose bounding boxes + * are within [maxGap] of each other (in both X and Y) are merged into + * the same group using union-find. + * + * If [shapeStrokes] is provided, strokes are first partitioned by which + * shape contains them (centroid inside shape bounding box). Strokes in + * different shapes are never grouped together, regardless of distance. + * Strokes not inside any shape are grouped by proximity with each other. + * + * @return list of stroke groups (each group is a list of strokes) + */ + fun groupByProximity( + strokes: List, + maxGap: Float, + shapeStrokes: List = emptyList() + ): List> { + if (strokes.isEmpty()) return emptyList() + + // If shapes are available, partition freehand strokes by containment + if (shapeStrokes.isNotEmpty()) { + val shapeBounds = shapeStrokes.map { s -> + BBox(s.minX, s.minY, s.maxX, s.maxY) + } + + // Assign each freehand stroke to a shape (by centroid containment), or -1 + val assignments = strokes.map { s -> + val cx = (s.minX + s.maxX) / 2f + val cy = (s.minY + s.maxY) / 2f + shapeBounds.indexOfFirst { b -> + cx >= b.minX && cx <= b.maxX && cy >= b.minY && cy <= b.maxY + } + } + + // Group by shape assignment, then proximity-group unassigned strokes + val result = mutableListOf>() + val byShape = strokes.indices.groupBy { assignments[it] } + for ((shapeIdx, indices) in byShape) { + val group = indices.map { strokes[it] } + if (shapeIdx >= 0) { + // All strokes in the same shape form one group + result.add(group) + } else { + // Unassigned strokes: group by proximity + result.addAll(groupByProximitySimple(group, maxGap)) + } + } + return result + } + + return groupByProximitySimple(strokes, maxGap) + } + + /** Simple proximity grouping without shape awareness. */ + private fun groupByProximitySimple(strokes: List, maxGap: Float): List> = + SpatialGrouping.groupByProximity(strokes, maxGap) { s -> BBox(s.minX, s.minY, s.maxX, s.maxY) } + + /** + * Split a group of strokes into horizontal rows for recognition. + * A new row starts when there is a vertical gap > [maxGap] between + * the bottom of one stroke cluster and the top of the next. + * + * This keeps tall letters (like "A" spanning two lines) together + * while separating vertically stacked text blocks (like "Side" / "Text"). + * + * @return list of rows, each row is a list of strokes sorted left-to-right + */ + fun splitIntoRows(group: List, maxGap: Float): List> { + if (group.isEmpty()) return emptyList() + + val byY = group.sortedBy { s -> + (s.minY + s.maxY) / 2f + } + + val rows = mutableListOf(mutableListOf(byY.first())) + for (i in 1 until byY.size) { + val prevMaxY = rows.last().maxOf { s -> s.maxY } + val curMinY = byY[i].minY + if (curMinY - prevMaxY > maxGap) { + rows.add(mutableListOf(byY[i])) + } else { + rows.last().add(byY[i]) + } + } + + return rows.map { row -> row.sortedBy { s -> s.minX } } + } + + /** + * Adaptive row splitting using median stroke height as the baseline reference. + * + * Instead of a fixed gap threshold, estimates the handwriting's x-height from + * the median stroke height, then uses Y-centroid clustering to group strokes + * into rows. This adapts to the user's writing size and handles: + * - Tall letters (like "A") that span two ruled lines → stay as one row + * - Stacked text blocks ("Side" / "Text") → split into separate rows + * + * Falls back to [splitIntoRows] with [fallbackGap] for groups with fewer + * than 3 strokes (insufficient data for reliable median estimation). + * + * @param group strokes to split + * @param fallbackGap gap threshold for small groups + * @return list of rows, each sorted left-to-right + */ + fun splitIntoRowsAdaptive(group: List, fallbackGap: Float): List> { + if (group.isEmpty()) return emptyList() + if (group.size < 3) return splitIntoRows(group, fallbackGap) + + // Compute median stroke height as x-height proxy + val heights = group.map { s -> s.yRange }.filter { it > 0f }.sorted() + + if (heights.isEmpty()) return splitIntoRows(group, fallbackGap) + + val medianHeight = heights[heights.size / 2] + + // Use centroid-based clustering: sort by Y-centroid, split when + // consecutive centroids are more than 0.8 * medianHeight apart + val centroidGap = medianHeight * 0.8f + + data class StrokeWithCentroid(val stroke: InkStroke, val centroidY: Float) + val sorted = group.map { s -> + StrokeWithCentroid(s, (s.minY + s.maxY) / 2f) + }.sortedBy { it.centroidY } + + val rows = mutableListOf(mutableListOf(sorted.first().stroke)) + var lastCentroid = sorted.first().centroidY + for (i in 1 until sorted.size) { + if (sorted[i].centroidY - lastCentroid > centroidGap) { + rows.add(mutableListOf(sorted[i].stroke)) + } else { + rows.last().add(sorted[i].stroke) + } + // Update lastCentroid to the max centroid in current row for stability + lastCentroid = maxOf(lastCentroid, sorted[i].centroidY) + } + + return rows.map { row -> row.sortedBy { s -> s.minX } } + } +} diff --git a/app/src/main/java/com/writer/ui/writing/DisplayManager.kt b/app/src/main/java/com/writer/ui/writing/DisplayManager.kt new file mode 100644 index 0000000..5d2d21d --- /dev/null +++ b/app/src/main/java/com/writer/ui/writing/DisplayManager.kt @@ -0,0 +1,299 @@ +package com.writer.ui.writing + +import android.util.Log +import com.writer.model.DocumentModel +import com.writer.model.InkStroke +import com.writer.model.maxY +import com.writer.view.CanvasTheme +import com.writer.view.HandwritingCanvasView +import com.writer.view.PreviewLayoutCalculator +import com.writer.view.RecognizedTextView +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import com.writer.recognition.LineSegmenter + +/** Callback interface for DisplayManager to communicate with its host. */ +interface DisplayManagerHost { + val documentModel: DocumentModel + val diagramManager: DiagramManager + val lineTextCache: Map + val highestLineIndex: Int + fun eagerRecognizeLine(lineIndex: Int) + /** Batch-recognize a line, adding/removing from recognizingLines internally. */ + fun markRecognizing(lineIndex: Int) + suspend fun doRecognizeLine(lineIndex: Int): String? + fun isRecognizing(lineIndex: Int): Boolean +} + +/** A segment of text within a paragraph, with its own dimming state. */ +data class TextSegment(val text: String, val dimmed: Boolean, val lineIndex: Int, val listItem: Boolean = false, val heading: Boolean = false) + +/** A text label to render inside a diagram at its original stroke position. */ +data class DiagramTextLabel(val text: String, val x: Float, val y: Float) + +/** Diagram display data for inline rendering in the text view. */ +data class DiagramDisplay( + val startLineIndex: Int, + val strokes: List, + val canvasWidth: Float, + val heightPx: Float, + val offsetY: Float, + /** How much of the diagram's height is scrolled off the canvas (for partial rendering). */ + val visibleHeightPx: Float = heightPx, + /** Recognized text labels replacing freehand strokes in the diagram. */ + val textLabels: List = emptyList() +) + +class DisplayManager( + private val inkCanvas: HandwritingCanvasView, + private val textView: RecognizedTextView, + private val scope: CoroutineScope, + private val lineSegmenter: LineSegmenter, + private val paragraphBuilder: ParagraphBuilder, + private val host: DisplayManagerHost +) { + companion object { + private const val TAG = "DisplayManager" + // Scroll when writing passes this fraction of canvas height from top + private const val SCROLL_THRESHOLD = 0.25f + // Delay before refreshing e-ink display after text view updates + private const val TEXT_REFRESH_DELAY_MS = 500L + } + + /** Lines that have ever scrolled above the viewport (text stays rendered once shown). */ + internal val everHiddenLines = mutableSetOf() + + fun clearEverHiddenLines() { everHiddenLines.clear() } + fun addEverHiddenLines(lines: Set) { everHiddenLines.addAll(lines) } + fun getEverHiddenLinesSnapshot(): Set = everHiddenLines.toSet() + fun isEverHidden(lineIndex: Int): Boolean = lineIndex in everHiddenLines + /** Auto-scroll animation state. */ + var scrollAnimating = false + private set + /** Deferred e-ink refresh for text view updates. */ + var textRefreshJob: Job? = null + + fun onIdle(currentLineIndex: Int) { + if (currentLineIndex >= 0) { + host.eagerRecognizeLine(currentLineIndex) + } + checkAutoScroll(currentLineIndex) + } + + fun checkAutoScroll(currentLineIndex: Int) { + if (scrollAnimating) return + + val strokes = host.documentModel.activeStrokes + if (strokes.isEmpty()) return + + val canvasHeight = inkCanvas.height.toFloat() + if (canvasHeight <= 0) return + + val bottomLine = lineSegmenter.getBottomOccupiedLine(strokes) + if (bottomLine < 0) return + + if (currentLineIndex != bottomLine) return + + val bottomLineDocY = lineSegmenter.getLineY(bottomLine) + HandwritingCanvasView.LINE_SPACING + val bottomLineScreenY = bottomLineDocY - inkCanvas.scrollOffsetY + val targetY = canvasHeight * SCROLL_THRESHOLD + + if (bottomLineScreenY < 0 || bottomLineScreenY > canvasHeight) return + if (bottomLineScreenY < targetY) return + + val rawOffset = bottomLineDocY - targetY + val newOffset = inkCanvas.snapToLine(rawOffset) + if (newOffset > inkCanvas.scrollOffsetY) { + animateScroll(inkCanvas.scrollOffsetY, newOffset) + } + } + + fun scrollToLine(lineIndex: Int) { + if (scrollAnimating) return + + val canvasHeight = inkCanvas.height.toFloat() + if (canvasHeight <= 0) return + + val lineY = lineSegmenter.getLineY(lineIndex) + val targetOffset = inkCanvas.snapToLine(lineY).coerceAtLeast(0f) + + if (targetOffset != inkCanvas.scrollOffsetY) { + Log.i(TAG, "Tap-to-scroll: line $lineIndex -> offset ${targetOffset.toInt()}") + animateScroll(inkCanvas.scrollOffsetY, targetOffset, 500L) + } + } + + fun animateScroll(fromOffset: Float, toOffset: Float, duration: Long = 1000L) { + scrollAnimating = true + Log.i(TAG, "AutoScroll: animating from ${fromOffset.toInt()} to ${toOffset.toInt()}") + inkCanvas.pauseRawDrawing() + + val distance = toOffset - fromOffset + + scope.launch { + val startTime = System.currentTimeMillis() + while (scrollAnimating) { + val elapsed = System.currentTimeMillis() - startTime + if (elapsed >= duration) break + + val t = elapsed.toFloat() / duration + val interpolated = 1f - (1f - t) * (1f - t) + inkCanvas.scrollOffsetY = fromOffset + distance * interpolated + inkCanvas.drawToSurface() + displayHiddenLines() + + kotlinx.coroutines.delay(33) // ~30fps + } + + inkCanvas.scrollOffsetY = toOffset + inkCanvas.drawToSurface() + inkCanvas.resumeRawDrawing() + scrollAnimating = false + displayHiddenLines() + } + } + + /** Schedule a deferred e-ink refresh so text view updates become visible. + * Cancelled if a new stroke arrives, so we never interrupt active writing. */ + fun scheduleTextRefresh() { + textRefreshJob?.cancel() + textRefreshJob = scope.launch { + delay(TEXT_REFRESH_DELAY_MS) + inkCanvas.pauseRawDrawing() + inkCanvas.drawToSurface() + inkCanvas.resumeRawDrawing() + } + } + + fun displayHiddenLines() { + val strokesByLine = lineSegmenter.groupByLine(host.documentModel.activeStrokes) + + val currentlyHidden = PreviewLayoutCalculator.currentlyHiddenLines( + strokesByLine.keys, inkCanvas.scrollOffsetY, + HandwritingCanvasView.TOP_MARGIN, HandwritingCanvasView.LINE_SPACING + ) + + val notYetVisible = PreviewLayoutCalculator.notYetVisibleLines( + strokesByLine.keys, inkCanvas.scrollOffsetY, + HandwritingCanvasView.TOP_MARGIN, HandwritingCanvasView.LINE_SPACING + ) + + everHiddenLines.addAll(currentlyHidden) + everHiddenLines.retainAll(strokesByLine.keys) + + updateTextView(notYetVisible, strokesByLine) + updateTextScrollOffset() + + val uncached = everHiddenLines.filter { !host.lineTextCache.containsKey(it) && !host.isRecognizing(it) } + if (uncached.isNotEmpty()) { + for (lineIdx in uncached) { + host.markRecognizing(lineIdx) + } + scope.launch { + for (lineIdx in uncached) { + host.doRecognizeLine(lineIdx) + } + val freshStrokesByLine = lineSegmenter.groupByLine(host.documentModel.activeStrokes) + val stillNotVisible = PreviewLayoutCalculator.notYetVisibleLines( + freshStrokesByLine.keys, inkCanvas.scrollOffsetY, + HandwritingCanvasView.TOP_MARGIN, HandwritingCanvasView.LINE_SPACING + ) + updateTextView(stillNotVisible, freshStrokesByLine) + } + } + } + + private fun updateTextView(currentlyHidden: Set, strokesByLine: Map>) { + val writingWidth = inkCanvas.width.toFloat() + + val classifiedLines = currentlyHidden.sorted() + .filter { !host.diagramManager.isDiagramLine(it) } + .mapNotNull { lineIdx -> + paragraphBuilder.classifyLine(lineIdx, host.lineTextCache[lineIdx], strokesByLine[lineIdx], writingWidth) + } + + val grouped = paragraphBuilder.groupIntoParagraphs(classifiedLines, strokesByLine, writingWidth, host.documentModel.diagramAreas) + + val paragraphs = grouped.map { group -> + group.map { line -> + TextSegment( + text = line.text, + dimmed = line.lineIndex !in currentlyHidden, + lineIndex = line.lineIndex, + listItem = line.isList, + heading = line.isHeading + ) + } + } + + // Build diagram displays -- include as soon as any part scrolls off the canvas + val strokeMaxYByArea = host.documentModel.diagramAreas.associate { area -> + val areaStrokes = host.documentModel.activeStrokes.filter { stroke -> + area.containsLine(lineSegmenter.getStrokeLineIndex(stroke)) + } + area.startLineIndex to (if (areaStrokes.isNotEmpty()) areaStrokes.maxOf { it.maxY } else null) + }.filterValues { it != null }.mapValues { it.value!! } + + val visibilities = PreviewLayoutCalculator.diagramVisibilities( + areas = host.documentModel.diagramAreas, + scrollOffsetY = inkCanvas.scrollOffsetY, + topMargin = HandwritingCanvasView.TOP_MARGIN, + lineSpacing = HandwritingCanvasView.LINE_SPACING, + strokeMaxYByArea = strokeMaxYByArea, + strokeWidthPadding = CanvasTheme.DEFAULT_STROKE_WIDTH + ) + + val areaByStartLine = host.documentModel.diagramAreas.associateBy { it.startLineIndex } + val diagrams = visibilities.map { vis -> + val area = areaByStartLine[vis.startLineIndex] + val areaStrokes = if (area != null) { + host.documentModel.activeStrokes.filter { stroke -> + area.containsLine(lineSegmenter.getStrokeLineIndex(stroke)) + } + } else emptyList() + + // Use diagram text cache for recognized spatial groups + val textGroups = host.diagramManager.getTextGroups(vis.startLineIndex) + val hiddenIds = textGroups.flatMap { it.strokeIds }.toSet() + val displayStrokes = areaStrokes.filter { it.strokeId !in hiddenIds } + val textLabels = textGroups.map { group -> + DiagramTextLabel(text = group.text, x = group.centerX, y = group.centerY) + } + DiagramDisplay( + startLineIndex = vis.startLineIndex, + strokes = displayStrokes, + canvasWidth = writingWidth, + heightPx = vis.fullHeight, + offsetY = vis.areaTop, + visibleHeightPx = vis.visibleHeight, + textLabels = textLabels + ) + } + + textView.setContent(paragraphs, diagrams) + } + + /** Called when the text view is scrolled via overscroll. */ + fun onManualTextScroll(textOverscroll: Float) { + textView.textContentScroll = textOverscroll + textView.invalidate() + } + + private fun updateTextScrollOffset() { + // Content is flush with the divider -- preview and canvas are complementary + textView.textScrollOffset = 0f + } + + fun stop() { + scrollAnimating = false + textRefreshJob?.cancel() + } + + fun reset() { + scrollAnimating = false + textRefreshJob?.cancel() + everHiddenLines.clear() + } +} diff --git a/app/src/main/java/com/writer/ui/writing/LineRecognitionManager.kt b/app/src/main/java/com/writer/ui/writing/LineRecognitionManager.kt new file mode 100644 index 0000000..f7f1f0e --- /dev/null +++ b/app/src/main/java/com/writer/ui/writing/LineRecognitionManager.kt @@ -0,0 +1,146 @@ +package com.writer.ui.writing + +import android.util.Log +import com.writer.model.DocumentModel +import com.writer.model.InkStroke +import com.writer.recognition.LineSegmenter +import com.writer.recognition.StrokeClassifier +import com.writer.recognition.TextRecognizer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** Build recognition context from recent cached text above [lineIndex]. */ +internal fun buildPreContext(lineTextCache: Map, lineIndex: Int): String { + return lineTextCache.entries + .filter { it.key < lineIndex && it.value.isNotBlank() && it.value != "[?]" } + .sortedBy { it.key } + .joinToString(" ") { it.value } + .takeLast(20) +} + +/** Callback interface for LineRecognitionManager to communicate with its host. */ +interface RecognitionManagerHost { + val lineTextCache: MutableMap + val userRenamed: Boolean + val everHiddenLines: Set + fun onHeadingDetected(heading: String) + fun isDiagramLine(lineIndex: Int): Boolean + fun onRecognitionComplete(lineIndex: Int) +} + +class LineRecognitionManager( + private val documentModel: DocumentModel, + private val recognizer: TextRecognizer, + private val lineSegmenter: LineSegmenter, + private val strokeClassifier: StrokeClassifier, + private val scope: CoroutineScope, + private val host: RecognitionManagerHost, + private val canvasWidthProvider: () -> Float +) { + companion object { + private const val TAG = "LineRecognitionManager" + } + + /** Track which lines are currently being recognized (avoid duplicates). */ + internal val recognizingLines = mutableSetOf() + + fun isRecognizing(lineIndex: Int): Boolean = recognizingLines.contains(lineIndex) + fun markRecognizing(lineIndex: Int) { recognizingLines.add(lineIndex) } + /** Lines that need re-recognition after current recognition finishes. */ + val pendingRerecognize = mutableSetOf() + + fun eagerRecognizeLine(lineIndex: Int) { + if (host.lineTextCache.containsKey(lineIndex)) return + if (recognizingLines.contains(lineIndex)) return + recognizingLines.add(lineIndex) + + scope.launch { + val text = doRecognizeLine(lineIndex) ?: return@launch + Log.d(TAG, "Eager recognized line $lineIndex: \"$text\"") + if (lineIndex in host.everHiddenLines) { + host.onRecognitionComplete(lineIndex) + } + } + } + + fun recognizeRenderedLine(lineIndex: Int) { + if (recognizingLines.contains(lineIndex)) { + pendingRerecognize.add(lineIndex) + return + } + recognizingLines.add(lineIndex) + host.lineTextCache.remove(lineIndex) + + scope.launch { + val text = doRecognizeLine(lineIndex) ?: return@launch + Log.d(TAG, "Rendered line recognized $lineIndex: \"$text\"") + host.onRecognitionComplete(lineIndex) + if (lineIndex in pendingRerecognize) { + pendingRerecognize.remove(lineIndex) + recognizeRenderedLine(lineIndex) + } + } + } + + /** + * Core recognition: get strokes for [lineIndex], filter markers, recognize, + * and cache the result. Returns the recognized text, or null if there were + * no strokes or recognition failed. + */ + suspend fun doRecognizeLine(lineIndex: Int): String? { + if (host.isDiagramLine(lineIndex)) { + recognizingLines.remove(lineIndex) + return null + } + try { + val allStrokes = lineSegmenter.getStrokesForLine( + documentModel.activeStrokes, lineIndex + ) + if (allStrokes.isEmpty()) { + recognizingLines.remove(lineIndex) + return null + } + val strokes = strokeClassifier.filterMarkerStrokes(allStrokes, canvasWidthProvider()) + if (strokes.isEmpty()) { + recognizingLines.remove(lineIndex) + return null + } + + val line = lineSegmenter.buildInkLine(strokes, lineIndex) + val preContext = buildPreContext(lineIndex) + val text = withContext(Dispatchers.IO) { + recognizer.recognizeLine(line, preContext) + }.trim() + + host.lineTextCache[lineIndex] = text + recognizingLines.remove(lineIndex) + checkHeadingRename(lineIndex, text, allStrokes) + return text + } catch (e: Exception) { + Log.e(TAG, "Recognition failed for line $lineIndex", e) + host.lineTextCache[lineIndex] = "[?]" + recognizingLines.remove(lineIndex) + pendingRerecognize.remove(lineIndex) + return null + } + } + + private fun checkHeadingRename(lineIndex: Int, text: String, strokes: List) { + if (host.userRenamed) return + if (lineIndex != 0) return + if (text.isEmpty() || text == "[?]") return + val isHeading = strokeClassifier.findUnderlineStrokeId(strokes, lineIndex) != null + if (!isHeading) return + host.onHeadingDetected(text) + } + + fun buildPreContext(lineIndex: Int): String = + buildPreContext(host.lineTextCache, lineIndex) + + fun reset() { + recognizingLines.clear() + pendingRerecognize.clear() + } +} diff --git a/app/src/main/java/com/writer/ui/writing/SaveAsActivity.kt b/app/src/main/java/com/writer/ui/writing/SaveAsActivity.kt index 42f1065..cc2c0b0 100644 --- a/app/src/main/java/com/writer/ui/writing/SaveAsActivity.kt +++ b/app/src/main/java/com/writer/ui/writing/SaveAsActivity.kt @@ -1,7 +1,6 @@ package com.writer.ui.writing import android.content.Intent -import android.graphics.RectF import android.os.Bundle import android.util.Log import android.widget.TextView @@ -13,7 +12,7 @@ import com.writer.model.InkLine import com.writer.model.InkStroke import com.writer.model.minX import com.writer.model.maxX -import com.writer.recognition.HandwritingRecognizer +import com.writer.recognition.TextRecognizerFactory import com.writer.view.HandwritingNameInput import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -30,8 +29,7 @@ class SaveAsActivity : AppCompatActivity() { private lateinit var nameDisplay: TextView private lateinit var handwritingInput: HandwritingNameInput - private val recognizer = HandwritingRecognizer() - private var recognizerReady = false + private var recognizer: com.writer.recognition.TextRecognizer? = null private val allStrokes = mutableListOf() private var currentName = "" @@ -39,6 +37,18 @@ class SaveAsActivity : AppCompatActivity() { super.onCreate(savedInstanceState) setContentView(R.layout.activity_save_as) + lifecycleScope.launch { + val rec = TextRecognizerFactory.create(this@SaveAsActivity) + try { + rec.initialize("en-US") + recognizer = rec + } catch (e: Exception) { + rec.close() + Log.w(TAG, "Recognizer init failed", e) + Toast.makeText(this@SaveAsActivity, "Handwriting recognition unavailable", Toast.LENGTH_SHORT).show() + } + } + nameDisplay = findViewById(R.id.nameDisplay) handwritingInput = findViewById(R.id.handwritingInput) @@ -47,17 +57,6 @@ class SaveAsActivity : AppCompatActivity() { nameDisplay.text = currentName handwritingInput.placeholderText = currentName - // Initialize recognizer - lifecycleScope.launch { - try { - recognizer.initialize("en-US") - recognizerReady = true - } catch (e: Exception) { - Log.e(TAG, "Failed to init recognizer", e) - Toast.makeText(this@SaveAsActivity, "Recognition unavailable", Toast.LENGTH_SHORT).show() - } - } - // Handle stroke completion handwritingInput.onStrokeCompleted = { stroke -> onStrokeCompleted(stroke) @@ -122,7 +121,7 @@ class SaveAsActivity : AppCompatActivity() { } private fun recognizeAll() { - if (!recognizerReady) return + val rec = recognizer ?: return if (allStrokes.isEmpty()) { currentName = intent.getStringExtra(EXTRA_CURRENT_NAME) ?: "" nameDisplay.text = currentName @@ -136,7 +135,7 @@ class SaveAsActivity : AppCompatActivity() { lifecycleScope.launch { try { val text = withContext(Dispatchers.IO) { - recognizer.recognizeLine(line) + rec.recognizeLine(line) } currentName = text.trim() nameDisplay.text = currentName @@ -148,6 +147,6 @@ class SaveAsActivity : AppCompatActivity() { override fun onDestroy() { super.onDestroy() - recognizer.close() + recognizer?.close() } } diff --git a/app/src/main/java/com/writer/ui/writing/SpatialGrouping.kt b/app/src/main/java/com/writer/ui/writing/SpatialGrouping.kt new file mode 100644 index 0000000..53d6be3 --- /dev/null +++ b/app/src/main/java/com/writer/ui/writing/SpatialGrouping.kt @@ -0,0 +1,62 @@ +package com.writer.ui.writing + +/** Bounding box for spatial proximity grouping. */ +data class BBox(val minX: Float, val minY: Float, val maxX: Float, val maxY: Float) + +/** Grid-based proximity grouping using union-find. O(n) average. */ +object SpatialGrouping { + fun groupByProximity( + items: List, + maxGap: Float, + toBBox: (T) -> BBox + ): List> { + if (items.isEmpty()) return emptyList() + + val boxes = items.map { toBBox(it) } + + // Union-find + val parent = IntArray(items.size) { it } + fun find(i: Int): Int { + var r = i + while (parent[r] != r) r = parent[r] + var x = i + while (x != r) { val n = parent[x]; parent[x] = r; x = n } + return r + } + fun union(a: Int, b: Int) { parent[find(a)] = find(b) } + + // Grid-based spatial index: cell size = maxGap. + // Each bbox is inserted into all cells it overlaps (expanded by maxGap). + // Boxes in the same cell are candidates for union -- O(n) average. + val cellSize = maxGap.coerceAtLeast(1f) + val grid = HashMap>() + fun cellKey(cx: Int, cy: Int) = cx.toLong() shl 32 or (cy.toLong() and 0xFFFFFFFFL) + + for (i in boxes.indices) { + val b = boxes[i] + val cxMin = ((b.minX - maxGap) / cellSize).toInt() + val cxMax = ((b.maxX + maxGap) / cellSize).toInt() + val cyMin = ((b.minY - maxGap) / cellSize).toInt() + val cyMax = ((b.maxY + maxGap) / cellSize).toInt() + for (cx in cxMin..cxMax) { + for (cy in cyMin..cyMax) { + val key = cellKey(cx, cy) + val cell = grid.getOrPut(key) { mutableListOf() } + // Check proximity with existing entries in this cell + for (j in cell) { + if (find(i) == find(j)) continue // already merged + val xDist = maxOf(0f, maxOf(boxes[i].minX - boxes[j].maxX, boxes[j].minX - boxes[i].maxX)) + val yDist = maxOf(0f, maxOf(boxes[i].minY - boxes[j].maxY, boxes[j].minY - boxes[i].maxY)) + if (xDist <= maxGap && yDist <= maxGap) { + union(i, j) + } + } + cell.add(i) + } + } + } + + return items.indices.groupBy { find(it) } + .values.map { indices -> indices.map { items[it] } } + } +} diff --git a/app/src/main/java/com/writer/ui/writing/StrokeEventLog.kt b/app/src/main/java/com/writer/ui/writing/StrokeEventLog.kt new file mode 100644 index 0000000..7dab370 --- /dev/null +++ b/app/src/main/java/com/writer/ui/writing/StrokeEventLog.kt @@ -0,0 +1,137 @@ +package com.writer.ui.writing + +import com.writer.model.InkStroke +import com.writer.model.StrokePoint +import com.writer.recognition.StrokeDownsampler + +/** + * Always-on ring buffer that records raw strokes and processing decisions. + * + * Every stroke is captured via [recordStroke] before any processing (scratch-out, + * shape snap, recognition). Processing decisions are recorded via [recordEvent] + * as they happen. When the user taps "Report Bug", [snapshot] freezes the current + * state into a serializable [Snapshot]. + * + * The ring buffer has a fixed capacity ([maxStrokes]). When full, the oldest + * strokes and their associated events are dropped. + */ +class StrokeEventLog(private val maxStrokes: Int = 50) { + + enum class EventType { + /** Stroke added to document as freehand */ + ADDED, + /** Stroke detected as scratch-out; detail = erased stroke IDs */ + SCRATCH_OUT, + /** Stroke snapped to geometric shape; detail = shape type */ + SHAPE_SNAPPED, + /** Raw stroke replaced with snapped version; detail = new stroke type */ + REPLACED, + /** Text recognized on a line; detail = "line:N text:..." */ + RECOGNIZED, + /** Diagram area auto-created; detail = "start:N height:M" */ + DIAGRAM_CREATED, + /** Diagram area expanded; detail = "old:S-E new:S-E" */ + DIAGRAM_EXPANDED, + /** Stroke consumed by gesture handler (strikethrough, etc.) */ + GESTURE_CONSUMED + } + + data class RawStrokeEntry( + val index: Int, + val points: List, + val timestampMs: Long + ) + + data class ProcessingEvent( + val strokeIndex: Int, + val type: EventType, + val detail: String, + val timestampMs: Long + ) + + data class Snapshot( + val strokes: List, + val events: List + ) + + private val strokes = ArrayDeque() + private val events = ArrayDeque() + private var nextIndex = 0 + /** Minimum valid stroke index — events below this are stale and filtered on snapshot. */ + private var minValidIndex = 0 + + /** + * Record a raw stroke before any processing. + * The stroke is downsampled to reduce memory usage. + * @return the stroke index for use in [recordEvent] + */ + fun recordStroke(points: List): Int { + val index = nextIndex++ + + // Downsample to reduce memory: collapse dwells + RDP simplification + val tempStroke = InkStroke(points = points) + val downsampled = StrokeDownsampler.downsample(tempStroke) + + val entry = RawStrokeEntry( + index = index, + points = downsampled.points, + timestampMs = System.currentTimeMillis() + ) + + strokes.addLast(entry) + + // Evict oldest if over capacity — O(1) per eviction + while (strokes.size > maxStrokes) { + val evicted = strokes.removeFirst() + minValidIndex = evicted.index + 1 + } + + return index + } + + /** + * Record a processing decision for a stroke. + * @param strokeIndex the index returned by [recordStroke], or -1 for events + * not tied to a specific stroke (e.g. recognition) + */ + fun recordEvent(strokeIndex: Int, type: EventType, detail: String = "") { + events.addLast(ProcessingEvent( + strokeIndex = strokeIndex, + type = type, + detail = detail, + timestampMs = System.currentTimeMillis() + )) + + // Cap events at 3x stroke capacity + while (events.size > maxStrokes * 3) { + events.removeFirst() + } + } + + /** Freeze the current ring buffer state into a serializable snapshot. */ + fun snapshot(): Snapshot { + // Lazily filter stale events (for evicted strokes) at snapshot time + // instead of O(n) removal on every eviction. + val validEvents = events.filter { + it.strokeIndex < 0 || it.strokeIndex >= minValidIndex + } + return Snapshot( + strokes = strokes.toList(), + events = validEvents + ) + } + + /** Number of strokes currently in the buffer. */ + val strokeCount: Int get() = strokes.size + + /** Number of events currently in the buffer. */ + val eventCount: Int get() = events.size + + /** Clear all strokes and events. */ + fun clear() { + strokes.clear() + events.clear() + nextIndex = 0 + minValidIndex = 0 + } +} diff --git a/app/src/main/java/com/writer/ui/writing/TutorialContent.kt b/app/src/main/java/com/writer/ui/writing/TutorialContent.kt index 8916889..6a3308c 100644 --- a/app/src/main/java/com/writer/ui/writing/TutorialContent.kt +++ b/app/src/main/java/com/writer/ui/writing/TutorialContent.kt @@ -30,17 +30,16 @@ data class TutorialData( val annotations: List, val textAnnotations: List, val scrollOffsetY: Float, - val textParagraphs: List>, + val textParagraphs: List>, val canvasContentHeight: Float = 0f, val diagramAreas: List = emptyList(), - val diagramDisplays: List = emptyList() + val diagramDisplays: List = emptyList() ) object TutorialContent { private val LINE_SPACING = HandwritingCanvasView.LINE_SPACING private val TOP_MARGIN = HandwritingCanvasView.TOP_MARGIN - private val GUTTER_WIDTH get() = HandwritingCanvasView.GUTTER_WIDTH private val textPaint = Paint().apply { typeface = Typeface.create("cursive", Typeface.NORMAL) @@ -48,7 +47,7 @@ object TutorialContent { } fun generate(canvasWidth: Int, canvasHeight: Int): TutorialData { - val writingWidth = canvasWidth - GUTTER_WIDTH + val writingWidth = canvasWidth.toFloat() val strokes = mutableListOf() val annotations = mutableListOf() @@ -103,15 +102,10 @@ object TutorialContent { TextAnnotation("Strike through to delete words", 700f, strikeY + 10f, red, 32f) ) - // --- Gutter scroll hint (top line, matching resize arrow style) --- + // --- Finger scroll hint --- val scrollHintY = lineTop(0) + LINE_SPACING * 0.4f - val scrollHintRight = writingWidth - 20f - val scrollHintLeft = writingWidth - 370f - annotations.add(makeLine(scrollHintLeft, scrollHintY, scrollHintRight, scrollHintY, blue, 4f)) - annotations.add(makeLine(scrollHintRight - 20f, scrollHintY - 12f, scrollHintRight, scrollHintY, blue, 4f)) - annotations.add(makeLine(scrollHintRight - 20f, scrollHintY + 12f, scrollHintRight, scrollHintY, blue, 4f)) textAnnotations.add( - TextAnnotation("Drag this gutter to scroll", scrollHintLeft.toFloat() - 10f, scrollHintY - 21f, blue, 34f) + TextAnnotation("Scroll with your finger", writingWidth - 200f, scrollHintY, blue, 34f) ) // --- Insert/delete line demo (right of eggs/bread area, +100px right) --- @@ -251,22 +245,22 @@ object TutorialContent { // --- Text paragraphs for the text view --- val textParagraphs = listOf( listOf( - WritingCoordinator.TextSegment("Shopping List", dimmed = false, lineIndex = 0, heading = true) + TextSegment("Shopping List", dimmed = false, lineIndex = 0, heading = true) ), listOf( - WritingCoordinator.TextSegment("Eggs", dimmed = false, lineIndex = 1, listItem = true) + TextSegment("Eggs", dimmed = false, lineIndex = 1, listItem = true) ), listOf( - WritingCoordinator.TextSegment("Bread", dimmed = false, lineIndex = 2, listItem = true) + TextSegment("Bread", dimmed = false, lineIndex = 2, listItem = true) ), listOf( - WritingCoordinator.TextSegment("The quick brown fox", dimmed = false, lineIndex = 3), - WritingCoordinator.TextSegment("jumps over the dog", dimmed = false, lineIndex = 4) + TextSegment("The quick brown fox", dimmed = false, lineIndex = 3), + TextSegment("jumps over the dog", dimmed = false, lineIndex = 4) ) ) // Diagram display for the text view (smiley rendered inline) - val diagramDisplay = WritingCoordinator.DiagramDisplay( + val diagramDisplay = DiagramDisplay( startLineIndex = 6, strokes = smileyStrokes, canvasWidth = writingWidth.toFloat(), diff --git a/app/src/main/java/com/writer/ui/writing/WritingActivity.kt b/app/src/main/java/com/writer/ui/writing/WritingActivity.kt index fb12b39..a3a4390 100644 --- a/app/src/main/java/com/writer/ui/writing/WritingActivity.kt +++ b/app/src/main/java/com/writer/ui/writing/WritingActivity.kt @@ -7,9 +7,10 @@ import android.util.Log import android.view.WindowInsets import android.view.WindowInsetsController import android.app.Activity -import android.widget.LinearLayout import android.view.Gravity import android.view.LayoutInflater +import android.view.View +import android.widget.LinearLayout import android.widget.PopupWindow import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts @@ -19,11 +20,14 @@ import androidx.core.view.WindowCompat import androidx.lifecycle.lifecycleScope import com.writer.R import com.writer.model.DocumentModel -import com.writer.recognition.HandwritingRecognizer +import com.writer.recognition.TextRecognizer +import com.writer.recognition.TextRecognizerFactory import com.writer.model.DocumentData import com.writer.storage.DocumentStorage import com.writer.view.HandwritingCanvasView import com.writer.view.RecognizedTextView +import com.writer.view.TouchFilter +import com.writer.view.ScreenMetrics import kotlinx.coroutines.launch class WritingActivity : AppCompatActivity() { @@ -39,7 +43,7 @@ class WritingActivity : AppCompatActivity() { private lateinit var recognizedTextView: RecognizedTextView private lateinit var documentModel: DocumentModel - private lateinit var recognizer: HandwritingRecognizer + private lateinit var recognizer: TextRecognizer private var coordinator: WritingCoordinator? = null private lateinit var tutorialManager: TutorialManager @@ -59,7 +63,8 @@ class WritingActivity : AppCompatActivity() { ActivityResultContracts.StartActivityForResult() ) { result -> if (result.resultCode == Activity.RESULT_OK) { - val name = result.data?.getStringExtra(SaveAsActivity.EXTRA_RESULT_NAME) + val rawName = result.data?.getStringExtra(SaveAsActivity.EXTRA_RESULT_NAME) + val name = rawName?.let { DocumentStorage.headingToFileName(it) } if (!name.isNullOrBlank() && name != currentDocumentName) { val oldName = currentDocumentName currentDocumentName = name @@ -106,9 +111,14 @@ class WritingActivity : AppCompatActivity() { inkCanvas = findViewById(R.id.inkCanvas) recognizedTextView = findViewById(R.id.recognizedTextView) + val splitLayout = findViewById(R.id.splitLayout) + val splitDivider = findViewById(R.id.splitDivider) + + val touchFilter = TouchFilter() + inkCanvas.touchFilter = touchFilter + recognizedTextView.touchFilter = touchFilter documentModel = DocumentModel() - recognizer = HandwritingRecognizer() tutorialManager = TutorialManager( context = this, @@ -117,7 +127,11 @@ class WritingActivity : AppCompatActivity() { getCoordinator = { coordinator }, getPendingRestore = { pendingRestore }, clearPendingRestore = { pendingRestore = null }, - onClosed = { recognizedTextView.onLogoTap = { showMenu() } } + onClosed = { + recognizedTextView.onLogoTap = { showMenu() } + recognizedTextView.onUndoTap = { coordinator?.undo() } + recognizedTextView.onRedoTap = { coordinator?.redo() } + } ) // Migrate old single-file storage if needed, then determine current document @@ -132,17 +146,58 @@ class WritingActivity : AppCompatActivity() { pendingRestore = DocumentStorage.load(this, currentDocumentName) restoreDocumentVisuals() + // Wire pen state from canvas to text view (for floating icon auto-hide) + inkCanvas.onPenStateChanged = { active -> + recognizedTextView.onPenStateChanged(active) + } + + // Text view scroll drives canvas scroll (complementary views) + recognizedTextView.onScroll = { dy -> + // dy > 0 = finger dragged down = see earlier content = scroll canvas up + val raw = inkCanvas.scrollOffsetY - dy + inkCanvas.scrollOffsetY = raw.coerceAtLeast(0f) + inkCanvas.drawToSurface() + inkCanvas.onManualScroll?.invoke() + } + recognizedTextView.onScrollEnd = { + inkCanvas.scrollOffsetY = inkCanvas.snapToLine(inkCanvas.scrollOffsetY) + inkCanvas.drawToSurface() + inkCanvas.onManualScroll?.invoke() + } + // Tap "I" logo to open menu recognizedTextView.onLogoTap = { showMenu() } + recognizedTextView.onUndoTap = { coordinator?.undo() } + recognizedTextView.onRedoTap = { coordinator?.redo() } + + // Pick the best available recognizer synchronously (initialized later in coroutine). + // OnyxHwrTextRecognizer binds to a system service using applicationContext, so holding + // it in the activity is safe (no activity leak). GoogleMLKitTextRecognizer is stateless + // and does not retain a context reference. + recognizer = TextRecognizerFactory.create(this) // Create coordinator early so cached text can be displayed before model loads startCoordinator() - // Capture default heights after initial layout, then wire up the gutter + // Capture default heights after initial layout, then wire up the divider drag. + // Override the XML weight-based split with an adaptive calculation so that + // all supported screen sizes get a proportional canvas/text split. recognizedTextView.post { - defaultTextHeight = recognizedTextView.height - defaultCanvasHeight = inkCanvas.height - setupTextGutter() + val totalHeight = recognizedTextView.height + inkCanvas.height + defaultCanvasHeight = ScreenMetrics.computeDefaultCanvasHeight(totalHeight) + defaultTextHeight = totalHeight - defaultCanvasHeight + + val textParams = recognizedTextView.layoutParams as LinearLayout.LayoutParams + textParams.height = defaultTextHeight + textParams.weight = 0f + recognizedTextView.layoutParams = textParams + + val canvasParams = inkCanvas.layoutParams as LinearLayout.LayoutParams + canvasParams.height = defaultCanvasHeight + canvasParams.weight = 0f + inkCanvas.layoutParams = canvasParams + + setupSplitDrag(splitLayout, splitDivider) // Restore cached text and scroll position immediately (no recognizer needed) restoreCoordinatorState() @@ -197,40 +252,28 @@ class WritingActivity : AppCompatActivity() { coordinator?.restoreState(data) } - private fun setupTextGutter() { - recognizedTextView.onGutterDrag = { delta -> + private fun setupSplitDrag(splitLayout: com.writer.view.SplitLayout, divider: View) { + splitLayout.dividerView = divider + splitLayout.onSplitDragStart = { inkCanvas.pauseRawDrawing() } + splitLayout.onSplitDragEnd = { inkCanvas.resumeRawDrawing() } + splitLayout.onSplitDrag = { delta -> val totalHeight = defaultTextHeight + defaultCanvasHeight val minTextHeight = (totalHeight * 0.25f).toInt() val maxOffset = (totalHeight - minTextHeight).toFloat() - if (delta > 0f && splitOffset >= maxOffset) { - // At max size, dragging down scrolls text content - val topPadding = 40f - val maxOverscroll = (recognizedTextView.totalTextHeight - recognizedTextView.height + topPadding).coerceAtLeast(0f) - inkCanvas.textOverscroll = (inkCanvas.textOverscroll + delta).coerceIn(0f, maxOverscroll) - coordinator?.onManualTextScroll() - } else if (delta < 0f && inkCanvas.textOverscroll > 0f) { - // Dragging back up — reduce overscroll first - inkCanvas.textOverscroll = (inkCanvas.textOverscroll + delta).coerceAtLeast(0f) - coordinator?.onManualTextScroll() - } else { - val totalHeight = defaultTextHeight + defaultCanvasHeight - val minTextHeight = (totalHeight * 0.25f).toInt() - val maxOffset = (totalHeight - minTextHeight).toFloat() - splitOffset = (splitOffset + delta).coerceIn(0f, maxOffset) - - val newTextHeight = defaultTextHeight + splitOffset.toInt() - val newCanvasHeight = defaultCanvasHeight - splitOffset.toInt() - - val textParams = recognizedTextView.layoutParams as LinearLayout.LayoutParams - textParams.height = newTextHeight - textParams.weight = 0f - recognizedTextView.layoutParams = textParams - - val canvasParams = inkCanvas.layoutParams as LinearLayout.LayoutParams - canvasParams.height = newCanvasHeight.coerceAtLeast(0) - canvasParams.weight = 0f - inkCanvas.layoutParams = canvasParams - } + splitOffset = (splitOffset + delta).coerceIn(0f, maxOffset) + + val newTextHeight = defaultTextHeight + splitOffset.toInt() + val newCanvasHeight = defaultCanvasHeight - splitOffset.toInt() + + val textParams = recognizedTextView.layoutParams as LinearLayout.LayoutParams + textParams.height = newTextHeight + textParams.weight = 0f + recognizedTextView.layoutParams = textParams + + val canvasParams = inkCanvas.layoutParams as LinearLayout.LayoutParams + canvasParams.height = newCanvasHeight.coerceAtLeast(0) + canvasParams.weight = 0f + inkCanvas.layoutParams = canvasParams } } @@ -249,6 +292,7 @@ class WritingActivity : AppCompatActivity() { ) coordinator?.onHeadingDetected = { heading -> autoRenameFromHeading(heading) } coordinator?.start() + } private fun autoRenameFromHeading(heading: String) { @@ -454,6 +498,10 @@ class WritingActivity : AppCompatActivity() { popup.dismiss() tutorialManager.show() } + popupView.findViewById(R.id.menuBugReport).setOnClickListener { + popup.dismiss() + generateAndShareBugReport() + } popupView.findViewById(R.id.menuClose).setOnClickListener { popup.dismiss() saveDocument() @@ -465,11 +513,10 @@ class WritingActivity : AppCompatActivity() { Toast.makeText(this, "Tutorial reset — will show on next launch", Toast.LENGTH_SHORT).show() } - // Position to the left of the gutter, at the top of the text view - val gutterWidth = HandwritingCanvasView.GUTTER_WIDTH.toInt() + // Position to the left of the floating icon, at the top of the text view val loc = IntArray(2) recognizedTextView.getLocationOnScreen(loc) - val x = loc[0] + recognizedTextView.width - gutterWidth - popupWidth + val x = loc[0] + recognizedTextView.width - popupWidth val y = loc[1] popup.showAtLocation(recognizedTextView, Gravity.NO_GRAVITY, x, y) } @@ -504,6 +551,34 @@ class WritingActivity : AppCompatActivity() { } } + // --- Bug report --- + + private fun generateAndShareBugReport() { + val file = coordinator?.generateBugReport() + if (file == null) { + Toast.makeText(this, "No strokes to report", Toast.LENGTH_SHORT).show() + return + } + + Toast.makeText(this, "Bug report saved", Toast.LENGTH_SHORT).show() + + try { + val uri = androidx.core.content.FileProvider.getUriForFile( + this, "$packageName.fileprovider", file + ) + val intent = Intent(Intent.ACTION_SEND).apply { + type = "application/json" + putExtra(Intent.EXTRA_STREAM, uri) + putExtra(Intent.EXTRA_SUBJECT, "InkUp Bug Report") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + startActivity(Intent.createChooser(intent, "Share Bug Report")) + } catch (e: Exception) { + Log.e(TAG, "Failed to share bug report: ${e.message}") + Toast.makeText(this, "Report saved to ${file.absolutePath}", Toast.LENGTH_LONG).show() + } + } + override fun onDestroy() { super.onDestroy() coordinator?.stop() diff --git a/app/src/main/java/com/writer/ui/writing/WritingCoordinator.kt b/app/src/main/java/com/writer/ui/writing/WritingCoordinator.kt index 9c94daa..8ad4577 100644 --- a/app/src/main/java/com/writer/ui/writing/WritingCoordinator.kt +++ b/app/src/main/java/com/writer/ui/writing/WritingCoordinator.kt @@ -1,11 +1,18 @@ package com.writer.ui.writing import android.util.Log -import com.writer.model.DiagramArea +import com.writer.model.DiagramNode import com.writer.model.DocumentModel import com.writer.model.InkStroke -import com.writer.model.shiftY -import com.writer.recognition.HandwritingRecognizer +import com.writer.model.StrokePoint +import com.writer.model.StrokeType +import com.writer.model.minX +import com.writer.model.minY +import com.writer.model.maxX +import com.writer.model.maxY +import com.writer.view.ScratchOutDetection +import com.writer.view.ScreenMetrics +import com.writer.recognition.TextRecognizer import com.writer.recognition.LineSegmenter import com.writer.recognition.StrokeClassifier import com.writer.model.DocumentData @@ -13,28 +20,18 @@ import com.writer.storage.SvgExporter import com.writer.view.HandwritingCanvasView import com.writer.view.RecognizedTextView import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext class WritingCoordinator( private val documentModel: DocumentModel, - private val recognizer: HandwritingRecognizer, + private val recognizer: TextRecognizer, private val inkCanvas: HandwritingCanvasView, private val textView: RecognizedTextView, private val scope: CoroutineScope, private val onStatusUpdate: (String) -> Unit -) { +) : DiagramManagerHost { companion object { private const val TAG = "WritingCoordinator" - // Scroll when writing passes this fraction of canvas height from top - // 25% of canvas ≈ 50% of full screen (since canvas is 75% of screen) - private const val SCROLL_THRESHOLD = 0.25f - private val GUTTER_WIDTH get() = HandwritingCanvasView.GUTTER_WIDTH - // Delay before refreshing e-ink display after text view updates - private const val TEXT_REFRESH_DELAY_MS = 500L } private val lineSegmenter = LineSegmenter() @@ -50,7 +47,7 @@ class WritingCoordinator( for (line in invalidatedLines) { lineTextCache.remove(line) } - displayHiddenLines() + displayManager.displayHiddenLines() }, onBeforeMutation = { saveUndoSnapshot() } ) @@ -59,93 +56,120 @@ class WritingCoordinator( // All shared mutable state below is accessed only from Dispatchers.Main // (scope is lifecycleScope; only recognizer.recognizeLine runs on IO). private val lineTextCache = mutableMapOf() - // Track which lines are currently being recognized (avoid duplicates) - private val recognizingLines = mutableSetOf() - // Lines that need re-recognition after current recognition finishes - private val pendingRerecognize = mutableSetOf() // Track the highest (bottommost) line the user has written on private var highestLineIndex = -1 - // Lines that have ever scrolled above the viewport (text stays rendered once shown) - private val everHiddenLines = mutableSetOf() - // Auto-scroll animation - private var scrollAnimating = false // Track which line the user is currently writing on private var currentLineIndex = -1 // Whether the user has manually renamed this document var userRenamed = false // Callback to notify activity when heading-based rename should happen var onHeadingDetected: ((String) -> Unit)? = null - // Deferred e-ink refresh for text view updates (avoids interrupting active writing) - private var textRefreshJob: Job? = null - - // Line-drag gesture state - private var lineDragAnchorLine = -1 - private var lineDragOriginalStrokes: List? = null - private var lineDragOriginalDiagrams: List? = null - private var lineDragCurrentShift = 0 - - // Diagram insert gesture state - private var diagramInsertAnchorLine = -1 - private var diagramInsertOriginalStrokes: List? = null - private var diagramInsertOriginalDiagrams: List? = null - private var diagramInsertCurrentHeight = 0 + // Diagram lifecycle manager (created in start()) + private lateinit var diagramManager: DiagramManager + // Display manager (created in start()) + private lateinit var displayManager: DisplayManager + // Line recognition manager (created in start()) + private lateinit var recognitionManager: LineRecognitionManager + + /** Always-on ring buffer for bug report capture. */ + val eventLog = StrokeEventLog() + /** Index of the most recent raw stroke in the event log. + * Safe as a single field because stylus input is single-touch on the main thread. */ + private var lastStrokeIndex = -1 + + override fun onDiagramAreasChanged() = displayManager.displayHiddenLines() + override fun getLineTextCache() = lineTextCache fun start() { Log.i(TAG, "Coordinator started") + + val canvasAdapter = object : DiagramCanvas { + override var diagramAreas + get() = inkCanvas.diagramAreas + set(v) { inkCanvas.diagramAreas = v } + override var scrollOffsetY + get() = inkCanvas.scrollOffsetY + set(v) { inkCanvas.scrollOffsetY = v } + override fun loadStrokes(strokes: List) = inkCanvas.loadStrokes(strokes) + override fun pauseAndRedraw() { + inkCanvas.pauseRawDrawing() + inkCanvas.drawToSurface() + inkCanvas.resumeRawDrawing() + } + } + diagramManager = DiagramManager(documentModel, lineSegmenter, recognizer, canvasAdapter, this, scope) + + val recognitionHost = object : RecognitionManagerHost { + override val lineTextCache: MutableMap get() = this@WritingCoordinator.lineTextCache + override val userRenamed: Boolean get() = this@WritingCoordinator.userRenamed + override val everHiddenLines: Set get() = displayManager.getEverHiddenLinesSnapshot() + override fun onHeadingDetected(heading: String) { this@WritingCoordinator.onHeadingDetected?.invoke(heading) } + override fun isDiagramLine(lineIndex: Int): Boolean = diagramManager.isDiagramLine(lineIndex) + override fun onRecognitionComplete(lineIndex: Int) { + displayManager.displayHiddenLines() + displayManager.scheduleTextRefresh() + } + } + recognitionManager = LineRecognitionManager( + documentModel, recognizer, lineSegmenter, strokeClassifier, scope, + recognitionHost, canvasWidthProvider = { inkCanvas.width.toFloat() } + ) + + val displayHost = object : DisplayManagerHost { + override val documentModel: DocumentModel get() = this@WritingCoordinator.documentModel + override val diagramManager: DiagramManager get() = this@WritingCoordinator.diagramManager + override val lineTextCache: Map get() = this@WritingCoordinator.lineTextCache + override val highestLineIndex: Int get() = this@WritingCoordinator.highestLineIndex + override fun eagerRecognizeLine(lineIndex: Int) = recognitionManager.eagerRecognizeLine(lineIndex) + override fun markRecognizing(lineIndex: Int) { recognitionManager.markRecognizing(lineIndex) } + override suspend fun doRecognizeLine(lineIndex: Int): String? = recognitionManager.doRecognizeLine(lineIndex) + override fun isRecognizing(lineIndex: Int): Boolean = recognitionManager.isRecognizing(lineIndex) + } + displayManager = DisplayManager(inkCanvas, textView, scope, lineSegmenter, paragraphBuilder, displayHost) + + inkCanvas.documentModel = documentModel + inkCanvas.onRawStrokeCapture = { points -> + lastStrokeIndex = eventLog.recordStroke(points) + } inkCanvas.onStrokeCompleted = { stroke -> onStrokeCompleted(stroke) } - inkCanvas.onIdleTimeout = { onIdle() } + inkCanvas.onIdleTimeout = { displayManager.onIdle(currentLineIndex) } inkCanvas.onManualScroll = { // Clamp text overscroll so text doesn't scroll completely out of view val maxOverscroll = (textView.totalTextHeight - textView.height).coerceAtLeast(0).toFloat() if (inkCanvas.textOverscroll > maxOverscroll) { inkCanvas.textOverscroll = maxOverscroll } - displayHiddenLines() - } - textView.onTextTap = { lineIndex -> scrollToLine(lineIndex) } - inkCanvas.onLineDragStart = { anchorLine -> onLineDragStart(anchorLine) } - inkCanvas.onLineDragStep = { shiftLines -> onLineDragStep(shiftLines) } - inkCanvas.onLineDragEnd = { onLineDragEnd() } - inkCanvas.onDiagramInsertStart = { anchorLine -> onDiagramInsertStart(anchorLine) } - inkCanvas.onDiagramInsertStep = { height -> onDiagramInsertStep(height) } - inkCanvas.onDiagramInsertEnd = { onDiagramInsertEnd() } - inkCanvas.onUndoGestureStart = { - undoManager.beginScrub(currentSnapshot()) + displayManager.displayHiddenLines() } - inkCanvas.onUndoGestureStep = step@{ offset -> - val snapshot = undoManager.scrubTo(offset) ?: return@step - applySnapshot(snapshot) + textView.onTextTap = { lineIndex -> displayManager.scrollToLine(lineIndex) } + inkCanvas.onDiagramShapeDetected = { stroke -> diagramManager.onShapeDetected(stroke) } + inkCanvas.onDiagramStrokeOverflow = { strokeId, minY, maxY -> diagramManager.onStrokeOverflow(strokeId, minY, maxY) } + inkCanvas.onScratchOut = { scratchPoints, left, top, right, bottom -> + onScratchOut(scratchPoints, left, top, right, bottom) } - inkCanvas.onUndoGestureEnd = { - undoManager.endScrub() + inkCanvas.onStrokeReplaced = { oldStrokeId, newStroke -> + onStrokeReplaced(oldStrokeId, newStroke) } } fun stop() { - scrollAnimating = false - textRefreshJob?.cancel() + displayManager.stop() + diagramManager.stop() inkCanvas.onStrokeCompleted = null inkCanvas.onIdleTimeout = null inkCanvas.onManualScroll = null textView.onTextTap = null - inkCanvas.onLineDragStart = null - inkCanvas.onLineDragStep = null - inkCanvas.onLineDragEnd = null - inkCanvas.onDiagramInsertStart = null - inkCanvas.onDiagramInsertStep = null - inkCanvas.onDiagramInsertEnd = null - inkCanvas.onUndoGestureStart = null - inkCanvas.onUndoGestureStep = null - inkCanvas.onUndoGestureEnd = null + inkCanvas.onDiagramShapeDetected = null + inkCanvas.onDiagramStrokeOverflow = null + inkCanvas.onScratchOut = null + inkCanvas.onStrokeReplaced = null } fun reset() { - scrollAnimating = false - textRefreshJob?.cancel() + displayManager.reset() + recognitionManager.reset() + diagramManager.reset() lineTextCache.clear() - recognizingLines.clear() - pendingRerecognize.clear() - everHiddenLines.clear() highestLineIndex = -1 currentLineIndex = -1 userRenamed = false @@ -153,21 +177,53 @@ class WritingCoordinator( inkCanvas.diagramAreas = emptyList() } - /** Check if a line index falls inside any diagram area. */ - private fun isDiagramLine(lineIdx: Int): Boolean = - documentModel.diagramAreas.any { it.containsLine(lineIdx) } - private fun onStrokeCompleted(stroke: InkStroke) { - textRefreshJob?.cancel() - if (gestureHandler.tryHandle(stroke)) return + displayManager.textRefreshJob?.cancel() + if (gestureHandler.tryHandle(stroke)) { + eventLog.recordEvent(lastStrokeIndex, StrokeEventLog.EventType.GESTURE_CONSUMED) + return + } saveUndoSnapshot() documentModel.activeStrokes.add(stroke) + eventLog.recordEvent(lastStrokeIndex, StrokeEventLog.EventType.ADDED, "line=${lineSegmenter.getStrokeLineIndex(stroke)}") val lineIdx = lineSegmenter.getStrokeLineIndex(stroke) - // Diagram strokes: no recognition, no line tracking - if (isDiagramLine(lineIdx)) return + // Diagram strokes: recognize freehand by spatial groups, not by line + if (diagramManager.isDiagramLine(lineIdx)) { + if (stroke.strokeType == StrokeType.FREEHAND) { + val area = documentModel.diagramAreas.find { it.containsLine(lineIdx) } + if (area != null) diagramManager.recognizeDiagramArea(area) + } + return + } + + // Sticky zone: if a geometric (shape-snapped) stroke lands adjacent to a diagram + // area, expand it. Freehand strokes do NOT trigger sticky expansion — they're + // likely text and should stay outside the diagram. + val adjacentArea = documentModel.diagramAreas.find { + lineIdx == it.startLineIndex - 1 || lineIdx == it.endLineIndex + 1 + } + if (adjacentArea != null && stroke.isGeometric && !diagramManager.hasTextStrokesOnLine(lineIdx, excluding = stroke)) { + val newStart = minOf(adjacentArea.startLineIndex, lineIdx) + val newEnd = maxOf(adjacentArea.endLineIndex, lineIdx) + documentModel.diagramAreas.remove(adjacentArea) + val expanded = adjacentArea.copy( + startLineIndex = newStart, + heightInLines = newEnd - newStart + 1 + ) + documentModel.diagramAreas.add(expanded) + inkCanvas.diagramAreas = documentModel.diagramAreas.toList() + for (line in newStart..newEnd) { + lineTextCache.remove(line) + } + Log.i(TAG, "Sticky zone: expanded diagram ${adjacentArea.startLineIndex}-${adjacentArea.endLineIndex} → $newStart-$newEnd") + if (stroke.strokeType == StrokeType.FREEHAND) { + diagramManager.recognizeDiagramArea(expanded) + } + return + } // If the user modified a previously recognized line, invalidate cache lineTextCache.remove(lineIdx) @@ -175,17 +231,17 @@ class WritingCoordinator( // Only trigger recognition when the user moves to a DIFFERENT line if (lineIdx != currentLineIndex) { if (currentLineIndex >= 0) { - eagerRecognizeLine(currentLineIndex) + recognitionManager.eagerRecognizeLine(currentLineIndex) } currentLineIndex = lineIdx } // If editing a line that has rendered text, re-recognize immediately - if (lineIdx in everHiddenLines) { + if (displayManager.isEverHidden(lineIdx)) { Log.i(TAG, "Stroke on rendered line $lineIdx, triggering re-recognition") - recognizeRenderedLine(lineIdx) + recognitionManager.recognizeRenderedLine(lineIdx) } else { - Log.d(TAG, "Stroke on line $lineIdx (not in everHiddenLines=$everHiddenLines)") + Log.d(TAG, "Stroke on line $lineIdx (not in everHiddenLines=${displayManager.getEverHiddenLinesSnapshot()})") } if (lineIdx > highestLineIndex) { @@ -193,371 +249,99 @@ class WritingCoordinator( } } - // --- Recognition --- - - /** Recognize all lines that have strokes but no cached text or failed recognition. */ - fun recognizeAllLines() { - val strokesByLine = lineSegmenter.groupByLine(documentModel.activeStrokes) - if (strokesByLine.isEmpty()) return - scope.launch { - for (lineIndex in strokesByLine.keys.sorted()) { - if (isDiagramLine(lineIndex)) continue - // Re-recognize lines that failed ("[?]") or were never cached - val cached = lineTextCache[lineIndex] - if (cached != null && cached != "[?]") continue - if (recognizingLines.contains(lineIndex)) continue - recognizingLines.add(lineIndex) - lineTextCache.remove(lineIndex) - val text = doRecognizeLine(lineIndex) - if (text != null) { - Log.d(TAG, "Post-load recognized line $lineIndex: \"$text\"") - displayHiddenLines() - } - } - } - } - - private fun eagerRecognizeLine(lineIndex: Int) { - if (lineTextCache.containsKey(lineIndex)) return - if (recognizingLines.contains(lineIndex)) return - recognizingLines.add(lineIndex) + private fun onStrokeReplaced(oldStrokeId: String, newStroke: InkStroke) { + saveUndoSnapshot() // captures state with raw stroke (state N+1) + documentModel.activeStrokes.removeAll { it.strokeId == oldStrokeId } + documentModel.activeStrokes.add(newStroke) + eventLog.recordEvent(lastStrokeIndex, StrokeEventLog.EventType.REPLACED, newStroke.strokeType.name) - scope.launch { - val text = doRecognizeLine(lineIndex) ?: return@launch - Log.d(TAG, "Eager recognized line $lineIndex: \"$text\"") - if (lineIndex in everHiddenLines) { - displayHiddenLines() - } - } - } - - private fun recognizeRenderedLine(lineIndex: Int) { - if (recognizingLines.contains(lineIndex)) { - pendingRerecognize.add(lineIndex) - return - } - recognizingLines.add(lineIndex) - lineTextCache.remove(lineIndex) - - scope.launch { - val text = doRecognizeLine(lineIndex) ?: return@launch - Log.d(TAG, "Rendered line recognized $lineIndex: \"$text\"") - displayHiddenLines() - scheduleTextRefresh() - if (lineIndex in pendingRerecognize) { - pendingRerecognize.remove(lineIndex) - recognizeRenderedLine(lineIndex) - } - } - } - - /** - * Core recognition: get strokes for [lineIndex], filter markers, recognize, - * and cache the result. Returns the recognized text, or null if there were - * no strokes or recognition failed. - */ - private suspend fun doRecognizeLine(lineIndex: Int): String? { - if (isDiagramLine(lineIndex)) { - recognizingLines.remove(lineIndex) - return null - } - try { - val allStrokes = lineSegmenter.getStrokesForLine( - documentModel.activeStrokes, lineIndex + // Track shape nodes for magnetic arrow snapping + if (!newStroke.strokeType.isConnector && newStroke.strokeType != StrokeType.FREEHAND) { + val bounds = android.graphics.RectF( + newStroke.minX, newStroke.minY, newStroke.maxX, newStroke.maxY + ) + documentModel.diagram.nodes[newStroke.strokeId] = DiagramNode( + strokeId = newStroke.strokeId, + shapeType = newStroke.strokeType, + bounds = bounds ) - if (allStrokes.isEmpty()) { - recognizingLines.remove(lineIndex) - return null - } - val strokes = strokeClassifier.filterMarkerStrokes(allStrokes, inkCanvas.width - GUTTER_WIDTH) - if (strokes.isEmpty()) { - recognizingLines.remove(lineIndex) - return null - } - - val line = lineSegmenter.buildInkLine(strokes, lineIndex) - val preContext = buildPreContext(lineIndex) - val text = withContext(Dispatchers.IO) { - recognizer.recognizeLine(line, preContext) - }.trim() - - lineTextCache[lineIndex] = text - recognizingLines.remove(lineIndex) - checkHeadingRename(lineIndex, text, allStrokes) - return text - } catch (e: Exception) { - Log.e(TAG, "Recognition failed for line $lineIndex", e) - lineTextCache[lineIndex] = "[?]" - recognizingLines.remove(lineIndex) - pendingRerecognize.remove(lineIndex) - return null } - } - - private fun checkHeadingRename(lineIndex: Int, text: String, strokes: List) { - if (userRenamed) return - if (lineIndex != 0) return - if (text.isEmpty() || text == "[?]") return - val isHeading = strokeClassifier.findUnderlineStrokeId(strokes, lineIndex) != null - if (!isHeading) return - onHeadingDetected?.invoke(text) - } - private fun buildPreContext(lineIndex: Int): String { - val previousLines = lineTextCache.keys.filter { it < lineIndex }.sorted() - if (previousLines.isEmpty()) return "" - val lastText = previousLines.map { lineTextCache[it] ?: "" } - .filter { it.isNotEmpty() && it != "[?]" } - .joinToString(" ") - return lastText.takeLast(20) + Log.i(TAG, "Stroke replaced: $oldStrokeId → ${newStroke.strokeId} (${newStroke.strokeType})") } - // --- Auto-scroll --- - - private fun onIdle() { - if (currentLineIndex >= 0) { - eagerRecognizeLine(currentLineIndex) + private fun onScratchOut(scratchPoints: List, left: Float, top: Float, right: Float, bottom: Float) { + // Erase strokes that the scratch-out physically intersects. + // The caller (checkPostStrokeScratchOut) already verified this is a focused + // scratch-out via isFocusedScratchOut — most of its path covers existing strokes. + val radius = ScreenMetrics.dp(ScratchOutDetection.COVERAGE_RADIUS_DP) + val radiusSq = radius * radius + val overlapping = documentModel.activeStrokes.filter { stroke -> + if (stroke.points.size < 5) { + // Small strokes (dots, taps): segment intersection is unreliable, + // use proximity check instead — any scratch point within radius counts. + stroke.points.any { tp -> + scratchPoints.any { sp -> + val dx = sp.x - tp.x; val dy = sp.y - tp.y + dx * dx + dy * dy <= radiusSq + } + } + } else { + ScratchOutDetection.strokesIntersect(scratchPoints, stroke.points) + } || stroke.strokeType.isConnector + && ScratchOutDetection.strokeIntersectsRect(stroke.points, left, top, right, bottom) } - checkAutoScroll() - } - - private fun checkAutoScroll() { - if (scrollAnimating) return - - val strokes = documentModel.activeStrokes - if (strokes.isEmpty()) return + if (overlapping.isEmpty()) return - val canvasHeight = inkCanvas.height.toFloat() - if (canvasHeight <= 0) return - - val bottomLine = lineSegmenter.getBottomOccupiedLine(strokes) - if (bottomLine < 0) return + saveUndoSnapshot() - if (currentLineIndex != bottomLine) return + val idsToRemove = overlapping.map { it.strokeId }.toSet() + documentModel.activeStrokes.removeAll { it.strokeId in idsToRemove } + for (id in idsToRemove) { documentModel.diagram.nodes.remove(id) } - val bottomLineDocY = lineSegmenter.getLineY(bottomLine) + HandwritingCanvasView.LINE_SPACING - val bottomLineScreenY = bottomLineDocY - inkCanvas.scrollOffsetY - val targetY = canvasHeight * SCROLL_THRESHOLD + inkCanvas.removeStrokes(idsToRemove) + inkCanvas.drawToSurface() - if (bottomLineScreenY < 0 || bottomLineScreenY > canvasHeight) return - if (bottomLineScreenY < targetY) return + // Delegate diagram cache invalidation and shrinking to DiagramManager + diagramManager.onStrokesErased(idsToRemove, overlapping) - val rawOffset = bottomLineDocY - targetY - val newOffset = inkCanvas.snapToLine(rawOffset) - if (newOffset > inkCanvas.scrollOffsetY) { - animateScroll(inkCanvas.scrollOffsetY, newOffset) - } + eventLog.recordEvent(lastStrokeIndex, StrokeEventLog.EventType.SCRATCH_OUT, + "erased=${idsToRemove.joinToString(",")}") + Log.i(TAG, "Scratch-out erase: removed ${overlapping.size} strokes in [$left,$top,$right,$bottom]") } - private fun scrollToLine(lineIndex: Int) { - if (scrollAnimating) return - - val canvasHeight = inkCanvas.height.toFloat() - if (canvasHeight <= 0) return - - val lineY = lineSegmenter.getLineY(lineIndex) - val targetOffset = inkCanvas.snapToLine(lineY).coerceAtLeast(0f) + // --- Recognition --- - if (targetOffset != inkCanvas.scrollOffsetY) { - Log.i(TAG, "Tap-to-scroll: line $lineIndex → offset ${targetOffset.toInt()}") - animateScroll(inkCanvas.scrollOffsetY, targetOffset, 500L) + /** Recognize all lines that have strokes but no cached text or failed recognition. */ + fun recognizeAllLines() { + val strokesByLine = lineSegmenter.groupByLine(documentModel.activeStrokes) + if (strokesByLine.isEmpty()) return + // Recognize diagram areas immediately on load (no debounce) + for (area in documentModel.diagramAreas) { + diagramManager.recognizeDiagramArea(area, immediate = true) } - } - - private fun animateScroll(fromOffset: Float, toOffset: Float, duration: Long = 1000L) { - scrollAnimating = true - Log.i(TAG, "AutoScroll: animating from ${fromOffset.toInt()} to ${toOffset.toInt()}") - inkCanvas.pauseRawDrawing() - - val distance = toOffset - fromOffset - scope.launch { - val startTime = System.currentTimeMillis() - while (scrollAnimating) { - val elapsed = System.currentTimeMillis() - startTime - if (elapsed >= duration) break - - val t = elapsed.toFloat() / duration - val interpolated = 1f - (1f - t) * (1f - t) - inkCanvas.scrollOffsetY = fromOffset + distance * interpolated - inkCanvas.drawToSurface() - displayHiddenLines() - - kotlinx.coroutines.delay(33) // ~30fps - } - - inkCanvas.scrollOffsetY = toOffset - inkCanvas.drawToSurface() - inkCanvas.resumeRawDrawing() - scrollAnimating = false - displayHiddenLines() - } - } - - /** Schedule a deferred e-ink refresh so text view updates become visible. - * Cancelled if a new stroke arrives, so we never interrupt active writing. */ - private fun scheduleTextRefresh() { - textRefreshJob?.cancel() - textRefreshJob = scope.launch { - delay(TEXT_REFRESH_DELAY_MS) - inkCanvas.pauseRawDrawing() - inkCanvas.drawToSurface() - inkCanvas.resumeRawDrawing() - } - } - - // --- Text display sync --- - - private fun displayHiddenLines() { - val strokesByLine = lineSegmenter.groupByLine(documentModel.activeStrokes) - - val currentlyHidden = strokesByLine.keys.filter { lineIdx -> - val lineBottom = lineSegmenter.getLineY(lineIdx) + HandwritingCanvasView.LINE_SPACING - lineBottom <= inkCanvas.scrollOffsetY - }.toSet() - - val notYetVisible = strokesByLine.keys.filter { lineIdx -> - val lineMid = lineSegmenter.getLineY(lineIdx) + HandwritingCanvasView.LINE_SPACING / 2f - lineMid <= inkCanvas.scrollOffsetY - }.toSet() - - everHiddenLines.addAll(currentlyHidden) - everHiddenLines.retainAll(strokesByLine.keys) - - updateTextView(notYetVisible) - updateTextScrollOffset() - - val uncached = everHiddenLines.filter { !lineTextCache.containsKey(it) && !isDiagramLine(it) } - if (uncached.isNotEmpty()) { - scope.launch { - for (lineIdx in uncached) { - if (lineTextCache.containsKey(lineIdx)) continue - if (isDiagramLine(lineIdx)) continue - try { - val allStrokes = strokesByLine[lineIdx] ?: continue - val strokes = strokeClassifier.filterMarkerStrokes(allStrokes, inkCanvas.width - GUTTER_WIDTH) - if (strokes.isEmpty()) continue - val line = lineSegmenter.buildInkLine(strokes, lineIdx) - val preContext = buildPreContext(lineIdx) - val text = withContext(Dispatchers.IO) { - recognizer.recognizeLine(line, preContext) - } - lineTextCache[lineIdx] = text.trim() - Log.d(TAG, "On-scroll recognized line $lineIdx: \"${text.trim()}\"") - } catch (e: Exception) { - Log.e(TAG, "Recognition failed for line $lineIdx", e) - lineTextCache[lineIdx] = "[?]" - } + for (lineIndex in strokesByLine.keys.sorted()) { + // Re-recognize lines that failed ("[?]") or were never cached + val cached = lineTextCache[lineIndex] + if (cached != null && cached != "[?]") continue + if (recognitionManager.isRecognizing(lineIndex)) continue + recognitionManager.markRecognizing(lineIndex) + lineTextCache.remove(lineIndex) + val text = recognitionManager.doRecognizeLine(lineIndex) + if (text != null) { + Log.d(TAG, "Post-load recognized line $lineIndex: \"$text\"") + displayManager.displayHiddenLines() } - val stillNotVisible = strokesByLine.keys.filter { lineIdx -> - val lineMid = lineSegmenter.getLineY(lineIdx) + HandwritingCanvasView.LINE_SPACING / 2f - lineMid <= inkCanvas.scrollOffsetY - }.toSet() - updateTextView(stillNotVisible) } } } - /** A segment of text within a paragraph, with its own dimming state. */ - data class TextSegment(val text: String, val dimmed: Boolean, val lineIndex: Int, val listItem: Boolean = false, val heading: Boolean = false) - - /** Diagram display data for inline rendering in the text view. */ - data class DiagramDisplay( - val startLineIndex: Int, - val strokes: List, - val canvasWidth: Float, - val heightPx: Float, - val offsetY: Float - ) - - private fun updateTextView(currentlyHidden: Set) { - val strokesByLine = lineSegmenter.groupByLine(documentModel.activeStrokes) - val writingWidth = inkCanvas.width - GUTTER_WIDTH - - val classifiedLines = everHiddenLines.sorted().filter { !isDiagramLine(it) }.mapNotNull { lineIdx -> - paragraphBuilder.classifyLine(lineIdx, lineTextCache[lineIdx], strokesByLine[lineIdx], writingWidth) - } - - val grouped = paragraphBuilder.groupIntoParagraphs(classifiedLines, strokesByLine, writingWidth, documentModel.diagramAreas) - - val paragraphs = grouped.map { group -> - group.map { line -> - TextSegment( - text = line.text, - dimmed = line.lineIndex !in currentlyHidden, - lineIndex = line.lineIndex, - listItem = line.isList, - heading = line.isHeading - ) - } - } - - // Build diagram displays for fully-hidden diagram areas - val diagrams = documentModel.diagramAreas.filter { area -> - val areaBottom = lineSegmenter.getLineY(area.endLineIndex + 1) - areaBottom <= inkCanvas.scrollOffsetY - }.map { area -> - val areaStrokes = documentModel.activeStrokes.filter { stroke -> - val strokeLine = lineSegmenter.getStrokeLineIndex(stroke) - area.containsLine(strokeLine) - } - DiagramDisplay( - startLineIndex = area.startLineIndex, - strokes = areaStrokes, - canvasWidth = writingWidth, - heightPx = area.heightInLines * HandwritingCanvasView.LINE_SPACING, - offsetY = lineSegmenter.getLineY(area.startLineIndex) - ) - } - - textView.setContent(paragraphs, diagrams) - } + // --- Text display sync --- - /** Called when the text view is scrolled via its gutter overscroll. */ + /** Called when the text view is scrolled via overscroll. */ fun onManualTextScroll() { - textView.textContentScroll = inkCanvas.textOverscroll - textView.invalidate() - } - - private fun updateTextScrollOffset() { - val lineHeights = textView.writtenLineHeights - if (lineHeights.isEmpty()) { - textView.textScrollOffset = 0f - return - } - - var offset = 0f - - for (i in lineHeights.indices.reversed()) { - val (lineIdx, textHeight) = lineHeights[i] - - if (textHeight <= 0f) continue - - val lineTop = lineSegmenter.getLineY(lineIdx) - if (lineTop < inkCanvas.scrollOffsetY) { - break - } - - val drivingLine = lineIdx - 1 - if (drivingLine < 0) { - offset += textHeight - continue - } - - val drivingLineBottom = lineSegmenter.getLineY(drivingLine) + HandwritingCanvasView.LINE_SPACING - val fraction = ((drivingLineBottom - inkCanvas.scrollOffsetY) / HandwritingCanvasView.LINE_SPACING) - .coerceIn(0f, 1f) - - if (fraction >= 1f) { - offset += textHeight - continue - } - - offset += fraction * textHeight - break - } - - textView.textScrollOffset = offset + displayManager.onManualTextScroll(inkCanvas.textOverscroll) } // --- Markdown export --- @@ -566,9 +350,9 @@ class WritingCoordinator( if (lineTextCache.isEmpty() && documentModel.diagramAreas.isEmpty()) return "" val strokesByLine = lineSegmenter.groupByLine(documentModel.activeStrokes) - val writingWidth = inkCanvas.width - GUTTER_WIDTH + val writingWidth = inkCanvas.width.toFloat() - val classifiedLines = lineTextCache.keys.sorted().filter { !isDiagramLine(it) }.mapNotNull { lineIdx -> + val classifiedLines = lineTextCache.keys.sorted().filter { !diagramManager.isDiagramLine(it) }.mapNotNull { lineIdx -> paragraphBuilder.classifyLine(lineIdx, lineTextCache[lineIdx], strokesByLine[lineIdx], writingWidth) } @@ -606,251 +390,20 @@ class WritingCoordinator( return blocks.sortedBy { it.lineIndex }.joinToString("\n\n") { it.text } } - // --- Line-drag gesture --- - - private fun onLineDragStart(anchorLine: Int) { - saveUndoSnapshot() - lineDragAnchorLine = anchorLine - lineDragOriginalStrokes = documentModel.activeStrokes.toList() - lineDragOriginalDiagrams = documentModel.diagramAreas.toList() - lineDragCurrentShift = 0 - Log.i(TAG, "Line-drag started: anchorLine=$anchorLine") - } - - private fun onLineDragStep(shiftLines: Int) { - val originals = lineDragOriginalStrokes ?: return - val originalDiagrams = lineDragOriginalDiagrams ?: return - val clamped = shiftLines.coerceAtLeast(-lineDragAnchorLine) - if (clamped == lineDragCurrentShift) return - lineDragCurrentShift = clamped - - val shiftAmount = clamped * HandwritingCanvasView.LINE_SPACING - val newStrokes = mutableListOf() - - for (stroke in originals) { - val strokeLine = lineSegmenter.getStrokeLineIndex(stroke) - if (strokeLine >= lineDragAnchorLine) { - // Anchor line and below: shift - newStrokes.add(stroke.shiftY(shiftAmount)) - } else if (clamped < 0) { - // Upward drag: check if this stroke is in the overwritten zone - val overwrittenStart = lineDragAnchorLine + clamped - if (strokeLine >= overwrittenStart) { - continue - } - newStrokes.add(stroke) - } else { - newStrokes.add(stroke) - } - } - - // Shift/shrink diagram areas - val newDiagrams = mutableListOf() - for (area in originalDiagrams) { - if (area.startLineIndex >= lineDragAnchorLine) { - // Below anchor: shift - newDiagrams.add(area.copy(startLineIndex = area.startLineIndex + clamped)) - } else if (clamped < 0) { - val overwrittenStart = lineDragAnchorLine + clamped - if (area.endLineIndex < overwrittenStart) { - // Entirely above overwritten zone: keep - newDiagrams.add(area) - } else if (area.startLineIndex >= overwrittenStart) { - // Entirely in overwritten zone: remove - } else { - // Partially overlaps: shrink - val newHeight = overwrittenStart - area.startLineIndex - if (newHeight > 0) { - newDiagrams.add(area.copy(heightInLines = newHeight)) - } - } - } else { - newDiagrams.add(area) - } - } + // --- Diagram node rebuild --- - // Truncate strokes that extend outside their diagram area bounds - val truncatedStrokes = if (clamped < 0 && newDiagrams.isNotEmpty()) { - newStrokes.mapNotNull { stroke -> - val strokeLine = lineSegmenter.getStrokeLineIndex(stroke) - val area = newDiagrams.find { it.containsLine(strokeLine) } - if (area != null) { - val topY = lineSegmenter.getLineY(area.startLineIndex) - val bottomY = lineSegmenter.getLineY(area.startLineIndex + area.heightInLines) - val clipped = stroke.points.filter { it.y >= topY && it.y <= bottomY } - if (clipped.size >= 2) InkStroke( - strokeId = stroke.strokeId, - points = clipped, - strokeWidth = stroke.strokeWidth - ) else null - } else { - stroke - } - } - } else { - newStrokes - } - - documentModel.activeStrokes.clear() - documentModel.activeStrokes.addAll(truncatedStrokes) - documentModel.diagramAreas.clear() - documentModel.diagramAreas.addAll(newDiagrams) - inkCanvas.diagramAreas = newDiagrams - inkCanvas.loadStrokes(truncatedStrokes) - } - - private fun onLineDragEnd() { - val shift = lineDragCurrentShift - if (shift == 0) { - // No net shift — restore originals - val originals = lineDragOriginalStrokes - val originalDiagrams = lineDragOriginalDiagrams - if (originals != null) { - documentModel.activeStrokes.clear() - documentModel.activeStrokes.addAll(originals) - inkCanvas.loadStrokes(originals) - } - if (originalDiagrams != null) { - documentModel.diagramAreas.clear() - documentModel.diagramAreas.addAll(originalDiagrams) - inkCanvas.diagramAreas = originalDiagrams - } - } else { - // Invalidate text cache for all affected lines - val minAffected = if (shift < 0) { - lineDragAnchorLine + shift - } else { - lineDragAnchorLine - } - val maxAffected = (lineSegmenter.getBottomOccupiedLine(documentModel.activeStrokes) - .coerceAtLeast(lineDragAnchorLine)) + kotlin.math.abs(shift) - val invalidated = (minAffected..maxAffected).toSet() - for (line in invalidated) { - lineTextCache.remove(line) - } - displayHiddenLines() - } - - lineDragOriginalStrokes = null - lineDragOriginalDiagrams = null - lineDragAnchorLine = -1 - lineDragCurrentShift = 0 - Log.i(TAG, "Line-drag ended: shift=$shift lines") - } - - // --- Diagram insert gesture --- - - private fun onDiagramInsertStart(anchorLine: Int) { - saveUndoSnapshot() - diagramInsertAnchorLine = anchorLine - diagramInsertOriginalStrokes = documentModel.activeStrokes.toList() - diagramInsertOriginalDiagrams = documentModel.diagramAreas.toList() - diagramInsertCurrentHeight = 0 - Log.i(TAG, "Diagram insert started: anchorLine=$anchorLine") - } - - private fun onDiagramInsertStep(heightInLines: Int) { - val originals = diagramInsertOriginalStrokes ?: return - val originalDiagrams = diagramInsertOriginalDiagrams ?: return - if (heightInLines == diagramInsertCurrentHeight) return - diagramInsertCurrentHeight = heightInLines - - val shiftAmount = heightInLines * HandwritingCanvasView.LINE_SPACING - val newStrokes = originals.map { stroke -> - val strokeLine = lineSegmenter.getStrokeLineIndex(stroke) - if (strokeLine >= diagramInsertAnchorLine) { - stroke.shiftY(shiftAmount) - } else { - stroke - } - } - - // Shift existing diagram areas at/below anchor - val newDiagrams = originalDiagrams.map { area -> - if (area.startLineIndex >= diagramInsertAnchorLine) { - area.copy(startLineIndex = area.startLineIndex + heightInLines) - } else { - area - } - } - - // Add preview diagram area so borders render during drag - val previewDiagram = DiagramArea( - startLineIndex = diagramInsertAnchorLine, - heightInLines = heightInLines - ) - - documentModel.activeStrokes.clear() - documentModel.activeStrokes.addAll(newStrokes) - documentModel.diagramAreas.clear() - documentModel.diagramAreas.addAll(newDiagrams) - inkCanvas.diagramAreas = newDiagrams + previewDiagram - inkCanvas.loadStrokes(newStrokes) - } - - private fun onDiagramInsertEnd() { - val height = diagramInsertCurrentHeight - if (height == 0) { - // No insertion — restore originals - val originals = diagramInsertOriginalStrokes - val originalDiagrams = diagramInsertOriginalDiagrams - if (originals != null) { - documentModel.activeStrokes.clear() - documentModel.activeStrokes.addAll(originals) - inkCanvas.loadStrokes(originals) - } - if (originalDiagrams != null) { - documentModel.diagramAreas.clear() - documentModel.diagramAreas.addAll(originalDiagrams) - inkCanvas.diagramAreas = originalDiagrams - } - } else { - // Merge with adjacent diagram areas if present - var mergeStart = diagramInsertAnchorLine - var mergeHeight = height - - // Check above: existing diagram ending right above the new one - val above = documentModel.diagramAreas.find { - it.endLineIndex + 1 == diagramInsertAnchorLine - } - if (above != null) { - mergeStart = above.startLineIndex - mergeHeight += above.heightInLines - documentModel.diagramAreas.remove(above) - } - - // Check below: existing diagram starting right below the new one - val below = documentModel.diagramAreas.find { - it.startLineIndex == diagramInsertAnchorLine + height - } - if (below != null) { - mergeHeight += below.heightInLines - documentModel.diagramAreas.remove(below) - } - - val newArea = DiagramArea( - startLineIndex = mergeStart, - heightInLines = mergeHeight - ) - documentModel.diagramAreas.add(newArea) - inkCanvas.diagramAreas = documentModel.diagramAreas.toList() - - // Invalidate text cache for affected lines - val maxAffected = (lineSegmenter.getBottomOccupiedLine(documentModel.activeStrokes) - .coerceAtLeast(diagramInsertAnchorLine)) + height - val invalidated = (diagramInsertAnchorLine..maxAffected).toSet() - for (line in invalidated) { - lineTextCache.remove(line) + private fun rebuildDiagramNodes(strokes: List) { + documentModel.diagram.nodes.clear() + for (stroke in strokes) { + if (!stroke.strokeType.isConnector && stroke.strokeType != StrokeType.FREEHAND) { + val bounds = android.graphics.RectF( + stroke.minX, stroke.minY, stroke.maxX, stroke.maxY + ) + documentModel.diagram.nodes[stroke.strokeId] = DiagramNode( + strokeId = stroke.strokeId, shapeType = stroke.strokeType, bounds = bounds + ) } - displayHiddenLines() - inkCanvas.drawToSurface() } - - diagramInsertOriginalStrokes = null - diagramInsertOriginalDiagrams = null - diagramInsertAnchorLine = -1 - diagramInsertCurrentHeight = 0 - Log.i(TAG, "Diagram insert ended: height=$height lines") } // --- Undo / Redo --- @@ -869,14 +422,16 @@ class WritingCoordinator( documentModel.activeStrokes.addAll(snapshot.strokes) documentModel.diagramAreas.clear() documentModel.diagramAreas.addAll(snapshot.diagramAreas) + diagramManager.clearTextCache() + rebuildDiagramNodes(snapshot.strokes) inkCanvas.diagramAreas = snapshot.diagramAreas inkCanvas.loadStrokes(snapshot.strokes) inkCanvas.scrollOffsetY = snapshot.scrollOffsetY inkCanvas.drawToSurface() lineTextCache.clear() lineTextCache.putAll(snapshot.lineTextCache) - everHiddenLines.clear() - displayHiddenLines() + displayManager.clearEverHiddenLines() + displayManager.displayHiddenLines() } private fun currentSnapshot() = UndoManager.Snapshot( @@ -902,6 +457,42 @@ class WritingCoordinator( Log.i(TAG, "Redo: restored ${snapshot.strokes.size} strokes") } + + + // --- Bug report --- + + /** + * Generate a bug report file containing device info, recent stroke history, + * processing decisions, and current document state. + * @return the file, or null if no strokes in the buffer + */ + fun generateBugReport(): java.io.File? { + val snapshot = eventLog.snapshot() + if (snapshot.strokes.isEmpty()) return null + + val json = BugReport.serialize( + eventSnapshot = snapshot, + activeStrokes = documentModel.activeStrokes.toList(), + diagramAreas = documentModel.diagramAreas.toList(), + lineTextCache = lineTextCache.toMap() + ) + + val outDir = java.io.File( + inkCanvas.context.getExternalFilesDir(android.os.Environment.DIRECTORY_DOCUMENTS), + "bug-reports" + ) + outDir.mkdirs() + + val timestamp = java.text.SimpleDateFormat("yyyyMMdd-HHmmss", java.util.Locale.US) + .format(java.util.Date()) + val outFile = java.io.File(outDir, "bug-report-$timestamp.json") + outFile.writeText(json.toString(2)) + + Log.i(TAG, "Bug report generated: ${outFile.absolutePath} " + + "(${snapshot.strokes.size} strokes, ${snapshot.events.size} events)") + return outFile + } + // --- State persistence --- fun getState(): DocumentData { @@ -909,7 +500,7 @@ class WritingCoordinator( strokes = inkCanvas.getStrokes(), scrollOffsetY = inkCanvas.scrollOffsetY, lineTextCache = lineTextCache.toMap(), - everHiddenLines = everHiddenLines.toSet(), + everHiddenLines = displayManager.getEverHiddenLinesSnapshot(), highestLineIndex = highestLineIndex, currentLineIndex = currentLineIndex, userRenamed = userRenamed, @@ -919,13 +510,14 @@ class WritingCoordinator( fun restoreState(data: DocumentData) { lineTextCache.putAll(data.lineTextCache) - everHiddenLines.addAll(data.everHiddenLines) + displayManager.addEverHiddenLines(data.everHiddenLines) highestLineIndex = data.highestLineIndex currentLineIndex = data.currentLineIndex userRenamed = data.userRenamed documentModel.diagramAreas.clear() documentModel.diagramAreas.addAll(data.diagramAreas) inkCanvas.diagramAreas = data.diagramAreas - displayHiddenLines() + rebuildDiagramNodes(documentModel.activeStrokes) + displayManager.displayHiddenLines() } } diff --git a/app/src/main/java/com/writer/view/ArrowDwellDetection.kt b/app/src/main/java/com/writer/view/ArrowDwellDetection.kt new file mode 100644 index 0000000..bf3220b --- /dev/null +++ b/app/src/main/java/com/writer/view/ArrowDwellDetection.kt @@ -0,0 +1,81 @@ +package com.writer.view + +import com.writer.model.StrokePoint +import com.writer.model.StrokeType + +object ArrowDwellDetection { + + /** + * Returns true if the last points in [pts] clustered within [radiusPx] of ([ex],[ey]) + * for at least [dwellMs] milliseconds — indicating a deliberate dwell/pause at the tip. + */ + fun hasDwellAtEnd( + pts: List, + ex: Float, ey: Float, + radiusPx: Float, + dwellMs: Long + ): Boolean { + if (pts.isEmpty()) return false + val r2 = radiusPx * radiusPx + val endTime = pts.last().timestamp + var startTime = endTime + for (pt in pts.asReversed()) { + val dx = pt.x - ex; val dy = pt.y - ey + if (dx * dx + dy * dy > r2) break + startTime = pt.timestamp + } + return endTime - startTime >= dwellMs + } + + /** + * Returns true if [pts] started within [radiusPx] of its first point for at least [dwellMs] ms. + * Used to check for a start-of-stroke dwell (the pen paused before beginning to move). + */ + fun hasDwellAtStart( + pts: List, + radiusPx: Float, + dwellMs: Long + ): Boolean { + if (pts.isEmpty()) return false + val r2 = radiusPx * radiusPx + val first = pts.first() + val startTime = first.timestamp + var endTime = startTime + for (pt in pts) { + val dx = pt.x - first.x; val dy = pt.y - first.y + if (dx * dx + dy * dy > r2) break + endTime = pt.timestamp + } + return endTime - startTime >= dwellMs + } + + /** + * Classify the arrow type from dwell flags. + */ + fun classifyArrow(tipDwell: Boolean, tailDwell: Boolean): StrokeType = when { + tipDwell && tailDwell -> StrokeType.ARROW_BOTH + tipDwell -> StrokeType.ARROW_HEAD + tailDwell -> StrokeType.ARROW_TAIL + else -> StrokeType.LINE + } + + /** + * Classify elbow stroke type from dwell flags. + */ + fun classifyElbow(tipDwell: Boolean, tailDwell: Boolean): StrokeType = when { + tipDwell && tailDwell -> StrokeType.ELBOW_ARROW_BOTH + tipDwell -> StrokeType.ELBOW_ARROW_HEAD + tailDwell -> StrokeType.ELBOW_ARROW_TAIL + else -> StrokeType.ELBOW + } + + /** + * Classify arc stroke type from dwell flags. + */ + fun classifyArc(tipDwell: Boolean, tailDwell: Boolean): StrokeType = when { + tipDwell && tailDwell -> StrokeType.ARC_ARROW_BOTH + tipDwell -> StrokeType.ARC_ARROW_HEAD + tailDwell -> StrokeType.ARC_ARROW_TAIL + else -> StrokeType.ARC + } +} diff --git a/app/src/main/java/com/writer/view/CanvasTheme.kt b/app/src/main/java/com/writer/view/CanvasTheme.kt index 6206fa9..91ff6cb 100644 --- a/app/src/main/java/com/writer/view/CanvasTheme.kt +++ b/app/src/main/java/com/writer/view/CanvasTheme.kt @@ -5,15 +5,17 @@ import android.graphics.Color import android.graphics.Paint import android.graphics.Path import com.writer.model.InkStroke +import com.writer.model.StrokeType +import kotlin.math.hypot +import kotlin.math.sqrt /** * Shared visual constants and drawing utilities used by both * HandwritingCanvasView and HandwritingNameInput. */ object CanvasTheme { - const val DEFAULT_STROKE_WIDTH = 5f + val DEFAULT_STROKE_WIDTH get() = ScreenMetrics.strokeWidth val LINE_COLOR: Int = Color.parseColor("#AAAAAA") - val GUTTER_FILL_COLOR: Int = Color.parseColor("#DDDDDD") fun newStrokePaint() = Paint().apply { color = Color.BLACK @@ -30,17 +32,6 @@ object CanvasTheme { style = Paint.Style.STROKE } - fun newGutterFillPaint() = Paint().apply { - color = GUTTER_FILL_COLOR - style = Paint.Style.FILL - } - - fun newGutterLinePaint() = Paint().apply { - color = LINE_COLOR - strokeWidth = 1f - style = Paint.Style.STROKE - } - val DIAGRAM_BORDER_COLOR: Int = Color.parseColor("#555555") fun newDiagramBorderPaint() = Paint().apply { @@ -56,16 +47,128 @@ object CanvasTheme { fun drawStroke(canvas: Canvas, stroke: InkStroke, path: Path, paint: Paint) { if (stroke.points.size < 2) return path.reset() - path.moveTo(stroke.points[0].x, stroke.points[0].y) - for (i in 1 until stroke.points.size) { - val prev = stroke.points[i - 1] - val curr = stroke.points[i] - val midX = (prev.x + curr.x) / 2f - val midY = (prev.y + curr.y) / 2f - path.quadTo(prev.x, prev.y, midX, midY) + val pts = stroke.points + val n = pts.size + path.moveTo(pts[0].x, pts[0].y) + if (stroke.strokeType.isArc && n == 3) { + // Arc: 3 points = start, bezier control, end → quadratic bezier + path.quadTo(pts[1].x, pts[1].y, pts[2].x, pts[2].y) + } else if (stroke.isGeometric) { + // Sharp corners: lineTo each point (rectangle, triangle, arrow line, elbow, diamond). + for (i in 1 until n) { + path.lineTo(pts[i].x, pts[i].y) + } + } else { + // Smooth freehand rendering via quadratic bezier through midpoints. + for (i in 1 until n) { + val prev = pts[i - 1] + val curr = pts[i] + val midX = (prev.x + curr.x) / 2f + val midY = (prev.y + curr.y) / 2f + path.quadTo(prev.x, prev.y, midX, midY) + } + path.lineTo(pts.last().x, pts.last().y) } - val last = stroke.points.last() - path.lineTo(last.x, last.y) canvas.drawPath(path, paint) + + // Draw arrowheads + val first = pts.first() + val last = pts.last() + val size = stroke.strokeWidth * 4f + val st = stroke.strokeType + + if (st.hasArrowAtTip) { + val (dx, dy) = tipDirection(stroke) + drawArrowhead(canvas, paint, last.x, last.y, dx, dy, size) + } + if (st.hasArrowAtTail) { + val (dx, dy) = tailDirection(stroke) + drawArrowhead(canvas, paint, first.x, first.y, dx, dy, size) + } + } + + /** + * Compute the direction vector for the arrowhead at the tip (end) of a stroke. + * Uses local tangent for arcs/elbows/freehand, chord for simple geometric lines. + */ + private fun tipDirection(stroke: InkStroke): Pair { + val pts = stroke.points + val n = pts.size + val last = pts.last() + val st = stroke.strokeType + return when { + // Arc: tip tangent = derivative of quadratic bezier at t=1 = 2(P2 - C) + st.isArc && n == 3 -> { + val dx = 2f * (pts[2].x - pts[1].x) + val dy = 2f * (pts[2].y - pts[1].y) + Pair(dx, dy) + } + // Elbow: tip direction = corner → end + st.isElbow && n == 3 -> { + Pair(pts[2].x - pts[1].x, pts[2].y - pts[1].y) + } + // Freehand with enough points: use local tangent + !stroke.isGeometric && n > 3 -> { + Pair(last.x - pts[n - 3].x, last.y - pts[n - 3].y) + } + // Simple geometric line: chord direction + else -> Pair(last.x - pts.first().x, last.y - pts.first().y) + } + } + + /** + * Compute the direction vector for the arrowhead at the tail (start) of a stroke. + */ + private fun tailDirection(stroke: InkStroke): Pair { + val pts = stroke.points + val n = pts.size + val first = pts.first() + val st = stroke.strokeType + return when { + // Arc: tail tangent = derivative at t=0 = 2(C - P0), reversed for pointing away + st.isArc && n == 3 -> { + val dx = 2f * (pts[0].x - pts[1].x) + val dy = 2f * (pts[0].y - pts[1].y) + Pair(dx, dy) + } + // Elbow: tail direction = corner → start + st.isElbow && n == 3 -> { + Pair(pts[0].x - pts[1].x, pts[0].y - pts[1].y) + } + // Freehand with enough points: use local tangent + !stroke.isGeometric && n > 3 -> { + Pair(first.x - pts[2].x, first.y - pts[2].y) + } + // Simple geometric line: reversed chord direction + else -> Pair(first.x - pts.last().x, first.y - pts.last().y) + } + } + + /** + * Draw a filled isoceles triangle arrowhead at ([tipX], [tipY]) pointing in direction ([dx], [dy]). + * [size] is the base half-width; height = size × 1.5. + */ + private fun drawArrowhead( + canvas: Canvas, paint: Paint, + tipX: Float, tipY: Float, + dx: Float, dy: Float, + size: Float + ) { + val len = hypot(dx, dy) + if (len == 0f) return + // Unit forward vector + val fx = dx / len; val fy = dy / len + // Unit perpendicular + val px = -fy; val py = fx + val height = size * 1.5f + // Base center = tip − height × forward + val bx = tipX - height * fx; val by = tipY - height * fy + val arrowPath = Path() + arrowPath.moveTo(tipX, tipY) + arrowPath.lineTo(bx + size * px, by + size * py) + arrowPath.lineTo(bx - size * px, by - size * py) + arrowPath.close() + val fillPaint = Paint(paint).apply { style = Paint.Style.FILL } + canvas.drawPath(arrowPath, fillPaint) } } diff --git a/app/src/main/java/com/writer/view/DiagramNodeSnap.kt b/app/src/main/java/com/writer/view/DiagramNodeSnap.kt new file mode 100644 index 0000000..871fe32 --- /dev/null +++ b/app/src/main/java/com/writer/view/DiagramNodeSnap.kt @@ -0,0 +1,277 @@ +package com.writer.view + +import android.graphics.RectF +import com.writer.model.DiagramNode +import com.writer.model.StrokeType +import kotlin.math.abs +import kotlin.math.hypot +import kotlin.math.max +import kotlin.math.sqrt + +/** + * Pure geometry helpers for magnetic arrow-endpoint snapping to diagram nodes. + * + * Extracted from [HandwritingCanvasView] private methods so they can be unit-tested + * and to centralise the degenerate-endpoint fix (Bug 2: zero-length arrow). + * + * All geometry is implemented in the `*Raw` internal functions that accept plain + * floats, keeping them free of android.graphics dependencies for pure-JVM unit tests. + * The public overloads that accept [DiagramNode] / [RectF] are thin wrappers used + * in production code. + */ +object DiagramNodeSnap { + + // ── Raw geometry (android-free, unit-testable) ──────────────────────────── + + /** Distance from ([px],[py]) to nearest point on the bbox perimeter (0 if inside). */ + internal fun distToBboxRaw( + px: Float, py: Float, + left: Float, top: Float, right: Float, bottom: Float + ): Float { + val cx = px.coerceIn(left, right) + val cy = py.coerceIn(top, bottom) + return hypot(px - cx, py - cy) + } + + /** + * Nearest point on a node's perimeter to ([px],[py]). + * + * For points outside the bounding box the nearest bbox edge is returned. + * For points inside the bounding box the nearest of the four edges is returned. + * + * Note: a point just *outside* an edge and a point just *inside* the same edge + * both project to the **same** perimeter coordinate (the edge itself). + * See [snapArrowEndpointsRaw] for the higher-level guard against degenerate arrows. + */ + internal fun nearestPerimeterPointRaw( + px: Float, py: Float, + left: Float, top: Float, right: Float, bottom: Float, + shapeType: StrokeType + ): Pair { + if (shapeType == StrokeType.ELLIPSE) { + val cx = (left + right) / 2f; val cy = (top + bottom) / 2f + val ra = (right - left) / 2f; val rb = (bottom - top) / 2f + val dx = px - cx; val dy = py - cy + val len = hypot(dx, dy) + return if (len == 0f) Pair(cx + ra, cy) + else Pair(cx + ra * dx / len, cy + rb * dy / len) + } + // Rectangle / RoundedRectangle / Diamond / Triangle: clamp onto bbox + val cx = px.coerceIn(left, right) + val cy = py.coerceIn(top, bottom) + if (cx != px || cy != py) return Pair(cx, cy) // outside — clamp is the nearest edge point + // Inside — project to nearest of the four edges + val dLeft = px - left + val dRight = right - px + val dTop = py - top + val dBottom = bottom - py + val minD = minOf(dLeft, dRight, dTop, dBottom) + return when (minD) { + dLeft -> Pair(left, py) + dRight -> Pair(right, py) + dTop -> Pair(px, top) + else -> Pair(px, bottom) + } + } + + /** + * Snap arrow endpoints to diagram-node perimeters, guarding against degenerate results. + * + * Each endpoint independently finds the nearest node within [threshold] px. + * + * **Degenerate-endpoint guard**: if both endpoints would snap to the *same* perimeter + * point (e.g. stroke barely crosses one edge of a node), the FROM endpoint is left at + * its original position so the rendered arrow line remains visible. + * + * @return Triple of (snappedFrom, snappedTo, Pair(fromNodeId, toNodeId)) + */ + internal fun snapArrowEndpointsRaw( + fromPx: Float, fromPy: Float, + toPx: Float, toPy: Float, + nodes: Map, + threshold: Float + ): Triple, Pair, Pair> { + fun nearest(px: Float, py: Float): DiagramNode? = nodes.values + .filter { n -> + val b = n.bounds + distToBboxRaw(px, py, b.left, b.top, b.right, b.bottom) < threshold + } + .minByOrNull { n -> + val b = n.bounds + distToBboxRaw(px, py, b.left, b.top, b.right, b.bottom) + } + + val fromNode = nearest(fromPx, fromPy) + val toNode = nearest(toPx, toPy) + + val snappedFrom = if (fromNode != null) { + val b = fromNode.bounds + nearestPerimeterPointRaw(fromPx, fromPy, b.left, b.top, b.right, b.bottom, fromNode.shapeType) + } else Pair(fromPx, fromPy) + + val snappedTo = if (toNode != null) { + val b = toNode.bounds + nearestPerimeterPointRaw(toPx, toPy, b.left, b.top, b.right, b.bottom, toNode.shapeType) + } else Pair(toPx, toPy) + + // Degenerate-endpoint guard (Bug 2 fix): + // If both endpoints snapped to the same perimeter point (e.g. the stroke barely + // crosses one edge of a node), the arrow line is zero-length and only the arrowhead + // renders. Leave the FROM endpoint at its original position in that case so the + // arrow line remains visible. + val (resolvedFrom, resolvedFromId) = + if (snappedFrom == snappedTo && fromNode != null) Pair(Pair(fromPx, fromPy), null) + else Pair(snappedFrom, fromNode?.strokeId) + + return Triple(resolvedFrom, snappedTo, Pair(resolvedFromId, toNode?.strokeId)) + } + + // ── Self-loop detection (android-free, unit-testable) ──────────────────── + + /** + * Check if both endpoints of a stroke are near the **same** diagram node. + * + * @param pathLength total path length of the stroke in px + * @param threshold max distance from endpoint to node bbox perimeter + * @param minPathLengthPx minimum path length to qualify (rejects short grazes) + * @return the matching nodeId, or null if no single node is within threshold of both endpoints + * or if the path isn't curved enough (pathLength / endpointDistance must be > 2.0) + */ + fun detectSelfLoop( + firstX: Float, firstY: Float, + lastX: Float, lastY: Float, + pathLength: Float, + nodes: Map, + threshold: Float, + minPathLengthPx: Float, + xReversals: Int = 0 + ): String? { + // A stroke with ≥2 X-direction reversals is a scratch-out, not a self-loop. + if (xReversals >= ScratchOutDetection.MIN_REVERSALS) return null + if (pathLength < minPathLengthPx) return null + val endpointDist = hypot(lastX - firstX, lastY - firstY) + if (endpointDist > 0f && pathLength / endpointDist <= 2.0f) return null + + // Find node within threshold of first point + val firstNode = nodes.values + .filter { n -> + val b = n.bounds + distToBboxRaw(firstX, firstY, b.left, b.top, b.right, b.bottom) < threshold + } + .minByOrNull { n -> + val b = n.bounds + distToBboxRaw(firstX, firstY, b.left, b.top, b.right, b.bottom) + } ?: return null + + // Last point must also be within threshold of the SAME node + val b = firstNode.bounds + if (distToBboxRaw(lastX, lastY, b.left, b.top, b.right, b.bottom) >= threshold) return null + + return firstNode.strokeId + } + + /** + * Compute the perimeter point on a node shape from an approach direction. + * + * This mirrors the ray-intersection logic in [RecognizedTextView.perimeterPoint] + * but works on raw floats (no android.graphics dependency). + * + * @param dx approach direction X component (need not be normalised) + * @param dy approach direction Y component + */ + internal fun perimeterPointFromDirectionRaw( + dx: Float, dy: Float, + left: Float, top: Float, right: Float, bottom: Float, + shapeType: StrokeType + ): Pair { + val cx = (left + right) / 2f + val cy = (top + bottom) / 2f + if (dx == 0f && dy == 0f) return right to cy // default: right-side midpoint + return when (shapeType) { + StrokeType.ELLIPSE -> { + val a = (right - left) / 2f + val b = (bottom - top) / 2f + val t = 1f / sqrt((dx / a) * (dx / a) + (dy / b) * (dy / b)) + (cx + t * dx) to (cy + t * dy) + } + StrokeType.DIAMOND -> { + val hw = (right - left) / 2f + val hh = (bottom - top) / 2f + val t = 1f / (abs(dx) / hw + abs(dy) / hh) + (cx + t * dx) to (cy + t * dy) + } + else -> { + val hw = (right - left) / 2f + val hh = (bottom - top) / 2f + val tx = if (dx != 0f) hw / abs(dx) else Float.MAX_VALUE + val ty = if (dy != 0f) hh / abs(dy) else Float.MAX_VALUE + val t = minOf(tx, ty) + (cx + t * dx) to (cy + t * dy) + } + } + } + + // ── Self-loop arc generation (android-free, unit-testable) ───────────── + + /** + * Generate a clean cubic Bézier arc for a self-loop arrow. + * + * The arc starts at ([startX],[startY]) and ends at ([endX],[endY]) — both + * perimeter snap points on the same node. Control points are pushed outward + * from the node center through each endpoint, producing a curve that bulges + * away from the node. + * + * @return list of [numPoints]+1 evenly-sampled points along the cubic Bézier. + */ + fun generateSelfLoopArc( + startX: Float, startY: Float, + endX: Float, endY: Float, + cx: Float, cy: Float, + nodeWidth: Float, nodeHeight: Float, + numPoints: Int = 20 + ): List> { + val loopRadius = max(nodeWidth, nodeHeight) * 0.6f + + // Outward direction: from center through perimeter point + fun outward(px: Float, py: Float): Pair { + val dx = px - cx; val dy = py - cy + val len = hypot(dx, dy) + return if (len == 0f) Pair(1f, 0f) else Pair(dx / len, dy / len) + } + + val (odx0, ody0) = outward(startX, startY) + val (odx1, ody1) = outward(endX, endY) + + val cp1x = startX + odx0 * loopRadius + val cp1y = startY + ody0 * loopRadius + val cp2x = endX + odx1 * loopRadius + val cp2y = endY + ody1 * loopRadius + + val result = ArrayList>(numPoints + 1) + for (i in 0..numPoints) { + val t = i.toFloat() / numPoints + val u = 1f - t + val x = u * u * u * startX + 3f * u * u * t * cp1x + 3f * u * t * t * cp2x + t * t * t * endX + val y = u * u * u * startY + 3f * u * u * t * cp1y + 3f * u * t * t * cp2y + t * t * t * endY + result.add(Pair(x, y)) + } + return result + } + + // ── Public wrappers (use DiagramNode / RectF, for production callers) ──── + + fun distToBbox(px: Float, py: Float, bounds: RectF): Float = + distToBboxRaw(px, py, bounds.left, bounds.top, bounds.right, bounds.bottom) + + fun nearestPerimeterPoint(px: Float, py: Float, node: DiagramNode): Pair { + val b = node.bounds + return nearestPerimeterPointRaw(px, py, b.left, b.top, b.right, b.bottom, node.shapeType) + } + + fun snapArrowEndpoints( + fromPx: Float, fromPy: Float, + toPx: Float, toPy: Float, + nodes: Map, + threshold: Float + ) = snapArrowEndpointsRaw(fromPx, fromPy, toPx, toPy, nodes, threshold) +} diff --git a/app/src/main/java/com/writer/view/DiagramTextFilter.kt b/app/src/main/java/com/writer/view/DiagramTextFilter.kt new file mode 100644 index 0000000..dba76f6 --- /dev/null +++ b/app/src/main/java/com/writer/view/DiagramTextFilter.kt @@ -0,0 +1,101 @@ +package com.writer.view + +import com.writer.model.InkStroke +import com.writer.model.StrokeType + +/** + * Pure logic for filtering out text lines that are entirely inside diagram shape bounds. + * + * Strokes written inside a shape are recognized as the shape's label (shown in the diagram + * preview) and must not also appear as independent text paragraphs. + */ +internal object DiagramTextFilter { + + /** + * Returns the set of line indices whose freehand strokes all lie inside a diagram node. + * + * A line is excluded when every [StrokeType.FREEHAND] stroke on it has its center + * contained within at least one node's bounding box. Lines that have no freehand + * strokes at all are also excluded (they carry no text content). + * + * @param strokesByLine map from line index to all strokes on that line + * @param nodeBounds node bounding boxes as [left, top, right, bottom] float arrays + * @return set of line indices to suppress from the text preview + */ + fun diagramOnlyLines( + strokesByLine: Map>, + nodeBounds: List + ): Set { + val validBounds = nodeBounds.filter { it.size == 4 } + if (validBounds.isEmpty()) return emptySet() + return strokesByLine.entries + .filter { (_, strokes) -> + val freehand = strokes.filter { it.strokeType == StrokeType.FREEHAND } + // No freehand strokes → no text on this line + if (freehand.isEmpty()) return@filter true + // All freehand strokes must be inside some node + freehand.all { stroke -> + val cx = (stroke.points.minOf { it.x } + stroke.points.maxOf { it.x }) / 2f + val cy = (stroke.points.minOf { it.y } + stroke.points.maxOf { it.y }) / 2f + validBounds.any { (l, t, r, b) -> cx >= l && cx <= r && cy >= t && cy <= b } + } + } + .map { it.key } + .toSet() + } + + /** + * Returns line indices whose freehand strokes all lie in the diagram's Y-band + * but outside every node's bounds — i.e. "beside" the diagram, not inside a shape. + * + * @param strokesByLine map from line index to strokes + * @param nodeBounds node bounds as [left, top, right, bottom] + * @param diagramBBox overall diagram bounding box as [left, top, right, bottom] + * @param yTolerance Y padding around the diagram bbox (pass LINE_SPACING) + */ + /** + * @param rightBandLimit upper X bound for "right-side" band notes. Strokes whose + * center X exceeds this value are treated as ordinary text, not diagram notes. + * Pass `canvasWidth − 2 × gutterWidth` to exclude the rightmost gutter-zone column + * (fixes Bug #6: text near the right margin wrongly suppressed after a shape is drawn). + * Defaults to [Float.MAX_VALUE] (no upper bound — original behaviour). + */ + fun diagramBandLines( + strokesByLine: Map>, + nodeBounds: List, + diagramBBox: FloatArray, + yTolerance: Float, + leftBandLimit: Float = diagramBBox[0] - yTolerance * 4, + rightBandLimit: Float = diagramBBox[2] + yTolerance * 4 + ): Set { + if (diagramBBox.size != 4) return emptySet() + val validNodeBounds = nodeBounds.filter { it.size == 4 } + val bLeft = diagramBBox[0]; val bTop = diagramBBox[1] + val bRight = diagramBBox[2]; val bBottom = diagramBBox[3] + return strokesByLine.entries + .filter { (_, strokes) -> + val freehand = strokes.filter { it.strokeType == StrokeType.FREEHAND } + if (freehand.isEmpty()) return@filter false // no text on this line + // Exclude shape-label strokes (inside a node) — they are already handled by + // diagramOnlyLines. Only the remaining strokes need to qualify as band notes. + // This allows "side" strokes to be detected even when a shape label (e.g. "A") + // happens to share the same written line index. + val nonLabel = freehand.filter { stroke -> + val cx = (stroke.points.minOf { it.x } + stroke.points.maxOf { it.x }) / 2f + val cy = (stroke.points.minOf { it.y } + stroke.points.maxOf { it.y }) / 2f + validNodeBounds.none { (l, t, r, b) -> cx >= l && cx <= r && cy >= t && cy <= b } + } + if (nonLabel.isEmpty()) return@filter false // pure shape-label line + nonLabel.all { stroke -> + val cx = (stroke.points.minOf { it.x } + stroke.points.maxOf { it.x }) / 2f + val cy = (stroke.points.minOf { it.y } + stroke.points.maxOf { it.y }) / 2f + val inYBand = cy >= bTop - yTolerance && cy <= bBottom + yTolerance + // "Right-side" band notes must not extend past the rightmost gutter zone. + val outsideX = (cx < bLeft && cx > leftBandLimit) || (cx > bRight && cx < rightBandLimit) + inYBand && outsideX + } + } + .map { it.key } + .toSet() + } +} diff --git a/app/src/main/java/com/writer/view/HandwritingCanvasView.kt b/app/src/main/java/com/writer/view/HandwritingCanvasView.kt index 4f13e06..2e5e2f3 100644 --- a/app/src/main/java/com/writer/view/HandwritingCanvasView.kt +++ b/app/src/main/java/com/writer/view/HandwritingCanvasView.kt @@ -5,7 +5,10 @@ import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.Path +import android.graphics.DashPathEffect +import android.graphics.PointF import android.graphics.Rect + import android.graphics.Typeface import android.util.AttributeSet import android.util.Log @@ -19,10 +22,16 @@ import com.onyx.android.sdk.pen.RawInputCallback import com.onyx.android.sdk.pen.TouchHelper import com.onyx.android.sdk.pen.data.TouchPointList import com.writer.model.DiagramArea +import com.writer.ui.writing.DiagramStrokeClassifier +import com.writer.model.DocumentModel import com.writer.model.InkStroke import com.writer.model.StrokePoint +import com.writer.model.StrokeType import com.writer.model.minY import com.writer.model.maxY +import kotlin.math.cos +import kotlin.math.hypot +import kotlin.math.sin /** * Primary ink input surface. Uses Onyx Pen SDK for low-latency @@ -37,32 +46,17 @@ class HandwritingCanvasView @JvmOverloads constructor( companion object { private const val TAG = "HandwritingCanvas" - // Line spacing, top margin and gutter width are DPI-scaled via ScreenMetrics. + // Line spacing and top margin are DPI-scaled via ScreenMetrics. val LINE_SPACING get() = ScreenMetrics.lineSpacing // Idle timeout before checking scroll condition (ms) private const val IDLE_TIMEOUT_MS = 2000L // Top margin before the first line val TOP_MARGIN get() = ScreenMetrics.topMargin - // Width of the scroll gutter on the right edge - val GUTTER_WIDTH get() = ScreenMetrics.gutterWidth - // Line-drag gesture: vertical span to activate (either direction) - private const val LINE_DRAG_MIN_SPANS = 1f - // Line-drag gesture: max horizontal drift ratio during activation - private const val LINE_DRAG_MAX_DRIFT = 0.3f - // Undo gesture: minimum horizontal span to detect initial stroke - private const val UNDO_HORIZONTAL_MIN_SPANS = 1.5f - // Undo gesture: max vertical drift ratio during horizontal stroke - private const val UNDO_MAX_VERTICAL_DRIFT = 0.2f - // Undo gesture: vertical span (in line spacings) to activate after horizontal stroke - private const val UNDO_VERTICAL_ACTIVATION = 0.75f - // Undo scrub: ~1.8 mm per undo/redo step, scaled to device DPI - private val UNDO_STEP_SIZE get() = ScreenMetrics.dp(11f) - // Diagram insert: fraction of stroke to analyze for scribble detection - private const val SCRIBBLE_SEGMENT_FRACTION = 0.4f - // Diagram insert: path-length / displacement ratio threshold for scribble - private const val SCRIBBLE_MIN_COMPLEXITY = 3.0f - // Diagram insert: minimum height in lines - private const val DIAGRAM_MIN_HEIGHT = 2 + // Arrow dwell detection: radius and time for start/end dwell + private const val ARROW_DWELL_RADIUS_PX = 15f // ~8 dp + // Magnetic snap: max distance in line-spacings to snap arrow to node + private const val MAGNET_THRESHOLD_SPANS = 1.5f + private const val ARROW_DWELL_MS = 500L } private val completedStrokes = mutableListOf() @@ -73,8 +67,6 @@ class HandwritingCanvasView @JvmOverloads constructor( private val strokePaint = CanvasTheme.newStrokePaint() private val linePaint = CanvasTheme.newLinePaint() - private val gutterPaint = CanvasTheme.newGutterFillPaint() - private val gutterLinePaint = CanvasTheme.newGutterLinePaint() private val diagramBorderPaint = CanvasTheme.newDiagramBorderPaint() private val annotationPaint = Paint().apply { @@ -92,6 +84,27 @@ class HandwritingCanvasView @JvmOverloads constructor( /** Diagram areas in the current document. */ var diagramAreas: List = emptyList() + /** Document model reference for magnetic snap access. */ + var documentModel: DocumentModel? = null + + // Dwell indicator state (inside diagram → arrow tail; outside → freeform zone) + private var dwellJob: Runnable? = null + private var dwellIndicatorShown = false + private var dwellDotCenter: PointF? = null + + private val dwellDotPaint = Paint().apply { + color = Color.DKGRAY + style = Paint.Style.FILL + isAntiAlias = true + } + + private val freeformPreviewPaint = Paint().apply { + color = CanvasTheme.DIAGRAM_BORDER_COLOR + strokeWidth = 3f + style = Paint.Style.STROKE + pathEffect = DashPathEffect(floatArrayOf(20f, 14f), 0f) + } + /** When true, all pen input is blocked and annotations are rendered. */ var tutorialMode = false var annotationStrokes: List = emptyList() @@ -102,20 +115,24 @@ class HandwritingCanvasView @JvmOverloads constructor( /** Called when manual scrolling changes the offset. */ var onManualScroll: (() -> Unit)? = null - // Line-drag gesture callbacks - var onLineDragStart: ((anchorLine: Int) -> Unit)? = null - var onLineDragStep: ((shiftLines: Int) -> Unit)? = null - var onLineDragEnd: (() -> Unit)? = null + /** Called when pen state changes: true = pen down (writing), false = pen lifted. */ + var onPenStateChanged: ((Boolean) -> Unit)? = null - // Diagram insert gesture callbacks - var onDiagramInsertStart: ((anchorLine: Int) -> Unit)? = null - var onDiagramInsertStep: ((heightInLines: Int) -> Unit)? = null - var onDiagramInsertEnd: (() -> Unit)? = null + /** Called when a shape is detected outside a diagram area. Returns diagram bounds if created. */ + var onDiagramShapeDetected: ((stroke: InkStroke) -> Pair?)? = null - // Undo scrub callbacks (horizontal stroke then vertical movement) - var onUndoGestureStart: (() -> Unit)? = null - var onUndoGestureStep: ((absoluteOffset: Int) -> Unit)? = null - var onUndoGestureEnd: (() -> Unit)? = null + /** Raw stroke capture: called with every finished stroke BEFORE any processing. + * Used for test fixture recording. Set to non-null to enable capture. */ + var onRawStrokeCapture: ((points: List) -> Unit)? = null + + /** Called when a stroke inside a diagram extends beyond its bounds, to expand the area. */ + var onDiagramStrokeOverflow: ((strokeId: String, minY: Float, maxY: Float) -> Unit)? = null + + // Scratch-out callback (inside diagram areas: erase overlapping strokes) + var onScratchOut: ((scratchPoints: List, left: Float, top: Float, right: Float, bottom: Float) -> Unit)? = null + + // Stroke-replaced callback (shape snap: raw freehand → snapped geometric) + var onStrokeReplaced: ((oldStrokeId: String, newStroke: InkStroke) -> Unit)? = null /** Scroll offset in document-space pixels. Increase to scroll content up. */ var scrollOffsetY: Float = 0f @@ -123,31 +140,15 @@ class HandwritingCanvasView @JvmOverloads constructor( /** Extra scroll past the top of the document, for scrolling the text view. */ var textOverscroll: Float = 0f - // Gutter scrolling state - private var isGutterDragging = false - private var gutterDragLastY = 0f - - // Line-drag gesture state - private var lineDragActive = false - private var lineDragStartScreenY = 0f - private var lineDragLastShift = 0 - - // Diagram insert gesture state - private var diagramInsertActive = false - private var diagramInsertStartScreenY = 0f - private var diagramInsertLastHeight = 0 - - // Undo gesture state: horizontal stroke → vertical scrub - private var undoGestureReady = false - private var undoReadyScreenY = 0f - private var undoScrubActive = false - private var undoScrubTriggerY = 0f - private var undoScrubLastStep = 0 - // Diagram drawing bounds: when stroke starts in a diagram area, Y is clamped to these bounds private var currentDiagramBounds: Pair? = null // (topY, bottomY) in doc space - // Whether we've temporarily changed the Onyx SDK limit rect for a diagram stroke - private var diagramLimitActive = false + + /** Shared palm-rejection filter, set by WritingActivity. */ + var touchFilter: TouchFilter? = null + + // Finger scroll state + private var fingerScrollActive = false + private var fingerScrollLastY = 0f private val idleRunnable = Runnable { onIdleTimeout?.invoke() } @@ -156,8 +157,8 @@ class HandwritingCanvasView @JvmOverloads constructor( private var surfaceReady = false // ── Running stroke bounding box ─────────────────────────────────────────── - // checkGestures needs xRange and yRange of the stroke so far. Maintaining - // a running bounding box updated O(1) per point avoids an O(n²) scan. + // Post-stroke detection (scratch-out, shape snap) needs stroke bounds. + // Running bounding box updated O(1) per point avoids an O(n²) scan. // // Coordinates are in document space (y includes scrollOffsetY), matching // currentStrokePoints. Reset at stroke start; updated by updateStrokeBounds(). @@ -172,43 +173,23 @@ class HandwritingCanvasView @JvmOverloads constructor( private val onyxCallback = object : RawInputCallback() { override fun onBeginRawDrawing(b: Boolean, tp: TouchPoint) { + touchFilter?.penActive = true + onPenStateChanged?.invoke(true) handler.removeCallbacks(idleRunnable) currentStrokePoints.clear() val docPt = tp.toDocStrokePoint() currentDiagramBounds = getDiagramBounds(docPt.y) - if (currentDiagramBounds != null) { - val (topY, bottomY) = currentDiagramBounds!! - setDiagramLimitRect(topY, bottomY) - } else if (diagramLimitActive) { - // Safety: previous diagram limit wasn't cleaned up - restoreLimitRect() - } currentStrokePoints.add(docPt) - lineDragActive = false - diagramInsertActive = false - undoGestureReady = false - undoScrubActive = false initStrokeBounds(currentStrokePoints.last()) + // Start dwell detection: inside diagram → arrow tail; outside → freeform zone + startDwellJob() } override fun onRawDrawingTouchPointMoveReceived(tp: TouchPoint) { - if (lineDragActive || diagramInsertActive || undoScrubActive) { - // SDK buffer dump after we disabled it — ignore. - // Real movement comes via onTouchEvent now. - return - } val docPt = tp.toDocStrokePoint() - currentDiagramBounds?.let { (topY, bottomY) -> - if (docPt.y < topY || docPt.y > bottomY) return - } currentStrokePoints.add(docPt) updateStrokeBounds(currentStrokePoints.last()) - if (undoGestureReady) { - // Horizontal threshold met — check for vertical activation - processUndoReadyMove(tp.y) - } else { - checkGestures(tp.x, tp.y) - } + checkDwellCancellation() } override fun onRawDrawingTouchPointListReceived(tpl: TouchPointList) { @@ -216,33 +197,16 @@ class HandwritingCanvasView @JvmOverloads constructor( } override fun onEndRawDrawing(b: Boolean, tp: TouchPoint) { - Log.d(TAG, "onEndRawDrawing: ${currentStrokePoints.size} points, lineDrag=$lineDragActive, diagramInsert=$diagramInsertActive, undoReady=$undoGestureReady, undoScrub=$undoScrubActive") - if (lineDragActive || diagramInsertActive || undoScrubActive) { - // SDK fires this when disabled mid-stroke (buffer dump). - // Ignore it — the real pen-up comes via onTouchEvent. - Log.d(TAG, "Ignoring SDK onEndRawDrawing during interactive gesture") - return + cancelDwellJob() + touchFilter?.let { + it.penActive = false + it.penUpTimestamp = android.os.SystemClock.uptimeMillis() } - // If undoGestureReady but not undoScrubActive, the user drew a - // horizontal stroke without going vertical — treat as normal stroke - // (e.g. strikethrough). - undoGestureReady = false + onPenStateChanged?.invoke(false) + Log.d(TAG, "onEndRawDrawing: ${currentStrokePoints.size} points") val docPt = tp.toDocStrokePoint() - currentDiagramBounds?.let { (topY, bottomY) -> - if (docPt.y >= topY && docPt.y <= bottomY) { - currentStrokePoints.add(docPt) - } - } ?: currentStrokePoints.add(docPt) + currentStrokePoints.add(docPt) finishStroke() - if (diagramLimitActive) { - // Pen exited diagram limit rect or lifted inside diagram. - // DON'T restore limit rect here — if pen exited, it's still - // physically down and restoring would let the SDK start a new - // stroke outside the diagram. Restore on true pen-up instead - // (via onTouchEvent ACTION_UP). - drawToSurface() - return - } handler.postDelayed(idleRunnable, IDLE_TIMEOUT_MS) } @@ -276,7 +240,6 @@ class HandwritingCanvasView @JvmOverloads constructor( try { val limit = Rect() getLocalVisibleRect(limit) - limit.right = (limit.right - GUTTER_WIDTH).toInt() touchHelper?.setLimitRect(limit, emptyList()) } catch (e: Exception) { Log.w(TAG, "Error updating limit rect: ${e.message}") @@ -301,7 +264,6 @@ class HandwritingCanvasView @JvmOverloads constructor( try { val limit = Rect() getLocalVisibleRect(limit) - limit.right = (limit.right - GUTTER_WIDTH).toInt() touchHelper = TouchHelper.create(this, onyxCallback) touchHelper?.setStrokeWidth(CanvasTheme.DEFAULT_STROKE_WIDTH) @@ -325,46 +287,17 @@ class HandwritingCanvasView @JvmOverloads constructor( override fun onTouchEvent(event: MotionEvent): Boolean { val toolType = event.getToolType(0) - // Reject all finger/palm touches, but cancel idle timer + // Finger touches: filter through palm rejection, allow vertical scroll if (toolType == MotionEvent.TOOL_TYPE_FINGER) { handler.removeCallbacks(idleRunnable) - return false - } - - // If already in a gutter drag, keep handling as gutter even if pen leaves the area - if (isGutterDragging) { - return handleGutterTouch(event) - } - - // Stylus/mouse in gutter area → scroll drag - if (event.x >= width - GUTTER_WIDTH) { - return handleGutterTouch(event) - } - - // If an interactive gesture is active, we've disabled the SDK and handle here - if (lineDragActive) { - return handleLineDragTouch(event) - } - if (diagramInsertActive) { - return handleDiagramInsertTouch(event) - } - if (undoScrubActive) { - return handleUndoTouch(event) + return handleFingerTouch(event) } - // In tutorial mode, block all writing input but allow gutter (handled above) + // In tutorial mode, block all writing input if (tutorialMode) return false // If using Onyx SDK, pen input in the canvas area is handled by SDK callbacks - if (useOnyxSdk) { - // Detect true pen-up to restore limit rect after diagram stroke - if (diagramLimitActive && event.action == MotionEvent.ACTION_UP) { - restoreLimitRect() - drawToSurface() - handler.postDelayed(idleRunnable, IDLE_TIMEOUT_MS) - } - return true - } + if (useOnyxSdk) return true // Stylus/mouse on canvas → writing (fallback for non-Boox devices) val x = event.x @@ -374,63 +307,45 @@ class HandwritingCanvasView @JvmOverloads constructor( when (event.action) { MotionEvent.ACTION_DOWN -> { + touchFilter?.penActive = true + onPenStateChanged?.invoke(true) handler.removeCallbacks(idleRunnable) currentStrokePoints.clear() currentPath.reset() currentDiagramBounds = getDiagramBounds(y) currentPath.moveTo(x, y) currentStrokePoints.add(StrokePoint(x, y, pressure, timestamp)) - lineDragActive = false - diagramInsertActive = false - undoGestureReady = false - undoScrubActive = false initStrokeBounds(currentStrokePoints.last()) + startDwellJob() drawToSurface() return true } MotionEvent.ACTION_MOVE -> { - val bounds = currentDiagramBounds for (i in 0 until event.historySize) { val hx = event.getHistoricalX(i) val hy = event.getHistoricalY(i) + scrollOffsetY val hp = event.getHistoricalPressure(i) val ht = event.getHistoricalEventTime(i) - if (bounds == null || (hy >= bounds.first && hy <= bounds.second)) { - currentPath.lineTo(hx, hy) - currentStrokePoints.add(StrokePoint(hx, hy, hp, ht)) - updateStrokeBounds(currentStrokePoints.last()) - } - } - if (bounds == null || (y >= bounds.first && y <= bounds.second)) { - currentPath.lineTo(x, y) - currentStrokePoints.add(StrokePoint(x, y, pressure, timestamp)) + currentPath.lineTo(hx, hy) + currentStrokePoints.add(StrokePoint(hx, hy, hp, ht)) updateStrokeBounds(currentStrokePoints.last()) } - checkGesturesFallback(event.x, event.y) - if (lineDragActive || diagramInsertActive || undoScrubActive) return true + currentPath.lineTo(x, y) + currentStrokePoints.add(StrokePoint(x, y, pressure, timestamp)) + updateStrokeBounds(currentStrokePoints.last()) + checkDwellCancellation() drawToSurface() return true } MotionEvent.ACTION_UP -> { - if (lineDragActive) { - endLineDrag() - return true - } - if (diagramInsertActive) { - endDiagramInsert() - return true - } - if (undoScrubActive) { - endUndoGesture() - return true - } - // If undoGestureReady but not active, treat as normal stroke - undoGestureReady = false - val upBounds = currentDiagramBounds - if (upBounds == null || (y >= upBounds.first && y <= upBounds.second)) { - currentPath.lineTo(x, y) - currentStrokePoints.add(StrokePoint(x, y, pressure, timestamp)) + cancelDwellJob() + touchFilter?.let { + it.penActive = false + it.penUpTimestamp = android.os.SystemClock.uptimeMillis() } + onPenStateChanged?.invoke(false) + currentPath.lineTo(x, y) + currentStrokePoints.add(StrokePoint(x, y, pressure, timestamp)) finishStroke() handler.postDelayed(idleRunnable, IDLE_TIMEOUT_MS) drawToSurface() @@ -440,21 +355,52 @@ class HandwritingCanvasView @JvmOverloads constructor( return super.onTouchEvent(event) } - private fun handleGutterTouch(event: MotionEvent): Boolean { + /** + * Handle filtered finger touches on the canvas. Only vertical scrolling is + * allowed — no taps (avoids accidental palm taps). + */ + private fun handleFingerTouch(event: MotionEvent): Boolean { + val tf = touchFilter ?: return false + val touchMinorDp = event.touchMinor / ScreenMetrics.density + when (event.action) { MotionEvent.ACTION_DOWN -> { - isGutterDragging = true - gutterDragLastY = event.y - handler.removeCallbacks(idleRunnable) + if (tf.evaluateDown( + pointerCount = event.pointerCount, + touchMinorDp = touchMinorDp, + eventTime = event.eventTime, + x = event.x, + y = event.y, + ) == TouchFilter.Decision.REJECT + ) { + fingerScrollActive = false + return false + } + fingerScrollLastY = event.y + fingerScrollActive = true pauseRawDrawing() return true } MotionEvent.ACTION_MOVE -> { - if (!isGutterDragging) return false - val dy = gutterDragLastY - event.y // drag up = positive = scroll down - gutterDragLastY = event.y + if (!fingerScrollActive) return false + if (tf.evaluateMove( + pointerCount = event.pointerCount, + touchMinorDp = touchMinorDp, + eventTime = event.eventTime, + x = event.x, + y = event.y, + checkStationary = true, + ) == TouchFilter.Decision.REJECT + ) { + // Cancel this finger gesture + fingerScrollActive = false + if (!tutorialMode) resumeRawDrawing() + return false + } + if (!tf.hasMovedPastSlop()) return true // wait for intentional drag + val dy = fingerScrollLastY - event.y // drag up = scroll down + fingerScrollLastY = event.y if (textOverscroll > 0f && dy > 0f) { - // Scrolling back down — reduce text overscroll first textOverscroll = (textOverscroll - dy).coerceAtLeast(0f) } else { val raw = scrollOffsetY + dy @@ -470,8 +416,8 @@ class HandwritingCanvasView @JvmOverloads constructor( return true } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { - if (!isGutterDragging) return false - isGutterDragging = false + if (!fingerScrollActive) return false + fingerScrollActive = false if (textOverscroll == 0f) { scrollOffsetY = snapToLine(scrollOffsetY) } @@ -489,17 +435,171 @@ class HandwritingCanvasView @JvmOverloads constructor( currentStrokePoints.clear() currentPath.reset() currentDiagramBounds = null + dwellDotCenter = null + dwellIndicatorShown = false return } - val stroke = InkStroke(points = currentStrokePoints.toList()) - completedStrokes.add(stroke) - onStrokeCompleted?.invoke(stroke) - currentStrokePoints.clear() - currentPath.reset() - currentDiagramBounds = null + + // Raw capture: record stroke before any processing (scratch-out, shape snap, etc.) + onRawStrokeCapture?.invoke(currentStrokePoints.toList()) + + if (currentDiagramBounds != null) { + // INSIDE DIAGRAM AREA: post-stroke shape-snap pipeline + val tailDwell = dwellIndicatorShown + dwellDotCenter = null + dwellIndicatorShown = false + + + // Save raw points before checkShapeSnap overwrites currentStrokePoints + val rawPoints = currentStrokePoints.toList() + + // Shape snap before scratch-out: rectangles have X-reversals that + // would otherwise be consumed as scratch-out. + val snapData = checkShapeSnap(tailDwell) + + // Only check scratch-out if no shape was snapped. + if (snapData == null && checkPostStrokeScratchOut()) { + currentDiagramBounds = null + return + } + + val finalStroke: InkStroke + if (snapData != null) { + // Two-phase commit: emit raw stroke first, then replace with snapped + val rawStroke = InkStroke( + points = rawPoints, + isGeometric = false, + strokeType = StrokeType.FREEHAND + ) + completedStrokes.add(rawStroke) + onStrokeCompleted?.invoke(rawStroke) + + val snappedStroke = InkStroke( + points = currentStrokePoints.toList(), + isGeometric = snapData.isGeometric, + strokeType = snapData.strokeType + ) + completedStrokes.remove(rawStroke) + completedStrokes.add(snappedStroke) + onStrokeReplaced?.invoke(rawStroke.strokeId, snappedStroke) + finalStroke = snappedStroke + } else { + val stroke = InkStroke( + points = currentStrokePoints.toList(), + isGeometric = false, + strokeType = StrokeType.FREEHAND + ) + completedStrokes.add(stroke) + onStrokeCompleted?.invoke(stroke) + finalStroke = stroke + } + + // Expand diagram if any stroke (raw or snapped) crosses the boundary. + // Use the union of raw bounds and final stroke bounds to catch both + // freehand overflow and snapped geometry that extends further. + val (diagTopY, diagBottomY) = currentDiagramBounds!! + val overflowMinY = minOf(strokeMinY, finalStroke.points.minOf { it.y }) + val overflowMaxY = maxOf(strokeMaxY, finalStroke.points.maxOf { it.y }) + if (overflowMinY < diagTopY || overflowMaxY > diagBottomY) { + onDiagramStrokeOverflow?.invoke(finalStroke.strokeId, overflowMinY, overflowMaxY) + } + currentStrokePoints.clear() + currentPath.reset() + currentDiagramBounds = null + if (snapData != null) { + // Flush SDK hardware overlay showing freehand stroke, redraw clean snapped shape + pauseRawDrawing() + drawToSurface() + resumeRawDrawing() + } + } else { + // OUTSIDE DIAGRAM AREA + if (checkPostStrokeScratchOut()) { + + return + } + + // Check for shape-intent: dwell at end → shape detection → auto-create diagram + val snapData = checkShapeSnap() + if (snapData != null) { + val rawPoints = currentStrokePoints.toList() + + // Two-phase commit: emit raw stroke, then replace with snapped shape + val rawStroke = InkStroke( + points = rawPoints, + isGeometric = false, + strokeType = StrokeType.FREEHAND + ) + completedStrokes.add(rawStroke) + onStrokeCompleted?.invoke(rawStroke) + + // Notify coordinator to create diagram area around the shape + onDiagramShapeDetected?.invoke(rawStroke) + + val snappedStroke = InkStroke( + points = currentStrokePoints.toList(), + isGeometric = snapData.isGeometric, + strokeType = snapData.strokeType + ) + completedStrokes.remove(rawStroke) + completedStrokes.add(snappedStroke) + onStrokeReplaced?.invoke(rawStroke.strokeId, snappedStroke) + + currentStrokePoints.clear() + currentPath.reset() + currentDiagramBounds = null + + // Flush SDK hardware overlay, redraw clean snapped shape + pauseRawDrawing() + drawToSurface() + resumeRawDrawing() + } else if (dwellIndicatorShown) { + // Start-dwell without shape → check if stroke looks like drawing + // (layer 2 disambiguation: reject text-like strokes even with dwell) + val stroke = InkStroke( + points = currentStrokePoints.toList(), + isGeometric = false, + strokeType = StrokeType.FREEHAND + ) + val drawingScore = DiagramStrokeClassifier.classifyStroke( + stroke, LINE_SPACING, includeConnector = false + ) + if (drawingScore >= 0.5f) { + // Drawing-like stroke with dwell → create freeform diagram zone + completedStrokes.add(stroke) + onStrokeCompleted?.invoke(stroke) + onDiagramShapeDetected?.invoke(stroke) + dwellDotCenter = null + dwellIndicatorShown = false + currentStrokePoints.clear() + currentPath.reset() + currentDiagramBounds = null + pauseRawDrawing() + drawToSurface() + resumeRawDrawing() + } else { + // Text-like stroke despite dwell → treat as normal text + completedStrokes.add(stroke) + onStrokeCompleted?.invoke(stroke) + dwellDotCenter = null + dwellIndicatorShown = false + currentStrokePoints.clear() + currentPath.reset() + currentDiagramBounds = null + } + } else { + val stroke = InkStroke(points = currentStrokePoints.toList()) + completedStrokes.add(stroke) + onStrokeCompleted?.invoke(stroke) + currentStrokePoints.clear() + currentPath.reset() + currentDiagramBounds = null + + } + } } - // --- Gesture detection --- + // --- Diagram area helpers --- /** Check if a document-space Y coordinate falls inside a diagram area. */ private fun isInDiagramArea(docY: Float): Boolean { @@ -516,377 +616,306 @@ class HandwritingCanvasView @JvmOverloads constructor( return Pair(topY, bottomY) } - /** Temporarily constrain Onyx SDK drawing area to the diagram bounds (screen coords). */ - private fun setDiagramLimitRect(topY: Float, bottomY: Float) { - if (!useOnyxSdk) return - try { - val limit = Rect() - limit.left = 0 - limit.right = (width - GUTTER_WIDTH).toInt() - limit.top = (topY - scrollOffsetY).toInt().coerceAtLeast(0) - limit.bottom = (bottomY - scrollOffsetY).toInt().coerceAtMost(height) - touchHelper?.setLimitRect(limit, emptyList()) - diagramLimitActive = true - } catch (e: Exception) { - Log.w(TAG, "Error setting diagram limit rect: ${e.message}") - } - } - - /** Restore Onyx SDK drawing area to the full canvas minus gutter. */ - private fun restoreLimitRect() { - if (!diagramLimitActive || !useOnyxSdk) return - try { - val limit = Rect() - getLocalVisibleRect(limit) - limit.right = (limit.right - GUTTER_WIDTH).toInt() - touchHelper?.setLimitRect(limit, emptyList()) - diagramLimitActive = false - } catch (e: Exception) { - Log.w(TAG, "Error restoring limit rect: ${e.message}") - } - } - - /** - * Initialise the running stroke bounding box from the first point of a new stroke. - * Must be called once at stroke start (ACTION_DOWN / onBeginRawDrawing). - */ - private fun initStrokeBounds(p: StrokePoint) { - strokeMinX = p.x; strokeMaxX = p.x - strokeMinY = p.y; strokeMaxY = p.y - } + // ── Diagram-area post-stroke detection ────────────────────────────────── - /** - * Expand the running bounding box to include [p]. - * - * Called in O(1) on every incoming point so that [checkGestures] can read - * xRange and yRange in O(1) rather than scanning [currentStrokePoints] each time. - * - * The naive approach called currentStrokePoints.maxOf/minOf on every point: - * O(n) per point → O(n²) per stroke. Profiling with STROKE_DIAG confirmed - * this cost: 136 ms for a 551-point stroke, 267 ms for a 781-point stroke. - * With running bounds the total cost across the whole stroke is O(n); - * the same strokes measured 3 ms and were not observed in the new session. - */ - private fun updateStrokeBounds(p: StrokePoint) { - if (p.x < strokeMinX) strokeMinX = p.x - if (p.x > strokeMaxX) strokeMaxX = p.x - if (p.y < strokeMinY) strokeMinY = p.y - if (p.y > strokeMaxY) strokeMaxY = p.y - } + /** Result of shape snap: strokeType + isGeometric flag. */ + private data class SnapData( + val strokeType: StrokeType, + val isGeometric: Boolean + ) /** - * Check if the in-progress stroke qualifies as a gesture. - * Called during Onyx SDK move events with screen-space coordinates. - * Detects vertical strokes (line-drag) and horizontal strokes (undo). - * - * xRange and yRange are read from the running bounding box — O(1) per call. + * Attempt to snap the completed stroke to a known geometric shape. + * Only called for strokes inside diagram areas. */ - private fun checkGestures(screenX: Float, screenY: Float) { - if (lineDragActive || diagramInsertActive || undoGestureReady || undoScrubActive) return - if (currentStrokePoints.size < 3) return - // No gestures start inside diagram areas - if (isInDiagramArea(currentStrokePoints.first().y)) return - - val yDelta = currentStrokePoints.last().y - currentStrokePoints.first().y - val absYDelta = kotlin.math.abs(yDelta) - val xRange = strokeMaxX - strokeMinX - val yRange = strokeMaxY - strokeMinY - - // Vertical stroke → line-drag or diagram insert - if (absYDelta > LINE_DRAG_MIN_SPANS * LINE_SPACING) { - if (yDelta > 0 && isScribbleStart()) { - // Scribble then downward → diagram insert - activateDiagramInsert(screenX, screenY) - } else if (xRange < absYDelta * LINE_DRAG_MAX_DRIFT) { - activateLineDrag(screenX, screenY) - } - return - } - - // Horizontal stroke → undo ready - if (xRange > UNDO_HORIZONTAL_MIN_SPANS * LINE_SPACING && yRange < xRange * UNDO_MAX_VERTICAL_DRIFT) { - activateUndoReady(screenY) + private fun checkShapeSnap(tailDwell: Boolean = false): SnapData? { + if (currentStrokePoints.size < 2) return null + + // Shape snapping requires a dwell at the end of the stroke — the user + // holds the pen still briefly to signal "snap this to a shape". + // Without the dwell, the stroke is treated as freehand. + val last = currentStrokePoints.last() + val hasEndDwell = ArrowDwellDetection.hasDwellAtEnd( + currentStrokePoints, last.x, last.y, ARROW_DWELL_RADIUS_PX, ARROW_DWELL_MS + ) + if (!hasEndDwell) return null + + val xs = FloatArray(currentStrokePoints.size) { currentStrokePoints[it].x } + val ys = FloatArray(currentStrokePoints.size) { currentStrokePoints[it].y } + val result = ShapeSnapDetection.detect(xs, ys, LINE_SPACING) + if (result == null) return null + + val t = currentStrokePoints.first().timestamp + var strokeType = StrokeType.FREEHAND + var isGeometric = false + + // Magnetic snap: resolve arrow endpoints to nearest shape perimeters + val nodes = documentModel?.diagram?.nodes ?: emptyMap() + val magnetThreshold = MAGNET_THRESHOLD_SPANS * LINE_SPACING + + fun magnetSnap(x1: Float, y1: Float, x2: Float, y2: Float): Pair, Pair> { + if (nodes.isEmpty()) return Pair(x1 to y1, x2 to y2) + val (from, to, _) = DiagramNodeSnap.snapArrowEndpointsRaw( + x1, y1, x2, y2, nodes, magnetThreshold + ) + return Pair(from, to) } - } - /** - * Fallback version of [checkGestures] for the non-Onyx touch path. - * Also handles in-progress step updates for active gestures inline. - * Reads xRange/yRange from the running bounding box — same O(1) approach. - */ - private fun checkGesturesFallback(screenX: Float, screenY: Float) { - if (undoScrubActive) { - // Already in undo scrub — process vertical movement - val steps = ((screenY - undoScrubTriggerY) / UNDO_STEP_SIZE).toInt() - if (steps != undoScrubLastStep) { - onUndoGestureStep?.invoke(steps) - undoScrubLastStep = steps + val snappedPoints: List = when (result) { + is ShapeSnapDetection.SnapResult.Line -> { + val tipDwell = ArrowDwellDetection.hasDwellAtEnd( + currentStrokePoints, result.x2, result.y2, ARROW_DWELL_RADIUS_PX, ARROW_DWELL_MS + ) + strokeType = ArrowDwellDetection.classifyArrow(tipDwell, tailDwell) + isGeometric = true + + val (from, to) = magnetSnap(result.x1, result.y1, result.x2, result.y2) + listOf( + StrokePoint(from.first, from.second, 0f, t), + StrokePoint(to.first, to.second, 0f, t) + ) } - return - } - if (undoGestureReady) { - // Waiting for vertical activation - processUndoReadyMove(screenY) - return - } - if (diagramInsertActive) { - processDiagramInsertMove(screenY) - return - } - if (lineDragActive) { - // Already in line-drag — process vertical movement - processLineDragMove(screenY) - return - } - if (currentStrokePoints.size < 3) return - // No gestures start inside diagram areas - if (isInDiagramArea(currentStrokePoints.first().y)) return - - val yDelta = currentStrokePoints.last().y - currentStrokePoints.first().y - val absYDelta = kotlin.math.abs(yDelta) - val xRange = strokeMaxX - strokeMinX - val yRange = strokeMaxY - strokeMinY - - // Vertical stroke → line-drag or diagram insert - if (absYDelta > LINE_DRAG_MIN_SPANS * LINE_SPACING) { - if (yDelta > 0 && isScribbleStart()) { - activateDiagramInsert(screenX, screenY) - } else if (xRange < absYDelta * LINE_DRAG_MAX_DRIFT) { - activateLineDrag(screenX, screenY) + is ShapeSnapDetection.SnapResult.Arrow -> return null + is ShapeSnapDetection.SnapResult.Elbow -> { + val tipDwell = ArrowDwellDetection.hasDwellAtEnd( + currentStrokePoints, result.x2, result.y2, ARROW_DWELL_RADIUS_PX, ARROW_DWELL_MS + ) + strokeType = ArrowDwellDetection.classifyElbow(tipDwell, tailDwell) + isGeometric = true + + val (from, to) = magnetSnap(result.x1, result.y1, result.x2, result.y2) + listOf( + StrokePoint(from.first, from.second, 0f, t), + StrokePoint(result.cx, result.cy, 0f, t), + StrokePoint(to.first, to.second, 0f, t) + ) } - return - } - - // Horizontal stroke → undo ready - if (xRange > UNDO_HORIZONTAL_MIN_SPANS * LINE_SPACING && yRange < xRange * UNDO_MAX_VERTICAL_DRIFT) { - activateUndoReady(screenY) - } - } - - private fun activateLineDrag(screenX: Float, screenY: Float) { - lineDragActive = true - - val anchorDocY = currentStrokePoints.first().y - val anchorLine = ((anchorDocY - TOP_MARGIN) / LINE_SPACING).toInt().coerceAtLeast(0) - - // Use the anchor line's screen position as the reference, so the - // grabbed line tracks directly under the pen from the start. - lineDragStartScreenY = TOP_MARGIN + anchorLine * LINE_SPACING - scrollOffsetY - lineDragLastShift = 0 - - currentStrokePoints.clear() - currentPath.reset() - - // Disable SDK so onTouchEvent receives all subsequent move/up events - if (useOnyxSdk) { - try { - touchHelper?.setRawDrawingEnabled(false) - } catch (e: Exception) { - Log.w(TAG, "Error disabling SDK for line-drag: ${e.message}") + is ShapeSnapDetection.SnapResult.Arc -> { + val tipDwell = ArrowDwellDetection.hasDwellAtEnd( + currentStrokePoints, result.x2, result.y2, ARROW_DWELL_RADIUS_PX, ARROW_DWELL_MS + ) + strokeType = ArrowDwellDetection.classifyArc(tipDwell, tailDwell) + isGeometric = false + + val (from, to) = magnetSnap(result.x1, result.y1, result.x2, result.y2) + listOf( + StrokePoint(from.first, from.second, 0f, t), + StrokePoint(result.cx, result.cy, 0f, t), + StrokePoint(to.first, to.second, 0f, t) + ) } - } - - onLineDragStart?.invoke(anchorLine) - Log.i(TAG, "Line-drag activated: anchorLine=$anchorLine, screenY=$screenY") - } - - /** - * Handle touch events during an active line-drag gesture. - * The SDK is disabled, so we receive all move/up events here. - */ - private fun handleLineDragTouch(event: MotionEvent): Boolean { - when (event.action) { - MotionEvent.ACTION_MOVE -> { - processLineDragMove(event.y) - return true + is ShapeSnapDetection.SnapResult.Ellipse -> { + strokeType = StrokeType.ELLIPSE + val n = 60 + (0..n).map { i -> + val angle = 2 * Math.PI * i / n + StrokePoint( + (result.cx + result.a * cos(angle)).toFloat(), + (result.cy + result.b * sin(angle)).toFloat(), + 0f, t + ) + } } - MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { - endLineDrag() - return true + is ShapeSnapDetection.SnapResult.RoundedRectangle -> { + strokeType = StrokeType.ROUNDED_RECTANGLE + val r = result.cornerRadius.coerceAtMost( + minOf(result.right - result.left, result.bottom - result.top) / 2f + ) + val cl = result.left + r; val cr = result.right - r + val ct = result.top + r; val cb = result.bottom - r + val arcN = 8 + val list = mutableListOf() + fun arc(cx: Float, cy: Float, startDeg: Double, endDeg: Double) { + for (i in 0 until arcN) { + val a = Math.toRadians(startDeg + (endDeg - startDeg) * i / arcN) + list += StrokePoint((cx + r * cos(a)).toFloat(), + (cy + r * sin(a)).toFloat(), 0f, t) + } + } + arc(cr, ct, -90.0, 0.0); list += StrokePoint(result.right, cb, 0f, t) + arc(cr, cb, 0.0, 90.0); list += StrokePoint(cl, result.bottom, 0f, t) + arc(cl, cb, 90.0, 180.0); list += StrokePoint(result.left, ct, 0f, t) + arc(cl, ct, 180.0, 270.0); list += StrokePoint(cr, result.top, 0f, t) + list + } + is ShapeSnapDetection.SnapResult.Rectangle -> { + strokeType = StrokeType.RECTANGLE + isGeometric = true + listOf( + StrokePoint(result.left, result.top, 0f, t), + StrokePoint(result.right, result.top, 0f, t), + StrokePoint(result.right, result.bottom, 0f, t), + StrokePoint(result.left, result.bottom, 0f, t), + StrokePoint(result.left, result.top, 0f, t), + ) + } + is ShapeSnapDetection.SnapResult.Diamond -> { + strokeType = StrokeType.DIAMOND + isGeometric = true + val cx = (result.left + result.right) / 2f + val cy = (result.top + result.bottom) / 2f + listOf( + StrokePoint(cx, result.top, 0f, t), + StrokePoint(result.right, cy, 0f, t), + StrokePoint(cx, result.bottom, 0f, t), + StrokePoint(result.left, cy, 0f, t), + StrokePoint(cx, result.top, 0f, t), + ) + } + is ShapeSnapDetection.SnapResult.Triangle -> { + strokeType = StrokeType.TRIANGLE + isGeometric = true + listOf( + StrokePoint(result.x1, result.y1, 0f, t), + StrokePoint(result.x2, result.y2, 0f, t), + StrokePoint(result.x3, result.y3, 0f, t), + StrokePoint(result.x1, result.y1, 0f, t), + ) + } + is ShapeSnapDetection.SnapResult.Curve -> { + val tipDwell = ArrowDwellDetection.hasDwellAtEnd( + currentStrokePoints, last.x, last.y, ARROW_DWELL_RADIUS_PX, ARROW_DWELL_MS + ) + strokeType = ArrowDwellDetection.classifyArc(tipDwell, tailDwell) + isGeometric = false + + result.points.map { (x, y) -> StrokePoint(x, y, 0f, t) } + } + is ShapeSnapDetection.SnapResult.SelfLoop -> { + val tipDwell = ArrowDwellDetection.hasDwellAtEnd( + currentStrokePoints, last.x, last.y, ARROW_DWELL_RADIUS_PX, ARROW_DWELL_MS + ) + strokeType = ArrowDwellDetection.classifyArc(tipDwell, tailDwell) + isGeometric = false + + val nPts = 40 + (0..nPts).map { i -> + val angle = result.startAngle + result.sweepAngle * i.toFloat() / nPts + StrokePoint( + (result.cx + result.rx * cos(angle.toDouble())).toFloat(), + (result.cy + result.ry * sin(angle.toDouble())).toFloat(), + 0f, t + ) + } } } - return true - } - - private fun processLineDragMove(screenY: Float) { - val delta = screenY - lineDragStartScreenY - val shiftLines = kotlin.math.floor(delta / LINE_SPACING).toInt() - if (shiftLines != lineDragLastShift) { - lineDragLastShift = shiftLines - onLineDragStep?.invoke(shiftLines) - } - } - - private fun endLineDrag() { - lineDragActive = false - lineDragLastShift = 0 currentStrokePoints.clear() - currentPath.reset() - onLineDragEnd?.invoke() - finishInteractiveGesture() - Log.i(TAG, "Line-drag ended") - } - - // --- Diagram insert gesture (scribble → downward drag) --- - - /** Check if the first segment of the stroke is a scribble (high path complexity). */ - private fun isScribbleStart(): Boolean { - val points = currentStrokePoints - if (points.size < 10) return false + currentStrokePoints.addAll(snappedPoints) + Log.i(TAG, "Shape snap: $result → $strokeType") - val segmentEnd = (points.size * SCRIBBLE_SEGMENT_FRACTION).toInt().coerceAtLeast(5) - val segment = points.subList(0, segmentEnd) - - var pathLen = 0f - for (i in 1 until segment.size) { - val dx = segment[i].x - segment[i - 1].x - val dy = segment[i].y - segment[i - 1].y - pathLen += kotlin.math.sqrt(dx * dx + dy * dy) - } - - val dx = segment.last().x - segment.first().x - val dy = segment.last().y - segment.first().y - val displacement = kotlin.math.sqrt(dx * dx + dy * dy) - if (displacement < 1f) return true - - return pathLen / displacement > SCRIBBLE_MIN_COMPLEXITY + return SnapData(strokeType, isGeometric) } - private fun activateDiagramInsert(screenX: Float, screenY: Float) { - diagramInsertActive = true + /** Check if the completed stroke is a scratch-out erase gesture. */ + private fun checkPostStrokeScratchOut(): Boolean { + val diagonal = hypot(strokeMaxX - strokeMinX, strokeMaxY - strokeMinY) + val first = currentStrokePoints.first() + val last = currentStrokePoints.last() + val closeDist = hypot(last.x - first.x, last.y - first.y) + val isClosedLoop = diagonal > 0f && closeDist < ShapeSnapDetection.CLOSE_FRACTION * diagonal + + val xs = FloatArray(currentStrokePoints.size) { currentStrokePoints[it].x } + val yRange = strokeMaxY - strokeMinY + if (!ScratchOutDetection.detect(xs, yRange, LINE_SPACING, isClosedLoop)) return false - val anchorDocY = currentStrokePoints.first().y - val anchorLine = ((anchorDocY - TOP_MARGIN) / LINE_SPACING).toInt().coerceAtLeast(0) + val left = strokeMinX; val top = strokeMinY + val right = strokeMaxX; val bottom = strokeMaxY - diagramInsertStartScreenY = TOP_MARGIN + anchorLine * LINE_SPACING - scrollOffsetY - diagramInsertLastHeight = 0 + // Only treat as scratch-out if: + // 1) It intersects existing strokes, AND + // 2) The bulk of its path is focused on crossing those strokes + // (not just grazing a descender while writing new text) + val scratchPoints = currentStrokePoints.toList() + if (!ScratchOutDetection.isFocusedScratchOut(scratchPoints, completedStrokes)) return false currentStrokePoints.clear() currentPath.reset() - if (useOnyxSdk) { - try { - touchHelper?.setRawDrawingEnabled(false) - } catch (e: Exception) { - Log.w(TAG, "Error disabling SDK for diagram insert: ${e.message}") - } - } + pauseRawDrawing() + onScratchOut?.invoke(scratchPoints, left, top, right, bottom) + resumeRawDrawing() - onDiagramInsertStart?.invoke(anchorLine) - Log.i(TAG, "Diagram insert activated: anchorLine=$anchorLine") + Log.i(TAG, "Post-stroke scratch-out: region=[$left,$top,$right,$bottom]") + return true } - private fun handleDiagramInsertTouch(event: MotionEvent): Boolean { - when (event.action) { - MotionEvent.ACTION_MOVE -> { - processDiagramInsertMove(event.y) - return true - } - MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { - endDiagramInsert() - return true + // ── Dwell helpers ───────────────────────────────────────────────────────── + + private fun startDwellJob() { + dwellIndicatorShown = false + dwellDotCenter = null + val job = Runnable { + val pts = currentStrokePoints + if (pts.isEmpty()) return@Runnable + val first = pts.first() + val last = pts.lastOrNull() ?: return@Runnable + val dx = last.x - first.x + val dy = last.y - first.y + if (dx * dx + dy * dy < ARROW_DWELL_RADIUS_PX * ARROW_DWELL_RADIUS_PX) { + dwellIndicatorShown = true + dwellDotCenter = PointF(first.x, first.y) + // Draw indicator to surface; on Boox the SDK overlay may obscure it + // mid-stroke, but the dwell state is preserved for finishStroke(). + // Do NOT pause/resume SDK here — it triggers onBeginRawDrawing + // which resets the stroke + dwell state. + drawToSurface() } } - return true + dwellJob = job + handler.postDelayed(job, ARROW_DWELL_MS) } - private fun processDiagramInsertMove(screenY: Float) { - val delta = screenY - diagramInsertStartScreenY - val heightInLines = kotlin.math.floor(delta / LINE_SPACING).toInt() - .coerceAtLeast(DIAGRAM_MIN_HEIGHT) - if (heightInLines != diagramInsertLastHeight) { - diagramInsertLastHeight = heightInLines - onDiagramInsertStep?.invoke(heightInLines) + /** Cancel dwell if the pen has moved significantly from the first point. */ + private fun checkDwellCancellation() { + val first = currentStrokePoints.firstOrNull() ?: return + val last = currentStrokePoints.lastOrNull() ?: return + val dx = last.x - first.x + val dy = last.y - first.y + val distSq = dx * dx + dy * dy + // Cancel pending timer if pen moved beyond dwell radius + if (dwellJob != null && distSq > ARROW_DWELL_RADIUS_PX * ARROW_DWELL_RADIUS_PX) { + cancelDwellJob() } - } - - private fun endDiagramInsert() { - diagramInsertActive = false - diagramInsertLastHeight = 0 - currentStrokePoints.clear() - currentPath.reset() - onDiagramInsertEnd?.invoke() - finishInteractiveGesture() - Log.i(TAG, "Diagram insert ended") - } - - // --- Undo gesture (horizontal stroke → vertical scrub) --- - - private fun activateUndoReady(screenY: Float) { - undoGestureReady = true - undoReadyScreenY = screenY - Log.i(TAG, "Undo gesture ready (horizontal stroke detected), screenY=$screenY") - } - - private fun handleUndoTouch(event: MotionEvent): Boolean { - when (event.action) { - MotionEvent.ACTION_MOVE -> { - val steps = ((event.y - undoScrubTriggerY) / UNDO_STEP_SIZE).toInt() - if (steps != undoScrubLastStep) { - undoScrubLastStep = steps - onUndoGestureStep?.invoke(steps) - } - return true - } - MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { - endUndoGesture() - return true - } + // Clear already-fired dwell if pen moved beyond 2× radius + if (dwellIndicatorShown && distSq > ARROW_DWELL_RADIUS_PX * ARROW_DWELL_RADIUS_PX * 4) { + dwellIndicatorShown = false + dwellDotCenter = null } - return true } - private fun processUndoReadyMove(screenY: Float) { - if (kotlin.math.abs(screenY - undoReadyScreenY) > UNDO_VERTICAL_ACTIVATION * LINE_SPACING) { - // Vertical movement confirmed — now disable SDK and activate undo scrub - currentStrokePoints.clear() - currentPath.reset() - if (useOnyxSdk) { - try { - touchHelper?.setRawDrawingEnabled(false) - } catch (e: Exception) { - Log.w(TAG, "Error disabling SDK for undo gesture: ${e.message}") - } - } - undoScrubActive = true - undoScrubTriggerY = undoReadyScreenY - undoScrubLastStep = 0 - onUndoGestureStart?.invoke() - // Process the initial step immediately - val steps = ((screenY - undoScrubTriggerY) / UNDO_STEP_SIZE).toInt() - if (steps != 0) { - undoScrubLastStep = steps - onUndoGestureStep?.invoke(steps) - } - Log.i(TAG, "Undo scrub activated at screenY=$screenY") - } + private fun cancelDwellJob() { + dwellJob?.let { handler.removeCallbacks(it) } + dwellJob = null } - private fun endUndoGesture() { - val wasActive = undoScrubActive - undoGestureReady = false - undoScrubActive = false - undoScrubLastStep = 0 - currentStrokePoints.clear() - currentPath.reset() - if (wasActive) { - onUndoGestureEnd?.invoke() - } - finishInteractiveGesture() - Log.i(TAG, "Undo gesture ended (wasActive=$wasActive)") + // ── Running stroke bounding box ─────────────────────────────────────────── + + /** + * Initialise the running stroke bounding box from the first point of a new stroke. + * Must be called once at stroke start (ACTION_DOWN / onBeginRawDrawing). + */ + private fun initStrokeBounds(p: StrokePoint) { + strokeMinX = p.x; strokeMaxX = p.x + strokeMinY = p.y; strokeMaxY = p.y } - /** Common cleanup after any interactive gesture (line-drag or undo scrub). */ - private fun finishInteractiveGesture() { - drawToSurface() - if (useOnyxSdk) { - try { - touchHelper?.setRawDrawingEnabled(true) - } catch (e: Exception) { - Log.w(TAG, "Error re-enabling SDK after gesture: ${e.message}") - } - } - handler.postDelayed(idleRunnable, IDLE_TIMEOUT_MS) + /** + * Expand the running bounding box to include [p]. + * + * Called in O(1) on every incoming point so that post-stroke detection can read + * xRange and yRange in O(1) rather than scanning [currentStrokePoints] each time. + * + * The naive approach called currentStrokePoints.maxOf/minOf on every point: + * O(n) per point → O(n²) per stroke. Profiling with STROKE_DIAG confirmed + * this cost: 136 ms for a 551-point stroke, 267 ms for a 781-point stroke. + * With running bounds the total cost across the whole stroke is O(n); + * the same strokes measured 3 ms and were not observed in the new session. + */ + private fun updateStrokeBounds(p: StrokePoint) { + if (p.x < strokeMinX) strokeMinX = p.x + if (p.x > strokeMaxX) strokeMaxX = p.x + if (p.y < strokeMinY) strokeMinY = p.y + if (p.y > strokeMaxY) strokeMaxY = p.y } /** Draw all content to the SurfaceView's surface. */ @@ -904,25 +933,17 @@ class HandwritingCanvasView @JvmOverloads constructor( // Clear background canvas.drawColor(Color.WHITE) - val gutterLeft = width - GUTTER_WIDTH + val canvasRight = width.toFloat() // Apply scroll offset canvas.save() canvas.translate(0f, -scrollOffsetY) - // Draw ruled lines (skip interior of diagram areas, darken borders) + // Draw ruled lines (text lines show through diagram areas) val maxDocY = scrollOffsetY + height + LINE_SPACING var lineY = TOP_MARGIN + LINE_SPACING while (lineY < maxDocY) { - val lineIdx = ((lineY - TOP_MARGIN) / LINE_SPACING).toInt() - val isTopBorder = diagramAreas.any { lineIdx == it.startLineIndex } - val isBottomBorder = diagramAreas.any { lineIdx == it.endLineIndex + 1 } - val isInterior = diagramAreas.any { lineIdx > it.startLineIndex && lineIdx <= it.endLineIndex } - if (isTopBorder || isBottomBorder) { - canvas.drawLine(0f, lineY, gutterLeft, lineY, diagramBorderPaint) - } else if (!isInterior) { - canvas.drawLine(0f, lineY, gutterLeft, lineY, linePaint) - } + canvas.drawLine(0f, lineY, canvasRight, lineY, linePaint) lineY += LINE_SPACING } @@ -940,13 +961,21 @@ class HandwritingCanvasView @JvmOverloads constructor( canvas.drawPath(currentPath, strokePaint) } - canvas.restore() + // Draw dwell indicator dot (start-dwell) + dwellDotCenter?.let { dot -> + canvas.drawCircle(dot.x, dot.y, CanvasTheme.DEFAULT_STROKE_WIDTH * 3f, dwellDotPaint) + } + + // Draw dashed borders around all diagram areas + for (area in diagramAreas) { + val topY = TOP_MARGIN + area.startLineIndex * LINE_SPACING + val bottomY = TOP_MARGIN + (area.startLineIndex + area.heightInLines) * LINE_SPACING + canvas.drawRect(0f, topY, canvasRight, bottomY, freeformPreviewPaint) + } - // Draw gutter (in screen space) - canvas.drawRect(gutterLeft, 0f, width.toFloat(), height.toFloat(), gutterPaint) - canvas.drawLine(gutterLeft, 0f, gutterLeft, height.toFloat(), gutterLinePaint) + canvas.restore() - // Draw tutorial annotations on top of everything (including gutter) + // Draw tutorial annotations on top of everything if (annotationStrokes.isNotEmpty() || textAnnotations.isNotEmpty()) { canvas.save() canvas.translate(0f, -scrollOffsetY) diff --git a/app/src/main/java/com/writer/view/LineDragDetection.kt b/app/src/main/java/com/writer/view/LineDragDetection.kt new file mode 100644 index 0000000..8e6eae8 --- /dev/null +++ b/app/src/main/java/com/writer/view/LineDragDetection.kt @@ -0,0 +1,76 @@ +package com.writer.view + +import kotlin.math.abs +import kotlin.math.roundToInt + +/** + * Pure geometry functions for line-drag gesture detection. + * + * Extracted from [HandwritingCanvasView] to allow JVM unit testing + * without Android framework dependencies. + */ +object LineDragDetection { + + /** + * Minimum vertical span (in line spacings) to classify a stroke as a line-drag. + * + * Set to 2.0 so the gesture must span two full line spacings (≈ 15 mm at 300 PPI). + * This is far taller than any single handwritten letter (including capitals and + * ascenders) and eliminates the false positives that caused writing strokes near + * the right margin to be silently consumed after a diagram was drawn (Bug #6). + */ + const val MIN_SPANS = 2f + + /** + * Maximum allowed horizontal drift as a fraction of the stroke's vertical span. + * A perfectly vertical stroke has drift 0; a diagonal has drift ≥ 1. + */ + const val MAX_DRIFT = 0.3f + + /** + * Determine whether a completed stroke has the shape of a line-drag gesture. + * + * A line-drag is a nearly-vertical stroke: its net vertical displacement must + * exceed [MIN_SPANS] line spacings, and its horizontal bounding-box width must + * be less than [MAX_DRIFT] times the vertical displacement. + * + * @param firstY doc-space Y of stroke start (first point) + * @param lastY doc-space Y of stroke end (last point) + * @param xRange horizontal bounding-box width: maxX − minX across all points + * @param lineSpacing line spacing in pixels + * @return the shift in lines (positive = downward, negative = upward), + * or null if the stroke is not a line-drag + */ + fun detect(firstY: Float, lastY: Float, xRange: Float, lineSpacing: Float): Int? { + val yDelta = lastY - firstY + val absYDelta = abs(yDelta) + if (absYDelta <= MIN_SPANS * lineSpacing) return null + if (xRange >= absYDelta * MAX_DRIFT) return null + return (yDelta / lineSpacing).roundToInt() + } + + /** + * Returns true if the stroke would snap to a straight line via [ShapeSnapDetection], + * meaning it should NOT be consumed as a line-drag gesture. + */ + fun isSnappableLine(xs: FloatArray, ys: FloatArray, lineSpacing: Float): Boolean { + return ShapeSnapDetection.detect(xs, ys, lineSpacing) is ShapeSnapDetection.SnapResult.Line + } + + /** + * Returns true if the stroke's leftmost X position is within the valid line-drag zone: + * the rightmost [gutterWidth] px of the writing area (i.e. the column immediately to + * the left of the scroll gutter). + * + * This guard prevents tall narrow writing strokes (ascender letters, digit '1', etc.) + * in the main text area from being falsely consumed as line-drag gestures. Only + * deliberate gestures drawn right beside the gutter should trigger a drag. + * + * @param strokeMinX leftmost X coordinate of the stroke (document space) + * @param canvasWidth full width of the writing canvas in px (before gutter is excluded) + * @param gutterWidth width of the scroll gutter in px + */ + fun isInDragZone(strokeMinX: Float, canvasWidth: Float, gutterWidth: Float): Boolean { + return strokeMinX >= canvasWidth - gutterWidth * 2 + } +} diff --git a/app/src/main/java/com/writer/view/PreviewLayoutCalculator.kt b/app/src/main/java/com/writer/view/PreviewLayoutCalculator.kt new file mode 100644 index 0000000..d8e780c --- /dev/null +++ b/app/src/main/java/com/writer/view/PreviewLayoutCalculator.kt @@ -0,0 +1,165 @@ +package com.writer.view + +import com.writer.model.DiagramArea + +/** + * Pure-logic calculator for the complementary preview layout. + * + * Determines which lines are currently hidden (scrolled off the canvas), + * which diagram areas are visible in the preview, and how to size/clip + * diagram render items so the preview is flush with the canvas boundary. + * + * All methods are side-effect-free and depend only on their parameters, + * making them easy to test on JVM without Android framework dependencies. + */ +object PreviewLayoutCalculator { + + // ── Line visibility ───────────────────────────────────────────────── + + /** + * Returns the set of line indices whose bottom edge is at or above [scrollOffsetY]. + * These lines are fully scrolled off the canvas and should appear in the preview. + */ + fun currentlyHiddenLines( + lineIndices: Set, + scrollOffsetY: Float, + topMargin: Float, + lineSpacing: Float + ): Set = lineIndices.filter { lineIdx -> + val lineBottom = topMargin + lineIdx * lineSpacing + lineSpacing + lineBottom <= scrollOffsetY + }.toSet() + + /** + * Returns the set of line indices whose midpoint is at or above [scrollOffsetY]. + * Used for dimming: a line is "not yet visible" once its midpoint has scrolled off. + */ + fun notYetVisibleLines( + lineIndices: Set, + scrollOffsetY: Float, + topMargin: Float, + lineSpacing: Float + ): Set = lineIndices.filter { lineIdx -> + val lineMid = topMargin + lineIdx * lineSpacing + lineSpacing / 2f + lineMid <= scrollOffsetY + }.toSet() + + // ── Diagram visibility ────────────────────────────────────────────── + + /** Result of computing diagram visibility for the preview. */ + data class DiagramVisibility( + val startLineIndex: Int, + /** Full area height in document pixels. */ + val fullHeight: Float, + /** Document-space Y of the area top. */ + val areaTop: Float, + /** How much of the diagram is scrolled off the canvas (capped at stroke bounds). */ + val visibleHeight: Float, + /** Whether this is a partial render (not all strokes visible yet). */ + val isPartial: Boolean + ) + + /** + * Determine which diagram areas should appear in the preview and how much + * of each is visible. A diagram appears as soon as its top edge scrolls off + * the canvas. The visible height is capped at the actual stroke bottom + * (not the full area height) to avoid empty-line gaps. + */ + fun diagramVisibilities( + areas: List, + scrollOffsetY: Float, + topMargin: Float, + lineSpacing: Float, + /** For each area, the max Y of its strokes in document space (null if no strokes). */ + strokeMaxYByArea: Map, + strokeWidthPadding: Float + ): List { + return areas.mapNotNull { area -> + val areaTop = topMargin + area.startLineIndex * lineSpacing + if (areaTop >= scrollOffsetY) return@mapNotNull null // not scrolled off yet + + val fullHeight = area.heightInLines * lineSpacing + val scrolledOff = (scrollOffsetY - areaTop).coerceIn(0f, fullHeight) + + val strokeMaxY = strokeMaxYByArea[area.startLineIndex] + val strokeBottom = if (strokeMaxY != null) { + (strokeMaxY - areaTop + strokeWidthPadding).coerceAtMost(fullHeight) + } else { + fullHeight + } + + val visibleHeight = scrolledOff.coerceAtMost(strokeBottom) + + DiagramVisibility( + startLineIndex = area.startLineIndex, + fullHeight = fullHeight, + areaTop = areaTop, + visibleHeight = visibleHeight, + isPartial = visibleHeight < fullHeight + ) + } + } + + // ── Render item layout ────────────────────────────────────────────── + + /** Computed layout metrics for a diagram render item. */ + data class DiagramRenderMetrics( + val scale: Float, + val fullRenderedHeight: Float, + val renderedHeight: Float, + val isPartial: Boolean + ) + + /** + * Compute the render metrics for a diagram in the preview. + * + * @param visibleHeight How much of the diagram is scrolled off (document px) + * @param fullHeight Full diagram area height (document px) + * @param canvasWidth Width of the canvas (source coordinate space) + * @param textViewWidth Width of the text view (target coordinate space) + * @param paragraphSpacing Spacing added between render items + */ + fun diagramRenderMetrics( + visibleHeight: Float, + fullHeight: Float, + canvasWidth: Float, + textViewWidth: Float, + paragraphSpacing: Float + ): DiagramRenderMetrics { + val scale = if (canvasWidth > 0f) textViewWidth / canvasWidth else 1f + val isPartial = visibleHeight < fullHeight + val fullRenderedHeight = fullHeight * scale + paragraphSpacing + val renderedHeight = visibleHeight * scale + if (isPartial) 0f else paragraphSpacing + + return DiagramRenderMetrics( + scale = scale, + fullRenderedHeight = fullRenderedHeight, + renderedHeight = renderedHeight, + isPartial = isPartial + ) + } + + /** + * Strip trailing spacing from the last item height so the preview content + * is flush with the divider. Returns the trimmed height. + * + * @param heightPx The item's current rendered height + * @param fullHeightPx The item's full rendered height (including spacing) + * @param paragraphSpacing Spacing to remove + * @param isText True if text item, false if diagram + * @param textLayoutHeight The text StaticLayout height (only used for text items) + */ + fun trimLastItemHeight( + heightPx: Float, + fullHeightPx: Float, + paragraphSpacing: Float, + isText: Boolean, + textLayoutHeight: Float = 0f + ): Float { + return if (isText) { + textLayoutHeight + } else { + heightPx.coerceAtMost(fullHeightPx - paragraphSpacing) + } + } +} diff --git a/app/src/main/java/com/writer/view/RecognizedTextView.kt b/app/src/main/java/com/writer/view/RecognizedTextView.kt index a9741cb..f7175d3 100644 --- a/app/src/main/java/com/writer/view/RecognizedTextView.kt +++ b/app/src/main/java/com/writer/view/RecognizedTextView.kt @@ -18,8 +18,9 @@ import android.util.AttributeSet import android.view.MotionEvent import android.view.View import com.writer.model.InkStroke -import com.writer.ui.writing.WritingCoordinator.DiagramDisplay -import com.writer.ui.writing.WritingCoordinator.TextSegment +import com.writer.ui.writing.DiagramDisplay +import com.writer.ui.writing.DiagramTextLabel +import com.writer.ui.writing.TextSegment /** * Displays recognized text as flowing word-wrapped paragraphs. @@ -27,7 +28,7 @@ import com.writer.ui.writing.WritingCoordinator.TextSegment * Individual line segments within a paragraph can be dimmed independently * using colored spans. * - * Includes a right-side gutter with a "I" logo and resize drag handling. + * Includes a floating "I" logo icon that auto-hides during writing. */ class RecognizedTextView @JvmOverloads constructor( context: Context, @@ -36,7 +37,6 @@ class RecognizedTextView @JvmOverloads constructor( ) : View(context, attrs, defStyleAttr) { companion object { - private val GUTTER_WIDTH get() = ScreenMetrics.gutterWidth private val HORIZONTAL_PADDING get() = ScreenMetrics.dp(21f) private val PARAGRAPH_SPACING get() = ScreenMetrics.dp(12f) private val LIST_ITEM_SPACING get() = ScreenMetrics.dp(3f) @@ -45,7 +45,11 @@ class RecognizedTextView @JvmOverloads constructor( private val BULLET_HANG_INDENT get() = ScreenMetrics.dp(54f).toInt() private const val BULLET_PREFIX = "\u2022 " private val HEADING_SPACING_AFTER get() = ScreenMetrics.dp(6f) - private val BOTTOM_PADDING get() = ScreenMetrics.dp(5f) + + // Gutter tap zone thresholds (multiples of GUTTER_WIDTH from top) + private const val GUTTER_LOGO_ZONE = 1.2f + private const val GUTTER_UNDO_ZONE = 2.4f + private const val GUTTER_REDO_ZONE = 3.6f } private val textPaint = TextPaint().apply { @@ -56,9 +60,6 @@ class RecognizedTextView @JvmOverloads constructor( private val dimmedColor = CanvasTheme.LINE_COLOR - private val gutterPaint = CanvasTheme.newGutterFillPaint() - private val gutterLinePaint = CanvasTheme.newGutterLinePaint() - private val logoPaint = TextPaint().apply { color = Color.BLACK textSize = ScreenMetrics.textLogo @@ -119,6 +120,11 @@ class RecognizedTextView @JvmOverloads constructor( // Diagram rendering private val diagramStrokePaint = CanvasTheme.newStrokePaint() + private val diagramTextPaint = TextPaint().apply { + color = Color.BLACK + textSize = ScreenMetrics.textBody + isAntiAlias = false + } private val diagramPath = Path() // Render items: text paragraphs and inline diagrams @@ -137,8 +143,10 @@ class RecognizedTextView @JvmOverloads constructor( val strokes: List, val scale: Float, val offsetY: Float, + val fullHeightPx: Float, override val heightPx: Float, - val lineIndex: Int + val lineIndex: Int, + val textLabels: List = emptyList() ) : RenderItem() @@ -169,12 +177,21 @@ class RecognizedTextView @JvmOverloads constructor( /** Pixel offset to scroll text content upward (for viewing earlier text). */ var textContentScroll: Float = 0f - /** Called when the user drags the gutter. Delta is positive = drag down. */ - var onGutterDrag: ((Float) -> Unit)? = null + /** Called when the user scrolls the text view. Delta is in pixels (positive = finger drag down). */ + var onScroll: ((Float) -> Unit)? = null + + /** Called when a scroll gesture ends (finger lifted). */ + var onScrollEnd: (() -> Unit)? = null /** Called when the user taps the "I" logo. */ var onLogoTap: (() -> Unit)? = null + /** Called when the user taps the undo button in the gutter. */ + var onUndoTap: (() -> Unit)? = null + + /** Called when the user taps the redo button in the gutter. */ + var onRedoTap: (() -> Unit)? = null + /** Called when the user taps on a text segment. Passes the written lineIndex. */ var onTextTap: ((Int) -> Unit)? = null @@ -192,17 +209,39 @@ class RecognizedTextView @JvmOverloads constructor( invalidate() } - // Gutter drag state - private var isGutterDragging = false - private var gutterDragLastY = 0f - private var gutterDragStartY = 0f - private var gutterDragMoved = false + // Floating icon visibility (auto-hides during writing) + private var iconVisible = true + private val iconSize get() = ScreenMetrics.dp(56f) + private val iconShowRunnable = Runnable { + iconVisible = true + invalidate() + } // Text tap tracking private var textTapDownX = 0f private var textTapDownY = 0f private var textTapTracking = false + /** Shared palm-rejection filter, set by WritingActivity. */ + var touchFilter: TouchFilter? = null + + // Finger scroll state + private var fingerScrollActive = false + private var fingerScrollLastY = 0f + + /** Called by WritingActivity when pen state changes on the canvas. */ + fun onPenStateChanged(active: Boolean) { + handler.removeCallbacks(iconShowRunnable) + if (active) { + if (iconVisible) { + iconVisible = false + invalidate() + } + } else { + handler.postDelayed(iconShowRunnable, 300L) + } + } + fun setParagraphs(paragraphs: List>) { setContent(paragraphs, emptyList()) } @@ -214,7 +253,7 @@ class RecognizedTextView @JvmOverloads constructor( } private fun rebuildRenderItems(paragraphs: List>, diagrams: List) { - val availableWidth = (width - HORIZONTAL_PADDING - HandwritingCanvasView.GUTTER_WIDTH).toInt() + val availableWidth = (width - 2 * HORIZONTAL_PADDING).toInt() if (availableWidth <= 0) return data class Indexed(val lineIndex: Int, val item: RenderItem, val lineHeights: List>) @@ -316,20 +355,46 @@ class RecognizedTextView @JvmOverloads constructor( // Build diagram render items (full width, no text padding) val diagramItems = diagrams.map { diagram -> - val fullWidth = width - HandwritingCanvasView.GUTTER_WIDTH - val scale = if (diagram.canvasWidth > 0f) fullWidth / diagram.canvasWidth else 1f - val renderedHeight = diagram.heightPx * scale + PARAGRAPH_SPACING + val metrics = PreviewLayoutCalculator.diagramRenderMetrics( + visibleHeight = diagram.visibleHeightPx, + fullHeight = diagram.heightPx, + canvasWidth = diagram.canvasWidth, + textViewWidth = width.toFloat(), + paragraphSpacing = PARAGRAPH_SPACING + ) Indexed( lineIndex = diagram.startLineIndex, - item = DiagramRenderItem(diagram.strokes, scale, diagram.offsetY, renderedHeight, diagram.startLineIndex), - lineHeights = listOf(Pair(diagram.startLineIndex, renderedHeight)) + item = DiagramRenderItem(diagram.strokes, metrics.scale, diagram.offsetY, metrics.fullRenderedHeight, metrics.renderedHeight, diagram.startLineIndex, diagram.textLabels), + lineHeights = listOf(Pair(diagram.startLineIndex, metrics.renderedHeight)) ) } // Merge and sort by line index val allItems = (textItems + diagramItems).sortedBy { it.lineIndex } - renderItems = allItems.map { it.item } + // Strip trailing spacing from the last item so content is flush with the divider + val items = allItems.map { it.item }.toMutableList() + if (items.isNotEmpty()) { + val last = items.last() + when (last) { + is TextRenderItem -> { + val trimmedHeight = PreviewLayoutCalculator.trimLastItemHeight( + last.heightPx, last.heightPx, PARAGRAPH_SPACING, + isText = true, textLayoutHeight = last.layout.height.toFloat() + ) + items[items.lastIndex] = last.copy(heightPx = trimmedHeight) + } + is DiagramRenderItem -> { + val trimmedHeight = PreviewLayoutCalculator.trimLastItemHeight( + last.heightPx, last.fullHeightPx, PARAGRAPH_SPACING, + isText = false + ) + items[items.lastIndex] = last.copy(heightPx = trimmedHeight) + } + } + } + + renderItems = items paragraphHeights = renderItems.map { it.heightPx } writtenLineHeights = allItems.flatMap { it.lineHeights } totalTextHeight = paragraphHeights.sum().toInt() @@ -340,23 +405,23 @@ class RecognizedTextView @JvmOverloads constructor( // Can't rebuild without paragraph data; next setParagraphs call will handle it } + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + handler.removeCallbacks(iconShowRunnable) + } + // --- Touch handling --- override fun onTouchEvent(event: MotionEvent): Boolean { val toolType = event.getToolType(0) - // Reject finger/palm touches + // Finger touches: filter through palm rejection, allow taps and scroll if (toolType == MotionEvent.TOOL_TYPE_FINGER) { - return false - } - - // If already in a gutter drag, keep handling even if pen leaves gutter area - if (isGutterDragging) { - return handleGutterTouch(event) + return handleFingerTouch(event) } // Tutorial: close button tap at top of text area - if (tutorialMode && event.x < width - HandwritingCanvasView.GUTTER_WIDTH && event.y < closeButtonHeight) { + if (tutorialMode && event.y < closeButtonHeight) { if (event.action == MotionEvent.ACTION_DOWN) { return true } @@ -366,9 +431,13 @@ class RecognizedTextView @JvmOverloads constructor( } } - // Stylus/mouse in gutter area → resize drag - if (event.x >= width - HandwritingCanvasView.GUTTER_WIDTH) { - return handleGutterTouch(event) + // Floating icon tap detection + if (iconVisible && event.action == MotionEvent.ACTION_DOWN && isInIconArea(event.x, event.y)) { + return true + } + if (iconVisible && event.action == MotionEvent.ACTION_UP && isInIconArea(event.x, event.y)) { + onLogoTap?.invoke() + return true } // Stylus/mouse in text area → detect taps to scroll canvas to that line @@ -406,42 +475,144 @@ class RecognizedTextView @JvmOverloads constructor( return super.onTouchEvent(event) } - private fun handleGutterTouch(event: MotionEvent): Boolean { + /** + * Handle filtered finger touches on the text view. + * Allows: logo tap, text tap, text body scroll. + */ + private fun handleFingerTouch(event: MotionEvent): Boolean { + val tf = touchFilter ?: return false + val touchMinorDp = event.touchMinor / ScreenMetrics.density + + // If already in a finger scroll, keep handling + if (fingerScrollActive) { + when (event.action) { + MotionEvent.ACTION_MOVE -> { + if (tf.evaluateMove( + pointerCount = event.pointerCount, + touchMinorDp = touchMinorDp, + eventTime = event.eventTime, + x = event.x, + y = event.y, + checkStationary = false, + ) == TouchFilter.Decision.REJECT + ) { + fingerScrollActive = false + return false + } + val dy = event.y - fingerScrollLastY + fingerScrollLastY = event.y + onScroll?.invoke(dy) + return true + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + fingerScrollActive = false + onScrollEnd?.invoke() + return true + } + } + return true + } + when (event.action) { MotionEvent.ACTION_DOWN -> { - isGutterDragging = true - gutterDragLastY = event.y - gutterDragStartY = event.y - gutterDragMoved = false + if (tf.evaluateDown( + pointerCount = event.pointerCount, + touchMinorDp = touchMinorDp, + eventTime = event.eventTime, + x = event.x, + y = event.y, + ) == TouchFilter.Decision.REJECT + ) { + return false + } + + // Floating icon tap + if (iconVisible && isInIconArea(event.x, event.y)) { + return true + } + + // Tutorial close button + if (tutorialMode && event.y < closeButtonHeight) { + return true + } + + // Track for tap or scroll on text body + if (!tutorialMode) { + textTapDownX = event.x + textTapDownY = event.y + textTapTracking = true + fingerScrollLastY = event.y + } return true } MotionEvent.ACTION_MOVE -> { - if (!isGutterDragging) return false - val dy = event.y - gutterDragLastY // drag down = positive - gutterDragLastY = event.y - if (Math.abs(event.y - gutterDragStartY) > 20f) gutterDragMoved = true - onGutterDrag?.invoke(dy) + if (tf.evaluateMove( + pointerCount = event.pointerCount, + touchMinorDp = touchMinorDp, + eventTime = event.eventTime, + x = event.x, + y = event.y, + checkStationary = false, + ) == TouchFilter.Decision.REJECT + ) { + textTapTracking = false + fingerScrollActive = false + return false + } + if (textTapTracking) { + val dx = event.x - textTapDownX + val dy = event.y - textTapDownY + if (dx * dx + dy * dy > 400f) { + textTapTracking = false + // Transition to finger scroll + fingerScrollActive = true + fingerScrollLastY = event.y + } + } + if (fingerScrollActive) { + val dy = event.y - fingerScrollLastY + fingerScrollLastY = event.y + onScroll?.invoke(dy) + } return true } - MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { - if (!isGutterDragging) return false - isGutterDragging = false - // Detect tap on the logo area (top of gutter, no significant drag) - if (!gutterDragMoved && gutterDragStartY < 130f) { + MotionEvent.ACTION_UP -> { + if (iconVisible && isInIconArea(event.x, event.y)) { onLogoTap?.invoke() + return true + } + if (tutorialMode && event.y < closeButtonHeight) { + onCloseTutorialTap?.invoke() + return true + } + if (textTapTracking) { + textTapTracking = false + resolveTextTap(event.x, event.y) + return true } + fingerScrollActive = false + return true + } + MotionEvent.ACTION_CANCEL -> { + textTapTracking = false + fingerScrollActive = false return true } } return false } + /** Check if touch coordinates are within the floating icon tap target. */ + private fun isInIconArea(x: Float, y: Float): Boolean { + return x >= width - iconSize && y <= iconSize + } + /** Resolve a tap at screen coordinates (x, y) to a written lineIndex. */ private fun resolveTextTap(x: Float, y: Float) { if (renderItems.isEmpty()) return val callback = onTextTap ?: return - val baseY = height - totalTextHeight - BOTTOM_PADDING + val baseY = (height - totalTextHeight).toFloat() val startY = baseY + textScrollOffset + textContentScroll val localX = x - HORIZONTAL_PADDING @@ -481,27 +652,24 @@ class RecognizedTextView @JvmOverloads constructor( override fun onDraw(canvas: Canvas) { super.onDraw(canvas) - val gutterLeft = width - HandwritingCanvasView.GUTTER_WIDTH - val gutterCenterX = gutterLeft + HandwritingCanvasView.GUTTER_WIDTH / 2f - // Draw text content or status/hint message if (statusMessage.isNotEmpty() && renderItems.isEmpty()) { // Draw status message centered in the content area - val contentCenterX = (width - HandwritingCanvasView.GUTTER_WIDTH) / 2f + val contentCenterX = width / 2f val contentCenterY = height / 2f canvas.drawText(statusMessage, contentCenterX, contentCenterY, statusPaint) if (statusSubtext.isNotEmpty()) { canvas.drawText(statusSubtext, contentCenterX, contentCenterY + 50f, statusSubtextPaint) } } else if (showScrollHint && renderItems.isEmpty() && !tutorialMode) { - val contentCenterX = (width - HandwritingCanvasView.GUTTER_WIDTH) / 2f + val contentCenterX = width / 2f val contentCenterY = height / 2f canvas.drawText("Scroll to turn writing into text", contentCenterX, contentCenterY, scrollHintPaint) } else if (renderItems.isNotEmpty()) { val baseY = if (tutorialMode) { closeButtonHeight + 5f // top-align below close button } else { - height - totalTextHeight - BOTTOM_PADDING + (height - totalTextHeight).toFloat() } val startY = baseY + textScrollOffset + textContentScroll @@ -519,17 +687,16 @@ class RecognizedTextView @JvmOverloads constructor( canvas.restore() } - // Draw gutter background - canvas.drawRect(gutterLeft, 0f, width.toFloat(), height.toFloat(), gutterPaint) - canvas.drawLine(gutterLeft, 0f, gutterLeft, height.toFloat(), gutterLinePaint) - - // Draw "I" logo in the top of the gutter - val logoY = 100f - canvas.drawText("I", gutterCenterX, logoY, logoPaint) + // Draw floating "I" icon in top-right corner + if (iconVisible) { + val iconCenterX = width - iconSize / 2f + val logoY = iconSize * 0.7f + canvas.drawText("I", iconCenterX, logoY, logoPaint) + } if (tutorialMode) { // "Close Tutorial" button centered at top of text area - val contentCenterX = (width - HandwritingCanvasView.GUTTER_WIDTH) / 2f + val contentCenterX = width / 2f val btnTextY = 52f canvas.drawText("Close Tutorial", contentCenterX, btnTextY, closeButtonPaint) val btnTextWidth = closeButtonPaint.measureText("Close Tutorial") @@ -544,29 +711,30 @@ class RecognizedTextView @JvmOverloads constructor( closeButtonBorderPaint ) - // Arrow pointing at "I" logo saying "Menu" - val menuArrowY = logoY - 20f - val menuArrowRight = gutterLeft - 10f - val menuArrowLeft = gutterLeft - 180f + // Arrow pointing at floating "I" icon saying "Menu" + val iconCenterX = width - iconSize / 2f + val menuArrowY = iconSize * 0.5f + val menuArrowRight = iconCenterX - iconSize / 2f - 10f + val menuArrowLeft = menuArrowRight - 170f canvas.drawLine(menuArrowLeft, menuArrowY, menuArrowRight, menuArrowY, tutorialAnnotationPaint) canvas.drawLine(menuArrowRight - 20f, menuArrowY - 12f, menuArrowRight, menuArrowY, tutorialAnnotationPaint) canvas.drawLine(menuArrowRight - 20f, menuArrowY + 12f, menuArrowRight, menuArrowY, tutorialAnnotationPaint) canvas.drawText("Menu", menuArrowLeft - 110f, menuArrowY + 12f, tutorialTextPaint) - // "Drag gutter to resize" with arrow pointing right toward gutter + // "Drag divider to resize" annotation at bottom val resizeY = height - 60f - val resizeLeft = gutterLeft - 370f - val resizeRight = gutterLeft - 20f - canvas.drawLine(resizeLeft, resizeY, resizeRight, resizeY, tutorialAnnotationPaint) - canvas.drawLine(resizeRight - 20f, resizeY - 12f, resizeRight, resizeY, tutorialAnnotationPaint) - canvas.drawLine(resizeRight - 20f, resizeY + 12f, resizeRight, resizeY, tutorialAnnotationPaint) - canvas.drawText("Drag this gutter to resize", resizeLeft - 10f, resizeY - 21f, tutorialTextPaint) + canvas.drawText("Drag divider to resize", width / 2f, resizeY, tutorialTextPaint) } } private fun drawDiagramItem(canvas: Canvas, item: DiagramRenderItem) { - if (item.strokes.isEmpty()) return + if (item.strokes.isEmpty() && item.textLabels.isEmpty()) return canvas.save() + // Always clip diagram to its allocated height so strokes don't overflow into adjacent items + canvas.clipRect( + -HORIZONTAL_PADDING, 0f, + width.toFloat(), item.heightPx + ) // Undo the HORIZONTAL_PADDING translation so diagram uses full page width canvas.translate(-HORIZONTAL_PADDING, 0f) canvas.scale(item.scale, item.scale) @@ -574,6 +742,18 @@ class RecognizedTextView @JvmOverloads constructor( for (stroke in item.strokes) { CanvasTheme.drawStroke(canvas, stroke, diagramPath, diagramStrokePaint) } + // Draw recognized text labels centered at their stroke group positions + for (label in item.textLabels) { + val textWidth = diagramTextPaint.measureText(label.text) + val metrics = diagramTextPaint.fontMetrics + val textHeight = metrics.descent - metrics.ascent + canvas.drawText( + label.text, + label.x - textWidth / 2f, + label.y + textHeight / 2f - metrics.descent, + diagramTextPaint + ) + } canvas.restore() } } diff --git a/app/src/main/java/com/writer/view/ScratchOutDetection.kt b/app/src/main/java/com/writer/view/ScratchOutDetection.kt new file mode 100644 index 0000000..a46a128 --- /dev/null +++ b/app/src/main/java/com/writer/view/ScratchOutDetection.kt @@ -0,0 +1,430 @@ +package com.writer.view + +import com.writer.model.InkStroke +import com.writer.model.StrokePoint +import com.writer.model.minX +import com.writer.model.maxX +import com.writer.model.minY +import com.writer.model.maxY +import kotlin.math.abs + +/** + * Pure geometry functions for scratch-out (scribble-to-erase) gesture detection. + * + * A scratch-out is a rapid back-and-forth horizontal stroke. Detected post-stroke + * (after pen-up) by counting X-direction reversals. Natural writing strokes have + * at most one reversal; a deliberate scratch-out has two or more. + * + * ## Threshold rationale (standard device, LS = 118 px) + * + * | Constant | Value | Standard device (118 px LS) | + * |---------------------|-------|------------------------------| + * | MIN_REVERSALS | 2 | ≥ 2 direction changes | + * | MIN_X_TRAVEL_SPANS | 1.5 | ≥ 177 px total x-travel | + * | MAX_Y_DRIFT | 0.4 | y-range < 40% of x-travel | + */ +object ScratchOutDetection { + + /** + * Minimum number of direction reversals to qualify as a scratch-out. + * Set to 3 because cursive words naturally have 2 reversals from letter + * loops (e.g., h, l, p, d in "helped"). A deliberate scratch-out has + * rapid back-and-forth motion with 3+ reversals. + */ + const val MIN_REVERSALS = 3 + + /** Minimum total X-travel (sum of all |dx|) in line spacings. */ + const val MIN_X_TRAVEL_SPANS = 0.5f + + /** + * Reversal density threshold: reversals per line-spacing of X-span. + * A tight scribble (many reversals packed into a small area) is unmistakably + * intentional erasing. When density exceeds this, the travel requirement + * is halved so tight scribbles are accepted sooner. + */ + const val TIGHT_DENSITY_THRESHOLD = 3.0f + + /** + * Maximum vertical bounding-box height as a fraction of total X-travel. + * Keeps the scratch roughly horizontal. + */ + const val MAX_Y_DRIFT = 0.4f + + /** + * Maximum net horizontal advance as a fraction of total X-travel. + * A scratch-out goes back and forth over the same region (advance ≈ 0); + * cursive writing progresses steadily forward (advance ≈ word width). + * If `|lastX − firstX| / totalXTravel ≥ MAX_ADVANCE_RATIO`, the stroke + * is progressive writing, not a scratch-out. + */ + const val MAX_ADVANCE_RATIO = 0.4f + + /** + * Maximum path-length / diagonal ratio for a stroke to be considered a closed loop. + * A shape outline traces its perimeter once (ratio ~3-4). + * A zigzag scratch-out covers the same ground repeatedly (ratio >> 4). + * If `pathLength / diagonal ≥ PATH_RATIO_THRESHOLD`, the stroke is a scratch-out + * zigzag even if start ≈ end, and should NOT be classified as a closed loop. + */ + const val PATH_RATIO_THRESHOLD = 4.5f + + /** + * Determine whether a stroke is a true closed loop (shape drawn around content) + * vs a compact scratch-out (start ≈ end but zigzags back and forth). + * + * @param closeDist distance between first and last points + * @param diagonal bounding-box diagonal of the stroke + * @param pathLength total path length of the stroke + * @return true if the stroke is a genuine closed loop (not a scratch-out zigzag) + */ + fun isClosedLoop(closeDist: Float, diagonal: Float, pathLength: Float): Boolean { + if (diagonal <= 0f) return false + if (closeDist >= ShapeSnapDetection.CLOSE_FRACTION * diagonal) return false + // High path/diagonal ratio → zigzag covering same ground, not a shape outline + return pathLength / diagonal < PATH_RATIO_THRESHOLD + } + + /** + * Detect a scratch-out gesture along either axis. + * + * Analyses reversals along both X and Y independently, then uses the axis + * with more reversals as the oscillation axis. The perpendicular axis is + * used for drift and advance checks. This allows scratch-outs in any + * direction — horizontal scribbles over vertical arrows and vice versa. + * + * @param xs x-coordinates of all stroke points + * @param ys y-coordinates of all stroke points (may be empty for + * backward compatibility — falls back to X-only analysis) + * @param lineSpacing line spacing in pixels + * @param isClosedLoop true if the stroke is a closed loop (start ≈ end in 2D space). + * A closed loop is never a scratch-out — it is a shape drawn around + * existing content. Callers should compute this from full (x,y) + * data before calling detect(). + * @return true if the stroke is a scratch-out gesture + */ + fun detect( + xs: FloatArray, ys: FloatArray = floatArrayOf(), + lineSpacing: Float, isClosedLoop: Boolean = false + ): Boolean { + if (isClosedLoop) return false + if (xs.size < 4) return false + + val xStats = axisStats(xs) + val yStats = if (ys.size == xs.size) axisStats(ys) else null + + // Pick the dominant oscillation axis: more reversals, or more travel if tied + val osc: AxisStats // oscillation axis (the back-and-forth direction) + val cross: Float // perpendicular axis range (drift) + val yDominant = yStats != null && (yStats.reversals > xStats.reversals + || (yStats.reversals == xStats.reversals && yStats.totalTravel > xStats.totalTravel)) + if (yDominant) { + osc = yStats + cross = xStats.span + } else { + osc = xStats + cross = yStats?.span ?: (xs.max() - xs.min()) // fallback: yRange not available + } + + if (osc.reversals < MIN_REVERSALS) return false + + // Tight scribble: many reversals packed into a small span. + // Halve the travel requirement when reversal density is high. + val spanInSpans = (osc.span / lineSpacing).coerceAtLeast(0.01f) + val density = osc.reversals / spanInSpans + val travelThreshold = if (density >= TIGHT_DENSITY_THRESHOLD) + MIN_X_TRAVEL_SPANS * lineSpacing * 0.5f + else + MIN_X_TRAVEL_SPANS * lineSpacing + if (osc.totalTravel < travelThreshold) return false + if (cross >= osc.totalTravel * MAX_Y_DRIFT) return false + // Progressive-advance guard: cursive writing advances along the oscillation + // axis while a scratch-out covers the same ground repeatedly. + if (osc.netAdvance >= osc.totalTravel * MAX_ADVANCE_RATIO) return false + return true + } + + /** @suppress backward compat: old signature with yRange instead of ys array */ + fun detect(xs: FloatArray, yRange: Float, lineSpacing: Float, isClosedLoop: Boolean = false): Boolean { + // Delegate to the full version. Without ys, only X-axis reversals are checked + // and yRange is used as the cross-axis drift. + if (isClosedLoop) return false + if (xs.size < 4) return false + + val xStats = axisStats(xs) + if (xStats.reversals < MIN_REVERSALS) return false + + val spanInSpans = (xStats.span / lineSpacing).coerceAtLeast(0.01f) + val density = xStats.reversals / spanInSpans + val travelThreshold = if (density >= TIGHT_DENSITY_THRESHOLD) + MIN_X_TRAVEL_SPANS * lineSpacing * 0.5f + else + MIN_X_TRAVEL_SPANS * lineSpacing + if (xStats.totalTravel < travelThreshold) return false + if (yRange >= xStats.totalTravel * MAX_Y_DRIFT) return false + if (xStats.netAdvance >= xStats.totalTravel * MAX_ADVANCE_RATIO) return false + return true + } + + /** Reversal / travel statistics for a single axis. */ + internal data class AxisStats( + val reversals: Int, + val totalTravel: Float, + val span: Float, + val netAdvance: Float + ) + + /** Compute reversal count, total travel, span, and net advance for one axis. */ + internal fun axisStats(vals: FloatArray): AxisStats { + var reversals = 0 + var totalTravel = 0f + var prevDir = 0 + for (i in 1 until vals.size) { + val d = vals[i] - vals[i - 1] + if (d == 0f) continue + val dir = if (d > 0f) 1 else -1 + totalTravel += abs(d) + if (prevDir != 0 && dir != prevDir) reversals++ + prevDir = dir + } + val span = vals.max() - vals.min() + val netAdvance = abs(vals.last() - vals.first()) + return AxisStats(reversals, totalTravel, span, netAdvance) + } + + /** + * Check whether any existing stroke overlaps the scratch-out bounding box. + * A scratch-out should only erase when there is pre-existing content underneath; + * otherwise new cursive words with many reversals (e.g. "difficulty") are + * consumed as scratch-outs and disappear. + */ + /** + * Minimum fraction of scratch-out points that must be near existing strokes + * for the scratch-out to be considered "focused" on erasing. A real scratch-out + * stays over the target text; cursive writing that grazes a descender has + * most of its points elsewhere. + */ + const val MIN_COVERAGE_FRACTION = 0.3f + + /** + * Maximum distance (in dp) from a scratch-out point to the nearest + * existing stroke point for the point to count as "covering" the target. + * Scaled by density at runtime via [ScreenMetrics.dp]. + */ + const val COVERAGE_RADIUS_DP = 16f + + /** + * Check if a scratch-out stroke is focused on erasing existing strokes. + * + * Two requirements: + * 1. The stroke must physically intersect at least one existing stroke + * 2. At least [MIN_COVERAGE_FRACTION] of the scratch-out's points must be + * near existing stroke points (within [COVERAGE_RADIUS]) + * + * This prevents cursive text that merely grazes a descender from being + * treated as a scratch-out. + */ + fun isFocusedScratchOut( + scratchPoints: List, + existingStrokes: List + ): Boolean { + if (scratchPoints.size < 2 || existingStrokes.isEmpty()) return false + + val scratchMinX = scratchPoints.minOf { it.x } + val scratchMaxX = scratchPoints.maxOf { it.x } + val scratchMinY = scratchPoints.minOf { it.y } + val scratchMaxY = scratchPoints.maxOf { it.y } + val radius = ScreenMetrics.dp(COVERAGE_RADIUS_DP) + + // Pre-filter: only check strokes whose bounding box overlaps the scratch-out + // region (expanded by coverage radius). Avoids expensive intersection checks + // against distant strokes. + val candidates = existingStrokes.filter { stroke -> + val sMinX = stroke.minX + val sMaxX = stroke.maxX + val sMinY = stroke.minY + val sMaxY = stroke.maxY + sMaxX >= scratchMinX - radius && sMinX <= scratchMaxX + radius && + sMaxY >= scratchMinY - radius && sMinY <= scratchMaxY + radius + } + if (candidates.isEmpty()) return false + + // 1. Must intersect or be very close to at least one candidate stroke. + // For small targets (dots, taps with < 5 points), proximity is sufficient + // since segment intersection is unreliable with 1-2 point strokes. + val radiusSq = radius * radius + val intersecting = candidates.filter { stroke -> + if (stroke.points.size < 5) { + // Small target: any scratch point within radius counts + stroke.points.any { tp -> + scratchPoints.any { sp -> + val dx = sp.x - tp.x; val dy = sp.y - tp.y + dx * dx + dy * dy <= radiusSq + } + } + } else { + strokesIntersect(scratchPoints, stroke.points) + } + } + if (intersecting.isEmpty()) return false + + // 2. Check coverage: what fraction of scratch-out points are near target strokes? + // For small targets (< 5 total points), intersection/proximity alone is sufficient. + val targetPoints = intersecting.flatMap { it.points } + if (targetPoints.size < 5) return true + + // Grid-based spatial index: O(n + m) instead of O(n*m). + // Cell size = radius. For each scratch point, check its cell + 8 neighbors + // and do exact distance checks on the (few) target points in those cells. + val cellSize = radius + val grid = HashMap>() + fun cellKey(x: Int, y: Int) = x.toLong() shl 32 or (y.toLong() and 0xFFFFFFFFL) + for (tp in targetPoints) { + val cx = (tp.x / cellSize).toInt() + val cy = (tp.y / cellSize).toInt() + grid.getOrPut(cellKey(cx, cy)) { mutableListOf() }.add(tp) + } + + var nearCount = 0 + for (sp in scratchPoints) { + val cx = (sp.x / cellSize).toInt() + val cy = (sp.y / cellSize).toInt() + var found = false + outer@ for (dx in -1..1) { + for (dy in -1..1) { + val cell = grid[cellKey(cx + dx, cy + dy)] ?: continue + for (tp in cell) { + val ddx = sp.x - tp.x; val ddy = sp.y - tp.y + if (ddx * ddx + ddy * ddy <= radiusSq) { + found = true + break@outer + } + } + } + } + if (found) nearCount++ + } + + val coverage = nearCount.toFloat() / scratchPoints.size + return coverage >= MIN_COVERAGE_FRACTION + } + + fun hasTargetStrokes( + existingStrokes: List, + left: Float, top: Float, right: Float, bottom: Float + ): Boolean = existingStrokes.any { stroke -> + stroke.points.any { pt -> pt.x in left..right && pt.y in top..bottom } + || stroke.strokeType.isConnector + && strokeIntersectsRect(stroke.points, left, top, right, bottom) + } + + /** + * Test whether any line segment between consecutive stroke points intersects + * the axis-aligned rectangle [left, top, right, bottom]. + * + * This catches geometric strokes (arrows/lines with only 2 points) where the + * visual line passes through a region even though neither endpoint is inside it. + * Uses Cohen–Sutherland-style segment clipping. + */ + fun strokeIntersectsRect( + points: List, + left: Float, top: Float, right: Float, bottom: Float + ): Boolean { + for (i in 0 until points.size - 1) { + if (segmentIntersectsRect( + points[i].x, points[i].y, + points[i + 1].x, points[i + 1].y, + left, top, right, bottom + )) return true + } + return false + } + + /** + * Cohen–Sutherland line segment vs AABB intersection test. + * Returns true if the segment (x1,y1)→(x2,y2) intersects or is inside the rect. + */ + internal fun segmentIntersectsRect( + x1: Float, y1: Float, x2: Float, y2: Float, + left: Float, top: Float, right: Float, bottom: Float + ): Boolean { + var ax = x1; var ay = y1; var bx = x2; var by = y2 + + fun outcode(x: Float, y: Float): Int { + var code = 0 + if (x < left) code = code or 1 + if (x > right) code = code or 2 + if (y < top) code = code or 4 + if (y > bottom) code = code or 8 + return code + } + + var codeA = outcode(ax, ay) + var codeB = outcode(bx, by) + + while (true) { + if (codeA or codeB == 0) return true // both inside + if (codeA and codeB != 0) return false // both on same outside side + // Pick the point outside the rect + val codeOut = if (codeA != 0) codeA else codeB + val x: Float; val y: Float + when { + codeOut and 8 != 0 -> { // below bottom + x = ax + (bx - ax) * (bottom - ay) / (by - ay); y = bottom + } + codeOut and 4 != 0 -> { // above top + x = ax + (bx - ax) * (top - ay) / (by - ay); y = top + } + codeOut and 2 != 0 -> { // right of right + y = ay + (by - ay) * (right - ax) / (bx - ax); x = right + } + else -> { // left of left + y = ay + (by - ay) * (left - ax) / (bx - ax); x = left + } + } + if (codeOut == codeA) { ax = x; ay = y; codeA = outcode(ax, ay) } + else { bx = x; by = y; codeB = outcode(bx, by) } + } + } + + /** + * Test whether any segment of [strokeA] intersects any segment of [strokeB]. + * Uses a segment-segment intersection test based on cross products. + * + * This is more precise than bounding-box overlap — the scratch-out stroke + * must actually cross the target stroke's path. + */ + fun strokesIntersect(strokeA: List, strokeB: List): Boolean { + if (strokeA.size < 2 || strokeB.size < 2) return false + for (i in 0 until strokeA.size - 1) { + for (j in 0 until strokeB.size - 1) { + if (segmentsIntersect( + strokeA[i].x, strokeA[i].y, strokeA[i + 1].x, strokeA[i + 1].y, + strokeB[j].x, strokeB[j].y, strokeB[j + 1].x, strokeB[j + 1].y + )) return true + } + } + return false + } + + /** + * Test whether two line segments (p1→p2) and (p3→p4) intersect. + * Uses the cross-product orientation test. + */ + internal fun segmentsIntersect( + x1: Float, y1: Float, x2: Float, y2: Float, + x3: Float, y3: Float, x4: Float, y4: Float + ): Boolean { + fun cross(ax: Float, ay: Float, bx: Float, by: Float) = ax * by - ay * bx + + val d1 = cross(x4 - x3, y4 - y3, x1 - x3, y1 - y3) + val d2 = cross(x4 - x3, y4 - y3, x2 - x3, y2 - y3) + val d3 = cross(x2 - x1, y2 - y1, x3 - x1, y3 - y1) + val d4 = cross(x2 - x1, y2 - y1, x4 - x1, y4 - y1) + + if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && + ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) return true + + // Collinear/touching cases — skip for scratch-out (require clear crossing) + return false + } +} diff --git a/app/src/main/java/com/writer/view/ScreenMetrics.kt b/app/src/main/java/com/writer/view/ScreenMetrics.kt index 5e17cde..3b81917 100644 --- a/app/src/main/java/com/writer/view/ScreenMetrics.kt +++ b/app/src/main/java/com/writer/view/ScreenMetrics.kt @@ -1,23 +1,26 @@ package com.writer.view +import android.util.TypedValue import kotlin.math.roundToInt /** * Converts dp/sp design constants to device pixels using Android's standard - * density system ([DisplayMetrics.density] and [DisplayMetrics.scaledDensity]). + * density system ([DisplayMetrics.density] and [TypedValue.applyDimension]). * * This is the Android platform best practice: * - **dp** (density-independent pixels) for all spatial measurements. * 1 dp = 1 px at 160 ppi; scaled by [DisplayMetrics.density]. * - **sp** (scale-independent pixels) for text sizes. * Same as dp but additionally respects the user's system font-size preference - * via [DisplayMetrics.scaledDensity]. + * via [TypedValue.applyDimension] with [TypedValue.COMPLEX_UNIT_SP]. * * The compact/standard breakpoint uses [Configuration.smallestScreenWidthDp] — * the same mechanism Android resource qualifiers (e.g. `values-sw600dp/`) use — * rather than computing a physical diagonal. * - * Call [init] once in Application.onCreate() before any view is inflated. + * Call [init] once in `Application.onCreate()` before any view is inflated, + * and re-call it in `onConfigurationChanged` if the user changes font scale + * or display density at runtime. * Tests use the plain-value overload to avoid an Android framework dependency. */ object ScreenMetrics { @@ -28,16 +31,10 @@ object ScreenMetrics { // ── Standard dp constants (Go 7, Note 5C, Tab X C) ─────────────────────── private const val LINE_SPACING_DP = 63f // ≈ 10.0 mm - private const val GUTTER_TARGET_DP = 69f // ≈ 11.0 mm - private const val GUTTER_MIN_DP = 57f // ≈ 9.0 mm (hard floor) - private const val GUTTER_MAX_FRACTION = 0.12f // ≤ 12 % of screen width private const val CANVAS_FRACTION = 0.70f // 70 % of screen height for canvas // ── Compact dp constants (Palma 2 Pro) ─────────────────────────────────── private const val LINE_SPACING_COMPACT_DP = 41f // ≈ 6.5 mm - private const val GUTTER_TARGET_COMPACT_DP = 47f // ≈ 7.5 mm - private const val GUTTER_MIN_COMPACT_DP = 38f // ≈ 6.0 mm (stylus floor) - private const val GUTTER_MAX_FRACTION_COMPACT = 0.09f // ≤ 9 % of screen width private const val CANVAS_FRACTION_COMPACT = 0.82f // 82 % of screen height for canvas // ── Shared dp constants ─────────────────────────────────────────────────── @@ -55,13 +52,16 @@ object ScreenMetrics { // ── Computed pixel values (set by init) ─────────────────────────────────── var density: Float = 1f; private set - var scaledDensity: Float = 1f; private set /** True when the device's smallestScreenWidthDp is below [COMPACT_SW_DP]. */ var isCompact: Boolean = false; private set + // DisplayMetrics reference for proper SP conversion (null in tests) + private var displayMetrics: android.util.DisplayMetrics? = null + // Font scale for test init (1.0 = no scaling) + private var fontScale: Float = 1f + var lineSpacing: Float = 100f; private set var topMargin: Float = 30f; private set - var gutterWidth: Float = 110f; private set var strokeWidth: Float = 4f; private set var textBody: Float = 52f; private set var textLogo: Float = 96f; private set @@ -83,55 +83,62 @@ object ScreenMetrics { displayMetrics: android.util.DisplayMetrics, configuration: android.content.res.Configuration ) { - init( - density = displayMetrics.density, - scaledDensity = displayMetrics.scaledDensity, - smallestWidthDp = configuration.smallestScreenWidthDp, - widthPixels = displayMetrics.widthPixels, - heightPixels = displayMetrics.heightPixels + this.displayMetrics = displayMetrics + initLayout( + density = displayMetrics.density, + smallestWidthDp = configuration.smallestScreenWidthDp, + widthPixels = displayMetrics.widthPixels, + heightPixels = displayMetrics.heightPixels ) + computeTextSizes() } /** * Plain-value overload for unit tests — no Android framework dependency. * * @param density [DisplayMetrics.density] (= densityDpi / 160) - * @param scaledDensity [DisplayMetrics.scaledDensity] (density × fontScale) + * @param fontScale User font scale preference (1.0 = default, >1.0 = larger text) * @param smallestWidthDp [Configuration.smallestScreenWidthDp] - * @param widthPixels screen width in pixels (used for gutter cap) + * @param widthPixels screen width in pixels * @param heightPixels screen height in pixels */ fun init( density: Float, - scaledDensity: Float, + fontScale: Float = 1f, + smallestWidthDp: Int, + widthPixels: Int, + heightPixels: Int + ) { + this.displayMetrics = null + this.fontScale = fontScale.coerceAtLeast(0.5f) + initLayout(density, smallestWidthDp, widthPixels, heightPixels) + computeTextSizes() + } + + private fun initLayout( + density: Float, smallestWidthDp: Int, widthPixels: Int, heightPixels: Int ) { - this.density = density.coerceAtLeast(0.5f) - this.scaledDensity = scaledDensity.coerceAtLeast(0.5f) - isCompact = smallestWidthDp < COMPACT_SW_DP + this.density = density.coerceAtLeast(0.5f) + isCompact = smallestWidthDp < COMPACT_SW_DP val lineSpacingDp = if (isCompact) LINE_SPACING_COMPACT_DP else LINE_SPACING_DP - val gutterTargetDp = if (isCompact) GUTTER_TARGET_COMPACT_DP else GUTTER_TARGET_DP - val gutterMinDp = if (isCompact) GUTTER_MIN_COMPACT_DP else GUTTER_MIN_DP - val gutterMaxFrac = if (isCompact) GUTTER_MAX_FRACTION_COMPACT else GUTTER_MAX_FRACTION canvasFraction = if (isCompact) CANVAS_FRACTION_COMPACT else CANVAS_FRACTION lineSpacing = (lineSpacingDp * this.density).roundToInt().toFloat() topMargin = (TOP_MARGIN_DP * this.density).roundToInt().toFloat() strokeWidth = STROKE_WIDTH_DP * this.density - gutterWidth = (gutterTargetDp * this.density) - .coerceAtMost(widthPixels * gutterMaxFrac) - .coerceAtLeast(gutterMinDp * this.density) - .roundToInt().toFloat() - - textBody = TEXT_BODY_SP * this.scaledDensity - textLogo = TEXT_LOGO_SP * this.scaledDensity - textStatus = TEXT_STATUS_SP * this.scaledDensity - textSubtext = TEXT_SUBTEXT_SP * this.scaledDensity - textCloseBtn = TEXT_CLOSE_BTN_SP * this.scaledDensity - textTutorial = TEXT_TUTORIAL_SP * this.scaledDensity + } + + private fun computeTextSizes() { + textBody = spToPx(TEXT_BODY_SP) + textLogo = spToPx(TEXT_LOGO_SP) + textStatus = spToPx(TEXT_STATUS_SP) + textSubtext = spToPx(TEXT_SUBTEXT_SP) + textCloseBtn = spToPx(TEXT_CLOSE_BTN_SP) + textTutorial = spToPx(TEXT_TUTORIAL_SP) } // ── Conversion helpers ──────────────────────────────────────────────────── @@ -139,8 +146,24 @@ object ScreenMetrics { /** Convert dp to pixels at the current display density. */ fun dp(value: Float): Float = value * density - /** Convert sp to pixels, respecting the user's font-size preference. */ - fun sp(value: Float): Float = value * scaledDensity + /** + * Convert sp to pixels, respecting the user's font-size preference. + * Uses [TypedValue.applyDimension] for proper adaptive font scaling on API 34+. + */ + fun sp(value: Float): Float = spToPx(value) + + /** + * Internal SP to pixel conversion using [TypedValue.applyDimension] when available. + * Falls back to manual calculation for tests without Android framework. + */ + private fun spToPx(value: Float): Float { + val dm = displayMetrics + return if (dm != null) { + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, value, dm) + } else { + value * density * fontScale + } + } // ── Layout helpers ──────────────────────────────────────────────────────── diff --git a/app/src/main/java/com/writer/view/ShapeSnapDetection.kt b/app/src/main/java/com/writer/view/ShapeSnapDetection.kt new file mode 100644 index 0000000..308fc53 --- /dev/null +++ b/app/src/main/java/com/writer/view/ShapeSnapDetection.kt @@ -0,0 +1,902 @@ +package com.writer.view + +import kotlin.math.abs +import kotlin.math.acos +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.hypot +import kotlin.math.min +import kotlin.math.PI +import kotlin.math.sin +import kotlin.math.sqrt + +/** + * Post-stroke shape snapping: converts a freehand stroke to a clean geometric + * shape when the stroke closely approximates a known shape. + * + * Shapes detected (in priority order for closed strokes): + * 1. Ellipse — closed loop with 0–1 sharp corners and low deviation from an inscribed ellipse + * 2. Triangle — closed loop with exactly 3 sharp corners + * 3. Rectangle — closed loop with ≥4 sharp corners and points near bounding-box edges + * 4. Line — open stroke closely following a straight path + * + * Corner detection uses a windowed angle-change derivative: at each interior point, + * the angle between the backward and forward vectors (window = max(3, N/12)) is computed. + * Peaks above CORNER_ANGLE_DEG that are separated by below-threshold points count as one corner. + * + * For very short strokes (N < 2·window+1) where reliable corner counting is impossible, + * closed shapes fall back to bounding-box rectangle detection (handles the minimal 5-point + * test rectangle used in unit tests without risking false positives in practice). + */ +object ShapeSnapDetection { + + // ── Line constants ──────────────────────────────────────────────────────── + + /** Maximum perpendicular deviation / line length for straight-line snap. */ + const val LINE_MAX_DEVIATION = 0.12f + + /** Minimum stroke length in line spacings for straight-line snap. */ + const val LINE_MIN_SPANS = 1.0f + + /** + * Maximum ratio of path length to straight-line length for line snap. + * Rejects curved strokes (arcs, unclosed circles) whose path winds far from + * the start-to-end axis. A straight line = 1.0; a semicircle ≈ 1.57. + */ + const val LINE_MAX_PATH_RATIO = 1.5f + + // ── Closed-shape constants ──────────────────────────────────────────────── + + /** Max distance start→end / diagonal for a stroke to be considered "closed". */ + const val CLOSE_FRACTION = 0.20f + + /** Max mean point-to-nearest-edge distance / diagonal for rectangle snapping. */ + const val RECT_MAX_PERIM_DEV = 0.15f + + /** Max single-point distance to nearest bbox edge / diagonal for rectangle snapping. + * Rejects letter-like shapes (B, P, D) whose interior valleys lie far from all edges, + * while accepting genuine rectangles and rounded rectangles whose corners merely curve. */ + const val RECT_MAX_POINT_DEV = 0.12f + + /** Minimum length of both sides of the snapped rectangle in line spacings. */ + const val RECT_MIN_SIDE_SPANS = 0.4f + + /** + * Maximum mean point-to-inscribed-ellipse distance / diagonal for ellipse snap. + * A perfect circle or oval has deviation 0; a rectangle or triangle has much higher deviation. + */ + const val ELLIPSE_MAX_DEV = 0.07f + + /** + * Maximum windowed angle (degrees) for a point to be considered "locally straight". + * Much lower than [CORNER_ANGLE_DEG] — this detects flat segments, not corners. + */ + const val STRAIGHT_ANGLE_DEG = 10f + + /** + * Minimum fraction of stroke points that are locally straight for a shape to be + * classified as a rounded rectangle rather than an ellipse. An ellipse has curvature + * everywhere (straightFraction ≈ 0); a rounded rectangle always has four flat sides + * (straightFraction > 0.2 even with generous corner radii). + */ + const val STRAIGHT_FRACTION_MIN = 0.12f + + /** + * Minimum direction-change angle (degrees) at a point to be counted as a corner. + * A right-angle (90°) corner is well above this threshold; gentle curves are below. + */ + const val CORNER_ANGLE_DEG = 50f + + /** + * Minimum distance from a bounding-box corner to the nearest stroke point, + * expressed as a fraction of the diagonal, for the shape to be treated as having + * rounded corners rather than sharp ones. If every bounding-box corner (TL/TR/BR/BL) + * is farther than this threshold from all stroke points, the shape is classified as + * a rounded rectangle before corner counting. + * + * A sharp rectangle has stroke points at or near each bounding-box corner (distance ≈ 0). + * A rounded rectangle with radius r has its closest arc point at distance 0.414·r from each + * bounding-box corner; for r ≥ ~3% of diagonal that exceeds this threshold. + */ + const val ROUNDED_CORNER_THRESHOLD = 0.03f + + // ── Elbow constants ──────────────────────────────────────────────────────── + + /** Minimum corner angle (degrees) for elbow detection. */ + const val ELBOW_MIN_ANGLE_DEG = 60f + + /** Maximum corner angle (degrees) for elbow detection. */ + const val ELBOW_MAX_ANGLE_DEG = 120f + + /** Maximum perpendicular deviation / leg length for each elbow leg to be considered straight. */ + const val ELBOW_LEG_MAX_DEVIATION = 0.15f + + // ── Arc constants ──────────────────────────────────────────────────────── + + /** Minimum path ratio (path length / chord length) for arc detection. */ + const val ARC_MIN_PATH_RATIO = 1.02f + + /** Maximum path ratio for arc detection. */ + const val ARC_MAX_PATH_RATIO = 2.5f + + /** Maximum point-to-bezier deviation / chord length for arc fit quality. */ + const val ARC_MAX_FIT_DEVIATION = 0.08f + + // ── Self-loop constants ──────────────────────────────────────────────────── + + /** Maximum gap (start→end distance / diagonal) for self-loop detection. */ + const val SELF_LOOP_MAX_GAP = 0.75f + + /** Maximum ellipse fit deviation / diagonal for self-loop (more lenient than ELLIPSE_MAX_DEV). */ + const val SELF_LOOP_MAX_ELLIPSE_DEV = 0.15f + + // ── Diamond constant ────────────────────────────────────────────────────── + + /** + * Maximum distance from each detected corner to its nearest bounding-box edge midpoint, + * as a fraction of min(width, height). If all corners satisfy this threshold, the shape + * is classified as a diamond rather than a rectangle. + */ + const val DIAMOND_CORNER_THRESHOLD = 0.20f + + // ── Result types ────────────────────────────────────────────────────────── + + sealed class SnapResult { + data class Line(val x1: Float, val y1: Float, val x2: Float, val y2: Float) : SnapResult() + data class Arrow( + val x1: Float, val y1: Float, // tail + val x2: Float, val y2: Float, // head + val tailHead: Boolean = false, // arrowhead at tail + val tipHead: Boolean = true // arrowhead at tip + ) : SnapResult() + data class Ellipse(val cx: Float, val cy: Float, val a: Float, val b: Float) : SnapResult() + data class RoundedRectangle( + val left: Float, val top: Float, + val right: Float, val bottom: Float, + val cornerRadius: Float + ) : SnapResult() + data class Rectangle( + val left: Float, val top: Float, + val right: Float, val bottom: Float + ) : SnapResult() + data class Diamond( + val left: Float, val top: Float, + val right: Float, val bottom: Float + ) : SnapResult() + data class Triangle( + val x1: Float, val y1: Float, + val x2: Float, val y2: Float, + val x3: Float, val y3: Float + ) : SnapResult() + /** L-shaped connector: start → corner → end. */ + data class Elbow( + val x1: Float, val y1: Float, + val cx: Float, val cy: Float, + val x2: Float, val y2: Float + ) : SnapResult() + /** Curved connector: start, quadratic bezier control point, end. */ + data class Arc( + val x1: Float, val y1: Float, + val cx: Float, val cy: Float, + val x2: Float, val y2: Float + ) : SnapResult() + /** Smooth open curve that doesn't fit a single bezier (e.g. U-shaped self-referential arc). */ + data class Curve(val points: List>) : SnapResult() + /** Self-referential loop: elliptical arc from startAngle sweeping sweepAngle radians. */ + data class SelfLoop( + val cx: Float, val cy: Float, + val rx: Float, val ry: Float, + val startAngle: Float, + val sweepAngle: Float + ) : SnapResult() + } + + // ── Public API ──────────────────────────────────────────────────────────── + + fun detect(xs: FloatArray, ys: FloatArray, lineSpacing: Float): SnapResult? { + if (xs.size < 2 || xs.size != ys.size) return null + + val minX = xs.min(); val maxX = xs.max() + val minY = ys.min(); val maxY = ys.max() + val w = maxX - minX; val h = maxY - minY + val diagonal = sqrt(w * w + h * h) + + val closeDistance = dist(xs.first(), ys.first(), xs.last(), ys.last()) + val isClosed = diagonal > 0f && closeDistance < CLOSE_FRACTION * diagonal + + if (isClosed && min(w, h) >= RECT_MIN_SIDE_SPANS * lineSpacing) { + // Try ellipse first: max-deviation test is robust because rounded + // shapes (circles, ovals, sloppy circles with small bumps) keep all + // points within ELLIPSE_MAX_DEV of the inscribed ellipse, while + // shapes with true sharp corners (rectangles, triangles) have corner + // points that exceed the threshold. + detectEllipse(xs, ys, minX, maxX, minY, maxY, diagonal)?.let { return it } + + // Ellipse failed: use corner counting to distinguish rectangle vs triangle. + val n = xs.size + val window = maxOf(3, n / 12) + if (n >= 2 * window + 1) { + // Count corners first so we can route correctly before the rounded-corner check. + // Cyclic corner detection: treats the closed stroke as a ring so that a + // corner exactly at the start/end boundary is never missed. + val corners = findCornerIndicesCyclic(xs, ys, window) + + // If every bounding-box corner is far from all stroke points, the stroke + // never reaches the bbox corners — typical of rounded rectangles AND diamonds. + // We check for diamond (corners near edge midpoints) before rounded rect. + if (hasRoundedBboxCorners(xs, ys, minX, maxX, minY, maxY, diagonal)) { + if (corners.size >= 4) { + detectDiamond(xs, ys, corners, minX, maxX, minY, maxY)?.let { return it } + } + detectRoundedRectangle(xs, ys, minX, maxX, minY, maxY, diagonal) + ?.let { return it } + } + + when { + corners.size == 3 -> detectTriangle(xs, ys, corners) + ?.let { return it } + corners.size >= 4 -> (detectDiamond(xs, ys, corners, minX, maxX, minY, maxY) + ?: detectRectangle(xs, ys, minX, maxX, minY, maxY, diagonal)) + ?.let { return it } + // 0–2 sharp corners: check rounded-rectangle as a final fallback. + else -> detectRoundedRectangle(xs, ys, minX, maxX, minY, maxY, diagonal) + ?.let { return it } + } + } else { + // Stroke has too few points for reliable corner counting. + // Fall back to bounding-box rectangle detection — safe because + // a genuine circle with so few points cannot form a closed loop. + detectRectangle(xs, ys, minX, maxX, minY, maxY, diagonal)?.let { return it } + } + } + + // Open strokes: try line (strictest), then elbow, then arc, self-loop, curve + detectLine(xs, ys, lineSpacing)?.let { return it } + detectElbow(xs, ys, lineSpacing)?.let { return it } + detectArc(xs, ys, lineSpacing)?.let { return it } + detectSelfLoop(xs, ys, lineSpacing)?.let { return it } + return detectCurve(xs, ys, lineSpacing) + } + + // ── Corner detection ────────────────────────────────────────────────────── + + /** + * Find corner indices: positions where the stroke direction changes sharply. + * Uses a windowed angle-change derivative and suppresses duplicate detections + * within the same corner region via an [inCorner] flag that tracks the current peak. + * + * Returns indices sorted ascending (stroke order), one per geometric corner. + */ + private fun findCornerIndices( + xs: FloatArray, ys: FloatArray, window: Int, count: Int = xs.size + ): List { + val n = count + val threshold = (CORNER_ANGLE_DEG * PI / 180).toFloat() + val result = mutableListOf() + var inCorner = false + var peakAngle = 0f + var peakIndex = -1 + + for (i in window until n - window) { + val ax = xs[i] - xs[i - window]; val ay = ys[i] - ys[i - window] + val bx = xs[i + window] - xs[i]; val by = ys[i + window] - ys[i] + val lenA = hypot(ax.toDouble(), ay.toDouble()).toFloat() + val lenB = hypot(bx.toDouble(), by.toDouble()).toFloat() + if (lenA == 0f || lenB == 0f) { + if (inCorner) { result.add(peakIndex); inCorner = false } + continue + } + val dot = ((ax * bx + ay * by) / (lenA * lenB)).coerceIn(-1f, 1f) + val angle = acos(dot.toDouble()).toFloat() + + if (angle > threshold) { + if (!inCorner) { inCorner = true; peakAngle = angle; peakIndex = i } + else if (angle > peakAngle) { peakAngle = angle; peakIndex = i } + } else { + if (inCorner) { result.add(peakIndex); inCorner = false } + } + } + if (inCorner) result.add(peakIndex) + return result + } + + /** + * Cyclic variant of [findCornerIndices] for closed strokes. + * Prepends the last [window] points and appends the first [window] points so + * that a corner exactly at the start/end boundary is detected correctly. + * + * If the last point duplicates the first (the typical explicit-close convention), + * it is trimmed before extending to avoid counting the start corner twice. + */ + // Reusable buffers for findCornerIndicesCyclic to avoid per-call allocations. + // ShapeSnapDetection is a singleton object, so these are effectively static. + // NOT thread-safe: detect() must only be called from the main thread. + private var cyclicBufXs = FloatArray(0) + private var cyclicBufYs = FloatArray(0) + + private fun findCornerIndicesCyclic(xs: FloatArray, ys: FloatArray, window: Int): List { + // Strip the closing duplicate if present so the start/end corner is not detected twice. + val lastDuplicatesFirst = dist(xs.last(), ys.last(), xs.first(), ys.first()) < 0.01f + val n = if (lastDuplicatesFirst) xs.size - 1 else xs.size + + val ext = n + 2 * window + // Grow buffers only when needed; never shrink to avoid repeated allocation + // across similarly-sized strokes. + if (cyclicBufXs.size < ext) { + cyclicBufXs = FloatArray(ext) + cyclicBufYs = FloatArray(ext) + } + val extXs = cyclicBufXs + val extYs = cyclicBufYs + for (i in 0 until ext) { + when { + i < window -> { extXs[i] = xs[n - window + i]; extYs[i] = ys[n - window + i] } + i < n + window -> { extXs[i] = xs[i - window]; extYs[i] = ys[i - window] } + else -> { extXs[i] = xs[i - n - window]; extYs[i] = ys[i - n - window] } + } + } + // Extended loop covers extIdx ∈ [window, n+window-1] → origIdx = extIdx - window ∈ [0, n-1] + // Note: findCornerIndices only reads indices [0, ext), which is within our buffer. + val translated = findCornerIndices(extXs, extYs, window, count = ext) + .map { it - window } + .distinct() + .sorted() + + // Merge corners whose cyclic distance is ≤ window: they represent the same corner + // on opposite sides of the stroke's start/end boundary. + if (translated.size < 2) return translated + val merged = translated.toMutableList() + val cyclicDistFirstLast = n - merged.last() + merged.first() + if (cyclicDistFirstLast <= window) merged.removeAt(merged.lastIndex) + return merged + } + + /** + * Returns true if every bounding-box corner (TL/TR/BR/BL) is farther than + * [ROUNDED_CORNER_THRESHOLD] × [diagonal] from all stroke points. + * Indicates that the stroke never reaches the sharp corners of its bounding box, + * which is the defining characteristic of a rounded rectangle (as opposed to a + * sharp rectangle or a triangle that touches or lands on a bounding-box corner). + */ + private fun hasRoundedBboxCorners( + xs: FloatArray, ys: FloatArray, + minX: Float, maxX: Float, minY: Float, maxY: Float, + diagonal: Float + ): Boolean { + val threshold = ROUNDED_CORNER_THRESHOLD * diagonal + val bboxCorners = arrayOf( + Pair(minX, minY), Pair(maxX, minY), Pair(maxX, maxY), Pair(minX, maxY) + ) + for ((cx, cy) in bboxCorners) { + var minDist = Float.MAX_VALUE + for (i in xs.indices) { + val d = dist(xs[i], ys[i], cx, cy) + if (d < minDist) minDist = d + } + if (minDist < threshold) return false // a sharp corner is close to this bbox corner + } + return true + } + + // ── Shape detectors ─────────────────────────────────────────────────────── + + private fun detectEllipse( + xs: FloatArray, ys: FloatArray, + minX: Float, maxX: Float, minY: Float, maxY: Float, + diagonal: Float + ): SnapResult.Ellipse? { + val cx = (minX + maxX) / 2f; val cy = (minY + maxY) / 2f + val a = (maxX - minX) / 2f; val b = (maxY - minY) / 2f + if (a == 0f || b == 0f) return null + val a2 = a * a; val b2 = b * b + + // First-order signed distance to ellipse: |f(P)| / |∇f(P)| where f(P) = dx²/a² + dy²/b² − 1. + // Exact for points on the ellipse (f=0) and accurate for points close to it. + // Unlike radial projection (atan2 → place on ellipse), this formula is correct for + // non-circular ellipses — atan2 projection gives non-zero error for oval points. + // + // We use max deviation: a rectangle with uniformly-spaced edge points has low mean + // deviation (edge midpoints lie on the inscribed ellipse) but high MAX deviation at + // corners. A true ellipse has all points near the surface, so max ≈ 0. + var maxDev = 0f + for (i in xs.indices) { + val dx = xs[i] - cx; val dy = ys[i] - cy + val f = dx * dx / a2 + dy * dy / b2 - 1f + val gx = 2f * dx / a2; val gy = 2f * dy / b2 + val gLen = sqrt(gx * gx + gy * gy) + val d = if (gLen > 0f) abs(f) / gLen else 0f + if (d > maxDev) maxDev = d + } + // For high-aspect-ratio ovals, hand-drawn strokes naturally have more + // deviation because the narrow dimension amplifies imperfections. + // Scale the tolerance for aspect ratios above 2:1. + val aspectRatio = maxOf(a, b) / minOf(a, b) + // Quadratic scaling: gentle for low ratios, aggressive for high + val ar = maxOf(0f, aspectRatio - 1f) + val adjustedMaxDev = ELLIPSE_MAX_DEV * (1f + ar * ar * 0.5f) + if (maxDev > adjustedMaxDev * diagonal) return null + + // Straightness check: an ellipse has non-zero curvature everywhere, so its + // straight fraction is near 0. A rounded rectangle that passes the ellipse-fit + // test (generous corner radii) still has four flat sides with straight fraction + // > 0.20. Reject the ellipse classification if the stroke has too many straight + // points — the caller will then fall through to rounded-rectangle detection. + // For high-aspect-ratio ellipses, allow more "straight" points because the + // tips naturally have very low curvature (especially with hand-drawn noise). + // Scale linearly for aspect ratios above 2:1 (where tips are naturally flat) + val adjustedStraightMax = STRAIGHT_FRACTION_MIN * (1f + ar * ar * 0.5f) + if (straightFraction(xs, ys, minX, maxX, minY, maxY) > adjustedStraightMax) return null + + return SnapResult.Ellipse(cx, cy, a, b) + } + + private fun detectTriangle( + xs: FloatArray, ys: FloatArray, + corners: List + ): SnapResult.Triangle? { + if (corners.size != 3) return null + return SnapResult.Triangle( + xs[corners[0]], ys[corners[0]], + xs[corners[1]], ys[corners[1]], + xs[corners[2]], ys[corners[2]] + ) + } + + /** + * Returns a Diamond if all detected corners are near the four bounding-box edge midpoints + * `(cx,minY)`, `(maxX,cy)`, `(cx,maxY)`, `(minX,cy)`, within [DIAMOND_CORNER_THRESHOLD]×min(w,h). + */ + private fun detectDiamond( + xs: FloatArray, ys: FloatArray, + corners: List, + minX: Float, maxX: Float, minY: Float, maxY: Float + ): SnapResult.Diamond? { + val cx = (minX + maxX) / 2f + val cy = (minY + maxY) / 2f + val w = maxX - minX + val h = maxY - minY + val threshold = DIAMOND_CORNER_THRESHOLD * min(w, h) + val edgeMidpoints = arrayOf( + Pair(cx, minY), Pair(maxX, cy), Pair(cx, maxY), Pair(minX, cy) + ) + for (ci in corners) { + val px = xs[ci]; val py = ys[ci] + val nearestDist = edgeMidpoints.minOf { (mx, my) -> dist(px, py, mx, my) } + if (nearestDist > threshold) return null + } + return SnapResult.Diamond(minX, minY, maxX, maxY) + } + + private fun detectRectangle( + xs: FloatArray, ys: FloatArray, + minX: Float, maxX: Float, minY: Float, maxY: Float, + diagonal: Float + ): SnapResult.Rectangle? { + if (perimeterDeviation(xs, ys, minX, maxX, minY, maxY) > RECT_MAX_PERIM_DEV * diagonal) return null + if (maxPerimeterDeviation(xs, ys, minX, maxX, minY, maxY) > RECT_MAX_POINT_DEV * diagonal) return null + return SnapResult.Rectangle(minX, minY, maxX, maxY) + } + + private fun detectRoundedRectangle( + xs: FloatArray, ys: FloatArray, + minX: Float, maxX: Float, minY: Float, maxY: Float, + diagonal: Float + ): SnapResult.RoundedRectangle? { + // Same bounding-box perimeter check: points must sit near the edges. + // Rounded corners slightly increase mean deviation vs a sharp rectangle, + // but the threshold is generous enough to accept them. + if (perimeterDeviation(xs, ys, minX, maxX, minY, maxY) > RECT_MAX_PERIM_DEV * diagonal) return null + if (maxPerimeterDeviation(xs, ys, minX, maxX, minY, maxY) > RECT_MAX_POINT_DEV * diagonal) return null + val w = maxX - minX; val h = maxY - minY + val cornerRadius = min(w, h) / 4f + return SnapResult.RoundedRectangle(minX, minY, maxX, maxY, cornerRadius) + } + + private fun perimeterDeviation( + xs: FloatArray, ys: FloatArray, + minX: Float, maxX: Float, minY: Float, maxY: Float + ): Float { + var total = 0f + for (i in xs.indices) { + total += minOf(abs(xs[i] - minX), abs(xs[i] - maxX), + abs(ys[i] - minY), abs(ys[i] - maxY)) + } + return total / xs.size + } + + private fun maxPerimeterDeviation( + xs: FloatArray, ys: FloatArray, + minX: Float, maxX: Float, minY: Float, maxY: Float + ): Float { + var max = 0f + for (i in xs.indices) { + val d = minOf(abs(xs[i] - minX), abs(xs[i] - maxX), + abs(ys[i] - minY), abs(ys[i] - maxY)) + if (d > max) max = d + } + return max + } + + private fun detectLine(xs: FloatArray, ys: FloatArray, lineSpacing: Float): SnapResult? { + val x0 = xs.first(); val y0 = ys.first() + val x1 = xs.last(); val y1 = ys.last() + val len = dist(x0, y0, x1, y1) + if (len < LINE_MIN_SPANS * lineSpacing) return null + if (maxPerpendicularDeviation(xs, ys, x0, y0, x1, y1) > LINE_MAX_DEVIATION * len) return null + if (pathLength(xs, ys) > LINE_MAX_PATH_RATIO * len) return null + return SnapResult.Line(x0, y0, x1, y1) + } + + /** + * Detect an L-shaped elbow connector: exactly 1 sharp corner with angle in [60°, 120°], + * each leg locally straight. Snaps corner to the nearest right-angle position. + */ + private fun detectElbow(xs: FloatArray, ys: FloatArray, lineSpacing: Float): SnapResult.Elbow? { + val n = xs.size + if (n < 5) return null + + val x0 = xs.first(); val y0 = ys.first() + val x1 = xs.last(); val y1 = ys.last() + val totalLen = dist(x0, y0, x1, y1) + if (totalLen < LINE_MIN_SPANS * lineSpacing) return null + + val window = maxOf(3, n / 12) + if (n < 2 * window + 1) return null + + val corners = findCornerIndices(xs, ys, window) + if (corners.size != 1) return null + val ci = corners[0] + + // Verify corner angle is in [60°, 120°] + val ax = xs[ci] - x0; val ay = ys[ci] - y0 + val bx = x1 - xs[ci]; val by = y1 - ys[ci] + val lenA = hypot(ax.toDouble(), ay.toDouble()).toFloat() + val lenB = hypot(bx.toDouble(), by.toDouble()).toFloat() + if (lenA == 0f || lenB == 0f) return null + val dot = ((ax * bx + ay * by) / (lenA * lenB)).coerceIn(-1f, 1f) + val angleDeg = (acos(dot.toDouble()) * 180.0 / PI).toFloat() + if (angleDeg < ELBOW_MIN_ANGLE_DEG || angleDeg > ELBOW_MAX_ANGLE_DEG) return null + + // Each leg must be locally straight + val leg1Xs = FloatArray(ci + 1) { xs[it] } + val leg1Ys = FloatArray(ci + 1) { ys[it] } + if (maxPerpendicularDeviation(leg1Xs, leg1Ys, x0, y0, xs[ci], ys[ci]) > ELBOW_LEG_MAX_DEVIATION * lenA) return null + + val leg2Size = n - ci + val leg2Xs = FloatArray(leg2Size) { xs[ci + it] } + val leg2Ys = FloatArray(leg2Size) { ys[ci + it] } + if (maxPerpendicularDeviation(leg2Xs, leg2Ys, xs[ci], ys[ci], x1, y1) > ELBOW_LEG_MAX_DEVIATION * lenB) return null + + // Snap corner to right angle: choose (x0, y1) or (x1, y0) — whichever is closer to the raw corner + val opt1x = x0; val opt1y = y1 + val opt2x = x1; val opt2y = y0 + val d1 = dist(xs[ci], ys[ci], opt1x, opt1y) + val d2 = dist(xs[ci], ys[ci], opt2x, opt2y) + val (cx, cy) = if (d1 <= d2) Pair(opt1x, opt1y) else Pair(opt2x, opt2y) + + return SnapResult.Elbow(x0, y0, cx, cy, x1, y1) + } + + /** + * Detect a smooth arc connector via quadratic bezier fit quality. + * + * Uses bounding box diagonal (not chord) for minimum size and fit normalization, + * so self-referential arcs with close endpoints are still detected. No path-ratio + * or corner-count gates — the bezier fit alone rejects zigzags, scribbles, and + * other non-arc shapes because they deviate far from any single quadratic curve. + */ + private fun detectArc(xs: FloatArray, ys: FloatArray, lineSpacing: Float): SnapResult.Arc? { + val n = xs.size + if (n < 5) return null + + val x0 = xs.first(); val y0 = ys.first() + val x1 = xs.last(); val y1 = ys.last() + val chordLen = dist(x0, y0, x1, y1) + + val minX = xs.min(); val maxX = xs.max() + val minY = ys.min(); val maxY = ys.max() + val w = maxX - minX; val h = maxY - minY + val diagonal = sqrt(w * w + h * h) + + // Must be large enough (bounding box, not chord — short-chord arcs are valid) + if (diagonal < LINE_MIN_SPANS * lineSpacing * 0.5f) return null + + // Must not be closed + if (diagonal > 0f && chordLen < CLOSE_FRACTION * diagonal) return null + + // Find point of max perpendicular deviation from chord — this is the arc midpoint M. + // For short-chord arcs, use distance from chord midpoint instead. + var maxDev = 0f + var maxIdx = n / 2 + if (chordLen > 1f) { + val dx = x1 - x0; val dy = y1 - y0 + for (i in xs.indices) { + val dev = abs((xs[i] - x0) * dy - (ys[i] - y0) * dx) / chordLen + if (dev > maxDev) { maxDev = dev; maxIdx = i } + } + } else { + // Near-zero chord: find the point farthest from the midpoint + val midX = (x0 + x1) / 2f; val midY = (y0 + y1) / 2f + for (i in xs.indices) { + val d = dist(xs[i], ys[i], midX, midY) + if (d > maxDev) { maxDev = d; maxIdx = i } + } + } + + // The arc must have meaningful curvature (not nearly straight) + val fitRef = maxOf(chordLen, diagonal * 0.5f) + if (maxDev < fitRef * 0.05f) return null + + // Convert midpoint M to quadratic bezier control point: C = 2*M - 0.5*P0 - 0.5*P2 + val mx = xs[maxIdx]; val my = ys[maxIdx] + val cx = 2f * mx - 0.5f * x0 - 0.5f * x1 + val cy = 2f * my - 0.5f * y0 - 0.5f * y1 + + // Validate fit: for each stroke point, find minimum distance to the bezier curve. + // Normalize by the larger of chord and half-diagonal, so short-chord arcs + // get a reasonable tolerance. + val samples = 50 + var maxFitDev = 0f + for (i in xs.indices) { + var minD = Float.MAX_VALUE + for (s in 0..samples) { + val t = s.toFloat() / samples + val omt = 1f - t + val bx = omt * omt * x0 + 2f * omt * t * cx + t * t * x1 + val by = omt * omt * y0 + 2f * omt * t * cy + t * t * y1 + val d = dist(xs[i], ys[i], bx, by) + if (d < minD) minD = d + } + if (minD > maxFitDev) maxFitDev = minD + } + if (maxFitDev > ARC_MAX_FIT_DEVIATION * fitRef) return null + + return SnapResult.Arc(x0, y0, cx, cy, x1, y1) + } + + /** + * Detect a smooth open curve that doesn't fit a single quadratic bezier + * (e.g. U-shaped self-referential arcs). Strips dwell duplicates, checks + * smoothness with a fixed window, and resamples to evenly-spaced points. + */ + private fun detectCurve(xs: FloatArray, ys: FloatArray, lineSpacing: Float): SnapResult.Curve? { + val n = xs.size + if (n < 10) return null + + // Strip dwell duplicates (consecutive near-identical points) + val sxs = mutableListOf(xs[0]) + val sys = mutableListOf(ys[0]) + for (i in 1 until n) { + if (dist(xs[i], ys[i], sxs.last(), sys.last()) > 0.5f) { + sxs.add(xs[i]); sys.add(ys[i]) + } + } + val sx = sxs.toFloatArray() + val sy = sys.toFloatArray() + val sn = sx.size + if (sn < 10) return null + + val minX = sx.min(); val maxX = sx.max() + val minY = sy.min(); val maxY = sy.max() + val w = maxX - minX; val h = maxY - minY + val diagonal = sqrt(w * w + h * h) + if (diagonal < LINE_MIN_SPANS * lineSpacing * 0.5f) return null + + // Must not be closed + val chordLen = dist(sx.first(), sy.first(), sx.last(), sy.last()) + if (diagonal > 0f && chordLen < CLOSE_FRACTION * diagonal) return null + + // Smooth: no SHARP corners (>120°). Uses a higher threshold than the standard + // CORNER_ANGLE_DEG (50°) because smooth U-turns have 70-100° direction changes + // that are valid curve features, not zigzag corners. + val window = minOf(8, sn / 4) + val sharpThreshold = (120f * PI / 180f).toFloat() + if (window >= 3 && sn >= 2 * window + 1) { + for (i in window until sn - window) { + val ax = sx[i] - sx[i - window]; val ay = sy[i] - sy[i - window] + val bx = sx[i + window] - sx[i]; val by = sy[i + window] - sy[i] + val lenA = hypot(ax.toDouble(), ay.toDouble()).toFloat() + val lenB = hypot(bx.toDouble(), by.toDouble()).toFloat() + if (lenA == 0f || lenB == 0f) continue + val dot = ((ax * bx + ay * by) / (lenA * lenB)).coerceIn(-1f, 1f) + if (acos(dot.toDouble()).toFloat() > sharpThreshold) return null + } + } + + // Must have meaningful curvature + val maxPerp = maxPerpendicularDeviation(sx, sy, sx.first(), sy.first(), sx.last(), sy.last()) + if (maxPerp < diagonal * 0.05f) return null + + // Resample to ~30 evenly-spaced points along the path + val totalLen = pathLength(sx, sy) + val numPts = 30 + val step = totalLen / numPts + val pts = mutableListOf(Pair(sx[0], sy[0])) + var accumulated = 0f + var nextTarget = step + for (i in 1 until sn) { + val segLen = dist(sx[i - 1], sy[i - 1], sx[i], sy[i]) + accumulated += segLen + while (accumulated >= nextTarget && pts.size < numPts) { + val overshoot = accumulated - nextTarget + val t = if (segLen > 0f) 1f - overshoot / segLen else 1f + pts.add(Pair( + sx[i - 1] + (sx[i] - sx[i - 1]) * t, + sy[i - 1] + (sy[i] - sy[i - 1]) * t + )) + nextTarget += step + } + } + pts.add(Pair(sx.last(), sy.last())) + + return SnapResult.Curve(pts) + } + + /** + * Detect a self-referential loop: a near-closed smooth curve that fits an elliptical arc. + * Used for flowchart self-loop connectors (arrows from a node back to itself). + */ + private fun detectSelfLoop(xs: FloatArray, ys: FloatArray, lineSpacing: Float): SnapResult.SelfLoop? { + val n = xs.size + if (n < 10) return null + + val x0 = xs.first(); val y0 = ys.first() + val x1 = xs.last(); val y1 = ys.last() + + val minX = xs.min(); val maxX = xs.max() + val minY = ys.min(); val maxY = ys.max() + val w = maxX - minX; val h = maxY - minY + val diagonal = sqrt(w * w + h * h) + + // Must have significant extent + if (min(w, h) < LINE_MIN_SPANS * lineSpacing * 0.5f) return null + + // Must be nearly closed + val closeDist = dist(x0, y0, x1, y1) + if (closeDist > SELF_LOOP_MAX_GAP * diagonal) return null + + // Must be smooth (0 corners) + val window = maxOf(3, n / 12) + if (n >= 2 * window + 1) { + val corners = findCornerIndices(xs, ys, window) + if (corners.isNotEmpty()) return null + } + + // Fit ellipse using bounding box + val cx = (minX + maxX) / 2f + val cy = (minY + maxY) / 2f + val rx = w / 2f + val ry = h / 2f + if (rx == 0f || ry == 0f) return null + + // Validate ellipse fit (more lenient than full ellipse detection) + val rx2 = rx * rx; val ry2 = ry * ry + var maxDev = 0f + for (i in xs.indices) { + val dx = xs[i] - cx; val dy = ys[i] - cy + val f = dx * dx / rx2 + dy * dy / ry2 - 1f + val gx = 2f * dx / rx2; val gy = 2f * dy / ry2 + val gLen = sqrt(gx * gx + gy * gy) + val d = if (gLen > 0f) abs(f) / gLen else 0f + if (d > maxDev) maxDev = d + } + if (maxDev > SELF_LOOP_MAX_ELLIPSE_DEV * diagonal) return null + + // Compute start/end angles on the normalized ellipse + val startAngle = atan2((y0 - cy).toDouble() / ry, (x0 - cx).toDouble() / rx).toFloat() + val endAngle = atan2((y1 - cy).toDouble() / ry, (x1 - cx).toDouble() / rx).toFloat() + + // Determine winding direction using cross product of chord × midpoint offset. + // The shoelace formula doesn't work for open curves; this method checks which + // side of the start→end chord the arc's midpoint falls on. + // In screen coords (y-down): cross < 0 → clockwise, cross > 0 → counter-clockwise. + val midIdx = n / 2 + val chordDx = x1 - x0; val chordDy = y1 - y0 + val midDx = xs[midIdx] - x0; val midDy = ys[midIdx] - y0 + val cross = chordDx * midDy - chordDy * midDx + val cw = cross < 0 + + var sweep = endAngle - startAngle + if (cw) { + if (sweep < 0) sweep += (2 * PI).toFloat() + } else { + if (sweep > 0) sweep -= (2 * PI).toFloat() + } + + // Sweep must cover most of the ellipse (at least 180°) + if (abs(sweep) < PI.toFloat()) return null + + return SnapResult.SelfLoop(cx, cy, rx, ry, startAngle, sweep) + } + + // ── Geometry helpers ────────────────────────────────────────────────────── + + private fun pathLength(xs: FloatArray, ys: FloatArray): Float { + var total = 0f + for (i in 1 until xs.size) total += dist(xs[i - 1], ys[i - 1], xs[i], ys[i]) + return total + } + + /** + * Maximum perpendicular distance from any point in [xs]/[ys] to the + * line through (x0,y0)→(x1,y1). Returns 0f if line has zero length. + */ + internal fun maxPerpendicularDeviation( + xs: FloatArray, ys: FloatArray, + x0: Float, y0: Float, x1: Float, y1: Float + ): Float { + val dx = x1 - x0; val dy = y1 - y0 + val len = sqrt(dx * dx + dy * dy) + if (len == 0f) return 0f + var maxDev = 0f + for (i in xs.indices) { + val dev = abs((xs[i] - x0) * dy - (ys[i] - y0) * dx) / len + if (dev > maxDev) maxDev = dev + } + return maxDev + } + + /** + * Fraction of stroke points that are "locally straight" — where the windowed + * direction change is well below the minimum curvature expected from an + * ellipse inscribed in the same bounding box. + * + * An ellipse with semi-axes a,b has minimum curvature k_min = min(a,b)/max(a,b)² + * at the endpoints of the long axis. The corresponding windowed angle for a + * window spanning arc length s is approximately s × k_min. Points with angle + * below half this expected minimum are "truly straight" — they can only come + * from a rounded rectangle's flat sides, not from an ellipse's low-curvature zone. + * + * This adaptive threshold ensures elongated ovals (3:1, 4:1) are never + * misclassified as rounded rectangles, while still detecting the flat sides + * of rounded rectangles at any aspect ratio. + */ + internal fun straightFraction( + xs: FloatArray, ys: FloatArray, + minX: Float = xs.min(), maxX: Float = xs.max(), + minY: Float = ys.min(), maxY: Float = ys.max() + ): Float { + val n = xs.size + val window = maxOf(3, n / 14) + if (n < 2 * window + 1) return 0f + + val w = maxX - minX; val h = maxY - minY + val a = w / 2f; val b = h / 2f + if (a == 0f || b == 0f) return 0f + + // Approximate arc length per point, assuming roughly uniform spacing + // around the perimeter. Ellipse perimeter ≈ PI * (3(a+b) - sqrt((3a+b)(a+3b))). + val perim = (PI * (3 * (a + b) - sqrt((3 * a + b).toDouble() * (a + 3 * b)))).toFloat() + val arcPerWindow = perim * window / n + + // Minimum curvature of the inscribed ellipse: k_min = min(a,b) / max(a,b)² + val kMin = min(a, b) / (maxOf(a, b) * maxOf(a, b)) + + // Expected minimum windowed angle on the ellipse = arc × curvature. + // A point is "truly straight" if its angle is below half this — meaning + // it has less curvature than even the flattest part of the ellipse. + val threshold = (arcPerWindow * kMin * 0.5f) + .coerceIn((STRAIGHT_ANGLE_DEG * PI / 180).toFloat() * 0.1f, + (STRAIGHT_ANGLE_DEG * PI / 180).toFloat()) + + var straightCount = 0 + var measuredCount = 0 + for (i in window until n - window) { + val ax = xs[i] - xs[i - window]; val ay = ys[i] - ys[i - window] + val bx = xs[i + window] - xs[i]; val by = ys[i + window] - ys[i] + val lenA = hypot(ax.toDouble(), ay.toDouble()).toFloat() + val lenB = hypot(bx.toDouble(), by.toDouble()).toFloat() + if (lenA == 0f || lenB == 0f) continue + val dot = ((ax * bx + ay * by) / (lenA * lenB)).coerceIn(-1f, 1f) + val angle = acos(dot.toDouble()).toFloat() + measuredCount++ + if (angle < threshold) straightCount++ + } + return if (measuredCount > 0) straightCount.toFloat() / measuredCount else 0f + } + + private fun dist(x0: Float, y0: Float, x1: Float, y1: Float): Float { + val dx = x1 - x0; val dy = y1 - y0 + return sqrt(dx * dx + dy * dy) + } +} diff --git a/app/src/main/java/com/writer/view/SplitLayout.kt b/app/src/main/java/com/writer/view/SplitLayout.kt new file mode 100644 index 0000000..8cd4ca2 --- /dev/null +++ b/app/src/main/java/com/writer/view/SplitLayout.kt @@ -0,0 +1,88 @@ +package com.writer.view + +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import android.widget.LinearLayout + +/** + * A vertical LinearLayout that intercepts touch events near the divider + * to enable split-resize dragging. The divider stays visually thin (1dp) + * while the touch target is expanded to [TOUCH_TARGET_DP] on each side. + * + * Set [dividerView] after inflation and [onSplitDrag] to receive drag deltas. + */ +class SplitLayout @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr) { + + companion object { + /** Touch target expansion on each side of the divider, in dp. */ + private const val TOUCH_TARGET_DP = 24f + } + + /** The divider View whose position defines the drag zone. Must be set after inflation. */ + var dividerView: View? = null + + /** Called during a split drag with the raw Y delta in pixels. */ + var onSplitDrag: ((delta: Float) -> Unit)? = null + + /** Called when a split drag starts. */ + var onSplitDragStart: (() -> Unit)? = null + + /** Called when a split drag ends. */ + var onSplitDragEnd: (() -> Unit)? = null + + private var dragging = false + private var dragLastY = 0f + + private val touchTargetPx get() = ScreenMetrics.dp(TOUCH_TARGET_DP) + + override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { + // Only intercept finger touches — stylus must pass through for writing + if (ev.getToolType(0) != MotionEvent.TOOL_TYPE_FINGER) { + return super.onInterceptTouchEvent(ev) + } + + val divider = dividerView ?: return super.onInterceptTouchEvent(ev) + + when (ev.action) { + MotionEvent.ACTION_DOWN -> { + if (isNearDivider(ev.y, divider)) { + dragging = true + dragLastY = ev.rawY + onSplitDragStart?.invoke() + return true + } + } + } + return super.onInterceptTouchEvent(ev) + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + if (!dragging) return super.onTouchEvent(event) + + when (event.action) { + MotionEvent.ACTION_MOVE -> { + val delta = event.rawY - dragLastY + dragLastY = event.rawY + onSplitDrag?.invoke(delta) + return true + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + dragging = false + onSplitDragEnd?.invoke() + return true + } + } + return true + } + + private fun isNearDivider(y: Float, divider: View): Boolean { + val dividerCenter = divider.top + divider.height / 2f + return kotlin.math.abs(y - dividerCenter) <= touchTargetPx + } +} diff --git a/app/src/main/java/com/writer/view/TouchFilter.kt b/app/src/main/java/com/writer/view/TouchFilter.kt new file mode 100644 index 0000000..19edeb5 --- /dev/null +++ b/app/src/main/java/com/writer/view/TouchFilter.kt @@ -0,0 +1,126 @@ +package com.writer.view + +/** + * Layered palm-rejection filter for finger touches. + * + * Pure logic — no Android view dependency. Takes primitive parameters, returns + * accept/reject decisions. Unit-testable on JVM. + * + * Filter layers (cheapest first): + * 1. Concurrent stylus suppression — pen is down + * 2. Pen cooldown — pen lifted within [penCooldownMs] + * 3. Touch size threshold — contact area too large (palm) + * 4. Multi-touch rejection — pointerCount > 1 + * 5. Stationary contact timeout — finger hasn't moved past slop (canvas only) + */ +class TouchFilter( + private val palmSizeThresholdDp: Float = 40f, + private val penCooldownMs: Long = 150L, + private val stationarySlopDp: Float = 8f, + private val stationaryTimeoutMs: Long = 200L, +) { + + enum class Decision { ACCEPT, REJECT } + + // --- Pen state, set by the view --- + + @Volatile var penActive: Boolean = false + + /** Timestamp (uptimeMillis) when pen last lifted. */ + @Volatile var penUpTimestamp: Long = 0L + + // --- Per-touch tracking for stationary check --- + + private var fingerDownX: Float = 0f + private var fingerDownY: Float = 0f + private var fingerDownTime: Long = 0L + private var fingerMovedPastSlop: Boolean = false + + /** + * Evaluate whether a finger ACTION_DOWN should be accepted. + * + * @param pointerCount number of active pointers + * @param touchMinorDp minor axis of touch area in dp + * @param eventTime uptimeMillis of the event + * @param x screen x of the touch + * @param y screen y of the touch + */ + fun evaluateDown( + pointerCount: Int, + touchMinorDp: Float, + eventTime: Long, + x: Float, + y: Float, + ): Decision { + // Layer 1: pen is currently down + if (penActive) return Decision.REJECT + + // Layer 2: pen lifted recently + if (penUpTimestamp > 0L && (eventTime - penUpTimestamp) < penCooldownMs) { + return Decision.REJECT + } + + // Layer 3: contact area too large + if (touchMinorDp > palmSizeThresholdDp) return Decision.REJECT + + // Layer 4: multi-touch + if (pointerCount > 1) return Decision.REJECT + + // Passed all down-time checks — start tracking for stationary timeout + fingerDownX = x + fingerDownY = y + fingerDownTime = eventTime + fingerMovedPastSlop = false + + return Decision.ACCEPT + } + + /** + * Evaluate whether a finger ACTION_MOVE should be accepted. + * Must be called on every move after a successful [evaluateDown]. + * + * @param pointerCount number of active pointers + * @param touchMinorDp minor axis of touch area in dp + * @param eventTime uptimeMillis of the event + * @param x screen x + * @param y screen y + * @param checkStationary true for canvas (reject resting palm), false for text view + */ + fun evaluateMove( + pointerCount: Int, + touchMinorDp: Float, + eventTime: Long, + x: Float, + y: Float, + checkStationary: Boolean, + ): Decision { + // Layer 1: pen went down mid-gesture + if (penActive) return Decision.REJECT + + // Layer 3: contact area grew (palm settling) + if (touchMinorDp > palmSizeThresholdDp) return Decision.REJECT + + // Layer 4: second finger appeared + if (pointerCount > 1) return Decision.REJECT + + // Layer 5: stationary contact timeout (canvas only) + if (checkStationary && !fingerMovedPastSlop) { + val dx = x - fingerDownX + val dy = y - fingerDownY + val slopPx = stationarySlopDp * ScreenMetrics.density + if (dx * dx + dy * dy > slopPx * slopPx) { + fingerMovedPastSlop = true + } else if ((eventTime - fingerDownTime) > stationaryTimeoutMs) { + return Decision.REJECT + } + } + + return Decision.ACCEPT + } + + /** + * Check whether enough movement has happened for a scroll gesture. + * Call after [evaluateMove] returns ACCEPT. + */ + fun hasMovedPastSlop(): Boolean = fingerMovedPastSlop +} diff --git a/app/src/main/res/layout/activity_writing.xml b/app/src/main/res/layout/activity_writing.xml index 83dd92c..53fd024 100644 --- a/app/src/main/res/layout/activity_writing.xml +++ b/app/src/main/res/layout/activity_writing.xml @@ -1,6 +1,7 @@ - - + @@ -27,4 +29,4 @@ android:layout_height="0dp" android:layout_weight="3" /> - + diff --git a/app/src/main/res/layout/popup_menu.xml b/app/src/main/res/layout/popup_menu.xml index 8eb10e2..c2300e6 100644 --- a/app/src/main/res/layout/popup_menu.xml +++ b/app/src/main/res/layout/popup_menu.xml @@ -107,6 +107,26 @@ android:layout_marginStart="16dp" android:layout_marginEnd="16dp" /> + + + + + + + diff --git a/app/src/test/java/com/onyx/android/sdk/hwr/service/HWRInputArgsTest.kt b/app/src/test/java/com/onyx/android/sdk/hwr/service/HWRInputArgsTest.kt new file mode 100644 index 0000000..9d8c8b1 --- /dev/null +++ b/app/src/test/java/com/onyx/android/sdk/hwr/service/HWRInputArgsTest.kt @@ -0,0 +1,79 @@ +package com.onyx.android.sdk.hwr.service + +import android.app.Application +import android.os.Parcel +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class HWRInputArgsTest { + + @Test + fun parcelRoundTrip_preservesAllFields() { + val original = HWRInputArgs().apply { + lang = "fr_FR" + contentType = "Math" + recognizerType = "MS_ON_SCREEN" + viewWidth = 1920f + viewHeight = 1080f + offsetX = 10f + offsetY = 20f + isGestureEnable = true + isTextEnable = false + isShapeEnable = true + isIncremental = true + content = "some content" + } + + val parcel = Parcel.obtain() + try { + original.writeToParcel(parcel, 0) + parcel.setDataPosition(0) + + val restored = HWRInputArgs(parcel) + + assertEquals(original.lang, restored.lang) + assertEquals(original.contentType, restored.contentType) + assertEquals(original.recognizerType, restored.recognizerType) + assertEquals(original.viewWidth, restored.viewWidth, 0f) + assertEquals(original.viewHeight, restored.viewHeight, 0f) + assertEquals(original.offsetX, restored.offsetX, 0f) + assertEquals(original.offsetY, restored.offsetY, 0f) + assertEquals(original.isGestureEnable, restored.isGestureEnable) + assertEquals(original.isTextEnable, restored.isTextEnable) + assertEquals(original.isShapeEnable, restored.isShapeEnable) + assertEquals(original.isIncremental, restored.isIncremental) + assertEquals(original.content, restored.content) + } finally { + parcel.recycle() + } + } + + @Test + fun parcelRoundTrip_defaultValues() { + val original = HWRInputArgs() + + val parcel = Parcel.obtain() + try { + original.writeToParcel(parcel, 0) + parcel.setDataPosition(0) + + val restored = HWRInputArgs(parcel) + + assertEquals("en_US", restored.lang) + assertEquals("Text", restored.contentType) + assertEquals("Text", restored.recognizerType) + assertEquals(0f, restored.viewWidth, 0f) + assertEquals(0f, restored.viewHeight, 0f) + assertNull(restored.pfd) + assertNull(restored.content) + } finally { + parcel.recycle() + } + } +} diff --git a/app/src/test/java/com/writer/recognition/HwrProtobufTest.kt b/app/src/test/java/com/writer/recognition/HwrProtobufTest.kt new file mode 100644 index 0000000..e1b8b96 --- /dev/null +++ b/app/src/test/java/com/writer/recognition/HwrProtobufTest.kt @@ -0,0 +1,355 @@ +package com.writer.recognition + +import android.graphics.RectF +import com.writer.model.InkLine +import com.writer.model.InkStroke +import com.writer.model.StrokePoint +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.ByteArrayInputStream +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * Tests for [HwrProtobuf] — protobuf encoding for the Boox MyScript HWR service + * and JSON result parsing. + */ +class HwrProtobufTest { + + // ── Protobuf encoding ──────────────────────────────────────────────────── + + @Test fun protobuf_containsLanguageField() { + val line = singlePointLine(100f, 200f) + val bytes = HwrProtobuf.buildProtobuf(line, 1000f, 500f) + val fields = parseProtobuf(bytes) + // Field 1 (lang) should be "en_US" + val lang = fields.firstOrNull { it.fieldNumber == 1 } + assertEquals("en_US", lang?.stringValue) + } + + @Test fun protobuf_containsContentType() { + val line = singlePointLine(100f, 200f) + val bytes = HwrProtobuf.buildProtobuf(line, 1000f, 500f) + val fields = parseProtobuf(bytes) + val contentType = fields.firstOrNull { it.fieldNumber == 2 } + assertEquals("Text", contentType?.stringValue) + } + + @Test fun protobuf_containsRecognizerType() { + val line = singlePointLine(100f, 200f) + val bytes = HwrProtobuf.buildProtobuf(line, 1000f, 500f) + val fields = parseProtobuf(bytes) + val recognizerType = fields.firstOrNull { it.fieldNumber == 4 } + assertEquals("MS_ON_SCREEN", recognizerType?.stringValue) + } + + @Test fun protobuf_containsViewDimensions() { + val line = singlePointLine(100f, 200f) + val bytes = HwrProtobuf.buildProtobuf(line, 1000f, 500f) + val fields = parseProtobuf(bytes) + val width = fields.firstOrNull { it.fieldNumber == 5 } + val height = fields.firstOrNull { it.fieldNumber == 6 } + assertEquals(1000f, width?.floatValue) + assertEquals(500f, height?.floatValue) + } + + @Test fun protobuf_singleStroke_producesDownAndUpEvents() { + // Single stroke with 2 points → DOWN then UP + val stroke = InkStroke( + strokeId = "s1", + points = listOf( + StrokePoint(10f, 20f, 0.5f, 1000L), + StrokePoint(30f, 40f, 0.6f, 1010L) + ) + ) + val line = InkLine(listOf(stroke), RectF(10f, 20f, 30f, 40f)) + val bytes = HwrProtobuf.buildProtobuf(line, 100f, 100f) + val fields = parseProtobuf(bytes) + + // Field 15 = pointer events (length-delimited) + val pointerEvents = fields.filter { it.fieldNumber == 15 } + assertEquals("2 points → 2 pointer events", 2, pointerEvents.size) + + // Verify event types: first=DOWN(0), last=UP(2) + val firstEvent = parseProtobuf(pointerEvents[0].bytesValue!!) + val lastEvent = parseProtobuf(pointerEvents[1].bytesValue!!) + assertEquals("First point should be DOWN", 0L, firstEvent.first { it.fieldNumber == 6 }.varintValue) + assertEquals("Last point should be UP", 2L, lastEvent.first { it.fieldNumber == 6 }.varintValue) + } + + @Test fun protobuf_multiPointStroke_producesDownMoveUpEvents() { + // Stroke with 4 points → DOWN, MOVE, MOVE, UP + val stroke = InkStroke( + strokeId = "s1", + points = listOf( + StrokePoint(0f, 0f, 0.5f, 100L), + StrokePoint(10f, 10f, 0.5f, 110L), + StrokePoint(20f, 20f, 0.5f, 120L), + StrokePoint(30f, 30f, 0.5f, 130L) + ) + ) + val line = InkLine(listOf(stroke), RectF(0f, 0f, 30f, 30f)) + val bytes = HwrProtobuf.buildProtobuf(line, 100f, 100f) + val fields = parseProtobuf(bytes) + val pointerEvents = fields.filter { it.fieldNumber == 15 } + assertEquals("4 points → 4 pointer events", 4, pointerEvents.size) + } + + @Test fun protobuf_emptyStroke_skipped() { + val stroke = InkStroke(strokeId = "s1", points = emptyList()) + val line = InkLine(listOf(stroke), RectF()) + val bytes = HwrProtobuf.buildProtobuf(line, 100f, 100f) + val fields = parseProtobuf(bytes) + val pointerEvents = fields.filter { it.fieldNumber == 15 } + assertEquals("Empty stroke should produce no pointer events", 0, pointerEvents.size) + } + + @Test fun protobuf_multipleStrokes_allPointsEncoded() { + val stroke1 = InkStroke( + strokeId = "s1", + points = listOf( + StrokePoint(0f, 0f, 0.5f, 100L), + StrokePoint(10f, 10f, 0.5f, 110L) + ) + ) + val stroke2 = InkStroke( + strokeId = "s2", + points = listOf( + StrokePoint(20f, 0f, 0.5f, 200L), + StrokePoint(30f, 10f, 0.5f, 210L), + StrokePoint(40f, 20f, 0.5f, 220L) + ) + ) + val line = InkLine(listOf(stroke1, stroke2), RectF(0f, 0f, 40f, 20f)) + val bytes = HwrProtobuf.buildProtobuf(line, 100f, 100f) + val fields = parseProtobuf(bytes) + val pointerEvents = fields.filter { it.fieldNumber == 15 } + assertEquals("2+3 points → 5 pointer events", 5, pointerEvents.size) + } + + @Test fun protobuf_singlePointStroke_producesDownAndUpEvents() { + // Single-point stroke → must emit both DOWN and UP (regression: previously only DOWN) + val line = singlePointLine(50f, 75f) + val bytes = HwrProtobuf.buildProtobuf(line, 100f, 100f) + val fields = parseProtobuf(bytes) + val pointerEvents = fields.filter { it.fieldNumber == 15 } + assertEquals("1-point stroke → 2 pointer events (DOWN+UP)", 2, pointerEvents.size) + + val downEvent = parseProtobuf(pointerEvents[0].bytesValue!!) + val upEvent = parseProtobuf(pointerEvents[1].bytesValue!!) + assertEquals("First event should be DOWN", 0L, downEvent.first { it.fieldNumber == 6 }.varintValue) + assertEquals("Second event should be UP", 2L, upEvent.first { it.fieldNumber == 6 }.varintValue) + + // Both events should have the same coordinates + assertEquals(50f, downEvent.first { it.fieldNumber == 1 }.floatValue) + assertEquals(75f, downEvent.first { it.fieldNumber == 2 }.floatValue) + assertEquals(50f, upEvent.first { it.fieldNumber == 1 }.floatValue) + assertEquals(75f, upEvent.first { it.fieldNumber == 2 }.floatValue) + } + + @Test fun protobuf_customLanguage_isEncoded() { + val line = singlePointLine(0f, 0f) + val bytes = HwrProtobuf.buildProtobuf(line, 100f, 100f, lang = "zh_CN") + val fields = parseProtobuf(bytes) + val lang = fields.firstOrNull { it.fieldNumber == 1 } + assertEquals("zh_CN", lang?.stringValue) + } + + @Test fun protobuf_multiPointStroke_eventTypeSequence() { + // 4 points → DOWN, MOVE, MOVE, UP + val stroke = InkStroke( + strokeId = "s1", + points = listOf( + StrokePoint(0f, 0f, 0.5f, 100L), + StrokePoint(10f, 10f, 0.5f, 110L), + StrokePoint(20f, 20f, 0.5f, 120L), + StrokePoint(30f, 30f, 0.5f, 130L) + ) + ) + val line = InkLine(listOf(stroke), RectF(0f, 0f, 30f, 30f)) + val bytes = HwrProtobuf.buildProtobuf(line, 100f, 100f) + val fields = parseProtobuf(bytes) + val pointerEvents = fields.filter { it.fieldNumber == 15 } + + val eventTypes = pointerEvents.map { event -> + parseProtobuf(event.bytesValue!!).first { it.fieldNumber == 6 }.varintValue + } + assertEquals("DOWN, MOVE, MOVE, UP", listOf(0L, 1L, 1L, 2L), eventTypes) + } + + @Test fun protobuf_pointerEvent_coordinatesAndPressureRoundTrip() { + val bytes = HwrProtobuf.encodePointerProto( + x = 123.456f, y = 789.012f, t = 999L, f = 0.42f, + pointerId = 0, eventType = 1, pointerType = 0 + ) + val fields = parseProtobuf(bytes) + assertEquals(123.456f, fields.first { it.fieldNumber == 1 }.floatValue) + assertEquals(789.012f, fields.first { it.fieldNumber == 2 }.floatValue) + assertEquals(0.42f, fields.first { it.fieldNumber == 4 }.floatValue) + } + + @Test fun protobuf_outputIsNonEmpty() { + val line = singlePointLine(100f, 200f) + val bytes = HwrProtobuf.buildProtobuf(line, 1000f, 500f) + assertTrue("Protobuf output should be non-empty", bytes.isNotEmpty()) + } + + // ── Pointer event encoding ─────────────────────────────────────────────── + + @Test fun pointerProto_containsCoordinates() { + val bytes = HwrProtobuf.encodePointerProto( + x = 42.5f, y = 99.0f, t = 12345L, f = 0.7f, + pointerId = 0, eventType = 1, pointerType = 0 + ) + val fields = parseProtobuf(bytes) + assertEquals(42.5f, fields.first { it.fieldNumber == 1 }.floatValue) + assertEquals(99.0f, fields.first { it.fieldNumber == 2 }.floatValue) + } + + @Test fun pointerProto_containsPressure() { + val bytes = HwrProtobuf.encodePointerProto( + x = 0f, y = 0f, t = 0L, f = 0.85f, + pointerId = 0, eventType = 0, pointerType = 0 + ) + val fields = parseProtobuf(bytes) + assertEquals(0.85f, fields.first { it.fieldNumber == 4 }.floatValue) + } + + @Test fun pointerProto_containsEventType() { + for (eventType in listOf(0, 1, 2)) { + val bytes = HwrProtobuf.encodePointerProto( + x = 0f, y = 0f, t = 0L, f = 0.5f, + pointerId = 0, eventType = eventType, pointerType = 0 + ) + val fields = parseProtobuf(bytes) + assertEquals( + "Event type $eventType should be encoded", + eventType.toLong(), fields.first { it.fieldNumber == 6 }.varintValue + ) + } + } + + // ── JSON result parsing ────────────────────────────────────────────────── + + @Test fun parseResult_successWithLabel() { + val json = """{"result":{"label":"hello world"}}""" + assertEquals("hello world", HwrProtobuf.parseHwrResult(json)) + } + + @Test fun parseResult_topLevelLabel() { + val json = """{"label":"fallback text"}""" + assertEquals("fallback text", HwrProtobuf.parseHwrResult(json)) + } + + @Test fun parseResult_emptyResult() { + val json = """{"result":{"label":""}}""" + assertEquals("", HwrProtobuf.parseHwrResult(json)) + } + + @Test fun parseResult_exception_returnsEmpty() { + val json = """{"exception":{"cause":{"message":"recognition failed"}}}""" + assertEquals("", HwrProtobuf.parseHwrResult(json)) + } + + @Test fun parseResult_invalidJson_returnsEmpty() { + assertEquals("", HwrProtobuf.parseHwrResult("not json")) + } + + @Test fun parseResult_emptyString_returnsEmpty() { + assertEquals("", HwrProtobuf.parseHwrResult("")) + } + + @Test fun parseResult_noLabelField_returnsEmpty() { + val json = """{"result":{"other":"data"}}""" + assertEquals("", HwrProtobuf.parseHwrResult(json)) + } + + // ── Protobuf primitives ────────────────────────────────────────────────── + + @Test fun writeVarint_smallValue() { + val out = java.io.ByteArrayOutputStream() + HwrProtobuf.writeVarint(out, 1L) + assertEquals(1, out.size()) + assertEquals(1, out.toByteArray()[0].toInt()) + } + + @Test fun writeVarint_multiByteValue() { + val out = java.io.ByteArrayOutputStream() + HwrProtobuf.writeVarint(out, 300L) + // 300 = 0b100101100 → varint: 0xAC 0x02 + assertEquals(2, out.size()) + } + + @Test fun writeFixed32_encodesFloatCorrectly() { + val out = java.io.ByteArrayOutputStream() + HwrProtobuf.writeFixed32(out, 1.0f) + val expected = java.lang.Float.floatToIntBits(1.0f) + val actual = ByteBuffer.wrap(out.toByteArray()).order(ByteOrder.LITTLE_ENDIAN).int + assertEquals(expected, actual) + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private fun singlePointLine(x: Float, y: Float): InkLine { + val stroke = InkStroke( + strokeId = "test", + points = listOf(StrokePoint(x, y, 0.5f, 1000L)) + ) + return InkLine(listOf(stroke), RectF(x, y, x, y)) + } + + /** Minimal protobuf field parser for test assertions. */ + private data class ProtoField( + val fieldNumber: Int, + val wireType: Int, + val varintValue: Long = 0, + val floatValue: Float? = null, + val stringValue: String? = null, + val bytesValue: ByteArray? = null + ) + + private fun parseProtobuf(data: ByteArray): List { + val fields = mutableListOf() + val stream = ByteArrayInputStream(data) + + while (stream.available() > 0) { + val tag = readVarint(stream) ?: break + val fieldNumber = (tag shr 3).toInt() + val wireType = (tag and 0x7).toInt() + + when (wireType) { + 0 -> { // varint + val value = readVarint(stream) ?: break + fields.add(ProtoField(fieldNumber, wireType, varintValue = value)) + } + 2 -> { // length-delimited + val len = readVarint(stream)?.toInt() ?: break + val bytes = ByteArray(len) + stream.read(bytes) + val str = try { String(bytes, Charsets.UTF_8) } catch (_: Exception) { null } + fields.add(ProtoField(fieldNumber, wireType, stringValue = str, bytesValue = bytes)) + } + 5 -> { // fixed32 + val bytes = ByteArray(4) + stream.read(bytes) + val float = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).float + fields.add(ProtoField(fieldNumber, wireType, floatValue = float)) + } + } + } + return fields + } + + private fun readVarint(stream: ByteArrayInputStream): Long? { + var result = 0L + var shift = 0 + while (true) { + val b = stream.read() + if (b == -1) return null + result = result or ((b.toLong() and 0x7F) shl shift) + if (b and 0x80 == 0) return result + shift += 7 + } + } +} diff --git a/app/src/test/java/com/writer/recognition/LineSegmenterTest.kt b/app/src/test/java/com/writer/recognition/LineSegmenterTest.kt new file mode 100644 index 0000000..1dc414c --- /dev/null +++ b/app/src/test/java/com/writer/recognition/LineSegmenterTest.kt @@ -0,0 +1,186 @@ +package com.writer.recognition + +import com.writer.model.InkStroke +import com.writer.model.StrokePoint +import com.writer.view.ScreenMetrics +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Unit tests for [LineSegmenter] line assignment using Y-centroid. + * + * Uses standard Boox device metrics: density=1.875, line spacing=77px. + * Lines are numbered from 0 at TOP_MARGIN. + */ +class LineSegmenterTest { + + companion object { + private const val DENSITY = 1.875f + } + + private lateinit var segmenter: LineSegmenter + private var LS = 0f + private var TM = 0f + + @Before + fun setUp() { + ScreenMetrics.init(DENSITY, smallestWidthDp = 674, widthPixels = 1264, heightPixels = 1680) + segmenter = LineSegmenter() + LS = ScreenMetrics.lineSpacing + TM = ScreenMetrics.topMargin + } + + /** Create a stroke from (x,y) pairs. */ + private fun stroke(vararg pairs: Pair): InkStroke = + InkStroke(points = pairs.map { (x, y) -> StrokePoint(x, y, 0.5f, 0L) }) + + /** Y coordinate at the center of a given line. */ + private fun lineCenter(line: Int) = TM + line * LS + LS * 0.5f + + /** Y coordinate at the top of a given line. */ + private fun lineTop(line: Int) = TM + line * LS + + // ── Basic assignment ────────────────────────────────────────────────────── + + @Test + fun `stroke centered on line 0 assigns to line 0`() { + val s = stroke(100f to lineCenter(0), 200f to lineCenter(0)) + assertEquals(0, segmenter.getStrokeLineIndex(s)) + } + + @Test + fun `stroke centered on line 5 assigns to line 5`() { + val s = stroke(100f to lineCenter(5), 200f to lineCenter(5)) + assertEquals(5, segmenter.getStrokeLineIndex(s)) + } + + // ── Centroid-based assignment ────────────────────────────────────────────── + + @Test + fun `stroke starting above line but centered on line assigns correctly`() { + // Stroke starts slightly above line 3 but body is on line 3 + val startY = lineTop(3) - LS * 0.1f // above line 3 + val endY = lineCenter(3) + LS * 0.3f // well into line 3 + // Centroid = (startY + endY) / 2, which is in line 3 + val s = stroke(100f to startY, 200f to endY) + assertEquals("Centroid is in line 3", 3, segmenter.getStrokeLineIndex(s)) + } + + @Test + fun `descender stroke stays on its line`() { + // "g" or "y": body on line 4, descender extends into line 5 + val bodyTop = lineCenter(4) - LS * 0.3f + val descenderBottom = lineTop(5) + LS * 0.3f + // Centroid = midpoint of bodyTop and descenderBottom + val s = stroke(100f to bodyTop, 100f to descenderBottom) + assertEquals("Descender should stay on line 4", 4, segmenter.getStrokeLineIndex(s)) + } + + @Test + fun `ascender stroke stays on its line`() { + // "t" crossbar or "l": starts high, body on line 4 + val ascenderTop = lineTop(4) - LS * 0.2f + val bodyBottom = lineCenter(4) + LS * 0.2f + val s = stroke(100f to ascenderTop, 100f to bodyBottom) + assertEquals("Ascender should stay on line 4", 4, segmenter.getStrokeLineIndex(s)) + } + + // ── Cursive / overlapping words on same line ────────────────────────────── + + @Test + fun `two words at slightly different heights assign to same line`() { + // "helped" starts slightly lower, "problematic" starts slightly higher + // but both are on the same visual line + val line = 5 + val helpedStart = lineCenter(line) + LS * 0.1f // slightly below center + val helpedEnd = lineCenter(line) + LS * 0.3f + val helped = stroke(100f to helpedStart, 200f to helpedEnd) + + val probStart = lineCenter(line) - LS * 0.15f // slightly above center + val probEnd = lineCenter(line) + LS * 0.2f + val problematic = stroke(300f to probStart, 500f to probEnd) + + assertEquals( + "Both words should be on the same line", + segmenter.getStrokeLineIndex(helped), + segmenter.getStrokeLineIndex(problematic) + ) + } + + @Test + fun `cursive stroke crossing line boundary assigns by centroid`() { + // Cursive "p" starts with upstroke from line 6, body on line 5 + // First point is on line 6, but centroid is on line 5 + val firstPointY = lineCenter(6) - LS * 0.1f // in line 6 + val bodyTopY = lineCenter(5) - LS * 0.2f // in line 5 + val bodyBottomY = lineCenter(5) + LS * 0.3f // in line 5 + val s = stroke( + 100f to firstPointY, // starts in line 6 + 110f to bodyTopY, // moves up to line 5 + 120f to bodyBottomY // body in line 5 + ) + // Centroid = (min(firstPointY, bodyTopY) + max(firstPointY, bodyBottomY)) / 2 + // = (bodyTopY + bodyBottomY) / 2 ≈ center of line 5 + assertEquals("Centroid-based assignment should pick line 5", 5, segmenter.getStrokeLineIndex(s)) + } + + @Test + fun `first-point-based would misassign but centroid assigns correctly`() { + // Stroke that starts on line 4 but has most of its body on line 3 + // (e.g. an upstroke starting from a previous word's descender) + val startY = lineCenter(4) // first point: line 4 + val bodyMid = lineCenter(3) // body: line 3 + val bodyTop = lineTop(3) + LS * 0.1f // body top: line 3 + val s = stroke( + 100f to startY, // starts in line 4 + 110f to bodyMid, // body in line 3 + 120f to bodyTop, // body in line 3 + 130f to bodyMid // body in line 3 + ) + // minY = bodyTop (line 3), maxY = startY (line 4) + // centroid = (bodyTop + startY) / 2 — should be in line 3 + assertEquals("Centroid should place this on line 3", 3, segmenter.getStrokeLineIndex(s)) + } + + // ── groupByLine with overlapping strokes ────────────────────────────────── + + @Test + fun `groupByLine puts overlapping words on same line`() { + val line = 5 + // Two words at slightly different Y positions but same visual line + val word1 = stroke(100f to (lineCenter(line) + 5f), 200f to (lineCenter(line) + 10f)) + val word2 = stroke(300f to (lineCenter(line) - 5f), 400f to (lineCenter(line) + 5f)) + + val groups = segmenter.groupByLine(listOf(word1, word2)) + assertEquals("Both strokes should be in 1 line group", 1, groups.size) + assertEquals("That group should have 2 strokes", 2, groups.values.first().size) + } + + @Test + fun `groupByLine separates words on different lines`() { + val word1 = stroke(100f to lineCenter(3), 200f to lineCenter(3)) + val word2 = stroke(100f to lineCenter(5), 200f to lineCenter(5)) + + val groups = segmenter.groupByLine(listOf(word1, word2)) + assertEquals("Strokes on different lines should form 2 groups", 2, groups.size) + } + + @Test + fun `groupByLine with descender keeps stroke on original line`() { + // "g" on line 3: body in line 3, descender reaches line 4 + val g = stroke( + 100f to (lineCenter(3) - LS * 0.2f), + 110f to (lineCenter(3) + LS * 0.1f), + 105f to (lineTop(4) + LS * 0.2f) // descender + ) + // "a" on line 4: entirely on line 4 + val a = stroke(100f to lineCenter(4), 150f to lineCenter(4)) + + val groups = segmenter.groupByLine(listOf(g, a)) + assertEquals("g and a should be on different lines", 2, groups.size) + assertTrue("g should be on line 3", groups.containsKey(3)) + assertTrue("a should be on line 4", groups.containsKey(4)) + } +} diff --git a/app/src/test/java/com/writer/recognition/OnyxHwrTextRecognizerTest.kt b/app/src/test/java/com/writer/recognition/OnyxHwrTextRecognizerTest.kt new file mode 100644 index 0000000..9dcdc93 --- /dev/null +++ b/app/src/test/java/com/writer/recognition/OnyxHwrTextRecognizerTest.kt @@ -0,0 +1,79 @@ +package com.writer.recognition + +import android.app.Application +import android.os.ParcelFileDescriptor +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.FileInputStream +import java.io.FileOutputStream + +/** + * Tests for the pipe-based IPC used by [OnyxHwrTextRecognizer] + * to pass protobuf data to the Boox HWR service via ParcelFileDescriptor. + * + * Runs under Robolectric with a plain Application to avoid WriterApplication's + * HiddenApiBypass dependency on sun.misc.Unsafe. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class OnyxHwrTextRecognizerTest { + + // ── Pipe-based IPC ──────────────────────────────────────────────────────── + + @Test + fun pipe_roundTrips_data() { + val data = ByteArray(17_000) { (it % 256).toByte() } + val pipe = ParcelFileDescriptor.createPipe() + val readPfd = pipe[0] + val writePfd = pipe[1] + + FileOutputStream(writePfd.fileDescriptor).use { it.write(data) } + writePfd.close() + + val readBack = FileInputStream(readPfd.fileDescriptor).use { it.readBytes() } + readPfd.close() + + assertEquals(data.size, readBack.size) + for (i in data.indices) { + assertEquals("Byte $i", data[i], readBack[i]) + } + } + + // ── End-to-end: real protobuf through pipe ────────────────────────────── + + @Test + fun realProtobuf_throughPipe_roundTrips() { + val stroke = com.writer.model.InkStroke( + strokeId = "s1", + points = listOf( + com.writer.model.StrokePoint(100f, 200f, 0.5f, 1000L), + com.writer.model.StrokePoint(110f, 210f, 0.6f, 1010L), + com.writer.model.StrokePoint(120f, 220f, 0.7f, 1020L) + ) + ) + val line = com.writer.model.InkLine( + listOf(stroke), + android.graphics.RectF(100f, 200f, 120f, 220f) + ) + val protoBytes = HwrProtobuf.buildProtobuf(line, 1000f, 500f, "en_US") + + val pipe = ParcelFileDescriptor.createPipe() + val readPfd = pipe[0] + val writePfd = pipe[1] + + FileOutputStream(writePfd.fileDescriptor).use { it.write(protoBytes) } + writePfd.close() + + val readBack = FileInputStream(readPfd.fileDescriptor).use { it.readBytes() } + readPfd.close() + + assertEquals("Protobuf bytes should survive pipe round-trip", + protoBytes.size, readBack.size) + for (i in protoBytes.indices) { + assertEquals("Byte $i", protoBytes[i], readBack[i]) + } + } +} diff --git a/app/src/test/java/com/writer/recognition/StrokeDownsamplerTest.kt b/app/src/test/java/com/writer/recognition/StrokeDownsamplerTest.kt new file mode 100644 index 0000000..03f798f --- /dev/null +++ b/app/src/test/java/com/writer/recognition/StrokeDownsamplerTest.kt @@ -0,0 +1,90 @@ +package com.writer.recognition + +import com.writer.model.InkStroke +import com.writer.model.StrokePoint +import org.junit.Assert.assertEquals +import org.junit.Test + +class StrokeDownsamplerTest { + + private fun pt(x: Float, y: Float, pressure: Float = 100f, timestamp: Long = 0L) = + StrokePoint(x, y, pressure, timestamp) + + @Test + fun collapseIdenticalPositions_collapsesDwell() { + val points = listOf( + pt(10f, 20f, 100f, 1L), + pt(10f, 20f, 200f, 2L), + pt(10f, 20f, 300f, 3L), + pt(10f, 20f, 400f, 4L), + pt(15f, 25f, 500f, 5L), + ) + val result = StrokeDownsampler.collapseIdenticalPositions(points) + assertEquals(3, result.size) + // First point of dwell run + assertEquals(1L, result[0].timestamp) + // Last point of dwell run + assertEquals(4L, result[1].timestamp) + // Next distinct point + assertEquals(5L, result[2].timestamp) + } + + @Test + fun collapseIdenticalPositions_singlePoint() { + val points = listOf(pt(10f, 20f)) + assertEquals(1, StrokeDownsampler.collapseIdenticalPositions(points).size) + } + + @Test + fun collapseIdenticalPositions_noDuplicates() { + val points = listOf(pt(1f, 1f), pt(2f, 2f), pt(3f, 3f)) + assertEquals(3, StrokeDownsampler.collapseIdenticalPositions(points).size) + } + + @Test + fun rdp_epsilonZero_preservesAll() { + val points = listOf(pt(0f, 0f), pt(1f, 1f), pt(2f, 0f)) + val result = StrokeDownsampler.rdp(points, 0f) + assertEquals(3, result.size) + } + + @Test + fun rdp_largeEpsilon_endpointsOnly() { + val points = listOf(pt(0f, 0f), pt(1f, 0.1f), pt(2f, 0f)) + val result = StrokeDownsampler.rdp(points, 100f) + assertEquals(2, result.size) + assertEquals(0f, result[0].x, 0.001f) + assertEquals(2f, result[1].x, 0.001f) + } + + @Test + fun rdp_twoPoints_passthrough() { + val points = listOf(pt(0f, 0f), pt(10f, 10f)) + val result = StrokeDownsampler.rdp(points, 1f) + assertEquals(2, result.size) + } + + @Test + fun rdp_singlePoint_passthrough() { + val points = listOf(pt(5f, 5f)) + assertEquals(1, StrokeDownsampler.rdp(points, 1f).size) + } + + @Test + fun downsample_combinesBothPasses() { + val points = listOf( + // Dwell at start + pt(0f, 0f, 100f, 1L), + pt(0f, 0f, 200f, 2L), + pt(0f, 0f, 300f, 3L), + // Meaningful movement + pt(5f, 5f, 400f, 4L), + pt(10f, 0f, 500f, 5L), + ) + val stroke = InkStroke(strokeId = "test", points = points) + val result = StrokeDownsampler.downsample(stroke, 0.5f) + // Dwell collapsed to 2 points, then RDP keeps significant points + assert(result.points.size < points.size) + assertEquals("test", result.strokeId) + } +} diff --git a/app/src/test/java/com/writer/recognition/TextRecognizerFactoryTest.kt b/app/src/test/java/com/writer/recognition/TextRecognizerFactoryTest.kt new file mode 100644 index 0000000..72f02c5 --- /dev/null +++ b/app/src/test/java/com/writer/recognition/TextRecognizerFactoryTest.kt @@ -0,0 +1,59 @@ +package com.writer.recognition + +import android.app.Application +import android.content.ComponentName +import android.content.Intent +import android.content.pm.ResolveInfo +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class TextRecognizerFactoryTest { + + @Test + fun create_withOnyxService_returnsOnyxRecognizer() { + val app = RuntimeEnvironment.getApplication() + val pm = shadowOf(app.packageManager) + val intent = Intent().apply { + component = ComponentName( + "com.onyx.android.ksync", + "com.onyx.android.ksync.service.KHwrService" + ) + } + pm.addResolveInfoForIntent(intent, ResolveInfo()) + + val recognizer = TextRecognizerFactory.create(app) + assertTrue( + "Expected OnyxHwrTextRecognizer when Onyx service is available", + recognizer is OnyxHwrTextRecognizer + ) + recognizer.close() + } + + @Test + fun create_withoutOnyxService_returnsGoogleMLKit() { + // Robolectric has no Onyx service registered — but GoogleMLKitTextRecognizer + // requires MlKitContext which isn't available in unit tests. + // Instead, verify the selection logic: without the service, isOnyxHwrAvailable returns false. + val app = RuntimeEnvironment.getApplication() + val pm = shadowOf(app.packageManager) + val intent = Intent().apply { + component = ComponentName( + "com.onyx.android.ksync", + "com.onyx.android.ksync.service.KHwrService" + ) + } + // Ensure no resolve info is registered + val resolved = app.packageManager.resolveService(intent, 0) + assertTrue( + "Onyx HWR service should not be resolvable in test environment", + resolved == null + ) + } +} diff --git a/app/src/test/java/com/writer/storage/DocumentStorageSerializationTest.kt b/app/src/test/java/com/writer/storage/DocumentStorageSerializationTest.kt new file mode 100644 index 0000000..db56343 --- /dev/null +++ b/app/src/test/java/com/writer/storage/DocumentStorageSerializationTest.kt @@ -0,0 +1,178 @@ +package com.writer.storage + +import com.writer.model.DocumentData +import com.writer.model.InkStroke +import com.writer.model.StrokePoint +import com.writer.model.StrokeType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Round-trip tests for [DocumentStorage] JSON serialization. + * Verifies that strokeType and isGeometric survive save/load. + */ +class DocumentStorageSerializationTest { + + private fun makeData(strokes: List) = DocumentData( + strokes = strokes, + scrollOffsetY = 0f, + lineTextCache = emptyMap(), + everHiddenLines = emptySet(), + highestLineIndex = 0, + currentLineIndex = 0 + ) + + private fun roundTrip(strokes: List): List { + val json = DocumentStorage.serializeToJson(makeData(strokes)) + val loaded = DocumentStorage.deserializeFromJson(json.toString()) + return loaded.strokes + } + + private fun samplePoints() = listOf( + StrokePoint(0f, 0f, 1f, 0L), + StrokePoint(100f, 100f, 1f, 100L) + ) + + // ── strokeType persistence ────────────────────────────────────────────── + + @Test fun arrowHead_survivesRoundTrip() { + val strokes = roundTrip(listOf( + InkStroke(points = samplePoints(), strokeType = StrokeType.ARROW_HEAD, isGeometric = true) + )) + assertEquals(StrokeType.ARROW_HEAD, strokes[0].strokeType) + } + + @Test fun arrowTail_survivesRoundTrip() { + val strokes = roundTrip(listOf( + InkStroke(points = samplePoints(), strokeType = StrokeType.ARROW_TAIL, isGeometric = true) + )) + assertEquals(StrokeType.ARROW_TAIL, strokes[0].strokeType) + } + + @Test fun arrowBoth_survivesRoundTrip() { + val strokes = roundTrip(listOf( + InkStroke(points = samplePoints(), strokeType = StrokeType.ARROW_BOTH, isGeometric = true) + )) + assertEquals(StrokeType.ARROW_BOTH, strokes[0].strokeType) + } + + @Test fun line_survivesRoundTrip() { + val strokes = roundTrip(listOf( + InkStroke(points = samplePoints(), strokeType = StrokeType.LINE, isGeometric = true) + )) + assertEquals(StrokeType.LINE, strokes[0].strokeType) + } + + @Test fun rectangle_survivesRoundTrip() { + val strokes = roundTrip(listOf( + InkStroke(points = samplePoints(), strokeType = StrokeType.RECTANGLE, isGeometric = true) + )) + assertEquals(StrokeType.RECTANGLE, strokes[0].strokeType) + } + + @Test fun ellipse_survivesRoundTrip() { + val strokes = roundTrip(listOf( + InkStroke(points = samplePoints(), strokeType = StrokeType.ELLIPSE, isGeometric = true) + )) + assertEquals(StrokeType.ELLIPSE, strokes[0].strokeType) + } + + @Test fun freehand_survivesRoundTrip() { + val strokes = roundTrip(listOf( + InkStroke(points = samplePoints(), strokeType = StrokeType.FREEHAND) + )) + assertEquals(StrokeType.FREEHAND, strokes[0].strokeType) + } + + // ── isGeometric persistence ───────────────────────────────────────────── + + @Test fun isGeometricTrue_survivesRoundTrip() { + val strokes = roundTrip(listOf( + InkStroke(points = samplePoints(), isGeometric = true, strokeType = StrokeType.RECTANGLE) + )) + assertTrue("isGeometric should be true", strokes[0].isGeometric) + } + + @Test fun isGeometricFalse_survivesRoundTrip() { + val strokes = roundTrip(listOf( + InkStroke(points = samplePoints(), isGeometric = false) + )) + assertFalse("isGeometric should be false", strokes[0].isGeometric) + } + + // ── Backward compatibility ────────────────────────────────────────────── + + @Test fun oldJsonWithoutStrokeType_defaultsToFreehand() { + // Simulate a document saved before strokeType was added + val json = """ + { + "scrollOffsetY": 0, + "highestLineIndex": 0, + "currentLineIndex": 0, + "lineTextCache": {}, + "everHiddenLines": [], + "strokes": [{ + "strokeId": "s1", + "strokeWidth": 3.0, + "points": [ + {"x": 0, "y": 0, "pressure": 1, "timestamp": 0}, + {"x": 100, "y": 100, "pressure": 1, "timestamp": 100} + ] + }], + "diagramAreas": [] + } + """.trimIndent() + + val loaded = DocumentStorage.deserializeFromJson(json) + assertEquals(StrokeType.FREEHAND, loaded.strokes[0].strokeType) + assertFalse(loaded.strokes[0].isGeometric) + } + + @Test fun unknownStrokeType_defaultsToFreehand() { + val json = """ + { + "scrollOffsetY": 0, + "highestLineIndex": 0, + "currentLineIndex": 0, + "lineTextCache": {}, + "everHiddenLines": [], + "strokes": [{ + "strokeId": "s1", + "strokeWidth": 3.0, + "strokeType": "FUTURE_TYPE", + "points": [ + {"x": 0, "y": 0, "pressure": 1, "timestamp": 0} + ] + }], + "diagramAreas": [] + } + """.trimIndent() + + val loaded = DocumentStorage.deserializeFromJson(json) + assertEquals(StrokeType.FREEHAND, loaded.strokes[0].strokeType) + } + + // ── Multiple strokes with mixed types ─────────────────────────────────── + + @Test fun mixedStrokeTypes_allSurviveRoundTrip() { + val original = listOf( + InkStroke(points = samplePoints(), strokeType = StrokeType.FREEHAND), + InkStroke(points = samplePoints(), strokeType = StrokeType.ARROW_HEAD, isGeometric = true), + InkStroke(points = samplePoints(), strokeType = StrokeType.RECTANGLE, isGeometric = true), + InkStroke(points = samplePoints(), strokeType = StrokeType.ELLIPSE, isGeometric = true), + ) + + val loaded = roundTrip(original) + assertEquals(4, loaded.size) + assertEquals(StrokeType.FREEHAND, loaded[0].strokeType) + assertFalse(loaded[0].isGeometric) + assertEquals(StrokeType.ARROW_HEAD, loaded[1].strokeType) + assertTrue(loaded[1].isGeometric) + assertEquals(StrokeType.RECTANGLE, loaded[2].strokeType) + assertTrue(loaded[2].isGeometric) + assertEquals(StrokeType.ELLIPSE, loaded[3].strokeType) + assertTrue(loaded[3].isGeometric) + } +} diff --git a/app/src/test/java/com/writer/ui/writing/DiagramIntentTest.kt b/app/src/test/java/com/writer/ui/writing/DiagramIntentTest.kt new file mode 100644 index 0000000..f0cb0ab --- /dev/null +++ b/app/src/test/java/com/writer/ui/writing/DiagramIntentTest.kt @@ -0,0 +1,1157 @@ +package com.writer.ui.writing + +import com.writer.model.DiagramArea +import com.writer.model.DocumentModel +import com.writer.model.InkStroke +import com.writer.model.StrokePoint +import com.writer.model.StrokeType +import com.writer.model.shiftY +import com.writer.recognition.LineSegmenter +import com.writer.view.HandwritingCanvasView +import com.writer.view.ScreenMetrics +import com.writer.view.ShapeSnapDetection +import com.writer.view.ScratchOutDetection +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin + +/** + * Integration tests for diagram-intent classification: when a stroke is detected + * as a diagram shape outside a diagram area, a diagram region is auto-created. + * + * Exercises the full flow: shape detection → diagram area creation → merge → undo/redo. + * Uses the same simulation pattern as [UndoUnsnapTest] — pure JVM, no Android framework. + */ +class DiagramIntentTest { + + companion object { + private const val DENSITY = 1.875f + private val LS get() = HandwritingCanvasView.LINE_SPACING + private val TM get() = HandwritingCanvasView.TOP_MARGIN + } + + private lateinit var documentModel: DocumentModel + private lateinit var undoManager: UndoManager + private lateinit var lineSegmenter: LineSegmenter + + private fun currentSnapshot() = UndoManager.Snapshot( + strokes = documentModel.activeStrokes.toList(), + scrollOffsetY = 0f, + lineTextCache = emptyMap(), + diagramAreas = documentModel.diagramAreas.toList() + ) + + private fun saveUndoSnapshot() { + undoManager.saveSnapshot(currentSnapshot()) + } + + private fun applySnapshot(snapshot: UndoManager.Snapshot) { + documentModel.activeStrokes.clear() + documentModel.activeStrokes.addAll(snapshot.strokes) + documentModel.diagramAreas.clear() + documentModel.diagramAreas.addAll(snapshot.diagramAreas) + } + + private fun undo(): Boolean { + val snapshot = undoManager.undo(currentSnapshot()) ?: return false + applySnapshot(snapshot) + return true + } + + private fun redo(): Boolean { + val snapshot = undoManager.redo(currentSnapshot()) ?: return false + applySnapshot(snapshot) + return true + } + + private fun makePoints(vararg pairs: Pair): List = + pairs.map { (x, y) -> StrokePoint(x, y, 0.5f, 0L) } + + /** Create a rectangle stroke spanning the given line range. */ + private fun makeRectangleStroke(startLine: Int, endLine: Int, left: Float = 100f, right: Float = 400f): InkStroke { + val top = TM + startLine * LS + LS * 0.1f + val bottom = TM + endLine * LS + LS * 0.9f + return InkStroke( + points = makePoints( + left to top, right to top, + right to bottom, left to bottom, + left to top + ), + strokeType = StrokeType.FREEHAND + ) + } + + /** Create an oval stroke centered on the given line range. */ + private fun makeOvalStroke(startLine: Int, endLine: Int, n: Int = 60): InkStroke { + val cx = 300f + val cy = TM + (startLine + endLine) * LS / 2f + LS * 0.5f + val rx = 150f + val ry = (endLine - startLine + 1) * LS / 2f + val points = (0..n).map { i -> + val angle = 2 * PI * i / n + StrokePoint( + (cx + rx * cos(angle)).toFloat(), + (cy + ry * sin(angle)).toFloat(), + 0.5f, 0L + ) + } + return InkStroke(points = points, strokeType = StrokeType.FREEHAND) + } + + /** Create a straight line stroke spanning the given line range. */ + private fun makeLineStroke(startLine: Int, endLine: Int): InkStroke { + val y1 = TM + startLine * LS + LS * 0.5f + val y2 = TM + endLine * LS + LS * 0.5f + // Diagonal line + return InkStroke( + points = makePoints(100f to y1, 400f to y2), + strokeType = StrokeType.FREEHAND + ) + } + + /** Create a short freehand stroke (text-like) on a single line. */ + private fun makeTextStroke(line: Int): InkStroke { + val y = TM + line * LS + LS * 0.5f + return InkStroke( + points = makePoints(100f to y, 110f to y + 5f, 120f to y - 3f, 130f to y + 2f), + strokeType = StrokeType.FREEHAND + ) + } + + /** Create a geometric (shape-snapped) stroke on a single line. */ + private fun makeGeometricStroke(line: Int): InkStroke { + val y = TM + line * LS + LS * 0.5f + return InkStroke( + points = makePoints(100f to y, 200f to y, 200f to y + LS * 0.8f, 100f to y + LS * 0.8f, 100f to y), + strokeType = StrokeType.RECTANGLE, + isGeometric = true + ) + } + + /** Create a strikethrough-like stroke on a single line. */ + private fun makeStrikethroughStroke(line: Int): InkStroke { + val y = TM + line * LS + LS * 0.5f + return InkStroke( + points = makePoints(50f to y, 350f to y + 2f), + strokeType = StrokeType.FREEHAND + ) + } + + /** Create a zigzag scratch-out stroke. */ + private fun makeScratchOutStroke(line: Int): InkStroke { + val y = TM + line * LS + LS * 0.5f + val points = mutableListOf() + var x = 50f + for (i in 0..8) { + points.add(StrokePoint(x, y + (i % 2) * 5f, 0.5f, 0L)) + x += if (i % 2 == 0) 60f else -50f + } + return InkStroke(points = points, strokeType = StrokeType.FREEHAND) + } + + /** + * Simulate the coordinator's onDiagramShapeDetected logic. + * This mirrors [WritingCoordinator.onDiagramShapeDetected]. + */ + private fun simulateDiagramShapeDetected(stroke: InkStroke): Pair? { + val minY = stroke.points.minOf { it.y } + val maxY = stroke.points.maxOf { it.y } + val topLine = lineSegmenter.getLineIndex(minY) + val bottomLine = lineSegmenter.getLineIndex(maxY) + + // Exact bounding box — no forced padding + var mergeStart = topLine + var mergeHeight = bottomLine - mergeStart + 1 + + // Merge with adjacent diagram area above + val above = documentModel.diagramAreas.find { it.endLineIndex + 1 >= mergeStart && it.endLineIndex < mergeStart + mergeHeight } + if (above != null && above.endLineIndex + 1 >= mergeStart) { + if (above.startLineIndex < mergeStart) { + mergeHeight += mergeStart - above.startLineIndex + mergeStart = above.startLineIndex + } + documentModel.diagramAreas.remove(above) + } + + // Merge with adjacent diagram area below + val below = documentModel.diagramAreas.find { it.startLineIndex <= mergeStart + mergeHeight && it.startLineIndex >= mergeStart } + if (below != null && below != above) { + val belowEnd = below.startLineIndex + below.heightInLines + if (belowEnd > mergeStart + mergeHeight) { + mergeHeight = belowEnd - mergeStart + } + documentModel.diagramAreas.remove(below) + } + + val newArea = DiagramArea(startLineIndex = mergeStart, heightInLines = mergeHeight) + documentModel.diagramAreas.add(newArea) + + val topY = lineSegmenter.getLineY(mergeStart) + val bottomY = lineSegmenter.getLineY(mergeStart + mergeHeight) + return Pair(topY, bottomY) + } + + /** Simulate full stroke flow: stroke completed → shape detected → diagram created → shape snapped. */ + private fun simulateShapeStroke(stroke: InkStroke, snappedType: StrokeType) { + // Phase 1: raw stroke completed + saveUndoSnapshot() + documentModel.activeStrokes.add(stroke) + + // Phase 2: diagram area created + saveUndoSnapshot() + simulateDiagramShapeDetected(stroke) + + // Phase 3: shape snapped + val snappedStroke = stroke.copy( + strokeType = snappedType, + isGeometric = true + ) + saveUndoSnapshot() + documentModel.activeStrokes.removeAll { it.strokeId == stroke.strokeId } + documentModel.activeStrokes.add(snappedStroke) + } + + @Before + fun setUp() { + ScreenMetrics.init(DENSITY, smallestWidthDp = 674, widthPixels = 1264, heightPixels = 1680) + documentModel = DocumentModel() + undoManager = UndoManager() + lineSegmenter = LineSegmenter() + } + + // ── Shape detection triggers diagram creation ───────────────────────────── + + @Test + fun `rectangle outside diagram auto-creates diagram area`() { + val stroke = makeRectangleStroke(startLine = 3, endLine = 6) + val xs = FloatArray(stroke.points.size) { stroke.points[it].x } + val ys = FloatArray(stroke.points.size) { stroke.points[it].y } + + // Verify shape is detected + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("Rectangle stroke should be detected as a shape", result) + + // Simulate diagram creation + val bounds = simulateDiagramShapeDetected(stroke) + assertNotNull(bounds) + + assertEquals("Should have 1 diagram area", 1, documentModel.diagramAreas.size) + val area = documentModel.diagramAreas[0] + // Exact bounding box: lines 3-6 + assertEquals("Start line should match shape top", 3, area.startLineIndex) + assertTrue("Should cover line 6", area.containsLine(6)) + } + + @Test + fun `non-shape stroke does not create diagram`() { + val stroke = makeTextStroke(line = 5) + val xs = FloatArray(stroke.points.size) { stroke.points[it].x } + val ys = FloatArray(stroke.points.size) { stroke.points[it].y } + + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNull("Short text-like stroke should not be a shape", result) + assertEquals("No diagram areas should be created", 0, documentModel.diagramAreas.size) + } + + @Test + fun `ellipse outside diagram auto-creates diagram area`() { + val stroke = makeOvalStroke(startLine = 4, endLine = 7) + val xs = FloatArray(stroke.points.size) { stroke.points[it].x } + val ys = FloatArray(stroke.points.size) { stroke.points[it].y } + + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("Oval stroke should be detected as ellipse", result) + assertTrue("Should be an Ellipse", result is ShapeSnapDetection.SnapResult.Ellipse) + + val bounds = simulateDiagramShapeDetected(stroke) + assertNotNull(bounds) + + val area = documentModel.diagramAreas[0] + // Exact bounding box: lines 4-7 + assertEquals("Start should match shape top", 4, area.startLineIndex) + assertTrue("Should contain line 7", area.containsLine(7)) + } + + @Test + fun `line stroke creates diagram area`() { + val stroke = makeLineStroke(startLine = 2, endLine = 5) + val xs = FloatArray(stroke.points.size) { stroke.points[it].x } + val ys = FloatArray(stroke.points.size) { stroke.points[it].y } + + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("Long line stroke should be detected", result) + + val bounds = simulateDiagramShapeDetected(stroke) + assertNotNull(bounds) + assertEquals(1, documentModel.diagramAreas.size) + } + + // ── Merge logic ─────────────────────────────────────────────────────────── + + @Test + fun `diagram area merges with overlapping area above`() { + // Existing area covers lines 0-4 + documentModel.diagramAreas.add(DiagramArea(startLineIndex = 0, heightInLines = 5)) + + // Shape at lines 3-6 overlaps with existing area + val stroke = makeRectangleStroke(startLine = 3, endLine = 6) + simulateDiagramShapeDetected(stroke) + + assertEquals("Should merge into 1 area", 1, documentModel.diagramAreas.size) + val area = documentModel.diagramAreas[0] + assertEquals("Merged area should start at 0", 0, area.startLineIndex) + assertTrue("Merged area should contain line 6", area.containsLine(6)) + } + + @Test + fun `diagram area merges with adjacent area below`() { + // Existing area at lines 7-9 + documentModel.diagramAreas.add(DiagramArea(startLineIndex = 7, heightInLines = 3)) + + // Shape at lines 5-7 overlaps with existing area at line 7 + val stroke = makeRectangleStroke(startLine = 5, endLine = 7) + simulateDiagramShapeDetected(stroke) + + assertEquals("Should merge into 1 area", 1, documentModel.diagramAreas.size) + val area = documentModel.diagramAreas[0] + assertEquals("Merged area should start at 5", 5, area.startLineIndex) + assertTrue("Merged area should contain line 9 (original below end)", area.containsLine(9)) + } + + @Test + fun `diagram area merges with areas both above and below`() { + documentModel.diagramAreas.add(DiagramArea(startLineIndex = 0, heightInLines = 5)) + documentModel.diagramAreas.add(DiagramArea(startLineIndex = 6, heightInLines = 3)) + + // Shape spans lines 3-7, overlapping both + val stroke = makeRectangleStroke(startLine = 3, endLine = 7) + simulateDiagramShapeDetected(stroke) + + val totalAreas = documentModel.diagramAreas.size + assertTrue("Should merge to 1 or 2 areas, got $totalAreas", totalAreas <= 2) + } + + // ── Undo / Redo ────────────────────────────────────────────────────────── + + @Test + fun `undo removes auto-created diagram area and restores raw stroke`() { + val stroke = makeRectangleStroke(startLine = 3, endLine = 5) + simulateShapeStroke(stroke, StrokeType.RECTANGLE) + + assertEquals(1, documentModel.diagramAreas.size) + assertEquals(1, documentModel.activeStrokes.size) + assertEquals(StrokeType.RECTANGLE, documentModel.activeStrokes[0].strokeType) + + // Undo 1: unsnap → raw freehand (diagram still present) + assertTrue(undo()) + assertEquals(1, documentModel.activeStrokes.size) + assertEquals(StrokeType.FREEHAND, documentModel.activeStrokes[0].strokeType) + assertEquals(1, documentModel.diagramAreas.size) + + // Undo 2: remove diagram area (stroke still present) + assertTrue(undo()) + assertEquals(1, documentModel.activeStrokes.size) + assertEquals(0, documentModel.diagramAreas.size) + + // Undo 3: remove stroke entirely + assertTrue(undo()) + assertEquals(0, documentModel.activeStrokes.size) + assertEquals(0, documentModel.diagramAreas.size) + } + + @Test + fun `redo restores auto-created diagram area`() { + val stroke = makeRectangleStroke(startLine = 3, endLine = 5) + simulateShapeStroke(stroke, StrokeType.RECTANGLE) + + // Undo all 3 phases + undo(); undo(); undo() + assertEquals(0, documentModel.activeStrokes.size) + assertEquals(0, documentModel.diagramAreas.size) + + // Redo phase 1: raw stroke + assertTrue(redo()) + assertEquals(1, documentModel.activeStrokes.size) + assertEquals(StrokeType.FREEHAND, documentModel.activeStrokes[0].strokeType) + + // Redo phase 2: diagram area + assertTrue(redo()) + assertEquals(1, documentModel.diagramAreas.size) + + // Redo phase 3: snapped shape + assertTrue(redo()) + assertEquals(StrokeType.RECTANGLE, documentModel.activeStrokes[0].strokeType) + } + + // ── Edge cases ──────────────────────────────────────────────────────────── + + @Test + fun `strikethrough shape is not misclassified as diagram shape`() { + val stroke = makeStrikethroughStroke(line = 5) + assertTrue( + "Strikethrough should be detected by GestureHandler", + GestureHandler.isStrikethroughShape(stroke) + ) + } + + @Test + fun `scratch-out is detected before shape detection`() { + val stroke = makeScratchOutStroke(line = 5) + val xs = FloatArray(stroke.points.size) { stroke.points[it].x } + val yRange = stroke.points.maxOf { it.y } - stroke.points.minOf { it.y } + + val isScratchOut = ScratchOutDetection.detect(xs, yRange, LS) + // Scratch-out should be detected (or at minimum not be a shape) + val xsShape = FloatArray(stroke.points.size) { stroke.points[it].x } + val ysShape = FloatArray(stroke.points.size) { stroke.points[it].y } + val shapeResult = ShapeSnapDetection.detect(xsShape, ysShape, LS) + + // Either scratch-out is detected, or shape is not detected — either way no false diagram + assertTrue( + "Scratch-out should be handled before shape detection", + isScratchOut || shapeResult == null + ) + } + + @Test + fun `diagram at top starts at line 0`() { + val stroke = makeRectangleStroke(startLine = 0, endLine = 1) + simulateDiagramShapeDetected(stroke) + + val area = documentModel.diagramAreas[0] + assertEquals("Start line should be 0", 0, area.startLineIndex) + } + + @Test + fun `multiple shapes create separate diagram areas`() { + val stroke1 = makeRectangleStroke(startLine = 2, endLine = 4) + val stroke2 = makeRectangleStroke(startLine = 10, endLine = 12) + + simulateDiagramShapeDetected(stroke1) + simulateDiagramShapeDetected(stroke2) + + assertEquals("Should have 2 separate diagram areas", 2, documentModel.diagramAreas.size) + } + + @Test + fun `second shape overlapping first diagram merges`() { + // First shape at lines 2-4 → diagram 2-4 + val stroke1 = makeRectangleStroke(startLine = 2, endLine = 4) + simulateDiagramShapeDetected(stroke1) + assertEquals(1, documentModel.diagramAreas.size) + + // Second shape at lines 4-6 → overlaps at line 4, should merge + val stroke2 = makeRectangleStroke(startLine = 4, endLine = 6) + simulateDiagramShapeDetected(stroke2) + + assertEquals("Overlapping diagrams should merge", 1, documentModel.diagramAreas.size) + val area = documentModel.diagramAreas[0] + assertTrue("Merged area should contain line 2", area.containsLine(2)) + assertTrue("Merged area should contain line 6", area.containsLine(6)) + } + + // ── Diagram expansion (stroke overflow) ─────────────────────────────────── + + /** + * Simulate onDiagramStrokeOverflow: when a stroke inside a diagram extends + * beyond its bounds, shift diagram+below DOWN and expand the area. + * Text above stays in place. + */ + /** Convenience for tests without a specific overflow stroke. */ + private fun simulateOverflow(strokeMinY: Float, strokeMaxY: Float) = + simulateOverflow("", strokeMinY, strokeMaxY) + + private fun simulateOverflow(overflowStrokeId: String, strokeMinY: Float, strokeMaxY: Float) { + val strokeTopLine = lineSegmenter.getLineIndex(strokeMinY) + val strokeBottomLine = lineSegmenter.getLineIndex(strokeMaxY) + + val area = documentModel.diagramAreas.find { + strokeTopLine <= it.endLineIndex && strokeBottomLine >= it.startLineIndex + } ?: return + + val linesAbove = maxOf(0, area.startLineIndex - strokeTopLine + 1) // +1 for padding + val linesBelow = maxOf(0, strokeBottomLine - area.endLineIndex + 1) + + if (linesAbove == 0 && linesBelow == 0) return + + val ls = LS + + // Upward expansion: shift diagram strokes + overflow stroke DOWN. + if (linesAbove > 0) { + val shiftPx = linesAbove * ls + val shifted = documentModel.activeStrokes.map { stroke -> + val strokeLine = lineSegmenter.getStrokeLineIndex(stroke) + val isInsideDiagram = strokeLine >= area.startLineIndex + val isOverflowStroke = stroke.strokeId == overflowStrokeId + if (isInsideDiagram || isOverflowStroke) stroke.shiftY(shiftPx) else stroke + } + documentModel.activeStrokes.clear() + documentModel.activeStrokes.addAll(shifted) + } + + // Downward expansion: shift strokes below the diagram DOWN, + // but NOT the overflow stroke (it stays — it's part of the diagram). + if (linesBelow > 0) { + val shiftPx = linesBelow * ls + val postEndLine = area.endLineIndex + linesAbove + val shifted = documentModel.activeStrokes.map { stroke -> + val strokeLine = lineSegmenter.getStrokeLineIndex(stroke) + val isOverflowStroke = stroke.strokeId == overflowStrokeId + if (strokeLine > postEndLine && !isOverflowStroke) stroke.shiftY(shiftPx) else stroke + } + documentModel.activeStrokes.clear() + documentModel.activeStrokes.addAll(shifted) + } + + // Expand diagram: keep original startLineIndex, grow height + documentModel.diagramAreas.remove(area) + documentModel.diagramAreas.add(DiagramArea( + startLineIndex = area.startLineIndex, + heightInLines = area.heightInLines + linesAbove + linesBelow + )) + } + + @Test + fun `stroke extending below diagram expands it downward`() { + documentModel.diagramAreas.add(DiagramArea(startLineIndex = 3, heightInLines = 3)) + + val strokeMinY = TM + 4 * LS + LS * 0.5f + val strokeMaxY = TM + 7 * LS + LS * 0.5f + simulateOverflow(strokeMinY, strokeMaxY) + + assertEquals(1, documentModel.diagramAreas.size) + val area = documentModel.diagramAreas[0] + assertEquals("Start should remain at 3", 3, area.startLineIndex) + assertTrue("Should now contain line 7", area.containsLine(7)) + } + + @Test + fun `stroke extending above diagram shifts content down and expands`() { + // Diagram shape on line 6 + val diagramStroke = makeTextStroke(line = 6) + documentModel.activeStrokes.add(diagramStroke) + documentModel.diagramAreas.add(DiagramArea(startLineIndex = 5, heightInLines = 4)) + + // Overflow stroke from line 3 to line 6 (crosses diagram start) + val overflowStroke = makeLineStroke(startLine = 3, endLine = 6) + documentModel.activeStrokes.add(overflowStroke) + + simulateOverflow(overflowStroke.strokeId, + strokeMinY = TM + 3 * LS + LS * 0.2f, + strokeMaxY = TM + 6 * LS + LS * 0.5f + ) + + val area = documentModel.diagramAreas[0] + // Diagram keeps its original startLineIndex + assertEquals("Diagram start stays at 5", 5, area.startLineIndex) + // Height grew by linesAbove (5 - 3 + 1 = 3) + assertEquals("Height should grow", 4 + 3, area.heightInLines) + + // Diagram stroke was shifted down (its maxY was inside diagram) + val shiftedDiagramLine = lineSegmenter.getStrokeLineIndex(documentModel.activeStrokes[0]) + assertEquals("Diagram stroke shifted down by 3", 6 + 3, shiftedDiagramLine) + + // Overflow stroke was shifted down (its maxY was inside diagram) + val shiftedOverflowLine = lineSegmenter.getStrokeLineIndex(documentModel.activeStrokes[1]) + assertTrue("Overflow stroke shifted down, now inside diagram", + area.containsLine(shiftedOverflowLine)) + } + + @Test + fun `scenario 1 - overflow crosses diagram start, text above stays outside`() { + // "Hello World" text on line 1 + val helloStroke = makeTextStroke(line = 1) + documentModel.activeStrokes.add(helloStroke) + + // Diagram at lines 4-6 with a shape + val shapeStroke = makeTextStroke(line = 5) + documentModel.activeStrokes.add(shapeStroke) + documentModel.diagramAreas.add(DiagramArea(startLineIndex = 4, heightInLines = 3)) + + // Overflow stroke from line 3 (above diagram) to line 5 (inside diagram) + val overflowStroke = makeLineStroke(startLine = 3, endLine = 5) + documentModel.activeStrokes.add(overflowStroke) + + simulateOverflow(overflowStroke.strokeId, + strokeMinY = TM + 3 * LS + LS * 0.2f, + strokeMaxY = TM + 5 * LS + LS * 0.5f + ) + + // Text stays at line 1 (not shifted — maxY is above diagram) + val textLine = lineSegmenter.getStrokeLineIndex(documentModel.activeStrokes[0]) + assertEquals("Hello World stays at line 1", 1, textLine) + + // Diagram area should NOT contain text line + val area = documentModel.diagramAreas[0] + assertTrue("Diagram should not contain line 1", !area.containsLine(1)) + assertTrue("Diagram should not contain line 2", !area.containsLine(textLine + 1)) + + // All diagram/overflow strokes should be inside the diagram area + val shapeLine = lineSegmenter.getStrokeLineIndex(documentModel.activeStrokes[1]) + assertTrue("Shape should be inside diagram", area.containsLine(shapeLine)) + val overflowLine = lineSegmenter.getStrokeLineIndex(documentModel.activeStrokes[2]) + assertTrue("Overflow should be inside diagram", area.containsLine(overflowLine)) + } + + @Test + fun `scenario 2 - overflow overlaps text, text stays above diagram`() { + // "Hello World" text on line 2 + val helloStroke = makeTextStroke(line = 2) + documentModel.activeStrokes.add(helloStroke) + + // Diagram at lines 4-6 with a shape + val shapeStroke = makeTextStroke(line = 5) + documentModel.activeStrokes.add(shapeStroke) + documentModel.diagramAreas.add(DiagramArea(startLineIndex = 4, heightInLines = 3)) + + // Overflow stroke from line 2 (SAME line as Hello World) to line 5 (inside diagram) + val overflowStroke = makeLineStroke(startLine = 2, endLine = 5) + documentModel.activeStrokes.add(overflowStroke) + + simulateOverflow(overflowStroke.strokeId, + strokeMinY = TM + 2 * LS + LS * 0.2f, + strokeMaxY = TM + 5 * LS + LS * 0.5f + ) + + // Hello World stays at line 2 (maxY is on line 2, below diagram top at line 4) + val textLine = lineSegmenter.getStrokeLineIndex(documentModel.activeStrokes[0]) + assertEquals("Hello World stays at line 2", 2, textLine) + + // Diagram should not contain Hello World's line + val area = documentModel.diagramAreas[0] + assertTrue("Diagram should not contain line 2 (Hello World)", !area.containsLine(textLine)) + + // Overflow stroke was shifted down — now inside the diagram + val overflowLine = lineSegmenter.getStrokeLineIndex(documentModel.activeStrokes[2]) + assertTrue("Overflow should be inside diagram", area.containsLine(overflowLine)) + } + + @Test + fun `stroke within diagram bounds does not expand`() { + documentModel.diagramAreas.add(DiagramArea(startLineIndex = 3, heightInLines = 6)) + + val strokeMinY = TM + 4 * LS + LS * 0.2f + val strokeMaxY = TM + 6 * LS + LS * 0.8f + simulateOverflow(strokeMinY, strokeMaxY) + + val area = documentModel.diagramAreas[0] + assertEquals("Start should remain 3", 3, area.startLineIndex) + assertEquals("Height should remain 6", 6, area.heightInLines) + } + + @Test + fun `scenario 3 - overflow crosses diagram end, text below stays outside`() { + // Hello World on line 1 + val helloStroke = makeTextStroke(line = 1) + documentModel.activeStrokes.add(helloStroke) + + // Diagram at lines 3-5 with a shape + val shapeStroke = makeTextStroke(line = 4) + documentModel.activeStrokes.add(shapeStroke) + documentModel.diagramAreas.add(DiagramArea(startLineIndex = 3, heightInLines = 3)) + + // Text underneath on line 7 + val textBelow = makeTextStroke(line = 7) + documentModel.activeStrokes.add(textBelow) + + // Overflow stroke from line 4 (inside) to line 6 (below diagram end at 5) + val overflowStroke = makeLineStroke(startLine = 4, endLine = 6) + documentModel.activeStrokes.add(overflowStroke) + + simulateOverflow(overflowStroke.strokeId, + strokeMinY = TM + 4 * LS + LS * 0.2f, + strokeMaxY = TM + 6 * LS + LS * 0.5f + ) + + val area = documentModel.diagramAreas[0] + + // Hello World stays at line 1, outside diagram + assertEquals("Hello stays at 1", 1, lineSegmenter.getStrokeLineIndex(documentModel.activeStrokes[0])) + assertTrue("Hello outside diagram", !area.containsLine(1)) + + // Shape inside diagram + assertTrue("Shape inside diagram", area.containsLine(lineSegmenter.getStrokeLineIndex(documentModel.activeStrokes[1]))) + + // Overflow stroke inside diagram (not shifted — it's diagram content) + val overflowLine = lineSegmenter.getStrokeLineIndex(documentModel.activeStrokes[3]) + assertTrue("Overflow inside diagram", area.containsLine(overflowLine)) + + // Text below shifted down, outside diagram + val textBelowLine = lineSegmenter.getStrokeLineIndex(documentModel.activeStrokes[2]) + assertTrue("Text below outside diagram", !area.containsLine(textBelowLine)) + assertTrue("Text below shifted further down", textBelowLine > 7) + } + + @Test + fun `scenario 4 - overflow overlaps text below, text shifts down`() { + // Hello World on line 1 + val helloStroke = makeTextStroke(line = 1) + documentModel.activeStrokes.add(helloStroke) + + // Diagram at lines 3-5 with a shape + val shapeStroke = makeTextStroke(line = 4) + documentModel.activeStrokes.add(shapeStroke) + documentModel.diagramAreas.add(DiagramArea(startLineIndex = 3, heightInLines = 3)) + + // Text underneath on line 6 (same line as overflow will reach) + val textBelow = makeTextStroke(line = 6) + documentModel.activeStrokes.add(textBelow) + + // Overflow stroke from line 4 (inside) to line 6 (overlaps text below) + val overflowStroke = makeLineStroke(startLine = 4, endLine = 6) + documentModel.activeStrokes.add(overflowStroke) + + simulateOverflow(overflowStroke.strokeId, + strokeMinY = TM + 4 * LS + LS * 0.2f, + strokeMaxY = TM + 6 * LS + LS * 0.5f + ) + + val area = documentModel.diagramAreas[0] + + // Overflow stroke is inside diagram + val overflowLine = lineSegmenter.getStrokeLineIndex(documentModel.activeStrokes[3]) + assertTrue("Overflow inside diagram", area.containsLine(overflowLine)) + + // Text below was on same line as overflow — should be shifted out + val textBelowLine = lineSegmenter.getStrokeLineIndex(documentModel.activeStrokes[2]) + assertTrue("Text below outside diagram", !area.containsLine(textBelowLine)) + } + + @Test + fun `text descender crossing diagram boundary is not shifted`() { + // Text "World" on line 3 — the "d" descender dips slightly into line 4 + val textWithDescender = InkStroke( + points = makePoints( + 100f to TM + 3 * LS + LS * 0.3f, // top of text + 150f to TM + 3 * LS + LS * 0.5f, // middle + 180f to TM + 4 * LS + LS * 0.1f // descender dips into line 4 + ), + strokeType = StrokeType.FREEHAND + ) + documentModel.activeStrokes.add(textWithDescender) + + // Diagram at lines 4-7 with a shape + val shapeStroke = makeTextStroke(line = 6) + documentModel.activeStrokes.add(shapeStroke) + documentModel.diagramAreas.add(DiagramArea(startLineIndex = 4, heightInLines = 4)) + + // Overflow stroke from line 3 to line 5 (crosses diagram start) + val overflowStroke = makeLineStroke(startLine = 3, endLine = 5) + documentModel.activeStrokes.add(overflowStroke) + + simulateOverflow(overflowStroke.strokeId, + strokeMinY = TM + 3 * LS + LS * 0.2f, + strokeMaxY = TM + 5 * LS + LS * 0.5f + ) + + // Text with descender should stay at line 3 (centroid is on line 3) + val textLine = lineSegmenter.getStrokeLineIndex(documentModel.activeStrokes[0]) + assertEquals("Text with descender should stay at line 3", 3, textLine) + + // Text should be outside the diagram + val area = documentModel.diagramAreas[0] + assertTrue("Text should be outside diagram", !area.containsLine(textLine)) + } + + // ── Freeform dwell creates diagram area ────────────────────────────────── + + @Test + fun `freehand stroke with dwell outside diagram creates diagram area`() { + // Simulate: user draws a freehand doodle on line 5 with start-dwell + val stroke = makeTextStroke(line = 5) + simulateDiagramShapeDetected(stroke) + + assertEquals("Should have 1 diagram area", 1, documentModel.diagramAreas.size) + val area = documentModel.diagramAreas[0] + assertTrue("Diagram should contain line 5", area.containsLine(5)) + } + + @Test + fun `freehand dwell diagram merges with adjacent shape diagram`() { + // First: shape creates diagram at lines 3-5 + val shapeStroke = makeRectangleStroke(startLine = 3, endLine = 5) + simulateDiagramShapeDetected(shapeStroke) + assertEquals(1, documentModel.diagramAreas.size) + + // Freehand dwell stroke at line 5 (overlapping) → should merge + val freehandStroke = makeTextStroke(line = 5) + simulateDiagramShapeDetected(freehandStroke) + + assertEquals("Should merge into 1 area", 1, documentModel.diagramAreas.size) + assertTrue("Merged area covers line 3", documentModel.diagramAreas[0].containsLine(3)) + assertTrue("Merged area covers line 5", documentModel.diagramAreas[0].containsLine(5)) + } + + @Test + fun `undo reverses freehand dwell diagram creation`() { + saveUndoSnapshot() // baseline + + val stroke = makeTextStroke(line = 5) + saveUndoSnapshot() + documentModel.activeStrokes.add(stroke) + simulateDiagramShapeDetected(stroke) + + assertEquals(1, documentModel.diagramAreas.size) + + undo() + assertEquals("Undo should remove diagram area", 0, documentModel.diagramAreas.size) + } + + // ── Sticky zone expansion ──────────────────────────────────────────────── + + /** Check if a line has pre-existing strokes not inside a diagram area. */ + private fun hasTextStrokesOnLine(lineIdx: Int, excluding: InkStroke? = null): Boolean = + documentModel.activeStrokes.any { + it !== excluding && + !documentModel.diagramAreas.any { area -> area.containsLine(lineSegmenter.getStrokeLineIndex(it)) } && + lineSegmenter.getStrokeLineIndex(it) == lineIdx + } + + /** + * Simulate the sticky zone logic from WritingCoordinator.onStrokeCompleted: + * Only geometric (shape-snapped) strokes trigger sticky expansion. + * Freehand strokes don't expand — they're likely text. + */ + private fun simulateStickyZoneExpansion(stroke: InkStroke): Boolean { + val lineIdx = lineSegmenter.getStrokeLineIndex(stroke) + val adjacentArea = documentModel.diagramAreas.find { + lineIdx == it.startLineIndex - 1 || lineIdx == it.endLineIndex + 1 + } ?: return false + + // Only geometric strokes trigger expansion + if (!stroke.isGeometric) return false + + // Don't expand into lines with pre-existing text strokes + if (hasTextStrokesOnLine(lineIdx, excluding = stroke)) return false + + val newStart = minOf(adjacentArea.startLineIndex, lineIdx) + val newEnd = maxOf(adjacentArea.endLineIndex, lineIdx) + documentModel.diagramAreas.remove(adjacentArea) + documentModel.diagramAreas.add(adjacentArea.copy( + startLineIndex = newStart, + heightInLines = newEnd - newStart + 1 + )) + return true + } + + @Test + fun `geometric stroke adjacent below diagram expands via sticky zone`() { + documentModel.diagramAreas.add(DiagramArea(startLineIndex = 3, heightInLines = 3)) + + val stroke = makeGeometricStroke(line = 6) + val expanded = simulateStickyZoneExpansion(stroke) + + assertTrue("Geometric stroke should expand via sticky zone", expanded) + assertEquals(1, documentModel.diagramAreas.size) + val area = documentModel.diagramAreas[0] + assertEquals("Start should remain 3", 3, area.startLineIndex) + assertTrue("Should now contain line 6", area.containsLine(6)) + } + + @Test + fun `geometric stroke adjacent above diagram expands via sticky zone`() { + documentModel.diagramAreas.add(DiagramArea(startLineIndex = 5, heightInLines = 3)) + + val stroke = makeGeometricStroke(line = 4) + val expanded = simulateStickyZoneExpansion(stroke) + + assertTrue("Geometric stroke should expand via sticky zone", expanded) + assertEquals(1, documentModel.diagramAreas.size) + val area = documentModel.diagramAreas[0] + assertEquals("Start should expand to 4", 4, area.startLineIndex) + assertTrue("Should contain line 7", area.containsLine(7)) + } + + @Test + fun `freehand text adjacent to diagram does NOT expand via sticky zone`() { + documentModel.diagramAreas.add(DiagramArea(startLineIndex = 3, heightInLines = 3)) + + // Freehand text stroke on line 6 (adjacent below) — should NOT expand + val stroke = makeTextStroke(line = 6) + val expanded = simulateStickyZoneExpansion(stroke) + + assertTrue("Freehand text should NOT expand", !expanded) + assertEquals("Height should remain 3", 3, documentModel.diagramAreas[0].heightInLines) + } + + @Test + fun `stroke 2 lines away from diagram does not trigger sticky zone`() { + documentModel.diagramAreas.add(DiagramArea(startLineIndex = 3, heightInLines = 3)) + + val stroke = makeGeometricStroke(line = 7) + val expanded = simulateStickyZoneExpansion(stroke) + + assertTrue("Should NOT expand — not adjacent", !expanded) + assertEquals("Height should remain 3", 3, documentModel.diagramAreas[0].heightInLines) + } + + // ── Text stroke buffer protection ──────────────────────────────────────── + + @Test + fun `sticky zone does not expand for geometric stroke into line with pre-existing text`() { + documentModel.diagramAreas.add(DiagramArea(startLineIndex = 3, heightInLines = 3)) + + // Pre-existing text stroke on line 6 + val textStroke = makeTextStroke(line = 6) + documentModel.activeStrokes.add(textStroke) + + // New geometric stroke also on line 6 — geometric but text exists + val newStroke = makeGeometricStroke(line = 6) + documentModel.activeStrokes.add(newStroke) + val expanded = simulateStickyZoneExpansion(newStroke) + + assertTrue("Should NOT expand — line 6 has pre-existing text", !expanded) + assertEquals("Height should remain 3", 3, documentModel.diagramAreas[0].heightInLines) + } + + @Test + fun `sticky zone expands into empty adjacent line for geometric stroke`() { + documentModel.diagramAreas.add(DiagramArea(startLineIndex = 3, heightInLines = 3)) + + // Geometric stroke on line 6 (adjacent below, no pre-existing strokes) + val newStroke = makeGeometricStroke(line = 6) + documentModel.activeStrokes.add(newStroke) + val expanded = simulateStickyZoneExpansion(newStroke) + + assertTrue("Should expand — geometric on empty line", expanded) + assertTrue("Should contain line 6", documentModel.diagramAreas[0].containsLine(6)) + } + + @Test + fun `sticky zone does not expand above into line with pre-existing text`() { + documentModel.diagramAreas.add(DiagramArea(startLineIndex = 5, heightInLines = 3)) + + // Pre-existing text stroke on line 4 + val textStroke = makeTextStroke(line = 4) + documentModel.activeStrokes.add(textStroke) + + // New geometric stroke on line 4 (adjacent above diagram, but text exists) + val newStroke = makeGeometricStroke(line = 4) + documentModel.activeStrokes.add(newStroke) + val expanded = simulateStickyZoneExpansion(newStroke) + + assertTrue("Should NOT expand — line 4 has pre-existing text", !expanded) + assertEquals("Start should remain 5", 5, documentModel.diagramAreas[0].startLineIndex) + } + + // ── Padding respects text lines ────────────────────────────────────────── + + @Test + fun `diagram creation does not absorb adjacent text above`() { + val textStroke = makeTextStroke(line = 4) + documentModel.activeStrokes.add(textStroke) + + val shapeStroke = makeRectangleStroke(startLine = 5, endLine = 7) + simulateDiagramShapeDetected(shapeStroke) + + val area = documentModel.diagramAreas[0] + assertEquals("Start should be 5 (no padding into text)", 5, area.startLineIndex) + assertTrue("Should contain line 7", area.containsLine(7)) + assertTrue("Should NOT contain text line 4", !area.containsLine(4)) + } + + @Test + fun `diagram creation does not absorb adjacent text below`() { + val textStroke = makeTextStroke(line = 8) + documentModel.activeStrokes.add(textStroke) + + val shapeStroke = makeRectangleStroke(startLine = 5, endLine = 7) + simulateDiagramShapeDetected(shapeStroke) + + val area = documentModel.diagramAreas[0] + assertEquals("Start should be 5", 5, area.startLineIndex) + assertTrue("Should contain line 7", area.containsLine(7)) + assertTrue("Should NOT contain text line 8", !area.containsLine(8)) + } + + @Test + fun `diagram creation uses exact bounds when no text nearby`() { + val shapeStroke = makeRectangleStroke(startLine = 5, endLine = 7) + simulateDiagramShapeDetected(shapeStroke) + + val area = documentModel.diagramAreas[0] + assertEquals("Start should be 5 (exact bounds)", 5, area.startLineIndex) + assertTrue("Should contain line 7", area.containsLine(7)) + } + + // ── Diagram shrink on scratch-out ──────────────────────────────────────── + + /** + * Simulate shrinkDiagramAfterErase: compute new bounds from remaining strokes, + * shrink the area, and shift content to reclaim freed lines. + */ + private fun simulateShrink(area: DiagramArea) { + val ls = LS + val remaining = documentModel.activeStrokes.filter { + area.containsLine(lineSegmenter.getStrokeLineIndex(it)) + } + + if (remaining.isEmpty()) { + val linesFreed = area.heightInLines + val shiftUpPx = linesFreed * ls + val oldEnd = area.endLineIndex + documentModel.diagramAreas.remove(area) + val shifted = documentModel.activeStrokes.map { stroke -> + if (lineSegmenter.getStrokeLineIndex(stroke) > oldEnd) stroke.shiftY(-shiftUpPx) else stroke + } + documentModel.activeStrokes.clear() + documentModel.activeStrokes.addAll(shifted) + return + } + + val minLine = remaining.minOf { lineSegmenter.getLineIndex(it.points.minOf { p -> p.y }) } + val maxLine = remaining.maxOf { lineSegmenter.getLineIndex(it.points.maxOf { p -> p.y }) } + val newStart = (minLine - 1).coerceAtLeast(area.startLineIndex) + val newEnd = (maxLine + 1).coerceAtMost(area.endLineIndex) + val linesFreedBelow = area.endLineIndex - newEnd + val linesFreedAbove = newStart - area.startLineIndex + if (linesFreedAbove + linesFreedBelow == 0) return + + documentModel.diagramAreas.remove(area) + documentModel.diagramAreas.add(DiagramArea(startLineIndex = newStart, heightInLines = newEnd - newStart + 1)) + + if (linesFreedBelow > 0) { + val shiftUpPx = linesFreedBelow * ls + val shifted = documentModel.activeStrokes.map { stroke -> + if (lineSegmenter.getStrokeLineIndex(stroke) > area.endLineIndex) stroke.shiftY(-shiftUpPx) else stroke + } + documentModel.activeStrokes.clear() + documentModel.activeStrokes.addAll(shifted) + } + if (linesFreedAbove > 0) { + val shiftUpPx = linesFreedAbove * ls + val shifted = documentModel.activeStrokes.map { stroke -> + if (lineSegmenter.getStrokeLineIndex(stroke) >= newStart) stroke.shiftY(-shiftUpPx) else stroke + } + documentModel.activeStrokes.clear() + documentModel.activeStrokes.addAll(shifted) + val shiftedAreas = documentModel.diagramAreas.map { other -> + if (other.startLineIndex >= newStart) + other.copy(startLineIndex = other.startLineIndex - linesFreedAbove) + else other + } + documentModel.diagramAreas.clear() + documentModel.diagramAreas.addAll(shiftedAreas) + } + } + + @Test + fun `scenario 1 - erase near bottom shrinks diagram and shifts text up`() { + // Text above + val helloStroke = makeTextStroke(line = 1) + documentModel.activeStrokes.add(helloStroke) + + // Diagram at lines 3-7 + documentModel.diagramAreas.add(DiagramArea(startLineIndex = 3, heightInLines = 5)) + val topShape = makeTextStroke(line = 4) // stays + documentModel.activeStrokes.add(topShape) + val bottomShape = makeTextStroke(line = 6) // will be erased + documentModel.activeStrokes.add(bottomShape) + + // Text below diagram + val textBelow = makeTextStroke(line = 9) + documentModel.activeStrokes.add(textBelow) + + // Erase bottom shape + documentModel.activeStrokes.remove(bottomShape) + + // Shrink + simulateShrink(documentModel.diagramAreas[0]) + + // Diagram should have shrunk + val area = documentModel.diagramAreas[0] + assertTrue("Diagram should not contain line 6 anymore", !area.containsLine(6)) + assertTrue("Diagram should still contain line 4", area.containsLine(lineSegmenter.getStrokeLineIndex( + documentModel.activeStrokes.find { it.strokeId == topShape.strokeId }!! + ))) + + // Text below should have shifted up + val textBelowLine = lineSegmenter.getStrokeLineIndex( + documentModel.activeStrokes.find { it.strokeId == textBelow.strokeId }!! + ) + assertTrue("Text below should have shifted up", textBelowLine < 9) + + // Hello World stays + assertEquals("Hello stays at 1", 1, lineSegmenter.getStrokeLineIndex( + documentModel.activeStrokes.find { it.strokeId == helloStroke.strokeId }!! + )) + } + + @Test + fun `scenario 2 - erase near top shrinks diagram from top`() { + val helloStroke = makeTextStroke(line = 1) + documentModel.activeStrokes.add(helloStroke) + + documentModel.diagramAreas.add(DiagramArea(startLineIndex = 3, heightInLines = 5)) + val topShape = makeTextStroke(line = 4) // will be erased + documentModel.activeStrokes.add(topShape) + val bottomShape = makeTextStroke(line = 6) // stays + documentModel.activeStrokes.add(bottomShape) + + val textBelow = makeTextStroke(line = 9) + documentModel.activeStrokes.add(textBelow) + + // Erase top shape + documentModel.activeStrokes.remove(topShape) + + simulateShrink(documentModel.diagramAreas[0]) + + // Diagram should have shrunk from the top + val area = documentModel.diagramAreas[0] + assertTrue("Diagram should still contain the remaining shape", + area.containsLine(lineSegmenter.getStrokeLineIndex( + documentModel.activeStrokes.find { it.strokeId == bottomShape.strokeId }!! + ))) + assertTrue("Diagram height should be smaller than 5", area.heightInLines < 5) + } + + @Test + fun `erase all strokes removes diagram entirely`() { + documentModel.diagramAreas.add(DiagramArea(startLineIndex = 3, heightInLines = 5)) + val shape = makeTextStroke(line = 5) + documentModel.activeStrokes.add(shape) + + val textBelow = makeTextStroke(line = 9) + documentModel.activeStrokes.add(textBelow) + + // Erase the only stroke + documentModel.activeStrokes.remove(shape) + + simulateShrink(documentModel.diagramAreas[0]) + + assertTrue("Diagram area should be removed", documentModel.diagramAreas.isEmpty()) + + // Text below should have shifted up + val textBelowLine = lineSegmenter.getStrokeLineIndex( + documentModel.activeStrokes.find { it.strokeId == textBelow.strokeId }!! + ) + assertTrue("Text below should have shifted up", textBelowLine < 9) + } + + @Test + fun `erase middle stroke does not shrink diagram`() { + documentModel.diagramAreas.add(DiagramArea(startLineIndex = 3, heightInLines = 5)) + val topShape = makeTextStroke(line = 4) + documentModel.activeStrokes.add(topShape) + val middleShape = makeTextStroke(line = 5) // will be erased + documentModel.activeStrokes.add(middleShape) + val bottomShape = makeTextStroke(line = 6) + documentModel.activeStrokes.add(bottomShape) + + documentModel.activeStrokes.remove(middleShape) + + val areaBefore = documentModel.diagramAreas[0].copy() + simulateShrink(documentModel.diagramAreas[0]) + + // Diagram should stay the same — strokes still at top and bottom + val area = documentModel.diagramAreas[0] + assertEquals("Start should be unchanged", areaBefore.startLineIndex, area.startLineIndex) + assertEquals("Height should be unchanged", areaBefore.heightInLines, area.heightInLines) + } +} diff --git a/app/src/test/java/com/writer/ui/writing/DiagramRecognizeDebounceTest.kt b/app/src/test/java/com/writer/ui/writing/DiagramRecognizeDebounceTest.kt new file mode 100644 index 0000000..6302a34 --- /dev/null +++ b/app/src/test/java/com/writer/ui/writing/DiagramRecognizeDebounceTest.kt @@ -0,0 +1,128 @@ +package com.writer.ui.writing + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Tests for the debounce pattern used by diagram text recognition. + * + * The actual WritingCoordinator is too tightly coupled to Android to test directly, + * so we test the debounce mechanism in isolation — same pattern, same constants. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DiagramRecognizeDebounceTest { + + companion object { + private const val DELAY_MS = 600L + } + + /** Simulates the debounce wrapper from WritingCoordinator. */ + private class Debouncer(private val delayMs: Long = DELAY_MS) { + val jobs = mutableMapOf() + var executionCount = 0 + val executedAreas = mutableListOf() + + fun schedule(areaKey: Int, immediate: Boolean = false, scope: kotlinx.coroutines.CoroutineScope) { + jobs[areaKey]?.cancel() + if (immediate) { + execute(areaKey) + return + } + jobs[areaKey] = scope.launch { + delay(delayMs) + execute(areaKey) + } + } + + private fun execute(areaKey: Int) { + executionCount++ + executedAreas.add(areaKey) + } + } + + @Test + fun `rapid calls are debounced to single execution`() = runTest { + val debouncer = Debouncer() + + // 5 rapid calls for the same area + repeat(5) { + debouncer.schedule(areaKey = 3, scope = this) + } + + // Before delay expires — nothing executed + advanceTimeBy(500) + assertEquals("Should not have executed yet", 0, debouncer.executionCount) + + // After delay + advanceTimeBy(200) + assertEquals("Should execute exactly once", 1, debouncer.executionCount) + assertEquals("Should be for area 3", 3, debouncer.executedAreas[0]) + } + + @Test + fun `immediate flag skips delay`() = runTest { + val debouncer = Debouncer() + + debouncer.schedule(areaKey = 5, immediate = true, scope = this) + + // Should execute immediately without advancing time + assertEquals("Should execute immediately", 1, debouncer.executionCount) + assertEquals("Should be for area 5", 5, debouncer.executedAreas[0]) + } + + @Test + fun `new call cancels previous pending`() = runTest { + val debouncer = Debouncer() + + // First call + debouncer.schedule(areaKey = 2, scope = this) + advanceTimeBy(300) // halfway + + // Second call — cancels first + debouncer.schedule(areaKey = 2, scope = this) + advanceTimeBy(300) // 300ms into second call, first would have fired at 600ms + assertEquals("First should have been cancelled", 0, debouncer.executionCount) + + advanceTimeBy(400) // 700ms into second call — past its 600ms delay + assertEquals("Second should have fired", 1, debouncer.executionCount) + } + + @Test + fun `different areas are independent`() = runTest { + val debouncer = Debouncer() + + debouncer.schedule(areaKey = 1, scope = this) + advanceTimeBy(200) + debouncer.schedule(areaKey = 2, scope = this) + + // Area 1 fires at 600ms, area 2 fires at 800ms + advanceTimeBy(500) // t=700 + assertEquals("Area 1 should have fired", 1, debouncer.executionCount) + assertEquals("Should be area 1", 1, debouncer.executedAreas[0]) + + advanceTimeBy(200) // t=900 + assertEquals("Area 2 should have fired", 2, debouncer.executionCount) + assertEquals("Should be area 2", 2, debouncer.executedAreas[1]) + } + + @Test + fun `cancelling area does not affect other areas`() = runTest { + val debouncer = Debouncer() + + debouncer.schedule(areaKey = 1, scope = this) + debouncer.schedule(areaKey = 2, scope = this) + + // Cancel area 1 by re-scheduling then cancelling the job + debouncer.jobs[1]?.cancel() + + advanceTimeBy(700) + assertEquals("Only area 2 should fire", 1, debouncer.executionCount) + assertEquals("Should be area 2", 2, debouncer.executedAreas[0]) + } +} diff --git a/app/src/test/java/com/writer/ui/writing/DiagramStrokeClassifierTest.kt b/app/src/test/java/com/writer/ui/writing/DiagramStrokeClassifierTest.kt new file mode 100644 index 0000000..6b32d34 --- /dev/null +++ b/app/src/test/java/com/writer/ui/writing/DiagramStrokeClassifierTest.kt @@ -0,0 +1,274 @@ +package com.writer.ui.writing + +import com.writer.model.InkStroke +import com.writer.model.StrokePoint +import com.writer.model.StrokeType +import com.writer.view.HandwritingCanvasView +import com.writer.view.ScreenMetrics +import org.junit.Assert.assertTrue +import org.junit.Assert.assertFalse +import org.junit.Before +import org.junit.Test +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin + +class DiagramStrokeClassifierTest { + + companion object { + private const val DENSITY = 1.875f + private val LS get() = HandwritingCanvasView.LINE_SPACING + private val TM get() = HandwritingCanvasView.TOP_MARGIN + } + + @Before + fun setUp() { + ScreenMetrics.init(DENSITY, smallestWidthDp = 674, widthPixels = 1264, heightPixels = 1680) + } + + private fun makePoints(vararg pairs: Pair): List = + pairs.map { (x, y) -> StrokePoint(x, y, 0.5f, 0L) } + + /** Small text-like stroke on a single line. */ + private fun makeTextStroke(x: Float, y: Float): InkStroke = + InkStroke(points = makePoints( + x to y, (x + 10f) to (y + 5f), (x + 20f) to (y - 3f), (x + 30f) to (y + 2f) + )) + + /** Tall vertical stroke spanning multiple lines. */ + private fun makeTallStroke(x: Float, topY: Float, height: Float): InkStroke = + InkStroke(points = makePoints( + x to topY, (x + 5f) to (topY + height * 0.3f), + (x + 2f) to (topY + height * 0.7f), x to (topY + height) + )) + + /** Closed circle/oval. */ + private fun makeCircle(cx: Float, cy: Float, r: Float, n: Int = 40): InkStroke { + val points = (0..n).map { i -> + StrokePoint( + (cx + r * cos(2 * PI * i / n)).toFloat(), + (cy + r * sin(2 * PI * i / n)).toFloat(), + 0.5f, 0L + ) + } + return InkStroke(points = points) + } + + /** Wide flat connector-like stroke. */ + private fun makeConnector(startX: Float, y: Float, width: Float): InkStroke = + InkStroke(points = makePoints( + startX to y, (startX + width * 0.5f) to (y + 3f), (startX + width) to (y - 2f) + )) + + /** Geometric shape (rectangle). */ + private fun makeGeometricRect(x: Float, y: Float, w: Float, h: Float): InkStroke = + InkStroke( + points = makePoints(x to y, (x + w) to y, (x + w) to (y + h), x to (y + h), x to y), + strokeType = StrokeType.RECTANGLE, + isGeometric = true + ) + + // ── Per-stroke classification ──────────────────────────────────────────── + + @Test fun smallTextStroke_classifiedAsText() { + val stroke = makeTextStroke(100f, 300f) + val score = DiagramStrokeClassifier.classifyStroke(stroke, LS) + assertTrue("Small text stroke should be text (score=$score)", score < 0.5f) + } + + @Test fun tallStroke_1_7xLS_classifiedAsDrawing() { + val stroke = makeTallStroke(100f, 200f, LS * 1.7f) + val score = DiagramStrokeClassifier.classifyStroke(stroke, LS) + assertTrue("Tall stroke (1.7×LS) should be drawing (score=$score)", score >= 0.5f) + } + + @Test fun tallStroke_1_2xLS_classifiedAsText() { + // Moderately tall but not enough alone (0.3 < 0.5) + val stroke = makeTallStroke(100f, 200f, LS * 1.2f) + val score = DiagramStrokeClassifier.classifyStroke(stroke, LS) + assertTrue("Moderately tall stroke (1.2×LS) should be text (score=$score)", score < 0.5f) + } + + @Test fun complexCircle_classifiedAsDrawing() { + // Circle has pathLength/diagonal ≈ π ≈ 3.14 > 2.5, plus closure + val stroke = makeCircle(300f, 300f, LS * 0.8f) + val score = DiagramStrokeClassifier.classifyStroke(stroke, LS) + assertTrue("Complex circular stroke should be drawing (score=$score)", score >= 0.5f) + } + + @Test fun largeCircle_classifiedAsDrawing() { + // Large circle: size > 2.5×LS + closure + complexity + val stroke = makeCircle(300f, 300f, LS * 1.5f) + val score = DiagramStrokeClassifier.classifyStroke(stroke, LS) + assertTrue("Large circle should be drawing (score=$score)", score >= 0.5f) + } + + @Test fun wideFlatConnector_classifiedAsDrawing() { + val stroke = makeConnector(100f, 300f, LS * 2.5f) + val score = DiagramStrokeClassifier.classifyStroke(stroke, LS) + assertTrue("Wide flat connector should be drawing (score=$score)", score >= 0.5f) + } + + @Test fun closedLoop_smallEnough_notDrawing() { + // Small closed loop (< 0.5×LS diagonal) — could be a letter "o" + val stroke = makeCircle(300f, 300f, LS * 0.15f, n = 20) + val score = DiagramStrokeClassifier.classifyStroke(stroke, LS) + assertTrue("Small closed loop should be text (score=$score)", score < 0.5f) + } + + @Test fun normalLetterA_classifiedAsText() { + // Two-stroke "A": moderate height, simple path + val stroke = InkStroke(points = makePoints( + 100f to (300f + LS * 0.8f), // bottom-left + 115f to 300f, // top + 130f to (300f + LS * 0.8f), // bottom-right + )) + val score = DiagramStrokeClassifier.classifyStroke(stroke, LS) + assertTrue("Letter A should be text (score=$score)", score < 0.5f) + } + + // ── Per-stroke partition ───────────────────────────────────────────────── + + @Test fun partitionByStroke_separatesTextAndDrawing() { + val text1 = makeTextStroke(100f, 300f) + val text2 = makeTextStroke(200f, 300f) + val drawing1 = makeTallStroke(300f, 200f, LS * 2f) + val drawing2 = makeCircle(400f, 300f, LS * 1f) + + val (textResult, drawingResult) = DiagramStrokeClassifier.partitionByStroke( + listOf(text1, text2, drawing1, drawing2), LS + ) + + assertTrue("Should have 2 text strokes", textResult.size == 2) + assertTrue("Should have 2 drawing strokes", drawingResult.size == 2) + } + + // ── Group-level: multi-line span ───────────────────────────────────────── + + @Test fun groupSpanning_0_8xLS_isText() { + val group = listOf( + makeTextStroke(100f, 300f), + makeTextStroke(150f, 300f + LS * 0.3f) + ) + assertTrue("Group spanning 0.8×LS should be text", + DiagramStrokeClassifier.isTextGroup(group, LS)) + } + + @Test fun groupSpanning_2_5xLS_isDrawing() { + val group = listOf( + makeTextStroke(100f, 300f), + makeTextStroke(100f, 300f + LS * 2.5f) + ) + assertFalse("Group spanning 2.5×LS should be drawing", + DiagramStrokeClassifier.isTextGroup(group, LS)) + } + + // ── Drawing contagion ──────────────────────────────────────────────────── + + @Test fun freehandAdjacentToGeometricShape_staysText() { + // Small freehand stroke near a geometric rectangle — text labels near + // shapes are common and should NOT become drawing via contagion + val textStroke = makeTextStroke(155f, 350f) // adjacent to the shape + val shape = makeGeometricRect(100f, 300f, 50f, 80f) + + val (text, drawing) = DiagramStrokeClassifier.partition( + freehandStrokes = listOf(textStroke), + geometricStrokes = listOf(shape), + lineSpacing = LS + ) + + assertTrue("Freehand adjacent to shape should stay text", text.size == 1) + assertTrue("No drawing strokes", drawing.isEmpty()) + } + + @Test fun freehandAdjacentToTallDrawingStroke_becomesDrawing() { + // A tall drawing stroke and a small freehand stroke nearby + val tallStroke = makeTallStroke(100f, 200f, LS * 2f) // classified as drawing + val smallStroke = makeTextStroke(110f, 200f + LS * 1.5f) // adjacent, text-like + + val (text, drawing) = DiagramStrokeClassifier.partition( + freehandStrokes = listOf(tallStroke, smallStroke), + geometricStrokes = emptyList(), + lineSpacing = LS + ) + + assertTrue("Both strokes should be drawing (contagion)", drawing.size == 2) + assertTrue("No text candidates", text.isEmpty()) + } + + @Test fun freehandFarFromDrawing_staysText() { + // A tall drawing stroke far from a small text stroke + val tallStroke = makeTallStroke(100f, 200f, LS * 2f) + val textStroke = makeTextStroke(500f, 800f) // far away + + val (text, drawing) = DiagramStrokeClassifier.partition( + freehandStrokes = listOf(tallStroke, textStroke), + geometricStrokes = emptyList(), + lineSpacing = LS + ) + + assertTrue("Text stroke should remain text", text.size == 1) + assertTrue("Drawing stroke should be drawing", drawing.size == 1) + assertTrue("Text stroke ID preserved", text[0].strokeId == textStroke.strokeId) + } + + @Test fun textInsideShape_staysText() { + // Text stroke inside a shape — this is a common diagram label + // (e.g., "Start" inside a rectangle). Shapes must NOT trigger contagion. + val shape = makeGeometricRect(80f, 280f, 100f, 60f) + val textInside = makeTextStroke(100f, 300f) + + val (text, drawing) = DiagramStrokeClassifier.partition( + freehandStrokes = listOf(textInside), + geometricStrokes = listOf(shape), + lineSpacing = LS + ) + + assertTrue("Text inside shape should stay text", text.size == 1) + assertTrue("No drawing strokes", drawing.isEmpty()) + } + + @Test fun isolatedTextGroup_notAffectedByDistantShape() { + // Text far from any shape + val shape = makeGeometricRect(100f, 100f, 50f, 50f) + val textFar = makeTextStroke(500f, 800f) + + val (text, drawing) = DiagramStrokeClassifier.partition( + freehandStrokes = listOf(textFar), + geometricStrokes = listOf(shape), + lineSpacing = LS + ) + + assertTrue("Distant text should stay text", text.size == 1) + assertTrue("No drawing strokes", drawing.isEmpty()) + } + + // ── Full partition with mixed content ──────────────────────────────────── + + @Test fun mixedDiagramContent_correctlyPartitioned() { + // Simulate a real diagram: shapes + text labels + freehand drawings + val shape1 = makeGeometricRect(100f, 200f, 80f, 60f) + val shape2 = makeGeometricRect(300f, 200f, 80f, 60f) + + // Text label far from drawings and shapes (diagram title well above) + val titleText = makeTextStroke(200f, 10f) + + // Freehand arrow between shapes (tall, near shapes) + val arrow = makeTallStroke(190f, 210f, LS * 1.8f) + + // Small annotation near shape1 + val annotation = makeTextStroke(105f, 270f) + + val (text, drawing) = DiagramStrokeClassifier.partition( + freehandStrokes = listOf(titleText, arrow, annotation), + geometricStrokes = listOf(shape1, shape2), + lineSpacing = LS + ) + + // Arrow is drawing (tall). Annotation near shape stays text (shapes don't + // trigger contagion). But if annotation is near the arrow, contagion applies. + assertTrue("Arrow should be drawing", drawing.any { it.strokeId == arrow.strokeId }) + // Title text stays text (far from drawing strokes) + assertTrue("Title text should be text", text.any { it.strokeId == titleText.strokeId }) + } +} diff --git a/app/src/test/java/com/writer/ui/writing/DiagramTextGroupingTest.kt b/app/src/test/java/com/writer/ui/writing/DiagramTextGroupingTest.kt new file mode 100644 index 0000000..f395e50 --- /dev/null +++ b/app/src/test/java/com/writer/ui/writing/DiagramTextGroupingTest.kt @@ -0,0 +1,281 @@ +package com.writer.ui.writing + +import com.writer.model.InkStroke +import com.writer.model.StrokePoint +import com.writer.view.ScreenMetrics +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Unit tests for [DiagramTextGrouping]: 2D spatial grouping and Y-row splitting + * of freehand strokes in diagram areas. + */ +class DiagramTextGroupingTest { + + companion object { + private const val DENSITY = 1.875f + private const val GAP = 118f // ~1 line spacing at standard density + } + + @Before + fun setUp() { + ScreenMetrics.init(DENSITY, smallestWidthDp = 674, widthPixels = 1264, heightPixels = 1680) + } + + private fun stroke(vararg pairs: Pair): InkStroke = + InkStroke(points = pairs.map { (x, y) -> StrokePoint(x, y, 0.5f, 0L) }) + + // ── groupByProximity ────────────────────────────────────────────────────── + + @Test + fun `single stroke forms one group`() { + val s = stroke(100f to 100f, 150f to 130f) + val groups = DiagramTextGrouping.groupByProximity(listOf(s), GAP) + assertEquals(1, groups.size) + assertEquals(1, groups[0].size) + } + + @Test + fun `empty list returns empty`() { + val groups = DiagramTextGrouping.groupByProximity(emptyList(), GAP) + assertTrue(groups.isEmpty()) + } + + @Test + fun `two strokes close in X and Y are grouped together`() { + // Two strokes of letter "A" — overlapping bounding boxes + val s1 = stroke(100f to 100f, 130f to 150f) // left leg + val s2 = stroke(130f to 100f, 160f to 150f) // right leg + val groups = DiagramTextGrouping.groupByProximity(listOf(s1, s2), GAP) + assertEquals("Close strokes should form 1 group", 1, groups.size) + assertEquals(2, groups[0].size) + } + + @Test + fun `two strokes far apart in X form separate groups`() { + // "A" in left shape, "B" in right shape — far apart horizontally + val sA = stroke(100f to 200f, 150f to 250f) + val sB = stroke(500f to 200f, 550f to 250f) + val groups = DiagramTextGrouping.groupByProximity(listOf(sA, sB), GAP) + assertEquals("Far-apart strokes should form 2 groups", 2, groups.size) + } + + @Test + fun `two strokes close in X but far in Y form separate groups`() { + // "A" at top, "C" at bottom — same X column but different shapes vertically + val sA = stroke(200f to 100f, 250f to 150f) + val sC = stroke(210f to 400f, 260f to 450f) + val groups = DiagramTextGrouping.groupByProximity(listOf(sA, sC), GAP) + assertEquals("Vertically separated strokes should form 2 groups", 2, groups.size) + } + + @Test + fun `strokes exactly at gap distance are grouped`() { + val s1 = stroke(100f to 100f, 150f to 120f) + // s2 starts exactly GAP px to the right of s1's right edge + val s2 = stroke(150f + GAP to 100f, 200f + GAP to 120f) + val groups = DiagramTextGrouping.groupByProximity(listOf(s1, s2), GAP) + assertEquals("Strokes at exactly gap distance should group", 1, groups.size) + } + + @Test + fun `strokes just beyond gap distance are separate`() { + val s1 = stroke(100f to 100f, 150f to 120f) + val s2 = stroke(150f + GAP + 1f to 100f, 200f + GAP + 1f to 120f) + val groups = DiagramTextGrouping.groupByProximity(listOf(s1, s2), GAP) + assertEquals("Strokes beyond gap should be separate", 2, groups.size) + } + + @Test + fun `chain of close strokes forms one group`() { + // Three strokes in a chain: s1 close to s2, s2 close to s3, but s1 far from s3 + val s1 = stroke(100f to 100f, 150f to 120f) + val s2 = stroke(200f to 100f, 250f to 120f) // within GAP of s1 + val s3 = stroke(300f to 100f, 350f to 120f) // within GAP of s2, >GAP from s1 + val groups = DiagramTextGrouping.groupByProximity(listOf(s1, s2, s3), GAP) + assertEquals("Transitive proximity should form 1 group", 1, groups.size) + assertEquals(3, groups[0].size) + } + + @Test + fun `three separate groups at different positions`() { + // A (top-left), B (top-right), C (bottom-center) — all far apart + val sA = stroke(100f to 100f, 140f to 140f) + val sB = stroke(500f to 100f, 540f to 140f) + val sC = stroke(300f to 500f, 340f to 540f) + val groups = DiagramTextGrouping.groupByProximity(listOf(sA, sB, sC), GAP) + assertEquals("Three isolated strokes should form 3 groups", 3, groups.size) + } + + // ── splitIntoRows ───────────────────────────────────────────────────────── + + @Test + fun `single stroke returns one row`() { + val s = stroke(100f to 100f, 150f to 130f) + val rows = DiagramTextGrouping.splitIntoRows(listOf(s), GAP) + assertEquals(1, rows.size) + assertEquals(1, rows[0].size) + } + + @Test + fun `empty group returns empty`() { + val rows = DiagramTextGrouping.splitIntoRows(emptyList(), GAP) + assertTrue(rows.isEmpty()) + } + + @Test + fun `tall letter A spanning two lines stays as one row`() { + // Peak stroke at y=100-130, legs stroke at y=120-200 + // These overlap vertically — no gap + val peak = stroke(140f to 100f, 160f to 130f) + val legs = stroke(120f to 120f, 180f to 200f) + val rows = DiagramTextGrouping.splitIntoRows(listOf(peak, legs), GAP) + assertEquals("Overlapping strokes should stay in 1 row", 1, rows.size) + assertEquals(2, rows[0].size) + } + + @Test + fun `Side and Text on separate lines split into two rows`() { + // "Side" at y=100-150, "Text" at y=300-350 — gap of 150 > GAP + val side1 = stroke(500f to 100f, 550f to 130f) + val side2 = stroke(550f to 100f, 600f to 140f) + val text1 = stroke(500f to 300f, 560f to 340f) + val text2 = stroke(560f to 300f, 620f to 350f) + val rows = DiagramTextGrouping.splitIntoRows(listOf(side1, side2, text1, text2), GAP) + assertEquals("Stacked text blocks should split into 2 rows", 2, rows.size) + assertEquals(2, rows[0].size) // "Side" + assertEquals(2, rows[1].size) // "Text" + } + + @Test + fun `strokes with gap exactly at threshold stay together`() { + val s1 = stroke(100f to 100f, 150f to 120f) // maxY = 120 + // s2's minY is exactly GAP away from s1's maxY + val s2 = stroke(100f to 120f + GAP, 150f to 140f + GAP) + val rows = DiagramTextGrouping.splitIntoRows(listOf(s1, s2), GAP) + assertEquals("Gap exactly at threshold should stay as 1 row", 1, rows.size) + } + + @Test + fun `strokes with gap just beyond threshold split`() { + val s1 = stroke(100f to 100f, 150f to 120f) + val s2 = stroke(100f to 120f + GAP + 1f, 150f to 140f + GAP + 1f) + val rows = DiagramTextGrouping.splitIntoRows(listOf(s1, s2), GAP) + assertEquals("Gap beyond threshold should split into 2 rows", 2, rows.size) + } + + @Test + fun `rows are sorted left-to-right within each row`() { + // Two strokes in same row, added in reverse X order + val right = stroke(300f to 100f, 350f to 130f) + val left = stroke(100f to 100f, 150f to 130f) + val rows = DiagramTextGrouping.splitIntoRows(listOf(right, left), GAP) + assertEquals(1, rows.size) + // First stroke in row should be the leftmost + assertTrue( + "Row should be sorted left-to-right", + rows[0][0].points.minOf { it.x } < rows[0][1].points.minOf { it.x } + ) + } + + @Test + fun `three stacked lines split into three rows`() { + val line1 = stroke(100f to 100f, 200f to 130f) + val line2 = stroke(100f to 300f, 200f to 330f) + val line3 = stroke(100f to 500f, 200f to 530f) + val rows = DiagramTextGrouping.splitIntoRows(listOf(line1, line2, line3), GAP) + assertEquals("Three stacked lines should form 3 rows", 3, rows.size) + } + + // ── splitIntoRowsAdaptive ───────────────────────────────────────────────── + + @Test + fun `adaptive - tall A spanning two lines stays as one row`() { + // Strokes of "A": peak at y=100-130, left leg y=110-200, right leg y=110-200 + // All overlap vertically — centroids are close + val peak = stroke(140f to 100f, 160f to 130f) // height=30, centroid=115 + val leftLeg = stroke(120f to 110f, 140f to 200f) // height=90, centroid=155 + val rightLeg = stroke(160f to 110f, 180f to 200f) // height=90, centroid=155 + // Median height = 90. Centroid gap: 155-115=40, threshold=90*0.8=72. 40<72 → same row + val rows = DiagramTextGrouping.splitIntoRowsAdaptive(listOf(peak, leftLeg, rightLeg), GAP) + assertEquals("Tall A strokes should stay as 1 row", 1, rows.size) + assertEquals(3, rows[0].size) + } + + @Test + fun `adaptive - Side and Text on adjacent lines split into two rows`() { + // "Side" strokes: each ~30px tall, centroids around y=115 + val s1 = stroke(500f to 100f, 520f to 130f) // height=30, centroid=115 + val s2 = stroke(520f to 100f, 540f to 130f) // height=30, centroid=115 + val s3 = stroke(540f to 105f, 560f to 135f) // height=30, centroid=120 + // "Text" strokes: each ~30px tall, centroids around y=195 + val t1 = stroke(500f to 180f, 520f to 210f) // height=30, centroid=195 + val t2 = stroke(520f to 180f, 540f to 210f) // height=30, centroid=195 + val t3 = stroke(540f to 185f, 560f to 215f) // height=30, centroid=200 + // Median height = 30. Centroid gap: 195-120=75, threshold=30*0.8=24. 75>24 → split + val rows = DiagramTextGrouping.splitIntoRowsAdaptive( + listOf(s1, s2, s3, t1, t2, t3), GAP + ) + assertEquals("Side/Text should split into 2 rows", 2, rows.size) + assertEquals(3, rows[0].size) + assertEquals(3, rows[1].size) + } + + @Test + fun `adaptive - falls back for fewer than 3 strokes`() { + val s1 = stroke(100f to 100f, 150f to 130f) + val s2 = stroke(100f to 300f, 150f to 330f) + // Only 2 strokes — falls back to splitIntoRows with fallbackGap + val rows = DiagramTextGrouping.splitIntoRowsAdaptive(listOf(s1, s2), GAP) + assertEquals("Fallback should split on large gap", 2, rows.size) + } + + @Test + fun `adaptive - single stroke returns one row`() { + val s = stroke(100f to 100f, 150f to 130f) + val rows = DiagramTextGrouping.splitIntoRowsAdaptive(listOf(s), GAP) + assertEquals(1, rows.size) + } + + @Test + fun `adaptive - mixed large and small strokes uses median height`() { + // 3 small strokes (height=30) + 1 tall stroke (height=100) + // Median of [30, 30, 30, 100] = 30 (index 2 of sorted 4) + val small1 = stroke(100f to 100f, 130f to 130f) // h=30, cy=115 + val small2 = stroke(140f to 100f, 170f to 130f) // h=30, cy=115 + val tall = stroke(200f to 80f, 230f to 180f) // h=100, cy=130 + val small3 = stroke(100f to 250f, 130f to 280f) // h=30, cy=265 + // Median height=30, centroid gap threshold=24 + // Centroids: 115, 115, 130, 265 + // Gap 115→115=0, 115→130=15 (<24), 130→265=135 (>24) → split + val rows = DiagramTextGrouping.splitIntoRowsAdaptive( + listOf(small1, small2, tall, small3), GAP + ) + assertEquals("Should split into 2 rows at the large gap", 2, rows.size) + assertEquals("First row has 3 strokes", 3, rows[0].size) + assertEquals("Second row has 1 stroke", 1, rows[1].size) + } + + @Test + fun `adaptive - three stacked text lines split correctly`() { + // Three lines of text, each ~30px tall, spaced 80px apart + val line1a = stroke(100f to 100f, 130f to 130f) + val line1b = stroke(140f to 100f, 170f to 130f) + val line1c = stroke(180f to 100f, 210f to 130f) + val line2a = stroke(100f to 210f, 130f to 240f) + val line2b = stroke(140f to 210f, 170f to 240f) + val line2c = stroke(180f to 210f, 210f to 240f) + val line3a = stroke(100f to 320f, 130f to 350f) + val line3b = stroke(140f to 320f, 170f to 350f) + val line3c = stroke(180f to 320f, 210f to 350f) + // Median height=30, centroid gap threshold=24 + // Line 1 centroids ~115, Line 2 centroids ~225, Line 3 centroids ~335 + // Gaps: 225-115=110 (>24), 335-225=110 (>24) → 3 rows + val rows = DiagramTextGrouping.splitIntoRowsAdaptive( + listOf(line1a, line1b, line1c, line2a, line2b, line2c, line3a, line3b, line3c), GAP + ) + assertEquals("Three stacked text lines should form 3 rows", 3, rows.size) + } +} diff --git a/app/src/test/java/com/writer/ui/writing/DwellDisambiguationTest.kt b/app/src/test/java/com/writer/ui/writing/DwellDisambiguationTest.kt new file mode 100644 index 0000000..8955df1 --- /dev/null +++ b/app/src/test/java/com/writer/ui/writing/DwellDisambiguationTest.kt @@ -0,0 +1,263 @@ +package com.writer.ui.writing + +import com.writer.model.InkStroke +import com.writer.model.StrokePoint +import com.writer.view.HandwritingCanvasView +import com.writer.view.ScreenMetrics +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin + +/** + * Tests for dwell disambiguation: ensuring that the start-dwell gesture + * only creates freeform diagram areas for drawing-like strokes, not text. + * + * The two-layer disambiguation: + * 1. Movement cancellation: dwell is cancelled when pen moves beyond radius + * 2. Stroke classification: even if dwell fires, text-like strokes don't + * create diagram areas + * + * These tests simulate the logic in HandwritingCanvasView.finishStroke() + * where dwellIndicatorShown + stroke classification gate diagram creation. + */ +class DwellDisambiguationTest { + + companion object { + private const val DENSITY = 1.875f + private val LS get() = HandwritingCanvasView.LINE_SPACING + private const val DWELL_RADIUS = 15f // ARROW_DWELL_RADIUS_PX + private const val DRAWING_THRESHOLD = 0.5f + } + + @Before + fun setUp() { + ScreenMetrics.init(DENSITY, smallestWidthDp = 674, widthPixels = 1264, heightPixels = 1680) + } + + private fun makePoints(vararg pairs: Pair): List = + pairs.map { (x, y) -> StrokePoint(x, y, 0.5f, 0L) } + + /** + * Simulate the dwell disambiguation logic: + * - Check if pen moved beyond dwell radius (layer 1) + * - Check if stroke is drawing-like (layer 2) + * @return true if a diagram area should be created + */ + private fun shouldCreateDiagram( + stroke: InkStroke, + dwellFired: Boolean + ): Boolean { + if (!dwellFired) return false + + // Layer 1: Check if pen moved beyond 2× dwell radius from first point + val first = stroke.points.first() + val last = stroke.points.last() + val maxDist = stroke.points.maxOf { pt -> + val dx = pt.x - first.x + val dy = pt.y - first.y + dx * dx + dy * dy + } + if (maxDist > DWELL_RADIUS * DWELL_RADIUS * 4) { + // Pen moved significantly — check stroke classification + // (movement alone cancels dwell in the actual implementation, + // but we still have layer 2 as backup) + } + + // Layer 2: Stroke must look like drawing, not text. + // Exclude connector heuristic — cursive text is wide+flat like connectors. + val score = DiagramStrokeClassifier.classifyStroke(stroke, LS, includeConnector = false) + return score >= DRAWING_THRESHOLD + } + + /** + * Simulate whether the dwell timer would fire: pen hasn't moved + * beyond DWELL_RADIUS from the first point after 500ms. + */ + private fun wouldDwellFire(points: List): Boolean { + if (points.size < 2) return false + val first = points.first() + // Check if at ~500ms (approximated by checking early points) + // the pen was still within the radius + val earlyPoints = points.take(points.size / 3) // first third of stroke + return earlyPoints.all { pt -> + val dx = pt.x - first.x + val dy = pt.y - first.y + dx * dx + dy * dy < DWELL_RADIUS * DWELL_RADIUS + } + } + + // ── Text strokes should NOT create diagram areas ───────────────────────── + + @Test + fun `short horizontal text Hello World should not create diagram`() { + // "Hello World" written horizontally on one line + val stroke = InkStroke(points = makePoints( + 100f to 500f, 110f to 498f, 120f to 502f, 130f to 499f, + 150f to 501f, 170f to 498f, 200f to 500f, 230f to 502f, + 260f to 499f, 300f to 501f + )) + assertFalse("Text 'Hello World' should not create diagram", + shouldCreateDiagram(stroke, dwellFired = true)) + } + + @Test + fun `cursive writing should not create diagram even with dwell`() { + // "cursive is cool" — connected cursive with ups and downs + val stroke = InkStroke(points = makePoints( + 50f to 800f, 60f to 795f, 70f to 805f, 80f to 790f, + 90f to 810f, 100f to 795f, 120f to 800f, 140f to 792f, + 160f to 808f, 180f to 795f, 200f to 805f, 220f to 790f, + 250f to 800f, 280f to 795f, 310f to 805f, 340f to 798f, + 370f to 802f, 400f to 795f + )) + assertFalse("Cursive text should not create diagram", + shouldCreateDiagram(stroke, dwellFired = true)) + } + + @Test + fun `small single letter should not create diagram`() { + val stroke = InkStroke(points = makePoints( + 100f to 300f, 105f to 290f, 110f to 295f, 115f to 305f, 120f to 300f + )) + assertFalse("Single letter should not create diagram", + shouldCreateDiagram(stroke, dwellFired = true)) + } + + @Test + fun `text spanning most of line width should not create diagram`() { + // Wide text line but still short height — clearly text + val points = (0..20).map { i -> + StrokePoint(50f + i * 30f, 400f + (i % 3 - 1) * 5f, 0.5f, 0L) + } + val stroke = InkStroke(points = points) + assertFalse("Wide text should not create diagram", + shouldCreateDiagram(stroke, dwellFired = true)) + } + + // ── Drawing strokes SHOULD create diagram areas ────────────────────────── + + @Test + fun `tall vertical stroke should create diagram with dwell`() { + val stroke = InkStroke(points = makePoints( + 200f to 300f, 205f to 350f, 202f to 400f, 198f to 500f, 200f to 600f + )) + assertTrue("Tall stroke with dwell should create diagram", + shouldCreateDiagram(stroke, dwellFired = true)) + } + + @Test + fun `circle should create diagram with dwell`() { + val n = 40 + val cx = 300f; val cy = 400f; val r = LS * 0.8f + val points = (0..n).map { i -> + StrokePoint( + (cx + r * cos(2 * PI * i / n)).toFloat(), + (cy + r * sin(2 * PI * i / n)).toFloat(), + 0.5f, 0L + ) + } + val stroke = InkStroke(points = points) + assertTrue("Circle with dwell should create diagram", + shouldCreateDiagram(stroke, dwellFired = true)) + } + + @Test + fun `large freehand shape should create diagram with dwell`() { + // A large irregular shape spanning multiple lines + val stroke = InkStroke(points = makePoints( + 200f to 200f, 300f to 200f, 350f to 300f, 300f to 400f, + 200f to 450f, 150f to 350f, 150f to 250f, 200f to 200f + )) + assertTrue("Large shape with dwell should create diagram", + shouldCreateDiagram(stroke, dwellFired = true)) + } + + // ── No dwell = no diagram regardless of stroke type ────────────────────── + + @Test + fun `drawing stroke without dwell should not create diagram`() { + val stroke = InkStroke(points = makePoints( + 200f to 300f, 205f to 350f, 202f to 400f, 198f to 500f, 200f to 600f + )) + assertFalse("Drawing stroke without dwell should not create diagram", + shouldCreateDiagram(stroke, dwellFired = false)) + } + + // ── Movement cancellation (layer 1) ────────────────────────────────────── + + @Test + fun `dwell should not fire if pen moves immediately`() { + // Pen moves beyond radius in the first few points (realistic: ~30 points) + val points = mutableListOf() + // First 3 points near start, then moves away + points.add(StrokePoint(100f, 300f, 0.5f, 0L)) + points.add(StrokePoint(102f, 301f, 0.5f, 0L)) + points.add(StrokePoint(105f, 302f, 0.5f, 0L)) + // Remaining 27 points: pen moves far + for (i in 0..26) { + points.add(StrokePoint(110f + i * 10f, 305f + i * 2f, 0.5f, 0L)) + } + assertFalse("Dwell should not fire when pen moves immediately", + wouldDwellFire(points)) + } + + @Test + fun `dwell should fire if pen stays still in early portion`() { + // Pen stays within radius for the first third, then moves + val points = mutableListOf() + // First 10 points: stationary (within 5px of start) + for (i in 0..9) { + points.add(StrokePoint(100f + i * 0.3f, 300f + i * 0.2f, 0.5f, 0L)) + } + // Then 20 points: moves away + for (i in 0..19) { + points.add(StrokePoint(105f + i * 15f, 305f + i * 5f, 0.5f, 0L)) + } + val stroke = InkStroke(points = points) + assertTrue("Dwell should fire when pen is still initially", + wouldDwellFire(stroke.points)) + } + + // ── Edge cases ─────────────────────────────────────────────────────────── + + @Test + fun `dot stroke (very short) should not create diagram`() { + // A single tap — too small to be a meaningful drawing + val stroke = InkStroke(points = makePoints( + 200f to 400f, 201f to 401f + )) + assertFalse("Dot should not create diagram", + shouldCreateDiagram(stroke, dwellFired = true)) + } + + @Test + fun `stroke from bug report - Hello World text at line 10`() { + // Simulated "Hello World" text — horizontal, small height, simple path + val stroke = InkStroke(points = makePoints( + 50f to 837f, 70f to 835f, 90f to 840f, 110f to 833f, + 130f to 838f, 160f to 836f, 190f to 841f, 220f to 834f, + 250f to 839f, 280f to 836f, 320f to 840f, 360f to 835f, + 400f to 838f + )) + assertFalse("Hello World text (from bug report) should not create diagram", + shouldCreateDiagram(stroke, dwellFired = true)) + } + + @Test + fun `stroke from bug report - cursive is cool at line 17`() { + // Simulated cursive — connected, moderate height, horizontal + val stroke = InkStroke(points = makePoints( + 79f to 1382f, 70f to 1385f, 65f to 1370f, 75f to 1365f, + 90f to 1375f, 110f to 1360f, 130f to 1380f, 155f to 1365f, + 180f to 1375f, 200f to 1358f, 225f to 1370f, 250f to 1362f, + 280f to 1378f, 310f to 1365f, 340f to 1372f, 370f to 1360f, + 400f to 1375f, 420f to 1368f + )) + assertFalse("Cursive text (from bug report) should not create diagram", + shouldCreateDiagram(stroke, dwellFired = true)) + } +} diff --git a/app/src/test/java/com/writer/ui/writing/StrokeEventLogTest.kt b/app/src/test/java/com/writer/ui/writing/StrokeEventLogTest.kt new file mode 100644 index 0000000..c11da1d --- /dev/null +++ b/app/src/test/java/com/writer/ui/writing/StrokeEventLogTest.kt @@ -0,0 +1,155 @@ +package com.writer.ui.writing + +import com.writer.model.StrokePoint +import com.writer.view.ScreenMetrics +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Unit tests for [StrokeEventLog] ring buffer and event recording. + */ +class StrokeEventLogTest { + + @Before + fun setUp() { + ScreenMetrics.init(1.875f, smallestWidthDp = 674, widthPixels = 1264, heightPixels = 1680) + } + + private fun makePoints(n: Int = 10): List = + (0 until n).map { i -> StrokePoint(i * 10f, 100f, 0.5f, i * 10L) } + + @Test + fun `recordStroke returns incrementing indices`() { + val log = StrokeEventLog(maxStrokes = 10) + assertEquals(0, log.recordStroke(makePoints())) + assertEquals(1, log.recordStroke(makePoints())) + assertEquals(2, log.recordStroke(makePoints())) + } + + @Test + fun `strokeCount tracks buffer size`() { + val log = StrokeEventLog(maxStrokes = 10) + assertEquals(0, log.strokeCount) + log.recordStroke(makePoints()) + assertEquals(1, log.strokeCount) + log.recordStroke(makePoints()) + assertEquals(2, log.strokeCount) + } + + @Test + fun `ring buffer evicts oldest when full`() { + val log = StrokeEventLog(maxStrokes = 3) + log.recordStroke(makePoints()) // index 0 + log.recordStroke(makePoints()) // index 1 + log.recordStroke(makePoints()) // index 2 + assertEquals(3, log.strokeCount) + + log.recordStroke(makePoints()) // index 3, evicts index 0 + assertEquals(3, log.strokeCount) + + val snapshot = log.snapshot() + assertEquals(3, snapshot.strokes.size) + assertEquals(1, snapshot.strokes[0].index) // oldest is now index 1 + assertEquals(3, snapshot.strokes[2].index) // newest is index 3 + } + + @Test + fun `events are recorded with correct stroke index`() { + val log = StrokeEventLog() + val idx = log.recordStroke(makePoints()) + log.recordEvent(idx, StrokeEventLog.EventType.ADDED, "line=3") + + val snapshot = log.snapshot() + assertEquals(1, snapshot.events.size) + assertEquals(idx, snapshot.events[0].strokeIndex) + assertEquals(StrokeEventLog.EventType.ADDED, snapshot.events[0].type) + assertEquals("line=3", snapshot.events[0].detail) + } + + @Test + fun `events for evicted strokes are removed`() { + val log = StrokeEventLog(maxStrokes = 2) + val idx0 = log.recordStroke(makePoints()) + log.recordEvent(idx0, StrokeEventLog.EventType.ADDED) + val idx1 = log.recordStroke(makePoints()) + log.recordEvent(idx1, StrokeEventLog.EventType.ADDED) + + // Evict idx0 + log.recordStroke(makePoints()) + + val snapshot = log.snapshot() + // Event for idx0 should be gone + assertTrue(snapshot.events.none { it.strokeIndex == idx0 }) + } + + @Test + fun `multiple events per stroke`() { + val log = StrokeEventLog() + val idx = log.recordStroke(makePoints()) + log.recordEvent(idx, StrokeEventLog.EventType.ADDED) + log.recordEvent(idx, StrokeEventLog.EventType.SHAPE_SNAPPED, "RECTANGLE") + log.recordEvent(idx, StrokeEventLog.EventType.REPLACED, "RECTANGLE") + + val snapshot = log.snapshot() + assertEquals(3, snapshot.events.size) + } + + @Test + fun `snapshot is a copy not a reference`() { + val log = StrokeEventLog() + log.recordStroke(makePoints()) + val snap1 = log.snapshot() + + log.recordStroke(makePoints()) + val snap2 = log.snapshot() + + assertEquals(1, snap1.strokes.size) + assertEquals(2, snap2.strokes.size) + } + + @Test + fun `clear resets everything`() { + val log = StrokeEventLog() + log.recordStroke(makePoints()) + log.recordEvent(0, StrokeEventLog.EventType.ADDED) + log.clear() + + assertEquals(0, log.strokeCount) + assertEquals(0, log.eventCount) + val snapshot = log.snapshot() + assertTrue(snapshot.strokes.isEmpty()) + assertTrue(snapshot.events.isEmpty()) + } + + @Test + fun `event cap prevents unbounded growth`() { + val log = StrokeEventLog(maxStrokes = 5) + val idx = log.recordStroke(makePoints()) + // Add 20 events (cap = 5 * 3 = 15) + for (i in 0 until 20) { + log.recordEvent(idx, StrokeEventLog.EventType.RECOGNIZED, "text $i") + } + assertTrue("Events should be capped", log.eventCount <= 15) + } + + @Test + fun `downsampling reduces point count`() { + // Create a stroke with many redundant points (dwell + straight line) + val points = mutableListOf() + // Dwell: 20 points at same position + for (i in 0 until 20) points.add(StrokePoint(100f, 100f, 0.5f, i.toLong())) + // Straight line: 50 points + for (i in 0 until 50) points.add(StrokePoint(100f + i * 2f, 100f, 0.5f, (20 + i).toLong())) + + val log = StrokeEventLog() + log.recordStroke(points) + + val snapshot = log.snapshot() + assertTrue( + "Downsampled points (${snapshot.strokes[0].points.size}) should be less than raw (${points.size})", + snapshot.strokes[0].points.size < points.size + ) + } +} diff --git a/app/src/test/java/com/writer/ui/writing/UndoUnsnapTest.kt b/app/src/test/java/com/writer/ui/writing/UndoUnsnapTest.kt new file mode 100644 index 0000000..ec47169 --- /dev/null +++ b/app/src/test/java/com/writer/ui/writing/UndoUnsnapTest.kt @@ -0,0 +1,310 @@ +package com.writer.ui.writing + +import com.writer.model.DiagramArea +import com.writer.model.DocumentModel +import com.writer.model.InkStroke +import com.writer.model.StrokePoint +import com.writer.model.StrokeType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Tests the undo-to-unsnap behavior: when a stroke is snapped to a shape, + * undo once restores the raw freehand stroke; undo again removes it entirely. + * + * Exercises the two-phase commit logic that [WritingCoordinator] uses when + * [HandwritingCanvasView.onStrokeReplaced] fires after a shape snap. + */ +class UndoUnsnapTest { + + private lateinit var documentModel: DocumentModel + private lateinit var undoManager: UndoManager + + /** Simulates the state captured by [WritingCoordinator.saveUndoSnapshot]. */ + private fun currentSnapshot() = UndoManager.Snapshot( + strokes = documentModel.activeStrokes.toList(), + scrollOffsetY = 0f, + lineTextCache = emptyMap(), + diagramAreas = documentModel.diagramAreas.toList() + ) + + private fun saveUndoSnapshot() { + undoManager.saveSnapshot(currentSnapshot()) + } + + private fun applySnapshot(snapshot: UndoManager.Snapshot) { + documentModel.activeStrokes.clear() + documentModel.activeStrokes.addAll(snapshot.strokes) + documentModel.diagramAreas.clear() + documentModel.diagramAreas.addAll(snapshot.diagramAreas) + } + + private fun undo(): Boolean { + val snapshot = undoManager.undo(currentSnapshot()) ?: return false + applySnapshot(snapshot) + return true + } + + private fun redo(): Boolean { + val snapshot = undoManager.redo(currentSnapshot()) ?: return false + applySnapshot(snapshot) + return true + } + + /** Simulate onStrokeCompleted as WritingCoordinator does. */ + private fun simulateStrokeCompleted(stroke: InkStroke) { + saveUndoSnapshot() + documentModel.activeStrokes.add(stroke) + } + + /** Simulate onStrokeReplaced as WritingCoordinator does. */ + private fun simulateStrokeReplaced(oldStrokeId: String, newStroke: InkStroke) { + saveUndoSnapshot() + documentModel.activeStrokes.removeAll { it.strokeId == oldStrokeId } + documentModel.activeStrokes.add(newStroke) + } + + private fun makePoints(vararg pairs: Pair): List = + pairs.map { (x, y) -> StrokePoint(x, y, 0.5f, 0L) } + + @Before + fun setUp() { + documentModel = DocumentModel() + undoManager = UndoManager() + } + + @Test + fun `snapped stroke - undo once shows raw freehand`() { + val rawStroke = InkStroke( + points = makePoints(10f to 10f, 50f to 50f, 100f to 100f), + strokeType = StrokeType.FREEHAND + ) + val snappedStroke = InkStroke( + points = makePoints(10f to 10f, 100f to 100f), + strokeType = StrokeType.LINE, + isGeometric = true + ) + + // Phase 1: raw stroke completed + simulateStrokeCompleted(rawStroke) + // Phase 2: replaced with snapped + simulateStrokeReplaced(rawStroke.strokeId, snappedStroke) + + // Verify current state has snapped stroke + assertEquals(1, documentModel.activeStrokes.size) + assertEquals(StrokeType.LINE, documentModel.activeStrokes[0].strokeType) + + // Undo once → raw freehand + assertTrue(undo()) + assertEquals(1, documentModel.activeStrokes.size) + assertEquals(StrokeType.FREEHAND, documentModel.activeStrokes[0].strokeType) + assertEquals(rawStroke.strokeId, documentModel.activeStrokes[0].strokeId) + } + + @Test + fun `snapped stroke - undo twice removes stroke entirely`() { + val rawStroke = InkStroke( + points = makePoints(10f to 10f, 100f to 100f), + strokeType = StrokeType.FREEHAND + ) + val snappedStroke = InkStroke( + points = makePoints(10f to 10f, 100f to 100f), + strokeType = StrokeType.RECTANGLE, + isGeometric = true + ) + + simulateStrokeCompleted(rawStroke) + simulateStrokeReplaced(rawStroke.strokeId, snappedStroke) + + // Undo once → raw stroke + assertTrue(undo()) + assertEquals(1, documentModel.activeStrokes.size) + + // Undo again → empty + assertTrue(undo()) + assertEquals(0, documentModel.activeStrokes.size) + } + + @Test + fun `snapped stroke - redo from raw restores snapped`() { + val rawStroke = InkStroke( + points = makePoints(10f to 10f, 50f to 50f, 100f to 100f), + strokeType = StrokeType.FREEHAND + ) + val snappedStroke = InkStroke( + points = makePoints(10f to 10f, 100f to 100f), + strokeType = StrokeType.ARROW_HEAD, + isGeometric = true + ) + + simulateStrokeCompleted(rawStroke) + simulateStrokeReplaced(rawStroke.strokeId, snappedStroke) + + // Undo to raw + undo() + assertEquals(StrokeType.FREEHAND, documentModel.activeStrokes[0].strokeType) + + // Redo → snapped restored + assertTrue(redo()) + assertEquals(1, documentModel.activeStrokes.size) + assertEquals(StrokeType.ARROW_HEAD, documentModel.activeStrokes[0].strokeType) + } + + @Test + fun `snapped stroke - redo from empty restores raw then snapped`() { + val rawStroke = InkStroke( + points = makePoints(10f to 10f, 100f to 100f), + strokeType = StrokeType.FREEHAND + ) + val snappedStroke = InkStroke( + points = makePoints(10f to 10f, 100f to 100f), + strokeType = StrokeType.ELLIPSE + ) + + simulateStrokeCompleted(rawStroke) + simulateStrokeReplaced(rawStroke.strokeId, snappedStroke) + + // Undo twice → empty + undo() + undo() + assertEquals(0, documentModel.activeStrokes.size) + + // Redo → raw + assertTrue(redo()) + assertEquals(1, documentModel.activeStrokes.size) + assertEquals(StrokeType.FREEHAND, documentModel.activeStrokes[0].strokeType) + + // Redo → snapped + assertTrue(redo()) + assertEquals(1, documentModel.activeStrokes.size) + assertEquals(StrokeType.ELLIPSE, documentModel.activeStrokes[0].strokeType) + } + + @Test + fun `non-snapped stroke undoes in one step`() { + val stroke = InkStroke( + points = makePoints(10f to 10f, 50f to 50f), + strokeType = StrokeType.FREEHAND + ) + + // Normal stroke — only onStrokeCompleted, no onStrokeReplaced + simulateStrokeCompleted(stroke) + + assertEquals(1, documentModel.activeStrokes.size) + + // Single undo removes it + assertTrue(undo()) + assertEquals(0, documentModel.activeStrokes.size) + } + + @Test + fun `mixed strokes - snapped and non-snapped undo independently`() { + // First: a normal freehand stroke + val freehand = InkStroke( + points = makePoints(10f to 10f, 50f to 50f), + strokeType = StrokeType.FREEHAND + ) + simulateStrokeCompleted(freehand) + + // Second: a snapped rectangle + val rawStroke = InkStroke( + points = makePoints(200f to 200f, 250f to 220f, 300f to 300f), + strokeType = StrokeType.FREEHAND + ) + val snappedStroke = InkStroke( + points = makePoints(200f to 200f, 300f to 200f, 300f to 300f, 200f to 300f, 200f to 200f), + strokeType = StrokeType.RECTANGLE, + isGeometric = true + ) + simulateStrokeCompleted(rawStroke) + simulateStrokeReplaced(rawStroke.strokeId, snappedStroke) + + // State: freehand + snapped rectangle + assertEquals(2, documentModel.activeStrokes.size) + + // Undo 1: rectangle → raw + undo() + assertEquals(2, documentModel.activeStrokes.size) + assertEquals(StrokeType.FREEHAND, documentModel.activeStrokes[0].strokeType) + assertEquals(StrokeType.FREEHAND, documentModel.activeStrokes[1].strokeType) + assertEquals(rawStroke.strokeId, documentModel.activeStrokes[1].strokeId) + + // Undo 2: raw stroke removed + undo() + assertEquals(1, documentModel.activeStrokes.size) + assertEquals(freehand.strokeId, documentModel.activeStrokes[0].strokeId) + + // Undo 3: freehand removed + undo() + assertEquals(0, documentModel.activeStrokes.size) + } + + @Test + fun `scrub-based undo walks through snap states`() { + val rawStroke = InkStroke( + points = makePoints(10f to 10f, 50f to 50f, 100f to 100f), + strokeType = StrokeType.FREEHAND + ) + val snappedStroke = InkStroke( + points = makePoints(10f to 10f, 100f to 100f), + strokeType = StrokeType.LINE, + isGeometric = true + ) + + simulateStrokeCompleted(rawStroke) + simulateStrokeReplaced(rawStroke.strokeId, snappedStroke) + + // Begin scrub from current (snapped) state + undoManager.beginScrub(currentSnapshot()) + + // Scrub -1 → raw stroke + val snap1 = undoManager.scrubTo(-1)!! + applySnapshot(snap1) + assertEquals(1, documentModel.activeStrokes.size) + assertEquals(StrokeType.FREEHAND, documentModel.activeStrokes[0].strokeType) + + // Scrub -2 → empty + val snap2 = undoManager.scrubTo(-2)!! + applySnapshot(snap2) + assertEquals(0, documentModel.activeStrokes.size) + + // Scrub back to 0 → snapped + val snap3 = undoManager.scrubTo(0)!! + applySnapshot(snap3) + assertEquals(1, documentModel.activeStrokes.size) + assertEquals(StrokeType.LINE, documentModel.activeStrokes[0].strokeType) + + undoManager.endScrub() + } + + @Test + fun `diagram areas preserved through snap undo cycle`() { + val diagramArea = DiagramArea(startLineIndex = 2, heightInLines = 4) + documentModel.diagramAreas.add(diagramArea) + + val rawStroke = InkStroke( + points = makePoints(50f to 300f, 100f to 350f), + strokeType = StrokeType.FREEHAND + ) + val snappedStroke = InkStroke( + points = makePoints(50f to 300f, 100f to 350f), + strokeType = StrokeType.LINE, + isGeometric = true + ) + + simulateStrokeCompleted(rawStroke) + simulateStrokeReplaced(rawStroke.strokeId, snappedStroke) + + // Undo to raw — diagram areas still present + undo() + assertEquals(1, documentModel.diagramAreas.size) + assertEquals(diagramArea, documentModel.diagramAreas[0]) + + // Undo to empty — diagram areas still present (was there before the stroke) + undo() + assertEquals(0, documentModel.activeStrokes.size) + assertEquals(1, documentModel.diagramAreas.size) + } +} diff --git a/app/src/test/java/com/writer/view/ArrowDwellDetectionTest.kt b/app/src/test/java/com/writer/view/ArrowDwellDetectionTest.kt new file mode 100644 index 0000000..5d8412a --- /dev/null +++ b/app/src/test/java/com/writer/view/ArrowDwellDetectionTest.kt @@ -0,0 +1,113 @@ +package com.writer.view + +import com.writer.model.StrokePoint +import com.writer.model.StrokeType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for [ArrowDwellDetection]. + * + * Uses a fixed dwell radius of 15 px and dwell duration of 300 ms. + */ +class ArrowDwellDetectionTest { + + companion object { + private const val RADIUS_PX = 15f + private const val DWELL_MS = 300L + } + + // ── hasDwellAtEnd ───────────────────────────────────────────────────────── + + @Test fun hasDwellAtEnd_clusteringNearEndpointForSufficientTime_returnsTrue() { + // Last 4 points cluster within 15 px of (100, 100) for 300 ms total + val pts = listOf( + StrokePoint(0f, 0f, 1f, 0L), + StrokePoint(50f, 50f, 1f, 100L), + StrokePoint(98f, 100f, 1f, 500L), + StrokePoint(99f, 101f, 1f, 600L), + StrokePoint(100f, 100f, 1f, 700L), + StrokePoint(101f, 99f, 1f, 800L), + ) + val result = ArrowDwellDetection.hasDwellAtEnd(pts, 100f, 100f, RADIUS_PX, DWELL_MS) + assertTrue("Should detect dwell when clustering >= 300ms near endpoint", result) + } + + @Test fun hasDwellAtEnd_clusteringForLessThanDwellMs_returnsFalse() { + // Last points cluster near (100, 100) but only for 200 ms (< 300 ms) + val pts = listOf( + StrokePoint(0f, 0f, 1f, 0L), + StrokePoint(50f, 50f, 1f, 100L), + StrokePoint(98f, 100f, 1f, 500L), + StrokePoint(100f, 100f, 1f, 600L), + StrokePoint(101f, 99f, 1f, 700L), // only 200ms span in radius + StrokePoint(100f, 100f, 1f, 700L), + ) + // Start is at 500L, end is at 700L = 200ms, below 300ms threshold + val result = ArrowDwellDetection.hasDwellAtEnd(pts, 100f, 100f, RADIUS_PX, DWELL_MS) + assertFalse("Should NOT detect dwell when clustering < 300ms", result) + } + + @Test fun hasDwellAtEnd_lastPointsFarFromEndpoint_returnsFalse() { + // Points end far from (100, 100) + val pts = listOf( + StrokePoint(0f, 0f, 1f, 0L), + StrokePoint(100f, 100f, 1f, 100L), + StrokePoint(200f, 200f, 1f, 500L), + StrokePoint(250f, 250f, 1f, 800L), + ) + val result = ArrowDwellDetection.hasDwellAtEnd(pts, 100f, 100f, RADIUS_PX, DWELL_MS) + assertFalse("Should NOT detect dwell when last points are far from endpoint", result) + } + + // ── hasDwellAtStart ─────────────────────────────────────────────────────── + + @Test fun hasDwellAtStart_firstPointsClusterNearStartForSufficientTime_returnsTrue() { + // First points stay near (50, 50) for 400 ms before moving away + val pts = listOf( + StrokePoint(50f, 50f, 1f, 0L), + StrokePoint(52f, 49f, 1f, 100L), + StrokePoint(51f, 51f, 1f, 200L), + StrokePoint(50f, 50f, 1f, 400L), // 400 ms in radius + StrokePoint(100f, 100f, 1f, 500L), // moves away + ) + val result = ArrowDwellDetection.hasDwellAtStart(pts, RADIUS_PX, DWELL_MS) + assertTrue("Should detect dwell at start when pen pauses >= 300ms", result) + } + + @Test fun hasDwellAtStart_penMovesAwayQuickly_returnsFalse() { + // Pen moves away from start right away — only 50ms within radius + val pts = listOf( + StrokePoint(50f, 50f, 1f, 0L), + StrokePoint(51f, 50f, 1f, 50L), // still near + StrokePoint(100f, 100f, 1f, 200L), // moves away at 50ms + StrokePoint(200f, 200f, 1f, 400L), + ) + val result = ArrowDwellDetection.hasDwellAtStart(pts, RADIUS_PX, DWELL_MS) + assertFalse("Should NOT detect dwell when pen moves away quickly (only 50ms in radius)", result) + } + + // ── classifyArrow ───────────────────────────────────────────────────────── + + @Test fun classifyArrow_tipDwellOnly_returnsArrowHead() { + val result = ArrowDwellDetection.classifyArrow(tipDwell = true, tailDwell = false) + assertEquals(StrokeType.ARROW_HEAD, result) + } + + @Test fun classifyArrow_tailDwellOnly_returnsArrowTail() { + val result = ArrowDwellDetection.classifyArrow(tipDwell = false, tailDwell = true) + assertEquals(StrokeType.ARROW_TAIL, result) + } + + @Test fun classifyArrow_bothDwells_returnsArrowBoth() { + val result = ArrowDwellDetection.classifyArrow(tipDwell = true, tailDwell = true) + assertEquals(StrokeType.ARROW_BOTH, result) + } + + @Test fun classifyArrow_noDwells_returnsLine() { + val result = ArrowDwellDetection.classifyArrow(tipDwell = false, tailDwell = false) + assertEquals(StrokeType.LINE, result) + } +} diff --git a/app/src/test/java/com/writer/view/DiagramBandLinesTest.kt b/app/src/test/java/com/writer/view/DiagramBandLinesTest.kt new file mode 100644 index 0000000..0264e67 --- /dev/null +++ b/app/src/test/java/com/writer/view/DiagramBandLinesTest.kt @@ -0,0 +1,234 @@ +package com.writer.view + +import com.writer.model.InkStroke +import com.writer.model.StrokePoint +import com.writer.model.StrokeType +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Tests for [DiagramTextFilter.diagramBandLines]: text written beside a diagram + * (in its Y-band but outside its X-extent) should be detected as a band note. + */ +class DiagramBandLinesTest { + + // Diagram bbox: x=[100..300], y=[200..400] + private val bbox = floatArrayOf(100f, 200f, 300f, 400f) + private val yTol = 50f + + // Helper: a single-point freehand stroke at (cx, cy) + private fun stroke(cx: Float, cy: Float, type: StrokeType = StrokeType.FREEHAND): InkStroke { + val pt = StrokePoint(cx, cy, 1f, 0L) + return InkStroke(points = listOf(pt), strokeType = type) + } + + // Node bounds as [left, top, right, bottom] + private fun nodeBounds(l: Float, t: Float, r: Float, b: Float) = floatArrayOf(l, t, r, b) + + @Test fun strokeToRightOfDiagram_isDetected() { + val strokesByLine = mapOf(0 to listOf(stroke(350f, 300f))) + val result = DiagramTextFilter.diagramBandLines(strokesByLine, emptyList(), bbox, yTol) + assertTrue("Stroke to the right should be detected as a band note", 0 in result) + } + + @Test fun strokeToLeftOfDiagram_isDetected() { + val strokesByLine = mapOf(0 to listOf(stroke(50f, 300f))) + val result = DiagramTextFilter.diagramBandLines(strokesByLine, emptyList(), bbox, yTol) + assertTrue("Stroke to the left should be detected as a band note", 0 in result) + } + + @Test fun strokeAboveDiagram_notDetected() { + // cy = 100 < (bTop - yTol) = 150 + val strokesByLine = mapOf(0 to listOf(stroke(350f, 100f))) + val result = DiagramTextFilter.diagramBandLines(strokesByLine, emptyList(), bbox, yTol) + assertFalse("Stroke above Y-band should NOT be detected", 0 in result) + } + + @Test fun strokeBelowDiagram_notDetected() { + // cy = 500 > (bBottom + yTol) = 450 + val strokesByLine = mapOf(0 to listOf(stroke(350f, 500f))) + val result = DiagramTextFilter.diagramBandLines(strokesByLine, emptyList(), bbox, yTol) + assertFalse("Stroke below Y-band should NOT be detected", 0 in result) + } + + @Test fun strokeInsideNode_notDetected() { + // Stroke is to the right of bbox X but inside a node — shape label, not a note + val nodeB = nodeBounds(300f, 200f, 450f, 400f) + val strokesByLine = mapOf(0 to listOf(stroke(375f, 300f))) + val result = DiagramTextFilter.diagramBandLines(strokesByLine, listOf(nodeB), bbox, yTol) + assertFalse("Stroke inside a node should NOT be detected (it is a shape label)", 0 in result) + } + + @Test fun mixedLine_shapeLabelPlusBandNote_isDetected() { + // Real-world case from Issue #5: "side" text written at the same Y-level as shape + // label "A". The shape-label stroke is inside node A; the "side" stroke is outside X. + // Shape-label strokes must be ignored so the band note is still detected. + val nodeA = nodeBounds(100f, 200f, 200f, 320f) // inside the diagram bbox + val strokesByLine = mapOf( + 0 to listOf( + stroke(150f, 260f), // shape label "A" — inside nodeA + stroke(350f, 260f) // "side" — outside X, in Y-band + ) + ) + val result = DiagramTextFilter.diagramBandLines(strokesByLine, listOf(nodeA), bbox, yTol) + assertTrue("Line with shape-label + band-note strokes should be detected as a band note", 0 in result) + } + + @Test fun noFreehandStrokes_notDetected() { + val strokesByLine = mapOf(0 to listOf(stroke(350f, 300f, StrokeType.RECTANGLE))) + val result = DiagramTextFilter.diagramBandLines(strokesByLine, emptyList(), bbox, yTol) + assertFalse("Line with no freehand strokes should NOT be detected", 0 in result) + } + + @Test fun noDiagram_returnsEmpty() { + // When there are no nodes the caller passes no diagramBBox, so emptySet is expected. + // This tests the function directly with an arbitrary bbox but empty strokesByLine. + val result = DiagramTextFilter.diagramBandLines(emptyMap(), emptyList(), bbox, yTol) + assertTrue("No strokes → result should be empty", result.isEmpty()) + } + + @Test fun strokeJustOutsideBbox_detected() { + // cx = bbox.right + 1 = 301 → strictly outside X + val strokesByLine = mapOf(0 to listOf(stroke(301f, 300f))) + val result = DiagramTextFilter.diagramBandLines(strokesByLine, emptyList(), bbox, yTol) + assertTrue("Stroke just outside bbox X should be detected", 0 in result) + } + + @Test fun strokeExactlyAtBboxEdge_notDetected() { + // cx = bbox.right = 300 → on the X border → inside (not < bLeft, not > bRight) + val strokesByLine = mapOf(0 to listOf(stroke(300f, 300f))) + val result = DiagramTextFilter.diagramBandLines(strokesByLine, emptyList(), bbox, yTol) + assertFalse("Stroke exactly at bbox right edge should NOT be detected (on border = inside)", 0 in result) + } + + // ── Right-margin exclusion (Bug #6) ─────────────────────────────────────── + // + // After drawing a diagram, diagramBandLines activates. Without an upper-X + // bound, ANY text written to the right of the diagram's bbox and within its + // Y-band is classified as a band note — including normal text at the far-right + // margin of the page (just before the scroll gutter). That text should not + // be suppressed from the text paragraph panel. + // + // Fix: add a rightBandLimit parameter. Only strokes with cx < rightBandLimit + // qualify as "right-side" band notes. The caller passes + // canvasWidth − 2·gutterWidth so strokes in the rightmost gutter-zone are + // treated as ordinary text. + // + // Canvas geometry used in these tests (matching a 3200 px wide device with + // 129 px gutter, i.e. Tab X C at 300 PPI): + // rightBandLimit = 3200 − 2·129 = 2942 + + private val rightBandLimit = 2942f // canvasWidth − 2·gutterWidth + + @Test fun strokeFarRightOfDiagram_notBandNote() { + // Stroke at x=2950 is to the right of the diagram (bbox.right=300) but + // also past the rightBandLimit=2942 — it is regular text, NOT a band note. + // FAILS currently (no rightBandLimit check → detected as band note). + val strokesByLine = mapOf(0 to listOf(stroke(2950f, 300f))) + val result = DiagramTextFilter.diagramBandLines( + strokesByLine, emptyList(), bbox, yTol, rightBandLimit = rightBandLimit) + assertFalse("Text beyond rightBandLimit should not be a band note", 0 in result) + } + + @Test fun strokeJustInsideRightBandLimit_isBandNote() { + // Stroke at x=2940 is outside the diagram X and below rightBandLimit → IS a band note. + val strokesByLine = mapOf(0 to listOf(stroke(2940f, 300f))) + val result = DiagramTextFilter.diagramBandLines( + strokesByLine, emptyList(), bbox, yTol, rightBandLimit = rightBandLimit) + assertTrue("Text just inside rightBandLimit should still be a band note", 0 in result) + } + + @Test fun noRightBandLimit_farRightDetected() { + // Explicit MAX_VALUE preserves the old unlimited behaviour for callers that need it. + val strokesByLine = mapOf(0 to listOf(stroke(2950f, 300f))) + val result = DiagramTextFilter.diagramBandLines( + strokesByLine, emptyList(), bbox, yTol, rightBandLimit = Float.MAX_VALUE) + assertTrue("Explicit MAX_VALUE limit: far-right stroke is still detected", 0 in result) + } + + // ── Default right-band limit (Bug #6 root cause) ────────────────────────── + // + // The previous fix used rightBandLimit = canvasWidth − 2·gutterWidth ≈ 2942, + // meaning any stroke between bRight (300) and 2942 was classified as a band note. + // That is 2600 px of canvas — nearly the entire page. Text written on the right + // side of the canvas after drawing a small left-side diagram was silently suppressed. + // + // Fix: make rightBandLimit default to bRight + yTolerance × 4 (a narrow column + // proportional to the diagram's already-known line spacing). Text further away + // is ordinary writing, not a diagram side note. + // + // With bbox x=[100..300] and yTol=50: default rightBandLimit = 300 + 50×4 = 500. + + @Test fun textFarRightOfSmallDiagram_defaultLimit_notBandNote() { + // Text at x=2800 is 2500 px to the right of a small diagram. + // With the sensible default (rightBandLimit = bRight + 4·yTol = 500) it is NOT a band note. + // CURRENTLY FAILS: default is Float.MAX_VALUE → 2800 IS classified as a band note. + val strokesByLine = mapOf(0 to listOf(stroke(2800f, 300f))) + val result = DiagramTextFilter.diagramBandLines(strokesByLine, emptyList(), bbox, yTol) + assertFalse("Text 2500 px from diagram right edge should not be a band note", 0 in result) + } + + @Test fun textJustBeyondDefaultColumnWidth_notBandNote() { + // x = bRight(300) + 4·yTol(50) + 1 = 501 → just outside the default column → not a band note + val strokesByLine = mapOf(0 to listOf(stroke(501f, 300f))) + val result = DiagramTextFilter.diagramBandLines(strokesByLine, emptyList(), bbox, yTol) + assertFalse("Stroke just outside default column should not be a band note", 0 in result) + } + + @Test fun textWithinDefaultColumnWidth_isBandNote() { + // x = 499 → inside default column (300 < 499 < 500) → IS a band note + val strokesByLine = mapOf(0 to listOf(stroke(499f, 300f))) + val result = DiagramTextFilter.diagramBandLines(strokesByLine, emptyList(), bbox, yTol) + assertTrue("Stroke inside default column (x=499 < limit=500) should be a band note", 0 in result) + } + + // ── Left-margin exclusion (Bug #7) ──────────────────────────────────────── + // + // The left-side band check was `cx < bLeft` — unlimited on the left. If the + // diagram sits in the right column, the entire normal writing area (which lies + // to the LEFT of the diagram) falls inside this half-plane. Every text line + // written beside the diagram in the standard left-column is wrongly suppressed. + // + // The user observes 4-5 "dead" lines: those are the writing lines that overlap + // the diagram's Y-extent while the strokes are in the normal left column. + // + // Fix: add a symmetric leftBandLimit = bLeft − yTolerance × 4. Only strokes + // within a narrow column immediately to the LEFT of the diagram are captured + // as left-side band notes; normal writing further left is ordinary text. + // + // With bbox x=[100..300] and yTol=50: + // leftBandLimit = 100 − 50×4 = −100 + // rightBandLimit = 300 + 50×4 = 500 + + @Test fun textFarLeftOfDiagram_defaultLimit_notBandNote() { + // Stroke at x=-200 is far to the left of the diagram (bLeft=100). + // Default leftBandLimit = 100 − 4×50 = −100. cx=−200 < −100 → NOT a band note. + // FAILS currently: no leftBandLimit → any cx < bLeft qualifies. + val strokesByLine = mapOf(0 to listOf(stroke(-200f, 300f))) + val result = DiagramTextFilter.diagramBandLines(strokesByLine, emptyList(), bbox, yTol) + assertFalse("Text far to the left of the diagram should not be a band note", 0 in result) + } + + @Test fun textJustInsideLeftBandLimit_isBandNote() { + // x = bLeft(100) − 4×yTol(50) + 1 = −99 → just inside the left column → IS a band note + val strokesByLine = mapOf(0 to listOf(stroke(-99f, 300f))) + val result = DiagramTextFilter.diagramBandLines(strokesByLine, emptyList(), bbox, yTol) + assertTrue("Stroke inside left column (x=−99 > limit=−100) should be a band note", 0 in result) + } + + @Test fun textJustOutsideLeftBandLimit_notBandNote() { + // x = bLeft(100) − 4×yTol(50) − 1 = −101 → just beyond the left column → NOT a band note + val strokesByLine = mapOf(0 to listOf(stroke(-101f, 300f))) + val result = DiagramTextFilter.diagramBandLines(strokesByLine, emptyList(), bbox, yTol) + assertFalse("Stroke just outside left column (x=−101 < limit=−100) should not be a band note", 0 in result) + } + + @Test fun explicitUnlimitedLeftBand_farLeftDetected() { + // Passing leftBandLimit = -MAX_VALUE restores the old unlimited-left behaviour. + val strokesByLine = mapOf(0 to listOf(stroke(-200f, 300f))) + val result = DiagramTextFilter.diagramBandLines( + strokesByLine, emptyList(), bbox, yTol, leftBandLimit = -Float.MAX_VALUE) + assertTrue("Explicit MIN_VALUE left limit: far-left stroke is still detected", 0 in result) + } +} diff --git a/app/src/test/java/com/writer/view/DiagramEraseTest.kt b/app/src/test/java/com/writer/view/DiagramEraseTest.kt new file mode 100644 index 0000000..6663571 --- /dev/null +++ b/app/src/test/java/com/writer/view/DiagramEraseTest.kt @@ -0,0 +1,311 @@ +package com.writer.view + +import com.writer.model.InkStroke +import com.writer.model.StrokePoint +import com.writer.model.StrokeType +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Tests for scratch-out overlap detection in diagram areas. + * + * WritingCoordinator.onScratchOut finds overlapping strokes by checking if + * any stored stroke point falls inside the scratch-out bounding box. + * For geometric arrows (2 endpoints only), segment intersection is also checked. + */ +class DiagramEraseTest { + + /** + * Replicates the overlap check from WritingCoordinator.onScratchOut. + * Checks point containment for all strokes. Segment intersection is only + * applied to arrow/line strokes (sparse points) — applying it to shape + * outlines would cause nearby shapes to be erased when targeting an arrow. + */ + private fun findOverlappingStrokes( + strokes: List, + left: Float, top: Float, right: Float, bottom: Float + ): List = strokes.filter { stroke -> + stroke.points.any { pt -> pt.x in left..right && pt.y in top..bottom } + || stroke.strokeType.isConnector + && ScratchOutDetection.strokeIntersectsRect(stroke.points, left, top, right, bottom) + } + + @Test fun scratchOut_overArrowMidpoint_findsGeometricArrow() { + val arrow = InkStroke( + strokeId = "arrow1", + points = listOf( + StrokePoint(100f, 300f, 0.5f, 0L), + StrokePoint(500f, 300f, 0.5f, 0L) + ), + isGeometric = true, + strokeType = StrokeType.ARROW_HEAD + ) + + val overlapping = findOverlappingStrokes( + listOf(arrow), + left = 250f, top = 280f, right = 350f, bottom = 320f + ) + + assertTrue( + "Scratch-out over arrow midpoint should find the arrow stroke", + overlapping.any { it.strokeId == "arrow1" } + ) + } + + @Test fun scratchOut_overArrowMidpoint_findsDiagonalArrow() { + val arrow = InkStroke( + strokeId = "arrow2", + points = listOf( + StrokePoint(100f, 100f, 0.5f, 0L), + StrokePoint(500f, 500f, 0.5f, 0L) + ), + isGeometric = true, + strokeType = StrokeType.ARROW_HEAD + ) + + val overlapping = findOverlappingStrokes( + listOf(arrow), + left = 270f, top = 270f, right = 330f, bottom = 330f + ) + + assertTrue( + "Scratch-out over diagonal arrow midpoint should find the arrow", + overlapping.any { it.strokeId == "arrow2" } + ) + } + + @Test fun scratchOut_overLineMidpoint_findsGeometricLine() { + val line = InkStroke( + strokeId = "line1", + points = listOf( + StrokePoint(100f, 300f, 0.5f, 0L), + StrokePoint(500f, 300f, 0.5f, 0L) + ), + isGeometric = true, + strokeType = StrokeType.LINE + ) + + val overlapping = findOverlappingStrokes( + listOf(line), + left = 250f, top = 280f, right = 350f, bottom = 320f + ) + + assertTrue( + "Scratch-out over line midpoint should find the line stroke", + overlapping.any { it.strokeId == "line1" } + ) + } + + @Test fun scratchOut_overSnappedArrowMidpoint_findsArrow() { + val arrow = InkStroke( + strokeId = "snappedArrow", + points = listOf( + StrokePoint(300f, 200f, 0.5f, 0L), + StrokePoint(600f, 200f, 0.5f, 0L) + ), + isGeometric = true, + strokeType = StrokeType.ARROW_HEAD + ) + + val overlapping = findOverlappingStrokes( + listOf(arrow), + left = 420f, top = 190f, right = 480f, bottom = 210f + ) + + assertTrue( + "Scratch-out over magnetically-snapped arrow midpoint should find it", + overlapping.any { it.strokeId == "snappedArrow" } + ) + } + + @Test fun scratchOut_overFreehandArrowMidpoint_findsArrow() { + val arrow = InkStroke( + strokeId = "freehandArrow", + points = listOf( + StrokePoint(100f, 300f, 0.5f, 0L), + StrokePoint(200f, 300f, 0.5f, 0L), + StrokePoint(300f, 300f, 0.5f, 0L), + StrokePoint(400f, 300f, 0.5f, 0L), + StrokePoint(500f, 300f, 0.5f, 0L) + ), + isGeometric = false, + strokeType = StrokeType.ARROW_HEAD + ) + + val overlapping = findOverlappingStrokes( + listOf(arrow), + left = 250f, top = 280f, right = 350f, bottom = 320f + ) + + assertTrue( + "Scratch-out between stored points of an arrow should find it via segment intersection", + overlapping.any { it.strokeId == "freehandArrow" } + ) + } + + @Test fun scratchOut_overArrowNearNode_doesNotCatchNode() { + val nodeStroke = InkStroke( + strokeId = "node1", + points = listOf( + StrokePoint(100f, 100f, 0.5f, 0L), + StrokePoint(300f, 100f, 0.5f, 0L), + StrokePoint(300f, 300f, 0.5f, 0L), + StrokePoint(100f, 300f, 0.5f, 0L), + StrokePoint(100f, 100f, 0.5f, 0L) + ), + isGeometric = true, + strokeType = StrokeType.RECTANGLE + ) + val arrowStroke = InkStroke( + strokeId = "arrow1", + points = listOf( + StrokePoint(300f, 200f, 0.5f, 0L), + StrokePoint(500f, 200f, 0.5f, 0L) + ), + isGeometric = true, + strokeType = StrokeType.ARROW_HEAD + ) + + val overlapping = findOverlappingStrokes( + listOf(nodeStroke, arrowStroke), + left = 290f, top = 190f, right = 360f, bottom = 210f + ) + + assertTrue( + "Arrow should be found (its segment crosses the scratch region)", + overlapping.any { it.strokeId == "arrow1" } + ) + assertFalse( + "Node should NOT be found (scratch targets the arrow, not the shape)", + overlapping.any { it.strokeId == "node1" } + ) + } + + @Test fun scratchOut_missesArrowCompletely_doesNotFind() { + val arrow = InkStroke( + strokeId = "arrow3", + points = listOf( + StrokePoint(100f, 300f, 0.5f, 0L), + StrokePoint(500f, 300f, 0.5f, 0L) + ), + isGeometric = true, + strokeType = StrokeType.ARROW_HEAD + ) + + val overlapping = findOverlappingStrokes( + listOf(arrow), + left = 100f, top = 500f, right = 500f, bottom = 520f + ) + + assertFalse( + "Scratch-out far from arrow should not find it", + overlapping.any { it.strokeId == "arrow3" } + ) + } + + @Test fun scratchOutGesture_overRectangleStroke_detectsAndFindsOverlap() { + val rectangle = InkStroke( + strokeId = "rect1", + points = listOf( + StrokePoint(100f, 100f, 0.5f, 0L), + StrokePoint(300f, 100f, 0.5f, 0L), + StrokePoint(300f, 250f, 0.5f, 0L), + StrokePoint(100f, 250f, 0.5f, 0L), + StrokePoint(100f, 100f, 0.5f, 0L) + ), + isGeometric = true, + strokeType = StrokeType.RECTANGLE + ) + + val scratchXs = floatArrayOf(90f, 310f, 90f, 310f, 90f) + val scratchYRange = 25f + val lineSpacing = 118f + + assertTrue("Zigzag should be detected as scratch-out", + ScratchOutDetection.detect(scratchXs, scratchYRange, lineSpacing)) + + val left = 90f; val top = 90f; val right = 310f; val bottom = 115f + val overlapping = findOverlappingStrokes(listOf(rectangle), left, top, right, bottom) + assertTrue("Rectangle should be found under scratch-out", + overlapping.any { it.strokeId == "rect1" }) + } + + // ── Scratch-out in non-diagram (text) areas ───────────────────────────── + + @Test fun scratchOut_overFreehandTextStrokes_detectsAndFindsOverlap() { + // Freehand handwriting strokes in a text area (not a diagram). + // Scratch-out should erase these just like diagram strokes. + val stroke1 = InkStroke( + strokeId = "text1", + points = listOf( + StrokePoint(100f, 200f, 0.5f, 0L), + StrokePoint(120f, 210f, 0.5f, 10L), + StrokePoint(140f, 195f, 0.5f, 20L), + StrokePoint(160f, 205f, 0.5f, 30L), + StrokePoint(180f, 200f, 0.5f, 40L) + ), + isGeometric = false, + strokeType = StrokeType.FREEHAND + ) + val stroke2 = InkStroke( + strokeId = "text2", + points = listOf( + StrokePoint(200f, 200f, 0.5f, 0L), + StrokePoint(220f, 215f, 0.5f, 10L), + StrokePoint(240f, 190f, 0.5f, 20L), + StrokePoint(260f, 200f, 0.5f, 30L) + ), + isGeometric = false, + strokeType = StrokeType.FREEHAND + ) + + // Scratch-out zigzag over both strokes + val scratchXs = floatArrayOf(80f, 280f, 80f, 280f, 80f) + val scratchYRange = 20f + val lineSpacing = 118f + + assertTrue("Zigzag over text strokes should be detected as scratch-out", + ScratchOutDetection.detect(scratchXs, scratchYRange, lineSpacing)) + + val left = 80f; val top = 185f; val right = 280f; val bottom = 220f + val overlapping = findOverlappingStrokes( + listOf(stroke1, stroke2), left, top, right, bottom + ) + assertTrue("First text stroke should be found", + overlapping.any { it.strokeId == "text1" }) + assertTrue("Second text stroke should be found", + overlapping.any { it.strokeId == "text2" }) + } + + @Test fun scratchOut_overTextStroke_doesNotAffectDistantStrokes() { + // Scratch-out over one text stroke should not catch strokes on other lines. + val targetStroke = InkStroke( + strokeId = "target", + points = listOf( + StrokePoint(100f, 200f, 0.5f, 0L), + StrokePoint(200f, 205f, 0.5f, 10L) + ), + isGeometric = false, + strokeType = StrokeType.FREEHAND + ) + val distantStroke = InkStroke( + strokeId = "distant", + points = listOf( + StrokePoint(100f, 500f, 0.5f, 0L), + StrokePoint(200f, 505f, 0.5f, 10L) + ), + isGeometric = false, + strokeType = StrokeType.FREEHAND + ) + + val left = 80f; val top = 190f; val right = 220f; val bottom = 215f + val overlapping = findOverlappingStrokes( + listOf(targetStroke, distantStroke), left, top, right, bottom + ) + assertTrue("Target stroke should be found", + overlapping.any { it.strokeId == "target" }) + assertFalse("Distant stroke should NOT be found", + overlapping.any { it.strokeId == "distant" }) + } +} diff --git a/app/src/test/java/com/writer/view/DiagramNodeSnapTest.kt b/app/src/test/java/com/writer/view/DiagramNodeSnapTest.kt new file mode 100644 index 0000000..3c056be --- /dev/null +++ b/app/src/test/java/com/writer/view/DiagramNodeSnapTest.kt @@ -0,0 +1,496 @@ +package com.writer.view + +import android.graphics.RectF +import com.writer.model.DiagramNode +import com.writer.model.StrokeType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.math.abs +import kotlin.math.hypot +import kotlin.math.max + +/** + * Tests for [DiagramNodeSnap]: magnetic snap of arrow endpoints to diagram-node perimeters. + * + * All geometry tests use the `*Raw` internal functions that accept plain floats, + * avoiding the android.graphics.RectF stub issue in JVM unit tests. + * + * ── Bug 2 regression ───────────────────────────────────────────────────────────────────── + * When both arrow endpoints are on opposite sides of the *same* node edge (one just outside, + * one just inside), [DiagramNodeSnap.nearestPerimeterPointRaw] maps them to the **same** + * perimeter coordinate. The resulting arrow has a zero-length line — only the arrowhead + * is visible at the node. + * + * [DiagramNodeSnap.snapArrowEndpointsRaw] must guard against this by leaving the FROM + * endpoint unsnapped when snapping would collapse both ends to the same point. + */ +class DiagramNodeSnapTest { + + companion object { + private const val LS = 118f + private val THRESHOLD = 1.5f * LS // 177 px — same as HandwritingCanvasView + + /** Build a simple rectangle DiagramNode using raw bounds (avoids RectF stub). */ + private fun rectNodeRaw( + left: Float, top: Float, right: Float, bottom: Float, id: String = "n1" + ): Triple = + Triple(id, StrokeType.RECTANGLE, floatArrayOf(left, top, right, bottom)) + + /** Nearest perimeter point on a rectangle defined by raw bounds. */ + private fun perimRaw(px: Float, py: Float, bounds: FloatArray): Pair = + DiagramNodeSnap.nearestPerimeterPointRaw( + px, py, bounds[0], bounds[1], bounds[2], bounds[3], StrokeType.RECTANGLE + ) + + /** distToBbox using raw bounds array [left, top, right, bottom]. */ + private fun distRaw(px: Float, py: Float, bounds: FloatArray): Float = + DiagramNodeSnap.distToBboxRaw(px, py, bounds[0], bounds[1], bounds[2], bounds[3]) + + // Convenience: create a Map for snapArrowEndpointsRaw. + // DiagramNode is used only as a carrier here; its bounds are set via public fields + // (field writes work even in JVM unit tests with isReturnDefaultValues = true). + private fun makeNodes(vararg defs: Triple): Map { + return defs.associate { (id, type, b) -> + val bounds = RectF() + bounds.left = b[0]; bounds.top = b[1]; bounds.right = b[2]; bounds.bottom = b[3] + id to DiagramNode(id, type, bounds) + } + } + } + + // ── nearestPerimeterPointRaw: basic behaviour ───────────────────────────────────────── + + @Test fun outsideLeftEdge_snapsToLeftEdge() { + val b = floatArrayOf(100f, 100f, 300f, 200f) + val (x, y) = perimRaw(50f, 150f, b) + assertEquals(100f, x, 0.01f) + assertEquals(150f, y, 0.01f) + } + + @Test fun outsideRightEdge_snapsToRightEdge() { + val b = floatArrayOf(100f, 100f, 300f, 200f) + val (x, y) = perimRaw(350f, 150f, b) + assertEquals(300f, x, 0.01f) + assertEquals(150f, y, 0.01f) + } + + @Test fun insideNearLeftEdge_snapsToLeftEdge() { + val b = floatArrayOf(100f, 100f, 300f, 200f) + val (x, y) = perimRaw(105f, 150f, b) + assertEquals(100f, x, 0.01f) + assertEquals(150f, y, 0.01f) + } + + @Test fun insideNearRightEdge_snapsToRightEdge() { + val b = floatArrayOf(100f, 100f, 300f, 200f) + val (x, y) = perimRaw(295f, 150f, b) + assertEquals(300f, x, 0.01f) + assertEquals(150f, y, 0.01f) + } + + /** + * Documents the root cause of Bug 2: + * a point 1 px *outside* the left edge and a point 1 px *inside* the left edge + * both project to the exact same perimeter coordinate (left, py). + * + * This test currently PASSES — it is here to document the raw geometric fact. + * The fix lives in [snapArrowEndpointsRaw], not in [nearestPerimeterPointRaw]. + */ + @Test fun justOutsideAndJustInsideSameEdge_documentsBug_samePerimeterPoint() { + val b = floatArrayOf(100f, 100f, 300f, 200f) + val outside = perimRaw(99f, 150f, b) // 1 px outside left edge + val inside = perimRaw(101f, 150f, b) // 1 px inside left edge + // Both collapse to (100, 150) — the root cause of the zero-length arrow bug. + assertEquals( + "Both sides of edge map to same perimeter point (Bug 2 root cause)", + outside, inside + ) + } + + // ── snapArrowEndpointsRaw: Bug 2 failing tests ──────────────────────────────────────── + + /** + * BUG 2 — CURRENTLY FAILS. + * + * When the user draws a short arrow that crosses one edge of a node (start just outside, + * end just inside), both raw perimeter snaps produce the same coordinate → zero-length + * line → only the arrowhead renders at the node. + * + * [snapArrowEndpointsRaw] must detect the degenerate collapse and leave the FROM endpoint + * at its original position, preserving a visible arrow line. + */ + @Test fun arrowCrossingNodeEdge_notDegenerate() { + val nodes = makeNodes(Triple("n1", StrokeType.RECTANGLE, floatArrayOf(100f, 100f, 300f, 200f))) + + val (from, to, _) = DiagramNodeSnap.snapArrowEndpointsRaw( + fromPx = 99f, fromPy = 150f, // 1 px outside left edge + toPx = 101f, toPy = 150f, // 1 px inside left edge + nodes = nodes, threshold = THRESHOLD + ) + assertNotEquals("Arrow endpoints must not collapse to the same point (Bug 2)", from, to) + } + + /** + * BUG 2 variant — start well outside, end just inside same edge. + * Even a slightly-longer stroke that barely enters the node must have a visible line. + */ + @Test fun arrowBarelyEnteringNode_endNotCollapsingToStart() { + val nodes = makeNodes(Triple("n1", StrokeType.RECTANGLE, floatArrayOf(100f, 100f, 300f, 200f))) + + val (from, to, _) = DiagramNodeSnap.snapArrowEndpointsRaw( + fromPx = 80f, fromPy = 150f, // 20 px outside left edge (within threshold) + toPx = 110f, toPy = 150f, // 10 px inside left edge + nodes = nodes, threshold = THRESHOLD + ) + assertNotEquals( + "Arrow endpoints must differ even when start and end are both near the same edge (Bug 2)", + from, to + ) + } + + // ── snapArrowEndpointsRaw: normal (non-degenerate) cases must still work ───────────── + + @Test fun arrowBetweenTwoNodes_snapsCorrectly() { + // nodeA at left (0–200 × 100–200), nodeB at right (300–500 × 100–200). + // Use points close to the shared gap so they clearly snap to the facing edges. + val nodes = makeNodes( + Triple("a", StrokeType.RECTANGLE, floatArrayOf( 0f, 100f, 200f, 200f)), + Triple("b", StrokeType.RECTANGLE, floatArrayOf(300f, 100f, 500f, 200f)) + ) + + val (from, to, ids) = DiagramNodeSnap.snapArrowEndpointsRaw( + fromPx = 180f, fromPy = 150f, // inside nodeA, 20 px from right edge → snaps to (200, 150) + toPx = 320f, toPy = 150f, // inside nodeB, 20 px from left edge → snaps to (300, 150) + nodes = nodes, threshold = THRESHOLD + ) + assertNotEquals("Arrow between two nodes must have distinct endpoints", from, to) + assertEquals("FROM should snap to nodeA right edge", Pair(200f, 150f), from) + assertEquals("TO should snap to nodeB left edge", Pair(300f, 150f), to) + assertEquals("fromNodeId should be 'a'", "a", ids.first) + assertEquals("toNodeId should be 'b'", "b", ids.second) + } + + @Test fun arrowFromUnconnectedPointToNode_fromUnsnapped() { + // FROM is 250 px from nodeB (beyond threshold 177) → unsnapped. + val nodes = makeNodes( + Triple("b", StrokeType.RECTANGLE, floatArrayOf(300f, 100f, 500f, 200f)) + ) + + val (from, to, ids) = DiagramNodeSnap.snapArrowEndpointsRaw( + fromPx = 50f, fromPy = 150f, // far left, distToBbox = 250 px > threshold + toPx = 400f, toPy = 150f, // inside nodeB + nodes = nodes, threshold = THRESHOLD + ) + assertEquals("Unconnected FROM should stay at original position", Pair(50f, 150f), from) + assertNotEquals("TO should be snapped to node perimeter", Pair(400f, 150f), to) + assertNull("fromNodeId should be null (unsnapped)", ids.first) + assertNotNull("toNodeId should be non-null", ids.second) + } + + @Test fun arrowFromNodeToEmptySpace_toUnsnapped() { + val nodes = makeNodes( + Triple("a", StrokeType.RECTANGLE, floatArrayOf(0f, 100f, 200f, 200f)) + ) + + val (from, to, ids) = DiagramNodeSnap.snapArrowEndpointsRaw( + fromPx = 100f, fromPy = 150f, // inside nodeA + toPx = 800f, toPy = 150f, // far right, no node nearby + nodes = nodes, threshold = THRESHOLD + ) + assertNotNull("fromNodeId should be set", ids.first) + assertNull("toNodeId should be null (no nearby node)", ids.second) + assertEquals("TO should stay at original position", Pair(800f, 150f), to) + } + + // ── distToBboxRaw ───────────────────────────────────────────────────────────────────── + + @Test fun distToBbox_pointInside_returnsZero() { + assertEquals(0f, distRaw(100f, 50f, floatArrayOf(0f, 0f, 200f, 100f)), 0.01f) + } + + @Test fun distToBbox_pointOutsideLeft_returnsHorizontalDist() { + assertEquals(50f, distRaw(50f, 100f, floatArrayOf(100f, 0f, 300f, 200f)), 0.01f) + } + + @Test fun distToBbox_pointOutsideCorner_returnsDiagonalDist() { + val expected = hypot(50f, 50f) + assertEquals(expected, distRaw(150f, 150f, floatArrayOf(0f, 0f, 100f, 100f)), 0.01f) + } + + // ── detectSelfLoop ───────────────────────────────────────────────────────────────────── + + @Test fun selfLoop_bothEndpointsNearSameNode_returnsNodeId() { + val nodes = makeNodes(Triple("n1", StrokeType.RECTANGLE, floatArrayOf(100f, 100f, 300f, 300f))) + // Both endpoints near the node, pathLength long enough and curved + val result = DiagramNodeSnap.detectSelfLoop( + firstX = 305f, firstY = 200f, // just outside right edge + lastX = 200f, lastY = 95f, // just above top edge + pathLength = 600f, // well above minPathLength and curved (endpointDist ~150) + nodes = nodes, threshold = THRESHOLD, minPathLengthPx = 1.5f * LS + ) + assertEquals("n1", result) + } + + @Test fun selfLoop_endpointsNearDifferentNodes_returnsNull() { + val nodes = makeNodes( + Triple("a", StrokeType.RECTANGLE, floatArrayOf(0f, 100f, 100f, 200f)), + Triple("b", StrokeType.RECTANGLE, floatArrayOf(400f, 100f, 500f, 200f)) + ) + val result = DiagramNodeSnap.detectSelfLoop( + firstX = 50f, firstY = 150f, // near node a + lastX = 450f, lastY = 150f, // near node b + pathLength = 800f, + nodes = nodes, threshold = THRESHOLD, minPathLengthPx = 1.5f * LS + ) + assertNull("Different nodes should return null", result) + } + + @Test fun selfLoop_pathTooShort_returnsNull() { + val nodes = makeNodes(Triple("n1", StrokeType.RECTANGLE, floatArrayOf(100f, 100f, 300f, 300f))) + val result = DiagramNodeSnap.detectSelfLoop( + firstX = 305f, firstY = 200f, + lastX = 200f, lastY = 95f, + pathLength = 50f, // way below minPathLength + nodes = nodes, threshold = THRESHOLD, minPathLengthPx = 1.5f * LS + ) + assertNull("Short path should return null", result) + } + + @Test fun selfLoop_notCurvedEnough_returnsNull() { + val nodes = makeNodes(Triple("n1", StrokeType.RECTANGLE, floatArrayOf(100f, 100f, 300f, 300f))) + // Endpoints 100px apart, pathLength 180 → ratio 1.8 which is ≤ 2.0 + val result = DiagramNodeSnap.detectSelfLoop( + firstX = 305f, firstY = 200f, + lastX = 205f, lastY = 200f, // ~100px apart + pathLength = 180f, // ratio = 1.8 ≤ 2.0 + nodes = nodes, threshold = THRESHOLD, minPathLengthPx = 1.5f * LS + ) + assertNull("Insufficiently curved path should return null", result) + } + + @Test fun selfLoop_scratchOutZigzagNearNode_shouldReject() { + // A scratch-out zigzag whose endpoints happen to be near the same node. + // The zigzag has ≥2 X-reversals — a clear scratch-out, not a self-loop. + // detectSelfLoop currently accepts this because it only checks endpoints + // and path curvature, not X-reversal count. + val nodes = makeNodes(Triple("n1", StrokeType.RECTANGLE, floatArrayOf(100f, 100f, 300f, 300f))) + + // Zigzag: starts near right edge (305,200), zigzags left-right trending + // toward top edge, ends near top edge (200,95). + // X positions: 305→250→310→245→300→230→290→215→275→200 + // = 8 X-direction reversals → definite scratch-out + // Path length of zigzag ≈ 600px, endpoint distance ≈ 148px → ratio 4.0 > 2.0 + val result = DiagramNodeSnap.detectSelfLoop( + firstX = 305f, firstY = 200f, + lastX = 200f, lastY = 95f, + pathLength = 600f, + nodes = nodes, threshold = THRESHOLD, minPathLengthPx = 1.5f * LS, + xReversals = 8 + ) + assertNull("Scratch-out zigzag near node should NOT be detected as self-loop", result) + } + + @Test fun selfLoop_noNearbyNode_returnsNull() { + val nodes = makeNodes(Triple("n1", StrokeType.RECTANGLE, floatArrayOf(100f, 100f, 300f, 300f))) + val result = DiagramNodeSnap.detectSelfLoop( + firstX = 800f, firstY = 800f, + lastX = 810f, lastY = 790f, + pathLength = 600f, + nodes = nodes, threshold = THRESHOLD, minPathLengthPx = 1.5f * LS + ) + assertNull("No nearby node should return null", result) + } + + // ── perimeterPointFromDirectionRaw ────────────────────────────────────────────────────── + + @Test fun perimeterFromDirection_rayRight_hitsRightEdge() { + // Rectangle 100-300 x 100-200, center (200, 150) + val (x, y) = DiagramNodeSnap.perimeterPointFromDirectionRaw( + dx = 1f, dy = 0f, + left = 100f, top = 100f, right = 300f, bottom = 200f, + shapeType = StrokeType.RECTANGLE + ) + assertEquals(300f, x, 0.01f) + assertEquals(150f, y, 0.01f) + } + + @Test fun perimeterFromDirection_rayUp_hitsTopEdge() { + val (x, y) = DiagramNodeSnap.perimeterPointFromDirectionRaw( + dx = 0f, dy = -1f, + left = 100f, top = 100f, right = 300f, bottom = 200f, + shapeType = StrokeType.RECTANGLE + ) + assertEquals(200f, x, 0.01f) // centerX + assertEquals(100f, y, 0.01f) // top edge + } + + @Test fun perimeterFromDirection_twoDifferentDirections_differentPoints() { + val bounds = floatArrayOf(100f, 100f, 300f, 200f) + val p1 = DiagramNodeSnap.perimeterPointFromDirectionRaw( + 1f, 0f, bounds[0], bounds[1], bounds[2], bounds[3], StrokeType.RECTANGLE + ) + val p2 = DiagramNodeSnap.perimeterPointFromDirectionRaw( + 0f, -1f, bounds[0], bounds[1], bounds[2], bounds[3], StrokeType.RECTANGLE + ) + assertNotEquals("Different directions should give different perimeter points", p1, p2) + } + + @Test fun perimeterFromDirection_zeroDirection_ellipse_mustNotReturnCenter() { + // When consecutive stylus points are identical, direction is (0,0). + // The function must NOT return the center — it should return a valid perimeter point. + // Ellipse (circle) 100-300 x 100-300, center (200, 200), radius 100 + val (x, y) = DiagramNodeSnap.perimeterPointFromDirectionRaw( + dx = 0f, dy = 0f, + left = 100f, top = 100f, right = 300f, bottom = 300f, + shapeType = StrokeType.ELLIPSE + ) + val cx = 200f; val cy = 200f + val distFromCenter = hypot(x - cx, y - cy) + // Must be on the perimeter (radius 100), not at center + assertEquals( + "Zero direction should still return a perimeter point, not center", + 100f, distFromCenter, 1f + ) + } + + @Test fun perimeterFromDirection_zeroDirection_rectangle_mustNotReturnCenter() { + val (x, y) = DiagramNodeSnap.perimeterPointFromDirectionRaw( + dx = 0f, dy = 0f, + left = 100f, top = 100f, right = 300f, bottom = 200f, + shapeType = StrokeType.RECTANGLE + ) + val cx = 200f; val cy = 150f + // Must be on a perimeter edge, not at center + val onPerimeter = x == 100f || x == 300f || y == 100f || y == 200f + assertTrue("Zero direction should return a perimeter point, not center ($x, $y)", onPerimeter) + } + + // ── generateSelfLoopArc ───────────────────────────────────────────────────────────────── + + @Test fun selfLoopArc_startsAtStartPoint() { + val arc = DiagramNodeSnap.generateSelfLoopArc( + startX = 300f, startY = 200f, // right edge midpoint + endX = 200f, endY = 100f, // top edge midpoint + cx = 200f, cy = 200f, + nodeWidth = 200f, nodeHeight = 200f + ) + assertEquals(300f, arc.first().first, 0.01f) + assertEquals(200f, arc.first().second, 0.01f) + } + + @Test fun selfLoopArc_endsAtEndPoint() { + val arc = DiagramNodeSnap.generateSelfLoopArc( + startX = 300f, startY = 200f, + endX = 200f, endY = 100f, + cx = 200f, cy = 200f, + nodeWidth = 200f, nodeHeight = 200f + ) + assertEquals(200f, arc.last().first, 0.01f) + assertEquals(100f, arc.last().second, 0.01f) + } + + @Test fun selfLoopArc_bulgesOutwardFromNode() { + // Node bbox: 100-300 x 100-300, center (200,200) + val arc = DiagramNodeSnap.generateSelfLoopArc( + startX = 300f, startY = 200f, // right edge + endX = 200f, endY = 100f, // top edge + cx = 200f, cy = 200f, + nodeWidth = 200f, nodeHeight = 200f + ) + // The arc should bulge significantly outward: its peak distance from center + // should exceed the node's half-width (perimeter distance) + val maxDist = arc.maxOf { (px, py) -> hypot(px - 200f, py - 200f) } + val halfSize = 100f // node half-width/height + assertTrue( + "Arc peak distance ($maxDist) should significantly exceed node half-size ($halfSize)", + maxDist > halfSize * 1.2f + ) + } + + @Test fun selfLoopArc_approachesStartFromOutside() { + val arc = DiagramNodeSnap.generateSelfLoopArc( + startX = 300f, startY = 200f, + endX = 200f, endY = 100f, + cx = 200f, cy = 200f, + nodeWidth = 200f, nodeHeight = 200f + ) + val d0 = hypot(arc[0].first - 200f, arc[0].second - 200f) + val d1 = hypot(arc[1].first - 200f, arc[1].second - 200f) + assertTrue("point[1] should be further from center than point[0]", d1 > d0) + } + + @Test fun selfLoopArc_approachesEndFromOutside() { + val arc = DiagramNodeSnap.generateSelfLoopArc( + startX = 300f, startY = 200f, + endX = 200f, endY = 100f, + cx = 200f, cy = 200f, + nodeWidth = 200f, nodeHeight = 200f + ) + val n = arc.size + val dLast = hypot(arc[n - 1].first - 200f, arc[n - 1].second - 200f) + val dPrev = hypot(arc[n - 2].first - 200f, arc[n - 2].second - 200f) + assertTrue("point[N-2] should be further from center than point[N-1]", dPrev > dLast) + } + + @Test fun selfLoopArc_generatesRequestedPointCount() { + val arc = DiagramNodeSnap.generateSelfLoopArc( + startX = 300f, startY = 200f, + endX = 200f, endY = 100f, + cx = 200f, cy = 200f, + nodeWidth = 200f, nodeHeight = 200f, + numPoints = 20 + ) + assertEquals(21, arc.size) // numPoints + 1 + } + + @Test fun selfLoopArc_worksForEllipseNode() { + // Ellipse node 100-300 x 100-300 — perimeter points on the ellipse + // Use right and top midpoints as snap points + val arc = DiagramNodeSnap.generateSelfLoopArc( + startX = 300f, startY = 200f, + endX = 200f, endY = 100f, + cx = 200f, cy = 200f, + nodeWidth = 200f, nodeHeight = 200f + ) + // Same properties should hold: starts at start, ends at end, bulges outward + assertEquals(300f, arc.first().first, 0.01f) + assertEquals(200f, arc.first().second, 0.01f) + assertEquals(200f, arc.last().first, 0.01f) + assertEquals(100f, arc.last().second, 0.01f) + val d0 = hypot(arc[0].first - 200f, arc[0].second - 200f) + val d1 = hypot(arc[1].first - 200f, arc[1].second - 200f) + assertTrue("Ellipse: point[1] further from center than point[0]", d1 > d0) + } + + @Test fun selfLoopArc_closeSnapPoints_stillProducesVisibleArc() { + // Start and end perimeter points are close together (both near top-right) + val arc = DiagramNodeSnap.generateSelfLoopArc( + startX = 300f, startY = 180f, // right edge, slightly above center + endX = 280f, endY = 100f, // top edge, slightly right of center + cx = 200f, cy = 200f, + nodeWidth = 200f, nodeHeight = 200f + ) + // Arc should still bulge significantly outward + val maxDist = arc.maxOf { (px, py) -> hypot(px - 200f, py - 200f) } + val nodeHalfDiag = hypot(100f, 100f) + assertTrue( + "Arc max distance ($maxDist) should exceed node half-diagonal ($nodeHalfDiag)", + maxDist > nodeHalfDiag + ) + } + + @Test fun perimeterFromDirection_ellipse_rayUpRight() { + // Ellipse 0-200 x 0-100, center (100, 50), a=100, b=50 + val (x, y) = DiagramNodeSnap.perimeterPointFromDirectionRaw( + dx = 1f, dy = 0f, + left = 0f, top = 0f, right = 200f, bottom = 100f, + shapeType = StrokeType.ELLIPSE + ) + assertEquals(200f, x, 0.01f) // right edge of ellipse + assertEquals(50f, y, 0.01f) // centerY + } +} diff --git a/app/src/test/java/com/writer/view/DiagramTextFilterTest.kt b/app/src/test/java/com/writer/view/DiagramTextFilterTest.kt new file mode 100644 index 0000000..566cee9 --- /dev/null +++ b/app/src/test/java/com/writer/view/DiagramTextFilterTest.kt @@ -0,0 +1,117 @@ +package com.writer.view + +import com.writer.model.InkStroke +import com.writer.model.StrokePoint +import com.writer.model.StrokeType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Tests for [DiagramTextFilter]: strokes inside diagram shapes must not + * appear in text paragraphs (they are shape labels, already shown in the diagram). + */ +class DiagramTextFilterTest { + + // Helper: a stroke whose single point sits at (cx, cy) + private fun stroke(cx: Float, cy: Float, type: StrokeType = StrokeType.FREEHAND): InkStroke { + val pt = StrokePoint(cx, cy, 1f, 0L) + return InkStroke(points = listOf(pt), strokeType = type) + } + + // Node bounds as [left, top, right, bottom] + private fun bounds(l: Float, t: Float, r: Float, b: Float) = floatArrayOf(l, t, r, b) + + // ── single line fully inside one node ────────────────────────────────────── + + @Test fun lineFullyInsideNode_isExcluded() { + val strokesByLine = mapOf( + 0 to listOf(stroke(50f, 50f)) + ) + val nodes = listOf(bounds(0f, 0f, 100f, 100f)) + val result = DiagramTextFilter.diagramOnlyLines(strokesByLine, nodes) + assertTrue("Line 0 should be excluded (inside node)", 0 in result) + } + + // ── single line fully outside all nodes ─────────────────────────────────── + + @Test fun lineOutsideAllNodes_isNotExcluded() { + val strokesByLine = mapOf( + 0 to listOf(stroke(200f, 200f)) + ) + val nodes = listOf(bounds(0f, 0f, 100f, 100f)) + val result = DiagramTextFilter.diagramOnlyLines(strokesByLine, nodes) + assertFalse("Line 0 should NOT be excluded (outside node)", 0 in result) + } + + // ── line has strokes both inside and outside → not excluded ─────────────── + + @Test fun lineMixed_isNotExcluded() { + val strokesByLine = mapOf( + 0 to listOf(stroke(50f, 50f), stroke(200f, 50f)) + ) + val nodes = listOf(bounds(0f, 0f, 100f, 100f)) + val result = DiagramTextFilter.diagramOnlyLines(strokesByLine, nodes) + assertFalse("Line with mixed strokes should NOT be excluded", 0 in result) + } + + // ── two separate lines: one inside, one outside ──────────────────────────── + + @Test fun twoLines_onlyInsideLineExcluded() { + val strokesByLine = mapOf( + 0 to listOf(stroke(50f, 50f)), // inside node + 1 to listOf(stroke(200f, 200f)) // outside + ) + val nodes = listOf(bounds(0f, 0f, 100f, 100f)) + val result = DiagramTextFilter.diagramOnlyLines(strokesByLine, nodes) + assertTrue("Line 0 inside node should be excluded", 0 in result) + assertFalse("Line 1 outside should NOT be excluded", 1 in result) + } + + // ── stroke on node boundary is treated as inside ────────────────────────── + + @Test fun strokeOnBoundary_isTreatedAsInside() { + val strokesByLine = mapOf( + 0 to listOf(stroke(0f, 0f)) // exactly on corner + ) + val nodes = listOf(bounds(0f, 0f, 100f, 100f)) + val result = DiagramTextFilter.diagramOnlyLines(strokesByLine, nodes) + assertTrue("Stroke on boundary should be treated as inside", 0 in result) + } + + // ── no nodes → nothing excluded ─────────────────────────────────────────── + + @Test fun noNodes_nothingExcluded() { + val strokesByLine = mapOf( + 0 to listOf(stroke(50f, 50f)) + ) + val result = DiagramTextFilter.diagramOnlyLines(strokesByLine, emptyList()) + assertTrue("With no nodes, result should be empty", result.isEmpty()) + } + + // ── non-freehand strokes on a line don't block exclusion ────────────────── + + @Test fun nonFreehandStrokesIgnored_lineStillExcluded() { + val strokesByLine = mapOf( + 0 to listOf( + stroke(50f, 50f, StrokeType.FREEHAND), // inside, freehand + stroke(50f, 50f, StrokeType.RECTANGLE) // inside, but not freehand → ignored + ) + ) + val nodes = listOf(bounds(0f, 0f, 100f, 100f)) + val result = DiagramTextFilter.diagramOnlyLines(strokesByLine, nodes) + assertTrue("Non-freehand strokes should not block exclusion", 0 in result) + } + + // ── line with only non-freehand strokes → excluded (no freehand = no text) ─ + + @Test fun lineWithNoFreehandStrokes_isExcluded() { + val strokesByLine = mapOf( + 0 to listOf(stroke(50f, 50f, StrokeType.RECTANGLE)) + ) + val nodes = listOf(bounds(0f, 0f, 100f, 100f)) + val result = DiagramTextFilter.diagramOnlyLines(strokesByLine, nodes) + assertTrue("Line with no freehand strokes has no text to show", 0 in result) + } +} diff --git a/app/src/test/java/com/writer/view/PreviewLayoutCalculatorTest.kt b/app/src/test/java/com/writer/view/PreviewLayoutCalculatorTest.kt new file mode 100644 index 0000000..db67ea9 --- /dev/null +++ b/app/src/test/java/com/writer/view/PreviewLayoutCalculatorTest.kt @@ -0,0 +1,405 @@ +package com.writer.view + +import com.writer.model.DiagramArea +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Tests for [PreviewLayoutCalculator] — the pure-logic engine that decides + * what appears in the preview and how it's sized for flush alignment with + * the canvas boundary. + * + * Uses concrete numbers based on the Go 7 device (density 1.875, line + * spacing 118px, top margin 36px) for readability, but the logic is + * density-independent. + */ +class PreviewLayoutCalculatorTest { + + companion object { + // Go 7 at 300 PPI (density = 1.875) + const val LINE_SPACING = 118f // 63dp * 1.875 + const val TOP_MARGIN = 36f // 19dp * 1.875 + const val STROKE_WIDTH = 5f + const val PARAGRAPH_SPACING = 22f // 12dp * 1.875 (approx) + const val CANVAS_WIDTH = 824f + const val TEXT_VIEW_WIDTH = 824f // same width, no gutter + } + + private fun lineTop(idx: Int) = TOP_MARGIN + idx * LINE_SPACING + private fun lineBottom(idx: Int) = lineTop(idx) + LINE_SPACING + private fun lineMid(idx: Int) = lineTop(idx) + LINE_SPACING / 2f + + // ── currentlyHiddenLines ──────────────────────────────────────────── + + @Test fun hidden_noScroll_nothingHidden() { + val hidden = PreviewLayoutCalculator.currentlyHiddenLines( + lineIndices = setOf(0, 1, 2), + scrollOffsetY = 0f, + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING + ) + assertTrue("No lines should be hidden at scroll=0", hidden.isEmpty()) + } + + @Test fun hidden_scrollPastFirstLine_firstLineHidden() { + val hidden = PreviewLayoutCalculator.currentlyHiddenLines( + lineIndices = setOf(0, 1, 2), + scrollOffsetY = lineBottom(0), + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING + ) + assertEquals(setOf(0), hidden) + } + + @Test fun hidden_scrollPartiallyPastLine_notHidden() { + // Scroll to just before line 0's bottom — not fully hidden + val hidden = PreviewLayoutCalculator.currentlyHiddenLines( + lineIndices = setOf(0, 1, 2), + scrollOffsetY = lineBottom(0) - 1f, + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING + ) + assertTrue("Line 0 should NOT be hidden when scroll is 1px short", hidden.isEmpty()) + } + + @Test fun hidden_scrollPastMultipleLines() { + val hidden = PreviewLayoutCalculator.currentlyHiddenLines( + lineIndices = setOf(0, 1, 2, 3, 4), + scrollOffsetY = lineBottom(2), + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING + ) + assertEquals(setOf(0, 1, 2), hidden) + } + + @Test fun hidden_onlyLinesWithStrokes_emptyGapsIgnored() { + // Lines 0 and 3 have strokes, lines 1 and 2 don't + val hidden = PreviewLayoutCalculator.currentlyHiddenLines( + lineIndices = setOf(0, 3), + scrollOffsetY = lineBottom(2), + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING + ) + // Line 0 is hidden, line 3 is not (its bottom is below scroll) + assertEquals(setOf(0), hidden) + } + + // ── notYetVisibleLines ────────────────────────────────────────────── + + @Test fun notYetVisible_usesLineMidpoint() { + // Scroll to exactly line 1's midpoint + val notVisible = PreviewLayoutCalculator.notYetVisibleLines( + lineIndices = setOf(0, 1, 2), + scrollOffsetY = lineMid(1), + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING + ) + // Line 0 midpoint is above scroll, line 1 midpoint is exactly at scroll (<=) + assertEquals(setOf(0, 1), notVisible) + } + + @Test fun notYetVisible_justBeforeMidpoint_notIncluded() { + val notVisible = PreviewLayoutCalculator.notYetVisibleLines( + lineIndices = setOf(0, 1), + scrollOffsetY = lineMid(1) - 1f, + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING + ) + assertEquals(setOf(0), notVisible) + } + + // ── diagramVisibilities ────────────────────────────────────────────── + + @Test fun diagram_notScrolledOff_notIncluded() { + val areas = listOf(DiagramArea(startLineIndex = 5, heightInLines = 3)) + val result = PreviewLayoutCalculator.diagramVisibilities( + areas = areas, + scrollOffsetY = lineTop(5) - 1f, // 1px before area top + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING, + strokeMaxYByArea = emptyMap(), + strokeWidthPadding = STROKE_WIDTH + ) + assertTrue("Diagram should not be included before any part scrolls off", result.isEmpty()) + } + + @Test fun diagram_1pxScrolledOff_included() { + val areas = listOf(DiagramArea(startLineIndex = 5, heightInLines = 3)) + val areaTop = lineTop(5) + val result = PreviewLayoutCalculator.diagramVisibilities( + areas = areas, + scrollOffsetY = areaTop + 1f, + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING, + strokeMaxYByArea = emptyMap(), + strokeWidthPadding = STROKE_WIDTH + ) + assertEquals(1, result.size) + assertEquals(1f, result[0].visibleHeight, 0.01f) + assertTrue("Should be partial", result[0].isPartial) + } + + @Test fun diagram_fullyScrolledOff_fullHeight() { + val areas = listOf(DiagramArea(startLineIndex = 2, heightInLines = 4)) + val areaTop = lineTop(2) + val fullHeight = 4 * LINE_SPACING + val result = PreviewLayoutCalculator.diagramVisibilities( + areas = areas, + scrollOffsetY = areaTop + fullHeight + 100f, // well past + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING, + strokeMaxYByArea = emptyMap(), + strokeWidthPadding = STROKE_WIDTH + ) + assertEquals(1, result.size) + assertEquals(fullHeight, result[0].visibleHeight, 0.01f) + assertFalse("Should not be partial when fully visible", result[0].isPartial) + } + + @Test fun diagram_croppedToStrokeBounds() { + val area = DiagramArea(startLineIndex = 3, heightInLines = 5) + val areaTop = lineTop(3) + val fullHeight = 5 * LINE_SPACING + // Strokes only reach 3 lines down (not all 5) + val strokeMaxY = areaTop + 3 * LINE_SPACING - 10f + + val result = PreviewLayoutCalculator.diagramVisibilities( + areas = listOf(area), + scrollOffsetY = areaTop + fullHeight, // fully scrolled + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING, + strokeMaxYByArea = mapOf(3 to strokeMaxY), + strokeWidthPadding = STROKE_WIDTH + ) + assertEquals(1, result.size) + val expectedStrokeBottom = strokeMaxY - areaTop + STROKE_WIDTH + assertEquals(expectedStrokeBottom, result[0].visibleHeight, 0.01f) + assertTrue("Cropped diagram should be partial", result[0].isPartial) + } + + @Test fun diagram_strokeBoundsExceedArea_clampedToFullHeight() { + val area = DiagramArea(startLineIndex = 0, heightInLines = 3) + val areaTop = lineTop(0) + val fullHeight = 3 * LINE_SPACING + // Stroke maxY is beyond the area bottom + val strokeMaxY = areaTop + fullHeight + 50f + + val result = PreviewLayoutCalculator.diagramVisibilities( + areas = listOf(area), + scrollOffsetY = areaTop + fullHeight, + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING, + strokeMaxYByArea = mapOf(0 to strokeMaxY), + strokeWidthPadding = STROKE_WIDTH + ) + assertEquals(fullHeight, result[0].visibleHeight, 0.01f) + } + + @Test fun diagram_partialScroll_croppedToScrolledOff() { + val area = DiagramArea(startLineIndex = 2, heightInLines = 5) + val areaTop = lineTop(2) + // Scroll only 1 line into the diagram + val scrolledOff = LINE_SPACING + // Strokes go all the way down + val strokeMaxY = areaTop + 4.5f * LINE_SPACING + + val result = PreviewLayoutCalculator.diagramVisibilities( + areas = listOf(area), + scrollOffsetY = areaTop + scrolledOff, + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING, + strokeMaxYByArea = mapOf(2 to strokeMaxY), + strokeWidthPadding = STROKE_WIDTH + ) + // visibleHeight = min(scrolledOff, strokeBottom) = scrolledOff (since strokes go further) + assertEquals(scrolledOff, result[0].visibleHeight, 0.01f) + } + + @Test fun diagram_noStrokes_usesFullHeight() { + val area = DiagramArea(startLineIndex = 1, heightInLines = 3) + val areaTop = lineTop(1) + val fullHeight = 3 * LINE_SPACING + + val result = PreviewLayoutCalculator.diagramVisibilities( + areas = listOf(area), + scrollOffsetY = areaTop + fullHeight, + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING, + strokeMaxYByArea = emptyMap(), // no strokes + strokeWidthPadding = STROKE_WIDTH + ) + assertEquals(fullHeight, result[0].visibleHeight, 0.01f) + } + + @Test fun diagram_multipleDiagrams_correctOrder() { + val areas = listOf( + DiagramArea(startLineIndex = 1, heightInLines = 2), + DiagramArea(startLineIndex = 5, heightInLines = 3), + DiagramArea(startLineIndex = 10, heightInLines = 2) + ) + // Scroll past the first two, not the third + val scrollOffsetY = lineTop(8) + + val result = PreviewLayoutCalculator.diagramVisibilities( + areas = areas, + scrollOffsetY = scrollOffsetY, + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING, + strokeMaxYByArea = emptyMap(), + strokeWidthPadding = STROKE_WIDTH + ) + assertEquals(2, result.size) + assertEquals(1, result[0].startLineIndex) + assertEquals(5, result[1].startLineIndex) + } + + // ── diagramRenderMetrics ──────────────────────────────────────────── + + @Test fun renderMetrics_sameWidth_scale1() { + val metrics = PreviewLayoutCalculator.diagramRenderMetrics( + visibleHeight = 200f, + fullHeight = 400f, + canvasWidth = CANVAS_WIDTH, + textViewWidth = CANVAS_WIDTH, // same width + paragraphSpacing = PARAGRAPH_SPACING + ) + assertEquals(1f, metrics.scale, 0.001f) + assertTrue(metrics.isPartial) + assertEquals(200f, metrics.renderedHeight, 0.01f) // no spacing for partial + assertEquals(400f + PARAGRAPH_SPACING, metrics.fullRenderedHeight, 0.01f) + } + + @Test fun renderMetrics_fullDiagram_includesSpacing() { + val metrics = PreviewLayoutCalculator.diagramRenderMetrics( + visibleHeight = 400f, + fullHeight = 400f, + canvasWidth = CANVAS_WIDTH, + textViewWidth = CANVAS_WIDTH, + paragraphSpacing = PARAGRAPH_SPACING + ) + assertFalse(metrics.isPartial) + assertEquals(400f + PARAGRAPH_SPACING, metrics.renderedHeight, 0.01f) + } + + @Test fun renderMetrics_partialDiagram_noSpacing() { + val metrics = PreviewLayoutCalculator.diagramRenderMetrics( + visibleHeight = 100f, + fullHeight = 400f, + canvasWidth = CANVAS_WIDTH, + textViewWidth = CANVAS_WIDTH, + paragraphSpacing = PARAGRAPH_SPACING + ) + assertTrue(metrics.isPartial) + assertEquals(100f, metrics.renderedHeight, 0.01f) // no spacing + } + + @Test fun renderMetrics_differentWidths_scalesCorrectly() { + val metrics = PreviewLayoutCalculator.diagramRenderMetrics( + visibleHeight = 200f, + fullHeight = 400f, + canvasWidth = 800f, + textViewWidth = 400f, // half width + paragraphSpacing = PARAGRAPH_SPACING + ) + assertEquals(0.5f, metrics.scale, 0.001f) + assertEquals(200f * 0.5f, metrics.renderedHeight, 0.01f) + } + + // ── trimLastItemHeight ────────────────────────────────────────────── + + @Test fun trimLast_textItem_usesLayoutHeight() { + val result = PreviewLayoutCalculator.trimLastItemHeight( + heightPx = 150f, // includes spacing + fullHeightPx = 150f, + paragraphSpacing = PARAGRAPH_SPACING, + isText = true, + textLayoutHeight = 128f // raw layout height without spacing + ) + assertEquals(128f, result, 0.01f) + } + + @Test fun trimLast_diagramItem_removesSpacing() { + val fullRendered = 400f + PARAGRAPH_SPACING + val result = PreviewLayoutCalculator.trimLastItemHeight( + heightPx = fullRendered, + fullHeightPx = fullRendered, + paragraphSpacing = PARAGRAPH_SPACING, + isText = false + ) + assertEquals(400f, result, 0.01f) + } + + @Test fun trimLast_partialDiagram_alreadySmall_noChange() { + // Partial diagram: heightPx = 100 (no spacing), fullHeightPx = 422 + val result = PreviewLayoutCalculator.trimLastItemHeight( + heightPx = 100f, + fullHeightPx = 400f + PARAGRAPH_SPACING, + paragraphSpacing = PARAGRAPH_SPACING, + isText = false + ) + // coerceAtMost(422 - 22) = coerceAtMost(400) → 100 (already smaller) + assertEquals(100f, result, 0.01f) + } + + // ── Complementary view invariants ─────────────────────────────────── + + @Test fun complementary_previewAndCanvasShowComplementaryContent() { + // For any scroll position, the hidden lines + visible lines should + // cover all lines with no overlap. + val allLines = setOf(0, 1, 2, 3, 4, 5) + val scrollOffsetY = lineBottom(2) // lines 0-2 hidden + + val hidden = PreviewLayoutCalculator.currentlyHiddenLines( + allLines, scrollOffsetY, TOP_MARGIN, LINE_SPACING + ) + val visible = allLines - hidden + + assertEquals("Hidden + visible should cover all lines", allLines, hidden + visible) + assertTrue("Hidden and visible should not overlap", hidden.intersect(visible).isEmpty()) + assertEquals(setOf(0, 1, 2), hidden) + assertEquals(setOf(3, 4, 5), visible) + } + + @Test fun complementary_diagramVisibleHeight_matchesScrolledOffPortion() { + // The preview shows exactly what's scrolled off — no more, no less. + val area = DiagramArea(startLineIndex = 3, heightInLines = 4) + val areaTop = lineTop(3) + val scrolledAmount = 2.5f * LINE_SPACING + val scrollOffsetY = areaTop + scrolledAmount + + val result = PreviewLayoutCalculator.diagramVisibilities( + areas = listOf(area), + scrollOffsetY = scrollOffsetY, + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING, + strokeMaxYByArea = emptyMap(), // no stroke cropping + strokeWidthPadding = STROKE_WIDTH + ) + + // Preview shows scrolledAmount, canvas shows the rest + assertEquals(scrolledAmount, result[0].visibleHeight, 0.01f) + val canvasRemaining = result[0].fullHeight - result[0].visibleHeight + assertEquals( + "Preview + canvas should equal full height", + result[0].fullHeight, + result[0].visibleHeight + canvasRemaining, + 0.01f + ) + } + + @Test fun complementary_partialDiagram_noSpacingGap() { + // A partial diagram should have zero spacing after it, ensuring the + // preview content is flush against the divider. + val metrics = PreviewLayoutCalculator.diagramRenderMetrics( + visibleHeight = 150f, + fullHeight = 400f, + canvasWidth = CANVAS_WIDTH, + textViewWidth = TEXT_VIEW_WIDTH, + paragraphSpacing = PARAGRAPH_SPACING + ) + // renderedHeight should NOT include spacing for partial diagrams + assertEquals(150f, metrics.renderedHeight, 0.01f) + } +} diff --git a/app/src/test/java/com/writer/view/ScratchOutDescenderTest.kt b/app/src/test/java/com/writer/view/ScratchOutDescenderTest.kt new file mode 100644 index 0000000..a7f3028 --- /dev/null +++ b/app/src/test/java/com/writer/view/ScratchOutDescenderTest.kt @@ -0,0 +1,225 @@ +package com.writer.view + +import com.writer.model.InkStroke +import com.writer.model.StrokePoint +import com.writer.recognition.LineSegmenter +import org.json.JSONObject +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import kotlin.math.hypot + +/** + * Regression test for false scratch-out detection when cursive text on one line + * has descenders that overlap with strokes on the next line. + * + * Uses a bug report fixture captured from a real session where "helped" strokes + * on line 6 falsely triggered scratch-out and erased "disappear" strokes on line 4. + * + * The fixture records every raw stroke before processing, plus the processing + * decisions. Stroke 26 and 29 were falsely classified as SCRATCH_OUT. + */ +class ScratchOutDescenderTest { + + private lateinit var segmenter: LineSegmenter + private lateinit var rawStrokes: List> + private lateinit var events: List> // (strokeIndex, type, detail) + private var lineSpacing = 0f + + @Before + fun setUp() { + val stream = javaClass.classLoader!!.getResourceAsStream("fixtures/bug-report-scratch-out.json") + ?: throw IllegalStateException("Fixture not found") + val json = JSONObject(stream.reader().readText()) + + lineSpacing = json.getJSONObject("device").getDouble("lineSpacing").toFloat() + val density = json.getJSONObject("device").getDouble("density").toFloat() + ScreenMetrics.init(density, smallestWidthDp = 674, widthPixels = 1264, heightPixels = 1680) + segmenter = LineSegmenter() + + // Load raw strokes + val strokesArr = json.getJSONArray("recentStrokes") + rawStrokes = (0 until strokesArr.length()).map { i -> + val strokeObj = strokesArr.getJSONObject(i) + val pointsArr = strokeObj.getJSONArray("points") + (0 until pointsArr.length()).map { j -> + val pt = pointsArr.getJSONObject(j) + StrokePoint( + pt.getDouble("x").toFloat(), + pt.getDouble("y").toFloat(), + pt.getDouble("pressure").toFloat(), + pt.getLong("timestamp") + ) + } + } + + // Load processing events + val eventsArr = json.getJSONArray("processingEvents") + events = (0 until eventsArr.length()).map { i -> + val ev = eventsArr.getJSONObject(i) + Triple(ev.getInt("strokeIndex"), ev.getString("type"), ev.getString("detail")) + } + } + + @Test + fun `fixture loads with strokes and events`() { + assertTrue("Should have strokes", rawStrokes.isNotEmpty()) + assertTrue("Should have events", events.isNotEmpty()) + } + + @Test + fun `fixture contains the false scratch-out events`() { + val scratchOuts = events.filter { it.second == "SCRATCH_OUT" } + assertTrue( + "Bug report should contain SCRATCH_OUT events (the bug we're testing)", + scratchOuts.isNotEmpty() + ) + } + + @Test + fun `false scratch-out strokes are detected by ScratchOutDetection`() { + // Verify that the strokes flagged as SCRATCH_OUT in the bug report + // actually pass ScratchOutDetection.detect() — confirming the detector + // is the source of the false positive + val scratchOutIndices = events.filter { it.second == "SCRATCH_OUT" }.map { it.first } + + for (idx in scratchOutIndices) { + if (idx >= rawStrokes.size) continue + val points = rawStrokes[idx] + val xs = FloatArray(points.size) { points[it].x } + val yRange = points.maxOf { it.y } - points.minOf { it.y } + val diagonal = hypot( + points.maxOf { it.x } - points.minOf { it.x }, + yRange + ) + val first = points.first(); val last = points.last() + val closeDist = hypot(last.x - first.x, last.y - first.y) + val isClosedLoop = diagonal > 0f && closeDist < ShapeSnapDetection.CLOSE_FRACTION * diagonal + + val detected = ScratchOutDetection.detect(xs, yRange, lineSpacing, isClosedLoop) + // This documents the current (buggy) behavior: + // These strokes ARE detected as scratch-outs even though they're normal text + assertTrue( + "Stroke $idx should be detected as scratch-out (confirming the bug exists)", + detected + ) + } + } + + /** + * Core regression test: replay strokes through the full pipeline. + * No stroke that was ADDED to the document on one line should be + * erased by a stroke on a different line. + */ + @Test + fun `replay should not erase strokes from different line`() { + val completedStrokes = mutableListOf() + + for ((idx, points) in rawStrokes.withIndex()) { + if (points.size < 2) continue + + val xs = FloatArray(points.size) { points[it].x } + val yRange = points.maxOf { it.y } - points.minOf { it.y } + val diagonal = hypot( + points.maxOf { it.x } - points.minOf { it.x }, + yRange + ) + val first = points.first(); val last = points.last() + val closeDist = hypot(last.x - first.x, last.y - first.y) + val isClosedLoop = diagonal > 0f && closeDist < ShapeSnapDetection.CLOSE_FRACTION * diagonal + + val isScratchOut = ScratchOutDetection.detect(xs, yRange, lineSpacing, isClosedLoop) + + if (isScratchOut && completedStrokes.isNotEmpty()) { + // Check what would be erased using stroke intersection + val scratchStroke = InkStroke(points = points) + val scratchLine = segmenter.getStrokeLineIndex(scratchStroke) + + val wouldErase = completedStrokes.filter { existing -> + ScratchOutDetection.strokesIntersect(points, existing.points) + } + + // The bug: strokes from a different line are being erased + for (erased in wouldErase) { + val erasedLine = segmenter.getStrokeLineIndex(erased) + assertFalse( + "Stroke $idx (line $scratchLine) should NOT erase stroke on line $erasedLine. " + + "This is the cross-line scratch-out bug.", + erasedLine != scratchLine + ) + } + } + + // Add stroke to completed (simulating normal add) + completedStrokes.add(InkStroke(points = points)) + } + } + + @Test + fun `strokes span multiple lines`() { + val lines = rawStrokes.filter { it.size >= 2 }.map { points -> + segmenter.getStrokeLineIndex(InkStroke(points = points)) + }.toSet() + assertTrue("Should span at least 2 lines, got $lines", lines.size >= 2) + } + + /** + * The bug report recorded SCRATCH_OUT events at strokes 26 and 29. + * With the current intersection guard, these strokes should NOT trigger + * cross-line erasure in a replay. This is a regression test — if the + * guard is removed, the replay test above would start failing. + */ + @Test + fun `scratch-out strokes from bug report do not cause cross-line erasure with intersection guard`() { + val completedStrokes = mutableListOf() + val scratchOutIndices = events.filter { it.second == "SCRATCH_OUT" }.map { it.first }.toSet() + var crossLineErasureAttempted = false + + for ((idx, points) in rawStrokes.withIndex()) { + if (points.size < 2) continue + + if (idx in scratchOutIndices) { + // This stroke was flagged as scratch-out in the original bug + val scratchLine = segmenter.getStrokeLineIndex(InkStroke(points = points)) + + // With intersection guard: check what would be erased + val wouldErase = completedStrokes.filter { existing -> + ScratchOutDetection.strokesIntersect(points, existing.points) + } + for (erased in wouldErase) { + val erasedLine = segmenter.getStrokeLineIndex(erased) + if (erasedLine != scratchLine) { + crossLineErasureAttempted = true + } + } + } + + completedStrokes.add(InkStroke(points = points)) + } + + assertFalse( + "Intersection guard should prevent cross-line erasure for the reported scratch-out strokes", + crossLineErasureAttempted + ) + } + + @Test + fun `descenders from line 4 overlap with line 6 Y range`() { + val line4Strokes = rawStrokes.filter { points -> + points.size >= 2 && segmenter.getStrokeLineIndex(InkStroke(points = points)) == 4 + } + val line6Strokes = rawStrokes.filter { points -> + points.size >= 2 && segmenter.getStrokeLineIndex(InkStroke(points = points)) == 6 + } + if (line4Strokes.isEmpty() || line6Strokes.isEmpty()) return + + val line4MaxY = line4Strokes.maxOf { points -> points.maxOf { it.y } } + val line6MinY = line6Strokes.minOf { points -> points.minOf { it.y } } + + assertTrue( + "Line 4 descenders ($line4MaxY) should overlap line 6 region ($line6MinY)", + line4MaxY > line6MinY + ) + } +} diff --git a/app/src/test/java/com/writer/view/ScratchOutDetectionTest.kt b/app/src/test/java/com/writer/view/ScratchOutDetectionTest.kt new file mode 100644 index 0000000..6494c2a --- /dev/null +++ b/app/src/test/java/com/writer/view/ScratchOutDetectionTest.kt @@ -0,0 +1,431 @@ +package com.writer.view + +import com.writer.model.InkStroke +import com.writer.model.StrokePoint +import com.writer.model.StrokeType +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for [ScratchOutDetection]. + * + * Uses a fixed line spacing of 118 px (standard device: 63 dp × 1.875 density). + * + * Geometry recap: + * MIN_REVERSALS = 2 → stroke must change X direction at least twice + * MIN_X_TRAVEL_SPANS = 1.5 → total |dx| ≥ 177 px at 118 px LS + * MAX_Y_DRIFT = 0.4 → yRange < 40% of total x-travel + */ +class ScratchOutDetectionTest { + + companion object { + private const val LS = 118f + private val X_THRESH get() = ScratchOutDetection.MIN_X_TRAVEL_SPANS * LS // ≈ 177 px + } + + // ── Strokes that SHOULD qualify ────────────────────────────────────────── + + @Test fun wideZigzag_detects() { + // 4 segments: right → left → right → left — clearly a scratch-out + val xs = zigzag(startX = 0f, segmentWidth = 60f, segments = 4) + assertTrue(ScratchOutDetection.detect(xs, yRange = 5f, lineSpacing = LS)) + } + + @Test fun minimalZigzag_exactlyThreeReversals_detects() { + // 4 segments → 3 reversals; total travel just above threshold + val segW = X_THRESH / 3f + 2f + val xs = zigzag(startX = 0f, segmentWidth = segW, segments = 4) + assertTrue(ScratchOutDetection.detect(xs, yRange = 2f, lineSpacing = LS)) + } + + @Test fun twoReversals_notDetected() { + // 3 segments → 2 reversals; below MIN_REVERSALS threshold of 3 + val segW = X_THRESH / 2f + 2f + val xs = zigzag(startX = 0f, segmentWidth = segW, segments = 3) + assertFalse(ScratchOutDetection.detect(xs, yRange = 2f, lineSpacing = LS)) + } + + @Test fun manyReversals_detects() { + val xs = zigzag(startX = 0f, segmentWidth = 50f, segments = 6) + assertTrue(ScratchOutDetection.detect(xs, yRange = 3f, lineSpacing = LS)) + } + + // ── Strokes that should NOT qualify ────────────────────────────────────── + + @Test fun singleDirection_noReversal_notDetected() { + val xs = floatArrayOf(0f, 50f, 100f, 200f, 300f) + assertFalse(ScratchOutDetection.detect(xs, yRange = 5f, lineSpacing = LS)) + } + + @Test fun oneReversal_notDetected() { + // Right then left — only 1 reversal (U-shape), not a scratch-out + val xs = floatArrayOf(0f, 100f, 200f, 100f, 0f) + assertFalse(ScratchOutDetection.detect(xs, yRange = 5f, lineSpacing = LS)) + } + + @Test fun twoReversals_tooNarrow_notDetected() { + // Zigzag with 2 reversals but total travel below threshold + val xs = zigzag(startX = 0f, segmentWidth = 5f, segments = 3) + assertFalse(ScratchOutDetection.detect(xs, yRange = 1f, lineSpacing = LS)) + } + + @Test fun twoReversals_tooWobbly_notDetected() { + // Wide zigzag but excessive vertical displacement + val xs = zigzag(startX = 0f, segmentWidth = 100f, segments = 4) + val totalXTravel = 100f * 4 // each segment is 100 px + // yRange = 200% of total x-travel → way over MAX_Y_DRIFT (0.4) + assertFalse(ScratchOutDetection.detect(xs, yRange = totalXTravel * 2f, lineSpacing = LS)) + } + + @Test fun tooFewPoints_notDetected() { + // Less than 4 points — cannot reliably detect reversals + assertFalse(ScratchOutDetection.detect(floatArrayOf(0f, 50f, 100f), yRange = 5f, lineSpacing = LS)) + } + + @Test fun emptyArray_notDetected() { + assertFalse(ScratchOutDetection.detect(floatArrayOf(), yRange = 0f, lineSpacing = LS)) + } + + // ── Boundary ───────────────────────────────────────────────────────────── + + @Test fun exactlyAtYDriftLimit_notDetected() { + // yRange == totalXTravel * MAX_Y_DRIFT — must be strictly less than + val xs = zigzag(startX = 0f, segmentWidth = 60f, segments = 4) + // total travel = 4 × 60 = 240 px; drift limit = 240 × 0.4 = 96 px + assertFalse(ScratchOutDetection.detect(xs, yRange = 240f * ScratchOutDetection.MAX_Y_DRIFT, lineSpacing = LS)) + } + + @Test fun justBelowYDriftLimit_detects() { + val xs = zigzag(startX = 0f, segmentWidth = 60f, segments = 4) + val totalXTravel = 60f * 4 + assertFalse(ScratchOutDetection.detect(xs, yRange = totalXTravel * ScratchOutDetection.MAX_Y_DRIFT, lineSpacing = LS)) + // one less px → should detect + assertTrue(ScratchOutDetection.detect(xs, yRange = totalXTravel * ScratchOutDetection.MAX_Y_DRIFT - 1f, lineSpacing = LS)) + } + + // ── Bug 1: closed-loop strokes must not trigger scratch-out ────────────── + // + // When the user draws a shape (e.g. a rounded-rectangle outline) around existing + // handwritten letters, the shape snap may fail if the stroke is too wobbly. + // checkPostStrokeScratchOut() then runs. A closed loop with multiple x-reversals + // satisfies all three scratch-out criteria (reversals ≥ 2, travel ≥ 177 px, low + // y-drift) and currently ERASES the letters inside — a false positive. + // + // The fix: ScratchOutDetection.detect() must return false whenever the stroke is + // a closed loop (stroke start ≈ stroke end relative to its own diagonal). + + /** + * BUG 1 — CURRENTLY FAILS. + * + * A stroke whose x-coordinate series returns to the same value as it started + * (xs.first() == xs.last()) with multiple x-reversals is detected as scratch-out. + * This can happen when the user draws a bumpy oval around text: + * — shape snap fails (stroke too irregular for any shape detector) + * — scratch-out sees ≥ 2 reversals + ≥ 177 px travel + low y-drift → true + * — interior letters are erased + * + * Correct behaviour: a closed loop is NEVER a scratch-out. + */ + @Test fun closedLoop_multipleXReversals_notScratchOut() { + // xs traces a figure-8 / bumpy closed oval: 0 → 200 → 0 → 200 → 0 + // reversals = 3, total x-travel = 800 px, yRange = 30 px → passes all three checks. + // But the stroke IS a closed loop — must never be treated as scratch-out. + val xs = floatArrayOf(0f, 200f, 0f, 200f, 0f) + val yRange = 30f + assertFalse( + "Closed loop must NOT trigger scratch-out (Bug 1: erases letters drawn inside a shape)", + ScratchOutDetection.detect(xs, yRange, LS, isClosedLoop = true) + ) + } + + /** + * BUG 1 variant: a rectangular outline with corner overshoots. + * + * Real freehand rectangles commonly overshoot corners: after going right the pen + * briefly continues then reverses, adding extra x-reversals. + * 300×100 px rectangle, 2 corner overshoots → 2 reversals, total x-travel = 620 px, + * yRange = 100 px → 100 < 0.4×620 = 248 — passes all scratch-out checks. + * But the stroke is closed, so it must not trigger scratch-out. + */ + @Test fun closedRectangleWithCornerOvershoots_notScratchOut() { + val xs = floatArrayOf(0f, 310f, 300f, -10f, 0f, 0f) + val yRange = 100f + assertFalse( + "Rectangular closed stroke with overshoots must NOT trigger scratch-out (Bug 1)", + ScratchOutDetection.detect(xs, yRange, LS, isClosedLoop = true) + ) + } + + // ── Compact scratch-out (start ≈ end) ───────────────────────────────────── + // + // When scratching out a thin target (arrow line), the scribble naturally + // starts and ends at nearly the same position. The closed-loop guard in + // checkPostStrokeScratchOut classifies this as a "closed loop" and rejects + // the scratch-out — but a tight zigzag is clearly NOT a shape drawn around + // content. The caller should not classify high-reversal zigzags as closed + // loops. + + @Test fun compactScratchOut_startNearEnd_notClassifiedAsClosedLoop() { + // Tight scratch-out: zigzag right-left-right-left, ending near start. + // closeDist = 5, diagonal = 80, pathLength = 500 (with Y jitter) + // closeDist/diagonal = 0.0625 < CLOSE_FRACTION (0.20) → geometrically "closed" + // BUT pathLength/diagonal = 6.25 >> PATH_RATIO_THRESHOLD (4.5) → zigzag, not shape + // isClosedLoop should return false so scratch-out detection proceeds. + val closedLoop = ScratchOutDetection.isClosedLoop( + closeDist = 5f, diagonal = 80f, pathLength = 500f + ) + assertFalse( + "Compact scratch-out (high path ratio) should NOT be classified as closed loop", + closedLoop + ) + } + + @Test fun shapeOutline_startNearEnd_classifiedAsClosedLoop() { + // Real shape outline: closeDist = 5, diagonal = 200, pathLength = 600 + // closeDist/diagonal = 0.025 < CLOSE_FRACTION → geometrically closed + // pathLength/diagonal = 3.0 < PATH_RATIO_THRESHOLD → shape outline + val closedLoop = ScratchOutDetection.isClosedLoop( + closeDist = 5f, diagonal = 200f, pathLength = 600f + ) + assertTrue( + "Shape outline (low path ratio) should be classified as closed loop", + closedLoop + ) + } + + @Test fun compactScratchOut_fullDetection_shouldDetect() { + // End-to-end: a compact zigzag with start ≈ end should be detected + // when isClosedLoop is correctly computed as false. + val xs = floatArrayOf(0f, 80f, 0f, 80f, 5f) + // isClosedLoop: closeDist=5, diagonal≈80, pathLength≈315 (X-only) + // With Y jitter, real pathLength would be higher. Use X-only path as lower bound. + // pathLength/diagonal = 315/80 ≈ 3.9 — still below 4.5 in X-only case. + // In practice, a real scratch-out has significant Y component pushing ratio > 4.5. + // Test with isClosedLoop=false to verify detect() accepts the zigzag. + assertTrue( + "Compact scratch-out should be detected when not classified as closed loop", + ScratchOutDetection.detect(xs, yRange = 10f, lineSpacing = LS, isClosedLoop = false) + ) + } + + // ── Connected cursive false positives ───────────────────────────────────── + // + // A connected cursive word is a single stroke that advances left-to-right, + // with small X-direction reversals at letter transitions (e.g. at the join + // between 'e' and 'l' in "hello"). These reversals, combined with a mostly- + // horizontal profile, satisfy all three scratch-out checks: + // + // - Reversals ≥ 2 (letter transitions in a 4+ letter word) + // - Total X-travel ≥ 1.5× LS (a word is easily > 177 px wide) + // - Y-range < 0.4 × X-travel (writing stays within one line) + // + // The key difference: a scratch-out goes BACK AND FORTH over the same region + // (net X-advance ≈ 0), while cursive PROGRESSES forward (net advance ≈ word + // width). + // + // Fix: add a progressive-advance guard. If |lastX − firstX| ≥ totalXTravel × + // MAX_ADVANCE_RATIO (0.4), the stroke is advancing forward and is NOT a + // scratch-out. + + @Test fun cursiveWord_progressiveAdvance_notScratchOut() { + // Simulated cursive "hello": advances right with small leftward dips at + // letter joins. Total travel ≈ 300 px, net advance = 240 px, + // advance ratio = 240/300 = 0.80 — clearly progressive writing. + val xs = floatArrayOf(0f, 50f, 40f, 100f, 90f, 160f, 150f, 210f, 200f, 240f) + // 4 reversals, total travel = 50+10+60+10+70+10+60+10+40 = 320 + // net advance = 240, ratio = 0.75 + assertFalse( + "Cursive word with progressive advance must NOT be scratch-out", + ScratchOutDetection.detect(xs, yRange = 40f, lineSpacing = LS) + ) + } + + @Test fun cursiveWordShort_threeLetters_notScratchOut() { + // Simulated "the": 3 letters, 2 reversals at joins + // right 80, left 15, right 80, left 15, right 60 → advances to 190 + val xs = floatArrayOf(0f, 80f, 65f, 145f, 130f, 190f) + // total travel = 80+15+80+15+60 = 250, net = 190, ratio = 0.76 + assertFalse( + "Short cursive word must NOT be scratch-out", + ScratchOutDetection.detect(xs, yRange = 35f, lineSpacing = LS) + ) + } + + @Test fun cursiveLongWord_manyReversals_notScratchOut() { + // Simulated "minimum": many up-down strokes, 6+ reversals, but steady advance + val xs = floatArrayOf( + 0f, 40f, 30f, 70f, 60f, 100f, 90f, 130f, 120f, 160f, 150f, 200f, 190f, 240f + ) + // 6 reversals, progressive advance 0→240 + assertFalse( + "Long cursive word with many reversals must NOT be scratch-out", + ScratchOutDetection.detect(xs, yRange = 45f, lineSpacing = LS) + ) + } + + @Test fun scratchOutStaysInPlace_stillDetected() { + // True scratch-out: rapid back and forth over same region, ends near start + // right 100, left 100, right 100, left 100 → net advance = 0 + val xs = floatArrayOf(0f, 100f, 0f, 100f, 0f) + // total travel = 400, net = 0, ratio = 0.0 + assertTrue( + "Scratch-out that stays in place should still be detected", + ScratchOutDetection.detect(xs, yRange = 10f, lineSpacing = LS) + ) + } + + @Test fun scratchOutSmallDrift_stillDetected() { + // Scratch-out that drifts slightly rightward: net advance is small vs total travel + // right 100, left 80, right 100, left 80 → net = 40, total = 360 + // advance ratio = 40/360 = 0.11 — well below 0.3 → still scratch-out + val xs = floatArrayOf(0f, 100f, 20f, 120f, 40f) + assertTrue( + "Scratch-out with small rightward drift should still be detected", + ScratchOutDetection.detect(xs, yRange = 10f, lineSpacing = LS) + ) + } + + // ── Vertical scratch-out (Y-axis oscillation) ─────────────────────────── + + @Test fun verticalZigzag_detects() { + // Scribbling up-down over a horizontal arrow. + // X stays roughly constant, Y zigzags. + val xs = floatArrayOf(200f, 202f, 198f, 201f, 199f) // barely moves in X + val ys = zigzag(startX = 100f, segmentWidth = 60f, segments = 4) // 4 Y-segments + assertTrue( + "Vertical zigzag should be detected as scratch-out", + ScratchOutDetection.detect(xs, ys, lineSpacing = LS) + ) + } + + @Test fun verticalZigzag_tooFewReversals_notDetected() { + val xs = floatArrayOf(200f, 202f, 198f) + val ys = floatArrayOf(100f, 160f, 100f) // 1 reversal only + assertFalse( + "Vertical zigzag with only 1 reversal should not be detected", + ScratchOutDetection.detect(xs, ys, lineSpacing = LS) + ) + } + + @Test fun diagonalZigzag_detects() { + // Scribble at ~45 degrees: both X and Y oscillate, but one axis dominates + val xs = floatArrayOf(0f, 50f, 10f, 60f, 20f) // 3 X-reversals? No: 0→50→10→60→20, reversals=3 + val ys = floatArrayOf(0f, 50f, 10f, 60f, 20f) // same pattern in Y + // Both axes have 3 reversals. Either axis works. Total travel per axis ≈ 180px. + assertTrue( + "Diagonal zigzag should be detected as scratch-out", + ScratchOutDetection.detect(xs, ys, lineSpacing = LS) + ) + } + + // ── Device-captured false positives ───────────────────────────────────── + // + // Real strokes captured from the device that were incorrectly detected as + // scratch-outs. These are downsampled (every 20th point) but preserve the + // reversal structure and advance ratio of the originals. + + /** + * Device-captured: word "difficulty" written in cursive on Palma 2 Pro (ls=77). + * 23 reversals from tight letter forms (ffi, lt), advance_ratio=0.37. + * + * This stroke DOES pass the detection heuristic (advance_ratio < 0.4). + * The actual false-positive was fixed in [HandwritingCanvasView.checkPostStrokeScratchOut] + * by requiring existing strokes under the scratch-out region — a new word written + * onto blank canvas has nothing to erase, so the scratch-out is rejected. + * + * This test documents that the detection heuristic alone matches this pattern + * (so future threshold changes don't unknowingly regress). + */ + @Test fun cursive_difficulty_matchesDetectionHeuristic() { + val ls = 77f + val xs = floatArrayOf(113.9f,113.9f,102.0f,92.7f,90.7f,103.8f,116.1f,122.0f,124.6f,124.6f,112.9f,112.7f,125.2f,134.7f,135.1f,135.1f,143.0f,157.3f,169.2f,177.1f,176.7f,158.3f,157.9f,167.0f,175.5f,184.3f,188.2f,188.2f,182.5f,165.2f,167.2f,174.9f,172.4f,172.4f,179.5f,193.8f,203.5f,203.5f,207.2f,215.2f,205.7f,185.0f,188.0f,201.7f,202.7f,201.1f,201.1f,200.5f,199.9f,198.5f,196.9f,197.3f,207.0f,209.4f,222.1f,233.0f,235.4f,241.3f,243.3f,241.7f,240.3f,252.4f,263.3f,264.3f,265.1f,273.4f,273.8f,284.9f,294.8f,301.7f,296.0f,287.1f,294.0f,307.5f,311.1f,311.8f,311.8f,316.0f,319.2f,315.6f,302.5f,312.2f,329.3f,330.3f,331.1f,329.3f,342.4f,350.7f,335.6f,317.8f,328.3f,344.7f,345.1f) + val yRange = 87.8f // 676.0 - 588.2 + assertTrue( + "Cursive 'difficulty' matches scratch-out heuristic (advance_ratio=0.37 < 0.4)", + ScratchOutDetection.detect(xs, yRange, ls) + ) + } + + // ── Target-stroke gating ──────────────────────────────────────────────── + // + // Scratch-out must only erase when there are pre-existing strokes under + // the scratch region. Without this, new cursive words with many reversals + // (e.g. "difficulty") pass the detection heuristic and disappear. + + private fun makeStroke(vararg pairs: Pair, type: StrokeType = StrokeType.FREEHAND) = + InkStroke( + points = pairs.map { (x, y) -> StrokePoint(x, y, 0.5f, 0L) }, + strokeType = type + ) + + @Test fun hasTargetStrokes_withOverlappingStroke_returnsTrue() { + val existing = listOf(makeStroke(50f to 50f, 60f to 60f)) + assertTrue( + "Scratch-out over existing stroke should find target", + ScratchOutDetection.hasTargetStrokes(existing, 40f, 40f, 70f, 70f) + ) + } + + @Test fun hasTargetStrokes_noStrokes_returnsFalse() { + assertFalse( + "Scratch-out on blank canvas must not find target", + ScratchOutDetection.hasTargetStrokes(emptyList(), 0f, 0f, 100f, 100f) + ) + } + + @Test fun hasTargetStrokes_strokeOutsideRegion_returnsFalse() { + val existing = listOf(makeStroke(200f to 200f, 210f to 210f)) + assertFalse( + "Scratch-out should not match strokes outside its region", + ScratchOutDetection.hasTargetStrokes(existing, 0f, 0f, 100f, 100f) + ) + } + + @Test fun hasTargetStrokes_connectorCrossingRegion_returnsTrue() { + // A geometric line whose segment crosses the region even though + // neither endpoint is inside it. + val connector = makeStroke(0f to 50f, 200f to 50f, type = StrokeType.LINE) + assertTrue( + "Connector line crossing scratch region should be found", + ScratchOutDetection.hasTargetStrokes(listOf(connector), 80f, 40f, 120f, 60f) + ) + } + + @Test fun cursive_difficulty_noTarget_notErased() { + // End-to-end: "difficulty" matches the detection heuristic but has no + // strokes underneath. The hasTargetStrokes guard prevents erasure. + val ls = 77f + val xs = floatArrayOf(113.9f,113.9f,102.0f,92.7f,90.7f,103.8f,116.1f,122.0f,124.6f,124.6f,112.9f,112.7f,125.2f,134.7f,135.1f,135.1f,143.0f,157.3f,169.2f,177.1f,176.7f,158.3f,157.9f,167.0f,175.5f,184.3f,188.2f,188.2f,182.5f,165.2f,167.2f,174.9f,172.4f,172.4f,179.5f,193.8f,203.5f,203.5f,207.2f,215.2f,205.7f,185.0f,188.0f,201.7f,202.7f,201.1f,201.1f,200.5f,199.9f,198.5f,196.9f,197.3f,207.0f,209.4f,222.1f,233.0f,235.4f,241.3f,243.3f,241.7f,240.3f,252.4f,263.3f,264.3f,265.1f,273.4f,273.8f,284.9f,294.8f,301.7f,296.0f,287.1f,294.0f,307.5f,311.1f,311.8f,311.8f,316.0f,319.2f,315.6f,302.5f,312.2f,329.3f,330.3f,331.1f,329.3f,342.4f,350.7f,335.6f,317.8f,328.3f,344.7f,345.1f) + val yRange = 87.8f + + // Step 1: detection heuristic fires + assertTrue("Heuristic should match", ScratchOutDetection.detect(xs, yRange, ls)) + + // Step 2: but no existing strokes → scratch-out is rejected + assertFalse( + "Cursive 'difficulty' on blank canvas must NOT be erased", + ScratchOutDetection.hasTargetStrokes( + emptyList(), + xs.min(), 588.2f, xs.max(), 676.0f + ) + ) + } + + // ── Helper ──────────────────────────────────────────────────────────────── + + /** + * Build a zigzag x-coordinate array. + * [segments] equal-width segments alternating right/left. + * Result has [segments + 1] points. + */ + private fun zigzag(startX: Float, segmentWidth: Float, segments: Int): FloatArray { + val pts = FloatArray(segments + 1) + pts[0] = startX + for (i in 1..segments) { + val dir = if (i % 2 == 1) 1f else -1f + pts[i] = pts[i - 1] + dir * segmentWidth + } + return pts + } +} diff --git a/app/src/test/java/com/writer/view/ScratchOutFocusedTest.kt b/app/src/test/java/com/writer/view/ScratchOutFocusedTest.kt new file mode 100644 index 0000000..d7aa1b4 --- /dev/null +++ b/app/src/test/java/com/writer/view/ScratchOutFocusedTest.kt @@ -0,0 +1,269 @@ +package com.writer.view + +import com.writer.model.InkStroke +import com.writer.model.StrokePoint +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Unit tests for [ScratchOutDetection.isFocusedScratchOut]. + * + * Tests the coverage-based scratch-out validation: a scratch-out must + * intersect existing strokes AND have most of its path near them. + */ +class ScratchOutFocusedTest { + + @Before + fun setUp() { + ScreenMetrics.init(1.875f, smallestWidthDp = 674, widthPixels = 1264, heightPixels = 1680) + } + + private fun stroke(vararg pairs: Pair) = InkStroke( + points = pairs.map { (x, y) -> StrokePoint(x, y, 0.5f, 0L) } + ) + + private fun points(vararg pairs: Pair) = + pairs.map { (x, y) -> StrokePoint(x, y, 0.5f, 0L) } + + /** Create a zigzag scratch-out centered at (cx, cy) with given width. */ + private fun zigzagAt(cx: Float, cy: Float, width: Float = 100f, segments: Int = 6): List { + val pts = mutableListOf() + val halfW = width / 2f + for (i in 0..segments) { + val x = cx - halfW + width * i / segments + val y = cy + if (i % 2 == 0) 5f else -5f + pts.add(StrokePoint(x, y, 0.5f, i.toLong())) + } + return pts + } + + // ── Small targets (dots, taps) ──────────────────────────────────────────── + + @Test + fun `scratch-out over small dot erases it`() { + // A 2-point dot at (200, 300) + val dot = stroke(200f to 300f, 201f to 301f) + // Zigzag crossing over the dot + val scratch = zigzagAt(200f, 300f, width = 80f) + + assertTrue( + "Scratch-out should erase small dot (< 5 points, intersection sufficient)", + ScratchOutDetection.isFocusedScratchOut(scratch, listOf(dot)) + ) + } + + @Test + fun `scratch-out over single-point tap erases it`() { + val tap = stroke(150f to 250f, 150f to 250f) + val scratch = zigzagAt(150f, 250f, width = 60f) + + assertTrue( + "Scratch-out should erase single-point tap", + ScratchOutDetection.isFocusedScratchOut(scratch, listOf(tap)) + ) + } + + @Test + fun `scratch-out over 3-point dwell mark erases it`() { + val dwell = stroke(300f to 400f, 301f to 401f, 300f to 400f) + val scratch = zigzagAt(300f, 400f, width = 50f) + + assertTrue( + "Scratch-out should erase 3-point dwell mark", + ScratchOutDetection.isFocusedScratchOut(scratch, listOf(dwell)) + ) + } + + @Test + fun `scratch-out NOT over dot does not erase it`() { + val dot = stroke(200f to 300f, 201f to 301f) + // Zigzag far from the dot + val scratch = zigzagAt(500f, 300f, width = 80f) + + assertFalse( + "Scratch-out far from dot should not erase it", + ScratchOutDetection.isFocusedScratchOut(scratch, listOf(dot)) + ) + } + + @Test + fun `scratch-out over multiple small dots erases them`() { + val dot1 = stroke(100f to 300f, 101f to 301f) + val dot2 = stroke(150f to 300f, 151f to 301f) + val scratch = zigzagAt(125f, 300f, width = 120f) + + assertTrue( + "Scratch-out should erase multiple small dots in its path", + ScratchOutDetection.isFocusedScratchOut(scratch, listOf(dot1, dot2)) + ) + } + + // ── Normal strokes (coverage check applies) ─────────────────────────────── + + @Test + fun `scratch-out focused on text erases it`() { + // A word-sized stroke from (100,300) to (300,310) + val word = stroke( + 100f to 300f, 130f to 305f, 160f to 298f, + 190f to 303f, 220f to 300f, 250f to 308f, 280f to 302f + ) + // Zigzag directly over the word + val scratch = zigzagAt(190f, 303f, width = 200f, segments = 10) + + assertTrue( + "Scratch-out focused on a word should erase it", + ScratchOutDetection.isFocusedScratchOut(scratch, listOf(word)) + ) + } + + @Test + fun `cursive grazing descender does not erase`() { + // "disappear" descender at y=280-320, x=100-150 + val descender = stroke( + 100f to 280f, 110f to 290f, 120f to 300f, + 130f to 310f, 140f to 320f, 145f to 315f, 150f to 305f + ) + // "helped" written mostly below at y=340-380, only briefly crossing at x=100 + // Most of the stroke is NOT near the descender + val helpedPoints = mutableListOf() + // Brief crossing at top + helpedPoints.add(StrokePoint(100f, 310f, 0.5f, 0L)) + helpedPoints.add(StrokePoint(110f, 320f, 0.5f, 1L)) + // Rest of the word far below + for (i in 2..20) { + helpedPoints.add(StrokePoint(100f + i * 15f, 360f + (i % 3) * 5f, 0.5f, i.toLong())) + } + + assertFalse( + "Cursive text that briefly crosses a descender should NOT trigger scratch-out", + ScratchOutDetection.isFocusedScratchOut(helpedPoints, listOf(descender)) + ) + } + + // ── Edge cases ──────────────────────────────────────────────────────────── + + @Test + fun `empty scratch points returns false`() { + val word = stroke(100f to 300f, 200f to 300f) + assertFalse(ScratchOutDetection.isFocusedScratchOut(emptyList(), listOf(word))) + } + + @Test + fun `empty existing strokes returns false`() { + val scratch = zigzagAt(200f, 300f) + assertFalse(ScratchOutDetection.isFocusedScratchOut(scratch, emptyList())) + } + + @Test + fun `scratch-out with no intersection returns false`() { + // Stroke at y=100, scratch-out at y=500 — no intersection possible + val word = stroke(100f to 100f, 200f to 100f, 300f to 100f) + val scratch = zigzagAt(200f, 500f, width = 200f) + + assertFalse( + "Non-intersecting scratch-out should return false", + ScratchOutDetection.isFocusedScratchOut(scratch, listOf(word)) + ) + } + + // ── Bounding box pre-filter ─────────────────────────────────────────────── + + @Test + fun `distant strokes are filtered by bounding box precheck`() { + // Many strokes far away — should be filtered before intersection check + val distantStrokes = (0..20).map { i -> + stroke(1000f + i * 50f to 1000f, 1010f + i * 50f to 1010f) + } + // One nearby stroke + val nearby = stroke(200f to 300f, 250f to 300f, 300f to 300f) + val all = distantStrokes + nearby + val scratch = zigzagAt(250f, 300f, width = 150f) + + // Should still work (and fast — distant strokes filtered by bbox) + assertTrue( + "Should find and erase the nearby stroke despite many distant ones", + ScratchOutDetection.isFocusedScratchOut(scratch, all) + ) + } + + // ── Small dot erasure ──────────────────────────────────────────────────── + + @Test + fun `isFocusedScratchOut detects small dot target`() { + // 1-point dot + val dot = stroke(300f to 400f) + val scratch = zigzagAt(300f, 400f) + assertTrue( + "Scratch-out over a 1-point dot should be focused", + ScratchOutDetection.isFocusedScratchOut(scratch, listOf(dot)) + ) + } + + @Test + fun `isFocusedScratchOut detects 2-point dot target`() { + val dot = stroke(300f to 400f, 301f to 401f) + val scratch = zigzagAt(300f, 400f) + assertTrue( + "Scratch-out over a 2-point dot should be focused", + ScratchOutDetection.isFocusedScratchOut(scratch, listOf(dot)) + ) + } + + /** + * Simulate the overlap check from WritingCoordinator.onScratchOut: + * for small strokes (< 5 points), use proximity; for larger ones, use intersection. + */ + private fun findOverlapping( + scratchPoints: List, + strokes: List + ): List { + val radius = ScreenMetrics.dp(ScratchOutDetection.COVERAGE_RADIUS_DP) + val radiusSq = radius * radius + return strokes.filter { stroke -> + if (stroke.points.size < 5) { + stroke.points.any { tp -> + scratchPoints.any { sp -> + val dx = sp.x - tp.x; val dy = sp.y - tp.y + dx * dx + dy * dy <= radiusSq + } + } + } else { + ScratchOutDetection.strokesIntersect(scratchPoints, stroke.points) + } + } + } + + @Test + fun `overlap check finds 1-point dot via proximity`() { + val dot = stroke(300f to 400f) + val scratch = zigzagAt(300f, 400f) + val overlapping = findOverlapping(scratch, listOf(dot)) + assertTrue("1-point dot should be found by proximity overlap", overlapping.isNotEmpty()) + } + + @Test + fun `overlap check finds 2-point dot via proximity`() { + val dot = stroke(300f to 400f, 302f to 401f) + val scratch = zigzagAt(300f, 400f) + val overlapping = findOverlapping(scratch, listOf(dot)) + assertTrue("2-point dot should be found by proximity overlap", overlapping.isNotEmpty()) + } + + @Test + fun `overlap check does not find distant dot`() { + val dot = stroke(300f to 400f) + val scratch = zigzagAt(600f, 600f) // far away + val overlapping = findOverlapping(scratch, listOf(dot)) + assertTrue("Distant dot should NOT be found", overlapping.isEmpty()) + } + + @Test + fun `overlap check finds normal stroke via intersection`() { + val word = stroke(250f to 300f, 350f to 300f) + val scratch = zigzagAt(300f, 300f) + val overlapping = findOverlapping(scratch, listOf(word)) + assertTrue("Normal stroke should be found by intersection", overlapping.isNotEmpty()) + } +} diff --git a/app/src/test/java/com/writer/view/ScreenMetricsTest.kt b/app/src/test/java/com/writer/view/ScreenMetricsTest.kt index fbabe13..ed86b09 100644 --- a/app/src/test/java/com/writer/view/ScreenMetricsTest.kt +++ b/app/src/test/java/com/writer/view/ScreenMetricsTest.kt @@ -49,13 +49,13 @@ class ScreenMetricsTest { // Re-initialise before each test so state from previous tests doesn't leak. @Before fun resetToDefault() { - ScreenMetrics.init(DENSITY, DENSITY, SW_GO_7, W_GO_7, H_GO_7) + ScreenMetrics.init(DENSITY, smallestWidthDp = SW_GO_7, widthPixels = W_GO_7, heightPixels = H_GO_7) } // ── helpers ────────────────────────────────────────────────────────────── private fun init(sw: Int, w: Int, h: Int) = - ScreenMetrics.init(DENSITY, DENSITY, sw, w, h) + ScreenMetrics.init(DENSITY, smallestWidthDp = sw, widthPixels = w, heightPixels = h) /** Convert pixels back to mm at 300 PPI. */ private fun toMm(px: Float) = px / (DENSITY * 160f) * 25.4f @@ -91,45 +91,6 @@ class ScreenMetricsTest { assertTrue("lineSpacing should be <= 9 mm on Palma 2 Pro, was $mm mm", mm <= 9f) } - // ── gutter width ───────────────────────────────────────────────────────── - - @Test fun gutterWidth_isAtLeastMinimumTouchTarget_standardDevices() { - val standard = listOf( - Triple(SW_TAB_X_C, W_TAB_X_C, H_TAB_X_C), - Triple(SW_NOTE_5C, W_NOTE_5C, H_NOTE_5C), - Triple(SW_GO_7, W_GO_7, H_GO_7), - ) - for ((sw, w, h) in standard) { - init(sw, w, h) - val mm = toMm(ScreenMetrics.gutterWidth) - assertTrue("gutterWidth < 9 mm on sw=$sw (was $mm mm)", mm >= 9f) - } - } - - @Test fun gutterWidth_isAtLeastStylusTarget_compactDevices() { - // Compact screens use a narrower gutter; 6 mm is still reliable for a stylus. - init(SW_PALMA_2PRO, W_PALMA_2PRO, H_PALMA_2PRO) - val mm = toMm(ScreenMetrics.gutterWidth) - assertTrue("gutterWidth < 6 mm on Palma 2 Pro (was $mm mm)", mm >= 6f) - } - - @Test fun gutterWidth_doesNotExceedMaxFraction_narrowScreen() { - // Palma 2 Pro portrait — 824 px wide at 300 PPI - init(SW_PALMA_2PRO, W_PALMA_2PRO, H_PALMA_2PRO) - val fraction = ScreenMetrics.gutterWidth / W_PALMA_2PRO.toFloat() - assertTrue("gutter fraction $fraction exceeds 0.12 on Palma 2 Pro portrait", fraction <= 0.12f) - } - - @Test fun gutterWidth_isConsistentAcrossStandardDevices() { - // All standard devices share the same density, so gutter pixel values should be equal - // (barring screen-width cap, which doesn't apply at these resolutions). - init(SW_TAB_X_C, W_TAB_X_C, H_TAB_X_C) - val tabXC = ScreenMetrics.gutterWidth - init(SW_NOTE_5C, W_NOTE_5C, H_NOTE_5C) - val note5C = ScreenMetrics.gutterWidth - assertEquals("Standard devices at same density should have equal gutter px", tabXC, note5C, 1f) - } - // ── text sizes ─────────────────────────────────────────────────────────── @Test fun textBody_isReadable_allDevices() { @@ -251,7 +212,6 @@ class ScreenMetricsTest { init(sw, w, h) assertTrue("lineSpacing <= 0 on sw=$sw", ScreenMetrics.lineSpacing > 0f) assertTrue("topMargin <= 0 on sw=$sw", ScreenMetrics.topMargin > 0f) - assertTrue("gutterWidth <= 0 on sw=$sw", ScreenMetrics.gutterWidth > 0f) assertTrue("strokeWidth <= 0 on sw=$sw", ScreenMetrics.strokeWidth > 0f) assertTrue("textBody <= 0 on sw=$sw", ScreenMetrics.textBody > 0f) assertTrue("textLogo <= 0 on sw=$sw", ScreenMetrics.textLogo > 0f) @@ -264,9 +224,15 @@ class ScreenMetricsTest { @Test fun extremelyLowDensity_doesNotCrash() { // Clamps to minimum 0.5 internally - ScreenMetrics.init(0.3f, 0.3f, 400, 800, 600) + ScreenMetrics.init(0.3f, fontScale = 0.3f, smallestWidthDp = 400, widthPixels = 800, heightPixels = 600) assertTrue(ScreenMetrics.lineSpacing > 0f) - assertTrue(ScreenMetrics.gutterWidth > 0f) + } + + @Test fun fontScale_2x_doublesTextSizes() { + ScreenMetrics.init(DENSITY, fontScale = 1f, smallestWidthDp = SW_GO_7, widthPixels = W_GO_7, heightPixels = H_GO_7) + val baseTextBody = ScreenMetrics.textBody + ScreenMetrics.init(DENSITY, fontScale = 2f, smallestWidthDp = SW_GO_7, widthPixels = W_GO_7, heightPixels = H_GO_7) + assertEquals("textBody at fontScale=2 should be ~2x default", baseTextBody * 2f, ScreenMetrics.textBody, 0.1f) } // ── compact-mode classification ─────────────────────────────────────────── diff --git a/app/src/test/java/com/writer/view/ShapeSnapDetectionTest.kt b/app/src/test/java/com/writer/view/ShapeSnapDetectionTest.kt new file mode 100644 index 0000000..0e3ac0c --- /dev/null +++ b/app/src/test/java/com/writer/view/ShapeSnapDetectionTest.kt @@ -0,0 +1,1456 @@ +package com.writer.view + +import com.writer.model.StrokePoint +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt +import kotlin.math.PI +import kotlin.math.abs + +/** + * Unit tests for [ShapeSnapDetection]. + * + * Uses a fixed line spacing of 118 px (standard device: 63 dp × 1.875 density). + * + * Shape classification: + * - Circle/oval (0 corners) → Ellipse + * - Triangle (3 corners) → Triangle + * - Rectangle (4 corners) → Rectangle + * - Straight stroke → Line + */ +class ShapeSnapDetectionTest { + + companion object { + private const val LS = 118f + } + + // ── Ellipse: circles and ovals ──────────────────────────────────────────── + + @Test fun perfectCircle_snapsToEllipse() { + // 40-point circle, radius 100 px, centered at (100,100). Fully closed (point 40 == point 0). + val n = 40 + val xs = FloatArray(n + 1) { i -> (100 + 100 * cos(2 * PI * i / n)).toFloat() } + val ys = FloatArray(n + 1) { i -> (100 + 100 * sin(2 * PI * i / n)).toFloat() } + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("Circle should snap to an ellipse", result) + assertTrue("Circle snaps to Ellipse", result is ShapeSnapDetection.SnapResult.Ellipse) + val e = result as ShapeSnapDetection.SnapResult.Ellipse + assertEquals(100f, e.cx, 2f) + assertEquals(100f, e.cy, 2f) + assertEquals(100f, e.a, 2f) + assertEquals(100f, e.b, 2f) + } + + @Test fun oval_snapsToEllipse() { + // 40-point oval: 300 px wide, 150 px tall. Fully closed. + val n = 40 + val a = 150f; val b = 75f + val xs = FloatArray(n + 1) { i -> (a + a * cos(2 * PI * i / n)).toFloat() } + val ys = FloatArray(n + 1) { i -> (b + b * sin(2 * PI * i / n)).toFloat() } + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("Oval should snap to an ellipse", result) + assertTrue("Oval snaps to Ellipse", result is ShapeSnapDetection.SnapResult.Ellipse) + val e = result as ShapeSnapDetection.SnapResult.Ellipse + assertEquals(a, e.cx, 3f) + assertEquals(b, e.cy, 3f) + assertEquals(a, e.a, 3f) + assertEquals(b, e.b, 3f) + } + + @Test fun circle20Points_snapsToEllipse() { + // 20-point circle: each arc segment = 18°. With window=3 the subtended angle + // is 54° > CORNER_ANGLE_DEG (50°), so every point registers as a "corner". + // This causes the circle to snap to Rectangle or Triangle instead of Ellipse. + val n = 20 + val xs = FloatArray(n + 1) { i -> (100 + 100 * cos(2 * PI * i / n)).toFloat() } + val ys = FloatArray(n + 1) { i -> (100 + 100 * sin(2 * PI * i / n)).toFloat() } + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("20-point circle should snap to an ellipse", result) + assertTrue("20-point circle must snap to Ellipse, got $result", + result is ShapeSnapDetection.SnapResult.Ellipse) + } + + @Test fun circle15Points_snapsToEllipse() { + // 15-point circle: arc step = 24°, window=3, subtended = 72° >> 50°. + val n = 15 + val xs = FloatArray(n + 1) { i -> (100 + 100 * cos(2 * PI * i / n)).toFloat() } + val ys = FloatArray(n + 1) { i -> (100 + 100 * sin(2 * PI * i / n)).toFloat() } + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("15-point circle should snap to an ellipse", result) + assertTrue("15-point circle must snap to Ellipse, got $result", + result is ShapeSnapDetection.SnapResult.Ellipse) + } + + @Test fun bumpyCircleWithSpuriousCorners_snapsToEllipse() { + // 20-point circle (arc step = 18°, window = 3, subtended = 54° > 50°). + // Two points nudged inward by 15 px cause brief angle dips that reset + // the inCorner flag, splitting the stroke into 3 distinct corner-regions: + // region 1: i 3..6 → corner at ~6 + // region 2: i 8..13 → corner at ~13 + // region 3: i 15..17 → corner at ~17 + // corners.size == 3 → current code misclassifies as Triangle. + // Max ellipse deviation = 15 / (100*√2*2) ≈ 5.3% < ELLIPSE_MAX_DEV (7%) + // so the shape should snap to Ellipse. + val n = 20 + val nudgeAt = setOf(7, 14) + val xs = FloatArray(n + 1) { i -> + val angle = 2 * PI * i / n + val r = if (i in nudgeAt) 85.0 else 100.0 + (100 + r * cos(angle)).toFloat() + } + val ys = FloatArray(n + 1) { i -> + val angle = 2 * PI * i / n + val r = if (i in nudgeAt) 85.0 else 100.0 + (100 + r * sin(angle)).toFloat() + } + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("Bumpy circle should snap to an ellipse", result) + assertTrue("Bumpy circle must snap to Ellipse (not Triangle), got $result", + result is ShapeSnapDetection.SnapResult.Ellipse) + } + + /** Helper: generate an oval with optional noise and non-uniform point spacing. */ + private fun makeOval( + cx: Float, cy: Float, a: Float, b: Float, + n: Int = 60, noise: Float = 0f, gapFraction: Float = 0f + ): Pair { + val count = n + 1 + // Non-uniform angular spacing: slight speed variation like hand-drawn + val angles = DoubleArray(count) { i -> + val base = 2 * PI * i / n + // Small angular jitter for realism + if (noise > 0 && i > 0 && i < n) base + (((i * 17) % 7) - 3) * 0.01 + else base + } + val xs = FloatArray(count) { i -> + val px = (cx + a * cos(angles[i])).toFloat() + if (noise > 0) px + ((i * 7 % 11) - 5).toFloat() * noise / 5f else px + } + val ys = FloatArray(count) { i -> + val py = (cy + b * sin(angles[i])).toFloat() + if (noise > 0) py + ((i * 13 % 11) - 5).toFloat() * noise / 5f else py + } + // Simulate imperfect closure: last point offset from first + if (gapFraction > 0) { + val gapPx = gapFraction * sqrt((2 * a) * (2 * a) + (2 * b) * (2 * b)) + xs[n] = xs[0] + gapPx * 0.3f + ys[n] = ys[0] + gapPx * 0.7f + } + return Pair(xs, ys) + } + + // ── Tall/narrow ovals at various aspect ratios ────────────────────────── + + @Test fun tallOval_2to1_perfect() { + val (xs, ys) = makeOval(200f, 200f, 50f, 100f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("2:1 tall oval → Ellipse, got $result", result is ShapeSnapDetection.SnapResult.Ellipse) + } + + @Test fun tallOval_3to1_perfect() { + val (xs, ys) = makeOval(200f, 300f, 40f, 120f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("3:1 tall oval → Ellipse, got $result", result is ShapeSnapDetection.SnapResult.Ellipse) + } + + @Test fun tallOval_4to1_perfect() { + val (xs, ys) = makeOval(200f, 300f, 30f, 120f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("4:1 tall oval → Ellipse, got $result", result is ShapeSnapDetection.SnapResult.Ellipse) + } + + @Test fun tallOval_5to1_perfect() { + // Very narrow: 48 px wide, 240 px tall — tests RECT_MIN_SIDE_SPANS gate + val (xs, ys) = makeOval(200f, 300f, 24f, 120f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("5:1 tall oval → Ellipse, got $result", result is ShapeSnapDetection.SnapResult.Ellipse) + } + + @Test fun wideOval_3to1_perfect() { + val (xs, ys) = makeOval(200f, 200f, 150f, 50f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("3:1 wide oval → Ellipse, got $result", result is ShapeSnapDetection.SnapResult.Ellipse) + } + + // ── Hand-drawn ovals with noise ───────────────────────────────────────── + + @Test fun tallOval_3to1_noise5px() { + val (xs, ys) = makeOval(200f, 300f, 40f, 120f, noise = 5f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("Noisy 3:1 tall oval → Ellipse, got $result", result is ShapeSnapDetection.SnapResult.Ellipse) + } + + @Test fun tallOval_3to1_noise8px() { + val (xs, ys) = makeOval(200f, 300f, 40f, 120f, noise = 8f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("Noisy 8px 3:1 tall oval → Ellipse, got $result", result is ShapeSnapDetection.SnapResult.Ellipse) + } + + @Test fun tallOval_2to1_noise3px() { + // 3px noise is realistic for pen input on a 100px-wide oval + val (xs, ys) = makeOval(200f, 200f, 50f, 100f, noise = 3f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("Noisy 2:1 tall oval → Ellipse, got $result", result is ShapeSnapDetection.SnapResult.Ellipse) + } + + // ── Imperfect closure ─────────────────────────────────────────────────── + + @Test fun tallOval_3to1_slightGap() { + // Stroke doesn't perfectly close — 5% gap + val (xs, ys) = makeOval(200f, 300f, 40f, 120f, noise = 3f, gapFraction = 0.05f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("Slightly open 3:1 oval → Ellipse, got $result", result is ShapeSnapDetection.SnapResult.Ellipse) + } + + // ── Device-realistic: 80-point strokes like real pen input ────────────── + + @Test fun tallOval_deviceRealistic_80points() { + // Simulate real device: ~80 points, 3:1 aspect, moderate noise + val (xs, ys) = makeOval(400f, 500f, 60f, 180f, n = 80, noise = 6f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("80pt realistic tall oval → Ellipse, got $result", result is ShapeSnapDetection.SnapResult.Ellipse) + } + + @Test fun tallOval_deviceRealistic_40points() { + // Fast draw: fewer points, 3:1 aspect + val (xs, ys) = makeOval(400f, 500f, 50f, 150f, n = 40, noise = 4f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("40pt realistic tall oval → Ellipse, got $result", result is ShapeSnapDetection.SnapResult.Ellipse) + } + + @Test fun tallOval_deviceRealistic_smallOnScreen() { + // Small oval: 60 px wide, 120 px tall — near minimum size + val (xs, ys) = makeOval(300f, 300f, 30f, 60f, n = 50, noise = 3f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("Small realistic tall oval → Ellipse, got $result", result is ShapeSnapDetection.SnapResult.Ellipse) + } + + // ── Real device strokes from bug report (misclassified as non-ellipse) ── + + @Test fun deviceStroke_rectangle_shouldBeEllipse() { + // Real stroke from bug report: classified as RECTANGLE, aspect ratio 4.02 + val raw = floatArrayOf( + 349.69f, 184.92f, 347.11f, 194.43f, 333.25f, 234.05f, + 324.13f, 267.73f, 312.64f, 321.81f, 307.09f, 353.90f, + 294.21f, 412.74f, 289.86f, 441.26f, 288.27f, 477.32f, + 288.27f, 505.25f, 286.69f, 530.01f, 286.88f, 548.04f, + 289.26f, 559.33f, 294.21f, 568.84f, 299.76f, 572.21f, + 305.51f, 573.20f, 311.25f, 572.80f, 315.61f, 571.22f, + 318.39f, 569.63f, 323.93f, 564.48f, 336.22f, 547.05f, + 347.51f, 527.64f, 357.81f, 503.47f, 365.14f, 474.74f, + 374.85f, 428.19f, 379.01f, 389.36f, 383.37f, 358.46f, + 383.37f, 260.20f, 381.19f, 247.32f, 377.23f, 231.48f, + 375.05f, 215.03f, 373.86f, 211.07f, 370.10f, 204.53f, + 363.36f, 196.81f, 357.81f, 192.65f, 353.85f, 191.06f, + 347.11f, 191.86f, 337.21f, 191.46f + ) + val xs = FloatArray(raw.size / 2) { raw[it * 2] } + val ys = FloatArray(raw.size / 2) { raw[it * 2 + 1] } + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("Device stroke (was RECTANGLE) → Ellipse, got $result", + result is ShapeSnapDetection.SnapResult.Ellipse) + } + + @Test fun deviceStroke_roundedRect_shouldBeEllipse() { + // Real stroke from bug report: classified as ROUNDED_RECTANGLE, aspect ratio 4.12 + val raw = floatArrayOf( + 666.49f, 196.61f, 653.41f, 231.48f, 642.72f, 268.52f, + 638.16f, 287.93f, 632.02f, 320.22f, 621.71f, 382.03f, + 618.54f, 406.99f, 616.56f, 450.38f, 617.75f, 493.56f, + 618.35f, 503.27f, 620.53f, 517.73f, 623.50f, 527.64f, + 627.86f, 535.76f, 631.03f, 539.72f, 635.78f, 543.29f, + 640.14f, 545.27f, 645.69f, 546.46f, 652.03f, 545.66f, + 659.36f, 542.10f, 664.51f, 536.55f, 667.68f, 531.20f, + 669.86f, 524.66f, 681.75f, 468.01f, 695.81f, 414.72f, + 698.19f, 402.04f, 701.36f, 379.06f, 701.56f, 347.76f, + 697.99f, 319.43f, 697.20f, 297.44f, 690.46f, 234.65f, + 687.69f, 222.17f, 686.50f, 219.79f, 679.17f, 211.67f, + 672.24f, 206.71f, 668.67f, 205.13f + ) + val xs = FloatArray(raw.size / 2) { raw[it * 2] } + val ys = FloatArray(raw.size / 2) { raw[it * 2 + 1] } + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("Device stroke (was ROUNDED_RECT) → Ellipse, got $result", + result is ShapeSnapDetection.SnapResult.Ellipse) + } + + @Test fun deviceStroke_triangle_shouldBeEllipse() { + // Real stroke from bug report: classified as TRIANGLE, aspect ratio 2.77 + val raw = floatArrayOf( + 562.08f, 588.45f, 557.32f, 592.22f, 554.15f, 596.18f, + 547.42f, 611.43f, 537.91f, 641.54f, 526.02f, 694.83f, + 523.64f, 715.63f, 522.85f, 745.15f, 524.04f, 765.36f, + 527.21f, 776.65f, 530.38f, 778.83f, 533.55f, 779.42f, + 539.30f, 777.84f, 545.04f, 774.67f, 564.06f, 744.56f, + 569.01f, 735.05f, 575.35f, 719.79f, 579.51f, 705.53f, + 587.44f, 658.58f, 590.01f, 651.85f, 593.18f, 637.38f, + 593.78f, 626.29f, 592.59f, 619.95f, 586.05f, 596.18f, + 583.28f, 590.63f, 578.52f, 585.68f, 570.60f, 582.91f, + 558.12f, 584.49f + ) + val xs = FloatArray(raw.size / 2) { raw[it * 2] } + val ys = FloatArray(raw.size / 2) { raw[it * 2 + 1] } + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("Device stroke (was TRIANGLE) → Ellipse, got $result", + result is ShapeSnapDetection.SnapResult.Ellipse) + } + + @Test fun threeQuarterArc_snapsToSelfLoop() { + // 3/4 of a circle (270°): start at (200,100), end at (100,0). + // Nearly closed (gap ratio ~0.50 < SELF_LOOP_MAX_GAP), smooth, fits ellipse. + val n = 30 + val xs = FloatArray(n + 1) { i -> (100 + 100 * cos(3 * PI / 2 * i / n)).toFloat() } + val ys = FloatArray(n + 1) { i -> (100 + 100 * sin(3 * PI / 2 * i / n)).toFloat() } + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("3/4 arc should snap to SelfLoop", result) + assertTrue("Should be SelfLoop, got $result", + result is ShapeSnapDetection.SnapResult.SelfLoop) + } + + // ── RoundedRectangle ────────────────────────────────────────────────────── + + @Test fun roundedRectangle_snapsToRoundedRectangle() { + // 200×150 rounded rectangle with corner radius 37 px. + // 15 arc points per 90° corner, 5 points per straight side. + // N ≈ 81 → window = max(3, 81/12) = 6; arc step = 6°; detected angle = 36° < 50°. + // So 0 sharp corners are found → should snap to RoundedRectangle, not Rectangle. + // Max ellipse deviation at the 45° corner arc position ≈ 7.2% > 7% threshold, + // so it must NOT snap to Ellipse either. + val left = 0f; val top = 0f; val right = 200f; val bottom = 150f + val r = 37f + val arcN = 15; val sideN = 5 + val cl = left + r; val cr = right - r + val ct = top + r; val cb = bottom - r + + val pts = mutableListOf>() + fun arc(cx: Float, cy: Float, startDeg: Double, endDeg: Double) { + for (i in 0 until arcN) { + val a = Math.toRadians(startDeg + (endDeg - startDeg) * i / arcN) + pts += Pair((cx + r * cos(a)).toFloat(), (cy + r * sin(a)).toFloat()) + } + } + fun side(x0: Float, y0: Float, x1: Float, y1: Float) { + for (i in 0 until sideN) { + val t = i.toFloat() / sideN + pts += Pair(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t) + } + } + // Clockwise from (cr, top): TR arc → right side → BR arc → bottom → BL arc → left → TL arc → top → close + arc(cr, ct, -90.0, 0.0); side(right, ct, right, cb) + arc(cr, cb, 0.0, 90.0); side(cr, bottom, cl, bottom) + arc(cl, cb, 90.0, 180.0); side(left, cb, left, ct) + arc(cl, ct, 180.0, 270.0); side(cl, top, cr, top) + pts += Pair(cr, top) // close + + val xs = pts.map { it.first }.toFloatArray() + val ys = pts.map { it.second }.toFloatArray() + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("Rounded rectangle should snap", result) + assertTrue("Rounded rectangle snaps to RoundedRectangle, got $result", + result is ShapeSnapDetection.SnapResult.RoundedRectangle) + val rr = result as ShapeSnapDetection.SnapResult.RoundedRectangle + assertEquals(left, rr.left, 3f) + assertEquals(top, rr.top, 3f) + assertEquals(right, rr.right, 3f) + assertEquals(bottom, rr.bottom, 3f) + assertTrue("Corner radius should be positive", rr.cornerRadius > 0f) + } + + @Test fun sharpRectangle_doesNotSnapToRoundedRectangle() { + // A rectangle with sharp 90° corners (5 points) must snap to Rectangle, + // not RoundedRectangle, even though it has 0 smooth corner-detections + // in the small-N fallback path. + val xs = floatArrayOf(0f, 200f, 200f, 0f, 0f) + val ys = floatArrayOf(0f, 0f, 150f, 150f, 0f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("5-point sharp rect must snap to Rectangle", + result is ShapeSnapDetection.SnapResult.Rectangle) + } + + // ── RoundedRectangle with fewer arc points per corner ───────────────────── + + @Test fun roundedRectangleFewArcPoints_snapsToRoundedRectangle() { + // 200×150 rounded rectangle with corner radius 30 px. + // Only 5 arc points per 90° corner → arc step = 18°/pt. + // N = 4*(5+5)+1 = 41 → window = max(3, 41/12) = 3. + // At each arc midpoint the windowed angle ≈ 54° > CORNER_ANGLE_DEG (50°), + // so currently 4 sharp corners are detected → snaps to Rectangle (BUG). + // Expected: RoundedRectangle. + val left = 0f; val top = 0f; val right = 200f; val bottom = 150f + val r = 30f + val arcN = 5; val sideN = 5 + val cl = left + r; val cr = right - r + val ct = top + r; val cb = bottom - r + + val pts = mutableListOf>() + fun arc(cx: Float, cy: Float, startDeg: Double, endDeg: Double) { + for (i in 0 until arcN) { + val a = Math.toRadians(startDeg + (endDeg - startDeg) * i / arcN) + pts += Pair((cx + r * cos(a)).toFloat(), (cy + r * sin(a)).toFloat()) + } + } + fun side(x0: Float, y0: Float, x1: Float, y1: Float) { + for (i in 0 until sideN) { + val t = i.toFloat() / sideN + pts += Pair(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t) + } + } + arc(cr, ct, -90.0, 0.0); side(right, ct, right, cb) + arc(cr, cb, 0.0, 90.0); side(cr, bottom, cl, bottom) + arc(cl, cb, 90.0, 180.0); side(left, cb, left, ct) + arc(cl, ct, 180.0, 270.0); side(cl, top, cr, top) + pts += Pair(cr, top) // close + + val xs = pts.map { it.first }.toFloatArray() + val ys = pts.map { it.second }.toFloatArray() + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("Rounded rectangle (few arc pts) should snap", result) + assertTrue("Rounded rectangle (few arc pts) snaps to RoundedRectangle, got $result", + result is ShapeSnapDetection.SnapResult.RoundedRectangle) + } + + // ── Rounded rectangle vs ellipse boundary ───────────────────────────────── + + /** + * Builds a rounded rectangle with the given corner radius. + * As radius approaches min(w,h)/2, the shape approaches an ellipse. + */ + private fun makeRoundedRect( + w: Float = 200f, h: Float = 150f, r: Float, arcN: Int = 12, sideN: Int = 6 + ): Pair { + val left = 0f; val top = 0f; val right = w; val bottom = h + val cl = left + r; val cr = right - r + val ct = top + r; val cb = bottom - r + val pts = mutableListOf>() + fun arc(cx: Float, cy: Float, startDeg: Double, endDeg: Double) { + for (i in 0 until arcN) { + val a = Math.toRadians(startDeg + (endDeg - startDeg) * i / arcN) + pts += Pair((cx + r * cos(a)).toFloat(), (cy + r * sin(a)).toFloat()) + } + } + fun side(x0: Float, y0: Float, x1: Float, y1: Float) { + for (i in 0 until sideN) { + val t = i.toFloat() / sideN + pts += Pair(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t) + } + } + arc(cr, ct, -90.0, 0.0); side(right, ct, right, cb) + arc(cr, cb, 0.0, 90.0); side(cr, bottom, cl, bottom) + arc(cl, cb, 90.0, 180.0); side(left, cb, left, ct) + arc(cl, ct, 180.0, 270.0); side(cl, top, cr, top) + pts += pts[0] // close + return pts.map { it.first }.toFloatArray() to pts.map { it.second }.toFloatArray() + } + + @Test fun roundedRect_smallRadius_snapsToRoundedRectangle() { + // r=20 on 200x150 — clearly rectangular with slight rounding. + val (xs, ys) = makeRoundedRect(r = 20f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("r=20 should be RoundedRectangle, got $result", + result is ShapeSnapDetection.SnapResult.RoundedRectangle) + } + + @Test fun roundedRect_moderateRadius_snapsToRoundedRectangle() { + // r=40 on 200x150 — noticeably rounded but still clearly a rectangle. + val (xs, ys) = makeRoundedRect(r = 40f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("r=40 should be RoundedRectangle, got $result", + result is ShapeSnapDetection.SnapResult.RoundedRectangle) + } + + @Test fun roundedRect_largeRadius_snapsToRoundedRectangle() { + // r=55 on 200x150 — very rounded (73% of min side). This is the boundary + // case where hand-drawn rounded rects get misclassified as ellipses. + val (xs, ys) = makeRoundedRect(r = 55f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("r=55 should be RoundedRectangle, got $result", + result is ShapeSnapDetection.SnapResult.RoundedRectangle) + } + + @Test fun roundedRect_nearMaxRadius_snapsToEllipse() { + // r=75 on 200x150 — radius = min(w,h)/2, this IS an ellipse. + val (xs, ys) = makeRoundedRect(r = 75f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("r=75 (full radius) should be Ellipse, got $result", + result is ShapeSnapDetection.SnapResult.Ellipse) + } + + // ── Ovals at various aspect ratios must snap to Ellipse ──────────────────── + + private fun makeOval(a: Float, b: Float, n: Int = 60): Pair { + val xs = FloatArray(n + 1) { i -> (a + a * cos(2 * PI * i / n)).toFloat() } + val ys = FloatArray(n + 1) { i -> (b + b * sin(2 * PI * i / n)).toFloat() } + return xs to ys + } + + @Test fun oval_2to1_40pts_snapsToEllipse() { + val (xs, ys) = makeOval(a = 150f, b = 75f, n = 40) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("2:1 oval (40 pts) should be Ellipse, got $result", + result is ShapeSnapDetection.SnapResult.Ellipse) + } + + @Test fun oval_2to1_60pts_snapsToEllipse() { + val (xs, ys) = makeOval(a = 150f, b = 75f, n = 60) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("2:1 oval (60 pts) should be Ellipse, got $result", + result is ShapeSnapDetection.SnapResult.Ellipse) + } + + @Test fun oval_3to1_snapsToEllipse() { + // Very elongated oval — the long sides have very low curvature. + val (xs, ys) = makeOval(a = 225f, b = 75f, n = 60) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("3:1 oval should be Ellipse, got $result", + result is ShapeSnapDetection.SnapResult.Ellipse) + } + + @Test fun oval_3to1_100pts_snapsToEllipse() { + // More points → finer sampling → long-side segments look even straighter. + val (xs, ys) = makeOval(a = 225f, b = 75f, n = 100) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("3:1 oval (100 pts) should be Ellipse, got $result", + result is ShapeSnapDetection.SnapResult.Ellipse) + } + + @Test fun oval_4to1_snapsToEllipse() { + val (xs, ys) = makeOval(a = 300f, b = 75f, n = 80) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("4:1 oval should be Ellipse, got $result", + result is ShapeSnapDetection.SnapResult.Ellipse) + } + + @Test fun oval_1to3_tall_snapsToEllipse() { + // Tall narrow oval — same problem but in the vertical direction. + val (xs, ys) = makeOval(a = 75f, b = 225f, n = 60) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("1:3 tall oval should be Ellipse, got $result", + result is ShapeSnapDetection.SnapResult.Ellipse) + } + + // ── straightFraction boundary verification ────────────────────────────── + + @Test fun straightFraction_ellipsesBelowThreshold_roundedRectsAbove() { + val thresh = ShapeSnapDetection.STRAIGHT_FRACTION_MIN + + // Ellipses: all must be below threshold + val shapes = listOf( + "circle" to makeOval(100f, 100f, 40), + "oval 2:1" to makeOval(150f, 75f, 40), + "oval 3:1" to makeOval(225f, 75f, 60), + "oval 4:1" to makeOval(300f, 75f, 80), + "oval 1:3" to makeOval(75f, 225f, 60), + "rr75/ellipse" to makeRoundedRect(r = 75f), + ) + val sfValues = mutableListOf() + for ((name, data) in shapes) { + val sf = ShapeSnapDetection.straightFraction(data.first, data.second) + sfValues += "$name=$sf" + assertTrue("$name: SF=$sf should be < $thresh (all: ${sfValues.joinToString()})", sf < thresh) + } + + // Rounded rects: all must be at or above threshold + val rrs = listOf( + "rr20" to makeRoundedRect(r = 20f), + "rr40" to makeRoundedRect(r = 40f), + "rr55" to makeRoundedRect(r = 55f), + ) + val rrValues = mutableListOf() + for ((name, data) in rrs) { + val sf = ShapeSnapDetection.straightFraction(data.first, data.second) + rrValues += "$name=$sf" + assertTrue("$name: SF=$sf should be >= $thresh (all: ${rrValues.joinToString()})", sf >= thresh) + } + } + + // ── Triangle ────────────────────────────────────────────────────────────── + + @Test fun triangleStartingAtCorner_snapsToTriangle() { + // Equilateral triangle where the stroke starts AT corner A=(0,0). + // Corner A is at stroke index 0, which is outside the corner-detection + // window range [window..n-window). With window=3, i=0 is never evaluated, + // so corner A is missed → only 2 corners detected → currently snaps to + // RoundedRectangle (BUG). Expected: Triangle. + val pts = mutableListOf>() + val c = listOf(Pair(0f, 0f), Pair(200f, 0f), Pair(100f, 173f), Pair(0f, 0f)) + for (side in 0 until 3) { + val from = c[side]; val to = c[side + 1] + for (i in 0 until 14) { + val t = i / 14f + pts += Pair(from.first + (to.first - from.first) * t, + from.second + (to.second - from.second) * t) + } + } + pts += c[0] // close + + val xs = pts.map { it.first }.toFloatArray() + val ys = pts.map { it.second }.toFloatArray() + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("Triangle starting at corner should snap", result) + assertTrue("Triangle starting at corner snaps to Triangle, got $result", + result is ShapeSnapDetection.SnapResult.Triangle) + } + + @Test fun equilateralTriangle_snapsToTriangle() { + // Triangle: top=(100,0), bottom-right=(200,173), bottom-left=(0,173). + // Start from mid-bottom (100,173) so all 3 corners are away from the + // boundary of the corner-detection window (corners at ~i=10, 20, 30 of 41). + val pts = mutableListOf>() + val corners = listOf(Pair(100f, 173f), Pair(200f, 173f), Pair(100f, 0f), Pair(0f, 173f)) + // Sides: mid-bottom→BR, BR→top, top→BL, BL→mid-bottom + for (side in 0 until 3) { + val from = corners[side]; val to = corners[side + 1] + for (i in 0 until 10) { + val t = i / 10f + pts += Pair(from.first + (to.first - from.first) * t, + from.second + (to.second - from.second) * t) + } + } + // Last segment: BL → mid-bottom (close) + val from = corners[3]; val to = corners[0] + for (i in 0 until 10) { + val t = i / 10f + pts += Pair(from.first + (to.first - from.first) * t, + from.second + (to.second - from.second) * t) + } + pts += corners[0] // close + + val xs = pts.map { it.first }.toFloatArray() + val ys = pts.map { it.second }.toFloatArray() + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("Triangle should snap", result) + assertTrue("Triangle snaps to Triangle", + result is ShapeSnapDetection.SnapResult.Triangle) + } + + // ── Rectangle ───────────────────────────────────────────────────────────── + + @Test fun freehandRectangleLoop_snapsToRectangle() { + // Simulate a hand-drawn closed rectangle (200×150 px). + // Start from mid-top (100, 0) so all 4 corners fall within the + // corner-detection window range (not cut off at stroke boundaries). + val pts = mutableListOf>() + + fun edge(x0: Float, y0: Float, x1: Float, y1: Float, n: Int) { + for (i in 0 until n) { + val t = i.toFloat() / n + pts += Pair( + x0 + (x1 - x0) * t + (i % 2) * 2f - 1f, + y0 + (y1 - y0) * t + (i % 3) * 1.5f + ) + } + } + + // Clockwise from mid-top: mid(100,0) → TR → BR → BL → TL → mid(100,0) + edge(100f, 0f, 200f, 0f, 5) + edge(200f, 0f, 200f, 150f, 8) + edge(200f, 150f, 0f, 150f, 10) + edge(0f, 150f, 0f, 0f, 8) + edge(0f, 0f, 100f, 0f, 5) + pts += Pair(100f, 0f) // close + + val xs = pts.map { it.first }.toFloatArray() + val ys = pts.map { it.second }.toFloatArray() + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("Freehand rectangle loop should snap", result) + assertTrue("Freehand rectangle loop snaps to Rectangle", + result is ShapeSnapDetection.SnapResult.Rectangle) + } + + // ── detectLine: should snap ─────────────────────────────────────────────── + + @Test fun straightHorizontalLine_snaps() { + val xs = floatArrayOf(0f, 50f, 100f, 150f, 200f) + val ys = floatArrayOf(100f, 100f, 100f, 100f, 100f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull(result) + assertTrue(result is ShapeSnapDetection.SnapResult.Line) + val line = result as ShapeSnapDetection.SnapResult.Line + assertEquals(0f, line.x1, 0.01f) + assertEquals(100f, line.y1, 0.01f) + assertEquals(200f, line.x2, 0.01f) + assertEquals(100f, line.y2, 0.01f) + } + + @Test fun straightVerticalLine_snaps() { + val xs = floatArrayOf(50f, 50f, 50f, 50f, 50f) + val ys = floatArrayOf(0f, 50f, 100f, 150f, 200f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull(result) + assertTrue(result is ShapeSnapDetection.SnapResult.Line) + } + + @Test fun straightDiagonalLine_snaps() { + val n = 6 + val xs = FloatArray(n) { it * 40f } + val ys = FloatArray(n) { it * 40f } + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull(result) + assertTrue(result is ShapeSnapDetection.SnapResult.Line) + } + + @Test fun nearlyStraitLine_smallDeviation_snaps() { + val xs = floatArrayOf(0f, 100f, 150f, 200f, 300f) + val ys = floatArrayOf(0f, 0f, 5f, 0f, 0f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull(result) + assertTrue(result is ShapeSnapDetection.SnapResult.Line) + } + + // ── detectLine: should NOT snap ────────────────────────────────────────── + + @Test fun tooShortLine_notDetected() { + val xs = floatArrayOf(0f, 50f, 100f) + val ys = floatArrayOf(0f, 0f, 0f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNull(result) + } + + @Test fun curvedLine_tooMuchDeviation_notDetected() { + val xs = floatArrayOf(0f, 100f, 200f) + val ys = floatArrayOf(0f, 50f, 0f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNull(result) + } + + @Test fun semicircle_doesNotSnapToLine() { + // Semicircle: path length ≈ 314, straight-line length = 200, ratio ≈ 1.57 > 1.5. + // Must not snap to Line (too much path ratio), but may snap to Arc. + val n = 20 + val xs = FloatArray(n + 1) { (100f * cos(PI * it / n)).toFloat() } + val ys = FloatArray(n + 1) { (-100f * sin(PI * it / n)).toFloat() } + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("Semicircle must not snap to a line, got $result", + result !is ShapeSnapDetection.SnapResult.Line) + } + + @Test fun closedLoop_tooSmallForLine_notDetected() { + val xs = floatArrayOf(50f, 55f, 55f, 50f, 50f) + val ys = floatArrayOf(50f, 50f, 55f, 55f, 50f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNull(result) + } + + // ── Rectangle (minimal point sets, fallback path) ───────────────────────── + + @Test fun cleanRectangularLoop_snaps() { + // 5-point rectangle — uses the small-N fallback (bounding-box detection). + val xs = floatArrayOf(0f, 200f, 200f, 0f, 0f) + val ys = floatArrayOf(0f, 0f, 150f, 150f, 0f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull(result) + assertTrue(result is ShapeSnapDetection.SnapResult.Rectangle) + val rect = result as ShapeSnapDetection.SnapResult.Rectangle + assertEquals(0f, rect.left, 0.01f) + assertEquals(0f, rect.top, 0.01f) + assertEquals(200f, rect.right, 0.01f) + assertEquals(150f, rect.bottom, 0.01f) + } + + @Test fun slightlyRoughRectangularLoop_snaps() { + // 5-point rectangle with small jitter — small-N fallback. + val xs = floatArrayOf(2f, 198f, 202f, 1f, -1f) + val ys = floatArrayOf(1f, -2f, 148f, 152f, 2f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull(result) + assertTrue(result is ShapeSnapDetection.SnapResult.Rectangle) + } + + // ── Rectangle: should NOT snap ──────────────────────────────────────────── + + @Test fun openRectangularStroke_notRect() { + val xs = floatArrayOf(0f, 200f, 200f, 0f) + val ys = floatArrayOf(0f, 0f, 150f, 148f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNull(result) + } + + @Test fun tooSmallRectangle_notDetected() { + val xs = floatArrayOf(0f, 30f, 30f, 0f, 0f) + val ys = floatArrayOf(0f, 0f, 30f, 30f, 0f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNull(result) + } + + @Test fun spiralLoop_notRect() { + val pts = mutableListOf>() + val n = 30; val cx = 100f; val cy = 100f + for (i in 0..n) { + val angle = 4 * PI * i / n + val r = 100f * (1f - i.toFloat() / n) + pts.add(Pair((cx + r * cos(angle)).toFloat(), (cy + r * sin(angle)).toFloat())) + } + val xs = pts.map { it.first }.toFloatArray() + val ys = pts.map { it.second }.toFloatArray() + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNull(result) + } + + // ── Diamond ─────────────────────────────────────────────────────────────── + + @Test fun diamond_snapsToDialmond() { + // 200×160 diamond: top=(100,0), right=(200,80), bottom=(100,160), left=(0,80). + // 10 points per side, closed. + val cx = 100f; val cy = 80f + val vertices = listOf( + Pair(cx, 0f), Pair(200f, cy), Pair(cx, 160f), Pair(0f, cy), Pair(cx, 0f) + ) + val pts = mutableListOf>() + for (side in 0 until 4) { + val from = vertices[side]; val to = vertices[side + 1] + for (i in 0 until 10) { + val t = i / 10f + pts += Pair(from.first + (to.first - from.first) * t, + from.second + (to.second - from.second) * t) + } + } + pts += vertices[0] // close + val xs = pts.map { it.first }.toFloatArray() + val ys = pts.map { it.second }.toFloatArray() + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("Diamond should snap", result) + assertTrue("Diamond snaps to Diamond, got $result", + result is ShapeSnapDetection.SnapResult.Diamond) + } + + @Test fun squareDiamond_snapsToDialmond() { + // Square rotated 45°: diagonal = 200 px + val pts = mutableListOf>() + val vertices = listOf( + Pair(100f, 0f), Pair(200f, 100f), Pair(100f, 200f), Pair(0f, 100f), Pair(100f, 0f) + ) + for (side in 0 until 4) { + val from = vertices[side]; val to = vertices[side + 1] + for (i in 0 until 8) { + val t = i / 8f + pts += Pair(from.first + (to.first - from.first) * t, + from.second + (to.second - from.second) * t) + } + } + pts += vertices[0] + val xs = pts.map { it.first }.toFloatArray() + val ys = pts.map { it.second }.toFloatArray() + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("Square diamond should snap", result) + assertTrue("Square diamond snaps to Diamond, got $result", + result is ShapeSnapDetection.SnapResult.Diamond) + } + + @Test fun axisAlignedRectangle_doesNotSnapToDiamond() { + // Axis-aligned rectangle: corners at bbox corners, not edge midpoints → Rectangle + val xs = FloatArray(41 + 1) + val ys = FloatArray(41 + 1) + val pts = mutableListOf>() + fun edge(x0: Float, y0: Float, x1: Float, y1: Float, n: Int) { + for (i in 0 until n) { + val t = i.toFloat() / n + pts += Pair(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t) + } + } + edge(100f, 0f, 200f, 0f, 5) + edge(200f, 0f, 200f, 150f, 8) + edge(200f, 150f, 0f, 150f, 10) + edge(0f, 150f, 0f, 0f, 8) + edge(0f, 0f, 100f, 0f, 5) + pts += Pair(100f, 0f) + val xsArr = pts.map { it.first }.toFloatArray() + val ysArr = pts.map { it.second }.toFloatArray() + val result = ShapeSnapDetection.detect(xsArr, ysArr, LS) + assertNotNull("Rectangle should snap", result) + assertTrue("Axis-aligned rectangle must NOT snap to Diamond, got $result", + result is ShapeSnapDetection.SnapResult.Rectangle) + } + + // ── Realistic pen input tests ────────────────────────────────────────────── + + private fun jitterX(i: Int) = (sin(i * 7.3) * 1.5).toFloat() + private fun jitterY(i: Int) = (cos(i * 5.7) * 1.5).toFloat() + + @Test fun realisticRectangle_snapsToRectangle() { + // Sharp-cornered rectangle ~200x150 px, clockwise from top-left, 41 points. + val left = 100f; val top = 100f; val right = 300f; val bottom = 250f + val pts = mutableListOf>() + var idx = 0 + + fun addPt(x: Float, y: Float) { + pts += Pair(x + jitterX(idx), y + jitterY(idx)) + idx++ + } + + // Top edge: 10 points + for (i in 0 until 10) { + addPt(left + (right - left) * i / 10f, top) + } + // Top-right corner + addPt(right, top) + // Right edge: 9 points + for (i in 1 until 10) { + addPt(right, top + (bottom - top) * i / 10f) + } + // Bottom-right corner + addPt(right, bottom) + // Bottom edge: 9 points + for (i in 1 until 10) { + addPt(right - (right - left) * i / 10f, bottom) + } + // Bottom-left corner + addPt(left, bottom) + // Left edge: 9 points + for (i in 1 until 10) { + addPt(left, bottom - (bottom - top) * i / 10f) + } + // Close (~3px from start) + pts += Pair(pts[0].first + 1.2f, pts[0].second - 0.8f) + + val xs = pts.map { it.first }.toFloatArray() + val ys = pts.map { it.second }.toFloatArray() + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("Realistic rectangle should snap", result) + assertTrue("Realistic rectangle snaps to Rectangle, got $result", + result is ShapeSnapDetection.SnapResult.Rectangle) + } + + @Test fun realisticRoundedRectangle_snapsToRoundedRectangle() { + // Rounded-corner rectangle ~200x150 px, corner radius 30px, clockwise, 55 points. + val left = 100f; val top = 100f; val right = 300f; val bottom = 250f + val r = 30f + val cl = left + r; val crX = right - r + val ct = top + r; val cb = bottom - r + val arcN = 8 + val pts = mutableListOf>() + var idx = 0 + + fun addPt(x: Float, y: Float) { + pts += Pair(x + jitterX(idx), y + jitterY(idx)) + idx++ + } + + fun arc(cx: Float, cy: Float, startDeg: Double, endDeg: Double) { + for (i in 0 until arcN) { + val angle = Math.toRadians(startDeg + (endDeg - startDeg) * i / arcN) + addPt((cx + r * cos(angle)).toFloat(), (cy + r * sin(angle)).toFloat()) + } + } + + // Top-right arc (-90 to 0) + arc(crX, ct, -90.0, 0.0) + // Right edge: 6 points + for (i in 0 until 6) addPt(right, ct + (cb - ct) * i / 6f) + // Bottom-right arc (0 to 90) + arc(crX, cb, 0.0, 90.0) + // Bottom edge: 6 points + for (i in 0 until 6) addPt(crX - (crX - cl) * i / 6f, bottom) + // Bottom-left arc (90 to 180) + arc(cl, cb, 90.0, 180.0) + // Left edge: 6 points + for (i in 0 until 6) addPt(left, cb - (cb - ct) * i / 6f) + // Top-left arc (180 to 270) + arc(cl, ct, 180.0, 270.0) + // Top edge: 4 points + for (i in 0 until 4) addPt(cl + (crX - cl) * i / 4f, top) + // Close (~3px from start) + pts += Pair(pts[0].first + 1.0f, pts[0].second - 0.5f) + + val xs = pts.map { it.first }.toFloatArray() + val ys = pts.map { it.second }.toFloatArray() + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("Realistic rounded rectangle should snap", result) + assertTrue("Realistic rounded rectangle snaps to RoundedRectangle, got $result", + result is ShapeSnapDetection.SnapResult.RoundedRectangle) + } + + // ── maxPerpendicularDeviation ───────────────────────────────────────────── + + @Test fun perfectLine_zeroDeviation() { + val xs = floatArrayOf(0f, 50f, 100f, 150f, 200f) + val ys = floatArrayOf(0f, 0f, 0f, 0f, 0f) + val dev = ShapeSnapDetection.maxPerpendicularDeviation(xs, ys, 0f, 0f, 200f, 0f) + assertEquals(0f, dev, 0.001f) + } + + @Test fun singlePointOffLine_correctDeviation() { + val xs = floatArrayOf(0f, 100f, 200f) + val ys = floatArrayOf(0f, 10f, 0f) + val dev = ShapeSnapDetection.maxPerpendicularDeviation(xs, ys, 0f, 0f, 200f, 0f) + assertEquals(10f, dev, 0.001f) + } + + @Test fun zeroLengthLine_returnsZero() { + val xs = floatArrayOf(50f, 60f) + val ys = floatArrayOf(50f, 70f) + val dev = ShapeSnapDetection.maxPerpendicularDeviation(xs, ys, 50f, 50f, 50f, 50f) + assertEquals(0f, dev, 0.001f) + } + + @Test fun diagonalLine_correctDeviation() { + val xs = floatArrayOf(0f, 0f, 100f) + val ys = floatArrayOf(0f, 100f, 100f) + val dev = ShapeSnapDetection.maxPerpendicularDeviation(xs, ys, 0f, 0f, 100f, 100f) + val expected = 50f * sqrt(2f) + assertEquals(expected, dev, 0.01f) + } + + // ── False-positive rejection: letter-like closed loops ──────────────────── + + /** + * Builds a "letter B"-like stroke: left spine (x=0) going down from (0,0) to (0,h), + * then two bumps on the right side whose valley at (junctionX, h/2) is interior to + * the bounding box, then back up the spine to close. + * + * Valley point deviation from nearest bbox edge = junctionX = w/3. + * For w=90, h=200: diagonal ≈ 224, ratio ≈ 30/224 = 0.134 > RECT_MAX_POINT_DEV (0.12). + */ + private fun makeBLikeStroke(w: Float = 90f, h: Float = 200f, pts: Int = 30): Pair { + val xs = mutableListOf() + val ys = mutableListOf() + val junctionX = w / 3f + val bumpAmp = w - junctionX + + // Top: spine top (0,0) → junction top (junctionX, 0) + xs.add(0f); ys.add(0f) + xs.add(junctionX); ys.add(0f) + + // Right side: 2 bumps via raised cosine from (junctionX,0) to (junctionX,h) + for (i in 0..pts) { + val t = i.toFloat() / pts + xs.add(junctionX + bumpAmp * (1 - cos(4 * PI * t)).toFloat() / 2) + ys.add(h * t) + } + + // Bottom: junction bottom (junctionX, h) → spine bottom (0, h) + xs.add(0f); ys.add(h) + + // Left spine back up to (0, 0) + for (i in pts downTo 0) { + xs.add(0f) + ys.add(h * i.toFloat() / pts) + } + + return xs.toFloatArray() to ys.toFloatArray() + } + + /** + * Letter P: one bump on right side spanning the top half, spine on left. + * The end of the bump (junctionX, h/2) has deviation = junctionX from nearest edge, + * ratio = junctionX/diagonal ≈ 30/219 = 0.137 > RECT_MAX_POINT_DEV (0.12). + */ + private fun makePLikeStroke(w: Float = 90f, h: Float = 200f, pts: Int = 30): Pair { + val xs = mutableListOf() + val ys = mutableListOf() + val junctionX = w / 3f + val bumpAmp = w - junctionX + val halfH = h / 2f + + // Top edge: spine top (0,0) → junction top (junctionX, 0) + for (i in 0..4) { xs.add(junctionX * i / 4f); ys.add(0f) } + + // Single bump from (junctionX, 0) to (junctionX, halfH) + for (i in 1..pts) { + val t = i.toFloat() / pts + xs.add(junctionX + bumpAmp * (1 - cos(2 * PI * t)).toFloat() / 2) + ys.add(halfH * t) + } + // Now at (junctionX, halfH) — interior point, deviation = junctionX = 30 px + + // Return to spine at mid-height: (junctionX, halfH) → (0, halfH) + for (i in 1..4) { xs.add(junctionX * (1f - i / 4f)); ys.add(halfH) } + + // Spine down: (0, halfH) → (0, h) + for (i in 1..pts / 2) { xs.add(0f); ys.add(halfH + halfH * i / (pts / 2f)) } + + // Spine up: (0, h) → (0, 0) [closes the loop] + for (i in 1..pts) { xs.add(0f); ys.add(h * (1f - i.toFloat() / pts)) } + + return xs.toFloatArray() to ys.toFloatArray() + } + + @Test fun letterB_twoRightBumps_doesNotSnap() { + val (xs, ys) = makeBLikeStroke() + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNull("Letter B should not snap to any shape, got $result", result) + } + + @Test fun letterP_oneRightBump_doesNotSnap() { + val (xs, ys) = makePLikeStroke() + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNull("Letter P should not snap to any shape, got $result", result) + } + + @Test fun wavyClosedLoop_doesNotSnapToRoundedRectangle() { + // Generic horizontally-wavy closed loop: x oscillates 0..w..0..w..0 (2 bumps), + // y goes 0..h top-to-bottom then spine back up. Interior valley has x=w/3. + val (xs, ys) = makeBLikeStroke(w = 120f, h = 180f, pts = 40) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue( + "Wavy closed loop must not snap to RoundedRectangle, got $result", + result !is ShapeSnapDetection.SnapResult.RoundedRectangle + ) + } + + // ── Boundary positives: sloppy shapes that must still snap ──────────────── + + @Test fun tallNarrowRectangle_snapsToRectangle() { + // h/w = 3. Use a full corner-counting path (enough points). + val pts = mutableListOf>() + fun edge(x0: Float, y0: Float, x1: Float, y1: Float, n: Int) { + for (i in 0 until n) { + val t = i.toFloat() / n + pts += Pair(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t) + } + } + edge(50f, 0f, 100f, 0f, 5) + edge(100f, 0f, 100f, 300f, 15) + edge(100f, 300f, 0f, 300f, 5) + edge(0f, 300f, 0f, 0f, 15) + edge(0f, 0f, 50f, 0f, 5) + pts += Pair(50f, 0f) + + val xs = pts.map { it.first }.toFloatArray() + val ys = pts.map { it.second }.toFloatArray() + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("Tall narrow rectangle should snap", result) + assertTrue("Tall narrow rectangle snaps to Rectangle, got $result", + result is ShapeSnapDetection.SnapResult.Rectangle) + } + + @Test fun sloppyRectangle_belowMaxPointDev_snapsToRectangle() { + // Rectangle with moderate jitter — max single-point offset ≈ 8% of diagonal, + // which is below RECT_MAX_POINT_DEV (12%). Should still snap. + // 200×150 rect: diagonal ≈ 250. 8% × 250 = 20 px max jitter. + val pts = mutableListOf>() + fun edge(x0: Float, y0: Float, x1: Float, y1: Float, n: Int) { + for (i in 0 until n) { + val t = i.toFloat() / n + // Jitter perpendicular to edge direction, capped at 18 px (< 20 px limit) + val jitter = if (i % 3 == 1) 10f else 0f + val isHoriz = (y0 == y1) + pts += Pair( + x0 + (x1 - x0) * t + if (!isHoriz) jitter else 0f, + y0 + (y1 - y0) * t + if (isHoriz) jitter else 0f + ) + } + } + edge(100f, 0f, 200f, 0f, 5) + edge(200f, 0f, 200f, 150f, 8) + edge(200f, 150f, 0f, 150f, 10) + edge(0f, 150f, 0f, 0f, 8) + edge(0f, 0f, 100f, 0f, 5) + pts += Pair(100f, 0f) + + val xs = pts.map { it.first }.toFloatArray() + val ys = pts.map { it.second }.toFloatArray() + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("Sloppy rectangle (jitter < maxPointDev) should snap", result) + assertTrue("Sloppy rectangle snaps to Rectangle, got $result", + result is ShapeSnapDetection.SnapResult.Rectangle) + } + + // ── Dwell-gated shape snapping ──────────────────────────────────────────── + // + // Shape snapping requires the user to hold the pen still at the end of the + // stroke. These tests verify the combined dwell + detection pipeline: + // hasDwellAtEnd gates whether ShapeSnapDetection.detect is consulted. + + private val DWELL_RADIUS = 15f + private val DWELL_MS = 300L + + /** Build StrokePoints for a rectangle with timestamps. If [dwellAtEnd], the + * last few points cluster at the close point for >= DWELL_MS. */ + private fun makeTimedRectangle(dwellAtEnd: Boolean): List { + val pts = mutableListOf() + var t = 0L + val step = 15L // 15ms between points — fast drawing + + // Top edge: (0,0) → (200,0) + for (i in 0..9) { pts += StrokePoint(i * 20f, 0f, 1f, t); t += step } + // Right edge: (200,0) → (200,150) + for (i in 1..7) { pts += StrokePoint(200f, i * 150f / 7, 1f, t); t += step } + // Bottom edge: (200,150) → (0,150) + for (i in 1..9) { pts += StrokePoint(200f - i * 20f, 150f, 1f, t); t += step } + // Left edge: (0,150) → (0,0) + for (i in 1..7) { pts += StrokePoint(0f, 150f - i * 150f / 7, 1f, t); t += step } + // Close point + pts += StrokePoint(1f, 1f, 1f, t); t += step + + if (dwellAtEnd) { + // Add dwell: 5 points clustered at (1,1) spanning 350ms (> DWELL_MS) + for (i in 1..5) { + pts += StrokePoint(1f + i * 0.5f, 1f + i * 0.3f, 1f, t) + t += 70L + } + } + + return pts + } + + @Test fun rectangle_withEndDwell_snapsToShape() { + val pts = makeTimedRectangle(dwellAtEnd = true) + val last = pts.last() + val hasDwell = ArrowDwellDetection.hasDwellAtEnd(pts, last.x, last.y, DWELL_RADIUS, DWELL_MS) + assertTrue("Should detect end dwell", hasDwell) + + // Since dwell is present, shape detection should proceed + val xs = FloatArray(pts.size) { pts[it].x } + val ys = FloatArray(pts.size) { pts[it].y } + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("Rectangle with dwell should snap", result) + assertTrue("Should snap to Rectangle, got $result", + result is ShapeSnapDetection.SnapResult.Rectangle) + } + + @Test fun rectangle_withoutEndDwell_doesNotSnap() { + val pts = makeTimedRectangle(dwellAtEnd = false) + val last = pts.last() + val hasDwell = ArrowDwellDetection.hasDwellAtEnd(pts, last.x, last.y, DWELL_RADIUS, DWELL_MS) + assertFalse("Should NOT detect end dwell", hasDwell) + + // The shape IS a valid rectangle geometrically... + val xs = FloatArray(pts.size) { pts[it].x } + val ys = FloatArray(pts.size) { pts[it].y } + val shapeResult = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("Shape IS a rectangle geometrically", shapeResult) + + // ...but the dwell gate blocks snapping + val gatedResult = if (hasDwell) shapeResult else null + assertNull("Without dwell, shape snapping should not activate", gatedResult) + } + + // ── Elbow detection ───────────────────────────────────────────────────── + + /** Build an L-shaped stroke from (x0,y0) → corner → (x1,y1) with n points per leg. */ + private fun makeElbow( + x0: Float, y0: Float, cx: Float, cy: Float, x1: Float, y1: Float, + pointsPerLeg: Int = 15 + ): Pair { + val xs = mutableListOf() + val ys = mutableListOf() + for (i in 0 until pointsPerLeg) { + val t = i.toFloat() / pointsPerLeg + xs += x0 + (cx - x0) * t + ys += y0 + (cy - y0) * t + } + for (i in 0..pointsPerLeg) { + val t = i.toFloat() / pointsPerLeg + xs += cx + (x1 - cx) * t + ys += cy + (y1 - cy) * t + } + return xs.toFloatArray() to ys.toFloatArray() + } + + @Test fun rightAngleElbow_horizontal_then_vertical_snaps() { + // L-shape: (0,0) → (200,0) → (200,200) + val (xs, ys) = makeElbow(0f, 0f, 200f, 0f, 200f, 200f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("L-shaped stroke should snap to Elbow", result) + assertTrue("Should be Elbow, got $result", result is ShapeSnapDetection.SnapResult.Elbow) + val elbow = result as ShapeSnapDetection.SnapResult.Elbow + assertEquals(0f, elbow.x1, 1f) + assertEquals(0f, elbow.y1, 1f) + assertEquals(200f, elbow.x2, 1f) + assertEquals(200f, elbow.y2, 1f) + } + + @Test fun rightAngleElbow_vertical_then_horizontal_snaps() { + // L-shape: (0,0) → (0,200) → (200,200) + val (xs, ys) = makeElbow(0f, 0f, 0f, 200f, 200f, 200f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("Vertical-then-horizontal L should snap to Elbow", result) + assertTrue("Should be Elbow, got $result", result is ShapeSnapDetection.SnapResult.Elbow) + } + + @Test fun elbowWithAcuteAngle_doesNotSnap() { + // Angle < 60° — too sharp for an elbow + // V-shape: (0,0) → (100,200) → (50,0) + val (xs, ys) = makeElbow(0f, 0f, 100f, 200f, 50f, 0f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("Acute angle should not snap to Elbow, got $result", + result !is ShapeSnapDetection.SnapResult.Elbow) + } + + @Test fun straightLine_doesNotSnapToElbow() { + val xs = FloatArray(20) { it * 15f } + val ys = FloatArray(20) { 100f } + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("Straight line must not snap to Elbow, got $result", + result !is ShapeSnapDetection.SnapResult.Elbow) + } + + // ── Arc detection ────────────────────────────────────────────────────── + + /** Build a circular arc stroke from startAngle to endAngle (radians) with given center and radius. */ + private fun makeArcStroke( + cx: Float, cy: Float, radius: Float, + startAngle: Double, endAngle: Double, + n: Int = 30 + ): Pair { + val xs = FloatArray(n + 1) { i -> + val angle = startAngle + (endAngle - startAngle) * i / n + (cx + radius * cos(angle)).toFloat() + } + val ys = FloatArray(n + 1) { i -> + val angle = startAngle + (endAngle - startAngle) * i / n + (cy + radius * sin(angle)).toFloat() + } + return xs to ys + } + + @Test fun semicircularArc_snapsToArc() { + // Half circle: 180° arc, radius 100 + val (xs, ys) = makeArcStroke(100f, 100f, 100f, 0.0, PI, 30) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("Semicircular arc should snap", result) + assertTrue("Semicircle should snap to Arc, got $result", + result is ShapeSnapDetection.SnapResult.Arc) + } + + @Test fun quarterCircleArc_snapsToArc() { + // Quarter circle: 90° arc, radius 150 + val (xs, ys) = makeArcStroke(0f, 0f, 150f, 0.0, PI / 2, 25) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("Quarter-circle arc should snap", result) + assertTrue("Quarter-circle should snap to Arc, got $result", + result is ShapeSnapDetection.SnapResult.Arc) + } + + @Test fun shallowArc_snapsToArc() { + // Shallow arc: 60° sweep, radius 200 + val (xs, ys) = makeArcStroke(0f, 0f, 200f, -PI / 6, PI / 6, 25) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("Shallow arc should snap", result) + assertTrue("Shallow arc should snap to Arc, got $result", + result is ShapeSnapDetection.SnapResult.Arc) + } + + @Test fun closedLoop_doesNotSnapToArc() { + // Full circle — closed, should not snap to arc + val n = 40 + val xs = FloatArray(n + 1) { i -> (100 + 100 * cos(2 * PI * i / n)).toFloat() } + val ys = FloatArray(n + 1) { i -> (100 + 100 * sin(2 * PI * i / n)).toFloat() } + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("Closed loop must not snap to Arc, got $result", + result !is ShapeSnapDetection.SnapResult.Arc) + } + + @Test fun straightLine_doesNotSnapToArc() { + val xs = FloatArray(20) { it * 15f } + val ys = FloatArray(20) { 100f } + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("Straight line must not snap to Arc, got $result", + result !is ShapeSnapDetection.SnapResult.Arc) + } + + @Test fun selfReferentialArc_shortChord_snapsToArc() { + // Arc from one side of a circle to another — chord is short (80px) + // but the arc extends 120px outward. + val (xs2, ys2) = makeArcStroke(0f, 0f, 100f, -PI / 8, PI / 8, 25) + // chord ≈ 76.5px < LS (118px), but diagonal ≈ 100px > 59px + val result = ShapeSnapDetection.detect(xs2, ys2, LS) + assertNotNull("Short-chord self-referential arc should snap", result) + assertTrue("Should be Arc, got $result", + result is ShapeSnapDetection.SnapResult.Arc) + } + + @Test fun selfReferentialArc_realPenFixture_snapsToArc() { + // Downsampled real pen data: U-shaped arc from one side of a circle to another. + // Start ≈ (651, 2629), loops right to (721, 2653), then left-down to (656, 2567), + // back up to (656, 2584). Chord ≈ 43px, diagonal ≈ 111px. + // Originally 553 points; downsampled every ~18th point for the moving portion. + val xs = floatArrayOf( + 651.0f, 652.4f, 653.6f, 655.0f, 657.4f, 660.5f, 664.7f, + 668.7f, 673.2f, 677.8f, 682.3f, 686.5f, 690.3f, 695.4f, + 700.6f, 705.5f, 710.5f, 714.4f, 717.8f, 720.0f, 721.2f, + 721.2f, 720.4f, 717.2f, 713.1f, 709.9f, 706.3f, 702.4f, + 698.4f, 694.4f, 690.9f, 686.9f, 684.5f, 680.4f, 676.2f, + 672.0f, 668.1f, 665.7f, 660.3f, 656.2f + ) + val ys = floatArrayOf( + 2629.0f, 2635.2f, 2638.8f, 2641.3f, 2643.3f, 2646.5f, 2649.2f, + 2650.8f, 2652.0f, 2653.0f, 2653.6f, 2653.6f, 2652.6f, 2649.2f, + 2646.1f, 2642.9f, 2639.3f, 2635.4f, 2630.6f, 2625.9f, 2620.5f, + 2614.0f, 2608.0f, 2601.7f, 2596.2f, 2591.0f, 2585.3f, 2580.5f, + 2575.8f, 2572.0f, 2569.2f, 2567.0f, 2566.8f, 2567.4f, 2568.8f, + 2572.0f, 2575.4f, 2576.7f, 2580.7f, 2584.1f + ) + val result = ShapeSnapDetection.detect(xs, ys, 77f) + assertNotNull("Real pen self-referential arc should snap, got null", result) + assertTrue("Should be Arc or Curve, got $result", + result is ShapeSnapDetection.SnapResult.Arc || + result is ShapeSnapDetection.SnapResult.Curve) + } + + @Test fun zigzag_doesNotSnapToArc() { + // Zigzag: has corners, should not snap to arc + val xs = mutableListOf() + val ys = mutableListOf() + for (i in 0..30) { + xs += i * 10f + ys += if (i % 2 == 0) 0f else 50f + } + val result = ShapeSnapDetection.detect(xs.toFloatArray(), ys.toFloatArray(), LS) + assertTrue("Zigzag must not snap to Arc, got $result", + result !is ShapeSnapDetection.SnapResult.Arc) + } + + // ── Self-loop detection ────────────────────────────────────────────────── + + @Test fun nearCompleteCircle_snapsToSelfLoop() { + // 300° arc (5/6 of a circle), radius 100 + val n = 40 + val sweepRad = 300.0 * PI / 180.0 + val xs = FloatArray(n + 1) { i -> (100 + 100 * cos(sweepRad * i / n)).toFloat() } + val ys = FloatArray(n + 1) { i -> (100 + 100 * sin(sweepRad * i / n)).toFloat() } + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("300° arc should snap to SelfLoop", result) + assertTrue("Should be SelfLoop, got $result", + result is ShapeSnapDetection.SnapResult.SelfLoop) + } + + @Test fun nearCompleteOval_snapsToSelfLoop() { + // 300° oval arc, rx=150, ry=80 + val n = 40 + val sweepRad = 300.0 * PI / 180.0 + val xs = FloatArray(n + 1) { i -> (150 + 150 * cos(sweepRad * i / n)).toFloat() } + val ys = FloatArray(n + 1) { i -> (80 + 80 * sin(sweepRad * i / n)).toFloat() } + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("300° oval should snap to SelfLoop", result) + assertTrue("Should be SelfLoop, got $result", + result is ShapeSnapDetection.SnapResult.SelfLoop) + } + + @Test fun fullClosedCircle_doesNotSnapToSelfLoop() { + // Full closed circle should snap to Ellipse, not SelfLoop + val n = 40 + val xs = FloatArray(n + 1) { i -> (100 + 100 * cos(2 * PI * i / n)).toFloat() } + val ys = FloatArray(n + 1) { i -> (100 + 100 * sin(2 * PI * i / n)).toFloat() } + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("Full circle should be Ellipse not SelfLoop, got $result", + result is ShapeSnapDetection.SnapResult.Ellipse) + } + + @Test fun halfCircle_doesNotSnapToSelfLoop() { + // 180° arc — gap too large for self-loop (ratio > 0.75) + val (xs, ys) = makeArcStroke(100f, 100f, 100f, 0.0, PI, 30) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("Half circle should not be SelfLoop, got $result", + result !is ShapeSnapDetection.SnapResult.SelfLoop) + } + + @Test fun zigzagNearClosed_doesNotSnapToSelfLoop() { + // Near-closed zigzag: has corners, should not be SelfLoop + val xs = mutableListOf() + val ys = mutableListOf() + for (i in 0..30) { + xs += 100f + 80f * cos(2 * PI * i / 30).toFloat() + if (i % 2 == 0) 20f else -20f + ys += 100f + 80f * sin(2 * PI * i / 30).toFloat() + } + val result = ShapeSnapDetection.detect(xs.toFloatArray(), ys.toFloatArray(), LS) + assertTrue("Zigzag near-closed loop must not snap to SelfLoop, got $result", + result !is ShapeSnapDetection.SnapResult.SelfLoop) + } +} diff --git a/app/src/test/java/com/writer/view/TouchFilterTest.kt b/app/src/test/java/com/writer/view/TouchFilterTest.kt new file mode 100644 index 0000000..5393a04 --- /dev/null +++ b/app/src/test/java/com/writer/view/TouchFilterTest.kt @@ -0,0 +1,283 @@ +package com.writer.view + +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +/** + * Unit tests for [TouchFilter]. + * + * Uses the plain-float [ScreenMetrics.init] overload so no Android framework + * dependency is needed — these run on the JVM with plain JUnit. + */ +class TouchFilterTest { + + companion object { + private const val DENSITY = 1.875f // 300 PPI Boox devices + } + + private lateinit var filter: TouchFilter + + @Before + fun setUp() { + ScreenMetrics.init(DENSITY, smallestWidthDp = 674, widthPixels = 1264, heightPixels = 1680) + filter = TouchFilter( + palmSizeThresholdDp = 40f, + penCooldownMs = 150L, + stationarySlopDp = 8f, + stationaryTimeoutMs = 200L, + ) + } + + // ── Layer 1: Pen active ──────────────────────────────────────────────── + + @Test + fun rejects_finger_when_pen_is_down() { + filter.penActive = true + val result = filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + assertEquals(TouchFilter.Decision.REJECT, result) + } + + @Test + fun accepts_finger_when_pen_is_not_down() { + filter.penActive = false + val result = filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + assertEquals(TouchFilter.Decision.ACCEPT, result) + } + + // ── Layer 2: Pen cooldown ────────────────────────────────────────────── + + @Test + fun rejects_finger_during_pen_cooldown() { + filter.penUpTimestamp = 900L + val result = filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + assertEquals(TouchFilter.Decision.REJECT, result) + } + + @Test + fun accepts_finger_after_pen_cooldown() { + filter.penUpTimestamp = 800L + val result = filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + assertEquals(TouchFilter.Decision.ACCEPT, result) + } + + @Test + fun accepts_finger_exactly_at_cooldown_boundary() { + filter.penUpTimestamp = 850L + val result = filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + assertEquals(TouchFilter.Decision.ACCEPT, result) + } + + // ── Layer 3: Palm size ───────────────────────────────────────────────── + + @Test + fun rejects_large_touch_area() { + val result = filter.evaluateDown( + pointerCount = 1, touchMinorDp = 50f, eventTime = 1000L, x = 100f, y = 100f + ) + assertEquals(TouchFilter.Decision.REJECT, result) + } + + @Test + fun accepts_small_touch_area() { + val result = filter.evaluateDown( + pointerCount = 1, touchMinorDp = 15f, eventTime = 1000L, x = 100f, y = 100f + ) + assertEquals(TouchFilter.Decision.ACCEPT, result) + } + + @Test + fun rejects_touch_at_exact_threshold() { + val result = filter.evaluateDown( + pointerCount = 1, touchMinorDp = 40.1f, eventTime = 1000L, x = 100f, y = 100f + ) + assertEquals(TouchFilter.Decision.REJECT, result) + } + + // ── Layer 4: Multi-touch ─────────────────────────────────────────────── + + @Test + fun rejects_multi_touch() { + val result = filter.evaluateDown( + pointerCount = 2, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + assertEquals(TouchFilter.Decision.REJECT, result) + } + + // ── Layer 5: Stationary timeout (canvas) ─────────────────────────────── + + @Test + fun rejects_stationary_finger_on_canvas_after_timeout() { + filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + // Finger hasn't moved, 250ms later + val result = filter.evaluateMove( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1250L, + x = 100f, y = 100f, checkStationary = true + ) + assertEquals(TouchFilter.Decision.REJECT, result) + } + + @Test + fun accepts_stationary_finger_on_text_view() { + filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + // Finger hasn't moved, 250ms later — but checkStationary is false + val result = filter.evaluateMove( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1250L, + x = 100f, y = 100f, checkStationary = false + ) + assertEquals(TouchFilter.Decision.ACCEPT, result) + } + + @Test + fun accepts_moving_finger_on_canvas_before_timeout() { + filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + // Finger hasn't moved much yet, within timeout + val result = filter.evaluateMove( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1100L, + x = 101f, y = 101f, checkStationary = true + ) + assertEquals(TouchFilter.Decision.ACCEPT, result) + } + + @Test + fun accepts_finger_that_moves_past_slop_on_canvas() { + filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + // Move well past slop (8dp * 1.875 = 15px, move 50px) + val result = filter.evaluateMove( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1300L, + x = 100f, y = 150f, checkStationary = true + ) + assertEquals(TouchFilter.Decision.ACCEPT, result) + } + + // ── Move-time checks ─────────────────────────────────────────────────── + + @Test + fun rejects_move_when_pen_goes_down() { + filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + filter.penActive = true + val result = filter.evaluateMove( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1050L, + x = 100f, y = 150f, checkStationary = false + ) + assertEquals(TouchFilter.Decision.REJECT, result) + } + + @Test + fun rejects_move_when_touch_grows() { + filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + val result = filter.evaluateMove( + pointerCount = 1, touchMinorDp = 50f, eventTime = 1050L, + x = 100f, y = 150f, checkStationary = false + ) + assertEquals(TouchFilter.Decision.REJECT, result) + } + + @Test + fun rejects_move_when_second_finger_appears() { + filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + val result = filter.evaluateMove( + pointerCount = 2, touchMinorDp = 10f, eventTime = 1050L, + x = 100f, y = 150f, checkStationary = false + ) + assertEquals(TouchFilter.Decision.REJECT, result) + } + + // ── hasMovedPastSlop ─────────────────────────────────────────────────── + + @Test + fun hasMovedPastSlop_false_initially() { + filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + assertEquals(false, filter.hasMovedPastSlop()) + } + + @Test + fun hasMovedPastSlop_true_after_large_move() { + filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + filter.evaluateMove( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1050L, + x = 100f, y = 150f, checkStationary = true + ) + assertEquals(true, filter.hasMovedPastSlop()) + } + + // ── Scroll acceptance (full sequence) ────────────────────────────────── + + @Test + fun full_scroll_sequence_accepted() { + // Down + assertEquals( + TouchFilter.Decision.ACCEPT, + filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 400f, y = 500f + ) + ) + // Small move (within slop) + assertEquals( + TouchFilter.Decision.ACCEPT, + filter.evaluateMove( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1020L, + x = 400f, y = 505f, checkStationary = true + ) + ) + assertEquals(false, filter.hasMovedPastSlop()) + + // Larger move (past slop) + assertEquals( + TouchFilter.Decision.ACCEPT, + filter.evaluateMove( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1040L, + x = 400f, y = 550f, checkStationary = true + ) + ) + assertEquals(true, filter.hasMovedPastSlop()) + } + + // ── Tap acceptance on text view ──────────────────────────────────────── + + @Test + fun tap_accepted_on_text_view() { + assertEquals( + TouchFilter.Decision.ACCEPT, + filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 200f, y = 300f + ) + ) + // Stationary for 300ms — but text view doesn't check stationary + assertEquals( + TouchFilter.Decision.ACCEPT, + filter.evaluateMove( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1300L, + x = 200f, y = 300f, checkStationary = false + ) + ) + } +} diff --git a/app/src/test/resources/fixtures/bug-report-scratch-out.json b/app/src/test/resources/fixtures/bug-report-scratch-out.json new file mode 100644 index 0000000..158c3c9 --- /dev/null +++ b/app/src/test/resources/fixtures/bug-report-scratch-out.json @@ -0,0 +1,5806 @@ +{ + "version": 1, + "device": { + "model": "Palma2_Pro_C", + "manufacturer": "ONYX", + "osVersion": 35, + "density": 1.875, + "lineSpacing": 77, + "topMargin": 36, + "isCompact": true + }, + "recentStrokes": [ + { + "index": 0, + "timestampMs": 1774182439622, + "points": [ + { + "x": 83.01422119140625, + "y": 385.94000244140625, + "pressure": 49, + "timestamp": 1774182439582 + }, + { + "x": 83.01422119140625, + "y": 385.94000244140625, + "pressure": 35, + "timestamp": 1774182439615 + } + ] + }, + { + "index": 1, + "timestampMs": 1774182440327, + "points": [ + { + "x": 80.63671875, + "y": 382.968505859375, + "pressure": 15, + "timestamp": 1774182439698 + }, + { + "x": 78.45733642578125, + "y": 414.86273193359375, + "pressure": 2002, + "timestamp": 1774182439970 + }, + { + "x": 81.6273193359375, + "y": 410.90069580078125, + "pressure": 2028, + "timestamp": 1774182440071 + }, + { + "x": 84.9954833984375, + "y": 404.56146240234375, + "pressure": 2011, + "timestamp": 1774182440115 + }, + { + "x": 90.14666748046875, + "y": 401.98614501953125, + "pressure": 2054, + "timestamp": 1774182440193 + }, + { + "x": 92.7222900390625, + "y": 406.54248046875, + "pressure": 2085, + "timestamp": 1774182440203 + }, + { + "x": 95.892333984375, + "y": 420.21142578125, + "pressure": 1761, + "timestamp": 1774182440280 + }, + { + "x": 98.0716552734375, + "y": 421.99432373046875, + "pressure": 43, + "timestamp": 1774182440323 + } + ] + }, + { + "index": 2, + "timestampMs": 1774182440757, + "points": [ + { + "x": 107.383544921875, + "y": 418.82470703125, + "pressure": 199, + "timestamp": 1774182440436 + }, + { + "x": 114.1197509765625, + "y": 412.68359375, + "pressure": 1385, + "timestamp": 1774182440529 + }, + { + "x": 117.09161376953125, + "y": 408.52349853515625, + "pressure": 1486, + "timestamp": 1774182440540 + }, + { + "x": 118.2803955078125, + "y": 404.165283203125, + "pressure": 1832, + "timestamp": 1774182440585 + }, + { + "x": 117.8841552734375, + "y": 402.5804443359375, + "pressure": 1928, + "timestamp": 1774182440611 + }, + { + "x": 115.30853271484375, + "y": 401.98614501953125, + "pressure": 1937, + "timestamp": 1774182440617 + }, + { + "x": 112.13848876953125, + "y": 402.9766845703125, + "pressure": 1951, + "timestamp": 1774182440636 + }, + { + "x": 110.15728759765625, + "y": 405.15576171875, + "pressure": 1929, + "timestamp": 1774182440646 + }, + { + "x": 107.9779052734375, + "y": 410.306396484375, + "pressure": 1887, + "timestamp": 1774182440662 + }, + { + "x": 108.968505859375, + "y": 414.2684326171875, + "pressure": 1834, + "timestamp": 1774182440679 + }, + { + "x": 114.5159912109375, + "y": 417.6361083984375, + "pressure": 1804, + "timestamp": 1774182440699 + }, + { + "x": 119.27099609375, + "y": 417.6361083984375, + "pressure": 1790, + "timestamp": 1774182440715 + }, + { + "x": 123.2335205078125, + "y": 416.6456298828125, + "pressure": 1743, + "timestamp": 1774182440731 + }, + { + "x": 126.40350341796875, + "y": 414.86273193359375, + "pressure": 727, + "timestamp": 1774182440754 + } + ] + }, + { + "index": 3, + "timestampMs": 1774182441093, + "points": [ + { + "x": 135.9134521484375, + "y": 371.6767578125, + "pressure": 163, + "timestamp": 1774182440917 + }, + { + "x": 133.93218994140625, + "y": 395.250732421875, + "pressure": 1748, + "timestamp": 1774182441009 + }, + { + "x": 133.93218994140625, + "y": 406.3443603515625, + "pressure": 1578, + "timestamp": 1774182441045 + }, + { + "x": 134.7247314453125, + "y": 410.90069580078125, + "pressure": 68, + "timestamp": 1774182441091 + } + ] + }, + { + "index": 4, + "timestampMs": 1774182441929, + "points": [ + { + "x": 150.57470703125, + "y": 376.6292724609375, + "pressure": 380, + "timestamp": 1774182441302 + }, + { + "x": 147.4046630859375, + "y": 404.56146240234375, + "pressure": 1762, + "timestamp": 1774182441446 + }, + { + "x": 147.4046630859375, + "y": 411.296875, + "pressure": 1646, + "timestamp": 1774182441468 + }, + { + "x": 148.19720458984375, + "y": 414.0703125, + "pressure": 1294, + "timestamp": 1774182441507 + }, + { + "x": 150.970947265625, + "y": 415.85321044921875, + "pressure": 1088, + "timestamp": 1774182441524 + }, + { + "x": 154.14093017578125, + "y": 416.05133056640625, + "pressure": 921, + "timestamp": 1774182441548 + }, + { + "x": 156.9146728515625, + "y": 414.2684326171875, + "pressure": 852, + "timestamp": 1774182441565 + }, + { + "x": 159.68841552734375, + "y": 409.1177978515625, + "pressure": 932, + "timestamp": 1774182441594 + }, + { + "x": 159.09405517578125, + "y": 413.079833984375, + "pressure": 1548, + "timestamp": 1774182441711 + }, + { + "x": 159.88653564453125, + "y": 415.85321044921875, + "pressure": 1623, + "timestamp": 1774182441755 + }, + { + "x": 163.0565185546875, + "y": 417.834228515625, + "pressure": 1692, + "timestamp": 1774182441779 + }, + { + "x": 166.22650146484375, + "y": 417.23992919921875, + "pressure": 1891, + "timestamp": 1774182441813 + }, + { + "x": 168.207763671875, + "y": 414.46649169921875, + "pressure": 1975, + "timestamp": 1774182441828 + }, + { + "x": 169.000244140625, + "y": 408.919677734375, + "pressure": 2089, + "timestamp": 1774182441851 + }, + { + "x": 168.0096435546875, + "y": 405.3538818359375, + "pressure": 2128, + "timestamp": 1774182441897 + }, + { + "x": 164.83966064453125, + "y": 404.56146240234375, + "pressure": 1606, + "timestamp": 1774182441926 + } + ] + }, + { + "index": 5, + "timestampMs": 1774182443227, + "points": [ + { + "x": 213.57830810546875, + "y": 399.0146484375, + "pressure": 562, + "timestamp": 1774182442742 + }, + { + "x": 211.002685546875, + "y": 407.92919921875, + "pressure": 1555, + "timestamp": 1774182442847 + }, + { + "x": 210.6064453125, + "y": 414.86273193359375, + "pressure": 1411, + "timestamp": 1774182442893 + }, + { + "x": 212.98388671875, + "y": 415.2589111328125, + "pressure": 1472, + "timestamp": 1774182442909 + }, + { + "x": 217.7388916015625, + "y": 412.08929443359375, + "pressure": 1544, + "timestamp": 1774182442925 + }, + { + "x": 220.90887451171875, + "y": 407.13677978515625, + "pressure": 1576, + "timestamp": 1774182442940 + }, + { + "x": 223.8807373046875, + "y": 399.80706787109375, + "pressure": 1595, + "timestamp": 1774182442961 + }, + { + "x": 222.2957763671875, + "y": 407.13677978515625, + "pressure": 1746, + "timestamp": 1774182443023 + }, + { + "x": 222.6920166015625, + "y": 413.67413330078125, + "pressure": 1707, + "timestamp": 1774182443051 + }, + { + "x": 225.26763916015625, + "y": 416.6456298828125, + "pressure": 1725, + "timestamp": 1774182443071 + }, + { + "x": 229.2301025390625, + "y": 417.6361083984375, + "pressure": 1819, + "timestamp": 1774182443086 + }, + { + "x": 233.985107421875, + "y": 416.05133056640625, + "pressure": 1926, + "timestamp": 1774182443100 + }, + { + "x": 237.55133056640625, + "y": 412.08929443359375, + "pressure": 1998, + "timestamp": 1774182443116 + }, + { + "x": 240.126953125, + "y": 407.33489990234375, + "pressure": 2024, + "timestamp": 1774182443129 + }, + { + "x": 241.91009521484375, + "y": 400.2032470703125, + "pressure": 2063, + "timestamp": 1774182443150 + }, + { + "x": 241.11761474609375, + "y": 392.6754150390625, + "pressure": 697, + "timestamp": 1774182443225 + } + ] + }, + { + "index": 6, + "timestampMs": 1774182443853, + "points": [ + { + "x": 263.90191650390625, + "y": 398.22222900390625, + "pressure": 478, + "timestamp": 1774182443548 + }, + { + "x": 257.16571044921875, + "y": 407.532958984375, + "pressure": 1560, + "timestamp": 1774182443663 + }, + { + "x": 255.9769287109375, + "y": 410.5045166015625, + "pressure": 1564, + "timestamp": 1774182443672 + }, + { + "x": 255.77880859375, + "y": 411.693115234375, + "pressure": 1636, + "timestamp": 1774182443695 + }, + { + "x": 258.55255126953125, + "y": 413.079833984375, + "pressure": 1786, + "timestamp": 1774182443709 + }, + { + "x": 262.51507568359375, + "y": 411.693115234375, + "pressure": 1909, + "timestamp": 1774182443728 + }, + { + "x": 265.09063720703125, + "y": 408.919677734375, + "pressure": 1960, + "timestamp": 1774182443744 + }, + { + "x": 267.0718994140625, + "y": 405.75006103515625, + "pressure": 2004, + "timestamp": 1774182443757 + }, + { + "x": 267.8643798828125, + "y": 400.5994873046875, + "pressure": 2042, + "timestamp": 1774182443776 + }, + { + "x": 266.873779296875, + "y": 397.03363037109375, + "pressure": 2070, + "timestamp": 1774182443793 + }, + { + "x": 265.68505859375, + "y": 395.4488525390625, + "pressure": 2078, + "timestamp": 1774182443806 + }, + { + "x": 263.30755615234375, + "y": 394.26025390625, + "pressure": 1832, + "timestamp": 1774182443819 + }, + { + "x": 259.54315185546875, + "y": 394.85455322265625, + "pressure": 1586, + "timestamp": 1774182443833 + }, + { + "x": 256.967529296875, + "y": 397.6279296875, + "pressure": 477, + "timestamp": 1774182443851 + } + ] + }, + { + "index": 7, + "timestampMs": 1774182444269, + "points": [ + { + "x": 279.15753173828125, + "y": 395.4488525390625, + "pressure": 273, + "timestamp": 1774182443965 + }, + { + "x": 278.95941162109375, + "y": 415.45703125, + "pressure": 1618, + "timestamp": 1774182444058 + }, + { + "x": 284.70501708984375, + "y": 405.94818115234375, + "pressure": 2024, + "timestamp": 1774182444150 + }, + { + "x": 286.88433837890625, + "y": 400.79754638671875, + "pressure": 2098, + "timestamp": 1774182444207 + }, + { + "x": 290.45062255859375, + "y": 401.391845703125, + "pressure": 2079, + "timestamp": 1774182444230 + }, + { + "x": 297.18682861328125, + "y": 400.99566650390625, + "pressure": 155, + "timestamp": 1774182444268 + } + ] + }, + { + "index": 8, + "timestampMs": 1774182444593, + "points": [ + { + "x": 313.63116455078125, + "y": 365.139404296875, + "pressure": 760, + "timestamp": 1774182444463 + }, + { + "x": 304.715576171875, + "y": 400.99566650390625, + "pressure": 1859, + "timestamp": 1774182444540 + }, + { + "x": 305.11181640625, + "y": 416.84375, + "pressure": 55, + "timestamp": 1774182444589 + } + ] + }, + { + "index": 9, + "timestampMs": 1774182445104, + "points": [ + { + "x": 327.4999084472656, + "y": 406.3443603515625, + "pressure": 478, + "timestamp": 1774182444703 + }, + { + "x": 322.7449035644531, + "y": 409.71209716796875, + "pressure": 1907, + "timestamp": 1774182444816 + }, + { + "x": 317.98992919921875, + "y": 414.66461181640625, + "pressure": 1893, + "timestamp": 1774182444836 + }, + { + "x": 317.98992919921875, + "y": 415.45703125, + "pressure": 1886, + "timestamp": 1774182444849 + }, + { + "x": 319.9711608886719, + "y": 415.85321044921875, + "pressure": 1913, + "timestamp": 1774182444865 + }, + { + "x": 326.905517578125, + "y": 413.27789306640625, + "pressure": 1945, + "timestamp": 1774182444881 + }, + { + "x": 332.2548828125, + "y": 408.72161865234375, + "pressure": 1961, + "timestamp": 1774182444897 + }, + { + "x": 337.8023681640625, + "y": 399.80706787109375, + "pressure": 1987, + "timestamp": 1774182444915 + }, + { + "x": 340.77423095703125, + "y": 391.48681640625, + "pressure": 2005, + "timestamp": 1774182444929 + }, + { + "x": 343.349853515625, + "y": 375.440673828125, + "pressure": 2037, + "timestamp": 1774182444956 + }, + { + "x": 343.5479736328125, + "y": 369.89385986328125, + "pressure": 2059, + "timestamp": 1774182444967 + }, + { + "x": 342.1611022949219, + "y": 369.89385986328125, + "pressure": 2330, + "timestamp": 1774182444997 + }, + { + "x": 338.7929992675781, + "y": 372.27105712890625, + "pressure": 2327, + "timestamp": 1774182445006 + }, + { + "x": 332.4530029296875, + "y": 383.36468505859375, + "pressure": 2330, + "timestamp": 1774182445022 + }, + { + "x": 327.8961486816406, + "y": 401.98614501953125, + "pressure": 2338, + "timestamp": 1774182445042 + }, + { + "x": 328.0942687988281, + "y": 410.70257568359375, + "pressure": 2339, + "timestamp": 1774182445052 + }, + { + "x": 329.87738037109375, + "y": 416.05133056640625, + "pressure": 2290, + "timestamp": 1774182445060 + }, + { + "x": 334.23614501953125, + "y": 420.607666015625, + "pressure": 2174, + "timestamp": 1774182445070 + }, + { + "x": 343.349853515625, + "y": 423.18292236328125, + "pressure": 1486, + "timestamp": 1774182445086 + }, + { + "x": 352.4635925292969, + "y": 422.7867431640625, + "pressure": 917, + "timestamp": 1774182445102 + } + ] + }, + { + "index": 10, + "timestampMs": 1774182453879, + "points": [ + { + "x": 388.1260070800781, + "y": 398.6941833496094, + "pressure": 26, + "timestamp": 1774182453656 + }, + { + "x": 395.25848388671875, + "y": 398.8923034667969, + "pressure": 2076, + "timestamp": 1774182453786 + }, + { + "x": 404.7684631347656, + "y": 397.70367431640625, + "pressure": 2073, + "timestamp": 1774182453810 + }, + { + "x": 411.10845947265625, + "y": 396.1188659667969, + "pressure": 2070, + "timestamp": 1774182453826 + }, + { + "x": 419.033447265625, + "y": 391.9587707519531, + "pressure": 590, + "timestamp": 1774182453877 + } + ] + }, + { + "index": 11, + "timestampMs": 1774182454312, + "points": [ + { + "x": 407.1459655761719, + "y": 372.74298095703125, + "pressure": 554, + "timestamp": 1774182454167 + }, + { + "x": 403.381591796875, + "y": 385.22332763671875, + "pressure": 2007, + "timestamp": 1774182454229 + }, + { + "x": 400.60784912109375, + "y": 400.6751708984375, + "pressure": 2160, + "timestamp": 1774182454253 + }, + { + "x": 401.00408935546875, + "y": 411.76885986328125, + "pressure": 782, + "timestamp": 1774182454310 + } + ] + }, + { + "index": 12, + "timestampMs": 1774182454968, + "points": [ + { + "x": 429.1377868652344, + "y": 375.31829833984375, + "pressure": 203, + "timestamp": 1774182454439 + }, + { + "x": 429.3359069824219, + "y": 384.23284912109375, + "pressure": 2053, + "timestamp": 1774182454536 + }, + { + "x": 425.3734130859375, + "y": 404.8353271484375, + "pressure": 1912, + "timestamp": 1774182454613 + }, + { + "x": 427.75091552734375, + "y": 404.24102783203125, + "pressure": 1912, + "timestamp": 1774182454624 + }, + { + "x": 435.0815124511719, + "y": 396.7131652832031, + "pressure": 1912, + "timestamp": 1774182454654 + }, + { + "x": 439.04400634765625, + "y": 394.13787841796875, + "pressure": 2012, + "timestamp": 1774182454694 + }, + { + "x": 440.0346374511719, + "y": 397.90179443359375, + "pressure": 2124, + "timestamp": 1774182454701 + }, + { + "x": 438.84588623046875, + "y": 410.9764404296875, + "pressure": 2165, + "timestamp": 1774182454728 + }, + { + "x": 439.6383972167969, + "y": 415.7308349609375, + "pressure": 1726, + "timestamp": 1774182454768 + }, + { + "x": 442.4121398925781, + "y": 415.928955078125, + "pressure": 1118, + "timestamp": 1774182454789 + }, + { + "x": 448.1577453613281, + "y": 411.76885986328125, + "pressure": 190, + "timestamp": 1774182454808 + }, + { + "x": 453.7052307128906, + "y": 403.84478759765625, + "pressure": 329, + "timestamp": 1774182454845 + }, + { + "x": 451.922119140625, + "y": 412.3631591796875, + "pressure": 1751, + "timestamp": 1774182454917 + }, + { + "x": 453.3089904785156, + "y": 412.56121826171875, + "pressure": 452, + "timestamp": 1774182454954 + }, + { + "x": 455.8846130371094, + "y": 410.9764404296875, + "pressure": 325, + "timestamp": 1774182454966 + } + ] + }, + { + "index": 13, + "timestampMs": 1774182455127, + "points": [ + { + "x": 462.6208190917969, + "y": 387.4024353027344, + "pressure": 301, + "timestamp": 1774182455033 + }, + { + "x": 462.6208190917969, + "y": 387.4024353027344, + "pressure": 32, + "timestamp": 1774182455127 + } + ] + }, + { + "index": 14, + "timestampMs": 1774182455483, + "points": [ + { + "x": 475.6970520019531, + "y": 387.4024353027344, + "pressure": 458, + "timestamp": 1774182455161 + }, + { + "x": 472.9233093261719, + "y": 393.5435791015625, + "pressure": 1608, + "timestamp": 1774182455275 + }, + { + "x": 470.3476867675781, + "y": 395.9207763671875, + "pressure": 1647, + "timestamp": 1774182455299 + }, + { + "x": 469.158935546875, + "y": 398.2979736328125, + "pressure": 1562, + "timestamp": 1774182455328 + }, + { + "x": 472.13079833984375, + "y": 400.6751708984375, + "pressure": 1599, + "timestamp": 1774182455346 + }, + { + "x": 478.0745544433594, + "y": 403.25048828125, + "pressure": 2358, + "timestamp": 1774182455386 + }, + { + "x": 479.2633056640625, + "y": 406.42010498046875, + "pressure": 2593, + "timestamp": 1774182455403 + }, + { + "x": 478.8670349121094, + "y": 407.80682373046875, + "pressure": 2612, + "timestamp": 1774182455407 + }, + { + "x": 473.5176696777344, + "y": 413.5517578125, + "pressure": 2649, + "timestamp": 1774182455426 + }, + { + "x": 462.2245788574219, + "y": 419.69287109375, + "pressure": 2001, + "timestamp": 1774182455480 + } + ] + }, + { + "index": 15, + "timestampMs": 1774182456301, + "points": [ + { + "x": 520.4732055664062, + "y": 404.04290771484375, + "pressure": 179, + "timestamp": 1774182456139 + }, + { + "x": 521.860107421875, + "y": 413.74981689453125, + "pressure": 1725, + "timestamp": 1774182456237 + }, + { + "x": 521.6619873046875, + "y": 414.9384765625, + "pressure": 166, + "timestamp": 1774182456287 + }, + { + "x": 520.0769653320312, + "y": 415.928955078125, + "pressure": 109, + "timestamp": 1774182456297 + } + ] + }, + { + "index": 16, + "timestampMs": 1774182456556, + "points": [ + { + "x": 528.2000732421875, + "y": 381.65753173828125, + "pressure": 410, + "timestamp": 1774182456444 + }, + { + "x": 528.2000732421875, + "y": 381.65753173828125, + "pressure": 68, + "timestamp": 1774182456554 + } + ] + }, + { + "index": 17, + "timestampMs": 1774182457006, + "points": [ + { + "x": 548.8050537109375, + "y": 394.3359680175781, + "pressure": 299, + "timestamp": 1774182456669 + }, + { + "x": 541.87060546875, + "y": 395.7226867675781, + "pressure": 1966, + "timestamp": 1774182456781 + }, + { + "x": 540.681884765625, + "y": 396.51507568359375, + "pressure": 1832, + "timestamp": 1774182456818 + }, + { + "x": 540.28564453125, + "y": 398.0998840332031, + "pressure": 1790, + "timestamp": 1774182456832 + }, + { + "x": 542.6631469726562, + "y": 400.873291015625, + "pressure": 1789, + "timestamp": 1774182456847 + }, + { + "x": 552.7674560546875, + "y": 408.2030029296875, + "pressure": 2089, + "timestamp": 1774182456879 + }, + { + "x": 556.1356201171875, + "y": 411.9669189453125, + "pressure": 2262, + "timestamp": 1774182456895 + }, + { + "x": 557.7205810546875, + "y": 416.32513427734375, + "pressure": 2455, + "timestamp": 1774182456916 + }, + { + "x": 557.5224609375, + "y": 417.71185302734375, + "pressure": 2499, + "timestamp": 1774182456925 + }, + { + "x": 554.748779296875, + "y": 421.67388916015625, + "pressure": 2524, + "timestamp": 1774182456941 + }, + { + "x": 548.0125122070312, + "y": 425.6358642578125, + "pressure": 2532, + "timestamp": 1774182456958 + }, + { + "x": 542.0687866210938, + "y": 427.0225830078125, + "pressure": 2508, + "timestamp": 1774182456978 + }, + { + "x": 539.4931640625, + "y": 426.824462890625, + "pressure": 1590, + "timestamp": 1774182456990 + }, + { + "x": 538.304443359375, + "y": 425.43780517578125, + "pressure": 1288, + "timestamp": 1774182457003 + } + ] + }, + { + "index": 18, + "timestampMs": 1774182458064, + "points": [ + { + "x": 600.3173828125, + "y": 398.0998840332031, + "pressure": 35, + "timestamp": 1774182457438 + }, + { + "x": 594.7698974609375, + "y": 399.28851318359375, + "pressure": 1740, + "timestamp": 1774182457531 + }, + { + "x": 588.6280517578125, + "y": 402.85430908203125, + "pressure": 1733, + "timestamp": 1774182457549 + }, + { + "x": 583.6749267578125, + "y": 408.2030029296875, + "pressure": 1708, + "timestamp": 1774182457564 + }, + { + "x": 582.684326171875, + "y": 410.7783203125, + "pressure": 1694, + "timestamp": 1774182457572 + }, + { + "x": 582.8824462890625, + "y": 413.155517578125, + "pressure": 1612, + "timestamp": 1774182457591 + }, + { + "x": 585.6561889648438, + "y": 415.7308349609375, + "pressure": 1525, + "timestamp": 1774182457611 + }, + { + "x": 589.8167724609375, + "y": 416.32513427734375, + "pressure": 1488, + "timestamp": 1774182457629 + }, + { + "x": 594.57177734375, + "y": 415.13653564453125, + "pressure": 1491, + "timestamp": 1774182457649 + }, + { + "x": 597.7417602539062, + "y": 413.3536376953125, + "pressure": 1509, + "timestamp": 1774182457666 + }, + { + "x": 600.9117431640625, + "y": 410.9764404296875, + "pressure": 1537, + "timestamp": 1774182457682 + }, + { + "x": 602.6948852539062, + "y": 408.401123046875, + "pressure": 1843, + "timestamp": 1774182457773 + }, + { + "x": 599.52490234375, + "y": 409.98590087890625, + "pressure": 1819, + "timestamp": 1774182457789 + }, + { + "x": 597.5436401367188, + "y": 413.155517578125, + "pressure": 1747, + "timestamp": 1774182457828 + }, + { + "x": 599.52490234375, + "y": 414.542236328125, + "pressure": 1739, + "timestamp": 1774182457846 + }, + { + "x": 604.6761474609375, + "y": 413.74981689453125, + "pressure": 1771, + "timestamp": 1774182457873 + }, + { + "x": 607.4498901367188, + "y": 412.1650390625, + "pressure": 1840, + "timestamp": 1774182457904 + }, + { + "x": 608.8367309570312, + "y": 408.99542236328125, + "pressure": 1924, + "timestamp": 1774182457937 + }, + { + "x": 607.4498901367188, + "y": 407.41064453125, + "pressure": 1963, + "timestamp": 1774182457972 + }, + { + "x": 605.4686279296875, + "y": 406.81634521484375, + "pressure": 409, + "timestamp": 1774182458062 + } + ] + }, + { + "index": 19, + "timestampMs": 1774182458539, + "points": [ + { + "x": 623.8942260742188, + "y": 404.4390869140625, + "pressure": 27, + "timestamp": 1774182458192 + }, + { + "x": 623.8942260742188, + "y": 404.4390869140625, + "pressure": 56, + "timestamp": 1774182458206 + } + ] + }, + { + "index": 20, + "timestampMs": 1774182458801, + "points": [ + { + "x": 623.4979248046875, + "y": 405.23150634765625, + "pressure": 64, + "timestamp": 1774182458256 + }, + { + "x": 618.3467407226562, + "y": 410.9764404296875, + "pressure": 1949, + "timestamp": 1774182458582 + }, + { + "x": 617.157958984375, + "y": 414.542236328125, + "pressure": 2004, + "timestamp": 1774182458643 + }, + { + "x": 619.1392211914062, + "y": 415.53277587890625, + "pressure": 2008, + "timestamp": 1774182458648 + }, + { + "x": 621.5167236328125, + "y": 415.33465576171875, + "pressure": 2013, + "timestamp": 1774182458658 + }, + { + "x": 629.6398315429688, + "y": 411.174560546875, + "pressure": 2029, + "timestamp": 1774182458708 + }, + { + "x": 631.4229736328125, + "y": 408.00494384765625, + "pressure": 2069, + "timestamp": 1774182458728 + }, + { + "x": 631.8192138671875, + "y": 405.627685546875, + "pressure": 2156, + "timestamp": 1774182458760 + }, + { + "x": 628.649169921875, + "y": 404.63720703125, + "pressure": 2100, + "timestamp": 1774182458776 + }, + { + "x": 623.8942260742188, + "y": 406.22198486328125, + "pressure": 1278, + "timestamp": 1774182458799 + } + ] + }, + { + "index": 21, + "timestampMs": 1774182459203, + "points": [ + { + "x": 645.6879272460938, + "y": 375.91259765625, + "pressure": 273, + "timestamp": 1774182459026 + }, + { + "x": 645.4898071289062, + "y": 382.84613037109375, + "pressure": 2060, + "timestamp": 1774182459113 + }, + { + "x": 644.1029052734375, + "y": 388.7891540527344, + "pressure": 2192, + "timestamp": 1774182459122 + }, + { + "x": 641.3291625976562, + "y": 399.28851318359375, + "pressure": 2268, + "timestamp": 1774182459138 + }, + { + "x": 637.1685791015625, + "y": 410.18402099609375, + "pressure": 1490, + "timestamp": 1774182459200 + } + ] + }, + { + "index": 22, + "timestampMs": 1774182469400, + "points": [ + { + "x": 81.6273193359375, + "y": 533.2657470703125, + "pressure": 20, + "timestamp": 1774182465022 + }, + { + "x": 72.31549072265625, + "y": 535.6430053710938, + "pressure": 2747, + "timestamp": 1774182465288 + }, + { + "x": 66.37176513671875, + "y": 538.614501953125, + "pressure": 2732, + "timestamp": 1774182465302 + }, + { + "x": 60.42803955078125, + "y": 544.3594360351562, + "pressure": 2686, + "timestamp": 1774182465330 + }, + { + "x": 56.26739501953125, + "y": 552.0853271484375, + "pressure": 2660, + "timestamp": 1774182465350 + }, + { + "x": 55.87115478515625, + "y": 556.2454223632812, + "pressure": 2596, + "timestamp": 1774182465379 + }, + { + "x": 57.45611572265625, + "y": 558.6226806640625, + "pressure": 2564, + "timestamp": 1774182465397 + }, + { + "x": 63.2017822265625, + "y": 560.8017578125, + "pressure": 2559, + "timestamp": 1774182465416 + }, + { + "x": 71.91925048828125, + "y": 559.0188598632812, + "pressure": 2570, + "timestamp": 1774182465440 + }, + { + "x": 74.2967529296875, + "y": 557.43408203125, + "pressure": 2575, + "timestamp": 1774182465448 + }, + { + "x": 78.65545654296875, + "y": 552.8777465820312, + "pressure": 2587, + "timestamp": 1774182465461 + }, + { + "x": 86.5804443359375, + "y": 540.1992797851562, + "pressure": 2612, + "timestamp": 1774182465492 + }, + { + "x": 92.92041015625, + "y": 523.9550170898438, + "pressure": 2628, + "timestamp": 1774182465520 + }, + { + "x": 94.90167236328125, + "y": 512.0689697265625, + "pressure": 2731, + "timestamp": 1774182465579 + }, + { + "x": 92.3260498046875, + "y": 511.47467041015625, + "pressure": 2696, + "timestamp": 1774182465602 + }, + { + "x": 88.16546630859375, + "y": 515.8328857421875, + "pressure": 2670, + "timestamp": 1774182465616 + }, + { + "x": 85.3917236328125, + "y": 520.5873413085938, + "pressure": 2655, + "timestamp": 1774182465623 + }, + { + "x": 83.01422119140625, + "y": 526.1341552734375, + "pressure": 2648, + "timestamp": 1774182465632 + }, + { + "x": 79.05169677734375, + "y": 541.1898193359375, + "pressure": 2629, + "timestamp": 1774182465654 + }, + { + "x": 79.6461181640625, + "y": 549.1138305664062, + "pressure": 2598, + "timestamp": 1774182465670 + }, + { + "x": 81.825439453125, + "y": 552.6796264648438, + "pressure": 2571, + "timestamp": 1774182465686 + }, + { + "x": 85.3917236328125, + "y": 555.2549438476562, + "pressure": 2554, + "timestamp": 1774182465707 + }, + { + "x": 87.76922607421875, + "y": 555.4530639648438, + "pressure": 2555, + "timestamp": 1774182465715 + }, + { + "x": 93.31671142578125, + "y": 554.0663452148438, + "pressure": 2562, + "timestamp": 1774182465728 + }, + { + "x": 96.4866943359375, + "y": 552.283447265625, + "pressure": 2568, + "timestamp": 1774182465735 + }, + { + "x": 99.26043701171875, + "y": 550.1043090820312, + "pressure": 2570, + "timestamp": 1774182465745 + }, + { + "x": 105.99664306640625, + "y": 540.99169921875, + "pressure": 2585, + "timestamp": 1774182465777 + }, + { + "x": 102.6285400390625, + "y": 545.5480346679688, + "pressure": 2587, + "timestamp": 1774182465839 + }, + { + "x": 100.05291748046875, + "y": 550.896728515625, + "pressure": 2548, + "timestamp": 1774182465857 + }, + { + "x": 100.25103759765625, + "y": 554.4625244140625, + "pressure": 2468, + "timestamp": 1774182465892 + }, + { + "x": 104.015380859375, + "y": 557.2359619140625, + "pressure": 2461, + "timestamp": 1774182465903 + }, + { + "x": 108.7703857421875, + "y": 557.037841796875, + "pressure": 2462, + "timestamp": 1774182465919 + }, + { + "x": 113.129150390625, + "y": 554.4625244140625, + "pressure": 2478, + "timestamp": 1774182465937 + }, + { + "x": 118.6766357421875, + "y": 548.51953125, + "pressure": 2485, + "timestamp": 1774182465963 + }, + { + "x": 121.05413818359375, + "y": 547.3309326171875, + "pressure": 2517, + "timestamp": 1774182466033 + }, + { + "x": 122.44097900390625, + "y": 549.90625, + "pressure": 2534, + "timestamp": 1774182466048 + }, + { + "x": 123.03533935546875, + "y": 555.651123046875, + "pressure": 2610, + "timestamp": 1774182466100 + }, + { + "x": 117.8841552734375, + "y": 559.0188598632812, + "pressure": 2598, + "timestamp": 1774182466117 + }, + { + "x": 113.921630859375, + "y": 559.4150390625, + "pressure": 2486, + "timestamp": 1774182466132 + }, + { + "x": 108.968505859375, + "y": 558.2264404296875, + "pressure": 2154, + "timestamp": 1774182466226 + }, + { + "x": 123.2335205078125, + "y": 554.0663452148438, + "pressure": 2184, + "timestamp": 1774182466281 + }, + { + "x": 126.40350341796875, + "y": 552.4815673828125, + "pressure": 2217, + "timestamp": 1774182466310 + }, + { + "x": 141.857177734375, + "y": 540.1992797851562, + "pressure": 2242, + "timestamp": 1774182466488 + }, + { + "x": 134.526611328125, + "y": 545.9442138671875, + "pressure": 2559, + "timestamp": 1774182466646 + }, + { + "x": 131.158447265625, + "y": 551.6891479492188, + "pressure": 2533, + "timestamp": 1774182466661 + }, + { + "x": 130.5640869140625, + "y": 555.2549438476562, + "pressure": 2485, + "timestamp": 1774182466684 + }, + { + "x": 130.9603271484375, + "y": 556.4435424804688, + "pressure": 2431, + "timestamp": 1774182466707 + }, + { + "x": 134.9228515625, + "y": 557.43408203125, + "pressure": 2437, + "timestamp": 1774182466725 + }, + { + "x": 138.68719482421875, + "y": 554.2644653320312, + "pressure": 2458, + "timestamp": 1774182466746 + }, + { + "x": 140.4703369140625, + "y": 550.6986083984375, + "pressure": 2486, + "timestamp": 1774182466775 + }, + { + "x": 139.08343505859375, + "y": 554.2644653320312, + "pressure": 2234, + "timestamp": 1774182466887 + }, + { + "x": 140.66845703125, + "y": 556.04736328125, + "pressure": 2204, + "timestamp": 1774182466903 + }, + { + "x": 143.04595947265625, + "y": 556.6416625976562, + "pressure": 2212, + "timestamp": 1774182466921 + }, + { + "x": 145.4234619140625, + "y": 555.8492431640625, + "pressure": 2228, + "timestamp": 1774182466935 + }, + { + "x": 149.78216552734375, + "y": 552.4815673828125, + "pressure": 2239, + "timestamp": 1774182466955 + }, + { + "x": 157.509033203125, + "y": 541.7841186523438, + "pressure": 2247, + "timestamp": 1774182467006 + }, + { + "x": 151.763427734375, + "y": 559.2169799804688, + "pressure": 2517, + "timestamp": 1774182467112 + }, + { + "x": 143.44219970703125, + "y": 576.847900390625, + "pressure": 2531, + "timestamp": 1774182467143 + }, + { + "x": 136.7059326171875, + "y": 586.5548706054688, + "pressure": 2538, + "timestamp": 1774182467172 + }, + { + "x": 130.9603271484375, + "y": 589.1301879882812, + "pressure": 2154, + "timestamp": 1774182467192 + }, + { + "x": 127.79034423828125, + "y": 587.9415893554688, + "pressure": 2052, + "timestamp": 1774182467208 + }, + { + "x": 126.00726318359375, + "y": 584.77197265625, + "pressure": 2042, + "timestamp": 1774182467215 + }, + { + "x": 125.80908203125, + "y": 582.394775390625, + "pressure": 2018, + "timestamp": 1774182467223 + }, + { + "x": 128.18658447265625, + "y": 574.6688232421875, + "pressure": 1999, + "timestamp": 1774182467239 + }, + { + "x": 132.34722900390625, + "y": 568.9238891601562, + "pressure": 1994, + "timestamp": 1774182467253 + }, + { + "x": 153.74468994140625, + "y": 552.8777465820312, + "pressure": 2092, + "timestamp": 1774182467303 + }, + { + "x": 169.396484375, + "y": 538.614501953125, + "pressure": 2143, + "timestamp": 1774182467422 + }, + { + "x": 170.18902587890625, + "y": 538.8125610351562, + "pressure": 2339, + "timestamp": 1774182467460 + }, + { + "x": 170.98150634765625, + "y": 541.7841186523438, + "pressure": 2373, + "timestamp": 1774182467467 + }, + { + "x": 169.000244140625, + "y": 546.3403930664062, + "pressure": 2404, + "timestamp": 1774182467497 + }, + { + "x": 165.03778076171875, + "y": 549.3119506835938, + "pressure": 2410, + "timestamp": 1774182467518 + }, + { + "x": 159.09405517578125, + "y": 552.283447265625, + "pressure": 2398, + "timestamp": 1774182467536 + }, + { + "x": 154.93341064453125, + "y": 553.0758666992188, + "pressure": 2394, + "timestamp": 1774182467556 + }, + { + "x": 160.08465576171875, + "y": 549.90625, + "pressure": 2212, + "timestamp": 1774182467659 + }, + { + "x": 170.98150634765625, + "y": 545.5480346679688, + "pressure": 2233, + "timestamp": 1774182467686 + }, + { + "x": 179.302734375, + "y": 540.3973999023438, + "pressure": 2265, + "timestamp": 1774182467720 + }, + { + "x": 183.2652587890625, + "y": 539.0106811523438, + "pressure": 2484, + "timestamp": 1774182467798 + }, + { + "x": 185.04833984375, + "y": 546.9346923828125, + "pressure": 2603, + "timestamp": 1774182467814 + }, + { + "x": 180.491455078125, + "y": 561.5941772460938, + "pressure": 2624, + "timestamp": 1774182467837 + }, + { + "x": 172.17022705078125, + "y": 577.6403198242188, + "pressure": 2644, + "timestamp": 1774182467869 + }, + { + "x": 167.8115234375, + "y": 581.99853515625, + "pressure": 2657, + "timestamp": 1774182467897 + }, + { + "x": 162.8583984375, + "y": 583.9795532226562, + "pressure": 2654, + "timestamp": 1774182467925 + }, + { + "x": 160.67901611328125, + "y": 580.8099365234375, + "pressure": 2568, + "timestamp": 1774182467945 + }, + { + "x": 160.48089599609375, + "y": 577.244140625, + "pressure": 2557, + "timestamp": 1774182467950 + }, + { + "x": 164.047119140625, + "y": 568.32958984375, + "pressure": 2528, + "timestamp": 1774182467964 + }, + { + "x": 169.59466552734375, + "y": 559.6131591796875, + "pressure": 2513, + "timestamp": 1774182467978 + }, + { + "x": 182.47271728515625, + "y": 546.7366333007812, + "pressure": 2504, + "timestamp": 1774182468005 + }, + { + "x": 188.02020263671875, + "y": 543.1707763671875, + "pressure": 2496, + "timestamp": 1774182468023 + }, + { + "x": 193.17144775390625, + "y": 541.1898193359375, + "pressure": 2499, + "timestamp": 1774182468044 + }, + { + "x": 194.7564697265625, + "y": 541.5859985351562, + "pressure": 2561, + "timestamp": 1774182468071 + }, + { + "x": 196.93585205078125, + "y": 543.76513671875, + "pressure": 2622, + "timestamp": 1774182468089 + }, + { + "x": 197.92645263671875, + "y": 546.9346923828125, + "pressure": 2679, + "timestamp": 1774182468108 + }, + { + "x": 197.53021240234375, + "y": 548.1233520507812, + "pressure": 2712, + "timestamp": 1774182468124 + }, + { + "x": 196.3414306640625, + "y": 549.7081298828125, + "pressure": 2707, + "timestamp": 1774182468138 + }, + { + "x": 191.190185546875, + "y": 552.283447265625, + "pressure": 2707, + "timestamp": 1774182468150 + }, + { + "x": 185.8408203125, + "y": 552.283447265625, + "pressure": 2663, + "timestamp": 1774182468166 + }, + { + "x": 184.255859375, + "y": 551.88720703125, + "pressure": 2626, + "timestamp": 1774182468178 + }, + { + "x": 184.652099609375, + "y": 551.4910278320312, + "pressure": 2379, + "timestamp": 1774182468250 + }, + { + "x": 203.87017822265625, + "y": 548.51953125, + "pressure": 2383, + "timestamp": 1774182468296 + }, + { + "x": 210.6064453125, + "y": 546.5385131835938, + "pressure": 2395, + "timestamp": 1774182468312 + }, + { + "x": 216.5501708984375, + "y": 542.9727172851562, + "pressure": 2462, + "timestamp": 1774182468362 + }, + { + "x": 218.333251953125, + "y": 539.4068603515625, + "pressure": 2528, + "timestamp": 1774182468396 + }, + { + "x": 216.9464111328125, + "y": 538.614501953125, + "pressure": 2559, + "timestamp": 1774182468440 + }, + { + "x": 213.38018798828125, + "y": 540.001220703125, + "pressure": 2516, + "timestamp": 1774182468460 + }, + { + "x": 209.41766357421875, + "y": 546.3403930664062, + "pressure": 2480, + "timestamp": 1774182468478 + }, + { + "x": 209.61578369140625, + "y": 549.7081298828125, + "pressure": 2436, + "timestamp": 1774182468492 + }, + { + "x": 211.002685546875, + "y": 552.4815673828125, + "pressure": 2400, + "timestamp": 1774182468509 + }, + { + "x": 212.98388671875, + "y": 554.4625244140625, + "pressure": 2392, + "timestamp": 1774182468522 + }, + { + "x": 216.9464111328125, + "y": 556.2454223632812, + "pressure": 2395, + "timestamp": 1774182468538 + }, + { + "x": 220.90887451171875, + "y": 555.651123046875, + "pressure": 2407, + "timestamp": 1774182468555 + }, + { + "x": 224.87139892578125, + "y": 553.670166015625, + "pressure": 2415, + "timestamp": 1774182468569 + }, + { + "x": 228.8338623046875, + "y": 550.1043090820312, + "pressure": 2419, + "timestamp": 1774182468583 + }, + { + "x": 232.9945068359375, + "y": 544.5574951171875, + "pressure": 2607, + "timestamp": 1774182468745 + }, + { + "x": 230.61700439453125, + "y": 545.9442138671875, + "pressure": 2593, + "timestamp": 1774182468755 + }, + { + "x": 226.45635986328125, + "y": 551.0948486328125, + "pressure": 2422, + "timestamp": 1774182468830 + }, + { + "x": 228.8338623046875, + "y": 551.4910278320312, + "pressure": 2427, + "timestamp": 1774182468845 + }, + { + "x": 231.60760498046875, + "y": 550.5005493164062, + "pressure": 2425, + "timestamp": 1774182468856 + }, + { + "x": 235.17388916015625, + "y": 547.1328125, + "pressure": 2463, + "timestamp": 1774182468953 + }, + { + "x": 234.38134765625, + "y": 549.7081298828125, + "pressure": 2452, + "timestamp": 1774182468959 + }, + { + "x": 235.17388916015625, + "y": 554.2644653320312, + "pressure": 2363, + "timestamp": 1774182468995 + }, + { + "x": 237.15509033203125, + "y": 555.4530639648438, + "pressure": 2362, + "timestamp": 1774182469007 + }, + { + "x": 241.91009521484375, + "y": 554.4625244140625, + "pressure": 2381, + "timestamp": 1774182469025 + }, + { + "x": 247.45758056640625, + "y": 550.6986083984375, + "pressure": 2399, + "timestamp": 1774182469049 + }, + { + "x": 254.19378662109375, + "y": 542.9727172851562, + "pressure": 2712, + "timestamp": 1774182469137 + }, + { + "x": 253.00506591796875, + "y": 544.9537353515625, + "pressure": 2589, + "timestamp": 1774182469194 + }, + { + "x": 261.920654296875, + "y": 549.1138305664062, + "pressure": 2605, + "timestamp": 1774182469260 + }, + { + "x": 263.10943603515625, + "y": 554.8587646484375, + "pressure": 2632, + "timestamp": 1774182469274 + }, + { + "x": 261.5244140625, + "y": 564.3675537109375, + "pressure": 2614, + "timestamp": 1774182469309 + }, + { + "x": 264.10003662109375, + "y": 567.339111328125, + "pressure": 2580, + "timestamp": 1774182469331 + }, + { + "x": 266.4775390625, + "y": 568.32958984375, + "pressure": 2581, + "timestamp": 1774182469338 + }, + { + "x": 271.2325439453125, + "y": 567.7352905273438, + "pressure": 2587, + "timestamp": 1774182469347 + }, + { + "x": 278.95941162109375, + "y": 563.7732543945312, + "pressure": 1632, + "timestamp": 1774182469374 + }, + { + "x": 281.53497314453125, + "y": 560.2074584960938, + "pressure": 1518, + "timestamp": 1774182469386 + } + ] + }, + { + "index": 23, + "timestampMs": 1774182472515, + "points": [ + { + "x": 113.3272705078125, + "y": 504.5411376953125, + "pressure": 947, + "timestamp": 1774182469694 + }, + { + "x": 113.3272705078125, + "y": 504.5411376953125, + "pressure": 591, + "timestamp": 1774182469772 + } + ] + }, + { + "index": 24, + "timestampMs": 1774182472527, + "points": [ + { + "x": 351.27484130859375, + "y": 541.982177734375, + "pressure": 342, + "timestamp": 1774182471972 + }, + { + "x": 342.3592224121094, + "y": 544.5574951171875, + "pressure": 1772, + "timestamp": 1774182472071 + }, + { + "x": 334.83050537109375, + "y": 548.51953125, + "pressure": 1751, + "timestamp": 1774182472091 + }, + { + "x": 331.6605224609375, + "y": 552.283447265625, + "pressure": 1720, + "timestamp": 1774182472105 + }, + { + "x": 328.4905090332031, + "y": 559.6131591796875, + "pressure": 1596, + "timestamp": 1774182472151 + }, + { + "x": 329.28302001953125, + "y": 561.197998046875, + "pressure": 1745, + "timestamp": 1774182472171 + }, + { + "x": 331.6605224609375, + "y": 562.5846557617188, + "pressure": 1847, + "timestamp": 1774182472182 + }, + { + "x": 336.4154968261719, + "y": 562.9808959960938, + "pressure": 1935, + "timestamp": 1774182472198 + }, + { + "x": 344.3404846191406, + "y": 560.6036987304688, + "pressure": 2018, + "timestamp": 1774182472218 + }, + { + "x": 349.6898498535156, + "y": 556.04736328125, + "pressure": 2087, + "timestamp": 1774182472233 + }, + { + "x": 355.6335754394531, + "y": 550.1043090820312, + "pressure": 2122, + "timestamp": 1774182472248 + }, + { + "x": 362.76605224609375, + "y": 540.3973999023438, + "pressure": 2146, + "timestamp": 1774182472266 + }, + { + "x": 371.0872802734375, + "y": 522.5682983398438, + "pressure": 2173, + "timestamp": 1774182472296 + }, + { + "x": 372.2760314941406, + "y": 516.2291259765625, + "pressure": 2410, + "timestamp": 1774182472347 + }, + { + "x": 367.52105712890625, + "y": 516.2291259765625, + "pressure": 2376, + "timestamp": 1774182472359 + }, + { + "x": 360.1904602050781, + "y": 521.3796997070312, + "pressure": 2344, + "timestamp": 1774182472373 + }, + { + "x": 351.27484130859375, + "y": 531.6809692382812, + "pressure": 2315, + "timestamp": 1774182472391 + }, + { + "x": 346.5198669433594, + "y": 540.99169921875, + "pressure": 2300, + "timestamp": 1774182472405 + }, + { + "x": 342.557373046875, + "y": 553.8682250976562, + "pressure": 2286, + "timestamp": 1774182472424 + }, + { + "x": 342.3592224121094, + "y": 560.6036987304688, + "pressure": 2266, + "timestamp": 1774182472439 + }, + { + "x": 345.52923583984375, + "y": 566.3485717773438, + "pressure": 2244, + "timestamp": 1774182472462 + }, + { + "x": 352.6617126464844, + "y": 570.1124877929688, + "pressure": 2235, + "timestamp": 1774182472481 + } + ] + }, + { + "index": 25, + "timestampMs": 1774182473495, + "points": [ + { + "x": 362.9642028808594, + "y": 569.1220092773438, + "pressure": 2236, + "timestamp": 1774182472506 + }, + { + "x": 367.71917724609375, + "y": 567.1409912109375, + "pressure": 2238, + "timestamp": 1774182472524 + }, + { + "x": 372.0779113769531, + "y": 563.7732543945312, + "pressure": 2245, + "timestamp": 1774182472547 + }, + { + "x": 379.21038818359375, + "y": 552.283447265625, + "pressure": 2095, + "timestamp": 1774182472743 + }, + { + "x": 375.6441650390625, + "y": 560.9998779296875, + "pressure": 1969, + "timestamp": 1774182473002 + }, + { + "x": 375.2479248046875, + "y": 564.1694946289062, + "pressure": 1934, + "timestamp": 1774182473039 + }, + { + "x": 376.238525390625, + "y": 566.94287109375, + "pressure": 1875, + "timestamp": 1774182473082 + }, + { + "x": 379.4085388183594, + "y": 569.1220092773438, + "pressure": 532, + "timestamp": 1774182473494 + } + ] + }, + { + "index": 26, + "timestampMs": 1774182479609, + "points": [ + { + "x": 371.8797912597656, + "y": 526.0117797851562, + "pressure": 256, + "timestamp": 1774182478551 + }, + { + "x": 357.21856689453125, + "y": 529.7756958007812, + "pressure": 1236, + "timestamp": 1774182478606 + }, + { + "x": 349.6898498535156, + "y": 533.3414916992188, + "pressure": 1295, + "timestamp": 1774182478618 + }, + { + "x": 342.7554931640625, + "y": 538.4921264648438, + "pressure": 1338, + "timestamp": 1774182478642 + }, + { + "x": 341.1705017089844, + "y": 540.8693237304688, + "pressure": 1392, + "timestamp": 1774182478659 + }, + { + "x": 341.3686218261719, + "y": 542.4541625976562, + "pressure": 1598, + "timestamp": 1774182478679 + }, + { + "x": 345.52923583984375, + "y": 544.237060546875, + "pressure": 1625, + "timestamp": 1774182478686 + }, + { + "x": 349.6898498535156, + "y": 544.237060546875, + "pressure": 1674, + "timestamp": 1774182478694 + }, + { + "x": 388.1260070800781, + "y": 538.6902465820312, + "pressure": 1783, + "timestamp": 1774182478740 + }, + { + "x": 391.4941101074219, + "y": 538.6902465820312, + "pressure": 2419, + "timestamp": 1774182478788 + }, + { + "x": 391.0978698730469, + "y": 539.2845458984375, + "pressure": 2430, + "timestamp": 1774182478790 + }, + { + "x": 383.96539306640625, + "y": 545.4256591796875, + "pressure": 2441, + "timestamp": 1774182478802 + }, + { + "x": 374.8516540527344, + "y": 550.1800537109375, + "pressure": 2451, + "timestamp": 1774182478813 + }, + { + "x": 338.7929992675781, + "y": 563.2546997070312, + "pressure": 2428, + "timestamp": 1774182478861 + }, + { + "x": 344.5386047363281, + "y": 563.0565795898438, + "pressure": 2086, + "timestamp": 1774182479077 + }, + { + "x": 367.32293701171875, + "y": 564.8395385742188, + "pressure": 2553, + "timestamp": 1774182479162 + }, + { + "x": 345.1329650878906, + "y": 567.81103515625, + "pressure": 2543, + "timestamp": 1774182479199 + }, + { + "x": 334.63238525390625, + "y": 568.0091552734375, + "pressure": 2446, + "timestamp": 1774182479244 + }, + { + "x": 334.83050537109375, + "y": 566.42431640625, + "pressure": 2426, + "timestamp": 1774182479253 + }, + { + "x": 338.3967590332031, + "y": 563.4528198242188, + "pressure": 2407, + "timestamp": 1774182479260 + }, + { + "x": 350.68048095703125, + "y": 558.5003051757812, + "pressure": 2401, + "timestamp": 1774182479273 + }, + { + "x": 392.682861328125, + "y": 549.1895751953125, + "pressure": 2417, + "timestamp": 1774182479312 + }, + { + "x": 375.6441650390625, + "y": 553.745849609375, + "pressure": 2950, + "timestamp": 1774182479357 + }, + { + "x": 335.42486572265625, + "y": 567.4148559570312, + "pressure": 2920, + "timestamp": 1774182479400 + }, + { + "x": 339.1892395019531, + "y": 562.0661010742188, + "pressure": 2717, + "timestamp": 1774182479438 + }, + { + "x": 358.2091979980469, + "y": 545.8218383789062, + "pressure": 2719, + "timestamp": 1774182479464 + }, + { + "x": 365.3416748046875, + "y": 537.5015869140625, + "pressure": 2749, + "timestamp": 1774182479478 + }, + { + "x": 366.1341857910156, + "y": 534.33203125, + "pressure": 2775, + "timestamp": 1774182479485 + }, + { + "x": 364.9454345703125, + "y": 532.549072265625, + "pressure": 2825, + "timestamp": 1774182479497 + }, + { + "x": 359.5960693359375, + "y": 531.7567138671875, + "pressure": 2832, + "timestamp": 1774182479508 + }, + { + "x": 355.0392150878906, + "y": 532.9453125, + "pressure": 2836, + "timestamp": 1774182479515 + }, + { + "x": 349.2936096191406, + "y": 536.31298828125, + "pressure": 2824, + "timestamp": 1774182479526 + }, + { + "x": 345.52923583984375, + "y": 541.8598022460938, + "pressure": 2810, + "timestamp": 1774182479540 + }, + { + "x": 345.92547607421875, + "y": 545.0294189453125, + "pressure": 2746, + "timestamp": 1774182479547 + }, + { + "x": 348.302978515625, + "y": 547.4066772460938, + "pressure": 2716, + "timestamp": 1774182479560 + }, + { + "x": 354.44482421875, + "y": 549.3876342773438, + "pressure": 2709, + "timestamp": 1774182479568 + }, + { + "x": 359.1998291015625, + "y": 549.98193359375, + "pressure": 2709, + "timestamp": 1774182479576 + }, + { + "x": 372.87042236328125, + "y": 549.5857543945312, + "pressure": 2357, + "timestamp": 1774182479592 + }, + { + "x": 381.7860107421875, + "y": 548.3971557617188, + "pressure": 2241, + "timestamp": 1774182479606 + } + ] + }, + { + "index": 27, + "timestampMs": 1774182480835, + "points": [ + { + "x": 366.7285461425781, + "y": 542.850341796875, + "pressure": 510, + "timestamp": 1774182480251 + }, + { + "x": 349.2936096191406, + "y": 546.0199584960938, + "pressure": 2072, + "timestamp": 1774182480318 + }, + { + "x": 335.8211364746094, + "y": 549.98193359375, + "pressure": 2097, + "timestamp": 1774182480339 + }, + { + "x": 338.00048828125, + "y": 546.4161376953125, + "pressure": 2112, + "timestamp": 1774182480387 + }, + { + "x": 346.5198669433594, + "y": 540.6712036132812, + "pressure": 2097, + "timestamp": 1774182480401 + }, + { + "x": 358.8035583496094, + "y": 537.1054077148438, + "pressure": 2108, + "timestamp": 1774182480417 + }, + { + "x": 369.5022888183594, + "y": 535.322509765625, + "pressure": 2149, + "timestamp": 1774182480441 + }, + { + "x": 364.5491943359375, + "y": 537.69970703125, + "pressure": 2627, + "timestamp": 1774182480480 + }, + { + "x": 332.2548828125, + "y": 545.0294189453125, + "pressure": 2610, + "timestamp": 1774182480523 + }, + { + "x": 328.88677978515625, + "y": 544.6332397460938, + "pressure": 2468, + "timestamp": 1774182480573 + }, + { + "x": 331.6605224609375, + "y": 541.6617431640625, + "pressure": 2466, + "timestamp": 1774182480580 + }, + { + "x": 338.9911193847656, + "y": 538.6902465820312, + "pressure": 2445, + "timestamp": 1774182480590 + }, + { + "x": 349.6898498535156, + "y": 536.709228515625, + "pressure": 2446, + "timestamp": 1774182480601 + }, + { + "x": 382.3804016113281, + "y": 534.33203125, + "pressure": 2446, + "timestamp": 1774182480631 + } + ] + }, + { + "index": 28, + "timestampMs": 1774182481216, + "points": [ + { + "x": 397.4378662109375, + "y": 545.4256591796875, + "pressure": 2746, + "timestamp": 1774182480820 + }, + { + "x": 385.7485046386719, + "y": 553.5477905273438, + "pressure": 3024, + "timestamp": 1774182480856 + }, + { + "x": 377.0310363769531, + "y": 558.3021850585938, + "pressure": 3030, + "timestamp": 1774182480863 + }, + { + "x": 343.349853515625, + "y": 571.3768310546875, + "pressure": 3018, + "timestamp": 1774182480898 + }, + { + "x": 368.7098083496094, + "y": 563.4528198242188, + "pressure": 2883, + "timestamp": 1774182480958 + }, + { + "x": 380.0028991699219, + "y": 561.0756225585938, + "pressure": 2882, + "timestamp": 1774182480965 + }, + { + "x": 404.1741027832031, + "y": 557.906005859375, + "pressure": 2882, + "timestamp": 1774182480981 + }, + { + "x": 423.3921813964844, + "y": 555.9249877929688, + "pressure": 2771, + "timestamp": 1774182481004 + }, + { + "x": 421.0146789550781, + "y": 559.0946044921875, + "pressure": 1053, + "timestamp": 1774182481038 + }, + { + "x": 414.4765625, + "y": 562.8585205078125, + "pressure": 871, + "timestamp": 1774182481050 + } + ] + }, + { + "index": 29, + "timestampMs": 1774182487898, + "points": [ + { + "x": 362.56793212890625, + "y": 551.5667724609375, + "pressure": 458, + "timestamp": 1774182482497 + }, + { + "x": 354.44482421875, + "y": 550.9724731445312, + "pressure": 2206, + "timestamp": 1774182482613 + }, + { + "x": 347.3123474121094, + "y": 552.3591918945312, + "pressure": 2178, + "timestamp": 1774182482626 + }, + { + "x": 341.9629821777344, + "y": 555.132568359375, + "pressure": 2162, + "timestamp": 1774182482638 + }, + { + "x": 337.604248046875, + "y": 560.8775024414062, + "pressure": 2126, + "timestamp": 1774182482657 + }, + { + "x": 334.83050537109375, + "y": 566.42431640625, + "pressure": 2104, + "timestamp": 1774182482670 + }, + { + "x": 334.43426513671875, + "y": 571.1787109375, + "pressure": 2066, + "timestamp": 1774182482690 + }, + { + "x": 335.42486572265625, + "y": 572.7635498046875, + "pressure": 2086, + "timestamp": 1774182482706 + }, + { + "x": 339.3873596191406, + "y": 574.942626953125, + "pressure": 2092, + "timestamp": 1774182482717 + }, + { + "x": 342.3592224121094, + "y": 574.942626953125, + "pressure": 2217, + "timestamp": 1774182482722 + }, + { + "x": 347.1142272949219, + "y": 573.5559692382812, + "pressure": 2234, + "timestamp": 1774182482733 + }, + { + "x": 353.0579528808594, + "y": 570.5844116210938, + "pressure": 2273, + "timestamp": 1774182482742 + }, + { + "x": 361.3791809082031, + "y": 564.047119140625, + "pressure": 2282, + "timestamp": 1774182482758 + }, + { + "x": 367.91729736328125, + "y": 556.1231079101562, + "pressure": 2298, + "timestamp": 1774182482772 + }, + { + "x": 373.8610534667969, + "y": 545.0294189453125, + "pressure": 2314, + "timestamp": 1774182482792 + }, + { + "x": 378.41790771484375, + "y": 531.55859375, + "pressure": 2326, + "timestamp": 1774182482815 + }, + { + "x": 380.0028991699219, + "y": 522.2478637695312, + "pressure": 2499, + "timestamp": 1774182482872 + }, + { + "x": 375.0497741699219, + "y": 525.6156005859375, + "pressure": 2476, + "timestamp": 1774182482884 + }, + { + "x": 368.11541748046875, + "y": 532.7471923828125, + "pressure": 2468, + "timestamp": 1774182482899 + }, + { + "x": 357.8129577636719, + "y": 550.378173828125, + "pressure": 2465, + "timestamp": 1774182482926 + }, + { + "x": 354.6429443359375, + "y": 560.0850830078125, + "pressure": 2460, + "timestamp": 1774182482942 + }, + { + "x": 354.2467041015625, + "y": 567.4148559570312, + "pressure": 2448, + "timestamp": 1774182482958 + }, + { + "x": 356.62420654296875, + "y": 572.1692504882812, + "pressure": 2428, + "timestamp": 1774182482981 + }, + { + "x": 362.17169189453125, + "y": 575.5369262695312, + "pressure": 2434, + "timestamp": 1774182483002 + }, + { + "x": 367.71917724609375, + "y": 575.9331665039062, + "pressure": 2441, + "timestamp": 1774182483024 + }, + { + "x": 371.6816711425781, + "y": 575.1407470703125, + "pressure": 2445, + "timestamp": 1774182483038 + }, + { + "x": 378.81414794921875, + "y": 571.1787109375, + "pressure": 2457, + "timestamp": 1774182483072 + }, + { + "x": 384.9560241699219, + "y": 565.03759765625, + "pressure": 2463, + "timestamp": 1774182483093 + }, + { + "x": 392.0885009765625, + "y": 555.3306884765625, + "pressure": 2451, + "timestamp": 1774182483319 + }, + { + "x": 393.2772521972656, + "y": 561.8679809570312, + "pressure": 2471, + "timestamp": 1774182483412 + }, + { + "x": 391.2959899902344, + "y": 569.5939331054688, + "pressure": 2470, + "timestamp": 1774182483466 + }, + { + "x": 391.2959899902344, + "y": 572.7635498046875, + "pressure": 2427, + "timestamp": 1774182483505 + }, + { + "x": 394.2678527832031, + "y": 574.5464477539062, + "pressure": 2470, + "timestamp": 1774182483574 + }, + { + "x": 399.81536865234375, + "y": 573.5559692382812, + "pressure": 2494, + "timestamp": 1774182483599 + }, + { + "x": 403.77783203125, + "y": 571.574951171875, + "pressure": 2506, + "timestamp": 1774182483615 + }, + { + "x": 410.5140686035156, + "y": 566.42431640625, + "pressure": 2523, + "timestamp": 1774182483633 + }, + { + "x": 418.4390563964844, + "y": 556.3211669921875, + "pressure": 2502, + "timestamp": 1774182483872 + }, + { + "x": 419.825927734375, + "y": 557.509765625, + "pressure": 2529, + "timestamp": 1774182483878 + }, + { + "x": 421.6090393066406, + "y": 563.6509399414062, + "pressure": 2553, + "timestamp": 1774182483900 + }, + { + "x": 421.0146789550781, + "y": 569.5939331054688, + "pressure": 2628, + "timestamp": 1774182483975 + }, + { + "x": 416.85406494140625, + "y": 573.1597290039062, + "pressure": 2616, + "timestamp": 1774182483990 + }, + { + "x": 412.0990905761719, + "y": 575.5369262695312, + "pressure": 2583, + "timestamp": 1774182484003 + }, + { + "x": 408.1365966796875, + "y": 576.5274658203125, + "pressure": 2416, + "timestamp": 1774182484025 + }, + { + "x": 406.7497253417969, + "y": 575.3388671875, + "pressure": 2212, + "timestamp": 1774182484115 + }, + { + "x": 406.55157470703125, + "y": 573.9521484375, + "pressure": 2124, + "timestamp": 1774182484219 + }, + { + "x": 413.8822021484375, + "y": 572.1692504882812, + "pressure": 2140, + "timestamp": 1774182484237 + }, + { + "x": 420.22216796875, + "y": 571.574951171875, + "pressure": 2196, + "timestamp": 1774182484259 + }, + { + "x": 427.35467529296875, + "y": 569.7920532226562, + "pressure": 2221, + "timestamp": 1774182484280 + }, + { + "x": 430.524658203125, + "y": 568.2072143554688, + "pressure": 2234, + "timestamp": 1774182484296 + }, + { + "x": 437.6571350097656, + "y": 562.8585205078125, + "pressure": 2246, + "timestamp": 1774182484323 + }, + { + "x": 443.60089111328125, + "y": 556.1231079101562, + "pressure": 2248, + "timestamp": 1774182484350 + }, + { + "x": 448.3558654785156, + "y": 552.5572509765625, + "pressure": 2257, + "timestamp": 1774182484371 + }, + { + "x": 450.13897705078125, + "y": 549.5857543945312, + "pressure": 2493, + "timestamp": 1774182484436 + }, + { + "x": 443.79901123046875, + "y": 553.3496704101562, + "pressure": 2490, + "timestamp": 1774182484450 + }, + { + "x": 437.6571350097656, + "y": 561.8679809570312, + "pressure": 2461, + "timestamp": 1774182484473 + }, + { + "x": 436.0721435546875, + "y": 566.6224365234375, + "pressure": 2396, + "timestamp": 1774182484507 + }, + { + "x": 438.25152587890625, + "y": 568.0091552734375, + "pressure": 2412, + "timestamp": 1774182484537 + }, + { + "x": 439.8365173339844, + "y": 568.0091552734375, + "pressure": 2427, + "timestamp": 1774182484550 + }, + { + "x": 444.59149169921875, + "y": 566.2261962890625, + "pressure": 2437, + "timestamp": 1774182484566 + }, + { + "x": 450.13897705078125, + "y": 561.273681640625, + "pressure": 2441, + "timestamp": 1774182484584 + }, + { + "x": 453.9033508300781, + "y": 554.7363891601562, + "pressure": 2439, + "timestamp": 1774182484611 + }, + { + "x": 449.14837646484375, + "y": 559.4907836914062, + "pressure": 2358, + "timestamp": 1774182484667 + }, + { + "x": 446.3746337890625, + "y": 565.2357177734375, + "pressure": 2316, + "timestamp": 1774182484694 + }, + { + "x": 447.1671142578125, + "y": 567.6129150390625, + "pressure": 2288, + "timestamp": 1774182484727 + }, + { + "x": 451.1296081542969, + "y": 569.5939331054688, + "pressure": 2302, + "timestamp": 1774182484740 + }, + { + "x": 455.8846130371094, + "y": 568.9996337890625, + "pressure": 2332, + "timestamp": 1774182484759 + }, + { + "x": 463.01708984375, + "y": 565.03759765625, + "pressure": 2346, + "timestamp": 1774182484792 + }, + { + "x": 468.1683349609375, + "y": 561.0756225585938, + "pressure": 2350, + "timestamp": 1774182484811 + }, + { + "x": 475.3008117675781, + "y": 551.9629516601562, + "pressure": 2462, + "timestamp": 1774182485148 + }, + { + "x": 475.4989318847656, + "y": 555.132568359375, + "pressure": 2464, + "timestamp": 1774182485156 + }, + { + "x": 473.9139404296875, + "y": 562.0661010742188, + "pressure": 2481, + "timestamp": 1774182485168 + }, + { + "x": 466.38519287109375, + "y": 584.8477172851562, + "pressure": 2498, + "timestamp": 1774182485208 + }, + { + "x": 463.6114501953125, + "y": 590.9888305664062, + "pressure": 2511, + "timestamp": 1774182485222 + }, + { + "x": 454.69586181640625, + "y": 600.4976196289062, + "pressure": 2528, + "timestamp": 1774182485245 + }, + { + "x": 451.5258483886719, + "y": 602.8748779296875, + "pressure": 2517, + "timestamp": 1774182485254 + }, + { + "x": 443.99713134765625, + "y": 606.0444946289062, + "pressure": 2131, + "timestamp": 1774182485306 + }, + { + "x": 441.8177490234375, + "y": 602.4786376953125, + "pressure": 2124, + "timestamp": 1774182485317 + }, + { + "x": 441.8177490234375, + "y": 599.1109619140625, + "pressure": 2100, + "timestamp": 1774182485323 + }, + { + "x": 444.39337158203125, + "y": 592.37548828125, + "pressure": 2097, + "timestamp": 1774182485339 + }, + { + "x": 447.9596252441406, + "y": 587.4229736328125, + "pressure": 2092, + "timestamp": 1774182485353 + }, + { + "x": 455.8846130371094, + "y": 579.102783203125, + "pressure": 2094, + "timestamp": 1774182485372 + }, + { + "x": 464.2058410644531, + "y": 568.6034545898438, + "pressure": 2097, + "timestamp": 1774182485398 + }, + { + "x": 473.3195495605469, + "y": 561.8679809570312, + "pressure": 2090, + "timestamp": 1774182485421 + }, + { + "x": 478.8670349121094, + "y": 556.7174072265625, + "pressure": 2087, + "timestamp": 1774182485436 + }, + { + "x": 488.7732849121094, + "y": 551.36865234375, + "pressure": 2205, + "timestamp": 1774182485548 + }, + { + "x": 490.556396484375, + "y": 555.3306884765625, + "pressure": 2243, + "timestamp": 1774182485569 + }, + { + "x": 490.16015625, + "y": 557.7078857421875, + "pressure": 2276, + "timestamp": 1774182485580 + }, + { + "x": 487.1882629394531, + "y": 561.8679809570312, + "pressure": 2294, + "timestamp": 1774182485596 + }, + { + "x": 482.82952880859375, + "y": 566.0281372070312, + "pressure": 2293, + "timestamp": 1774182485610 + }, + { + "x": 478.8670349121094, + "y": 568.9996337890625, + "pressure": 2298, + "timestamp": 1774182485628 + }, + { + "x": 474.7064208984375, + "y": 570.7825317382812, + "pressure": 2295, + "timestamp": 1774182485644 + }, + { + "x": 469.5552062988281, + "y": 571.1787109375, + "pressure": 2167, + "timestamp": 1774182485663 + }, + { + "x": 467.1777038574219, + "y": 570.188232421875, + "pressure": 1954, + "timestamp": 1774182485745 + }, + { + "x": 486.7920227050781, + "y": 565.2357177734375, + "pressure": 1972, + "timestamp": 1774182485794 + }, + { + "x": 493.13201904296875, + "y": 562.0661010742188, + "pressure": 1985, + "timestamp": 1774182485819 + }, + { + "x": 495.90576171875, + "y": 560.4813232421875, + "pressure": 1991, + "timestamp": 1774182485831 + }, + { + "x": 503.83074951171875, + "y": 551.764892578125, + "pressure": 2036, + "timestamp": 1774182485974 + }, + { + "x": 501.0570068359375, + "y": 560.283203125, + "pressure": 2078, + "timestamp": 1774182485990 + }, + { + "x": 486.9901428222656, + "y": 587.4229736328125, + "pressure": 2387, + "timestamp": 1774182486042 + }, + { + "x": 482.82952880859375, + "y": 592.771728515625, + "pressure": 2419, + "timestamp": 1774182486077 + }, + { + "x": 479.2633056640625, + "y": 594.5546264648438, + "pressure": 2402, + "timestamp": 1774182486125 + }, + { + "x": 478.2726745605469, + "y": 590.9888305664062, + "pressure": 2346, + "timestamp": 1774182486140 + }, + { + "x": 480.4520568847656, + "y": 583.857177734375, + "pressure": 2322, + "timestamp": 1774182486154 + }, + { + "x": 484.6126708984375, + "y": 575.9331665039062, + "pressure": 2313, + "timestamp": 1774182486168 + }, + { + "x": 493.52825927734375, + "y": 565.03759765625, + "pressure": 2306, + "timestamp": 1774182486189 + }, + { + "x": 498.87762451171875, + "y": 559.8870239257812, + "pressure": 2303, + "timestamp": 1774182486203 + }, + { + "x": 509.57635498046875, + "y": 551.9629516601562, + "pressure": 2305, + "timestamp": 1774182486222 + }, + { + "x": 515.9163208007812, + "y": 548.5952758789062, + "pressure": 2295, + "timestamp": 1774182486235 + }, + { + "x": 523.6431884765625, + "y": 546.0199584960938, + "pressure": 2321, + "timestamp": 1774182486279 + }, + { + "x": 525.030029296875, + "y": 548.1990356445312, + "pressure": 2356, + "timestamp": 1774182486284 + }, + { + "x": 526.0206909179688, + "y": 552.9534912109375, + "pressure": 2380, + "timestamp": 1774182486299 + }, + { + "x": 525.42626953125, + "y": 558.1040649414062, + "pressure": 2413, + "timestamp": 1774182486329 + }, + { + "x": 523.84130859375, + "y": 560.4813232421875, + "pressure": 2415, + "timestamp": 1774182486345 + }, + { + "x": 520.8694458007812, + "y": 562.8585205078125, + "pressure": 2404, + "timestamp": 1774182486352 + }, + { + "x": 515.3219604492188, + "y": 565.2357177734375, + "pressure": 2373, + "timestamp": 1774182486367 + }, + { + "x": 505.0195007324219, + "y": 565.03759765625, + "pressure": 2075, + "timestamp": 1774182486418 + }, + { + "x": 501.0570068359375, + "y": 564.047119140625, + "pressure": 2047, + "timestamp": 1774182486478 + }, + { + "x": 505.0195007324219, + "y": 565.03759765625, + "pressure": 1862, + "timestamp": 1774182486594 + }, + { + "x": 514.1331787109375, + "y": 564.8395385742188, + "pressure": 1925, + "timestamp": 1774182486617 + }, + { + "x": 532.3607177734375, + "y": 560.0850830078125, + "pressure": 1997, + "timestamp": 1774182486673 + }, + { + "x": 547.6162719726562, + "y": 547.604736328125, + "pressure": 2075, + "timestamp": 1774182486794 + }, + { + "x": 549.2012939453125, + "y": 544.4351196289062, + "pressure": 2060, + "timestamp": 1774182486831 + }, + { + "x": 547.6162719726562, + "y": 543.6427612304688, + "pressure": 2058, + "timestamp": 1774182486840 + }, + { + "x": 545.23876953125, + "y": 543.6427612304688, + "pressure": 2013, + "timestamp": 1774182486847 + }, + { + "x": 540.4837646484375, + "y": 545.4256591796875, + "pressure": 1986, + "timestamp": 1774182486860 + }, + { + "x": 535.5306396484375, + "y": 548.991455078125, + "pressure": 1979, + "timestamp": 1774182486873 + }, + { + "x": 531.9644165039062, + "y": 553.745849609375, + "pressure": 1953, + "timestamp": 1774182486883 + }, + { + "x": 529.190673828125, + "y": 559.292724609375, + "pressure": 1921, + "timestamp": 1774182486901 + }, + { + "x": 528.398193359375, + "y": 563.2546997070312, + "pressure": 1877, + "timestamp": 1774182486921 + }, + { + "x": 529.7850341796875, + "y": 567.2167358398438, + "pressure": 1827, + "timestamp": 1774182486947 + }, + { + "x": 531.9644165039062, + "y": 569.3958129882812, + "pressure": 1837, + "timestamp": 1774182486960 + }, + { + "x": 535.1343994140625, + "y": 570.7825317382812, + "pressure": 1858, + "timestamp": 1774182486974 + }, + { + "x": 539.889404296875, + "y": 570.7825317382812, + "pressure": 1890, + "timestamp": 1774182486990 + }, + { + "x": 547.0218505859375, + "y": 567.4148559570312, + "pressure": 1927, + "timestamp": 1774182487018 + }, + { + "x": 565.2493896484375, + "y": 553.1515502929688, + "pressure": 1839, + "timestamp": 1774182487209 + }, + { + "x": 561.0887451171875, + "y": 554.5382690429688, + "pressure": 1830, + "timestamp": 1774182487217 + }, + { + "x": 554.1543579101562, + "y": 559.6889038085938, + "pressure": 1819, + "timestamp": 1774182487234 + }, + { + "x": 551.1824951171875, + "y": 563.2546997070312, + "pressure": 1780, + "timestamp": 1774182487251 + }, + { + "x": 550.19189453125, + "y": 566.0281372070312, + "pressure": 1667, + "timestamp": 1774182487295 + }, + { + "x": 553.7581176757812, + "y": 566.8204956054688, + "pressure": 1684, + "timestamp": 1774182487310 + }, + { + "x": 559.3056030273438, + "y": 564.6414184570312, + "pressure": 1667, + "timestamp": 1774182487327 + }, + { + "x": 563.26806640625, + "y": 561.669921875, + "pressure": 1651, + "timestamp": 1774182487343 + }, + { + "x": 568.0230712890625, + "y": 556.1231079101562, + "pressure": 1655, + "timestamp": 1774182487362 + }, + { + "x": 562.2774658203125, + "y": 565.6318969726562, + "pressure": 1893, + "timestamp": 1774182487446 + }, + { + "x": 562.079345703125, + "y": 569.19775390625, + "pressure": 1796, + "timestamp": 1774182487473 + }, + { + "x": 566.239990234375, + "y": 572.1692504882812, + "pressure": 1838, + "timestamp": 1774182487489 + }, + { + "x": 572.5799560546875, + "y": 571.574951171875, + "pressure": 1884, + "timestamp": 1774182487507 + }, + { + "x": 582.0899658203125, + "y": 565.2357177734375, + "pressure": 1856, + "timestamp": 1774182487536 + }, + { + "x": 593.1849365234375, + "y": 552.5572509765625, + "pressure": 1661, + "timestamp": 1774182487564 + }, + { + "x": 600.1192626953125, + "y": 546.218017578125, + "pressure": 2283, + "timestamp": 1774182487627 + }, + { + "x": 592.7886962890625, + "y": 551.36865234375, + "pressure": 2272, + "timestamp": 1774182487645 + }, + { + "x": 590.2130126953125, + "y": 554.14208984375, + "pressure": 2259, + "timestamp": 1774182487651 + }, + { + "x": 588.826171875, + "y": 557.3117065429688, + "pressure": 2195, + "timestamp": 1774182487677 + }, + { + "x": 592.3923950195312, + "y": 559.8870239257812, + "pressure": 2161, + "timestamp": 1774182487701 + }, + { + "x": 602.2986450195312, + "y": 560.0850830078125, + "pressure": 2139, + "timestamp": 1774182487761 + }, + { + "x": 603.0911254882812, + "y": 561.4718017578125, + "pressure": 2143, + "timestamp": 1774182487765 + }, + { + "x": 603.0911254882812, + "y": 565.03759765625, + "pressure": 2195, + "timestamp": 1774182487776 + }, + { + "x": 596.3549194335938, + "y": 578.70654296875, + "pressure": 2182, + "timestamp": 1774182487814 + }, + { + "x": 596.15673828125, + "y": 579.8951416015625, + "pressure": 2146, + "timestamp": 1774182487828 + }, + { + "x": 597.3455200195312, + "y": 580.8856811523438, + "pressure": 2147, + "timestamp": 1774182487831 + }, + { + "x": 601.7042846679688, + "y": 581.6781005859375, + "pressure": 2164, + "timestamp": 1774182487841 + }, + { + "x": 607.8461303710938, + "y": 580.2913818359375, + "pressure": 2167, + "timestamp": 1774182487847 + }, + { + "x": 622.903564453125, + "y": 575.5369262695312, + "pressure": 2173, + "timestamp": 1774182487863 + }, + { + "x": 636.3760375976562, + "y": 569.19775390625, + "pressure": 1481, + "timestamp": 1774182487885 + } + ] + }, + { + "index": 30, + "timestampMs": 1774182492804, + "points": [ + { + "x": 411.50469970703125, + "y": 528.9832763671875, + "pressure": 783, + "timestamp": 1774182488962 + }, + { + "x": 411.50469970703125, + "y": 528.9832763671875, + "pressure": 1489, + "timestamp": 1774182489057 + } + ] + }, + { + "index": 31, + "timestampMs": 1774182497857, + "points": [ + { + "x": 44.18182373046875, + "y": 652.59814453125, + "pressure": 967, + "timestamp": 1774182492793 + }, + { + "x": 52.1068115234375, + "y": 648.0418090820312, + "pressure": 1947, + "timestamp": 1774182492864 + }, + { + "x": 59.2392578125, + "y": 641.7025756835938, + "pressure": 2091, + "timestamp": 1774182492878 + }, + { + "x": 67.95672607421875, + "y": 628.8260498046875, + "pressure": 2177, + "timestamp": 1774182492905 + }, + { + "x": 75.0892333984375, + "y": 614.958984375, + "pressure": 2197, + "timestamp": 1774182492928 + }, + { + "x": 83.21234130859375, + "y": 596.9318237304688, + "pressure": 2248, + "timestamp": 1774182492960 + }, + { + "x": 84.9954833984375, + "y": 588.6116333007812, + "pressure": 2269, + "timestamp": 1774182492978 + }, + { + "x": 84.20294189453125, + "y": 581.0838012695312, + "pressure": 2342, + "timestamp": 1774182493021 + }, + { + "x": 81.825439453125, + "y": 577.9141845703125, + "pressure": 2289, + "timestamp": 1774182493103 + }, + { + "x": 79.24981689453125, + "y": 578.9046630859375, + "pressure": 2271, + "timestamp": 1774182493115 + }, + { + "x": 75.4854736328125, + "y": 583.6590576171875, + "pressure": 2261, + "timestamp": 1774182493124 + }, + { + "x": 70.1361083984375, + "y": 595.14892578125, + "pressure": 2254, + "timestamp": 1774182493146 + }, + { + "x": 62.60736083984375, + "y": 620.109619140625, + "pressure": 2258, + "timestamp": 1774182493184 + }, + { + "x": 60.2298583984375, + "y": 632.1937866210938, + "pressure": 2265, + "timestamp": 1774182493215 + }, + { + "x": 59.0411376953125, + "y": 647.84375, + "pressure": 2380, + "timestamp": 1774182493422 + }, + { + "x": 65.1829833984375, + "y": 631.7975463867188, + "pressure": 2438, + "timestamp": 1774182493651 + }, + { + "x": 69.7398681640625, + "y": 623.0811157226562, + "pressure": 2399, + "timestamp": 1774182493730 + }, + { + "x": 72.31549072265625, + "y": 620.7039184570312, + "pressure": 2368, + "timestamp": 1774182493828 + }, + { + "x": 75.4854736328125, + "y": 625.0621337890625, + "pressure": 2362, + "timestamp": 1774182493896 + }, + { + "x": 77.46673583984375, + "y": 631.0051879882812, + "pressure": 2405, + "timestamp": 1774182493935 + }, + { + "x": 77.86297607421875, + "y": 645.0703125, + "pressure": 2394, + "timestamp": 1774182494024 + }, + { + "x": 79.24981689453125, + "y": 650.8152465820312, + "pressure": 2374, + "timestamp": 1774182494083 + }, + { + "x": 83.80670166015625, + "y": 653.588623046875, + "pressure": 2375, + "timestamp": 1774182494151 + }, + { + "x": 87.76922607421875, + "y": 652.59814453125, + "pressure": 2393, + "timestamp": 1774182494178 + }, + { + "x": 92.524169921875, + "y": 649.4285278320312, + "pressure": 2403, + "timestamp": 1774182494210 + }, + { + "x": 96.28857421875, + "y": 645.0703125, + "pressure": 2427, + "timestamp": 1774182494240 + }, + { + "x": 103.0247802734375, + "y": 634.7691040039062, + "pressure": 2509, + "timestamp": 1774182494345 + }, + { + "x": 102.82666015625, + "y": 631.9956665039062, + "pressure": 2543, + "timestamp": 1774182494369 + }, + { + "x": 101.04351806640625, + "y": 629.2222290039062, + "pressure": 2543, + "timestamp": 1774182494390 + }, + { + "x": 97.0810546875, + "y": 627.4393310546875, + "pressure": 2514, + "timestamp": 1774182494431 + }, + { + "x": 94.70355224609375, + "y": 628.0336303710938, + "pressure": 2497, + "timestamp": 1774182494438 + }, + { + "x": 91.9298095703125, + "y": 630.0146484375, + "pressure": 2467, + "timestamp": 1774182494457 + }, + { + "x": 88.36358642578125, + "y": 636.3538818359375, + "pressure": 2438, + "timestamp": 1774182494484 + }, + { + "x": 89.35418701171875, + "y": 642.693115234375, + "pressure": 2376, + "timestamp": 1774182494547 + }, + { + "x": 94.70355224609375, + "y": 646.6550903320312, + "pressure": 2383, + "timestamp": 1774182494591 + }, + { + "x": 101.8360595703125, + "y": 647.447509765625, + "pressure": 2417, + "timestamp": 1774182494670 + }, + { + "x": 107.383544921875, + "y": 645.6646118164062, + "pressure": 2440, + "timestamp": 1774182494718 + }, + { + "x": 119.667236328125, + "y": 637.1463012695312, + "pressure": 2445, + "timestamp": 1774182494826 + }, + { + "x": 131.35662841796875, + "y": 623.0811157226562, + "pressure": 2462, + "timestamp": 1774182494916 + }, + { + "x": 137.69659423828125, + "y": 612.3836669921875, + "pressure": 2478, + "timestamp": 1774182494964 + }, + { + "x": 141.6590576171875, + "y": 602.2805786132812, + "pressure": 2488, + "timestamp": 1774182494997 + }, + { + "x": 144.43280029296875, + "y": 590.9888305664062, + "pressure": 2512, + "timestamp": 1774182495039 + }, + { + "x": 144.03656005859375, + "y": 583.857177734375, + "pressure": 2562, + "timestamp": 1774182495249 + }, + { + "x": 141.6590576171875, + "y": 583.0647583007812, + "pressure": 2513, + "timestamp": 1774182495254 + }, + { + "x": 138.88531494140625, + "y": 583.4609985351562, + "pressure": 2508, + "timestamp": 1774182495264 + }, + { + "x": 134.13037109375, + "y": 586.630615234375, + "pressure": 2508, + "timestamp": 1774182495281 + }, + { + "x": 132.34722900390625, + "y": 588.8096923828125, + "pressure": 2506, + "timestamp": 1774182495288 + }, + { + "x": 127.98846435546875, + "y": 595.3470458984375, + "pressure": 2496, + "timestamp": 1774182495316 + }, + { + "x": 124.4222412109375, + "y": 602.6767578125, + "pressure": 2485, + "timestamp": 1774182495334 + }, + { + "x": 121.45037841796875, + "y": 613.7703857421875, + "pressure": 2478, + "timestamp": 1774182495361 + }, + { + "x": 121.05413818359375, + "y": 627.2412719726562, + "pressure": 2512, + "timestamp": 1774182495395 + }, + { + "x": 121.64849853515625, + "y": 633.5804443359375, + "pressure": 2510, + "timestamp": 1774182495428 + }, + { + "x": 123.431640625, + "y": 640.9102172851562, + "pressure": 2490, + "timestamp": 1774182495511 + }, + { + "x": 126.79974365234375, + "y": 645.4664916992188, + "pressure": 2491, + "timestamp": 1774182495654 + }, + { + "x": 129.9697265625, + "y": 646.8532104492188, + "pressure": 2504, + "timestamp": 1774182495714 + }, + { + "x": 133.13970947265625, + "y": 646.060791015625, + "pressure": 2517, + "timestamp": 1774182495750 + }, + { + "x": 136.7059326171875, + "y": 643.2874145507812, + "pressure": 2527, + "timestamp": 1774182495767 + }, + { + "x": 146.6121826171875, + "y": 629.4203491210938, + "pressure": 2527, + "timestamp": 1774182495832 + }, + { + "x": 147.4046630859375, + "y": 631.0051879882812, + "pressure": 2536, + "timestamp": 1774182495976 + }, + { + "x": 145.225341796875, + "y": 645.8627319335938, + "pressure": 2542, + "timestamp": 1774182496007 + }, + { + "x": 137.69659423828125, + "y": 672.6063232421875, + "pressure": 2621, + "timestamp": 1774182496053 + }, + { + "x": 134.7247314453125, + "y": 678.945556640625, + "pressure": 2577, + "timestamp": 1774182496102 + }, + { + "x": 132.34722900390625, + "y": 680.3322143554688, + "pressure": 2526, + "timestamp": 1774182496120 + }, + { + "x": 129.17724609375, + "y": 680.5303344726562, + "pressure": 2497, + "timestamp": 1774182496140 + }, + { + "x": 125.80908203125, + "y": 675.181640625, + "pressure": 2357, + "timestamp": 1774182496169 + }, + { + "x": 125.6109619140625, + "y": 672.0120239257812, + "pressure": 2349, + "timestamp": 1774182496182 + }, + { + "x": 127.98846435546875, + "y": 662.3050537109375, + "pressure": 2342, + "timestamp": 1774182496204 + }, + { + "x": 130.365966796875, + "y": 657.3525390625, + "pressure": 2337, + "timestamp": 1774182496226 + }, + { + "x": 143.44219970703125, + "y": 642.296875, + "pressure": 2331, + "timestamp": 1774182496276 + }, + { + "x": 155.52777099609375, + "y": 632.1937866210938, + "pressure": 2321, + "timestamp": 1774182496310 + }, + { + "x": 167.01904296875, + "y": 624.864013671875, + "pressure": 2301, + "timestamp": 1774182496361 + }, + { + "x": 170.18902587890625, + "y": 624.0716552734375, + "pressure": 2303, + "timestamp": 1774182496392 + }, + { + "x": 173.3590087890625, + "y": 624.864013671875, + "pressure": 2422, + "timestamp": 1774182496461 + }, + { + "x": 173.953369140625, + "y": 627.637451171875, + "pressure": 2462, + "timestamp": 1774182496473 + }, + { + "x": 171.97210693359375, + "y": 631.2032470703125, + "pressure": 2473, + "timestamp": 1774182496507 + }, + { + "x": 167.2171630859375, + "y": 634.7691040039062, + "pressure": 2472, + "timestamp": 1774182496527 + }, + { + "x": 160.48089599609375, + "y": 637.9386596679688, + "pressure": 2455, + "timestamp": 1774182496552 + }, + { + "x": 150.57470703125, + "y": 640.7120971679688, + "pressure": 2400, + "timestamp": 1774182496762 + }, + { + "x": 162.6602783203125, + "y": 640.5139770507812, + "pressure": 2301, + "timestamp": 1774182496974 + }, + { + "x": 175.34027099609375, + "y": 637.7406005859375, + "pressure": 2309, + "timestamp": 1774182497028 + }, + { + "x": 195.1527099609375, + "y": 627.4393310546875, + "pressure": 2322, + "timestamp": 1774182497145 + }, + { + "x": 196.3414306640625, + "y": 626.4488525390625, + "pressure": 2702, + "timestamp": 1774182497229 + }, + { + "x": 192.57708740234375, + "y": 625.0621337890625, + "pressure": 2662, + "timestamp": 1774182497263 + }, + { + "x": 190.00146484375, + "y": 625.6564331054688, + "pressure": 2626, + "timestamp": 1774182497274 + }, + { + "x": 185.04833984375, + "y": 629.4203491210938, + "pressure": 2598, + "timestamp": 1774182497290 + }, + { + "x": 181.08587646484375, + "y": 636.7500610351562, + "pressure": 2548, + "timestamp": 1774182497324 + }, + { + "x": 181.48211669921875, + "y": 639.919677734375, + "pressure": 2508, + "timestamp": 1774182497351 + }, + { + "x": 186.23712158203125, + "y": 642.8911743164062, + "pressure": 2504, + "timestamp": 1774182497366 + }, + { + "x": 191.78460693359375, + "y": 643.4855346679688, + "pressure": 2512, + "timestamp": 1774182497383 + }, + { + "x": 196.3414306640625, + "y": 642.4949951171875, + "pressure": 2513, + "timestamp": 1774182497391 + }, + { + "x": 211.2008056640625, + "y": 634.7691040039062, + "pressure": 2516, + "timestamp": 1774182497429 + }, + { + "x": 216.748291015625, + "y": 630.8070678710938, + "pressure": 2631, + "timestamp": 1774182497518 + }, + { + "x": 211.5970458984375, + "y": 634.3728637695312, + "pressure": 2618, + "timestamp": 1774182497533 + }, + { + "x": 209.61578369140625, + "y": 636.7500610351562, + "pressure": 2609, + "timestamp": 1774182497543 + }, + { + "x": 207.634521484375, + "y": 640.31591796875, + "pressure": 2563, + "timestamp": 1774182497567 + }, + { + "x": 208.03082275390625, + "y": 641.9006958007812, + "pressure": 2519, + "timestamp": 1774182497580 + }, + { + "x": 210.8045654296875, + "y": 643.0892944335938, + "pressure": 2521, + "timestamp": 1774182497594 + }, + { + "x": 214.17266845703125, + "y": 642.693115234375, + "pressure": 2523, + "timestamp": 1774182497600 + }, + { + "x": 223.4844970703125, + "y": 636.9481811523438, + "pressure": 2543, + "timestamp": 1774182497623 + }, + { + "x": 230.02264404296875, + "y": 630.6089477539062, + "pressure": 2551, + "timestamp": 1774182497639 + }, + { + "x": 236.16448974609375, + "y": 621.496337890625, + "pressure": 2557, + "timestamp": 1774182497655 + }, + { + "x": 245.2781982421875, + "y": 596.7337036132812, + "pressure": 2570, + "timestamp": 1774182497688 + }, + { + "x": 247.06134033203125, + "y": 588.6116333007812, + "pressure": 2657, + "timestamp": 1774182497706 + }, + { + "x": 244.683837890625, + "y": 588.4135131835938, + "pressure": 2663, + "timestamp": 1774182497725 + }, + { + "x": 241.31573486328125, + "y": 590.1964111328125, + "pressure": 2663, + "timestamp": 1774182497730 + }, + { + "x": 234.9757080078125, + "y": 596.53564453125, + "pressure": 2660, + "timestamp": 1774182497741 + }, + { + "x": 227.84326171875, + "y": 607.43115234375, + "pressure": 2675, + "timestamp": 1774182497754 + }, + { + "x": 224.67327880859375, + "y": 613.968505859375, + "pressure": 2680, + "timestamp": 1774182497759 + }, + { + "x": 222.2957763671875, + "y": 620.109619140625, + "pressure": 2683, + "timestamp": 1774182497766 + }, + { + "x": 221.10699462890625, + "y": 626.0526123046875, + "pressure": 2789, + "timestamp": 1774182497771 + }, + { + "x": 220.90887451171875, + "y": 631.9956665039062, + "pressure": 2803, + "timestamp": 1774182497777 + }, + { + "x": 222.09765625, + "y": 638.532958984375, + "pressure": 2821, + "timestamp": 1774182497786 + }, + { + "x": 227.447021484375, + "y": 645.4664916992188, + "pressure": 2829, + "timestamp": 1774182497798 + }, + { + "x": 229.82452392578125, + "y": 647.0513305664062, + "pressure": 2817, + "timestamp": 1774182497802 + }, + { + "x": 237.15509033203125, + "y": 648.834228515625, + "pressure": 2814, + "timestamp": 1774182497810 + }, + { + "x": 253.20318603515625, + "y": 647.447509765625, + "pressure": 2810, + "timestamp": 1774182497830 + }, + { + "x": 257.95819091796875, + "y": 646.060791015625, + "pressure": 1810, + "timestamp": 1774182497836 + }, + { + "x": 265.09063720703125, + "y": 641.9006958007812, + "pressure": 1672, + "timestamp": 1774182497848 + } + ] + }, + { + "index": 32, + "timestampMs": 1774182504612, + "points": [ + { + "x": 317.98992919921875, + "y": 650.0228271484375, + "pressure": 620, + "timestamp": 1774182499249 + }, + { + "x": 330.8680114746094, + "y": 635.165283203125, + "pressure": 1958, + "timestamp": 1774182499358 + }, + { + "x": 339.3873596191406, + "y": 623.87353515625, + "pressure": 2359, + "timestamp": 1774182499471 + }, + { + "x": 339.98175048828125, + "y": 627.0431518554688, + "pressure": 2370, + "timestamp": 1774182499476 + }, + { + "x": 339.3873596191406, + "y": 634.9671630859375, + "pressure": 2382, + "timestamp": 1774182499489 + }, + { + "x": 334.43426513671875, + "y": 651.21142578125, + "pressure": 2409, + "timestamp": 1774182499508 + }, + { + "x": 329.67926025390625, + "y": 662.503173828125, + "pressure": 2417, + "timestamp": 1774182499521 + }, + { + "x": 323.93365478515625, + "y": 673.3987426757812, + "pressure": 2419, + "timestamp": 1774182499533 + }, + { + "x": 311.4517822265625, + "y": 691.227783203125, + "pressure": 2419, + "timestamp": 1774182499565 + }, + { + "x": 307.88555908203125, + "y": 692.8125610351562, + "pressure": 2328, + "timestamp": 1774182499590 + }, + { + "x": 305.508056640625, + "y": 691.8220825195312, + "pressure": 2283, + "timestamp": 1774182499604 + }, + { + "x": 303.32867431640625, + "y": 687.6619873046875, + "pressure": 2268, + "timestamp": 1774182499615 + }, + { + "x": 303.7249755859375, + "y": 682.1151733398438, + "pressure": 2256, + "timestamp": 1774182499624 + }, + { + "x": 309.2724609375, + "y": 669.4367065429688, + "pressure": 2230, + "timestamp": 1774182499641 + }, + { + "x": 319.3768005371094, + "y": 655.3715209960938, + "pressure": 2214, + "timestamp": 1774182499657 + }, + { + "x": 328.88677978515625, + "y": 645.8627319335938, + "pressure": 2207, + "timestamp": 1774182499670 + }, + { + "x": 338.00048828125, + "y": 639.3253784179688, + "pressure": 2210, + "timestamp": 1774182499682 + }, + { + "x": 351.67108154296875, + "y": 632.1937866210938, + "pressure": 2221, + "timestamp": 1774182499695 + }, + { + "x": 362.9642028808594, + "y": 627.637451171875, + "pressure": 2233, + "timestamp": 1774182499709 + }, + { + "x": 370.2947998046875, + "y": 625.4583129882812, + "pressure": 2406, + "timestamp": 1774182499750 + }, + { + "x": 371.8797912597656, + "y": 630.410888671875, + "pressure": 2499, + "timestamp": 1774182499766 + }, + { + "x": 370.88916015625, + "y": 633.9766845703125, + "pressure": 2533, + "timestamp": 1774182499778 + }, + { + "x": 367.91729736328125, + "y": 638.3348999023438, + "pressure": 2545, + "timestamp": 1774182499786 + }, + { + "x": 361.57733154296875, + "y": 643.2874145507812, + "pressure": 2555, + "timestamp": 1774182499802 + }, + { + "x": 353.2560729980469, + "y": 646.8532104492188, + "pressure": 2553, + "timestamp": 1774182499818 + }, + { + "x": 347.3123474121094, + "y": 648.0418090820312, + "pressure": 2539, + "timestamp": 1774182499830 + }, + { + "x": 337.604248046875, + "y": 648.4380493164062, + "pressure": 2518, + "timestamp": 1774182499850 + }, + { + "x": 334.43426513671875, + "y": 647.6456298828125, + "pressure": 2472, + "timestamp": 1774182499864 + }, + { + "x": 334.63238525390625, + "y": 646.45703125, + "pressure": 2260, + "timestamp": 1774182499943 + }, + { + "x": 340.17987060546875, + "y": 643.2874145507812, + "pressure": 2263, + "timestamp": 1774182499957 + }, + { + "x": 354.2467041015625, + "y": 639.5234985351562, + "pressure": 2287, + "timestamp": 1774182499986 + }, + { + "x": 375.0497741699219, + "y": 630.8070678710938, + "pressure": 2331, + "timestamp": 1774182500030 + }, + { + "x": 380.79541015625, + "y": 627.2412719726562, + "pressure": 2352, + "timestamp": 1774182500050 + }, + { + "x": 393.0791320800781, + "y": 617.336181640625, + "pressure": 2368, + "timestamp": 1774182500126 + }, + { + "x": 389.51287841796875, + "y": 623.0811157226562, + "pressure": 2580, + "timestamp": 1774182500262 + }, + { + "x": 389.71099853515625, + "y": 624.6659545898438, + "pressure": 2550, + "timestamp": 1774182500281 + }, + { + "x": 392.8809814453125, + "y": 626.0526123046875, + "pressure": 2549, + "timestamp": 1774182500291 + }, + { + "x": 401.9947204589844, + "y": 625.26025390625, + "pressure": 2600, + "timestamp": 1774182500355 + }, + { + "x": 401.5984802246094, + "y": 627.637451171875, + "pressure": 2617, + "timestamp": 1774182500360 + }, + { + "x": 395.65472412109375, + "y": 638.7310791015625, + "pressure": 2623, + "timestamp": 1774182500399 + }, + { + "x": 396.8434753417969, + "y": 640.1177978515625, + "pressure": 2536, + "timestamp": 1774182500452 + }, + { + "x": 400.40972900390625, + "y": 641.5045166015625, + "pressure": 2540, + "timestamp": 1774182500459 + }, + { + "x": 408.334716796875, + "y": 640.1177978515625, + "pressure": 2557, + "timestamp": 1774182500485 + }, + { + "x": 414.6746826171875, + "y": 636.552001953125, + "pressure": 2570, + "timestamp": 1774182500506 + }, + { + "x": 418.2409362792969, + "y": 631.7975463867188, + "pressure": 2580, + "timestamp": 1774182500536 + }, + { + "x": 413.68408203125, + "y": 639.1273193359375, + "pressure": 2570, + "timestamp": 1774182500706 + }, + { + "x": 410.5140686035156, + "y": 646.8532104492188, + "pressure": 2546, + "timestamp": 1774182500722 + }, + { + "x": 411.10845947265625, + "y": 650.8152465820312, + "pressure": 2494, + "timestamp": 1774182500753 + }, + { + "x": 415.0709533691406, + "y": 653.1924438476562, + "pressure": 2486, + "timestamp": 1774182500764 + }, + { + "x": 418.2409362792969, + "y": 653.1924438476562, + "pressure": 2503, + "timestamp": 1774182500774 + }, + { + "x": 421.80718994140625, + "y": 651.4095458984375, + "pressure": 2522, + "timestamp": 1774182500790 + }, + { + "x": 424.779052734375, + "y": 647.447509765625, + "pressure": 2520, + "timestamp": 1774182500805 + }, + { + "x": 427.55279541015625, + "y": 640.31591796875, + "pressure": 2468, + "timestamp": 1774182500834 + }, + { + "x": 425.9678039550781, + "y": 634.7691040039062, + "pressure": 2417, + "timestamp": 1774182500866 + }, + { + "x": 424.5809326171875, + "y": 633.1842651367188, + "pressure": 2400, + "timestamp": 1774182500871 + }, + { + "x": 421.80718994140625, + "y": 631.9956665039062, + "pressure": 2352, + "timestamp": 1774182500898 + }, + { + "x": 428.9396667480469, + "y": 635.5614624023438, + "pressure": 2255, + "timestamp": 1774182501003 + }, + { + "x": 434.6852722167969, + "y": 635.5614624023438, + "pressure": 2274, + "timestamp": 1774182501017 + }, + { + "x": 441.8177490234375, + "y": 634.1747436523438, + "pressure": 2309, + "timestamp": 1774182501035 + }, + { + "x": 452.318359375, + "y": 628.6279296875, + "pressure": 2381, + "timestamp": 1774182501062 + }, + { + "x": 457.8658447265625, + "y": 624.4678344726562, + "pressure": 2396, + "timestamp": 1774182501077 + }, + { + "x": 463.413330078125, + "y": 618.9210205078125, + "pressure": 2415, + "timestamp": 1774182501098 + }, + { + "x": 470.1495666503906, + "y": 608.2235717773438, + "pressure": 2432, + "timestamp": 1774182501126 + }, + { + "x": 474.904541015625, + "y": 597.526123046875, + "pressure": 2438, + "timestamp": 1774182501147 + }, + { + "x": 476.88580322265625, + "y": 586.630615234375, + "pressure": 2471, + "timestamp": 1774182501178 + }, + { + "x": 476.68768310546875, + "y": 585.4420166015625, + "pressure": 2507, + "timestamp": 1774182501206 + }, + { + "x": 474.112060546875, + "y": 584.4514770507812, + "pressure": 2510, + "timestamp": 1774182501212 + }, + { + "x": 470.5458068847656, + "y": 584.8477172851562, + "pressure": 2488, + "timestamp": 1774182501221 + }, + { + "x": 468.5645751953125, + "y": 586.0363159179688, + "pressure": 2483, + "timestamp": 1774182501224 + }, + { + "x": 460.44146728515625, + "y": 594.158447265625, + "pressure": 2462, + "timestamp": 1774182501239 + }, + { + "x": 449.74273681640625, + "y": 610.4027099609375, + "pressure": 2456, + "timestamp": 1774182501256 + }, + { + "x": 445.3840026855469, + "y": 620.109619140625, + "pressure": 2452, + "timestamp": 1774182501267 + }, + { + "x": 442.2140197753906, + "y": 632.5899658203125, + "pressure": 2447, + "timestamp": 1774182501283 + }, + { + "x": 442.4121398925781, + "y": 638.532958984375, + "pressure": 2444, + "timestamp": 1774182501292 + }, + { + "x": 444.39337158203125, + "y": 644.4760131835938, + "pressure": 2467, + "timestamp": 1774182501317 + }, + { + "x": 450.7333679199219, + "y": 648.4380493164062, + "pressure": 2482, + "timestamp": 1774182501345 + }, + { + "x": 455.48834228515625, + "y": 648.4380493164062, + "pressure": 2531, + "timestamp": 1774182501368 + }, + { + "x": 459.4508361816406, + "y": 645.8627319335938, + "pressure": 2573, + "timestamp": 1774182501394 + }, + { + "x": 463.413330078125, + "y": 639.5234985351562, + "pressure": 2616, + "timestamp": 1774182501417 + }, + { + "x": 464.9983215332031, + "y": 633.5804443359375, + "pressure": 2656, + "timestamp": 1774182501491 + }, + { + "x": 464.9983215332031, + "y": 629.6184692382812, + "pressure": 2552, + "timestamp": 1774182501815 + }, + { + "x": 483.6220397949219, + "y": 625.4583129882812, + "pressure": 2603, + "timestamp": 1774182501884 + }, + { + "x": 491.5470275878906, + "y": 621.2982177734375, + "pressure": 2619, + "timestamp": 1774182501928 + }, + { + "x": 498.67950439453125, + "y": 615.9495239257812, + "pressure": 2626, + "timestamp": 1774182501957 + }, + { + "x": 502.2457275390625, + "y": 611.9874877929688, + "pressure": 2628, + "timestamp": 1774182501971 + }, + { + "x": 507.7932434082031, + "y": 602.4786376953125, + "pressure": 2653, + "timestamp": 1774182501997 + }, + { + "x": 510.7651062011719, + "y": 592.5736083984375, + "pressure": 2699, + "timestamp": 1774182502056 + }, + { + "x": 507.9913635253906, + "y": 591.385009765625, + "pressure": 2682, + "timestamp": 1774182502072 + }, + { + "x": 502.8401184082031, + "y": 591.9793090820312, + "pressure": 2677, + "timestamp": 1774182502083 + }, + { + "x": 498.67950439453125, + "y": 594.3565063476562, + "pressure": 2659, + "timestamp": 1774182502090 + }, + { + "x": 494.7170104980469, + "y": 598.3185424804688, + "pressure": 2658, + "timestamp": 1774182502099 + }, + { + "x": 487.78265380859375, + "y": 608.81787109375, + "pressure": 2636, + "timestamp": 1774182502112 + }, + { + "x": 481.2445373535156, + "y": 623.4773559570312, + "pressure": 2628, + "timestamp": 1774182502132 + }, + { + "x": 479.065185546875, + "y": 631.7975463867188, + "pressure": 2614, + "timestamp": 1774182502142 + }, + { + "x": 480.0557861328125, + "y": 639.919677734375, + "pressure": 2587, + "timestamp": 1774182502158 + }, + { + "x": 484.0182800292969, + "y": 644.6741333007812, + "pressure": 2579, + "timestamp": 1774182502180 + }, + { + "x": 490.3582763671875, + "y": 648.2399291992188, + "pressure": 2584, + "timestamp": 1774182502196 + }, + { + "x": 495.1132507324219, + "y": 648.6361083984375, + "pressure": 2596, + "timestamp": 1774182502206 + }, + { + "x": 500.6607360839844, + "y": 647.2494506835938, + "pressure": 2613, + "timestamp": 1774182502220 + }, + { + "x": 505.4157409667969, + "y": 644.8721923828125, + "pressure": 2630, + "timestamp": 1774182502236 + }, + { + "x": 507.7932434082031, + "y": 642.4949951171875, + "pressure": 2636, + "timestamp": 1774182502248 + }, + { + "x": 511.953857421875, + "y": 635.3634033203125, + "pressure": 2612, + "timestamp": 1774182502268 + }, + { + "x": 512.9444580078125, + "y": 631.7975463867188, + "pressure": 2621, + "timestamp": 1774182502286 + }, + { + "x": 512.5482177734375, + "y": 630.2127685546875, + "pressure": 2603, + "timestamp": 1774182502313 + }, + { + "x": 511.1613464355469, + "y": 629.6184692382812, + "pressure": 2600, + "timestamp": 1774182502317 + }, + { + "x": 505.8119812011719, + "y": 629.2222290039062, + "pressure": 2541, + "timestamp": 1774182502333 + }, + { + "x": 499.8682556152344, + "y": 633.3823852539062, + "pressure": 2479, + "timestamp": 1774182502361 + }, + { + "x": 497.8869934082031, + "y": 638.1367797851562, + "pressure": 2435, + "timestamp": 1774182502381 + }, + { + "x": 498.67950439453125, + "y": 641.7025756835938, + "pressure": 2407, + "timestamp": 1774182502395 + }, + { + "x": 506.2082214355469, + "y": 646.2589111328125, + "pressure": 2414, + "timestamp": 1774182502425 + }, + { + "x": 509.37823486328125, + "y": 647.0513305664062, + "pressure": 2418, + "timestamp": 1774182502432 + }, + { + "x": 514.9257202148438, + "y": 646.8532104492188, + "pressure": 2430, + "timestamp": 1774182502452 + }, + { + "x": 520.4732055664062, + "y": 644.6741333007812, + "pressure": 2455, + "timestamp": 1774182502470 + }, + { + "x": 524.4356689453125, + "y": 641.7025756835938, + "pressure": 2466, + "timestamp": 1774182502485 + }, + { + "x": 530.775634765625, + "y": 635.3634033203125, + "pressure": 2476, + "timestamp": 1774182502507 + }, + { + "x": 533.5494384765625, + "y": 631.4013671875, + "pressure": 2476, + "timestamp": 1774182502525 + }, + { + "x": 541.2762451171875, + "y": 624.4678344726562, + "pressure": 2544, + "timestamp": 1774182502665 + }, + { + "x": 543.0593872070312, + "y": 631.5994873046875, + "pressure": 2563, + "timestamp": 1774182502682 + }, + { + "x": 540.8800048828125, + "y": 643.4855346679688, + "pressure": 2614, + "timestamp": 1774182502801 + }, + { + "x": 553.1637573242188, + "y": 631.7975463867188, + "pressure": 2759, + "timestamp": 1774182502876 + }, + { + "x": 552.5693359375, + "y": 636.15576171875, + "pressure": 2772, + "timestamp": 1774182502887 + }, + { + "x": 550.588134765625, + "y": 641.7025756835938, + "pressure": 2672, + "timestamp": 1774182502958 + }, + { + "x": 555.541259765625, + "y": 640.1177978515625, + "pressure": 2675, + "timestamp": 1774182502971 + }, + { + "x": 568.22119140625, + "y": 632.5899658203125, + "pressure": 2597, + "timestamp": 1774182503040 + }, + { + "x": 571.985595703125, + "y": 641.306396484375, + "pressure": 2553, + "timestamp": 1774182503085 + }, + { + "x": 575.9480590820312, + "y": 643.4855346679688, + "pressure": 2548, + "timestamp": 1774182503096 + }, + { + "x": 580.7030639648438, + "y": 643.68359375, + "pressure": 2547, + "timestamp": 1774182503106 + }, + { + "x": 589.0242919921875, + "y": 640.5139770507812, + "pressure": 2542, + "timestamp": 1774182503133 + }, + { + "x": 595.3642578125, + "y": 632.9861450195312, + "pressure": 2426, + "timestamp": 1774182503162 + }, + { + "x": 595.760498046875, + "y": 631.0051879882812, + "pressure": 2484, + "timestamp": 1774182503200 + }, + { + "x": 593.5811767578125, + "y": 631.4013671875, + "pressure": 2482, + "timestamp": 1774182503206 + }, + { + "x": 586.4486694335938, + "y": 635.3634033203125, + "pressure": 2472, + "timestamp": 1774182503216 + }, + { + "x": 576.7406005859375, + "y": 645.2684326171875, + "pressure": 2457, + "timestamp": 1774182503238 + }, + { + "x": 575.1555786132812, + "y": 648.0418090820312, + "pressure": 2297, + "timestamp": 1774182503275 + }, + { + "x": 576.7406005859375, + "y": 648.6361083984375, + "pressure": 2301, + "timestamp": 1774182503288 + }, + { + "x": 579.71240234375, + "y": 648.2399291992188, + "pressure": 2306, + "timestamp": 1774182503297 + }, + { + "x": 583.6749267578125, + "y": 646.060791015625, + "pressure": 2320, + "timestamp": 1774182503311 + }, + { + "x": 589.0242919921875, + "y": 641.5045166015625, + "pressure": 2266, + "timestamp": 1774182503350 + }, + { + "x": 588.429931640625, + "y": 644.2778930664062, + "pressure": 2387, + "timestamp": 1774182503421 + }, + { + "x": 589.4205322265625, + "y": 645.8627319335938, + "pressure": 2452, + "timestamp": 1774182503436 + }, + { + "x": 591.4017944335938, + "y": 646.8532104492188, + "pressure": 2463, + "timestamp": 1774182503447 + }, + { + "x": 594.175537109375, + "y": 647.0513305664062, + "pressure": 2519, + "timestamp": 1774182503452 + }, + { + "x": 599.7230224609375, + "y": 645.6646118164062, + "pressure": 2536, + "timestamp": 1774182503466 + }, + { + "x": 609.43115234375, + "y": 640.1177978515625, + "pressure": 2555, + "timestamp": 1774182503489 + }, + { + "x": 617.950439453125, + "y": 630.8070678710938, + "pressure": 2561, + "timestamp": 1774182503511 + }, + { + "x": 625.6773681640625, + "y": 619.9114990234375, + "pressure": 2525, + "timestamp": 1774182503528 + }, + { + "x": 636.3760375976562, + "y": 600.299560546875, + "pressure": 2327, + "timestamp": 1774182503551 + }, + { + "x": 636.7723388671875, + "y": 597.7242431640625, + "pressure": 2719, + "timestamp": 1774182503587 + }, + { + "x": 631.8192138671875, + "y": 601.686279296875, + "pressure": 2715, + "timestamp": 1774182503598 + }, + { + "x": 621.71484375, + "y": 612.3836669921875, + "pressure": 2694, + "timestamp": 1774182503612 + }, + { + "x": 610.2236328125, + "y": 627.8355712890625, + "pressure": 2675, + "timestamp": 1774182503628 + }, + { + "x": 605.0723876953125, + "y": 637.1463012695312, + "pressure": 2673, + "timestamp": 1774182503640 + }, + { + "x": 602.8930053710938, + "y": 643.8817138671875, + "pressure": 2649, + "timestamp": 1774182503648 + }, + { + "x": 602.4967651367188, + "y": 648.6361083984375, + "pressure": 2638, + "timestamp": 1774182503659 + }, + { + "x": 604.6761474609375, + "y": 652.59814453125, + "pressure": 2658, + "timestamp": 1774182503683 + }, + { + "x": 607.8461303710938, + "y": 654.3810424804688, + "pressure": 2692, + "timestamp": 1774182503693 + }, + { + "x": 612.204833984375, + "y": 654.5791625976562, + "pressure": 2697, + "timestamp": 1774182503703 + }, + { + "x": 622.903564453125, + "y": 651.4095458984375, + "pressure": 2724, + "timestamp": 1774182503725 + }, + { + "x": 632.2154541015625, + "y": 644.4760131835938, + "pressure": 2730, + "timestamp": 1774182503744 + }, + { + "x": 643.90478515625, + "y": 631.5994873046875, + "pressure": 2702, + "timestamp": 1774182503774 + }, + { + "x": 642.9141845703125, + "y": 635.5614624023438, + "pressure": 2782, + "timestamp": 1774182504045 + }, + { + "x": 638.555419921875, + "y": 644.2778930664062, + "pressure": 2756, + "timestamp": 1774182504097 + }, + { + "x": 638.95166015625, + "y": 647.0513305664062, + "pressure": 2767, + "timestamp": 1774182504174 + }, + { + "x": 642.716064453125, + "y": 647.84375, + "pressure": 2787, + "timestamp": 1774182504190 + }, + { + "x": 649.8485107421875, + "y": 644.8721923828125, + "pressure": 2788, + "timestamp": 1774182504206 + }, + { + "x": 664.3116455078125, + "y": 637.1463012695312, + "pressure": 2780, + "timestamp": 1774182504237 + }, + { + "x": 689.0771484375, + "y": 626.250732421875, + "pressure": 2733, + "timestamp": 1774182504301 + }, + { + "x": 690.0678100585938, + "y": 628.6279296875, + "pressure": 2746, + "timestamp": 1774182504339 + }, + { + "x": 689.0771484375, + "y": 632.5899658203125, + "pressure": 2765, + "timestamp": 1774182504350 + }, + { + "x": 686.1053466796875, + "y": 638.532958984375, + "pressure": 2641, + "timestamp": 1774182504427 + }, + { + "x": 682.3409423828125, + "y": 639.1273193359375, + "pressure": 2492, + "timestamp": 1774182504445 + }, + { + "x": 676.5953369140625, + "y": 637.54248046875, + "pressure": 2447, + "timestamp": 1774182504477 + }, + { + "x": 673.2272338867188, + "y": 638.3348999023438, + "pressure": 2447, + "timestamp": 1774182504489 + }, + { + "x": 669.6610107421875, + "y": 641.9006958007812, + "pressure": 2474, + "timestamp": 1774182504499 + }, + { + "x": 668.0759887695312, + "y": 645.4664916992188, + "pressure": 2540, + "timestamp": 1774182504509 + }, + { + "x": 668.4722290039062, + "y": 648.6361083984375, + "pressure": 2563, + "timestamp": 1774182504521 + }, + { + "x": 671.444091796875, + "y": 651.607666015625, + "pressure": 2604, + "timestamp": 1774182504528 + }, + { + "x": 679.5671997070312, + "y": 654.7772216796875, + "pressure": 2649, + "timestamp": 1774182504544 + }, + { + "x": 683.1334228515625, + "y": 655.3715209960938, + "pressure": 2653, + "timestamp": 1774182504549 + }, + { + "x": 692.4453125, + "y": 654.975341796875, + "pressure": 2652, + "timestamp": 1774182504564 + }, + { + "x": 702.747802734375, + "y": 652.4000244140625, + "pressure": 1362, + "timestamp": 1774182504603 + } + ] + }, + { + "index": 33, + "timestampMs": 1774182504861, + "points": [ + { + "x": 592.7886962890625, + "y": 612.9779663085938, + "pressure": 968, + "timestamp": 1774182504782 + }, + { + "x": 604.6761474609375, + "y": 614.958984375, + "pressure": 2096, + "timestamp": 1774182504810 + }, + { + "x": 621.5167236328125, + "y": 616.5438232421875, + "pressure": 2113, + "timestamp": 1774182504822 + }, + { + "x": 656.1884765625, + "y": 616.5438232421875, + "pressure": 1773, + "timestamp": 1774182504860 + } + ] + }, + { + "index": 34, + "timestampMs": 1774182505068, + "points": [ + { + "x": 677.189697265625, + "y": 599.3090209960938, + "pressure": 1116, + "timestamp": 1774182505006 + }, + { + "x": 677.189697265625, + "y": 599.3090209960938, + "pressure": 1879, + "timestamp": 1774182505068 + } + ] + } + ], + "processingEvents": [ + { + "strokeIndex": 0, + "type": "ADDED", + "detail": "line=4", + "timestampMs": 1774182439645 + }, + { + "strokeIndex": 1, + "type": "ADDED", + "detail": "line=4", + "timestampMs": 1774182440328 + }, + { + "strokeIndex": 2, + "type": "ADDED", + "detail": "line=4", + "timestampMs": 1774182440757 + }, + { + "strokeIndex": 3, + "type": "ADDED", + "detail": "line=4", + "timestampMs": 1774182441093 + }, + { + "strokeIndex": 4, + "type": "ADDED", + "detail": "line=4", + "timestampMs": 1774182441930 + }, + { + "strokeIndex": 5, + "type": "ADDED", + "detail": "line=4", + "timestampMs": 1774182443228 + }, + { + "strokeIndex": 6, + "type": "ADDED", + "detail": "line=4", + "timestampMs": 1774182443853 + }, + { + "strokeIndex": 7, + "type": "ADDED", + "detail": "line=4", + "timestampMs": 1774182444270 + }, + { + "strokeIndex": 8, + "type": "ADDED", + "detail": "line=4", + "timestampMs": 1774182444593 + }, + { + "strokeIndex": 9, + "type": "ADDED", + "detail": "line=4", + "timestampMs": 1774182445104 + }, + { + "strokeIndex": 10, + "type": "ADDED", + "detail": "line=4", + "timestampMs": 1774182453879 + }, + { + "strokeIndex": 11, + "type": "ADDED", + "detail": "line=4", + "timestampMs": 1774182454312 + }, + { + "strokeIndex": 12, + "type": "ADDED", + "detail": "line=4", + "timestampMs": 1774182454968 + }, + { + "strokeIndex": 13, + "type": "ADDED", + "detail": "line=4", + "timestampMs": 1774182455128 + }, + { + "strokeIndex": 14, + "type": "ADDED", + "detail": "line=4", + "timestampMs": 1774182455484 + }, + { + "strokeIndex": 15, + "type": "ADDED", + "detail": "line=4", + "timestampMs": 1774182456301 + }, + { + "strokeIndex": 16, + "type": "ADDED", + "detail": "line=4", + "timestampMs": 1774182456556 + }, + { + "strokeIndex": 17, + "type": "ADDED", + "detail": "line=4", + "timestampMs": 1774182457007 + }, + { + "strokeIndex": 18, + "type": "ADDED", + "detail": "line=4", + "timestampMs": 1774182458532 + }, + { + "strokeIndex": 19, + "type": "ADDED", + "detail": "line=4", + "timestampMs": 1774182458539 + }, + { + "strokeIndex": 20, + "type": "ADDED", + "detail": "line=4", + "timestampMs": 1774182458804 + }, + { + "strokeIndex": 21, + "type": "ADDED", + "detail": "line=4", + "timestampMs": 1774182459203 + }, + { + "strokeIndex": 22, + "type": "ADDED", + "detail": "line=6", + "timestampMs": 1774182472458 + }, + { + "strokeIndex": 23, + "type": "ADDED", + "detail": "line=6", + "timestampMs": 1774182472515 + }, + { + "strokeIndex": 24, + "type": "ADDED", + "detail": "line=6", + "timestampMs": 1774182472527 + }, + { + "strokeIndex": 25, + "type": "ADDED", + "detail": "line=6", + "timestampMs": 1774182473496 + }, + { + "strokeIndex": 26, + "type": "SCRATCH_OUT", + "detail": "erased=7f6f17cd-1b34-468d-aa62-bf857958d9a5,b7117311-6578-485b-ae9f-daaf1e5b7bde", + "timestampMs": 1774182480807 + }, + { + "strokeIndex": 27, + "type": "ADDED", + "detail": "line=6", + "timestampMs": 1774182481205 + }, + { + "strokeIndex": 28, + "type": "ADDED", + "detail": "line=6", + "timestampMs": 1774182481451 + }, + { + "strokeIndex": 29, + "type": "SCRATCH_OUT", + "detail": "erased=5e788ca2-b307-482f-b1b2-c00dba849751", + "timestampMs": 1774182492772 + }, + { + "strokeIndex": 30, + "type": "ADDED", + "detail": "line=6", + "timestampMs": 1774182492804 + }, + { + "strokeIndex": 31, + "type": "ADDED", + "detail": "line=7", + "timestampMs": 1774182497858 + }, + { + "strokeIndex": 32, + "type": "ADDED", + "detail": "line=7", + "timestampMs": 1774182504618 + }, + { + "strokeIndex": 33, + "type": "ADDED", + "detail": "line=7", + "timestampMs": 1774182504861 + }, + { + "strokeIndex": 34, + "type": "ADDED", + "detail": "line=7", + "timestampMs": 1774182505069 + } + ] +} \ No newline at end of file diff --git a/app/src/test/resources/fixtures/descender_test.json b/app/src/test/resources/fixtures/descender_test.json new file mode 100644 index 0000000..1ec29c2 --- /dev/null +++ b/app/src/test/resources/fixtures/descender_test.json @@ -0,0 +1,44983 @@ +{ + "lineSpacing": 77, + "topMargin": 36, + "strokeCount": 9, + "strokes": [ + { + "index": 0, + "pointCount": 1910, + "points": [ + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 162, + "timestamp": 1774179066658 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 282, + "timestamp": 1774179066660 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 342, + "timestamp": 1774179066663 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 372, + "timestamp": 1774179066663 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 387, + "timestamp": 1774179066664 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 394, + "timestamp": 1774179066666 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 398, + "timestamp": 1774179066668 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 401, + "timestamp": 1774179066674 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 548, + "timestamp": 1774179066675 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 621, + "timestamp": 1774179066678 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 658, + "timestamp": 1774179066679 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 676, + "timestamp": 1774179066681 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 685, + "timestamp": 1774179066682 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 690, + "timestamp": 1774179066684 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 693, + "timestamp": 1774179066690 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 875, + "timestamp": 1774179066692 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 966, + "timestamp": 1774179066694 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 1011, + "timestamp": 1774179066695 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 1034, + "timestamp": 1774179066699 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 1045, + "timestamp": 1774179066699 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 1051, + "timestamp": 1774179066700 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 1054, + "timestamp": 1774179066702 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 1171, + "timestamp": 1774179066707 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 1229, + "timestamp": 1774179066710 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 1258, + "timestamp": 1774179066711 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 1273, + "timestamp": 1774179066713 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 1280, + "timestamp": 1774179066714 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 1284, + "timestamp": 1774179066716 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 1287, + "timestamp": 1774179066722 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 1343, + "timestamp": 1774179066723 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 1371, + "timestamp": 1774179066726 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 1385, + "timestamp": 1774179066727 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 1392, + "timestamp": 1774179066729 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 1396, + "timestamp": 1774179066730 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 1399, + "timestamp": 1774179066734 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 1453, + "timestamp": 1774179066740 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 1479, + "timestamp": 1774179066742 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 1492, + "timestamp": 1774179066743 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 1498, + "timestamp": 1774179066745 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 1501, + "timestamp": 1774179066747 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 1504, + "timestamp": 1774179066750 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 1517, + "timestamp": 1774179066755 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 1522, + "timestamp": 1774179066758 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 1525, + "timestamp": 1774179066759 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 1529, + "timestamp": 1774179066764 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 1526, + "timestamp": 1774179066775 + }, + { + "x": 78.25921630859375, + "y": 163.72531127929688, + "pressure": 1572, + "timestamp": 1774179066788 + }, + { + "x": 77.26861572265625, + "y": 163.5272216796875, + "pressure": 1594, + "timestamp": 1774179066790 + }, + { + "x": 75.287353515625, + "y": 163.32913208007812, + "pressure": 1605, + "timestamp": 1774179066792 + }, + { + "x": 73.30609130859375, + "y": 163.13101196289062, + "pressure": 1611, + "timestamp": 1774179066793 + }, + { + "x": 71.72113037109375, + "y": 162.93292236328125, + "pressure": 1614, + "timestamp": 1774179066795 + }, + { + "x": 70.5323486328125, + "y": 162.73483276367188, + "pressure": 1615, + "timestamp": 1774179066796 + }, + { + "x": 69.541748046875, + "y": 162.53671264648438, + "pressure": 1616, + "timestamp": 1774179066798 + }, + { + "x": 68.5511474609375, + "y": 162.338623046875, + "pressure": 1618, + "timestamp": 1774179066803 + }, + { + "x": 67.75860595703125, + "y": 162.338623046875, + "pressure": 1611, + "timestamp": 1774179066804 + }, + { + "x": 67.36236572265625, + "y": 162.338623046875, + "pressure": 1607, + "timestamp": 1774179066806 + }, + { + "x": 66.96612548828125, + "y": 162.338623046875, + "pressure": 1604, + "timestamp": 1774179066809 + }, + { + "x": 66.56988525390625, + "y": 162.338623046875, + "pressure": 1606, + "timestamp": 1774179066811 + }, + { + "x": 66.17364501953125, + "y": 162.53671264648438, + "pressure": 1605, + "timestamp": 1774179066812 + }, + { + "x": 65.77740478515625, + "y": 162.53671264648438, + "pressure": 1604, + "timestamp": 1774179066814 + }, + { + "x": 65.77740478515625, + "y": 162.53671264648438, + "pressure": 1588, + "timestamp": 1774179066820 + }, + { + "x": 65.77740478515625, + "y": 162.53671264648438, + "pressure": 1579, + "timestamp": 1774179066822 + }, + { + "x": 65.381103515625, + "y": 162.73483276367188, + "pressure": 1574, + "timestamp": 1774179066823 + }, + { + "x": 64.588623046875, + "y": 162.93292236328125, + "pressure": 1572, + "timestamp": 1774179066825 + }, + { + "x": 63.796142578125, + "y": 163.32913208007812, + "pressure": 1571, + "timestamp": 1774179066827 + }, + { + "x": 63.39990234375, + "y": 163.32913208007812, + "pressure": 1570, + "timestamp": 1774179066828 + }, + { + "x": 63.39990234375, + "y": 163.32913208007812, + "pressure": 1581, + "timestamp": 1774179066836 + }, + { + "x": 63.00360107421875, + "y": 163.5272216796875, + "pressure": 1586, + "timestamp": 1774179066839 + }, + { + "x": 62.21112060546875, + "y": 164.12152099609375, + "pressure": 1588, + "timestamp": 1774179066839 + }, + { + "x": 61.81488037109375, + "y": 164.31961059570312, + "pressure": 1589, + "timestamp": 1774179066841 + }, + { + "x": 61.81488037109375, + "y": 164.31961059570312, + "pressure": 1592, + "timestamp": 1774179066844 + }, + { + "x": 61.41864013671875, + "y": 164.7158203125, + "pressure": 1592, + "timestamp": 1774179066855 + }, + { + "x": 60.62615966796875, + "y": 165.31011962890625, + "pressure": 1591, + "timestamp": 1774179066857 + }, + { + "x": 60.2298583984375, + "y": 165.50823974609375, + "pressure": 1590, + "timestamp": 1774179066859 + }, + { + "x": 59.8336181640625, + "y": 165.9044189453125, + "pressure": 1566, + "timestamp": 1774179066868 + }, + { + "x": 59.2392578125, + "y": 166.89492797851562, + "pressure": 1554, + "timestamp": 1774179066870 + }, + { + "x": 58.6448974609375, + "y": 167.68734741210938, + "pressure": 1548, + "timestamp": 1774179066872 + }, + { + "x": 58.2486572265625, + "y": 168.479736328125, + "pressure": 1545, + "timestamp": 1774179066873 + }, + { + "x": 58.050537109375, + "y": 168.87594604492188, + "pressure": 1543, + "timestamp": 1774179066875 + }, + { + "x": 57.654296875, + "y": 169.27215576171875, + "pressure": 1543, + "timestamp": 1774179066882 + }, + { + "x": 57.05987548828125, + "y": 170.26263427734375, + "pressure": 1523, + "timestamp": 1774179066884 + }, + { + "x": 56.66363525390625, + "y": 171.0550537109375, + "pressure": 1513, + "timestamp": 1774179066886 + }, + { + "x": 56.26739501953125, + "y": 171.84744262695312, + "pressure": 1508, + "timestamp": 1774179066888 + }, + { + "x": 56.06927490234375, + "y": 172.24365234375, + "pressure": 1505, + "timestamp": 1774179066889 + }, + { + "x": 55.87115478515625, + "y": 172.63986206054688, + "pressure": 1505, + "timestamp": 1774179066894 + }, + { + "x": 55.67303466796875, + "y": 173.63037109375, + "pressure": 1504, + "timestamp": 1774179066898 + }, + { + "x": 55.27679443359375, + "y": 174.42276000976562, + "pressure": 1478, + "timestamp": 1774179066900 + }, + { + "x": 55.07867431640625, + "y": 174.8189697265625, + "pressure": 1465, + "timestamp": 1774179066902 + }, + { + "x": 55.07867431640625, + "y": 174.8189697265625, + "pressure": 1459, + "timestamp": 1774179066904 + }, + { + "x": 55.07867431640625, + "y": 174.8189697265625, + "pressure": 1456, + "timestamp": 1774179066905 + }, + { + "x": 54.88055419921875, + "y": 175.21514892578125, + "pressure": 1453, + "timestamp": 1774179066909 + }, + { + "x": 54.682373046875, + "y": 176.007568359375, + "pressure": 1455, + "timestamp": 1774179066910 + }, + { + "x": 54.4842529296875, + "y": 176.40377807617188, + "pressure": 1454, + "timestamp": 1774179066914 + }, + { + "x": 54.4842529296875, + "y": 176.40377807617188, + "pressure": 1422, + "timestamp": 1774179066916 + }, + { + "x": 54.4842529296875, + "y": 176.40377807617188, + "pressure": 1406, + "timestamp": 1774179066918 + }, + { + "x": 54.2861328125, + "y": 176.79995727539062, + "pressure": 1398, + "timestamp": 1774179066920 + }, + { + "x": 54.0880126953125, + "y": 177.59237670898438, + "pressure": 1394, + "timestamp": 1774179066921 + }, + { + "x": 54.0880126953125, + "y": 177.98858642578125, + "pressure": 1392, + "timestamp": 1774179066923 + }, + { + "x": 53.889892578125, + "y": 178.384765625, + "pressure": 1389, + "timestamp": 1774179066934 + }, + { + "x": 53.6917724609375, + "y": 179.17718505859375, + "pressure": 1388, + "timestamp": 1774179066936 + }, + { + "x": 53.6917724609375, + "y": 179.57339477539062, + "pressure": 1390, + "timestamp": 1774179066937 + }, + { + "x": 53.6917724609375, + "y": 179.96957397460938, + "pressure": 1380, + "timestamp": 1774179066948 + }, + { + "x": 53.889892578125, + "y": 180.76199340820312, + "pressure": 1375, + "timestamp": 1774179066950 + }, + { + "x": 53.889892578125, + "y": 181.15817260742188, + "pressure": 1373, + "timestamp": 1774179066952 + }, + { + "x": 53.889892578125, + "y": 181.15817260742188, + "pressure": 1366, + "timestamp": 1774179066964 + }, + { + "x": 53.889892578125, + "y": 181.15817260742188, + "pressure": 1363, + "timestamp": 1774179066968 + }, + { + "x": 54.0880126953125, + "y": 181.55438232421875, + "pressure": 1364, + "timestamp": 1774179066971 + }, + { + "x": 54.2861328125, + "y": 182.3468017578125, + "pressure": 1363, + "timestamp": 1774179066973 + }, + { + "x": 54.4842529296875, + "y": 182.74298095703125, + "pressure": 1362, + "timestamp": 1774179066975 + }, + { + "x": 54.4842529296875, + "y": 182.74298095703125, + "pressure": 1370, + "timestamp": 1774179066980 + }, + { + "x": 54.4842529296875, + "y": 182.74298095703125, + "pressure": 1373, + "timestamp": 1774179066983 + }, + { + "x": 54.4842529296875, + "y": 182.74298095703125, + "pressure": 1377, + "timestamp": 1774179066987 + }, + { + "x": 54.4842529296875, + "y": 182.74298095703125, + "pressure": 1390, + "timestamp": 1774179066996 + }, + { + "x": 54.4842529296875, + "y": 182.74298095703125, + "pressure": 1396, + "timestamp": 1774179066999 + }, + { + "x": 54.4842529296875, + "y": 182.74298095703125, + "pressure": 1399, + "timestamp": 1774179067000 + }, + { + "x": 54.88055419921875, + "y": 183.13919067382812, + "pressure": 1402, + "timestamp": 1774179067003 + }, + { + "x": 55.67303466796875, + "y": 183.73348999023438, + "pressure": 1404, + "timestamp": 1774179067005 + }, + { + "x": 56.06927490234375, + "y": 183.93161010742188, + "pressure": 1403, + "timestamp": 1774179067007 + }, + { + "x": 56.06927490234375, + "y": 183.93161010742188, + "pressure": 1425, + "timestamp": 1774179067012 + }, + { + "x": 56.06927490234375, + "y": 183.93161010742188, + "pressure": 1435, + "timestamp": 1774179067015 + }, + { + "x": 56.06927490234375, + "y": 183.93161010742188, + "pressure": 1440, + "timestamp": 1774179067016 + }, + { + "x": 56.46551513671875, + "y": 184.12969970703125, + "pressure": 1443, + "timestamp": 1774179067018 + }, + { + "x": 57.25799560546875, + "y": 184.7239990234375, + "pressure": 1444, + "timestamp": 1774179067019 + }, + { + "x": 57.654296875, + "y": 184.92208862304688, + "pressure": 1445, + "timestamp": 1774179067022 + }, + { + "x": 57.654296875, + "y": 184.92208862304688, + "pressure": 1470, + "timestamp": 1774179067028 + }, + { + "x": 57.654296875, + "y": 184.92208862304688, + "pressure": 1482, + "timestamp": 1774179067030 + }, + { + "x": 57.654296875, + "y": 184.92208862304688, + "pressure": 1488, + "timestamp": 1774179067032 + }, + { + "x": 57.654296875, + "y": 184.92208862304688, + "pressure": 1491, + "timestamp": 1774179067033 + }, + { + "x": 57.654296875, + "y": 184.92208862304688, + "pressure": 1494, + "timestamp": 1774179067037 + }, + { + "x": 57.654296875, + "y": 184.92208862304688, + "pressure": 1513, + "timestamp": 1774179067044 + }, + { + "x": 57.654296875, + "y": 184.92208862304688, + "pressure": 1522, + "timestamp": 1774179067047 + }, + { + "x": 58.050537109375, + "y": 185.12020874023438, + "pressure": 1526, + "timestamp": 1774179067048 + }, + { + "x": 58.44677734375, + "y": 185.12020874023438, + "pressure": 1528, + "timestamp": 1774179067050 + }, + { + "x": 58.44677734375, + "y": 185.12020874023438, + "pressure": 1532, + "timestamp": 1774179067055 + }, + { + "x": 58.44677734375, + "y": 185.12020874023438, + "pressure": 1562, + "timestamp": 1774179067060 + }, + { + "x": 58.44677734375, + "y": 185.12020874023438, + "pressure": 1577, + "timestamp": 1774179067063 + }, + { + "x": 58.843017578125, + "y": 185.31829833984375, + "pressure": 1585, + "timestamp": 1774179067064 + }, + { + "x": 59.635498046875, + "y": 185.51638793945312, + "pressure": 1589, + "timestamp": 1774179067066 + }, + { + "x": 60.03173828125, + "y": 185.51638793945312, + "pressure": 1591, + "timestamp": 1774179067067 + }, + { + "x": 60.03173828125, + "y": 185.51638793945312, + "pressure": 1594, + "timestamp": 1774179067071 + }, + { + "x": 60.03173828125, + "y": 185.51638793945312, + "pressure": 1612, + "timestamp": 1774179067076 + }, + { + "x": 60.42803955078125, + "y": 185.51638793945312, + "pressure": 1621, + "timestamp": 1774179067079 + }, + { + "x": 61.22052001953125, + "y": 185.31829833984375, + "pressure": 1626, + "timestamp": 1774179067080 + }, + { + "x": 61.61676025390625, + "y": 185.12020874023438, + "pressure": 1628, + "timestamp": 1774179067082 + }, + { + "x": 61.61676025390625, + "y": 185.12020874023438, + "pressure": 1632, + "timestamp": 1774179067087 + }, + { + "x": 62.01300048828125, + "y": 184.92208862304688, + "pressure": 1631, + "timestamp": 1774179067091 + }, + { + "x": 62.80548095703125, + "y": 184.52590942382812, + "pressure": 1650, + "timestamp": 1774179067093 + }, + { + "x": 63.5980224609375, + "y": 184.12969970703125, + "pressure": 1660, + "timestamp": 1774179067095 + }, + { + "x": 63.9942626953125, + "y": 183.93161010742188, + "pressure": 1665, + "timestamp": 1774179067096 + }, + { + "x": 63.9942626953125, + "y": 183.93161010742188, + "pressure": 1668, + "timestamp": 1774179067100 + }, + { + "x": 64.3905029296875, + "y": 183.73348999023438, + "pressure": 1669, + "timestamp": 1774179067101 + }, + { + "x": 65.1829833984375, + "y": 183.13919067382812, + "pressure": 1671, + "timestamp": 1774179067103 + }, + { + "x": 65.5792236328125, + "y": 182.74298095703125, + "pressure": 1670, + "timestamp": 1774179067107 + }, + { + "x": 65.5792236328125, + "y": 182.74298095703125, + "pressure": 1684, + "timestamp": 1774179067108 + }, + { + "x": 65.97552490234375, + "y": 182.3468017578125, + "pressure": 1691, + "timestamp": 1774179067111 + }, + { + "x": 66.76800537109375, + "y": 181.55438232421875, + "pressure": 1694, + "timestamp": 1774179067112 + }, + { + "x": 67.56048583984375, + "y": 180.76199340820312, + "pressure": 1696, + "timestamp": 1774179067114 + }, + { + "x": 68.35296630859375, + "y": 179.96957397460938, + "pressure": 1697, + "timestamp": 1774179067115 + }, + { + "x": 68.9473876953125, + "y": 179.17718505859375, + "pressure": 1699, + "timestamp": 1774179067117 + }, + { + "x": 69.541748046875, + "y": 178.384765625, + "pressure": 1698, + "timestamp": 1774179067119 + }, + { + "x": 69.7398681640625, + "y": 177.98858642578125, + "pressure": 1700, + "timestamp": 1774179067124 + }, + { + "x": 69.7398681640625, + "y": 177.98858642578125, + "pressure": 1703, + "timestamp": 1774179067125 + }, + { + "x": 69.93798828125, + "y": 177.59237670898438, + "pressure": 1705, + "timestamp": 1774179067127 + }, + { + "x": 70.5323486328125, + "y": 176.79995727539062, + "pressure": 1706, + "timestamp": 1774179067128 + }, + { + "x": 71.126708984375, + "y": 176.007568359375, + "pressure": 1708, + "timestamp": 1774179067130 + }, + { + "x": 71.52301025390625, + "y": 175.61135864257812, + "pressure": 1707, + "timestamp": 1774179067132 + }, + { + "x": 71.91925048828125, + "y": 175.21514892578125, + "pressure": 1708, + "timestamp": 1774179067135 + }, + { + "x": 72.71173095703125, + "y": 174.42276000976562, + "pressure": 1707, + "timestamp": 1774179067139 + }, + { + "x": 73.50421142578125, + "y": 173.63037109375, + "pressure": 1715, + "timestamp": 1774179067141 + }, + { + "x": 74.0986328125, + "y": 172.83795166015625, + "pressure": 1719, + "timestamp": 1774179067143 + }, + { + "x": 74.6929931640625, + "y": 172.04556274414062, + "pressure": 1721, + "timestamp": 1774179067144 + }, + { + "x": 74.89111328125, + "y": 171.64935302734375, + "pressure": 1722, + "timestamp": 1774179067146 + }, + { + "x": 75.0892333984375, + "y": 171.25314331054688, + "pressure": 1723, + "timestamp": 1774179067149 + }, + { + "x": 75.68359375, + "y": 170.46075439453125, + "pressure": 1725, + "timestamp": 1774179067151 + }, + { + "x": 76.2779541015625, + "y": 169.6683349609375, + "pressure": 1724, + "timestamp": 1774179067155 + }, + { + "x": 76.87237548828125, + "y": 168.87594604492188, + "pressure": 1738, + "timestamp": 1774179067157 + }, + { + "x": 77.07049560546875, + "y": 168.479736328125, + "pressure": 1745, + "timestamp": 1774179067159 + }, + { + "x": 77.07049560546875, + "y": 168.479736328125, + "pressure": 1749, + "timestamp": 1774179067160 + }, + { + "x": 77.26861572265625, + "y": 168.08352661132812, + "pressure": 1751, + "timestamp": 1774179067162 + }, + { + "x": 77.86297607421875, + "y": 167.2911376953125, + "pressure": 1752, + "timestamp": 1774179067164 + }, + { + "x": 78.25921630859375, + "y": 166.49871826171875, + "pressure": 1754, + "timestamp": 1774179067166 + }, + { + "x": 78.45733642578125, + "y": 165.70632934570312, + "pressure": 1753, + "timestamp": 1774179067167 + }, + { + "x": 78.65545654296875, + "y": 164.9139404296875, + "pressure": 1755, + "timestamp": 1774179067171 + }, + { + "x": 78.85357666015625, + "y": 164.51773071289062, + "pressure": 1785, + "timestamp": 1774179067173 + }, + { + "x": 78.85357666015625, + "y": 164.51773071289062, + "pressure": 1800, + "timestamp": 1774179067175 + }, + { + "x": 78.85357666015625, + "y": 164.12152099609375, + "pressure": 1807, + "timestamp": 1774179067177 + }, + { + "x": 79.05169677734375, + "y": 163.32913208007812, + "pressure": 1811, + "timestamp": 1774179067178 + }, + { + "x": 79.24981689453125, + "y": 162.53671264648438, + "pressure": 1813, + "timestamp": 1774179067180 + }, + { + "x": 79.447998046875, + "y": 161.74432373046875, + "pressure": 1814, + "timestamp": 1774179067182 + }, + { + "x": 79.6461181640625, + "y": 160.951904296875, + "pressure": 1816, + "timestamp": 1774179067183 + }, + { + "x": 79.84423828125, + "y": 160.55572509765625, + "pressure": 1815, + "timestamp": 1774179067187 + }, + { + "x": 79.84423828125, + "y": 160.55572509765625, + "pressure": 1827, + "timestamp": 1774179067189 + }, + { + "x": 79.84423828125, + "y": 160.15951538085938, + "pressure": 1833, + "timestamp": 1774179067191 + }, + { + "x": 80.0423583984375, + "y": 159.36709594726562, + "pressure": 1836, + "timestamp": 1774179067192 + }, + { + "x": 80.0423583984375, + "y": 158.57470703125, + "pressure": 1837, + "timestamp": 1774179067194 + }, + { + "x": 80.0423583984375, + "y": 157.78228759765625, + "pressure": 1838, + "timestamp": 1774179067196 + }, + { + "x": 80.240478515625, + "y": 156.98989868164062, + "pressure": 1840, + "timestamp": 1774179067197 + }, + { + "x": 80.240478515625, + "y": 156.19747924804688, + "pressure": 1839, + "timestamp": 1774179067199 + }, + { + "x": 80.240478515625, + "y": 155.80130004882812, + "pressure": 1841, + "timestamp": 1774179067204 + }, + { + "x": 80.240478515625, + "y": 155.80130004882812, + "pressure": 1837, + "timestamp": 1774179067205 + }, + { + "x": 80.240478515625, + "y": 155.40509033203125, + "pressure": 1835, + "timestamp": 1774179067207 + }, + { + "x": 80.4385986328125, + "y": 154.61270141601562, + "pressure": 1834, + "timestamp": 1774179067209 + }, + { + "x": 80.4385986328125, + "y": 153.82028198242188, + "pressure": 1833, + "timestamp": 1774179067210 + }, + { + "x": 80.4385986328125, + "y": 153.424072265625, + "pressure": 1835, + "timestamp": 1774179067212 + }, + { + "x": 80.4385986328125, + "y": 153.02789306640625, + "pressure": 1833, + "timestamp": 1774179067215 + }, + { + "x": 80.63671875, + "y": 152.2354736328125, + "pressure": 1835, + "timestamp": 1774179067219 + }, + { + "x": 80.8348388671875, + "y": 151.44308471679688, + "pressure": 1827, + "timestamp": 1774179067221 + }, + { + "x": 81.032958984375, + "y": 150.65066528320312, + "pressure": 1823, + "timestamp": 1774179067223 + }, + { + "x": 81.2310791015625, + "y": 149.8582763671875, + "pressure": 1821, + "timestamp": 1774179067224 + }, + { + "x": 81.42919921875, + "y": 149.46206665039062, + "pressure": 1820, + "timestamp": 1774179067226 + }, + { + "x": 81.42919921875, + "y": 149.06585693359375, + "pressure": 1820, + "timestamp": 1774179067232 + }, + { + "x": 81.6273193359375, + "y": 148.27346801757812, + "pressure": 1819, + "timestamp": 1774179067236 + }, + { + "x": 81.825439453125, + "y": 147.48104858398438, + "pressure": 1817, + "timestamp": 1774179067237 + }, + { + "x": 82.0235595703125, + "y": 146.68865966796875, + "pressure": 1816, + "timestamp": 1774179067239 + }, + { + "x": 82.22174072265625, + "y": 146.29244995117188, + "pressure": 1818, + "timestamp": 1774179067241 + }, + { + "x": 82.22174072265625, + "y": 145.89627075195312, + "pressure": 1818, + "timestamp": 1774179067246 + }, + { + "x": 82.41986083984375, + "y": 145.10385131835938, + "pressure": 1817, + "timestamp": 1774179067247 + }, + { + "x": 82.61798095703125, + "y": 144.7076416015625, + "pressure": 1816, + "timestamp": 1774179067251 + }, + { + "x": 82.61798095703125, + "y": 144.7076416015625, + "pressure": 1819, + "timestamp": 1774179067253 + }, + { + "x": 82.61798095703125, + "y": 144.31146240234375, + "pressure": 1821, + "timestamp": 1774179067257 + }, + { + "x": 82.81610107421875, + "y": 143.51904296875, + "pressure": 1823, + "timestamp": 1774179067258 + }, + { + "x": 83.01422119140625, + "y": 143.12283325195312, + "pressure": 1822, + "timestamp": 1774179067260 + }, + { + "x": 83.01422119140625, + "y": 142.72665405273438, + "pressure": 1829, + "timestamp": 1774179067269 + }, + { + "x": 83.21234130859375, + "y": 141.93423461914062, + "pressure": 1832, + "timestamp": 1774179067271 + }, + { + "x": 83.41046142578125, + "y": 141.53802490234375, + "pressure": 1834, + "timestamp": 1774179067273 + }, + { + "x": 83.41046142578125, + "y": 141.53802490234375, + "pressure": 1837, + "timestamp": 1774179067276 + }, + { + "x": 83.41046142578125, + "y": 141.53802490234375, + "pressure": 1858, + "timestamp": 1774179067285 + }, + { + "x": 83.41046142578125, + "y": 141.53802490234375, + "pressure": 1869, + "timestamp": 1774179067287 + }, + { + "x": 83.41046142578125, + "y": 141.53802490234375, + "pressure": 1874, + "timestamp": 1774179067289 + }, + { + "x": 83.41046142578125, + "y": 141.53802490234375, + "pressure": 1877, + "timestamp": 1774179067290 + }, + { + "x": 83.41046142578125, + "y": 141.53802490234375, + "pressure": 1881, + "timestamp": 1774179067295 + }, + { + "x": 83.41046142578125, + "y": 141.53802490234375, + "pressure": 1914, + "timestamp": 1774179067301 + }, + { + "x": 83.41046142578125, + "y": 141.53802490234375, + "pressure": 1931, + "timestamp": 1774179067303 + }, + { + "x": 83.41046142578125, + "y": 141.53802490234375, + "pressure": 1940, + "timestamp": 1774179067305 + }, + { + "x": 83.41046142578125, + "y": 141.53802490234375, + "pressure": 1944, + "timestamp": 1774179067306 + }, + { + "x": 83.41046142578125, + "y": 141.53802490234375, + "pressure": 1947, + "timestamp": 1774179067310 + }, + { + "x": 83.41046142578125, + "y": 141.53802490234375, + "pressure": 1950, + "timestamp": 1774179067316 + }, + { + "x": 83.41046142578125, + "y": 141.53802490234375, + "pressure": 1937, + "timestamp": 1774179067317 + }, + { + "x": 83.41046142578125, + "y": 141.53802490234375, + "pressure": 1930, + "timestamp": 1774179067319 + }, + { + "x": 83.41046142578125, + "y": 141.53802490234375, + "pressure": 1927, + "timestamp": 1774179067321 + }, + { + "x": 83.41046142578125, + "y": 141.53802490234375, + "pressure": 1924, + "timestamp": 1774179067324 + }, + { + "x": 83.41046142578125, + "y": 141.53802490234375, + "pressure": 1910, + "timestamp": 1774179067333 + }, + { + "x": 83.41046142578125, + "y": 141.53802490234375, + "pressure": 1903, + "timestamp": 1774179067335 + }, + { + "x": 83.41046142578125, + "y": 141.53802490234375, + "pressure": 1900, + "timestamp": 1774179067337 + }, + { + "x": 83.41046142578125, + "y": 141.53802490234375, + "pressure": 1897, + "timestamp": 1774179067340 + }, + { + "x": 83.41046142578125, + "y": 141.53802490234375, + "pressure": 1884, + "timestamp": 1774179067349 + }, + { + "x": 83.41046142578125, + "y": 141.53802490234375, + "pressure": 1878, + "timestamp": 1774179067352 + }, + { + "x": 83.41046142578125, + "y": 141.53802490234375, + "pressure": 1875, + "timestamp": 1774179067353 + }, + { + "x": 83.41046142578125, + "y": 141.53802490234375, + "pressure": 1872, + "timestamp": 1774179067356 + }, + { + "x": 83.41046142578125, + "y": 141.53802490234375, + "pressure": 1856, + "timestamp": 1774179067365 + }, + { + "x": 83.41046142578125, + "y": 141.53802490234375, + "pressure": 1848, + "timestamp": 1774179067367 + }, + { + "x": 83.41046142578125, + "y": 141.53802490234375, + "pressure": 1844, + "timestamp": 1774179067369 + }, + { + "x": 83.41046142578125, + "y": 141.53802490234375, + "pressure": 1841, + "timestamp": 1774179067373 + }, + { + "x": 83.41046142578125, + "y": 141.53802490234375, + "pressure": 1852, + "timestamp": 1774179067382 + }, + { + "x": 83.41046142578125, + "y": 141.53802490234375, + "pressure": 1857, + "timestamp": 1774179067383 + }, + { + "x": 83.41046142578125, + "y": 141.53802490234375, + "pressure": 1860, + "timestamp": 1774179067385 + }, + { + "x": 83.01422119140625, + "y": 141.53802490234375, + "pressure": 1861, + "timestamp": 1774179067387 + }, + { + "x": 82.22174072265625, + "y": 141.53802490234375, + "pressure": 1862, + "timestamp": 1774179067388 + }, + { + "x": 81.825439453125, + "y": 141.53802490234375, + "pressure": 1864, + "timestamp": 1774179067390 + }, + { + "x": 81.825439453125, + "y": 141.53802490234375, + "pressure": 1860, + "timestamp": 1774179067400 + }, + { + "x": 81.42919921875, + "y": 141.93423461914062, + "pressure": 1859, + "timestamp": 1774179067401 + }, + { + "x": 80.63671875, + "y": 142.52853393554688, + "pressure": 1861, + "timestamp": 1774179067403 + }, + { + "x": 79.84423828125, + "y": 143.32095336914062, + "pressure": 1860, + "timestamp": 1774179067404 + }, + { + "x": 79.6461181640625, + "y": 143.7171630859375, + "pressure": 1859, + "timestamp": 1774179067406 + }, + { + "x": 79.24981689453125, + "y": 144.11334228515625, + "pressure": 1861, + "timestamp": 1774179067408 + }, + { + "x": 78.65545654296875, + "y": 145.10385131835938, + "pressure": 1860, + "timestamp": 1774179067412 + }, + { + "x": 77.86297607421875, + "y": 146.0943603515625, + "pressure": 1851, + "timestamp": 1774179067413 + }, + { + "x": 77.46673583984375, + "y": 147.08486938476562, + "pressure": 1846, + "timestamp": 1774179067416 + }, + { + "x": 77.07049560546875, + "y": 148.07537841796875, + "pressure": 1844, + "timestamp": 1774179067417 + }, + { + "x": 76.6741943359375, + "y": 149.06585693359375, + "pressure": 1843, + "timestamp": 1774179067419 + }, + { + "x": 76.2779541015625, + "y": 150.05636596679688, + "pressure": 1842, + "timestamp": 1774179067420 + }, + { + "x": 75.68359375, + "y": 151.046875, + "pressure": 1844, + "timestamp": 1774179067423 + }, + { + "x": 75.0892333984375, + "y": 152.03738403320312, + "pressure": 1843, + "timestamp": 1774179067424 + }, + { + "x": 74.6929931640625, + "y": 153.02789306640625, + "pressure": 1842, + "timestamp": 1774179067428 + }, + { + "x": 74.2967529296875, + "y": 154.01837158203125, + "pressure": 1799, + "timestamp": 1774179067429 + }, + { + "x": 73.90045166015625, + "y": 155.00888061523438, + "pressure": 1778, + "timestamp": 1774179067433 + }, + { + "x": 73.50421142578125, + "y": 156.19747924804688, + "pressure": 1767, + "timestamp": 1774179067433 + }, + { + "x": 73.10797119140625, + "y": 157.3861083984375, + "pressure": 1762, + "timestamp": 1774179067435 + }, + { + "x": 72.71173095703125, + "y": 158.3765869140625, + "pressure": 1759, + "timestamp": 1774179067436 + }, + { + "x": 72.31549072265625, + "y": 159.36709594726562, + "pressure": 1758, + "timestamp": 1774179067438 + }, + { + "x": 71.91925048828125, + "y": 160.35760498046875, + "pressure": 1757, + "timestamp": 1774179067440 + }, + { + "x": 71.52301025390625, + "y": 161.34811401367188, + "pressure": 1759, + "timestamp": 1774179067444 + }, + { + "x": 71.126708984375, + "y": 162.338623046875, + "pressure": 1726, + "timestamp": 1774179067446 + }, + { + "x": 70.73046875, + "y": 163.32913208007812, + "pressure": 1710, + "timestamp": 1774179067448 + }, + { + "x": 70.334228515625, + "y": 164.51773071289062, + "pressure": 1702, + "timestamp": 1774179067449 + }, + { + "x": 69.93798828125, + "y": 165.50823974609375, + "pressure": 1698, + "timestamp": 1774179067451 + }, + { + "x": 69.541748046875, + "y": 166.49871826171875, + "pressure": 1696, + "timestamp": 1774179067452 + }, + { + "x": 69.3436279296875, + "y": 167.2911376953125, + "pressure": 1695, + "timestamp": 1774179067454 + }, + { + "x": 69.1455078125, + "y": 168.08352661132812, + "pressure": 1694, + "timestamp": 1774179067456 + }, + { + "x": 68.9473876953125, + "y": 168.87594604492188, + "pressure": 1696, + "timestamp": 1774179067461 + }, + { + "x": 68.749267578125, + "y": 169.6683349609375, + "pressure": 1675, + "timestamp": 1774179067461 + }, + { + "x": 68.749267578125, + "y": 170.46075439453125, + "pressure": 1665, + "timestamp": 1774179067464 + }, + { + "x": 68.749267578125, + "y": 170.85693359375, + "pressure": 1660, + "timestamp": 1774179067465 + }, + { + "x": 68.749267578125, + "y": 171.25314331054688, + "pressure": 1657, + "timestamp": 1774179067467 + }, + { + "x": 68.749267578125, + "y": 172.24365234375, + "pressure": 1656, + "timestamp": 1774179067469 + }, + { + "x": 68.749267578125, + "y": 173.03604125976562, + "pressure": 1655, + "timestamp": 1774179067471 + }, + { + "x": 68.749267578125, + "y": 173.82846069335938, + "pressure": 1657, + "timestamp": 1774179067472 + }, + { + "x": 68.9473876953125, + "y": 174.620849609375, + "pressure": 1656, + "timestamp": 1774179067476 + }, + { + "x": 69.1455078125, + "y": 175.41326904296875, + "pressure": 1653, + "timestamp": 1774179067478 + }, + { + "x": 69.1455078125, + "y": 175.80947875976562, + "pressure": 1651, + "timestamp": 1774179067480 + }, + { + "x": 69.1455078125, + "y": 176.20565795898438, + "pressure": 1651, + "timestamp": 1774179067484 + }, + { + "x": 69.3436279296875, + "y": 177.1961669921875, + "pressure": 1650, + "timestamp": 1774179067486 + }, + { + "x": 69.541748046875, + "y": 177.98858642578125, + "pressure": 1652, + "timestamp": 1774179067488 + }, + { + "x": 69.7398681640625, + "y": 178.384765625, + "pressure": 1651, + "timestamp": 1774179067492 + }, + { + "x": 69.7398681640625, + "y": 178.384765625, + "pressure": 1646, + "timestamp": 1774179067494 + }, + { + "x": 69.7398681640625, + "y": 178.78097534179688, + "pressure": 1643, + "timestamp": 1774179067497 + }, + { + "x": 69.93798828125, + "y": 179.57339477539062, + "pressure": 1642, + "timestamp": 1774179067499 + }, + { + "x": 70.1361083984375, + "y": 180.36578369140625, + "pressure": 1644, + "timestamp": 1774179067500 + }, + { + "x": 70.334228515625, + "y": 180.76199340820312, + "pressure": 1643, + "timestamp": 1774179067502 + }, + { + "x": 70.334228515625, + "y": 180.76199340820312, + "pressure": 1636, + "timestamp": 1774179067510 + }, + { + "x": 70.334228515625, + "y": 180.76199340820312, + "pressure": 1632, + "timestamp": 1774179067512 + }, + { + "x": 70.5323486328125, + "y": 181.15817260742188, + "pressure": 1630, + "timestamp": 1774179067514 + }, + { + "x": 70.9285888671875, + "y": 181.95059204101562, + "pressure": 1629, + "timestamp": 1774179067515 + }, + { + "x": 71.32489013671875, + "y": 182.74298095703125, + "pressure": 1628, + "timestamp": 1774179067517 + }, + { + "x": 71.52301025390625, + "y": 183.13919067382812, + "pressure": 1630, + "timestamp": 1774179067518 + }, + { + "x": 71.52301025390625, + "y": 183.13919067382812, + "pressure": 1627, + "timestamp": 1774179067526 + }, + { + "x": 71.91925048828125, + "y": 183.535400390625, + "pressure": 1629, + "timestamp": 1774179067533 + }, + { + "x": 72.71173095703125, + "y": 184.52590942382812, + "pressure": 1628, + "timestamp": 1774179067534 + }, + { + "x": 73.50421142578125, + "y": 185.31829833984375, + "pressure": 1627, + "timestamp": 1774179067536 + }, + { + "x": 74.2967529296875, + "y": 186.1107177734375, + "pressure": 1629, + "timestamp": 1774179067541 + }, + { + "x": 74.6929931640625, + "y": 186.30880737304688, + "pressure": 1638, + "timestamp": 1774179067542 + }, + { + "x": 74.6929931640625, + "y": 186.30880737304688, + "pressure": 1643, + "timestamp": 1774179067544 + }, + { + "x": 75.0892333984375, + "y": 186.50689697265625, + "pressure": 1645, + "timestamp": 1774179067545 + }, + { + "x": 75.8817138671875, + "y": 187.1011962890625, + "pressure": 1646, + "timestamp": 1774179067547 + }, + { + "x": 76.6741943359375, + "y": 187.49740600585938, + "pressure": 1647, + "timestamp": 1774179067549 + }, + { + "x": 77.46673583984375, + "y": 187.89361572265625, + "pressure": 1649, + "timestamp": 1774179067550 + }, + { + "x": 77.86297607421875, + "y": 187.89361572265625, + "pressure": 1648, + "timestamp": 1774179067552 + }, + { + "x": 78.25921630859375, + "y": 188.09170532226562, + "pressure": 1651, + "timestamp": 1774179067560 + }, + { + "x": 79.24981689453125, + "y": 188.09170532226562, + "pressure": 1650, + "timestamp": 1774179067561 + }, + { + "x": 80.0423583984375, + "y": 188.09170532226562, + "pressure": 1649, + "timestamp": 1774179067563 + }, + { + "x": 80.8348388671875, + "y": 187.89361572265625, + "pressure": 1651, + "timestamp": 1774179067565 + }, + { + "x": 81.6273193359375, + "y": 187.69549560546875, + "pressure": 1650, + "timestamp": 1774179067566 + }, + { + "x": 82.0235595703125, + "y": 187.49740600585938, + "pressure": 1649, + "timestamp": 1774179067568 + }, + { + "x": 82.0235595703125, + "y": 187.49740600585938, + "pressure": 1666, + "timestamp": 1774179067574 + }, + { + "x": 82.0235595703125, + "y": 187.49740600585938, + "pressure": 1673, + "timestamp": 1774179067576 + }, + { + "x": 82.0235595703125, + "y": 187.49740600585938, + "pressure": 1677, + "timestamp": 1774179067578 + }, + { + "x": 82.41986083984375, + "y": 187.49740600585938, + "pressure": 1679, + "timestamp": 1774179067579 + }, + { + "x": 83.21234130859375, + "y": 187.1011962890625, + "pressure": 1680, + "timestamp": 1774179067581 + }, + { + "x": 83.60858154296875, + "y": 186.90310668945312, + "pressure": 1682, + "timestamp": 1774179067582 + }, + { + "x": 83.60858154296875, + "y": 186.90310668945312, + "pressure": 1707, + "timestamp": 1774179067590 + }, + { + "x": 84.00482177734375, + "y": 186.70501708984375, + "pressure": 1719, + "timestamp": 1774179067592 + }, + { + "x": 84.79730224609375, + "y": 186.1107177734375, + "pressure": 1725, + "timestamp": 1774179067594 + }, + { + "x": 85.193603515625, + "y": 185.91259765625, + "pressure": 1728, + "timestamp": 1774179067595 + }, + { + "x": 85.193603515625, + "y": 185.91259765625, + "pressure": 1731, + "timestamp": 1774179067598 + }, + { + "x": 85.58984375, + "y": 185.51638793945312, + "pressure": 1757, + "timestamp": 1774179067606 + }, + { + "x": 86.38232421875, + "y": 184.7239990234375, + "pressure": 1770, + "timestamp": 1774179067608 + }, + { + "x": 86.9766845703125, + "y": 183.93161010742188, + "pressure": 1776, + "timestamp": 1774179067610 + }, + { + "x": 87.3729248046875, + "y": 183.535400390625, + "pressure": 1779, + "timestamp": 1774179067611 + }, + { + "x": 87.571044921875, + "y": 183.13919067382812, + "pressure": 1782, + "timestamp": 1774179067615 + }, + { + "x": 88.16546630859375, + "y": 182.3468017578125, + "pressure": 1784, + "timestamp": 1774179067616 + }, + { + "x": 88.56170654296875, + "y": 181.95059204101562, + "pressure": 1783, + "timestamp": 1774179067620 + }, + { + "x": 88.56170654296875, + "y": 181.95059204101562, + "pressure": 1797, + "timestamp": 1774179067622 + }, + { + "x": 88.75982666015625, + "y": 181.55438232421875, + "pressure": 1804, + "timestamp": 1774179067624 + }, + { + "x": 89.35418701171875, + "y": 180.76199340820312, + "pressure": 1808, + "timestamp": 1774179067626 + }, + { + "x": 89.94854736328125, + "y": 179.96957397460938, + "pressure": 1810, + "timestamp": 1774179067627 + }, + { + "x": 90.34478759765625, + "y": 179.17718505859375, + "pressure": 1811, + "timestamp": 1774179067629 + }, + { + "x": 90.54296875, + "y": 178.78097534179688, + "pressure": 1813, + "timestamp": 1774179067631 + }, + { + "x": 90.7410888671875, + "y": 178.384765625, + "pressure": 1814, + "timestamp": 1774179067637 + }, + { + "x": 91.1373291015625, + "y": 177.59237670898438, + "pressure": 1823, + "timestamp": 1774179067639 + }, + { + "x": 91.33544921875, + "y": 176.79995727539062, + "pressure": 1828, + "timestamp": 1774179067640 + }, + { + "x": 91.5335693359375, + "y": 176.40377807617188, + "pressure": 1830, + "timestamp": 1774179067642 + }, + { + "x": 91.5335693359375, + "y": 176.40377807617188, + "pressure": 1834, + "timestamp": 1774179067647 + }, + { + "x": 91.5335693359375, + "y": 176.007568359375, + "pressure": 1835, + "timestamp": 1774179067652 + }, + { + "x": 91.9298095703125, + "y": 175.21514892578125, + "pressure": 1842, + "timestamp": 1774179067654 + }, + { + "x": 92.1279296875, + "y": 174.42276000976562, + "pressure": 1845, + "timestamp": 1774179067656 + }, + { + "x": 92.3260498046875, + "y": 174.02655029296875, + "pressure": 1847, + "timestamp": 1774179067658 + }, + { + "x": 92.3260498046875, + "y": 174.02655029296875, + "pressure": 1850, + "timestamp": 1774179067661 + }, + { + "x": 92.3260498046875, + "y": 174.02655029296875, + "pressure": 1854, + "timestamp": 1774179067670 + }, + { + "x": 92.3260498046875, + "y": 174.02655029296875, + "pressure": 1857, + "timestamp": 1774179067674 + }, + { + "x": 92.3260498046875, + "y": 174.02655029296875, + "pressure": 1860, + "timestamp": 1774179067677 + }, + { + "x": 92.3260498046875, + "y": 174.02655029296875, + "pressure": 1886, + "timestamp": 1774179067686 + }, + { + "x": 92.3260498046875, + "y": 174.02655029296875, + "pressure": 1899, + "timestamp": 1774179067688 + }, + { + "x": 92.3260498046875, + "y": 174.02655029296875, + "pressure": 1905, + "timestamp": 1774179067691 + }, + { + "x": 92.3260498046875, + "y": 174.02655029296875, + "pressure": 1908, + "timestamp": 1774179067691 + }, + { + "x": 92.3260498046875, + "y": 174.02655029296875, + "pressure": 1911, + "timestamp": 1774179067695 + }, + { + "x": 92.3260498046875, + "y": 174.02655029296875, + "pressure": 1947, + "timestamp": 1774179067702 + }, + { + "x": 92.3260498046875, + "y": 174.02655029296875, + "pressure": 1964, + "timestamp": 1774179067705 + }, + { + "x": 92.3260498046875, + "y": 174.02655029296875, + "pressure": 1973, + "timestamp": 1774179067707 + }, + { + "x": 92.3260498046875, + "y": 174.02655029296875, + "pressure": 1977, + "timestamp": 1774179067707 + }, + { + "x": 92.3260498046875, + "y": 174.02655029296875, + "pressure": 1980, + "timestamp": 1774179067712 + }, + { + "x": 92.3260498046875, + "y": 174.02655029296875, + "pressure": 1983, + "timestamp": 1774179067717 + }, + { + "x": 92.3260498046875, + "y": 174.02655029296875, + "pressure": 1975, + "timestamp": 1774179067718 + }, + { + "x": 92.3260498046875, + "y": 174.02655029296875, + "pressure": 1971, + "timestamp": 1774179067720 + }, + { + "x": 92.3260498046875, + "y": 174.02655029296875, + "pressure": 1968, + "timestamp": 1774179067724 + }, + { + "x": 92.3260498046875, + "y": 174.02655029296875, + "pressure": 1956, + "timestamp": 1774179067734 + }, + { + "x": 92.3260498046875, + "y": 174.02655029296875, + "pressure": 1950, + "timestamp": 1774179067736 + }, + { + "x": 92.3260498046875, + "y": 174.02655029296875, + "pressure": 1947, + "timestamp": 1774179067738 + }, + { + "x": 92.3260498046875, + "y": 174.02655029296875, + "pressure": 1930, + "timestamp": 1774179067750 + }, + { + "x": 92.3260498046875, + "y": 174.02655029296875, + "pressure": 1922, + "timestamp": 1774179067753 + }, + { + "x": 92.3260498046875, + "y": 174.02655029296875, + "pressure": 1918, + "timestamp": 1774179067755 + }, + { + "x": 92.3260498046875, + "y": 174.02655029296875, + "pressure": 1915, + "timestamp": 1774179067757 + }, + { + "x": 92.3260498046875, + "y": 174.02655029296875, + "pressure": 1895, + "timestamp": 1774179067766 + }, + { + "x": 92.3260498046875, + "y": 174.02655029296875, + "pressure": 1885, + "timestamp": 1774179067768 + }, + { + "x": 92.1279296875, + "y": 174.42276000976562, + "pressure": 1880, + "timestamp": 1774179067770 + }, + { + "x": 91.5335693359375, + "y": 175.21514892578125, + "pressure": 1878, + "timestamp": 1774179067772 + }, + { + "x": 91.33544921875, + "y": 175.61135864257812, + "pressure": 1877, + "timestamp": 1774179067773 + }, + { + "x": 91.1373291015625, + "y": 176.007568359375, + "pressure": 1878, + "timestamp": 1774179067777 + }, + { + "x": 90.939208984375, + "y": 176.79995727539062, + "pressure": 1877, + "timestamp": 1774179067782 + }, + { + "x": 90.7410888671875, + "y": 177.1961669921875, + "pressure": 1861, + "timestamp": 1774179067783 + }, + { + "x": 90.7410888671875, + "y": 177.1961669921875, + "pressure": 1853, + "timestamp": 1774179067785 + }, + { + "x": 90.54296875, + "y": 177.59237670898438, + "pressure": 1849, + "timestamp": 1774179067786 + }, + { + "x": 90.34478759765625, + "y": 178.384765625, + "pressure": 1847, + "timestamp": 1774179067788 + }, + { + "x": 90.34478759765625, + "y": 179.17718505859375, + "pressure": 1846, + "timestamp": 1774179067789 + }, + { + "x": 90.34478759765625, + "y": 179.57339477539062, + "pressure": 1848, + "timestamp": 1774179067791 + }, + { + "x": 90.34478759765625, + "y": 179.57339477539062, + "pressure": 1836, + "timestamp": 1774179067798 + }, + { + "x": 90.34478759765625, + "y": 179.57339477539062, + "pressure": 1831, + "timestamp": 1774179067801 + }, + { + "x": 90.34478759765625, + "y": 179.96957397460938, + "pressure": 1828, + "timestamp": 1774179067802 + }, + { + "x": 90.34478759765625, + "y": 180.76199340820312, + "pressure": 1827, + "timestamp": 1774179067804 + }, + { + "x": 90.34478759765625, + "y": 181.55438232421875, + "pressure": 1826, + "timestamp": 1774179067805 + }, + { + "x": 90.34478759765625, + "y": 181.95059204101562, + "pressure": 1828, + "timestamp": 1774179067807 + }, + { + "x": 90.34478759765625, + "y": 181.95059204101562, + "pressure": 1813, + "timestamp": 1774179067814 + }, + { + "x": 90.34478759765625, + "y": 181.95059204101562, + "pressure": 1807, + "timestamp": 1774179067817 + }, + { + "x": 90.34478759765625, + "y": 182.3468017578125, + "pressure": 1804, + "timestamp": 1774179067818 + }, + { + "x": 90.54296875, + "y": 183.13919067382812, + "pressure": 1802, + "timestamp": 1774179067820 + }, + { + "x": 90.7410888671875, + "y": 183.93161010742188, + "pressure": 1801, + "timestamp": 1774179067821 + }, + { + "x": 90.939208984375, + "y": 184.7239990234375, + "pressure": 1803, + "timestamp": 1774179067823 + }, + { + "x": 91.1373291015625, + "y": 185.51638793945312, + "pressure": 1802, + "timestamp": 1774179067825 + }, + { + "x": 91.33544921875, + "y": 185.91259765625, + "pressure": 1801, + "timestamp": 1774179067829 + }, + { + "x": 91.33544921875, + "y": 185.91259765625, + "pressure": 1797, + "timestamp": 1774179067831 + }, + { + "x": 91.33544921875, + "y": 185.91259765625, + "pressure": 1794, + "timestamp": 1774179067834 + }, + { + "x": 91.33544921875, + "y": 186.30880737304688, + "pressure": 1793, + "timestamp": 1774179067836 + }, + { + "x": 91.5335693359375, + "y": 187.1011962890625, + "pressure": 1795, + "timestamp": 1774179067837 + }, + { + "x": 91.731689453125, + "y": 187.49740600585938, + "pressure": 1794, + "timestamp": 1774179067839 + }, + { + "x": 91.731689453125, + "y": 187.49740600585938, + "pressure": 1777, + "timestamp": 1774179067863 + }, + { + "x": 91.731689453125, + "y": 187.49740600585938, + "pressure": 1769, + "timestamp": 1774179067865 + }, + { + "x": 91.731689453125, + "y": 187.49740600585938, + "pressure": 1765, + "timestamp": 1774179067866 + }, + { + "x": 91.731689453125, + "y": 187.49740600585938, + "pressure": 1762, + "timestamp": 1774179067870 + }, + { + "x": 91.731689453125, + "y": 187.49740600585938, + "pressure": 1758, + "timestamp": 1774179067879 + }, + { + "x": 91.731689453125, + "y": 187.49740600585938, + "pressure": 1755, + "timestamp": 1774179067882 + }, + { + "x": 92.1279296875, + "y": 187.89361572265625, + "pressure": 1756, + "timestamp": 1774179067886 + }, + { + "x": 92.92041015625, + "y": 188.4879150390625, + "pressure": 1755, + "timestamp": 1774179067887 + }, + { + "x": 93.31671142578125, + "y": 188.68600463867188, + "pressure": 1757, + "timestamp": 1774179067889 + }, + { + "x": 93.31671142578125, + "y": 188.68600463867188, + "pressure": 1766, + "timestamp": 1774179067895 + }, + { + "x": 93.71295166015625, + "y": 188.88412475585938, + "pressure": 1771, + "timestamp": 1774179067897 + }, + { + "x": 94.50543212890625, + "y": 189.08221435546875, + "pressure": 1774, + "timestamp": 1774179067899 + }, + { + "x": 95.29791259765625, + "y": 189.28030395507812, + "pressure": 1775, + "timestamp": 1774179067900 + }, + { + "x": 95.69415283203125, + "y": 189.28030395507812, + "pressure": 1776, + "timestamp": 1774179067902 + }, + { + "x": 95.69415283203125, + "y": 189.28030395507812, + "pressure": 1779, + "timestamp": 1774179067909 + }, + { + "x": 96.0904541015625, + "y": 189.08221435546875, + "pressure": 1788, + "timestamp": 1774179067911 + }, + { + "x": 96.8829345703125, + "y": 188.68600463867188, + "pressure": 1792, + "timestamp": 1774179067913 + }, + { + "x": 97.6754150390625, + "y": 188.28982543945312, + "pressure": 1794, + "timestamp": 1774179067914 + }, + { + "x": 98.0716552734375, + "y": 188.09170532226562, + "pressure": 1795, + "timestamp": 1774179067916 + }, + { + "x": 98.0716552734375, + "y": 188.09170532226562, + "pressure": 1798, + "timestamp": 1774179067919 + }, + { + "x": 98.4678955078125, + "y": 187.89361572265625, + "pressure": 1799, + "timestamp": 1774179067925 + }, + { + "x": 99.26043701171875, + "y": 187.29931640625, + "pressure": 1801, + "timestamp": 1774179067927 + }, + { + "x": 100.05291748046875, + "y": 186.70501708984375, + "pressure": 1802, + "timestamp": 1774179067929 + }, + { + "x": 100.44915771484375, + "y": 186.30880737304688, + "pressure": 1803, + "timestamp": 1774179067930 + }, + { + "x": 100.84539794921875, + "y": 186.1107177734375, + "pressure": 1806, + "timestamp": 1774179067935 + }, + { + "x": 101.637939453125, + "y": 185.51638793945312, + "pressure": 1805, + "timestamp": 1774179067937 + }, + { + "x": 102.0341796875, + "y": 185.12020874023438, + "pressure": 1804, + "timestamp": 1774179067941 + }, + { + "x": 102.0341796875, + "y": 185.12020874023438, + "pressure": 1812, + "timestamp": 1774179067943 + }, + { + "x": 102.0341796875, + "y": 185.12020874023438, + "pressure": 1816, + "timestamp": 1774179067945 + }, + { + "x": 102.430419921875, + "y": 184.92208862304688, + "pressure": 1818, + "timestamp": 1774179067947 + }, + { + "x": 103.222900390625, + "y": 184.32778930664062, + "pressure": 1819, + "timestamp": 1774179067948 + }, + { + "x": 103.619140625, + "y": 183.93161010742188, + "pressure": 1821, + "timestamp": 1774179067950 + }, + { + "x": 104.015380859375, + "y": 183.73348999023438, + "pressure": 1819, + "timestamp": 1774179067959 + }, + { + "x": 104.80792236328125, + "y": 183.13919067382812, + "pressure": 1818, + "timestamp": 1774179067961 + }, + { + "x": 105.60040283203125, + "y": 182.54489135742188, + "pressure": 1817, + "timestamp": 1774179067963 + }, + { + "x": 105.99664306640625, + "y": 182.148681640625, + "pressure": 1819, + "timestamp": 1774179067964 + }, + { + "x": 106.39288330078125, + "y": 181.95059204101562, + "pressure": 1819, + "timestamp": 1774179067969 + }, + { + "x": 107.1854248046875, + "y": 181.35629272460938, + "pressure": 1818, + "timestamp": 1774179067973 + }, + { + "x": 107.9779052734375, + "y": 180.76199340820312, + "pressure": 1826, + "timestamp": 1774179067975 + }, + { + "x": 108.3741455078125, + "y": 180.36578369140625, + "pressure": 1830, + "timestamp": 1774179067977 + }, + { + "x": 108.3741455078125, + "y": 180.36578369140625, + "pressure": 1833, + "timestamp": 1774179067980 + }, + { + "x": 108.7703857421875, + "y": 180.16769409179688, + "pressure": 1834, + "timestamp": 1774179067984 + }, + { + "x": 109.56292724609375, + "y": 179.37527465820312, + "pressure": 1836, + "timestamp": 1774179067986 + }, + { + "x": 110.35540771484375, + "y": 178.5828857421875, + "pressure": 1835, + "timestamp": 1774179067989 + }, + { + "x": 110.75164794921875, + "y": 178.18667602539062, + "pressure": 1839, + "timestamp": 1774179067991 + }, + { + "x": 110.75164794921875, + "y": 178.18667602539062, + "pressure": 1842, + "timestamp": 1774179067995 + }, + { + "x": 111.14788818359375, + "y": 177.79046630859375, + "pressure": 1844, + "timestamp": 1774179067996 + }, + { + "x": 111.94036865234375, + "y": 176.99807739257812, + "pressure": 1843, + "timestamp": 1774179067998 + }, + { + "x": 112.73291015625, + "y": 176.20565795898438, + "pressure": 1845, + "timestamp": 1774179068000 + }, + { + "x": 113.129150390625, + "y": 175.80947875976562, + "pressure": 1844, + "timestamp": 1774179068002 + }, + { + "x": 113.525390625, + "y": 175.41326904296875, + "pressure": 1847, + "timestamp": 1774179068011 + }, + { + "x": 114.31787109375, + "y": 174.620849609375, + "pressure": 1846, + "timestamp": 1774179068013 + }, + { + "x": 115.11041259765625, + "y": 174.02655029296875, + "pressure": 1848, + "timestamp": 1774179068014 + }, + { + "x": 115.50665283203125, + "y": 173.63037109375, + "pressure": 1847, + "timestamp": 1774179068016 + }, + { + "x": 115.90289306640625, + "y": 173.4322509765625, + "pressure": 1847, + "timestamp": 1774179068023 + }, + { + "x": 116.69537353515625, + "y": 172.83795166015625, + "pressure": 1849, + "timestamp": 1774179068025 + }, + { + "x": 117.09161376953125, + "y": 172.44174194335938, + "pressure": 1848, + "timestamp": 1774179068027 + }, + { + "x": 117.28973388671875, + "y": 172.04556274414062, + "pressure": 1848, + "timestamp": 1774179068032 + }, + { + "x": 117.8841552734375, + "y": 171.25314331054688, + "pressure": 1847, + "timestamp": 1774179068034 + }, + { + "x": 118.2803955078125, + "y": 170.85693359375, + "pressure": 1849, + "timestamp": 1774179068037 + }, + { + "x": 118.2803955078125, + "y": 170.85693359375, + "pressure": 1856, + "timestamp": 1774179068039 + }, + { + "x": 118.2803955078125, + "y": 170.85693359375, + "pressure": 1860, + "timestamp": 1774179068041 + }, + { + "x": 118.2803955078125, + "y": 170.85693359375, + "pressure": 1863, + "timestamp": 1774179068044 + }, + { + "x": 118.2803955078125, + "y": 170.85693359375, + "pressure": 1866, + "timestamp": 1774179068050 + }, + { + "x": 118.2803955078125, + "y": 170.85693359375, + "pressure": 1914, + "timestamp": 1774179068055 + }, + { + "x": 118.2803955078125, + "y": 170.85693359375, + "pressure": 1939, + "timestamp": 1774179068058 + }, + { + "x": 118.2803955078125, + "y": 170.85693359375, + "pressure": 1951, + "timestamp": 1774179068059 + }, + { + "x": 118.2803955078125, + "y": 170.85693359375, + "pressure": 1957, + "timestamp": 1774179068060 + }, + { + "x": 118.2803955078125, + "y": 170.85693359375, + "pressure": 1960, + "timestamp": 1774179068062 + }, + { + "x": 118.2803955078125, + "y": 170.85693359375, + "pressure": 1963, + "timestamp": 1774179068065 + }, + { + "x": 118.2803955078125, + "y": 170.85693359375, + "pressure": 1990, + "timestamp": 1774179068071 + }, + { + "x": 118.2803955078125, + "y": 170.85693359375, + "pressure": 2002, + "timestamp": 1774179068074 + }, + { + "x": 118.2803955078125, + "y": 170.85693359375, + "pressure": 2008, + "timestamp": 1774179068075 + }, + { + "x": 118.2803955078125, + "y": 170.85693359375, + "pressure": 2011, + "timestamp": 1774179068076 + }, + { + "x": 118.2803955078125, + "y": 170.85693359375, + "pressure": 2014, + "timestamp": 1774179068080 + }, + { + "x": 118.2803955078125, + "y": 170.85693359375, + "pressure": 2011, + "timestamp": 1774179068091 + }, + { + "x": 118.2803955078125, + "y": 171.25314331054688, + "pressure": 2011, + "timestamp": 1774179068096 + }, + { + "x": 118.478515625, + "y": 172.24365234375, + "pressure": 2013, + "timestamp": 1774179068098 + }, + { + "x": 118.6766357421875, + "y": 173.23416137695312, + "pressure": 2012, + "timestamp": 1774179068102 + }, + { + "x": 118.874755859375, + "y": 174.42276000976562, + "pressure": 2008, + "timestamp": 1774179068103 + }, + { + "x": 119.0728759765625, + "y": 175.61135864257812, + "pressure": 2006, + "timestamp": 1774179068106 + }, + { + "x": 119.27099609375, + "y": 176.60186767578125, + "pressure": 2005, + "timestamp": 1774179068107 + }, + { + "x": 119.27099609375, + "y": 177.59237670898438, + "pressure": 2007, + "timestamp": 1774179068108 + }, + { + "x": 119.27099609375, + "y": 178.384765625, + "pressure": 2006, + "timestamp": 1774179068110 + }, + { + "x": 119.27099609375, + "y": 179.17718505859375, + "pressure": 2005, + "timestamp": 1774179068112 + }, + { + "x": 119.27099609375, + "y": 179.96957397460938, + "pressure": 2007, + "timestamp": 1774179068114 + }, + { + "x": 119.27099609375, + "y": 180.36578369140625, + "pressure": 2006, + "timestamp": 1774179068118 + }, + { + "x": 119.27099609375, + "y": 180.36578369140625, + "pressure": 2003, + "timestamp": 1774179068119 + }, + { + "x": 119.0728759765625, + "y": 180.76199340820312, + "pressure": 2002, + "timestamp": 1774179068122 + }, + { + "x": 118.874755859375, + "y": 181.55438232421875, + "pressure": 2001, + "timestamp": 1774179068123 + }, + { + "x": 118.874755859375, + "y": 182.3468017578125, + "pressure": 2003, + "timestamp": 1774179068125 + }, + { + "x": 118.874755859375, + "y": 182.74298095703125, + "pressure": 2002, + "timestamp": 1774179068126 + }, + { + "x": 118.6766357421875, + "y": 183.13919067382812, + "pressure": 2005, + "timestamp": 1774179068135 + }, + { + "x": 118.478515625, + "y": 184.12969970703125, + "pressure": 2006, + "timestamp": 1774179068137 + }, + { + "x": 118.2803955078125, + "y": 184.92208862304688, + "pressure": 2007, + "timestamp": 1774179068139 + }, + { + "x": 118.082275390625, + "y": 185.71450805664062, + "pressure": 2009, + "timestamp": 1774179068141 + }, + { + "x": 118.082275390625, + "y": 186.1107177734375, + "pressure": 2008, + "timestamp": 1774179068142 + }, + { + "x": 118.082275390625, + "y": 186.1107177734375, + "pressure": 2016, + "timestamp": 1774179068151 + }, + { + "x": 118.082275390625, + "y": 186.1107177734375, + "pressure": 2020, + "timestamp": 1774179068154 + }, + { + "x": 118.082275390625, + "y": 186.1107177734375, + "pressure": 2023, + "timestamp": 1774179068157 + }, + { + "x": 117.8841552734375, + "y": 186.50689697265625, + "pressure": 2024, + "timestamp": 1774179068160 + }, + { + "x": 117.28973388671875, + "y": 187.29931640625, + "pressure": 2026, + "timestamp": 1774179068162 + }, + { + "x": 117.09161376953125, + "y": 187.69549560546875, + "pressure": 2025, + "timestamp": 1774179068166 + }, + { + "x": 117.09161376953125, + "y": 187.69549560546875, + "pressure": 1894, + "timestamp": 1774179068167 + }, + { + "x": 117.09161376953125, + "y": 187.69549560546875, + "pressure": 1828, + "timestamp": 1774179068170 + }, + { + "x": 116.69537353515625, + "y": 188.09170532226562, + "pressure": 1795, + "timestamp": 1774179068171 + }, + { + "x": 115.90289306640625, + "y": 188.68600463867188, + "pressure": 1779, + "timestamp": 1774179068173 + }, + { + "x": 115.11041259765625, + "y": 189.08221435546875, + "pressure": 1771, + "timestamp": 1774179068174 + }, + { + "x": 114.31787109375, + "y": 189.47842407226562, + "pressure": 1767, + "timestamp": 1774179068176 + }, + { + "x": 113.525390625, + "y": 189.676513671875, + "pressure": 1765, + "timestamp": 1774179068178 + }, + { + "x": 113.129150390625, + "y": 189.676513671875, + "pressure": 1764, + "timestamp": 1774179068182 + }, + { + "x": 112.73291015625, + "y": 189.87460327148438, + "pressure": 1622, + "timestamp": 1774179068183 + }, + { + "x": 111.74224853515625, + "y": 190.07272338867188, + "pressure": 1551, + "timestamp": 1774179068186 + }, + { + "x": 110.75164794921875, + "y": 190.07272338867188, + "pressure": 1515, + "timestamp": 1774179068187 + }, + { + "x": 109.76104736328125, + "y": 190.07272338867188, + "pressure": 1497, + "timestamp": 1774179068189 + }, + { + "x": 108.7703857421875, + "y": 190.07272338867188, + "pressure": 1488, + "timestamp": 1774179068191 + }, + { + "x": 107.77978515625, + "y": 189.87460327148438, + "pressure": 1484, + "timestamp": 1774179068192 + }, + { + "x": 106.7891845703125, + "y": 189.87460327148438, + "pressure": 1482, + "timestamp": 1774179068194 + }, + { + "x": 105.79852294921875, + "y": 189.87460327148438, + "pressure": 1481, + "timestamp": 1774179068198 + }, + { + "x": 104.80792236328125, + "y": 189.676513671875, + "pressure": 1414, + "timestamp": 1774179068200 + }, + { + "x": 104.015380859375, + "y": 189.676513671875, + "pressure": 1380, + "timestamp": 1774179068202 + }, + { + "x": 103.222900390625, + "y": 189.676513671875, + "pressure": 1363, + "timestamp": 1774179068203 + }, + { + "x": 102.430419921875, + "y": 189.47842407226562, + "pressure": 1355, + "timestamp": 1774179068205 + }, + { + "x": 101.637939453125, + "y": 189.47842407226562, + "pressure": 1351, + "timestamp": 1774179068206 + }, + { + "x": 101.24163818359375, + "y": 189.47842407226562, + "pressure": 1349, + "timestamp": 1774179068208 + }, + { + "x": 100.84539794921875, + "y": 189.47842407226562, + "pressure": 1247, + "timestamp": 1774179068215 + }, + { + "x": 100.05291748046875, + "y": 189.28030395507812, + "pressure": 1197, + "timestamp": 1774179068218 + }, + { + "x": 99.26043701171875, + "y": 189.08221435546875, + "pressure": 1172, + "timestamp": 1774179068219 + }, + { + "x": 98.4678955078125, + "y": 189.08221435546875, + "pressure": 1159, + "timestamp": 1774179068221 + }, + { + "x": 98.0716552734375, + "y": 189.08221435546875, + "pressure": 1153, + "timestamp": 1774179068223 + }, + { + "x": 98.0716552734375, + "y": 189.08221435546875, + "pressure": 1150, + "timestamp": 1774179068224 + }, + { + "x": 98.0716552734375, + "y": 189.08221435546875, + "pressure": 1147, + "timestamp": 1774179068230 + }, + { + "x": 97.6754150390625, + "y": 189.08221435546875, + "pressure": 1049, + "timestamp": 1774179068232 + }, + { + "x": 96.8829345703125, + "y": 188.88412475585938, + "pressure": 1000, + "timestamp": 1774179068234 + }, + { + "x": 96.4866943359375, + "y": 188.68600463867188, + "pressure": 976, + "timestamp": 1774179068235 + }, + { + "x": 96.4866943359375, + "y": 188.68600463867188, + "pressure": 964, + "timestamp": 1774179068237 + }, + { + "x": 96.4866943359375, + "y": 188.68600463867188, + "pressure": 958, + "timestamp": 1774179068239 + }, + { + "x": 96.4866943359375, + "y": 188.68600463867188, + "pressure": 955, + "timestamp": 1774179068240 + }, + { + "x": 96.4866943359375, + "y": 188.68600463867188, + "pressure": 952, + "timestamp": 1774179068246 + }, + { + "x": 96.4866943359375, + "y": 188.68600463867188, + "pressure": 906, + "timestamp": 1774179068248 + }, + { + "x": 96.4866943359375, + "y": 188.68600463867188, + "pressure": 883, + "timestamp": 1774179068250 + }, + { + "x": 96.4866943359375, + "y": 188.68600463867188, + "pressure": 871, + "timestamp": 1774179068251 + }, + { + "x": 96.4866943359375, + "y": 188.68600463867188, + "pressure": 865, + "timestamp": 1774179068253 + }, + { + "x": 96.4866943359375, + "y": 188.68600463867188, + "pressure": 862, + "timestamp": 1774179068255 + }, + { + "x": 96.4866943359375, + "y": 188.68600463867188, + "pressure": 851, + "timestamp": 1774179068264 + }, + { + "x": 96.4866943359375, + "y": 188.68600463867188, + "pressure": 846, + "timestamp": 1774179068266 + }, + { + "x": 96.4866943359375, + "y": 188.68600463867188, + "pressure": 843, + "timestamp": 1774179068267 + }, + { + "x": 96.4866943359375, + "y": 188.68600463867188, + "pressure": 891, + "timestamp": 1774179068280 + }, + { + "x": 96.4866943359375, + "y": 188.68600463867188, + "pressure": 916, + "timestamp": 1774179068282 + }, + { + "x": 96.4866943359375, + "y": 188.68600463867188, + "pressure": 929, + "timestamp": 1774179068283 + }, + { + "x": 96.4866943359375, + "y": 188.68600463867188, + "pressure": 935, + "timestamp": 1774179068285 + }, + { + "x": 96.4866943359375, + "y": 188.68600463867188, + "pressure": 938, + "timestamp": 1774179068287 + }, + { + "x": 96.4866943359375, + "y": 188.68600463867188, + "pressure": 941, + "timestamp": 1774179068290 + }, + { + "x": 96.4866943359375, + "y": 188.68600463867188, + "pressure": 1016, + "timestamp": 1774179068296 + }, + { + "x": 96.8829345703125, + "y": 188.88412475585938, + "pressure": 1053, + "timestamp": 1774179068298 + }, + { + "x": 97.2791748046875, + "y": 188.88412475585938, + "pressure": 1071, + "timestamp": 1774179068299 + }, + { + "x": 97.2791748046875, + "y": 188.88412475585938, + "pressure": 1080, + "timestamp": 1774179068301 + }, + { + "x": 97.6754150390625, + "y": 189.08221435546875, + "pressure": 1085, + "timestamp": 1774179068303 + }, + { + "x": 98.4678955078125, + "y": 189.28030395507812, + "pressure": 1087, + "timestamp": 1774179068304 + }, + { + "x": 99.26043701171875, + "y": 189.47842407226562, + "pressure": 1088, + "timestamp": 1774179068306 + }, + { + "x": 99.65667724609375, + "y": 189.47842407226562, + "pressure": 1089, + "timestamp": 1774179068310 + }, + { + "x": 99.65667724609375, + "y": 189.47842407226562, + "pressure": 1171, + "timestamp": 1774179068312 + }, + { + "x": 100.05291748046875, + "y": 189.47842407226562, + "pressure": 1212, + "timestamp": 1774179068314 + }, + { + "x": 100.84539794921875, + "y": 189.676513671875, + "pressure": 1232, + "timestamp": 1774179068316 + }, + { + "x": 101.637939453125, + "y": 189.87460327148438, + "pressure": 1242, + "timestamp": 1774179068317 + }, + { + "x": 102.430419921875, + "y": 189.87460327148438, + "pressure": 1247, + "timestamp": 1774179068319 + }, + { + "x": 103.222900390625, + "y": 189.87460327148438, + "pressure": 1250, + "timestamp": 1774179068321 + }, + { + "x": 103.619140625, + "y": 189.87460327148438, + "pressure": 1251, + "timestamp": 1774179068322 + }, + { + "x": 104.015380859375, + "y": 189.87460327148438, + "pressure": 1318, + "timestamp": 1774179068328 + }, + { + "x": 104.80792236328125, + "y": 189.87460327148438, + "pressure": 1351, + "timestamp": 1774179068330 + }, + { + "x": 105.60040283203125, + "y": 189.87460327148438, + "pressure": 1367, + "timestamp": 1774179068332 + }, + { + "x": 106.39288330078125, + "y": 189.87460327148438, + "pressure": 1375, + "timestamp": 1774179068333 + }, + { + "x": 107.1854248046875, + "y": 189.87460327148438, + "pressure": 1379, + "timestamp": 1774179068335 + }, + { + "x": 107.9779052734375, + "y": 189.87460327148438, + "pressure": 1381, + "timestamp": 1774179068337 + }, + { + "x": 108.3741455078125, + "y": 189.87460327148438, + "pressure": 1382, + "timestamp": 1774179068338 + }, + { + "x": 108.7703857421875, + "y": 189.87460327148438, + "pressure": 1425, + "timestamp": 1774179068344 + }, + { + "x": 109.56292724609375, + "y": 189.676513671875, + "pressure": 1446, + "timestamp": 1774179068347 + }, + { + "x": 110.35540771484375, + "y": 189.676513671875, + "pressure": 1456, + "timestamp": 1774179068348 + }, + { + "x": 111.14788818359375, + "y": 189.676513671875, + "pressure": 1461, + "timestamp": 1774179068349 + }, + { + "x": 111.94036865234375, + "y": 189.47842407226562, + "pressure": 1464, + "timestamp": 1774179068351 + }, + { + "x": 112.73291015625, + "y": 189.28030395507812, + "pressure": 1465, + "timestamp": 1774179068353 + }, + { + "x": 113.525390625, + "y": 189.08221435546875, + "pressure": 1466, + "timestamp": 1774179068354 + }, + { + "x": 113.921630859375, + "y": 189.08221435546875, + "pressure": 1468, + "timestamp": 1774179068358 + }, + { + "x": 113.921630859375, + "y": 189.08221435546875, + "pressure": 1512, + "timestamp": 1774179068360 + }, + { + "x": 114.31787109375, + "y": 188.88412475585938, + "pressure": 1534, + "timestamp": 1774179068362 + }, + { + "x": 115.11041259765625, + "y": 188.4879150390625, + "pressure": 1545, + "timestamp": 1774179068364 + }, + { + "x": 115.90289306640625, + "y": 188.09170532226562, + "pressure": 1551, + "timestamp": 1774179068365 + }, + { + "x": 116.69537353515625, + "y": 187.89361572265625, + "pressure": 1554, + "timestamp": 1774179068367 + }, + { + "x": 117.48785400390625, + "y": 187.69549560546875, + "pressure": 1555, + "timestamp": 1774179068369 + }, + { + "x": 118.2803955078125, + "y": 187.29931640625, + "pressure": 1556, + "timestamp": 1774179068370 + }, + { + "x": 119.0728759765625, + "y": 187.1011962890625, + "pressure": 1558, + "timestamp": 1774179068374 + }, + { + "x": 119.8653564453125, + "y": 186.90310668945312, + "pressure": 1588, + "timestamp": 1774179068376 + }, + { + "x": 120.65789794921875, + "y": 186.50689697265625, + "pressure": 1603, + "timestamp": 1774179068378 + }, + { + "x": 121.05413818359375, + "y": 186.30880737304688, + "pressure": 1611, + "timestamp": 1774179068380 + }, + { + "x": 121.05413818359375, + "y": 186.30880737304688, + "pressure": 1615, + "timestamp": 1774179068381 + }, + { + "x": 121.45037841796875, + "y": 186.1107177734375, + "pressure": 1617, + "timestamp": 1774179068383 + }, + { + "x": 122.24285888671875, + "y": 185.51638793945312, + "pressure": 1618, + "timestamp": 1774179068386 + }, + { + "x": 123.03533935546875, + "y": 185.12020874023438, + "pressure": 1620, + "timestamp": 1774179068386 + }, + { + "x": 123.827880859375, + "y": 184.7239990234375, + "pressure": 1619, + "timestamp": 1774179068390 + }, + { + "x": 124.620361328125, + "y": 184.32778930664062, + "pressure": 1644, + "timestamp": 1774179068392 + }, + { + "x": 125.0166015625, + "y": 184.12969970703125, + "pressure": 1656, + "timestamp": 1774179068394 + }, + { + "x": 125.0166015625, + "y": 184.12969970703125, + "pressure": 1662, + "timestamp": 1774179068396 + }, + { + "x": 125.0166015625, + "y": 184.12969970703125, + "pressure": 1665, + "timestamp": 1774179068397 + }, + { + "x": 125.412841796875, + "y": 183.93161010742188, + "pressure": 1667, + "timestamp": 1774179068399 + }, + { + "x": 126.20538330078125, + "y": 183.3372802734375, + "pressure": 1668, + "timestamp": 1774179068401 + }, + { + "x": 126.99786376953125, + "y": 182.94110107421875, + "pressure": 1670, + "timestamp": 1774179068403 + }, + { + "x": 127.39410400390625, + "y": 182.74298095703125, + "pressure": 1669, + "timestamp": 1774179068407 + }, + { + "x": 127.39410400390625, + "y": 182.74298095703125, + "pressure": 1677, + "timestamp": 1774179068408 + }, + { + "x": 127.39410400390625, + "y": 182.74298095703125, + "pressure": 1681, + "timestamp": 1774179068410 + }, + { + "x": 127.39410400390625, + "y": 182.74298095703125, + "pressure": 1684, + "timestamp": 1774179068413 + }, + { + "x": 127.79034423828125, + "y": 182.54489135742188, + "pressure": 1685, + "timestamp": 1774179068415 + }, + { + "x": 128.58282470703125, + "y": 181.95059204101562, + "pressure": 1687, + "timestamp": 1774179068417 + }, + { + "x": 128.9791259765625, + "y": 181.55438232421875, + "pressure": 1686, + "timestamp": 1774179068418 + }, + { + "x": 129.3753662109375, + "y": 181.35629272460938, + "pressure": 1690, + "timestamp": 1774179068424 + }, + { + "x": 130.1678466796875, + "y": 180.76199340820312, + "pressure": 1691, + "timestamp": 1774179068426 + }, + { + "x": 130.5640869140625, + "y": 180.56387329101562, + "pressure": 1692, + "timestamp": 1774179068428 + }, + { + "x": 130.5640869140625, + "y": 180.56387329101562, + "pressure": 1695, + "timestamp": 1774179068433 + }, + { + "x": 130.9603271484375, + "y": 180.36578369140625, + "pressure": 1694, + "timestamp": 1774179068435 + }, + { + "x": 131.75286865234375, + "y": 179.771484375, + "pressure": 1693, + "timestamp": 1774179068438 + }, + { + "x": 132.14910888671875, + "y": 179.37527465820312, + "pressure": 1695, + "timestamp": 1774179068440 + }, + { + "x": 132.14910888671875, + "y": 179.37527465820312, + "pressure": 1699, + "timestamp": 1774179068445 + }, + { + "x": 132.54534912109375, + "y": 179.17718505859375, + "pressure": 1698, + "timestamp": 1774179068455 + }, + { + "x": 133.33782958984375, + "y": 178.5828857421875, + "pressure": 1708, + "timestamp": 1774179068456 + }, + { + "x": 133.73406982421875, + "y": 178.18667602539062, + "pressure": 1713, + "timestamp": 1774179068458 + }, + { + "x": 133.73406982421875, + "y": 178.18667602539062, + "pressure": 1716, + "timestamp": 1774179068461 + }, + { + "x": 133.73406982421875, + "y": 178.18667602539062, + "pressure": 1719, + "timestamp": 1774179068465 + }, + { + "x": 133.73406982421875, + "y": 178.18667602539062, + "pressure": 1739, + "timestamp": 1774179068472 + }, + { + "x": 133.93218994140625, + "y": 177.79046630859375, + "pressure": 1748, + "timestamp": 1774179068474 + }, + { + "x": 134.526611328125, + "y": 176.99807739257812, + "pressure": 1753, + "timestamp": 1774179068476 + }, + { + "x": 134.9228515625, + "y": 176.60186767578125, + "pressure": 1755, + "timestamp": 1774179068478 + }, + { + "x": 134.9228515625, + "y": 176.60186767578125, + "pressure": 1759, + "timestamp": 1774179068483 + }, + { + "x": 134.9228515625, + "y": 176.60186767578125, + "pressure": 1775, + "timestamp": 1774179068489 + }, + { + "x": 134.9228515625, + "y": 176.60186767578125, + "pressure": 1784, + "timestamp": 1774179068491 + }, + { + "x": 134.9228515625, + "y": 176.60186767578125, + "pressure": 1788, + "timestamp": 1774179068492 + }, + { + "x": 134.9228515625, + "y": 176.60186767578125, + "pressure": 1791, + "timestamp": 1774179068495 + }, + { + "x": 134.9228515625, + "y": 176.60186767578125, + "pressure": 1794, + "timestamp": 1774179068499 + }, + { + "x": 134.9228515625, + "y": 176.60186767578125, + "pressure": 1804, + "timestamp": 1774179068505 + }, + { + "x": 134.9228515625, + "y": 176.60186767578125, + "pressure": 1810, + "timestamp": 1774179068507 + }, + { + "x": 135.1209716796875, + "y": 176.20565795898438, + "pressure": 1813, + "timestamp": 1774179068508 + }, + { + "x": 135.5172119140625, + "y": 175.80947875976562, + "pressure": 1814, + "timestamp": 1774179068510 + }, + { + "x": 135.5172119140625, + "y": 175.80947875976562, + "pressure": 1817, + "timestamp": 1774179068513 + }, + { + "x": 135.71533203125, + "y": 175.41326904296875, + "pressure": 1816, + "timestamp": 1774179068528 + }, + { + "x": 135.9134521484375, + "y": 174.620849609375, + "pressure": 1818, + "timestamp": 1774179068529 + }, + { + "x": 136.111572265625, + "y": 174.22467041015625, + "pressure": 1817, + "timestamp": 1774179068531 + }, + { + "x": 136.111572265625, + "y": 174.22467041015625, + "pressure": 1825, + "timestamp": 1774179068537 + }, + { + "x": 136.111572265625, + "y": 174.22467041015625, + "pressure": 1830, + "timestamp": 1774179068539 + }, + { + "x": 136.111572265625, + "y": 174.22467041015625, + "pressure": 1833, + "timestamp": 1774179068542 + }, + { + "x": 136.111572265625, + "y": 174.22467041015625, + "pressure": 1836, + "timestamp": 1774179068545 + }, + { + "x": 136.111572265625, + "y": 174.22467041015625, + "pressure": 1888, + "timestamp": 1774179068552 + }, + { + "x": 136.111572265625, + "y": 174.22467041015625, + "pressure": 1914, + "timestamp": 1774179068555 + }, + { + "x": 136.111572265625, + "y": 174.22467041015625, + "pressure": 1927, + "timestamp": 1774179068556 + }, + { + "x": 136.111572265625, + "y": 174.22467041015625, + "pressure": 1933, + "timestamp": 1774179068558 + }, + { + "x": 136.111572265625, + "y": 174.22467041015625, + "pressure": 1936, + "timestamp": 1774179068560 + }, + { + "x": 136.111572265625, + "y": 174.22467041015625, + "pressure": 1939, + "timestamp": 1774179068563 + }, + { + "x": 136.111572265625, + "y": 174.22467041015625, + "pressure": 1983, + "timestamp": 1774179068569 + }, + { + "x": 136.111572265625, + "y": 174.22467041015625, + "pressure": 2004, + "timestamp": 1774179068571 + }, + { + "x": 136.111572265625, + "y": 174.22467041015625, + "pressure": 2015, + "timestamp": 1774179068572 + }, + { + "x": 136.111572265625, + "y": 174.22467041015625, + "pressure": 2020, + "timestamp": 1774179068574 + }, + { + "x": 136.111572265625, + "y": 174.22467041015625, + "pressure": 2023, + "timestamp": 1774179068575 + }, + { + "x": 136.111572265625, + "y": 174.22467041015625, + "pressure": 2027, + "timestamp": 1774179068583 + }, + { + "x": 136.111572265625, + "y": 174.22467041015625, + "pressure": 2038, + "timestamp": 1774179068585 + }, + { + "x": 136.111572265625, + "y": 174.22467041015625, + "pressure": 2044, + "timestamp": 1774179068587 + }, + { + "x": 136.111572265625, + "y": 174.22467041015625, + "pressure": 2047, + "timestamp": 1774179068588 + }, + { + "x": 136.111572265625, + "y": 174.22467041015625, + "pressure": 2051, + "timestamp": 1774179068593 + }, + { + "x": 136.111572265625, + "y": 174.22467041015625, + "pressure": 2054, + "timestamp": 1774179068603 + }, + { + "x": 136.111572265625, + "y": 174.22467041015625, + "pressure": 2057, + "timestamp": 1774179068608 + }, + { + "x": 136.111572265625, + "y": 174.22467041015625, + "pressure": 2082, + "timestamp": 1774179068617 + }, + { + "x": 136.111572265625, + "y": 174.22467041015625, + "pressure": 2094, + "timestamp": 1774179068619 + }, + { + "x": 136.111572265625, + "y": 174.22467041015625, + "pressure": 2100, + "timestamp": 1774179068620 + }, + { + "x": 136.111572265625, + "y": 174.22467041015625, + "pressure": 2103, + "timestamp": 1774179068622 + }, + { + "x": 136.111572265625, + "y": 174.22467041015625, + "pressure": 2106, + "timestamp": 1774179068625 + }, + { + "x": 136.111572265625, + "y": 174.22467041015625, + "pressure": 2098, + "timestamp": 1774179068633 + }, + { + "x": 136.111572265625, + "y": 174.22467041015625, + "pressure": 2094, + "timestamp": 1774179068635 + }, + { + "x": 136.111572265625, + "y": 174.22467041015625, + "pressure": 2091, + "timestamp": 1774179068638 + }, + { + "x": 136.111572265625, + "y": 174.22467041015625, + "pressure": 2075, + "timestamp": 1774179068649 + }, + { + "x": 136.111572265625, + "y": 174.22467041015625, + "pressure": 2068, + "timestamp": 1774179068651 + }, + { + "x": 136.111572265625, + "y": 174.22467041015625, + "pressure": 2064, + "timestamp": 1774179068652 + }, + { + "x": 136.111572265625, + "y": 174.22467041015625, + "pressure": 2061, + "timestamp": 1774179068656 + }, + { + "x": 136.111572265625, + "y": 174.22467041015625, + "pressure": 2050, + "timestamp": 1774179068665 + }, + { + "x": 136.111572265625, + "y": 174.22467041015625, + "pressure": 2045, + "timestamp": 1774179068667 + }, + { + "x": 136.111572265625, + "y": 174.22467041015625, + "pressure": 2042, + "timestamp": 1774179068668 + }, + { + "x": 135.71533203125, + "y": 174.42276000976562, + "pressure": 2041, + "timestamp": 1774179068670 + }, + { + "x": 134.9228515625, + "y": 174.620849609375, + "pressure": 2040, + "timestamp": 1774179068672 + }, + { + "x": 134.526611328125, + "y": 174.620849609375, + "pressure": 2042, + "timestamp": 1774179068674 + }, + { + "x": 134.526611328125, + "y": 174.620849609375, + "pressure": 2034, + "timestamp": 1774179068681 + }, + { + "x": 134.526611328125, + "y": 174.620849609375, + "pressure": 2031, + "timestamp": 1774179068683 + }, + { + "x": 134.13037109375, + "y": 174.8189697265625, + "pressure": 2030, + "timestamp": 1774179068684 + }, + { + "x": 133.33782958984375, + "y": 175.41326904296875, + "pressure": 2029, + "timestamp": 1774179068687 + }, + { + "x": 132.94158935546875, + "y": 175.61135864257812, + "pressure": 2031, + "timestamp": 1774179068688 + }, + { + "x": 132.94158935546875, + "y": 175.61135864257812, + "pressure": 2021, + "timestamp": 1774179068697 + }, + { + "x": 132.94158935546875, + "y": 175.61135864257812, + "pressure": 2016, + "timestamp": 1774179068699 + }, + { + "x": 132.94158935546875, + "y": 175.61135864257812, + "pressure": 2013, + "timestamp": 1774179068701 + }, + { + "x": 132.54534912109375, + "y": 176.007568359375, + "pressure": 2013, + "timestamp": 1774179068706 + }, + { + "x": 131.75286865234375, + "y": 176.60186767578125, + "pressure": 2012, + "timestamp": 1774179068707 + }, + { + "x": 131.35662841796875, + "y": 176.79995727539062, + "pressure": 2011, + "timestamp": 1774179068712 + }, + { + "x": 131.35662841796875, + "y": 176.79995727539062, + "pressure": 2005, + "timestamp": 1774179068713 + }, + { + "x": 131.35662841796875, + "y": 176.79995727539062, + "pressure": 2002, + "timestamp": 1774179068715 + }, + { + "x": 130.9603271484375, + "y": 177.1961669921875, + "pressure": 2001, + "timestamp": 1774179068722 + }, + { + "x": 130.365966796875, + "y": 177.98858642578125, + "pressure": 2000, + "timestamp": 1774179068723 + }, + { + "x": 129.7716064453125, + "y": 178.78097534179688, + "pressure": 2002, + "timestamp": 1774179068728 + }, + { + "x": 129.573486328125, + "y": 179.17718505859375, + "pressure": 1996, + "timestamp": 1774179068729 + }, + { + "x": 129.573486328125, + "y": 179.17718505859375, + "pressure": 1993, + "timestamp": 1774179068732 + }, + { + "x": 129.17724609375, + "y": 179.57339477539062, + "pressure": 1990, + "timestamp": 1774179068734 + }, + { + "x": 128.58282470703125, + "y": 180.56387329101562, + "pressure": 1992, + "timestamp": 1774179068736 + }, + { + "x": 128.18658447265625, + "y": 181.55438232421875, + "pressure": 1991, + "timestamp": 1774179068739 + }, + { + "x": 127.79034423828125, + "y": 182.3468017578125, + "pressure": 1990, + "timestamp": 1774179068739 + }, + { + "x": 127.59222412109375, + "y": 182.74298095703125, + "pressure": 1992, + "timestamp": 1774179068744 + }, + { + "x": 127.59222412109375, + "y": 182.74298095703125, + "pressure": 1858, + "timestamp": 1774179068745 + }, + { + "x": 127.39410400390625, + "y": 183.3372802734375, + "pressure": 1791, + "timestamp": 1774179068747 + }, + { + "x": 126.79974365234375, + "y": 184.32778930664062, + "pressure": 1757, + "timestamp": 1774179068749 + }, + { + "x": 126.20538330078125, + "y": 185.51638793945312, + "pressure": 1740, + "timestamp": 1774179068750 + }, + { + "x": 125.80908203125, + "y": 186.30880737304688, + "pressure": 1732, + "timestamp": 1774179068752 + }, + { + "x": 125.412841796875, + "y": 187.1011962890625, + "pressure": 1728, + "timestamp": 1774179068754 + }, + { + "x": 125.2147216796875, + "y": 187.49740600585938, + "pressure": 1726, + "timestamp": 1774179068755 + }, + { + "x": 125.2147216796875, + "y": 187.49740600585938, + "pressure": 1716, + "timestamp": 1774179068761 + }, + { + "x": 125.0166015625, + "y": 187.89361572265625, + "pressure": 1711, + "timestamp": 1774179068764 + }, + { + "x": 124.8184814453125, + "y": 188.68600463867188, + "pressure": 1709, + "timestamp": 1774179068765 + }, + { + "x": 124.620361328125, + "y": 189.08221435546875, + "pressure": 1708, + "timestamp": 1774179068767 + }, + { + "x": 124.620361328125, + "y": 189.08221435546875, + "pressure": 1678, + "timestamp": 1774179068777 + }, + { + "x": 124.620361328125, + "y": 189.08221435546875, + "pressure": 1663, + "timestamp": 1774179068779 + }, + { + "x": 124.620361328125, + "y": 189.08221435546875, + "pressure": 1656, + "timestamp": 1774179068781 + }, + { + "x": 124.620361328125, + "y": 189.08221435546875, + "pressure": 1652, + "timestamp": 1774179068782 + }, + { + "x": 124.620361328125, + "y": 189.08221435546875, + "pressure": 1649, + "timestamp": 1774179068786 + }, + { + "x": 124.620361328125, + "y": 189.08221435546875, + "pressure": 1622, + "timestamp": 1774179068793 + }, + { + "x": 124.620361328125, + "y": 189.08221435546875, + "pressure": 1608, + "timestamp": 1774179068795 + }, + { + "x": 124.620361328125, + "y": 189.08221435546875, + "pressure": 1601, + "timestamp": 1774179068797 + }, + { + "x": 124.620361328125, + "y": 189.08221435546875, + "pressure": 1598, + "timestamp": 1774179068798 + }, + { + "x": 124.620361328125, + "y": 189.08221435546875, + "pressure": 1595, + "timestamp": 1774179068802 + }, + { + "x": 124.620361328125, + "y": 189.08221435546875, + "pressure": 1605, + "timestamp": 1774179068825 + }, + { + "x": 125.0166015625, + "y": 189.28030395507812, + "pressure": 1611, + "timestamp": 1774179068827 + }, + { + "x": 125.80908203125, + "y": 189.47842407226562, + "pressure": 1614, + "timestamp": 1774179068829 + }, + { + "x": 126.20538330078125, + "y": 189.47842407226562, + "pressure": 1615, + "timestamp": 1774179068830 + }, + { + "x": 126.60162353515625, + "y": 189.47842407226562, + "pressure": 1618, + "timestamp": 1774179068834 + }, + { + "x": 127.39410400390625, + "y": 189.28030395507812, + "pressure": 1617, + "timestamp": 1774179068836 + }, + { + "x": 127.79034423828125, + "y": 189.08221435546875, + "pressure": 1619, + "timestamp": 1774179068840 + }, + { + "x": 127.79034423828125, + "y": 189.08221435546875, + "pressure": 1630, + "timestamp": 1774179068841 + }, + { + "x": 128.18658447265625, + "y": 188.88412475585938, + "pressure": 1636, + "timestamp": 1774179068844 + }, + { + "x": 128.9791259765625, + "y": 188.4879150390625, + "pressure": 1639, + "timestamp": 1774179068845 + }, + { + "x": 129.7716064453125, + "y": 187.89361572265625, + "pressure": 1640, + "timestamp": 1774179068847 + }, + { + "x": 130.5640869140625, + "y": 187.29931640625, + "pressure": 1641, + "timestamp": 1774179068848 + }, + { + "x": 131.35662841796875, + "y": 186.90310668945312, + "pressure": 1643, + "timestamp": 1774179068850 + }, + { + "x": 131.75286865234375, + "y": 186.70501708984375, + "pressure": 1642, + "timestamp": 1774179068852 + }, + { + "x": 131.75286865234375, + "y": 186.70501708984375, + "pressure": 1651, + "timestamp": 1774179068857 + }, + { + "x": 131.75286865234375, + "y": 186.70501708984375, + "pressure": 1654, + "timestamp": 1774179068860 + }, + { + "x": 131.75286865234375, + "y": 186.70501708984375, + "pressure": 1657, + "timestamp": 1774179068863 + }, + { + "x": 132.14910888671875, + "y": 186.30880737304688, + "pressure": 1659, + "timestamp": 1774179068864 + }, + { + "x": 132.94158935546875, + "y": 185.51638793945312, + "pressure": 1658, + "timestamp": 1774179068866 + }, + { + "x": 133.53594970703125, + "y": 184.7239990234375, + "pressure": 1660, + "timestamp": 1774179068868 + }, + { + "x": 134.13037109375, + "y": 183.93161010742188, + "pressure": 1659, + "timestamp": 1774179068873 + }, + { + "x": 134.3284912109375, + "y": 183.535400390625, + "pressure": 1667, + "timestamp": 1774179068873 + }, + { + "x": 134.3284912109375, + "y": 183.535400390625, + "pressure": 1671, + "timestamp": 1774179068876 + }, + { + "x": 134.526611328125, + "y": 183.13919067382812, + "pressure": 1674, + "timestamp": 1774179068879 + }, + { + "x": 135.1209716796875, + "y": 182.3468017578125, + "pressure": 1676, + "timestamp": 1774179068881 + }, + { + "x": 135.71533203125, + "y": 181.55438232421875, + "pressure": 1675, + "timestamp": 1774179068882 + }, + { + "x": 135.9134521484375, + "y": 181.15817260742188, + "pressure": 1677, + "timestamp": 1774179068884 + }, + { + "x": 135.9134521484375, + "y": 181.15817260742188, + "pressure": 1691, + "timestamp": 1774179068889 + }, + { + "x": 136.111572265625, + "y": 180.76199340820312, + "pressure": 1699, + "timestamp": 1774179068892 + }, + { + "x": 136.7059326171875, + "y": 179.96957397460938, + "pressure": 1703, + "timestamp": 1774179068894 + }, + { + "x": 137.10223388671875, + "y": 179.17718505859375, + "pressure": 1705, + "timestamp": 1774179068895 + }, + { + "x": 137.30035400390625, + "y": 178.78097534179688, + "pressure": 1706, + "timestamp": 1774179068897 + }, + { + "x": 137.49847412109375, + "y": 178.384765625, + "pressure": 1707, + "timestamp": 1774179068900 + }, + { + "x": 137.69659423828125, + "y": 177.59237670898438, + "pressure": 1709, + "timestamp": 1774179068904 + }, + { + "x": 137.89471435546875, + "y": 177.1961669921875, + "pressure": 1721, + "timestamp": 1774179068905 + }, + { + "x": 137.89471435546875, + "y": 177.1961669921875, + "pressure": 1727, + "timestamp": 1774179068908 + }, + { + "x": 137.89471435546875, + "y": 177.1961669921875, + "pressure": 1730, + "timestamp": 1774179068909 + }, + { + "x": 137.89471435546875, + "y": 177.1961669921875, + "pressure": 1733, + "timestamp": 1774179068912 + }, + { + "x": 137.89471435546875, + "y": 177.1961669921875, + "pressure": 1736, + "timestamp": 1774179068921 + }, + { + "x": 137.89471435546875, + "y": 177.1961669921875, + "pressure": 1777, + "timestamp": 1774179068922 + }, + { + "x": 137.89471435546875, + "y": 177.1961669921875, + "pressure": 1798, + "timestamp": 1774179068924 + }, + { + "x": 137.89471435546875, + "y": 177.1961669921875, + "pressure": 1808, + "timestamp": 1774179068925 + }, + { + "x": 137.89471435546875, + "y": 177.1961669921875, + "pressure": 1813, + "timestamp": 1774179068927 + }, + { + "x": 137.89471435546875, + "y": 177.1961669921875, + "pressure": 1816, + "timestamp": 1774179068928 + }, + { + "x": 137.89471435546875, + "y": 177.1961669921875, + "pressure": 1820, + "timestamp": 1774179068937 + }, + { + "x": 137.89471435546875, + "y": 177.1961669921875, + "pressure": 1865, + "timestamp": 1774179068938 + }, + { + "x": 137.89471435546875, + "y": 177.1961669921875, + "pressure": 1887, + "timestamp": 1774179068940 + }, + { + "x": 137.89471435546875, + "y": 177.1961669921875, + "pressure": 1898, + "timestamp": 1774179068942 + }, + { + "x": 137.89471435546875, + "y": 177.1961669921875, + "pressure": 1904, + "timestamp": 1774179068943 + }, + { + "x": 137.89471435546875, + "y": 177.1961669921875, + "pressure": 1907, + "timestamp": 1774179068945 + }, + { + "x": 137.89471435546875, + "y": 177.1961669921875, + "pressure": 1911, + "timestamp": 1774179068952 + }, + { + "x": 137.89471435546875, + "y": 177.1961669921875, + "pressure": 1878, + "timestamp": 1774179068953 + }, + { + "x": 137.89471435546875, + "y": 177.1961669921875, + "pressure": 1862, + "timestamp": 1774179068956 + }, + { + "x": 137.89471435546875, + "y": 177.1961669921875, + "pressure": 1854, + "timestamp": 1774179068957 + }, + { + "x": 137.89471435546875, + "y": 177.1961669921875, + "pressure": 1850, + "timestamp": 1774179068959 + }, + { + "x": 137.89471435546875, + "y": 177.1961669921875, + "pressure": 1847, + "timestamp": 1774179068962 + }, + { + "x": 137.89471435546875, + "y": 177.1961669921875, + "pressure": 1818, + "timestamp": 1774179068970 + }, + { + "x": 137.89471435546875, + "y": 177.1961669921875, + "pressure": 1803, + "timestamp": 1774179068972 + }, + { + "x": 137.89471435546875, + "y": 177.1961669921875, + "pressure": 1796, + "timestamp": 1774179068973 + }, + { + "x": 137.89471435546875, + "y": 177.1961669921875, + "pressure": 1792, + "timestamp": 1774179068975 + }, + { + "x": 137.89471435546875, + "y": 177.1961669921875, + "pressure": 1789, + "timestamp": 1774179068978 + }, + { + "x": 137.89471435546875, + "y": 177.1961669921875, + "pressure": 1781, + "timestamp": 1774179068986 + }, + { + "x": 137.89471435546875, + "y": 177.1961669921875, + "pressure": 1777, + "timestamp": 1774179068988 + }, + { + "x": 137.69659423828125, + "y": 177.59237670898438, + "pressure": 1774, + "timestamp": 1774179068991 + }, + { + "x": 137.49847412109375, + "y": 178.384765625, + "pressure": 1773, + "timestamp": 1774179068993 + }, + { + "x": 137.49847412109375, + "y": 178.78097534179688, + "pressure": 1775, + "timestamp": 1774179068994 + }, + { + "x": 137.30035400390625, + "y": 179.17718505859375, + "pressure": 1764, + "timestamp": 1774179069002 + }, + { + "x": 137.10223388671875, + "y": 179.96957397460938, + "pressure": 1759, + "timestamp": 1774179069004 + }, + { + "x": 137.10223388671875, + "y": 180.36578369140625, + "pressure": 1757, + "timestamp": 1774179069005 + }, + { + "x": 137.10223388671875, + "y": 180.76199340820312, + "pressure": 1742, + "timestamp": 1774179069018 + }, + { + "x": 137.10223388671875, + "y": 181.55438232421875, + "pressure": 1736, + "timestamp": 1774179069020 + }, + { + "x": 137.10223388671875, + "y": 181.95059204101562, + "pressure": 1733, + "timestamp": 1774179069021 + }, + { + "x": 137.10223388671875, + "y": 181.95059204101562, + "pressure": 1730, + "timestamp": 1774179069025 + }, + { + "x": 137.10223388671875, + "y": 181.95059204101562, + "pressure": 1721, + "timestamp": 1774179069034 + }, + { + "x": 137.10223388671875, + "y": 181.95059204101562, + "pressure": 1717, + "timestamp": 1774179069036 + }, + { + "x": 137.10223388671875, + "y": 182.3468017578125, + "pressure": 1714, + "timestamp": 1774179069039 + }, + { + "x": 137.49847412109375, + "y": 183.13919067382812, + "pressure": 1713, + "timestamp": 1774179069041 + }, + { + "x": 137.69659423828125, + "y": 183.535400390625, + "pressure": 1715, + "timestamp": 1774179069043 + }, + { + "x": 137.69659423828125, + "y": 183.535400390625, + "pressure": 1711, + "timestamp": 1774179069050 + }, + { + "x": 138.09283447265625, + "y": 183.93161010742188, + "pressure": 1710, + "timestamp": 1774179069057 + }, + { + "x": 138.88531494140625, + "y": 184.52590942382812, + "pressure": 1712, + "timestamp": 1774179069059 + }, + { + "x": 139.28155517578125, + "y": 184.7239990234375, + "pressure": 1711, + "timestamp": 1774179069060 + }, + { + "x": 139.6778564453125, + "y": 184.92208862304688, + "pressure": 1714, + "timestamp": 1774179069068 + }, + { + "x": 140.4703369140625, + "y": 185.31829833984375, + "pressure": 1715, + "timestamp": 1774179069070 + }, + { + "x": 141.2628173828125, + "y": 185.71450805664062, + "pressure": 1717, + "timestamp": 1774179069071 + }, + { + "x": 142.0552978515625, + "y": 186.1107177734375, + "pressure": 1716, + "timestamp": 1774179069073 + }, + { + "x": 142.84783935546875, + "y": 186.1107177734375, + "pressure": 1718, + "timestamp": 1774179069075 + }, + { + "x": 143.24407958984375, + "y": 186.1107177734375, + "pressure": 1717, + "timestamp": 1774179069076 + }, + { + "x": 143.24407958984375, + "y": 186.1107177734375, + "pressure": 1729, + "timestamp": 1774179069082 + }, + { + "x": 143.24407958984375, + "y": 186.1107177734375, + "pressure": 1736, + "timestamp": 1774179069084 + }, + { + "x": 143.64031982421875, + "y": 186.1107177734375, + "pressure": 1739, + "timestamp": 1774179069086 + }, + { + "x": 144.43280029296875, + "y": 185.91259765625, + "pressure": 1741, + "timestamp": 1774179069087 + }, + { + "x": 144.82904052734375, + "y": 185.71450805664062, + "pressure": 1742, + "timestamp": 1774179069089 + }, + { + "x": 145.225341796875, + "y": 185.71450805664062, + "pressure": 1745, + "timestamp": 1774179069096 + }, + { + "x": 146.017822265625, + "y": 185.51638793945312, + "pressure": 1763, + "timestamp": 1774179069098 + }, + { + "x": 146.810302734375, + "y": 185.31829833984375, + "pressure": 1772, + "timestamp": 1774179069100 + }, + { + "x": 147.20654296875, + "y": 185.12020874023438, + "pressure": 1776, + "timestamp": 1774179069102 + }, + { + "x": 147.20654296875, + "y": 185.12020874023438, + "pressure": 1779, + "timestamp": 1774179069105 + }, + { + "x": 147.602783203125, + "y": 184.92208862304688, + "pressure": 1782, + "timestamp": 1774179069108 + }, + { + "x": 148.39532470703125, + "y": 184.32778930664062, + "pressure": 1781, + "timestamp": 1774179069113 + }, + { + "x": 148.79156494140625, + "y": 183.93161010742188, + "pressure": 1799, + "timestamp": 1774179069114 + }, + { + "x": 148.79156494140625, + "y": 183.93161010742188, + "pressure": 1808, + "timestamp": 1774179069116 + }, + { + "x": 148.79156494140625, + "y": 183.93161010742188, + "pressure": 1812, + "timestamp": 1774179069118 + }, + { + "x": 149.18780517578125, + "y": 183.535400390625, + "pressure": 1814, + "timestamp": 1774179069120 + }, + { + "x": 149.98028564453125, + "y": 182.74298095703125, + "pressure": 1815, + "timestamp": 1774179069121 + }, + { + "x": 150.57470703125, + "y": 181.95059204101562, + "pressure": 1816, + "timestamp": 1774179069123 + }, + { + "x": 150.970947265625, + "y": 181.55438232421875, + "pressure": 1818, + "timestamp": 1774179069124 + }, + { + "x": 151.1690673828125, + "y": 181.15817260742188, + "pressure": 1822, + "timestamp": 1774179069132 + }, + { + "x": 151.763427734375, + "y": 180.36578369140625, + "pressure": 1823, + "timestamp": 1774179069134 + }, + { + "x": 152.15966796875, + "y": 179.96957397460938, + "pressure": 1825, + "timestamp": 1774179069135 + }, + { + "x": 152.3577880859375, + "y": 179.57339477539062, + "pressure": 1825, + "timestamp": 1774179069140 + }, + { + "x": 152.9521484375, + "y": 178.78097534179688, + "pressure": 1824, + "timestamp": 1774179069145 + }, + { + "x": 153.54656982421875, + "y": 177.98858642578125, + "pressure": 1822, + "timestamp": 1774179069146 + }, + { + "x": 153.94281005859375, + "y": 177.59237670898438, + "pressure": 1821, + "timestamp": 1774179069148 + }, + { + "x": 154.14093017578125, + "y": 177.1961669921875, + "pressure": 1822, + "timestamp": 1774179069165 + }, + { + "x": 154.73529052734375, + "y": 176.40377807617188, + "pressure": 1824, + "timestamp": 1774179069166 + }, + { + "x": 155.13153076171875, + "y": 176.007568359375, + "pressure": 1823, + "timestamp": 1774179069168 + }, + { + "x": 155.13153076171875, + "y": 176.007568359375, + "pressure": 1826, + "timestamp": 1774179069184 + }, + { + "x": 155.13153076171875, + "y": 176.007568359375, + "pressure": 1829, + "timestamp": 1774179069194 + }, + { + "x": 155.13153076171875, + "y": 176.007568359375, + "pressure": 1832, + "timestamp": 1774179069198 + }, + { + "x": 155.32965087890625, + "y": 175.61135864257812, + "pressure": 1833, + "timestamp": 1774179069201 + }, + { + "x": 155.92401123046875, + "y": 174.8189697265625, + "pressure": 1835, + "timestamp": 1774179069203 + }, + { + "x": 156.3203125, + "y": 174.42276000976562, + "pressure": 1834, + "timestamp": 1774179069205 + }, + { + "x": 156.3203125, + "y": 174.42276000976562, + "pressure": 1838, + "timestamp": 1774179069212 + }, + { + "x": 156.3203125, + "y": 174.42276000976562, + "pressure": 1841, + "timestamp": 1774179069216 + }, + { + "x": 156.3203125, + "y": 174.42276000976562, + "pressure": 1836, + "timestamp": 1774179069242 + }, + { + "x": 156.3203125, + "y": 174.42276000976562, + "pressure": 1833, + "timestamp": 1774179069245 + }, + { + "x": 156.3203125, + "y": 174.42276000976562, + "pressure": 1828, + "timestamp": 1774179069259 + }, + { + "x": 156.3203125, + "y": 174.42276000976562, + "pressure": 1825, + "timestamp": 1774179069261 + }, + { + "x": 156.3203125, + "y": 174.42276000976562, + "pressure": 1835, + "timestamp": 1774179069274 + }, + { + "x": 156.3203125, + "y": 174.42276000976562, + "pressure": 1840, + "timestamp": 1774179069277 + }, + { + "x": 156.3203125, + "y": 174.42276000976562, + "pressure": 1843, + "timestamp": 1774179069278 + }, + { + "x": 156.3203125, + "y": 174.42276000976562, + "pressure": 1847, + "timestamp": 1774179069283 + }, + { + "x": 156.5184326171875, + "y": 174.02655029296875, + "pressure": 1846, + "timestamp": 1774179069285 + }, + { + "x": 157.11279296875, + "y": 173.23416137695312, + "pressure": 1848, + "timestamp": 1774179069289 + }, + { + "x": 157.3109130859375, + "y": 172.83795166015625, + "pressure": 1876, + "timestamp": 1774179069291 + }, + { + "x": 157.3109130859375, + "y": 172.83795166015625, + "pressure": 1890, + "timestamp": 1774179069293 + }, + { + "x": 157.3109130859375, + "y": 172.83795166015625, + "pressure": 1897, + "timestamp": 1774179069294 + }, + { + "x": 157.3109130859375, + "y": 172.83795166015625, + "pressure": 1900, + "timestamp": 1774179069296 + }, + { + "x": 157.3109130859375, + "y": 172.83795166015625, + "pressure": 1903, + "timestamp": 1774179069300 + }, + { + "x": 157.3109130859375, + "y": 172.83795166015625, + "pressure": 1923, + "timestamp": 1774179069307 + }, + { + "x": 157.3109130859375, + "y": 172.83795166015625, + "pressure": 1933, + "timestamp": 1774179069309 + }, + { + "x": 157.3109130859375, + "y": 172.83795166015625, + "pressure": 1938, + "timestamp": 1774179069310 + }, + { + "x": 157.3109130859375, + "y": 172.83795166015625, + "pressure": 1941, + "timestamp": 1774179069313 + }, + { + "x": 157.3109130859375, + "y": 172.83795166015625, + "pressure": 1944, + "timestamp": 1774179069317 + }, + { + "x": 157.3109130859375, + "y": 172.83795166015625, + "pressure": 1981, + "timestamp": 1774179069323 + }, + { + "x": 157.3109130859375, + "y": 172.83795166015625, + "pressure": 2000, + "timestamp": 1774179069325 + }, + { + "x": 157.3109130859375, + "y": 172.83795166015625, + "pressure": 2009, + "timestamp": 1774179069326 + }, + { + "x": 157.3109130859375, + "y": 172.83795166015625, + "pressure": 2014, + "timestamp": 1774179069328 + }, + { + "x": 157.3109130859375, + "y": 172.83795166015625, + "pressure": 2017, + "timestamp": 1774179069331 + }, + { + "x": 157.3109130859375, + "y": 172.83795166015625, + "pressure": 2020, + "timestamp": 1774179069337 + }, + { + "x": 157.3109130859375, + "y": 172.83795166015625, + "pressure": 2031, + "timestamp": 1774179069339 + }, + { + "x": 157.3109130859375, + "y": 172.83795166015625, + "pressure": 2036, + "timestamp": 1774179069341 + }, + { + "x": 157.3109130859375, + "y": 172.83795166015625, + "pressure": 2039, + "timestamp": 1774179069343 + }, + { + "x": 157.3109130859375, + "y": 172.83795166015625, + "pressure": 2043, + "timestamp": 1774179069347 + }, + { + "x": 157.3109130859375, + "y": 172.83795166015625, + "pressure": 2046, + "timestamp": 1774179069357 + }, + { + "x": 157.3109130859375, + "y": 172.83795166015625, + "pressure": 2049, + "timestamp": 1774179069362 + }, + { + "x": 157.3109130859375, + "y": 173.23416137695312, + "pressure": 2049, + "timestamp": 1774179069369 + }, + { + "x": 157.11279296875, + "y": 174.22467041015625, + "pressure": 2052, + "timestamp": 1774179069371 + }, + { + "x": 156.9146728515625, + "y": 175.21514892578125, + "pressure": 2054, + "timestamp": 1774179069373 + }, + { + "x": 156.716552734375, + "y": 176.40377807617188, + "pressure": 2055, + "timestamp": 1774179069374 + }, + { + "x": 156.5184326171875, + "y": 177.1961669921875, + "pressure": 2057, + "timestamp": 1774179069376 + }, + { + "x": 156.3203125, + "y": 177.98858642578125, + "pressure": 2056, + "timestamp": 1774179069378 + }, + { + "x": 156.1221923828125, + "y": 178.78097534179688, + "pressure": 2058, + "timestamp": 1774179069379 + }, + { + "x": 155.92401123046875, + "y": 179.771484375, + "pressure": 2057, + "timestamp": 1774179069381 + }, + { + "x": 155.52777099609375, + "y": 180.76199340820312, + "pressure": 2056, + "timestamp": 1774179069385 + }, + { + "x": 155.13153076171875, + "y": 181.95059204101562, + "pressure": 2075, + "timestamp": 1774179069387 + }, + { + "x": 154.73529052734375, + "y": 182.94110107421875, + "pressure": 2085, + "timestamp": 1774179069389 + }, + { + "x": 154.53717041015625, + "y": 183.93161010742188, + "pressure": 2090, + "timestamp": 1774179069390 + }, + { + "x": 154.14093017578125, + "y": 185.12020874023438, + "pressure": 2092, + "timestamp": 1774179069392 + }, + { + "x": 153.74468994140625, + "y": 186.30880737304688, + "pressure": 2093, + "timestamp": 1774179069394 + }, + { + "x": 153.34844970703125, + "y": 187.49740600585938, + "pressure": 2094, + "timestamp": 1774179069396 + }, + { + "x": 152.9521484375, + "y": 188.4879150390625, + "pressure": 2096, + "timestamp": 1774179069397 + }, + { + "x": 152.555908203125, + "y": 189.47842407226562, + "pressure": 2095, + "timestamp": 1774179069401 + }, + { + "x": 152.15966796875, + "y": 190.46893310546875, + "pressure": 2113, + "timestamp": 1774179069403 + }, + { + "x": 151.763427734375, + "y": 191.45941162109375, + "pressure": 2122, + "timestamp": 1774179069405 + }, + { + "x": 151.3671875, + "y": 192.44992065429688, + "pressure": 2126, + "timestamp": 1774179069406 + }, + { + "x": 150.970947265625, + "y": 193.4404296875, + "pressure": 2128, + "timestamp": 1774179069408 + }, + { + "x": 150.57470703125, + "y": 194.43093872070312, + "pressure": 2129, + "timestamp": 1774179069410 + }, + { + "x": 150.17840576171875, + "y": 195.42144775390625, + "pressure": 2130, + "timestamp": 1774179069412 + }, + { + "x": 149.78216552734375, + "y": 196.41195678710938, + "pressure": 2132, + "timestamp": 1774179069413 + }, + { + "x": 149.38592529296875, + "y": 197.40243530273438, + "pressure": 2131, + "timestamp": 1774179069418 + }, + { + "x": 148.98968505859375, + "y": 198.591064453125, + "pressure": 2135, + "timestamp": 1774179069419 + }, + { + "x": 148.59344482421875, + "y": 199.7796630859375, + "pressure": 2137, + "timestamp": 1774179069421 + }, + { + "x": 147.99908447265625, + "y": 200.77017211914062, + "pressure": 2138, + "timestamp": 1774179069423 + }, + { + "x": 147.4046630859375, + "y": 201.95877075195312, + "pressure": 2140, + "timestamp": 1774179069424 + }, + { + "x": 146.810302734375, + "y": 203.14736938476562, + "pressure": 2139, + "timestamp": 1774179069426 + }, + { + "x": 146.2159423828125, + "y": 204.13787841796875, + "pressure": 2141, + "timestamp": 1774179069428 + }, + { + "x": 145.62158203125, + "y": 205.12838745117188, + "pressure": 2140, + "timestamp": 1774179069429 + }, + { + "x": 145.0272216796875, + "y": 205.9207763671875, + "pressure": 2139, + "timestamp": 1774179069433 + }, + { + "x": 144.43280029296875, + "y": 206.71316528320312, + "pressure": 2137, + "timestamp": 1774179069435 + }, + { + "x": 143.83843994140625, + "y": 207.50558471679688, + "pressure": 2136, + "timestamp": 1774179069437 + }, + { + "x": 143.44219970703125, + "y": 208.2979736328125, + "pressure": 2138, + "timestamp": 1774179069439 + }, + { + "x": 142.84783935546875, + "y": 209.09039306640625, + "pressure": 2137, + "timestamp": 1774179069440 + }, + { + "x": 142.25341796875, + "y": 209.8828125, + "pressure": 2136, + "timestamp": 1774179069442 + }, + { + "x": 141.6590576171875, + "y": 210.6751708984375, + "pressure": 2138, + "timestamp": 1774179069444 + }, + { + "x": 141.064697265625, + "y": 211.46759033203125, + "pressure": 2137, + "timestamp": 1774179069446 + }, + { + "x": 140.4703369140625, + "y": 212.260009765625, + "pressure": 2136, + "timestamp": 1774179069449 + }, + { + "x": 139.8759765625, + "y": 213.05242919921875, + "pressure": 2135, + "timestamp": 1774179069451 + }, + { + "x": 139.6778564453125, + "y": 213.4486083984375, + "pressure": 2137, + "timestamp": 1774179069453 + }, + { + "x": 139.28155517578125, + "y": 213.84478759765625, + "pressure": 2135, + "timestamp": 1774179069456 + }, + { + "x": 138.68719482421875, + "y": 214.63720703125, + "pressure": 2137, + "timestamp": 1774179069458 + }, + { + "x": 137.89471435546875, + "y": 215.42962646484375, + "pressure": 2136, + "timestamp": 1774179069460 + }, + { + "x": 137.69659423828125, + "y": 215.8258056640625, + "pressure": 2135, + "timestamp": 1774179069462 + }, + { + "x": 137.69659423828125, + "y": 215.8258056640625, + "pressure": 2114, + "timestamp": 1774179069467 + }, + { + "x": 137.10223388671875, + "y": 216.22198486328125, + "pressure": 2103, + "timestamp": 1774179069470 + }, + { + "x": 136.111572265625, + "y": 216.81634521484375, + "pressure": 2097, + "timestamp": 1774179069471 + }, + { + "x": 135.1209716796875, + "y": 217.60870361328125, + "pressure": 2094, + "timestamp": 1774179069473 + }, + { + "x": 134.3284912109375, + "y": 218.2030029296875, + "pressure": 2093, + "timestamp": 1774179069474 + }, + { + "x": 133.93218994140625, + "y": 218.401123046875, + "pressure": 2092, + "timestamp": 1774179069476 + }, + { + "x": 133.53594970703125, + "y": 218.5992431640625, + "pressure": 2024, + "timestamp": 1774179069483 + }, + { + "x": 132.54534912109375, + "y": 219.19354248046875, + "pressure": 1989, + "timestamp": 1774179069487 + }, + { + "x": 131.75286865234375, + "y": 219.5897216796875, + "pressure": 1972, + "timestamp": 1774179069487 + }, + { + "x": 131.35662841796875, + "y": 219.787841796875, + "pressure": 1963, + "timestamp": 1774179069488 + }, + { + "x": 131.35662841796875, + "y": 219.787841796875, + "pressure": 1959, + "timestamp": 1774179069490 + }, + { + "x": 131.35662841796875, + "y": 219.787841796875, + "pressure": 1956, + "timestamp": 1774179069493 + }, + { + "x": 131.35662841796875, + "y": 219.787841796875, + "pressure": 1909, + "timestamp": 1774179069499 + }, + { + "x": 131.35662841796875, + "y": 219.787841796875, + "pressure": 1886, + "timestamp": 1774179069501 + }, + { + "x": 131.35662841796875, + "y": 219.787841796875, + "pressure": 1874, + "timestamp": 1774179069503 + }, + { + "x": 131.35662841796875, + "y": 219.787841796875, + "pressure": 1868, + "timestamp": 1774179069504 + }, + { + "x": 131.35662841796875, + "y": 219.787841796875, + "pressure": 1865, + "timestamp": 1774179069506 + }, + { + "x": 131.35662841796875, + "y": 219.787841796875, + "pressure": 1810, + "timestamp": 1774179069515 + }, + { + "x": 131.35662841796875, + "y": 219.787841796875, + "pressure": 1783, + "timestamp": 1774179069518 + }, + { + "x": 131.35662841796875, + "y": 219.787841796875, + "pressure": 1769, + "timestamp": 1774179069519 + }, + { + "x": 131.35662841796875, + "y": 219.787841796875, + "pressure": 1762, + "timestamp": 1774179069520 + }, + { + "x": 131.35662841796875, + "y": 219.787841796875, + "pressure": 1759, + "timestamp": 1774179069522 + }, + { + "x": 131.35662841796875, + "y": 219.787841796875, + "pressure": 1756, + "timestamp": 1774179069526 + }, + { + "x": 131.35662841796875, + "y": 219.787841796875, + "pressure": 1750, + "timestamp": 1774179069532 + }, + { + "x": 131.35662841796875, + "y": 219.3916015625, + "pressure": 1746, + "timestamp": 1774179069533 + }, + { + "x": 131.35662841796875, + "y": 218.401123046875, + "pressure": 1744, + "timestamp": 1774179069535 + }, + { + "x": 131.55474853515625, + "y": 217.41064453125, + "pressure": 1743, + "timestamp": 1774179069537 + }, + { + "x": 131.95098876953125, + "y": 216.42010498046875, + "pressure": 1745, + "timestamp": 1774179069538 + }, + { + "x": 132.34722900390625, + "y": 215.42962646484375, + "pressure": 1744, + "timestamp": 1774179069540 + }, + { + "x": 132.74346923828125, + "y": 214.4390869140625, + "pressure": 1743, + "timestamp": 1774179069542 + }, + { + "x": 133.13970947265625, + "y": 213.4486083984375, + "pressure": 1745, + "timestamp": 1774179069546 + }, + { + "x": 133.73406982421875, + "y": 212.4581298828125, + "pressure": 1744, + "timestamp": 1774179069547 + }, + { + "x": 134.3284912109375, + "y": 211.46759033203125, + "pressure": 1746, + "timestamp": 1774179069549 + }, + { + "x": 134.9228515625, + "y": 210.27899169921875, + "pressure": 1745, + "timestamp": 1774179069551 + }, + { + "x": 135.5172119140625, + "y": 209.28851318359375, + "pressure": 1744, + "timestamp": 1774179069553 + }, + { + "x": 136.111572265625, + "y": 208.2979736328125, + "pressure": 1746, + "timestamp": 1774179069554 + }, + { + "x": 136.90411376953125, + "y": 207.3074951171875, + "pressure": 1745, + "timestamp": 1774179069556 + }, + { + "x": 137.69659423828125, + "y": 206.31698608398438, + "pressure": 1744, + "timestamp": 1774179069558 + }, + { + "x": 138.48907470703125, + "y": 205.32647705078125, + "pressure": 1746, + "timestamp": 1774179069562 + }, + { + "x": 139.28155517578125, + "y": 204.33596801757812, + "pressure": 1753, + "timestamp": 1774179069563 + }, + { + "x": 140.0740966796875, + "y": 203.345458984375, + "pressure": 1757, + "timestamp": 1774179069565 + }, + { + "x": 140.8665771484375, + "y": 202.1568603515625, + "pressure": 1759, + "timestamp": 1774179069567 + }, + { + "x": 141.6590576171875, + "y": 201.16635131835938, + "pressure": 1760, + "timestamp": 1774179069569 + }, + { + "x": 142.64971923828125, + "y": 200.17584228515625, + "pressure": 1762, + "timestamp": 1774179069570 + }, + { + "x": 143.44219970703125, + "y": 198.98724365234375, + "pressure": 1761, + "timestamp": 1774179069572 + }, + { + "x": 144.43280029296875, + "y": 197.99673461914062, + "pressure": 1763, + "timestamp": 1774179069574 + }, + { + "x": 145.4234619140625, + "y": 197.00625610351562, + "pressure": 1762, + "timestamp": 1774179069578 + }, + { + "x": 146.4140625, + "y": 196.0157470703125, + "pressure": 1770, + "timestamp": 1774179069579 + }, + { + "x": 147.4046630859375, + "y": 195.02523803710938, + "pressure": 1774, + "timestamp": 1774179069582 + }, + { + "x": 148.39532470703125, + "y": 194.03472900390625, + "pressure": 1776, + "timestamp": 1774179069583 + }, + { + "x": 149.38592529296875, + "y": 193.04421997070312, + "pressure": 1777, + "timestamp": 1774179069585 + }, + { + "x": 150.37652587890625, + "y": 191.85562133789062, + "pressure": 1779, + "timestamp": 1774179069586 + }, + { + "x": 151.3671875, + "y": 190.8651123046875, + "pressure": 1778, + "timestamp": 1774179069588 + }, + { + "x": 152.3577880859375, + "y": 189.87460327148438, + "pressure": 1780, + "timestamp": 1774179069590 + }, + { + "x": 153.34844970703125, + "y": 188.88412475585938, + "pressure": 1779, + "timestamp": 1774179069595 + }, + { + "x": 154.33905029296875, + "y": 187.89361572265625, + "pressure": 1793, + "timestamp": 1774179069595 + }, + { + "x": 155.32965087890625, + "y": 186.90310668945312, + "pressure": 1800, + "timestamp": 1774179069598 + }, + { + "x": 156.1221923828125, + "y": 185.91259765625, + "pressure": 1804, + "timestamp": 1774179069599 + }, + { + "x": 156.9146728515625, + "y": 184.92208862304688, + "pressure": 1806, + "timestamp": 1774179069601 + }, + { + "x": 157.9052734375, + "y": 183.93161010742188, + "pressure": 1807, + "timestamp": 1774179069602 + }, + { + "x": 158.69781494140625, + "y": 182.94110107421875, + "pressure": 1809, + "timestamp": 1774179069604 + }, + { + "x": 159.49029541015625, + "y": 181.95059204101562, + "pressure": 1808, + "timestamp": 1774179069606 + }, + { + "x": 160.48089599609375, + "y": 180.9600830078125, + "pressure": 1810, + "timestamp": 1774179069610 + }, + { + "x": 161.27337646484375, + "y": 179.96957397460938, + "pressure": 1817, + "timestamp": 1774179069611 + }, + { + "x": 162.06591796875, + "y": 178.97906494140625, + "pressure": 1820, + "timestamp": 1774179069614 + }, + { + "x": 162.8583984375, + "y": 178.18667602539062, + "pressure": 1822, + "timestamp": 1774179069615 + }, + { + "x": 163.65087890625, + "y": 177.394287109375, + "pressure": 1823, + "timestamp": 1774179069617 + }, + { + "x": 164.44342041015625, + "y": 176.40377807617188, + "pressure": 1825, + "timestamp": 1774179069618 + }, + { + "x": 165.23590087890625, + "y": 175.61135864257812, + "pressure": 1824, + "timestamp": 1774179069620 + }, + { + "x": 166.02838134765625, + "y": 174.8189697265625, + "pressure": 1826, + "timestamp": 1774179069622 + }, + { + "x": 166.82086181640625, + "y": 174.02655029296875, + "pressure": 1825, + "timestamp": 1774179069626 + }, + { + "x": 167.2171630859375, + "y": 173.63037109375, + "pressure": 1824, + "timestamp": 1774179069628 + }, + { + "x": 167.6134033203125, + "y": 173.23416137695312, + "pressure": 1825, + "timestamp": 1774179069631 + }, + { + "x": 168.4058837890625, + "y": 172.44174194335938, + "pressure": 1824, + "timestamp": 1774179069633 + }, + { + "x": 169.1983642578125, + "y": 171.64935302734375, + "pressure": 1823, + "timestamp": 1774179069634 + }, + { + "x": 169.59466552734375, + "y": 171.25314331054688, + "pressure": 1825, + "timestamp": 1774179069637 + }, + { + "x": 169.99090576171875, + "y": 171.0550537109375, + "pressure": 1823, + "timestamp": 1774179069642 + }, + { + "x": 170.78338623046875, + "y": 170.46075439453125, + "pressure": 1825, + "timestamp": 1774179069644 + }, + { + "x": 171.17962646484375, + "y": 170.06454467773438, + "pressure": 1824, + "timestamp": 1774179069646 + }, + { + "x": 171.57586669921875, + "y": 169.866455078125, + "pressure": 1823, + "timestamp": 1774179069652 + }, + { + "x": 172.368408203125, + "y": 169.27215576171875, + "pressure": 1825, + "timestamp": 1774179069654 + }, + { + "x": 172.7646484375, + "y": 168.87594604492188, + "pressure": 1824, + "timestamp": 1774179069658 + }, + { + "x": 172.7646484375, + "y": 168.87594604492188, + "pressure": 1827, + "timestamp": 1774179069662 + }, + { + "x": 172.7646484375, + "y": 168.87594604492188, + "pressure": 1830, + "timestamp": 1774179069667 + }, + { + "x": 172.7646484375, + "y": 168.87594604492188, + "pressure": 1833, + "timestamp": 1774179069678 + }, + { + "x": 173.160888671875, + "y": 168.67782592773438, + "pressure": 1836, + "timestamp": 1774179069681 + }, + { + "x": 173.953369140625, + "y": 168.08352661132812, + "pressure": 1835, + "timestamp": 1774179069683 + }, + { + "x": 174.349609375, + "y": 167.88543701171875, + "pressure": 1837, + "timestamp": 1774179069684 + }, + { + "x": 174.349609375, + "y": 167.88543701171875, + "pressure": 1850, + "timestamp": 1774179069692 + }, + { + "x": 174.349609375, + "y": 167.88543701171875, + "pressure": 1857, + "timestamp": 1774179069694 + }, + { + "x": 174.349609375, + "y": 167.88543701171875, + "pressure": 1861, + "timestamp": 1774179069695 + }, + { + "x": 174.349609375, + "y": 167.88543701171875, + "pressure": 1864, + "timestamp": 1774179069699 + }, + { + "x": 174.349609375, + "y": 167.88543701171875, + "pressure": 1867, + "timestamp": 1774179069706 + }, + { + "x": 174.745849609375, + "y": 168.08352661132812, + "pressure": 1864, + "timestamp": 1774179069710 + }, + { + "x": 175.14215087890625, + "y": 168.08352661132812, + "pressure": 1866, + "timestamp": 1774179069712 + }, + { + "x": 175.53839111328125, + "y": 168.28164672851562, + "pressure": 1866, + "timestamp": 1774179069717 + }, + { + "x": 175.93463134765625, + "y": 168.28164672851562, + "pressure": 1865, + "timestamp": 1774179069718 + }, + { + "x": 175.93463134765625, + "y": 168.28164672851562, + "pressure": 1855, + "timestamp": 1774179069724 + }, + { + "x": 175.93463134765625, + "y": 168.28164672851562, + "pressure": 1850, + "timestamp": 1774179069726 + }, + { + "x": 175.93463134765625, + "y": 168.28164672851562, + "pressure": 1847, + "timestamp": 1774179069729 + }, + { + "x": 176.33087158203125, + "y": 168.479736328125, + "pressure": 1846, + "timestamp": 1774179069731 + }, + { + "x": 177.12335205078125, + "y": 168.67782592773438, + "pressure": 1848, + "timestamp": 1774179069733 + }, + { + "x": 177.51959228515625, + "y": 168.87594604492188, + "pressure": 1847, + "timestamp": 1774179069734 + }, + { + "x": 177.51959228515625, + "y": 168.87594604492188, + "pressure": 1844, + "timestamp": 1774179069740 + }, + { + "x": 177.51959228515625, + "y": 168.87594604492188, + "pressure": 1882, + "timestamp": 1774179069756 + }, + { + "x": 177.51959228515625, + "y": 168.87594604492188, + "pressure": 1902, + "timestamp": 1774179069758 + }, + { + "x": 177.51959228515625, + "y": 168.87594604492188, + "pressure": 1912, + "timestamp": 1774179069760 + }, + { + "x": 177.51959228515625, + "y": 168.87594604492188, + "pressure": 1917, + "timestamp": 1774179069761 + }, + { + "x": 177.51959228515625, + "y": 168.87594604492188, + "pressure": 1920, + "timestamp": 1774179069765 + }, + { + "x": 177.51959228515625, + "y": 168.87594604492188, + "pressure": 1923, + "timestamp": 1774179069770 + }, + { + "x": 177.51959228515625, + "y": 168.87594604492188, + "pressure": 1951, + "timestamp": 1774179069772 + }, + { + "x": 177.51959228515625, + "y": 168.87594604492188, + "pressure": 1965, + "timestamp": 1774179069774 + }, + { + "x": 177.51959228515625, + "y": 168.87594604492188, + "pressure": 1972, + "timestamp": 1774179069776 + }, + { + "x": 177.51959228515625, + "y": 169.27215576171875, + "pressure": 1976, + "timestamp": 1774179069777 + }, + { + "x": 177.71771240234375, + "y": 170.06454467773438, + "pressure": 1978, + "timestamp": 1774179069780 + }, + { + "x": 177.9158935546875, + "y": 170.85693359375, + "pressure": 1979, + "timestamp": 1774179069781 + }, + { + "x": 177.9158935546875, + "y": 171.25314331054688, + "pressure": 1981, + "timestamp": 1774179069782 + }, + { + "x": 177.9158935546875, + "y": 171.25314331054688, + "pressure": 1986, + "timestamp": 1774179069788 + }, + { + "x": 177.9158935546875, + "y": 171.25314331054688, + "pressure": 1989, + "timestamp": 1774179069790 + }, + { + "x": 177.71771240234375, + "y": 171.64935302734375, + "pressure": 1991, + "timestamp": 1774179069793 + }, + { + "x": 177.32147216796875, + "y": 172.44174194335938, + "pressure": 1992, + "timestamp": 1774179069794 + }, + { + "x": 177.12335205078125, + "y": 172.83795166015625, + "pressure": 1994, + "timestamp": 1774179069795 + }, + { + "x": 176.92523193359375, + "y": 173.23416137695312, + "pressure": 1995, + "timestamp": 1774179069798 + }, + { + "x": 176.33087158203125, + "y": 174.02655029296875, + "pressure": 1994, + "timestamp": 1774179069803 + }, + { + "x": 176.13275146484375, + "y": 174.42276000976562, + "pressure": 1991, + "timestamp": 1774179069804 + }, + { + "x": 175.93463134765625, + "y": 174.8189697265625, + "pressure": 1991, + "timestamp": 1774179069809 + }, + { + "x": 175.34027099609375, + "y": 175.61135864257812, + "pressure": 1990, + "timestamp": 1774179069811 + }, + { + "x": 175.14215087890625, + "y": 176.007568359375, + "pressure": 1989, + "timestamp": 1774179069813 + }, + { + "x": 175.14215087890625, + "y": 176.007568359375, + "pressure": 1985, + "timestamp": 1774179069820 + }, + { + "x": 174.745849609375, + "y": 176.40377807617188, + "pressure": 1982, + "timestamp": 1774179069823 + }, + { + "x": 174.1514892578125, + "y": 177.1961669921875, + "pressure": 1981, + "timestamp": 1774179069824 + }, + { + "x": 173.953369140625, + "y": 177.59237670898438, + "pressure": 1980, + "timestamp": 1774179069826 + }, + { + "x": 173.953369140625, + "y": 177.59237670898438, + "pressure": 1977, + "timestamp": 1774179069839 + }, + { + "x": 173.55712890625, + "y": 177.98858642578125, + "pressure": 1976, + "timestamp": 1774179069840 + }, + { + "x": 172.9627685546875, + "y": 178.78097534179688, + "pressure": 1978, + "timestamp": 1774179069842 + }, + { + "x": 172.7646484375, + "y": 179.17718505859375, + "pressure": 1977, + "timestamp": 1774179069844 + }, + { + "x": 172.7646484375, + "y": 179.17718505859375, + "pressure": 1980, + "timestamp": 1774179069856 + }, + { + "x": 172.7646484375, + "y": 179.17718505859375, + "pressure": 1984, + "timestamp": 1774179069868 + }, + { + "x": 172.7646484375, + "y": 179.17718505859375, + "pressure": 1987, + "timestamp": 1774179069871 + }, + { + "x": 172.368408203125, + "y": 179.57339477539062, + "pressure": 1991, + "timestamp": 1774179069875 + }, + { + "x": 171.77398681640625, + "y": 180.36578369140625, + "pressure": 1990, + "timestamp": 1774179069877 + }, + { + "x": 171.57586669921875, + "y": 180.76199340820312, + "pressure": 1992, + "timestamp": 1774179069879 + }, + { + "x": 171.17962646484375, + "y": 181.15817260742188, + "pressure": 1992, + "timestamp": 1774179069891 + }, + { + "x": 170.18902587890625, + "y": 181.75250244140625, + "pressure": 1994, + "timestamp": 1774179069894 + }, + { + "x": 169.396484375, + "y": 182.148681640625, + "pressure": 1993, + "timestamp": 1774179069895 + }, + { + "x": 169.000244140625, + "y": 182.3468017578125, + "pressure": 1992, + "timestamp": 1774179069899 + }, + { + "x": 169.000244140625, + "y": 182.3468017578125, + "pressure": 1987, + "timestamp": 1774179069900 + }, + { + "x": 168.60400390625, + "y": 182.54489135742188, + "pressure": 1984, + "timestamp": 1774179069903 + }, + { + "x": 167.8115234375, + "y": 182.94110107421875, + "pressure": 1983, + "timestamp": 1774179069904 + }, + { + "x": 167.01904296875, + "y": 183.3372802734375, + "pressure": 1982, + "timestamp": 1774179069906 + }, + { + "x": 166.22650146484375, + "y": 183.73348999023438, + "pressure": 1984, + "timestamp": 1774179069908 + }, + { + "x": 165.83026123046875, + "y": 183.93161010742188, + "pressure": 1983, + "timestamp": 1774179069909 + }, + { + "x": 165.43402099609375, + "y": 184.12969970703125, + "pressure": 1984, + "timestamp": 1774179069915 + }, + { + "x": 164.64154052734375, + "y": 184.32778930664062, + "pressure": 1977, + "timestamp": 1774179069916 + }, + { + "x": 163.8489990234375, + "y": 184.52590942382812, + "pressure": 1974, + "timestamp": 1774179069918 + }, + { + "x": 163.0565185546875, + "y": 184.7239990234375, + "pressure": 1972, + "timestamp": 1774179069920 + }, + { + "x": 162.6602783203125, + "y": 184.7239990234375, + "pressure": 1971, + "timestamp": 1774179069922 + }, + { + "x": 162.2640380859375, + "y": 184.92208862304688, + "pressure": 1959, + "timestamp": 1774179069933 + }, + { + "x": 161.4715576171875, + "y": 185.12020874023438, + "pressure": 1952, + "timestamp": 1774179069935 + }, + { + "x": 161.07525634765625, + "y": 185.12020874023438, + "pressure": 1949, + "timestamp": 1774179069936 + }, + { + "x": 161.07525634765625, + "y": 185.12020874023438, + "pressure": 1946, + "timestamp": 1774179069939 + }, + { + "x": 161.07525634765625, + "y": 185.12020874023438, + "pressure": 1925, + "timestamp": 1774179069949 + }, + { + "x": 161.07525634765625, + "y": 185.12020874023438, + "pressure": 1914, + "timestamp": 1774179069951 + }, + { + "x": 161.07525634765625, + "y": 185.12020874023438, + "pressure": 1909, + "timestamp": 1774179069953 + }, + { + "x": 161.07525634765625, + "y": 185.12020874023438, + "pressure": 1906, + "timestamp": 1774179069954 + }, + { + "x": 161.07525634765625, + "y": 185.12020874023438, + "pressure": 1857, + "timestamp": 1774179069965 + }, + { + "x": 161.07525634765625, + "y": 185.12020874023438, + "pressure": 1833, + "timestamp": 1774179069967 + }, + { + "x": 161.07525634765625, + "y": 185.12020874023438, + "pressure": 1821, + "timestamp": 1774179069968 + }, + { + "x": 161.07525634765625, + "y": 185.12020874023438, + "pressure": 1815, + "timestamp": 1774179069970 + }, + { + "x": 161.07525634765625, + "y": 185.12020874023438, + "pressure": 1812, + "timestamp": 1774179069971 + }, + { + "x": 161.07525634765625, + "y": 185.12020874023438, + "pressure": 1791, + "timestamp": 1774179069981 + }, + { + "x": 161.07525634765625, + "y": 185.12020874023438, + "pressure": 1781, + "timestamp": 1774179069983 + }, + { + "x": 161.07525634765625, + "y": 185.12020874023438, + "pressure": 1776, + "timestamp": 1774179069985 + }, + { + "x": 161.07525634765625, + "y": 185.12020874023438, + "pressure": 1773, + "timestamp": 1774179069986 + }, + { + "x": 161.07525634765625, + "y": 185.12020874023438, + "pressure": 1776, + "timestamp": 1774179070004 + }, + { + "x": 161.07525634765625, + "y": 185.12020874023438, + "pressure": 1775, + "timestamp": 1774179070004 + }, + { + "x": 161.07525634765625, + "y": 185.12020874023438, + "pressure": 1778, + "timestamp": 1774179070015 + }, + { + "x": 161.07525634765625, + "y": 185.12020874023438, + "pressure": 1781, + "timestamp": 1774179070018 + }, + { + "x": 161.07525634765625, + "y": 185.12020874023438, + "pressure": 1803, + "timestamp": 1774179070029 + }, + { + "x": 161.07525634765625, + "y": 185.12020874023438, + "pressure": 1815, + "timestamp": 1774179070032 + }, + { + "x": 161.07525634765625, + "y": 185.12020874023438, + "pressure": 1821, + "timestamp": 1774179070035 + }, + { + "x": 161.07525634765625, + "y": 185.12020874023438, + "pressure": 1824, + "timestamp": 1774179070035 + }, + { + "x": 161.07525634765625, + "y": 185.12020874023438, + "pressure": 1828, + "timestamp": 1774179070039 + }, + { + "x": 161.07525634765625, + "y": 185.12020874023438, + "pressure": 1930, + "timestamp": 1774179070045 + }, + { + "x": 161.07525634765625, + "y": 185.12020874023438, + "pressure": 1981, + "timestamp": 1774179070047 + }, + { + "x": 161.07525634765625, + "y": 185.12020874023438, + "pressure": 2007, + "timestamp": 1774179070048 + }, + { + "x": 161.07525634765625, + "y": 185.12020874023438, + "pressure": 2020, + "timestamp": 1774179070050 + }, + { + "x": 161.07525634765625, + "y": 185.12020874023438, + "pressure": 2026, + "timestamp": 1774179070052 + }, + { + "x": 161.07525634765625, + "y": 185.12020874023438, + "pressure": 2029, + "timestamp": 1774179070053 + }, + { + "x": 161.07525634765625, + "y": 185.12020874023438, + "pressure": 2032, + "timestamp": 1774179070059 + }, + { + "x": 161.07525634765625, + "y": 185.12020874023438, + "pressure": 2051, + "timestamp": 1774179070061 + }, + { + "x": 161.07525634765625, + "y": 185.12020874023438, + "pressure": 2060, + "timestamp": 1774179070063 + }, + { + "x": 161.07525634765625, + "y": 185.12020874023438, + "pressure": 2065, + "timestamp": 1774179070064 + }, + { + "x": 161.07525634765625, + "y": 185.12020874023438, + "pressure": 2068, + "timestamp": 1774179070068 + }, + { + "x": 161.07525634765625, + "y": 185.12020874023438, + "pressure": 2071, + "timestamp": 1774179070071 + }, + { + "x": 161.07525634765625, + "y": 185.12020874023438, + "pressure": 2065, + "timestamp": 1774179070093 + }, + { + "x": 161.07525634765625, + "y": 185.12020874023438, + "pressure": 2062, + "timestamp": 1774179070095 + }, + { + "x": 161.07525634765625, + "y": 185.12020874023438, + "pressure": 2065, + "timestamp": 1774179070111 + }, + { + "x": 161.07525634765625, + "y": 185.12020874023438, + "pressure": 2089, + "timestamp": 1774179070125 + }, + { + "x": 161.07525634765625, + "y": 185.12020874023438, + "pressure": 2101, + "timestamp": 1774179070127 + }, + { + "x": 160.67901611328125, + "y": 185.31829833984375, + "pressure": 2107, + "timestamp": 1774179070129 + }, + { + "x": 159.88653564453125, + "y": 185.51638793945312, + "pressure": 2110, + "timestamp": 1774179070130 + }, + { + "x": 159.49029541015625, + "y": 185.51638793945312, + "pressure": 2111, + "timestamp": 1774179070132 + }, + { + "x": 159.49029541015625, + "y": 185.51638793945312, + "pressure": 2114, + "timestamp": 1774179070136 + }, + { + "x": 159.49029541015625, + "y": 185.51638793945312, + "pressure": 2102, + "timestamp": 1774179070141 + }, + { + "x": 159.09405517578125, + "y": 185.71450805664062, + "pressure": 2096, + "timestamp": 1774179070143 + }, + { + "x": 158.301513671875, + "y": 185.91259765625, + "pressure": 2093, + "timestamp": 1774179070145 + }, + { + "x": 157.9052734375, + "y": 185.91259765625, + "pressure": 2092, + "timestamp": 1774179070146 + }, + { + "x": 157.9052734375, + "y": 185.91259765625, + "pressure": 2084, + "timestamp": 1774179070157 + }, + { + "x": 157.9052734375, + "y": 185.91259765625, + "pressure": 2081, + "timestamp": 1774179070159 + }, + { + "x": 157.9052734375, + "y": 185.91259765625, + "pressure": 2078, + "timestamp": 1774179070162 + }, + { + "x": 157.9052734375, + "y": 185.91259765625, + "pressure": 2062, + "timestamp": 1774179070174 + }, + { + "x": 157.9052734375, + "y": 185.91259765625, + "pressure": 2053, + "timestamp": 1774179070176 + }, + { + "x": 157.9052734375, + "y": 185.91259765625, + "pressure": 2048, + "timestamp": 1774179070177 + }, + { + "x": 157.9052734375, + "y": 185.91259765625, + "pressure": 2045, + "timestamp": 1774179070180 + }, + { + "x": 157.9052734375, + "y": 185.91259765625, + "pressure": 2003, + "timestamp": 1774179070189 + }, + { + "x": 157.9052734375, + "y": 185.91259765625, + "pressure": 1982, + "timestamp": 1774179070191 + }, + { + "x": 157.9052734375, + "y": 185.91259765625, + "pressure": 1971, + "timestamp": 1774179070193 + }, + { + "x": 157.9052734375, + "y": 185.91259765625, + "pressure": 1966, + "timestamp": 1774179070194 + }, + { + "x": 157.9052734375, + "y": 185.91259765625, + "pressure": 1963, + "timestamp": 1774179070197 + }, + { + "x": 157.9052734375, + "y": 185.91259765625, + "pressure": 1959, + "timestamp": 1774179070205 + }, + { + "x": 157.9052734375, + "y": 185.91259765625, + "pressure": 1956, + "timestamp": 1774179070209 + }, + { + "x": 157.9052734375, + "y": 185.91259765625, + "pressure": 1968, + "timestamp": 1774179070221 + }, + { + "x": 157.9052734375, + "y": 185.91259765625, + "pressure": 1974, + "timestamp": 1774179070223 + }, + { + "x": 157.9052734375, + "y": 185.91259765625, + "pressure": 1977, + "timestamp": 1774179070225 + }, + { + "x": 157.9052734375, + "y": 185.91259765625, + "pressure": 1981, + "timestamp": 1774179070230 + }, + { + "x": 157.9052734375, + "y": 185.91259765625, + "pressure": 1978, + "timestamp": 1774179070241 + }, + { + "x": 158.301513671875, + "y": 186.1107177734375, + "pressure": 1979, + "timestamp": 1774179070244 + }, + { + "x": 159.09405517578125, + "y": 186.30880737304688, + "pressure": 1978, + "timestamp": 1774179070246 + }, + { + "x": 159.88653564453125, + "y": 186.50689697265625, + "pressure": 1980, + "timestamp": 1774179070248 + }, + { + "x": 160.67901611328125, + "y": 186.70501708984375, + "pressure": 1979, + "timestamp": 1774179070252 + }, + { + "x": 161.4715576171875, + "y": 186.70501708984375, + "pressure": 1981, + "timestamp": 1774179070253 + }, + { + "x": 162.2640380859375, + "y": 186.70501708984375, + "pressure": 1980, + "timestamp": 1774179070255 + }, + { + "x": 163.0565185546875, + "y": 186.70501708984375, + "pressure": 1982, + "timestamp": 1774179070257 + }, + { + "x": 163.8489990234375, + "y": 186.70501708984375, + "pressure": 1981, + "timestamp": 1774179070258 + }, + { + "x": 164.64154052734375, + "y": 186.70501708984375, + "pressure": 1980, + "timestamp": 1774179070261 + }, + { + "x": 165.43402099609375, + "y": 186.50689697265625, + "pressure": 1982, + "timestamp": 1774179070262 + }, + { + "x": 166.22650146484375, + "y": 186.50689697265625, + "pressure": 1981, + "timestamp": 1774179070264 + }, + { + "x": 167.01904296875, + "y": 186.50689697265625, + "pressure": 1980, + "timestamp": 1774179070268 + }, + { + "x": 167.415283203125, + "y": 186.30880737304688, + "pressure": 1995, + "timestamp": 1774179070269 + }, + { + "x": 167.415283203125, + "y": 186.30880737304688, + "pressure": 2003, + "timestamp": 1774179070271 + }, + { + "x": 167.8115234375, + "y": 186.30880737304688, + "pressure": 2007, + "timestamp": 1774179070273 + }, + { + "x": 168.60400390625, + "y": 186.1107177734375, + "pressure": 2009, + "timestamp": 1774179070275 + }, + { + "x": 169.396484375, + "y": 185.91259765625, + "pressure": 2010, + "timestamp": 1774179070277 + }, + { + "x": 170.18902587890625, + "y": 185.51638793945312, + "pressure": 2012, + "timestamp": 1774179070278 + }, + { + "x": 170.98150634765625, + "y": 185.12020874023438, + "pressure": 2011, + "timestamp": 1774179070281 + }, + { + "x": 171.77398681640625, + "y": 184.7239990234375, + "pressure": 2013, + "timestamp": 1774179070284 + }, + { + "x": 172.5665283203125, + "y": 184.32778930664062, + "pressure": 2018, + "timestamp": 1774179070285 + }, + { + "x": 173.3590087890625, + "y": 183.93161010742188, + "pressure": 2021, + "timestamp": 1774179070288 + }, + { + "x": 173.7552490234375, + "y": 183.73348999023438, + "pressure": 2022, + "timestamp": 1774179070289 + }, + { + "x": 174.1514892578125, + "y": 183.535400390625, + "pressure": 2025, + "timestamp": 1774179070293 + }, + { + "x": 174.9439697265625, + "y": 182.94110107421875, + "pressure": 2024, + "timestamp": 1774179070294 + }, + { + "x": 175.73651123046875, + "y": 182.3468017578125, + "pressure": 2026, + "timestamp": 1774179070296 + }, + { + "x": 176.52899169921875, + "y": 181.75250244140625, + "pressure": 2025, + "timestamp": 1774179070300 + }, + { + "x": 177.32147216796875, + "y": 181.15817260742188, + "pressure": 2028, + "timestamp": 1774179070301 + }, + { + "x": 177.71771240234375, + "y": 180.9600830078125, + "pressure": 2029, + "timestamp": 1774179070304 + }, + { + "x": 178.114013671875, + "y": 180.76199340820312, + "pressure": 2032, + "timestamp": 1774179070307 + }, + { + "x": 178.906494140625, + "y": 180.16769409179688, + "pressure": 2031, + "timestamp": 1774179070309 + }, + { + "x": 179.302734375, + "y": 179.771484375, + "pressure": 2033, + "timestamp": 1774179070310 + }, + { + "x": 179.698974609375, + "y": 179.57339477539062, + "pressure": 2032, + "timestamp": 1774179070312 + }, + { + "x": 180.491455078125, + "y": 178.97906494140625, + "pressure": 2031, + "timestamp": 1774179070316 + }, + { + "x": 181.28399658203125, + "y": 178.384765625, + "pressure": 2033, + "timestamp": 1774179070317 + }, + { + "x": 181.68023681640625, + "y": 177.98858642578125, + "pressure": 2034, + "timestamp": 1774179070320 + }, + { + "x": 181.87835693359375, + "y": 177.59237670898438, + "pressure": 2035, + "timestamp": 1774179070323 + }, + { + "x": 182.47271728515625, + "y": 176.79995727539062, + "pressure": 2037, + "timestamp": 1774179070325 + }, + { + "x": 183.06707763671875, + "y": 176.007568359375, + "pressure": 2036, + "timestamp": 1774179070326 + }, + { + "x": 183.46337890625, + "y": 175.61135864257812, + "pressure": 2035, + "timestamp": 1774179070328 + }, + { + "x": 183.46337890625, + "y": 175.61135864257812, + "pressure": 2039, + "timestamp": 1774179070333 + }, + { + "x": 183.6614990234375, + "y": 175.21514892578125, + "pressure": 2040, + "timestamp": 1774179070336 + }, + { + "x": 184.255859375, + "y": 174.42276000976562, + "pressure": 2041, + "timestamp": 1774179070337 + }, + { + "x": 184.8502197265625, + "y": 173.63037109375, + "pressure": 2043, + "timestamp": 1774179070339 + }, + { + "x": 185.444580078125, + "y": 172.83795166015625, + "pressure": 2042, + "timestamp": 1774179070341 + }, + { + "x": 185.8408203125, + "y": 172.44174194335938, + "pressure": 2044, + "timestamp": 1774179070342 + }, + { + "x": 185.8408203125, + "y": 172.44174194335938, + "pressure": 2041, + "timestamp": 1774179070349 + }, + { + "x": 186.23712158203125, + "y": 172.04556274414062, + "pressure": 2040, + "timestamp": 1774179070352 + }, + { + "x": 187.02960205078125, + "y": 171.25314331054688, + "pressure": 2042, + "timestamp": 1774179070354 + }, + { + "x": 187.42584228515625, + "y": 170.85693359375, + "pressure": 2041, + "timestamp": 1774179070355 + }, + { + "x": 187.42584228515625, + "y": 170.85693359375, + "pressure": 2037, + "timestamp": 1774179070366 + }, + { + "x": 187.62396240234375, + "y": 170.46075439453125, + "pressure": 2035, + "timestamp": 1774179070368 + }, + { + "x": 188.21832275390625, + "y": 169.6683349609375, + "pressure": 2034, + "timestamp": 1774179070369 + }, + { + "x": 188.61456298828125, + "y": 169.27215576171875, + "pressure": 2036, + "timestamp": 1774179070371 + }, + { + "x": 188.61456298828125, + "y": 169.27215576171875, + "pressure": 2048, + "timestamp": 1774179070398 + }, + { + "x": 188.61456298828125, + "y": 169.27215576171875, + "pressure": 2054, + "timestamp": 1774179070400 + }, + { + "x": 188.61456298828125, + "y": 169.27215576171875, + "pressure": 2057, + "timestamp": 1774179070402 + }, + { + "x": 188.61456298828125, + "y": 169.27215576171875, + "pressure": 2061, + "timestamp": 1774179070407 + }, + { + "x": 188.61456298828125, + "y": 169.27215576171875, + "pressure": 2077, + "timestamp": 1774179070414 + }, + { + "x": 188.61456298828125, + "y": 169.27215576171875, + "pressure": 2084, + "timestamp": 1774179070416 + }, + { + "x": 188.61456298828125, + "y": 169.27215576171875, + "pressure": 2088, + "timestamp": 1774179070417 + }, + { + "x": 188.61456298828125, + "y": 169.27215576171875, + "pressure": 2091, + "timestamp": 1774179070421 + }, + { + "x": 188.61456298828125, + "y": 169.27215576171875, + "pressure": 2094, + "timestamp": 1774179070428 + }, + { + "x": 188.61456298828125, + "y": 169.27215576171875, + "pressure": 2102, + "timestamp": 1774179070430 + }, + { + "x": 188.61456298828125, + "y": 169.27215576171875, + "pressure": 2106, + "timestamp": 1774179070432 + }, + { + "x": 188.61456298828125, + "y": 169.27215576171875, + "pressure": 2109, + "timestamp": 1774179070435 + }, + { + "x": 188.61456298828125, + "y": 169.27215576171875, + "pressure": 2112, + "timestamp": 1774179070439 + }, + { + "x": 188.61456298828125, + "y": 169.27215576171875, + "pressure": 2115, + "timestamp": 1774179070446 + }, + { + "x": 188.61456298828125, + "y": 169.6683349609375, + "pressure": 2114, + "timestamp": 1774179070453 + }, + { + "x": 188.812744140625, + "y": 170.46075439453125, + "pressure": 2113, + "timestamp": 1774179070455 + }, + { + "x": 189.0108642578125, + "y": 171.25314331054688, + "pressure": 2115, + "timestamp": 1774179070456 + }, + { + "x": 189.208984375, + "y": 172.04556274414062, + "pressure": 2114, + "timestamp": 1774179070460 + }, + { + "x": 189.4071044921875, + "y": 172.44174194335938, + "pressure": 2117, + "timestamp": 1774179070462 + }, + { + "x": 189.4071044921875, + "y": 172.83795166015625, + "pressure": 2118, + "timestamp": 1774179070464 + }, + { + "x": 189.4071044921875, + "y": 173.82846069335938, + "pressure": 2119, + "timestamp": 1774179070466 + }, + { + "x": 189.4071044921875, + "y": 174.8189697265625, + "pressure": 2121, + "timestamp": 1774179070467 + }, + { + "x": 189.208984375, + "y": 175.80947875976562, + "pressure": 2120, + "timestamp": 1774179070469 + }, + { + "x": 189.0108642578125, + "y": 176.79995727539062, + "pressure": 2122, + "timestamp": 1774179070471 + }, + { + "x": 188.812744140625, + "y": 177.79046630859375, + "pressure": 2121, + "timestamp": 1774179070472 + }, + { + "x": 188.61456298828125, + "y": 178.78097534179688, + "pressure": 2120, + "timestamp": 1774179070476 + }, + { + "x": 188.41644287109375, + "y": 179.771484375, + "pressure": 2126, + "timestamp": 1774179070478 + }, + { + "x": 188.21832275390625, + "y": 180.76199340820312, + "pressure": 2129, + "timestamp": 1774179070480 + }, + { + "x": 188.02020263671875, + "y": 181.75250244140625, + "pressure": 2130, + "timestamp": 1774179070482 + }, + { + "x": 187.82208251953125, + "y": 182.74298095703125, + "pressure": 2131, + "timestamp": 1774179070484 + }, + { + "x": 187.42584228515625, + "y": 183.73348999023438, + "pressure": 2133, + "timestamp": 1774179070485 + }, + { + "x": 187.02960205078125, + "y": 184.92208862304688, + "pressure": 2132, + "timestamp": 1774179070487 + }, + { + "x": 186.63336181640625, + "y": 186.1107177734375, + "pressure": 2134, + "timestamp": 1774179070488 + }, + { + "x": 186.23712158203125, + "y": 187.29931640625, + "pressure": 2133, + "timestamp": 1774179070492 + }, + { + "x": 185.8408203125, + "y": 188.4879150390625, + "pressure": 2137, + "timestamp": 1774179070494 + }, + { + "x": 185.444580078125, + "y": 189.676513671875, + "pressure": 2139, + "timestamp": 1774179070496 + }, + { + "x": 185.04833984375, + "y": 190.8651123046875, + "pressure": 2140, + "timestamp": 1774179070498 + }, + { + "x": 184.652099609375, + "y": 192.05374145507812, + "pressure": 2142, + "timestamp": 1774179070499 + }, + { + "x": 184.255859375, + "y": 193.24234008789062, + "pressure": 2141, + "timestamp": 1774179070501 + }, + { + "x": 183.6614990234375, + "y": 194.43093872070312, + "pressure": 2143, + "timestamp": 1774179070503 + }, + { + "x": 183.06707763671875, + "y": 195.42144775390625, + "pressure": 2142, + "timestamp": 1774179070504 + }, + { + "x": 182.67083740234375, + "y": 196.41195678710938, + "pressure": 2141, + "timestamp": 1774179070508 + }, + { + "x": 182.07647705078125, + "y": 197.60055541992188, + "pressure": 2145, + "timestamp": 1774179070510 + }, + { + "x": 181.48211669921875, + "y": 198.78915405273438, + "pressure": 2147, + "timestamp": 1774179070512 + }, + { + "x": 181.08587646484375, + "y": 199.7796630859375, + "pressure": 2148, + "timestamp": 1774179070514 + }, + { + "x": 180.68963623046875, + "y": 200.77017211914062, + "pressure": 2150, + "timestamp": 1774179070515 + }, + { + "x": 180.2933349609375, + "y": 201.56256103515625, + "pressure": 2149, + "timestamp": 1774179070517 + }, + { + "x": 179.698974609375, + "y": 202.35494995117188, + "pressure": 2151, + "timestamp": 1774179070519 + }, + { + "x": 179.1046142578125, + "y": 203.14736938476562, + "pressure": 2150, + "timestamp": 1774179070523 + }, + { + "x": 178.7083740234375, + "y": 203.93975830078125, + "pressure": 2149, + "timestamp": 1774179070525 + }, + { + "x": 178.51025390625, + "y": 204.33596801757812, + "pressure": 2150, + "timestamp": 1774179070526 + }, + { + "x": 178.3121337890625, + "y": 204.732177734375, + "pressure": 2152, + "timestamp": 1774179070528 + }, + { + "x": 177.71771240234375, + "y": 205.52456665039062, + "pressure": 2151, + "timestamp": 1774179070530 + }, + { + "x": 177.51959228515625, + "y": 205.9207763671875, + "pressure": 2153, + "timestamp": 1774179070531 + }, + { + "x": 177.12335205078125, + "y": 206.31698608398438, + "pressure": 2153, + "timestamp": 1774179070537 + }, + { + "x": 176.52899169921875, + "y": 207.109375, + "pressure": 2152, + "timestamp": 1774179070540 + }, + { + "x": 176.33087158203125, + "y": 207.50558471679688, + "pressure": 2154, + "timestamp": 1774179070542 + }, + { + "x": 175.93463134765625, + "y": 207.90179443359375, + "pressure": 2153, + "timestamp": 1774179070549 + }, + { + "x": 175.14215087890625, + "y": 208.69418334960938, + "pressure": 2155, + "timestamp": 1774179070551 + }, + { + "x": 174.745849609375, + "y": 209.09039306640625, + "pressure": 2154, + "timestamp": 1774179070552 + }, + { + "x": 174.745849609375, + "y": 209.09039306640625, + "pressure": 2145, + "timestamp": 1774179070560 + }, + { + "x": 174.349609375, + "y": 209.486572265625, + "pressure": 2141, + "timestamp": 1774179070560 + }, + { + "x": 173.55712890625, + "y": 210.08087158203125, + "pressure": 2139, + "timestamp": 1774179070562 + }, + { + "x": 172.7646484375, + "y": 210.47711181640625, + "pressure": 2138, + "timestamp": 1774179070564 + }, + { + "x": 172.368408203125, + "y": 210.6751708984375, + "pressure": 2140, + "timestamp": 1774179070565 + }, + { + "x": 171.97210693359375, + "y": 210.873291015625, + "pressure": 2121, + "timestamp": 1774179070574 + }, + { + "x": 171.17962646484375, + "y": 211.26947021484375, + "pressure": 2112, + "timestamp": 1774179070577 + }, + { + "x": 170.78338623046875, + "y": 211.46759033203125, + "pressure": 2107, + "timestamp": 1774179070578 + }, + { + "x": 170.78338623046875, + "y": 211.46759033203125, + "pressure": 2104, + "timestamp": 1774179070581 + }, + { + "x": 170.78338623046875, + "y": 211.46759033203125, + "pressure": 2065, + "timestamp": 1774179070590 + }, + { + "x": 170.78338623046875, + "y": 211.46759033203125, + "pressure": 2046, + "timestamp": 1774179070592 + }, + { + "x": 170.78338623046875, + "y": 211.46759033203125, + "pressure": 2036, + "timestamp": 1774179070594 + }, + { + "x": 170.78338623046875, + "y": 211.46759033203125, + "pressure": 2031, + "timestamp": 1774179070596 + }, + { + "x": 170.78338623046875, + "y": 211.46759033203125, + "pressure": 2028, + "timestamp": 1774179070599 + }, + { + "x": 170.78338623046875, + "y": 211.46759033203125, + "pressure": 1993, + "timestamp": 1774179070612 + }, + { + "x": 170.58526611328125, + "y": 211.0714111328125, + "pressure": 1975, + "timestamp": 1774179070612 + }, + { + "x": 170.38714599609375, + "y": 210.27899169921875, + "pressure": 1966, + "timestamp": 1774179070612 + }, + { + "x": 169.99090576171875, + "y": 209.486572265625, + "pressure": 1961, + "timestamp": 1774179070612 + }, + { + "x": 169.99090576171875, + "y": 208.69418334960938, + "pressure": 1959, + "timestamp": 1774179070613 + }, + { + "x": 169.99090576171875, + "y": 207.90179443359375, + "pressure": 1958, + "timestamp": 1774179070615 + }, + { + "x": 170.18902587890625, + "y": 207.109375, + "pressure": 1957, + "timestamp": 1774179070617 + }, + { + "x": 170.38714599609375, + "y": 206.11886596679688, + "pressure": 1959, + "timestamp": 1774179070622 + }, + { + "x": 170.58526611328125, + "y": 205.12838745117188, + "pressure": 1948, + "timestamp": 1774179070622 + }, + { + "x": 170.98150634765625, + "y": 204.13787841796875, + "pressure": 1943, + "timestamp": 1774179070625 + }, + { + "x": 171.37774658203125, + "y": 202.94927978515625, + "pressure": 1940, + "timestamp": 1774179070626 + }, + { + "x": 171.97210693359375, + "y": 201.76065063476562, + "pressure": 1939, + "timestamp": 1774179070628 + }, + { + "x": 172.5665283203125, + "y": 200.57205200195312, + "pressure": 1938, + "timestamp": 1774179070629 + }, + { + "x": 173.160888671875, + "y": 199.38345336914062, + "pressure": 1940, + "timestamp": 1774179070631 + }, + { + "x": 173.7552490234375, + "y": 198.19485473632812, + "pressure": 1939, + "timestamp": 1774179070633 + }, + { + "x": 174.349609375, + "y": 197.00625610351562, + "pressure": 1938, + "timestamp": 1774179070637 + }, + { + "x": 175.14215087890625, + "y": 195.817626953125, + "pressure": 1944, + "timestamp": 1774179070638 + }, + { + "x": 175.93463134765625, + "y": 194.23284912109375, + "pressure": 1947, + "timestamp": 1774179070641 + }, + { + "x": 176.72711181640625, + "y": 192.84613037109375, + "pressure": 1949, + "timestamp": 1774179070642 + }, + { + "x": 177.51959228515625, + "y": 191.45941162109375, + "pressure": 1950, + "timestamp": 1774179070644 + }, + { + "x": 178.51025390625, + "y": 190.07272338867188, + "pressure": 1952, + "timestamp": 1774179070645 + }, + { + "x": 179.5008544921875, + "y": 188.68600463867188, + "pressure": 1951, + "timestamp": 1774179070647 + }, + { + "x": 180.491455078125, + "y": 187.29931640625, + "pressure": 1953, + "timestamp": 1774179070649 + }, + { + "x": 181.48211669921875, + "y": 185.91259765625, + "pressure": 1952, + "timestamp": 1774179070653 + }, + { + "x": 182.47271728515625, + "y": 184.7239990234375, + "pressure": 1967, + "timestamp": 1774179070654 + }, + { + "x": 183.46337890625, + "y": 183.535400390625, + "pressure": 1975, + "timestamp": 1774179070657 + }, + { + "x": 184.4539794921875, + "y": 182.54489135742188, + "pressure": 1979, + "timestamp": 1774179070658 + }, + { + "x": 185.2464599609375, + "y": 181.55438232421875, + "pressure": 1981, + "timestamp": 1774179070659 + }, + { + "x": 186.03900146484375, + "y": 180.56387329101562, + "pressure": 1982, + "timestamp": 1774179070661 + }, + { + "x": 186.83148193359375, + "y": 179.57339477539062, + "pressure": 1984, + "timestamp": 1774179070663 + }, + { + "x": 187.62396240234375, + "y": 178.5828857421875, + "pressure": 1983, + "timestamp": 1774179070665 + }, + { + "x": 188.41644287109375, + "y": 177.79046630859375, + "pressure": 1985, + "timestamp": 1774179070669 + }, + { + "x": 189.208984375, + "y": 177.1961669921875, + "pressure": 1993, + "timestamp": 1774179070670 + }, + { + "x": 189.605224609375, + "y": 176.79995727539062, + "pressure": 1997, + "timestamp": 1774179070673 + }, + { + "x": 190.00146484375, + "y": 176.40377807617188, + "pressure": 2000, + "timestamp": 1774179070676 + }, + { + "x": 190.7939453125, + "y": 175.61135864257812, + "pressure": 2002, + "timestamp": 1774179070677 + }, + { + "x": 191.58648681640625, + "y": 175.01705932617188, + "pressure": 2001, + "timestamp": 1774179070679 + }, + { + "x": 191.98272705078125, + "y": 174.620849609375, + "pressure": 2003, + "timestamp": 1774179070681 + }, + { + "x": 191.98272705078125, + "y": 174.620849609375, + "pressure": 2000, + "timestamp": 1774179070686 + }, + { + "x": 192.37896728515625, + "y": 174.42276000976562, + "pressure": 1999, + "timestamp": 1774179070689 + }, + { + "x": 193.17144775390625, + "y": 173.82846069335938, + "pressure": 2001, + "timestamp": 1774179070690 + }, + { + "x": 193.96392822265625, + "y": 173.23416137695312, + "pressure": 2000, + "timestamp": 1774179070692 + }, + { + "x": 194.7564697265625, + "y": 172.63986206054688, + "pressure": 1999, + "timestamp": 1774179070693 + }, + { + "x": 195.1527099609375, + "y": 172.44174194335938, + "pressure": 2001, + "timestamp": 1774179070695 + }, + { + "x": 195.5489501953125, + "y": 172.24365234375, + "pressure": 1999, + "timestamp": 1774179070702 + }, + { + "x": 196.3414306640625, + "y": 171.64935302734375, + "pressure": 2000, + "timestamp": 1774179070702 + }, + { + "x": 197.13397216796875, + "y": 171.25314331054688, + "pressure": 2002, + "timestamp": 1774179070705 + }, + { + "x": 197.53021240234375, + "y": 171.0550537109375, + "pressure": 2001, + "timestamp": 1774179070706 + }, + { + "x": 197.92645263671875, + "y": 170.85693359375, + "pressure": 2002, + "timestamp": 1774179070709 + }, + { + "x": 198.71893310546875, + "y": 170.65884399414062, + "pressure": 2001, + "timestamp": 1774179070712 + }, + { + "x": 199.51141357421875, + "y": 170.46075439453125, + "pressure": 2003, + "timestamp": 1774179070713 + }, + { + "x": 200.303955078125, + "y": 170.26263427734375, + "pressure": 2002, + "timestamp": 1774179070717 + }, + { + "x": 200.7001953125, + "y": 170.06454467773438, + "pressure": 1997, + "timestamp": 1774179070718 + }, + { + "x": 200.7001953125, + "y": 170.06454467773438, + "pressure": 1994, + "timestamp": 1774179070722 + }, + { + "x": 201.096435546875, + "y": 170.06454467773438, + "pressure": 1995, + "timestamp": 1774179070726 + }, + { + "x": 201.888916015625, + "y": 169.866455078125, + "pressure": 1994, + "timestamp": 1774179070727 + }, + { + "x": 202.28515625, + "y": 169.6683349609375, + "pressure": 1993, + "timestamp": 1774179070729 + }, + { + "x": 202.28515625, + "y": 169.6683349609375, + "pressure": 1990, + "timestamp": 1774179070738 + }, + { + "x": 202.28515625, + "y": 169.6683349609375, + "pressure": 2009, + "timestamp": 1774179070751 + }, + { + "x": 202.28515625, + "y": 169.6683349609375, + "pressure": 2018, + "timestamp": 1774179070753 + }, + { + "x": 202.28515625, + "y": 169.6683349609375, + "pressure": 2023, + "timestamp": 1774179070754 + }, + { + "x": 202.28515625, + "y": 169.6683349609375, + "pressure": 2026, + "timestamp": 1774179070758 + }, + { + "x": 202.28515625, + "y": 169.6683349609375, + "pressure": 2029, + "timestamp": 1774179070761 + }, + { + "x": 202.28515625, + "y": 169.6683349609375, + "pressure": 2041, + "timestamp": 1774179070767 + }, + { + "x": 202.48333740234375, + "y": 170.06454467773438, + "pressure": 2048, + "timestamp": 1774179070769 + }, + { + "x": 203.07769775390625, + "y": 170.85693359375, + "pressure": 2051, + "timestamp": 1774179070771 + }, + { + "x": 203.47393798828125, + "y": 171.64935302734375, + "pressure": 2053, + "timestamp": 1774179070772 + }, + { + "x": 203.67205810546875, + "y": 172.04556274414062, + "pressure": 2054, + "timestamp": 1774179070774 + }, + { + "x": 203.67205810546875, + "y": 172.44174194335938, + "pressure": 2057, + "timestamp": 1774179070781 + }, + { + "x": 203.87017822265625, + "y": 173.4322509765625, + "pressure": 2058, + "timestamp": 1774179070783 + }, + { + "x": 204.06829833984375, + "y": 174.22467041015625, + "pressure": 2059, + "timestamp": 1774179070785 + }, + { + "x": 204.06829833984375, + "y": 174.620849609375, + "pressure": 2061, + "timestamp": 1774179070787 + }, + { + "x": 203.87017822265625, + "y": 175.01705932617188, + "pressure": 2061, + "timestamp": 1774179070791 + }, + { + "x": 203.67205810546875, + "y": 175.80947875976562, + "pressure": 2060, + "timestamp": 1774179070793 + }, + { + "x": 203.67205810546875, + "y": 176.20565795898438, + "pressure": 2062, + "timestamp": 1774179070797 + }, + { + "x": 203.67205810546875, + "y": 176.20565795898438, + "pressure": 2065, + "timestamp": 1774179070801 + }, + { + "x": 203.47393798828125, + "y": 176.60186767578125, + "pressure": 2065, + "timestamp": 1774179070806 + }, + { + "x": 203.27581787109375, + "y": 177.394287109375, + "pressure": 2064, + "timestamp": 1774179070808 + }, + { + "x": 203.07769775390625, + "y": 177.79046630859375, + "pressure": 2066, + "timestamp": 1774179070809 + }, + { + "x": 203.07769775390625, + "y": 177.79046630859375, + "pressure": 2071, + "timestamp": 1774179070815 + }, + { + "x": 203.07769775390625, + "y": 177.79046630859375, + "pressure": 2074, + "timestamp": 1774179070817 + }, + { + "x": 202.68145751953125, + "y": 178.18667602539062, + "pressure": 2077, + "timestamp": 1774179070820 + }, + { + "x": 202.0870361328125, + "y": 178.97906494140625, + "pressure": 2079, + "timestamp": 1774179070822 + }, + { + "x": 201.888916015625, + "y": 179.37527465820312, + "pressure": 2078, + "timestamp": 1774179070824 + }, + { + "x": 201.888916015625, + "y": 179.37527465820312, + "pressure": 2082, + "timestamp": 1774179070831 + }, + { + "x": 201.888916015625, + "y": 179.37527465820312, + "pressure": 2085, + "timestamp": 1774179070835 + }, + { + "x": 201.49267578125, + "y": 179.771484375, + "pressure": 2087, + "timestamp": 1774179070836 + }, + { + "x": 200.5020751953125, + "y": 180.36578369140625, + "pressure": 2086, + "timestamp": 1774179070838 + }, + { + "x": 199.7095947265625, + "y": 181.15817260742188, + "pressure": 2088, + "timestamp": 1774179070840 + }, + { + "x": 198.91705322265625, + "y": 181.55438232421875, + "pressure": 2087, + "timestamp": 1774179070841 + }, + { + "x": 198.52081298828125, + "y": 181.75250244140625, + "pressure": 2086, + "timestamp": 1774179070845 + }, + { + "x": 198.52081298828125, + "y": 181.75250244140625, + "pressure": 2076, + "timestamp": 1774179070847 + }, + { + "x": 198.12457275390625, + "y": 181.95059204101562, + "pressure": 2071, + "timestamp": 1774179070849 + }, + { + "x": 197.33209228515625, + "y": 182.3468017578125, + "pressure": 2069, + "timestamp": 1774179070851 + }, + { + "x": 196.53955078125, + "y": 182.74298095703125, + "pressure": 2068, + "timestamp": 1774179070852 + }, + { + "x": 195.7470703125, + "y": 183.13919067382812, + "pressure": 2067, + "timestamp": 1774179070854 + }, + { + "x": 195.350830078125, + "y": 183.13919067382812, + "pressure": 2069, + "timestamp": 1774179070855 + }, + { + "x": 194.95458984375, + "y": 183.3372802734375, + "pressure": 2067, + "timestamp": 1774179070861 + }, + { + "x": 194.16204833984375, + "y": 183.535400390625, + "pressure": 2057, + "timestamp": 1774179070863 + }, + { + "x": 193.36956787109375, + "y": 183.73348999023438, + "pressure": 2052, + "timestamp": 1774179070865 + }, + { + "x": 192.97332763671875, + "y": 183.73348999023438, + "pressure": 2050, + "timestamp": 1774179070867 + }, + { + "x": 192.57708740234375, + "y": 183.73348999023438, + "pressure": 2049, + "timestamp": 1774179070873 + }, + { + "x": 191.78460693359375, + "y": 183.73348999023438, + "pressure": 2048, + "timestamp": 1774179070877 + }, + { + "x": 191.3883056640625, + "y": 183.73348999023438, + "pressure": 2029, + "timestamp": 1774179070879 + }, + { + "x": 191.3883056640625, + "y": 183.73348999023438, + "pressure": 2020, + "timestamp": 1774179070881 + }, + { + "x": 191.3883056640625, + "y": 183.73348999023438, + "pressure": 2015, + "timestamp": 1774179070883 + }, + { + "x": 191.3883056640625, + "y": 183.73348999023438, + "pressure": 2012, + "timestamp": 1774179070886 + }, + { + "x": 190.9920654296875, + "y": 183.73348999023438, + "pressure": 2013, + "timestamp": 1774179070889 + }, + { + "x": 190.1995849609375, + "y": 183.535400390625, + "pressure": 2012, + "timestamp": 1774179070893 + }, + { + "x": 189.8033447265625, + "y": 183.3372802734375, + "pressure": 1972, + "timestamp": 1774179070895 + }, + { + "x": 189.8033447265625, + "y": 183.3372802734375, + "pressure": 1952, + "timestamp": 1774179070897 + }, + { + "x": 189.8033447265625, + "y": 183.3372802734375, + "pressure": 1942, + "timestamp": 1774179070899 + }, + { + "x": 189.8033447265625, + "y": 183.3372802734375, + "pressure": 1937, + "timestamp": 1774179070900 + }, + { + "x": 189.8033447265625, + "y": 183.3372802734375, + "pressure": 1934, + "timestamp": 1774179070902 + }, + { + "x": 189.8033447265625, + "y": 183.3372802734375, + "pressure": 1889, + "timestamp": 1774179070911 + }, + { + "x": 189.8033447265625, + "y": 183.3372802734375, + "pressure": 1867, + "timestamp": 1774179070913 + }, + { + "x": 189.8033447265625, + "y": 183.3372802734375, + "pressure": 1856, + "timestamp": 1774179070915 + }, + { + "x": 189.8033447265625, + "y": 183.3372802734375, + "pressure": 1850, + "timestamp": 1774179070916 + }, + { + "x": 189.8033447265625, + "y": 183.3372802734375, + "pressure": 1847, + "timestamp": 1774179070918 + }, + { + "x": 189.8033447265625, + "y": 183.3372802734375, + "pressure": 1809, + "timestamp": 1774179070927 + }, + { + "x": 189.8033447265625, + "y": 183.3372802734375, + "pressure": 1790, + "timestamp": 1774179070929 + }, + { + "x": 189.8033447265625, + "y": 183.3372802734375, + "pressure": 1781, + "timestamp": 1774179070931 + }, + { + "x": 189.8033447265625, + "y": 183.3372802734375, + "pressure": 1776, + "timestamp": 1774179070932 + }, + { + "x": 189.8033447265625, + "y": 183.3372802734375, + "pressure": 1773, + "timestamp": 1774179070936 + }, + { + "x": 189.8033447265625, + "y": 183.3372802734375, + "pressure": 1760, + "timestamp": 1774179070943 + }, + { + "x": 189.8033447265625, + "y": 183.3372802734375, + "pressure": 1753, + "timestamp": 1774179070946 + }, + { + "x": 189.8033447265625, + "y": 183.3372802734375, + "pressure": 1750, + "timestamp": 1774179070947 + }, + { + "x": 189.8033447265625, + "y": 183.3372802734375, + "pressure": 1747, + "timestamp": 1774179070950 + }, + { + "x": 189.8033447265625, + "y": 183.3372802734375, + "pressure": 1750, + "timestamp": 1774179070961 + }, + { + "x": 189.8033447265625, + "y": 183.3372802734375, + "pressure": 1763, + "timestamp": 1774179070975 + }, + { + "x": 189.8033447265625, + "y": 183.3372802734375, + "pressure": 1770, + "timestamp": 1774179070978 + }, + { + "x": 189.8033447265625, + "y": 183.3372802734375, + "pressure": 1773, + "timestamp": 1774179070979 + }, + { + "x": 190.1995849609375, + "y": 183.3372802734375, + "pressure": 1775, + "timestamp": 1774179070980 + }, + { + "x": 190.9920654296875, + "y": 183.13919067382812, + "pressure": 1776, + "timestamp": 1774179070982 + }, + { + "x": 191.78460693359375, + "y": 182.94110107421875, + "pressure": 1778, + "timestamp": 1774179070984 + }, + { + "x": 192.57708740234375, + "y": 182.74298095703125, + "pressure": 1777, + "timestamp": 1774179070986 + }, + { + "x": 192.97332763671875, + "y": 182.54489135742188, + "pressure": 1779, + "timestamp": 1774179070990 + }, + { + "x": 192.97332763671875, + "y": 182.54489135742188, + "pressure": 1792, + "timestamp": 1774179070991 + }, + { + "x": 193.36956787109375, + "y": 182.54489135742188, + "pressure": 1799, + "timestamp": 1774179070993 + }, + { + "x": 194.16204833984375, + "y": 182.3468017578125, + "pressure": 1802, + "timestamp": 1774179070995 + }, + { + "x": 194.95458984375, + "y": 182.148681640625, + "pressure": 1804, + "timestamp": 1774179070996 + }, + { + "x": 195.7470703125, + "y": 181.95059204101562, + "pressure": 1805, + "timestamp": 1774179070998 + }, + { + "x": 196.53955078125, + "y": 181.75250244140625, + "pressure": 1807, + "timestamp": 1774179071000 + }, + { + "x": 197.33209228515625, + "y": 181.55438232421875, + "pressure": 1806, + "timestamp": 1774179071002 + }, + { + "x": 198.12457275390625, + "y": 181.55438232421875, + "pressure": 1808, + "timestamp": 1774179071006 + }, + { + "x": 198.91705322265625, + "y": 181.55438232421875, + "pressure": 1814, + "timestamp": 1774179071007 + }, + { + "x": 199.7095947265625, + "y": 181.55438232421875, + "pressure": 1817, + "timestamp": 1774179071009 + }, + { + "x": 200.5020751953125, + "y": 181.35629272460938, + "pressure": 1818, + "timestamp": 1774179071011 + }, + { + "x": 201.2945556640625, + "y": 181.15817260742188, + "pressure": 1819, + "timestamp": 1774179071013 + }, + { + "x": 202.0870361328125, + "y": 180.9600830078125, + "pressure": 1821, + "timestamp": 1774179071014 + }, + { + "x": 202.48333740234375, + "y": 180.76199340820312, + "pressure": 1820, + "timestamp": 1774179071016 + }, + { + "x": 202.87957763671875, + "y": 180.76199340820312, + "pressure": 1821, + "timestamp": 1774179071022 + }, + { + "x": 203.67205810546875, + "y": 180.56387329101562, + "pressure": 1824, + "timestamp": 1774179071023 + }, + { + "x": 204.46453857421875, + "y": 180.36578369140625, + "pressure": 1826, + "timestamp": 1774179071025 + }, + { + "x": 205.257080078125, + "y": 180.16769409179688, + "pressure": 1827, + "timestamp": 1774179071027 + }, + { + "x": 206.049560546875, + "y": 179.96957397460938, + "pressure": 1829, + "timestamp": 1774179071029 + }, + { + "x": 206.842041015625, + "y": 179.771484375, + "pressure": 1828, + "timestamp": 1774179071030 + }, + { + "x": 207.634521484375, + "y": 179.57339477539062, + "pressure": 1830, + "timestamp": 1774179071032 + }, + { + "x": 208.42706298828125, + "y": 179.37527465820312, + "pressure": 1829, + "timestamp": 1774179071034 + }, + { + "x": 209.21954345703125, + "y": 179.17718505859375, + "pressure": 1828, + "timestamp": 1774179071038 + }, + { + "x": 210.01202392578125, + "y": 178.97906494140625, + "pressure": 1831, + "timestamp": 1774179071039 + }, + { + "x": 210.8045654296875, + "y": 178.5828857421875, + "pressure": 1832, + "timestamp": 1774179071042 + }, + { + "x": 211.5970458984375, + "y": 178.384765625, + "pressure": 1833, + "timestamp": 1774179071043 + }, + { + "x": 212.3895263671875, + "y": 178.18667602539062, + "pressure": 1835, + "timestamp": 1774179071045 + }, + { + "x": 213.1820068359375, + "y": 177.79046630859375, + "pressure": 1834, + "timestamp": 1774179071046 + }, + { + "x": 213.97454833984375, + "y": 177.394287109375, + "pressure": 1836, + "timestamp": 1774179071048 + }, + { + "x": 214.37078857421875, + "y": 177.1961669921875, + "pressure": 1835, + "timestamp": 1774179071050 + }, + { + "x": 214.76702880859375, + "y": 176.99807739257812, + "pressure": 1832, + "timestamp": 1774179071055 + }, + { + "x": 215.55950927734375, + "y": 176.60186767578125, + "pressure": 1831, + "timestamp": 1774179071058 + }, + { + "x": 216.35205078125, + "y": 176.20565795898438, + "pressure": 1830, + "timestamp": 1774179071059 + }, + { + "x": 217.14453125, + "y": 175.80947875976562, + "pressure": 1832, + "timestamp": 1774179071061 + }, + { + "x": 217.93701171875, + "y": 175.41326904296875, + "pressure": 1831, + "timestamp": 1774179071062 + }, + { + "x": 218.7294921875, + "y": 175.01705932617188, + "pressure": 1830, + "timestamp": 1774179071064 + }, + { + "x": 219.52203369140625, + "y": 174.620849609375, + "pressure": 1832, + "timestamp": 1774179071066 + }, + { + "x": 220.31451416015625, + "y": 174.22467041015625, + "pressure": 1831, + "timestamp": 1774179071070 + }, + { + "x": 221.10699462890625, + "y": 174.02655029296875, + "pressure": 1832, + "timestamp": 1774179071071 + }, + { + "x": 221.8995361328125, + "y": 173.63037109375, + "pressure": 1833, + "timestamp": 1774179071074 + }, + { + "x": 222.2957763671875, + "y": 173.4322509765625, + "pressure": 1835, + "timestamp": 1774179071075 + }, + { + "x": 222.6920166015625, + "y": 173.23416137695312, + "pressure": 1835, + "timestamp": 1774179071080 + }, + { + "x": 223.4844970703125, + "y": 172.63986206054688, + "pressure": 1834, + "timestamp": 1774179071082 + }, + { + "x": 224.27703857421875, + "y": 172.44174194335938, + "pressure": 1836, + "timestamp": 1774179071086 + }, + { + "x": 224.67327880859375, + "y": 172.24365234375, + "pressure": 1831, + "timestamp": 1774179071087 + }, + { + "x": 224.67327880859375, + "y": 172.24365234375, + "pressure": 1828, + "timestamp": 1774179071090 + }, + { + "x": 225.06951904296875, + "y": 172.04556274414062, + "pressure": 1826, + "timestamp": 1774179071093 + }, + { + "x": 225.86199951171875, + "y": 171.45126342773438, + "pressure": 1828, + "timestamp": 1774179071095 + }, + { + "x": 226.25823974609375, + "y": 171.25314331054688, + "pressure": 1827, + "timestamp": 1774179071096 + }, + { + "x": 226.25823974609375, + "y": 171.25314331054688, + "pressure": 1831, + "timestamp": 1774179071103 + }, + { + "x": 226.65447998046875, + "y": 171.0550537109375, + "pressure": 1835, + "timestamp": 1774179071109 + }, + { + "x": 227.447021484375, + "y": 170.46075439453125, + "pressure": 1834, + "timestamp": 1774179071110 + }, + { + "x": 227.84326171875, + "y": 170.06454467773438, + "pressure": 1836, + "timestamp": 1774179071112 + }, + { + "x": 227.84326171875, + "y": 170.06454467773438, + "pressure": 1839, + "timestamp": 1774179071123 + }, + { + "x": 227.84326171875, + "y": 170.06454467773438, + "pressure": 1855, + "timestamp": 1774179071136 + }, + { + "x": 227.84326171875, + "y": 170.06454467773438, + "pressure": 1862, + "timestamp": 1774179071138 + }, + { + "x": 227.84326171875, + "y": 170.06454467773438, + "pressure": 1866, + "timestamp": 1774179071139 + }, + { + "x": 227.84326171875, + "y": 170.06454467773438, + "pressure": 1869, + "timestamp": 1774179071143 + }, + { + "x": 227.84326171875, + "y": 170.06454467773438, + "pressure": 1872, + "timestamp": 1774179071150 + }, + { + "x": 227.84326171875, + "y": 170.06454467773438, + "pressure": 1913, + "timestamp": 1774179071152 + }, + { + "x": 227.84326171875, + "y": 170.06454467773438, + "pressure": 1933, + "timestamp": 1774179071154 + }, + { + "x": 227.84326171875, + "y": 170.06454467773438, + "pressure": 1943, + "timestamp": 1774179071155 + }, + { + "x": 227.84326171875, + "y": 170.06454467773438, + "pressure": 1948, + "timestamp": 1774179071157 + }, + { + "x": 227.84326171875, + "y": 170.06454467773438, + "pressure": 1951, + "timestamp": 1774179071159 + }, + { + "x": 227.84326171875, + "y": 170.06454467773438, + "pressure": 1955, + "timestamp": 1774179071166 + }, + { + "x": 227.84326171875, + "y": 170.06454467773438, + "pressure": 2016, + "timestamp": 1774179071168 + }, + { + "x": 227.84326171875, + "y": 170.06454467773438, + "pressure": 2046, + "timestamp": 1774179071170 + }, + { + "x": 227.84326171875, + "y": 170.06454467773438, + "pressure": 2061, + "timestamp": 1774179071172 + }, + { + "x": 227.84326171875, + "y": 170.06454467773438, + "pressure": 2069, + "timestamp": 1774179071173 + }, + { + "x": 227.84326171875, + "y": 170.06454467773438, + "pressure": 2073, + "timestamp": 1774179071175 + }, + { + "x": 227.84326171875, + "y": 170.06454467773438, + "pressure": 2076, + "timestamp": 1774179071178 + }, + { + "x": 228.0413818359375, + "y": 169.6683349609375, + "pressure": 2078, + "timestamp": 1774179071183 + }, + { + "x": 228.239501953125, + "y": 168.87594604492188, + "pressure": 2086, + "timestamp": 1774179071184 + }, + { + "x": 228.4376220703125, + "y": 168.479736328125, + "pressure": 2090, + "timestamp": 1774179071186 + }, + { + "x": 228.4376220703125, + "y": 168.479736328125, + "pressure": 2093, + "timestamp": 1774179071189 + }, + { + "x": 228.4376220703125, + "y": 168.479736328125, + "pressure": 2096, + "timestamp": 1774179071194 + }, + { + "x": 228.4376220703125, + "y": 168.479736328125, + "pressure": 2099, + "timestamp": 1774179071203 + }, + { + "x": 228.4376220703125, + "y": 168.479736328125, + "pressure": 2102, + "timestamp": 1774179071209 + }, + { + "x": 228.4376220703125, + "y": 168.479736328125, + "pressure": 2098, + "timestamp": 1774179071216 + }, + { + "x": 228.0413818359375, + "y": 168.479736328125, + "pressure": 2097, + "timestamp": 1774179071223 + }, + { + "x": 227.2489013671875, + "y": 168.28164672851562, + "pressure": 2099, + "timestamp": 1774179071225 + }, + { + "x": 226.85260009765625, + "y": 168.08352661132812, + "pressure": 2098, + "timestamp": 1774179071227 + }, + { + "x": 226.45635986328125, + "y": 168.08352661132812, + "pressure": 2090, + "timestamp": 1774179071232 + }, + { + "x": 225.66387939453125, + "y": 168.28164672851562, + "pressure": 2086, + "timestamp": 1774179071234 + }, + { + "x": 224.87139892578125, + "y": 168.479736328125, + "pressure": 2084, + "timestamp": 1774179071236 + }, + { + "x": 224.078857421875, + "y": 168.87594604492188, + "pressure": 2083, + "timestamp": 1774179071237 + }, + { + "x": 223.6826171875, + "y": 168.87594604492188, + "pressure": 2085, + "timestamp": 1774179071239 + }, + { + "x": 223.286376953125, + "y": 169.27215576171875, + "pressure": 2085, + "timestamp": 1774179071246 + }, + { + "x": 222.493896484375, + "y": 169.866455078125, + "pressure": 2078, + "timestamp": 1774179071248 + }, + { + "x": 222.09765625, + "y": 170.06454467773438, + "pressure": 2074, + "timestamp": 1774179071251 + }, + { + "x": 222.09765625, + "y": 170.06454467773438, + "pressure": 2071, + "timestamp": 1774179071253 + }, + { + "x": 221.701416015625, + "y": 170.46075439453125, + "pressure": 2073, + "timestamp": 1774179071262 + }, + { + "x": 221.10699462890625, + "y": 171.45126342773438, + "pressure": 2060, + "timestamp": 1774179071264 + }, + { + "x": 220.31451416015625, + "y": 172.24365234375, + "pressure": 2054, + "timestamp": 1774179071266 + }, + { + "x": 219.91827392578125, + "y": 173.03604125976562, + "pressure": 2051, + "timestamp": 1774179071268 + }, + { + "x": 219.72015380859375, + "y": 173.4322509765625, + "pressure": 2049, + "timestamp": 1774179071269 + }, + { + "x": 219.52203369140625, + "y": 173.82846069335938, + "pressure": 2049, + "timestamp": 1774179071274 + }, + { + "x": 218.92767333984375, + "y": 174.620849609375, + "pressure": 2048, + "timestamp": 1774179071279 + }, + { + "x": 218.7294921875, + "y": 175.01705932617188, + "pressure": 2036, + "timestamp": 1774179071280 + }, + { + "x": 218.7294921875, + "y": 175.01705932617188, + "pressure": 2030, + "timestamp": 1774179071282 + }, + { + "x": 218.5313720703125, + "y": 175.41326904296875, + "pressure": 2027, + "timestamp": 1774179071284 + }, + { + "x": 218.333251953125, + "y": 176.20565795898438, + "pressure": 2026, + "timestamp": 1774179071285 + }, + { + "x": 217.93701171875, + "y": 176.99807739257812, + "pressure": 2025, + "timestamp": 1774179071287 + }, + { + "x": 217.93701171875, + "y": 177.394287109375, + "pressure": 2027, + "timestamp": 1774179071289 + }, + { + "x": 217.93701171875, + "y": 177.394287109375, + "pressure": 2014, + "timestamp": 1774179071296 + }, + { + "x": 217.93701171875, + "y": 177.394287109375, + "pressure": 2008, + "timestamp": 1774179071298 + }, + { + "x": 217.93701171875, + "y": 177.79046630859375, + "pressure": 2005, + "timestamp": 1774179071300 + }, + { + "x": 217.93701171875, + "y": 178.5828857421875, + "pressure": 2004, + "timestamp": 1774179071301 + }, + { + "x": 218.1351318359375, + "y": 179.37527465820312, + "pressure": 2003, + "timestamp": 1774179071303 + }, + { + "x": 218.333251953125, + "y": 179.771484375, + "pressure": 2005, + "timestamp": 1774179071305 + }, + { + "x": 218.333251953125, + "y": 179.771484375, + "pressure": 1993, + "timestamp": 1774179071312 + }, + { + "x": 218.333251953125, + "y": 179.771484375, + "pressure": 1988, + "timestamp": 1774179071315 + }, + { + "x": 218.333251953125, + "y": 179.771484375, + "pressure": 1985, + "timestamp": 1774179071316 + }, + { + "x": 218.5313720703125, + "y": 180.16769409179688, + "pressure": 1984, + "timestamp": 1774179071317 + }, + { + "x": 219.12579345703125, + "y": 180.9600830078125, + "pressure": 1983, + "timestamp": 1774179071319 + }, + { + "x": 219.52203369140625, + "y": 181.35629272460938, + "pressure": 1985, + "timestamp": 1774179071321 + }, + { + "x": 219.52203369140625, + "y": 181.35629272460938, + "pressure": 1970, + "timestamp": 1774179071328 + }, + { + "x": 219.91827392578125, + "y": 181.75250244140625, + "pressure": 1963, + "timestamp": 1774179071331 + }, + { + "x": 220.71075439453125, + "y": 182.3468017578125, + "pressure": 1960, + "timestamp": 1774179071332 + }, + { + "x": 221.50323486328125, + "y": 182.74298095703125, + "pressure": 1958, + "timestamp": 1774179071334 + }, + { + "x": 222.2957763671875, + "y": 183.13919067382812, + "pressure": 1957, + "timestamp": 1774179071335 + }, + { + "x": 223.0882568359375, + "y": 183.535400390625, + "pressure": 1959, + "timestamp": 1774179071337 + }, + { + "x": 223.8807373046875, + "y": 183.93161010742188, + "pressure": 1958, + "timestamp": 1774179071339 + }, + { + "x": 224.27703857421875, + "y": 183.93161010742188, + "pressure": 1957, + "timestamp": 1774179071342 + }, + { + "x": 224.27703857421875, + "y": 183.93161010742188, + "pressure": 1943, + "timestamp": 1774179071344 + }, + { + "x": 224.67327880859375, + "y": 183.93161010742188, + "pressure": 1936, + "timestamp": 1774179071346 + }, + { + "x": 225.46575927734375, + "y": 183.93161010742188, + "pressure": 1932, + "timestamp": 1774179071348 + }, + { + "x": 226.25823974609375, + "y": 183.93161010742188, + "pressure": 1930, + "timestamp": 1774179071350 + }, + { + "x": 226.65447998046875, + "y": 183.73348999023438, + "pressure": 1929, + "timestamp": 1774179071351 + }, + { + "x": 227.05078125, + "y": 183.73348999023438, + "pressure": 1929, + "timestamp": 1774179071359 + }, + { + "x": 227.84326171875, + "y": 183.535400390625, + "pressure": 1920, + "timestamp": 1774179071360 + }, + { + "x": 228.6357421875, + "y": 183.3372802734375, + "pressure": 1916, + "timestamp": 1774179071363 + }, + { + "x": 229.031982421875, + "y": 183.13919067382812, + "pressure": 1914, + "timestamp": 1774179071364 + }, + { + "x": 229.42822265625, + "y": 182.94110107421875, + "pressure": 1914, + "timestamp": 1774179071369 + }, + { + "x": 230.22076416015625, + "y": 182.54489135742188, + "pressure": 1913, + "timestamp": 1774179071371 + }, + { + "x": 231.01324462890625, + "y": 182.148681640625, + "pressure": 1912, + "timestamp": 1774179071375 + }, + { + "x": 231.80572509765625, + "y": 181.75250244140625, + "pressure": 1911, + "timestamp": 1774179071376 + }, + { + "x": 232.20196533203125, + "y": 181.55438232421875, + "pressure": 1910, + "timestamp": 1774179071379 + }, + { + "x": 232.5982666015625, + "y": 181.35629272460938, + "pressure": 1910, + "timestamp": 1774179071383 + }, + { + "x": 233.3907470703125, + "y": 180.9600830078125, + "pressure": 1912, + "timestamp": 1774179071385 + }, + { + "x": 233.7869873046875, + "y": 180.76199340820312, + "pressure": 1911, + "timestamp": 1774179071387 + }, + { + "x": 233.7869873046875, + "y": 180.76199340820312, + "pressure": 1908, + "timestamp": 1774179071392 + }, + { + "x": 234.1832275390625, + "y": 180.56387329101562, + "pressure": 1909, + "timestamp": 1774179071396 + }, + { + "x": 234.9757080078125, + "y": 179.96957397460938, + "pressure": 1908, + "timestamp": 1774179071398 + }, + { + "x": 235.37200927734375, + "y": 179.771484375, + "pressure": 1907, + "timestamp": 1774179071399 + }, + { + "x": 235.76824951171875, + "y": 179.57339477539062, + "pressure": 1909, + "timestamp": 1774179071408 + }, + { + "x": 236.56072998046875, + "y": 179.17718505859375, + "pressure": 1910, + "timestamp": 1774179071411 + }, + { + "x": 237.35321044921875, + "y": 178.78097534179688, + "pressure": 1912, + "timestamp": 1774179071412 + }, + { + "x": 237.74945068359375, + "y": 178.5828857421875, + "pressure": 1911, + "timestamp": 1774179071414 + }, + { + "x": 238.145751953125, + "y": 178.384765625, + "pressure": 1909, + "timestamp": 1774179071424 + }, + { + "x": 238.938232421875, + "y": 177.79046630859375, + "pressure": 1907, + "timestamp": 1774179071427 + }, + { + "x": 239.730712890625, + "y": 177.1961669921875, + "pressure": 1906, + "timestamp": 1774179071428 + }, + { + "x": 240.126953125, + "y": 176.79995727539062, + "pressure": 1908, + "timestamp": 1774179071430 + }, + { + "x": 240.523193359375, + "y": 176.60186767578125, + "pressure": 1886, + "timestamp": 1774179071441 + }, + { + "x": 241.31573486328125, + "y": 176.007568359375, + "pressure": 1875, + "timestamp": 1774179071443 + }, + { + "x": 242.10821533203125, + "y": 175.41326904296875, + "pressure": 1870, + "timestamp": 1774179071444 + }, + { + "x": 242.90069580078125, + "y": 174.8189697265625, + "pressure": 1867, + "timestamp": 1774179071446 + }, + { + "x": 243.29693603515625, + "y": 174.42276000976562, + "pressure": 1866, + "timestamp": 1774179071448 + }, + { + "x": 243.6932373046875, + "y": 174.22467041015625, + "pressure": 1864, + "timestamp": 1774179071456 + }, + { + "x": 244.4857177734375, + "y": 173.63037109375, + "pressure": 1863, + "timestamp": 1774179071459 + }, + { + "x": 245.2781982421875, + "y": 173.03604125976562, + "pressure": 1862, + "timestamp": 1774179071460 + }, + { + "x": 245.6744384765625, + "y": 172.63986206054688, + "pressure": 1864, + "timestamp": 1774179071462 + }, + { + "x": 246.0706787109375, + "y": 172.44174194335938, + "pressure": 1867, + "timestamp": 1774179071473 + }, + { + "x": 246.86322021484375, + "y": 171.84744262695312, + "pressure": 1869, + "timestamp": 1774179071475 + }, + { + "x": 247.25946044921875, + "y": 171.45126342773438, + "pressure": 1870, + "timestamp": 1774179071476 + }, + { + "x": 247.25946044921875, + "y": 171.45126342773438, + "pressure": 1873, + "timestamp": 1774179071483 + }, + { + "x": 247.25946044921875, + "y": 171.45126342773438, + "pressure": 1872, + "timestamp": 1774179071483 + }, + { + "x": 247.25946044921875, + "y": 171.45126342773438, + "pressure": 1874, + "timestamp": 1774179071483 + }, + { + "x": 247.25946044921875, + "y": 171.45126342773438, + "pressure": 1877, + "timestamp": 1774179071491 + }, + { + "x": 247.25946044921875, + "y": 171.45126342773438, + "pressure": 1880, + "timestamp": 1774179071494 + }, + { + "x": 247.25946044921875, + "y": 171.45126342773438, + "pressure": 1894, + "timestamp": 1774179071505 + }, + { + "x": 247.25946044921875, + "y": 171.45126342773438, + "pressure": 1902, + "timestamp": 1774179071507 + }, + { + "x": 247.25946044921875, + "y": 171.45126342773438, + "pressure": 1906, + "timestamp": 1774179071509 + }, + { + "x": 247.45758056640625, + "y": 171.0550537109375, + "pressure": 1909, + "timestamp": 1774179071512 + }, + { + "x": 248.05194091796875, + "y": 170.26263427734375, + "pressure": 1911, + "timestamp": 1774179071514 + }, + { + "x": 248.44818115234375, + "y": 169.866455078125, + "pressure": 1910, + "timestamp": 1774179071515 + }, + { + "x": 248.44818115234375, + "y": 169.866455078125, + "pressure": 1943, + "timestamp": 1774179071521 + }, + { + "x": 248.44818115234375, + "y": 169.866455078125, + "pressure": 1958, + "timestamp": 1774179071523 + }, + { + "x": 248.44818115234375, + "y": 169.866455078125, + "pressure": 1966, + "timestamp": 1774179071525 + }, + { + "x": 248.44818115234375, + "y": 169.866455078125, + "pressure": 1970, + "timestamp": 1774179071527 + }, + { + "x": 248.44818115234375, + "y": 169.866455078125, + "pressure": 1973, + "timestamp": 1774179071530 + }, + { + "x": 248.44818115234375, + "y": 169.866455078125, + "pressure": 1999, + "timestamp": 1774179071537 + }, + { + "x": 248.44818115234375, + "y": 169.866455078125, + "pressure": 2012, + "timestamp": 1774179071539 + }, + { + "x": 248.44818115234375, + "y": 169.866455078125, + "pressure": 2018, + "timestamp": 1774179071541 + }, + { + "x": 248.44818115234375, + "y": 169.866455078125, + "pressure": 2021, + "timestamp": 1774179071542 + }, + { + "x": 248.44818115234375, + "y": 169.866455078125, + "pressure": 2024, + "timestamp": 1774179071545 + }, + { + "x": 248.44818115234375, + "y": 169.866455078125, + "pressure": 2030, + "timestamp": 1774179071553 + }, + { + "x": 248.44818115234375, + "y": 169.866455078125, + "pressure": 2033, + "timestamp": 1774179071557 + }, + { + "x": 248.44818115234375, + "y": 169.866455078125, + "pressure": 2036, + "timestamp": 1774179071560 + }, + { + "x": 248.44818115234375, + "y": 169.866455078125, + "pressure": 2039, + "timestamp": 1774179071573 + }, + { + "x": 248.44818115234375, + "y": 169.866455078125, + "pressure": 2032, + "timestamp": 1774179071585 + }, + { + "x": 248.44818115234375, + "y": 169.866455078125, + "pressure": 2029, + "timestamp": 1774179071589 + }, + { + "x": 248.44818115234375, + "y": 169.866455078125, + "pressure": 2018, + "timestamp": 1774179071601 + }, + { + "x": 248.44818115234375, + "y": 169.866455078125, + "pressure": 2012, + "timestamp": 1774179071603 + }, + { + "x": 248.44818115234375, + "y": 169.866455078125, + "pressure": 2009, + "timestamp": 1774179071605 + }, + { + "x": 248.44818115234375, + "y": 169.866455078125, + "pressure": 1999, + "timestamp": 1774179071617 + }, + { + "x": 248.44818115234375, + "y": 169.866455078125, + "pressure": 1995, + "timestamp": 1774179071619 + }, + { + "x": 248.44818115234375, + "y": 169.866455078125, + "pressure": 1992, + "timestamp": 1774179071622 + }, + { + "x": 248.05194091796875, + "y": 170.06454467773438, + "pressure": 1994, + "timestamp": 1774179071632 + }, + { + "x": 247.25946044921875, + "y": 170.65884399414062, + "pressure": 1988, + "timestamp": 1774179071633 + }, + { + "x": 246.86322021484375, + "y": 170.85693359375, + "pressure": 1985, + "timestamp": 1774179071635 + }, + { + "x": 246.46697998046875, + "y": 171.25314331054688, + "pressure": 1983, + "timestamp": 1774179071637 + }, + { + "x": 245.6744384765625, + "y": 171.84744262695312, + "pressure": 1982, + "timestamp": 1774179071638 + }, + { + "x": 245.2781982421875, + "y": 172.24365234375, + "pressure": 1984, + "timestamp": 1774179071640 + }, + { + "x": 244.8819580078125, + "y": 172.63986206054688, + "pressure": 1982, + "timestamp": 1774179071643 + }, + { + "x": 244.28759765625, + "y": 173.4322509765625, + "pressure": 1984, + "timestamp": 1774179071648 + }, + { + "x": 243.4951171875, + "y": 174.22467041015625, + "pressure": 1969, + "timestamp": 1774179071649 + }, + { + "x": 242.70257568359375, + "y": 175.01705932617188, + "pressure": 1961, + "timestamp": 1774179071651 + }, + { + "x": 242.10821533203125, + "y": 175.80947875976562, + "pressure": 1957, + "timestamp": 1774179071653 + }, + { + "x": 241.71197509765625, + "y": 176.60186767578125, + "pressure": 1955, + "timestamp": 1774179071655 + }, + { + "x": 241.51385498046875, + "y": 176.99807739257812, + "pressure": 1954, + "timestamp": 1774179071656 + }, + { + "x": 241.11761474609375, + "y": 177.394287109375, + "pressure": 1954, + "timestamp": 1774179071663 + }, + { + "x": 240.523193359375, + "y": 178.18667602539062, + "pressure": 1938, + "timestamp": 1774179071665 + }, + { + "x": 240.3250732421875, + "y": 178.5828857421875, + "pressure": 1930, + "timestamp": 1774179071667 + }, + { + "x": 240.3250732421875, + "y": 178.5828857421875, + "pressure": 1926, + "timestamp": 1774179071669 + }, + { + "x": 240.126953125, + "y": 178.97906494140625, + "pressure": 1923, + "timestamp": 1774179071672 + }, + { + "x": 239.9288330078125, + "y": 179.771484375, + "pressure": 1922, + "timestamp": 1774179071674 + }, + { + "x": 239.730712890625, + "y": 180.16769409179688, + "pressure": 1924, + "timestamp": 1774179071676 + }, + { + "x": 239.730712890625, + "y": 180.16769409179688, + "pressure": 1879, + "timestamp": 1774179071681 + }, + { + "x": 239.730712890625, + "y": 180.16769409179688, + "pressure": 1857, + "timestamp": 1774179071683 + }, + { + "x": 239.730712890625, + "y": 180.16769409179688, + "pressure": 1846, + "timestamp": 1774179071685 + }, + { + "x": 239.730712890625, + "y": 180.16769409179688, + "pressure": 1840, + "timestamp": 1774179071686 + }, + { + "x": 239.730712890625, + "y": 180.16769409179688, + "pressure": 1837, + "timestamp": 1774179071688 + }, + { + "x": 239.5325927734375, + "y": 180.56387329101562, + "pressure": 1836, + "timestamp": 1774179071690 + }, + { + "x": 239.5325927734375, + "y": 180.9600830078125, + "pressure": 1835, + "timestamp": 1774179071692 + }, + { + "x": 239.5325927734375, + "y": 180.9600830078125, + "pressure": 1799, + "timestamp": 1774179071697 + }, + { + "x": 239.5325927734375, + "y": 180.9600830078125, + "pressure": 1780, + "timestamp": 1774179071699 + }, + { + "x": 239.5325927734375, + "y": 180.9600830078125, + "pressure": 1770, + "timestamp": 1774179071702 + }, + { + "x": 239.5325927734375, + "y": 180.9600830078125, + "pressure": 1765, + "timestamp": 1774179071703 + }, + { + "x": 239.5325927734375, + "y": 180.9600830078125, + "pressure": 1762, + "timestamp": 1774179071706 + }, + { + "x": 239.5325927734375, + "y": 180.9600830078125, + "pressure": 1740, + "timestamp": 1774179071713 + }, + { + "x": 239.5325927734375, + "y": 180.9600830078125, + "pressure": 1729, + "timestamp": 1774179071716 + }, + { + "x": 239.5325927734375, + "y": 180.9600830078125, + "pressure": 1723, + "timestamp": 1774179071717 + }, + { + "x": 239.5325927734375, + "y": 180.9600830078125, + "pressure": 1720, + "timestamp": 1774179071719 + }, + { + "x": 239.9288330078125, + "y": 181.15817260742188, + "pressure": 1718, + "timestamp": 1774179071722 + }, + { + "x": 240.72137451171875, + "y": 181.55438232421875, + "pressure": 1720, + "timestamp": 1774179071724 + }, + { + "x": 241.11761474609375, + "y": 181.75250244140625, + "pressure": 1719, + "timestamp": 1774179071728 + }, + { + "x": 241.11761474609375, + "y": 181.75250244140625, + "pressure": 1708, + "timestamp": 1774179071729 + }, + { + "x": 241.51385498046875, + "y": 181.75250244140625, + "pressure": 1703, + "timestamp": 1774179071732 + }, + { + "x": 242.30633544921875, + "y": 181.55438232421875, + "pressure": 1700, + "timestamp": 1774179071733 + }, + { + "x": 243.09881591796875, + "y": 181.35629272460938, + "pressure": 1699, + "timestamp": 1774179071735 + }, + { + "x": 243.891357421875, + "y": 181.15817260742188, + "pressure": 1698, + "timestamp": 1774179071737 + }, + { + "x": 244.28759765625, + "y": 180.9600830078125, + "pressure": 1700, + "timestamp": 1774179071738 + }, + { + "x": 244.683837890625, + "y": 180.76199340820312, + "pressure": 1698, + "timestamp": 1774179071744 + }, + { + "x": 245.476318359375, + "y": 180.36578369140625, + "pressure": 1691, + "timestamp": 1774179071745 + }, + { + "x": 246.26885986328125, + "y": 179.96957397460938, + "pressure": 1688, + "timestamp": 1774179071748 + }, + { + "x": 247.06134033203125, + "y": 179.37527465820312, + "pressure": 1686, + "timestamp": 1774179071749 + }, + { + "x": 247.85382080078125, + "y": 178.78097534179688, + "pressure": 1685, + "timestamp": 1774179071751 + }, + { + "x": 248.25006103515625, + "y": 178.384765625, + "pressure": 1687, + "timestamp": 1774179071752 + }, + { + "x": 248.64630126953125, + "y": 177.98858642578125, + "pressure": 1687, + "timestamp": 1774179071760 + }, + { + "x": 249.4388427734375, + "y": 177.1961669921875, + "pressure": 1691, + "timestamp": 1774179071761 + }, + { + "x": 250.033203125, + "y": 176.40377807617188, + "pressure": 1693, + "timestamp": 1774179071764 + }, + { + "x": 250.6275634765625, + "y": 175.61135864257812, + "pressure": 1694, + "timestamp": 1774179071765 + }, + { + "x": 251.0238037109375, + "y": 174.8189697265625, + "pressure": 1696, + "timestamp": 1774179071767 + }, + { + "x": 251.221923828125, + "y": 174.42276000976562, + "pressure": 1695, + "timestamp": 1774179071768 + }, + { + "x": 251.4200439453125, + "y": 174.02655029296875, + "pressure": 1696, + "timestamp": 1774179071772 + }, + { + "x": 252.01446533203125, + "y": 173.23416137695312, + "pressure": 1695, + "timestamp": 1774179071776 + }, + { + "x": 252.60882568359375, + "y": 172.44174194335938, + "pressure": 1701, + "timestamp": 1774179071777 + }, + { + "x": 253.00506591796875, + "y": 171.64935302734375, + "pressure": 1704, + "timestamp": 1774179071780 + }, + { + "x": 253.20318603515625, + "y": 171.25314331054688, + "pressure": 1705, + "timestamp": 1774179071781 + }, + { + "x": 253.20318603515625, + "y": 171.25314331054688, + "pressure": 1708, + "timestamp": 1774179071784 + }, + { + "x": 253.40130615234375, + "y": 170.85693359375, + "pressure": 1708, + "timestamp": 1774179071792 + }, + { + "x": 253.59942626953125, + "y": 170.46075439453125, + "pressure": 1724, + "timestamp": 1774179071794 + }, + { + "x": 253.59942626953125, + "y": 170.46075439453125, + "pressure": 1732, + "timestamp": 1774179071796 + }, + { + "x": 253.59942626953125, + "y": 170.46075439453125, + "pressure": 1736, + "timestamp": 1774179071797 + }, + { + "x": 253.59942626953125, + "y": 170.46075439453125, + "pressure": 1739, + "timestamp": 1774179071800 + }, + { + "x": 253.59942626953125, + "y": 170.46075439453125, + "pressure": 1742, + "timestamp": 1774179071808 + }, + { + "x": 253.59942626953125, + "y": 170.46075439453125, + "pressure": 1820, + "timestamp": 1774179071809 + }, + { + "x": 253.59942626953125, + "y": 170.46075439453125, + "pressure": 1859, + "timestamp": 1774179071812 + }, + { + "x": 253.59942626953125, + "y": 170.46075439453125, + "pressure": 1879, + "timestamp": 1774179071813 + }, + { + "x": 253.59942626953125, + "y": 170.46075439453125, + "pressure": 1889, + "timestamp": 1774179071815 + }, + { + "x": 253.59942626953125, + "y": 170.46075439453125, + "pressure": 1894, + "timestamp": 1774179071816 + }, + { + "x": 253.59942626953125, + "y": 170.46075439453125, + "pressure": 1897, + "timestamp": 1774179071820 + }, + { + "x": 253.59942626953125, + "y": 170.46075439453125, + "pressure": 1872, + "timestamp": 1774179071826 + }, + { + "x": 253.59942626953125, + "y": 170.46075439453125, + "pressure": 1859, + "timestamp": 1774179071828 + }, + { + "x": 253.59942626953125, + "y": 170.46075439453125, + "pressure": 1853, + "timestamp": 1774179071829 + }, + { + "x": 253.59942626953125, + "y": 170.46075439453125, + "pressure": 1850, + "timestamp": 1774179071832 + }, + { + "x": 253.59942626953125, + "y": 170.46075439453125, + "pressure": 1847, + "timestamp": 1774179071834 + }, + { + "x": 253.59942626953125, + "y": 170.46075439453125, + "pressure": 1839, + "timestamp": 1774179071842 + }, + { + "x": 253.59942626953125, + "y": 170.46075439453125, + "pressure": 1835, + "timestamp": 1774179071844 + }, + { + "x": 253.59942626953125, + "y": 170.46075439453125, + "pressure": 1832, + "timestamp": 1774179071847 + }, + { + "x": 253.40130615234375, + "y": 170.85693359375, + "pressure": 1833, + "timestamp": 1774179071850 + }, + { + "x": 252.80694580078125, + "y": 171.84744262695312, + "pressure": 1832, + "timestamp": 1774179071852 + }, + { + "x": 252.41070556640625, + "y": 172.83795166015625, + "pressure": 1831, + "timestamp": 1774179071856 + }, + { + "x": 252.01446533203125, + "y": 173.63037109375, + "pressure": 1820, + "timestamp": 1774179071858 + }, + { + "x": 251.81634521484375, + "y": 174.02655029296875, + "pressure": 1815, + "timestamp": 1774179071860 + }, + { + "x": 251.61822509765625, + "y": 174.42276000976562, + "pressure": 1812, + "timestamp": 1774179071861 + }, + { + "x": 251.4200439453125, + "y": 175.21514892578125, + "pressure": 1811, + "timestamp": 1774179071863 + }, + { + "x": 251.4200439453125, + "y": 175.61135864257812, + "pressure": 1810, + "timestamp": 1774179071865 + }, + { + "x": 251.4200439453125, + "y": 176.007568359375, + "pressure": 1811, + "timestamp": 1774179071868 + }, + { + "x": 251.4200439453125, + "y": 176.79995727539062, + "pressure": 1810, + "timestamp": 1774179071872 + }, + { + "x": 251.4200439453125, + "y": 177.59237670898438, + "pressure": 1794, + "timestamp": 1774179071874 + }, + { + "x": 251.4200439453125, + "y": 177.98858642578125, + "pressure": 1786, + "timestamp": 1774179071876 + }, + { + "x": 251.4200439453125, + "y": 177.98858642578125, + "pressure": 1782, + "timestamp": 1774179071877 + }, + { + "x": 251.4200439453125, + "y": 177.98858642578125, + "pressure": 1779, + "timestamp": 1774179071881 + }, + { + "x": 251.4200439453125, + "y": 178.384765625, + "pressure": 1780, + "timestamp": 1774179071884 + }, + { + "x": 251.81634521484375, + "y": 179.17718505859375, + "pressure": 1779, + "timestamp": 1774179071888 + }, + { + "x": 252.01446533203125, + "y": 179.57339477539062, + "pressure": 1763, + "timestamp": 1774179071890 + }, + { + "x": 252.01446533203125, + "y": 179.57339477539062, + "pressure": 1755, + "timestamp": 1774179071892 + }, + { + "x": 252.01446533203125, + "y": 179.57339477539062, + "pressure": 1751, + "timestamp": 1774179071893 + }, + { + "x": 252.01446533203125, + "y": 179.57339477539062, + "pressure": 1748, + "timestamp": 1774179071897 + }, + { + "x": 252.41070556640625, + "y": 179.96957397460938, + "pressure": 1750, + "timestamp": 1774179071898 + }, + { + "x": 253.20318603515625, + "y": 180.56387329101562, + "pressure": 1749, + "timestamp": 1774179071900 + }, + { + "x": 253.99566650390625, + "y": 180.9600830078125, + "pressure": 1748, + "timestamp": 1774179071904 + }, + { + "x": 254.3919677734375, + "y": 181.15817260742188, + "pressure": 1739, + "timestamp": 1774179071906 + }, + { + "x": 254.3919677734375, + "y": 181.15817260742188, + "pressure": 1734, + "timestamp": 1774179071909 + }, + { + "x": 254.7882080078125, + "y": 181.35629272460938, + "pressure": 1732, + "timestamp": 1774179071910 + }, + { + "x": 255.5806884765625, + "y": 181.55438232421875, + "pressure": 1731, + "timestamp": 1774179071913 + }, + { + "x": 256.3731689453125, + "y": 181.75250244140625, + "pressure": 1730, + "timestamp": 1774179071913 + }, + { + "x": 256.7694091796875, + "y": 181.75250244140625, + "pressure": 1732, + "timestamp": 1774179071915 + }, + { + "x": 257.16571044921875, + "y": 181.75250244140625, + "pressure": 1732, + "timestamp": 1774179071922 + }, + { + "x": 257.95819091796875, + "y": 181.75250244140625, + "pressure": 1731, + "timestamp": 1774179071924 + }, + { + "x": 258.75067138671875, + "y": 181.75250244140625, + "pressure": 1733, + "timestamp": 1774179071926 + }, + { + "x": 259.54315185546875, + "y": 181.55438232421875, + "pressure": 1732, + "timestamp": 1774179071927 + }, + { + "x": 259.939453125, + "y": 181.35629272460938, + "pressure": 1731, + "timestamp": 1774179071929 + }, + { + "x": 260.335693359375, + "y": 181.15817260742188, + "pressure": 1731, + "timestamp": 1774179071937 + }, + { + "x": 261.128173828125, + "y": 180.76199340820312, + "pressure": 1729, + "timestamp": 1774179071938 + }, + { + "x": 261.920654296875, + "y": 180.36578369140625, + "pressure": 1728, + "timestamp": 1774179071940 + }, + { + "x": 262.31689453125, + "y": 180.16769409179688, + "pressure": 1730, + "timestamp": 1774179071942 + }, + { + "x": 262.71319580078125, + "y": 179.96957397460938, + "pressure": 1730, + "timestamp": 1774179071947 + }, + { + "x": 263.50567626953125, + "y": 179.37527465820312, + "pressure": 1729, + "timestamp": 1774179071949 + }, + { + "x": 264.29815673828125, + "y": 178.78097534179688, + "pressure": 1728, + "timestamp": 1774179071952 + }, + { + "x": 264.69439697265625, + "y": 178.384765625, + "pressure": 1719, + "timestamp": 1774179071954 + }, + { + "x": 264.69439697265625, + "y": 178.384765625, + "pressure": 1714, + "timestamp": 1774179071956 + }, + { + "x": 265.09063720703125, + "y": 177.98858642578125, + "pressure": 1711, + "timestamp": 1774179071959 + }, + { + "x": 265.8831787109375, + "y": 177.1961669921875, + "pressure": 1710, + "timestamp": 1774179071962 + }, + { + "x": 266.4775390625, + "y": 176.40377807617188, + "pressure": 1712, + "timestamp": 1774179071963 + }, + { + "x": 267.0718994140625, + "y": 175.61135864257812, + "pressure": 1711, + "timestamp": 1774179071964 + }, + { + "x": 267.27001953125, + "y": 175.21514892578125, + "pressure": 1710, + "timestamp": 1774179071968 + }, + { + "x": 267.27001953125, + "y": 175.21514892578125, + "pressure": 1705, + "timestamp": 1774179071970 + }, + { + "x": 267.27001953125, + "y": 175.21514892578125, + "pressure": 1702, + "timestamp": 1774179071972 + }, + { + "x": 267.4681396484375, + "y": 174.8189697265625, + "pressure": 1701, + "timestamp": 1774179071974 + }, + { + "x": 268.06256103515625, + "y": 174.02655029296875, + "pressure": 1700, + "timestamp": 1774179071975 + }, + { + "x": 268.65692138671875, + "y": 173.23416137695312, + "pressure": 1702, + "timestamp": 1774179071977 + }, + { + "x": 269.05316162109375, + "y": 172.83795166015625, + "pressure": 1701, + "timestamp": 1774179071979 + }, + { + "x": 269.25128173828125, + "y": 172.44174194335938, + "pressure": 1705, + "timestamp": 1774179071986 + }, + { + "x": 269.84564208984375, + "y": 171.64935302734375, + "pressure": 1706, + "timestamp": 1774179071988 + }, + { + "x": 270.04376220703125, + "y": 171.25314331054688, + "pressure": 1707, + "timestamp": 1774179071990 + }, + { + "x": 270.24188232421875, + "y": 170.85693359375, + "pressure": 1710, + "timestamp": 1774179071995 + }, + { + "x": 270.8363037109375, + "y": 170.06454467773438, + "pressure": 1709, + "timestamp": 1774179071996 + }, + { + "x": 271.034423828125, + "y": 169.6683349609375, + "pressure": 1708, + "timestamp": 1774179072000 + }, + { + "x": 271.034423828125, + "y": 169.6683349609375, + "pressure": 1711, + "timestamp": 1774179072002 + }, + { + "x": 271.034423828125, + "y": 169.6683349609375, + "pressure": 1714, + "timestamp": 1774179072007 + }, + { + "x": 271.2325439453125, + "y": 169.27215576171875, + "pressure": 1716, + "timestamp": 1774179072007 + }, + { + "x": 271.826904296875, + "y": 168.479736328125, + "pressure": 1715, + "timestamp": 1774179072009 + }, + { + "x": 272.0250244140625, + "y": 168.08352661132812, + "pressure": 1717, + "timestamp": 1774179072011 + }, + { + "x": 272.0250244140625, + "y": 168.08352661132812, + "pressure": 1720, + "timestamp": 1774179072018 + }, + { + "x": 272.0250244140625, + "y": 168.08352661132812, + "pressure": 1723, + "timestamp": 1774179072022 + }, + { + "x": 272.0250244140625, + "y": 168.08352661132812, + "pressure": 1726, + "timestamp": 1774179072025 + }, + { + "x": 272.0250244140625, + "y": 168.08352661132812, + "pressure": 1740, + "timestamp": 1774179072034 + }, + { + "x": 272.0250244140625, + "y": 168.08352661132812, + "pressure": 1747, + "timestamp": 1774179072036 + }, + { + "x": 272.0250244140625, + "y": 168.08352661132812, + "pressure": 1750, + "timestamp": 1774179072038 + }, + { + "x": 272.0250244140625, + "y": 168.08352661132812, + "pressure": 1753, + "timestamp": 1774179072041 + }, + { + "x": 272.0250244140625, + "y": 168.08352661132812, + "pressure": 1756, + "timestamp": 1774179072049 + }, + { + "x": 272.0250244140625, + "y": 168.08352661132812, + "pressure": 1833, + "timestamp": 1774179072050 + }, + { + "x": 272.0250244140625, + "y": 168.08352661132812, + "pressure": 1871, + "timestamp": 1774179072052 + }, + { + "x": 272.0250244140625, + "y": 168.08352661132812, + "pressure": 1890, + "timestamp": 1774179072054 + }, + { + "x": 272.0250244140625, + "y": 168.08352661132812, + "pressure": 1900, + "timestamp": 1774179072056 + }, + { + "x": 272.0250244140625, + "y": 168.08352661132812, + "pressure": 1905, + "timestamp": 1774179072057 + }, + { + "x": 272.0250244140625, + "y": 168.08352661132812, + "pressure": 1908, + "timestamp": 1774179072061 + }, + { + "x": 272.0250244140625, + "y": 168.08352661132812, + "pressure": 1924, + "timestamp": 1774179072066 + }, + { + "x": 272.0250244140625, + "y": 168.08352661132812, + "pressure": 1931, + "timestamp": 1774179072069 + }, + { + "x": 272.0250244140625, + "y": 168.08352661132812, + "pressure": 1935, + "timestamp": 1774179072070 + }, + { + "x": 272.0250244140625, + "y": 168.08352661132812, + "pressure": 1938, + "timestamp": 1774179072076 + }, + { + "x": 272.0250244140625, + "y": 168.08352661132812, + "pressure": 1940, + "timestamp": 1774179072076 + }, + { + "x": 272.0250244140625, + "y": 168.08352661132812, + "pressure": 1936, + "timestamp": 1774179072082 + }, + { + "x": 272.0250244140625, + "y": 168.08352661132812, + "pressure": 1933, + "timestamp": 1774179072085 + }, + { + "x": 272.0250244140625, + "y": 168.08352661132812, + "pressure": 1930, + "timestamp": 1774179072101 + }, + { + "x": 272.0250244140625, + "y": 168.08352661132812, + "pressure": 1919, + "timestamp": 1774179072114 + }, + { + "x": 271.826904296875, + "y": 168.479736328125, + "pressure": 1913, + "timestamp": 1774179072117 + }, + { + "x": 271.6287841796875, + "y": 169.27215576171875, + "pressure": 1910, + "timestamp": 1774179072118 + }, + { + "x": 271.6287841796875, + "y": 170.06454467773438, + "pressure": 1909, + "timestamp": 1774179072120 + }, + { + "x": 271.6287841796875, + "y": 170.46075439453125, + "pressure": 1908, + "timestamp": 1774179072121 + }, + { + "x": 271.6287841796875, + "y": 170.85693359375, + "pressure": 1910, + "timestamp": 1774179072123 + }, + { + "x": 271.6287841796875, + "y": 171.64935302734375, + "pressure": 1909, + "timestamp": 1774179072125 + }, + { + "x": 271.6287841796875, + "y": 172.04556274414062, + "pressure": 1908, + "timestamp": 1774179072129 + }, + { + "x": 271.6287841796875, + "y": 172.44174194335938, + "pressure": 1901, + "timestamp": 1774179072131 + }, + { + "x": 271.6287841796875, + "y": 172.83795166015625, + "pressure": 1897, + "timestamp": 1774179072133 + }, + { + "x": 271.6287841796875, + "y": 172.83795166015625, + "pressure": 1894, + "timestamp": 1774179072136 + }, + { + "x": 271.6287841796875, + "y": 173.23416137695312, + "pressure": 1896, + "timestamp": 1774179072137 + }, + { + "x": 271.826904296875, + "y": 174.02655029296875, + "pressure": 1895, + "timestamp": 1774179072139 + }, + { + "x": 272.0250244140625, + "y": 174.8189697265625, + "pressure": 1894, + "timestamp": 1774179072141 + }, + { + "x": 272.22314453125, + "y": 175.21514892578125, + "pressure": 1896, + "timestamp": 1774179072148 + }, + { + "x": 272.22314453125, + "y": 175.21514892578125, + "pressure": 1880, + "timestamp": 1774179072148 + }, + { + "x": 272.22314453125, + "y": 175.21514892578125, + "pressure": 1872, + "timestamp": 1774179072149 + }, + { + "x": 272.22314453125, + "y": 175.21514892578125, + "pressure": 1868, + "timestamp": 1774179072150 + }, + { + "x": 272.22314453125, + "y": 175.21514892578125, + "pressure": 1865, + "timestamp": 1774179072154 + }, + { + "x": 272.4212646484375, + "y": 175.61135864257812, + "pressure": 1867, + "timestamp": 1774179072155 + }, + { + "x": 273.015625, + "y": 176.40377807617188, + "pressure": 1866, + "timestamp": 1774179072157 + }, + { + "x": 273.411865234375, + "y": 176.79995727539062, + "pressure": 1865, + "timestamp": 1774179072161 + }, + { + "x": 273.411865234375, + "y": 176.79995727539062, + "pressure": 1840, + "timestamp": 1774179072163 + }, + { + "x": 273.411865234375, + "y": 176.79995727539062, + "pressure": 1827, + "timestamp": 1774179072165 + }, + { + "x": 273.80816650390625, + "y": 177.1961669921875, + "pressure": 1821, + "timestamp": 1774179072166 + }, + { + "x": 274.60064697265625, + "y": 177.79046630859375, + "pressure": 1818, + "timestamp": 1774179072168 + }, + { + "x": 275.39312744140625, + "y": 178.18667602539062, + "pressure": 1816, + "timestamp": 1774179072170 + }, + { + "x": 276.1856689453125, + "y": 178.5828857421875, + "pressure": 1815, + "timestamp": 1774179072171 + }, + { + "x": 276.5819091796875, + "y": 178.78097534179688, + "pressure": 1817, + "timestamp": 1774179072173 + }, + { + "x": 276.5819091796875, + "y": 178.78097534179688, + "pressure": 1779, + "timestamp": 1774179072179 + }, + { + "x": 276.9781494140625, + "y": 178.97906494140625, + "pressure": 1761, + "timestamp": 1774179072181 + }, + { + "x": 277.7706298828125, + "y": 179.17718505859375, + "pressure": 1752, + "timestamp": 1774179072182 + }, + { + "x": 278.5631103515625, + "y": 179.17718505859375, + "pressure": 1747, + "timestamp": 1774179072184 + }, + { + "x": 278.95941162109375, + "y": 179.17718505859375, + "pressure": 1745, + "timestamp": 1774179072186 + }, + { + "x": 278.95941162109375, + "y": 179.17718505859375, + "pressure": 1735, + "timestamp": 1774179072195 + }, + { + "x": 278.95941162109375, + "y": 179.17718505859375, + "pressure": 1730, + "timestamp": 1774179072197 + }, + { + "x": 279.35565185546875, + "y": 179.17718505859375, + "pressure": 1728, + "timestamp": 1774179072198 + }, + { + "x": 280.14813232421875, + "y": 179.17718505859375, + "pressure": 1727, + "timestamp": 1774179072200 + }, + { + "x": 280.54437255859375, + "y": 179.17718505859375, + "pressure": 1726, + "timestamp": 1774179072202 + }, + { + "x": 280.54437255859375, + "y": 179.17718505859375, + "pressure": 1734, + "timestamp": 1774179072211 + }, + { + "x": 280.54437255859375, + "y": 179.17718505859375, + "pressure": 1738, + "timestamp": 1774179072213 + }, + { + "x": 280.54437255859375, + "y": 179.17718505859375, + "pressure": 1741, + "timestamp": 1774179072216 + }, + { + "x": 280.94061279296875, + "y": 179.17718505859375, + "pressure": 1743, + "timestamp": 1774179072218 + }, + { + "x": 281.733154296875, + "y": 179.17718505859375, + "pressure": 1742, + "timestamp": 1774179072220 + }, + { + "x": 282.12939453125, + "y": 179.17718505859375, + "pressure": 1744, + "timestamp": 1774179072221 + }, + { + "x": 282.12939453125, + "y": 179.17718505859375, + "pressure": 1748, + "timestamp": 1774179072227 + }, + { + "x": 282.12939453125, + "y": 179.17718505859375, + "pressure": 1751, + "timestamp": 1774179072234 + }, + { + "x": 282.12939453125, + "y": 179.17718505859375, + "pressure": 1752, + "timestamp": 1774179072234 + }, + { + "x": 282.12939453125, + "y": 179.17718505859375, + "pressure": 1753, + "timestamp": 1774179072234 + }, + { + "x": 282.12939453125, + "y": 179.17718505859375, + "pressure": 1755, + "timestamp": 1774179072234 + }, + { + "x": 282.12939453125, + "y": 179.17718505859375, + "pressure": 1778, + "timestamp": 1774179072243 + }, + { + "x": 282.12939453125, + "y": 179.17718505859375, + "pressure": 1789, + "timestamp": 1774179072245 + }, + { + "x": 282.3275146484375, + "y": 179.57339477539062, + "pressure": 1795, + "timestamp": 1774179072247 + }, + { + "x": 282.525634765625, + "y": 180.56387329101562, + "pressure": 1798, + "timestamp": 1774179072248 + }, + { + "x": 282.7237548828125, + "y": 181.55438232421875, + "pressure": 1799, + "timestamp": 1774179072250 + }, + { + "x": 282.7237548828125, + "y": 182.3468017578125, + "pressure": 1800, + "timestamp": 1774179072251 + }, + { + "x": 282.7237548828125, + "y": 183.13919067382812, + "pressure": 1802, + "timestamp": 1774179072253 + }, + { + "x": 282.921875, + "y": 183.93161010742188, + "pressure": 1801, + "timestamp": 1774179072257 + }, + { + "x": 283.1199951171875, + "y": 184.7239990234375, + "pressure": 1814, + "timestamp": 1774179072259 + }, + { + "x": 283.1199951171875, + "y": 185.51638793945312, + "pressure": 1820, + "timestamp": 1774179072261 + }, + { + "x": 283.1199951171875, + "y": 186.30880737304688, + "pressure": 1823, + "timestamp": 1774179072262 + }, + { + "x": 283.1199951171875, + "y": 187.1011962890625, + "pressure": 1825, + "timestamp": 1774179072264 + }, + { + "x": 283.1199951171875, + "y": 187.89361572265625, + "pressure": 1826, + "timestamp": 1774179072266 + }, + { + "x": 283.1199951171875, + "y": 188.68600463867188, + "pressure": 1828, + "timestamp": 1774179072268 + }, + { + "x": 283.1199951171875, + "y": 189.08221435546875, + "pressure": 1827, + "timestamp": 1774179072269 + }, + { + "x": 283.1199951171875, + "y": 189.08221435546875, + "pressure": 1798, + "timestamp": 1774179072275 + }, + { + "x": 283.1199951171875, + "y": 189.47842407226562, + "pressure": 1782, + "timestamp": 1774179072277 + }, + { + "x": 283.318115234375, + "y": 190.27081298828125, + "pressure": 1774, + "timestamp": 1774179072279 + }, + { + "x": 283.5162353515625, + "y": 191.063232421875, + "pressure": 1770, + "timestamp": 1774179072280 + }, + { + "x": 283.71435546875, + "y": 191.45941162109375, + "pressure": 1768, + "timestamp": 1774179072282 + }, + { + "x": 284.110595703125, + "y": 191.85562133789062, + "pressure": 1721, + "timestamp": 1774179072291 + }, + { + "x": 284.90313720703125, + "y": 192.44992065429688, + "pressure": 1698, + "timestamp": 1774179072293 + }, + { + "x": 285.69561767578125, + "y": 192.84613037109375, + "pressure": 1686, + "timestamp": 1774179072294 + }, + { + "x": 286.48809814453125, + "y": 192.84613037109375, + "pressure": 1680, + "timestamp": 1774179072296 + }, + { + "x": 287.478759765625, + "y": 192.84613037109375, + "pressure": 1677, + "timestamp": 1774179072298 + }, + { + "x": 288.66748046875, + "y": 192.84613037109375, + "pressure": 1676, + "timestamp": 1774179072300 + }, + { + "x": 289.85626220703125, + "y": 192.64804077148438, + "pressure": 1675, + "timestamp": 1774179072301 + }, + { + "x": 291.04498291015625, + "y": 192.44992065429688, + "pressure": 1677, + "timestamp": 1774179072307 + }, + { + "x": 292.23370361328125, + "y": 192.05374145507812, + "pressure": 1437, + "timestamp": 1774179072307 + }, + { + "x": 293.4224853515625, + "y": 191.65753173828125, + "pressure": 1317, + "timestamp": 1774179072309 + }, + { + "x": 294.809326171875, + "y": 190.8651123046875, + "pressure": 1257, + "timestamp": 1774179072311 + }, + { + "x": 296.39434814453125, + "y": 190.07272338867188, + "pressure": 1227, + "timestamp": 1774179072312 + }, + { + "x": 297.97930908203125, + "y": 189.28030395507812, + "pressure": 1212, + "timestamp": 1774179072314 + }, + { + "x": 299.762451171875, + "y": 188.28982543945312, + "pressure": 1205, + "timestamp": 1774179072316 + }, + { + "x": 301.54559326171875, + "y": 187.29931640625, + "pressure": 1201, + "timestamp": 1774179072317 + }, + { + "x": 303.32867431640625, + "y": 186.30880737304688, + "pressure": 1199, + "timestamp": 1774179072321 + }, + { + "x": 305.11181640625, + "y": 185.12020874023438, + "pressure": 631, + "timestamp": 1774179072324 + }, + { + "x": 306.89495849609375, + "y": 183.93161010742188, + "pressure": 347, + "timestamp": 1774179072325 + }, + { + "x": 308.67803955078125, + "y": 182.74298095703125, + "pressure": 205, + "timestamp": 1774179072327 + }, + { + "x": 310.461181640625, + "y": 181.55438232421875, + "pressure": 134, + "timestamp": 1774179072329 + }, + { + "x": 312.2442932128906, + "y": 180.36578369140625, + "pressure": 98, + "timestamp": 1774179072330 + }, + { + "x": 314.0274353027344, + "y": 179.17718505859375, + "pressure": 80, + "timestamp": 1774179072332 + }, + { + "x": 315.6124267578125, + "y": 177.98858642578125, + "pressure": 71, + "timestamp": 1774179072333 + }, + { + "x": 317.1974182128906, + "y": 176.79995727539062, + "pressure": 67, + "timestamp": 1774179072337 + }, + { + "x": 317.1974182128906, + "y": 176.79995727539062, + "pressure": 67, + "timestamp": 1774179072339 + } + ] + }, + { + "index": 1, + "pointCount": 49, + "points": [ + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 250, + "timestamp": 1774179072822 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 375, + "timestamp": 1774179072824 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 438, + "timestamp": 1774179072825 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 469, + "timestamp": 1774179072827 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 485, + "timestamp": 1774179072829 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 493, + "timestamp": 1774179072831 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 497, + "timestamp": 1774179072834 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 837, + "timestamp": 1774179072836 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 1007, + "timestamp": 1774179072839 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 1092, + "timestamp": 1774179072840 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 1135, + "timestamp": 1774179072842 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 1156, + "timestamp": 1774179072843 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 1167, + "timestamp": 1774179072845 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 1172, + "timestamp": 1774179072847 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 1175, + "timestamp": 1774179072851 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 1353, + "timestamp": 1774179072852 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 1442, + "timestamp": 1774179072855 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 1487, + "timestamp": 1774179072856 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 1509, + "timestamp": 1774179072858 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 1520, + "timestamp": 1774179072859 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 1526, + "timestamp": 1774179072861 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 1529, + "timestamp": 1774179072863 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 1817, + "timestamp": 1774179072868 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 1960, + "timestamp": 1774179072871 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 2032, + "timestamp": 1774179072872 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 2068, + "timestamp": 1774179072874 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 2086, + "timestamp": 1774179072875 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 2095, + "timestamp": 1774179072877 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 2099, + "timestamp": 1774179072879 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 2152, + "timestamp": 1774179072885 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 2178, + "timestamp": 1774179072887 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 2191, + "timestamp": 1774179072888 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 2197, + "timestamp": 1774179072890 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 2200, + "timestamp": 1774179072891 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 2203, + "timestamp": 1774179072895 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 2166, + "timestamp": 1774179072901 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 2146, + "timestamp": 1774179072903 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 2136, + "timestamp": 1774179072904 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 2131, + "timestamp": 1774179072906 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 2128, + "timestamp": 1774179072909 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 1261, + "timestamp": 1774179072916 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 827, + "timestamp": 1774179072919 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 610, + "timestamp": 1774179072920 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 501, + "timestamp": 1774179072922 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 447, + "timestamp": 1774179072924 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 420, + "timestamp": 1774179072925 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 406, + "timestamp": 1774179072927 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 399, + "timestamp": 1774179072931 + }, + { + "x": 112.13848876953125, + "y": 145.50006103515625, + "pressure": 399, + "timestamp": 1774179072932 + } + ] + }, + { + "index": 2, + "pointCount": 1556, + "points": [ + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 305, + "timestamp": 1774179074058 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 457, + "timestamp": 1774179074059 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 533, + "timestamp": 1774179074061 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 571, + "timestamp": 1774179074063 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 590, + "timestamp": 1774179074064 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 600, + "timestamp": 1774179074066 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 605, + "timestamp": 1774179074070 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 584, + "timestamp": 1774179074071 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 574, + "timestamp": 1774179074074 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 569, + "timestamp": 1774179074075 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 566, + "timestamp": 1774179074077 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 695, + "timestamp": 1774179074087 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 760, + "timestamp": 1774179074090 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 792, + "timestamp": 1774179074091 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 808, + "timestamp": 1774179074093 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 816, + "timestamp": 1774179074095 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 820, + "timestamp": 1774179074096 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 823, + "timestamp": 1774179074102 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 1089, + "timestamp": 1774179074103 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 1222, + "timestamp": 1774179074106 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 1289, + "timestamp": 1774179074107 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 1322, + "timestamp": 1774179074109 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 1339, + "timestamp": 1774179074110 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 1347, + "timestamp": 1774179074112 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 1351, + "timestamp": 1774179074114 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 1593, + "timestamp": 1774179074120 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 1713, + "timestamp": 1774179074122 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 1773, + "timestamp": 1774179074123 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 1803, + "timestamp": 1774179074125 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 1818, + "timestamp": 1774179074127 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 1826, + "timestamp": 1774179074128 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 1830, + "timestamp": 1774179074130 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 1898, + "timestamp": 1774179074136 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 1931, + "timestamp": 1774179074138 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 1948, + "timestamp": 1774179074139 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 1956, + "timestamp": 1774179074141 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 1960, + "timestamp": 1774179074143 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 1963, + "timestamp": 1774179074146 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 2001, + "timestamp": 1774179074152 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 2020, + "timestamp": 1774179074154 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 2029, + "timestamp": 1774179074155 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 2034, + "timestamp": 1774179074157 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 2037, + "timestamp": 1774179074160 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 2040, + "timestamp": 1774179074167 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 2026, + "timestamp": 1774179074168 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 2019, + "timestamp": 1774179074170 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 2016, + "timestamp": 1774179074171 + }, + { + "x": 377.6253967285156, + "y": 171.45126342773438, + "pressure": 2013, + "timestamp": 1774179074175 + }, + { + "x": 376.6347961425781, + "y": 171.64935302734375, + "pressure": 2014, + "timestamp": 1774179074178 + }, + { + "x": 374.4554138183594, + "y": 172.24365234375, + "pressure": 2013, + "timestamp": 1774179074182 + }, + { + "x": 372.2760314941406, + "y": 172.83795166015625, + "pressure": 2003, + "timestamp": 1774179074184 + }, + { + "x": 370.2947998046875, + "y": 173.23416137695312, + "pressure": 1998, + "timestamp": 1774179074186 + }, + { + "x": 368.7098083496094, + "y": 173.63037109375, + "pressure": 1996, + "timestamp": 1774179074187 + }, + { + "x": 367.12481689453125, + "y": 174.02655029296875, + "pressure": 1995, + "timestamp": 1774179074189 + }, + { + "x": 365.7379455566406, + "y": 174.42276000976562, + "pressure": 1994, + "timestamp": 1774179074191 + }, + { + "x": 364.5491943359375, + "y": 175.01705932617188, + "pressure": 1996, + "timestamp": 1774179074193 + }, + { + "x": 363.3604431152344, + "y": 175.61135864257812, + "pressure": 1995, + "timestamp": 1774179074194 + }, + { + "x": 362.36981201171875, + "y": 176.007568359375, + "pressure": 1994, + "timestamp": 1774179074198 + }, + { + "x": 361.1810607910156, + "y": 176.60186767578125, + "pressure": 1977, + "timestamp": 1774179074200 + }, + { + "x": 360.1904602050781, + "y": 177.1961669921875, + "pressure": 1968, + "timestamp": 1774179074202 + }, + { + "x": 359.39794921875, + "y": 177.59237670898438, + "pressure": 1964, + "timestamp": 1774179074204 + }, + { + "x": 359.001708984375, + "y": 177.79046630859375, + "pressure": 1962, + "timestamp": 1774179074205 + }, + { + "x": 358.6054382324219, + "y": 178.18667602539062, + "pressure": 1961, + "timestamp": 1774179074207 + }, + { + "x": 358.2091979980469, + "y": 178.384765625, + "pressure": 1960, + "timestamp": 1774179074208 + }, + { + "x": 357.8129577636719, + "y": 178.78097534179688, + "pressure": 1962, + "timestamp": 1774179074210 + }, + { + "x": 357.41668701171875, + "y": 179.17718505859375, + "pressure": 1961, + "timestamp": 1774179074214 + }, + { + "x": 357.02044677734375, + "y": 179.57339477539062, + "pressure": 1950, + "timestamp": 1774179074216 + }, + { + "x": 356.42608642578125, + "y": 180.36578369140625, + "pressure": 1945, + "timestamp": 1774179074218 + }, + { + "x": 356.22796630859375, + "y": 180.76199340820312, + "pressure": 1942, + "timestamp": 1774179074220 + }, + { + "x": 355.8316955566406, + "y": 181.15817260742188, + "pressure": 1942, + "timestamp": 1774179074224 + }, + { + "x": 355.2373352050781, + "y": 181.95059204101562, + "pressure": 1941, + "timestamp": 1774179074226 + }, + { + "x": 355.0392150878906, + "y": 182.3468017578125, + "pressure": 1940, + "timestamp": 1774179074230 + }, + { + "x": 355.0392150878906, + "y": 182.3468017578125, + "pressure": 1924, + "timestamp": 1774179074232 + }, + { + "x": 355.0392150878906, + "y": 182.3468017578125, + "pressure": 1916, + "timestamp": 1774179074234 + }, + { + "x": 354.8410949707031, + "y": 182.74298095703125, + "pressure": 1912, + "timestamp": 1774179074236 + }, + { + "x": 354.44482421875, + "y": 183.73348999023438, + "pressure": 1910, + "timestamp": 1774179074237 + }, + { + "x": 354.048583984375, + "y": 184.7239990234375, + "pressure": 1909, + "timestamp": 1774179074239 + }, + { + "x": 353.65234375, + "y": 185.51638793945312, + "pressure": 1911, + "timestamp": 1774179074241 + }, + { + "x": 353.65234375, + "y": 185.91259765625, + "pressure": 1910, + "timestamp": 1774179074242 + }, + { + "x": 353.65234375, + "y": 185.91259765625, + "pressure": 1893, + "timestamp": 1774179074248 + }, + { + "x": 353.65234375, + "y": 185.91259765625, + "pressure": 1885, + "timestamp": 1774179074250 + }, + { + "x": 353.65234375, + "y": 186.30880737304688, + "pressure": 1881, + "timestamp": 1774179074252 + }, + { + "x": 353.65234375, + "y": 187.1011962890625, + "pressure": 1879, + "timestamp": 1774179074253 + }, + { + "x": 353.65234375, + "y": 187.49740600585938, + "pressure": 1878, + "timestamp": 1774179074255 + }, + { + "x": 353.65234375, + "y": 187.49740600585938, + "pressure": 1866, + "timestamp": 1774179074264 + }, + { + "x": 353.65234375, + "y": 187.49740600585938, + "pressure": 1860, + "timestamp": 1774179074266 + }, + { + "x": 353.65234375, + "y": 187.49740600585938, + "pressure": 1857, + "timestamp": 1774179074268 + }, + { + "x": 353.65234375, + "y": 187.49740600585938, + "pressure": 1854, + "timestamp": 1774179074271 + }, + { + "x": 353.8504638671875, + "y": 187.89361572265625, + "pressure": 1856, + "timestamp": 1774179074273 + }, + { + "x": 354.2467041015625, + "y": 188.28982543945312, + "pressure": 1855, + "timestamp": 1774179074274 + }, + { + "x": 354.6429443359375, + "y": 188.68600463867188, + "pressure": 1870, + "timestamp": 1774179074280 + }, + { + "x": 355.4354553222656, + "y": 189.28030395507812, + "pressure": 1878, + "timestamp": 1774179074282 + }, + { + "x": 356.22796630859375, + "y": 189.676513671875, + "pressure": 1882, + "timestamp": 1774179074284 + }, + { + "x": 357.02044677734375, + "y": 190.07272338867188, + "pressure": 1884, + "timestamp": 1774179074285 + }, + { + "x": 357.8129577636719, + "y": 190.46893310546875, + "pressure": 1885, + "timestamp": 1774179074287 + }, + { + "x": 358.6054382324219, + "y": 190.46893310546875, + "pressure": 1887, + "timestamp": 1774179074289 + }, + { + "x": 359.39794921875, + "y": 190.46893310546875, + "pressure": 1886, + "timestamp": 1774179074290 + }, + { + "x": 359.794189453125, + "y": 190.46893310546875, + "pressure": 1888, + "timestamp": 1774179074294 + }, + { + "x": 360.1904602050781, + "y": 190.46893310546875, + "pressure": 1904, + "timestamp": 1774179074296 + }, + { + "x": 360.9829406738281, + "y": 190.27081298828125, + "pressure": 1912, + "timestamp": 1774179074298 + }, + { + "x": 361.97357177734375, + "y": 190.07272338867188, + "pressure": 1916, + "timestamp": 1774179074300 + }, + { + "x": 362.9642028808594, + "y": 189.87460327148438, + "pressure": 1918, + "timestamp": 1774179074301 + }, + { + "x": 363.7566833496094, + "y": 189.47842407226562, + "pressure": 1919, + "timestamp": 1774179074303 + }, + { + "x": 364.5491943359375, + "y": 188.88412475585938, + "pressure": 1921, + "timestamp": 1774179074305 + }, + { + "x": 365.3416748046875, + "y": 188.4879150390625, + "pressure": 1920, + "timestamp": 1774179074306 + }, + { + "x": 366.1341857910156, + "y": 188.09170532226562, + "pressure": 1922, + "timestamp": 1774179074310 + }, + { + "x": 366.9266662597656, + "y": 187.69549560546875, + "pressure": 1942, + "timestamp": 1774179074312 + }, + { + "x": 367.71917724609375, + "y": 187.29931640625, + "pressure": 1952, + "timestamp": 1774179074314 + }, + { + "x": 368.5116882324219, + "y": 186.70501708984375, + "pressure": 1957, + "timestamp": 1774179074316 + }, + { + "x": 369.3041687011719, + "y": 186.1107177734375, + "pressure": 1959, + "timestamp": 1774179074318 + }, + { + "x": 370.0966796875, + "y": 185.51638793945312, + "pressure": 1960, + "timestamp": 1774179074320 + }, + { + "x": 370.88916015625, + "y": 184.92208862304688, + "pressure": 1961, + "timestamp": 1774179074321 + }, + { + "x": 371.6816711425781, + "y": 184.32778930664062, + "pressure": 1963, + "timestamp": 1774179074323 + }, + { + "x": 372.47418212890625, + "y": 183.73348999023438, + "pressure": 1962, + "timestamp": 1774179074326 + }, + { + "x": 373.26666259765625, + "y": 183.13919067382812, + "pressure": 1997, + "timestamp": 1774179074328 + }, + { + "x": 373.66290283203125, + "y": 182.74298095703125, + "pressure": 2015, + "timestamp": 1774179074330 + }, + { + "x": 373.66290283203125, + "y": 182.74298095703125, + "pressure": 2024, + "timestamp": 1774179074332 + }, + { + "x": 374.0591735839844, + "y": 182.3468017578125, + "pressure": 2028, + "timestamp": 1774179074333 + }, + { + "x": 374.8516540527344, + "y": 181.55438232421875, + "pressure": 2030, + "timestamp": 1774179074336 + }, + { + "x": 375.6441650390625, + "y": 180.76199340820312, + "pressure": 2031, + "timestamp": 1774179074337 + }, + { + "x": 376.4366455078125, + "y": 179.771484375, + "pressure": 2032, + "timestamp": 1774179074339 + }, + { + "x": 377.2291564941406, + "y": 178.78097534179688, + "pressure": 2034, + "timestamp": 1774179074343 + }, + { + "x": 378.02166748046875, + "y": 177.79046630859375, + "pressure": 2057, + "timestamp": 1774179074344 + }, + { + "x": 378.81414794921875, + "y": 176.99807739257812, + "pressure": 2069, + "timestamp": 1774179074346 + }, + { + "x": 379.6066589355469, + "y": 176.20565795898438, + "pressure": 2075, + "timestamp": 1774179074348 + }, + { + "x": 380.3991394042969, + "y": 175.21514892578125, + "pressure": 2078, + "timestamp": 1774179074349 + }, + { + "x": 381.191650390625, + "y": 174.22467041015625, + "pressure": 2079, + "timestamp": 1774179074351 + }, + { + "x": 381.984130859375, + "y": 173.23416137695312, + "pressure": 2080, + "timestamp": 1774179074353 + }, + { + "x": 382.7766418457031, + "y": 172.24365234375, + "pressure": 2082, + "timestamp": 1774179074356 + }, + { + "x": 383.56915283203125, + "y": 171.25314331054688, + "pressure": 2081, + "timestamp": 1774179074359 + }, + { + "x": 384.36163330078125, + "y": 170.26263427734375, + "pressure": 2095, + "timestamp": 1774179074360 + }, + { + "x": 385.1541442871094, + "y": 169.27215576171875, + "pressure": 2102, + "timestamp": 1774179074362 + }, + { + "x": 385.9466247558594, + "y": 168.28164672851562, + "pressure": 2105, + "timestamp": 1774179074364 + }, + { + "x": 386.541015625, + "y": 167.2911376953125, + "pressure": 2107, + "timestamp": 1774179074366 + }, + { + "x": 387.1353759765625, + "y": 166.30062866210938, + "pressure": 2108, + "timestamp": 1774179074368 + }, + { + "x": 387.7297668457031, + "y": 165.31011962890625, + "pressure": 2110, + "timestamp": 1774179074369 + }, + { + "x": 388.3241271972656, + "y": 164.31961059570312, + "pressure": 2109, + "timestamp": 1774179074371 + }, + { + "x": 388.7203674316406, + "y": 163.32913208007812, + "pressure": 2111, + "timestamp": 1774179074375 + }, + { + "x": 389.11663818359375, + "y": 162.338623046875, + "pressure": 2118, + "timestamp": 1774179074376 + }, + { + "x": 389.51287841796875, + "y": 161.34811401367188, + "pressure": 2121, + "timestamp": 1774179074379 + }, + { + "x": 389.71099853515625, + "y": 160.951904296875, + "pressure": 2123, + "timestamp": 1774179074380 + }, + { + "x": 389.90911865234375, + "y": 160.55572509765625, + "pressure": 2124, + "timestamp": 1774179074382 + }, + { + "x": 390.3053894042969, + "y": 159.7633056640625, + "pressure": 2126, + "timestamp": 1774179074383 + }, + { + "x": 390.7016296386719, + "y": 158.97091674804688, + "pressure": 2125, + "timestamp": 1774179074385 + }, + { + "x": 391.0978698730469, + "y": 158.17849731445312, + "pressure": 2127, + "timestamp": 1774179074387 + }, + { + "x": 391.2959899902344, + "y": 157.3861083984375, + "pressure": 2126, + "timestamp": 1774179074391 + }, + { + "x": 391.4941101074219, + "y": 156.59368896484375, + "pressure": 2130, + "timestamp": 1774179074392 + }, + { + "x": 391.6922607421875, + "y": 155.80130004882812, + "pressure": 2132, + "timestamp": 1774179074395 + }, + { + "x": 391.890380859375, + "y": 155.00888061523438, + "pressure": 2133, + "timestamp": 1774179074396 + }, + { + "x": 392.0885009765625, + "y": 154.61270141601562, + "pressure": 2134, + "timestamp": 1774179074398 + }, + { + "x": 392.0885009765625, + "y": 154.21649169921875, + "pressure": 2136, + "timestamp": 1774179074399 + }, + { + "x": 392.28662109375, + "y": 153.424072265625, + "pressure": 2135, + "timestamp": 1774179074401 + }, + { + "x": 392.4847412109375, + "y": 152.63168334960938, + "pressure": 2137, + "timestamp": 1774179074403 + }, + { + "x": 392.682861328125, + "y": 151.83926391601562, + "pressure": 2136, + "timestamp": 1774179074408 + }, + { + "x": 392.8809814453125, + "y": 151.44308471679688, + "pressure": 2140, + "timestamp": 1774179074408 + }, + { + "x": 392.8809814453125, + "y": 151.046875, + "pressure": 2143, + "timestamp": 1774179074412 + }, + { + "x": 393.0791320800781, + "y": 150.25448608398438, + "pressure": 2144, + "timestamp": 1774179074414 + }, + { + "x": 393.2772521972656, + "y": 149.8582763671875, + "pressure": 2146, + "timestamp": 1774179074416 + }, + { + "x": 393.2772521972656, + "y": 149.46206665039062, + "pressure": 2148, + "timestamp": 1774179074424 + }, + { + "x": 393.4753723144531, + "y": 148.669677734375, + "pressure": 2149, + "timestamp": 1774179074427 + }, + { + "x": 393.6734924316406, + "y": 148.27346801757812, + "pressure": 2150, + "timestamp": 1774179074429 + }, + { + "x": 393.6734924316406, + "y": 148.27346801757812, + "pressure": 2153, + "timestamp": 1774179074433 + }, + { + "x": 393.6734924316406, + "y": 148.27346801757812, + "pressure": 2191, + "timestamp": 1774179074441 + }, + { + "x": 393.6734924316406, + "y": 148.27346801757812, + "pressure": 2211, + "timestamp": 1774179074443 + }, + { + "x": 393.6734924316406, + "y": 148.27346801757812, + "pressure": 2221, + "timestamp": 1774179074444 + }, + { + "x": 393.6734924316406, + "y": 148.27346801757812, + "pressure": 2226, + "timestamp": 1774179074446 + }, + { + "x": 393.6734924316406, + "y": 148.27346801757812, + "pressure": 2229, + "timestamp": 1774179074449 + }, + { + "x": 393.6734924316406, + "y": 148.27346801757812, + "pressure": 2232, + "timestamp": 1774179074455 + }, + { + "x": 393.6734924316406, + "y": 148.27346801757812, + "pressure": 2274, + "timestamp": 1774179074457 + }, + { + "x": 393.6734924316406, + "y": 148.27346801757812, + "pressure": 2295, + "timestamp": 1774179074459 + }, + { + "x": 393.6734924316406, + "y": 148.27346801757812, + "pressure": 2306, + "timestamp": 1774179074460 + }, + { + "x": 393.6734924316406, + "y": 148.27346801757812, + "pressure": 2311, + "timestamp": 1774179074462 + }, + { + "x": 393.6734924316406, + "y": 148.27346801757812, + "pressure": 2314, + "timestamp": 1774179074464 + }, + { + "x": 393.6734924316406, + "y": 148.27346801757812, + "pressure": 2318, + "timestamp": 1774179074471 + }, + { + "x": 393.6734924316406, + "y": 148.27346801757812, + "pressure": 2313, + "timestamp": 1774179074473 + }, + { + "x": 393.6734924316406, + "y": 148.27346801757812, + "pressure": 2310, + "timestamp": 1774179074475 + }, + { + "x": 393.0791320800781, + "y": 148.669677734375, + "pressure": 2310, + "timestamp": 1774179074487 + }, + { + "x": 392.0885009765625, + "y": 149.26397705078125, + "pressure": 2302, + "timestamp": 1774179074489 + }, + { + "x": 391.2959899902344, + "y": 150.05636596679688, + "pressure": 2298, + "timestamp": 1774179074492 + }, + { + "x": 390.5035095214844, + "y": 150.84878540039062, + "pressure": 2296, + "timestamp": 1774179074493 + }, + { + "x": 389.71099853515625, + "y": 151.64117431640625, + "pressure": 2295, + "timestamp": 1774179074494 + }, + { + "x": 388.91851806640625, + "y": 152.43359375, + "pressure": 2294, + "timestamp": 1774179074496 + }, + { + "x": 388.1260070800781, + "y": 153.22598266601562, + "pressure": 2296, + "timestamp": 1774179074498 + }, + { + "x": 387.33349609375, + "y": 154.01837158203125, + "pressure": 2295, + "timestamp": 1774179074499 + }, + { + "x": 386.7391357421875, + "y": 154.810791015625, + "pressure": 2294, + "timestamp": 1774179074504 + }, + { + "x": 386.144775390625, + "y": 155.60317993164062, + "pressure": 2288, + "timestamp": 1774179074505 + }, + { + "x": 385.5503845214844, + "y": 156.39559936523438, + "pressure": 2285, + "timestamp": 1774179074507 + }, + { + "x": 384.9560241699219, + "y": 157.18798828125, + "pressure": 2283, + "timestamp": 1774179074509 + }, + { + "x": 384.36163330078125, + "y": 157.98040771484375, + "pressure": 2282, + "timestamp": 1774179074510 + }, + { + "x": 383.76727294921875, + "y": 158.77279663085938, + "pressure": 2284, + "timestamp": 1774179074512 + }, + { + "x": 383.1728820800781, + "y": 159.56521606445312, + "pressure": 2283, + "timestamp": 1774179074514 + }, + { + "x": 382.5785217285156, + "y": 160.55572509765625, + "pressure": 2282, + "timestamp": 1774179074516 + }, + { + "x": 381.984130859375, + "y": 161.54620361328125, + "pressure": 2284, + "timestamp": 1774179074519 + }, + { + "x": 381.587890625, + "y": 162.73483276367188, + "pressure": 2278, + "timestamp": 1774179074521 + }, + { + "x": 381.191650390625, + "y": 163.92343139648438, + "pressure": 2275, + "timestamp": 1774179074523 + }, + { + "x": 380.5972595214844, + "y": 164.9139404296875, + "pressure": 2274, + "timestamp": 1774179074525 + }, + { + "x": 380.0028991699219, + "y": 165.9044189453125, + "pressure": 2273, + "timestamp": 1774179074526 + }, + { + "x": 379.6066589355469, + "y": 166.89492797851562, + "pressure": 2275, + "timestamp": 1774179074528 + }, + { + "x": 379.21038818359375, + "y": 167.88543701171875, + "pressure": 2274, + "timestamp": 1774179074530 + }, + { + "x": 378.81414794921875, + "y": 168.87594604492188, + "pressure": 2273, + "timestamp": 1774179074531 + }, + { + "x": 378.41790771484375, + "y": 169.866455078125, + "pressure": 2275, + "timestamp": 1774179074536 + }, + { + "x": 378.02166748046875, + "y": 170.85693359375, + "pressure": 2270, + "timestamp": 1774179074537 + }, + { + "x": 377.6253967285156, + "y": 172.04556274414062, + "pressure": 2268, + "timestamp": 1774179074539 + }, + { + "x": 377.2291564941406, + "y": 173.23416137695312, + "pressure": 2267, + "timestamp": 1774179074541 + }, + { + "x": 376.8329162597656, + "y": 174.22467041015625, + "pressure": 2266, + "timestamp": 1774179074542 + }, + { + "x": 376.4366455078125, + "y": 175.41326904296875, + "pressure": 2268, + "timestamp": 1774179074544 + }, + { + "x": 376.0404052734375, + "y": 176.60186767578125, + "pressure": 2267, + "timestamp": 1774179074546 + }, + { + "x": 375.6441650390625, + "y": 177.59237670898438, + "pressure": 2266, + "timestamp": 1774179074547 + }, + { + "x": 375.2479248046875, + "y": 178.5828857421875, + "pressure": 2268, + "timestamp": 1774179074551 + }, + { + "x": 375.0497741699219, + "y": 179.37527465820312, + "pressure": 2262, + "timestamp": 1774179074553 + }, + { + "x": 374.8516540527344, + "y": 180.16769409179688, + "pressure": 2259, + "timestamp": 1774179074556 + }, + { + "x": 374.8516540527344, + "y": 180.9600830078125, + "pressure": 2257, + "timestamp": 1774179074557 + }, + { + "x": 374.8516540527344, + "y": 181.75250244140625, + "pressure": 2256, + "timestamp": 1774179074558 + }, + { + "x": 374.8516540527344, + "y": 182.148681640625, + "pressure": 2258, + "timestamp": 1774179074560 + }, + { + "x": 374.8516540527344, + "y": 182.54489135742188, + "pressure": 2256, + "timestamp": 1774179074563 + }, + { + "x": 375.0497741699219, + "y": 183.535400390625, + "pressure": 2258, + "timestamp": 1774179074568 + }, + { + "x": 375.2479248046875, + "y": 184.52590942382812, + "pressure": 2240, + "timestamp": 1774179074569 + }, + { + "x": 375.446044921875, + "y": 185.31829833984375, + "pressure": 2231, + "timestamp": 1774179074571 + }, + { + "x": 375.6441650390625, + "y": 186.1107177734375, + "pressure": 2226, + "timestamp": 1774179074573 + }, + { + "x": 375.84228515625, + "y": 186.50689697265625, + "pressure": 2224, + "timestamp": 1774179074574 + }, + { + "x": 376.238525390625, + "y": 186.90310668945312, + "pressure": 2223, + "timestamp": 1774179074583 + }, + { + "x": 377.0310363769531, + "y": 187.49740600585938, + "pressure": 2203, + "timestamp": 1774179074585 + }, + { + "x": 377.8235168457031, + "y": 188.09170532226562, + "pressure": 2193, + "timestamp": 1774179074587 + }, + { + "x": 378.61602783203125, + "y": 188.4879150390625, + "pressure": 2188, + "timestamp": 1774179074589 + }, + { + "x": 379.4085388183594, + "y": 188.88412475585938, + "pressure": 2186, + "timestamp": 1774179074590 + }, + { + "x": 380.2010192871094, + "y": 189.28030395507812, + "pressure": 2185, + "timestamp": 1774179074592 + }, + { + "x": 380.9935302734375, + "y": 189.28030395507812, + "pressure": 2184, + "timestamp": 1774179074593 + }, + { + "x": 382.1822814941406, + "y": 189.28030395507812, + "pressure": 2186, + "timestamp": 1774179074595 + }, + { + "x": 383.3710021972656, + "y": 189.28030395507812, + "pressure": 2185, + "timestamp": 1774179074599 + }, + { + "x": 384.36163330078125, + "y": 188.88412475585938, + "pressure": 2175, + "timestamp": 1774179074601 + }, + { + "x": 385.3522644042969, + "y": 188.4879150390625, + "pressure": 2170, + "timestamp": 1774179074603 + }, + { + "x": 386.3428955078125, + "y": 188.09170532226562, + "pressure": 2168, + "timestamp": 1774179074604 + }, + { + "x": 387.33349609375, + "y": 187.49740600585938, + "pressure": 2167, + "timestamp": 1774179074606 + }, + { + "x": 388.3241271972656, + "y": 186.90310668945312, + "pressure": 2166, + "timestamp": 1774179074608 + }, + { + "x": 389.31475830078125, + "y": 186.30880737304688, + "pressure": 2168, + "timestamp": 1774179074610 + }, + { + "x": 390.3053894042969, + "y": 185.71450805664062, + "pressure": 2167, + "timestamp": 1774179074611 + }, + { + "x": 391.4941101074219, + "y": 185.12020874023438, + "pressure": 2166, + "timestamp": 1774179074615 + }, + { + "x": 392.682861328125, + "y": 184.52590942382812, + "pressure": 2162, + "timestamp": 1774179074617 + }, + { + "x": 393.6734924316406, + "y": 183.93161010742188, + "pressure": 2160, + "timestamp": 1774179074619 + }, + { + "x": 394.66412353515625, + "y": 183.3372802734375, + "pressure": 2159, + "timestamp": 1774179074621 + }, + { + "x": 395.65472412109375, + "y": 182.74298095703125, + "pressure": 2161, + "timestamp": 1774179074622 + }, + { + "x": 396.6453552246094, + "y": 182.148681640625, + "pressure": 2160, + "timestamp": 1774179074624 + }, + { + "x": 397.635986328125, + "y": 181.35629272460938, + "pressure": 2159, + "timestamp": 1774179074626 + }, + { + "x": 398.6266174316406, + "y": 180.56387329101562, + "pressure": 2161, + "timestamp": 1774179074627 + }, + { + "x": 399.6172180175781, + "y": 179.771484375, + "pressure": 2160, + "timestamp": 1774179074632 + }, + { + "x": 400.40972900390625, + "y": 178.97906494140625, + "pressure": 2163, + "timestamp": 1774179074633 + }, + { + "x": 401.2022399902344, + "y": 178.18667602539062, + "pressure": 2165, + "timestamp": 1774179074635 + }, + { + "x": 402.1928405761719, + "y": 177.394287109375, + "pressure": 2166, + "timestamp": 1774179074637 + }, + { + "x": 402.7872314453125, + "y": 176.60186767578125, + "pressure": 2168, + "timestamp": 1774179074638 + }, + { + "x": 403.1834716796875, + "y": 176.20565795898438, + "pressure": 2167, + "timestamp": 1774179074640 + }, + { + "x": 403.5797119140625, + "y": 176.007568359375, + "pressure": 2168, + "timestamp": 1774179074643 + }, + { + "x": 404.3722229003906, + "y": 175.21514892578125, + "pressure": 2167, + "timestamp": 1774179074648 + }, + { + "x": 404.7684631347656, + "y": 174.8189697265625, + "pressure": 2173, + "timestamp": 1774179074649 + }, + { + "x": 404.7684631347656, + "y": 174.8189697265625, + "pressure": 2176, + "timestamp": 1774179074651 + }, + { + "x": 404.7684631347656, + "y": 174.8189697265625, + "pressure": 2179, + "timestamp": 1774179074654 + }, + { + "x": 404.9665832519531, + "y": 174.42276000976562, + "pressure": 2181, + "timestamp": 1774179074656 + }, + { + "x": 405.56097412109375, + "y": 173.63037109375, + "pressure": 2180, + "timestamp": 1774179074658 + }, + { + "x": 406.15533447265625, + "y": 172.83795166015625, + "pressure": 2182, + "timestamp": 1774179074660 + }, + { + "x": 406.55157470703125, + "y": 172.44174194335938, + "pressure": 2181, + "timestamp": 1774179074663 + }, + { + "x": 406.55157470703125, + "y": 172.44174194335938, + "pressure": 2184, + "timestamp": 1774179074667 + }, + { + "x": 406.55157470703125, + "y": 172.44174194335938, + "pressure": 2198, + "timestamp": 1774179074681 + }, + { + "x": 406.55157470703125, + "y": 172.44174194335938, + "pressure": 2205, + "timestamp": 1774179074684 + }, + { + "x": 406.55157470703125, + "y": 172.44174194335938, + "pressure": 2209, + "timestamp": 1774179074685 + }, + { + "x": 406.55157470703125, + "y": 172.44174194335938, + "pressure": 2212, + "timestamp": 1774179074689 + }, + { + "x": 406.55157470703125, + "y": 172.44174194335938, + "pressure": 2215, + "timestamp": 1774179074695 + }, + { + "x": 406.55157470703125, + "y": 172.44174194335938, + "pressure": 2245, + "timestamp": 1774179074697 + }, + { + "x": 406.55157470703125, + "y": 172.44174194335938, + "pressure": 2260, + "timestamp": 1774179074699 + }, + { + "x": 406.55157470703125, + "y": 172.44174194335938, + "pressure": 2268, + "timestamp": 1774179074701 + }, + { + "x": 406.55157470703125, + "y": 172.44174194335938, + "pressure": 2272, + "timestamp": 1774179074703 + }, + { + "x": 406.55157470703125, + "y": 172.44174194335938, + "pressure": 2275, + "timestamp": 1774179074706 + }, + { + "x": 406.55157470703125, + "y": 172.44174194335938, + "pressure": 2271, + "timestamp": 1774179074714 + }, + { + "x": 406.55157470703125, + "y": 172.44174194335938, + "pressure": 2268, + "timestamp": 1774179074717 + }, + { + "x": 406.55157470703125, + "y": 172.44174194335938, + "pressure": 2255, + "timestamp": 1774179074729 + }, + { + "x": 406.55157470703125, + "y": 172.44174194335938, + "pressure": 2248, + "timestamp": 1774179074731 + }, + { + "x": 406.55157470703125, + "y": 172.44174194335938, + "pressure": 2245, + "timestamp": 1774179074733 + }, + { + "x": 406.55157470703125, + "y": 172.44174194335938, + "pressure": 2242, + "timestamp": 1774179074737 + }, + { + "x": 406.55157470703125, + "y": 172.44174194335938, + "pressure": 2236, + "timestamp": 1774179074745 + }, + { + "x": 406.55157470703125, + "y": 172.44174194335938, + "pressure": 2233, + "timestamp": 1774179074748 + }, + { + "x": 406.55157470703125, + "y": 172.44174194335938, + "pressure": 2230, + "timestamp": 1774179074751 + }, + { + "x": 406.55157470703125, + "y": 172.44174194335938, + "pressure": 2227, + "timestamp": 1774179074761 + }, + { + "x": 406.55157470703125, + "y": 172.44174194335938, + "pressure": 2224, + "timestamp": 1774179074765 + }, + { + "x": 406.15533447265625, + "y": 172.83795166015625, + "pressure": 2225, + "timestamp": 1774179074776 + }, + { + "x": 405.36285400390625, + "y": 173.63037109375, + "pressure": 2219, + "timestamp": 1774179074777 + }, + { + "x": 404.5703430175781, + "y": 174.42276000976562, + "pressure": 2216, + "timestamp": 1774179074780 + }, + { + "x": 403.9759826660156, + "y": 175.21514892578125, + "pressure": 2214, + "timestamp": 1774179074781 + }, + { + "x": 403.5797119140625, + "y": 176.007568359375, + "pressure": 2213, + "timestamp": 1774179074783 + }, + { + "x": 403.381591796875, + "y": 176.40377807617188, + "pressure": 2215, + "timestamp": 1774179074784 + }, + { + "x": 403.1834716796875, + "y": 176.79995727539062, + "pressure": 2215, + "timestamp": 1774179074792 + }, + { + "x": 402.589111328125, + "y": 177.79046630859375, + "pressure": 2208, + "timestamp": 1774179074793 + }, + { + "x": 401.9947204589844, + "y": 178.5828857421875, + "pressure": 2205, + "timestamp": 1774179074796 + }, + { + "x": 401.5984802246094, + "y": 179.37527465820312, + "pressure": 2203, + "timestamp": 1774179074797 + }, + { + "x": 401.4003601074219, + "y": 179.771484375, + "pressure": 2202, + "timestamp": 1774179074799 + }, + { + "x": 401.2022399902344, + "y": 180.16769409179688, + "pressure": 2204, + "timestamp": 1774179074808 + }, + { + "x": 401.00408935546875, + "y": 180.9600830078125, + "pressure": 2193, + "timestamp": 1774179074809 + }, + { + "x": 401.00408935546875, + "y": 181.35629272460938, + "pressure": 2188, + "timestamp": 1774179074812 + }, + { + "x": 401.00408935546875, + "y": 181.35629272460938, + "pressure": 2185, + "timestamp": 1774179074813 + }, + { + "x": 400.80596923828125, + "y": 181.75250244140625, + "pressure": 2185, + "timestamp": 1774179074819 + }, + { + "x": 400.60784912109375, + "y": 182.54489135742188, + "pressure": 2184, + "timestamp": 1774179074820 + }, + { + "x": 400.60784912109375, + "y": 182.94110107421875, + "pressure": 2183, + "timestamp": 1774179074824 + }, + { + "x": 400.60784912109375, + "y": 182.94110107421875, + "pressure": 2171, + "timestamp": 1774179074826 + }, + { + "x": 400.60784912109375, + "y": 182.94110107421875, + "pressure": 2165, + "timestamp": 1774179074828 + }, + { + "x": 400.60784912109375, + "y": 182.94110107421875, + "pressure": 2162, + "timestamp": 1774179074829 + }, + { + "x": 400.60784912109375, + "y": 182.94110107421875, + "pressure": 2159, + "timestamp": 1774179074832 + }, + { + "x": 400.60784912109375, + "y": 182.94110107421875, + "pressure": 2145, + "timestamp": 1774179074841 + }, + { + "x": 400.60784912109375, + "y": 182.94110107421875, + "pressure": 2138, + "timestamp": 1774179074844 + }, + { + "x": 400.60784912109375, + "y": 182.94110107421875, + "pressure": 2135, + "timestamp": 1774179074845 + }, + { + "x": 400.60784912109375, + "y": 183.535400390625, + "pressure": 2132, + "timestamp": 1774179074849 + }, + { + "x": 400.80596923828125, + "y": 184.52590942382812, + "pressure": 2134, + "timestamp": 1774179074850 + }, + { + "x": 401.00408935546875, + "y": 185.31829833984375, + "pressure": 2133, + "timestamp": 1774179074852 + }, + { + "x": 401.2022399902344, + "y": 185.71450805664062, + "pressure": 2132, + "timestamp": 1774179074856 + }, + { + "x": 401.2022399902344, + "y": 185.71450805664062, + "pressure": 2117, + "timestamp": 1774179074858 + }, + { + "x": 401.2022399902344, + "y": 185.71450805664062, + "pressure": 2109, + "timestamp": 1774179074860 + }, + { + "x": 401.2022399902344, + "y": 185.71450805664062, + "pressure": 2105, + "timestamp": 1774179074861 + }, + { + "x": 401.2022399902344, + "y": 185.71450805664062, + "pressure": 2102, + "timestamp": 1774179074865 + }, + { + "x": 401.2022399902344, + "y": 185.71450805664062, + "pressure": 2095, + "timestamp": 1774179074874 + }, + { + "x": 401.2022399902344, + "y": 185.71450805664062, + "pressure": 2092, + "timestamp": 1774179074876 + }, + { + "x": 401.5984802246094, + "y": 185.91259765625, + "pressure": 2089, + "timestamp": 1774179074879 + }, + { + "x": 402.3909606933594, + "y": 186.1107177734375, + "pressure": 2091, + "timestamp": 1774179074881 + }, + { + "x": 402.7872314453125, + "y": 186.1107177734375, + "pressure": 2090, + "timestamp": 1774179074882 + }, + { + "x": 403.1834716796875, + "y": 186.1107177734375, + "pressure": 2089, + "timestamp": 1774179074890 + }, + { + "x": 403.5797119140625, + "y": 186.1107177734375, + "pressure": 2088, + "timestamp": 1774179074892 + }, + { + "x": 403.9759826660156, + "y": 186.1107177734375, + "pressure": 2089, + "timestamp": 1774179074895 + }, + { + "x": 404.7684631347656, + "y": 185.91259765625, + "pressure": 2088, + "timestamp": 1774179074897 + }, + { + "x": 405.1647033691406, + "y": 185.71450805664062, + "pressure": 2087, + "timestamp": 1774179074898 + }, + { + "x": 405.56097412109375, + "y": 185.71450805664062, + "pressure": 2088, + "timestamp": 1774179074904 + }, + { + "x": 406.35345458984375, + "y": 185.51638793945312, + "pressure": 2090, + "timestamp": 1774179074906 + }, + { + "x": 407.1459655761719, + "y": 185.31829833984375, + "pressure": 2089, + "timestamp": 1774179074908 + }, + { + "x": 407.9384460449219, + "y": 185.12020874023438, + "pressure": 2091, + "timestamp": 1774179074909 + }, + { + "x": 408.73095703125, + "y": 184.7239990234375, + "pressure": 2090, + "timestamp": 1774179074911 + }, + { + "x": 409.5234680175781, + "y": 184.52590942382812, + "pressure": 2089, + "timestamp": 1774179074913 + }, + { + "x": 410.3159484863281, + "y": 184.12969970703125, + "pressure": 2091, + "timestamp": 1774179074914 + }, + { + "x": 410.7121887207031, + "y": 183.93161010742188, + "pressure": 2090, + "timestamp": 1774179074916 + }, + { + "x": 411.10845947265625, + "y": 183.73348999023438, + "pressure": 2093, + "timestamp": 1774179074922 + }, + { + "x": 411.90093994140625, + "y": 183.13919067382812, + "pressure": 2095, + "timestamp": 1774179074924 + }, + { + "x": 412.6934509277344, + "y": 182.54489135742188, + "pressure": 2096, + "timestamp": 1774179074925 + }, + { + "x": 413.4859619140625, + "y": 181.95059204101562, + "pressure": 2097, + "timestamp": 1774179074927 + }, + { + "x": 414.2784423828125, + "y": 181.55438232421875, + "pressure": 2099, + "timestamp": 1774179074929 + }, + { + "x": 414.6746826171875, + "y": 181.35629272460938, + "pressure": 2098, + "timestamp": 1774179074930 + }, + { + "x": 415.0709533691406, + "y": 181.15817260742188, + "pressure": 2099, + "timestamp": 1774179074937 + }, + { + "x": 415.8634338378906, + "y": 180.56387329101562, + "pressure": 2101, + "timestamp": 1774179074938 + }, + { + "x": 416.65594482421875, + "y": 179.96957397460938, + "pressure": 2102, + "timestamp": 1774179074940 + }, + { + "x": 417.05218505859375, + "y": 179.57339477539062, + "pressure": 2104, + "timestamp": 1774179074942 + }, + { + "x": 417.44842529296875, + "y": 179.37527465820312, + "pressure": 2104, + "timestamp": 1774179074946 + }, + { + "x": 418.2409362792969, + "y": 178.78097534179688, + "pressure": 2103, + "timestamp": 1774179074948 + }, + { + "x": 418.6371765136719, + "y": 178.384765625, + "pressure": 2105, + "timestamp": 1774179074952 + }, + { + "x": 419.033447265625, + "y": 178.18667602539062, + "pressure": 2105, + "timestamp": 1774179074963 + }, + { + "x": 419.825927734375, + "y": 177.59237670898438, + "pressure": 2104, + "timestamp": 1774179074964 + }, + { + "x": 420.22216796875, + "y": 177.1961669921875, + "pressure": 2106, + "timestamp": 1774179074969 + }, + { + "x": 420.22216796875, + "y": 177.1961669921875, + "pressure": 2103, + "timestamp": 1774179074990 + }, + { + "x": 420.22216796875, + "y": 177.1961669921875, + "pressure": 2097, + "timestamp": 1774179075002 + }, + { + "x": 420.22216796875, + "y": 177.1961669921875, + "pressure": 2094, + "timestamp": 1774179075004 + }, + { + "x": 420.22216796875, + "y": 177.1961669921875, + "pressure": 2091, + "timestamp": 1774179075007 + }, + { + "x": 420.22216796875, + "y": 177.1961669921875, + "pressure": 2088, + "timestamp": 1774179075020 + }, + { + "x": 420.6184387207031, + "y": 176.99807739257812, + "pressure": 2089, + "timestamp": 1774179075023 + }, + { + "x": 421.4109191894531, + "y": 176.40377807617188, + "pressure": 2088, + "timestamp": 1774179075025 + }, + { + "x": 421.80718994140625, + "y": 176.007568359375, + "pressure": 2087, + "timestamp": 1774179075027 + }, + { + "x": 421.80718994140625, + "y": 176.007568359375, + "pressure": 2104, + "timestamp": 1774179075034 + }, + { + "x": 421.80718994140625, + "y": 176.007568359375, + "pressure": 2112, + "timestamp": 1774179075036 + }, + { + "x": 421.80718994140625, + "y": 176.007568359375, + "pressure": 2116, + "timestamp": 1774179075038 + }, + { + "x": 421.80718994140625, + "y": 176.007568359375, + "pressure": 2119, + "timestamp": 1774179075041 + }, + { + "x": 421.80718994140625, + "y": 176.007568359375, + "pressure": 2122, + "timestamp": 1774179075044 + }, + { + "x": 422.20343017578125, + "y": 176.007568359375, + "pressure": 2141, + "timestamp": 1774179075050 + }, + { + "x": 422.99591064453125, + "y": 175.80947875976562, + "pressure": 2151, + "timestamp": 1774179075053 + }, + { + "x": 423.3921813964844, + "y": 175.61135864257812, + "pressure": 2156, + "timestamp": 1774179075054 + }, + { + "x": 423.3921813964844, + "y": 175.61135864257812, + "pressure": 2159, + "timestamp": 1774179075057 + }, + { + "x": 423.3921813964844, + "y": 175.61135864257812, + "pressure": 2162, + "timestamp": 1774179075060 + }, + { + "x": 423.3921813964844, + "y": 175.61135864257812, + "pressure": 2182, + "timestamp": 1774179075066 + }, + { + "x": 423.3921813964844, + "y": 175.61135864257812, + "pressure": 2193, + "timestamp": 1774179075069 + }, + { + "x": 423.3921813964844, + "y": 175.61135864257812, + "pressure": 2198, + "timestamp": 1774179075070 + }, + { + "x": 423.3921813964844, + "y": 175.61135864257812, + "pressure": 2201, + "timestamp": 1774179075072 + }, + { + "x": 423.7884216308594, + "y": 175.80947875976562, + "pressure": 2202, + "timestamp": 1774179075073 + }, + { + "x": 424.5809326171875, + "y": 176.007568359375, + "pressure": 2203, + "timestamp": 1774179075075 + }, + { + "x": 424.9771728515625, + "y": 176.20565795898438, + "pressure": 2205, + "timestamp": 1774179075077 + }, + { + "x": 424.9771728515625, + "y": 176.20565795898438, + "pressure": 2215, + "timestamp": 1774179075082 + }, + { + "x": 424.9771728515625, + "y": 176.20565795898438, + "pressure": 2221, + "timestamp": 1774179075085 + }, + { + "x": 424.9771728515625, + "y": 176.20565795898438, + "pressure": 2224, + "timestamp": 1774179075087 + }, + { + "x": 424.9771728515625, + "y": 176.20565795898438, + "pressure": 2225, + "timestamp": 1774179075087 + }, + { + "x": 424.9771728515625, + "y": 176.20565795898438, + "pressure": 2228, + "timestamp": 1774179075091 + }, + { + "x": 425.17529296875, + "y": 176.60186767578125, + "pressure": 2239, + "timestamp": 1774179075098 + }, + { + "x": 425.7696838378906, + "y": 177.394287109375, + "pressure": 2244, + "timestamp": 1774179075101 + }, + { + "x": 425.9678039550781, + "y": 177.79046630859375, + "pressure": 2247, + "timestamp": 1774179075102 + }, + { + "x": 425.9678039550781, + "y": 177.79046630859375, + "pressure": 2251, + "timestamp": 1774179075107 + }, + { + "x": 425.9678039550781, + "y": 178.18667602539062, + "pressure": 2252, + "timestamp": 1774179075113 + }, + { + "x": 426.1659240722656, + "y": 178.97906494140625, + "pressure": 2263, + "timestamp": 1774179075115 + }, + { + "x": 426.3640441894531, + "y": 179.37527465820312, + "pressure": 2269, + "timestamp": 1774179075117 + }, + { + "x": 426.3640441894531, + "y": 179.37527465820312, + "pressure": 2272, + "timestamp": 1774179075118 + }, + { + "x": 426.1659240722656, + "y": 179.771484375, + "pressure": 2276, + "timestamp": 1774179075123 + }, + { + "x": 425.9678039550781, + "y": 180.56387329101562, + "pressure": 2275, + "timestamp": 1774179075125 + }, + { + "x": 425.571533203125, + "y": 181.35629272460938, + "pressure": 2277, + "timestamp": 1774179075129 + }, + { + "x": 425.3734130859375, + "y": 181.75250244140625, + "pressure": 2274, + "timestamp": 1774179075130 + }, + { + "x": 425.3734130859375, + "y": 181.75250244140625, + "pressure": 2271, + "timestamp": 1774179075134 + }, + { + "x": 424.9771728515625, + "y": 182.148681640625, + "pressure": 2272, + "timestamp": 1774179075137 + }, + { + "x": 423.9865417480469, + "y": 182.74298095703125, + "pressure": 2271, + "timestamp": 1774179075139 + }, + { + "x": 423.1940612792969, + "y": 183.535400390625, + "pressure": 2273, + "timestamp": 1774179075141 + }, + { + "x": 422.40155029296875, + "y": 184.12969970703125, + "pressure": 2272, + "timestamp": 1774179075145 + }, + { + "x": 421.6090393066406, + "y": 184.7239990234375, + "pressure": 2257, + "timestamp": 1774179075146 + }, + { + "x": 420.8165588378906, + "y": 185.31829833984375, + "pressure": 2250, + "timestamp": 1774179075149 + }, + { + "x": 420.0240478515625, + "y": 186.1107177734375, + "pressure": 2246, + "timestamp": 1774179075150 + }, + { + "x": 419.6278076171875, + "y": 186.30880737304688, + "pressure": 2244, + "timestamp": 1774179075152 + }, + { + "x": 419.2315673828125, + "y": 186.70501708984375, + "pressure": 2245, + "timestamp": 1774179075155 + }, + { + "x": 418.2409362792969, + "y": 187.29931640625, + "pressure": 2244, + "timestamp": 1774179075157 + }, + { + "x": 417.44842529296875, + "y": 187.69549560546875, + "pressure": 2243, + "timestamp": 1774179075161 + }, + { + "x": 416.65594482421875, + "y": 188.09170532226562, + "pressure": 2184, + "timestamp": 1774179075162 + }, + { + "x": 416.25970458984375, + "y": 188.28982543945312, + "pressure": 2154, + "timestamp": 1774179075165 + }, + { + "x": 416.25970458984375, + "y": 188.28982543945312, + "pressure": 2139, + "timestamp": 1774179075166 + }, + { + "x": 416.25970458984375, + "y": 188.28982543945312, + "pressure": 2132, + "timestamp": 1774179075168 + }, + { + "x": 415.8634338378906, + "y": 188.4879150390625, + "pressure": 2128, + "timestamp": 1774179075169 + }, + { + "x": 415.0709533691406, + "y": 188.88412475585938, + "pressure": 2126, + "timestamp": 1774179075171 + }, + { + "x": 414.2784423828125, + "y": 189.28030395507812, + "pressure": 2125, + "timestamp": 1774179075173 + }, + { + "x": 413.8822021484375, + "y": 189.47842407226562, + "pressure": 2127, + "timestamp": 1774179075177 + }, + { + "x": 413.8822021484375, + "y": 189.47842407226562, + "pressure": 2075, + "timestamp": 1774179075179 + }, + { + "x": 413.8822021484375, + "y": 189.47842407226562, + "pressure": 2049, + "timestamp": 1774179075181 + }, + { + "x": 413.8822021484375, + "y": 189.47842407226562, + "pressure": 2036, + "timestamp": 1774179075182 + }, + { + "x": 413.4859619140625, + "y": 189.676513671875, + "pressure": 2030, + "timestamp": 1774179075184 + }, + { + "x": 412.6934509277344, + "y": 189.87460327148438, + "pressure": 2027, + "timestamp": 1774179075185 + }, + { + "x": 412.2972106933594, + "y": 189.87460327148438, + "pressure": 2025, + "timestamp": 1774179075187 + }, + { + "x": 412.2972106933594, + "y": 189.87460327148438, + "pressure": 1951, + "timestamp": 1774179075195 + }, + { + "x": 412.2972106933594, + "y": 189.87460327148438, + "pressure": 1914, + "timestamp": 1774179075197 + }, + { + "x": 412.2972106933594, + "y": 189.87460327148438, + "pressure": 1895, + "timestamp": 1774179075198 + }, + { + "x": 412.2972106933594, + "y": 189.87460327148438, + "pressure": 1886, + "timestamp": 1774179075200 + }, + { + "x": 412.2972106933594, + "y": 189.87460327148438, + "pressure": 1881, + "timestamp": 1774179075201 + }, + { + "x": 412.2972106933594, + "y": 189.87460327148438, + "pressure": 1878, + "timestamp": 1774179075205 + }, + { + "x": 412.2972106933594, + "y": 189.87460327148438, + "pressure": 1860, + "timestamp": 1774179075211 + }, + { + "x": 412.2972106933594, + "y": 189.87460327148438, + "pressure": 1851, + "timestamp": 1774179075213 + }, + { + "x": 412.2972106933594, + "y": 189.87460327148438, + "pressure": 1847, + "timestamp": 1774179075214 + }, + { + "x": 411.90093994140625, + "y": 189.676513671875, + "pressure": 1845, + "timestamp": 1774179075216 + }, + { + "x": 411.50469970703125, + "y": 189.28030395507812, + "pressure": 1844, + "timestamp": 1774179075218 + }, + { + "x": 411.50469970703125, + "y": 189.28030395507812, + "pressure": 1848, + "timestamp": 1774179075227 + }, + { + "x": 411.50469970703125, + "y": 189.28030395507812, + "pressure": 1851, + "timestamp": 1774179075231 + }, + { + "x": 411.50469970703125, + "y": 189.28030395507812, + "pressure": 1854, + "timestamp": 1774179075235 + }, + { + "x": 411.50469970703125, + "y": 189.28030395507812, + "pressure": 1815, + "timestamp": 1774179075243 + }, + { + "x": 411.50469970703125, + "y": 189.28030395507812, + "pressure": 1797, + "timestamp": 1774179075245 + }, + { + "x": 411.50469970703125, + "y": 189.28030395507812, + "pressure": 1788, + "timestamp": 1774179075246 + }, + { + "x": 411.50469970703125, + "y": 189.28030395507812, + "pressure": 1783, + "timestamp": 1774179075248 + }, + { + "x": 411.50469970703125, + "y": 189.28030395507812, + "pressure": 1780, + "timestamp": 1774179075251 + }, + { + "x": 411.50469970703125, + "y": 189.28030395507812, + "pressure": 1776, + "timestamp": 1774179075259 + }, + { + "x": 411.50469970703125, + "y": 189.28030395507812, + "pressure": 1773, + "timestamp": 1774179075261 + }, + { + "x": 411.50469970703125, + "y": 189.28030395507812, + "pressure": 1768, + "timestamp": 1774179075275 + }, + { + "x": 411.50469970703125, + "y": 189.28030395507812, + "pressure": 1765, + "timestamp": 1774179075279 + }, + { + "x": 411.90093994140625, + "y": 189.28030395507812, + "pressure": 1764, + "timestamp": 1774179075280 + }, + { + "x": 412.6934509277344, + "y": 189.28030395507812, + "pressure": 1766, + "timestamp": 1774179075282 + }, + { + "x": 413.4859619140625, + "y": 189.08221435546875, + "pressure": 1765, + "timestamp": 1774179075283 + }, + { + "x": 414.2784423828125, + "y": 189.08221435546875, + "pressure": 1764, + "timestamp": 1774179075285 + }, + { + "x": 415.0709533691406, + "y": 188.68600463867188, + "pressure": 1766, + "timestamp": 1774179075289 + }, + { + "x": 415.8634338378906, + "y": 188.4879150390625, + "pressure": 1764, + "timestamp": 1774179075291 + }, + { + "x": 416.65594482421875, + "y": 188.28982543945312, + "pressure": 1763, + "timestamp": 1774179075293 + }, + { + "x": 417.44842529296875, + "y": 188.09170532226562, + "pressure": 1765, + "timestamp": 1774179075295 + }, + { + "x": 418.2409362792969, + "y": 187.89361572265625, + "pressure": 1764, + "timestamp": 1774179075296 + }, + { + "x": 419.033447265625, + "y": 187.69549560546875, + "pressure": 1763, + "timestamp": 1774179075298 + }, + { + "x": 419.825927734375, + "y": 187.49740600585938, + "pressure": 1765, + "timestamp": 1774179075299 + }, + { + "x": 420.6184387207031, + "y": 187.29931640625, + "pressure": 1764, + "timestamp": 1774179075301 + }, + { + "x": 421.4109191894531, + "y": 187.1011962890625, + "pressure": 1763, + "timestamp": 1774179075305 + }, + { + "x": 422.20343017578125, + "y": 186.90310668945312, + "pressure": 1787, + "timestamp": 1774179075307 + }, + { + "x": 422.99591064453125, + "y": 186.70501708984375, + "pressure": 1799, + "timestamp": 1774179075309 + }, + { + "x": 423.7884216308594, + "y": 186.50689697265625, + "pressure": 1805, + "timestamp": 1774179075310 + }, + { + "x": 424.5809326171875, + "y": 186.30880737304688, + "pressure": 1808, + "timestamp": 1774179075312 + }, + { + "x": 425.3734130859375, + "y": 186.1107177734375, + "pressure": 1810, + "timestamp": 1774179075314 + }, + { + "x": 426.1659240722656, + "y": 185.71450805664062, + "pressure": 1811, + "timestamp": 1774179075316 + }, + { + "x": 426.9584045410156, + "y": 185.51638793945312, + "pressure": 1813, + "timestamp": 1774179075317 + }, + { + "x": 427.75091552734375, + "y": 185.12020874023438, + "pressure": 1812, + "timestamp": 1774179075321 + }, + { + "x": 428.5434265136719, + "y": 184.7239990234375, + "pressure": 1822, + "timestamp": 1774179075323 + }, + { + "x": 429.3359069824219, + "y": 184.52590942382812, + "pressure": 1827, + "timestamp": 1774179075325 + }, + { + "x": 430.12841796875, + "y": 184.32778930664062, + "pressure": 1830, + "timestamp": 1774179075327 + }, + { + "x": 430.9208984375, + "y": 183.93161010742188, + "pressure": 1831, + "timestamp": 1774179075328 + }, + { + "x": 431.7134094238281, + "y": 183.535400390625, + "pressure": 1832, + "timestamp": 1774179075330 + }, + { + "x": 432.5058898925781, + "y": 183.13919067382812, + "pressure": 1834, + "timestamp": 1774179075332 + }, + { + "x": 433.29840087890625, + "y": 182.94110107421875, + "pressure": 1833, + "timestamp": 1774179075333 + }, + { + "x": 434.0909118652344, + "y": 182.54489135742188, + "pressure": 1835, + "timestamp": 1774179075338 + }, + { + "x": 434.8833923339844, + "y": 182.148681640625, + "pressure": 1841, + "timestamp": 1774179075339 + }, + { + "x": 435.6759033203125, + "y": 181.75250244140625, + "pressure": 1844, + "timestamp": 1774179075341 + }, + { + "x": 436.4683837890625, + "y": 181.35629272460938, + "pressure": 1846, + "timestamp": 1774179075343 + }, + { + "x": 437.2608947753906, + "y": 180.9600830078125, + "pressure": 1847, + "timestamp": 1774179075344 + }, + { + "x": 438.05340576171875, + "y": 180.56387329101562, + "pressure": 1849, + "timestamp": 1774179075346 + }, + { + "x": 438.44964599609375, + "y": 180.36578369140625, + "pressure": 1848, + "timestamp": 1774179075348 + }, + { + "x": 438.84588623046875, + "y": 180.16769409179688, + "pressure": 1849, + "timestamp": 1774179075353 + }, + { + "x": 439.6383972167969, + "y": 179.771484375, + "pressure": 1851, + "timestamp": 1774179075355 + }, + { + "x": 440.4308776855469, + "y": 179.17718505859375, + "pressure": 1852, + "timestamp": 1774179075357 + }, + { + "x": 441.223388671875, + "y": 178.78097534179688, + "pressure": 1853, + "timestamp": 1774179075359 + }, + { + "x": 442.015869140625, + "y": 178.384765625, + "pressure": 1855, + "timestamp": 1774179075360 + }, + { + "x": 442.8083801269531, + "y": 177.79046630859375, + "pressure": 1854, + "timestamp": 1774179075362 + }, + { + "x": 443.2046203613281, + "y": 177.59237670898438, + "pressure": 1856, + "timestamp": 1774179075364 + }, + { + "x": 443.60089111328125, + "y": 177.394287109375, + "pressure": 1858, + "timestamp": 1774179075371 + }, + { + "x": 444.39337158203125, + "y": 176.79995727539062, + "pressure": 1860, + "timestamp": 1774179075373 + }, + { + "x": 445.1858825683594, + "y": 176.20565795898438, + "pressure": 1861, + "timestamp": 1774179075375 + }, + { + "x": 445.9783630371094, + "y": 175.61135864257812, + "pressure": 1863, + "timestamp": 1774179075376 + }, + { + "x": 446.3746337890625, + "y": 175.41326904296875, + "pressure": 1862, + "timestamp": 1774179075378 + }, + { + "x": 446.7708740234375, + "y": 175.21514892578125, + "pressure": 1866, + "timestamp": 1774179075387 + }, + { + "x": 447.5633544921875, + "y": 174.620849609375, + "pressure": 1868, + "timestamp": 1774179075389 + }, + { + "x": 447.9596252441406, + "y": 174.22467041015625, + "pressure": 1869, + "timestamp": 1774179075391 + }, + { + "x": 447.9596252441406, + "y": 174.22467041015625, + "pressure": 1872, + "timestamp": 1774179075396 + }, + { + "x": 447.9596252441406, + "y": 174.22467041015625, + "pressure": 1877, + "timestamp": 1774179075403 + }, + { + "x": 448.3558654785156, + "y": 174.02655029296875, + "pressure": 1880, + "timestamp": 1774179075405 + }, + { + "x": 449.14837646484375, + "y": 173.4322509765625, + "pressure": 1882, + "timestamp": 1774179075407 + }, + { + "x": 449.54461669921875, + "y": 173.03604125976562, + "pressure": 1883, + "timestamp": 1774179075408 + }, + { + "x": 449.54461669921875, + "y": 173.03604125976562, + "pressure": 1886, + "timestamp": 1774179075414 + }, + { + "x": 449.94085693359375, + "y": 172.83795166015625, + "pressure": 1884, + "timestamp": 1774179075430 + }, + { + "x": 450.7333679199219, + "y": 172.24365234375, + "pressure": 1886, + "timestamp": 1774179075434 + }, + { + "x": 451.1296081542969, + "y": 171.84744262695312, + "pressure": 1881, + "timestamp": 1774179075435 + }, + { + "x": 451.1296081542969, + "y": 171.84744262695312, + "pressure": 1878, + "timestamp": 1774179075439 + }, + { + "x": 451.1296081542969, + "y": 171.84744262695312, + "pressure": 1895, + "timestamp": 1774179075451 + }, + { + "x": 451.1296081542969, + "y": 171.84744262695312, + "pressure": 1903, + "timestamp": 1774179075453 + }, + { + "x": 451.1296081542969, + "y": 171.84744262695312, + "pressure": 1907, + "timestamp": 1774179075455 + }, + { + "x": 451.1296081542969, + "y": 171.84744262695312, + "pressure": 1910, + "timestamp": 1774179075458 + }, + { + "x": 451.1296081542969, + "y": 171.84744262695312, + "pressure": 1913, + "timestamp": 1774179075466 + }, + { + "x": 451.1296081542969, + "y": 171.84744262695312, + "pressure": 1996, + "timestamp": 1774179075467 + }, + { + "x": 451.1296081542969, + "y": 171.84744262695312, + "pressure": 2037, + "timestamp": 1774179075470 + }, + { + "x": 451.1296081542969, + "y": 171.84744262695312, + "pressure": 2058, + "timestamp": 1774179075471 + }, + { + "x": 451.1296081542969, + "y": 171.84744262695312, + "pressure": 2068, + "timestamp": 1774179075473 + }, + { + "x": 451.1296081542969, + "y": 171.84744262695312, + "pressure": 2073, + "timestamp": 1774179075474 + }, + { + "x": 451.1296081542969, + "y": 171.84744262695312, + "pressure": 2076, + "timestamp": 1774179075476 + }, + { + "x": 451.1296081542969, + "y": 171.84744262695312, + "pressure": 2096, + "timestamp": 1774179075483 + }, + { + "x": 451.1296081542969, + "y": 171.84744262695312, + "pressure": 2105, + "timestamp": 1774179075486 + }, + { + "x": 451.1296081542969, + "y": 171.84744262695312, + "pressure": 2109, + "timestamp": 1774179075487 + }, + { + "x": 451.1296081542969, + "y": 171.84744262695312, + "pressure": 2112, + "timestamp": 1774179075490 + }, + { + "x": 451.1296081542969, + "y": 171.84744262695312, + "pressure": 2115, + "timestamp": 1774179075495 + }, + { + "x": 451.1296081542969, + "y": 171.84744262695312, + "pressure": 2109, + "timestamp": 1774179075500 + }, + { + "x": 451.1296081542969, + "y": 171.84744262695312, + "pressure": 2106, + "timestamp": 1774179075502 + }, + { + "x": 450.7333679199219, + "y": 172.04556274414062, + "pressure": 2105, + "timestamp": 1774179075508 + }, + { + "x": 449.74273681640625, + "y": 172.24365234375, + "pressure": 2104, + "timestamp": 1774179075510 + }, + { + "x": 448.7521057128906, + "y": 172.44174194335938, + "pressure": 2106, + "timestamp": 1774179075514 + }, + { + "x": 447.5633544921875, + "y": 172.83795166015625, + "pressure": 2098, + "timestamp": 1774179075515 + }, + { + "x": 446.57275390625, + "y": 173.23416137695312, + "pressure": 2094, + "timestamp": 1774179075518 + }, + { + "x": 445.7802429199219, + "y": 173.82846069335938, + "pressure": 2092, + "timestamp": 1774179075519 + }, + { + "x": 444.9877624511719, + "y": 174.42276000976562, + "pressure": 2091, + "timestamp": 1774179075521 + }, + { + "x": 444.19525146484375, + "y": 175.01705932617188, + "pressure": 2093, + "timestamp": 1774179075522 + }, + { + "x": 443.4027404785156, + "y": 175.61135864257812, + "pressure": 2092, + "timestamp": 1774179075524 + }, + { + "x": 442.6102600097656, + "y": 176.20565795898438, + "pressure": 2091, + "timestamp": 1774179075526 + }, + { + "x": 442.2140197753906, + "y": 176.40377807617188, + "pressure": 2093, + "timestamp": 1774179075530 + }, + { + "x": 442.2140197753906, + "y": 176.40377807617188, + "pressure": 2082, + "timestamp": 1774179075531 + }, + { + "x": 441.8177490234375, + "y": 176.79995727539062, + "pressure": 2077, + "timestamp": 1774179075534 + }, + { + "x": 440.8271484375, + "y": 177.79046630859375, + "pressure": 2074, + "timestamp": 1774179075535 + }, + { + "x": 439.8365173339844, + "y": 178.78097534179688, + "pressure": 2073, + "timestamp": 1774179075537 + }, + { + "x": 439.04400634765625, + "y": 179.57339477539062, + "pressure": 2072, + "timestamp": 1774179075539 + }, + { + "x": 438.25152587890625, + "y": 180.36578369140625, + "pressure": 2074, + "timestamp": 1774179075540 + }, + { + "x": 438.05340576171875, + "y": 180.76199340820312, + "pressure": 2073, + "timestamp": 1774179075542 + }, + { + "x": 438.05340576171875, + "y": 180.76199340820312, + "pressure": 2062, + "timestamp": 1774179075547 + }, + { + "x": 438.05340576171875, + "y": 180.76199340820312, + "pressure": 2057, + "timestamp": 1774179075550 + }, + { + "x": 437.6571350097656, + "y": 181.15817260742188, + "pressure": 2054, + "timestamp": 1774179075552 + }, + { + "x": 437.0627746582031, + "y": 181.95059204101562, + "pressure": 2053, + "timestamp": 1774179075553 + }, + { + "x": 436.66650390625, + "y": 182.74298095703125, + "pressure": 2052, + "timestamp": 1774179075555 + }, + { + "x": 436.4683837890625, + "y": 183.13919067382812, + "pressure": 2054, + "timestamp": 1774179075556 + }, + { + "x": 436.4683837890625, + "y": 183.13919067382812, + "pressure": 1953, + "timestamp": 1774179075564 + }, + { + "x": 436.4683837890625, + "y": 183.13919067382812, + "pressure": 1904, + "timestamp": 1774179075566 + }, + { + "x": 436.4683837890625, + "y": 183.13919067382812, + "pressure": 1879, + "timestamp": 1774179075567 + }, + { + "x": 436.4683837890625, + "y": 183.13919067382812, + "pressure": 1867, + "timestamp": 1774179075569 + }, + { + "x": 436.4683837890625, + "y": 183.13919067382812, + "pressure": 1861, + "timestamp": 1774179075571 + }, + { + "x": 436.4683837890625, + "y": 183.13919067382812, + "pressure": 1858, + "timestamp": 1774179075572 + }, + { + "x": 436.4683837890625, + "y": 183.13919067382812, + "pressure": 1855, + "timestamp": 1774179075578 + }, + { + "x": 436.4683837890625, + "y": 183.13919067382812, + "pressure": 1832, + "timestamp": 1774179075580 + }, + { + "x": 436.4683837890625, + "y": 183.13919067382812, + "pressure": 1820, + "timestamp": 1774179075582 + }, + { + "x": 436.4683837890625, + "y": 183.13919067382812, + "pressure": 1814, + "timestamp": 1774179075583 + }, + { + "x": 436.4683837890625, + "y": 183.13919067382812, + "pressure": 1811, + "timestamp": 1774179075585 + }, + { + "x": 436.8646545410156, + "y": 183.3372802734375, + "pressure": 1810, + "timestamp": 1774179075594 + }, + { + "x": 437.2608947753906, + "y": 183.3372802734375, + "pressure": 1812, + "timestamp": 1774179075596 + }, + { + "x": 437.6571350097656, + "y": 183.3372802734375, + "pressure": 1813, + "timestamp": 1774179075598 + }, + { + "x": 438.44964599609375, + "y": 183.3372802734375, + "pressure": 1815, + "timestamp": 1774179075600 + }, + { + "x": 439.24212646484375, + "y": 183.3372802734375, + "pressure": 1814, + "timestamp": 1774179075601 + }, + { + "x": 440.0346374511719, + "y": 183.13919067382812, + "pressure": 1816, + "timestamp": 1774179075603 + }, + { + "x": 440.4308776855469, + "y": 182.94110107421875, + "pressure": 1815, + "timestamp": 1774179075604 + }, + { + "x": 440.8271484375, + "y": 182.74298095703125, + "pressure": 1816, + "timestamp": 1774179075610 + }, + { + "x": 441.61962890625, + "y": 182.3468017578125, + "pressure": 1811, + "timestamp": 1774179075612 + }, + { + "x": 442.4121398925781, + "y": 181.95059204101562, + "pressure": 1808, + "timestamp": 1774179075614 + }, + { + "x": 443.2046203613281, + "y": 181.55438232421875, + "pressure": 1807, + "timestamp": 1774179075615 + }, + { + "x": 443.99713134765625, + "y": 180.9600830078125, + "pressure": 1806, + "timestamp": 1774179075617 + }, + { + "x": 444.39337158203125, + "y": 180.76199340820312, + "pressure": 1808, + "timestamp": 1774179075619 + }, + { + "x": 444.78961181640625, + "y": 180.56387329101562, + "pressure": 1806, + "timestamp": 1774179075622 + }, + { + "x": 445.5821228027344, + "y": 179.96957397460938, + "pressure": 1808, + "timestamp": 1774179075626 + }, + { + "x": 445.9783630371094, + "y": 179.57339477539062, + "pressure": 1810, + "timestamp": 1774179075628 + }, + { + "x": 445.9783630371094, + "y": 179.57339477539062, + "pressure": 1813, + "timestamp": 1774179075632 + }, + { + "x": 446.3746337890625, + "y": 179.17718505859375, + "pressure": 1812, + "timestamp": 1774179075633 + }, + { + "x": 447.1671142578125, + "y": 178.384765625, + "pressure": 1814, + "timestamp": 1774179075635 + }, + { + "x": 447.7615051269531, + "y": 177.59237670898438, + "pressure": 1813, + "timestamp": 1774179075636 + }, + { + "x": 448.1577453613281, + "y": 177.1961669921875, + "pressure": 1812, + "timestamp": 1774179075638 + }, + { + "x": 448.1577453613281, + "y": 177.1961669921875, + "pressure": 1819, + "timestamp": 1774179075644 + }, + { + "x": 448.1577453613281, + "y": 177.1961669921875, + "pressure": 1822, + "timestamp": 1774179075647 + }, + { + "x": 448.1577453613281, + "y": 177.1961669921875, + "pressure": 1825, + "timestamp": 1774179075651 + }, + { + "x": 448.1577453613281, + "y": 177.1961669921875, + "pressure": 1867, + "timestamp": 1774179075660 + }, + { + "x": 448.1577453613281, + "y": 177.1961669921875, + "pressure": 1888, + "timestamp": 1774179075662 + }, + { + "x": 448.1577453613281, + "y": 177.1961669921875, + "pressure": 1898, + "timestamp": 1774179075664 + }, + { + "x": 448.1577453613281, + "y": 177.1961669921875, + "pressure": 1903, + "timestamp": 1774179075665 + }, + { + "x": 448.1577453613281, + "y": 177.1961669921875, + "pressure": 1906, + "timestamp": 1774179075667 + }, + { + "x": 448.1577453613281, + "y": 177.1961669921875, + "pressure": 1910, + "timestamp": 1774179075675 + }, + { + "x": 448.1577453613281, + "y": 177.1961669921875, + "pressure": 1920, + "timestamp": 1774179075676 + }, + { + "x": 448.1577453613281, + "y": 177.1961669921875, + "pressure": 1925, + "timestamp": 1774179075678 + }, + { + "x": 448.1577453613281, + "y": 177.1961669921875, + "pressure": 1928, + "timestamp": 1774179075680 + }, + { + "x": 448.1577453613281, + "y": 177.1961669921875, + "pressure": 1932, + "timestamp": 1774179075685 + }, + { + "x": 448.1577453613281, + "y": 177.1961669921875, + "pressure": 1918, + "timestamp": 1774179075692 + }, + { + "x": 448.1577453613281, + "y": 177.1961669921875, + "pressure": 1911, + "timestamp": 1774179075694 + }, + { + "x": 448.1577453613281, + "y": 177.1961669921875, + "pressure": 1907, + "timestamp": 1774179075696 + }, + { + "x": 448.1577453613281, + "y": 177.1961669921875, + "pressure": 1904, + "timestamp": 1774179075699 + }, + { + "x": 447.9596252441406, + "y": 177.59237670898438, + "pressure": 1905, + "timestamp": 1774179075702 + }, + { + "x": 447.365234375, + "y": 178.384765625, + "pressure": 1904, + "timestamp": 1774179075706 + }, + { + "x": 447.1671142578125, + "y": 178.78097534179688, + "pressure": 1890, + "timestamp": 1774179075708 + }, + { + "x": 447.1671142578125, + "y": 178.78097534179688, + "pressure": 1883, + "timestamp": 1774179075710 + }, + { + "x": 446.968994140625, + "y": 179.17718505859375, + "pressure": 1880, + "timestamp": 1774179075712 + }, + { + "x": 446.57275390625, + "y": 179.96957397460938, + "pressure": 1878, + "timestamp": 1774179075714 + }, + { + "x": 446.3746337890625, + "y": 180.36578369140625, + "pressure": 1877, + "timestamp": 1774179075715 + }, + { + "x": 446.1764831542969, + "y": 180.76199340820312, + "pressure": 1879, + "timestamp": 1774179075717 + }, + { + "x": 445.9783630371094, + "y": 181.55438232421875, + "pressure": 1878, + "timestamp": 1774179075718 + }, + { + "x": 445.7802429199219, + "y": 181.95059204101562, + "pressure": 1877, + "timestamp": 1774179075723 + }, + { + "x": 445.7802429199219, + "y": 181.95059204101562, + "pressure": 1846, + "timestamp": 1774179075724 + }, + { + "x": 445.7802429199219, + "y": 181.95059204101562, + "pressure": 1830, + "timestamp": 1774179075726 + }, + { + "x": 445.7802429199219, + "y": 181.95059204101562, + "pressure": 1822, + "timestamp": 1774179075728 + }, + { + "x": 445.7802429199219, + "y": 182.3468017578125, + "pressure": 1818, + "timestamp": 1774179075729 + }, + { + "x": 445.7802429199219, + "y": 183.13919067382812, + "pressure": 1816, + "timestamp": 1774179075731 + }, + { + "x": 445.7802429199219, + "y": 183.535400390625, + "pressure": 1815, + "timestamp": 1774179075733 + }, + { + "x": 445.7802429199219, + "y": 184.12969970703125, + "pressure": 1797, + "timestamp": 1774179075740 + }, + { + "x": 445.9783630371094, + "y": 185.12020874023438, + "pressure": 1787, + "timestamp": 1774179075742 + }, + { + "x": 446.1764831542969, + "y": 185.91259765625, + "pressure": 1782, + "timestamp": 1774179075744 + }, + { + "x": 446.3746337890625, + "y": 186.30880737304688, + "pressure": 1780, + "timestamp": 1774179075745 + }, + { + "x": 446.3746337890625, + "y": 186.30880737304688, + "pressure": 1770, + "timestamp": 1774179075756 + }, + { + "x": 446.7708740234375, + "y": 186.70501708984375, + "pressure": 1765, + "timestamp": 1774179075758 + }, + { + "x": 447.5633544921875, + "y": 187.29931640625, + "pressure": 1763, + "timestamp": 1774179075760 + }, + { + "x": 448.3558654785156, + "y": 187.69549560546875, + "pressure": 1762, + "timestamp": 1774179075761 + }, + { + "x": 449.14837646484375, + "y": 188.09170532226562, + "pressure": 1761, + "timestamp": 1774179075763 + }, + { + "x": 449.94085693359375, + "y": 188.4879150390625, + "pressure": 1763, + "timestamp": 1774179075765 + }, + { + "x": 450.7333679199219, + "y": 188.88412475585938, + "pressure": 1762, + "timestamp": 1774179075767 + }, + { + "x": 451.5258483886719, + "y": 188.88412475585938, + "pressure": 1761, + "timestamp": 1774179075770 + }, + { + "x": 452.318359375, + "y": 188.88412475585938, + "pressure": 1765, + "timestamp": 1774179075772 + }, + { + "x": 453.1108703613281, + "y": 188.68600463867188, + "pressure": 1767, + "timestamp": 1774179075775 + }, + { + "x": 453.9033508300781, + "y": 188.28982543945312, + "pressure": 1768, + "timestamp": 1774179075776 + }, + { + "x": 454.69586181640625, + "y": 187.89361572265625, + "pressure": 1770, + "timestamp": 1774179075777 + }, + { + "x": 455.48834228515625, + "y": 187.49740600585938, + "pressure": 1769, + "timestamp": 1774179075779 + }, + { + "x": 456.2808532714844, + "y": 187.1011962890625, + "pressure": 1771, + "timestamp": 1774179075781 + }, + { + "x": 457.0733337402344, + "y": 186.70501708984375, + "pressure": 1770, + "timestamp": 1774179075783 + }, + { + "x": 457.8658447265625, + "y": 186.30880737304688, + "pressure": 1769, + "timestamp": 1774179075787 + }, + { + "x": 458.6583557128906, + "y": 185.71450805664062, + "pressure": 1764, + "timestamp": 1774179075788 + }, + { + "x": 459.4508361816406, + "y": 185.12020874023438, + "pressure": 1762, + "timestamp": 1774179075791 + }, + { + "x": 460.24334716796875, + "y": 184.52590942382812, + "pressure": 1761, + "timestamp": 1774179075792 + }, + { + "x": 461.03582763671875, + "y": 183.93161010742188, + "pressure": 1760, + "timestamp": 1774179075794 + }, + { + "x": 461.8283386230469, + "y": 183.3372802734375, + "pressure": 1762, + "timestamp": 1774179075795 + }, + { + "x": 462.6208190917969, + "y": 182.74298095703125, + "pressure": 1761, + "timestamp": 1774179075797 + }, + { + "x": 463.413330078125, + "y": 182.148681640625, + "pressure": 1760, + "timestamp": 1774179075799 + }, + { + "x": 464.2058410644531, + "y": 181.55438232421875, + "pressure": 1762, + "timestamp": 1774179075802 + }, + { + "x": 464.9983215332031, + "y": 180.9600830078125, + "pressure": 1754, + "timestamp": 1774179075804 + }, + { + "x": 465.79083251953125, + "y": 180.36578369140625, + "pressure": 1750, + "timestamp": 1774179075807 + }, + { + "x": 466.58331298828125, + "y": 179.771484375, + "pressure": 1748, + "timestamp": 1774179075808 + }, + { + "x": 466.9795837402344, + "y": 179.37527465820312, + "pressure": 1747, + "timestamp": 1774179075810 + }, + { + "x": 467.1777038574219, + "y": 178.97906494140625, + "pressure": 1746, + "timestamp": 1774179075811 + }, + { + "x": 467.7720642089844, + "y": 178.18667602539062, + "pressure": 1748, + "timestamp": 1774179075813 + }, + { + "x": 468.366455078125, + "y": 177.394287109375, + "pressure": 1747, + "timestamp": 1774179075815 + }, + { + "x": 468.9608154296875, + "y": 176.60186767578125, + "pressure": 1746, + "timestamp": 1774179075819 + }, + { + "x": 469.3570556640625, + "y": 176.20565795898438, + "pressure": 1742, + "timestamp": 1774179075820 + }, + { + "x": 469.7533264160156, + "y": 175.80947875976562, + "pressure": 1739, + "timestamp": 1774179075824 + }, + { + "x": 470.5458068847656, + "y": 175.01705932617188, + "pressure": 1738, + "timestamp": 1774179075826 + }, + { + "x": 471.33831787109375, + "y": 174.22467041015625, + "pressure": 1740, + "timestamp": 1774179075827 + }, + { + "x": 471.93267822265625, + "y": 173.4322509765625, + "pressure": 1739, + "timestamp": 1774179075829 + }, + { + "x": 472.5270690917969, + "y": 172.63986206054688, + "pressure": 1738, + "timestamp": 1774179075831 + }, + { + "x": 472.7251892089844, + "y": 172.24365234375, + "pressure": 1740, + "timestamp": 1774179075835 + }, + { + "x": 472.7251892089844, + "y": 172.24365234375, + "pressure": 1743, + "timestamp": 1774179075839 + }, + { + "x": 472.9233093261719, + "y": 171.84744262695312, + "pressure": 1743, + "timestamp": 1774179075843 + }, + { + "x": 473.5176696777344, + "y": 171.0550537109375, + "pressure": 1742, + "timestamp": 1774179075845 + }, + { + "x": 473.9139404296875, + "y": 170.65884399414062, + "pressure": 1744, + "timestamp": 1774179075847 + }, + { + "x": 473.9139404296875, + "y": 170.65884399414062, + "pressure": 1747, + "timestamp": 1774179075857 + }, + { + "x": 473.9139404296875, + "y": 170.65884399414062, + "pressure": 1750, + "timestamp": 1774179075861 + }, + { + "x": 473.9139404296875, + "y": 170.65884399414062, + "pressure": 1754, + "timestamp": 1774179075869 + }, + { + "x": 473.9139404296875, + "y": 170.65884399414062, + "pressure": 1757, + "timestamp": 1774179075871 + }, + { + "x": 474.112060546875, + "y": 170.26263427734375, + "pressure": 1761, + "timestamp": 1774179075875 + }, + { + "x": 474.50830078125, + "y": 169.866455078125, + "pressure": 1760, + "timestamp": 1774179075877 + }, + { + "x": 474.50830078125, + "y": 169.866455078125, + "pressure": 1798, + "timestamp": 1774179075884 + }, + { + "x": 474.50830078125, + "y": 169.866455078125, + "pressure": 1817, + "timestamp": 1774179075887 + }, + { + "x": 474.50830078125, + "y": 169.866455078125, + "pressure": 1826, + "timestamp": 1774179075888 + }, + { + "x": 474.50830078125, + "y": 169.866455078125, + "pressure": 1831, + "timestamp": 1774179075890 + }, + { + "x": 474.50830078125, + "y": 169.866455078125, + "pressure": 1834, + "timestamp": 1774179075894 + }, + { + "x": 474.50830078125, + "y": 169.866455078125, + "pressure": 1837, + "timestamp": 1774179075899 + }, + { + "x": 474.50830078125, + "y": 169.866455078125, + "pressure": 1865, + "timestamp": 1774179075901 + }, + { + "x": 474.50830078125, + "y": 169.866455078125, + "pressure": 1879, + "timestamp": 1774179075903 + }, + { + "x": 474.50830078125, + "y": 169.866455078125, + "pressure": 1886, + "timestamp": 1774179075904 + }, + { + "x": 474.50830078125, + "y": 169.866455078125, + "pressure": 1889, + "timestamp": 1774179075906 + }, + { + "x": 474.50830078125, + "y": 169.866455078125, + "pressure": 1892, + "timestamp": 1774179075909 + }, + { + "x": 474.50830078125, + "y": 169.866455078125, + "pressure": 1879, + "timestamp": 1774179075917 + }, + { + "x": 474.50830078125, + "y": 169.866455078125, + "pressure": 1872, + "timestamp": 1774179075919 + }, + { + "x": 474.50830078125, + "y": 169.866455078125, + "pressure": 1869, + "timestamp": 1774179075921 + }, + { + "x": 474.50830078125, + "y": 169.866455078125, + "pressure": 1866, + "timestamp": 1774179075924 + }, + { + "x": 474.50830078125, + "y": 169.866455078125, + "pressure": 1853, + "timestamp": 1774179075933 + }, + { + "x": 474.50830078125, + "y": 169.866455078125, + "pressure": 1846, + "timestamp": 1774179075935 + }, + { + "x": 474.50830078125, + "y": 169.866455078125, + "pressure": 1843, + "timestamp": 1774179075936 + }, + { + "x": 474.50830078125, + "y": 169.866455078125, + "pressure": 1840, + "timestamp": 1774179075940 + }, + { + "x": 474.50830078125, + "y": 169.866455078125, + "pressure": 1859, + "timestamp": 1774179075949 + }, + { + "x": 474.50830078125, + "y": 169.866455078125, + "pressure": 1868, + "timestamp": 1774179075951 + }, + { + "x": 474.50830078125, + "y": 169.866455078125, + "pressure": 1873, + "timestamp": 1774179075952 + }, + { + "x": 474.50830078125, + "y": 169.866455078125, + "pressure": 1876, + "timestamp": 1774179075957 + }, + { + "x": 474.50830078125, + "y": 169.866455078125, + "pressure": 1879, + "timestamp": 1774179075959 + }, + { + "x": 474.50830078125, + "y": 169.866455078125, + "pressure": 1917, + "timestamp": 1774179075965 + }, + { + "x": 474.50830078125, + "y": 169.866455078125, + "pressure": 1937, + "timestamp": 1774179075967 + }, + { + "x": 474.50830078125, + "y": 170.26263427734375, + "pressure": 1947, + "timestamp": 1774179075969 + }, + { + "x": 474.7064208984375, + "y": 171.0550537109375, + "pressure": 1952, + "timestamp": 1774179075973 + }, + { + "x": 474.904541015625, + "y": 171.84744262695312, + "pressure": 1954, + "timestamp": 1774179075973 + }, + { + "x": 474.904541015625, + "y": 172.63986206054688, + "pressure": 1955, + "timestamp": 1774179075973 + }, + { + "x": 474.7064208984375, + "y": 173.4322509765625, + "pressure": 1956, + "timestamp": 1774179075975 + }, + { + "x": 474.50830078125, + "y": 174.22467041015625, + "pressure": 1958, + "timestamp": 1774179075979 + }, + { + "x": 474.3101806640625, + "y": 175.01705932617188, + "pressure": 2002, + "timestamp": 1774179075981 + }, + { + "x": 474.112060546875, + "y": 175.80947875976562, + "pressure": 2024, + "timestamp": 1774179075983 + }, + { + "x": 473.7158203125, + "y": 176.60186767578125, + "pressure": 2035, + "timestamp": 1774179075985 + }, + { + "x": 473.3195495605469, + "y": 177.394287109375, + "pressure": 2041, + "timestamp": 1774179075986 + }, + { + "x": 472.9233093261719, + "y": 178.18667602539062, + "pressure": 2044, + "timestamp": 1774179075988 + }, + { + "x": 472.5270690917969, + "y": 178.97906494140625, + "pressure": 2045, + "timestamp": 1774179075990 + }, + { + "x": 472.13079833984375, + "y": 179.771484375, + "pressure": 2046, + "timestamp": 1774179075991 + }, + { + "x": 471.73455810546875, + "y": 180.56387329101562, + "pressure": 2048, + "timestamp": 1774179075995 + }, + { + "x": 471.33831787109375, + "y": 181.55438232421875, + "pressure": 2063, + "timestamp": 1774179075997 + }, + { + "x": 470.94207763671875, + "y": 182.54489135742188, + "pressure": 2070, + "timestamp": 1774179076000 + }, + { + "x": 470.5458068847656, + "y": 183.73348999023438, + "pressure": 2074, + "timestamp": 1774179076001 + }, + { + "x": 470.1495666503906, + "y": 184.92208862304688, + "pressure": 2076, + "timestamp": 1774179076002 + }, + { + "x": 469.7533264160156, + "y": 186.1107177734375, + "pressure": 2077, + "timestamp": 1774179076004 + }, + { + "x": 469.3570556640625, + "y": 187.29931640625, + "pressure": 2079, + "timestamp": 1774179076006 + }, + { + "x": 468.9608154296875, + "y": 188.28982543945312, + "pressure": 2078, + "timestamp": 1774179076007 + }, + { + "x": 468.5645751953125, + "y": 189.28030395507812, + "pressure": 2080, + "timestamp": 1774179076011 + }, + { + "x": 468.1683349609375, + "y": 190.46893310546875, + "pressure": 2110, + "timestamp": 1774179076013 + }, + { + "x": 467.7720642089844, + "y": 191.65753173828125, + "pressure": 2125, + "timestamp": 1774179076015 + }, + { + "x": 467.3758239746094, + "y": 192.84613037109375, + "pressure": 2133, + "timestamp": 1774179076017 + }, + { + "x": 466.9795837402344, + "y": 194.03472900390625, + "pressure": 2137, + "timestamp": 1774179076018 + }, + { + "x": 466.38519287109375, + "y": 195.22332763671875, + "pressure": 2139, + "timestamp": 1774179076020 + }, + { + "x": 465.79083251953125, + "y": 196.41195678710938, + "pressure": 2140, + "timestamp": 1774179076022 + }, + { + "x": 465.39459228515625, + "y": 197.60055541992188, + "pressure": 2142, + "timestamp": 1774179076023 + }, + { + "x": 464.9983215332031, + "y": 198.78915405273438, + "pressure": 2141, + "timestamp": 1774179076027 + }, + { + "x": 464.6020812988281, + "y": 199.97775268554688, + "pressure": 2166, + "timestamp": 1774179076029 + }, + { + "x": 464.0076904296875, + "y": 201.16635131835938, + "pressure": 2179, + "timestamp": 1774179076031 + }, + { + "x": 463.413330078125, + "y": 202.35494995117188, + "pressure": 2185, + "timestamp": 1774179076033 + }, + { + "x": 463.01708984375, + "y": 203.5435791015625, + "pressure": 2188, + "timestamp": 1774179076034 + }, + { + "x": 462.4226989746094, + "y": 204.732177734375, + "pressure": 2190, + "timestamp": 1774179076036 + }, + { + "x": 461.8283386230469, + "y": 205.9207763671875, + "pressure": 2191, + "timestamp": 1774179076038 + }, + { + "x": 461.4320983886719, + "y": 207.109375, + "pressure": 2193, + "timestamp": 1774179076039 + }, + { + "x": 460.83770751953125, + "y": 208.2979736328125, + "pressure": 2192, + "timestamp": 1774179076043 + }, + { + "x": 460.24334716796875, + "y": 209.28851318359375, + "pressure": 2213, + "timestamp": 1774179076045 + }, + { + "x": 459.6489562988281, + "y": 210.27899169921875, + "pressure": 2223, + "timestamp": 1774179076047 + }, + { + "x": 459.0545959472656, + "y": 211.26947021484375, + "pressure": 2228, + "timestamp": 1774179076049 + }, + { + "x": 458.460205078125, + "y": 212.260009765625, + "pressure": 2231, + "timestamp": 1774179076050 + }, + { + "x": 457.8658447265625, + "y": 213.25048828125, + "pressure": 2232, + "timestamp": 1774179076052 + }, + { + "x": 457.271484375, + "y": 214.04290771484375, + "pressure": 2233, + "timestamp": 1774179076054 + }, + { + "x": 456.6770935058594, + "y": 214.8353271484375, + "pressure": 2235, + "timestamp": 1774179076055 + }, + { + "x": 455.8846130371094, + "y": 215.627685546875, + "pressure": 2234, + "timestamp": 1774179076059 + }, + { + "x": 455.29022216796875, + "y": 216.42010498046875, + "pressure": 2242, + "timestamp": 1774179076061 + }, + { + "x": 454.69586181640625, + "y": 217.2125244140625, + "pressure": 2246, + "timestamp": 1774179076063 + }, + { + "x": 454.1014709472656, + "y": 218.00494384765625, + "pressure": 2248, + "timestamp": 1774179076065 + }, + { + "x": 453.5071105957031, + "y": 218.79730224609375, + "pressure": 2249, + "timestamp": 1774179076066 + }, + { + "x": 452.9127197265625, + "y": 219.5897216796875, + "pressure": 2251, + "timestamp": 1774179076068 + }, + { + "x": 452.318359375, + "y": 220.38214111328125, + "pressure": 2250, + "timestamp": 1774179076070 + }, + { + "x": 451.922119140625, + "y": 220.58026123046875, + "pressure": 2252, + "timestamp": 1774179076071 + }, + { + "x": 451.5258483886719, + "y": 220.9764404296875, + "pressure": 2247, + "timestamp": 1774179076077 + }, + { + "x": 450.5352478027344, + "y": 221.9669189453125, + "pressure": 2245, + "timestamp": 1774179076079 + }, + { + "x": 449.74273681640625, + "y": 222.75933837890625, + "pressure": 2244, + "timestamp": 1774179076081 + }, + { + "x": 448.9502258300781, + "y": 223.5517578125, + "pressure": 2243, + "timestamp": 1774179076082 + }, + { + "x": 448.1577453613281, + "y": 224.3441162109375, + "pressure": 2245, + "timestamp": 1774179076084 + }, + { + "x": 447.7615051269531, + "y": 224.542236328125, + "pressure": 2244, + "timestamp": 1774179076086 + }, + { + "x": 447.365234375, + "y": 224.9384765625, + "pressure": 2240, + "timestamp": 1774179076094 + }, + { + "x": 446.57275390625, + "y": 225.53277587890625, + "pressure": 2238, + "timestamp": 1774179076096 + }, + { + "x": 445.7802429199219, + "y": 225.928955078125, + "pressure": 2237, + "timestamp": 1774179076097 + }, + { + "x": 445.3840026855469, + "y": 226.1270751953125, + "pressure": 2236, + "timestamp": 1774179076098 + }, + { + "x": 445.3840026855469, + "y": 226.1270751953125, + "pressure": 2216, + "timestamp": 1774179076109 + }, + { + "x": 445.3840026855469, + "y": 226.1270751953125, + "pressure": 2205, + "timestamp": 1774179076112 + }, + { + "x": 445.3840026855469, + "y": 226.1270751953125, + "pressure": 2199, + "timestamp": 1774179076113 + }, + { + "x": 445.3840026855469, + "y": 226.1270751953125, + "pressure": 2196, + "timestamp": 1774179076114 + }, + { + "x": 445.3840026855469, + "y": 226.1270751953125, + "pressure": 2160, + "timestamp": 1774179076125 + }, + { + "x": 445.3840026855469, + "y": 226.1270751953125, + "pressure": 2143, + "timestamp": 1774179076127 + }, + { + "x": 445.3840026855469, + "y": 226.1270751953125, + "pressure": 2134, + "timestamp": 1774179076129 + }, + { + "x": 445.3840026855469, + "y": 226.1270751953125, + "pressure": 2130, + "timestamp": 1774179076130 + }, + { + "x": 445.3840026855469, + "y": 226.1270751953125, + "pressure": 2127, + "timestamp": 1774179076134 + }, + { + "x": 445.3840026855469, + "y": 226.1270751953125, + "pressure": 2086, + "timestamp": 1774179076141 + }, + { + "x": 445.3840026855469, + "y": 226.1270751953125, + "pressure": 2065, + "timestamp": 1774179076143 + }, + { + "x": 445.3840026855469, + "y": 226.1270751953125, + "pressure": 2055, + "timestamp": 1774179076145 + }, + { + "x": 445.3840026855469, + "y": 226.1270751953125, + "pressure": 2050, + "timestamp": 1774179076146 + }, + { + "x": 445.3840026855469, + "y": 226.1270751953125, + "pressure": 2047, + "timestamp": 1774179076148 + }, + { + "x": 445.3840026855469, + "y": 226.1270751953125, + "pressure": 2025, + "timestamp": 1774179076157 + }, + { + "x": 445.3840026855469, + "y": 226.1270751953125, + "pressure": 2014, + "timestamp": 1774179076159 + }, + { + "x": 445.3840026855469, + "y": 225.7308349609375, + "pressure": 2008, + "timestamp": 1774179076161 + }, + { + "x": 445.5821228027344, + "y": 224.9384765625, + "pressure": 2005, + "timestamp": 1774179076163 + }, + { + "x": 445.7802429199219, + "y": 223.94793701171875, + "pressure": 2004, + "timestamp": 1774179076165 + }, + { + "x": 446.1764831542969, + "y": 222.95745849609375, + "pressure": 2003, + "timestamp": 1774179076166 + }, + { + "x": 446.57275390625, + "y": 221.9669189453125, + "pressure": 2005, + "timestamp": 1774179076168 + }, + { + "x": 446.968994140625, + "y": 220.9764404296875, + "pressure": 2004, + "timestamp": 1774179076172 + }, + { + "x": 447.5633544921875, + "y": 219.98590087890625, + "pressure": 1997, + "timestamp": 1774179076174 + }, + { + "x": 448.1577453613281, + "y": 218.79730224609375, + "pressure": 1994, + "timestamp": 1774179076175 + }, + { + "x": 448.7521057128906, + "y": 217.80682373046875, + "pressure": 1992, + "timestamp": 1774179076177 + }, + { + "x": 449.54461669921875, + "y": 216.81634521484375, + "pressure": 1991, + "timestamp": 1774179076179 + }, + { + "x": 450.33709716796875, + "y": 215.627685546875, + "pressure": 1993, + "timestamp": 1774179076180 + }, + { + "x": 451.1296081542969, + "y": 214.4390869140625, + "pressure": 1992, + "timestamp": 1774179076182 + }, + { + "x": 451.922119140625, + "y": 213.25048828125, + "pressure": 1991, + "timestamp": 1774179076184 + }, + { + "x": 452.714599609375, + "y": 212.0618896484375, + "pressure": 1993, + "timestamp": 1774179076188 + }, + { + "x": 453.7052307128906, + "y": 210.6751708984375, + "pressure": 1994, + "timestamp": 1774179076189 + }, + { + "x": 454.69586181640625, + "y": 209.28851318359375, + "pressure": 1995, + "timestamp": 1774179076191 + }, + { + "x": 455.68646240234375, + "y": 207.90179443359375, + "pressure": 1997, + "timestamp": 1774179076193 + }, + { + "x": 456.8752136230469, + "y": 206.51507568359375, + "pressure": 1996, + "timestamp": 1774179076194 + }, + { + "x": 457.8658447265625, + "y": 205.12838745117188, + "pressure": 1998, + "timestamp": 1774179076196 + }, + { + "x": 458.8564758300781, + "y": 203.74166870117188, + "pressure": 1997, + "timestamp": 1774179076198 + }, + { + "x": 460.04522705078125, + "y": 202.35494995117188, + "pressure": 1996, + "timestamp": 1774179076200 + }, + { + "x": 461.03582763671875, + "y": 200.96826171875, + "pressure": 1998, + "timestamp": 1774179076205 + }, + { + "x": 462.2245788574219, + "y": 199.58154296875, + "pressure": 1997, + "timestamp": 1774179076205 + }, + { + "x": 463.2152099609375, + "y": 198.19485473632812, + "pressure": 1996, + "timestamp": 1774179076208 + }, + { + "x": 464.2058410644531, + "y": 196.80813598632812, + "pressure": 1998, + "timestamp": 1774179076209 + }, + { + "x": 465.1964416503906, + "y": 195.42144775390625, + "pressure": 1997, + "timestamp": 1774179076211 + }, + { + "x": 466.18707275390625, + "y": 193.83663940429688, + "pressure": 1996, + "timestamp": 1774179076212 + }, + { + "x": 467.1777038574219, + "y": 192.2518310546875, + "pressure": 1998, + "timestamp": 1774179076214 + }, + { + "x": 468.1683349609375, + "y": 190.8651123046875, + "pressure": 1997, + "timestamp": 1774179076216 + }, + { + "x": 469.158935546875, + "y": 189.47842407226562, + "pressure": 1996, + "timestamp": 1774179076220 + }, + { + "x": 470.1495666503906, + "y": 188.28982543945312, + "pressure": 1980, + "timestamp": 1774179076221 + }, + { + "x": 471.14019775390625, + "y": 187.1011962890625, + "pressure": 1972, + "timestamp": 1774179076224 + }, + { + "x": 472.13079833984375, + "y": 186.1107177734375, + "pressure": 1968, + "timestamp": 1774179076225 + }, + { + "x": 473.1214294433594, + "y": 185.12020874023438, + "pressure": 1966, + "timestamp": 1774179076227 + }, + { + "x": 473.9139404296875, + "y": 184.12969970703125, + "pressure": 1965, + "timestamp": 1774179076229 + }, + { + "x": 474.7064208984375, + "y": 183.13919067382812, + "pressure": 1967, + "timestamp": 1774179076231 + }, + { + "x": 475.4989318847656, + "y": 182.148681640625, + "pressure": 1966, + "timestamp": 1774179076232 + }, + { + "x": 476.2914123535156, + "y": 181.15817260742188, + "pressure": 1965, + "timestamp": 1774179076236 + }, + { + "x": 476.88580322265625, + "y": 180.36578369140625, + "pressure": 1951, + "timestamp": 1774179076237 + }, + { + "x": 477.28204345703125, + "y": 179.96957397460938, + "pressure": 1944, + "timestamp": 1774179076240 + }, + { + "x": 477.48016357421875, + "y": 179.57339477539062, + "pressure": 1941, + "timestamp": 1774179076241 + }, + { + "x": 478.2726745605469, + "y": 178.78097534179688, + "pressure": 1939, + "timestamp": 1774179076243 + }, + { + "x": 479.065185546875, + "y": 178.18667602539062, + "pressure": 1938, + "timestamp": 1774179076244 + }, + { + "x": 479.857666015625, + "y": 177.59237670898438, + "pressure": 1940, + "timestamp": 1774179076246 + }, + { + "x": 480.6501770019531, + "y": 176.99807739257812, + "pressure": 1939, + "timestamp": 1774179076248 + }, + { + "x": 481.0464172363281, + "y": 176.60186767578125, + "pressure": 1938, + "timestamp": 1774179076252 + }, + { + "x": 481.0464172363281, + "y": 176.60186767578125, + "pressure": 1918, + "timestamp": 1774179076254 + }, + { + "x": 481.4426574707031, + "y": 176.40377807617188, + "pressure": 1908, + "timestamp": 1774179076256 + }, + { + "x": 482.23516845703125, + "y": 175.61135864257812, + "pressure": 1903, + "timestamp": 1774179076257 + }, + { + "x": 483.02764892578125, + "y": 174.8189697265625, + "pressure": 1901, + "timestamp": 1774179076259 + }, + { + "x": 483.8201599121094, + "y": 174.02655029296875, + "pressure": 1900, + "timestamp": 1774179076260 + }, + { + "x": 484.6126708984375, + "y": 173.4322509765625, + "pressure": 1899, + "timestamp": 1774179076262 + }, + { + "x": 485.4051513671875, + "y": 172.83795166015625, + "pressure": 1901, + "timestamp": 1774179076264 + }, + { + "x": 486.1976623535156, + "y": 172.44174194335938, + "pressure": 1900, + "timestamp": 1774179076268 + }, + { + "x": 486.5939025878906, + "y": 172.24365234375, + "pressure": 1881, + "timestamp": 1774179076269 + }, + { + "x": 486.5939025878906, + "y": 172.24365234375, + "pressure": 1871, + "timestamp": 1774179076272 + }, + { + "x": 486.5939025878906, + "y": 172.24365234375, + "pressure": 1866, + "timestamp": 1774179076273 + }, + { + "x": 486.9901428222656, + "y": 172.04556274414062, + "pressure": 1864, + "timestamp": 1774179076275 + }, + { + "x": 487.78265380859375, + "y": 171.45126342773438, + "pressure": 1863, + "timestamp": 1774179076276 + }, + { + "x": 488.57513427734375, + "y": 170.85693359375, + "pressure": 1862, + "timestamp": 1774179076278 + }, + { + "x": 488.9714050292969, + "y": 170.46075439453125, + "pressure": 1864, + "timestamp": 1774179076280 + }, + { + "x": 488.9714050292969, + "y": 170.46075439453125, + "pressure": 1853, + "timestamp": 1774179076286 + }, + { + "x": 488.9714050292969, + "y": 170.46075439453125, + "pressure": 1848, + "timestamp": 1774179076288 + }, + { + "x": 488.9714050292969, + "y": 170.46075439453125, + "pressure": 1845, + "timestamp": 1774179076289 + }, + { + "x": 489.3676452636719, + "y": 170.26263427734375, + "pressure": 1843, + "timestamp": 1774179076292 + }, + { + "x": 490.16015625, + "y": 169.6683349609375, + "pressure": 1845, + "timestamp": 1774179076294 + }, + { + "x": 490.556396484375, + "y": 169.27215576171875, + "pressure": 1844, + "timestamp": 1774179076296 + }, + { + "x": 490.556396484375, + "y": 169.27215576171875, + "pressure": 1824, + "timestamp": 1774179076301 + }, + { + "x": 490.556396484375, + "y": 169.27215576171875, + "pressure": 1815, + "timestamp": 1774179076304 + }, + { + "x": 490.556396484375, + "y": 169.27215576171875, + "pressure": 1810, + "timestamp": 1774179076305 + }, + { + "x": 490.95263671875, + "y": 169.07403564453125, + "pressure": 1807, + "timestamp": 1774179076308 + }, + { + "x": 491.7451477050781, + "y": 168.87594604492188, + "pressure": 1806, + "timestamp": 1774179076310 + }, + { + "x": 492.1413879394531, + "y": 168.67782592773438, + "pressure": 1808, + "timestamp": 1774179076312 + }, + { + "x": 492.1413879394531, + "y": 168.67782592773438, + "pressure": 1804, + "timestamp": 1774179076318 + }, + { + "x": 492.1413879394531, + "y": 168.67782592773438, + "pressure": 1801, + "timestamp": 1774179076321 + }, + { + "x": 492.1413879394531, + "y": 168.67782592773438, + "pressure": 1815, + "timestamp": 1774179076334 + }, + { + "x": 492.1413879394531, + "y": 168.67782592773438, + "pressure": 1821, + "timestamp": 1774179076336 + }, + { + "x": 492.1413879394531, + "y": 168.67782592773438, + "pressure": 1824, + "timestamp": 1774179076337 + }, + { + "x": 492.1413879394531, + "y": 168.67782592773438, + "pressure": 1827, + "timestamp": 1774179076341 + }, + { + "x": 492.3395080566406, + "y": 169.07403564453125, + "pressure": 1829, + "timestamp": 1774179076342 + }, + { + "x": 492.73577880859375, + "y": 169.866455078125, + "pressure": 1828, + "timestamp": 1774179076344 + }, + { + "x": 492.93389892578125, + "y": 170.26263427734375, + "pressure": 1830, + "timestamp": 1774179076348 + }, + { + "x": 492.93389892578125, + "y": 170.65884399414062, + "pressure": 1858, + "timestamp": 1774179076350 + }, + { + "x": 493.13201904296875, + "y": 171.45126342773438, + "pressure": 1872, + "timestamp": 1774179076352 + }, + { + "x": 493.33013916015625, + "y": 171.84744262695312, + "pressure": 1879, + "timestamp": 1774179076353 + }, + { + "x": 493.33013916015625, + "y": 172.24365234375, + "pressure": 1883, + "timestamp": 1774179076355 + }, + { + "x": 493.13201904296875, + "y": 173.03604125976562, + "pressure": 1885, + "timestamp": 1774179076357 + }, + { + "x": 493.13201904296875, + "y": 173.4322509765625, + "pressure": 1886, + "timestamp": 1774179076358 + }, + { + "x": 492.93389892578125, + "y": 173.82846069335938, + "pressure": 1901, + "timestamp": 1774179076366 + }, + { + "x": 492.73577880859375, + "y": 174.8189697265625, + "pressure": 1908, + "timestamp": 1774179076368 + }, + { + "x": 492.3395080566406, + "y": 175.61135864257812, + "pressure": 1911, + "timestamp": 1774179076369 + }, + { + "x": 491.9432678222656, + "y": 176.40377807617188, + "pressure": 1913, + "timestamp": 1774179076371 + }, + { + "x": 491.7451477050781, + "y": 176.79995727539062, + "pressure": 1914, + "timestamp": 1774179076373 + }, + { + "x": 491.3489074707031, + "y": 177.1961669921875, + "pressure": 1917, + "timestamp": 1774179076380 + }, + { + "x": 490.7545166015625, + "y": 177.98858642578125, + "pressure": 1926, + "timestamp": 1774179076382 + }, + { + "x": 489.9620056152344, + "y": 178.78097534179688, + "pressure": 1930, + "timestamp": 1774179076384 + }, + { + "x": 489.5657653808594, + "y": 179.17718505859375, + "pressure": 1932, + "timestamp": 1774179076385 + }, + { + "x": 489.1695251464844, + "y": 179.57339477539062, + "pressure": 1934, + "timestamp": 1774179076389 + }, + { + "x": 488.17889404296875, + "y": 180.16769409179688, + "pressure": 1936, + "timestamp": 1774179076391 + }, + { + "x": 487.38641357421875, + "y": 180.9600830078125, + "pressure": 1935, + "timestamp": 1774179076392 + }, + { + "x": 486.5939025878906, + "y": 181.55438232421875, + "pressure": 1937, + "timestamp": 1774179076396 + }, + { + "x": 485.8013916015625, + "y": 181.95059204101562, + "pressure": 1939, + "timestamp": 1774179076398 + }, + { + "x": 485.0089111328125, + "y": 182.3468017578125, + "pressure": 1938, + "timestamp": 1774179076400 + }, + { + "x": 484.2164001464844, + "y": 182.74298095703125, + "pressure": 1937, + "timestamp": 1774179076401 + }, + { + "x": 483.4239196777344, + "y": 183.13919067382812, + "pressure": 1939, + "timestamp": 1774179076403 + }, + { + "x": 483.02764892578125, + "y": 183.3372802734375, + "pressure": 1938, + "timestamp": 1774179076405 + }, + { + "x": 482.63140869140625, + "y": 183.535400390625, + "pressure": 1939, + "timestamp": 1774179076408 + }, + { + "x": 481.6407775878906, + "y": 184.12969970703125, + "pressure": 1938, + "timestamp": 1774179076413 + }, + { + "x": 480.6501770019531, + "y": 184.52590942382812, + "pressure": 1934, + "timestamp": 1774179076414 + }, + { + "x": 479.6595458984375, + "y": 184.92208862304688, + "pressure": 1932, + "timestamp": 1774179076417 + }, + { + "x": 478.8670349121094, + "y": 185.31829833984375, + "pressure": 1931, + "timestamp": 1774179076418 + }, + { + "x": 478.0745544433594, + "y": 185.71450805664062, + "pressure": 1933, + "timestamp": 1774179076419 + }, + { + "x": 477.6783142089844, + "y": 185.71450805664062, + "pressure": 1932, + "timestamp": 1774179076421 + }, + { + "x": 477.28204345703125, + "y": 185.91259765625, + "pressure": 1932, + "timestamp": 1774179076428 + }, + { + "x": 476.48956298828125, + "y": 186.1107177734375, + "pressure": 1931, + "timestamp": 1774179076430 + }, + { + "x": 476.0932922363281, + "y": 186.30880737304688, + "pressure": 1930, + "timestamp": 1774179076432 + }, + { + "x": 475.6970520019531, + "y": 186.50689697265625, + "pressure": 1932, + "timestamp": 1774179076439 + }, + { + "x": 474.904541015625, + "y": 187.1011962890625, + "pressure": 1931, + "timestamp": 1774179076440 + }, + { + "x": 474.50830078125, + "y": 187.29931640625, + "pressure": 1930, + "timestamp": 1774179076444 + }, + { + "x": 474.50830078125, + "y": 187.29931640625, + "pressure": 1901, + "timestamp": 1774179076446 + }, + { + "x": 474.50830078125, + "y": 187.29931640625, + "pressure": 1887, + "timestamp": 1774179076448 + }, + { + "x": 474.50830078125, + "y": 187.29931640625, + "pressure": 1880, + "timestamp": 1774179076450 + }, + { + "x": 474.50830078125, + "y": 187.29931640625, + "pressure": 1876, + "timestamp": 1774179076451 + }, + { + "x": 474.50830078125, + "y": 187.29931640625, + "pressure": 1873, + "timestamp": 1774179076455 + }, + { + "x": 474.50830078125, + "y": 187.29931640625, + "pressure": 1843, + "timestamp": 1774179076462 + }, + { + "x": 474.50830078125, + "y": 187.29931640625, + "pressure": 1827, + "timestamp": 1774179076464 + }, + { + "x": 474.50830078125, + "y": 187.29931640625, + "pressure": 1819, + "timestamp": 1774179076466 + }, + { + "x": 474.50830078125, + "y": 187.29931640625, + "pressure": 1815, + "timestamp": 1774179076467 + }, + { + "x": 474.50830078125, + "y": 187.29931640625, + "pressure": 1812, + "timestamp": 1774179076471 + }, + { + "x": 474.50830078125, + "y": 187.29931640625, + "pressure": 1738, + "timestamp": 1774179076478 + }, + { + "x": 474.50830078125, + "y": 187.29931640625, + "pressure": 1700, + "timestamp": 1774179076480 + }, + { + "x": 474.50830078125, + "y": 187.29931640625, + "pressure": 1681, + "timestamp": 1774179076482 + }, + { + "x": 474.50830078125, + "y": 187.29931640625, + "pressure": 1672, + "timestamp": 1774179076483 + }, + { + "x": 474.50830078125, + "y": 187.29931640625, + "pressure": 1667, + "timestamp": 1774179076485 + }, + { + "x": 474.50830078125, + "y": 187.29931640625, + "pressure": 1664, + "timestamp": 1774179076488 + }, + { + "x": 474.50830078125, + "y": 187.29931640625, + "pressure": 1700, + "timestamp": 1774179076494 + }, + { + "x": 474.50830078125, + "y": 187.29931640625, + "pressure": 1719, + "timestamp": 1774179076496 + }, + { + "x": 474.50830078125, + "y": 187.29931640625, + "pressure": 1728, + "timestamp": 1774179076498 + }, + { + "x": 474.50830078125, + "y": 187.29931640625, + "pressure": 1733, + "timestamp": 1774179076499 + }, + { + "x": 474.50830078125, + "y": 187.29931640625, + "pressure": 1736, + "timestamp": 1774179076503 + }, + { + "x": 474.50830078125, + "y": 187.29931640625, + "pressure": 1739, + "timestamp": 1774179076509 + }, + { + "x": 474.50830078125, + "y": 187.29931640625, + "pressure": 1750, + "timestamp": 1774179076510 + }, + { + "x": 474.50830078125, + "y": 187.29931640625, + "pressure": 1756, + "timestamp": 1774179076512 + }, + { + "x": 474.50830078125, + "y": 187.29931640625, + "pressure": 1759, + "timestamp": 1774179076515 + }, + { + "x": 474.112060546875, + "y": 187.29931640625, + "pressure": 1760, + "timestamp": 1774179076516 + }, + { + "x": 473.3195495605469, + "y": 187.29931640625, + "pressure": 1761, + "timestamp": 1774179076517 + }, + { + "x": 472.9233093261719, + "y": 187.29931640625, + "pressure": 1763, + "timestamp": 1774179076519 + }, + { + "x": 472.9233093261719, + "y": 187.29931640625, + "pressure": 1751, + "timestamp": 1774179076526 + }, + { + "x": 472.9233093261719, + "y": 187.29931640625, + "pressure": 1744, + "timestamp": 1774179076528 + }, + { + "x": 472.9233093261719, + "y": 187.29931640625, + "pressure": 1741, + "timestamp": 1774179076530 + }, + { + "x": 472.9233093261719, + "y": 187.29931640625, + "pressure": 1738, + "timestamp": 1774179076533 + }, + { + "x": 472.5270690917969, + "y": 187.29931640625, + "pressure": 1740, + "timestamp": 1774179076535 + }, + { + "x": 472.13079833984375, + "y": 187.29931640625, + "pressure": 1739, + "timestamp": 1774179076537 + }, + { + "x": 472.13079833984375, + "y": 187.29931640625, + "pressure": 1723, + "timestamp": 1774179076543 + }, + { + "x": 472.13079833984375, + "y": 187.29931640625, + "pressure": 1716, + "timestamp": 1774179076544 + }, + { + "x": 472.13079833984375, + "y": 187.29931640625, + "pressure": 1712, + "timestamp": 1774179076546 + }, + { + "x": 472.13079833984375, + "y": 187.29931640625, + "pressure": 1709, + "timestamp": 1774179076549 + }, + { + "x": 472.13079833984375, + "y": 187.29931640625, + "pressure": 1622, + "timestamp": 1774179076559 + }, + { + "x": 472.13079833984375, + "y": 187.29931640625, + "pressure": 1579, + "timestamp": 1774179076560 + }, + { + "x": 472.13079833984375, + "y": 187.29931640625, + "pressure": 1557, + "timestamp": 1774179076562 + }, + { + "x": 472.13079833984375, + "y": 187.29931640625, + "pressure": 1546, + "timestamp": 1774179076564 + }, + { + "x": 472.13079833984375, + "y": 187.29931640625, + "pressure": 1541, + "timestamp": 1774179076565 + }, + { + "x": 472.13079833984375, + "y": 187.29931640625, + "pressure": 1538, + "timestamp": 1774179076567 + }, + { + "x": 472.13079833984375, + "y": 187.29931640625, + "pressure": 1546, + "timestamp": 1774179076575 + }, + { + "x": 472.13079833984375, + "y": 187.29931640625, + "pressure": 1551, + "timestamp": 1774179076577 + }, + { + "x": 472.13079833984375, + "y": 187.29931640625, + "pressure": 1554, + "timestamp": 1774179076578 + }, + { + "x": 472.13079833984375, + "y": 187.29931640625, + "pressure": 1558, + "timestamp": 1774179076583 + }, + { + "x": 472.5270690917969, + "y": 187.1011962890625, + "pressure": 1559, + "timestamp": 1774179076589 + }, + { + "x": 473.3195495605469, + "y": 186.50689697265625, + "pressure": 1566, + "timestamp": 1774179076590 + }, + { + "x": 474.112060546875, + "y": 186.1107177734375, + "pressure": 1570, + "timestamp": 1774179076593 + }, + { + "x": 474.904541015625, + "y": 185.71450805664062, + "pressure": 1572, + "timestamp": 1774179076594 + }, + { + "x": 475.6970520019531, + "y": 185.31829833984375, + "pressure": 1573, + "timestamp": 1774179076596 + }, + { + "x": 476.48956298828125, + "y": 185.12020874023438, + "pressure": 1575, + "timestamp": 1774179076597 + }, + { + "x": 477.28204345703125, + "y": 184.7239990234375, + "pressure": 1574, + "timestamp": 1774179076599 + }, + { + "x": 478.0745544433594, + "y": 184.52590942382812, + "pressure": 1576, + "timestamp": 1774179076601 + }, + { + "x": 478.8670349121094, + "y": 184.32778930664062, + "pressure": 1575, + "timestamp": 1774179076605 + }, + { + "x": 479.6595458984375, + "y": 183.93161010742188, + "pressure": 1579, + "timestamp": 1774179076606 + }, + { + "x": 480.4520568847656, + "y": 183.535400390625, + "pressure": 1581, + "timestamp": 1774179076609 + }, + { + "x": 481.2445373535156, + "y": 183.13919067382812, + "pressure": 1582, + "timestamp": 1774179076610 + }, + { + "x": 482.03704833984375, + "y": 182.74298095703125, + "pressure": 1584, + "timestamp": 1774179076612 + }, + { + "x": 482.82952880859375, + "y": 182.3468017578125, + "pressure": 1583, + "timestamp": 1774179076614 + }, + { + "x": 483.6220397949219, + "y": 181.95059204101562, + "pressure": 1585, + "timestamp": 1774179076615 + }, + { + "x": 484.4145202636719, + "y": 181.75250244140625, + "pressure": 1584, + "timestamp": 1774179076617 + }, + { + "x": 485.20703125, + "y": 181.35629272460938, + "pressure": 1583, + "timestamp": 1774179076621 + }, + { + "x": 485.9995422363281, + "y": 180.9600830078125, + "pressure": 1587, + "timestamp": 1774179076622 + }, + { + "x": 486.7920227050781, + "y": 180.76199340820312, + "pressure": 1589, + "timestamp": 1774179076625 + }, + { + "x": 487.58453369140625, + "y": 180.36578369140625, + "pressure": 1590, + "timestamp": 1774179076626 + }, + { + "x": 488.37701416015625, + "y": 179.96957397460938, + "pressure": 1592, + "timestamp": 1774179076628 + }, + { + "x": 489.1695251464844, + "y": 179.57339477539062, + "pressure": 1591, + "timestamp": 1774179076630 + }, + { + "x": 489.9620056152344, + "y": 179.17718505859375, + "pressure": 1593, + "timestamp": 1774179076631 + }, + { + "x": 490.7545166015625, + "y": 178.78097534179688, + "pressure": 1592, + "timestamp": 1774179076633 + }, + { + "x": 491.5470275878906, + "y": 178.18667602539062, + "pressure": 1591, + "timestamp": 1774179076637 + }, + { + "x": 492.3395080566406, + "y": 177.79046630859375, + "pressure": 1582, + "timestamp": 1774179076638 + }, + { + "x": 493.13201904296875, + "y": 177.394287109375, + "pressure": 1577, + "timestamp": 1774179076641 + }, + { + "x": 493.92449951171875, + "y": 176.99807739257812, + "pressure": 1575, + "timestamp": 1774179076645 + }, + { + "x": 494.7170104980469, + "y": 176.40377807617188, + "pressure": 1574, + "timestamp": 1774179076645 + }, + { + "x": 495.509521484375, + "y": 175.80947875976562, + "pressure": 1573, + "timestamp": 1774179076645 + }, + { + "x": 496.302001953125, + "y": 175.21514892578125, + "pressure": 1575, + "timestamp": 1774179076647 + }, + { + "x": 497.0945129394531, + "y": 174.620849609375, + "pressure": 1574, + "timestamp": 1774179076649 + }, + { + "x": 497.4907531738281, + "y": 174.42276000976562, + "pressure": 1573, + "timestamp": 1774179076653 + }, + { + "x": 497.8869934082031, + "y": 174.22467041015625, + "pressure": 1520, + "timestamp": 1774179076655 + }, + { + "x": 498.67950439453125, + "y": 173.82846069335938, + "pressure": 1493, + "timestamp": 1774179076657 + }, + { + "x": 499.47198486328125, + "y": 173.23416137695312, + "pressure": 1480, + "timestamp": 1774179076659 + }, + { + "x": 500.2644958496094, + "y": 172.63986206054688, + "pressure": 1473, + "timestamp": 1774179076660 + }, + { + "x": 501.0570068359375, + "y": 172.04556274414062, + "pressure": 1470, + "timestamp": 1774179076661 + }, + { + "x": 501.8494873046875, + "y": 171.45126342773438, + "pressure": 1468, + "timestamp": 1774179076663 + }, + { + "x": 502.6419982910156, + "y": 170.85693359375, + "pressure": 1467, + "timestamp": 1774179076665 + }, + { + "x": 503.0382385253906, + "y": 170.46075439453125, + "pressure": 1469, + "timestamp": 1774179076669 + }, + { + "x": 503.0382385253906, + "y": 170.46075439453125, + "pressure": 1449, + "timestamp": 1774179076671 + }, + { + "x": 503.0382385253906, + "y": 170.46075439453125, + "pressure": 1439, + "timestamp": 1774179076673 + }, + { + "x": 503.0382385253906, + "y": 170.46075439453125, + "pressure": 1434, + "timestamp": 1774179076674 + }, + { + "x": 503.4344787597656, + "y": 170.26263427734375, + "pressure": 1431, + "timestamp": 1774179076676 + }, + { + "x": 504.22698974609375, + "y": 169.6683349609375, + "pressure": 1430, + "timestamp": 1774179076677 + }, + { + "x": 504.62322998046875, + "y": 169.27215576171875, + "pressure": 1429, + "timestamp": 1774179076679 + }, + { + "x": 504.62322998046875, + "y": 169.27215576171875, + "pressure": 1442, + "timestamp": 1774179076687 + }, + { + "x": 504.62322998046875, + "y": 169.27215576171875, + "pressure": 1448, + "timestamp": 1774179076689 + }, + { + "x": 504.62322998046875, + "y": 169.27215576171875, + "pressure": 1451, + "timestamp": 1774179076690 + }, + { + "x": 504.62322998046875, + "y": 169.27215576171875, + "pressure": 1454, + "timestamp": 1774179076694 + }, + { + "x": 504.62322998046875, + "y": 169.27215576171875, + "pressure": 1457, + "timestamp": 1774179076701 + }, + { + "x": 504.62322998046875, + "y": 169.27215576171875, + "pressure": 1499, + "timestamp": 1774179076703 + }, + { + "x": 504.62322998046875, + "y": 169.27215576171875, + "pressure": 1520, + "timestamp": 1774179076705 + }, + { + "x": 504.62322998046875, + "y": 169.27215576171875, + "pressure": 1531, + "timestamp": 1774179076706 + }, + { + "x": 504.62322998046875, + "y": 169.27215576171875, + "pressure": 1536, + "timestamp": 1774179076708 + }, + { + "x": 504.62322998046875, + "y": 169.27215576171875, + "pressure": 1539, + "timestamp": 1774179076710 + }, + { + "x": 504.62322998046875, + "y": 169.27215576171875, + "pressure": 1543, + "timestamp": 1774179076717 + }, + { + "x": 504.62322998046875, + "y": 169.27215576171875, + "pressure": 1551, + "timestamp": 1774179076719 + }, + { + "x": 504.62322998046875, + "y": 169.27215576171875, + "pressure": 1555, + "timestamp": 1774179076721 + }, + { + "x": 504.62322998046875, + "y": 169.27215576171875, + "pressure": 1558, + "timestamp": 1774179076724 + }, + { + "x": 504.62322998046875, + "y": 169.27215576171875, + "pressure": 1561, + "timestamp": 1774179076729 + }, + { + "x": 504.42510986328125, + "y": 169.866455078125, + "pressure": 1560, + "timestamp": 1774179076733 + }, + { + "x": 504.22698974609375, + "y": 170.85693359375, + "pressure": 1584, + "timestamp": 1774179076735 + }, + { + "x": 503.83074951171875, + "y": 172.04556274414062, + "pressure": 1596, + "timestamp": 1774179076737 + }, + { + "x": 503.4344787597656, + "y": 173.23416137695312, + "pressure": 1602, + "timestamp": 1774179076738 + }, + { + "x": 503.0382385253906, + "y": 174.42276000976562, + "pressure": 1605, + "timestamp": 1774179076740 + }, + { + "x": 502.6419982910156, + "y": 175.61135864257812, + "pressure": 1606, + "timestamp": 1774179076742 + }, + { + "x": 502.047607421875, + "y": 176.79995727539062, + "pressure": 1607, + "timestamp": 1774179076743 + }, + { + "x": 501.4532470703125, + "y": 177.98858642578125, + "pressure": 1609, + "timestamp": 1774179076745 + }, + { + "x": 500.8588562011719, + "y": 179.17718505859375, + "pressure": 1608, + "timestamp": 1774179076749 + }, + { + "x": 500.2644958496094, + "y": 180.56387329101562, + "pressure": 1622, + "timestamp": 1774179076751 + }, + { + "x": 499.6701354980469, + "y": 181.95059204101562, + "pressure": 1629, + "timestamp": 1774179076753 + }, + { + "x": 498.87762451171875, + "y": 183.535400390625, + "pressure": 1632, + "timestamp": 1774179076754 + }, + { + "x": 498.28326416015625, + "y": 185.12020874023438, + "pressure": 1634, + "timestamp": 1774179076756 + }, + { + "x": 497.4907531738281, + "y": 186.50689697265625, + "pressure": 1635, + "timestamp": 1774179076758 + }, + { + "x": 496.8963928222656, + "y": 187.89361572265625, + "pressure": 1637, + "timestamp": 1774179076759 + }, + { + "x": 496.1038818359375, + "y": 189.28030395507812, + "pressure": 1636, + "timestamp": 1774179076761 + }, + { + "x": 495.509521484375, + "y": 190.66702270507812, + "pressure": 1638, + "timestamp": 1774179076766 + }, + { + "x": 494.9151306152344, + "y": 192.05374145507812, + "pressure": 1673, + "timestamp": 1774179076767 + }, + { + "x": 494.3207702636719, + "y": 193.24234008789062, + "pressure": 1690, + "timestamp": 1774179076769 + }, + { + "x": 493.72637939453125, + "y": 194.43093872070312, + "pressure": 1699, + "timestamp": 1774179076770 + }, + { + "x": 493.13201904296875, + "y": 195.61953735351562, + "pressure": 1703, + "timestamp": 1774179076772 + }, + { + "x": 492.73577880859375, + "y": 197.00625610351562, + "pressure": 1705, + "timestamp": 1774179076774 + }, + { + "x": 492.3395080566406, + "y": 198.3929443359375, + "pressure": 1706, + "timestamp": 1774179076776 + }, + { + "x": 491.9432678222656, + "y": 199.58154296875, + "pressure": 1707, + "timestamp": 1774179076777 + }, + { + "x": 491.5470275878906, + "y": 200.77017211914062, + "pressure": 1709, + "timestamp": 1774179076782 + }, + { + "x": 490.95263671875, + "y": 201.95877075195312, + "pressure": 1805, + "timestamp": 1774179076783 + }, + { + "x": 490.3582763671875, + "y": 203.14736938476562, + "pressure": 1853, + "timestamp": 1774179076785 + }, + { + "x": 489.7638854980469, + "y": 204.33596801757812, + "pressure": 1877, + "timestamp": 1774179076787 + }, + { + "x": 489.1695251464844, + "y": 205.52456665039062, + "pressure": 1889, + "timestamp": 1774179076788 + }, + { + "x": 488.57513427734375, + "y": 206.51507568359375, + "pressure": 1895, + "timestamp": 1774179076790 + }, + { + "x": 487.98077392578125, + "y": 207.50558471679688, + "pressure": 1898, + "timestamp": 1774179076792 + }, + { + "x": 487.38641357421875, + "y": 208.49609375, + "pressure": 1899, + "timestamp": 1774179076793 + }, + { + "x": 486.7920227050781, + "y": 209.28851318359375, + "pressure": 1900, + "timestamp": 1774179076798 + }, + { + "x": 486.1976623535156, + "y": 210.08087158203125, + "pressure": 1909, + "timestamp": 1774179076799 + }, + { + "x": 485.603271484375, + "y": 210.873291015625, + "pressure": 1913, + "timestamp": 1774179076801 + }, + { + "x": 485.0089111328125, + "y": 211.66571044921875, + "pressure": 1915, + "timestamp": 1774179076803 + }, + { + "x": 484.4145202636719, + "y": 212.4581298828125, + "pressure": 1916, + "timestamp": 1774179076804 + }, + { + "x": 483.8201599121094, + "y": 213.25048828125, + "pressure": 1917, + "timestamp": 1774179076806 + }, + { + "x": 483.6220397949219, + "y": 213.646728515625, + "pressure": 1919, + "timestamp": 1774179076808 + }, + { + "x": 483.2257995605469, + "y": 214.04290771484375, + "pressure": 1920, + "timestamp": 1774179076813 + }, + { + "x": 482.43328857421875, + "y": 214.8353271484375, + "pressure": 1918, + "timestamp": 1774179076815 + }, + { + "x": 481.6407775878906, + "y": 215.627685546875, + "pressure": 1917, + "timestamp": 1774179076817 + }, + { + "x": 480.8482971191406, + "y": 216.42010498046875, + "pressure": 1919, + "timestamp": 1774179076819 + }, + { + "x": 480.4520568847656, + "y": 216.61822509765625, + "pressure": 1918, + "timestamp": 1774179076820 + }, + { + "x": 480.0557861328125, + "y": 217.014404296875, + "pressure": 1917, + "timestamp": 1774179076830 + }, + { + "x": 479.6595458984375, + "y": 217.2125244140625, + "pressure": 1904, + "timestamp": 1774179076831 + }, + { + "x": 479.6595458984375, + "y": 217.2125244140625, + "pressure": 1897, + "timestamp": 1774179076833 + }, + { + "x": 479.6595458984375, + "y": 217.2125244140625, + "pressure": 1894, + "timestamp": 1774179076835 + }, + { + "x": 479.6595458984375, + "y": 217.2125244140625, + "pressure": 1891, + "timestamp": 1774179076838 + }, + { + "x": 479.6595458984375, + "y": 217.2125244140625, + "pressure": 1791, + "timestamp": 1774179076847 + }, + { + "x": 479.6595458984375, + "y": 217.2125244140625, + "pressure": 1741, + "timestamp": 1774179076849 + }, + { + "x": 479.6595458984375, + "y": 217.2125244140625, + "pressure": 1716, + "timestamp": 1774179076851 + }, + { + "x": 479.6595458984375, + "y": 217.2125244140625, + "pressure": 1703, + "timestamp": 1774179076852 + }, + { + "x": 479.6595458984375, + "y": 217.2125244140625, + "pressure": 1697, + "timestamp": 1774179076854 + }, + { + "x": 479.6595458984375, + "y": 217.2125244140625, + "pressure": 1694, + "timestamp": 1774179076856 + }, + { + "x": 479.6595458984375, + "y": 217.2125244140625, + "pressure": 1691, + "timestamp": 1774179076861 + }, + { + "x": 479.6595458984375, + "y": 217.2125244140625, + "pressure": 1665, + "timestamp": 1774179076863 + }, + { + "x": 479.46142578125, + "y": 216.81634521484375, + "pressure": 1652, + "timestamp": 1774179076865 + }, + { + "x": 479.2633056640625, + "y": 216.02392578125, + "pressure": 1646, + "timestamp": 1774179076867 + }, + { + "x": 479.2633056640625, + "y": 215.03338623046875, + "pressure": 1643, + "timestamp": 1774179076868 + }, + { + "x": 479.2633056640625, + "y": 214.04290771484375, + "pressure": 1641, + "timestamp": 1774179076870 + }, + { + "x": 479.6595458984375, + "y": 213.05242919921875, + "pressure": 1640, + "timestamp": 1774179076872 + }, + { + "x": 480.0557861328125, + "y": 211.86376953125, + "pressure": 1642, + "timestamp": 1774179076874 + }, + { + "x": 480.8482971191406, + "y": 210.6751708984375, + "pressure": 1641, + "timestamp": 1774179076878 + }, + { + "x": 481.6407775878906, + "y": 209.09039306640625, + "pressure": 1636, + "timestamp": 1774179076879 + }, + { + "x": 482.63140869140625, + "y": 207.50558471679688, + "pressure": 1634, + "timestamp": 1774179076882 + }, + { + "x": 483.8201599121094, + "y": 205.9207763671875, + "pressure": 1633, + "timestamp": 1774179076883 + }, + { + "x": 485.0089111328125, + "y": 204.13787841796875, + "pressure": 1632, + "timestamp": 1774179076884 + }, + { + "x": 486.1976623535156, + "y": 202.35494995117188, + "pressure": 1634, + "timestamp": 1774179076886 + }, + { + "x": 487.38641357421875, + "y": 200.37396240234375, + "pressure": 1633, + "timestamp": 1774179076888 + }, + { + "x": 488.57513427734375, + "y": 198.591064453125, + "pressure": 1632, + "timestamp": 1774179076890 + }, + { + "x": 489.7638854980469, + "y": 196.61004638671875, + "pressure": 1634, + "timestamp": 1774179076894 + }, + { + "x": 491.1507568359375, + "y": 194.6290283203125, + "pressure": 1549, + "timestamp": 1774179076895 + }, + { + "x": 492.5376281738281, + "y": 192.44992065429688, + "pressure": 1507, + "timestamp": 1774179076897 + }, + { + "x": 493.92449951171875, + "y": 190.46893310546875, + "pressure": 1486, + "timestamp": 1774179076899 + }, + { + "x": 495.509521484375, + "y": 188.4879150390625, + "pressure": 1475, + "timestamp": 1774179076901 + }, + { + "x": 497.0945129394531, + "y": 186.70501708984375, + "pressure": 1470, + "timestamp": 1774179076902 + }, + { + "x": 498.67950439453125, + "y": 184.92208862304688, + "pressure": 1467, + "timestamp": 1774179076904 + }, + { + "x": 500.2644958496094, + "y": 183.13919067382812, + "pressure": 1466, + "timestamp": 1774179076906 + }, + { + "x": 502.047607421875, + "y": 181.35629272460938, + "pressure": 1465, + "timestamp": 1774179076910 + }, + { + "x": 503.83074951171875, + "y": 179.771484375, + "pressure": 1467, + "timestamp": 1774179076911 + }, + { + "x": 505.6138610839844, + "y": 178.18667602539062, + "pressure": 1468, + "timestamp": 1774179076914 + }, + { + "x": 507.39697265625, + "y": 176.60186767578125, + "pressure": 1470, + "timestamp": 1774179076915 + }, + { + "x": 508.9819641113281, + "y": 175.21514892578125, + "pressure": 1469, + "timestamp": 1774179076917 + }, + { + "x": 510.5669860839844, + "y": 173.82846069335938, + "pressure": 1471, + "timestamp": 1774179076918 + }, + { + "x": 512.1519775390625, + "y": 172.63986206054688, + "pressure": 1470, + "timestamp": 1774179076920 + }, + { + "x": 513.7369384765625, + "y": 171.64935302734375, + "pressure": 1469, + "timestamp": 1774179076922 + }, + { + "x": 515.3219604492188, + "y": 170.65884399414062, + "pressure": 1471, + "timestamp": 1774179076926 + }, + { + "x": 516.7088623046875, + "y": 169.866455078125, + "pressure": 1461, + "timestamp": 1774179076928 + }, + { + "x": 518.095703125, + "y": 169.07403564453125, + "pressure": 1456, + "timestamp": 1774179076929 + }, + { + "x": 519.4825439453125, + "y": 168.08352661132812, + "pressure": 1454, + "timestamp": 1774179076931 + }, + { + "x": 520.8694458007812, + "y": 167.2911376953125, + "pressure": 1453, + "timestamp": 1774179076932 + }, + { + "x": 522.25634765625, + "y": 166.69683837890625, + "pressure": 1452, + "timestamp": 1774179076934 + }, + { + "x": 523.445068359375, + "y": 166.1025390625, + "pressure": 1454, + "timestamp": 1774179076936 + }, + { + "x": 524.6337890625, + "y": 165.50823974609375, + "pressure": 1453, + "timestamp": 1774179076938 + }, + { + "x": 525.8225708007812, + "y": 165.11203002929688, + "pressure": 1452, + "timestamp": 1774179076942 + }, + { + "x": 526.8131713867188, + "y": 164.9139404296875, + "pressure": 1439, + "timestamp": 1774179076943 + }, + { + "x": 527.8038330078125, + "y": 164.7158203125, + "pressure": 1432, + "timestamp": 1774179076946 + }, + { + "x": 528.79443359375, + "y": 164.51773071289062, + "pressure": 1429, + "timestamp": 1774179076947 + }, + { + "x": 529.7850341796875, + "y": 164.31961059570312, + "pressure": 1427, + "timestamp": 1774179076949 + }, + { + "x": 530.5775146484375, + "y": 164.12152099609375, + "pressure": 1426, + "timestamp": 1774179076950 + }, + { + "x": 531.3700561523438, + "y": 163.92343139648438, + "pressure": 1428, + "timestamp": 1774179076952 + }, + { + "x": 532.1625366210938, + "y": 163.72531127929688, + "pressure": 1427, + "timestamp": 1774179076954 + }, + { + "x": 532.558837890625, + "y": 163.5272216796875, + "pressure": 1426, + "timestamp": 1774179076959 + }, + { + "x": 532.558837890625, + "y": 163.5272216796875, + "pressure": 1427, + "timestamp": 1774179076959 + }, + { + "x": 532.558837890625, + "y": 163.5272216796875, + "pressure": 1430, + "timestamp": 1774179076965 + }, + { + "x": 532.955078125, + "y": 163.72531127929688, + "pressure": 1429, + "timestamp": 1774179076967 + }, + { + "x": 533.351318359375, + "y": 163.72531127929688, + "pressure": 1428, + "timestamp": 1774179076968 + }, + { + "x": 533.351318359375, + "y": 163.72531127929688, + "pressure": 1551, + "timestamp": 1774179076975 + }, + { + "x": 533.351318359375, + "y": 163.72531127929688, + "pressure": 1612, + "timestamp": 1774179076978 + }, + { + "x": 533.351318359375, + "y": 163.72531127929688, + "pressure": 1643, + "timestamp": 1774179076979 + }, + { + "x": 533.351318359375, + "y": 163.72531127929688, + "pressure": 1658, + "timestamp": 1774179076981 + }, + { + "x": 533.5494384765625, + "y": 164.12152099609375, + "pressure": 1666, + "timestamp": 1774179076983 + }, + { + "x": 533.74755859375, + "y": 165.11203002929688, + "pressure": 1670, + "timestamp": 1774179076984 + }, + { + "x": 533.9456787109375, + "y": 166.1025390625, + "pressure": 1672, + "timestamp": 1774179076986 + }, + { + "x": 534.143798828125, + "y": 166.89492797851562, + "pressure": 1673, + "timestamp": 1774179076991 + }, + { + "x": 534.3419189453125, + "y": 167.2911376953125, + "pressure": 1720, + "timestamp": 1774179076992 + }, + { + "x": 534.143798828125, + "y": 167.68734741210938, + "pressure": 1743, + "timestamp": 1774179076994 + }, + { + "x": 533.9456787109375, + "y": 168.67782592773438, + "pressure": 1755, + "timestamp": 1774179076995 + }, + { + "x": 533.74755859375, + "y": 169.47024536132812, + "pressure": 1761, + "timestamp": 1774179076997 + }, + { + "x": 533.5494384765625, + "y": 170.26263427734375, + "pressure": 1764, + "timestamp": 1774179076998 + }, + { + "x": 532.955078125, + "y": 171.0550537109375, + "pressure": 1765, + "timestamp": 1774179077000 + }, + { + "x": 532.7569580078125, + "y": 171.45126342773438, + "pressure": 1766, + "timestamp": 1774179077002 + }, + { + "x": 532.3607177734375, + "y": 171.84744262695312, + "pressure": 1769, + "timestamp": 1774179077007 + }, + { + "x": 531.7662963867188, + "y": 172.83795166015625, + "pressure": 1771, + "timestamp": 1774179077010 + }, + { + "x": 530.9738159179688, + "y": 173.82846069335938, + "pressure": 1770, + "timestamp": 1774179077011 + }, + { + "x": 530.1812744140625, + "y": 174.8189697265625, + "pressure": 1772, + "timestamp": 1774179077013 + }, + { + "x": 529.3887939453125, + "y": 175.61135864257812, + "pressure": 1771, + "timestamp": 1774179077014 + }, + { + "x": 528.5963134765625, + "y": 176.40377807617188, + "pressure": 1770, + "timestamp": 1774179077016 + }, + { + "x": 527.8038330078125, + "y": 177.1961669921875, + "pressure": 1772, + "timestamp": 1774179077018 + }, + { + "x": 527.0113525390625, + "y": 177.98858642578125, + "pressure": 1771, + "timestamp": 1774179077022 + }, + { + "x": 526.2188110351562, + "y": 178.5828857421875, + "pressure": 1767, + "timestamp": 1774179077024 + }, + { + "x": 525.2281494140625, + "y": 179.17718505859375, + "pressure": 1765, + "timestamp": 1774179077026 + }, + { + "x": 524.237548828125, + "y": 179.771484375, + "pressure": 1764, + "timestamp": 1774179077027 + }, + { + "x": 523.048828125, + "y": 180.36578369140625, + "pressure": 1766, + "timestamp": 1774179077029 + }, + { + "x": 521.860107421875, + "y": 180.9600830078125, + "pressure": 1765, + "timestamp": 1774179077031 + }, + { + "x": 520.8694458007812, + "y": 181.55438232421875, + "pressure": 1764, + "timestamp": 1774179077032 + }, + { + "x": 519.8787841796875, + "y": 182.148681640625, + "pressure": 1766, + "timestamp": 1774179077034 + }, + { + "x": 518.6900634765625, + "y": 182.54489135742188, + "pressure": 1765, + "timestamp": 1774179077038 + }, + { + "x": 517.5013427734375, + "y": 182.94110107421875, + "pressure": 1737, + "timestamp": 1774179077040 + }, + { + "x": 516.3126220703125, + "y": 183.3372802734375, + "pressure": 1723, + "timestamp": 1774179077042 + }, + { + "x": 515.1238403320312, + "y": 183.73348999023438, + "pressure": 1716, + "timestamp": 1774179077043 + }, + { + "x": 513.93505859375, + "y": 184.12969970703125, + "pressure": 1712, + "timestamp": 1774179077045 + }, + { + "x": 512.746337890625, + "y": 184.52590942382812, + "pressure": 1710, + "timestamp": 1774179077047 + }, + { + "x": 511.5575866699219, + "y": 184.92208862304688, + "pressure": 1709, + "timestamp": 1774179077048 + }, + { + "x": 510.36883544921875, + "y": 185.31829833984375, + "pressure": 1711, + "timestamp": 1774179077050 + }, + { + "x": 509.18011474609375, + "y": 185.71450805664062, + "pressure": 1710, + "timestamp": 1774179077054 + }, + { + "x": 507.9913635253906, + "y": 185.91259765625, + "pressure": 1689, + "timestamp": 1774179077056 + }, + { + "x": 506.8026123046875, + "y": 186.1107177734375, + "pressure": 1679, + "timestamp": 1774179077058 + }, + { + "x": 505.6138610839844, + "y": 186.30880737304688, + "pressure": 1674, + "timestamp": 1774179077059 + }, + { + "x": 504.42510986328125, + "y": 186.30880737304688, + "pressure": 1671, + "timestamp": 1774179077061 + }, + { + "x": 503.63262939453125, + "y": 186.30880737304688, + "pressure": 1670, + "timestamp": 1774179077063 + }, + { + "x": 502.8401184082031, + "y": 186.30880737304688, + "pressure": 1669, + "timestamp": 1774179077064 + }, + { + "x": 502.4438781738281, + "y": 186.30880737304688, + "pressure": 1671, + "timestamp": 1774179077066 + }, + { + "x": 502.4438781738281, + "y": 186.30880737304688, + "pressure": 1652, + "timestamp": 1774179077072 + }, + { + "x": 502.4438781738281, + "y": 186.30880737304688, + "pressure": 1643, + "timestamp": 1774179077074 + }, + { + "x": 502.4438781738281, + "y": 186.30880737304688, + "pressure": 1638, + "timestamp": 1774179077075 + }, + { + "x": 502.4438781738281, + "y": 186.30880737304688, + "pressure": 1635, + "timestamp": 1774179077079 + }, + { + "x": 502.4438781738281, + "y": 186.30880737304688, + "pressure": 1522, + "timestamp": 1774179077088 + }, + { + "x": 502.4438781738281, + "y": 186.30880737304688, + "pressure": 1465, + "timestamp": 1774179077091 + }, + { + "x": 502.4438781738281, + "y": 186.30880737304688, + "pressure": 1437, + "timestamp": 1774179077092 + }, + { + "x": 502.4438781738281, + "y": 186.30880737304688, + "pressure": 1423, + "timestamp": 1774179077093 + }, + { + "x": 502.4438781738281, + "y": 186.30880737304688, + "pressure": 1416, + "timestamp": 1774179077095 + }, + { + "x": 502.4438781738281, + "y": 186.30880737304688, + "pressure": 1412, + "timestamp": 1774179077096 + }, + { + "x": 502.4438781738281, + "y": 186.30880737304688, + "pressure": 1409, + "timestamp": 1774179077102 + }, + { + "x": 502.4438781738281, + "y": 186.30880737304688, + "pressure": 1412, + "timestamp": 1774179077107 + }, + { + "x": 502.8401184082031, + "y": 186.1107177734375, + "pressure": 1412, + "timestamp": 1774179077111 + }, + { + "x": 503.83074951171875, + "y": 185.51638793945312, + "pressure": 1411, + "timestamp": 1774179077112 + }, + { + "x": 504.82135009765625, + "y": 184.92208862304688, + "pressure": 1413, + "timestamp": 1774179077115 + }, + { + "x": 506.0101013183594, + "y": 184.32778930664062, + "pressure": 1412, + "timestamp": 1774179077119 + }, + { + "x": 507.1988525390625, + "y": 183.73348999023438, + "pressure": 1410, + "timestamp": 1774179077120 + }, + { + "x": 508.3876037597656, + "y": 183.3372802734375, + "pressure": 1409, + "timestamp": 1774179077122 + }, + { + "x": 509.57635498046875, + "y": 183.13919067382812, + "pressure": 1408, + "timestamp": 1774179077124 + }, + { + "x": 510.7651062011719, + "y": 182.94110107421875, + "pressure": 1410, + "timestamp": 1774179077125 + }, + { + "x": 511.953857421875, + "y": 182.74298095703125, + "pressure": 1409, + "timestamp": 1774179077127 + }, + { + "x": 513.142578125, + "y": 182.54489135742188, + "pressure": 1408, + "timestamp": 1774179077128 + }, + { + "x": 514.331298828125, + "y": 182.3468017578125, + "pressure": 1410, + "timestamp": 1774179077130 + }, + { + "x": 515.5200805664062, + "y": 182.148681640625, + "pressure": 1409, + "timestamp": 1774179077134 + }, + { + "x": 516.7088623046875, + "y": 181.95059204101562, + "pressure": 1410, + "timestamp": 1774179077136 + }, + { + "x": 517.8975830078125, + "y": 181.75250244140625, + "pressure": 1412, + "timestamp": 1774179077138 + }, + { + "x": 519.0863037109375, + "y": 181.55438232421875, + "pressure": 1411, + "timestamp": 1774179077140 + }, + { + "x": 520.4732055664062, + "y": 181.35629272460938, + "pressure": 1413, + "timestamp": 1774179077141 + }, + { + "x": 521.6619873046875, + "y": 181.35629272460938, + "pressure": 1412, + "timestamp": 1774179077143 + }, + { + "x": 522.8507080078125, + "y": 181.35629272460938, + "pressure": 1411, + "timestamp": 1774179077145 + }, + { + "x": 524.0394287109375, + "y": 181.15817260742188, + "pressure": 1413, + "timestamp": 1774179077146 + }, + { + "x": 525.2281494140625, + "y": 180.9600830078125, + "pressure": 1412, + "timestamp": 1774179077150 + }, + { + "x": 526.4169311523438, + "y": 180.9600830078125, + "pressure": 1413, + "timestamp": 1774179077152 + }, + { + "x": 527.4075927734375, + "y": 180.9600830078125, + "pressure": 1414, + "timestamp": 1774179077154 + }, + { + "x": 528.398193359375, + "y": 180.76199340820312, + "pressure": 1416, + "timestamp": 1774179077156 + }, + { + "x": 529.3887939453125, + "y": 180.56387329101562, + "pressure": 1415, + "timestamp": 1774179077157 + }, + { + "x": 530.37939453125, + "y": 180.56387329101562, + "pressure": 1417, + "timestamp": 1774179077159 + }, + { + "x": 531.3700561523438, + "y": 180.56387329101562, + "pressure": 1416, + "timestamp": 1774179077161 + }, + { + "x": 532.3607177734375, + "y": 180.36578369140625, + "pressure": 1415, + "timestamp": 1774179077162 + }, + { + "x": 533.351318359375, + "y": 180.16769409179688, + "pressure": 1417, + "timestamp": 1774179077166 + }, + { + "x": 534.3419189453125, + "y": 179.96957397460938, + "pressure": 1423, + "timestamp": 1774179077168 + }, + { + "x": 535.33251953125, + "y": 179.771484375, + "pressure": 1426, + "timestamp": 1774179077170 + }, + { + "x": 536.3231201171875, + "y": 179.57339477539062, + "pressure": 1428, + "timestamp": 1774179077172 + }, + { + "x": 537.1156616210938, + "y": 179.37527465820312, + "pressure": 1429, + "timestamp": 1774179077173 + }, + { + "x": 537.908203125, + "y": 179.17718505859375, + "pressure": 1431, + "timestamp": 1774179077175 + }, + { + "x": 538.70068359375, + "y": 178.97906494140625, + "pressure": 1430, + "timestamp": 1774179077177 + }, + { + "x": 539.4931640625, + "y": 178.78097534179688, + "pressure": 1432, + "timestamp": 1774179077178 + }, + { + "x": 540.28564453125, + "y": 178.5828857421875, + "pressure": 1431, + "timestamp": 1774179077183 + }, + { + "x": 541.078125, + "y": 178.384765625, + "pressure": 1421, + "timestamp": 1774179077184 + }, + { + "x": 541.87060546875, + "y": 178.18667602539062, + "pressure": 1416, + "timestamp": 1774179077187 + }, + { + "x": 542.6631469726562, + "y": 177.98858642578125, + "pressure": 1413, + "timestamp": 1774179077188 + }, + { + "x": 543.4556884765625, + "y": 177.79046630859375, + "pressure": 1412, + "timestamp": 1774179077190 + }, + { + "x": 544.2481689453125, + "y": 177.59237670898438, + "pressure": 1411, + "timestamp": 1774179077191 + }, + { + "x": 545.0406494140625, + "y": 177.1961669921875, + "pressure": 1413, + "timestamp": 1774179077193 + }, + { + "x": 545.8331298828125, + "y": 176.79995727539062, + "pressure": 1412, + "timestamp": 1774179077195 + }, + { + "x": 546.6256103515625, + "y": 176.40377807617188, + "pressure": 1411, + "timestamp": 1774179077199 + }, + { + "x": 547.4181518554688, + "y": 176.20565795898438, + "pressure": 1398, + "timestamp": 1774179077200 + }, + { + "x": 548.2106323242188, + "y": 175.80947875976562, + "pressure": 1391, + "timestamp": 1774179077202 + }, + { + "x": 549.2012939453125, + "y": 175.41326904296875, + "pressure": 1388, + "timestamp": 1774179077204 + }, + { + "x": 549.9937744140625, + "y": 175.01705932617188, + "pressure": 1386, + "timestamp": 1774179077205 + }, + { + "x": 550.7862548828125, + "y": 174.620849609375, + "pressure": 1385, + "timestamp": 1774179077208 + }, + { + "x": 551.5787353515625, + "y": 174.22467041015625, + "pressure": 1387, + "timestamp": 1774179077209 + }, + { + "x": 552.3712158203125, + "y": 173.82846069335938, + "pressure": 1386, + "timestamp": 1774179077210 + }, + { + "x": 553.1637573242188, + "y": 173.4322509765625, + "pressure": 1385, + "timestamp": 1774179077214 + }, + { + "x": 553.9562377929688, + "y": 173.03604125976562, + "pressure": 1351, + "timestamp": 1774179077216 + }, + { + "x": 554.748779296875, + "y": 172.83795166015625, + "pressure": 1334, + "timestamp": 1774179077218 + }, + { + "x": 555.541259765625, + "y": 172.44174194335938, + "pressure": 1326, + "timestamp": 1774179077220 + }, + { + "x": 555.9375, + "y": 172.24365234375, + "pressure": 1322, + "timestamp": 1774179077221 + }, + { + "x": 555.9375, + "y": 172.24365234375, + "pressure": 1319, + "timestamp": 1774179077225 + }, + { + "x": 556.333740234375, + "y": 172.04556274414062, + "pressure": 1320, + "timestamp": 1774179077231 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1311, + "timestamp": 1774179077232 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1306, + "timestamp": 1774179077234 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1303, + "timestamp": 1774179077237 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1337, + "timestamp": 1774179077248 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1355, + "timestamp": 1774179077251 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1364, + "timestamp": 1774179077252 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1368, + "timestamp": 1774179077253 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1371, + "timestamp": 1774179077257 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1374, + "timestamp": 1774179077263 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1407, + "timestamp": 1774179077264 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1424, + "timestamp": 1774179077266 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1432, + "timestamp": 1774179077268 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1436, + "timestamp": 1774179077269 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1439, + "timestamp": 1774179077273 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1442, + "timestamp": 1774179077279 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1503, + "timestamp": 1774179077280 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1533, + "timestamp": 1774179077282 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1548, + "timestamp": 1774179077284 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1556, + "timestamp": 1774179077285 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1560, + "timestamp": 1774179077287 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1563, + "timestamp": 1774179077291 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1606, + "timestamp": 1774179077296 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1627, + "timestamp": 1774179077298 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1637, + "timestamp": 1774179077300 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1642, + "timestamp": 1774179077302 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1645, + "timestamp": 1774179077304 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1649, + "timestamp": 1774179077311 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1657, + "timestamp": 1774179077312 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1661, + "timestamp": 1774179077315 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1664, + "timestamp": 1774179077318 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1667, + "timestamp": 1774179077323 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1675, + "timestamp": 1774179077328 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1679, + "timestamp": 1774179077331 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1682, + "timestamp": 1774179077334 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1685, + "timestamp": 1774179077337 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1681, + "timestamp": 1774179077347 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1676, + "timestamp": 1774179077361 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1673, + "timestamp": 1774179077364 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1664, + "timestamp": 1774179077376 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1659, + "timestamp": 1774179077379 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1656, + "timestamp": 1774179077380 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1633, + "timestamp": 1774179077393 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1622, + "timestamp": 1774179077395 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1617, + "timestamp": 1774179077396 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1614, + "timestamp": 1774179077398 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1564, + "timestamp": 1774179077409 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1539, + "timestamp": 1774179077411 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1527, + "timestamp": 1774179077412 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1521, + "timestamp": 1774179077414 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1518, + "timestamp": 1774179077415 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1515, + "timestamp": 1774179077419 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1483, + "timestamp": 1774179077425 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1466, + "timestamp": 1774179077427 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1457, + "timestamp": 1774179077428 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1453, + "timestamp": 1774179077430 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1450, + "timestamp": 1774179077433 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1465, + "timestamp": 1774179077441 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1472, + "timestamp": 1774179077443 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1476, + "timestamp": 1774179077445 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1479, + "timestamp": 1774179077448 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1482, + "timestamp": 1774179077455 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1552, + "timestamp": 1774179077457 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1587, + "timestamp": 1774179077459 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1605, + "timestamp": 1774179077461 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1614, + "timestamp": 1774179077462 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1618, + "timestamp": 1774179077464 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1621, + "timestamp": 1774179077467 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1635, + "timestamp": 1774179077473 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1642, + "timestamp": 1774179077475 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1645, + "timestamp": 1774179077477 + }, + { + "x": 556.72998046875, + "y": 171.64935302734375, + "pressure": 1648, + "timestamp": 1774179077480 + }, + { + "x": 556.333740234375, + "y": 171.45126342773438, + "pressure": 1649, + "timestamp": 1774179077483 + }, + { + "x": 555.541259765625, + "y": 171.0550537109375, + "pressure": 1651, + "timestamp": 1774179077487 + }, + { + "x": 555.14501953125, + "y": 170.85693359375, + "pressure": 1638, + "timestamp": 1774179077489 + }, + { + "x": 555.14501953125, + "y": 170.85693359375, + "pressure": 1632, + "timestamp": 1774179077491 + }, + { + "x": 555.14501953125, + "y": 170.85693359375, + "pressure": 1629, + "timestamp": 1774179077493 + }, + { + "x": 554.748779296875, + "y": 170.85693359375, + "pressure": 1626, + "timestamp": 1774179077496 + }, + { + "x": 553.9562377929688, + "y": 170.65884399414062, + "pressure": 1628, + "timestamp": 1774179077498 + }, + { + "x": 553.1637573242188, + "y": 170.46075439453125, + "pressure": 1627, + "timestamp": 1774179077499 + }, + { + "x": 552.7674560546875, + "y": 170.26263427734375, + "pressure": 1626, + "timestamp": 1774179077503 + }, + { + "x": 552.7674560546875, + "y": 170.26263427734375, + "pressure": 1596, + "timestamp": 1774179077505 + }, + { + "x": 552.7674560546875, + "y": 170.26263427734375, + "pressure": 1581, + "timestamp": 1774179077507 + }, + { + "x": 552.7674560546875, + "y": 170.26263427734375, + "pressure": 1574, + "timestamp": 1774179077509 + }, + { + "x": 552.7674560546875, + "y": 170.26263427734375, + "pressure": 1570, + "timestamp": 1774179077510 + }, + { + "x": 552.3712158203125, + "y": 170.26263427734375, + "pressure": 1568, + "timestamp": 1774179077512 + }, + { + "x": 551.5787353515625, + "y": 170.26263427734375, + "pressure": 1567, + "timestamp": 1774179077514 + }, + { + "x": 550.7862548828125, + "y": 170.26263427734375, + "pressure": 1569, + "timestamp": 1774179077515 + }, + { + "x": 550.3900146484375, + "y": 170.26263427734375, + "pressure": 1568, + "timestamp": 1774179077519 + }, + { + "x": 550.3900146484375, + "y": 170.26263427734375, + "pressure": 1543, + "timestamp": 1774179077521 + }, + { + "x": 550.3900146484375, + "y": 170.26263427734375, + "pressure": 1530, + "timestamp": 1774179077523 + }, + { + "x": 550.3900146484375, + "y": 170.26263427734375, + "pressure": 1524, + "timestamp": 1774179077525 + }, + { + "x": 549.9937744140625, + "y": 170.46075439453125, + "pressure": 1521, + "timestamp": 1774179077526 + }, + { + "x": 549.2012939453125, + "y": 170.65884399414062, + "pressure": 1519, + "timestamp": 1774179077528 + }, + { + "x": 548.4087524414062, + "y": 171.0550537109375, + "pressure": 1518, + "timestamp": 1774179077530 + }, + { + "x": 548.0125122070312, + "y": 171.25314331054688, + "pressure": 1520, + "timestamp": 1774179077532 + }, + { + "x": 547.6162719726562, + "y": 171.45126342773438, + "pressure": 1521, + "timestamp": 1774179077541 + }, + { + "x": 546.82373046875, + "y": 172.04556274414062, + "pressure": 1523, + "timestamp": 1774179077543 + }, + { + "x": 546.03125, + "y": 172.63986206054688, + "pressure": 1522, + "timestamp": 1774179077544 + }, + { + "x": 545.635009765625, + "y": 172.83795166015625, + "pressure": 1521, + "timestamp": 1774179077546 + }, + { + "x": 545.635009765625, + "y": 172.83795166015625, + "pressure": 1528, + "timestamp": 1774179077553 + }, + { + "x": 545.23876953125, + "y": 173.23416137695312, + "pressure": 1531, + "timestamp": 1774179077555 + }, + { + "x": 544.4462890625, + "y": 173.82846069335938, + "pressure": 1533, + "timestamp": 1774179077557 + }, + { + "x": 543.65380859375, + "y": 174.620849609375, + "pressure": 1534, + "timestamp": 1774179077558 + }, + { + "x": 543.0593872070312, + "y": 175.41326904296875, + "pressure": 1536, + "timestamp": 1774179077560 + }, + { + "x": 542.4650268554688, + "y": 176.20565795898438, + "pressure": 1535, + "timestamp": 1774179077562 + }, + { + "x": 542.2669067382812, + "y": 176.60186767578125, + "pressure": 1537, + "timestamp": 1774179077563 + }, + { + "x": 542.2669067382812, + "y": 176.60186767578125, + "pressure": 1528, + "timestamp": 1774179077569 + }, + { + "x": 541.87060546875, + "y": 176.99807739257812, + "pressure": 1524, + "timestamp": 1774179077572 + }, + { + "x": 541.2762451171875, + "y": 177.79046630859375, + "pressure": 1522, + "timestamp": 1774179077573 + }, + { + "x": 540.8800048828125, + "y": 178.5828857421875, + "pressure": 1521, + "timestamp": 1774179077574 + }, + { + "x": 540.681884765625, + "y": 178.97906494140625, + "pressure": 1523, + "timestamp": 1774179077576 + }, + { + "x": 540.4837646484375, + "y": 179.37527465820312, + "pressure": 1523, + "timestamp": 1774179077583 + }, + { + "x": 540.28564453125, + "y": 180.16769409179688, + "pressure": 1520, + "timestamp": 1774179077585 + }, + { + "x": 539.889404296875, + "y": 180.9600830078125, + "pressure": 1519, + "timestamp": 1774179077587 + }, + { + "x": 539.6912841796875, + "y": 181.75250244140625, + "pressure": 1518, + "timestamp": 1774179077589 + }, + { + "x": 539.6912841796875, + "y": 182.54489135742188, + "pressure": 1520, + "timestamp": 1774179077590 + }, + { + "x": 539.6912841796875, + "y": 182.94110107421875, + "pressure": 1519, + "timestamp": 1774179077592 + }, + { + "x": 539.6912841796875, + "y": 183.3372802734375, + "pressure": 1519, + "timestamp": 1774179077599 + }, + { + "x": 539.6912841796875, + "y": 184.32778930664062, + "pressure": 1546, + "timestamp": 1774179077601 + }, + { + "x": 539.889404296875, + "y": 185.31829833984375, + "pressure": 1559, + "timestamp": 1774179077603 + }, + { + "x": 540.0875244140625, + "y": 186.1107177734375, + "pressure": 1566, + "timestamp": 1774179077605 + }, + { + "x": 540.28564453125, + "y": 186.90310668945312, + "pressure": 1569, + "timestamp": 1774179077606 + }, + { + "x": 540.4837646484375, + "y": 187.29931640625, + "pressure": 1571, + "timestamp": 1774179077608 + }, + { + "x": 540.4837646484375, + "y": 187.29931640625, + "pressure": 1574, + "timestamp": 1774179077612 + }, + { + "x": 540.4837646484375, + "y": 187.29931640625, + "pressure": 1597, + "timestamp": 1774179077617 + }, + { + "x": 540.681884765625, + "y": 187.69549560546875, + "pressure": 1609, + "timestamp": 1774179077620 + }, + { + "x": 541.2762451171875, + "y": 188.4879150390625, + "pressure": 1615, + "timestamp": 1774179077621 + }, + { + "x": 541.6724853515625, + "y": 188.88412475585938, + "pressure": 1618, + "timestamp": 1774179077623 + }, + { + "x": 542.0687866210938, + "y": 189.28030395507812, + "pressure": 1620, + "timestamp": 1774179077626 + }, + { + "x": 542.8612670898438, + "y": 189.87460327148438, + "pressure": 1622, + "timestamp": 1774179077628 + }, + { + "x": 543.65380859375, + "y": 190.27081298828125, + "pressure": 1621, + "timestamp": 1774179077632 + }, + { + "x": 544.4462890625, + "y": 190.66702270507812, + "pressure": 1624, + "timestamp": 1774179077633 + }, + { + "x": 544.842529296875, + "y": 190.8651123046875, + "pressure": 1626, + "timestamp": 1774179077636 + }, + { + "x": 545.23876953125, + "y": 191.063232421875, + "pressure": 1629, + "timestamp": 1774179077639 + }, + { + "x": 546.03125, + "y": 191.26132202148438, + "pressure": 1628, + "timestamp": 1774179077640 + }, + { + "x": 546.82373046875, + "y": 191.45941162109375, + "pressure": 1630, + "timestamp": 1774179077642 + }, + { + "x": 547.6162719726562, + "y": 191.45941162109375, + "pressure": 1629, + "timestamp": 1774179077644 + }, + { + "x": 548.4087524414062, + "y": 191.45941162109375, + "pressure": 1628, + "timestamp": 1774179077648 + }, + { + "x": 549.2012939453125, + "y": 191.45941162109375, + "pressure": 1616, + "timestamp": 1774179077649 + }, + { + "x": 549.9937744140625, + "y": 191.45941162109375, + "pressure": 1610, + "timestamp": 1774179077651 + }, + { + "x": 550.7862548828125, + "y": 191.26132202148438, + "pressure": 1607, + "timestamp": 1774179077653 + }, + { + "x": 551.5787353515625, + "y": 191.063232421875, + "pressure": 1606, + "timestamp": 1774179077654 + }, + { + "x": 552.3712158203125, + "y": 190.8651123046875, + "pressure": 1605, + "timestamp": 1774179077656 + }, + { + "x": 553.1637573242188, + "y": 190.66702270507812, + "pressure": 1607, + "timestamp": 1774179077658 + }, + { + "x": 553.9562377929688, + "y": 190.27081298828125, + "pressure": 1606, + "timestamp": 1774179077660 + }, + { + "x": 554.748779296875, + "y": 189.676513671875, + "pressure": 1605, + "timestamp": 1774179077664 + }, + { + "x": 555.541259765625, + "y": 189.08221435546875, + "pressure": 1564, + "timestamp": 1774179077665 + }, + { + "x": 556.333740234375, + "y": 188.4879150390625, + "pressure": 1543, + "timestamp": 1774179077668 + }, + { + "x": 557.126220703125, + "y": 187.89361572265625, + "pressure": 1533, + "timestamp": 1774179077669 + }, + { + "x": 557.918701171875, + "y": 187.29931640625, + "pressure": 1528, + "timestamp": 1774179077671 + }, + { + "x": 558.7112426757812, + "y": 186.90310668945312, + "pressure": 1525, + "timestamp": 1774179077672 + }, + { + "x": 559.5037231445312, + "y": 186.50689697265625, + "pressure": 1524, + "timestamp": 1774179077674 + }, + { + "x": 560.2962646484375, + "y": 185.91259765625, + "pressure": 1523, + "timestamp": 1774179077676 + }, + { + "x": 561.0887451171875, + "y": 185.31829833984375, + "pressure": 1525, + "timestamp": 1774179077681 + }, + { + "x": 561.8812255859375, + "y": 184.92208862304688, + "pressure": 1476, + "timestamp": 1774179077681 + }, + { + "x": 562.6737060546875, + "y": 184.52590942382812, + "pressure": 1452, + "timestamp": 1774179077684 + }, + { + "x": 563.4661865234375, + "y": 184.12969970703125, + "pressure": 1440, + "timestamp": 1774179077685 + }, + { + "x": 564.2587280273438, + "y": 183.73348999023438, + "pressure": 1434, + "timestamp": 1774179077687 + }, + { + "x": 565.0512084960938, + "y": 183.3372802734375, + "pressure": 1431, + "timestamp": 1774179077688 + }, + { + "x": 565.84375, + "y": 182.74298095703125, + "pressure": 1429, + "timestamp": 1774179077690 + }, + { + "x": 566.63623046875, + "y": 182.148681640625, + "pressure": 1428, + "timestamp": 1774179077692 + }, + { + "x": 567.032470703125, + "y": 181.95059204101562, + "pressure": 1430, + "timestamp": 1774179077696 + }, + { + "x": 567.032470703125, + "y": 181.95059204101562, + "pressure": 1370, + "timestamp": 1774179077698 + }, + { + "x": 567.4287109375, + "y": 181.75250244140625, + "pressure": 1340, + "timestamp": 1774179077700 + }, + { + "x": 568.22119140625, + "y": 181.15817260742188, + "pressure": 1325, + "timestamp": 1774179077701 + }, + { + "x": 569.2118530273438, + "y": 180.56387329101562, + "pressure": 1318, + "timestamp": 1774179077703 + }, + { + "x": 570.0043334960938, + "y": 179.96957397460938, + "pressure": 1314, + "timestamp": 1774179077704 + }, + { + "x": 570.7968139648438, + "y": 179.37527465820312, + "pressure": 1312, + "timestamp": 1774179077706 + }, + { + "x": 571.58935546875, + "y": 178.78097534179688, + "pressure": 1311, + "timestamp": 1774179077708 + }, + { + "x": 572.3818359375, + "y": 178.384765625, + "pressure": 1313, + "timestamp": 1774179077712 + }, + { + "x": 573.17431640625, + "y": 177.98858642578125, + "pressure": 1184, + "timestamp": 1774179077713 + }, + { + "x": 573.966796875, + "y": 177.59237670898438, + "pressure": 1119, + "timestamp": 1774179077716 + }, + { + "x": 574.3630981445312, + "y": 177.1961669921875, + "pressure": 1087, + "timestamp": 1774179077717 + }, + { + "x": 574.3630981445312, + "y": 177.1961669921875, + "pressure": 1071, + "timestamp": 1774179077719 + }, + { + "x": 574.3630981445312, + "y": 177.1961669921875, + "pressure": 1063, + "timestamp": 1774179077720 + }, + { + "x": 574.7593383789062, + "y": 176.99807739257812, + "pressure": 1059, + "timestamp": 1774179077722 + }, + { + "x": 575.5518188476562, + "y": 176.40377807617188, + "pressure": 1057, + "timestamp": 1774179077724 + }, + { + "x": 575.9480590820312, + "y": 176.007568359375, + "pressure": 1056, + "timestamp": 1774179077728 + }, + { + "x": 575.9480590820312, + "y": 176.007568359375, + "pressure": 1043, + "timestamp": 1774179077731 + }, + { + "x": 575.9480590820312, + "y": 176.007568359375, + "pressure": 1036, + "timestamp": 1774179077732 + }, + { + "x": 575.9480590820312, + "y": 176.007568359375, + "pressure": 1033, + "timestamp": 1774179077733 + }, + { + "x": 576.3442993164062, + "y": 175.61135864257812, + "pressure": 1031, + "timestamp": 1774179077735 + }, + { + "x": 577.1368408203125, + "y": 175.01705932617188, + "pressure": 1030, + "timestamp": 1774179077737 + }, + { + "x": 577.5330810546875, + "y": 174.620849609375, + "pressure": 1032, + "timestamp": 1774179077739 + }, + { + "x": 577.5330810546875, + "y": 174.620849609375, + "pressure": 1023, + "timestamp": 1774179077745 + }, + { + "x": 577.5330810546875, + "y": 174.620849609375, + "pressure": 1019, + "timestamp": 1774179077748 + }, + { + "x": 577.9293212890625, + "y": 174.42276000976562, + "pressure": 1016, + "timestamp": 1774179077751 + }, + { + "x": 578.7218017578125, + "y": 173.82846069335938, + "pressure": 1018, + "timestamp": 1774179077753 + }, + { + "x": 579.1180419921875, + "y": 173.4322509765625, + "pressure": 1017, + "timestamp": 1774179077755 + }, + { + "x": 579.1180419921875, + "y": 173.4322509765625, + "pressure": 1013, + "timestamp": 1774179077762 + }, + { + "x": 579.1180419921875, + "y": 173.4322509765625, + "pressure": 1010, + "timestamp": 1774179077764 + }, + { + "x": 579.1180419921875, + "y": 173.4322509765625, + "pressure": 987, + "timestamp": 1774179077778 + }, + { + "x": 579.1180419921875, + "y": 173.4322509765625, + "pressure": 975, + "timestamp": 1774179077780 + }, + { + "x": 579.1180419921875, + "y": 173.4322509765625, + "pressure": 969, + "timestamp": 1774179077781 + }, + { + "x": 579.1180419921875, + "y": 173.4322509765625, + "pressure": 966, + "timestamp": 1774179077783 + }, + { + "x": 579.1180419921875, + "y": 173.4322509765625, + "pressure": 1011, + "timestamp": 1774179077794 + }, + { + "x": 579.1180419921875, + "y": 173.4322509765625, + "pressure": 1034, + "timestamp": 1774179077796 + }, + { + "x": 579.1180419921875, + "y": 173.4322509765625, + "pressure": 1045, + "timestamp": 1774179077797 + }, + { + "x": 579.1180419921875, + "y": 173.4322509765625, + "pressure": 1051, + "timestamp": 1774179077799 + }, + { + "x": 579.1180419921875, + "y": 173.4322509765625, + "pressure": 1054, + "timestamp": 1774179077801 + }, + { + "x": 579.1180419921875, + "y": 173.4322509765625, + "pressure": 1058, + "timestamp": 1774179077808 + }, + { + "x": 579.1180419921875, + "y": 173.4322509765625, + "pressure": 1112, + "timestamp": 1774179077810 + }, + { + "x": 579.1180419921875, + "y": 173.4322509765625, + "pressure": 1139, + "timestamp": 1774179077812 + }, + { + "x": 579.1180419921875, + "y": 173.4322509765625, + "pressure": 1152, + "timestamp": 1774179077813 + }, + { + "x": 578.7218017578125, + "y": 173.82846069335938, + "pressure": 1159, + "timestamp": 1774179077815 + }, + { + "x": 577.9293212890625, + "y": 174.42276000976562, + "pressure": 1162, + "timestamp": 1774179077817 + }, + { + "x": 577.1368408203125, + "y": 175.01705932617188, + "pressure": 1164, + "timestamp": 1774179077818 + }, + { + "x": 576.3442993164062, + "y": 175.61135864257812, + "pressure": 1165, + "timestamp": 1774179077820 + }, + { + "x": 575.5518188476562, + "y": 176.20565795898438, + "pressure": 1167, + "timestamp": 1774179077824 + }, + { + "x": 574.7593383789062, + "y": 176.99807739257812, + "pressure": 1224, + "timestamp": 1774179077826 + }, + { + "x": 573.966796875, + "y": 177.59237670898438, + "pressure": 1252, + "timestamp": 1774179077828 + }, + { + "x": 573.17431640625, + "y": 178.18667602539062, + "pressure": 1266, + "timestamp": 1774179077829 + }, + { + "x": 572.3818359375, + "y": 178.78097534179688, + "pressure": 1273, + "timestamp": 1774179077831 + }, + { + "x": 571.58935546875, + "y": 179.37527465820312, + "pressure": 1277, + "timestamp": 1774179077833 + }, + { + "x": 570.7968139648438, + "y": 179.96957397460938, + "pressure": 1279, + "timestamp": 1774179077835 + }, + { + "x": 570.0043334960938, + "y": 180.56387329101562, + "pressure": 1280, + "timestamp": 1774179077836 + }, + { + "x": 569.2118530273438, + "y": 181.15817260742188, + "pressure": 1282, + "timestamp": 1774179077841 + }, + { + "x": 568.4193115234375, + "y": 181.75250244140625, + "pressure": 1275, + "timestamp": 1774179077842 + }, + { + "x": 567.6268310546875, + "y": 182.3468017578125, + "pressure": 1272, + "timestamp": 1774179077844 + }, + { + "x": 567.2305908203125, + "y": 182.54489135742188, + "pressure": 1270, + "timestamp": 1774179077846 + }, + { + "x": 566.8343505859375, + "y": 182.94110107421875, + "pressure": 1271, + "timestamp": 1774179077856 + }, + { + "x": 566.4381103515625, + "y": 183.3372802734375, + "pressure": 1214, + "timestamp": 1774179077858 + }, + { + "x": 566.4381103515625, + "y": 183.3372802734375, + "pressure": 1186, + "timestamp": 1774179077860 + }, + { + "x": 566.4381103515625, + "y": 183.3372802734375, + "pressure": 1172, + "timestamp": 1774179077861 + }, + { + "x": 566.0418701171875, + "y": 183.73348999023438, + "pressure": 1165, + "timestamp": 1774179077863 + }, + { + "x": 565.6456298828125, + "y": 184.7239990234375, + "pressure": 1161, + "timestamp": 1774179077865 + }, + { + "x": 565.2493896484375, + "y": 185.51638793945312, + "pressure": 1159, + "timestamp": 1774179077867 + }, + { + "x": 565.0512084960938, + "y": 185.91259765625, + "pressure": 1158, + "timestamp": 1774179077868 + }, + { + "x": 565.0512084960938, + "y": 185.91259765625, + "pressure": 1112, + "timestamp": 1774179077874 + }, + { + "x": 565.0512084960938, + "y": 185.91259765625, + "pressure": 1088, + "timestamp": 1774179077877 + }, + { + "x": 565.0512084960938, + "y": 185.91259765625, + "pressure": 1076, + "timestamp": 1774179077878 + }, + { + "x": 565.0512084960938, + "y": 185.91259765625, + "pressure": 1070, + "timestamp": 1774179077880 + }, + { + "x": 565.0512084960938, + "y": 185.91259765625, + "pressure": 1067, + "timestamp": 1774179077881 + }, + { + "x": 565.447509765625, + "y": 186.1107177734375, + "pressure": 1034, + "timestamp": 1774179077890 + }, + { + "x": 566.239990234375, + "y": 186.30880737304688, + "pressure": 1017, + "timestamp": 1774179077892 + }, + { + "x": 567.032470703125, + "y": 186.30880737304688, + "pressure": 1009, + "timestamp": 1774179077894 + }, + { + "x": 567.4287109375, + "y": 186.30880737304688, + "pressure": 1005, + "timestamp": 1774179077895 + }, + { + "x": 567.824951171875, + "y": 186.30880737304688, + "pressure": 1002, + "timestamp": 1774179077899 + }, + { + "x": 568.617431640625, + "y": 186.1107177734375, + "pressure": 1001, + "timestamp": 1774179077900 + }, + { + "x": 569.6080932617188, + "y": 185.91259765625, + "pressure": 1003, + "timestamp": 1774179077904 + }, + { + "x": 570.4005737304688, + "y": 185.71450805664062, + "pressure": 977, + "timestamp": 1774179077906 + }, + { + "x": 571.193115234375, + "y": 185.31829833984375, + "pressure": 964, + "timestamp": 1774179077909 + }, + { + "x": 571.985595703125, + "y": 184.92208862304688, + "pressure": 958, + "timestamp": 1774179077910 + }, + { + "x": 572.778076171875, + "y": 184.52590942382812, + "pressure": 955, + "timestamp": 1774179077911 + }, + { + "x": 573.570556640625, + "y": 184.12969970703125, + "pressure": 953, + "timestamp": 1774179077913 + }, + { + "x": 574.3630981445312, + "y": 183.73348999023438, + "pressure": 952, + "timestamp": 1774179077915 + }, + { + "x": 575.1555786132812, + "y": 183.3372802734375, + "pressure": 954, + "timestamp": 1774179077916 + }, + { + "x": 575.9480590820312, + "y": 182.94110107421875, + "pressure": 953, + "timestamp": 1774179077920 + }, + { + "x": 576.3442993164062, + "y": 182.74298095703125, + "pressure": 869, + "timestamp": 1774179077922 + }, + { + "x": 576.3442993164062, + "y": 182.74298095703125, + "pressure": 827, + "timestamp": 1774179077924 + }, + { + "x": 576.7406005859375, + "y": 182.54489135742188, + "pressure": 806, + "timestamp": 1774179077926 + }, + { + "x": 577.5330810546875, + "y": 181.95059204101562, + "pressure": 796, + "timestamp": 1774179077927 + }, + { + "x": 578.3255615234375, + "y": 181.35629272460938, + "pressure": 791, + "timestamp": 1774179077929 + }, + { + "x": 579.1180419921875, + "y": 180.76199340820312, + "pressure": 788, + "timestamp": 1774179077931 + }, + { + "x": 579.5142822265625, + "y": 180.36578369140625, + "pressure": 787, + "timestamp": 1774179077932 + }, + { + "x": 579.71240234375, + "y": 179.96957397460938, + "pressure": 786, + "timestamp": 1774179077936 + }, + { + "x": 580.3068237304688, + "y": 179.17718505859375, + "pressure": 719, + "timestamp": 1774179077938 + }, + { + "x": 580.9011840820312, + "y": 178.384765625, + "pressure": 685, + "timestamp": 1774179077940 + }, + { + "x": 581.4955444335938, + "y": 177.59237670898438, + "pressure": 668, + "timestamp": 1774179077942 + }, + { + "x": 582.0899658203125, + "y": 176.79995727539062, + "pressure": 660, + "timestamp": 1774179077943 + }, + { + "x": 582.4862060546875, + "y": 176.40377807617188, + "pressure": 656, + "timestamp": 1774179077945 + }, + { + "x": 582.684326171875, + "y": 176.007568359375, + "pressure": 653, + "timestamp": 1774179077949 + }, + { + "x": 583.2786865234375, + "y": 175.21514892578125, + "pressure": 652, + "timestamp": 1774179077952 + }, + { + "x": 583.6749267578125, + "y": 174.8189697265625, + "pressure": 650, + "timestamp": 1774179077955 + }, + { + "x": 583.873046875, + "y": 174.42276000976562, + "pressure": 649, + "timestamp": 1774179077968 + }, + { + "x": 584.4674072265625, + "y": 173.63037109375, + "pressure": 711, + "timestamp": 1774179077970 + }, + { + "x": 584.8636474609375, + "y": 173.23416137695312, + "pressure": 742, + "timestamp": 1774179077972 + }, + { + "x": 584.8636474609375, + "y": 173.23416137695312, + "pressure": 758, + "timestamp": 1774179077974 + }, + { + "x": 584.8636474609375, + "y": 173.23416137695312, + "pressure": 766, + "timestamp": 1774179077975 + }, + { + "x": 584.8636474609375, + "y": 173.23416137695312, + "pressure": 770, + "timestamp": 1774179077977 + }, + { + "x": 584.8636474609375, + "y": 173.23416137695312, + "pressure": 773, + "timestamp": 1774179077981 + }, + { + "x": 584.8636474609375, + "y": 173.23416137695312, + "pressure": 877, + "timestamp": 1774179077986 + }, + { + "x": 584.8636474609375, + "y": 173.23416137695312, + "pressure": 928, + "timestamp": 1774179077989 + }, + { + "x": 584.8636474609375, + "y": 173.23416137695312, + "pressure": 953, + "timestamp": 1774179077991 + }, + { + "x": 584.8636474609375, + "y": 173.23416137695312, + "pressure": 966, + "timestamp": 1774179077992 + }, + { + "x": 584.8636474609375, + "y": 173.23416137695312, + "pressure": 972, + "timestamp": 1774179077993 + }, + { + "x": 584.8636474609375, + "y": 173.23416137695312, + "pressure": 975, + "timestamp": 1774179077995 + }, + { + "x": 584.8636474609375, + "y": 173.23416137695312, + "pressure": 978, + "timestamp": 1774179078001 + }, + { + "x": 584.8636474609375, + "y": 173.23416137695312, + "pressure": 1001, + "timestamp": 1774179078002 + }, + { + "x": 584.8636474609375, + "y": 173.23416137695312, + "pressure": 1012, + "timestamp": 1774179078005 + }, + { + "x": 584.8636474609375, + "y": 173.23416137695312, + "pressure": 1018, + "timestamp": 1774179078006 + }, + { + "x": 584.8636474609375, + "y": 173.23416137695312, + "pressure": 1021, + "timestamp": 1774179078008 + }, + { + "x": 584.66552734375, + "y": 173.63037109375, + "pressure": 1022, + "timestamp": 1774179078009 + }, + { + "x": 584.0711669921875, + "y": 174.620849609375, + "pressure": 1023, + "timestamp": 1774179078011 + }, + { + "x": 583.476806640625, + "y": 175.61135864257812, + "pressure": 1025, + "timestamp": 1774179078013 + }, + { + "x": 582.8824462890625, + "y": 176.60186767578125, + "pressure": 1024, + "timestamp": 1774179078017 + }, + { + "x": 582.4862060546875, + "y": 177.59237670898438, + "pressure": 1013, + "timestamp": 1774179078018 + }, + { + "x": 582.0899658203125, + "y": 178.5828857421875, + "pressure": 1008, + "timestamp": 1774179078020 + }, + { + "x": 581.6936645507812, + "y": 179.37527465820312, + "pressure": 1005, + "timestamp": 1774179078022 + }, + { + "x": 581.2974243164062, + "y": 180.16769409179688, + "pressure": 1004, + "timestamp": 1774179078023 + }, + { + "x": 580.9011840820312, + "y": 180.9600830078125, + "pressure": 1003, + "timestamp": 1774179078025 + }, + { + "x": 580.5049438476562, + "y": 181.75250244140625, + "pressure": 1005, + "timestamp": 1774179078027 + }, + { + "x": 580.3068237304688, + "y": 182.148681640625, + "pressure": 1004, + "timestamp": 1774179078029 + }, + { + "x": 580.3068237304688, + "y": 182.148681640625, + "pressure": 953, + "timestamp": 1774179078035 + }, + { + "x": 580.3068237304688, + "y": 182.148681640625, + "pressure": 928, + "timestamp": 1774179078037 + }, + { + "x": 580.1087036132812, + "y": 182.54489135742188, + "pressure": 915, + "timestamp": 1774179078039 + }, + { + "x": 579.9105834960938, + "y": 183.535400390625, + "pressure": 909, + "timestamp": 1774179078040 + }, + { + "x": 579.9105834960938, + "y": 184.52590942382812, + "pressure": 906, + "timestamp": 1774179078041 + }, + { + "x": 579.9105834960938, + "y": 185.31829833984375, + "pressure": 904, + "timestamp": 1774179078044 + }, + { + "x": 579.9105834960938, + "y": 186.1107177734375, + "pressure": 903, + "timestamp": 1774179078045 + }, + { + "x": 579.9105834960938, + "y": 186.50689697265625, + "pressure": 905, + "timestamp": 1774179078049 + }, + { + "x": 579.9105834960938, + "y": 186.50689697265625, + "pressure": 849, + "timestamp": 1774179078051 + }, + { + "x": 579.9105834960938, + "y": 186.50689697265625, + "pressure": 821, + "timestamp": 1774179078053 + }, + { + "x": 579.9105834960938, + "y": 186.50689697265625, + "pressure": 807, + "timestamp": 1774179078054 + }, + { + "x": 579.9105834960938, + "y": 186.50689697265625, + "pressure": 800, + "timestamp": 1774179078056 + }, + { + "x": 579.9105834960938, + "y": 186.50689697265625, + "pressure": 796, + "timestamp": 1774179078057 + }, + { + "x": 579.9105834960938, + "y": 186.50689697265625, + "pressure": 793, + "timestamp": 1774179078061 + }, + { + "x": 580.3068237304688, + "y": 186.90310668945312, + "pressure": 799, + "timestamp": 1774179078067 + }, + { + "x": 581.0993041992188, + "y": 187.49740600585938, + "pressure": 801, + "timestamp": 1774179078069 + }, + { + "x": 581.891845703125, + "y": 187.89361572265625, + "pressure": 802, + "timestamp": 1774179078070 + }, + { + "x": 582.684326171875, + "y": 188.28982543945312, + "pressure": 803, + "timestamp": 1774179078072 + }, + { + "x": 583.08056640625, + "y": 188.28982543945312, + "pressure": 805, + "timestamp": 1774179078073 + }, + { + "x": 583.476806640625, + "y": 188.28982543945312, + "pressure": 806, + "timestamp": 1774179078077 + }, + { + "x": 584.269287109375, + "y": 188.09170532226562, + "pressure": 805, + "timestamp": 1774179078081 + }, + { + "x": 584.66552734375, + "y": 187.89361572265625, + "pressure": 781, + "timestamp": 1774179078082 + }, + { + "x": 585.061767578125, + "y": 187.89361572265625, + "pressure": 769, + "timestamp": 1774179078085 + }, + { + "x": 585.8543090820312, + "y": 187.69549560546875, + "pressure": 763, + "timestamp": 1774179078086 + }, + { + "x": 586.8449096679688, + "y": 187.49740600585938, + "pressure": 760, + "timestamp": 1774179078088 + }, + { + "x": 587.637451171875, + "y": 187.1011962890625, + "pressure": 759, + "timestamp": 1774179078089 + }, + { + "x": 588.429931640625, + "y": 186.70501708984375, + "pressure": 758, + "timestamp": 1774179078092 + }, + { + "x": 589.222412109375, + "y": 186.30880737304688, + "pressure": 760, + "timestamp": 1774179078093 + }, + { + "x": 590.014892578125, + "y": 185.91259765625, + "pressure": 759, + "timestamp": 1774179078098 + }, + { + "x": 590.4111328125, + "y": 185.71450805664062, + "pressure": 682, + "timestamp": 1774179078099 + }, + { + "x": 590.4111328125, + "y": 185.71450805664062, + "pressure": 643, + "timestamp": 1774179078101 + }, + { + "x": 590.4111328125, + "y": 185.71450805664062, + "pressure": 624, + "timestamp": 1774179078102 + }, + { + "x": 590.8074340820312, + "y": 185.51638793945312, + "pressure": 614, + "timestamp": 1774179078104 + }, + { + "x": 591.5999145507812, + "y": 184.92208862304688, + "pressure": 609, + "timestamp": 1774179078105 + }, + { + "x": 592.3923950195312, + "y": 184.32778930664062, + "pressure": 607, + "timestamp": 1774179078107 + }, + { + "x": 593.1849365234375, + "y": 183.73348999023438, + "pressure": 606, + "timestamp": 1774179078109 + }, + { + "x": 593.9774169921875, + "y": 183.13919067382812, + "pressure": 605, + "timestamp": 1774179078114 + }, + { + "x": 594.7698974609375, + "y": 182.54489135742188, + "pressure": 339, + "timestamp": 1774179078115 + }, + { + "x": 595.5623779296875, + "y": 181.95059204101562, + "pressure": 206, + "timestamp": 1774179078117 + }, + { + "x": 596.3549194335938, + "y": 181.35629272460938, + "pressure": 140, + "timestamp": 1774179078119 + }, + { + "x": 597.1473999023438, + "y": 180.76199340820312, + "pressure": 107, + "timestamp": 1774179078120 + }, + { + "x": 597.9398803710938, + "y": 180.16769409179688, + "pressure": 90, + "timestamp": 1774179078121 + }, + { + "x": 598.732421875, + "y": 179.57339477539062, + "pressure": 82, + "timestamp": 1774179078123 + }, + { + "x": 599.52490234375, + "y": 178.97906494140625, + "pressure": 78, + "timestamp": 1774179078125 + }, + { + "x": 600.3173828125, + "y": 178.384765625, + "pressure": 76, + "timestamp": 1774179078129 + }, + { + "x": 600.3173828125, + "y": 178.384765625, + "pressure": 76, + "timestamp": 1774179078130 + } + ] + }, + { + "index": 3, + "pointCount": 540, + "points": [ + { + "x": 614.1860961914062, + "y": 163.13101196289062, + "pressure": 217, + "timestamp": 1774179078165 + }, + { + "x": 614.1860961914062, + "y": 163.13101196289062, + "pressure": 326, + "timestamp": 1774179078166 + }, + { + "x": 614.1860961914062, + "y": 163.13101196289062, + "pressure": 380, + "timestamp": 1774179078168 + }, + { + "x": 614.1860961914062, + "y": 163.13101196289062, + "pressure": 407, + "timestamp": 1774179078170 + }, + { + "x": 614.1860961914062, + "y": 163.13101196289062, + "pressure": 421, + "timestamp": 1774179078171 + }, + { + "x": 614.1860961914062, + "y": 163.13101196289062, + "pressure": 428, + "timestamp": 1774179078173 + }, + { + "x": 614.1860961914062, + "y": 163.13101196289062, + "pressure": 431, + "timestamp": 1774179078177 + }, + { + "x": 614.1860961914062, + "y": 163.13101196289062, + "pressure": 751, + "timestamp": 1774179078179 + }, + { + "x": 614.1860961914062, + "y": 163.13101196289062, + "pressure": 911, + "timestamp": 1774179078181 + }, + { + "x": 614.1860961914062, + "y": 163.13101196289062, + "pressure": 991, + "timestamp": 1774179078183 + }, + { + "x": 614.1860961914062, + "y": 163.13101196289062, + "pressure": 1031, + "timestamp": 1774179078184 + }, + { + "x": 614.1860961914062, + "y": 163.13101196289062, + "pressure": 1051, + "timestamp": 1774179078186 + }, + { + "x": 614.1860961914062, + "y": 163.13101196289062, + "pressure": 1061, + "timestamp": 1774179078188 + }, + { + "x": 614.1860961914062, + "y": 163.13101196289062, + "pressure": 1066, + "timestamp": 1774179078189 + }, + { + "x": 614.1860961914062, + "y": 163.13101196289062, + "pressure": 1196, + "timestamp": 1774179078195 + }, + { + "x": 614.1860961914062, + "y": 163.13101196289062, + "pressure": 1260, + "timestamp": 1774179078197 + }, + { + "x": 614.1860961914062, + "y": 163.13101196289062, + "pressure": 1292, + "timestamp": 1774179078199 + }, + { + "x": 614.1860961914062, + "y": 163.13101196289062, + "pressure": 1308, + "timestamp": 1774179078200 + }, + { + "x": 614.1860961914062, + "y": 163.13101196289062, + "pressure": 1316, + "timestamp": 1774179078202 + }, + { + "x": 614.1860961914062, + "y": 163.13101196289062, + "pressure": 1320, + "timestamp": 1774179078204 + }, + { + "x": 614.1860961914062, + "y": 163.13101196289062, + "pressure": 1323, + "timestamp": 1774179078209 + }, + { + "x": 614.1860961914062, + "y": 163.13101196289062, + "pressure": 1318, + "timestamp": 1774179078227 + }, + { + "x": 614.1860961914062, + "y": 163.13101196289062, + "pressure": 1315, + "timestamp": 1774179078229 + }, + { + "x": 614.1860961914062, + "y": 163.13101196289062, + "pressure": 1312, + "timestamp": 1774179078232 + }, + { + "x": 614.1860961914062, + "y": 163.13101196289062, + "pressure": 1309, + "timestamp": 1774179078247 + }, + { + "x": 614.1860961914062, + "y": 163.13101196289062, + "pressure": 1304, + "timestamp": 1774179078259 + }, + { + "x": 614.1860961914062, + "y": 163.13101196289062, + "pressure": 1301, + "timestamp": 1774179078261 + }, + { + "x": 614.1860961914062, + "y": 163.13101196289062, + "pressure": 1254, + "timestamp": 1774179078275 + }, + { + "x": 614.1860961914062, + "y": 163.13101196289062, + "pressure": 1230, + "timestamp": 1774179078278 + }, + { + "x": 614.1860961914062, + "y": 163.13101196289062, + "pressure": 1218, + "timestamp": 1774179078279 + }, + { + "x": 614.1860961914062, + "y": 163.13101196289062, + "pressure": 1212, + "timestamp": 1774179078280 + }, + { + "x": 613.7898559570312, + "y": 164.12152099609375, + "pressure": 1209, + "timestamp": 1774179078282 + }, + { + "x": 613.3936157226562, + "y": 166.1025390625, + "pressure": 1208, + "timestamp": 1774179078284 + }, + { + "x": 612.9973754882812, + "y": 167.68734741210938, + "pressure": 1207, + "timestamp": 1774179078286 + }, + { + "x": 612.9973754882812, + "y": 168.87594604492188, + "pressure": 1209, + "timestamp": 1774179078289 + }, + { + "x": 612.9973754882812, + "y": 169.866455078125, + "pressure": 1105, + "timestamp": 1774179078292 + }, + { + "x": 613.1954956054688, + "y": 170.26263427734375, + "pressure": 1053, + "timestamp": 1774179078294 + }, + { + "x": 613.1954956054688, + "y": 170.26263427734375, + "pressure": 1027, + "timestamp": 1774179078295 + }, + { + "x": 613.1954956054688, + "y": 170.26263427734375, + "pressure": 1014, + "timestamp": 1774179078296 + }, + { + "x": 613.1954956054688, + "y": 170.26263427734375, + "pressure": 1007, + "timestamp": 1774179078298 + }, + { + "x": 613.5917358398438, + "y": 170.46075439453125, + "pressure": 1004, + "timestamp": 1774179078300 + }, + { + "x": 614.3842163085938, + "y": 170.85693359375, + "pressure": 1002, + "timestamp": 1774179078302 + }, + { + "x": 615.1767578125, + "y": 171.25314331054688, + "pressure": 1001, + "timestamp": 1774179078306 + }, + { + "x": 615.96923828125, + "y": 171.64935302734375, + "pressure": 862, + "timestamp": 1774179078307 + }, + { + "x": 616.76171875, + "y": 171.84744262695312, + "pressure": 793, + "timestamp": 1774179078310 + }, + { + "x": 617.55419921875, + "y": 171.84744262695312, + "pressure": 758, + "timestamp": 1774179078311 + }, + { + "x": 618.3467407226562, + "y": 171.84744262695312, + "pressure": 741, + "timestamp": 1774179078313 + }, + { + "x": 619.1392211914062, + "y": 171.84744262695312, + "pressure": 732, + "timestamp": 1774179078314 + }, + { + "x": 619.9317016601562, + "y": 171.64935302734375, + "pressure": 728, + "timestamp": 1774179078316 + }, + { + "x": 620.7242431640625, + "y": 171.45126342773438, + "pressure": 726, + "timestamp": 1774179078318 + }, + { + "x": 621.1204833984375, + "y": 171.45126342773438, + "pressure": 725, + "timestamp": 1774179078322 + }, + { + "x": 621.1204833984375, + "y": 171.45126342773438, + "pressure": 697, + "timestamp": 1774179078323 + }, + { + "x": 621.5167236328125, + "y": 171.45126342773438, + "pressure": 683, + "timestamp": 1774179078326 + }, + { + "x": 622.3092041015625, + "y": 171.45126342773438, + "pressure": 676, + "timestamp": 1774179078327 + }, + { + "x": 623.1016845703125, + "y": 171.25314331054688, + "pressure": 673, + "timestamp": 1774179078329 + }, + { + "x": 623.4979248046875, + "y": 171.25314331054688, + "pressure": 671, + "timestamp": 1774179078330 + }, + { + "x": 623.4979248046875, + "y": 171.25314331054688, + "pressure": 748, + "timestamp": 1774179078339 + }, + { + "x": 623.4979248046875, + "y": 171.25314331054688, + "pressure": 786, + "timestamp": 1774179078341 + }, + { + "x": 623.4979248046875, + "y": 171.25314331054688, + "pressure": 805, + "timestamp": 1774179078343 + }, + { + "x": 623.4979248046875, + "y": 171.25314331054688, + "pressure": 815, + "timestamp": 1774179078344 + }, + { + "x": 623.4979248046875, + "y": 171.25314331054688, + "pressure": 820, + "timestamp": 1774179078346 + }, + { + "x": 623.4979248046875, + "y": 171.25314331054688, + "pressure": 823, + "timestamp": 1774179078350 + }, + { + "x": 623.4979248046875, + "y": 171.25314331054688, + "pressure": 1172, + "timestamp": 1774179078355 + }, + { + "x": 623.4979248046875, + "y": 171.25314331054688, + "pressure": 1346, + "timestamp": 1774179078358 + }, + { + "x": 623.4979248046875, + "y": 171.25314331054688, + "pressure": 1433, + "timestamp": 1774179078359 + }, + { + "x": 623.4979248046875, + "y": 171.64935302734375, + "pressure": 1476, + "timestamp": 1774179078361 + }, + { + "x": 623.6961059570312, + "y": 172.44174194335938, + "pressure": 1498, + "timestamp": 1774179078363 + }, + { + "x": 623.6961059570312, + "y": 172.83795166015625, + "pressure": 1509, + "timestamp": 1774179078364 + }, + { + "x": 623.4979248046875, + "y": 173.23416137695312, + "pressure": 1514, + "timestamp": 1774179078366 + }, + { + "x": 623.1016845703125, + "y": 174.22467041015625, + "pressure": 1517, + "timestamp": 1774179078370 + }, + { + "x": 622.50732421875, + "y": 175.21514892578125, + "pressure": 1603, + "timestamp": 1774179078371 + }, + { + "x": 621.9129638671875, + "y": 176.007568359375, + "pressure": 1646, + "timestamp": 1774179078373 + }, + { + "x": 621.318603515625, + "y": 176.79995727539062, + "pressure": 1667, + "timestamp": 1774179078375 + }, + { + "x": 620.7242431640625, + "y": 177.59237670898438, + "pressure": 1678, + "timestamp": 1774179078377 + }, + { + "x": 620.1298828125, + "y": 178.384765625, + "pressure": 1683, + "timestamp": 1774179078378 + }, + { + "x": 619.5354614257812, + "y": 179.17718505859375, + "pressure": 1686, + "timestamp": 1774179078381 + }, + { + "x": 618.7429809570312, + "y": 179.96957397460938, + "pressure": 1687, + "timestamp": 1774179078382 + }, + { + "x": 617.950439453125, + "y": 180.76199340820312, + "pressure": 1688, + "timestamp": 1774179078386 + }, + { + "x": 617.3560791015625, + "y": 181.55438232421875, + "pressure": 1691, + "timestamp": 1774179078387 + }, + { + "x": 616.76171875, + "y": 182.3468017578125, + "pressure": 1692, + "timestamp": 1774179078390 + }, + { + "x": 616.365478515625, + "y": 182.74298095703125, + "pressure": 1693, + "timestamp": 1774179078391 + }, + { + "x": 615.96923828125, + "y": 183.3372802734375, + "pressure": 1696, + "timestamp": 1774179078396 + }, + { + "x": 615.1767578125, + "y": 184.32778930664062, + "pressure": 1695, + "timestamp": 1774179078398 + }, + { + "x": 614.3842163085938, + "y": 185.51638793945312, + "pressure": 1694, + "timestamp": 1774179078402 + }, + { + "x": 613.5917358398438, + "y": 186.30880737304688, + "pressure": 1687, + "timestamp": 1774179078403 + }, + { + "x": 613.1954956054688, + "y": 187.1011962890625, + "pressure": 1684, + "timestamp": 1774179078406 + }, + { + "x": 612.9973754882812, + "y": 187.49740600585938, + "pressure": 1682, + "timestamp": 1774179078408 + }, + { + "x": 612.7992553710938, + "y": 187.89361572265625, + "pressure": 1683, + "timestamp": 1774179078418 + }, + { + "x": 612.204833984375, + "y": 188.68600463867188, + "pressure": 1670, + "timestamp": 1774179078420 + }, + { + "x": 612.0067138671875, + "y": 189.08221435546875, + "pressure": 1663, + "timestamp": 1774179078422 + }, + { + "x": 612.0067138671875, + "y": 189.08221435546875, + "pressure": 1660, + "timestamp": 1774179078423 + }, + { + "x": 612.0067138671875, + "y": 189.08221435546875, + "pressure": 1657, + "timestamp": 1774179078427 + }, + { + "x": 612.0067138671875, + "y": 189.08221435546875, + "pressure": 1671, + "timestamp": 1774179078435 + }, + { + "x": 612.0067138671875, + "y": 189.08221435546875, + "pressure": 1678, + "timestamp": 1774179078438 + }, + { + "x": 612.0067138671875, + "y": 189.08221435546875, + "pressure": 1682, + "timestamp": 1774179078439 + }, + { + "x": 612.0067138671875, + "y": 189.08221435546875, + "pressure": 1685, + "timestamp": 1774179078443 + }, + { + "x": 612.4029541015625, + "y": 189.28030395507812, + "pressure": 1688, + "timestamp": 1774179078450 + }, + { + "x": 613.1954956054688, + "y": 189.87460327148438, + "pressure": 1686, + "timestamp": 1774179078452 + }, + { + "x": 613.9879760742188, + "y": 190.46893310546875, + "pressure": 1685, + "timestamp": 1774179078454 + }, + { + "x": 614.780517578125, + "y": 190.8651123046875, + "pressure": 1687, + "timestamp": 1774179078455 + }, + { + "x": 615.572998046875, + "y": 191.26132202148438, + "pressure": 1686, + "timestamp": 1774179078457 + }, + { + "x": 616.365478515625, + "y": 191.65753173828125, + "pressure": 1685, + "timestamp": 1774179078458 + }, + { + "x": 617.157958984375, + "y": 191.65753173828125, + "pressure": 1687, + "timestamp": 1774179078460 + }, + { + "x": 617.950439453125, + "y": 191.65753173828125, + "pressure": 1686, + "timestamp": 1774179078462 + }, + { + "x": 618.7429809570312, + "y": 191.65753173828125, + "pressure": 1685, + "timestamp": 1774179078466 + }, + { + "x": 619.5354614257812, + "y": 191.65753173828125, + "pressure": 1659, + "timestamp": 1774179078468 + }, + { + "x": 620.3280029296875, + "y": 191.65753173828125, + "pressure": 1646, + "timestamp": 1774179078470 + }, + { + "x": 621.1204833984375, + "y": 191.65753173828125, + "pressure": 1639, + "timestamp": 1774179078471 + }, + { + "x": 621.9129638671875, + "y": 191.65753173828125, + "pressure": 1636, + "timestamp": 1774179078473 + }, + { + "x": 622.7054443359375, + "y": 191.45941162109375, + "pressure": 1634, + "timestamp": 1774179078475 + }, + { + "x": 623.4979248046875, + "y": 191.26132202148438, + "pressure": 1633, + "timestamp": 1774179078476 + }, + { + "x": 624.2904663085938, + "y": 191.063232421875, + "pressure": 1635, + "timestamp": 1774179078478 + }, + { + "x": 624.6867065429688, + "y": 190.8651123046875, + "pressure": 1634, + "timestamp": 1774179078482 + }, + { + "x": 624.6867065429688, + "y": 190.8651123046875, + "pressure": 1622, + "timestamp": 1774179078484 + }, + { + "x": 625.0829467773438, + "y": 190.8651123046875, + "pressure": 1616, + "timestamp": 1774179078486 + }, + { + "x": 625.87548828125, + "y": 190.66702270507812, + "pressure": 1613, + "timestamp": 1774179078488 + }, + { + "x": 626.66796875, + "y": 190.46893310546875, + "pressure": 1611, + "timestamp": 1774179078489 + }, + { + "x": 627.46044921875, + "y": 190.27081298828125, + "pressure": 1610, + "timestamp": 1774179078491 + }, + { + "x": 628.2529296875, + "y": 190.07272338867188, + "pressure": 1612, + "timestamp": 1774179078493 + }, + { + "x": 629.0454711914062, + "y": 189.87460327148438, + "pressure": 1611, + "timestamp": 1774179078494 + }, + { + "x": 629.4417114257812, + "y": 189.676513671875, + "pressure": 1610, + "timestamp": 1774179078498 + }, + { + "x": 629.4417114257812, + "y": 189.676513671875, + "pressure": 1587, + "timestamp": 1774179078500 + }, + { + "x": 629.8379516601562, + "y": 189.47842407226562, + "pressure": 1576, + "timestamp": 1774179078502 + }, + { + "x": 630.6304321289062, + "y": 188.88412475585938, + "pressure": 1570, + "timestamp": 1774179078504 + }, + { + "x": 631.4229736328125, + "y": 188.4879150390625, + "pressure": 1567, + "timestamp": 1774179078505 + }, + { + "x": 631.8192138671875, + "y": 188.28982543945312, + "pressure": 1566, + "timestamp": 1774179078507 + }, + { + "x": 632.2154541015625, + "y": 188.09170532226562, + "pressure": 1567, + "timestamp": 1774179078510 + }, + { + "x": 633.0079345703125, + "y": 187.49740600585938, + "pressure": 1566, + "timestamp": 1774179078514 + }, + { + "x": 633.8004150390625, + "y": 187.1011962890625, + "pressure": 1518, + "timestamp": 1774179078516 + }, + { + "x": 634.5929565429688, + "y": 186.70501708984375, + "pressure": 1494, + "timestamp": 1774179078518 + }, + { + "x": 634.9891967773438, + "y": 186.50689697265625, + "pressure": 1482, + "timestamp": 1774179078520 + }, + { + "x": 634.9891967773438, + "y": 186.50689697265625, + "pressure": 1476, + "timestamp": 1774179078521 + }, + { + "x": 635.3854370117188, + "y": 186.30880737304688, + "pressure": 1473, + "timestamp": 1774179078523 + }, + { + "x": 636.1779174804688, + "y": 185.71450805664062, + "pressure": 1472, + "timestamp": 1774179078524 + }, + { + "x": 637.1685791015625, + "y": 185.12020874023438, + "pressure": 1471, + "timestamp": 1774179078526 + }, + { + "x": 637.5648193359375, + "y": 184.92208862304688, + "pressure": 1473, + "timestamp": 1774179078530 + }, + { + "x": 637.9610595703125, + "y": 184.7239990234375, + "pressure": 1424, + "timestamp": 1774179078532 + }, + { + "x": 638.7535400390625, + "y": 184.32778930664062, + "pressure": 1400, + "timestamp": 1774179078534 + }, + { + "x": 639.5460205078125, + "y": 183.73348999023438, + "pressure": 1388, + "timestamp": 1774179078535 + }, + { + "x": 639.9423217773438, + "y": 183.535400390625, + "pressure": 1382, + "timestamp": 1774179078537 + }, + { + "x": 639.9423217773438, + "y": 183.535400390625, + "pressure": 1379, + "timestamp": 1774179078539 + }, + { + "x": 640.3385620117188, + "y": 183.3372802734375, + "pressure": 1377, + "timestamp": 1774179078541 + }, + { + "x": 641.1310424804688, + "y": 182.74298095703125, + "pressure": 1376, + "timestamp": 1774179078542 + }, + { + "x": 641.5272827148438, + "y": 182.3468017578125, + "pressure": 1378, + "timestamp": 1774179078546 + }, + { + "x": 641.5272827148438, + "y": 182.3468017578125, + "pressure": 1361, + "timestamp": 1774179078548 + }, + { + "x": 641.9235229492188, + "y": 182.148681640625, + "pressure": 1352, + "timestamp": 1774179078550 + }, + { + "x": 642.716064453125, + "y": 181.55438232421875, + "pressure": 1348, + "timestamp": 1774179078552 + }, + { + "x": 643.1123046875, + "y": 181.35629272460938, + "pressure": 1346, + "timestamp": 1774179078553 + }, + { + "x": 643.508544921875, + "y": 181.15817260742188, + "pressure": 1344, + "timestamp": 1774179078557 + }, + { + "x": 644.301025390625, + "y": 180.56387329101562, + "pressure": 1346, + "timestamp": 1774179078558 + }, + { + "x": 644.697265625, + "y": 180.16769409179688, + "pressure": 1345, + "timestamp": 1774179078562 + }, + { + "x": 644.697265625, + "y": 180.16769409179688, + "pressure": 1348, + "timestamp": 1774179078566 + }, + { + "x": 645.093505859375, + "y": 179.96957397460938, + "pressure": 1349, + "timestamp": 1774179078568 + }, + { + "x": 645.8860473632812, + "y": 179.37527465820312, + "pressure": 1351, + "timestamp": 1774179078569 + }, + { + "x": 646.6785278320312, + "y": 178.97906494140625, + "pressure": 1350, + "timestamp": 1774179078571 + }, + { + "x": 647.0747680664062, + "y": 178.78097534179688, + "pressure": 1352, + "timestamp": 1774179078573 + }, + { + "x": 647.0747680664062, + "y": 178.78097534179688, + "pressure": 1356, + "timestamp": 1774179078582 + }, + { + "x": 647.4710693359375, + "y": 178.5828857421875, + "pressure": 1357, + "timestamp": 1774179078584 + }, + { + "x": 648.2635498046875, + "y": 178.18667602539062, + "pressure": 1359, + "timestamp": 1774179078585 + }, + { + "x": 648.6597900390625, + "y": 177.98858642578125, + "pressure": 1358, + "timestamp": 1774179078587 + }, + { + "x": 648.6597900390625, + "y": 177.98858642578125, + "pressure": 1369, + "timestamp": 1774179078596 + }, + { + "x": 648.6597900390625, + "y": 177.98858642578125, + "pressure": 1374, + "timestamp": 1774179078598 + }, + { + "x": 648.6597900390625, + "y": 177.98858642578125, + "pressure": 1377, + "timestamp": 1774179078600 + }, + { + "x": 648.6597900390625, + "y": 177.98858642578125, + "pressure": 1381, + "timestamp": 1774179078605 + }, + { + "x": 649.0560302734375, + "y": 177.79046630859375, + "pressure": 1406, + "timestamp": 1774179078612 + }, + { + "x": 649.8485107421875, + "y": 177.1961669921875, + "pressure": 1418, + "timestamp": 1774179078615 + }, + { + "x": 650.2447509765625, + "y": 176.99807739257812, + "pressure": 1424, + "timestamp": 1774179078616 + }, + { + "x": 650.2447509765625, + "y": 176.99807739257812, + "pressure": 1427, + "timestamp": 1774179078618 + }, + { + "x": 650.2447509765625, + "y": 176.99807739257812, + "pressure": 1431, + "timestamp": 1774179078623 + }, + { + "x": 650.2447509765625, + "y": 176.99807739257812, + "pressure": 1444, + "timestamp": 1774179078628 + }, + { + "x": 650.2447509765625, + "y": 176.99807739257812, + "pressure": 1451, + "timestamp": 1774179078630 + }, + { + "x": 650.2447509765625, + "y": 176.99807739257812, + "pressure": 1454, + "timestamp": 1774179078632 + }, + { + "x": 650.6409912109375, + "y": 176.79995727539062, + "pressure": 1457, + "timestamp": 1774179078635 + }, + { + "x": 651.4335327148438, + "y": 176.20565795898438, + "pressure": 1459, + "timestamp": 1774179078637 + }, + { + "x": 651.8297729492188, + "y": 175.80947875976562, + "pressure": 1458, + "timestamp": 1774179078638 + }, + { + "x": 652.2260131835938, + "y": 175.61135864257812, + "pressure": 1459, + "timestamp": 1774179078650 + }, + { + "x": 653.0185546875, + "y": 175.01705932617188, + "pressure": 1458, + "timestamp": 1774179078651 + }, + { + "x": 653.414794921875, + "y": 174.620849609375, + "pressure": 1460, + "timestamp": 1774179078653 + }, + { + "x": 653.81103515625, + "y": 174.42276000976562, + "pressure": 1460, + "timestamp": 1774179078665 + }, + { + "x": 654.603515625, + "y": 173.82846069335938, + "pressure": 1459, + "timestamp": 1774179078667 + }, + { + "x": 654.999755859375, + "y": 173.63037109375, + "pressure": 1461, + "timestamp": 1774179078669 + }, + { + "x": 654.999755859375, + "y": 173.63037109375, + "pressure": 1453, + "timestamp": 1774179078676 + }, + { + "x": 654.999755859375, + "y": 173.63037109375, + "pressure": 1450, + "timestamp": 1774179078679 + }, + { + "x": 655.39599609375, + "y": 173.4322509765625, + "pressure": 1447, + "timestamp": 1774179078681 + }, + { + "x": 656.1884765625, + "y": 172.83795166015625, + "pressure": 1449, + "timestamp": 1774179078683 + }, + { + "x": 656.5847778320312, + "y": 172.63986206054688, + "pressure": 1448, + "timestamp": 1774179078685 + }, + { + "x": 656.5847778320312, + "y": 172.63986206054688, + "pressure": 1443, + "timestamp": 1774179078692 + }, + { + "x": 656.5847778320312, + "y": 172.63986206054688, + "pressure": 1440, + "timestamp": 1774179078694 + }, + { + "x": 656.9810180664062, + "y": 172.44174194335938, + "pressure": 1439, + "timestamp": 1774179078696 + }, + { + "x": 657.7734985351562, + "y": 171.84744262695312, + "pressure": 1438, + "timestamp": 1774179078697 + }, + { + "x": 658.5660400390625, + "y": 171.45126342773438, + "pressure": 1440, + "timestamp": 1774179078699 + }, + { + "x": 658.9622802734375, + "y": 171.25314331054688, + "pressure": 1439, + "timestamp": 1774179078701 + }, + { + "x": 658.9622802734375, + "y": 171.25314331054688, + "pressure": 1444, + "timestamp": 1774179078708 + }, + { + "x": 658.9622802734375, + "y": 171.25314331054688, + "pressure": 1447, + "timestamp": 1774179078712 + }, + { + "x": 658.9622802734375, + "y": 171.25314331054688, + "pressure": 1450, + "timestamp": 1774179078715 + }, + { + "x": 658.9622802734375, + "y": 171.25314331054688, + "pressure": 1516, + "timestamp": 1774179078724 + }, + { + "x": 658.9622802734375, + "y": 171.25314331054688, + "pressure": 1549, + "timestamp": 1774179078726 + }, + { + "x": 658.9622802734375, + "y": 171.25314331054688, + "pressure": 1566, + "timestamp": 1774179078728 + }, + { + "x": 658.9622802734375, + "y": 171.25314331054688, + "pressure": 1574, + "timestamp": 1774179078730 + }, + { + "x": 659.160400390625, + "y": 170.85693359375, + "pressure": 1578, + "timestamp": 1774179078732 + }, + { + "x": 659.7547607421875, + "y": 170.06454467773438, + "pressure": 1580, + "timestamp": 1774179078733 + }, + { + "x": 660.1510009765625, + "y": 169.6683349609375, + "pressure": 1581, + "timestamp": 1774179078735 + }, + { + "x": 660.1510009765625, + "y": 169.6683349609375, + "pressure": 1647, + "timestamp": 1774179078740 + }, + { + "x": 660.1510009765625, + "y": 169.6683349609375, + "pressure": 1680, + "timestamp": 1774179078742 + }, + { + "x": 660.1510009765625, + "y": 169.6683349609375, + "pressure": 1696, + "timestamp": 1774179078744 + }, + { + "x": 660.1510009765625, + "y": 169.6683349609375, + "pressure": 1704, + "timestamp": 1774179078746 + }, + { + "x": 660.1510009765625, + "y": 169.6683349609375, + "pressure": 1708, + "timestamp": 1774179078747 + }, + { + "x": 660.1510009765625, + "y": 169.27215576171875, + "pressure": 1711, + "timestamp": 1774179078751 + }, + { + "x": 660.34912109375, + "y": 168.479736328125, + "pressure": 1712, + "timestamp": 1774179078755 + }, + { + "x": 660.5472412109375, + "y": 168.08352661132812, + "pressure": 1735, + "timestamp": 1774179078756 + }, + { + "x": 660.5472412109375, + "y": 168.08352661132812, + "pressure": 1746, + "timestamp": 1774179078759 + }, + { + "x": 660.5472412109375, + "y": 168.08352661132812, + "pressure": 1752, + "timestamp": 1774179078760 + }, + { + "x": 660.5472412109375, + "y": 168.08352661132812, + "pressure": 1755, + "timestamp": 1774179078762 + }, + { + "x": 660.5472412109375, + "y": 168.08352661132812, + "pressure": 1759, + "timestamp": 1774179078767 + }, + { + "x": 660.5472412109375, + "y": 168.08352661132812, + "pressure": 1769, + "timestamp": 1774179078772 + }, + { + "x": 660.5472412109375, + "y": 168.08352661132812, + "pressure": 1775, + "timestamp": 1774179078775 + }, + { + "x": 660.5472412109375, + "y": 168.08352661132812, + "pressure": 1778, + "timestamp": 1774179078776 + }, + { + "x": 660.5472412109375, + "y": 168.08352661132812, + "pressure": 1782, + "timestamp": 1774179078781 + }, + { + "x": 660.5472412109375, + "y": 168.08352661132812, + "pressure": 1785, + "timestamp": 1774179078788 + }, + { + "x": 660.1510009765625, + "y": 167.88543701171875, + "pressure": 1784, + "timestamp": 1774179078791 + }, + { + "x": 659.160400390625, + "y": 167.68734741210938, + "pressure": 1783, + "timestamp": 1774179078792 + }, + { + "x": 658.1697387695312, + "y": 167.48922729492188, + "pressure": 1785, + "timestamp": 1774179078794 + }, + { + "x": 657.3772583007812, + "y": 167.48922729492188, + "pressure": 1784, + "timestamp": 1774179078795 + }, + { + "x": 656.9810180664062, + "y": 167.48922729492188, + "pressure": 1783, + "timestamp": 1774179078797 + }, + { + "x": 656.5847778320312, + "y": 167.48922729492188, + "pressure": 1785, + "timestamp": 1774179078799 + }, + { + "x": 655.5941162109375, + "y": 167.68734741210938, + "pressure": 1784, + "timestamp": 1774179078803 + }, + { + "x": 654.603515625, + "y": 167.88543701171875, + "pressure": 1796, + "timestamp": 1774179078804 + }, + { + "x": 653.6129150390625, + "y": 168.08352661132812, + "pressure": 1802, + "timestamp": 1774179078807 + }, + { + "x": 652.6222534179688, + "y": 168.479736328125, + "pressure": 1805, + "timestamp": 1774179078808 + }, + { + "x": 651.6316528320312, + "y": 168.87594604492188, + "pressure": 1806, + "timestamp": 1774179078810 + }, + { + "x": 650.8391723632812, + "y": 169.27215576171875, + "pressure": 1807, + "timestamp": 1774179078811 + }, + { + "x": 650.046630859375, + "y": 169.6683349609375, + "pressure": 1809, + "timestamp": 1774179078813 + }, + { + "x": 649.254150390625, + "y": 170.06454467773438, + "pressure": 1808, + "timestamp": 1774179078815 + }, + { + "x": 648.461669921875, + "y": 170.46075439453125, + "pressure": 1810, + "timestamp": 1774179078819 + }, + { + "x": 648.0654296875, + "y": 170.65884399414062, + "pressure": 1808, + "timestamp": 1774179078821 + }, + { + "x": 647.669189453125, + "y": 171.0550537109375, + "pressure": 1809, + "timestamp": 1774179078824 + }, + { + "x": 646.8766479492188, + "y": 171.64935302734375, + "pressure": 1808, + "timestamp": 1774179078826 + }, + { + "x": 646.4804077148438, + "y": 171.84744262695312, + "pressure": 1807, + "timestamp": 1774179078827 + }, + { + "x": 646.0841674804688, + "y": 172.24365234375, + "pressure": 1807, + "timestamp": 1774179078835 + }, + { + "x": 645.2916259765625, + "y": 173.03604125976562, + "pressure": 1787, + "timestamp": 1774179078836 + }, + { + "x": 644.4991455078125, + "y": 173.82846069335938, + "pressure": 1777, + "timestamp": 1774179078839 + }, + { + "x": 644.1029052734375, + "y": 174.620849609375, + "pressure": 1772, + "timestamp": 1774179078840 + }, + { + "x": 643.7066650390625, + "y": 175.41326904296875, + "pressure": 1769, + "timestamp": 1774179078842 + }, + { + "x": 643.508544921875, + "y": 175.80947875976562, + "pressure": 1768, + "timestamp": 1774179078843 + }, + { + "x": 643.3104248046875, + "y": 176.20565795898438, + "pressure": 1748, + "timestamp": 1774179078853 + }, + { + "x": 642.716064453125, + "y": 177.1961669921875, + "pressure": 1738, + "timestamp": 1774179078855 + }, + { + "x": 642.31982421875, + "y": 177.98858642578125, + "pressure": 1733, + "timestamp": 1774179078856 + }, + { + "x": 641.9235229492188, + "y": 178.78097534179688, + "pressure": 1730, + "timestamp": 1774179078858 + }, + { + "x": 641.9235229492188, + "y": 179.17718505859375, + "pressure": 1729, + "timestamp": 1774179078860 + }, + { + "x": 641.9235229492188, + "y": 179.57339477539062, + "pressure": 1737, + "timestamp": 1774179078869 + }, + { + "x": 641.9235229492188, + "y": 180.36578369140625, + "pressure": 1741, + "timestamp": 1774179078871 + }, + { + "x": 641.9235229492188, + "y": 181.15817260742188, + "pressure": 1743, + "timestamp": 1774179078872 + }, + { + "x": 641.9235229492188, + "y": 181.55438232421875, + "pressure": 1744, + "timestamp": 1774179078874 + }, + { + "x": 641.9235229492188, + "y": 181.55438232421875, + "pressure": 1747, + "timestamp": 1774179078877 + }, + { + "x": 641.9235229492188, + "y": 181.95059204101562, + "pressure": 1746, + "timestamp": 1774179078879 + }, + { + "x": 642.1217041015625, + "y": 182.74298095703125, + "pressure": 1748, + "timestamp": 1774179078883 + }, + { + "x": 642.31982421875, + "y": 183.13919067382812, + "pressure": 1783, + "timestamp": 1774179078885 + }, + { + "x": 642.31982421875, + "y": 183.13919067382812, + "pressure": 1801, + "timestamp": 1774179078887 + }, + { + "x": 642.31982421875, + "y": 183.13919067382812, + "pressure": 1810, + "timestamp": 1774179078888 + }, + { + "x": 642.31982421875, + "y": 183.13919067382812, + "pressure": 1814, + "timestamp": 1774179078890 + }, + { + "x": 642.5179443359375, + "y": 183.535400390625, + "pressure": 1816, + "timestamp": 1774179078892 + }, + { + "x": 642.9141845703125, + "y": 184.52590942382812, + "pressure": 1817, + "timestamp": 1774179078893 + }, + { + "x": 643.3104248046875, + "y": 185.31829833984375, + "pressure": 1818, + "timestamp": 1774179078895 + }, + { + "x": 643.90478515625, + "y": 186.1107177734375, + "pressure": 1820, + "timestamp": 1774179078899 + }, + { + "x": 644.301025390625, + "y": 186.50689697265625, + "pressure": 1840, + "timestamp": 1774179078901 + }, + { + "x": 644.301025390625, + "y": 186.50689697265625, + "pressure": 1850, + "timestamp": 1774179078903 + }, + { + "x": 644.301025390625, + "y": 186.50689697265625, + "pressure": 1855, + "timestamp": 1774179078904 + }, + { + "x": 644.301025390625, + "y": 186.50689697265625, + "pressure": 1858, + "timestamp": 1774179078906 + }, + { + "x": 644.697265625, + "y": 186.90310668945312, + "pressure": 1859, + "timestamp": 1774179078908 + }, + { + "x": 645.4898071289062, + "y": 187.49740600585938, + "pressure": 1860, + "timestamp": 1774179078909 + }, + { + "x": 646.2822875976562, + "y": 187.89361572265625, + "pressure": 1862, + "timestamp": 1774179078911 + }, + { + "x": 647.0747680664062, + "y": 188.28982543945312, + "pressure": 1861, + "timestamp": 1774179078915 + }, + { + "x": 647.4710693359375, + "y": 188.4879150390625, + "pressure": 1862, + "timestamp": 1774179078917 + }, + { + "x": 647.8673095703125, + "y": 188.68600463867188, + "pressure": 1865, + "timestamp": 1774179078920 + }, + { + "x": 648.6597900390625, + "y": 188.88412475585938, + "pressure": 1864, + "timestamp": 1774179078922 + }, + { + "x": 649.4522705078125, + "y": 189.28030395507812, + "pressure": 1866, + "timestamp": 1774179078924 + }, + { + "x": 650.2447509765625, + "y": 189.676513671875, + "pressure": 1865, + "timestamp": 1774179078925 + }, + { + "x": 650.6409912109375, + "y": 189.676513671875, + "pressure": 1864, + "timestamp": 1774179078927 + }, + { + "x": 651.0372924804688, + "y": 189.676513671875, + "pressure": 1837, + "timestamp": 1774179078933 + }, + { + "x": 651.8297729492188, + "y": 189.676513671875, + "pressure": 1823, + "timestamp": 1774179078935 + }, + { + "x": 652.6222534179688, + "y": 189.676513671875, + "pressure": 1816, + "timestamp": 1774179078937 + }, + { + "x": 653.414794921875, + "y": 189.676513671875, + "pressure": 1812, + "timestamp": 1774179078938 + }, + { + "x": 654.207275390625, + "y": 189.47842407226562, + "pressure": 1810, + "timestamp": 1774179078940 + }, + { + "x": 654.999755859375, + "y": 189.28030395507812, + "pressure": 1809, + "timestamp": 1774179078941 + }, + { + "x": 655.792236328125, + "y": 188.88412475585938, + "pressure": 1811, + "timestamp": 1774179078943 + }, + { + "x": 656.5847778320312, + "y": 188.68600463867188, + "pressure": 1810, + "timestamp": 1774179078947 + }, + { + "x": 657.3772583007812, + "y": 188.28982543945312, + "pressure": 1786, + "timestamp": 1774179078949 + }, + { + "x": 658.1697387695312, + "y": 187.89361572265625, + "pressure": 1774, + "timestamp": 1774179078951 + }, + { + "x": 658.9622802734375, + "y": 187.49740600585938, + "pressure": 1768, + "timestamp": 1774179078953 + }, + { + "x": 659.7547607421875, + "y": 187.1011962890625, + "pressure": 1765, + "timestamp": 1774179078954 + }, + { + "x": 660.5472412109375, + "y": 186.70501708984375, + "pressure": 1763, + "timestamp": 1774179078956 + }, + { + "x": 661.3397216796875, + "y": 186.30880737304688, + "pressure": 1762, + "timestamp": 1774179078958 + }, + { + "x": 661.7359619140625, + "y": 186.1107177734375, + "pressure": 1764, + "timestamp": 1774179078959 + }, + { + "x": 661.7359619140625, + "y": 186.1107177734375, + "pressure": 1758, + "timestamp": 1774179078965 + }, + { + "x": 662.1322631835938, + "y": 185.91259765625, + "pressure": 1756, + "timestamp": 1774179078968 + }, + { + "x": 663.1228637695312, + "y": 185.31829833984375, + "pressure": 1755, + "timestamp": 1774179078969 + }, + { + "x": 663.9154052734375, + "y": 184.7239990234375, + "pressure": 1754, + "timestamp": 1774179078970 + }, + { + "x": 664.7078857421875, + "y": 184.12969970703125, + "pressure": 1756, + "timestamp": 1774179078972 + }, + { + "x": 665.5003662109375, + "y": 183.535400390625, + "pressure": 1755, + "timestamp": 1774179078974 + }, + { + "x": 666.2928466796875, + "y": 183.13919067382812, + "pressure": 1754, + "timestamp": 1774179078975 + }, + { + "x": 667.0853271484375, + "y": 182.74298095703125, + "pressure": 1756, + "timestamp": 1774179078979 + }, + { + "x": 667.8778686523438, + "y": 182.3468017578125, + "pressure": 1751, + "timestamp": 1774179078981 + }, + { + "x": 668.6703491210938, + "y": 181.95059204101562, + "pressure": 1749, + "timestamp": 1774179078984 + }, + { + "x": 669.462890625, + "y": 181.55438232421875, + "pressure": 1748, + "timestamp": 1774179078985 + }, + { + "x": 670.25537109375, + "y": 181.15817260742188, + "pressure": 1747, + "timestamp": 1774179078987 + }, + { + "x": 670.651611328125, + "y": 180.76199340820312, + "pressure": 1749, + "timestamp": 1774179078988 + }, + { + "x": 671.0478515625, + "y": 180.56387329101562, + "pressure": 1749, + "timestamp": 1774179078996 + }, + { + "x": 671.84033203125, + "y": 179.96957397460938, + "pressure": 1746, + "timestamp": 1774179078997 + }, + { + "x": 672.6328125, + "y": 179.37527465820312, + "pressure": 1744, + "timestamp": 1774179078999 + }, + { + "x": 673.4253540039062, + "y": 178.78097534179688, + "pressure": 1743, + "timestamp": 1774179079001 + }, + { + "x": 673.8215942382812, + "y": 178.5828857421875, + "pressure": 1745, + "timestamp": 1774179079002 + }, + { + "x": 674.2178344726562, + "y": 178.384765625, + "pressure": 1743, + "timestamp": 1774179079006 + }, + { + "x": 675.0103759765625, + "y": 177.79046630859375, + "pressure": 1745, + "timestamp": 1774179079007 + }, + { + "x": 675.8028564453125, + "y": 177.1961669921875, + "pressure": 1744, + "timestamp": 1774179079011 + }, + { + "x": 676.1990966796875, + "y": 176.99807739257812, + "pressure": 1731, + "timestamp": 1774179079013 + }, + { + "x": 676.5953369140625, + "y": 176.79995727539062, + "pressure": 1724, + "timestamp": 1774179079015 + }, + { + "x": 677.3878173828125, + "y": 176.40377807617188, + "pressure": 1721, + "timestamp": 1774179079017 + }, + { + "x": 678.1803588867188, + "y": 175.80947875976562, + "pressure": 1719, + "timestamp": 1774179079018 + }, + { + "x": 678.5765991210938, + "y": 175.61135864257812, + "pressure": 1718, + "timestamp": 1774179079020 + }, + { + "x": 678.9728393554688, + "y": 175.41326904296875, + "pressure": 1699, + "timestamp": 1774179079029 + }, + { + "x": 679.7653198242188, + "y": 174.8189697265625, + "pressure": 1689, + "timestamp": 1774179079031 + }, + { + "x": 680.557861328125, + "y": 174.22467041015625, + "pressure": 1684, + "timestamp": 1774179079033 + }, + { + "x": 680.9541015625, + "y": 173.82846069335938, + "pressure": 1682, + "timestamp": 1774179079034 + }, + { + "x": 681.350341796875, + "y": 173.63037109375, + "pressure": 1682, + "timestamp": 1774179079040 + }, + { + "x": 682.142822265625, + "y": 173.03604125976562, + "pressure": 1681, + "timestamp": 1774179079044 + }, + { + "x": 682.5390625, + "y": 172.63986206054688, + "pressure": 1672, + "timestamp": 1774179079045 + }, + { + "x": 682.5390625, + "y": 172.63986206054688, + "pressure": 1667, + "timestamp": 1774179079048 + }, + { + "x": 682.5390625, + "y": 172.63986206054688, + "pressure": 1664, + "timestamp": 1774179079050 + }, + { + "x": 682.935302734375, + "y": 172.44174194335938, + "pressure": 1663, + "timestamp": 1774179079052 + }, + { + "x": 683.7278442382812, + "y": 171.84744262695312, + "pressure": 1665, + "timestamp": 1774179079054 + }, + { + "x": 684.1240844726562, + "y": 171.45126342773438, + "pressure": 1664, + "timestamp": 1774179079056 + }, + { + "x": 684.5203247070312, + "y": 171.25314331054688, + "pressure": 1664, + "timestamp": 1774179079069 + }, + { + "x": 685.3128051757812, + "y": 170.65884399414062, + "pressure": 1666, + "timestamp": 1774179079070 + }, + { + "x": 685.7091064453125, + "y": 170.26263427734375, + "pressure": 1665, + "timestamp": 1774179079072 + }, + { + "x": 685.7091064453125, + "y": 170.26263427734375, + "pressure": 1655, + "timestamp": 1774179079077 + }, + { + "x": 685.7091064453125, + "y": 170.26263427734375, + "pressure": 1650, + "timestamp": 1774179079080 + }, + { + "x": 685.7091064453125, + "y": 170.26263427734375, + "pressure": 1647, + "timestamp": 1774179079083 + }, + { + "x": 685.7091064453125, + "y": 170.26263427734375, + "pressure": 1644, + "timestamp": 1774179079093 + }, + { + "x": 685.7091064453125, + "y": 170.26263427734375, + "pressure": 1677, + "timestamp": 1774179079109 + }, + { + "x": 685.7091064453125, + "y": 170.26263427734375, + "pressure": 1694, + "timestamp": 1774179079112 + }, + { + "x": 685.7091064453125, + "y": 170.26263427734375, + "pressure": 1702, + "timestamp": 1774179079113 + }, + { + "x": 685.7091064453125, + "y": 170.26263427734375, + "pressure": 1706, + "timestamp": 1774179079115 + }, + { + "x": 685.7091064453125, + "y": 170.26263427734375, + "pressure": 1709, + "timestamp": 1774179079118 + }, + { + "x": 685.7091064453125, + "y": 170.26263427734375, + "pressure": 1712, + "timestamp": 1774179079124 + }, + { + "x": 685.7091064453125, + "y": 170.26263427734375, + "pressure": 1769, + "timestamp": 1774179079126 + }, + { + "x": 685.7091064453125, + "y": 170.26263427734375, + "pressure": 1798, + "timestamp": 1774179079128 + }, + { + "x": 685.7091064453125, + "y": 170.26263427734375, + "pressure": 1812, + "timestamp": 1774179079129 + }, + { + "x": 685.7091064453125, + "y": 170.26263427734375, + "pressure": 1819, + "timestamp": 1774179079131 + }, + { + "x": 685.7091064453125, + "y": 170.26263427734375, + "pressure": 1823, + "timestamp": 1774179079132 + }, + { + "x": 685.7091064453125, + "y": 170.26263427734375, + "pressure": 1826, + "timestamp": 1774179079136 + }, + { + "x": 685.7091064453125, + "y": 170.26263427734375, + "pressure": 1834, + "timestamp": 1774179079141 + }, + { + "x": 685.7091064453125, + "y": 170.26263427734375, + "pressure": 1837, + "timestamp": 1774179079144 + }, + { + "x": 685.7091064453125, + "y": 170.26263427734375, + "pressure": 1840, + "timestamp": 1774179079147 + }, + { + "x": 685.7091064453125, + "y": 170.26263427734375, + "pressure": 1843, + "timestamp": 1774179079152 + }, + { + "x": 685.7091064453125, + "y": 170.26263427734375, + "pressure": 1834, + "timestamp": 1774179079157 + }, + { + "x": 685.7091064453125, + "y": 170.26263427734375, + "pressure": 1830, + "timestamp": 1774179079160 + }, + { + "x": 685.3128051757812, + "y": 170.46075439453125, + "pressure": 1828, + "timestamp": 1774179079161 + }, + { + "x": 684.5203247070312, + "y": 170.65884399414062, + "pressure": 1827, + "timestamp": 1774179079163 + }, + { + "x": 683.7278442382812, + "y": 171.0550537109375, + "pressure": 1829, + "timestamp": 1774179079165 + }, + { + "x": 683.33154296875, + "y": 171.25314331054688, + "pressure": 1828, + "timestamp": 1774179079166 + }, + { + "x": 682.935302734375, + "y": 171.45126342773438, + "pressure": 1827, + "timestamp": 1774179079168 + }, + { + "x": 681.9447021484375, + "y": 172.04556274414062, + "pressure": 1829, + "timestamp": 1774179079172 + }, + { + "x": 680.9541015625, + "y": 172.63986206054688, + "pressure": 1815, + "timestamp": 1774179079173 + }, + { + "x": 680.1615600585938, + "y": 173.03604125976562, + "pressure": 1808, + "timestamp": 1774179079176 + }, + { + "x": 679.3690795898438, + "y": 173.4322509765625, + "pressure": 1804, + "timestamp": 1774179079177 + }, + { + "x": 678.9728393554688, + "y": 173.63037109375, + "pressure": 1802, + "timestamp": 1774179079179 + }, + { + "x": 678.5765991210938, + "y": 174.02655029296875, + "pressure": 1803, + "timestamp": 1774179079182 + }, + { + "x": 677.7840576171875, + "y": 174.620849609375, + "pressure": 1802, + "timestamp": 1774179079184 + }, + { + "x": 676.9915771484375, + "y": 175.21514892578125, + "pressure": 1801, + "timestamp": 1774179079188 + }, + { + "x": 676.1990966796875, + "y": 175.80947875976562, + "pressure": 1785, + "timestamp": 1774179079190 + }, + { + "x": 675.8028564453125, + "y": 176.007568359375, + "pressure": 1777, + "timestamp": 1774179079192 + }, + { + "x": 675.8028564453125, + "y": 176.007568359375, + "pressure": 1773, + "timestamp": 1774179079193 + }, + { + "x": 675.4066162109375, + "y": 176.40377807617188, + "pressure": 1770, + "timestamp": 1774179079196 + }, + { + "x": 674.6140747070312, + "y": 177.1961669921875, + "pressure": 1772, + "timestamp": 1774179079198 + }, + { + "x": 673.8215942382812, + "y": 177.98858642578125, + "pressure": 1771, + "timestamp": 1774179079200 + }, + { + "x": 673.2272338867188, + "y": 178.78097534179688, + "pressure": 1770, + "timestamp": 1774179079204 + }, + { + "x": 673.0291137695312, + "y": 179.17718505859375, + "pressure": 1749, + "timestamp": 1774179079206 + }, + { + "x": 673.0291137695312, + "y": 179.17718505859375, + "pressure": 1738, + "timestamp": 1774179079208 + }, + { + "x": 673.0291137695312, + "y": 179.17718505859375, + "pressure": 1733, + "timestamp": 1774179079209 + }, + { + "x": 673.0291137695312, + "y": 179.17718505859375, + "pressure": 1730, + "timestamp": 1774179079211 + }, + { + "x": 672.8309936523438, + "y": 179.57339477539062, + "pressure": 1729, + "timestamp": 1774179079213 + }, + { + "x": 672.236572265625, + "y": 180.36578369140625, + "pressure": 1728, + "timestamp": 1774179079215 + }, + { + "x": 672.0384521484375, + "y": 180.76199340820312, + "pressure": 1730, + "timestamp": 1774179079216 + }, + { + "x": 672.0384521484375, + "y": 180.76199340820312, + "pressure": 1712, + "timestamp": 1774179079222 + }, + { + "x": 672.0384521484375, + "y": 180.76199340820312, + "pressure": 1703, + "timestamp": 1774179079224 + }, + { + "x": 671.84033203125, + "y": 181.15817260742188, + "pressure": 1699, + "timestamp": 1774179079226 + }, + { + "x": 671.6422119140625, + "y": 181.95059204101562, + "pressure": 1697, + "timestamp": 1774179079227 + }, + { + "x": 671.6422119140625, + "y": 182.3468017578125, + "pressure": 1696, + "timestamp": 1774179079229 + }, + { + "x": 671.6422119140625, + "y": 182.3468017578125, + "pressure": 1692, + "timestamp": 1774179079238 + }, + { + "x": 671.6422119140625, + "y": 182.3468017578125, + "pressure": 1689, + "timestamp": 1774179079241 + }, + { + "x": 671.6422119140625, + "y": 182.3468017578125, + "pressure": 1696, + "timestamp": 1774179079254 + }, + { + "x": 671.6422119140625, + "y": 182.3468017578125, + "pressure": 1699, + "timestamp": 1774179079256 + }, + { + "x": 672.0384521484375, + "y": 182.74298095703125, + "pressure": 1701, + "timestamp": 1774179079257 + }, + { + "x": 672.8309936523438, + "y": 183.3372802734375, + "pressure": 1702, + "timestamp": 1774179079259 + }, + { + "x": 673.6234741210938, + "y": 183.73348999023438, + "pressure": 1704, + "timestamp": 1774179079261 + }, + { + "x": 674.4159545898438, + "y": 184.12969970703125, + "pressure": 1703, + "timestamp": 1774179079262 + }, + { + "x": 674.812255859375, + "y": 184.32778930664062, + "pressure": 1705, + "timestamp": 1774179079264 + }, + { + "x": 674.812255859375, + "y": 184.32778930664062, + "pressure": 1717, + "timestamp": 1774179079270 + }, + { + "x": 675.20849609375, + "y": 184.52590942382812, + "pressure": 1723, + "timestamp": 1774179079272 + }, + { + "x": 676.0009765625, + "y": 184.7239990234375, + "pressure": 1726, + "timestamp": 1774179079273 + }, + { + "x": 676.79345703125, + "y": 184.7239990234375, + "pressure": 1728, + "timestamp": 1774179079275 + }, + { + "x": 677.5859375, + "y": 184.7239990234375, + "pressure": 1729, + "timestamp": 1774179079277 + }, + { + "x": 678.3784790039062, + "y": 184.52590942382812, + "pressure": 1731, + "timestamp": 1774179079279 + }, + { + "x": 679.1709594726562, + "y": 184.32778930664062, + "pressure": 1730, + "timestamp": 1774179079280 + }, + { + "x": 679.9634399414062, + "y": 184.12969970703125, + "pressure": 1732, + "timestamp": 1774179079285 + }, + { + "x": 680.7559814453125, + "y": 183.93161010742188, + "pressure": 1733, + "timestamp": 1774179079286 + }, + { + "x": 681.5484619140625, + "y": 183.73348999023438, + "pressure": 1735, + "timestamp": 1774179079288 + }, + { + "x": 681.9447021484375, + "y": 183.535400390625, + "pressure": 1734, + "timestamp": 1774179079289 + }, + { + "x": 682.3409423828125, + "y": 183.3372802734375, + "pressure": 1736, + "timestamp": 1774179079291 + }, + { + "x": 683.1334228515625, + "y": 182.94110107421875, + "pressure": 1735, + "timestamp": 1774179079293 + }, + { + "x": 683.9259643554688, + "y": 182.3468017578125, + "pressure": 1734, + "timestamp": 1774179079295 + }, + { + "x": 684.7184448242188, + "y": 181.95059204101562, + "pressure": 1736, + "timestamp": 1774179079296 + }, + { + "x": 685.1146850585938, + "y": 181.75250244140625, + "pressure": 1735, + "timestamp": 1774179079300 + }, + { + "x": 685.1146850585938, + "y": 181.75250244140625, + "pressure": 1710, + "timestamp": 1774179079302 + }, + { + "x": 685.3128051757812, + "y": 181.35629272460938, + "pressure": 1698, + "timestamp": 1774179079304 + }, + { + "x": 685.9072265625, + "y": 180.56387329101562, + "pressure": 1692, + "timestamp": 1774179079306 + }, + { + "x": 686.69970703125, + "y": 179.771484375, + "pressure": 1689, + "timestamp": 1774179079307 + }, + { + "x": 687.4921875, + "y": 178.97906494140625, + "pressure": 1687, + "timestamp": 1774179079309 + }, + { + "x": 688.28466796875, + "y": 178.18667602539062, + "pressure": 1686, + "timestamp": 1774179079311 + }, + { + "x": 689.0771484375, + "y": 177.394287109375, + "pressure": 1688, + "timestamp": 1774179079312 + }, + { + "x": 689.6715698242188, + "y": 176.60186767578125, + "pressure": 1687, + "timestamp": 1774179079316 + }, + { + "x": 690.2659301757812, + "y": 175.80947875976562, + "pressure": 1652, + "timestamp": 1774179079318 + }, + { + "x": 690.6621704101562, + "y": 175.01705932617188, + "pressure": 1635, + "timestamp": 1774179079320 + }, + { + "x": 691.256591796875, + "y": 174.22467041015625, + "pressure": 1626, + "timestamp": 1774179079322 + }, + { + "x": 691.8509521484375, + "y": 173.23416137695312, + "pressure": 1622, + "timestamp": 1774179079323 + }, + { + "x": 692.4453125, + "y": 172.44174194335938, + "pressure": 1620, + "timestamp": 1774179079325 + }, + { + "x": 693.0396728515625, + "y": 171.45126342773438, + "pressure": 1619, + "timestamp": 1774179079327 + }, + { + "x": 693.634033203125, + "y": 170.46075439453125, + "pressure": 1618, + "timestamp": 1774179079328 + }, + { + "x": 694.2283935546875, + "y": 169.47024536132812, + "pressure": 1620, + "timestamp": 1774179079333 + }, + { + "x": 694.8228149414062, + "y": 168.28164672851562, + "pressure": 1468, + "timestamp": 1774179079334 + }, + { + "x": 695.4171752929688, + "y": 167.09304809570312, + "pressure": 1392, + "timestamp": 1774179079336 + }, + { + "x": 696.0115356445312, + "y": 165.9044189453125, + "pressure": 1354, + "timestamp": 1774179079338 + }, + { + "x": 696.6058959960938, + "y": 164.7158203125, + "pressure": 1335, + "timestamp": 1774179079339 + }, + { + "x": 697.2003173828125, + "y": 163.5272216796875, + "pressure": 1326, + "timestamp": 1774179079341 + }, + { + "x": 697.794677734375, + "y": 162.338623046875, + "pressure": 1321, + "timestamp": 1774179079343 + }, + { + "x": 698.3890380859375, + "y": 160.75381469726562, + "pressure": 1319, + "timestamp": 1774179079344 + }, + { + "x": 698.7852783203125, + "y": 159.16900634765625, + "pressure": 1318, + "timestamp": 1774179079349 + }, + { + "x": 699.1815185546875, + "y": 157.58419799804688, + "pressure": 1282, + "timestamp": 1774179079350 + }, + { + "x": 699.5777587890625, + "y": 155.9993896484375, + "pressure": 1264, + "timestamp": 1774179079352 + }, + { + "x": 699.9740600585938, + "y": 154.41458129882812, + "pressure": 1255, + "timestamp": 1774179079354 + }, + { + "x": 700.3703002929688, + "y": 152.63168334960938, + "pressure": 1250, + "timestamp": 1774179079355 + }, + { + "x": 700.7665405273438, + "y": 150.84878540039062, + "pressure": 1248, + "timestamp": 1774179079357 + }, + { + "x": 701.1627807617188, + "y": 149.06585693359375, + "pressure": 1247, + "timestamp": 1774179079359 + }, + { + "x": 701.5590209960938, + "y": 147.48104858398438, + "pressure": 1246, + "timestamp": 1774179079360 + }, + { + "x": 702.1533813476562, + "y": 145.89627075195312, + "pressure": 1248, + "timestamp": 1774179079365 + }, + { + "x": 702.5496826171875, + "y": 144.31146240234375, + "pressure": 1322, + "timestamp": 1774179079366 + }, + { + "x": 702.9459228515625, + "y": 142.72665405273438, + "pressure": 1359, + "timestamp": 1774179079368 + }, + { + "x": 703.3421630859375, + "y": 141.141845703125, + "pressure": 1377, + "timestamp": 1774179079370 + }, + { + "x": 703.7384033203125, + "y": 139.755126953125, + "pressure": 1386, + "timestamp": 1774179079371 + }, + { + "x": 703.9365234375, + "y": 138.76461791992188, + "pressure": 1391, + "timestamp": 1774179079373 + }, + { + "x": 704.1346435546875, + "y": 137.77413940429688, + "pressure": 1393, + "timestamp": 1774179079375 + }, + { + "x": 704.332763671875, + "y": 136.98171997070312, + "pressure": 1394, + "timestamp": 1774179079376 + }, + { + "x": 704.5308837890625, + "y": 136.1893310546875, + "pressure": 1395, + "timestamp": 1774179079380 + }, + { + "x": 704.72900390625, + "y": 135.79312133789062, + "pressure": 1681, + "timestamp": 1774179079382 + }, + { + "x": 704.72900390625, + "y": 135.79312133789062, + "pressure": 1824, + "timestamp": 1774179079385 + }, + { + "x": 704.72900390625, + "y": 135.79312133789062, + "pressure": 1896, + "timestamp": 1774179079386 + }, + { + "x": 704.72900390625, + "y": 135.79312133789062, + "pressure": 1932, + "timestamp": 1774179079387 + }, + { + "x": 704.72900390625, + "y": 135.79312133789062, + "pressure": 1950, + "timestamp": 1774179079389 + }, + { + "x": 704.72900390625, + "y": 135.79312133789062, + "pressure": 1959, + "timestamp": 1774179079391 + }, + { + "x": 704.72900390625, + "y": 135.79312133789062, + "pressure": 1963, + "timestamp": 1774179079393 + }, + { + "x": 704.72900390625, + "y": 135.79312133789062, + "pressure": 2020, + "timestamp": 1774179079398 + }, + { + "x": 704.72900390625, + "y": 135.79312133789062, + "pressure": 2048, + "timestamp": 1774179079400 + }, + { + "x": 704.72900390625, + "y": 135.79312133789062, + "pressure": 2062, + "timestamp": 1774179079402 + }, + { + "x": 704.72900390625, + "y": 135.79312133789062, + "pressure": 2069, + "timestamp": 1774179079403 + }, + { + "x": 704.332763671875, + "y": 135.79312133789062, + "pressure": 2072, + "timestamp": 1774179079405 + }, + { + "x": 703.3421630859375, + "y": 135.79312133789062, + "pressure": 2074, + "timestamp": 1774179079407 + }, + { + "x": 702.3515625, + "y": 136.1893310546875, + "pressure": 2075, + "timestamp": 1774179079408 + }, + { + "x": 701.1627807617188, + "y": 136.78363037109375, + "pressure": 2077, + "timestamp": 1774179079413 + }, + { + "x": 700.1721801757812, + "y": 137.1798095703125, + "pressure": 2110, + "timestamp": 1774179079414 + }, + { + "x": 699.1815185546875, + "y": 137.77413940429688, + "pressure": 2126, + "timestamp": 1774179079417 + }, + { + "x": 697.9927978515625, + "y": 138.36843872070312, + "pressure": 2134, + "timestamp": 1774179079418 + }, + { + "x": 696.8040771484375, + "y": 139.16082763671875, + "pressure": 2138, + "timestamp": 1774179079420 + }, + { + "x": 695.8134155273438, + "y": 139.9532470703125, + "pressure": 2140, + "timestamp": 1774179079421 + }, + { + "x": 694.6246948242188, + "y": 140.74563598632812, + "pressure": 2141, + "timestamp": 1774179079423 + }, + { + "x": 693.4359130859375, + "y": 141.73614501953125, + "pressure": 2142, + "timestamp": 1774179079425 + }, + { + "x": 692.4453125, + "y": 142.72665405273438, + "pressure": 2144, + "timestamp": 1774179079428 + }, + { + "x": 691.4547119140625, + "y": 143.91525268554688, + "pressure": 2138, + "timestamp": 1774179079430 + }, + { + "x": 690.4640502929688, + "y": 145.10385131835938, + "pressure": 2135, + "timestamp": 1774179079433 + }, + { + "x": 689.4734497070312, + "y": 146.29244995117188, + "pressure": 2134, + "timestamp": 1774179079434 + }, + { + "x": 688.4827880859375, + "y": 147.48104858398438, + "pressure": 2133, + "timestamp": 1774179079435 + }, + { + "x": 687.4921875, + "y": 148.86776733398438, + "pressure": 2135, + "timestamp": 1774179079437 + }, + { + "x": 686.5015869140625, + "y": 150.25448608398438, + "pressure": 2134, + "timestamp": 1774179079439 + }, + { + "x": 685.5109252929688, + "y": 151.44308471679688, + "pressure": 2133, + "timestamp": 1774179079441 + }, + { + "x": 684.7184448242188, + "y": 152.63168334960938, + "pressure": 2135, + "timestamp": 1774179079445 + }, + { + "x": 683.9259643554688, + "y": 154.01837158203125, + "pressure": 2128, + "timestamp": 1774179079446 + }, + { + "x": 683.1334228515625, + "y": 155.40509033203125, + "pressure": 2125, + "timestamp": 1774179079448 + }, + { + "x": 682.5390625, + "y": 156.59368896484375, + "pressure": 2123, + "timestamp": 1774179079450 + }, + { + "x": 682.142822265625, + "y": 157.78228759765625, + "pressure": 2122, + "timestamp": 1774179079451 + }, + { + "x": 681.74658203125, + "y": 159.16900634765625, + "pressure": 2124, + "timestamp": 1774179079453 + }, + { + "x": 681.350341796875, + "y": 160.55572509765625, + "pressure": 2123, + "timestamp": 1774179079455 + }, + { + "x": 680.9541015625, + "y": 161.74432373046875, + "pressure": 2122, + "timestamp": 1774179079457 + }, + { + "x": 680.557861328125, + "y": 162.93292236328125, + "pressure": 2124, + "timestamp": 1774179079461 + }, + { + "x": 680.557861328125, + "y": 164.12152099609375, + "pressure": 2150, + "timestamp": 1774179079462 + }, + { + "x": 680.557861328125, + "y": 165.31011962890625, + "pressure": 2163, + "timestamp": 1774179079465 + }, + { + "x": 680.557861328125, + "y": 166.69683837890625, + "pressure": 2170, + "timestamp": 1774179079466 + }, + { + "x": 680.7559814453125, + "y": 168.08352661132812, + "pressure": 2173, + "timestamp": 1774179079468 + }, + { + "x": 680.9541015625, + "y": 169.27215576171875, + "pressure": 2175, + "timestamp": 1774179079469 + }, + { + "x": 681.1522216796875, + "y": 170.46075439453125, + "pressure": 2176, + "timestamp": 1774179079471 + }, + { + "x": 681.5484619140625, + "y": 171.64935302734375, + "pressure": 2178, + "timestamp": 1774179079473 + }, + { + "x": 681.9447021484375, + "y": 172.83795166015625, + "pressure": 2177, + "timestamp": 1774179079477 + }, + { + "x": 682.5390625, + "y": 174.02655029296875, + "pressure": 2229, + "timestamp": 1774179079478 + }, + { + "x": 683.1334228515625, + "y": 175.01705932617188, + "pressure": 2255, + "timestamp": 1774179079483 + }, + { + "x": 683.7278442382812, + "y": 176.007568359375, + "pressure": 2268, + "timestamp": 1774179079483 + }, + { + "x": 684.5203247070312, + "y": 176.99807739257812, + "pressure": 2274, + "timestamp": 1774179079484 + }, + { + "x": 685.3128051757812, + "y": 177.79046630859375, + "pressure": 2277, + "timestamp": 1774179079485 + }, + { + "x": 686.5015869140625, + "y": 178.5828857421875, + "pressure": 2279, + "timestamp": 1774179079487 + }, + { + "x": 687.6903076171875, + "y": 179.37527465820312, + "pressure": 2280, + "timestamp": 1774179079489 + }, + { + "x": 688.8790283203125, + "y": 180.16769409179688, + "pressure": 2282, + "timestamp": 1774179079493 + }, + { + "x": 690.0678100585938, + "y": 180.9600830078125, + "pressure": 2311, + "timestamp": 1774179079494 + }, + { + "x": 691.256591796875, + "y": 181.55438232421875, + "pressure": 2325, + "timestamp": 1774179079497 + }, + { + "x": 692.6434326171875, + "y": 182.148681640625, + "pressure": 2332, + "timestamp": 1774179079498 + }, + { + "x": 694.0302734375, + "y": 182.54489135742188, + "pressure": 2336, + "timestamp": 1774179079500 + }, + { + "x": 695.6152954101562, + "y": 182.94110107421875, + "pressure": 2338, + "timestamp": 1774179079501 + }, + { + "x": 697.2003173828125, + "y": 183.3372802734375, + "pressure": 2339, + "timestamp": 1774179079503 + }, + { + "x": 699.1815185546875, + "y": 183.73348999023438, + "pressure": 2341, + "timestamp": 1774179079505 + }, + { + "x": 700.9646606445312, + "y": 184.12969970703125, + "pressure": 2340, + "timestamp": 1774179079509 + }, + { + "x": 702.747802734375, + "y": 184.52590942382812, + "pressure": 2322, + "timestamp": 1774179079510 + }, + { + "x": 704.72900390625, + "y": 184.92208862304688, + "pressure": 2313, + "timestamp": 1774179079513 + }, + { + "x": 706.7102661132812, + "y": 185.31829833984375, + "pressure": 2309, + "timestamp": 1774179079514 + }, + { + "x": 708.6915283203125, + "y": 185.51638793945312, + "pressure": 2307, + "timestamp": 1774179079516 + }, + { + "x": 710.6727294921875, + "y": 185.71450805664062, + "pressure": 2306, + "timestamp": 1774179079517 + }, + { + "x": 712.6539916992188, + "y": 185.71450805664062, + "pressure": 2305, + "timestamp": 1774179079519 + }, + { + "x": 714.8333740234375, + "y": 185.71450805664062, + "pressure": 2307, + "timestamp": 1774179079521 + }, + { + "x": 716.8146362304688, + "y": 185.71450805664062, + "pressure": 2306, + "timestamp": 1774179079525 + }, + { + "x": 718.5977172851562, + "y": 185.71450805664062, + "pressure": 2210, + "timestamp": 1774179079526 + }, + { + "x": 720.380859375, + "y": 185.71450805664062, + "pressure": 2162, + "timestamp": 1774179079529 + }, + { + "x": 722.1640014648438, + "y": 185.51638793945312, + "pressure": 2138, + "timestamp": 1774179079530 + }, + { + "x": 723.5508422851562, + "y": 185.31829833984375, + "pressure": 2126, + "timestamp": 1774179079532 + }, + { + "x": 724.937744140625, + "y": 185.12020874023438, + "pressure": 2120, + "timestamp": 1774179079534 + }, + { + "x": 726.3245849609375, + "y": 184.92208862304688, + "pressure": 2117, + "timestamp": 1774179079535 + }, + { + "x": 727.7114868164062, + "y": 184.7239990234375, + "pressure": 2115, + "timestamp": 1774179079537 + }, + { + "x": 729.0983276367188, + "y": 184.52590942382812, + "pressure": 2114, + "timestamp": 1774179079541 + }, + { + "x": 730.4852294921875, + "y": 184.32778930664062, + "pressure": 1845, + "timestamp": 1774179079542 + }, + { + "x": 731.6739501953125, + "y": 183.93161010742188, + "pressure": 1711, + "timestamp": 1774179079545 + }, + { + "x": 732.6646118164062, + "y": 183.535400390625, + "pressure": 1644, + "timestamp": 1774179079547 + }, + { + "x": 733.6552124023438, + "y": 183.13919067382812, + "pressure": 1610, + "timestamp": 1774179079548 + }, + { + "x": 734.4476928710938, + "y": 182.74298095703125, + "pressure": 1593, + "timestamp": 1774179079549 + }, + { + "x": 735.240234375, + "y": 182.54489135742188, + "pressure": 1585, + "timestamp": 1774179079551 + }, + { + "x": 735.636474609375, + "y": 182.3468017578125, + "pressure": 1581, + "timestamp": 1774179079553 + }, + { + "x": 735.636474609375, + "y": 182.3468017578125, + "pressure": 1579, + "timestamp": 1774179079558 + } + ] + }, + { + "index": 4, + "pointCount": 43, + "points": [ + { + "x": 428.9396667480469, + "y": 148.86776733398438, + "pressure": 615, + "timestamp": 1774179080283 + }, + { + "x": 428.9396667480469, + "y": 148.86776733398438, + "pressure": 923, + "timestamp": 1774179080284 + }, + { + "x": 428.9396667480469, + "y": 148.86776733398438, + "pressure": 1077, + "timestamp": 1774179080286 + }, + { + "x": 428.9396667480469, + "y": 148.86776733398438, + "pressure": 1154, + "timestamp": 1774179080287 + }, + { + "x": 428.9396667480469, + "y": 148.86776733398438, + "pressure": 1192, + "timestamp": 1774179080290 + }, + { + "x": 428.9396667480469, + "y": 148.86776733398438, + "pressure": 1211, + "timestamp": 1774179080291 + }, + { + "x": 428.9396667480469, + "y": 148.86776733398438, + "pressure": 1221, + "timestamp": 1774179080295 + }, + { + "x": 428.9396667480469, + "y": 148.86776733398438, + "pressure": 1408, + "timestamp": 1774179080296 + }, + { + "x": 428.9396667480469, + "y": 148.86776733398438, + "pressure": 1501, + "timestamp": 1774179080299 + }, + { + "x": 428.9396667480469, + "y": 148.86776733398438, + "pressure": 1548, + "timestamp": 1774179080300 + }, + { + "x": 428.9396667480469, + "y": 148.86776733398438, + "pressure": 1571, + "timestamp": 1774179080302 + }, + { + "x": 428.9396667480469, + "y": 148.86776733398438, + "pressure": 1583, + "timestamp": 1774179080303 + }, + { + "x": 428.9396667480469, + "y": 148.86776733398438, + "pressure": 1589, + "timestamp": 1774179080305 + }, + { + "x": 428.9396667480469, + "y": 148.86776733398438, + "pressure": 1592, + "timestamp": 1774179080307 + }, + { + "x": 428.9396667480469, + "y": 148.86776733398438, + "pressure": 1789, + "timestamp": 1774179080313 + }, + { + "x": 428.9396667480469, + "y": 148.86776733398438, + "pressure": 1887, + "timestamp": 1774179080319 + }, + { + "x": 428.9396667480469, + "y": 148.86776733398438, + "pressure": 1936, + "timestamp": 1774179080319 + }, + { + "x": 428.9396667480469, + "y": 148.86776733398438, + "pressure": 1961, + "timestamp": 1774179080319 + }, + { + "x": 428.9396667480469, + "y": 148.86776733398438, + "pressure": 1973, + "timestamp": 1774179080320 + }, + { + "x": 428.9396667480469, + "y": 148.86776733398438, + "pressure": 1979, + "timestamp": 1774179080321 + }, + { + "x": 428.9396667480469, + "y": 148.86776733398438, + "pressure": 1982, + "timestamp": 1774179080323 + }, + { + "x": 428.9396667480469, + "y": 148.86776733398438, + "pressure": 2015, + "timestamp": 1774179080329 + }, + { + "x": 428.9396667480469, + "y": 148.86776733398438, + "pressure": 2031, + "timestamp": 1774179080331 + }, + { + "x": 428.9396667480469, + "y": 148.86776733398438, + "pressure": 2039, + "timestamp": 1774179080332 + }, + { + "x": 428.9396667480469, + "y": 148.86776733398438, + "pressure": 2043, + "timestamp": 1774179080334 + }, + { + "x": 428.9396667480469, + "y": 148.86776733398438, + "pressure": 2046, + "timestamp": 1774179080337 + }, + { + "x": 428.9396667480469, + "y": 148.86776733398438, + "pressure": 2049, + "timestamp": 1774179080347 + }, + { + "x": 428.9396667480469, + "y": 148.86776733398438, + "pressure": 2052, + "timestamp": 1774179080352 + }, + { + "x": 428.9396667480469, + "y": 148.86776733398438, + "pressure": 1955, + "timestamp": 1774179080361 + }, + { + "x": 428.9396667480469, + "y": 148.86776733398438, + "pressure": 1907, + "timestamp": 1774179080363 + }, + { + "x": 428.9396667480469, + "y": 148.86776733398438, + "pressure": 1883, + "timestamp": 1774179080364 + }, + { + "x": 428.9396667480469, + "y": 148.86776733398438, + "pressure": 1871, + "timestamp": 1774179080366 + }, + { + "x": 428.9396667480469, + "y": 148.86776733398438, + "pressure": 1865, + "timestamp": 1774179080368 + }, + { + "x": 428.9396667480469, + "y": 148.86776733398438, + "pressure": 1862, + "timestamp": 1774179080369 + }, + { + "x": 428.9396667480469, + "y": 148.86776733398438, + "pressure": 1859, + "timestamp": 1774179080375 + }, + { + "x": 428.9396667480469, + "y": 148.86776733398438, + "pressure": 1689, + "timestamp": 1774179080377 + }, + { + "x": 428.9396667480469, + "y": 148.86776733398438, + "pressure": 1604, + "timestamp": 1774179080380 + }, + { + "x": 428.9396667480469, + "y": 148.86776733398438, + "pressure": 1562, + "timestamp": 1774179080381 + }, + { + "x": 428.9396667480469, + "y": 148.86776733398438, + "pressure": 1541, + "timestamp": 1774179080382 + }, + { + "x": 428.9396667480469, + "y": 148.86776733398438, + "pressure": 1530, + "timestamp": 1774179080384 + }, + { + "x": 428.9396667480469, + "y": 148.86776733398438, + "pressure": 1525, + "timestamp": 1774179080385 + }, + { + "x": 428.9396667480469, + "y": 148.86776733398438, + "pressure": 1522, + "timestamp": 1774179080387 + }, + { + "x": 428.9396667480469, + "y": 148.86776733398438, + "pressure": 1521, + "timestamp": 1774179080393 + } + ] + }, + { + "index": 5, + "pointCount": 1260, + "points": [ + { + "x": 50.52178955078125, + "y": 266.9158935546875, + "pressure": 387, + "timestamp": 1774179083535 + }, + { + "x": 50.52178955078125, + "y": 266.9158935546875, + "pressure": 625, + "timestamp": 1774179083539 + }, + { + "x": 50.52178955078125, + "y": 266.9158935546875, + "pressure": 744, + "timestamp": 1774179083539 + }, + { + "x": 50.52178955078125, + "y": 266.9158935546875, + "pressure": 804, + "timestamp": 1774179083541 + }, + { + "x": 50.52178955078125, + "y": 266.9158935546875, + "pressure": 834, + "timestamp": 1774179083542 + }, + { + "x": 50.52178955078125, + "y": 266.9158935546875, + "pressure": 849, + "timestamp": 1774179083543 + }, + { + "x": 50.52178955078125, + "y": 266.9158935546875, + "pressure": 856, + "timestamp": 1774179083547 + }, + { + "x": 50.52178955078125, + "y": 266.9158935546875, + "pressure": 860, + "timestamp": 1774179083547 + }, + { + "x": 50.52178955078125, + "y": 266.9158935546875, + "pressure": 1076, + "timestamp": 1774179083552 + }, + { + "x": 50.52178955078125, + "y": 266.9158935546875, + "pressure": 1183, + "timestamp": 1774179083555 + }, + { + "x": 50.52178955078125, + "y": 266.9158935546875, + "pressure": 1236, + "timestamp": 1774179083556 + }, + { + "x": 50.52178955078125, + "y": 266.9158935546875, + "pressure": 1263, + "timestamp": 1774179083558 + }, + { + "x": 50.52178955078125, + "y": 266.9158935546875, + "pressure": 1276, + "timestamp": 1774179083559 + }, + { + "x": 50.52178955078125, + "y": 266.9158935546875, + "pressure": 1283, + "timestamp": 1774179083561 + }, + { + "x": 50.52178955078125, + "y": 266.9158935546875, + "pressure": 1286, + "timestamp": 1774179083563 + }, + { + "x": 50.52178955078125, + "y": 266.9158935546875, + "pressure": 1422, + "timestamp": 1774179083568 + }, + { + "x": 50.52178955078125, + "y": 266.9158935546875, + "pressure": 1489, + "timestamp": 1774179083571 + }, + { + "x": 50.52178955078125, + "y": 266.9158935546875, + "pressure": 1522, + "timestamp": 1774179083572 + }, + { + "x": 50.52178955078125, + "y": 266.9158935546875, + "pressure": 1539, + "timestamp": 1774179083574 + }, + { + "x": 50.52178955078125, + "y": 266.9158935546875, + "pressure": 1547, + "timestamp": 1774179083576 + }, + { + "x": 50.52178955078125, + "y": 266.9158935546875, + "pressure": 1551, + "timestamp": 1774179083577 + }, + { + "x": 50.52178955078125, + "y": 266.9158935546875, + "pressure": 1554, + "timestamp": 1774179083583 + }, + { + "x": 50.52178955078125, + "y": 266.9158935546875, + "pressure": 1695, + "timestamp": 1774179083585 + }, + { + "x": 50.52178955078125, + "y": 266.9158935546875, + "pressure": 1765, + "timestamp": 1774179083587 + }, + { + "x": 50.52178955078125, + "y": 266.9158935546875, + "pressure": 1800, + "timestamp": 1774179083588 + }, + { + "x": 50.52178955078125, + "y": 266.9158935546875, + "pressure": 1818, + "timestamp": 1774179083590 + }, + { + "x": 50.52178955078125, + "y": 266.9158935546875, + "pressure": 1827, + "timestamp": 1774179083592 + }, + { + "x": 50.52178955078125, + "y": 266.9158935546875, + "pressure": 1831, + "timestamp": 1774179083593 + }, + { + "x": 50.52178955078125, + "y": 266.9158935546875, + "pressure": 1834, + "timestamp": 1774179083599 + }, + { + "x": 50.52178955078125, + "y": 266.9158935546875, + "pressure": 1957, + "timestamp": 1774179083601 + }, + { + "x": 51.51239013671875, + "y": 266.71783447265625, + "pressure": 2019, + "timestamp": 1774179083603 + }, + { + "x": 53.49365234375, + "y": 266.12353515625, + "pressure": 2050, + "timestamp": 1774179083605 + }, + { + "x": 55.27679443359375, + "y": 265.5291748046875, + "pressure": 2065, + "timestamp": 1774179083606 + }, + { + "x": 57.05987548828125, + "y": 264.93487548828125, + "pressure": 2073, + "timestamp": 1774179083608 + }, + { + "x": 58.44677734375, + "y": 264.340576171875, + "pressure": 2077, + "timestamp": 1774179083609 + }, + { + "x": 59.635498046875, + "y": 263.74627685546875, + "pressure": 2079, + "timestamp": 1774179083611 + }, + { + "x": 60.82427978515625, + "y": 263.1519775390625, + "pressure": 2080, + "timestamp": 1774179083615 + }, + { + "x": 62.01300048828125, + "y": 262.359619140625, + "pressure": 2161, + "timestamp": 1774179083617 + }, + { + "x": 63.00360107421875, + "y": 261.36907958984375, + "pressure": 2202, + "timestamp": 1774179083619 + }, + { + "x": 64.1923828125, + "y": 260.57666015625, + "pressure": 2222, + "timestamp": 1774179083620 + }, + { + "x": 65.1829833984375, + "y": 259.586181640625, + "pressure": 2232, + "timestamp": 1774179083622 + }, + { + "x": 66.17364501953125, + "y": 258.595703125, + "pressure": 2237, + "timestamp": 1774179083624 + }, + { + "x": 67.16424560546875, + "y": 257.60516357421875, + "pressure": 2240, + "timestamp": 1774179083625 + }, + { + "x": 68.15484619140625, + "y": 256.61468505859375, + "pressure": 2241, + "timestamp": 1774179083627 + }, + { + "x": 69.1455078125, + "y": 255.6241455078125, + "pressure": 2242, + "timestamp": 1774179083631 + }, + { + "x": 69.93798828125, + "y": 254.6336669921875, + "pressure": 2280, + "timestamp": 1774179083633 + }, + { + "x": 70.9285888671875, + "y": 253.445068359375, + "pressure": 2299, + "timestamp": 1774179083635 + }, + { + "x": 71.91925048828125, + "y": 252.2564697265625, + "pressure": 2309, + "timestamp": 1774179083636 + }, + { + "x": 72.90985107421875, + "y": 251.26593017578125, + "pressure": 2314, + "timestamp": 1774179083638 + }, + { + "x": 73.90045166015625, + "y": 250.27545166015625, + "pressure": 2316, + "timestamp": 1774179083640 + }, + { + "x": 74.89111328125, + "y": 249.08685302734375, + "pressure": 2317, + "timestamp": 1774179083642 + }, + { + "x": 75.68359375, + "y": 247.89825439453125, + "pressure": 2318, + "timestamp": 1774179083643 + }, + { + "x": 76.47607421875, + "y": 246.70965576171875, + "pressure": 2320, + "timestamp": 1774179083648 + }, + { + "x": 77.46673583984375, + "y": 245.52105712890625, + "pressure": 2325, + "timestamp": 1774179083649 + }, + { + "x": 78.25921630859375, + "y": 244.3323974609375, + "pressure": 2327, + "timestamp": 1774179083651 + }, + { + "x": 79.05169677734375, + "y": 243.3419189453125, + "pressure": 2328, + "timestamp": 1774179083652 + }, + { + "x": 80.0423583984375, + "y": 242.1533203125, + "pressure": 2329, + "timestamp": 1774179083654 + }, + { + "x": 81.032958984375, + "y": 240.9647216796875, + "pressure": 2331, + "timestamp": 1774179083656 + }, + { + "x": 81.825439453125, + "y": 239.776123046875, + "pressure": 2330, + "timestamp": 1774179083658 + }, + { + "x": 82.61798095703125, + "y": 238.5875244140625, + "pressure": 2332, + "timestamp": 1774179083659 + }, + { + "x": 83.21234130859375, + "y": 237.59698486328125, + "pressure": 2331, + "timestamp": 1774179083663 + }, + { + "x": 83.80670166015625, + "y": 236.40838623046875, + "pressure": 2334, + "timestamp": 1774179083665 + }, + { + "x": 84.59918212890625, + "y": 235.21978759765625, + "pressure": 2335, + "timestamp": 1774179083667 + }, + { + "x": 85.193603515625, + "y": 234.03118896484375, + "pressure": 2336, + "timestamp": 1774179083668 + }, + { + "x": 85.7879638671875, + "y": 232.84259033203125, + "pressure": 2338, + "timestamp": 1774179083670 + }, + { + "x": 86.38232421875, + "y": 231.65399169921875, + "pressure": 2337, + "timestamp": 1774179083672 + }, + { + "x": 86.9766845703125, + "y": 230.46539306640625, + "pressure": 2339, + "timestamp": 1774179083674 + }, + { + "x": 87.571044921875, + "y": 229.27679443359375, + "pressure": 2338, + "timestamp": 1774179083675 + }, + { + "x": 87.96734619140625, + "y": 227.89007568359375, + "pressure": 2337, + "timestamp": 1774179083679 + }, + { + "x": 88.36358642578125, + "y": 226.50335693359375, + "pressure": 2343, + "timestamp": 1774179083681 + }, + { + "x": 88.75982666015625, + "y": 225.31475830078125, + "pressure": 2346, + "timestamp": 1774179083683 + }, + { + "x": 89.35418701171875, + "y": 224.12615966796875, + "pressure": 2347, + "timestamp": 1774179083684 + }, + { + "x": 89.94854736328125, + "y": 222.93756103515625, + "pressure": 2348, + "timestamp": 1774179083686 + }, + { + "x": 90.34478759765625, + "y": 221.74893188476562, + "pressure": 2350, + "timestamp": 1774179083688 + }, + { + "x": 90.7410888671875, + "y": 220.56033325195312, + "pressure": 2349, + "timestamp": 1774179083690 + }, + { + "x": 91.1373291015625, + "y": 219.37173461914062, + "pressure": 2351, + "timestamp": 1774179083691 + }, + { + "x": 91.5335693359375, + "y": 218.18313598632812, + "pressure": 2350, + "timestamp": 1774179083695 + }, + { + "x": 91.9298095703125, + "y": 217.192626953125, + "pressure": 2357, + "timestamp": 1774179083697 + }, + { + "x": 92.1279296875, + "y": 216.20211791992188, + "pressure": 2360, + "timestamp": 1774179083699 + }, + { + "x": 92.3260498046875, + "y": 215.21160888671875, + "pressure": 2362, + "timestamp": 1774179083701 + }, + { + "x": 92.524169921875, + "y": 214.22113037109375, + "pressure": 2363, + "timestamp": 1774179083702 + }, + { + "x": 92.7222900390625, + "y": 213.23062133789062, + "pressure": 2365, + "timestamp": 1774179083704 + }, + { + "x": 92.92041015625, + "y": 212.43820190429688, + "pressure": 2364, + "timestamp": 1774179083706 + }, + { + "x": 93.11859130859375, + "y": 211.44769287109375, + "pressure": 2366, + "timestamp": 1774179083707 + }, + { + "x": 93.31671142578125, + "y": 210.45721435546875, + "pressure": 2365, + "timestamp": 1774179083711 + }, + { + "x": 93.51483154296875, + "y": 209.46670532226562, + "pressure": 2387, + "timestamp": 1774179083713 + }, + { + "x": 93.71295166015625, + "y": 209.07049560546875, + "pressure": 2398, + "timestamp": 1774179083715 + }, + { + "x": 93.71295166015625, + "y": 209.07049560546875, + "pressure": 2403, + "timestamp": 1774179083717 + }, + { + "x": 93.51483154296875, + "y": 208.67428588867188, + "pressure": 2406, + "timestamp": 1774179083718 + }, + { + "x": 93.31671142578125, + "y": 207.88189697265625, + "pressure": 2407, + "timestamp": 1774179083720 + }, + { + "x": 93.31671142578125, + "y": 207.48568725585938, + "pressure": 2408, + "timestamp": 1774179083722 + }, + { + "x": 93.31671142578125, + "y": 207.48568725585938, + "pressure": 2429, + "timestamp": 1774179083729 + }, + { + "x": 93.11859130859375, + "y": 207.0894775390625, + "pressure": 2439, + "timestamp": 1774179083731 + }, + { + "x": 92.524169921875, + "y": 206.29708862304688, + "pressure": 2444, + "timestamp": 1774179083733 + }, + { + "x": 92.3260498046875, + "y": 205.90087890625, + "pressure": 2447, + "timestamp": 1774179083734 + }, + { + "x": 92.3260498046875, + "y": 205.90087890625, + "pressure": 2451, + "timestamp": 1774179083739 + }, + { + "x": 91.9298095703125, + "y": 205.70278930664062, + "pressure": 2452, + "timestamp": 1774179083748 + }, + { + "x": 91.1373291015625, + "y": 205.10848999023438, + "pressure": 2454, + "timestamp": 1774179083749 + }, + { + "x": 90.34478759765625, + "y": 204.91036987304688, + "pressure": 2453, + "timestamp": 1774179083750 + }, + { + "x": 89.94854736328125, + "y": 204.7122802734375, + "pressure": 2455, + "timestamp": 1774179083752 + }, + { + "x": 89.55230712890625, + "y": 204.7122802734375, + "pressure": 2455, + "timestamp": 1774179083760 + }, + { + "x": 88.75982666015625, + "y": 204.51419067382812, + "pressure": 2445, + "timestamp": 1774179083761 + }, + { + "x": 87.96734619140625, + "y": 204.31607055664062, + "pressure": 2440, + "timestamp": 1774179083764 + }, + { + "x": 87.1748046875, + "y": 204.51419067382812, + "pressure": 2437, + "timestamp": 1774179083765 + }, + { + "x": 86.778564453125, + "y": 204.51419067382812, + "pressure": 2436, + "timestamp": 1774179083767 + }, + { + "x": 86.1842041015625, + "y": 204.7122802734375, + "pressure": 2437, + "timestamp": 1774179083770 + }, + { + "x": 85.193603515625, + "y": 205.10848999023438, + "pressure": 2436, + "timestamp": 1774179083771 + }, + { + "x": 84.40106201171875, + "y": 205.70278930664062, + "pressure": 2435, + "timestamp": 1774179083775 + }, + { + "x": 83.60858154296875, + "y": 206.29708862304688, + "pressure": 2426, + "timestamp": 1774179083777 + }, + { + "x": 82.81610107421875, + "y": 206.89138793945312, + "pressure": 2422, + "timestamp": 1774179083779 + }, + { + "x": 82.0235595703125, + "y": 207.68377685546875, + "pressure": 2420, + "timestamp": 1774179083781 + }, + { + "x": 81.6273193359375, + "y": 208.07998657226562, + "pressure": 2419, + "timestamp": 1774179083782 + }, + { + "x": 81.2310791015625, + "y": 208.4761962890625, + "pressure": 2420, + "timestamp": 1774179083786 + }, + { + "x": 80.240478515625, + "y": 209.46670532226562, + "pressure": 2419, + "timestamp": 1774179083788 + }, + { + "x": 79.447998046875, + "y": 210.45721435546875, + "pressure": 2418, + "timestamp": 1774179083792 + }, + { + "x": 78.65545654296875, + "y": 211.44769287109375, + "pressure": 2409, + "timestamp": 1774179083793 + }, + { + "x": 78.06109619140625, + "y": 212.43820190429688, + "pressure": 2404, + "timestamp": 1774179083796 + }, + { + "x": 77.26861572265625, + "y": 213.23062133789062, + "pressure": 2402, + "timestamp": 1774179083797 + }, + { + "x": 76.6741943359375, + "y": 214.02301025390625, + "pressure": 2401, + "timestamp": 1774179083798 + }, + { + "x": 76.079833984375, + "y": 214.8154296875, + "pressure": 2400, + "timestamp": 1774179083800 + }, + { + "x": 75.4854736328125, + "y": 215.60781860351562, + "pressure": 2402, + "timestamp": 1774179083802 + }, + { + "x": 74.89111328125, + "y": 216.40023803710938, + "pressure": 2401, + "timestamp": 1774179083804 + }, + { + "x": 74.494873046875, + "y": 217.192626953125, + "pressure": 2400, + "timestamp": 1774179083807 + }, + { + "x": 74.0986328125, + "y": 217.98501586914062, + "pressure": 2396, + "timestamp": 1774179083809 + }, + { + "x": 73.50421142578125, + "y": 218.77743530273438, + "pressure": 2394, + "timestamp": 1774179083812 + }, + { + "x": 72.90985107421875, + "y": 219.7679443359375, + "pressure": 2393, + "timestamp": 1774179083813 + }, + { + "x": 72.51361083984375, + "y": 220.75845336914062, + "pressure": 2392, + "timestamp": 1774179083814 + }, + { + "x": 72.11737060546875, + "y": 221.74893188476562, + "pressure": 2394, + "timestamp": 1774179083816 + }, + { + "x": 71.72113037109375, + "y": 222.73944091796875, + "pressure": 2393, + "timestamp": 1774179083818 + }, + { + "x": 71.32489013671875, + "y": 223.72994995117188, + "pressure": 2392, + "timestamp": 1774179083820 + }, + { + "x": 70.9285888671875, + "y": 224.720458984375, + "pressure": 2394, + "timestamp": 1774179083824 + }, + { + "x": 70.5323486328125, + "y": 225.71096801757812, + "pressure": 2387, + "timestamp": 1774179083825 + }, + { + "x": 70.1361083984375, + "y": 226.70144653320312, + "pressure": 2383, + "timestamp": 1774179083827 + }, + { + "x": 69.7398681640625, + "y": 227.69195556640625, + "pressure": 2381, + "timestamp": 1774179083829 + }, + { + "x": 69.3436279296875, + "y": 228.68246459960938, + "pressure": 2380, + "timestamp": 1774179083830 + }, + { + "x": 68.9473876953125, + "y": 229.6729736328125, + "pressure": 2382, + "timestamp": 1774179083832 + }, + { + "x": 68.35296630859375, + "y": 230.6634521484375, + "pressure": 2381, + "timestamp": 1774179083834 + }, + { + "x": 67.75860595703125, + "y": 231.65399169921875, + "pressure": 2380, + "timestamp": 1774179083836 + }, + { + "x": 67.36236572265625, + "y": 232.64447021484375, + "pressure": 2382, + "timestamp": 1774179083840 + }, + { + "x": 66.96612548828125, + "y": 233.635009765625, + "pressure": 2378, + "timestamp": 1774179083842 + }, + { + "x": 66.56988525390625, + "y": 234.62548828125, + "pressure": 2376, + "timestamp": 1774179083843 + }, + { + "x": 66.17364501953125, + "y": 235.615966796875, + "pressure": 2375, + "timestamp": 1774179083845 + }, + { + "x": 65.77740478515625, + "y": 236.60650634765625, + "pressure": 2374, + "timestamp": 1774179083847 + }, + { + "x": 65.381103515625, + "y": 237.79510498046875, + "pressure": 2376, + "timestamp": 1774179083848 + }, + { + "x": 64.98486328125, + "y": 238.98370361328125, + "pressure": 2375, + "timestamp": 1774179083850 + }, + { + "x": 64.588623046875, + "y": 240.17230224609375, + "pressure": 2374, + "timestamp": 1774179083852 + }, + { + "x": 64.1923828125, + "y": 241.36090087890625, + "pressure": 2376, + "timestamp": 1774179083856 + }, + { + "x": 63.796142578125, + "y": 242.54949951171875, + "pressure": 2372, + "timestamp": 1774179083857 + }, + { + "x": 63.39990234375, + "y": 243.73809814453125, + "pressure": 2370, + "timestamp": 1774179083861 + }, + { + "x": 63.00360107421875, + "y": 244.9267578125, + "pressure": 2369, + "timestamp": 1774179083861 + }, + { + "x": 62.60736083984375, + "y": 246.1153564453125, + "pressure": 2368, + "timestamp": 1774179083863 + }, + { + "x": 62.21112060546875, + "y": 247.303955078125, + "pressure": 2370, + "timestamp": 1774179083864 + }, + { + "x": 61.81488037109375, + "y": 248.4925537109375, + "pressure": 2369, + "timestamp": 1774179083866 + }, + { + "x": 61.41864013671875, + "y": 249.68115234375, + "pressure": 2368, + "timestamp": 1774179083868 + }, + { + "x": 61.02239990234375, + "y": 250.671630859375, + "pressure": 2370, + "timestamp": 1774179083872 + }, + { + "x": 60.62615966796875, + "y": 251.8602294921875, + "pressure": 2366, + "timestamp": 1774179083873 + }, + { + "x": 60.42803955078125, + "y": 253.04888916015625, + "pressure": 2364, + "timestamp": 1774179083876 + }, + { + "x": 60.03173828125, + "y": 254.23748779296875, + "pressure": 2363, + "timestamp": 1774179083877 + }, + { + "x": 59.8336181640625, + "y": 255.42608642578125, + "pressure": 2362, + "timestamp": 1774179083879 + }, + { + "x": 59.635498046875, + "y": 256.41656494140625, + "pressure": 2364, + "timestamp": 1774179083880 + }, + { + "x": 59.4373779296875, + "y": 257.4071044921875, + "pressure": 2363, + "timestamp": 1774179083882 + }, + { + "x": 59.2392578125, + "y": 258.3975830078125, + "pressure": 2362, + "timestamp": 1774179083884 + }, + { + "x": 59.2392578125, + "y": 259.3880615234375, + "pressure": 2364, + "timestamp": 1774179083888 + }, + { + "x": 59.2392578125, + "y": 260.18048095703125, + "pressure": 2358, + "timestamp": 1774179083889 + }, + { + "x": 59.2392578125, + "y": 260.972900390625, + "pressure": 2355, + "timestamp": 1774179083892 + }, + { + "x": 59.2392578125, + "y": 261.36907958984375, + "pressure": 2353, + "timestamp": 1774179083893 + }, + { + "x": 59.2392578125, + "y": 261.76531982421875, + "pressure": 2352, + "timestamp": 1774179083900 + }, + { + "x": 59.2392578125, + "y": 262.55767822265625, + "pressure": 2354, + "timestamp": 1774179083904 + }, + { + "x": 59.4373779296875, + "y": 262.95391845703125, + "pressure": 2339, + "timestamp": 1774179083906 + }, + { + "x": 59.4373779296875, + "y": 262.95391845703125, + "pressure": 2332, + "timestamp": 1774179083908 + }, + { + "x": 59.4373779296875, + "y": 262.95391845703125, + "pressure": 2328, + "timestamp": 1774179083909 + }, + { + "x": 59.4373779296875, + "y": 262.95391845703125, + "pressure": 2325, + "timestamp": 1774179083913 + }, + { + "x": 59.4373779296875, + "y": 262.95391845703125, + "pressure": 2305, + "timestamp": 1774179083921 + }, + { + "x": 59.4373779296875, + "y": 262.95391845703125, + "pressure": 2295, + "timestamp": 1774179083924 + }, + { + "x": 59.4373779296875, + "y": 262.95391845703125, + "pressure": 2290, + "timestamp": 1774179083925 + }, + { + "x": 59.4373779296875, + "y": 262.95391845703125, + "pressure": 2287, + "timestamp": 1774179083929 + }, + { + "x": 59.4373779296875, + "y": 262.95391845703125, + "pressure": 2279, + "timestamp": 1774179083938 + }, + { + "x": 59.4373779296875, + "y": 262.95391845703125, + "pressure": 2275, + "timestamp": 1774179083940 + }, + { + "x": 59.4373779296875, + "y": 262.95391845703125, + "pressure": 2272, + "timestamp": 1774179083943 + }, + { + "x": 59.4373779296875, + "y": 262.95391845703125, + "pressure": 2276, + "timestamp": 1774179083954 + }, + { + "x": 59.8336181640625, + "y": 263.1519775390625, + "pressure": 2275, + "timestamp": 1774179083956 + }, + { + "x": 60.2298583984375, + "y": 263.1519775390625, + "pressure": 2277, + "timestamp": 1774179083957 + }, + { + "x": 60.62615966796875, + "y": 263.35009765625, + "pressure": 2275, + "timestamp": 1774179083961 + }, + { + "x": 61.02239990234375, + "y": 263.35009765625, + "pressure": 2277, + "timestamp": 1774179083962 + }, + { + "x": 61.41864013671875, + "y": 263.35009765625, + "pressure": 2271, + "timestamp": 1774179083970 + }, + { + "x": 62.21112060546875, + "y": 263.35009765625, + "pressure": 2269, + "timestamp": 1774179083972 + }, + { + "x": 62.60736083984375, + "y": 263.35009765625, + "pressure": 2268, + "timestamp": 1774179083973 + }, + { + "x": 62.60736083984375, + "y": 263.35009765625, + "pressure": 2271, + "timestamp": 1774179083986 + }, + { + "x": 62.60736083984375, + "y": 263.35009765625, + "pressure": 2274, + "timestamp": 1774179083989 + }, + { + "x": 63.00360107421875, + "y": 263.35009765625, + "pressure": 2275, + "timestamp": 1774179083993 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2274, + "timestamp": 1774179083995 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2290, + "timestamp": 1774179084002 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2298, + "timestamp": 1774179084004 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2302, + "timestamp": 1774179084005 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2305, + "timestamp": 1774179084009 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2308, + "timestamp": 1774179084017 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2346, + "timestamp": 1774179084018 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2365, + "timestamp": 1774179084020 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2375, + "timestamp": 1774179084021 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2380, + "timestamp": 1774179084023 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2383, + "timestamp": 1774179084026 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2386, + "timestamp": 1774179084032 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2407, + "timestamp": 1774179084034 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2417, + "timestamp": 1774179084036 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2422, + "timestamp": 1774179084037 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2425, + "timestamp": 1774179084039 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2429, + "timestamp": 1774179084044 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2441, + "timestamp": 1774179084050 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2448, + "timestamp": 1774179084052 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2451, + "timestamp": 1774179084053 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2454, + "timestamp": 1774179084057 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2457, + "timestamp": 1774179084065 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2466, + "timestamp": 1774179084066 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2471, + "timestamp": 1774179084068 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2474, + "timestamp": 1774179084072 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2477, + "timestamp": 1774179084075 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2463, + "timestamp": 1774179084082 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2456, + "timestamp": 1774179084084 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2452, + "timestamp": 1774179084085 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2449, + "timestamp": 1774179084089 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2421, + "timestamp": 1774179084099 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2407, + "timestamp": 1774179084100 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2400, + "timestamp": 1774179084102 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2396, + "timestamp": 1774179084103 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2393, + "timestamp": 1774179084107 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2375, + "timestamp": 1774179084114 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2365, + "timestamp": 1774179084116 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2360, + "timestamp": 1774179084118 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2357, + "timestamp": 1774179084121 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2354, + "timestamp": 1774179084133 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2361, + "timestamp": 1774179084146 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2365, + "timestamp": 1774179084148 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2368, + "timestamp": 1774179084151 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2371, + "timestamp": 1774179084157 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2393, + "timestamp": 1774179084162 + }, + { + "x": 63.39990234375, + "y": 263.1519775390625, + "pressure": 2405, + "timestamp": 1774179084165 + }, + { + "x": 63.5980224609375, + "y": 262.75579833984375, + "pressure": 2411, + "timestamp": 1774179084167 + }, + { + "x": 64.1923828125, + "y": 261.96337890625, + "pressure": 2414, + "timestamp": 1774179084168 + }, + { + "x": 64.3905029296875, + "y": 261.56719970703125, + "pressure": 2415, + "timestamp": 1774179084169 + }, + { + "x": 64.588623046875, + "y": 261.17095947265625, + "pressure": 2418, + "timestamp": 1774179084173 + }, + { + "x": 64.7867431640625, + "y": 260.7747802734375, + "pressure": 2417, + "timestamp": 1774179084177 + }, + { + "x": 64.7867431640625, + "y": 260.7747802734375, + "pressure": 2420, + "timestamp": 1774179084178 + }, + { + "x": 64.98486328125, + "y": 260.37860107421875, + "pressure": 2421, + "timestamp": 1774179084181 + }, + { + "x": 65.381103515625, + "y": 259.586181640625, + "pressure": 2422, + "timestamp": 1774179084182 + }, + { + "x": 65.77740478515625, + "y": 258.79376220703125, + "pressure": 2424, + "timestamp": 1774179084186 + }, + { + "x": 66.37176513671875, + "y": 258.00140380859375, + "pressure": 2423, + "timestamp": 1774179084186 + }, + { + "x": 66.96612548828125, + "y": 257.208984375, + "pressure": 2425, + "timestamp": 1774179084187 + }, + { + "x": 67.56048583984375, + "y": 256.41656494140625, + "pressure": 2424, + "timestamp": 1774179084189 + }, + { + "x": 68.15484619140625, + "y": 255.6241455078125, + "pressure": 2423, + "timestamp": 1774179084193 + }, + { + "x": 68.5511474609375, + "y": 255.22796630859375, + "pressure": 2421, + "timestamp": 1774179084195 + }, + { + "x": 68.749267578125, + "y": 254.831787109375, + "pressure": 2422, + "timestamp": 1774179084198 + }, + { + "x": 69.3436279296875, + "y": 254.03936767578125, + "pressure": 2421, + "timestamp": 1774179084200 + }, + { + "x": 69.93798828125, + "y": 253.2469482421875, + "pressure": 2420, + "timestamp": 1774179084202 + }, + { + "x": 70.5323486328125, + "y": 252.45452880859375, + "pressure": 2422, + "timestamp": 1774179084203 + }, + { + "x": 70.9285888671875, + "y": 251.66217041015625, + "pressure": 2421, + "timestamp": 1774179084205 + }, + { + "x": 71.126708984375, + "y": 251.26593017578125, + "pressure": 2420, + "timestamp": 1774179084209 + }, + { + "x": 71.52301025390625, + "y": 250.8697509765625, + "pressure": 2420, + "timestamp": 1774179084214 + }, + { + "x": 72.31549072265625, + "y": 250.07733154296875, + "pressure": 2419, + "timestamp": 1774179084216 + }, + { + "x": 73.10797119140625, + "y": 249.28497314453125, + "pressure": 2421, + "timestamp": 1774179084217 + }, + { + "x": 73.50421142578125, + "y": 248.88873291015625, + "pressure": 2420, + "timestamp": 1774179084219 + }, + { + "x": 73.50421142578125, + "y": 248.88873291015625, + "pressure": 2423, + "timestamp": 1774179084226 + }, + { + "x": 73.90045166015625, + "y": 248.69061279296875, + "pressure": 2424, + "timestamp": 1774179084230 + }, + { + "x": 74.6929931640625, + "y": 248.0963134765625, + "pressure": 2423, + "timestamp": 1774179084232 + }, + { + "x": 75.0892333984375, + "y": 247.70013427734375, + "pressure": 2422, + "timestamp": 1774179084233 + }, + { + "x": 75.0892333984375, + "y": 247.70013427734375, + "pressure": 2431, + "timestamp": 1774179084242 + }, + { + "x": 75.4854736328125, + "y": 247.50201416015625, + "pressure": 2435, + "timestamp": 1774179084245 + }, + { + "x": 76.2779541015625, + "y": 246.90771484375, + "pressure": 2437, + "timestamp": 1774179084246 + }, + { + "x": 76.6741943359375, + "y": 246.70965576171875, + "pressure": 2438, + "timestamp": 1774179084248 + }, + { + "x": 76.6741943359375, + "y": 246.70965576171875, + "pressure": 2441, + "timestamp": 1774179084251 + }, + { + "x": 76.6741943359375, + "y": 246.70965576171875, + "pressure": 2451, + "timestamp": 1774179084258 + }, + { + "x": 76.6741943359375, + "y": 246.70965576171875, + "pressure": 2456, + "timestamp": 1774179084261 + }, + { + "x": 76.6741943359375, + "y": 246.70965576171875, + "pressure": 2459, + "timestamp": 1774179084264 + }, + { + "x": 76.6741943359375, + "y": 246.70965576171875, + "pressure": 2462, + "timestamp": 1774179084267 + }, + { + "x": 76.6741943359375, + "y": 246.70965576171875, + "pressure": 2466, + "timestamp": 1774179084274 + }, + { + "x": 76.6741943359375, + "y": 246.70965576171875, + "pressure": 2469, + "timestamp": 1774179084278 + }, + { + "x": 76.6741943359375, + "y": 246.70965576171875, + "pressure": 2472, + "timestamp": 1774179084283 + }, + { + "x": 76.6741943359375, + "y": 246.70965576171875, + "pressure": 2465, + "timestamp": 1774179084291 + }, + { + "x": 76.6741943359375, + "y": 246.70965576171875, + "pressure": 2462, + "timestamp": 1774179084293 + }, + { + "x": 76.6741943359375, + "y": 246.70965576171875, + "pressure": 2465, + "timestamp": 1774179084307 + }, + { + "x": 76.6741943359375, + "y": 246.70965576171875, + "pressure": 2468, + "timestamp": 1774179084310 + }, + { + "x": 76.6741943359375, + "y": 246.70965576171875, + "pressure": 2471, + "timestamp": 1774179084315 + }, + { + "x": 76.6741943359375, + "y": 246.70965576171875, + "pressure": 2474, + "timestamp": 1774179084323 + }, + { + "x": 77.07049560546875, + "y": 246.90771484375, + "pressure": 2476, + "timestamp": 1774179084325 + }, + { + "x": 77.46673583984375, + "y": 246.90771484375, + "pressure": 2477, + "timestamp": 1774179084327 + }, + { + "x": 77.46673583984375, + "y": 246.90771484375, + "pressure": 2480, + "timestamp": 1774179084330 + }, + { + "x": 77.46673583984375, + "y": 246.90771484375, + "pressure": 2484, + "timestamp": 1774179084339 + }, + { + "x": 77.46673583984375, + "y": 246.90771484375, + "pressure": 2487, + "timestamp": 1774179084342 + }, + { + "x": 77.66485595703125, + "y": 247.303955078125, + "pressure": 2488, + "timestamp": 1774179084344 + }, + { + "x": 78.25921630859375, + "y": 248.29443359375, + "pressure": 2490, + "timestamp": 1774179084346 + }, + { + "x": 78.85357666015625, + "y": 249.08685302734375, + "pressure": 2489, + "timestamp": 1774179084348 + }, + { + "x": 79.24981689453125, + "y": 249.8792724609375, + "pressure": 2491, + "timestamp": 1774179084349 + }, + { + "x": 79.447998046875, + "y": 250.27545166015625, + "pressure": 2490, + "timestamp": 1774179084353 + }, + { + "x": 79.447998046875, + "y": 250.27545166015625, + "pressure": 2494, + "timestamp": 1774179084355 + }, + { + "x": 79.447998046875, + "y": 250.671630859375, + "pressure": 2497, + "timestamp": 1774179084358 + }, + { + "x": 79.6461181640625, + "y": 251.66217041015625, + "pressure": 2498, + "timestamp": 1774179084360 + }, + { + "x": 79.84423828125, + "y": 252.45452880859375, + "pressure": 2500, + "timestamp": 1774179084362 + }, + { + "x": 80.0423583984375, + "y": 253.2469482421875, + "pressure": 2499, + "timestamp": 1774179084363 + }, + { + "x": 80.240478515625, + "y": 253.6431884765625, + "pressure": 2501, + "timestamp": 1774179084365 + }, + { + "x": 80.240478515625, + "y": 254.03936767578125, + "pressure": 2502, + "timestamp": 1774179084371 + }, + { + "x": 80.240478515625, + "y": 255.02984619140625, + "pressure": 2501, + "timestamp": 1774179084373 + }, + { + "x": 80.240478515625, + "y": 256.0203857421875, + "pressure": 2500, + "timestamp": 1774179084374 + }, + { + "x": 80.0423583984375, + "y": 257.0108642578125, + "pressure": 2502, + "timestamp": 1774179084376 + }, + { + "x": 79.84423828125, + "y": 257.80328369140625, + "pressure": 2501, + "timestamp": 1774179084379 + }, + { + "x": 79.84423828125, + "y": 258.595703125, + "pressure": 2500, + "timestamp": 1774179084380 + }, + { + "x": 79.84423828125, + "y": 258.99188232421875, + "pressure": 2502, + "timestamp": 1774179084381 + }, + { + "x": 79.84423828125, + "y": 258.99188232421875, + "pressure": 2497, + "timestamp": 1774179084387 + }, + { + "x": 79.84423828125, + "y": 259.3880615234375, + "pressure": 2495, + "timestamp": 1774179084389 + }, + { + "x": 79.84423828125, + "y": 260.37860107421875, + "pressure": 2494, + "timestamp": 1774179084390 + }, + { + "x": 79.84423828125, + "y": 261.17095947265625, + "pressure": 2493, + "timestamp": 1774179084392 + }, + { + "x": 79.84423828125, + "y": 261.96337890625, + "pressure": 2495, + "timestamp": 1774179084394 + }, + { + "x": 79.84423828125, + "y": 262.359619140625, + "pressure": 2494, + "timestamp": 1774179084396 + }, + { + "x": 79.84423828125, + "y": 262.75579833984375, + "pressure": 2495, + "timestamp": 1774179084402 + }, + { + "x": 79.84423828125, + "y": 263.5482177734375, + "pressure": 2488, + "timestamp": 1774179084403 + }, + { + "x": 79.84423828125, + "y": 264.340576171875, + "pressure": 2485, + "timestamp": 1774179084405 + }, + { + "x": 79.84423828125, + "y": 265.13299560546875, + "pressure": 2483, + "timestamp": 1774179084407 + }, + { + "x": 79.84423828125, + "y": 265.5291748046875, + "pressure": 2482, + "timestamp": 1774179084408 + }, + { + "x": 79.84423828125, + "y": 265.9254150390625, + "pressure": 2482, + "timestamp": 1774179084413 + }, + { + "x": 80.0423583984375, + "y": 266.71783447265625, + "pressure": 2484, + "timestamp": 1774179084418 + }, + { + "x": 80.240478515625, + "y": 267.51019287109375, + "pressure": 2473, + "timestamp": 1774179084419 + }, + { + "x": 80.4385986328125, + "y": 267.90643310546875, + "pressure": 2467, + "timestamp": 1774179084421 + }, + { + "x": 80.4385986328125, + "y": 267.90643310546875, + "pressure": 2464, + "timestamp": 1774179084423 + }, + { + "x": 80.4385986328125, + "y": 268.3026123046875, + "pressure": 2462, + "timestamp": 1774179084426 + }, + { + "x": 80.8348388671875, + "y": 269.09503173828125, + "pressure": 2464, + "timestamp": 1774179084428 + }, + { + "x": 81.032958984375, + "y": 269.4912109375, + "pressure": 2463, + "timestamp": 1774179084430 + }, + { + "x": 81.032958984375, + "y": 269.4912109375, + "pressure": 2454, + "timestamp": 1774179084435 + }, + { + "x": 81.032958984375, + "y": 269.4912109375, + "pressure": 2450, + "timestamp": 1774179084437 + }, + { + "x": 81.2310791015625, + "y": 269.887451171875, + "pressure": 2447, + "timestamp": 1774179084440 + }, + { + "x": 81.825439453125, + "y": 270.6798095703125, + "pressure": 2446, + "timestamp": 1774179084442 + }, + { + "x": 82.22174072265625, + "y": 271.0760498046875, + "pressure": 2448, + "timestamp": 1774179084444 + }, + { + "x": 82.22174072265625, + "y": 271.0760498046875, + "pressure": 2428, + "timestamp": 1774179084451 + }, + { + "x": 82.22174072265625, + "y": 271.0760498046875, + "pressure": 2419, + "timestamp": 1774179084453 + }, + { + "x": 82.61798095703125, + "y": 271.47222900390625, + "pressure": 2414, + "timestamp": 1774179084455 + }, + { + "x": 83.41046142578125, + "y": 272.0665283203125, + "pressure": 2412, + "timestamp": 1774179084456 + }, + { + "x": 84.20294189453125, + "y": 272.46270751953125, + "pressure": 2411, + "timestamp": 1774179084458 + }, + { + "x": 84.9954833984375, + "y": 272.85894775390625, + "pressure": 2410, + "timestamp": 1774179084460 + }, + { + "x": 85.3917236328125, + "y": 272.85894775390625, + "pressure": 2412, + "timestamp": 1774179084461 + }, + { + "x": 85.7879638671875, + "y": 272.85894775390625, + "pressure": 2395, + "timestamp": 1774179084467 + }, + { + "x": 86.5804443359375, + "y": 272.85894775390625, + "pressure": 2387, + "timestamp": 1774179084469 + }, + { + "x": 87.3729248046875, + "y": 272.85894775390625, + "pressure": 2383, + "timestamp": 1774179084471 + }, + { + "x": 88.16546630859375, + "y": 272.66082763671875, + "pressure": 2381, + "timestamp": 1774179084472 + }, + { + "x": 88.95794677734375, + "y": 272.46270751953125, + "pressure": 2380, + "timestamp": 1774179084474 + }, + { + "x": 89.75042724609375, + "y": 272.2646484375, + "pressure": 2379, + "timestamp": 1774179084476 + }, + { + "x": 90.14666748046875, + "y": 272.0665283203125, + "pressure": 2381, + "timestamp": 1774179084477 + }, + { + "x": 90.54296875, + "y": 271.868408203125, + "pressure": 2378, + "timestamp": 1774179084485 + }, + { + "x": 91.33544921875, + "y": 271.47222900390625, + "pressure": 2380, + "timestamp": 1774179084487 + }, + { + "x": 92.1279296875, + "y": 271.0760498046875, + "pressure": 2379, + "timestamp": 1774179084488 + }, + { + "x": 92.92041015625, + "y": 270.6798095703125, + "pressure": 2378, + "timestamp": 1774179084490 + }, + { + "x": 93.71295166015625, + "y": 270.28363037109375, + "pressure": 2380, + "timestamp": 1774179084492 + }, + { + "x": 94.10919189453125, + "y": 270.08551025390625, + "pressure": 2379, + "timestamp": 1774179084493 + }, + { + "x": 94.50543212890625, + "y": 269.887451171875, + "pressure": 2379, + "timestamp": 1774179084502 + }, + { + "x": 95.29791259765625, + "y": 269.2930908203125, + "pressure": 2381, + "timestamp": 1774179084503 + }, + { + "x": 96.0904541015625, + "y": 268.69879150390625, + "pressure": 2380, + "timestamp": 1774179084504 + }, + { + "x": 96.8829345703125, + "y": 268.1044921875, + "pressure": 2379, + "timestamp": 1774179084506 + }, + { + "x": 97.2791748046875, + "y": 267.70831298828125, + "pressure": 2381, + "timestamp": 1774179084508 + }, + { + "x": 97.6754150390625, + "y": 267.51019287109375, + "pressure": 2379, + "timestamp": 1774179084514 + }, + { + "x": 98.4678955078125, + "y": 266.9158935546875, + "pressure": 2387, + "timestamp": 1774179084515 + }, + { + "x": 99.26043701171875, + "y": 266.32159423828125, + "pressure": 2391, + "timestamp": 1774179084517 + }, + { + "x": 99.65667724609375, + "y": 266.12353515625, + "pressure": 2393, + "timestamp": 1774179084519 + }, + { + "x": 99.65667724609375, + "y": 266.12353515625, + "pressure": 2396, + "timestamp": 1774179084522 + }, + { + "x": 100.05291748046875, + "y": 265.9254150390625, + "pressure": 2395, + "timestamp": 1774179084524 + }, + { + "x": 100.84539794921875, + "y": 265.33111572265625, + "pressure": 2397, + "timestamp": 1774179084526 + }, + { + "x": 101.24163818359375, + "y": 265.13299560546875, + "pressure": 2396, + "timestamp": 1774179084530 + }, + { + "x": 101.24163818359375, + "y": 265.13299560546875, + "pressure": 2402, + "timestamp": 1774179084531 + }, + { + "x": 101.24163818359375, + "y": 265.13299560546875, + "pressure": 2405, + "timestamp": 1774179084533 + }, + { + "x": 101.637939453125, + "y": 264.93487548828125, + "pressure": 2406, + "timestamp": 1774179084535 + }, + { + "x": 102.430419921875, + "y": 264.340576171875, + "pressure": 2407, + "timestamp": 1774179084536 + }, + { + "x": 103.222900390625, + "y": 263.94439697265625, + "pressure": 2409, + "timestamp": 1774179084538 + }, + { + "x": 103.619140625, + "y": 263.74627685546875, + "pressure": 2408, + "timestamp": 1774179084540 + }, + { + "x": 104.015380859375, + "y": 263.5482177734375, + "pressure": 2412, + "timestamp": 1774179084550 + }, + { + "x": 104.80792236328125, + "y": 263.1519775390625, + "pressure": 2411, + "timestamp": 1774179084551 + }, + { + "x": 105.20416259765625, + "y": 262.95391845703125, + "pressure": 2413, + "timestamp": 1774179084553 + }, + { + "x": 105.40228271484375, + "y": 262.55767822265625, + "pressure": 2413, + "timestamp": 1774179084558 + }, + { + "x": 105.99664306640625, + "y": 261.76531982421875, + "pressure": 2412, + "timestamp": 1774179084562 + }, + { + "x": 106.39288330078125, + "y": 261.36907958984375, + "pressure": 2413, + "timestamp": 1774179084563 + }, + { + "x": 106.59100341796875, + "y": 260.972900390625, + "pressure": 2414, + "timestamp": 1774179084567 + }, + { + "x": 107.1854248046875, + "y": 260.18048095703125, + "pressure": 2416, + "timestamp": 1774179084568 + }, + { + "x": 107.77978515625, + "y": 259.3880615234375, + "pressure": 2415, + "timestamp": 1774179084570 + }, + { + "x": 108.176025390625, + "y": 258.99188232421875, + "pressure": 2414, + "timestamp": 1774179084572 + }, + { + "x": 108.176025390625, + "y": 258.99188232421875, + "pressure": 2422, + "timestamp": 1774179084579 + }, + { + "x": 108.3741455078125, + "y": 258.595703125, + "pressure": 2426, + "timestamp": 1774179084582 + }, + { + "x": 108.968505859375, + "y": 257.80328369140625, + "pressure": 2428, + "timestamp": 1774179084583 + }, + { + "x": 109.56292724609375, + "y": 257.0108642578125, + "pressure": 2429, + "timestamp": 1774179084585 + }, + { + "x": 109.95916748046875, + "y": 256.21844482421875, + "pressure": 2431, + "timestamp": 1774179084586 + }, + { + "x": 110.15728759765625, + "y": 255.822265625, + "pressure": 2430, + "timestamp": 1774179084588 + }, + { + "x": 110.15728759765625, + "y": 255.822265625, + "pressure": 2437, + "timestamp": 1774179084595 + }, + { + "x": 110.15728759765625, + "y": 255.42608642578125, + "pressure": 2440, + "timestamp": 1774179084598 + }, + { + "x": 110.35540771484375, + "y": 254.6336669921875, + "pressure": 2442, + "timestamp": 1774179084599 + }, + { + "x": 110.55352783203125, + "y": 253.84124755859375, + "pressure": 2443, + "timestamp": 1774179084601 + }, + { + "x": 110.75164794921875, + "y": 253.445068359375, + "pressure": 2445, + "timestamp": 1774179084602 + }, + { + "x": 110.75164794921875, + "y": 253.445068359375, + "pressure": 2453, + "timestamp": 1774179084611 + }, + { + "x": 110.75164794921875, + "y": 253.445068359375, + "pressure": 2457, + "timestamp": 1774179084614 + }, + { + "x": 110.75164794921875, + "y": 253.04888916015625, + "pressure": 2459, + "timestamp": 1774179084615 + }, + { + "x": 110.94976806640625, + "y": 252.2564697265625, + "pressure": 2460, + "timestamp": 1774179084617 + }, + { + "x": 111.14788818359375, + "y": 251.8602294921875, + "pressure": 2461, + "timestamp": 1774179084618 + }, + { + "x": 111.14788818359375, + "y": 251.8602294921875, + "pressure": 2464, + "timestamp": 1774179084626 + }, + { + "x": 111.14788818359375, + "y": 251.8602294921875, + "pressure": 2487, + "timestamp": 1774179084627 + }, + { + "x": 111.14788818359375, + "y": 251.8602294921875, + "pressure": 2499, + "timestamp": 1774179084630 + }, + { + "x": 111.14788818359375, + "y": 251.8602294921875, + "pressure": 2505, + "timestamp": 1774179084632 + }, + { + "x": 111.14788818359375, + "y": 251.8602294921875, + "pressure": 2508, + "timestamp": 1774179084633 + }, + { + "x": 111.14788818359375, + "y": 251.8602294921875, + "pressure": 2512, + "timestamp": 1774179084638 + }, + { + "x": 111.14788818359375, + "y": 251.8602294921875, + "pressure": 2531, + "timestamp": 1774179084643 + }, + { + "x": 111.14788818359375, + "y": 251.8602294921875, + "pressure": 2541, + "timestamp": 1774179084646 + }, + { + "x": 111.14788818359375, + "y": 251.8602294921875, + "pressure": 2546, + "timestamp": 1774179084647 + }, + { + "x": 110.94976806640625, + "y": 251.46405029296875, + "pressure": 2549, + "timestamp": 1774179084649 + }, + { + "x": 110.75164794921875, + "y": 251.06787109375, + "pressure": 2550, + "timestamp": 1774179084650 + }, + { + "x": 110.75164794921875, + "y": 251.06787109375, + "pressure": 2553, + "timestamp": 1774179084654 + }, + { + "x": 110.75164794921875, + "y": 251.06787109375, + "pressure": 2556, + "timestamp": 1774179084660 + }, + { + "x": 110.35540771484375, + "y": 250.8697509765625, + "pressure": 2558, + "timestamp": 1774179084662 + }, + { + "x": 109.56292724609375, + "y": 250.27545166015625, + "pressure": 2559, + "timestamp": 1774179084664 + }, + { + "x": 109.1666259765625, + "y": 250.07733154296875, + "pressure": 2561, + "timestamp": 1774179084665 + }, + { + "x": 108.7703857421875, + "y": 249.8792724609375, + "pressure": 2560, + "timestamp": 1774179084675 + }, + { + "x": 107.9779052734375, + "y": 249.4830322265625, + "pressure": 2558, + "timestamp": 1774179084676 + }, + { + "x": 107.5816650390625, + "y": 249.28497314453125, + "pressure": 2557, + "timestamp": 1774179084678 + }, + { + "x": 107.1854248046875, + "y": 249.28497314453125, + "pressure": 2557, + "timestamp": 1774179084690 + }, + { + "x": 106.39288330078125, + "y": 249.08685302734375, + "pressure": 2559, + "timestamp": 1774179084692 + }, + { + "x": 105.60040283203125, + "y": 248.88873291015625, + "pressure": 2558, + "timestamp": 1774179084694 + }, + { + "x": 105.20416259765625, + "y": 248.69061279296875, + "pressure": 2557, + "timestamp": 1774179084695 + }, + { + "x": 104.80792236328125, + "y": 248.88873291015625, + "pressure": 2557, + "timestamp": 1774179084700 + }, + { + "x": 104.015380859375, + "y": 249.08685302734375, + "pressure": 2559, + "timestamp": 1774179084702 + }, + { + "x": 103.222900390625, + "y": 249.28497314453125, + "pressure": 2558, + "timestamp": 1774179084706 + }, + { + "x": 102.82666015625, + "y": 249.4830322265625, + "pressure": 2548, + "timestamp": 1774179084708 + }, + { + "x": 102.82666015625, + "y": 249.4830322265625, + "pressure": 2543, + "timestamp": 1774179084710 + }, + { + "x": 102.430419921875, + "y": 249.8792724609375, + "pressure": 2540, + "timestamp": 1774179084713 + }, + { + "x": 101.637939453125, + "y": 250.47357177734375, + "pressure": 2539, + "timestamp": 1774179084715 + }, + { + "x": 100.84539794921875, + "y": 251.26593017578125, + "pressure": 2541, + "timestamp": 1774179084716 + }, + { + "x": 100.05291748046875, + "y": 252.058349609375, + "pressure": 2540, + "timestamp": 1774179084718 + }, + { + "x": 99.45855712890625, + "y": 252.85076904296875, + "pressure": 2539, + "timestamp": 1774179084722 + }, + { + "x": 98.86419677734375, + "y": 253.6431884765625, + "pressure": 2529, + "timestamp": 1774179084724 + }, + { + "x": 98.4678955078125, + "y": 254.435546875, + "pressure": 2524, + "timestamp": 1774179084727 + }, + { + "x": 97.87353515625, + "y": 255.22796630859375, + "pressure": 2522, + "timestamp": 1774179084728 + }, + { + "x": 97.2791748046875, + "y": 256.0203857421875, + "pressure": 2521, + "timestamp": 1774179084729 + }, + { + "x": 96.8829345703125, + "y": 256.812744140625, + "pressure": 2520, + "timestamp": 1774179084731 + }, + { + "x": 96.4866943359375, + "y": 257.60516357421875, + "pressure": 2522, + "timestamp": 1774179084732 + }, + { + "x": 96.28857421875, + "y": 258.00140380859375, + "pressure": 2521, + "timestamp": 1774179084734 + }, + { + "x": 96.28857421875, + "y": 258.00140380859375, + "pressure": 2515, + "timestamp": 1774179084740 + }, + { + "x": 96.0904541015625, + "y": 258.3975830078125, + "pressure": 2513, + "timestamp": 1774179084742 + }, + { + "x": 95.49603271484375, + "y": 259.3880615234375, + "pressure": 2512, + "timestamp": 1774179084743 + }, + { + "x": 95.09979248046875, + "y": 260.18048095703125, + "pressure": 2511, + "timestamp": 1774179084745 + }, + { + "x": 94.70355224609375, + "y": 260.972900390625, + "pressure": 2513, + "timestamp": 1774179084747 + }, + { + "x": 94.70355224609375, + "y": 261.36907958984375, + "pressure": 2512, + "timestamp": 1774179084748 + }, + { + "x": 94.50543212890625, + "y": 261.76531982421875, + "pressure": 2505, + "timestamp": 1774179084756 + }, + { + "x": 94.50543212890625, + "y": 262.1614990234375, + "pressure": 2501, + "timestamp": 1774179084758 + }, + { + "x": 94.50543212890625, + "y": 262.55767822265625, + "pressure": 2498, + "timestamp": 1774179084761 + }, + { + "x": 94.50543212890625, + "y": 263.35009765625, + "pressure": 2497, + "timestamp": 1774179084763 + }, + { + "x": 94.50543212890625, + "y": 263.74627685546875, + "pressure": 2499, + "timestamp": 1774179084764 + }, + { + "x": 94.50543212890625, + "y": 263.74627685546875, + "pressure": 2475, + "timestamp": 1774179084772 + }, + { + "x": 94.50543212890625, + "y": 263.74627685546875, + "pressure": 2464, + "timestamp": 1774179084774 + }, + { + "x": 94.50543212890625, + "y": 263.74627685546875, + "pressure": 2459, + "timestamp": 1774179084776 + }, + { + "x": 94.50543212890625, + "y": 263.74627685546875, + "pressure": 2456, + "timestamp": 1774179084777 + }, + { + "x": 94.70355224609375, + "y": 264.14251708984375, + "pressure": 2455, + "timestamp": 1774179084779 + }, + { + "x": 95.09979248046875, + "y": 264.93487548828125, + "pressure": 2454, + "timestamp": 1774179084781 + }, + { + "x": 95.49603271484375, + "y": 265.33111572265625, + "pressure": 2456, + "timestamp": 1774179084783 + }, + { + "x": 95.49603271484375, + "y": 265.33111572265625, + "pressure": 2432, + "timestamp": 1774179084788 + }, + { + "x": 95.892333984375, + "y": 265.727294921875, + "pressure": 2421, + "timestamp": 1774179084790 + }, + { + "x": 96.684814453125, + "y": 266.32159423828125, + "pressure": 2415, + "timestamp": 1774179084792 + }, + { + "x": 97.477294921875, + "y": 266.71783447265625, + "pressure": 2412, + "timestamp": 1774179084793 + }, + { + "x": 98.269775390625, + "y": 267.114013671875, + "pressure": 2411, + "timestamp": 1774179084795 + }, + { + "x": 99.06231689453125, + "y": 267.3121337890625, + "pressure": 2410, + "timestamp": 1774179084797 + }, + { + "x": 99.85479736328125, + "y": 267.3121337890625, + "pressure": 2412, + "timestamp": 1774179084798 + }, + { + "x": 100.64727783203125, + "y": 267.3121337890625, + "pressure": 2411, + "timestamp": 1774179084803 + }, + { + "x": 101.4398193359375, + "y": 267.3121337890625, + "pressure": 2407, + "timestamp": 1774179084804 + }, + { + "x": 102.2322998046875, + "y": 267.114013671875, + "pressure": 2405, + "timestamp": 1774179084806 + }, + { + "x": 103.0247802734375, + "y": 266.9158935546875, + "pressure": 2404, + "timestamp": 1774179084808 + }, + { + "x": 103.8172607421875, + "y": 266.71783447265625, + "pressure": 2403, + "timestamp": 1774179084809 + }, + { + "x": 104.60980224609375, + "y": 266.51971435546875, + "pressure": 2405, + "timestamp": 1774179084811 + }, + { + "x": 105.40228271484375, + "y": 266.32159423828125, + "pressure": 2404, + "timestamp": 1774179084813 + }, + { + "x": 106.19476318359375, + "y": 266.12353515625, + "pressure": 2403, + "timestamp": 1774179084814 + }, + { + "x": 107.1854248046875, + "y": 265.9254150390625, + "pressure": 2405, + "timestamp": 1774179084819 + }, + { + "x": 107.9779052734375, + "y": 265.5291748046875, + "pressure": 2402, + "timestamp": 1774179084820 + }, + { + "x": 108.7703857421875, + "y": 265.13299560546875, + "pressure": 2401, + "timestamp": 1774179084823 + }, + { + "x": 109.56292724609375, + "y": 264.73681640625, + "pressure": 2400, + "timestamp": 1774179084824 + }, + { + "x": 110.35540771484375, + "y": 264.340576171875, + "pressure": 2402, + "timestamp": 1774179084825 + }, + { + "x": 111.14788818359375, + "y": 263.94439697265625, + "pressure": 2401, + "timestamp": 1774179084827 + }, + { + "x": 111.94036865234375, + "y": 263.5482177734375, + "pressure": 2400, + "timestamp": 1774179084829 + }, + { + "x": 112.73291015625, + "y": 263.1519775390625, + "pressure": 2402, + "timestamp": 1774179084831 + }, + { + "x": 113.525390625, + "y": 262.75579833984375, + "pressure": 2401, + "timestamp": 1774179084835 + }, + { + "x": 114.31787109375, + "y": 262.359619140625, + "pressure": 2403, + "timestamp": 1774179084836 + }, + { + "x": 115.11041259765625, + "y": 261.96337890625, + "pressure": 2402, + "timestamp": 1774179084838 + }, + { + "x": 115.90289306640625, + "y": 261.56719970703125, + "pressure": 2401, + "timestamp": 1774179084840 + }, + { + "x": 116.69537353515625, + "y": 260.972900390625, + "pressure": 2403, + "timestamp": 1774179084841 + }, + { + "x": 117.48785400390625, + "y": 260.37860107421875, + "pressure": 2402, + "timestamp": 1774179084843 + }, + { + "x": 118.2803955078125, + "y": 259.7843017578125, + "pressure": 2401, + "timestamp": 1774179084845 + }, + { + "x": 119.0728759765625, + "y": 259.19000244140625, + "pressure": 2403, + "timestamp": 1774179084847 + }, + { + "x": 119.8653564453125, + "y": 258.595703125, + "pressure": 2402, + "timestamp": 1774179084850 + }, + { + "x": 120.2615966796875, + "y": 258.199462890625, + "pressure": 2406, + "timestamp": 1774179084852 + }, + { + "x": 120.2615966796875, + "y": 258.199462890625, + "pressure": 2409, + "timestamp": 1774179084856 + }, + { + "x": 120.65789794921875, + "y": 258.00140380859375, + "pressure": 2411, + "timestamp": 1774179084857 + }, + { + "x": 121.45037841796875, + "y": 257.4071044921875, + "pressure": 2410, + "timestamp": 1774179084859 + }, + { + "x": 122.24285888671875, + "y": 256.812744140625, + "pressure": 2412, + "timestamp": 1774179084861 + }, + { + "x": 123.03533935546875, + "y": 256.21844482421875, + "pressure": 2411, + "timestamp": 1774179084862 + }, + { + "x": 123.827880859375, + "y": 255.6241455078125, + "pressure": 2410, + "timestamp": 1774179084867 + }, + { + "x": 124.22412109375, + "y": 255.22796630859375, + "pressure": 2411, + "timestamp": 1774179084868 + }, + { + "x": 124.22412109375, + "y": 255.22796630859375, + "pressure": 2414, + "timestamp": 1774179084872 + }, + { + "x": 124.620361328125, + "y": 255.02984619140625, + "pressure": 2413, + "timestamp": 1774179084873 + }, + { + "x": 125.412841796875, + "y": 254.435546875, + "pressure": 2415, + "timestamp": 1774179084875 + }, + { + "x": 126.20538330078125, + "y": 253.84124755859375, + "pressure": 2414, + "timestamp": 1774179084877 + }, + { + "x": 126.60162353515625, + "y": 253.445068359375, + "pressure": 2413, + "timestamp": 1774179084879 + }, + { + "x": 126.60162353515625, + "y": 253.445068359375, + "pressure": 2416, + "timestamp": 1774179084884 + }, + { + "x": 126.99786376953125, + "y": 253.2469482421875, + "pressure": 2419, + "timestamp": 1774179084888 + }, + { + "x": 127.79034423828125, + "y": 252.65264892578125, + "pressure": 2418, + "timestamp": 1774179084890 + }, + { + "x": 128.18658447265625, + "y": 252.2564697265625, + "pressure": 2420, + "timestamp": 1774179084891 + }, + { + "x": 128.58282470703125, + "y": 251.8602294921875, + "pressure": 2420, + "timestamp": 1774179084898 + }, + { + "x": 129.3753662109375, + "y": 251.06787109375, + "pressure": 2419, + "timestamp": 1774179084900 + }, + { + "x": 130.1678466796875, + "y": 250.47357177734375, + "pressure": 2421, + "timestamp": 1774179084902 + }, + { + "x": 130.9603271484375, + "y": 249.8792724609375, + "pressure": 2420, + "timestamp": 1774179084904 + }, + { + "x": 131.35662841796875, + "y": 249.68115234375, + "pressure": 2419, + "timestamp": 1774179084905 + }, + { + "x": 131.55474853515625, + "y": 249.28497314453125, + "pressure": 2421, + "timestamp": 1774179084915 + }, + { + "x": 132.14910888671875, + "y": 248.4925537109375, + "pressure": 2424, + "timestamp": 1774179084916 + }, + { + "x": 132.74346923828125, + "y": 247.70013427734375, + "pressure": 2425, + "timestamp": 1774179084919 + }, + { + "x": 133.33782958984375, + "y": 246.90771484375, + "pressure": 2426, + "timestamp": 1774179084920 + }, + { + "x": 133.73406982421875, + "y": 246.51153564453125, + "pressure": 2428, + "timestamp": 1774179084922 + }, + { + "x": 133.93218994140625, + "y": 246.1153564453125, + "pressure": 2428, + "timestamp": 1774179084927 + }, + { + "x": 134.526611328125, + "y": 245.32293701171875, + "pressure": 2427, + "timestamp": 1774179084931 + }, + { + "x": 135.1209716796875, + "y": 244.530517578125, + "pressure": 2429, + "timestamp": 1774179084932 + }, + { + "x": 135.71533203125, + "y": 243.73809814453125, + "pressure": 2430, + "timestamp": 1774179084934 + }, + { + "x": 136.3096923828125, + "y": 242.94573974609375, + "pressure": 2432, + "timestamp": 1774179084936 + }, + { + "x": 136.90411376953125, + "y": 242.1533203125, + "pressure": 2431, + "timestamp": 1774179084937 + }, + { + "x": 137.30035400390625, + "y": 241.75714111328125, + "pressure": 2433, + "timestamp": 1774179084940 + }, + { + "x": 137.49847412109375, + "y": 241.36090087890625, + "pressure": 2431, + "timestamp": 1774179084943 + }, + { + "x": 138.09283447265625, + "y": 240.56854248046875, + "pressure": 2433, + "timestamp": 1774179084947 + }, + { + "x": 138.48907470703125, + "y": 240.17230224609375, + "pressure": 2430, + "timestamp": 1774179084948 + }, + { + "x": 138.68719482421875, + "y": 239.776123046875, + "pressure": 2428, + "timestamp": 1774179084951 + }, + { + "x": 139.28155517578125, + "y": 238.98370361328125, + "pressure": 2427, + "timestamp": 1774179084952 + }, + { + "x": 139.8759765625, + "y": 238.1912841796875, + "pressure": 2429, + "timestamp": 1774179084953 + }, + { + "x": 140.4703369140625, + "y": 237.39892578125, + "pressure": 2428, + "timestamp": 1774179084955 + }, + { + "x": 140.8665771484375, + "y": 237.002685546875, + "pressure": 2427, + "timestamp": 1774179084957 + }, + { + "x": 141.064697265625, + "y": 236.60650634765625, + "pressure": 2428, + "timestamp": 1774179084963 + }, + { + "x": 141.6590576171875, + "y": 235.8140869140625, + "pressure": 2426, + "timestamp": 1774179084964 + }, + { + "x": 142.25341796875, + "y": 235.02166748046875, + "pressure": 2425, + "timestamp": 1774179084967 + }, + { + "x": 142.84783935546875, + "y": 234.22930908203125, + "pressure": 2427, + "timestamp": 1774179084968 + }, + { + "x": 143.44219970703125, + "y": 233.4368896484375, + "pressure": 2426, + "timestamp": 1774179084970 + }, + { + "x": 144.03656005859375, + "y": 232.64447021484375, + "pressure": 2425, + "timestamp": 1774179084971 + }, + { + "x": 144.63092041015625, + "y": 231.85205078125, + "pressure": 2427, + "timestamp": 1774179084973 + }, + { + "x": 145.225341796875, + "y": 231.0596923828125, + "pressure": 2426, + "timestamp": 1774179084975 + }, + { + "x": 145.8197021484375, + "y": 230.26727294921875, + "pressure": 2425, + "timestamp": 1774179084979 + }, + { + "x": 146.4140625, + "y": 229.474853515625, + "pressure": 2430, + "timestamp": 1774179084980 + }, + { + "x": 147.0084228515625, + "y": 228.68246459960938, + "pressure": 2432, + "timestamp": 1774179084983 + }, + { + "x": 147.602783203125, + "y": 227.89007568359375, + "pressure": 2433, + "timestamp": 1774179084984 + }, + { + "x": 148.19720458984375, + "y": 227.09765625, + "pressure": 2434, + "timestamp": 1774179084986 + }, + { + "x": 148.59344482421875, + "y": 226.30526733398438, + "pressure": 2436, + "timestamp": 1774179084987 + }, + { + "x": 148.98968505859375, + "y": 225.51284790039062, + "pressure": 2435, + "timestamp": 1774179084989 + }, + { + "x": 149.38592529296875, + "y": 224.720458984375, + "pressure": 2437, + "timestamp": 1774179084991 + }, + { + "x": 149.78216552734375, + "y": 223.92803955078125, + "pressure": 2436, + "timestamp": 1774179084995 + }, + { + "x": 150.17840576171875, + "y": 223.13565063476562, + "pressure": 2438, + "timestamp": 1774179084996 + }, + { + "x": 150.57470703125, + "y": 222.34323120117188, + "pressure": 2439, + "timestamp": 1774179084999 + }, + { + "x": 150.970947265625, + "y": 221.55084228515625, + "pressure": 2441, + "timestamp": 1774179085000 + }, + { + "x": 151.1690673828125, + "y": 220.75845336914062, + "pressure": 2440, + "timestamp": 1774179085002 + }, + { + "x": 151.5653076171875, + "y": 219.96603393554688, + "pressure": 2442, + "timestamp": 1774179085003 + }, + { + "x": 151.763427734375, + "y": 219.17364501953125, + "pressure": 2441, + "timestamp": 1774179085005 + }, + { + "x": 151.9615478515625, + "y": 218.3812255859375, + "pressure": 2440, + "timestamp": 1774179085007 + }, + { + "x": 152.15966796875, + "y": 217.58883666992188, + "pressure": 2442, + "timestamp": 1774179085011 + }, + { + "x": 152.3577880859375, + "y": 216.79641723632812, + "pressure": 2445, + "timestamp": 1774179085013 + }, + { + "x": 152.555908203125, + "y": 216.0040283203125, + "pressure": 2446, + "timestamp": 1774179085015 + }, + { + "x": 152.7540283203125, + "y": 215.60781860351562, + "pressure": 2447, + "timestamp": 1774179085016 + }, + { + "x": 152.7540283203125, + "y": 215.21160888671875, + "pressure": 2448, + "timestamp": 1774179085019 + }, + { + "x": 152.9521484375, + "y": 214.41921997070312, + "pressure": 2450, + "timestamp": 1774179085021 + }, + { + "x": 153.1502685546875, + "y": 213.62680053710938, + "pressure": 2449, + "timestamp": 1774179085023 + }, + { + "x": 153.34844970703125, + "y": 213.23062133789062, + "pressure": 2448, + "timestamp": 1774179085027 + }, + { + "x": 153.34844970703125, + "y": 213.23062133789062, + "pressure": 2459, + "timestamp": 1774179085029 + }, + { + "x": 153.34844970703125, + "y": 213.23062133789062, + "pressure": 2464, + "timestamp": 1774179085031 + }, + { + "x": 153.34844970703125, + "y": 213.23062133789062, + "pressure": 2467, + "timestamp": 1774179085032 + }, + { + "x": 153.34844970703125, + "y": 212.83441162109375, + "pressure": 2468, + "timestamp": 1774179085034 + }, + { + "x": 153.34844970703125, + "y": 211.84390258789062, + "pressure": 2469, + "timestamp": 1774179085036 + }, + { + "x": 153.34844970703125, + "y": 210.8533935546875, + "pressure": 2471, + "timestamp": 1774179085037 + }, + { + "x": 153.34844970703125, + "y": 210.45721435546875, + "pressure": 2470, + "timestamp": 1774179085039 + }, + { + "x": 153.1502685546875, + "y": 210.06100463867188, + "pressure": 2482, + "timestamp": 1774179085045 + }, + { + "x": 152.9521484375, + "y": 209.26858520507812, + "pressure": 2487, + "timestamp": 1774179085047 + }, + { + "x": 152.7540283203125, + "y": 208.4761962890625, + "pressure": 2489, + "timestamp": 1774179085048 + }, + { + "x": 152.7540283203125, + "y": 208.07998657226562, + "pressure": 2490, + "timestamp": 1774179085050 + }, + { + "x": 152.7540283203125, + "y": 208.07998657226562, + "pressure": 2493, + "timestamp": 1774179085053 + }, + { + "x": 152.7540283203125, + "y": 208.07998657226562, + "pressure": 2498, + "timestamp": 1774179085061 + }, + { + "x": 152.555908203125, + "y": 207.68377685546875, + "pressure": 2500, + "timestamp": 1774179085063 + }, + { + "x": 151.9615478515625, + "y": 206.89138793945312, + "pressure": 2501, + "timestamp": 1774179085065 + }, + { + "x": 151.763427734375, + "y": 206.49517822265625, + "pressure": 2503, + "timestamp": 1774179085066 + }, + { + "x": 151.763427734375, + "y": 206.49517822265625, + "pressure": 2522, + "timestamp": 1774179085077 + }, + { + "x": 151.763427734375, + "y": 206.49517822265625, + "pressure": 2532, + "timestamp": 1774179085079 + }, + { + "x": 151.763427734375, + "y": 206.49517822265625, + "pressure": 2537, + "timestamp": 1774179085081 + }, + { + "x": 151.3671875, + "y": 206.29708862304688, + "pressure": 2540, + "timestamp": 1774179085084 + }, + { + "x": 150.57470703125, + "y": 205.90087890625, + "pressure": 2541, + "timestamp": 1774179085085 + }, + { + "x": 149.78216552734375, + "y": 205.70278930664062, + "pressure": 2543, + "timestamp": 1774179085087 + }, + { + "x": 149.38592529296875, + "y": 205.70278930664062, + "pressure": 2542, + "timestamp": 1774179085091 + }, + { + "x": 149.38592529296875, + "y": 205.70278930664062, + "pressure": 2537, + "timestamp": 1774179085093 + }, + { + "x": 148.98968505859375, + "y": 205.90087890625, + "pressure": 2534, + "timestamp": 1774179085095 + }, + { + "x": 147.99908447265625, + "y": 206.29708862304688, + "pressure": 2533, + "timestamp": 1774179085097 + }, + { + "x": 147.0084228515625, + "y": 207.28759765625, + "pressure": 2532, + "timestamp": 1774179085098 + }, + { + "x": 146.017822265625, + "y": 208.27810668945312, + "pressure": 2534, + "timestamp": 1774179085100 + }, + { + "x": 145.0272216796875, + "y": 209.26858520507812, + "pressure": 2533, + "timestamp": 1774179085101 + }, + { + "x": 144.03656005859375, + "y": 210.25909423828125, + "pressure": 2532, + "timestamp": 1774179085103 + }, + { + "x": 143.04595947265625, + "y": 211.24960327148438, + "pressure": 2534, + "timestamp": 1774179085107 + }, + { + "x": 142.0552978515625, + "y": 212.43820190429688, + "pressure": 2523, + "timestamp": 1774179085109 + }, + { + "x": 141.064697265625, + "y": 213.82492065429688, + "pressure": 2518, + "timestamp": 1774179085111 + }, + { + "x": 140.272216796875, + "y": 215.21160888671875, + "pressure": 2515, + "timestamp": 1774179085113 + }, + { + "x": 139.47967529296875, + "y": 216.79641723632812, + "pressure": 2514, + "timestamp": 1774179085114 + }, + { + "x": 138.68719482421875, + "y": 218.3812255859375, + "pressure": 2513, + "timestamp": 1774179085116 + }, + { + "x": 137.89471435546875, + "y": 219.96603393554688, + "pressure": 2515, + "timestamp": 1774179085118 + }, + { + "x": 137.10223388671875, + "y": 221.55084228515625, + "pressure": 2514, + "timestamp": 1774179085119 + }, + { + "x": 136.111572265625, + "y": 223.13565063476562, + "pressure": 2513, + "timestamp": 1774179085123 + }, + { + "x": 135.1209716796875, + "y": 224.91854858398438, + "pressure": 2506, + "timestamp": 1774179085125 + }, + { + "x": 134.13037109375, + "y": 226.70144653320312, + "pressure": 2502, + "timestamp": 1774179085127 + }, + { + "x": 133.13970947265625, + "y": 228.68246459960938, + "pressure": 2500, + "timestamp": 1774179085129 + }, + { + "x": 132.34722900390625, + "y": 230.6634521484375, + "pressure": 2499, + "timestamp": 1774179085130 + }, + { + "x": 131.55474853515625, + "y": 232.4464111328125, + "pressure": 2501, + "timestamp": 1774179085132 + }, + { + "x": 130.76220703125, + "y": 234.22930908203125, + "pressure": 2500, + "timestamp": 1774179085134 + }, + { + "x": 129.9697265625, + "y": 236.01220703125, + "pressure": 2499, + "timestamp": 1774179085135 + }, + { + "x": 129.3753662109375, + "y": 237.79510498046875, + "pressure": 2501, + "timestamp": 1774179085140 + }, + { + "x": 128.781005859375, + "y": 239.3798828125, + "pressure": 2496, + "timestamp": 1774179085141 + }, + { + "x": 128.18658447265625, + "y": 240.9647216796875, + "pressure": 2493, + "timestamp": 1774179085143 + }, + { + "x": 127.79034423828125, + "y": 242.54949951171875, + "pressure": 2492, + "timestamp": 1774179085145 + }, + { + "x": 127.39410400390625, + "y": 244.13433837890625, + "pressure": 2491, + "timestamp": 1774179085146 + }, + { + "x": 126.99786376953125, + "y": 245.7191162109375, + "pressure": 2493, + "timestamp": 1774179085148 + }, + { + "x": 126.60162353515625, + "y": 247.1058349609375, + "pressure": 2492, + "timestamp": 1774179085150 + }, + { + "x": 126.20538330078125, + "y": 248.4925537109375, + "pressure": 2491, + "timestamp": 1774179085151 + }, + { + "x": 126.20538330078125, + "y": 249.68115234375, + "pressure": 2493, + "timestamp": 1774179085155 + }, + { + "x": 126.20538330078125, + "y": 250.8697509765625, + "pressure": 2483, + "timestamp": 1774179085157 + }, + { + "x": 126.20538330078125, + "y": 252.058349609375, + "pressure": 2478, + "timestamp": 1774179085159 + }, + { + "x": 126.20538330078125, + "y": 253.2469482421875, + "pressure": 2476, + "timestamp": 1774179085161 + }, + { + "x": 126.20538330078125, + "y": 254.435546875, + "pressure": 2475, + "timestamp": 1774179085162 + }, + { + "x": 126.40350341796875, + "y": 255.6241455078125, + "pressure": 2474, + "timestamp": 1774179085164 + }, + { + "x": 126.60162353515625, + "y": 256.812744140625, + "pressure": 2476, + "timestamp": 1774179085166 + }, + { + "x": 126.79974365234375, + "y": 257.80328369140625, + "pressure": 2475, + "timestamp": 1774179085167 + }, + { + "x": 126.99786376953125, + "y": 258.595703125, + "pressure": 2474, + "timestamp": 1774179085171 + }, + { + "x": 127.39410400390625, + "y": 259.3880615234375, + "pressure": 2470, + "timestamp": 1774179085173 + }, + { + "x": 127.59222412109375, + "y": 259.7843017578125, + "pressure": 2468, + "timestamp": 1774179085175 + }, + { + "x": 127.79034423828125, + "y": 260.18048095703125, + "pressure": 2466, + "timestamp": 1774179085178 + }, + { + "x": 128.38470458984375, + "y": 260.972900390625, + "pressure": 2468, + "timestamp": 1774179085180 + }, + { + "x": 128.781005859375, + "y": 261.36907958984375, + "pressure": 2467, + "timestamp": 1774179085182 + }, + { + "x": 129.17724609375, + "y": 261.76531982421875, + "pressure": 2466, + "timestamp": 1774179085183 + }, + { + "x": 130.1678466796875, + "y": 262.359619140625, + "pressure": 2468, + "timestamp": 1774179085188 + }, + { + "x": 131.158447265625, + "y": 263.1519775390625, + "pressure": 2465, + "timestamp": 1774179085189 + }, + { + "x": 131.55474853515625, + "y": 263.35009765625, + "pressure": 2463, + "timestamp": 1774179085191 + }, + { + "x": 131.95098876953125, + "y": 263.74627685546875, + "pressure": 2462, + "timestamp": 1774179085193 + }, + { + "x": 132.74346923828125, + "y": 264.340576171875, + "pressure": 2464, + "timestamp": 1774179085195 + }, + { + "x": 133.53594970703125, + "y": 264.73681640625, + "pressure": 2463, + "timestamp": 1774179085196 + }, + { + "x": 134.3284912109375, + "y": 265.13299560546875, + "pressure": 2462, + "timestamp": 1774179085198 + }, + { + "x": 135.1209716796875, + "y": 265.5291748046875, + "pressure": 2464, + "timestamp": 1774179085199 + }, + { + "x": 136.111572265625, + "y": 265.727294921875, + "pressure": 2463, + "timestamp": 1774179085203 + }, + { + "x": 137.10223388671875, + "y": 265.9254150390625, + "pressure": 2453, + "timestamp": 1774179085205 + }, + { + "x": 137.89471435546875, + "y": 265.9254150390625, + "pressure": 2448, + "timestamp": 1774179085208 + }, + { + "x": 138.68719482421875, + "y": 265.9254150390625, + "pressure": 2446, + "timestamp": 1774179085209 + }, + { + "x": 139.6778564453125, + "y": 265.9254150390625, + "pressure": 2445, + "timestamp": 1774179085210 + }, + { + "x": 140.4703369140625, + "y": 265.727294921875, + "pressure": 2444, + "timestamp": 1774179085212 + }, + { + "x": 141.4609375, + "y": 265.5291748046875, + "pressure": 2446, + "timestamp": 1774179085214 + }, + { + "x": 142.45159912109375, + "y": 265.33111572265625, + "pressure": 2445, + "timestamp": 1774179085215 + }, + { + "x": 143.44219970703125, + "y": 265.13299560546875, + "pressure": 2444, + "timestamp": 1774179085220 + }, + { + "x": 144.23468017578125, + "y": 264.93487548828125, + "pressure": 2439, + "timestamp": 1774179085221 + }, + { + "x": 145.225341796875, + "y": 264.5386962890625, + "pressure": 2437, + "timestamp": 1774179085223 + }, + { + "x": 146.017822265625, + "y": 264.14251708984375, + "pressure": 2436, + "timestamp": 1774179085225 + }, + { + "x": 146.810302734375, + "y": 263.74627685546875, + "pressure": 2435, + "timestamp": 1774179085226 + }, + { + "x": 147.80096435546875, + "y": 263.35009765625, + "pressure": 2437, + "timestamp": 1774179085228 + }, + { + "x": 148.79156494140625, + "y": 262.95391845703125, + "pressure": 2436, + "timestamp": 1774179085230 + }, + { + "x": 149.58404541015625, + "y": 262.55767822265625, + "pressure": 2435, + "timestamp": 1774179085232 + }, + { + "x": 150.37652587890625, + "y": 262.1614990234375, + "pressure": 2437, + "timestamp": 1774179085236 + }, + { + "x": 151.1690673828125, + "y": 261.56719970703125, + "pressure": 2435, + "timestamp": 1774179085237 + }, + { + "x": 151.9615478515625, + "y": 260.972900390625, + "pressure": 2434, + "timestamp": 1774179085239 + }, + { + "x": 152.7540283203125, + "y": 260.37860107421875, + "pressure": 2433, + "timestamp": 1774179085241 + }, + { + "x": 153.54656982421875, + "y": 259.7843017578125, + "pressure": 2435, + "timestamp": 1774179085243 + }, + { + "x": 154.33905029296875, + "y": 259.19000244140625, + "pressure": 2434, + "timestamp": 1774179085244 + }, + { + "x": 154.73529052734375, + "y": 258.79376220703125, + "pressure": 2433, + "timestamp": 1774179085246 + }, + { + "x": 154.93341064453125, + "y": 258.3975830078125, + "pressure": 2434, + "timestamp": 1774179085252 + }, + { + "x": 155.52777099609375, + "y": 257.60516357421875, + "pressure": 2436, + "timestamp": 1774179085253 + }, + { + "x": 156.1221923828125, + "y": 256.812744140625, + "pressure": 2437, + "timestamp": 1774179085256 + }, + { + "x": 156.716552734375, + "y": 256.0203857421875, + "pressure": 2438, + "timestamp": 1774179085257 + }, + { + "x": 156.9146728515625, + "y": 255.6241455078125, + "pressure": 2440, + "timestamp": 1774179085259 + }, + { + "x": 157.11279296875, + "y": 255.22796630859375, + "pressure": 2441, + "timestamp": 1774179085262 + }, + { + "x": 157.7071533203125, + "y": 254.435546875, + "pressure": 2440, + "timestamp": 1774179085264 + }, + { + "x": 157.9052734375, + "y": 254.03936767578125, + "pressure": 2439, + "timestamp": 1774179085268 + }, + { + "x": 157.9052734375, + "y": 254.03936767578125, + "pressure": 2443, + "timestamp": 1774179085269 + }, + { + "x": 158.1033935546875, + "y": 253.6431884765625, + "pressure": 2445, + "timestamp": 1774179085272 + }, + { + "x": 158.4996337890625, + "y": 252.85076904296875, + "pressure": 2446, + "timestamp": 1774179085273 + }, + { + "x": 158.69781494140625, + "y": 252.45452880859375, + "pressure": 2447, + "timestamp": 1774179085274 + }, + { + "x": 158.69781494140625, + "y": 252.45452880859375, + "pressure": 2450, + "timestamp": 1774179085280 + }, + { + "x": 158.89593505859375, + "y": 252.058349609375, + "pressure": 2453, + "timestamp": 1774179085288 + }, + { + "x": 159.29217529296875, + "y": 251.26593017578125, + "pressure": 2454, + "timestamp": 1774179085289 + }, + { + "x": 159.49029541015625, + "y": 250.47357177734375, + "pressure": 2456, + "timestamp": 1774179085291 + }, + { + "x": 159.68841552734375, + "y": 250.07733154296875, + "pressure": 2455, + "timestamp": 1774179085292 + }, + { + "x": 159.68841552734375, + "y": 250.07733154296875, + "pressure": 2458, + "timestamp": 1774179085301 + }, + { + "x": 159.68841552734375, + "y": 250.07733154296875, + "pressure": 2462, + "timestamp": 1774179085307 + }, + { + "x": 159.68841552734375, + "y": 250.07733154296875, + "pressure": 2465, + "timestamp": 1774179085317 + }, + { + "x": 159.68841552734375, + "y": 250.07733154296875, + "pressure": 2468, + "timestamp": 1774179085321 + }, + { + "x": 159.68841552734375, + "y": 250.07733154296875, + "pressure": 2471, + "timestamp": 1774179085326 + }, + { + "x": 159.68841552734375, + "y": 250.07733154296875, + "pressure": 2468, + "timestamp": 1774179085333 + }, + { + "x": 159.68841552734375, + "y": 250.07733154296875, + "pressure": 2472, + "timestamp": 1774179085349 + }, + { + "x": 159.68841552734375, + "y": 250.07733154296875, + "pressure": 2479, + "timestamp": 1774179085366 + }, + { + "x": 159.68841552734375, + "y": 250.07733154296875, + "pressure": 2483, + "timestamp": 1774179085368 + }, + { + "x": 159.68841552734375, + "y": 250.07733154296875, + "pressure": 2486, + "timestamp": 1774179085371 + }, + { + "x": 159.68841552734375, + "y": 250.07733154296875, + "pressure": 2489, + "timestamp": 1774179085376 + }, + { + "x": 159.68841552734375, + "y": 250.07733154296875, + "pressure": 2499, + "timestamp": 1774179085381 + }, + { + "x": 159.68841552734375, + "y": 250.07733154296875, + "pressure": 2505, + "timestamp": 1774179085384 + }, + { + "x": 159.68841552734375, + "y": 250.07733154296875, + "pressure": 2508, + "timestamp": 1774179085385 + }, + { + "x": 159.68841552734375, + "y": 250.07733154296875, + "pressure": 2512, + "timestamp": 1774179085390 + }, + { + "x": 159.68841552734375, + "y": 250.07733154296875, + "pressure": 2516, + "timestamp": 1774179085398 + }, + { + "x": 159.68841552734375, + "y": 250.07733154296875, + "pressure": 2520, + "timestamp": 1774179085403 + }, + { + "x": 159.68841552734375, + "y": 250.07733154296875, + "pressure": 2516, + "timestamp": 1774179085414 + }, + { + "x": 159.68841552734375, + "y": 250.07733154296875, + "pressure": 2513, + "timestamp": 1774179085417 + }, + { + "x": 159.68841552734375, + "y": 250.07733154296875, + "pressure": 2510, + "timestamp": 1774179085430 + }, + { + "x": 159.68841552734375, + "y": 250.07733154296875, + "pressure": 2507, + "timestamp": 1774179085433 + }, + { + "x": 159.68841552734375, + "y": 250.07733154296875, + "pressure": 2512, + "timestamp": 1774179085446 + }, + { + "x": 159.68841552734375, + "y": 250.07733154296875, + "pressure": 2515, + "timestamp": 1774179085449 + }, + { + "x": 159.68841552734375, + "y": 250.07733154296875, + "pressure": 2518, + "timestamp": 1774179085453 + }, + { + "x": 159.68841552734375, + "y": 250.07733154296875, + "pressure": 2522, + "timestamp": 1774179085465 + }, + { + "x": 159.68841552734375, + "y": 250.07733154296875, + "pressure": 2514, + "timestamp": 1774179085494 + }, + { + "x": 159.68841552734375, + "y": 250.07733154296875, + "pressure": 2511, + "timestamp": 1774179085496 + }, + { + "x": 159.68841552734375, + "y": 250.07733154296875, + "pressure": 2493, + "timestamp": 1774179085510 + }, + { + "x": 159.68841552734375, + "y": 250.07733154296875, + "pressure": 2484, + "timestamp": 1774179085512 + }, + { + "x": 159.68841552734375, + "y": 250.07733154296875, + "pressure": 2480, + "timestamp": 1774179085513 + }, + { + "x": 159.68841552734375, + "y": 250.07733154296875, + "pressure": 2477, + "timestamp": 1774179085517 + }, + { + "x": 159.88653564453125, + "y": 249.68115234375, + "pressure": 2477, + "timestamp": 1774179085525 + }, + { + "x": 160.08465576171875, + "y": 249.28497314453125, + "pressure": 2486, + "timestamp": 1774179085526 + }, + { + "x": 160.08465576171875, + "y": 249.28497314453125, + "pressure": 2490, + "timestamp": 1774179085528 + }, + { + "x": 160.08465576171875, + "y": 248.88873291015625, + "pressure": 2492, + "timestamp": 1774179085530 + }, + { + "x": 160.28277587890625, + "y": 248.0963134765625, + "pressure": 2493, + "timestamp": 1774179085531 + }, + { + "x": 160.48089599609375, + "y": 247.70013427734375, + "pressure": 2494, + "timestamp": 1774179085533 + }, + { + "x": 160.48089599609375, + "y": 247.70013427734375, + "pressure": 2497, + "timestamp": 1774179085541 + }, + { + "x": 160.48089599609375, + "y": 247.70013427734375, + "pressure": 2510, + "timestamp": 1774179085542 + }, + { + "x": 160.48089599609375, + "y": 247.70013427734375, + "pressure": 2517, + "timestamp": 1774179085545 + }, + { + "x": 160.48089599609375, + "y": 247.70013427734375, + "pressure": 2520, + "timestamp": 1774179085546 + }, + { + "x": 160.48089599609375, + "y": 247.70013427734375, + "pressure": 2523, + "timestamp": 1774179085549 + }, + { + "x": 160.48089599609375, + "y": 247.70013427734375, + "pressure": 2526, + "timestamp": 1774179085557 + }, + { + "x": 160.48089599609375, + "y": 247.70013427734375, + "pressure": 2533, + "timestamp": 1774179085558 + }, + { + "x": 160.48089599609375, + "y": 247.70013427734375, + "pressure": 2537, + "timestamp": 1774179085560 + }, + { + "x": 160.48089599609375, + "y": 247.70013427734375, + "pressure": 2540, + "timestamp": 1774179085563 + }, + { + "x": 160.48089599609375, + "y": 247.70013427734375, + "pressure": 2543, + "timestamp": 1774179085568 + }, + { + "x": 160.48089599609375, + "y": 247.70013427734375, + "pressure": 2563, + "timestamp": 1774179085574 + }, + { + "x": 160.48089599609375, + "y": 247.70013427734375, + "pressure": 2573, + "timestamp": 1774179085576 + }, + { + "x": 160.48089599609375, + "y": 247.70013427734375, + "pressure": 2578, + "timestamp": 1774179085578 + }, + { + "x": 160.48089599609375, + "y": 247.70013427734375, + "pressure": 2581, + "timestamp": 1774179085579 + }, + { + "x": 160.48089599609375, + "y": 247.70013427734375, + "pressure": 2585, + "timestamp": 1774179085584 + }, + { + "x": 160.48089599609375, + "y": 247.70013427734375, + "pressure": 2595, + "timestamp": 1774179085590 + }, + { + "x": 160.48089599609375, + "y": 247.70013427734375, + "pressure": 2600, + "timestamp": 1774179085592 + }, + { + "x": 160.48089599609375, + "y": 247.70013427734375, + "pressure": 2603, + "timestamp": 1774179085594 + }, + { + "x": 160.48089599609375, + "y": 247.70013427734375, + "pressure": 2607, + "timestamp": 1774179085599 + }, + { + "x": 160.48089599609375, + "y": 247.70013427734375, + "pressure": 2604, + "timestamp": 1774179085606 + }, + { + "x": 160.48089599609375, + "y": 247.70013427734375, + "pressure": 2601, + "timestamp": 1774179085610 + }, + { + "x": 160.28277587890625, + "y": 248.0963134765625, + "pressure": 2600, + "timestamp": 1774179085611 + }, + { + "x": 160.08465576171875, + "y": 249.08685302734375, + "pressure": 2602, + "timestamp": 1774179085614 + }, + { + "x": 159.88653564453125, + "y": 250.07733154296875, + "pressure": 2601, + "timestamp": 1774179085615 + }, + { + "x": 159.49029541015625, + "y": 250.8697509765625, + "pressure": 2600, + "timestamp": 1774179085616 + }, + { + "x": 159.29217529296875, + "y": 251.8602294921875, + "pressure": 2602, + "timestamp": 1774179085621 + }, + { + "x": 158.89593505859375, + "y": 252.85076904296875, + "pressure": 2601, + "timestamp": 1774179085623 + }, + { + "x": 158.4996337890625, + "y": 254.23748779296875, + "pressure": 2600, + "timestamp": 1774179085625 + }, + { + "x": 158.1033935546875, + "y": 255.42608642578125, + "pressure": 2602, + "timestamp": 1774179085626 + }, + { + "x": 157.7071533203125, + "y": 256.61468505859375, + "pressure": 2601, + "timestamp": 1774179085628 + }, + { + "x": 157.3109130859375, + "y": 257.80328369140625, + "pressure": 2600, + "timestamp": 1774179085629 + }, + { + "x": 156.9146728515625, + "y": 258.99188232421875, + "pressure": 2602, + "timestamp": 1774179085631 + }, + { + "x": 156.3203125, + "y": 260.18048095703125, + "pressure": 2601, + "timestamp": 1774179085633 + }, + { + "x": 155.72589111328125, + "y": 261.36907958984375, + "pressure": 2600, + "timestamp": 1774179085637 + }, + { + "x": 155.13153076171875, + "y": 262.55767822265625, + "pressure": 2602, + "timestamp": 1774179085639 + }, + { + "x": 154.53717041015625, + "y": 263.94439697265625, + "pressure": 2603, + "timestamp": 1774179085640 + }, + { + "x": 153.94281005859375, + "y": 265.33111572265625, + "pressure": 2605, + "timestamp": 1774179085642 + }, + { + "x": 153.34844970703125, + "y": 266.9158935546875, + "pressure": 2604, + "timestamp": 1774179085643 + }, + { + "x": 152.555908203125, + "y": 268.3026123046875, + "pressure": 2606, + "timestamp": 1774179085645 + }, + { + "x": 151.763427734375, + "y": 269.6893310546875, + "pressure": 2605, + "timestamp": 1774179085647 + }, + { + "x": 150.970947265625, + "y": 271.0760498046875, + "pressure": 2604, + "timestamp": 1774179085649 + }, + { + "x": 150.17840576171875, + "y": 272.46270751953125, + "pressure": 2606, + "timestamp": 1774179085653 + }, + { + "x": 149.18780517578125, + "y": 273.84942626953125, + "pressure": 2604, + "timestamp": 1774179085654 + }, + { + "x": 148.39532470703125, + "y": 275.23614501953125, + "pressure": 2603, + "timestamp": 1774179085656 + }, + { + "x": 147.4046630859375, + "y": 276.42474365234375, + "pressure": 2602, + "timestamp": 1774179085658 + }, + { + "x": 146.6121826171875, + "y": 277.61334228515625, + "pressure": 2604, + "timestamp": 1774179085659 + }, + { + "x": 145.8197021484375, + "y": 278.80194091796875, + "pressure": 2603, + "timestamp": 1774179085661 + }, + { + "x": 145.0272216796875, + "y": 279.99053955078125, + "pressure": 2602, + "timestamp": 1774179085663 + }, + { + "x": 144.23468017578125, + "y": 281.17913818359375, + "pressure": 2604, + "timestamp": 1774179085665 + }, + { + "x": 143.44219970703125, + "y": 282.36773681640625, + "pressure": 2603, + "timestamp": 1774179085669 + }, + { + "x": 142.64971923828125, + "y": 283.3582763671875, + "pressure": 2600, + "timestamp": 1774179085670 + }, + { + "x": 141.857177734375, + "y": 284.3487548828125, + "pressure": 2599, + "timestamp": 1774179085672 + }, + { + "x": 141.2628173828125, + "y": 285.33929443359375, + "pressure": 2598, + "timestamp": 1774179085674 + }, + { + "x": 140.66845703125, + "y": 286.32977294921875, + "pressure": 2600, + "timestamp": 1774179085676 + }, + { + "x": 140.0740966796875, + "y": 287.1221923828125, + "pressure": 2599, + "timestamp": 1774179085677 + }, + { + "x": 139.47967529296875, + "y": 287.91461181640625, + "pressure": 2598, + "timestamp": 1774179085679 + }, + { + "x": 138.88531494140625, + "y": 288.70697021484375, + "pressure": 2600, + "timestamp": 1774179085681 + }, + { + "x": 138.68719482421875, + "y": 289.10321044921875, + "pressure": 2599, + "timestamp": 1774179085685 + }, + { + "x": 138.29095458984375, + "y": 289.4993896484375, + "pressure": 2591, + "timestamp": 1774179085686 + }, + { + "x": 137.69659423828125, + "y": 290.29180908203125, + "pressure": 2587, + "timestamp": 1774179085689 + }, + { + "x": 137.49847412109375, + "y": 290.68798828125, + "pressure": 2585, + "timestamp": 1774179085690 + }, + { + "x": 137.10223388671875, + "y": 291.084228515625, + "pressure": 2583, + "timestamp": 1774179085701 + }, + { + "x": 136.3096923828125, + "y": 291.8765869140625, + "pressure": 2568, + "timestamp": 1774179085702 + }, + { + "x": 135.9134521484375, + "y": 292.07470703125, + "pressure": 2560, + "timestamp": 1774179085705 + }, + { + "x": 135.9134521484375, + "y": 292.07470703125, + "pressure": 2556, + "timestamp": 1774179085706 + }, + { + "x": 135.9134521484375, + "y": 292.07470703125, + "pressure": 2553, + "timestamp": 1774179085709 + }, + { + "x": 135.9134521484375, + "y": 292.07470703125, + "pressure": 2480, + "timestamp": 1774179085719 + }, + { + "x": 135.9134521484375, + "y": 292.07470703125, + "pressure": 2444, + "timestamp": 1774179085721 + }, + { + "x": 135.5172119140625, + "y": 292.07470703125, + "pressure": 2426, + "timestamp": 1774179085722 + }, + { + "x": 134.7247314453125, + "y": 292.07470703125, + "pressure": 2417, + "timestamp": 1774179085724 + }, + { + "x": 134.3284912109375, + "y": 291.8765869140625, + "pressure": 2412, + "timestamp": 1774179085726 + }, + { + "x": 134.3284912109375, + "y": 291.8765869140625, + "pressure": 2409, + "timestamp": 1774179085729 + }, + { + "x": 134.3284912109375, + "y": 291.8765869140625, + "pressure": 2387, + "timestamp": 1774179085735 + }, + { + "x": 134.13037109375, + "y": 291.48040771484375, + "pressure": 2377, + "timestamp": 1774179085737 + }, + { + "x": 133.93218994140625, + "y": 290.68798828125, + "pressure": 2372, + "timestamp": 1774179085738 + }, + { + "x": 133.93218994140625, + "y": 289.89556884765625, + "pressure": 2369, + "timestamp": 1774179085741 + }, + { + "x": 133.93218994140625, + "y": 289.10321044921875, + "pressure": 2368, + "timestamp": 1774179085742 + }, + { + "x": 134.13037109375, + "y": 288.310791015625, + "pressure": 2367, + "timestamp": 1774179085743 + }, + { + "x": 134.3284912109375, + "y": 287.3203125, + "pressure": 2369, + "timestamp": 1774179085745 + }, + { + "x": 134.526611328125, + "y": 286.32977294921875, + "pressure": 2368, + "timestamp": 1774179085749 + }, + { + "x": 134.9228515625, + "y": 285.33929443359375, + "pressure": 2367, + "timestamp": 1774179085751 + }, + { + "x": 135.319091796875, + "y": 284.15069580078125, + "pressure": 2369, + "timestamp": 1774179085753 + }, + { + "x": 135.9134521484375, + "y": 282.96209716796875, + "pressure": 2368, + "timestamp": 1774179085754 + }, + { + "x": 136.5078125, + "y": 281.7734375, + "pressure": 2367, + "timestamp": 1774179085756 + }, + { + "x": 137.30035400390625, + "y": 280.18865966796875, + "pressure": 2369, + "timestamp": 1774179085757 + }, + { + "x": 138.09283447265625, + "y": 278.6038818359375, + "pressure": 2368, + "timestamp": 1774179085759 + }, + { + "x": 138.88531494140625, + "y": 277.01904296875, + "pressure": 2367, + "timestamp": 1774179085761 + }, + { + "x": 140.0740966796875, + "y": 275.23614501953125, + "pressure": 2369, + "timestamp": 1774179085765 + }, + { + "x": 141.2628173828125, + "y": 273.4532470703125, + "pressure": 2376, + "timestamp": 1774179085766 + }, + { + "x": 142.45159912109375, + "y": 271.67034912109375, + "pressure": 2380, + "timestamp": 1774179085769 + }, + { + "x": 143.83843994140625, + "y": 269.6893310546875, + "pressure": 2382, + "timestamp": 1774179085770 + }, + { + "x": 145.225341796875, + "y": 267.70831298828125, + "pressure": 2383, + "timestamp": 1774179085772 + }, + { + "x": 146.810302734375, + "y": 265.727294921875, + "pressure": 2385, + "timestamp": 1774179085773 + }, + { + "x": 148.39532470703125, + "y": 263.74627685546875, + "pressure": 2384, + "timestamp": 1774179085775 + }, + { + "x": 149.98028564453125, + "y": 261.76531982421875, + "pressure": 2386, + "timestamp": 1774179085777 + }, + { + "x": 151.5653076171875, + "y": 259.7843017578125, + "pressure": 2385, + "timestamp": 1774179085781 + }, + { + "x": 153.1502685546875, + "y": 258.00140380859375, + "pressure": 2390, + "timestamp": 1774179085783 + }, + { + "x": 154.73529052734375, + "y": 256.21844482421875, + "pressure": 2393, + "timestamp": 1774179085785 + }, + { + "x": 156.3203125, + "y": 254.435546875, + "pressure": 2394, + "timestamp": 1774179085786 + }, + { + "x": 157.9052734375, + "y": 252.65264892578125, + "pressure": 2395, + "timestamp": 1774179085788 + }, + { + "x": 159.68841552734375, + "y": 250.8697509765625, + "pressure": 2397, + "timestamp": 1774179085790 + }, + { + "x": 161.07525634765625, + "y": 249.4830322265625, + "pressure": 2396, + "timestamp": 1774179085791 + }, + { + "x": 162.462158203125, + "y": 248.0963134765625, + "pressure": 2398, + "timestamp": 1774179085793 + }, + { + "x": 163.8489990234375, + "y": 247.1058349609375, + "pressure": 2397, + "timestamp": 1774179085797 + }, + { + "x": 165.03778076171875, + "y": 246.1153564453125, + "pressure": 2407, + "timestamp": 1774179085799 + }, + { + "x": 166.02838134765625, + "y": 245.32293701171875, + "pressure": 2412, + "timestamp": 1774179085801 + }, + { + "x": 167.01904296875, + "y": 244.530517578125, + "pressure": 2415, + "timestamp": 1774179085803 + }, + { + "x": 168.0096435546875, + "y": 243.5400390625, + "pressure": 2416, + "timestamp": 1774179085804 + }, + { + "x": 169.000244140625, + "y": 242.94573974609375, + "pressure": 2417, + "timestamp": 1774179085806 + }, + { + "x": 169.99090576171875, + "y": 242.3514404296875, + "pressure": 2419, + "timestamp": 1774179085807 + }, + { + "x": 170.98150634765625, + "y": 241.75714111328125, + "pressure": 2418, + "timestamp": 1774179085809 + }, + { + "x": 171.97210693359375, + "y": 241.55902099609375, + "pressure": 2420, + "timestamp": 1774179085813 + }, + { + "x": 172.7646484375, + "y": 241.36090087890625, + "pressure": 2433, + "timestamp": 1774179085815 + }, + { + "x": 173.55712890625, + "y": 241.162841796875, + "pressure": 2439, + "timestamp": 1774179085817 + }, + { + "x": 173.953369140625, + "y": 240.9647216796875, + "pressure": 2442, + "timestamp": 1774179085818 + }, + { + "x": 174.349609375, + "y": 240.9647216796875, + "pressure": 2445, + "timestamp": 1774179085822 + }, + { + "x": 175.14215087890625, + "y": 240.7666015625, + "pressure": 2447, + "timestamp": 1774179085824 + }, + { + "x": 175.93463134765625, + "y": 240.56854248046875, + "pressure": 2446, + "timestamp": 1774179085825 + }, + { + "x": 176.72711181640625, + "y": 240.37042236328125, + "pressure": 2448, + "timestamp": 1774179085829 + }, + { + "x": 177.51959228515625, + "y": 240.37042236328125, + "pressure": 2452, + "timestamp": 1774179085831 + }, + { + "x": 177.9158935546875, + "y": 240.37042236328125, + "pressure": 2454, + "timestamp": 1774179085833 + }, + { + "x": 177.9158935546875, + "y": 240.37042236328125, + "pressure": 2457, + "timestamp": 1774179085836 + }, + { + "x": 178.3121337890625, + "y": 240.56854248046875, + "pressure": 2456, + "timestamp": 1774179085838 + }, + { + "x": 179.1046142578125, + "y": 240.7666015625, + "pressure": 2458, + "timestamp": 1774179085840 + }, + { + "x": 179.5008544921875, + "y": 240.7666015625, + "pressure": 2457, + "timestamp": 1774179085841 + }, + { + "x": 179.8970947265625, + "y": 240.9647216796875, + "pressure": 2458, + "timestamp": 1774179085849 + }, + { + "x": 180.68963623046875, + "y": 241.162841796875, + "pressure": 2460, + "timestamp": 1774179085851 + }, + { + "x": 181.08587646484375, + "y": 241.36090087890625, + "pressure": 2459, + "timestamp": 1774179085852 + }, + { + "x": 181.08587646484375, + "y": 241.36090087890625, + "pressure": 2469, + "timestamp": 1774179085863 + }, + { + "x": 181.08587646484375, + "y": 241.36090087890625, + "pressure": 2473, + "timestamp": 1774179085865 + }, + { + "x": 181.08587646484375, + "y": 241.36090087890625, + "pressure": 2476, + "timestamp": 1774179085868 + }, + { + "x": 181.08587646484375, + "y": 241.36090087890625, + "pressure": 2479, + "timestamp": 1774179085871 + }, + { + "x": 181.08587646484375, + "y": 241.75714111328125, + "pressure": 2478, + "timestamp": 1774179085873 + }, + { + "x": 181.28399658203125, + "y": 242.54949951171875, + "pressure": 2480, + "timestamp": 1774179085878 + }, + { + "x": 181.48211669921875, + "y": 242.94573974609375, + "pressure": 2495, + "timestamp": 1774179085879 + }, + { + "x": 181.48211669921875, + "y": 242.94573974609375, + "pressure": 2503, + "timestamp": 1774179085881 + }, + { + "x": 181.48211669921875, + "y": 243.3419189453125, + "pressure": 2507, + "timestamp": 1774179085882 + }, + { + "x": 181.48211669921875, + "y": 244.3323974609375, + "pressure": 2509, + "timestamp": 1774179085884 + }, + { + "x": 181.28399658203125, + "y": 245.12481689453125, + "pressure": 2510, + "timestamp": 1774179085886 + }, + { + "x": 181.28399658203125, + "y": 245.52105712890625, + "pressure": 2512, + "timestamp": 1774179085888 + }, + { + "x": 181.08587646484375, + "y": 245.917236328125, + "pressure": 2513, + "timestamp": 1774179085893 + }, + { + "x": 180.491455078125, + "y": 246.70965576171875, + "pressure": 2517, + "timestamp": 1774179085895 + }, + { + "x": 180.2933349609375, + "y": 247.1058349609375, + "pressure": 2519, + "timestamp": 1774179085897 + }, + { + "x": 179.8970947265625, + "y": 247.50201416015625, + "pressure": 2521, + "timestamp": 1774179085900 + }, + { + "x": 179.302734375, + "y": 248.29443359375, + "pressure": 2523, + "timestamp": 1774179085902 + }, + { + "x": 178.51025390625, + "y": 249.08685302734375, + "pressure": 2522, + "timestamp": 1774179085904 + }, + { + "x": 177.71771240234375, + "y": 249.8792724609375, + "pressure": 2524, + "timestamp": 1774179085905 + }, + { + "x": 176.92523193359375, + "y": 250.47357177734375, + "pressure": 2523, + "timestamp": 1774179085910 + }, + { + "x": 176.13275146484375, + "y": 251.06787109375, + "pressure": 2520, + "timestamp": 1774179085911 + }, + { + "x": 175.34027099609375, + "y": 251.66217041015625, + "pressure": 2518, + "timestamp": 1774179085913 + }, + { + "x": 174.5477294921875, + "y": 252.2564697265625, + "pressure": 2517, + "timestamp": 1774179085915 + }, + { + "x": 173.7552490234375, + "y": 252.65264892578125, + "pressure": 2519, + "timestamp": 1774179085916 + }, + { + "x": 172.9627685546875, + "y": 253.04888916015625, + "pressure": 2518, + "timestamp": 1774179085918 + }, + { + "x": 172.17022705078125, + "y": 253.445068359375, + "pressure": 2517, + "timestamp": 1774179085920 + }, + { + "x": 171.17962646484375, + "y": 253.84124755859375, + "pressure": 2519, + "timestamp": 1774179085921 + }, + { + "x": 170.18902587890625, + "y": 254.23748779296875, + "pressure": 2518, + "timestamp": 1774179085925 + }, + { + "x": 169.000244140625, + "y": 254.6336669921875, + "pressure": 2508, + "timestamp": 1774179085927 + }, + { + "x": 167.8115234375, + "y": 255.02984619140625, + "pressure": 2503, + "timestamp": 1774179085929 + }, + { + "x": 166.82086181640625, + "y": 255.42608642578125, + "pressure": 2501, + "timestamp": 1774179085931 + }, + { + "x": 165.83026123046875, + "y": 255.822265625, + "pressure": 2500, + "timestamp": 1774179085932 + }, + { + "x": 164.83966064453125, + "y": 256.21844482421875, + "pressure": 2499, + "timestamp": 1774179085934 + }, + { + "x": 163.8489990234375, + "y": 256.61468505859375, + "pressure": 2501, + "timestamp": 1774179085936 + }, + { + "x": 163.0565185546875, + "y": 256.812744140625, + "pressure": 2500, + "timestamp": 1774179085938 + }, + { + "x": 162.2640380859375, + "y": 257.0108642578125, + "pressure": 2499, + "timestamp": 1774179085941 + }, + { + "x": 161.4715576171875, + "y": 257.208984375, + "pressure": 2478, + "timestamp": 1774179085944 + }, + { + "x": 161.07525634765625, + "y": 257.208984375, + "pressure": 2467, + "timestamp": 1774179085946 + }, + { + "x": 161.07525634765625, + "y": 257.208984375, + "pressure": 2462, + "timestamp": 1774179085947 + }, + { + "x": 160.67901611328125, + "y": 257.208984375, + "pressure": 2459, + "timestamp": 1774179085948 + }, + { + "x": 159.88653564453125, + "y": 257.208984375, + "pressure": 2458, + "timestamp": 1774179085950 + }, + { + "x": 159.09405517578125, + "y": 257.208984375, + "pressure": 2457, + "timestamp": 1774179085952 + }, + { + "x": 158.69781494140625, + "y": 257.208984375, + "pressure": 2459, + "timestamp": 1774179085953 + }, + { + "x": 158.69781494140625, + "y": 257.208984375, + "pressure": 2430, + "timestamp": 1774179085959 + }, + { + "x": 158.69781494140625, + "y": 257.208984375, + "pressure": 2416, + "timestamp": 1774179085961 + }, + { + "x": 158.69781494140625, + "y": 257.208984375, + "pressure": 2409, + "timestamp": 1774179085963 + }, + { + "x": 158.69781494140625, + "y": 257.208984375, + "pressure": 2405, + "timestamp": 1774179085964 + }, + { + "x": 158.69781494140625, + "y": 257.208984375, + "pressure": 2402, + "timestamp": 1774179085968 + }, + { + "x": 158.69781494140625, + "y": 257.208984375, + "pressure": 2334, + "timestamp": 1774179085975 + }, + { + "x": 158.69781494140625, + "y": 257.208984375, + "pressure": 2299, + "timestamp": 1774179085977 + }, + { + "x": 158.69781494140625, + "y": 257.208984375, + "pressure": 2282, + "timestamp": 1774179085979 + }, + { + "x": 158.69781494140625, + "y": 257.208984375, + "pressure": 2273, + "timestamp": 1774179085980 + }, + { + "x": 158.69781494140625, + "y": 257.208984375, + "pressure": 2269, + "timestamp": 1774179085982 + }, + { + "x": 158.69781494140625, + "y": 257.208984375, + "pressure": 2266, + "timestamp": 1774179085986 + }, + { + "x": 158.69781494140625, + "y": 257.208984375, + "pressure": 2269, + "timestamp": 1774179085996 + }, + { + "x": 159.09405517578125, + "y": 257.0108642578125, + "pressure": 2267, + "timestamp": 1774179086000 + }, + { + "x": 159.88653564453125, + "y": 256.41656494140625, + "pressure": 2269, + "timestamp": 1774179086002 + }, + { + "x": 160.67901611328125, + "y": 255.822265625, + "pressure": 2268, + "timestamp": 1774179086006 + }, + { + "x": 161.669677734375, + "y": 255.22796630859375, + "pressure": 2271, + "timestamp": 1774179086007 + }, + { + "x": 162.462158203125, + "y": 254.6336669921875, + "pressure": 2273, + "timestamp": 1774179086009 + }, + { + "x": 163.254638671875, + "y": 254.23748779296875, + "pressure": 2274, + "timestamp": 1774179086011 + }, + { + "x": 164.24530029296875, + "y": 253.84124755859375, + "pressure": 2276, + "timestamp": 1774179086013 + }, + { + "x": 165.23590087890625, + "y": 253.6431884765625, + "pressure": 2275, + "timestamp": 1774179086015 + }, + { + "x": 166.02838134765625, + "y": 253.445068359375, + "pressure": 2277, + "timestamp": 1774179086016 + }, + { + "x": 166.82086181640625, + "y": 253.2469482421875, + "pressure": 2276, + "timestamp": 1774179086018 + }, + { + "x": 167.6134033203125, + "y": 253.04888916015625, + "pressure": 2275, + "timestamp": 1774179086022 + }, + { + "x": 168.4058837890625, + "y": 252.85076904296875, + "pressure": 2278, + "timestamp": 1774179086023 + }, + { + "x": 169.1983642578125, + "y": 252.65264892578125, + "pressure": 2280, + "timestamp": 1774179086025 + }, + { + "x": 169.99090576171875, + "y": 252.45452880859375, + "pressure": 2281, + "timestamp": 1774179086027 + }, + { + "x": 170.78338623046875, + "y": 252.2564697265625, + "pressure": 2283, + "timestamp": 1774179086028 + }, + { + "x": 171.57586669921875, + "y": 252.058349609375, + "pressure": 2282, + "timestamp": 1774179086030 + }, + { + "x": 172.368408203125, + "y": 251.8602294921875, + "pressure": 2284, + "timestamp": 1774179086032 + }, + { + "x": 173.160888671875, + "y": 251.66217041015625, + "pressure": 2283, + "timestamp": 1774179086034 + }, + { + "x": 173.953369140625, + "y": 251.46405029296875, + "pressure": 2282, + "timestamp": 1774179086038 + }, + { + "x": 174.745849609375, + "y": 251.26593017578125, + "pressure": 2289, + "timestamp": 1774179086040 + }, + { + "x": 175.53839111328125, + "y": 251.06787109375, + "pressure": 2293, + "timestamp": 1774179086041 + }, + { + "x": 176.33087158203125, + "y": 250.8697509765625, + "pressure": 2295, + "timestamp": 1774179086043 + }, + { + "x": 177.32147216796875, + "y": 250.671630859375, + "pressure": 2296, + "timestamp": 1774179086044 + }, + { + "x": 178.3121337890625, + "y": 250.47357177734375, + "pressure": 2298, + "timestamp": 1774179086047 + }, + { + "x": 179.5008544921875, + "y": 250.27545166015625, + "pressure": 2297, + "timestamp": 1774179086048 + }, + { + "x": 180.491455078125, + "y": 250.07733154296875, + "pressure": 2299, + "timestamp": 1774179086050 + }, + { + "x": 181.48211669921875, + "y": 249.8792724609375, + "pressure": 2298, + "timestamp": 1774179086054 + }, + { + "x": 182.47271728515625, + "y": 249.68115234375, + "pressure": 2299, + "timestamp": 1774179086056 + }, + { + "x": 183.46337890625, + "y": 249.4830322265625, + "pressure": 2301, + "timestamp": 1774179086058 + }, + { + "x": 184.4539794921875, + "y": 249.28497314453125, + "pressure": 2300, + "timestamp": 1774179086059 + }, + { + "x": 185.6427001953125, + "y": 249.08685302734375, + "pressure": 2302, + "timestamp": 1774179086061 + }, + { + "x": 186.63336181640625, + "y": 248.88873291015625, + "pressure": 2301, + "timestamp": 1774179086062 + }, + { + "x": 187.62396240234375, + "y": 248.4925537109375, + "pressure": 2300, + "timestamp": 1774179086064 + }, + { + "x": 188.812744140625, + "y": 248.29443359375, + "pressure": 2302, + "timestamp": 1774179086066 + }, + { + "x": 190.00146484375, + "y": 248.0963134765625, + "pressure": 2301, + "timestamp": 1774179086070 + }, + { + "x": 191.190185546875, + "y": 247.70013427734375, + "pressure": 2296, + "timestamp": 1774179086071 + }, + { + "x": 192.37896728515625, + "y": 247.303955078125, + "pressure": 2293, + "timestamp": 1774179086074 + }, + { + "x": 193.56768798828125, + "y": 246.90771484375, + "pressure": 2292, + "timestamp": 1774179086075 + }, + { + "x": 194.7564697265625, + "y": 246.51153564453125, + "pressure": 2291, + "timestamp": 1774179086077 + }, + { + "x": 196.143310546875, + "y": 245.917236328125, + "pressure": 2293, + "timestamp": 1774179086078 + }, + { + "x": 197.33209228515625, + "y": 245.52105712890625, + "pressure": 2292, + "timestamp": 1774179086080 + }, + { + "x": 198.52081298828125, + "y": 245.12481689453125, + "pressure": 2291, + "timestamp": 1774179086082 + }, + { + "x": 199.7095947265625, + "y": 244.530517578125, + "pressure": 2293, + "timestamp": 1774179086086 + }, + { + "x": 200.8983154296875, + "y": 243.93621826171875, + "pressure": 2295, + "timestamp": 1774179086087 + }, + { + "x": 202.0870361328125, + "y": 243.3419189453125, + "pressure": 2294, + "timestamp": 1774179086090 + }, + { + "x": 203.07769775390625, + "y": 242.74761962890625, + "pressure": 2293, + "timestamp": 1774179086091 + }, + { + "x": 204.06829833984375, + "y": 242.1533203125, + "pressure": 2295, + "timestamp": 1774179086093 + }, + { + "x": 205.05889892578125, + "y": 241.55902099609375, + "pressure": 2294, + "timestamp": 1774179086094 + }, + { + "x": 206.049560546875, + "y": 241.162841796875, + "pressure": 2293, + "timestamp": 1774179086096 + }, + { + "x": 207.0401611328125, + "y": 240.7666015625, + "pressure": 2295, + "timestamp": 1774179086098 + }, + { + "x": 207.8326416015625, + "y": 240.37042236328125, + "pressure": 2294, + "timestamp": 1774179086102 + }, + { + "x": 208.62518310546875, + "y": 239.97418212890625, + "pressure": 2293, + "timestamp": 1774179086103 + }, + { + "x": 209.61578369140625, + "y": 239.3798828125, + "pressure": 2295, + "timestamp": 1774179086106 + }, + { + "x": 210.6064453125, + "y": 238.98370361328125, + "pressure": 2294, + "timestamp": 1774179086107 + }, + { + "x": 211.002685546875, + "y": 238.78558349609375, + "pressure": 2293, + "timestamp": 1774179086109 + }, + { + "x": 211.39892578125, + "y": 238.5875244140625, + "pressure": 2294, + "timestamp": 1774179086112 + }, + { + "x": 212.19140625, + "y": 237.99322509765625, + "pressure": 2293, + "timestamp": 1774179086114 + }, + { + "x": 212.98388671875, + "y": 237.59698486328125, + "pressure": 2295, + "timestamp": 1774179086118 + }, + { + "x": 213.38018798828125, + "y": 237.39892578125, + "pressure": 2294, + "timestamp": 1774179086119 + }, + { + "x": 213.38018798828125, + "y": 237.39892578125, + "pressure": 2300, + "timestamp": 1774179086135 + }, + { + "x": 213.38018798828125, + "y": 237.39892578125, + "pressure": 2303, + "timestamp": 1774179086139 + }, + { + "x": 213.57830810546875, + "y": 237.002685546875, + "pressure": 2304, + "timestamp": 1774179086141 + }, + { + "x": 214.17266845703125, + "y": 236.21026611328125, + "pressure": 2306, + "timestamp": 1774179086143 + }, + { + "x": 214.56890869140625, + "y": 235.8140869140625, + "pressure": 2305, + "timestamp": 1774179086144 + }, + { + "x": 214.56890869140625, + "y": 235.8140869140625, + "pressure": 2365, + "timestamp": 1774179086152 + }, + { + "x": 214.56890869140625, + "y": 235.8140869140625, + "pressure": 2395, + "timestamp": 1774179086154 + }, + { + "x": 214.56890869140625, + "y": 235.8140869140625, + "pressure": 2410, + "timestamp": 1774179086155 + }, + { + "x": 214.56890869140625, + "y": 235.8140869140625, + "pressure": 2417, + "timestamp": 1774179086157 + }, + { + "x": 214.56890869140625, + "y": 235.8140869140625, + "pressure": 2421, + "timestamp": 1774179086159 + }, + { + "x": 214.56890869140625, + "y": 235.8140869140625, + "pressure": 2424, + "timestamp": 1774179086162 + }, + { + "x": 214.56890869140625, + "y": 235.8140869140625, + "pressure": 2440, + "timestamp": 1774179086168 + }, + { + "x": 214.56890869140625, + "y": 235.8140869140625, + "pressure": 2447, + "timestamp": 1774179086170 + }, + { + "x": 214.56890869140625, + "y": 235.8140869140625, + "pressure": 2451, + "timestamp": 1774179086171 + }, + { + "x": 214.17266845703125, + "y": 235.615966796875, + "pressure": 2453, + "timestamp": 1774179086173 + }, + { + "x": 213.38018798828125, + "y": 235.21978759765625, + "pressure": 2454, + "timestamp": 1774179086175 + }, + { + "x": 212.587646484375, + "y": 234.8236083984375, + "pressure": 2456, + "timestamp": 1774179086177 + }, + { + "x": 211.795166015625, + "y": 234.62548828125, + "pressure": 2455, + "timestamp": 1774179086178 + }, + { + "x": 211.002685546875, + "y": 234.4273681640625, + "pressure": 2457, + "timestamp": 1774179086182 + }, + { + "x": 210.21014404296875, + "y": 234.4273681640625, + "pressure": 2452, + "timestamp": 1774179086184 + }, + { + "x": 209.41766357421875, + "y": 234.4273681640625, + "pressure": 2450, + "timestamp": 1774179086186 + }, + { + "x": 208.62518310546875, + "y": 234.62548828125, + "pressure": 2449, + "timestamp": 1774179086188 + }, + { + "x": 207.8326416015625, + "y": 234.8236083984375, + "pressure": 2448, + "timestamp": 1774179086189 + }, + { + "x": 207.0401611328125, + "y": 235.02166748046875, + "pressure": 2450, + "timestamp": 1774179086191 + }, + { + "x": 206.2476806640625, + "y": 235.41790771484375, + "pressure": 2449, + "timestamp": 1774179086193 + }, + { + "x": 205.257080078125, + "y": 235.8140869140625, + "pressure": 2448, + "timestamp": 1774179086195 + }, + { + "x": 204.26641845703125, + "y": 236.21026611328125, + "pressure": 2450, + "timestamp": 1774179086198 + }, + { + "x": 203.27581787109375, + "y": 236.60650634765625, + "pressure": 2440, + "timestamp": 1774179086200 + }, + { + "x": 202.28515625, + "y": 237.2008056640625, + "pressure": 2435, + "timestamp": 1774179086202 + }, + { + "x": 201.2945556640625, + "y": 237.79510498046875, + "pressure": 2433, + "timestamp": 1774179086203 + }, + { + "x": 200.303955078125, + "y": 238.389404296875, + "pressure": 2432, + "timestamp": 1774179086205 + }, + { + "x": 199.31329345703125, + "y": 238.98370361328125, + "pressure": 2431, + "timestamp": 1774179086207 + }, + { + "x": 198.52081298828125, + "y": 239.5780029296875, + "pressure": 2433, + "timestamp": 1774179086208 + }, + { + "x": 197.72833251953125, + "y": 240.37042236328125, + "pressure": 2432, + "timestamp": 1774179086210 + }, + { + "x": 196.93585205078125, + "y": 241.162841796875, + "pressure": 2431, + "timestamp": 1774179086214 + }, + { + "x": 196.143310546875, + "y": 241.9552001953125, + "pressure": 2428, + "timestamp": 1774179086216 + }, + { + "x": 195.7470703125, + "y": 242.1533203125, + "pressure": 2426, + "timestamp": 1774179086219 + }, + { + "x": 195.350830078125, + "y": 242.54949951171875, + "pressure": 2425, + "timestamp": 1774179086219 + }, + { + "x": 194.7564697265625, + "y": 243.5400390625, + "pressure": 2427, + "timestamp": 1774179086221 + }, + { + "x": 193.96392822265625, + "y": 244.3323974609375, + "pressure": 2426, + "timestamp": 1774179086223 + }, + { + "x": 193.36956787109375, + "y": 245.12481689453125, + "pressure": 2425, + "timestamp": 1774179086224 + }, + { + "x": 192.77520751953125, + "y": 245.917236328125, + "pressure": 2427, + "timestamp": 1774179086226 + }, + { + "x": 192.18084716796875, + "y": 246.70965576171875, + "pressure": 2426, + "timestamp": 1774179086230 + }, + { + "x": 191.98272705078125, + "y": 247.1058349609375, + "pressure": 2417, + "timestamp": 1774179086232 + }, + { + "x": 191.78460693359375, + "y": 247.50201416015625, + "pressure": 2413, + "timestamp": 1774179086235 + }, + { + "x": 191.190185546875, + "y": 248.4925537109375, + "pressure": 2411, + "timestamp": 1774179086236 + }, + { + "x": 190.7939453125, + "y": 249.28497314453125, + "pressure": 2410, + "timestamp": 1774179086237 + }, + { + "x": 190.5958251953125, + "y": 249.68115234375, + "pressure": 2409, + "timestamp": 1774179086239 + }, + { + "x": 190.397705078125, + "y": 250.07733154296875, + "pressure": 2410, + "timestamp": 1774179086242 + }, + { + "x": 190.1995849609375, + "y": 250.8697509765625, + "pressure": 2409, + "timestamp": 1774179086246 + }, + { + "x": 190.1995849609375, + "y": 251.66217041015625, + "pressure": 2387, + "timestamp": 1774179086248 + }, + { + "x": 190.1995849609375, + "y": 252.058349609375, + "pressure": 2376, + "timestamp": 1774179086250 + }, + { + "x": 190.1995849609375, + "y": 252.058349609375, + "pressure": 2371, + "timestamp": 1774179086252 + }, + { + "x": 190.1995849609375, + "y": 252.058349609375, + "pressure": 2368, + "timestamp": 1774179086253 + }, + { + "x": 190.1995849609375, + "y": 252.45452880859375, + "pressure": 2367, + "timestamp": 1774179086255 + }, + { + "x": 190.397705078125, + "y": 253.2469482421875, + "pressure": 2366, + "timestamp": 1774179086257 + }, + { + "x": 190.7939453125, + "y": 254.03936767578125, + "pressure": 2368, + "timestamp": 1774179086258 + }, + { + "x": 190.9920654296875, + "y": 254.435546875, + "pressure": 2367, + "timestamp": 1774179086263 + }, + { + "x": 190.9920654296875, + "y": 254.435546875, + "pressure": 2342, + "timestamp": 1774179086264 + }, + { + "x": 190.9920654296875, + "y": 254.435546875, + "pressure": 2329, + "timestamp": 1774179086266 + }, + { + "x": 191.3883056640625, + "y": 254.831787109375, + "pressure": 2323, + "timestamp": 1774179086268 + }, + { + "x": 192.18084716796875, + "y": 255.42608642578125, + "pressure": 2320, + "timestamp": 1774179086269 + }, + { + "x": 192.97332763671875, + "y": 255.822265625, + "pressure": 2318, + "timestamp": 1774179086271 + }, + { + "x": 193.76580810546875, + "y": 256.21844482421875, + "pressure": 2317, + "timestamp": 1774179086273 + }, + { + "x": 194.558349609375, + "y": 256.61468505859375, + "pressure": 2319, + "timestamp": 1774179086274 + }, + { + "x": 195.5489501953125, + "y": 257.0108642578125, + "pressure": 2318, + "timestamp": 1774179086279 + }, + { + "x": 196.53955078125, + "y": 257.4071044921875, + "pressure": 2310, + "timestamp": 1774179086280 + }, + { + "x": 197.53021240234375, + "y": 257.60516357421875, + "pressure": 2306, + "timestamp": 1774179086283 + }, + { + "x": 198.52081298828125, + "y": 257.60516357421875, + "pressure": 2304, + "timestamp": 1774179086284 + }, + { + "x": 199.51141357421875, + "y": 257.60516357421875, + "pressure": 2303, + "timestamp": 1774179086285 + }, + { + "x": 200.5020751953125, + "y": 257.60516357421875, + "pressure": 2302, + "timestamp": 1774179086287 + }, + { + "x": 201.49267578125, + "y": 257.60516357421875, + "pressure": 2304, + "timestamp": 1774179086289 + }, + { + "x": 202.48333740234375, + "y": 257.60516357421875, + "pressure": 2303, + "timestamp": 1774179086290 + }, + { + "x": 203.47393798828125, + "y": 257.4071044921875, + "pressure": 2302, + "timestamp": 1774179086294 + }, + { + "x": 204.46453857421875, + "y": 257.208984375, + "pressure": 2297, + "timestamp": 1774179086296 + }, + { + "x": 205.4552001953125, + "y": 257.0108642578125, + "pressure": 2294, + "timestamp": 1774179086298 + }, + { + "x": 206.6439208984375, + "y": 256.812744140625, + "pressure": 2293, + "timestamp": 1774179086300 + }, + { + "x": 207.8326416015625, + "y": 256.61468505859375, + "pressure": 2292, + "timestamp": 1774179086301 + }, + { + "x": 208.82330322265625, + "y": 256.41656494140625, + "pressure": 2294, + "timestamp": 1774179086303 + }, + { + "x": 209.81390380859375, + "y": 256.0203857421875, + "pressure": 2293, + "timestamp": 1774179086305 + }, + { + "x": 210.8045654296875, + "y": 255.6241455078125, + "pressure": 2292, + "timestamp": 1774179086306 + }, + { + "x": 211.795166015625, + "y": 255.22796630859375, + "pressure": 2294, + "timestamp": 1774179086310 + }, + { + "x": 212.7857666015625, + "y": 254.831787109375, + "pressure": 2287, + "timestamp": 1774179086312 + }, + { + "x": 213.77642822265625, + "y": 254.435546875, + "pressure": 2283, + "timestamp": 1774179086314 + }, + { + "x": 214.56890869140625, + "y": 254.03936767578125, + "pressure": 2281, + "timestamp": 1774179086316 + }, + { + "x": 215.36138916015625, + "y": 253.6431884765625, + "pressure": 2280, + "timestamp": 1774179086317 + }, + { + "x": 216.1539306640625, + "y": 253.04888916015625, + "pressure": 2282, + "timestamp": 1774179086319 + }, + { + "x": 216.9464111328125, + "y": 252.65264892578125, + "pressure": 2281, + "timestamp": 1774179086321 + }, + { + "x": 217.7388916015625, + "y": 252.2564697265625, + "pressure": 2280, + "timestamp": 1774179086322 + }, + { + "x": 218.5313720703125, + "y": 251.66217041015625, + "pressure": 2282, + "timestamp": 1774179086326 + }, + { + "x": 219.32391357421875, + "y": 251.06787109375, + "pressure": 2280, + "timestamp": 1774179086328 + }, + { + "x": 220.11639404296875, + "y": 250.671630859375, + "pressure": 2279, + "timestamp": 1774179086330 + }, + { + "x": 220.90887451171875, + "y": 250.27545166015625, + "pressure": 2281, + "timestamp": 1774179086332 + }, + { + "x": 221.701416015625, + "y": 249.8792724609375, + "pressure": 2280, + "timestamp": 1774179086333 + }, + { + "x": 222.493896484375, + "y": 249.4830322265625, + "pressure": 2279, + "timestamp": 1774179086335 + }, + { + "x": 223.286376953125, + "y": 249.08685302734375, + "pressure": 2281, + "timestamp": 1774179086337 + }, + { + "x": 224.078857421875, + "y": 248.69061279296875, + "pressure": 2280, + "timestamp": 1774179086339 + }, + { + "x": 224.47515869140625, + "y": 248.4925537109375, + "pressure": 2279, + "timestamp": 1774179086343 + }, + { + "x": 224.87139892578125, + "y": 248.29443359375, + "pressure": 2279, + "timestamp": 1774179086348 + }, + { + "x": 225.66387939453125, + "y": 247.70013427734375, + "pressure": 2281, + "timestamp": 1774179086349 + }, + { + "x": 226.06011962890625, + "y": 247.50201416015625, + "pressure": 2280, + "timestamp": 1774179086351 + }, + { + "x": 226.45635986328125, + "y": 247.303955078125, + "pressure": 2283, + "timestamp": 1774179086363 + }, + { + "x": 227.2489013671875, + "y": 246.70965576171875, + "pressure": 2285, + "timestamp": 1774179086364 + }, + { + "x": 227.6451416015625, + "y": 246.31341552734375, + "pressure": 2284, + "timestamp": 1774179086366 + }, + { + "x": 227.6451416015625, + "y": 246.31341552734375, + "pressure": 2291, + "timestamp": 1774179086376 + }, + { + "x": 227.6451416015625, + "y": 246.31341552734375, + "pressure": 2294, + "timestamp": 1774179086379 + }, + { + "x": 228.0413818359375, + "y": 246.1153564453125, + "pressure": 2298, + "timestamp": 1774179086383 + }, + { + "x": 228.8338623046875, + "y": 245.52105712890625, + "pressure": 2297, + "timestamp": 1774179086385 + }, + { + "x": 229.2301025390625, + "y": 245.32293701171875, + "pressure": 2299, + "timestamp": 1774179086387 + }, + { + "x": 229.2301025390625, + "y": 245.32293701171875, + "pressure": 2302, + "timestamp": 1774179086398 + }, + { + "x": 229.2301025390625, + "y": 245.32293701171875, + "pressure": 2317, + "timestamp": 1774179086424 + }, + { + "x": 229.2301025390625, + "y": 245.32293701171875, + "pressure": 2325, + "timestamp": 1774179086427 + }, + { + "x": 229.2301025390625, + "y": 245.32293701171875, + "pressure": 2329, + "timestamp": 1774179086428 + }, + { + "x": 229.2301025390625, + "y": 245.32293701171875, + "pressure": 2332, + "timestamp": 1774179086431 + }, + { + "x": 229.2301025390625, + "y": 245.32293701171875, + "pressure": 2335, + "timestamp": 1774179086439 + }, + { + "x": 229.2301025390625, + "y": 245.32293701171875, + "pressure": 2363, + "timestamp": 1774179086440 + }, + { + "x": 229.2301025390625, + "y": 245.32293701171875, + "pressure": 2377, + "timestamp": 1774179086443 + }, + { + "x": 229.2301025390625, + "y": 245.32293701171875, + "pressure": 2384, + "timestamp": 1774179086444 + }, + { + "x": 229.2301025390625, + "y": 245.32293701171875, + "pressure": 2388, + "timestamp": 1774179086446 + }, + { + "x": 229.2301025390625, + "y": 245.32293701171875, + "pressure": 2391, + "timestamp": 1774179086449 + }, + { + "x": 229.2301025390625, + "y": 245.32293701171875, + "pressure": 2402, + "timestamp": 1774179086456 + }, + { + "x": 229.2301025390625, + "y": 245.32293701171875, + "pressure": 2407, + "timestamp": 1774179086459 + }, + { + "x": 229.2301025390625, + "y": 245.32293701171875, + "pressure": 2410, + "timestamp": 1774179086460 + }, + { + "x": 229.2301025390625, + "y": 245.32293701171875, + "pressure": 2414, + "timestamp": 1774179086465 + }, + { + "x": 229.2301025390625, + "y": 245.32293701171875, + "pressure": 2411, + "timestamp": 1774179086476 + }, + { + "x": 229.2301025390625, + "y": 245.32293701171875, + "pressure": 2427, + "timestamp": 1774179086488 + }, + { + "x": 229.2301025390625, + "y": 245.32293701171875, + "pressure": 2434, + "timestamp": 1774179086491 + }, + { + "x": 229.2301025390625, + "y": 245.32293701171875, + "pressure": 2438, + "timestamp": 1774179086492 + }, + { + "x": 229.2301025390625, + "y": 245.32293701171875, + "pressure": 2441, + "timestamp": 1774179086496 + }, + { + "x": 229.2301025390625, + "y": 245.32293701171875, + "pressure": 2444, + "timestamp": 1774179086503 + }, + { + "x": 229.2301025390625, + "y": 245.32293701171875, + "pressure": 2476, + "timestamp": 1774179086505 + }, + { + "x": 229.2301025390625, + "y": 245.32293701171875, + "pressure": 2492, + "timestamp": 1774179086507 + }, + { + "x": 229.2301025390625, + "y": 245.32293701171875, + "pressure": 2500, + "timestamp": 1774179086508 + }, + { + "x": 229.2301025390625, + "y": 245.32293701171875, + "pressure": 2504, + "timestamp": 1774179086510 + }, + { + "x": 229.2301025390625, + "y": 245.32293701171875, + "pressure": 2507, + "timestamp": 1774179086513 + }, + { + "x": 229.2301025390625, + "y": 245.32293701171875, + "pressure": 2510, + "timestamp": 1774179086521 + }, + { + "x": 228.8338623046875, + "y": 245.7191162109375, + "pressure": 2508, + "timestamp": 1774179086529 + }, + { + "x": 227.84326171875, + "y": 246.31341552734375, + "pressure": 2510, + "timestamp": 1774179086531 + }, + { + "x": 226.85260009765625, + "y": 247.303955078125, + "pressure": 2509, + "timestamp": 1774179086535 + }, + { + "x": 225.66387939453125, + "y": 248.0963134765625, + "pressure": 2502, + "timestamp": 1774179086537 + }, + { + "x": 224.67327880859375, + "y": 248.88873291015625, + "pressure": 2499, + "timestamp": 1774179086539 + }, + { + "x": 223.8807373046875, + "y": 249.68115234375, + "pressure": 2497, + "timestamp": 1774179086540 + }, + { + "x": 223.0882568359375, + "y": 250.47357177734375, + "pressure": 2496, + "timestamp": 1774179086542 + }, + { + "x": 222.2957763671875, + "y": 251.26593017578125, + "pressure": 2498, + "timestamp": 1774179086544 + }, + { + "x": 221.50323486328125, + "y": 252.058349609375, + "pressure": 2497, + "timestamp": 1774179086545 + }, + { + "x": 220.90887451171875, + "y": 252.85076904296875, + "pressure": 2496, + "timestamp": 1774179086547 + }, + { + "x": 220.31451416015625, + "y": 253.6431884765625, + "pressure": 2498, + "timestamp": 1774179086551 + }, + { + "x": 220.11639404296875, + "y": 254.03936767578125, + "pressure": 2489, + "timestamp": 1774179086553 + }, + { + "x": 220.11639404296875, + "y": 254.03936767578125, + "pressure": 2484, + "timestamp": 1774179086555 + }, + { + "x": 219.72015380859375, + "y": 254.6336669921875, + "pressure": 2482, + "timestamp": 1774179086557 + }, + { + "x": 219.12579345703125, + "y": 255.6241455078125, + "pressure": 2481, + "timestamp": 1774179086558 + }, + { + "x": 218.7294921875, + "y": 256.812744140625, + "pressure": 2480, + "timestamp": 1774179086560 + }, + { + "x": 218.333251953125, + "y": 257.60516357421875, + "pressure": 2482, + "timestamp": 1774179086561 + }, + { + "x": 218.1351318359375, + "y": 258.00140380859375, + "pressure": 2481, + "timestamp": 1774179086563 + }, + { + "x": 218.1351318359375, + "y": 258.00140380859375, + "pressure": 2458, + "timestamp": 1774179086569 + }, + { + "x": 218.1351318359375, + "y": 258.00140380859375, + "pressure": 2447, + "timestamp": 1774179086571 + }, + { + "x": 218.1351318359375, + "y": 258.00140380859375, + "pressure": 2442, + "timestamp": 1774179086572 + }, + { + "x": 218.1351318359375, + "y": 258.00140380859375, + "pressure": 2439, + "timestamp": 1774179086574 + }, + { + "x": 218.1351318359375, + "y": 258.3975830078125, + "pressure": 2439, + "timestamp": 1774179086579 + }, + { + "x": 218.333251953125, + "y": 259.19000244140625, + "pressure": 2438, + "timestamp": 1774179086584 + }, + { + "x": 218.5313720703125, + "y": 259.586181640625, + "pressure": 2401, + "timestamp": 1774179086585 + }, + { + "x": 218.5313720703125, + "y": 259.586181640625, + "pressure": 2383, + "timestamp": 1774179086587 + }, + { + "x": 218.5313720703125, + "y": 259.586181640625, + "pressure": 2374, + "timestamp": 1774179086588 + }, + { + "x": 218.5313720703125, + "y": 259.586181640625, + "pressure": 2369, + "timestamp": 1774179086590 + }, + { + "x": 218.92767333984375, + "y": 259.7843017578125, + "pressure": 2366, + "timestamp": 1774179086593 + }, + { + "x": 219.72015380859375, + "y": 260.18048095703125, + "pressure": 2365, + "timestamp": 1774179086595 + }, + { + "x": 220.51263427734375, + "y": 260.57666015625, + "pressure": 2367, + "timestamp": 1774179086599 + }, + { + "x": 221.30511474609375, + "y": 260.972900390625, + "pressure": 2354, + "timestamp": 1774179086601 + }, + { + "x": 222.09765625, + "y": 261.17095947265625, + "pressure": 2348, + "timestamp": 1774179086603 + }, + { + "x": 222.493896484375, + "y": 261.17095947265625, + "pressure": 2345, + "timestamp": 1774179086604 + }, + { + "x": 222.89013671875, + "y": 261.17095947265625, + "pressure": 2342, + "timestamp": 1774179086608 + }, + { + "x": 223.6826171875, + "y": 261.17095947265625, + "pressure": 2344, + "timestamp": 1774179086609 + }, + { + "x": 224.67327880859375, + "y": 260.972900390625, + "pressure": 2343, + "timestamp": 1774179086611 + }, + { + "x": 225.66387939453125, + "y": 260.7747802734375, + "pressure": 2342, + "timestamp": 1774179086615 + }, + { + "x": 226.45635986328125, + "y": 260.57666015625, + "pressure": 2341, + "timestamp": 1774179086617 + }, + { + "x": 227.2489013671875, + "y": 260.18048095703125, + "pressure": 2343, + "timestamp": 1774179086619 + }, + { + "x": 228.0413818359375, + "y": 259.7843017578125, + "pressure": 2342, + "timestamp": 1774179086621 + }, + { + "x": 228.8338623046875, + "y": 259.3880615234375, + "pressure": 2341, + "timestamp": 1774179086622 + }, + { + "x": 229.6263427734375, + "y": 258.99188232421875, + "pressure": 2343, + "timestamp": 1774179086624 + }, + { + "x": 230.41888427734375, + "y": 258.595703125, + "pressure": 2342, + "timestamp": 1774179086626 + }, + { + "x": 231.40948486328125, + "y": 258.00140380859375, + "pressure": 2341, + "timestamp": 1774179086627 + }, + { + "x": 232.20196533203125, + "y": 257.60516357421875, + "pressure": 2343, + "timestamp": 1774179086631 + }, + { + "x": 232.9945068359375, + "y": 257.208984375, + "pressure": 2346, + "timestamp": 1774179086633 + }, + { + "x": 233.7869873046875, + "y": 256.61468505859375, + "pressure": 2347, + "timestamp": 1774179086635 + }, + { + "x": 234.5794677734375, + "y": 256.0203857421875, + "pressure": 2348, + "timestamp": 1774179086637 + }, + { + "x": 235.37200927734375, + "y": 255.42608642578125, + "pressure": 2350, + "timestamp": 1774179086638 + }, + { + "x": 236.16448974609375, + "y": 254.831787109375, + "pressure": 2349, + "timestamp": 1774179086640 + }, + { + "x": 236.95697021484375, + "y": 254.23748779296875, + "pressure": 2351, + "timestamp": 1774179086642 + }, + { + "x": 237.74945068359375, + "y": 253.6431884765625, + "pressure": 2350, + "timestamp": 1774179086643 + }, + { + "x": 238.145751953125, + "y": 253.2469482421875, + "pressure": 2349, + "timestamp": 1774179086648 + }, + { + "x": 238.3438720703125, + "y": 252.85076904296875, + "pressure": 2350, + "timestamp": 1774179086651 + }, + { + "x": 238.938232421875, + "y": 252.058349609375, + "pressure": 2349, + "timestamp": 1774179086653 + }, + { + "x": 239.5325927734375, + "y": 251.26593017578125, + "pressure": 2351, + "timestamp": 1774179086654 + }, + { + "x": 240.126953125, + "y": 250.47357177734375, + "pressure": 2350, + "timestamp": 1774179086656 + }, + { + "x": 240.72137451171875, + "y": 249.68115234375, + "pressure": 2349, + "timestamp": 1774179086658 + }, + { + "x": 241.31573486328125, + "y": 248.88873291015625, + "pressure": 2351, + "timestamp": 1774179086660 + }, + { + "x": 241.91009521484375, + "y": 248.0963134765625, + "pressure": 2350, + "timestamp": 1774179086664 + }, + { + "x": 242.50445556640625, + "y": 247.303955078125, + "pressure": 2355, + "timestamp": 1774179086665 + }, + { + "x": 243.09881591796875, + "y": 246.51153564453125, + "pressure": 2357, + "timestamp": 1774179086668 + }, + { + "x": 243.6932373046875, + "y": 245.7191162109375, + "pressure": 2358, + "timestamp": 1774179086669 + }, + { + "x": 244.0894775390625, + "y": 244.9267578125, + "pressure": 2359, + "timestamp": 1774179086671 + }, + { + "x": 244.4857177734375, + "y": 243.93621826171875, + "pressure": 2361, + "timestamp": 1774179086672 + }, + { + "x": 245.080078125, + "y": 242.94573974609375, + "pressure": 2360, + "timestamp": 1774179086674 + }, + { + "x": 245.476318359375, + "y": 241.9552001953125, + "pressure": 2362, + "timestamp": 1774179086675 + }, + { + "x": 245.87255859375, + "y": 240.9647216796875, + "pressure": 2361, + "timestamp": 1774179086679 + }, + { + "x": 246.26885986328125, + "y": 239.97418212890625, + "pressure": 2367, + "timestamp": 1774179086681 + }, + { + "x": 246.66510009765625, + "y": 238.78558349609375, + "pressure": 2370, + "timestamp": 1774179086683 + }, + { + "x": 247.25946044921875, + "y": 237.59698486328125, + "pressure": 2372, + "timestamp": 1774179086685 + }, + { + "x": 247.65570068359375, + "y": 236.40838623046875, + "pressure": 2373, + "timestamp": 1774179086686 + }, + { + "x": 248.05194091796875, + "y": 235.21978759765625, + "pressure": 2375, + "timestamp": 1774179086688 + }, + { + "x": 248.44818115234375, + "y": 234.03118896484375, + "pressure": 2374, + "timestamp": 1774179086690 + }, + { + "x": 248.844482421875, + "y": 232.64447021484375, + "pressure": 2376, + "timestamp": 1774179086691 + }, + { + "x": 249.0426025390625, + "y": 231.25775146484375, + "pressure": 2375, + "timestamp": 1774179086695 + }, + { + "x": 249.24072265625, + "y": 230.06915283203125, + "pressure": 2383, + "timestamp": 1774179086697 + }, + { + "x": 249.4388427734375, + "y": 228.88058471679688, + "pressure": 2387, + "timestamp": 1774179086699 + }, + { + "x": 249.636962890625, + "y": 227.69195556640625, + "pressure": 2389, + "timestamp": 1774179086701 + }, + { + "x": 249.8350830078125, + "y": 226.50335693359375, + "pressure": 2390, + "timestamp": 1774179086702 + }, + { + "x": 250.033203125, + "y": 225.51284790039062, + "pressure": 2391, + "timestamp": 1774179086704 + }, + { + "x": 250.2313232421875, + "y": 224.5223388671875, + "pressure": 2393, + "timestamp": 1774179086706 + }, + { + "x": 250.429443359375, + "y": 223.5318603515625, + "pressure": 2392, + "timestamp": 1774179086707 + }, + { + "x": 250.6275634765625, + "y": 222.54135131835938, + "pressure": 2394, + "timestamp": 1774179086712 + }, + { + "x": 250.6275634765625, + "y": 221.55084228515625, + "pressure": 2412, + "timestamp": 1774179086713 + }, + { + "x": 250.6275634765625, + "y": 220.56033325195312, + "pressure": 2421, + "timestamp": 1774179086715 + }, + { + "x": 250.6275634765625, + "y": 220.16412353515625, + "pressure": 2426, + "timestamp": 1774179086717 + }, + { + "x": 250.429443359375, + "y": 219.7679443359375, + "pressure": 2429, + "timestamp": 1774179086720 + }, + { + "x": 250.2313232421875, + "y": 218.97552490234375, + "pressure": 2430, + "timestamp": 1774179086722 + }, + { + "x": 250.033203125, + "y": 218.18313598632812, + "pressure": 2432, + "timestamp": 1774179086724 + }, + { + "x": 250.033203125, + "y": 217.78692626953125, + "pressure": 2431, + "timestamp": 1774179086728 + }, + { + "x": 250.033203125, + "y": 217.78692626953125, + "pressure": 2489, + "timestamp": 1774179086729 + }, + { + "x": 250.033203125, + "y": 217.78692626953125, + "pressure": 2518, + "timestamp": 1774179086732 + }, + { + "x": 250.033203125, + "y": 217.78692626953125, + "pressure": 2533, + "timestamp": 1774179086733 + }, + { + "x": 250.033203125, + "y": 217.78692626953125, + "pressure": 2540, + "timestamp": 1774179086734 + }, + { + "x": 250.033203125, + "y": 217.78692626953125, + "pressure": 2544, + "timestamp": 1774179086736 + }, + { + "x": 250.033203125, + "y": 217.78692626953125, + "pressure": 2547, + "timestamp": 1774179086740 + }, + { + "x": 249.636962890625, + "y": 217.58883666992188, + "pressure": 2572, + "timestamp": 1774179086745 + }, + { + "x": 248.844482421875, + "y": 216.99453735351562, + "pressure": 2583, + "timestamp": 1774179086748 + }, + { + "x": 248.05194091796875, + "y": 216.79641723632812, + "pressure": 2589, + "timestamp": 1774179086749 + }, + { + "x": 247.65570068359375, + "y": 216.59832763671875, + "pressure": 2592, + "timestamp": 1774179086751 + }, + { + "x": 247.25946044921875, + "y": 216.59832763671875, + "pressure": 2594, + "timestamp": 1774179086754 + }, + { + "x": 246.46697998046875, + "y": 216.59832763671875, + "pressure": 2596, + "timestamp": 1774179086756 + }, + { + "x": 245.6744384765625, + "y": 216.79641723632812, + "pressure": 2595, + "timestamp": 1774179086760 + }, + { + "x": 244.8819580078125, + "y": 216.99453735351562, + "pressure": 2591, + "timestamp": 1774179086761 + }, + { + "x": 244.0894775390625, + "y": 217.192626953125, + "pressure": 2589, + "timestamp": 1774179086763 + }, + { + "x": 243.29693603515625, + "y": 217.39071655273438, + "pressure": 2588, + "timestamp": 1774179086765 + }, + { + "x": 242.50445556640625, + "y": 217.98501586914062, + "pressure": 2587, + "timestamp": 1774179086767 + }, + { + "x": 241.71197509765625, + "y": 218.579345703125, + "pressure": 2589, + "timestamp": 1774179086769 + }, + { + "x": 240.91949462890625, + "y": 219.17364501953125, + "pressure": 2588, + "timestamp": 1774179086770 + }, + { + "x": 240.126953125, + "y": 219.7679443359375, + "pressure": 2587, + "timestamp": 1774179086772 + }, + { + "x": 239.5325927734375, + "y": 220.56033325195312, + "pressure": 2589, + "timestamp": 1774179086776 + }, + { + "x": 238.938232421875, + "y": 221.35275268554688, + "pressure": 2583, + "timestamp": 1774179086778 + }, + { + "x": 238.3438720703125, + "y": 222.1451416015625, + "pressure": 2580, + "timestamp": 1774179086779 + }, + { + "x": 237.35321044921875, + "y": 223.13565063476562, + "pressure": 2579, + "timestamp": 1774179086781 + }, + { + "x": 236.56072998046875, + "y": 224.12615966796875, + "pressure": 2578, + "timestamp": 1774179086783 + }, + { + "x": 235.76824951171875, + "y": 225.31475830078125, + "pressure": 2580, + "timestamp": 1774179086784 + }, + { + "x": 234.9757080078125, + "y": 226.50335693359375, + "pressure": 2579, + "timestamp": 1774179086786 + }, + { + "x": 234.1832275390625, + "y": 227.69195556640625, + "pressure": 2578, + "timestamp": 1774179086788 + }, + { + "x": 233.3907470703125, + "y": 228.88058471679688, + "pressure": 2580, + "timestamp": 1774179086793 + }, + { + "x": 232.79638671875, + "y": 230.06915283203125, + "pressure": 2575, + "timestamp": 1774179086793 + }, + { + "x": 232.40008544921875, + "y": 231.25775146484375, + "pressure": 2573, + "timestamp": 1774179086796 + }, + { + "x": 231.80572509765625, + "y": 232.64447021484375, + "pressure": 2572, + "timestamp": 1774179086797 + }, + { + "x": 231.21136474609375, + "y": 234.03118896484375, + "pressure": 2571, + "timestamp": 1774179086799 + }, + { + "x": 230.81512451171875, + "y": 235.41790771484375, + "pressure": 2573, + "timestamp": 1774179086800 + }, + { + "x": 230.41888427734375, + "y": 236.80462646484375, + "pressure": 2572, + "timestamp": 1774179086802 + }, + { + "x": 230.02264404296875, + "y": 238.1912841796875, + "pressure": 2571, + "timestamp": 1774179086804 + }, + { + "x": 229.82452392578125, + "y": 239.3798828125, + "pressure": 2573, + "timestamp": 1774179086808 + }, + { + "x": 229.6263427734375, + "y": 240.56854248046875, + "pressure": 2563, + "timestamp": 1774179086810 + }, + { + "x": 229.6263427734375, + "y": 241.75714111328125, + "pressure": 2558, + "timestamp": 1774179086812 + }, + { + "x": 229.6263427734375, + "y": 242.94573974609375, + "pressure": 2555, + "timestamp": 1774179086814 + }, + { + "x": 229.6263427734375, + "y": 244.13433837890625, + "pressure": 2554, + "timestamp": 1774179086815 + }, + { + "x": 229.82452392578125, + "y": 245.32293701171875, + "pressure": 2553, + "timestamp": 1774179086817 + }, + { + "x": 230.02264404296875, + "y": 246.51153564453125, + "pressure": 2555, + "timestamp": 1774179086818 + }, + { + "x": 230.22076416015625, + "y": 247.50201416015625, + "pressure": 2554, + "timestamp": 1774179086820 + }, + { + "x": 230.41888427734375, + "y": 248.4925537109375, + "pressure": 2553, + "timestamp": 1774179086824 + }, + { + "x": 230.81512451171875, + "y": 249.28497314453125, + "pressure": 2542, + "timestamp": 1774179086826 + }, + { + "x": 231.21136474609375, + "y": 250.07733154296875, + "pressure": 2536, + "timestamp": 1774179086828 + }, + { + "x": 231.60760498046875, + "y": 250.8697509765625, + "pressure": 2533, + "timestamp": 1774179086829 + }, + { + "x": 232.20196533203125, + "y": 251.66217041015625, + "pressure": 2532, + "timestamp": 1774179086831 + }, + { + "x": 232.79638671875, + "y": 252.45452880859375, + "pressure": 2531, + "timestamp": 1774179086833 + }, + { + "x": 233.192626953125, + "y": 252.85076904296875, + "pressure": 2533, + "timestamp": 1774179086835 + }, + { + "x": 233.5888671875, + "y": 253.2469482421875, + "pressure": 2532, + "timestamp": 1774179086836 + }, + { + "x": 234.38134765625, + "y": 253.84124755859375, + "pressure": 2531, + "timestamp": 1774179086840 + }, + { + "x": 235.17388916015625, + "y": 254.6336669921875, + "pressure": 2522, + "timestamp": 1774179086841 + }, + { + "x": 236.16448974609375, + "y": 255.22796630859375, + "pressure": 2517, + "timestamp": 1774179086844 + }, + { + "x": 237.15509033203125, + "y": 255.822265625, + "pressure": 2515, + "timestamp": 1774179086845 + }, + { + "x": 238.145751953125, + "y": 256.61468505859375, + "pressure": 2514, + "timestamp": 1774179086847 + }, + { + "x": 239.33447265625, + "y": 257.4071044921875, + "pressure": 2513, + "timestamp": 1774179086848 + }, + { + "x": 240.523193359375, + "y": 258.00140380859375, + "pressure": 2515, + "timestamp": 1774179086850 + }, + { + "x": 241.71197509765625, + "y": 258.3975830078125, + "pressure": 2514, + "timestamp": 1774179086852 + }, + { + "x": 242.90069580078125, + "y": 258.79376220703125, + "pressure": 2513, + "timestamp": 1774179086856 + }, + { + "x": 244.0894775390625, + "y": 259.19000244140625, + "pressure": 2497, + "timestamp": 1774179086858 + }, + { + "x": 245.476318359375, + "y": 259.586181640625, + "pressure": 2489, + "timestamp": 1774179086860 + }, + { + "x": 247.06134033203125, + "y": 259.98236083984375, + "pressure": 2485, + "timestamp": 1774179086862 + }, + { + "x": 248.64630126953125, + "y": 260.18048095703125, + "pressure": 2483, + "timestamp": 1774179086863 + }, + { + "x": 250.2313232421875, + "y": 260.18048095703125, + "pressure": 2482, + "timestamp": 1774179086864 + }, + { + "x": 251.81634521484375, + "y": 260.18048095703125, + "pressure": 2484, + "timestamp": 1774179086867 + }, + { + "x": 253.40130615234375, + "y": 260.18048095703125, + "pressure": 2483, + "timestamp": 1774179086868 + }, + { + "x": 254.986328125, + "y": 259.98236083984375, + "pressure": 2482, + "timestamp": 1774179086872 + }, + { + "x": 256.5712890625, + "y": 259.586181640625, + "pressure": 2112, + "timestamp": 1774179086874 + }, + { + "x": 258.35443115234375, + "y": 259.19000244140625, + "pressure": 1927, + "timestamp": 1774179086876 + }, + { + "x": 259.939453125, + "y": 258.79376220703125, + "pressure": 1834, + "timestamp": 1774179086877 + }, + { + "x": 261.5244140625, + "y": 258.3975830078125, + "pressure": 1788, + "timestamp": 1774179086879 + }, + { + "x": 263.10943603515625, + "y": 258.00140380859375, + "pressure": 1765, + "timestamp": 1774179086881 + }, + { + "x": 264.69439697265625, + "y": 257.60516357421875, + "pressure": 1753, + "timestamp": 1774179086882 + }, + { + "x": 266.081298828125, + "y": 257.208984375, + "pressure": 1747, + "timestamp": 1774179086884 + }, + { + "x": 267.4681396484375, + "y": 256.61468505859375, + "pressure": 1744, + "timestamp": 1774179086888 + }, + { + "x": 267.4681396484375, + "y": 256.61468505859375, + "pressure": 1744, + "timestamp": 1774179086890 + } + ] + }, + { + "index": 6, + "pointCount": 2058, + "points": [ + { + "x": 313.43304443359375, + "y": 262.359619140625, + "pressure": 249, + "timestamp": 1774179088513 + }, + { + "x": 313.43304443359375, + "y": 262.359619140625, + "pressure": 374, + "timestamp": 1774179088517 + }, + { + "x": 313.43304443359375, + "y": 262.359619140625, + "pressure": 436, + "timestamp": 1774179088517 + }, + { + "x": 313.43304443359375, + "y": 262.359619140625, + "pressure": 467, + "timestamp": 1774179088517 + }, + { + "x": 313.43304443359375, + "y": 262.359619140625, + "pressure": 483, + "timestamp": 1774179088519 + }, + { + "x": 313.43304443359375, + "y": 262.359619140625, + "pressure": 491, + "timestamp": 1774179088520 + }, + { + "x": 313.43304443359375, + "y": 262.359619140625, + "pressure": 495, + "timestamp": 1774179088524 + }, + { + "x": 313.43304443359375, + "y": 262.359619140625, + "pressure": 465, + "timestamp": 1774179088525 + }, + { + "x": 313.43304443359375, + "y": 262.359619140625, + "pressure": 450, + "timestamp": 1774179088528 + }, + { + "x": 313.43304443359375, + "y": 262.359619140625, + "pressure": 442, + "timestamp": 1774179088529 + }, + { + "x": 313.43304443359375, + "y": 262.359619140625, + "pressure": 438, + "timestamp": 1774179088531 + }, + { + "x": 313.43304443359375, + "y": 262.359619140625, + "pressure": 435, + "timestamp": 1774179088534 + }, + { + "x": 313.43304443359375, + "y": 262.359619140625, + "pressure": 632, + "timestamp": 1774179088541 + }, + { + "x": 313.43304443359375, + "y": 262.359619140625, + "pressure": 730, + "timestamp": 1774179088544 + }, + { + "x": 313.43304443359375, + "y": 262.359619140625, + "pressure": 779, + "timestamp": 1774179088545 + }, + { + "x": 313.43304443359375, + "y": 262.359619140625, + "pressure": 804, + "timestamp": 1774179088547 + }, + { + "x": 313.43304443359375, + "y": 262.359619140625, + "pressure": 816, + "timestamp": 1774179088548 + }, + { + "x": 313.43304443359375, + "y": 262.359619140625, + "pressure": 822, + "timestamp": 1774179088550 + }, + { + "x": 313.43304443359375, + "y": 262.359619140625, + "pressure": 825, + "timestamp": 1774179088552 + }, + { + "x": 313.43304443359375, + "y": 262.359619140625, + "pressure": 1051, + "timestamp": 1774179088558 + }, + { + "x": 313.43304443359375, + "y": 262.359619140625, + "pressure": 1163, + "timestamp": 1774179088560 + }, + { + "x": 313.43304443359375, + "y": 262.359619140625, + "pressure": 1219, + "timestamp": 1774179088561 + }, + { + "x": 313.43304443359375, + "y": 262.359619140625, + "pressure": 1247, + "timestamp": 1774179088563 + }, + { + "x": 313.43304443359375, + "y": 262.359619140625, + "pressure": 1261, + "timestamp": 1774179088565 + }, + { + "x": 313.43304443359375, + "y": 262.359619140625, + "pressure": 1268, + "timestamp": 1774179088566 + }, + { + "x": 313.43304443359375, + "y": 262.359619140625, + "pressure": 1271, + "timestamp": 1774179088568 + }, + { + "x": 313.43304443359375, + "y": 262.359619140625, + "pressure": 1406, + "timestamp": 1774179088574 + }, + { + "x": 313.43304443359375, + "y": 262.359619140625, + "pressure": 1472, + "timestamp": 1774179088576 + }, + { + "x": 313.43304443359375, + "y": 262.359619140625, + "pressure": 1505, + "timestamp": 1774179088577 + }, + { + "x": 313.43304443359375, + "y": 262.359619140625, + "pressure": 1522, + "timestamp": 1774179088579 + }, + { + "x": 313.43304443359375, + "y": 262.359619140625, + "pressure": 1530, + "timestamp": 1774179088581 + }, + { + "x": 313.43304443359375, + "y": 262.359619140625, + "pressure": 1534, + "timestamp": 1774179088583 + }, + { + "x": 313.43304443359375, + "y": 262.359619140625, + "pressure": 1537, + "timestamp": 1774179088588 + }, + { + "x": 313.43304443359375, + "y": 262.359619140625, + "pressure": 1646, + "timestamp": 1774179088590 + }, + { + "x": 313.43304443359375, + "y": 262.359619140625, + "pressure": 1701, + "timestamp": 1774179088592 + }, + { + "x": 313.43304443359375, + "y": 262.359619140625, + "pressure": 1728, + "timestamp": 1774179088593 + }, + { + "x": 313.43304443359375, + "y": 262.359619140625, + "pressure": 1742, + "timestamp": 1774179088595 + }, + { + "x": 314.4236755371094, + "y": 261.36907958984375, + "pressure": 1749, + "timestamp": 1774179088597 + }, + { + "x": 316.206787109375, + "y": 259.3880615234375, + "pressure": 1752, + "timestamp": 1774179088598 + }, + { + "x": 317.98992919921875, + "y": 257.60516357421875, + "pressure": 1754, + "timestamp": 1774179088600 + }, + { + "x": 319.3768005371094, + "y": 256.21844482421875, + "pressure": 1755, + "timestamp": 1774179088604 + }, + { + "x": 320.763671875, + "y": 254.831787109375, + "pressure": 1807, + "timestamp": 1774179088606 + }, + { + "x": 321.9524230957031, + "y": 253.445068359375, + "pressure": 1833, + "timestamp": 1774179088608 + }, + { + "x": 323.1411437988281, + "y": 252.058349609375, + "pressure": 1846, + "timestamp": 1774179088609 + }, + { + "x": 324.13177490234375, + "y": 250.671630859375, + "pressure": 1852, + "timestamp": 1774179088611 + }, + { + "x": 325.1224060058594, + "y": 249.4830322265625, + "pressure": 1855, + "timestamp": 1774179088613 + }, + { + "x": 326.113037109375, + "y": 248.4925537109375, + "pressure": 1857, + "timestamp": 1774179088615 + }, + { + "x": 327.1036376953125, + "y": 247.50201416015625, + "pressure": 1858, + "timestamp": 1774179088616 + }, + { + "x": 328.0942687988281, + "y": 246.51153564453125, + "pressure": 1860, + "timestamp": 1774179088620 + }, + { + "x": 329.08489990234375, + "y": 245.52105712890625, + "pressure": 1885, + "timestamp": 1774179088622 + }, + { + "x": 329.87738037109375, + "y": 244.530517578125, + "pressure": 1897, + "timestamp": 1774179088624 + }, + { + "x": 330.6698913574219, + "y": 243.5400390625, + "pressure": 1903, + "timestamp": 1774179088625 + }, + { + "x": 331.4623718261719, + "y": 242.54949951171875, + "pressure": 1906, + "timestamp": 1774179088627 + }, + { + "x": 332.2548828125, + "y": 241.75714111328125, + "pressure": 1908, + "timestamp": 1774179088629 + }, + { + "x": 333.0473937988281, + "y": 240.9647216796875, + "pressure": 1909, + "timestamp": 1774179088630 + }, + { + "x": 333.8398742675781, + "y": 240.17230224609375, + "pressure": 1911, + "timestamp": 1774179088632 + }, + { + "x": 334.23614501953125, + "y": 239.776123046875, + "pressure": 1910, + "timestamp": 1774179088636 + }, + { + "x": 334.23614501953125, + "y": 239.776123046875, + "pressure": 1952, + "timestamp": 1774179088638 + }, + { + "x": 334.63238525390625, + "y": 239.3798828125, + "pressure": 1973, + "timestamp": 1774179088640 + }, + { + "x": 335.22674560546875, + "y": 238.5875244140625, + "pressure": 1983, + "timestamp": 1774179088641 + }, + { + "x": 335.8211364746094, + "y": 237.79510498046875, + "pressure": 1988, + "timestamp": 1774179088643 + }, + { + "x": 336.0192565917969, + "y": 237.39892578125, + "pressure": 1991, + "timestamp": 1774179088645 + }, + { + "x": 336.2173767089844, + "y": 237.002685546875, + "pressure": 1995, + "timestamp": 1774179088652 + }, + { + "x": 336.6136169433594, + "y": 236.60650634765625, + "pressure": 2015, + "timestamp": 1774179088654 + }, + { + "x": 336.6136169433594, + "y": 236.60650634765625, + "pressure": 2025, + "timestamp": 1774179088656 + }, + { + "x": 336.6136169433594, + "y": 236.60650634765625, + "pressure": 2030, + "timestamp": 1774179088657 + }, + { + "x": 336.6136169433594, + "y": 236.60650634765625, + "pressure": 2033, + "timestamp": 1774179088661 + }, + { + "x": 336.6136169433594, + "y": 236.60650634765625, + "pressure": 2036, + "timestamp": 1774179088664 + }, + { + "x": 336.6136169433594, + "y": 236.60650634765625, + "pressure": 2107, + "timestamp": 1774179088670 + }, + { + "x": 336.6136169433594, + "y": 236.60650634765625, + "pressure": 2143, + "timestamp": 1774179088672 + }, + { + "x": 336.6136169433594, + "y": 236.60650634765625, + "pressure": 2161, + "timestamp": 1774179088673 + }, + { + "x": 336.6136169433594, + "y": 236.60650634765625, + "pressure": 2170, + "timestamp": 1774179088675 + }, + { + "x": 336.6136169433594, + "y": 236.60650634765625, + "pressure": 2175, + "timestamp": 1774179088677 + }, + { + "x": 336.6136169433594, + "y": 236.60650634765625, + "pressure": 2178, + "timestamp": 1774179088680 + }, + { + "x": 336.6136169433594, + "y": 236.60650634765625, + "pressure": 2214, + "timestamp": 1774179088686 + }, + { + "x": 336.6136169433594, + "y": 236.60650634765625, + "pressure": 2232, + "timestamp": 1774179088689 + }, + { + "x": 336.6136169433594, + "y": 236.60650634765625, + "pressure": 2241, + "timestamp": 1774179088689 + }, + { + "x": 336.6136169433594, + "y": 236.60650634765625, + "pressure": 2245, + "timestamp": 1774179088691 + }, + { + "x": 336.6136169433594, + "y": 237.002685546875, + "pressure": 2248, + "timestamp": 1774179088695 + }, + { + "x": 336.8117370605469, + "y": 237.99322509765625, + "pressure": 2249, + "timestamp": 1774179088697 + }, + { + "x": 337.0098876953125, + "y": 239.18182373046875, + "pressure": 2251, + "timestamp": 1774179088700 + }, + { + "x": 337.2080078125, + "y": 240.37042236328125, + "pressure": 2247, + "timestamp": 1774179088702 + }, + { + "x": 337.2080078125, + "y": 241.75714111328125, + "pressure": 2245, + "timestamp": 1774179088704 + }, + { + "x": 337.0098876953125, + "y": 243.143798828125, + "pressure": 2244, + "timestamp": 1774179088706 + }, + { + "x": 336.8117370605469, + "y": 244.7286376953125, + "pressure": 2243, + "timestamp": 1774179088707 + }, + { + "x": 336.4154968261719, + "y": 246.31341552734375, + "pressure": 2245, + "timestamp": 1774179088709 + }, + { + "x": 336.0192565917969, + "y": 248.0963134765625, + "pressure": 2244, + "timestamp": 1774179088711 + }, + { + "x": 335.6230163574219, + "y": 249.8792724609375, + "pressure": 2243, + "timestamp": 1774179088712 + }, + { + "x": 335.22674560546875, + "y": 251.66217041015625, + "pressure": 2245, + "timestamp": 1774179088716 + }, + { + "x": 334.83050537109375, + "y": 253.445068359375, + "pressure": 2242, + "timestamp": 1774179088718 + }, + { + "x": 334.23614501953125, + "y": 255.22796630859375, + "pressure": 2240, + "timestamp": 1774179088720 + }, + { + "x": 333.6417541503906, + "y": 257.0108642578125, + "pressure": 2239, + "timestamp": 1774179088722 + }, + { + "x": 333.0473937988281, + "y": 258.79376220703125, + "pressure": 2241, + "timestamp": 1774179088723 + }, + { + "x": 332.4530029296875, + "y": 260.57666015625, + "pressure": 2240, + "timestamp": 1774179088725 + }, + { + "x": 331.858642578125, + "y": 262.359619140625, + "pressure": 2239, + "timestamp": 1774179088727 + }, + { + "x": 331.2642517089844, + "y": 264.14251708984375, + "pressure": 2241, + "timestamp": 1774179088728 + }, + { + "x": 330.6698913574219, + "y": 265.727294921875, + "pressure": 2240, + "timestamp": 1774179088732 + }, + { + "x": 330.2736511230469, + "y": 267.3121337890625, + "pressure": 2237, + "timestamp": 1774179088734 + }, + { + "x": 329.67926025390625, + "y": 268.69879150390625, + "pressure": 2236, + "timestamp": 1774179088737 + }, + { + "x": 329.08489990234375, + "y": 270.28363037109375, + "pressure": 2235, + "timestamp": 1774179088738 + }, + { + "x": 328.4905090332031, + "y": 271.67034912109375, + "pressure": 2237, + "timestamp": 1774179088739 + }, + { + "x": 327.8961486816406, + "y": 273.0570068359375, + "pressure": 2236, + "timestamp": 1774179088741 + }, + { + "x": 327.3017578125, + "y": 274.4437255859375, + "pressure": 2235, + "timestamp": 1774179088743 + }, + { + "x": 326.50927734375, + "y": 275.63232421875, + "pressure": 2237, + "timestamp": 1774179088744 + }, + { + "x": 325.9148864746094, + "y": 276.8209228515625, + "pressure": 2236, + "timestamp": 1774179088749 + }, + { + "x": 325.3205261230469, + "y": 278.009521484375, + "pressure": 2234, + "timestamp": 1774179088750 + }, + { + "x": 324.7261657714844, + "y": 279.19818115234375, + "pressure": 2233, + "timestamp": 1774179088752 + }, + { + "x": 324.13177490234375, + "y": 280.38677978515625, + "pressure": 2235, + "timestamp": 1774179088754 + }, + { + "x": 323.53741455078125, + "y": 281.37725830078125, + "pressure": 2234, + "timestamp": 1774179088755 + }, + { + "x": 322.9430236816406, + "y": 282.169677734375, + "pressure": 2233, + "timestamp": 1774179088757 + }, + { + "x": 322.3486633300781, + "y": 282.96209716796875, + "pressure": 2235, + "timestamp": 1774179088759 + }, + { + "x": 322.1505432128906, + "y": 283.3582763671875, + "pressure": 2234, + "timestamp": 1774179088760 + }, + { + "x": 321.7542724609375, + "y": 283.75445556640625, + "pressure": 2233, + "timestamp": 1774179088765 + }, + { + "x": 321.3580322265625, + "y": 284.15069580078125, + "pressure": 2229, + "timestamp": 1774179088766 + }, + { + "x": 320.9617919921875, + "y": 284.546875, + "pressure": 2227, + "timestamp": 1774179088768 + }, + { + "x": 320.1692810058594, + "y": 285.14117431640625, + "pressure": 2226, + "timestamp": 1774179088770 + }, + { + "x": 319.7730407714844, + "y": 285.33929443359375, + "pressure": 2228, + "timestamp": 1774179088771 + }, + { + "x": 319.3768005371094, + "y": 285.7354736328125, + "pressure": 2226, + "timestamp": 1774179088775 + }, + { + "x": 318.58428955078125, + "y": 286.32977294921875, + "pressure": 2228, + "timestamp": 1774179088777 + }, + { + "x": 318.18804931640625, + "y": 286.52789306640625, + "pressure": 2227, + "timestamp": 1774179088781 + }, + { + "x": 318.18804931640625, + "y": 286.52789306640625, + "pressure": 2208, + "timestamp": 1774179088782 + }, + { + "x": 317.7917785644531, + "y": 286.924072265625, + "pressure": 2199, + "timestamp": 1774179088785 + }, + { + "x": 316.8011779785156, + "y": 287.51837158203125, + "pressure": 2194, + "timestamp": 1774179088786 + }, + { + "x": 316.0086669921875, + "y": 287.91461181640625, + "pressure": 2192, + "timestamp": 1774179088787 + }, + { + "x": 315.2161865234375, + "y": 288.310791015625, + "pressure": 2191, + "timestamp": 1774179088789 + }, + { + "x": 314.8199157714844, + "y": 288.5089111328125, + "pressure": 2190, + "timestamp": 1774179088791 + }, + { + "x": 314.4236755371094, + "y": 288.5089111328125, + "pressure": 2162, + "timestamp": 1774179088798 + }, + { + "x": 313.43304443359375, + "y": 288.5089111328125, + "pressure": 2148, + "timestamp": 1774179088801 + }, + { + "x": 312.64056396484375, + "y": 288.310791015625, + "pressure": 2141, + "timestamp": 1774179088802 + }, + { + "x": 311.84808349609375, + "y": 288.1126708984375, + "pressure": 2137, + "timestamp": 1774179088804 + }, + { + "x": 311.4517822265625, + "y": 288.1126708984375, + "pressure": 2135, + "timestamp": 1774179088806 + }, + { + "x": 311.0555419921875, + "y": 287.91461181640625, + "pressure": 2110, + "timestamp": 1774179088814 + }, + { + "x": 310.2630615234375, + "y": 287.51837158203125, + "pressure": 2097, + "timestamp": 1774179088816 + }, + { + "x": 309.8668212890625, + "y": 287.1221923828125, + "pressure": 2091, + "timestamp": 1774179088818 + }, + { + "x": 309.8668212890625, + "y": 287.1221923828125, + "pressure": 2088, + "timestamp": 1774179088819 + }, + { + "x": 309.4705810546875, + "y": 286.72601318359375, + "pressure": 2085, + "timestamp": 1774179088823 + }, + { + "x": 308.87615966796875, + "y": 285.93359375, + "pressure": 2087, + "timestamp": 1774179088825 + }, + { + "x": 308.47991943359375, + "y": 284.94305419921875, + "pressure": 2086, + "timestamp": 1774179088829 + }, + { + "x": 308.08367919921875, + "y": 283.95257568359375, + "pressure": 2070, + "timestamp": 1774179088830 + }, + { + "x": 307.68743896484375, + "y": 283.16015625, + "pressure": 2062, + "timestamp": 1774179088833 + }, + { + "x": 307.29119873046875, + "y": 282.36773681640625, + "pressure": 2058, + "timestamp": 1774179088834 + }, + { + "x": 307.09307861328125, + "y": 281.17913818359375, + "pressure": 2056, + "timestamp": 1774179088835 + }, + { + "x": 307.09307861328125, + "y": 279.99053955078125, + "pressure": 2055, + "timestamp": 1774179088837 + }, + { + "x": 307.09307861328125, + "y": 278.80194091796875, + "pressure": 2057, + "timestamp": 1774179088839 + }, + { + "x": 307.29119873046875, + "y": 277.61334228515625, + "pressure": 2056, + "timestamp": 1774179088841 + }, + { + "x": 307.48931884765625, + "y": 276.42474365234375, + "pressure": 2055, + "timestamp": 1774179088845 + }, + { + "x": 307.68743896484375, + "y": 275.23614501953125, + "pressure": 2049, + "timestamp": 1774179088846 + }, + { + "x": 308.08367919921875, + "y": 274.04754638671875, + "pressure": 2046, + "timestamp": 1774179088848 + }, + { + "x": 308.47991943359375, + "y": 272.85894775390625, + "pressure": 2044, + "timestamp": 1774179088850 + }, + { + "x": 308.87615966796875, + "y": 271.47222900390625, + "pressure": 2043, + "timestamp": 1774179088851 + }, + { + "x": 309.2724609375, + "y": 270.08551025390625, + "pressure": 2045, + "timestamp": 1774179088853 + }, + { + "x": 309.8668212890625, + "y": 268.69879150390625, + "pressure": 2044, + "timestamp": 1774179088855 + }, + { + "x": 310.461181640625, + "y": 267.3121337890625, + "pressure": 2043, + "timestamp": 1774179088857 + }, + { + "x": 311.253662109375, + "y": 265.9254150390625, + "pressure": 2045, + "timestamp": 1774179088861 + }, + { + "x": 312.0461730957031, + "y": 264.340576171875, + "pressure": 2056, + "timestamp": 1774179088862 + }, + { + "x": 312.83868408203125, + "y": 262.75579833984375, + "pressure": 2061, + "timestamp": 1774179088865 + }, + { + "x": 313.8293151855469, + "y": 261.17095947265625, + "pressure": 2064, + "timestamp": 1774179088866 + }, + { + "x": 315.0180358886719, + "y": 259.7843017578125, + "pressure": 2065, + "timestamp": 1774179088868 + }, + { + "x": 316.206787109375, + "y": 258.3975830078125, + "pressure": 2066, + "timestamp": 1774179088869 + }, + { + "x": 317.3955383300781, + "y": 257.208984375, + "pressure": 2068, + "timestamp": 1774179088871 + }, + { + "x": 318.58428955078125, + "y": 256.0203857421875, + "pressure": 2067, + "timestamp": 1774179088873 + }, + { + "x": 319.7730407714844, + "y": 254.6336669921875, + "pressure": 2069, + "timestamp": 1774179088877 + }, + { + "x": 321.159912109375, + "y": 253.2469482421875, + "pressure": 2092, + "timestamp": 1774179088878 + }, + { + "x": 322.5467834472656, + "y": 252.058349609375, + "pressure": 2104, + "timestamp": 1774179088881 + }, + { + "x": 323.93365478515625, + "y": 250.8697509765625, + "pressure": 2110, + "timestamp": 1774179088882 + }, + { + "x": 325.3205261230469, + "y": 249.8792724609375, + "pressure": 2113, + "timestamp": 1774179088884 + }, + { + "x": 326.7073974609375, + "y": 248.88873291015625, + "pressure": 2114, + "timestamp": 1774179088885 + }, + { + "x": 328.2923889160156, + "y": 247.70013427734375, + "pressure": 2115, + "timestamp": 1774179088887 + }, + { + "x": 329.87738037109375, + "y": 246.70965576171875, + "pressure": 2117, + "timestamp": 1774179088889 + }, + { + "x": 331.4623718261719, + "y": 245.7191162109375, + "pressure": 2116, + "timestamp": 1774179088893 + }, + { + "x": 333.0473937988281, + "y": 244.7286376953125, + "pressure": 2138, + "timestamp": 1774179088895 + }, + { + "x": 334.63238525390625, + "y": 243.73809814453125, + "pressure": 2149, + "timestamp": 1774179088897 + }, + { + "x": 336.2173767089844, + "y": 242.74761962890625, + "pressure": 2154, + "timestamp": 1774179088898 + }, + { + "x": 337.8023681640625, + "y": 242.1533203125, + "pressure": 2157, + "timestamp": 1774179088900 + }, + { + "x": 339.3873596191406, + "y": 241.55902099609375, + "pressure": 2158, + "timestamp": 1774179088901 + }, + { + "x": 340.97235107421875, + "y": 240.9647216796875, + "pressure": 2159, + "timestamp": 1774179088903 + }, + { + "x": 342.557373046875, + "y": 240.37042236328125, + "pressure": 2161, + "timestamp": 1774179088905 + }, + { + "x": 344.1423645019531, + "y": 239.97418212890625, + "pressure": 2160, + "timestamp": 1774179088909 + }, + { + "x": 345.72735595703125, + "y": 239.5780029296875, + "pressure": 2176, + "timestamp": 1774179088910 + }, + { + "x": 347.1142272949219, + "y": 239.3798828125, + "pressure": 2184, + "timestamp": 1774179088913 + }, + { + "x": 348.5010986328125, + "y": 239.18182373046875, + "pressure": 2188, + "timestamp": 1774179088914 + }, + { + "x": 349.6898498535156, + "y": 238.98370361328125, + "pressure": 2190, + "timestamp": 1774179088916 + }, + { + "x": 350.68048095703125, + "y": 238.78558349609375, + "pressure": 2191, + "timestamp": 1774179088918 + }, + { + "x": 351.67108154296875, + "y": 238.5875244140625, + "pressure": 2193, + "timestamp": 1774179088919 + }, + { + "x": 352.4635925292969, + "y": 238.389404296875, + "pressure": 2192, + "timestamp": 1774179088921 + }, + { + "x": 353.2560729980469, + "y": 238.1912841796875, + "pressure": 2194, + "timestamp": 1774179088925 + }, + { + "x": 354.2467041015625, + "y": 237.99322509765625, + "pressure": 2211, + "timestamp": 1774179088926 + }, + { + "x": 355.0392150878906, + "y": 237.99322509765625, + "pressure": 2220, + "timestamp": 1774179088929 + }, + { + "x": 355.4354553222656, + "y": 237.99322509765625, + "pressure": 2224, + "timestamp": 1774179088930 + }, + { + "x": 355.4354553222656, + "y": 237.99322509765625, + "pressure": 2227, + "timestamp": 1774179088933 + }, + { + "x": 355.4354553222656, + "y": 237.99322509765625, + "pressure": 2230, + "timestamp": 1774179088937 + }, + { + "x": 355.8316955566406, + "y": 238.1912841796875, + "pressure": 2229, + "timestamp": 1774179088941 + }, + { + "x": 356.22796630859375, + "y": 238.1912841796875, + "pressure": 2289, + "timestamp": 1774179088943 + }, + { + "x": 356.22796630859375, + "y": 238.1912841796875, + "pressure": 2319, + "timestamp": 1774179088945 + }, + { + "x": 356.22796630859375, + "y": 238.1912841796875, + "pressure": 2334, + "timestamp": 1774179088946 + }, + { + "x": 356.22796630859375, + "y": 238.1912841796875, + "pressure": 2341, + "timestamp": 1774179088948 + }, + { + "x": 356.22796630859375, + "y": 238.1912841796875, + "pressure": 2345, + "timestamp": 1774179088949 + }, + { + "x": 356.22796630859375, + "y": 238.1912841796875, + "pressure": 2348, + "timestamp": 1774179088953 + }, + { + "x": 356.22796630859375, + "y": 238.1912841796875, + "pressure": 2377, + "timestamp": 1774179088959 + }, + { + "x": 356.22796630859375, + "y": 238.1912841796875, + "pressure": 2391, + "timestamp": 1774179088961 + }, + { + "x": 356.22796630859375, + "y": 238.1912841796875, + "pressure": 2398, + "timestamp": 1774179088962 + }, + { + "x": 356.22796630859375, + "y": 238.1912841796875, + "pressure": 2401, + "timestamp": 1774179088964 + }, + { + "x": 356.22796630859375, + "y": 238.5875244140625, + "pressure": 2404, + "timestamp": 1774179088967 + }, + { + "x": 356.42608642578125, + "y": 239.3798828125, + "pressure": 2406, + "timestamp": 1774179088969 + }, + { + "x": 356.62420654296875, + "y": 239.776123046875, + "pressure": 2405, + "timestamp": 1774179088973 + }, + { + "x": 356.62420654296875, + "y": 239.776123046875, + "pressure": 2410, + "timestamp": 1774179088975 + }, + { + "x": 356.42608642578125, + "y": 240.17230224609375, + "pressure": 2412, + "timestamp": 1774179088977 + }, + { + "x": 356.22796630859375, + "y": 240.9647216796875, + "pressure": 2413, + "timestamp": 1774179088978 + }, + { + "x": 355.6335754394531, + "y": 241.75714111328125, + "pressure": 2414, + "timestamp": 1774179088980 + }, + { + "x": 355.0392150878906, + "y": 242.54949951171875, + "pressure": 2416, + "timestamp": 1774179088982 + }, + { + "x": 354.8410949707031, + "y": 242.94573974609375, + "pressure": 2415, + "timestamp": 1774179088983 + }, + { + "x": 354.44482421875, + "y": 243.3419189453125, + "pressure": 2418, + "timestamp": 1774179088991 + }, + { + "x": 353.4542236328125, + "y": 244.13433837890625, + "pressure": 2417, + "timestamp": 1774179088993 + }, + { + "x": 352.4635925292969, + "y": 244.9267578125, + "pressure": 2416, + "timestamp": 1774179088995 + }, + { + "x": 351.27484130859375, + "y": 245.7191162109375, + "pressure": 2418, + "timestamp": 1774179088996 + }, + { + "x": 350.0860900878906, + "y": 246.51153564453125, + "pressure": 2417, + "timestamp": 1774179088998 + }, + { + "x": 349.095458984375, + "y": 247.1058349609375, + "pressure": 2416, + "timestamp": 1774179088999 + }, + { + "x": 348.1048583984375, + "y": 247.70013427734375, + "pressure": 2418, + "timestamp": 1774179089001 + }, + { + "x": 347.1142272949219, + "y": 248.0963134765625, + "pressure": 2417, + "timestamp": 1774179089005 + }, + { + "x": 346.12359619140625, + "y": 248.4925537109375, + "pressure": 2408, + "timestamp": 1774179089007 + }, + { + "x": 345.1329650878906, + "y": 249.08685302734375, + "pressure": 2404, + "timestamp": 1774179089009 + }, + { + "x": 344.1423645019531, + "y": 249.68115234375, + "pressure": 2402, + "timestamp": 1774179089010 + }, + { + "x": 343.1517333984375, + "y": 250.07733154296875, + "pressure": 2401, + "timestamp": 1774179089012 + }, + { + "x": 342.1611022949219, + "y": 250.47357177734375, + "pressure": 2400, + "timestamp": 1774179089014 + }, + { + "x": 341.1705017089844, + "y": 250.8697509765625, + "pressure": 2402, + "timestamp": 1774179089016 + }, + { + "x": 339.98175048828125, + "y": 251.26593017578125, + "pressure": 2401, + "timestamp": 1774179089017 + }, + { + "x": 338.7929992675781, + "y": 251.66217041015625, + "pressure": 2400, + "timestamp": 1774179089021 + }, + { + "x": 337.8023681640625, + "y": 252.058349609375, + "pressure": 2392, + "timestamp": 1774179089023 + }, + { + "x": 336.8117370605469, + "y": 252.45452880859375, + "pressure": 2388, + "timestamp": 1774179089025 + }, + { + "x": 335.6230163574219, + "y": 252.85076904296875, + "pressure": 2386, + "timestamp": 1774179089026 + }, + { + "x": 334.63238525390625, + "y": 253.2469482421875, + "pressure": 2385, + "timestamp": 1774179089028 + }, + { + "x": 333.6417541503906, + "y": 253.6431884765625, + "pressure": 2387, + "timestamp": 1774179089030 + }, + { + "x": 332.651123046875, + "y": 254.03936767578125, + "pressure": 2386, + "timestamp": 1774179089032 + }, + { + "x": 331.6605224609375, + "y": 254.23748779296875, + "pressure": 2385, + "timestamp": 1774179089033 + }, + { + "x": 330.6698913574219, + "y": 254.435546875, + "pressure": 2387, + "timestamp": 1774179089037 + }, + { + "x": 329.87738037109375, + "y": 254.6336669921875, + "pressure": 2362, + "timestamp": 1774179089039 + }, + { + "x": 329.08489990234375, + "y": 254.831787109375, + "pressure": 2350, + "timestamp": 1774179089041 + }, + { + "x": 328.2923889160156, + "y": 255.02984619140625, + "pressure": 2344, + "timestamp": 1774179089042 + }, + { + "x": 327.4999084472656, + "y": 255.22796630859375, + "pressure": 2341, + "timestamp": 1774179089044 + }, + { + "x": 326.7073974609375, + "y": 255.42608642578125, + "pressure": 2339, + "timestamp": 1774179089046 + }, + { + "x": 325.9148864746094, + "y": 255.42608642578125, + "pressure": 2338, + "timestamp": 1774179089047 + }, + { + "x": 325.5186462402344, + "y": 255.42608642578125, + "pressure": 2340, + "timestamp": 1774179089049 + }, + { + "x": 325.1224060058594, + "y": 255.42608642578125, + "pressure": 2339, + "timestamp": 1774179089053 + }, + { + "x": 324.32989501953125, + "y": 255.42608642578125, + "pressure": 2314, + "timestamp": 1774179089055 + }, + { + "x": 323.93365478515625, + "y": 255.42608642578125, + "pressure": 2301, + "timestamp": 1774179089057 + }, + { + "x": 323.93365478515625, + "y": 255.42608642578125, + "pressure": 2295, + "timestamp": 1774179089059 + }, + { + "x": 323.93365478515625, + "y": 255.42608642578125, + "pressure": 2292, + "timestamp": 1774179089060 + }, + { + "x": 323.93365478515625, + "y": 255.42608642578125, + "pressure": 2289, + "timestamp": 1774179089064 + }, + { + "x": 323.93365478515625, + "y": 255.42608642578125, + "pressure": 2264, + "timestamp": 1774179089071 + }, + { + "x": 323.93365478515625, + "y": 255.42608642578125, + "pressure": 2251, + "timestamp": 1774179089073 + }, + { + "x": 323.93365478515625, + "y": 255.42608642578125, + "pressure": 2244, + "timestamp": 1774179089074 + }, + { + "x": 323.93365478515625, + "y": 255.42608642578125, + "pressure": 2241, + "timestamp": 1774179089076 + }, + { + "x": 323.93365478515625, + "y": 255.42608642578125, + "pressure": 2238, + "timestamp": 1774179089080 + }, + { + "x": 323.93365478515625, + "y": 255.42608642578125, + "pressure": 2174, + "timestamp": 1774179089087 + }, + { + "x": 323.93365478515625, + "y": 255.42608642578125, + "pressure": 2141, + "timestamp": 1774179089089 + }, + { + "x": 323.93365478515625, + "y": 255.42608642578125, + "pressure": 2125, + "timestamp": 1774179089091 + }, + { + "x": 323.93365478515625, + "y": 255.42608642578125, + "pressure": 2117, + "timestamp": 1774179089092 + }, + { + "x": 323.93365478515625, + "y": 255.42608642578125, + "pressure": 2113, + "timestamp": 1774179089094 + }, + { + "x": 323.93365478515625, + "y": 255.42608642578125, + "pressure": 2110, + "timestamp": 1774179089097 + }, + { + "x": 323.93365478515625, + "y": 255.42608642578125, + "pressure": 2126, + "timestamp": 1774179089103 + }, + { + "x": 323.93365478515625, + "y": 255.42608642578125, + "pressure": 2134, + "timestamp": 1774179089105 + }, + { + "x": 323.93365478515625, + "y": 255.42608642578125, + "pressure": 2138, + "timestamp": 1774179089107 + }, + { + "x": 323.93365478515625, + "y": 255.42608642578125, + "pressure": 2141, + "timestamp": 1774179089110 + }, + { + "x": 324.32989501953125, + "y": 255.42608642578125, + "pressure": 2144, + "timestamp": 1774179089113 + }, + { + "x": 325.1224060058594, + "y": 255.42608642578125, + "pressure": 2143, + "timestamp": 1774179089118 + }, + { + "x": 326.113037109375, + "y": 255.42608642578125, + "pressure": 2144, + "timestamp": 1774179089119 + }, + { + "x": 327.1036376953125, + "y": 255.42608642578125, + "pressure": 2146, + "timestamp": 1774179089121 + }, + { + "x": 328.0942687988281, + "y": 255.22796630859375, + "pressure": 2145, + "timestamp": 1774179089123 + }, + { + "x": 329.08489990234375, + "y": 255.22796630859375, + "pressure": 2147, + "timestamp": 1774179089124 + }, + { + "x": 329.87738037109375, + "y": 255.22796630859375, + "pressure": 2146, + "timestamp": 1774179089126 + }, + { + "x": 330.6698913574219, + "y": 255.02984619140625, + "pressure": 2145, + "timestamp": 1774179089128 + }, + { + "x": 331.6605224609375, + "y": 254.831787109375, + "pressure": 2147, + "timestamp": 1774179089129 + }, + { + "x": 332.651123046875, + "y": 254.6336669921875, + "pressure": 2146, + "timestamp": 1774179089134 + }, + { + "x": 333.6417541503906, + "y": 254.435546875, + "pressure": 2159, + "timestamp": 1774179089135 + }, + { + "x": 334.63238525390625, + "y": 254.435546875, + "pressure": 2166, + "timestamp": 1774179089138 + }, + { + "x": 335.42486572265625, + "y": 254.435546875, + "pressure": 2169, + "timestamp": 1774179089139 + }, + { + "x": 336.2173767089844, + "y": 254.23748779296875, + "pressure": 2171, + "timestamp": 1774179089140 + }, + { + "x": 337.0098876953125, + "y": 254.03936767578125, + "pressure": 2172, + "timestamp": 1774179089142 + }, + { + "x": 337.8023681640625, + "y": 253.84124755859375, + "pressure": 2174, + "timestamp": 1774179089144 + }, + { + "x": 338.5948791503906, + "y": 253.6431884765625, + "pressure": 2173, + "timestamp": 1774179089145 + }, + { + "x": 339.3873596191406, + "y": 253.445068359375, + "pressure": 2175, + "timestamp": 1774179089149 + }, + { + "x": 340.17987060546875, + "y": 253.2469482421875, + "pressure": 2185, + "timestamp": 1774179089151 + }, + { + "x": 340.97235107421875, + "y": 253.04888916015625, + "pressure": 2190, + "timestamp": 1774179089153 + }, + { + "x": 341.7648620605469, + "y": 252.85076904296875, + "pressure": 2193, + "timestamp": 1774179089155 + }, + { + "x": 342.557373046875, + "y": 252.65264892578125, + "pressure": 2194, + "timestamp": 1774179089156 + }, + { + "x": 343.349853515625, + "y": 252.45452880859375, + "pressure": 2195, + "timestamp": 1774179089158 + }, + { + "x": 344.1423645019531, + "y": 252.2564697265625, + "pressure": 2197, + "timestamp": 1774179089160 + }, + { + "x": 345.1329650878906, + "y": 252.058349609375, + "pressure": 2196, + "timestamp": 1774179089161 + }, + { + "x": 346.12359619140625, + "y": 251.66217041015625, + "pressure": 2198, + "timestamp": 1774179089165 + }, + { + "x": 347.1142272949219, + "y": 251.26593017578125, + "pressure": 2206, + "timestamp": 1774179089167 + }, + { + "x": 348.1048583984375, + "y": 250.8697509765625, + "pressure": 2210, + "timestamp": 1774179089169 + }, + { + "x": 349.095458984375, + "y": 250.47357177734375, + "pressure": 2212, + "timestamp": 1774179089171 + }, + { + "x": 349.8879699707031, + "y": 250.07733154296875, + "pressure": 2213, + "timestamp": 1774179089172 + }, + { + "x": 350.87860107421875, + "y": 249.68115234375, + "pressure": 2215, + "timestamp": 1774179089174 + }, + { + "x": 351.86920166015625, + "y": 249.28497314453125, + "pressure": 2214, + "timestamp": 1774179089176 + }, + { + "x": 352.6617126464844, + "y": 248.88873291015625, + "pressure": 2216, + "timestamp": 1774179089177 + }, + { + "x": 353.65234375, + "y": 248.4925537109375, + "pressure": 2215, + "timestamp": 1774179089182 + }, + { + "x": 354.6429443359375, + "y": 248.0963134765625, + "pressure": 2229, + "timestamp": 1774179089183 + }, + { + "x": 355.4354553222656, + "y": 247.70013427734375, + "pressure": 2236, + "timestamp": 1774179089185 + }, + { + "x": 356.42608642578125, + "y": 247.303955078125, + "pressure": 2239, + "timestamp": 1774179089187 + }, + { + "x": 357.21856689453125, + "y": 246.90771484375, + "pressure": 2241, + "timestamp": 1774179089188 + }, + { + "x": 358.0110778808594, + "y": 246.51153564453125, + "pressure": 2242, + "timestamp": 1774179089190 + }, + { + "x": 358.8035583496094, + "y": 246.1153564453125, + "pressure": 2244, + "timestamp": 1774179089192 + }, + { + "x": 359.794189453125, + "y": 245.7191162109375, + "pressure": 2243, + "timestamp": 1774179089193 + }, + { + "x": 360.5867004394531, + "y": 245.12481689453125, + "pressure": 2245, + "timestamp": 1774179089197 + }, + { + "x": 361.3791809082031, + "y": 244.7286376953125, + "pressure": 2258, + "timestamp": 1774179089199 + }, + { + "x": 362.17169189453125, + "y": 244.3323974609375, + "pressure": 2264, + "timestamp": 1774179089202 + }, + { + "x": 362.9642028808594, + "y": 243.93621826171875, + "pressure": 2267, + "timestamp": 1774179089203 + }, + { + "x": 363.7566833496094, + "y": 243.5400390625, + "pressure": 2269, + "timestamp": 1774179089205 + }, + { + "x": 364.5491943359375, + "y": 243.143798828125, + "pressure": 2270, + "timestamp": 1774179089206 + }, + { + "x": 365.3416748046875, + "y": 242.74761962890625, + "pressure": 2272, + "timestamp": 1774179089208 + }, + { + "x": 366.1341857910156, + "y": 242.3514404296875, + "pressure": 2271, + "timestamp": 1774179089209 + }, + { + "x": 366.9266662597656, + "y": 242.1533203125, + "pressure": 2273, + "timestamp": 1774179089214 + }, + { + "x": 367.32293701171875, + "y": 241.9552001953125, + "pressure": 2281, + "timestamp": 1774179089216 + }, + { + "x": 367.32293701171875, + "y": 241.9552001953125, + "pressure": 2285, + "timestamp": 1774179089218 + }, + { + "x": 367.71917724609375, + "y": 241.75714111328125, + "pressure": 2287, + "timestamp": 1774179089219 + }, + { + "x": 368.5116882324219, + "y": 241.162841796875, + "pressure": 2288, + "timestamp": 1774179089220 + }, + { + "x": 369.3041687011719, + "y": 240.7666015625, + "pressure": 2290, + "timestamp": 1774179089222 + }, + { + "x": 369.7004089355469, + "y": 240.56854248046875, + "pressure": 2289, + "timestamp": 1774179089224 + }, + { + "x": 370.0966796875, + "y": 240.37042236328125, + "pressure": 2300, + "timestamp": 1774179089231 + }, + { + "x": 370.88916015625, + "y": 239.776123046875, + "pressure": 2305, + "timestamp": 1774179089233 + }, + { + "x": 371.2854309082031, + "y": 239.3798828125, + "pressure": 2307, + "timestamp": 1774179089235 + }, + { + "x": 371.2854309082031, + "y": 239.3798828125, + "pressure": 2311, + "timestamp": 1774179089240 + }, + { + "x": 371.6816711425781, + "y": 239.18182373046875, + "pressure": 2310, + "timestamp": 1774179089242 + }, + { + "x": 372.47418212890625, + "y": 238.5875244140625, + "pressure": 2312, + "timestamp": 1774179089246 + }, + { + "x": 372.87042236328125, + "y": 238.389404296875, + "pressure": 2321, + "timestamp": 1774179089247 + }, + { + "x": 372.87042236328125, + "y": 238.389404296875, + "pressure": 2325, + "timestamp": 1774179089249 + }, + { + "x": 372.87042236328125, + "y": 238.389404296875, + "pressure": 2328, + "timestamp": 1774179089252 + }, + { + "x": 372.87042236328125, + "y": 238.389404296875, + "pressure": 2331, + "timestamp": 1774179089256 + }, + { + "x": 372.87042236328125, + "y": 238.389404296875, + "pressure": 2337, + "timestamp": 1774179089263 + }, + { + "x": 372.87042236328125, + "y": 238.389404296875, + "pressure": 2340, + "timestamp": 1774179089267 + }, + { + "x": 372.87042236328125, + "y": 238.389404296875, + "pressure": 2343, + "timestamp": 1774179089271 + }, + { + "x": 373.26666259765625, + "y": 238.1912841796875, + "pressure": 2344, + "timestamp": 1774179089274 + }, + { + "x": 374.0591735839844, + "y": 237.59698486328125, + "pressure": 2343, + "timestamp": 1774179089278 + }, + { + "x": 374.4554138183594, + "y": 237.2008056640625, + "pressure": 2347, + "timestamp": 1774179089279 + }, + { + "x": 374.4554138183594, + "y": 237.2008056640625, + "pressure": 2350, + "timestamp": 1774179089283 + }, + { + "x": 374.4554138183594, + "y": 237.2008056640625, + "pressure": 2353, + "timestamp": 1774179089286 + }, + { + "x": 374.4554138183594, + "y": 237.2008056640625, + "pressure": 2358, + "timestamp": 1774179089295 + }, + { + "x": 374.4554138183594, + "y": 237.2008056640625, + "pressure": 2361, + "timestamp": 1774179089298 + }, + { + "x": 374.4554138183594, + "y": 237.2008056640625, + "pressure": 2365, + "timestamp": 1774179089302 + }, + { + "x": 374.4554138183594, + "y": 237.2008056640625, + "pressure": 2369, + "timestamp": 1774179089315 + }, + { + "x": 374.4554138183594, + "y": 237.2008056640625, + "pressure": 2377, + "timestamp": 1774179089328 + }, + { + "x": 374.4554138183594, + "y": 237.2008056640625, + "pressure": 2381, + "timestamp": 1774179089330 + }, + { + "x": 374.4554138183594, + "y": 237.2008056640625, + "pressure": 2384, + "timestamp": 1774179089333 + }, + { + "x": 374.4554138183594, + "y": 237.2008056640625, + "pressure": 2387, + "timestamp": 1774179089338 + }, + { + "x": 374.4554138183594, + "y": 237.2008056640625, + "pressure": 2406, + "timestamp": 1774179089344 + }, + { + "x": 374.8516540527344, + "y": 237.002685546875, + "pressure": 2416, + "timestamp": 1774179089346 + }, + { + "x": 375.6441650390625, + "y": 236.40838623046875, + "pressure": 2421, + "timestamp": 1774179089347 + }, + { + "x": 376.0404052734375, + "y": 236.01220703125, + "pressure": 2424, + "timestamp": 1774179089349 + }, + { + "x": 376.0404052734375, + "y": 236.01220703125, + "pressure": 2428, + "timestamp": 1774179089354 + }, + { + "x": 376.0404052734375, + "y": 236.01220703125, + "pressure": 2431, + "timestamp": 1774179089360 + }, + { + "x": 376.0404052734375, + "y": 236.01220703125, + "pressure": 2434, + "timestamp": 1774179089363 + }, + { + "x": 376.0404052734375, + "y": 236.01220703125, + "pressure": 2437, + "timestamp": 1774179089368 + }, + { + "x": 376.0404052734375, + "y": 236.01220703125, + "pressure": 2446, + "timestamp": 1774179089375 + }, + { + "x": 376.0404052734375, + "y": 236.01220703125, + "pressure": 2452, + "timestamp": 1774179089378 + }, + { + "x": 376.0404052734375, + "y": 236.01220703125, + "pressure": 2455, + "timestamp": 1774179089379 + }, + { + "x": 376.0404052734375, + "y": 236.01220703125, + "pressure": 2459, + "timestamp": 1774179089384 + }, + { + "x": 376.0404052734375, + "y": 236.01220703125, + "pressure": 2511, + "timestamp": 1774179089392 + }, + { + "x": 376.0404052734375, + "y": 236.01220703125, + "pressure": 2536, + "timestamp": 1774179089394 + }, + { + "x": 376.0404052734375, + "y": 236.01220703125, + "pressure": 2549, + "timestamp": 1774179089395 + }, + { + "x": 376.0404052734375, + "y": 236.01220703125, + "pressure": 2555, + "timestamp": 1774179089397 + }, + { + "x": 376.0404052734375, + "y": 236.01220703125, + "pressure": 2558, + "timestamp": 1774179089399 + }, + { + "x": 376.0404052734375, + "y": 236.01220703125, + "pressure": 2561, + "timestamp": 1774179089402 + }, + { + "x": 376.0404052734375, + "y": 236.01220703125, + "pressure": 2579, + "timestamp": 1774179089408 + }, + { + "x": 376.0404052734375, + "y": 236.01220703125, + "pressure": 2587, + "timestamp": 1774179089410 + }, + { + "x": 376.0404052734375, + "y": 236.01220703125, + "pressure": 2591, + "timestamp": 1774179089411 + }, + { + "x": 376.0404052734375, + "y": 236.01220703125, + "pressure": 2594, + "timestamp": 1774179089415 + }, + { + "x": 376.0404052734375, + "y": 236.01220703125, + "pressure": 2597, + "timestamp": 1774179089418 + }, + { + "x": 376.0404052734375, + "y": 236.01220703125, + "pressure": 2593, + "timestamp": 1774179089424 + }, + { + "x": 376.0404052734375, + "y": 236.01220703125, + "pressure": 2590, + "timestamp": 1774179089427 + }, + { + "x": 376.0404052734375, + "y": 236.01220703125, + "pressure": 2581, + "timestamp": 1774179089440 + }, + { + "x": 376.0404052734375, + "y": 236.01220703125, + "pressure": 2576, + "timestamp": 1774179089442 + }, + { + "x": 376.0404052734375, + "y": 236.01220703125, + "pressure": 2573, + "timestamp": 1774179089445 + }, + { + "x": 376.0404052734375, + "y": 236.01220703125, + "pressure": 2562, + "timestamp": 1774179089456 + }, + { + "x": 376.0404052734375, + "y": 236.01220703125, + "pressure": 2557, + "timestamp": 1774179089458 + }, + { + "x": 376.0404052734375, + "y": 236.01220703125, + "pressure": 2554, + "timestamp": 1774179089459 + }, + { + "x": 376.0404052734375, + "y": 236.01220703125, + "pressure": 2561, + "timestamp": 1774179089472 + }, + { + "x": 376.0404052734375, + "y": 236.01220703125, + "pressure": 2566, + "timestamp": 1774179089474 + }, + { + "x": 376.0404052734375, + "y": 236.01220703125, + "pressure": 2569, + "timestamp": 1774179089477 + }, + { + "x": 376.0404052734375, + "y": 236.01220703125, + "pressure": 2572, + "timestamp": 1774179089480 + }, + { + "x": 376.0404052734375, + "y": 236.01220703125, + "pressure": 2592, + "timestamp": 1774179089488 + }, + { + "x": 376.0404052734375, + "y": 236.01220703125, + "pressure": 2602, + "timestamp": 1774179089490 + }, + { + "x": 376.0404052734375, + "y": 236.01220703125, + "pressure": 2607, + "timestamp": 1774179089491 + }, + { + "x": 376.0404052734375, + "y": 236.01220703125, + "pressure": 2610, + "timestamp": 1774179089495 + }, + { + "x": 376.0404052734375, + "y": 236.01220703125, + "pressure": 2613, + "timestamp": 1774179089498 + }, + { + "x": 376.0404052734375, + "y": 236.01220703125, + "pressure": 2619, + "timestamp": 1774179089504 + }, + { + "x": 376.0404052734375, + "y": 236.01220703125, + "pressure": 2623, + "timestamp": 1774179089506 + }, + { + "x": 376.0404052734375, + "y": 236.01220703125, + "pressure": 2626, + "timestamp": 1774179089509 + }, + { + "x": 376.0404052734375, + "y": 236.01220703125, + "pressure": 2629, + "timestamp": 1774179089514 + }, + { + "x": 376.0404052734375, + "y": 236.01220703125, + "pressure": 2626, + "timestamp": 1774179089538 + }, + { + "x": 376.0404052734375, + "y": 236.01220703125, + "pressure": 2620, + "timestamp": 1774179089552 + }, + { + "x": 376.0404052734375, + "y": 236.01220703125, + "pressure": 2617, + "timestamp": 1774179089554 + }, + { + "x": 375.6441650390625, + "y": 236.40838623046875, + "pressure": 2614, + "timestamp": 1774179089557 + }, + { + "x": 374.8516540527344, + "y": 237.2008056640625, + "pressure": 2616, + "timestamp": 1774179089559 + }, + { + "x": 374.4554138183594, + "y": 237.59698486328125, + "pressure": 2615, + "timestamp": 1774179089561 + }, + { + "x": 374.4554138183594, + "y": 237.59698486328125, + "pressure": 2608, + "timestamp": 1774179089568 + }, + { + "x": 374.0591735839844, + "y": 237.99322509765625, + "pressure": 2604, + "timestamp": 1774179089570 + }, + { + "x": 373.46478271484375, + "y": 238.98370361328125, + "pressure": 2602, + "timestamp": 1774179089572 + }, + { + "x": 373.06854248046875, + "y": 239.776123046875, + "pressure": 2601, + "timestamp": 1774179089574 + }, + { + "x": 372.87042236328125, + "y": 240.17230224609375, + "pressure": 2600, + "timestamp": 1774179089575 + }, + { + "x": 372.87042236328125, + "y": 240.17230224609375, + "pressure": 2593, + "timestamp": 1774179089584 + }, + { + "x": 372.67230224609375, + "y": 240.56854248046875, + "pressure": 2590, + "timestamp": 1774179089586 + }, + { + "x": 372.0779113769531, + "y": 241.36090087890625, + "pressure": 2588, + "timestamp": 1774179089588 + }, + { + "x": 371.8797912597656, + "y": 241.75714111328125, + "pressure": 2587, + "timestamp": 1774179089589 + }, + { + "x": 371.8797912597656, + "y": 241.75714111328125, + "pressure": 2575, + "timestamp": 1774179089600 + }, + { + "x": 371.8797912597656, + "y": 241.75714111328125, + "pressure": 2568, + "timestamp": 1774179089602 + }, + { + "x": 371.8797912597656, + "y": 241.75714111328125, + "pressure": 2565, + "timestamp": 1774179089604 + }, + { + "x": 371.8797912597656, + "y": 241.75714111328125, + "pressure": 2562, + "timestamp": 1774179089607 + }, + { + "x": 371.6816711425781, + "y": 242.1533203125, + "pressure": 2564, + "timestamp": 1774179089609 + }, + { + "x": 371.4835510253906, + "y": 242.94573974609375, + "pressure": 2563, + "timestamp": 1774179089611 + }, + { + "x": 371.2854309082031, + "y": 243.3419189453125, + "pressure": 2562, + "timestamp": 1774179089614 + }, + { + "x": 371.2854309082031, + "y": 243.3419189453125, + "pressure": 2517, + "timestamp": 1774179089616 + }, + { + "x": 371.2854309082031, + "y": 243.3419189453125, + "pressure": 2495, + "timestamp": 1774179089618 + }, + { + "x": 371.2854309082031, + "y": 243.3419189453125, + "pressure": 2484, + "timestamp": 1774179089620 + }, + { + "x": 371.2854309082031, + "y": 243.3419189453125, + "pressure": 2478, + "timestamp": 1774179089621 + }, + { + "x": 371.2854309082031, + "y": 243.3419189453125, + "pressure": 2475, + "timestamp": 1774179089623 + }, + { + "x": 371.2854309082031, + "y": 243.3419189453125, + "pressure": 2465, + "timestamp": 1774179089632 + }, + { + "x": 371.2854309082031, + "y": 243.3419189453125, + "pressure": 2460, + "timestamp": 1774179089634 + }, + { + "x": 371.6816711425781, + "y": 243.5400390625, + "pressure": 2458, + "timestamp": 1774179089636 + }, + { + "x": 372.0779113769531, + "y": 243.5400390625, + "pressure": 2457, + "timestamp": 1774179089637 + }, + { + "x": 372.47418212890625, + "y": 243.73809814453125, + "pressure": 2458, + "timestamp": 1774179089641 + }, + { + "x": 373.26666259765625, + "y": 243.93621826171875, + "pressure": 2457, + "timestamp": 1774179089643 + }, + { + "x": 374.0591735839844, + "y": 243.93621826171875, + "pressure": 2456, + "timestamp": 1774179089647 + }, + { + "x": 375.0497741699219, + "y": 243.93621826171875, + "pressure": 2454, + "timestamp": 1774179089648 + }, + { + "x": 375.84228515625, + "y": 243.73809814453125, + "pressure": 2453, + "timestamp": 1774179089651 + }, + { + "x": 376.6347961425781, + "y": 243.5400390625, + "pressure": 2455, + "timestamp": 1774179089653 + }, + { + "x": 377.4272766113281, + "y": 243.3419189453125, + "pressure": 2454, + "timestamp": 1774179089653 + }, + { + "x": 378.21978759765625, + "y": 243.143798828125, + "pressure": 2453, + "timestamp": 1774179089655 + }, + { + "x": 379.01226806640625, + "y": 242.94573974609375, + "pressure": 2455, + "timestamp": 1774179089657 + }, + { + "x": 379.4085388183594, + "y": 242.74761962890625, + "pressure": 2454, + "timestamp": 1774179089659 + }, + { + "x": 379.8047790527344, + "y": 242.74761962890625, + "pressure": 2455, + "timestamp": 1774179089668 + }, + { + "x": 380.5972595214844, + "y": 242.54949951171875, + "pressure": 2457, + "timestamp": 1774179089669 + }, + { + "x": 381.3897705078125, + "y": 242.3514404296875, + "pressure": 2456, + "timestamp": 1774179089671 + }, + { + "x": 381.7860107421875, + "y": 242.1533203125, + "pressure": 2455, + "timestamp": 1774179089673 + }, + { + "x": 381.7860107421875, + "y": 242.1533203125, + "pressure": 2463, + "timestamp": 1774179089680 + }, + { + "x": 381.7860107421875, + "y": 242.1533203125, + "pressure": 2467, + "timestamp": 1774179089683 + }, + { + "x": 381.7860107421875, + "y": 242.1533203125, + "pressure": 2470, + "timestamp": 1774179089686 + }, + { + "x": 381.7860107421875, + "y": 242.1533203125, + "pressure": 2473, + "timestamp": 1774179089691 + }, + { + "x": 381.7860107421875, + "y": 242.1533203125, + "pressure": 2512, + "timestamp": 1774179089696 + }, + { + "x": 381.7860107421875, + "y": 242.1533203125, + "pressure": 2532, + "timestamp": 1774179089699 + }, + { + "x": 381.7860107421875, + "y": 242.1533203125, + "pressure": 2542, + "timestamp": 1774179089700 + }, + { + "x": 381.7860107421875, + "y": 242.1533203125, + "pressure": 2547, + "timestamp": 1774179089702 + }, + { + "x": 381.7860107421875, + "y": 242.1533203125, + "pressure": 2550, + "timestamp": 1774179089703 + }, + { + "x": 381.7860107421875, + "y": 242.1533203125, + "pressure": 2554, + "timestamp": 1774179089711 + }, + { + "x": 381.7860107421875, + "y": 242.1533203125, + "pressure": 2569, + "timestamp": 1774179089712 + }, + { + "x": 381.7860107421875, + "y": 242.1533203125, + "pressure": 2576, + "timestamp": 1774179089715 + }, + { + "x": 381.7860107421875, + "y": 242.1533203125, + "pressure": 2580, + "timestamp": 1774179089716 + }, + { + "x": 381.7860107421875, + "y": 242.1533203125, + "pressure": 2583, + "timestamp": 1774179089720 + }, + { + "x": 381.7860107421875, + "y": 242.1533203125, + "pressure": 2586, + "timestamp": 1774179089727 + }, + { + "x": 381.7860107421875, + "y": 242.54949951171875, + "pressure": 2587, + "timestamp": 1774179089728 + }, + { + "x": 381.984130859375, + "y": 243.3419189453125, + "pressure": 2589, + "timestamp": 1774179089731 + }, + { + "x": 382.1822814941406, + "y": 243.73809814453125, + "pressure": 2588, + "timestamp": 1774179089733 + }, + { + "x": 382.1822814941406, + "y": 244.13433837890625, + "pressure": 2590, + "timestamp": 1774179089734 + }, + { + "x": 382.1822814941406, + "y": 244.9267578125, + "pressure": 2589, + "timestamp": 1774179089735 + }, + { + "x": 381.984130859375, + "y": 245.7191162109375, + "pressure": 2588, + "timestamp": 1774179089737 + }, + { + "x": 381.7860107421875, + "y": 246.51153564453125, + "pressure": 2590, + "timestamp": 1774179089739 + }, + { + "x": 381.587890625, + "y": 247.303955078125, + "pressure": 2589, + "timestamp": 1774179089743 + }, + { + "x": 381.3897705078125, + "y": 248.0963134765625, + "pressure": 2590, + "timestamp": 1774179089745 + }, + { + "x": 380.9935302734375, + "y": 248.88873291015625, + "pressure": 2591, + "timestamp": 1774179089747 + }, + { + "x": 380.5972595214844, + "y": 249.68115234375, + "pressure": 2593, + "timestamp": 1774179089748 + }, + { + "x": 380.2010192871094, + "y": 250.47357177734375, + "pressure": 2592, + "timestamp": 1774179089750 + }, + { + "x": 379.8047790527344, + "y": 251.26593017578125, + "pressure": 2594, + "timestamp": 1774179089751 + }, + { + "x": 379.4085388183594, + "y": 252.058349609375, + "pressure": 2593, + "timestamp": 1774179089753 + }, + { + "x": 379.01226806640625, + "y": 252.85076904296875, + "pressure": 2592, + "timestamp": 1774179089755 + }, + { + "x": 378.61602783203125, + "y": 253.6431884765625, + "pressure": 2594, + "timestamp": 1774179089759 + }, + { + "x": 378.41790771484375, + "y": 254.03936767578125, + "pressure": 2590, + "timestamp": 1774179089760 + }, + { + "x": 378.21978759765625, + "y": 254.435546875, + "pressure": 2588, + "timestamp": 1774179089763 + }, + { + "x": 377.6253967285156, + "y": 255.42608642578125, + "pressure": 2587, + "timestamp": 1774179089764 + }, + { + "x": 377.0310363769531, + "y": 256.41656494140625, + "pressure": 2589, + "timestamp": 1774179089766 + }, + { + "x": 376.6347961425781, + "y": 257.4071044921875, + "pressure": 2588, + "timestamp": 1774179089768 + }, + { + "x": 376.238525390625, + "y": 258.199462890625, + "pressure": 2587, + "timestamp": 1774179089769 + }, + { + "x": 375.84228515625, + "y": 258.99188232421875, + "pressure": 2589, + "timestamp": 1774179089771 + }, + { + "x": 375.84228515625, + "y": 259.3880615234375, + "pressure": 2588, + "timestamp": 1774179089775 + }, + { + "x": 375.84228515625, + "y": 259.3880615234375, + "pressure": 2577, + "timestamp": 1774179089777 + }, + { + "x": 375.84228515625, + "y": 259.3880615234375, + "pressure": 2572, + "timestamp": 1774179089779 + }, + { + "x": 375.6441650390625, + "y": 259.7843017578125, + "pressure": 2569, + "timestamp": 1774179089780 + }, + { + "x": 375.446044921875, + "y": 260.57666015625, + "pressure": 2568, + "timestamp": 1774179089782 + }, + { + "x": 375.446044921875, + "y": 261.36907958984375, + "pressure": 2567, + "timestamp": 1774179089783 + }, + { + "x": 375.446044921875, + "y": 261.76531982421875, + "pressure": 2569, + "timestamp": 1774179089785 + }, + { + "x": 375.446044921875, + "y": 261.76531982421875, + "pressure": 2524, + "timestamp": 1774179089792 + }, + { + "x": 375.446044921875, + "y": 261.76531982421875, + "pressure": 2503, + "timestamp": 1774179089795 + }, + { + "x": 375.446044921875, + "y": 261.76531982421875, + "pressure": 2492, + "timestamp": 1774179089796 + }, + { + "x": 375.6441650390625, + "y": 262.1614990234375, + "pressure": 2487, + "timestamp": 1774179089798 + }, + { + "x": 376.0404052734375, + "y": 262.95391845703125, + "pressure": 2484, + "timestamp": 1774179089800 + }, + { + "x": 376.238525390625, + "y": 263.35009765625, + "pressure": 2483, + "timestamp": 1774179089801 + }, + { + "x": 376.6347961425781, + "y": 263.5482177734375, + "pressure": 2484, + "timestamp": 1774179089807 + }, + { + "x": 377.4272766113281, + "y": 264.14251708984375, + "pressure": 2472, + "timestamp": 1774179089809 + }, + { + "x": 378.21978759765625, + "y": 264.5386962890625, + "pressure": 2466, + "timestamp": 1774179089811 + }, + { + "x": 379.01226806640625, + "y": 264.93487548828125, + "pressure": 2463, + "timestamp": 1774179089812 + }, + { + "x": 379.8047790527344, + "y": 265.13299560546875, + "pressure": 2462, + "timestamp": 1774179089814 + }, + { + "x": 380.5972595214844, + "y": 265.13299560546875, + "pressure": 2461, + "timestamp": 1774179089816 + }, + { + "x": 381.3897705078125, + "y": 265.13299560546875, + "pressure": 2463, + "timestamp": 1774179089817 + }, + { + "x": 382.1822814941406, + "y": 264.93487548828125, + "pressure": 2462, + "timestamp": 1774179089820 + }, + { + "x": 382.9747619628906, + "y": 264.73681640625, + "pressure": 2461, + "timestamp": 1774179089823 + }, + { + "x": 383.76727294921875, + "y": 264.5386962890625, + "pressure": 2463, + "timestamp": 1774179089825 + }, + { + "x": 384.55975341796875, + "y": 264.340576171875, + "pressure": 2462, + "timestamp": 1774179089827 + }, + { + "x": 385.3522644042969, + "y": 264.14251708984375, + "pressure": 2461, + "timestamp": 1774179089828 + }, + { + "x": 386.144775390625, + "y": 263.94439697265625, + "pressure": 2463, + "timestamp": 1774179089830 + }, + { + "x": 386.937255859375, + "y": 263.5482177734375, + "pressure": 2462, + "timestamp": 1774179089832 + }, + { + "x": 387.7297668457031, + "y": 263.1519775390625, + "pressure": 2461, + "timestamp": 1774179089833 + }, + { + "x": 388.5222473144531, + "y": 262.75579833984375, + "pressure": 2463, + "timestamp": 1774179089835 + }, + { + "x": 389.31475830078125, + "y": 262.359619140625, + "pressure": 2462, + "timestamp": 1774179089839 + }, + { + "x": 390.10723876953125, + "y": 261.96337890625, + "pressure": 2463, + "timestamp": 1774179089841 + }, + { + "x": 390.8997497558594, + "y": 261.56719970703125, + "pressure": 2465, + "timestamp": 1774179089843 + }, + { + "x": 391.6922607421875, + "y": 260.972900390625, + "pressure": 2464, + "timestamp": 1774179089844 + }, + { + "x": 392.4847412109375, + "y": 260.37860107421875, + "pressure": 2466, + "timestamp": 1774179089846 + }, + { + "x": 393.2772521972656, + "y": 259.7843017578125, + "pressure": 2465, + "timestamp": 1774179089848 + }, + { + "x": 394.0697326660156, + "y": 259.19000244140625, + "pressure": 2464, + "timestamp": 1774179089849 + }, + { + "x": 394.86224365234375, + "y": 258.595703125, + "pressure": 2466, + "timestamp": 1774179089851 + }, + { + "x": 395.65472412109375, + "y": 258.00140380859375, + "pressure": 2465, + "timestamp": 1774179089855 + }, + { + "x": 396.4472351074219, + "y": 257.4071044921875, + "pressure": 2466, + "timestamp": 1774179089857 + }, + { + "x": 397.23974609375, + "y": 256.812744140625, + "pressure": 2468, + "timestamp": 1774179089859 + }, + { + "x": 397.635986328125, + "y": 256.41656494140625, + "pressure": 2467, + "timestamp": 1774179089860 + }, + { + "x": 397.8341064453125, + "y": 256.0203857421875, + "pressure": 2468, + "timestamp": 1774179089864 + }, + { + "x": 398.6266174316406, + "y": 255.22796630859375, + "pressure": 2467, + "timestamp": 1774179089865 + }, + { + "x": 399.4190979003906, + "y": 254.435546875, + "pressure": 2469, + "timestamp": 1774179089867 + }, + { + "x": 400.21160888671875, + "y": 253.6431884765625, + "pressure": 2468, + "timestamp": 1774179089871 + }, + { + "x": 400.80596923828125, + "y": 252.85076904296875, + "pressure": 2469, + "timestamp": 1774179089873 + }, + { + "x": 401.4003601074219, + "y": 252.058349609375, + "pressure": 2470, + "timestamp": 1774179089875 + }, + { + "x": 401.5984802246094, + "y": 251.66217041015625, + "pressure": 2472, + "timestamp": 1774179089876 + }, + { + "x": 401.7966003417969, + "y": 251.26593017578125, + "pressure": 2472, + "timestamp": 1774179089881 + }, + { + "x": 402.3909606933594, + "y": 250.47357177734375, + "pressure": 2471, + "timestamp": 1774179089883 + }, + { + "x": 402.9853515625, + "y": 249.68115234375, + "pressure": 2473, + "timestamp": 1774179089887 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2478, + "timestamp": 1774179089889 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2481, + "timestamp": 1774179089893 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2484, + "timestamp": 1774179089896 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2498, + "timestamp": 1774179089905 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2505, + "timestamp": 1774179089907 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2509, + "timestamp": 1774179089909 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2512, + "timestamp": 1774179089912 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2515, + "timestamp": 1774179089919 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2577, + "timestamp": 1774179089921 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2608, + "timestamp": 1774179089923 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2624, + "timestamp": 1774179089925 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2632, + "timestamp": 1774179089926 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2636, + "timestamp": 1774179089928 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2639, + "timestamp": 1774179089931 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2643, + "timestamp": 1774179089937 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2634, + "timestamp": 1774179089985 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2629, + "timestamp": 1774179089987 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2626, + "timestamp": 1774179089989 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2608, + "timestamp": 1774179090001 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2600, + "timestamp": 1774179090003 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2596, + "timestamp": 1774179090005 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2593, + "timestamp": 1774179090008 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2571, + "timestamp": 1774179090017 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2560, + "timestamp": 1774179090019 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2554, + "timestamp": 1774179090021 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2551, + "timestamp": 1774179090023 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2536, + "timestamp": 1774179090033 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2529, + "timestamp": 1774179090036 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2525, + "timestamp": 1774179090037 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2522, + "timestamp": 1774179090040 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2525, + "timestamp": 1774179090065 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2529, + "timestamp": 1774179090071 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2577, + "timestamp": 1774179090081 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2602, + "timestamp": 1774179090084 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2614, + "timestamp": 1774179090085 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2620, + "timestamp": 1774179090087 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2623, + "timestamp": 1774179090089 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2626, + "timestamp": 1774179090092 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2643, + "timestamp": 1774179090097 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2651, + "timestamp": 1774179090100 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2655, + "timestamp": 1774179090101 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2658, + "timestamp": 1774179090104 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2661, + "timestamp": 1774179090112 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2649, + "timestamp": 1774179090113 + }, + { + "x": 403.1834716796875, + "y": 249.28497314453125, + "pressure": 2643, + "timestamp": 1774179090116 + }, + { + "x": 402.7872314453125, + "y": 249.4830322265625, + "pressure": 2640, + "timestamp": 1774179090117 + }, + { + "x": 401.9947204589844, + "y": 249.68115234375, + "pressure": 2638, + "timestamp": 1774179090119 + }, + { + "x": 401.5984802246094, + "y": 249.68115234375, + "pressure": 2637, + "timestamp": 1774179090121 + }, + { + "x": 401.2022399902344, + "y": 250.07733154296875, + "pressure": 2637, + "timestamp": 1774179090128 + }, + { + "x": 400.40972900390625, + "y": 250.671630859375, + "pressure": 2629, + "timestamp": 1774179090130 + }, + { + "x": 400.01348876953125, + "y": 250.8697509765625, + "pressure": 2625, + "timestamp": 1774179090132 + }, + { + "x": 400.01348876953125, + "y": 250.8697509765625, + "pressure": 2622, + "timestamp": 1774179090135 + }, + { + "x": 399.6172180175781, + "y": 251.26593017578125, + "pressure": 2624, + "timestamp": 1774179090136 + }, + { + "x": 399.0228576660156, + "y": 252.058349609375, + "pressure": 2623, + "timestamp": 1774179090138 + }, + { + "x": 398.428466796875, + "y": 252.85076904296875, + "pressure": 2622, + "timestamp": 1774179090140 + }, + { + "x": 398.2303466796875, + "y": 253.2469482421875, + "pressure": 2624, + "timestamp": 1774179090144 + }, + { + "x": 398.2303466796875, + "y": 253.2469482421875, + "pressure": 2609, + "timestamp": 1774179090145 + }, + { + "x": 398.2303466796875, + "y": 253.2469482421875, + "pressure": 2601, + "timestamp": 1774179090148 + }, + { + "x": 398.2303466796875, + "y": 253.2469482421875, + "pressure": 2597, + "timestamp": 1774179090149 + }, + { + "x": 398.0322265625, + "y": 253.6431884765625, + "pressure": 2595, + "timestamp": 1774179090151 + }, + { + "x": 397.635986328125, + "y": 254.435546875, + "pressure": 2594, + "timestamp": 1774179090152 + }, + { + "x": 397.4378662109375, + "y": 254.831787109375, + "pressure": 2596, + "timestamp": 1774179090154 + }, + { + "x": 397.23974609375, + "y": 255.22796630859375, + "pressure": 2595, + "timestamp": 1774179090156 + }, + { + "x": 397.0415954589844, + "y": 256.21844482421875, + "pressure": 2594, + "timestamp": 1774179090160 + }, + { + "x": 397.0415954589844, + "y": 257.0108642578125, + "pressure": 2572, + "timestamp": 1774179090161 + }, + { + "x": 397.0415954589844, + "y": 257.4071044921875, + "pressure": 2561, + "timestamp": 1774179090164 + }, + { + "x": 397.0415954589844, + "y": 257.4071044921875, + "pressure": 2556, + "timestamp": 1774179090165 + }, + { + "x": 397.0415954589844, + "y": 257.4071044921875, + "pressure": 2553, + "timestamp": 1774179090167 + }, + { + "x": 397.0415954589844, + "y": 257.4071044921875, + "pressure": 2535, + "timestamp": 1774179090178 + }, + { + "x": 397.0415954589844, + "y": 257.4071044921875, + "pressure": 2526, + "timestamp": 1774179090180 + }, + { + "x": 397.4378662109375, + "y": 257.80328369140625, + "pressure": 2522, + "timestamp": 1774179090181 + }, + { + "x": 398.2303466796875, + "y": 258.3975830078125, + "pressure": 2520, + "timestamp": 1774179090183 + }, + { + "x": 399.0228576660156, + "y": 258.99188232421875, + "pressure": 2519, + "timestamp": 1774179090185 + }, + { + "x": 399.4190979003906, + "y": 259.19000244140625, + "pressure": 2518, + "timestamp": 1774179090186 + }, + { + "x": 399.81536865234375, + "y": 259.3880615234375, + "pressure": 2519, + "timestamp": 1774179090192 + }, + { + "x": 400.60784912109375, + "y": 259.98236083984375, + "pressure": 2510, + "timestamp": 1774179090194 + }, + { + "x": 401.4003601074219, + "y": 260.37860107421875, + "pressure": 2506, + "timestamp": 1774179090196 + }, + { + "x": 402.1928405761719, + "y": 260.7747802734375, + "pressure": 2504, + "timestamp": 1774179090197 + }, + { + "x": 402.9853515625, + "y": 260.972900390625, + "pressure": 2503, + "timestamp": 1774179090199 + }, + { + "x": 403.381591796875, + "y": 260.972900390625, + "pressure": 2502, + "timestamp": 1774179090200 + }, + { + "x": 403.77783203125, + "y": 260.972900390625, + "pressure": 2502, + "timestamp": 1774179090208 + }, + { + "x": 404.5703430175781, + "y": 260.972900390625, + "pressure": 2492, + "timestamp": 1774179090210 + }, + { + "x": 405.36285400390625, + "y": 260.972900390625, + "pressure": 2487, + "timestamp": 1774179090212 + }, + { + "x": 405.75909423828125, + "y": 260.972900390625, + "pressure": 2484, + "timestamp": 1774179090213 + }, + { + "x": 406.15533447265625, + "y": 260.972900390625, + "pressure": 2484, + "timestamp": 1774179090218 + }, + { + "x": 406.9478454589844, + "y": 260.57666015625, + "pressure": 2483, + "timestamp": 1774179090220 + }, + { + "x": 407.7403259277344, + "y": 260.18048095703125, + "pressure": 2482, + "timestamp": 1774179090224 + }, + { + "x": 408.1365966796875, + "y": 259.7843017578125, + "pressure": 2485, + "timestamp": 1774179090226 + }, + { + "x": 408.1365966796875, + "y": 259.7843017578125, + "pressure": 2489, + "timestamp": 1774179090231 + }, + { + "x": 408.5328369140625, + "y": 259.586181640625, + "pressure": 2490, + "timestamp": 1774179090235 + }, + { + "x": 409.3253173828125, + "y": 258.99188232421875, + "pressure": 2489, + "timestamp": 1774179090236 + }, + { + "x": 409.7215881347656, + "y": 258.595703125, + "pressure": 2488, + "timestamp": 1774179090240 + }, + { + "x": 409.7215881347656, + "y": 258.595703125, + "pressure": 2501, + "timestamp": 1774179090242 + }, + { + "x": 409.7215881347656, + "y": 258.595703125, + "pressure": 2508, + "timestamp": 1774179090244 + }, + { + "x": 409.7215881347656, + "y": 258.595703125, + "pressure": 2511, + "timestamp": 1774179090246 + }, + { + "x": 409.9197082519531, + "y": 258.199462890625, + "pressure": 2513, + "timestamp": 1774179090247 + }, + { + "x": 410.5140686035156, + "y": 257.4071044921875, + "pressure": 2514, + "timestamp": 1774179090249 + }, + { + "x": 410.7121887207031, + "y": 256.61468505859375, + "pressure": 2516, + "timestamp": 1774179090250 + }, + { + "x": 410.91033935546875, + "y": 255.822265625, + "pressure": 2515, + "timestamp": 1774179090252 + }, + { + "x": 410.91033935546875, + "y": 255.42608642578125, + "pressure": 2517, + "timestamp": 1774179090256 + }, + { + "x": 410.91033935546875, + "y": 255.02984619140625, + "pressure": 2520, + "timestamp": 1774179090260 + }, + { + "x": 411.10845947265625, + "y": 254.23748779296875, + "pressure": 2521, + "timestamp": 1774179090262 + }, + { + "x": 411.10845947265625, + "y": 253.445068359375, + "pressure": 2523, + "timestamp": 1774179090263 + }, + { + "x": 411.10845947265625, + "y": 252.65264892578125, + "pressure": 2522, + "timestamp": 1774179090265 + }, + { + "x": 411.10845947265625, + "y": 251.8602294921875, + "pressure": 2524, + "timestamp": 1774179090266 + }, + { + "x": 411.10845947265625, + "y": 251.06787109375, + "pressure": 2523, + "timestamp": 1774179090268 + }, + { + "x": 410.91033935546875, + "y": 250.27545166015625, + "pressure": 2522, + "timestamp": 1774179090272 + }, + { + "x": 410.7121887207031, + "y": 249.4830322265625, + "pressure": 2513, + "timestamp": 1774179090274 + }, + { + "x": 410.5140686035156, + "y": 248.69061279296875, + "pressure": 2509, + "timestamp": 1774179090276 + }, + { + "x": 410.5140686035156, + "y": 248.29443359375, + "pressure": 2507, + "timestamp": 1774179090277 + }, + { + "x": 410.3159484863281, + "y": 247.89825439453125, + "pressure": 2505, + "timestamp": 1774179090281 + }, + { + "x": 410.1178283691406, + "y": 247.1058349609375, + "pressure": 2507, + "timestamp": 1774179090282 + }, + { + "x": 409.7215881347656, + "y": 246.31341552734375, + "pressure": 2506, + "timestamp": 1774179090285 + }, + { + "x": 409.5234680175781, + "y": 245.917236328125, + "pressure": 2505, + "timestamp": 1774179090288 + }, + { + "x": 409.5234680175781, + "y": 245.917236328125, + "pressure": 2508, + "timestamp": 1774179090290 + }, + { + "x": 409.5234680175781, + "y": 245.917236328125, + "pressure": 2511, + "timestamp": 1774179090293 + }, + { + "x": 409.3253173828125, + "y": 245.52105712890625, + "pressure": 2514, + "timestamp": 1774179090298 + }, + { + "x": 409.127197265625, + "y": 245.12481689453125, + "pressure": 2513, + "timestamp": 1774179090300 + }, + { + "x": 409.127197265625, + "y": 245.12481689453125, + "pressure": 2516, + "timestamp": 1774179090309 + }, + { + "x": 409.127197265625, + "y": 245.12481689453125, + "pressure": 2519, + "timestamp": 1774179090315 + }, + { + "x": 409.127197265625, + "y": 245.12481689453125, + "pressure": 2527, + "timestamp": 1774179090322 + }, + { + "x": 409.127197265625, + "y": 245.12481689453125, + "pressure": 2532, + "timestamp": 1774179090324 + }, + { + "x": 409.127197265625, + "y": 245.12481689453125, + "pressure": 2535, + "timestamp": 1774179090327 + }, + { + "x": 409.127197265625, + "y": 245.12481689453125, + "pressure": 2538, + "timestamp": 1774179090331 + }, + { + "x": 409.127197265625, + "y": 245.12481689453125, + "pressure": 2534, + "timestamp": 1774179090338 + }, + { + "x": 409.127197265625, + "y": 245.12481689453125, + "pressure": 2531, + "timestamp": 1774179090340 + }, + { + "x": 409.127197265625, + "y": 245.12481689453125, + "pressure": 2507, + "timestamp": 1774179090354 + }, + { + "x": 409.127197265625, + "y": 245.12481689453125, + "pressure": 2495, + "timestamp": 1774179090356 + }, + { + "x": 409.127197265625, + "y": 245.12481689453125, + "pressure": 2489, + "timestamp": 1774179090358 + }, + { + "x": 409.127197265625, + "y": 245.12481689453125, + "pressure": 2486, + "timestamp": 1774179090359 + }, + { + "x": 409.127197265625, + "y": 245.12481689453125, + "pressure": 2483, + "timestamp": 1774179090363 + }, + { + "x": 409.127197265625, + "y": 245.12481689453125, + "pressure": 2465, + "timestamp": 1774179090370 + }, + { + "x": 409.127197265625, + "y": 245.12481689453125, + "pressure": 2456, + "timestamp": 1774179090372 + }, + { + "x": 409.127197265625, + "y": 245.12481689453125, + "pressure": 2451, + "timestamp": 1774179090374 + }, + { + "x": 409.127197265625, + "y": 245.12481689453125, + "pressure": 2448, + "timestamp": 1774179090377 + }, + { + "x": 409.127197265625, + "y": 245.12481689453125, + "pressure": 2433, + "timestamp": 1774179090386 + }, + { + "x": 409.127197265625, + "y": 245.12481689453125, + "pressure": 2426, + "timestamp": 1774179090388 + }, + { + "x": 409.127197265625, + "y": 245.12481689453125, + "pressure": 2422, + "timestamp": 1774179090390 + }, + { + "x": 409.127197265625, + "y": 245.12481689453125, + "pressure": 2419, + "timestamp": 1774179090393 + }, + { + "x": 409.5234680175781, + "y": 245.12481689453125, + "pressure": 2419, + "timestamp": 1774179090400 + }, + { + "x": 410.3159484863281, + "y": 244.9267578125, + "pressure": 2406, + "timestamp": 1774179090402 + }, + { + "x": 411.30657958984375, + "y": 244.7286376953125, + "pressure": 2400, + "timestamp": 1774179090404 + }, + { + "x": 412.0990905761719, + "y": 244.530517578125, + "pressure": 2397, + "timestamp": 1774179090406 + }, + { + "x": 412.8915710449219, + "y": 244.3323974609375, + "pressure": 2395, + "timestamp": 1774179090407 + }, + { + "x": 413.68408203125, + "y": 244.13433837890625, + "pressure": 2394, + "timestamp": 1774179090409 + }, + { + "x": 414.6746826171875, + "y": 243.93621826171875, + "pressure": 2396, + "timestamp": 1774179090411 + }, + { + "x": 415.6653137207031, + "y": 243.73809814453125, + "pressure": 2395, + "timestamp": 1774179090413 + }, + { + "x": 416.65594482421875, + "y": 243.5400390625, + "pressure": 2394, + "timestamp": 1774179090417 + }, + { + "x": 417.6465759277344, + "y": 243.3419189453125, + "pressure": 2395, + "timestamp": 1774179090418 + }, + { + "x": 418.6371765136719, + "y": 243.143798828125, + "pressure": 2396, + "timestamp": 1774179090420 + }, + { + "x": 419.6278076171875, + "y": 242.94573974609375, + "pressure": 2398, + "timestamp": 1774179090422 + }, + { + "x": 420.6184387207031, + "y": 242.74761962890625, + "pressure": 2397, + "timestamp": 1774179090423 + }, + { + "x": 421.6090393066406, + "y": 242.54949951171875, + "pressure": 2399, + "timestamp": 1774179090425 + }, + { + "x": 422.59967041015625, + "y": 242.3514404296875, + "pressure": 2398, + "timestamp": 1774179090427 + }, + { + "x": 423.5903015136719, + "y": 242.1533203125, + "pressure": 2397, + "timestamp": 1774179090428 + }, + { + "x": 424.5809326171875, + "y": 241.9552001953125, + "pressure": 2399, + "timestamp": 1774179090433 + }, + { + "x": 425.3734130859375, + "y": 241.55902099609375, + "pressure": 2397, + "timestamp": 1774179090435 + }, + { + "x": 426.3640441894531, + "y": 241.162841796875, + "pressure": 2396, + "timestamp": 1774179090436 + }, + { + "x": 427.35467529296875, + "y": 240.7666015625, + "pressure": 2395, + "timestamp": 1774179090438 + }, + { + "x": 428.14715576171875, + "y": 240.56854248046875, + "pressure": 2397, + "timestamp": 1774179090439 + }, + { + "x": 428.9396667480469, + "y": 240.17230224609375, + "pressure": 2396, + "timestamp": 1774179090441 + }, + { + "x": 429.7321472167969, + "y": 239.776123046875, + "pressure": 2395, + "timestamp": 1774179090443 + }, + { + "x": 430.524658203125, + "y": 239.3798828125, + "pressure": 2397, + "timestamp": 1774179090444 + }, + { + "x": 431.3171691894531, + "y": 239.18182373046875, + "pressure": 2396, + "timestamp": 1774179090449 + }, + { + "x": 431.7134094238281, + "y": 238.98370361328125, + "pressure": 2398, + "timestamp": 1774179090450 + }, + { + "x": 431.7134094238281, + "y": 238.98370361328125, + "pressure": 2401, + "timestamp": 1774179090454 + }, + { + "x": 432.1096496582031, + "y": 238.78558349609375, + "pressure": 2402, + "timestamp": 1774179090458 + }, + { + "x": 432.90216064453125, + "y": 238.389404296875, + "pressure": 2401, + "timestamp": 1774179090459 + }, + { + "x": 433.29840087890625, + "y": 238.1912841796875, + "pressure": 2400, + "timestamp": 1774179090461 + }, + { + "x": 433.69464111328125, + "y": 237.99322509765625, + "pressure": 2406, + "timestamp": 1774179090466 + }, + { + "x": 434.4871520996094, + "y": 237.59698486328125, + "pressure": 2408, + "timestamp": 1774179090468 + }, + { + "x": 434.8833923339844, + "y": 237.39892578125, + "pressure": 2409, + "timestamp": 1774179090470 + }, + { + "x": 434.8833923339844, + "y": 237.39892578125, + "pressure": 2412, + "timestamp": 1774179090475 + }, + { + "x": 434.8833923339844, + "y": 237.39892578125, + "pressure": 2415, + "timestamp": 1774179090486 + }, + { + "x": 435.2796325683594, + "y": 237.2008056640625, + "pressure": 2415, + "timestamp": 1774179090491 + }, + { + "x": 436.0721435546875, + "y": 236.60650634765625, + "pressure": 2414, + "timestamp": 1774179090493 + }, + { + "x": 436.4683837890625, + "y": 236.21026611328125, + "pressure": 2416, + "timestamp": 1774179090498 + }, + { + "x": 436.4683837890625, + "y": 236.21026611328125, + "pressure": 2415, + "timestamp": 1774179090498 + }, + { + "x": 436.66650390625, + "y": 235.8140869140625, + "pressure": 2415, + "timestamp": 1774179090514 + }, + { + "x": 437.2608947753906, + "y": 235.02166748046875, + "pressure": 2414, + "timestamp": 1774179090517 + }, + { + "x": 437.6571350097656, + "y": 234.62548828125, + "pressure": 2416, + "timestamp": 1774179090518 + }, + { + "x": 437.8552551269531, + "y": 234.22930908203125, + "pressure": 2416, + "timestamp": 1774179090523 + }, + { + "x": 438.44964599609375, + "y": 233.4368896484375, + "pressure": 2415, + "timestamp": 1774179090525 + }, + { + "x": 439.04400634765625, + "y": 232.64447021484375, + "pressure": 2414, + "timestamp": 1774179090529 + }, + { + "x": 439.4402770996094, + "y": 232.248291015625, + "pressure": 2420, + "timestamp": 1774179090530 + }, + { + "x": 439.6383972167969, + "y": 231.85205078125, + "pressure": 2423, + "timestamp": 1774179090532 + }, + { + "x": 440.2327575683594, + "y": 231.0596923828125, + "pressure": 2424, + "timestamp": 1774179090534 + }, + { + "x": 440.8271484375, + "y": 230.26727294921875, + "pressure": 2425, + "timestamp": 1774179090536 + }, + { + "x": 441.4215087890625, + "y": 229.474853515625, + "pressure": 2427, + "timestamp": 1774179090537 + }, + { + "x": 442.015869140625, + "y": 228.68246459960938, + "pressure": 2426, + "timestamp": 1774179090539 + }, + { + "x": 442.4121398925781, + "y": 227.89007568359375, + "pressure": 2428, + "timestamp": 1774179090541 + }, + { + "x": 442.6102600097656, + "y": 227.49386596679688, + "pressure": 2427, + "timestamp": 1774179090545 + }, + { + "x": 442.8083801269531, + "y": 227.09765625, + "pressure": 2428, + "timestamp": 1774179090549 + }, + { + "x": 443.4027404785156, + "y": 226.10714721679688, + "pressure": 2430, + "timestamp": 1774179090550 + }, + { + "x": 443.99713134765625, + "y": 225.11666870117188, + "pressure": 2429, + "timestamp": 1774179090552 + }, + { + "x": 444.59149169921875, + "y": 224.32424926757812, + "pressure": 2428, + "timestamp": 1774179090553 + }, + { + "x": 445.1858825683594, + "y": 223.333740234375, + "pressure": 2430, + "timestamp": 1774179090555 + }, + { + "x": 445.5821228027344, + "y": 222.34323120117188, + "pressure": 2429, + "timestamp": 1774179090557 + }, + { + "x": 445.9783630371094, + "y": 221.55084228515625, + "pressure": 2428, + "timestamp": 1774179090561 + }, + { + "x": 446.57275390625, + "y": 220.75845336914062, + "pressure": 2430, + "timestamp": 1774179090562 + }, + { + "x": 446.968994140625, + "y": 219.96603393554688, + "pressure": 2431, + "timestamp": 1774179090565 + }, + { + "x": 447.365234375, + "y": 219.17364501953125, + "pressure": 2432, + "timestamp": 1774179090566 + }, + { + "x": 447.7615051269531, + "y": 218.3812255859375, + "pressure": 2434, + "timestamp": 1774179090568 + }, + { + "x": 447.9596252441406, + "y": 217.58883666992188, + "pressure": 2433, + "timestamp": 1774179090570 + }, + { + "x": 448.1577453613281, + "y": 216.79641723632812, + "pressure": 2435, + "timestamp": 1774179090571 + }, + { + "x": 448.3558654785156, + "y": 216.0040283203125, + "pressure": 2434, + "timestamp": 1774179090573 + }, + { + "x": 448.5539855957031, + "y": 215.21160888671875, + "pressure": 2433, + "timestamp": 1774179090577 + }, + { + "x": 448.7521057128906, + "y": 214.41921997070312, + "pressure": 2441, + "timestamp": 1774179090578 + }, + { + "x": 448.9502258300781, + "y": 213.62680053710938, + "pressure": 2445, + "timestamp": 1774179090581 + }, + { + "x": 449.14837646484375, + "y": 212.83441162109375, + "pressure": 2447, + "timestamp": 1774179090582 + }, + { + "x": 449.34649658203125, + "y": 212.04202270507812, + "pressure": 2448, + "timestamp": 1774179090584 + }, + { + "x": 449.54461669921875, + "y": 211.24960327148438, + "pressure": 2449, + "timestamp": 1774179090585 + }, + { + "x": 449.54461669921875, + "y": 210.45721435546875, + "pressure": 2451, + "timestamp": 1774179090587 + }, + { + "x": 449.54461669921875, + "y": 209.664794921875, + "pressure": 2450, + "timestamp": 1774179090589 + }, + { + "x": 449.54461669921875, + "y": 208.87240600585938, + "pressure": 2452, + "timestamp": 1774179090593 + }, + { + "x": 449.54461669921875, + "y": 208.4761962890625, + "pressure": 2466, + "timestamp": 1774179090594 + }, + { + "x": 449.54461669921875, + "y": 208.4761962890625, + "pressure": 2473, + "timestamp": 1774179090597 + }, + { + "x": 449.34649658203125, + "y": 208.07998657226562, + "pressure": 2477, + "timestamp": 1774179090598 + }, + { + "x": 449.14837646484375, + "y": 207.28759765625, + "pressure": 2479, + "timestamp": 1774179090600 + }, + { + "x": 448.9502258300781, + "y": 206.89138793945312, + "pressure": 2480, + "timestamp": 1774179090601 + }, + { + "x": 448.9502258300781, + "y": 206.89138793945312, + "pressure": 2483, + "timestamp": 1774179090610 + }, + { + "x": 448.9502258300781, + "y": 206.89138793945312, + "pressure": 2509, + "timestamp": 1774179090610 + }, + { + "x": 448.9502258300781, + "y": 206.89138793945312, + "pressure": 2522, + "timestamp": 1774179090614 + }, + { + "x": 448.9502258300781, + "y": 206.89138793945312, + "pressure": 2528, + "timestamp": 1774179090614 + }, + { + "x": 448.9502258300781, + "y": 206.89138793945312, + "pressure": 2531, + "timestamp": 1774179090616 + }, + { + "x": 448.5539855957031, + "y": 206.69329833984375, + "pressure": 2533, + "timestamp": 1774179090618 + }, + { + "x": 447.5633544921875, + "y": 206.0989990234375, + "pressure": 2534, + "timestamp": 1774179090620 + }, + { + "x": 446.7708740234375, + "y": 205.50466918945312, + "pressure": 2536, + "timestamp": 1774179090621 + }, + { + "x": 445.9783630371094, + "y": 205.10848999023438, + "pressure": 2535, + "timestamp": 1774179090625 + }, + { + "x": 445.5821228027344, + "y": 204.91036987304688, + "pressure": 2540, + "timestamp": 1774179090626 + }, + { + "x": 445.5821228027344, + "y": 204.91036987304688, + "pressure": 2543, + "timestamp": 1774179090630 + }, + { + "x": 445.1858825683594, + "y": 204.91036987304688, + "pressure": 2544, + "timestamp": 1774179090632 + }, + { + "x": 444.19525146484375, + "y": 204.7122802734375, + "pressure": 2546, + "timestamp": 1774179090634 + }, + { + "x": 443.4027404785156, + "y": 204.91036987304688, + "pressure": 2545, + "timestamp": 1774179090635 + }, + { + "x": 442.6102600097656, + "y": 205.10848999023438, + "pressure": 2547, + "timestamp": 1774179090637 + }, + { + "x": 441.8177490234375, + "y": 205.30657958984375, + "pressure": 2546, + "timestamp": 1774179090641 + }, + { + "x": 441.4215087890625, + "y": 205.50466918945312, + "pressure": 2535, + "timestamp": 1774179090643 + }, + { + "x": 441.0252685546875, + "y": 205.90087890625, + "pressure": 2530, + "timestamp": 1774179090645 + }, + { + "x": 440.0346374511719, + "y": 206.69329833984375, + "pressure": 2527, + "timestamp": 1774179090646 + }, + { + "x": 439.24212646484375, + "y": 207.68377685546875, + "pressure": 2526, + "timestamp": 1774179090648 + }, + { + "x": 438.44964599609375, + "y": 208.67428588867188, + "pressure": 2525, + "timestamp": 1774179090650 + }, + { + "x": 437.6571350097656, + "y": 209.664794921875, + "pressure": 2527, + "timestamp": 1774179090651 + }, + { + "x": 436.8646545410156, + "y": 210.65530395507812, + "pressure": 2526, + "timestamp": 1774179090653 + }, + { + "x": 436.0721435546875, + "y": 211.84390258789062, + "pressure": 2525, + "timestamp": 1774179090657 + }, + { + "x": 435.477783203125, + "y": 213.23062133789062, + "pressure": 2515, + "timestamp": 1774179090659 + }, + { + "x": 434.8833923339844, + "y": 214.6173095703125, + "pressure": 2510, + "timestamp": 1774179090661 + }, + { + "x": 434.0909118652344, + "y": 216.0040283203125, + "pressure": 2508, + "timestamp": 1774179090662 + }, + { + "x": 433.29840087890625, + "y": 217.39071655273438, + "pressure": 2507, + "timestamp": 1774179090664 + }, + { + "x": 432.5058898925781, + "y": 219.17364501953125, + "pressure": 2506, + "timestamp": 1774179090666 + }, + { + "x": 431.9115295410156, + "y": 220.95654296875, + "pressure": 2508, + "timestamp": 1774179090667 + }, + { + "x": 431.1190185546875, + "y": 222.73944091796875, + "pressure": 2507, + "timestamp": 1774179090669 + }, + { + "x": 430.3265380859375, + "y": 224.5223388671875, + "pressure": 2506, + "timestamp": 1774179090673 + }, + { + "x": 429.5340270996094, + "y": 226.30526733398438, + "pressure": 2434, + "timestamp": 1774179090675 + }, + { + "x": 428.7415466308594, + "y": 228.2862548828125, + "pressure": 2398, + "timestamp": 1774179090677 + }, + { + "x": 427.94903564453125, + "y": 230.26727294921875, + "pressure": 2380, + "timestamp": 1774179090678 + }, + { + "x": 427.15655517578125, + "y": 232.248291015625, + "pressure": 2371, + "timestamp": 1774179090680 + }, + { + "x": 426.3640441894531, + "y": 234.22930908203125, + "pressure": 2367, + "timestamp": 1774179090682 + }, + { + "x": 425.571533203125, + "y": 236.21026611328125, + "pressure": 2365, + "timestamp": 1774179090684 + }, + { + "x": 424.9771728515625, + "y": 237.99322509765625, + "pressure": 2364, + "timestamp": 1774179090685 + }, + { + "x": 424.3827819824219, + "y": 239.776123046875, + "pressure": 2363, + "timestamp": 1774179090689 + }, + { + "x": 423.9865417480469, + "y": 241.55902099609375, + "pressure": 2357, + "timestamp": 1774179090691 + }, + { + "x": 423.5903015136719, + "y": 243.143798828125, + "pressure": 2354, + "timestamp": 1774179090693 + }, + { + "x": 423.1940612792969, + "y": 244.7286376953125, + "pressure": 2353, + "timestamp": 1774179090694 + }, + { + "x": 422.79779052734375, + "y": 246.31341552734375, + "pressure": 2352, + "timestamp": 1774179090696 + }, + { + "x": 422.40155029296875, + "y": 247.89825439453125, + "pressure": 2354, + "timestamp": 1774179090698 + }, + { + "x": 422.00531005859375, + "y": 249.28497314453125, + "pressure": 2353, + "timestamp": 1774179090699 + }, + { + "x": 422.00531005859375, + "y": 250.671630859375, + "pressure": 2352, + "timestamp": 1774179090701 + }, + { + "x": 422.00531005859375, + "y": 252.058349609375, + "pressure": 2354, + "timestamp": 1774179090705 + }, + { + "x": 422.00531005859375, + "y": 253.445068359375, + "pressure": 2356, + "timestamp": 1774179090707 + }, + { + "x": 422.00531005859375, + "y": 254.6336669921875, + "pressure": 2357, + "timestamp": 1774179090709 + }, + { + "x": 422.20343017578125, + "y": 255.822265625, + "pressure": 2359, + "timestamp": 1774179090710 + }, + { + "x": 422.40155029296875, + "y": 257.0108642578125, + "pressure": 2358, + "timestamp": 1774179090712 + }, + { + "x": 422.59967041015625, + "y": 258.199462890625, + "pressure": 2360, + "timestamp": 1774179090714 + }, + { + "x": 422.99591064453125, + "y": 259.19000244140625, + "pressure": 2359, + "timestamp": 1774179090715 + }, + { + "x": 423.3921813964844, + "y": 259.98236083984375, + "pressure": 2358, + "timestamp": 1774179090717 + }, + { + "x": 423.5903015136719, + "y": 260.37860107421875, + "pressure": 2360, + "timestamp": 1774179090721 + }, + { + "x": 423.7884216308594, + "y": 260.7747802734375, + "pressure": 2369, + "timestamp": 1774179090723 + }, + { + "x": 424.3827819824219, + "y": 261.56719970703125, + "pressure": 2374, + "timestamp": 1774179090725 + }, + { + "x": 424.779052734375, + "y": 261.96337890625, + "pressure": 2376, + "timestamp": 1774179090727 + }, + { + "x": 425.17529296875, + "y": 262.359619140625, + "pressure": 2377, + "timestamp": 1774179090728 + }, + { + "x": 425.9678039550781, + "y": 262.95391845703125, + "pressure": 2378, + "timestamp": 1774179090730 + }, + { + "x": 426.7602844238281, + "y": 263.35009765625, + "pressure": 2380, + "timestamp": 1774179090731 + }, + { + "x": 427.55279541015625, + "y": 263.74627685546875, + "pressure": 2379, + "timestamp": 1774179090733 + }, + { + "x": 428.34527587890625, + "y": 264.14251708984375, + "pressure": 2381, + "timestamp": 1774179090737 + }, + { + "x": 429.1377868652344, + "y": 264.5386962890625, + "pressure": 2370, + "timestamp": 1774179090739 + }, + { + "x": 430.12841796875, + "y": 264.5386962890625, + "pressure": 2364, + "timestamp": 1774179090741 + }, + { + "x": 430.9208984375, + "y": 264.5386962890625, + "pressure": 2361, + "timestamp": 1774179090742 + }, + { + "x": 431.7134094238281, + "y": 264.5386962890625, + "pressure": 2360, + "timestamp": 1774179090744 + }, + { + "x": 432.5058898925781, + "y": 264.5386962890625, + "pressure": 2359, + "timestamp": 1774179090746 + }, + { + "x": 433.49652099609375, + "y": 264.340576171875, + "pressure": 2361, + "timestamp": 1774179090748 + }, + { + "x": 434.4871520996094, + "y": 264.14251708984375, + "pressure": 2360, + "timestamp": 1774179090749 + }, + { + "x": 435.2796325683594, + "y": 263.94439697265625, + "pressure": 2359, + "timestamp": 1774179090753 + }, + { + "x": 436.0721435546875, + "y": 263.74627685546875, + "pressure": 2352, + "timestamp": 1774179090755 + }, + { + "x": 436.8646545410156, + "y": 263.35009765625, + "pressure": 2349, + "timestamp": 1774179090757 + }, + { + "x": 437.6571350097656, + "y": 262.95391845703125, + "pressure": 2347, + "timestamp": 1774179090758 + }, + { + "x": 438.44964599609375, + "y": 262.55767822265625, + "pressure": 2346, + "timestamp": 1774179090760 + }, + { + "x": 438.84588623046875, + "y": 262.359619140625, + "pressure": 2348, + "timestamp": 1774179090762 + }, + { + "x": 439.24212646484375, + "y": 261.96337890625, + "pressure": 2346, + "timestamp": 1774179090765 + }, + { + "x": 440.0346374511719, + "y": 261.17095947265625, + "pressure": 2348, + "timestamp": 1774179090770 + }, + { + "x": 440.8271484375, + "y": 260.37860107421875, + "pressure": 2356, + "timestamp": 1774179090771 + }, + { + "x": 441.61962890625, + "y": 259.586181640625, + "pressure": 2360, + "timestamp": 1774179090773 + }, + { + "x": 442.4121398925781, + "y": 258.79376220703125, + "pressure": 2362, + "timestamp": 1774179090775 + }, + { + "x": 443.0065002441406, + "y": 258.00140380859375, + "pressure": 2363, + "timestamp": 1774179090776 + }, + { + "x": 443.60089111328125, + "y": 257.208984375, + "pressure": 2365, + "timestamp": 1774179090778 + }, + { + "x": 443.99713134765625, + "y": 256.41656494140625, + "pressure": 2364, + "timestamp": 1774179090780 + }, + { + "x": 444.39337158203125, + "y": 255.6241455078125, + "pressure": 2366, + "timestamp": 1774179090781 + }, + { + "x": 444.9877624511719, + "y": 254.831787109375, + "pressure": 2365, + "timestamp": 1774179090786 + }, + { + "x": 445.5821228027344, + "y": 254.03936767578125, + "pressure": 2369, + "timestamp": 1774179090788 + }, + { + "x": 446.1764831542969, + "y": 253.2469482421875, + "pressure": 2371, + "timestamp": 1774179090790 + }, + { + "x": 446.57275390625, + "y": 252.45452880859375, + "pressure": 2372, + "timestamp": 1774179090791 + }, + { + "x": 446.968994140625, + "y": 251.66217041015625, + "pressure": 2374, + "timestamp": 1774179090792 + }, + { + "x": 447.365234375, + "y": 250.8697509765625, + "pressure": 2373, + "timestamp": 1774179090794 + }, + { + "x": 447.7615051269531, + "y": 250.07733154296875, + "pressure": 2375, + "timestamp": 1774179090796 + }, + { + "x": 448.1577453613281, + "y": 249.28497314453125, + "pressure": 2374, + "timestamp": 1774179090797 + }, + { + "x": 448.5539855957031, + "y": 248.4925537109375, + "pressure": 2373, + "timestamp": 1774179090802 + }, + { + "x": 448.9502258300781, + "y": 247.70013427734375, + "pressure": 2375, + "timestamp": 1774179090803 + }, + { + "x": 449.14837646484375, + "y": 247.303955078125, + "pressure": 2376, + "timestamp": 1774179090805 + }, + { + "x": 449.34649658203125, + "y": 246.90771484375, + "pressure": 2377, + "timestamp": 1774179090808 + }, + { + "x": 449.94085693359375, + "y": 246.1153564453125, + "pressure": 2379, + "timestamp": 1774179090810 + }, + { + "x": 450.33709716796875, + "y": 245.32293701171875, + "pressure": 2378, + "timestamp": 1774179090812 + }, + { + "x": 450.5352478027344, + "y": 244.9267578125, + "pressure": 2377, + "timestamp": 1774179090813 + }, + { + "x": 450.5352478027344, + "y": 244.9267578125, + "pressure": 2385, + "timestamp": 1774179090819 + }, + { + "x": 450.5352478027344, + "y": 244.9267578125, + "pressure": 2388, + "timestamp": 1774179090821 + }, + { + "x": 450.7333679199219, + "y": 244.530517578125, + "pressure": 2391, + "timestamp": 1774179090825 + }, + { + "x": 450.9314880371094, + "y": 243.73809814453125, + "pressure": 2393, + "timestamp": 1774179090826 + }, + { + "x": 451.1296081542969, + "y": 243.3419189453125, + "pressure": 2392, + "timestamp": 1774179090828 + }, + { + "x": 451.1296081542969, + "y": 243.3419189453125, + "pressure": 2403, + "timestamp": 1774179090835 + }, + { + "x": 451.1296081542969, + "y": 242.94573974609375, + "pressure": 2408, + "timestamp": 1774179090838 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2410, + "timestamp": 1774179090839 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2414, + "timestamp": 1774179090844 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2434, + "timestamp": 1774179090851 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2443, + "timestamp": 1774179090854 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2448, + "timestamp": 1774179090855 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2451, + "timestamp": 1774179090858 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2454, + "timestamp": 1774179090861 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2469, + "timestamp": 1774179090867 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2477, + "timestamp": 1774179090870 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2481, + "timestamp": 1774179090871 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2484, + "timestamp": 1774179090875 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2487, + "timestamp": 1774179090878 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2481, + "timestamp": 1774179090883 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2478, + "timestamp": 1774179090887 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2467, + "timestamp": 1774179090899 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2461, + "timestamp": 1774179090901 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2458, + "timestamp": 1774179090903 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2440, + "timestamp": 1774179090915 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2432, + "timestamp": 1774179090917 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2428, + "timestamp": 1774179090919 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2425, + "timestamp": 1774179090922 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2411, + "timestamp": 1774179090931 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2404, + "timestamp": 1774179090933 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2401, + "timestamp": 1774179090935 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2398, + "timestamp": 1774179090938 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2402, + "timestamp": 1774179090947 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2405, + "timestamp": 1774179090951 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2408, + "timestamp": 1774179090954 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2426, + "timestamp": 1774179090964 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2435, + "timestamp": 1774179090965 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2439, + "timestamp": 1774179090967 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2442, + "timestamp": 1774179090970 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2445, + "timestamp": 1774179090974 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2455, + "timestamp": 1774179090979 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2460, + "timestamp": 1774179090982 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2463, + "timestamp": 1774179090983 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2467, + "timestamp": 1774179090988 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2464, + "timestamp": 1774179090998 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2453, + "timestamp": 1774179091011 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2447, + "timestamp": 1774179091014 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2444, + "timestamp": 1774179091015 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2436, + "timestamp": 1774179091027 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2433, + "timestamp": 1774179091030 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2430, + "timestamp": 1774179091033 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2433, + "timestamp": 1774179091043 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2436, + "timestamp": 1774179091047 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2442, + "timestamp": 1774179091060 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2445, + "timestamp": 1774179091062 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2449, + "timestamp": 1774179091067 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2452, + "timestamp": 1774179091078 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2455, + "timestamp": 1774179091083 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2447, + "timestamp": 1774179091092 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2443, + "timestamp": 1774179091094 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2440, + "timestamp": 1774179091097 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2432, + "timestamp": 1774179091108 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2427, + "timestamp": 1774179091110 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2424, + "timestamp": 1774179091112 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2421, + "timestamp": 1774179091124 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2434, + "timestamp": 1774179091140 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2440, + "timestamp": 1774179091142 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2443, + "timestamp": 1774179091143 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2447, + "timestamp": 1774179091148 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2467, + "timestamp": 1774179091156 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2476, + "timestamp": 1774179091158 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2481, + "timestamp": 1774179091159 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2484, + "timestamp": 1774179091163 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2487, + "timestamp": 1774179091168 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2491, + "timestamp": 1774179091174 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2494, + "timestamp": 1774179091177 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2484, + "timestamp": 1774179091188 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2480, + "timestamp": 1774179091190 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2477, + "timestamp": 1774179091193 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2470, + "timestamp": 1774179091204 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2467, + "timestamp": 1774179091206 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2470, + "timestamp": 1774179091238 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2473, + "timestamp": 1774179091243 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2476, + "timestamp": 1774179091256 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2481, + "timestamp": 1774179091301 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2484, + "timestamp": 1774179091303 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2488, + "timestamp": 1774179091307 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2493, + "timestamp": 1774179091316 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2496, + "timestamp": 1774179091320 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2499, + "timestamp": 1774179091323 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2503, + "timestamp": 1774179091336 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2506, + "timestamp": 1774179091364 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2509, + "timestamp": 1774179091368 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2512, + "timestamp": 1774179091373 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2519, + "timestamp": 1774179091380 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2523, + "timestamp": 1774179091383 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2526, + "timestamp": 1774179091386 + }, + { + "x": 451.3277282714844, + "y": 242.54949951171875, + "pressure": 2529, + "timestamp": 1774179091389 + }, + { + "x": 451.3277282714844, + "y": 242.1533203125, + "pressure": 2528, + "timestamp": 1774179091391 + }, + { + "x": 451.5258483886719, + "y": 241.75714111328125, + "pressure": 2530, + "timestamp": 1774179091395 + }, + { + "x": 451.5258483886719, + "y": 241.75714111328125, + "pressure": 2536, + "timestamp": 1774179091396 + }, + { + "x": 451.5258483886719, + "y": 241.75714111328125, + "pressure": 2539, + "timestamp": 1774179091399 + }, + { + "x": 451.3277282714844, + "y": 241.36090087890625, + "pressure": 2543, + "timestamp": 1774179091403 + }, + { + "x": 451.3277282714844, + "y": 240.9647216796875, + "pressure": 2542, + "timestamp": 1774179091405 + }, + { + "x": 451.3277282714844, + "y": 240.9647216796875, + "pressure": 2551, + "timestamp": 1774179091413 + }, + { + "x": 451.3277282714844, + "y": 240.9647216796875, + "pressure": 2555, + "timestamp": 1774179091415 + }, + { + "x": 451.3277282714844, + "y": 240.9647216796875, + "pressure": 2558, + "timestamp": 1774179091418 + }, + { + "x": 451.3277282714844, + "y": 240.9647216796875, + "pressure": 2561, + "timestamp": 1774179091421 + }, + { + "x": 451.3277282714844, + "y": 240.9647216796875, + "pressure": 2564, + "timestamp": 1774179091428 + }, + { + "x": 451.3277282714844, + "y": 240.9647216796875, + "pressure": 2568, + "timestamp": 1774179091434 + }, + { + "x": 451.3277282714844, + "y": 240.9647216796875, + "pressure": 2560, + "timestamp": 1774179091444 + }, + { + "x": 451.3277282714844, + "y": 240.9647216796875, + "pressure": 2557, + "timestamp": 1774179091447 + }, + { + "x": 451.3277282714844, + "y": 240.9647216796875, + "pressure": 2554, + "timestamp": 1774179091450 + }, + { + "x": 451.3277282714844, + "y": 240.9647216796875, + "pressure": 2545, + "timestamp": 1774179091461 + }, + { + "x": 451.3277282714844, + "y": 240.9647216796875, + "pressure": 2540, + "timestamp": 1774179091463 + }, + { + "x": 451.3277282714844, + "y": 240.9647216796875, + "pressure": 2537, + "timestamp": 1774179091464 + }, + { + "x": 451.3277282714844, + "y": 240.9647216796875, + "pressure": 2540, + "timestamp": 1774179091480 + }, + { + "x": 451.3277282714844, + "y": 240.9647216796875, + "pressure": 2549, + "timestamp": 1774179091493 + }, + { + "x": 451.3277282714844, + "y": 240.9647216796875, + "pressure": 2553, + "timestamp": 1774179091495 + }, + { + "x": 451.3277282714844, + "y": 240.9647216796875, + "pressure": 2556, + "timestamp": 1774179091498 + }, + { + "x": 451.3277282714844, + "y": 240.9647216796875, + "pressure": 2559, + "timestamp": 1774179091501 + }, + { + "x": 451.3277282714844, + "y": 240.9647216796875, + "pressure": 2566, + "timestamp": 1774179091509 + }, + { + "x": 451.3277282714844, + "y": 240.9647216796875, + "pressure": 2569, + "timestamp": 1774179091511 + }, + { + "x": 451.3277282714844, + "y": 240.9647216796875, + "pressure": 2572, + "timestamp": 1774179091514 + }, + { + "x": 451.3277282714844, + "y": 240.9647216796875, + "pressure": 2575, + "timestamp": 1774179091519 + }, + { + "x": 451.3277282714844, + "y": 240.9647216796875, + "pressure": 2582, + "timestamp": 1774179091525 + }, + { + "x": 451.3277282714844, + "y": 240.9647216796875, + "pressure": 2586, + "timestamp": 1774179091527 + }, + { + "x": 451.3277282714844, + "y": 240.9647216796875, + "pressure": 2589, + "timestamp": 1774179091530 + }, + { + "x": 451.3277282714844, + "y": 240.9647216796875, + "pressure": 2592, + "timestamp": 1774179091533 + }, + { + "x": 451.3277282714844, + "y": 240.9647216796875, + "pressure": 2595, + "timestamp": 1774179091541 + }, + { + "x": 451.3277282714844, + "y": 240.9647216796875, + "pressure": 2599, + "timestamp": 1774179091546 + }, + { + "x": 451.3277282714844, + "y": 240.9647216796875, + "pressure": 2586, + "timestamp": 1774179091557 + }, + { + "x": 451.3277282714844, + "y": 240.9647216796875, + "pressure": 2580, + "timestamp": 1774179091559 + }, + { + "x": 451.3277282714844, + "y": 240.9647216796875, + "pressure": 2577, + "timestamp": 1774179091561 + }, + { + "x": 451.3277282714844, + "y": 240.9647216796875, + "pressure": 2574, + "timestamp": 1774179091564 + }, + { + "x": 451.3277282714844, + "y": 240.9647216796875, + "pressure": 2549, + "timestamp": 1774179091573 + }, + { + "x": 451.3277282714844, + "y": 240.9647216796875, + "pressure": 2536, + "timestamp": 1774179091575 + }, + { + "x": 451.3277282714844, + "y": 240.9647216796875, + "pressure": 2530, + "timestamp": 1774179091576 + }, + { + "x": 451.3277282714844, + "y": 240.9647216796875, + "pressure": 2527, + "timestamp": 1774179091578 + }, + { + "x": 451.3277282714844, + "y": 240.9647216796875, + "pressure": 2524, + "timestamp": 1774179091582 + }, + { + "x": 451.3277282714844, + "y": 240.9647216796875, + "pressure": 2519, + "timestamp": 1774179091589 + }, + { + "x": 451.3277282714844, + "y": 240.9647216796875, + "pressure": 2516, + "timestamp": 1774179091591 + }, + { + "x": 451.3277282714844, + "y": 240.9647216796875, + "pressure": 2519, + "timestamp": 1774179091607 + }, + { + "x": 451.3277282714844, + "y": 240.9647216796875, + "pressure": 2522, + "timestamp": 1774179091623 + }, + { + "x": 451.7239990234375, + "y": 240.9647216796875, + "pressure": 2524, + "timestamp": 1774179091625 + }, + { + "x": 452.5164794921875, + "y": 241.162841796875, + "pressure": 2523, + "timestamp": 1774179091627 + }, + { + "x": 453.3089904785156, + "y": 241.36090087890625, + "pressure": 2525, + "timestamp": 1774179091628 + }, + { + "x": 454.1014709472656, + "y": 241.55902099609375, + "pressure": 2524, + "timestamp": 1774179091630 + }, + { + "x": 454.89398193359375, + "y": 241.75714111328125, + "pressure": 2523, + "timestamp": 1774179091631 + }, + { + "x": 455.68646240234375, + "y": 241.9552001953125, + "pressure": 2525, + "timestamp": 1774179091635 + }, + { + "x": 456.4789733886719, + "y": 241.9552001953125, + "pressure": 2527, + "timestamp": 1774179091637 + }, + { + "x": 457.4696044921875, + "y": 241.9552001953125, + "pressure": 2526, + "timestamp": 1774179091639 + }, + { + "x": 458.460205078125, + "y": 241.9552001953125, + "pressure": 2528, + "timestamp": 1774179091641 + }, + { + "x": 459.4508361816406, + "y": 241.9552001953125, + "pressure": 2527, + "timestamp": 1774179091642 + }, + { + "x": 460.44146728515625, + "y": 241.9552001953125, + "pressure": 2526, + "timestamp": 1774179091644 + }, + { + "x": 461.4320983886719, + "y": 241.9552001953125, + "pressure": 2528, + "timestamp": 1774179091646 + }, + { + "x": 462.4226989746094, + "y": 241.9552001953125, + "pressure": 2527, + "timestamp": 1774179091648 + }, + { + "x": 463.413330078125, + "y": 241.9552001953125, + "pressure": 2526, + "timestamp": 1774179091652 + }, + { + "x": 464.4039611816406, + "y": 241.9552001953125, + "pressure": 2525, + "timestamp": 1774179091653 + }, + { + "x": 465.39459228515625, + "y": 241.75714111328125, + "pressure": 2524, + "timestamp": 1774179091655 + }, + { + "x": 466.38519287109375, + "y": 241.55902099609375, + "pressure": 2526, + "timestamp": 1774179091657 + }, + { + "x": 467.3758239746094, + "y": 241.36090087890625, + "pressure": 2525, + "timestamp": 1774179091658 + }, + { + "x": 468.366455078125, + "y": 241.162841796875, + "pressure": 2524, + "timestamp": 1774179091660 + }, + { + "x": 469.3570556640625, + "y": 240.9647216796875, + "pressure": 2526, + "timestamp": 1774179091662 + }, + { + "x": 470.3476867675781, + "y": 240.7666015625, + "pressure": 2525, + "timestamp": 1774179091663 + }, + { + "x": 471.33831787109375, + "y": 240.56854248046875, + "pressure": 2524, + "timestamp": 1774179091668 + }, + { + "x": 472.3289489746094, + "y": 240.37042236328125, + "pressure": 2525, + "timestamp": 1774179091669 + }, + { + "x": 473.1214294433594, + "y": 240.17230224609375, + "pressure": 2526, + "timestamp": 1774179091671 + }, + { + "x": 473.9139404296875, + "y": 239.97418212890625, + "pressure": 2528, + "timestamp": 1774179091673 + }, + { + "x": 474.904541015625, + "y": 239.776123046875, + "pressure": 2527, + "timestamp": 1774179091675 + }, + { + "x": 475.6970520019531, + "y": 239.5780029296875, + "pressure": 2529, + "timestamp": 1774179091676 + }, + { + "x": 476.48956298828125, + "y": 239.3798828125, + "pressure": 2528, + "timestamp": 1774179091678 + }, + { + "x": 477.28204345703125, + "y": 238.98370361328125, + "pressure": 2527, + "timestamp": 1774179091679 + }, + { + "x": 478.0745544433594, + "y": 238.78558349609375, + "pressure": 2529, + "timestamp": 1774179091684 + }, + { + "x": 479.065185546875, + "y": 238.5875244140625, + "pressure": 2528, + "timestamp": 1774179091685 + }, + { + "x": 479.46142578125, + "y": 238.389404296875, + "pressure": 2527, + "timestamp": 1774179091687 + }, + { + "x": 479.857666015625, + "y": 238.1912841796875, + "pressure": 2529, + "timestamp": 1774179091689 + }, + { + "x": 480.6501770019531, + "y": 237.79510498046875, + "pressure": 2528, + "timestamp": 1774179091691 + }, + { + "x": 481.4426574707031, + "y": 237.59698486328125, + "pressure": 2527, + "timestamp": 1774179091692 + }, + { + "x": 482.23516845703125, + "y": 237.2008056640625, + "pressure": 2529, + "timestamp": 1774179091694 + }, + { + "x": 483.02764892578125, + "y": 237.002685546875, + "pressure": 2528, + "timestamp": 1774179091696 + }, + { + "x": 483.4239196777344, + "y": 236.80462646484375, + "pressure": 2527, + "timestamp": 1774179091700 + }, + { + "x": 483.4239196777344, + "y": 236.80462646484375, + "pressure": 2530, + "timestamp": 1774179091704 + }, + { + "x": 483.8201599121094, + "y": 236.60650634765625, + "pressure": 2529, + "timestamp": 1774179091705 + }, + { + "x": 484.810791015625, + "y": 236.01220703125, + "pressure": 2531, + "timestamp": 1774179091706 + }, + { + "x": 485.20703125, + "y": 235.615966796875, + "pressure": 2530, + "timestamp": 1774179091708 + }, + { + "x": 485.603271484375, + "y": 235.41790771484375, + "pressure": 2529, + "timestamp": 1774179091710 + }, + { + "x": 486.3957824707031, + "y": 235.02166748046875, + "pressure": 2531, + "timestamp": 1774179091712 + }, + { + "x": 487.1882629394531, + "y": 234.62548828125, + "pressure": 2530, + "timestamp": 1774179091716 + }, + { + "x": 487.58453369140625, + "y": 234.4273681640625, + "pressure": 2532, + "timestamp": 1774179091717 + }, + { + "x": 487.98077392578125, + "y": 234.22930908203125, + "pressure": 2534, + "timestamp": 1774179091721 + }, + { + "x": 488.7732849121094, + "y": 233.635009765625, + "pressure": 2536, + "timestamp": 1774179091723 + }, + { + "x": 489.1695251464844, + "y": 233.23876953125, + "pressure": 2535, + "timestamp": 1774179091724 + }, + { + "x": 489.5657653808594, + "y": 233.04071044921875, + "pressure": 2536, + "timestamp": 1774179091728 + }, + { + "x": 490.3582763671875, + "y": 232.4464111328125, + "pressure": 2535, + "timestamp": 1774179091732 + }, + { + "x": 490.7545166015625, + "y": 232.0501708984375, + "pressure": 2539, + "timestamp": 1774179091733 + }, + { + "x": 491.1507568359375, + "y": 231.65399169921875, + "pressure": 2542, + "timestamp": 1774179091737 + }, + { + "x": 491.9432678222656, + "y": 230.861572265625, + "pressure": 2544, + "timestamp": 1774179091738 + }, + { + "x": 492.73577880859375, + "y": 230.06915283203125, + "pressure": 2543, + "timestamp": 1774179091740 + }, + { + "x": 493.13201904296875, + "y": 229.6729736328125, + "pressure": 2545, + "timestamp": 1774179091742 + }, + { + "x": 493.52825927734375, + "y": 229.27679443359375, + "pressure": 2545, + "timestamp": 1774179091749 + }, + { + "x": 494.3207702636719, + "y": 228.484375, + "pressure": 2544, + "timestamp": 1774179091751 + }, + { + "x": 495.1132507324219, + "y": 227.69195556640625, + "pressure": 2546, + "timestamp": 1774179091753 + }, + { + "x": 495.7076416015625, + "y": 226.89956665039062, + "pressure": 2545, + "timestamp": 1774179091755 + }, + { + "x": 496.302001953125, + "y": 226.10714721679688, + "pressure": 2544, + "timestamp": 1774179091756 + }, + { + "x": 496.6982421875, + "y": 225.31475830078125, + "pressure": 2546, + "timestamp": 1774179091758 + }, + { + "x": 497.0945129394531, + "y": 224.5223388671875, + "pressure": 2545, + "timestamp": 1774179091760 + }, + { + "x": 497.6888732910156, + "y": 223.72994995117188, + "pressure": 2544, + "timestamp": 1774179091764 + }, + { + "x": 497.8869934082031, + "y": 223.333740234375, + "pressure": 2541, + "timestamp": 1774179091767 + }, + { + "x": 498.0851135253906, + "y": 222.93756103515625, + "pressure": 2539, + "timestamp": 1774179091769 + }, + { + "x": 498.67950439453125, + "y": 222.1451416015625, + "pressure": 2541, + "timestamp": 1774179091771 + }, + { + "x": 499.27386474609375, + "y": 221.35275268554688, + "pressure": 2540, + "timestamp": 1774179091772 + }, + { + "x": 499.47198486328125, + "y": 220.95654296875, + "pressure": 2539, + "timestamp": 1774179091774 + }, + { + "x": 499.6701354980469, + "y": 220.56033325195312, + "pressure": 2540, + "timestamp": 1774179091780 + }, + { + "x": 500.0663757324219, + "y": 219.7679443359375, + "pressure": 2547, + "timestamp": 1774179091781 + }, + { + "x": 500.2644958496094, + "y": 219.37173461914062, + "pressure": 2551, + "timestamp": 1774179091784 + }, + { + "x": 500.2644958496094, + "y": 219.37173461914062, + "pressure": 2554, + "timestamp": 1774179091787 + }, + { + "x": 500.2644958496094, + "y": 219.37173461914062, + "pressure": 2557, + "timestamp": 1774179091792 + }, + { + "x": 500.4626159667969, + "y": 218.97552490234375, + "pressure": 2618, + "timestamp": 1774179091797 + }, + { + "x": 500.6607360839844, + "y": 218.18313598632812, + "pressure": 2649, + "timestamp": 1774179091800 + }, + { + "x": 500.8588562011719, + "y": 217.78692626953125, + "pressure": 2664, + "timestamp": 1774179091801 + }, + { + "x": 500.8588562011719, + "y": 217.78692626953125, + "pressure": 2672, + "timestamp": 1774179091803 + }, + { + "x": 500.8588562011719, + "y": 217.78692626953125, + "pressure": 2676, + "timestamp": 1774179091804 + }, + { + "x": 500.8588562011719, + "y": 217.78692626953125, + "pressure": 2679, + "timestamp": 1774179091808 + }, + { + "x": 500.8588562011719, + "y": 217.78692626953125, + "pressure": 2710, + "timestamp": 1774179091813 + }, + { + "x": 500.8588562011719, + "y": 217.78692626953125, + "pressure": 2724, + "timestamp": 1774179091816 + }, + { + "x": 500.8588562011719, + "y": 217.78692626953125, + "pressure": 2731, + "timestamp": 1774179091817 + }, + { + "x": 500.8588562011719, + "y": 217.78692626953125, + "pressure": 2735, + "timestamp": 1774179091819 + }, + { + "x": 500.4626159667969, + "y": 217.78692626953125, + "pressure": 2738, + "timestamp": 1774179091822 + }, + { + "x": 499.47198486328125, + "y": 217.58883666992188, + "pressure": 2740, + "timestamp": 1774179091824 + }, + { + "x": 498.48138427734375, + "y": 217.39071655273438, + "pressure": 2739, + "timestamp": 1774179091828 + }, + { + "x": 497.6888732910156, + "y": 217.192626953125, + "pressure": 2735, + "timestamp": 1774179091830 + }, + { + "x": 496.6982421875, + "y": 217.39071655273438, + "pressure": 2733, + "timestamp": 1774179091832 + }, + { + "x": 495.7076416015625, + "y": 217.58883666992188, + "pressure": 2732, + "timestamp": 1774179091833 + }, + { + "x": 494.5188903808594, + "y": 217.98501586914062, + "pressure": 2731, + "timestamp": 1774179091835 + }, + { + "x": 493.52825927734375, + "y": 218.3812255859375, + "pressure": 2733, + "timestamp": 1774179091837 + }, + { + "x": 492.5376281738281, + "y": 218.77743530273438, + "pressure": 2732, + "timestamp": 1774179091838 + }, + { + "x": 491.5470275878906, + "y": 219.17364501953125, + "pressure": 2731, + "timestamp": 1774179091840 + }, + { + "x": 490.556396484375, + "y": 219.7679443359375, + "pressure": 2733, + "timestamp": 1774179091845 + }, + { + "x": 489.5657653808594, + "y": 220.36224365234375, + "pressure": 2724, + "timestamp": 1774179091846 + }, + { + "x": 488.57513427734375, + "y": 220.95654296875, + "pressure": 2720, + "timestamp": 1774179091848 + }, + { + "x": 487.58453369140625, + "y": 221.94705200195312, + "pressure": 2718, + "timestamp": 1774179091849 + }, + { + "x": 486.5939025878906, + "y": 222.93756103515625, + "pressure": 2717, + "timestamp": 1774179091851 + }, + { + "x": 485.603271484375, + "y": 223.92803955078125, + "pressure": 2716, + "timestamp": 1774179091852 + }, + { + "x": 484.6126708984375, + "y": 224.91854858398438, + "pressure": 2718, + "timestamp": 1774179091854 + }, + { + "x": 483.6220397949219, + "y": 225.9090576171875, + "pressure": 2717, + "timestamp": 1774179091856 + }, + { + "x": 482.63140869140625, + "y": 226.89956665039062, + "pressure": 2716, + "timestamp": 1774179091860 + }, + { + "x": 481.6407775878906, + "y": 227.89007568359375, + "pressure": 2707, + "timestamp": 1774179091862 + }, + { + "x": 480.6501770019531, + "y": 229.07867431640625, + "pressure": 2702, + "timestamp": 1774179091864 + }, + { + "x": 479.6595458984375, + "y": 230.26727294921875, + "pressure": 2700, + "timestamp": 1774179091866 + }, + { + "x": 478.6689147949219, + "y": 231.45587158203125, + "pressure": 2699, + "timestamp": 1774179091867 + }, + { + "x": 477.6783142089844, + "y": 232.64447021484375, + "pressure": 2698, + "timestamp": 1774179091868 + }, + { + "x": 476.68768310546875, + "y": 233.83306884765625, + "pressure": 2700, + "timestamp": 1774179091870 + }, + { + "x": 475.8951721191406, + "y": 235.02166748046875, + "pressure": 2699, + "timestamp": 1774179091872 + }, + { + "x": 475.1026916503906, + "y": 236.21026611328125, + "pressure": 2698, + "timestamp": 1774179091876 + }, + { + "x": 474.3101806640625, + "y": 237.39892578125, + "pressure": 2690, + "timestamp": 1774179091878 + }, + { + "x": 473.5176696777344, + "y": 238.5875244140625, + "pressure": 2686, + "timestamp": 1774179091880 + }, + { + "x": 472.9233093261719, + "y": 239.776123046875, + "pressure": 2684, + "timestamp": 1774179091881 + }, + { + "x": 472.3289489746094, + "y": 240.7666015625, + "pressure": 2683, + "timestamp": 1774179091883 + }, + { + "x": 471.93267822265625, + "y": 241.75714111328125, + "pressure": 2685, + "timestamp": 1774179091885 + }, + { + "x": 471.53643798828125, + "y": 242.74761962890625, + "pressure": 2684, + "timestamp": 1774179091886 + }, + { + "x": 471.14019775390625, + "y": 243.73809814453125, + "pressure": 2683, + "timestamp": 1774179091888 + }, + { + "x": 470.7439270019531, + "y": 244.7286376953125, + "pressure": 2685, + "timestamp": 1774179091892 + }, + { + "x": 470.3476867675781, + "y": 245.52105712890625, + "pressure": 2669, + "timestamp": 1774179091894 + }, + { + "x": 470.1495666503906, + "y": 246.31341552734375, + "pressure": 2661, + "timestamp": 1774179091896 + }, + { + "x": 470.1495666503906, + "y": 246.70965576171875, + "pressure": 2657, + "timestamp": 1774179091897 + }, + { + "x": 470.1495666503906, + "y": 247.1058349609375, + "pressure": 2654, + "timestamp": 1774179091901 + }, + { + "x": 470.3476867675781, + "y": 248.0963134765625, + "pressure": 2656, + "timestamp": 1774179091902 + }, + { + "x": 470.5458068847656, + "y": 248.88873291015625, + "pressure": 2655, + "timestamp": 1774179091904 + }, + { + "x": 470.7439270019531, + "y": 249.68115234375, + "pressure": 2654, + "timestamp": 1774179091908 + }, + { + "x": 470.94207763671875, + "y": 250.07733154296875, + "pressure": 2643, + "timestamp": 1774179091910 + }, + { + "x": 470.94207763671875, + "y": 250.07733154296875, + "pressure": 2638, + "timestamp": 1774179091912 + }, + { + "x": 470.94207763671875, + "y": 250.07733154296875, + "pressure": 2635, + "timestamp": 1774179091914 + }, + { + "x": 471.33831787109375, + "y": 250.47357177734375, + "pressure": 2633, + "timestamp": 1774179091917 + }, + { + "x": 472.13079833984375, + "y": 251.26593017578125, + "pressure": 2635, + "timestamp": 1774179091918 + }, + { + "x": 472.9233093261719, + "y": 252.058349609375, + "pressure": 2634, + "timestamp": 1774179091920 + }, + { + "x": 473.7158203125, + "y": 252.85076904296875, + "pressure": 2633, + "timestamp": 1774179091924 + }, + { + "x": 474.50830078125, + "y": 253.2469482421875, + "pressure": 2629, + "timestamp": 1774179091926 + }, + { + "x": 475.4989318847656, + "y": 253.6431884765625, + "pressure": 2627, + "timestamp": 1774179091929 + }, + { + "x": 476.48956298828125, + "y": 254.03936767578125, + "pressure": 2626, + "timestamp": 1774179091930 + }, + { + "x": 477.48016357421875, + "y": 254.435546875, + "pressure": 2628, + "timestamp": 1774179091932 + }, + { + "x": 478.4707946777344, + "y": 254.831787109375, + "pressure": 2627, + "timestamp": 1774179091933 + }, + { + "x": 479.46142578125, + "y": 255.02984619140625, + "pressure": 2626, + "timestamp": 1774179091934 + }, + { + "x": 480.4520568847656, + "y": 255.22796630859375, + "pressure": 2628, + "timestamp": 1774179091936 + }, + { + "x": 481.4426574707031, + "y": 255.22796630859375, + "pressure": 2627, + "timestamp": 1774179091940 + }, + { + "x": 482.43328857421875, + "y": 255.22796630859375, + "pressure": 2619, + "timestamp": 1774179091942 + }, + { + "x": 483.6220397949219, + "y": 255.22796630859375, + "pressure": 2615, + "timestamp": 1774179091944 + }, + { + "x": 484.6126708984375, + "y": 255.02984619140625, + "pressure": 2613, + "timestamp": 1774179091945 + }, + { + "x": 485.603271484375, + "y": 254.831787109375, + "pressure": 2612, + "timestamp": 1774179091947 + }, + { + "x": 486.5939025878906, + "y": 254.6336669921875, + "pressure": 2611, + "timestamp": 1774179091949 + }, + { + "x": 487.58453369140625, + "y": 254.435546875, + "pressure": 2613, + "timestamp": 1774179091950 + }, + { + "x": 488.57513427734375, + "y": 254.23748779296875, + "pressure": 2612, + "timestamp": 1774179091952 + }, + { + "x": 489.5657653808594, + "y": 254.03936767578125, + "pressure": 2611, + "timestamp": 1774179091956 + }, + { + "x": 490.3582763671875, + "y": 253.84124755859375, + "pressure": 2608, + "timestamp": 1774179091958 + }, + { + "x": 491.1507568359375, + "y": 253.6431884765625, + "pressure": 2607, + "timestamp": 1774179091960 + }, + { + "x": 491.9432678222656, + "y": 253.445068359375, + "pressure": 2606, + "timestamp": 1774179091962 + }, + { + "x": 492.73577880859375, + "y": 253.04888916015625, + "pressure": 2608, + "timestamp": 1774179091963 + }, + { + "x": 493.52825927734375, + "y": 252.65264892578125, + "pressure": 2607, + "timestamp": 1774179091965 + }, + { + "x": 494.3207702636719, + "y": 252.2564697265625, + "pressure": 2606, + "timestamp": 1774179091966 + }, + { + "x": 495.1132507324219, + "y": 251.8602294921875, + "pressure": 2608, + "timestamp": 1774179091968 + }, + { + "x": 495.90576171875, + "y": 251.46405029296875, + "pressure": 2607, + "timestamp": 1774179091972 + }, + { + "x": 496.6982421875, + "y": 251.06787109375, + "pressure": 2603, + "timestamp": 1774179091974 + }, + { + "x": 497.4907531738281, + "y": 250.671630859375, + "pressure": 2601, + "timestamp": 1774179091976 + }, + { + "x": 497.8869934082031, + "y": 250.27545166015625, + "pressure": 2600, + "timestamp": 1774179091977 + }, + { + "x": 498.28326416015625, + "y": 250.07733154296875, + "pressure": 2600, + "timestamp": 1774179091983 + }, + { + "x": 499.07574462890625, + "y": 249.4830322265625, + "pressure": 2602, + "timestamp": 1774179091984 + }, + { + "x": 499.8682556152344, + "y": 248.88873291015625, + "pressure": 2601, + "timestamp": 1774179091988 + }, + { + "x": 500.2644958496094, + "y": 248.4925537109375, + "pressure": 2602, + "timestamp": 1774179091990 + }, + { + "x": 500.4626159667969, + "y": 248.0963134765625, + "pressure": 2605, + "timestamp": 1774179091995 + }, + { + "x": 501.0570068359375, + "y": 247.303955078125, + "pressure": 2604, + "timestamp": 1774179091997 + }, + { + "x": 501.6513671875, + "y": 246.51153564453125, + "pressure": 2603, + "timestamp": 1774179091999 + }, + { + "x": 502.2457275390625, + "y": 245.7191162109375, + "pressure": 2605, + "timestamp": 1774179092000 + }, + { + "x": 502.4438781738281, + "y": 245.32293701171875, + "pressure": 2604, + "timestamp": 1774179092004 + }, + { + "x": 502.4438781738281, + "y": 245.32293701171875, + "pressure": 2607, + "timestamp": 1774179092006 + }, + { + "x": 502.6419982910156, + "y": 244.9267578125, + "pressure": 2609, + "timestamp": 1774179092008 + }, + { + "x": 503.2363586425781, + "y": 244.13433837890625, + "pressure": 2610, + "timestamp": 1774179092010 + }, + { + "x": 503.4344787597656, + "y": 243.3419189453125, + "pressure": 2612, + "timestamp": 1774179092011 + }, + { + "x": 503.63262939453125, + "y": 242.94573974609375, + "pressure": 2611, + "timestamp": 1774179092013 + }, + { + "x": 503.83074951171875, + "y": 242.54949951171875, + "pressure": 2612, + "timestamp": 1774179092016 + }, + { + "x": 504.02886962890625, + "y": 241.75714111328125, + "pressure": 2611, + "timestamp": 1774179092020 + }, + { + "x": 504.22698974609375, + "y": 240.9647216796875, + "pressure": 2619, + "timestamp": 1774179092022 + }, + { + "x": 504.42510986328125, + "y": 240.56854248046875, + "pressure": 2623, + "timestamp": 1774179092024 + }, + { + "x": 504.42510986328125, + "y": 240.56854248046875, + "pressure": 2626, + "timestamp": 1774179092027 + }, + { + "x": 504.42510986328125, + "y": 240.56854248046875, + "pressure": 2629, + "timestamp": 1774179092032 + }, + { + "x": 504.42510986328125, + "y": 240.17230224609375, + "pressure": 2628, + "timestamp": 1774179092036 + }, + { + "x": 504.62322998046875, + "y": 239.3798828125, + "pressure": 2649, + "timestamp": 1774179092038 + }, + { + "x": 504.62322998046875, + "y": 238.98370361328125, + "pressure": 2660, + "timestamp": 1774179092040 + }, + { + "x": 504.62322998046875, + "y": 238.98370361328125, + "pressure": 2665, + "timestamp": 1774179092042 + }, + { + "x": 504.62322998046875, + "y": 238.98370361328125, + "pressure": 2668, + "timestamp": 1774179092043 + }, + { + "x": 504.62322998046875, + "y": 238.98370361328125, + "pressure": 2672, + "timestamp": 1774179092048 + }, + { + "x": 504.62322998046875, + "y": 238.98370361328125, + "pressure": 2677, + "timestamp": 1774179092054 + }, + { + "x": 504.62322998046875, + "y": 238.98370361328125, + "pressure": 2680, + "timestamp": 1774179092056 + }, + { + "x": 504.62322998046875, + "y": 238.98370361328125, + "pressure": 2683, + "timestamp": 1774179092059 + }, + { + "x": 504.22698974609375, + "y": 238.78558349609375, + "pressure": 2684, + "timestamp": 1774179092063 + }, + { + "x": 503.4344787597656, + "y": 238.1912841796875, + "pressure": 2686, + "timestamp": 1774179092064 + }, + { + "x": 503.0382385253906, + "y": 237.99322509765625, + "pressure": 2685, + "timestamp": 1774179092069 + }, + { + "x": 502.6419982910156, + "y": 237.99322509765625, + "pressure": 2666, + "timestamp": 1774179092070 + }, + { + "x": 501.6513671875, + "y": 237.79510498046875, + "pressure": 2657, + "timestamp": 1774179092073 + }, + { + "x": 500.8588562011719, + "y": 237.59698486328125, + "pressure": 2652, + "timestamp": 1774179092074 + }, + { + "x": 500.0663757324219, + "y": 237.39892578125, + "pressure": 2650, + "timestamp": 1774179092076 + }, + { + "x": 499.27386474609375, + "y": 237.39892578125, + "pressure": 2649, + "timestamp": 1774179092077 + }, + { + "x": 498.48138427734375, + "y": 237.59698486328125, + "pressure": 2648, + "timestamp": 1774179092079 + }, + { + "x": 498.0851135253906, + "y": 237.59698486328125, + "pressure": 2650, + "timestamp": 1774179092080 + }, + { + "x": 498.0851135253906, + "y": 237.59698486328125, + "pressure": 2633, + "timestamp": 1774179092086 + }, + { + "x": 497.6888732910156, + "y": 237.79510498046875, + "pressure": 2625, + "timestamp": 1774179092089 + }, + { + "x": 496.8963928222656, + "y": 238.389404296875, + "pressure": 2621, + "timestamp": 1774179092092 + }, + { + "x": 496.1038818359375, + "y": 238.98370361328125, + "pressure": 2619, + "timestamp": 1774179092092 + }, + { + "x": 495.7076416015625, + "y": 239.18182373046875, + "pressure": 2618, + "timestamp": 1774179092093 + }, + { + "x": 495.3113708496094, + "y": 239.5780029296875, + "pressure": 2618, + "timestamp": 1774179092101 + }, + { + "x": 494.7170104980469, + "y": 240.37042236328125, + "pressure": 2605, + "timestamp": 1774179092103 + }, + { + "x": 493.92449951171875, + "y": 241.162841796875, + "pressure": 2599, + "timestamp": 1774179092105 + }, + { + "x": 493.52825927734375, + "y": 241.9552001953125, + "pressure": 2596, + "timestamp": 1774179092106 + }, + { + "x": 493.33013916015625, + "y": 242.3514404296875, + "pressure": 2594, + "timestamp": 1774179092107 + }, + { + "x": 493.13201904296875, + "y": 242.74761962890625, + "pressure": 2595, + "timestamp": 1774179092111 + }, + { + "x": 492.73577880859375, + "y": 243.73809814453125, + "pressure": 2594, + "timestamp": 1774179092113 + }, + { + "x": 492.3395080566406, + "y": 244.530517578125, + "pressure": 2593, + "timestamp": 1774179092117 + }, + { + "x": 492.1413879394531, + "y": 245.32293701171875, + "pressure": 2573, + "timestamp": 1774179092118 + }, + { + "x": 492.1413879394531, + "y": 245.7191162109375, + "pressure": 2563, + "timestamp": 1774179092120 + }, + { + "x": 492.1413879394531, + "y": 245.7191162109375, + "pressure": 2558, + "timestamp": 1774179092122 + }, + { + "x": 492.1413879394531, + "y": 246.1153564453125, + "pressure": 2555, + "timestamp": 1774179092125 + }, + { + "x": 492.3395080566406, + "y": 246.90771484375, + "pressure": 2554, + "timestamp": 1774179092127 + }, + { + "x": 492.5376281738281, + "y": 247.70013427734375, + "pressure": 2556, + "timestamp": 1774179092129 + }, + { + "x": 492.73577880859375, + "y": 248.0963134765625, + "pressure": 2555, + "timestamp": 1774179092133 + }, + { + "x": 492.73577880859375, + "y": 248.0963134765625, + "pressure": 2542, + "timestamp": 1774179092134 + }, + { + "x": 492.73577880859375, + "y": 248.4925537109375, + "pressure": 2536, + "timestamp": 1774179092137 + }, + { + "x": 492.93389892578125, + "y": 249.28497314453125, + "pressure": 2533, + "timestamp": 1774179092138 + }, + { + "x": 493.13201904296875, + "y": 249.68115234375, + "pressure": 2531, + "timestamp": 1774179092140 + }, + { + "x": 493.33013916015625, + "y": 250.07733154296875, + "pressure": 2531, + "timestamp": 1774179092145 + }, + { + "x": 493.92449951171875, + "y": 250.8697509765625, + "pressure": 2530, + "timestamp": 1774179092149 + }, + { + "x": 494.3207702636719, + "y": 251.26593017578125, + "pressure": 2520, + "timestamp": 1774179092150 + }, + { + "x": 494.3207702636719, + "y": 251.26593017578125, + "pressure": 2515, + "timestamp": 1774179092153 + }, + { + "x": 494.3207702636719, + "y": 251.26593017578125, + "pressure": 2512, + "timestamp": 1774179092155 + }, + { + "x": 494.5188903808594, + "y": 251.66217041015625, + "pressure": 2511, + "timestamp": 1774179092157 + }, + { + "x": 495.3113708496094, + "y": 252.45452880859375, + "pressure": 2513, + "timestamp": 1774179092159 + }, + { + "x": 496.1038818359375, + "y": 253.04888916015625, + "pressure": 2512, + "timestamp": 1774179092161 + }, + { + "x": 496.8963928222656, + "y": 253.445068359375, + "pressure": 2511, + "timestamp": 1774179092165 + }, + { + "x": 497.6888732910156, + "y": 253.84124755859375, + "pressure": 2503, + "timestamp": 1774179092166 + }, + { + "x": 498.48138427734375, + "y": 254.23748779296875, + "pressure": 2499, + "timestamp": 1774179092168 + }, + { + "x": 499.27386474609375, + "y": 254.6336669921875, + "pressure": 2497, + "timestamp": 1774179092170 + }, + { + "x": 500.0663757324219, + "y": 255.02984619140625, + "pressure": 2496, + "timestamp": 1774179092172 + }, + { + "x": 500.8588562011719, + "y": 255.22796630859375, + "pressure": 2495, + "timestamp": 1774179092173 + }, + { + "x": 501.6513671875, + "y": 255.42608642578125, + "pressure": 2497, + "timestamp": 1774179092175 + }, + { + "x": 502.4438781738281, + "y": 255.42608642578125, + "pressure": 2496, + "timestamp": 1774179092177 + }, + { + "x": 503.2363586425781, + "y": 255.42608642578125, + "pressure": 2495, + "timestamp": 1774179092181 + }, + { + "x": 504.02886962890625, + "y": 255.42608642578125, + "pressure": 2492, + "timestamp": 1774179092182 + }, + { + "x": 504.82135009765625, + "y": 255.42608642578125, + "pressure": 2490, + "timestamp": 1774179092184 + }, + { + "x": 505.6138610839844, + "y": 255.42608642578125, + "pressure": 2489, + "timestamp": 1774179092186 + }, + { + "x": 506.4063720703125, + "y": 255.22796630859375, + "pressure": 2491, + "timestamp": 1774179092188 + }, + { + "x": 507.1988525390625, + "y": 255.02984619140625, + "pressure": 2490, + "timestamp": 1774179092189 + }, + { + "x": 507.5950927734375, + "y": 254.831787109375, + "pressure": 2489, + "timestamp": 1774179092191 + }, + { + "x": 507.9913635253906, + "y": 254.831787109375, + "pressure": 2490, + "timestamp": 1774179092197 + }, + { + "x": 508.7838439941406, + "y": 254.6336669921875, + "pressure": 2486, + "timestamp": 1774179092198 + }, + { + "x": 509.57635498046875, + "y": 254.435546875, + "pressure": 2484, + "timestamp": 1774179092201 + }, + { + "x": 510.36883544921875, + "y": 254.23748779296875, + "pressure": 2483, + "timestamp": 1774179092202 + }, + { + "x": 511.1613464355469, + "y": 254.03936767578125, + "pressure": 2485, + "timestamp": 1774179092204 + }, + { + "x": 511.953857421875, + "y": 253.6431884765625, + "pressure": 2484, + "timestamp": 1774179092205 + }, + { + "x": 512.35009765625, + "y": 253.445068359375, + "pressure": 2483, + "timestamp": 1774179092207 + }, + { + "x": 512.746337890625, + "y": 253.2469482421875, + "pressure": 2484, + "timestamp": 1774179092213 + }, + { + "x": 513.538818359375, + "y": 252.65264892578125, + "pressure": 2486, + "timestamp": 1774179092215 + }, + { + "x": 514.331298828125, + "y": 252.2564697265625, + "pressure": 2485, + "timestamp": 1774179092217 + }, + { + "x": 515.1238403320312, + "y": 251.8602294921875, + "pressure": 2484, + "timestamp": 1774179092218 + }, + { + "x": 515.5200805664062, + "y": 251.66217041015625, + "pressure": 2486, + "timestamp": 1774179092220 + }, + { + "x": 515.9163208007812, + "y": 251.46405029296875, + "pressure": 2486, + "timestamp": 1774179092225 + }, + { + "x": 516.7088623046875, + "y": 250.8697509765625, + "pressure": 2485, + "timestamp": 1774179092229 + }, + { + "x": 517.5013427734375, + "y": 250.27545166015625, + "pressure": 2487, + "timestamp": 1774179092231 + }, + { + "x": 517.8975830078125, + "y": 249.8792724609375, + "pressure": 2486, + "timestamp": 1774179092233 + }, + { + "x": 518.2938232421875, + "y": 249.68115234375, + "pressure": 2486, + "timestamp": 1774179092237 + }, + { + "x": 519.0863037109375, + "y": 249.08685302734375, + "pressure": 2485, + "timestamp": 1774179092239 + }, + { + "x": 519.4825439453125, + "y": 248.69061279296875, + "pressure": 2487, + "timestamp": 1774179092241 + }, + { + "x": 519.8787841796875, + "y": 248.29443359375, + "pressure": 2490, + "timestamp": 1774179092250 + }, + { + "x": 520.6713256835938, + "y": 247.50201416015625, + "pressure": 2492, + "timestamp": 1774179092252 + }, + { + "x": 521.4638671875, + "y": 246.90771484375, + "pressure": 2491, + "timestamp": 1774179092253 + }, + { + "x": 521.860107421875, + "y": 246.51153564453125, + "pressure": 2493, + "timestamp": 1774179092256 + }, + { + "x": 522.25634765625, + "y": 246.31341552734375, + "pressure": 2494, + "timestamp": 1774179092262 + }, + { + "x": 523.048828125, + "y": 245.7191162109375, + "pressure": 2495, + "timestamp": 1774179092265 + }, + { + "x": 523.445068359375, + "y": 245.32293701171875, + "pressure": 2496, + "timestamp": 1774179092266 + }, + { + "x": 523.84130859375, + "y": 244.9267578125, + "pressure": 2499, + "timestamp": 1774179092271 + }, + { + "x": 524.6337890625, + "y": 244.13433837890625, + "pressure": 2498, + "timestamp": 1774179092273 + }, + { + "x": 525.030029296875, + "y": 243.73809814453125, + "pressure": 2497, + "timestamp": 1774179092278 + }, + { + "x": 525.42626953125, + "y": 243.5400390625, + "pressure": 2499, + "timestamp": 1774179092284 + }, + { + "x": 526.2188110351562, + "y": 242.94573974609375, + "pressure": 2498, + "timestamp": 1774179092286 + }, + { + "x": 526.6150512695312, + "y": 242.54949951171875, + "pressure": 2497, + "timestamp": 1774179092287 + }, + { + "x": 526.6150512695312, + "y": 242.54949951171875, + "pressure": 2494, + "timestamp": 1774179092298 + }, + { + "x": 526.8131713867188, + "y": 242.1533203125, + "pressure": 2496, + "timestamp": 1774179092305 + }, + { + "x": 527.4075927734375, + "y": 241.36090087890625, + "pressure": 2495, + "timestamp": 1774179092309 + }, + { + "x": 527.8038330078125, + "y": 240.9647216796875, + "pressure": 2492, + "timestamp": 1774179092311 + }, + { + "x": 527.8038330078125, + "y": 240.9647216796875, + "pressure": 2498, + "timestamp": 1774179092343 + }, + { + "x": 527.8038330078125, + "y": 240.9647216796875, + "pressure": 2501, + "timestamp": 1774179092345 + }, + { + "x": 527.8038330078125, + "y": 240.9647216796875, + "pressure": 2505, + "timestamp": 1774179092350 + }, + { + "x": 527.8038330078125, + "y": 240.9647216796875, + "pressure": 2515, + "timestamp": 1774179092359 + }, + { + "x": 527.8038330078125, + "y": 240.9647216796875, + "pressure": 2520, + "timestamp": 1774179092361 + }, + { + "x": 527.8038330078125, + "y": 240.9647216796875, + "pressure": 2523, + "timestamp": 1774179092364 + }, + { + "x": 527.8038330078125, + "y": 240.9647216796875, + "pressure": 2526, + "timestamp": 1774179092367 + }, + { + "x": 527.8038330078125, + "y": 240.9647216796875, + "pressure": 2523, + "timestamp": 1774179092377 + }, + { + "x": 527.8038330078125, + "y": 240.9647216796875, + "pressure": 2514, + "timestamp": 1774179092391 + }, + { + "x": 527.8038330078125, + "y": 240.9647216796875, + "pressure": 2509, + "timestamp": 1774179092393 + }, + { + "x": 527.8038330078125, + "y": 240.9647216796875, + "pressure": 2506, + "timestamp": 1774179092396 + }, + { + "x": 527.8038330078125, + "y": 240.9647216796875, + "pressure": 2503, + "timestamp": 1774179092407 + }, + { + "x": 527.8038330078125, + "y": 240.9647216796875, + "pressure": 2499, + "timestamp": 1774179092423 + }, + { + "x": 528.2000732421875, + "y": 240.7666015625, + "pressure": 2500, + "timestamp": 1774179092439 + }, + { + "x": 528.9925537109375, + "y": 240.17230224609375, + "pressure": 2501, + "timestamp": 1774179092441 + }, + { + "x": 529.3887939453125, + "y": 239.776123046875, + "pressure": 2503, + "timestamp": 1774179092443 + }, + { + "x": 529.7850341796875, + "y": 239.776123046875, + "pressure": 2504, + "timestamp": 1774179092453 + }, + { + "x": 530.5775146484375, + "y": 239.5780029296875, + "pressure": 2529, + "timestamp": 1774179092455 + }, + { + "x": 530.9738159179688, + "y": 239.3798828125, + "pressure": 2542, + "timestamp": 1774179092457 + }, + { + "x": 530.9738159179688, + "y": 239.3798828125, + "pressure": 2548, + "timestamp": 1774179092459 + }, + { + "x": 530.9738159179688, + "y": 239.3798828125, + "pressure": 2551, + "timestamp": 1774179092460 + }, + { + "x": 530.9738159179688, + "y": 239.3798828125, + "pressure": 2554, + "timestamp": 1774179092464 + }, + { + "x": 530.9738159179688, + "y": 239.3798828125, + "pressure": 2566, + "timestamp": 1774179092471 + }, + { + "x": 530.9738159179688, + "y": 239.3798828125, + "pressure": 2572, + "timestamp": 1774179092473 + }, + { + "x": 530.9738159179688, + "y": 239.3798828125, + "pressure": 2575, + "timestamp": 1774179092475 + }, + { + "x": 530.9738159179688, + "y": 239.3798828125, + "pressure": 2579, + "timestamp": 1774179092480 + }, + { + "x": 530.9738159179688, + "y": 239.3798828125, + "pressure": 2593, + "timestamp": 1774179092487 + }, + { + "x": 530.9738159179688, + "y": 239.3798828125, + "pressure": 2600, + "timestamp": 1774179092489 + }, + { + "x": 530.9738159179688, + "y": 239.3798828125, + "pressure": 2603, + "timestamp": 1774179092491 + }, + { + "x": 531.1719360351562, + "y": 239.776123046875, + "pressure": 2605, + "timestamp": 1774179092492 + }, + { + "x": 531.7662963867188, + "y": 240.7666015625, + "pressure": 2606, + "timestamp": 1774179092494 + }, + { + "x": 532.3607177734375, + "y": 241.75714111328125, + "pressure": 2608, + "timestamp": 1774179092496 + }, + { + "x": 532.558837890625, + "y": 242.54949951171875, + "pressure": 2607, + "timestamp": 1774179092498 + }, + { + "x": 532.7569580078125, + "y": 242.94573974609375, + "pressure": 2609, + "timestamp": 1774179092501 + }, + { + "x": 532.7569580078125, + "y": 243.3419189453125, + "pressure": 2623, + "timestamp": 1774179092503 + }, + { + "x": 532.7569580078125, + "y": 244.3323974609375, + "pressure": 2630, + "timestamp": 1774179092505 + }, + { + "x": 532.955078125, + "y": 245.32293701171875, + "pressure": 2633, + "timestamp": 1774179092507 + }, + { + "x": 532.7569580078125, + "y": 246.31341552734375, + "pressure": 2635, + "timestamp": 1774179092508 + }, + { + "x": 532.558837890625, + "y": 247.303955078125, + "pressure": 2636, + "timestamp": 1774179092510 + }, + { + "x": 532.3607177734375, + "y": 248.29443359375, + "pressure": 2638, + "timestamp": 1774179092512 + }, + { + "x": 532.1625366210938, + "y": 249.28497314453125, + "pressure": 2637, + "timestamp": 1774179092514 + }, + { + "x": 531.9644165039062, + "y": 250.07733154296875, + "pressure": 2639, + "timestamp": 1774179092518 + }, + { + "x": 531.5681762695312, + "y": 250.8697509765625, + "pressure": 2647, + "timestamp": 1774179092519 + }, + { + "x": 531.3700561523438, + "y": 251.66217041015625, + "pressure": 2651, + "timestamp": 1774179092521 + }, + { + "x": 530.9738159179688, + "y": 252.45452880859375, + "pressure": 2653, + "timestamp": 1774179092523 + }, + { + "x": 530.5775146484375, + "y": 253.2469482421875, + "pressure": 2654, + "timestamp": 1774179092524 + }, + { + "x": 530.1812744140625, + "y": 254.03936767578125, + "pressure": 2656, + "timestamp": 1774179092526 + }, + { + "x": 529.7850341796875, + "y": 254.831787109375, + "pressure": 2655, + "timestamp": 1774179092528 + }, + { + "x": 529.3887939453125, + "y": 255.6241455078125, + "pressure": 2657, + "timestamp": 1774179092530 + }, + { + "x": 528.9925537109375, + "y": 256.41656494140625, + "pressure": 2656, + "timestamp": 1774179092534 + }, + { + "x": 528.5963134765625, + "y": 257.208984375, + "pressure": 2659, + "timestamp": 1774179092535 + }, + { + "x": 528.398193359375, + "y": 257.60516357421875, + "pressure": 2661, + "timestamp": 1774179092537 + }, + { + "x": 528.398193359375, + "y": 257.60516357421875, + "pressure": 2664, + "timestamp": 1774179092540 + }, + { + "x": 528.2000732421875, + "y": 258.00140380859375, + "pressure": 2663, + "timestamp": 1774179092542 + }, + { + "x": 527.605712890625, + "y": 258.79376220703125, + "pressure": 2665, + "timestamp": 1774179092544 + }, + { + "x": 527.4075927734375, + "y": 259.19000244140625, + "pressure": 2664, + "timestamp": 1774179092546 + }, + { + "x": 527.20947265625, + "y": 259.586181640625, + "pressure": 2663, + "timestamp": 1774179092550 + }, + { + "x": 527.0113525390625, + "y": 259.98236083984375, + "pressure": 2667, + "timestamp": 1774179092551 + }, + { + "x": 527.0113525390625, + "y": 259.98236083984375, + "pressure": 2670, + "timestamp": 1774179092555 + }, + { + "x": 527.0113525390625, + "y": 259.98236083984375, + "pressure": 2673, + "timestamp": 1774179092560 + }, + { + "x": 527.0113525390625, + "y": 259.98236083984375, + "pressure": 2676, + "timestamp": 1774179092571 + }, + { + "x": 527.0113525390625, + "y": 259.98236083984375, + "pressure": 2679, + "timestamp": 1774179092576 + }, + { + "x": 527.0113525390625, + "y": 259.98236083984375, + "pressure": 2675, + "timestamp": 1774179092584 + }, + { + "x": 527.0113525390625, + "y": 259.98236083984375, + "pressure": 2670, + "timestamp": 1774179092599 + }, + { + "x": 527.0113525390625, + "y": 259.98236083984375, + "pressure": 2667, + "timestamp": 1774179092603 + }, + { + "x": 527.4075927734375, + "y": 259.586181640625, + "pressure": 2656, + "timestamp": 1774179092615 + }, + { + "x": 528.2000732421875, + "y": 258.79376220703125, + "pressure": 2650, + "timestamp": 1774179092618 + }, + { + "x": 529.190673828125, + "y": 257.80328369140625, + "pressure": 2647, + "timestamp": 1774179092619 + }, + { + "x": 530.1812744140625, + "y": 256.812744140625, + "pressure": 2646, + "timestamp": 1774179092621 + }, + { + "x": 531.1719360351562, + "y": 255.822265625, + "pressure": 2645, + "timestamp": 1774179092622 + }, + { + "x": 532.1625366210938, + "y": 254.831787109375, + "pressure": 2647, + "timestamp": 1774179092624 + }, + { + "x": 533.351318359375, + "y": 253.84124755859375, + "pressure": 2646, + "timestamp": 1774179092626 + }, + { + "x": 534.5400390625, + "y": 252.85076904296875, + "pressure": 2645, + "timestamp": 1774179092630 + }, + { + "x": 535.728759765625, + "y": 251.66217041015625, + "pressure": 2630, + "timestamp": 1774179092631 + }, + { + "x": 536.7194213867188, + "y": 250.671630859375, + "pressure": 2622, + "timestamp": 1774179092634 + }, + { + "x": 537.7100219726562, + "y": 249.68115234375, + "pressure": 2618, + "timestamp": 1774179092635 + }, + { + "x": 538.70068359375, + "y": 248.69061279296875, + "pressure": 2616, + "timestamp": 1774179092637 + }, + { + "x": 539.6912841796875, + "y": 247.70013427734375, + "pressure": 2615, + "timestamp": 1774179092638 + }, + { + "x": 540.681884765625, + "y": 246.70965576171875, + "pressure": 2617, + "timestamp": 1774179092640 + }, + { + "x": 541.6724853515625, + "y": 245.7191162109375, + "pressure": 2616, + "timestamp": 1774179092642 + }, + { + "x": 542.6631469726562, + "y": 244.9267578125, + "pressure": 2615, + "timestamp": 1774179092646 + }, + { + "x": 543.65380859375, + "y": 244.13433837890625, + "pressure": 2609, + "timestamp": 1774179092648 + }, + { + "x": 544.6444091796875, + "y": 243.3419189453125, + "pressure": 2606, + "timestamp": 1774179092650 + }, + { + "x": 545.4368896484375, + "y": 242.74761962890625, + "pressure": 2604, + "timestamp": 1774179092651 + }, + { + "x": 546.2293701171875, + "y": 242.1533203125, + "pressure": 2603, + "timestamp": 1774179092653 + }, + { + "x": 546.6256103515625, + "y": 241.75714111328125, + "pressure": 2605, + "timestamp": 1774179092654 + }, + { + "x": 547.0218505859375, + "y": 241.55902099609375, + "pressure": 2603, + "timestamp": 1774179092658 + }, + { + "x": 547.4181518554688, + "y": 241.36090087890625, + "pressure": 2605, + "timestamp": 1774179092662 + }, + { + "x": 547.4181518554688, + "y": 241.36090087890625, + "pressure": 2609, + "timestamp": 1774179092667 + }, + { + "x": 547.4181518554688, + "y": 241.36090087890625, + "pressure": 2626, + "timestamp": 1774179092679 + }, + { + "x": 547.4181518554688, + "y": 241.36090087890625, + "pressure": 2634, + "timestamp": 1774179092682 + }, + { + "x": 547.4181518554688, + "y": 241.36090087890625, + "pressure": 2638, + "timestamp": 1774179092683 + }, + { + "x": 547.4181518554688, + "y": 241.36090087890625, + "pressure": 2641, + "timestamp": 1774179092687 + }, + { + "x": 547.4181518554688, + "y": 241.36090087890625, + "pressure": 2644, + "timestamp": 1774179092690 + }, + { + "x": 547.4181518554688, + "y": 241.36090087890625, + "pressure": 2669, + "timestamp": 1774179092696 + }, + { + "x": 547.4181518554688, + "y": 241.75714111328125, + "pressure": 2682, + "timestamp": 1774179092698 + }, + { + "x": 547.6162719726562, + "y": 242.74761962890625, + "pressure": 2688, + "timestamp": 1774179092700 + }, + { + "x": 547.8143920898438, + "y": 243.73809814453125, + "pressure": 2691, + "timestamp": 1774179092701 + }, + { + "x": 547.8143920898438, + "y": 244.530517578125, + "pressure": 2693, + "timestamp": 1774179092703 + }, + { + "x": 547.6162719726562, + "y": 245.32293701171875, + "pressure": 2694, + "timestamp": 1774179092705 + }, + { + "x": 547.4181518554688, + "y": 246.1153564453125, + "pressure": 2696, + "timestamp": 1774179092706 + }, + { + "x": 547.219970703125, + "y": 246.90771484375, + "pressure": 2695, + "timestamp": 1774179092710 + }, + { + "x": 546.82373046875, + "y": 247.70013427734375, + "pressure": 2708, + "timestamp": 1774179092712 + }, + { + "x": 546.427490234375, + "y": 248.4925537109375, + "pressure": 2714, + "timestamp": 1774179092714 + }, + { + "x": 546.03125, + "y": 249.28497314453125, + "pressure": 2717, + "timestamp": 1774179092715 + }, + { + "x": 545.635009765625, + "y": 250.07733154296875, + "pressure": 2719, + "timestamp": 1774179092717 + }, + { + "x": 545.4368896484375, + "y": 250.47357177734375, + "pressure": 2720, + "timestamp": 1774179092719 + }, + { + "x": 545.23876953125, + "y": 250.8697509765625, + "pressure": 2723, + "timestamp": 1774179092726 + }, + { + "x": 544.6444091796875, + "y": 251.66217041015625, + "pressure": 2730, + "timestamp": 1774179092728 + }, + { + "x": 544.4462890625, + "y": 252.058349609375, + "pressure": 2734, + "timestamp": 1774179092730 + }, + { + "x": 544.4462890625, + "y": 252.058349609375, + "pressure": 2737, + "timestamp": 1774179092733 + }, + { + "x": 544.4462890625, + "y": 252.058349609375, + "pressure": 2740, + "timestamp": 1774179092738 + }, + { + "x": 544.2481689453125, + "y": 252.45452880859375, + "pressure": 2739, + "timestamp": 1774179092742 + }, + { + "x": 544.050048828125, + "y": 252.85076904296875, + "pressure": 2736, + "timestamp": 1774179092744 + }, + { + "x": 544.050048828125, + "y": 252.85076904296875, + "pressure": 2711, + "timestamp": 1774179092760 + }, + { + "x": 544.050048828125, + "y": 252.85076904296875, + "pressure": 2699, + "timestamp": 1774179092762 + }, + { + "x": 544.050048828125, + "y": 252.85076904296875, + "pressure": 2693, + "timestamp": 1774179092764 + }, + { + "x": 544.050048828125, + "y": 252.85076904296875, + "pressure": 2690, + "timestamp": 1774179092765 + }, + { + "x": 544.050048828125, + "y": 252.85076904296875, + "pressure": 2667, + "timestamp": 1774179092776 + }, + { + "x": 544.050048828125, + "y": 252.85076904296875, + "pressure": 2656, + "timestamp": 1774179092778 + }, + { + "x": 544.050048828125, + "y": 252.85076904296875, + "pressure": 2651, + "timestamp": 1774179092780 + }, + { + "x": 544.050048828125, + "y": 252.85076904296875, + "pressure": 2648, + "timestamp": 1774179092781 + }, + { + "x": 544.4462890625, + "y": 252.65264892578125, + "pressure": 2647, + "timestamp": 1774179092790 + }, + { + "x": 545.23876953125, + "y": 252.058349609375, + "pressure": 2634, + "timestamp": 1774179092792 + }, + { + "x": 546.2293701171875, + "y": 251.26593017578125, + "pressure": 2627, + "timestamp": 1774179092794 + }, + { + "x": 547.219970703125, + "y": 250.47357177734375, + "pressure": 2624, + "timestamp": 1774179092796 + }, + { + "x": 548.2106323242188, + "y": 249.68115234375, + "pressure": 2622, + "timestamp": 1774179092797 + }, + { + "x": 549.2012939453125, + "y": 248.88873291015625, + "pressure": 2621, + "timestamp": 1774179092799 + }, + { + "x": 550.19189453125, + "y": 248.0963134765625, + "pressure": 2623, + "timestamp": 1774179092800 + }, + { + "x": 551.380615234375, + "y": 247.1058349609375, + "pressure": 2622, + "timestamp": 1774179092802 + }, + { + "x": 552.3712158203125, + "y": 246.31341552734375, + "pressure": 2621, + "timestamp": 1774179092806 + }, + { + "x": 553.3618774414062, + "y": 245.52105712890625, + "pressure": 2602, + "timestamp": 1774179092808 + }, + { + "x": 554.3525390625, + "y": 244.7286376953125, + "pressure": 2592, + "timestamp": 1774179092810 + }, + { + "x": 555.3431396484375, + "y": 243.93621826171875, + "pressure": 2587, + "timestamp": 1774179092811 + }, + { + "x": 556.333740234375, + "y": 243.143798828125, + "pressure": 2585, + "timestamp": 1774179092813 + }, + { + "x": 557.126220703125, + "y": 242.54949951171875, + "pressure": 2584, + "timestamp": 1774179092815 + }, + { + "x": 557.918701171875, + "y": 241.9552001953125, + "pressure": 2583, + "timestamp": 1774179092817 + }, + { + "x": 558.3150024414062, + "y": 241.75714111328125, + "pressure": 2585, + "timestamp": 1774179092818 + }, + { + "x": 558.3150024414062, + "y": 241.75714111328125, + "pressure": 2588, + "timestamp": 1774179092824 + }, + { + "x": 558.3150024414062, + "y": 241.75714111328125, + "pressure": 2591, + "timestamp": 1774179092828 + }, + { + "x": 558.3150024414062, + "y": 241.75714111328125, + "pressure": 2594, + "timestamp": 1774179092833 + }, + { + "x": 558.3150024414062, + "y": 241.75714111328125, + "pressure": 2620, + "timestamp": 1774179092840 + }, + { + "x": 558.3150024414062, + "y": 241.75714111328125, + "pressure": 2634, + "timestamp": 1774179092842 + }, + { + "x": 558.3150024414062, + "y": 241.75714111328125, + "pressure": 2641, + "timestamp": 1774179092844 + }, + { + "x": 558.3150024414062, + "y": 241.75714111328125, + "pressure": 2645, + "timestamp": 1774179092845 + }, + { + "x": 558.3150024414062, + "y": 241.75714111328125, + "pressure": 2648, + "timestamp": 1774179092849 + }, + { + "x": 558.3150024414062, + "y": 241.75714111328125, + "pressure": 2671, + "timestamp": 1774179092856 + }, + { + "x": 558.3150024414062, + "y": 242.3514404296875, + "pressure": 2682, + "timestamp": 1774179092858 + }, + { + "x": 558.5131225585938, + "y": 243.5400390625, + "pressure": 2688, + "timestamp": 1774179092860 + }, + { + "x": 558.5131225585938, + "y": 244.9267578125, + "pressure": 2691, + "timestamp": 1774179092861 + }, + { + "x": 558.5131225585938, + "y": 246.1153564453125, + "pressure": 2692, + "timestamp": 1774179092863 + }, + { + "x": 558.3150024414062, + "y": 247.303955078125, + "pressure": 2693, + "timestamp": 1774179092865 + }, + { + "x": 558.1168212890625, + "y": 248.4925537109375, + "pressure": 2695, + "timestamp": 1774179092866 + }, + { + "x": 557.918701171875, + "y": 249.68115234375, + "pressure": 2694, + "timestamp": 1774179092870 + }, + { + "x": 557.7205810546875, + "y": 250.671630859375, + "pressure": 2696, + "timestamp": 1774179092872 + }, + { + "x": 557.5224609375, + "y": 251.66217041015625, + "pressure": 2695, + "timestamp": 1774179092874 + }, + { + "x": 557.3243408203125, + "y": 252.65264892578125, + "pressure": 2694, + "timestamp": 1774179092876 + }, + { + "x": 557.126220703125, + "y": 253.6431884765625, + "pressure": 2696, + "timestamp": 1774179092877 + }, + { + "x": 557.126220703125, + "y": 254.435546875, + "pressure": 2695, + "timestamp": 1774179092879 + }, + { + "x": 557.126220703125, + "y": 255.22796630859375, + "pressure": 2694, + "timestamp": 1774179092881 + }, + { + "x": 557.126220703125, + "y": 256.0203857421875, + "pressure": 2696, + "timestamp": 1774179092882 + }, + { + "x": 557.126220703125, + "y": 256.812744140625, + "pressure": 2695, + "timestamp": 1774179092886 + }, + { + "x": 557.126220703125, + "y": 257.60516357421875, + "pressure": 2677, + "timestamp": 1774179092888 + }, + { + "x": 557.126220703125, + "y": 258.00140380859375, + "pressure": 2668, + "timestamp": 1774179092890 + }, + { + "x": 557.126220703125, + "y": 258.00140380859375, + "pressure": 2664, + "timestamp": 1774179092892 + }, + { + "x": 557.126220703125, + "y": 258.00140380859375, + "pressure": 2661, + "timestamp": 1774179092895 + }, + { + "x": 557.3243408203125, + "y": 258.3975830078125, + "pressure": 2661, + "timestamp": 1774179092903 + }, + { + "x": 557.7205810546875, + "y": 259.19000244140625, + "pressure": 2636, + "timestamp": 1774179092904 + }, + { + "x": 557.918701171875, + "y": 259.586181640625, + "pressure": 2624, + "timestamp": 1774179092906 + }, + { + "x": 557.918701171875, + "y": 259.586181640625, + "pressure": 2618, + "timestamp": 1774179092908 + }, + { + "x": 558.3150024414062, + "y": 259.98236083984375, + "pressure": 2615, + "timestamp": 1774179092909 + }, + { + "x": 559.1074829101562, + "y": 260.57666015625, + "pressure": 2613, + "timestamp": 1774179092911 + }, + { + "x": 559.9000244140625, + "y": 260.972900390625, + "pressure": 2612, + "timestamp": 1774179092913 + }, + { + "x": 560.6925048828125, + "y": 261.36907958984375, + "pressure": 2614, + "timestamp": 1774179092915 + }, + { + "x": 561.4849853515625, + "y": 261.76531982421875, + "pressure": 2613, + "timestamp": 1774179092919 + }, + { + "x": 562.2774658203125, + "y": 261.76531982421875, + "pressure": 2598, + "timestamp": 1774179092920 + }, + { + "x": 563.0699462890625, + "y": 261.76531982421875, + "pressure": 2591, + "timestamp": 1774179092922 + }, + { + "x": 563.8624877929688, + "y": 261.76531982421875, + "pressure": 2587, + "timestamp": 1774179092924 + }, + { + "x": 564.8530883789062, + "y": 261.76531982421875, + "pressure": 2585, + "timestamp": 1774179092925 + }, + { + "x": 565.84375, + "y": 261.76531982421875, + "pressure": 2584, + "timestamp": 1774179092927 + }, + { + "x": 566.63623046875, + "y": 261.56719970703125, + "pressure": 2586, + "timestamp": 1774179092929 + }, + { + "x": 567.4287109375, + "y": 261.36907958984375, + "pressure": 2585, + "timestamp": 1774179092930 + }, + { + "x": 568.22119140625, + "y": 261.17095947265625, + "pressure": 2584, + "timestamp": 1774179092935 + }, + { + "x": 569.0137329101562, + "y": 260.972900390625, + "pressure": 2575, + "timestamp": 1774179092936 + }, + { + "x": 570.0043334960938, + "y": 260.7747802734375, + "pressure": 2571, + "timestamp": 1774179092938 + }, + { + "x": 570.9949951171875, + "y": 260.37860107421875, + "pressure": 2569, + "timestamp": 1774179092940 + }, + { + "x": 571.7874755859375, + "y": 259.98236083984375, + "pressure": 2568, + "timestamp": 1774179092941 + }, + { + "x": 572.778076171875, + "y": 259.586181640625, + "pressure": 2567, + "timestamp": 1774179092943 + }, + { + "x": 573.570556640625, + "y": 259.19000244140625, + "pressure": 2569, + "timestamp": 1774179092945 + }, + { + "x": 574.3630981445312, + "y": 258.79376220703125, + "pressure": 2568, + "timestamp": 1774179092947 + }, + { + "x": 575.1555786132812, + "y": 258.3975830078125, + "pressure": 2567, + "timestamp": 1774179092951 + }, + { + "x": 575.9480590820312, + "y": 258.00140380859375, + "pressure": 2551, + "timestamp": 1774179092952 + }, + { + "x": 576.7406005859375, + "y": 257.60516357421875, + "pressure": 2543, + "timestamp": 1774179092954 + }, + { + "x": 577.5330810546875, + "y": 257.208984375, + "pressure": 2539, + "timestamp": 1774179092956 + }, + { + "x": 578.3255615234375, + "y": 256.812744140625, + "pressure": 2537, + "timestamp": 1774179092957 + }, + { + "x": 578.7218017578125, + "y": 256.61468505859375, + "pressure": 2536, + "timestamp": 1774179092959 + }, + { + "x": 579.1180419921875, + "y": 256.21844482421875, + "pressure": 2536, + "timestamp": 1774179092967 + }, + { + "x": 579.9105834960938, + "y": 255.42608642578125, + "pressure": 2511, + "timestamp": 1774179092968 + }, + { + "x": 580.5049438476562, + "y": 254.6336669921875, + "pressure": 2499, + "timestamp": 1774179092970 + }, + { + "x": 581.0993041992188, + "y": 253.84124755859375, + "pressure": 2493, + "timestamp": 1774179092972 + }, + { + "x": 581.4955444335938, + "y": 253.04888916015625, + "pressure": 2490, + "timestamp": 1774179092973 + }, + { + "x": 581.891845703125, + "y": 252.2564697265625, + "pressure": 2488, + "timestamp": 1774179092975 + }, + { + "x": 582.4862060546875, + "y": 251.46405029296875, + "pressure": 2487, + "timestamp": 1774179092977 + }, + { + "x": 582.8824462890625, + "y": 250.671630859375, + "pressure": 2489, + "timestamp": 1774179092979 + }, + { + "x": 583.2786865234375, + "y": 249.8792724609375, + "pressure": 2488, + "timestamp": 1774179092983 + }, + { + "x": 583.476806640625, + "y": 249.08685302734375, + "pressure": 2463, + "timestamp": 1774179092984 + }, + { + "x": 583.873046875, + "y": 248.29443359375, + "pressure": 2451, + "timestamp": 1774179092987 + }, + { + "x": 584.0711669921875, + "y": 247.50201416015625, + "pressure": 2445, + "timestamp": 1774179092988 + }, + { + "x": 584.269287109375, + "y": 246.70965576171875, + "pressure": 2442, + "timestamp": 1774179092990 + }, + { + "x": 584.4674072265625, + "y": 246.31341552734375, + "pressure": 2440, + "timestamp": 1774179092991 + }, + { + "x": 584.4674072265625, + "y": 245.917236328125, + "pressure": 2440, + "timestamp": 1774179092999 + }, + { + "x": 584.66552734375, + "y": 245.12481689453125, + "pressure": 2451, + "timestamp": 1774179093001 + }, + { + "x": 584.8636474609375, + "y": 244.7286376953125, + "pressure": 2456, + "timestamp": 1774179093003 + }, + { + "x": 584.8636474609375, + "y": 244.7286376953125, + "pressure": 2459, + "timestamp": 1774179093004 + }, + { + "x": 584.8636474609375, + "y": 244.7286376953125, + "pressure": 2463, + "timestamp": 1774179093009 + }, + { + "x": 584.66552734375, + "y": 244.3323974609375, + "pressure": 2534, + "timestamp": 1774179093016 + }, + { + "x": 584.4674072265625, + "y": 243.5400390625, + "pressure": 2569, + "timestamp": 1774179093019 + }, + { + "x": 584.4674072265625, + "y": 243.143798828125, + "pressure": 2587, + "timestamp": 1774179093020 + }, + { + "x": 584.4674072265625, + "y": 243.143798828125, + "pressure": 2596, + "timestamp": 1774179093022 + }, + { + "x": 584.4674072265625, + "y": 243.143798828125, + "pressure": 2600, + "timestamp": 1774179093023 + }, + { + "x": 584.4674072265625, + "y": 243.143798828125, + "pressure": 2603, + "timestamp": 1774179093027 + }, + { + "x": 584.0711669921875, + "y": 243.143798828125, + "pressure": 2601, + "timestamp": 1774179093035 + }, + { + "x": 583.2786865234375, + "y": 242.94573974609375, + "pressure": 2600, + "timestamp": 1774179093036 + }, + { + "x": 582.4862060546875, + "y": 243.143798828125, + "pressure": 2602, + "timestamp": 1774179093038 + }, + { + "x": 581.6936645507812, + "y": 243.3419189453125, + "pressure": 2601, + "timestamp": 1774179093039 + }, + { + "x": 580.9011840820312, + "y": 243.73809814453125, + "pressure": 2600, + "timestamp": 1774179093041 + }, + { + "x": 580.1087036132812, + "y": 244.13433837890625, + "pressure": 2602, + "timestamp": 1774179093043 + }, + { + "x": 579.316162109375, + "y": 244.530517578125, + "pressure": 2601, + "timestamp": 1774179093047 + }, + { + "x": 578.523681640625, + "y": 244.9267578125, + "pressure": 2593, + "timestamp": 1774179093049 + }, + { + "x": 577.731201171875, + "y": 245.32293701171875, + "pressure": 2589, + "timestamp": 1774179093051 + }, + { + "x": 576.938720703125, + "y": 245.7191162109375, + "pressure": 2587, + "timestamp": 1774179093052 + }, + { + "x": 576.1461791992188, + "y": 246.1153564453125, + "pressure": 2586, + "timestamp": 1774179093054 + }, + { + "x": 575.7499389648438, + "y": 246.31341552734375, + "pressure": 2588, + "timestamp": 1774179093055 + }, + { + "x": 575.3536987304688, + "y": 246.70965576171875, + "pressure": 2588, + "timestamp": 1774179093063 + }, + { + "x": 574.5612182617188, + "y": 247.303955078125, + "pressure": 2579, + "timestamp": 1774179093065 + }, + { + "x": 574.1649169921875, + "y": 247.70013427734375, + "pressure": 2575, + "timestamp": 1774179093067 + }, + { + "x": 573.7686767578125, + "y": 248.0963134765625, + "pressure": 2572, + "timestamp": 1774179093070 + }, + { + "x": 573.17431640625, + "y": 248.88873291015625, + "pressure": 2571, + "timestamp": 1774179093071 + }, + { + "x": 572.778076171875, + "y": 249.68115234375, + "pressure": 2573, + "timestamp": 1774179093073 + }, + { + "x": 572.5799560546875, + "y": 250.07733154296875, + "pressure": 2572, + "timestamp": 1774179093075 + }, + { + "x": 572.5799560546875, + "y": 250.07733154296875, + "pressure": 2561, + "timestamp": 1774179093080 + }, + { + "x": 572.5799560546875, + "y": 250.07733154296875, + "pressure": 2556, + "timestamp": 1774179093083 + }, + { + "x": 572.3818359375, + "y": 250.47357177734375, + "pressure": 2554, + "timestamp": 1774179093084 + }, + { + "x": 571.7874755859375, + "y": 251.26593017578125, + "pressure": 2553, + "timestamp": 1774179093086 + }, + { + "x": 571.58935546875, + "y": 251.66217041015625, + "pressure": 2552, + "timestamp": 1774179093088 + }, + { + "x": 571.3912353515625, + "y": 252.058349609375, + "pressure": 2534, + "timestamp": 1774179093096 + }, + { + "x": 571.193115234375, + "y": 252.85076904296875, + "pressure": 2525, + "timestamp": 1774179093099 + }, + { + "x": 571.193115234375, + "y": 253.2469482421875, + "pressure": 2521, + "timestamp": 1774179093100 + }, + { + "x": 571.193115234375, + "y": 253.2469482421875, + "pressure": 2518, + "timestamp": 1774179093104 + }, + { + "x": 571.193115234375, + "y": 253.6431884765625, + "pressure": 2518, + "timestamp": 1774179093111 + }, + { + "x": 571.3912353515625, + "y": 254.435546875, + "pressure": 2499, + "timestamp": 1774179093112 + }, + { + "x": 571.58935546875, + "y": 254.831787109375, + "pressure": 2489, + "timestamp": 1774179093115 + }, + { + "x": 571.58935546875, + "y": 254.831787109375, + "pressure": 2484, + "timestamp": 1774179093116 + }, + { + "x": 571.58935546875, + "y": 254.831787109375, + "pressure": 2481, + "timestamp": 1774179093120 + }, + { + "x": 571.985595703125, + "y": 255.22796630859375, + "pressure": 2482, + "timestamp": 1774179093123 + }, + { + "x": 572.778076171875, + "y": 255.822265625, + "pressure": 2481, + "timestamp": 1774179093127 + }, + { + "x": 573.570556640625, + "y": 256.21844482421875, + "pressure": 2462, + "timestamp": 1774179093129 + }, + { + "x": 574.3630981445312, + "y": 256.61468505859375, + "pressure": 2453, + "timestamp": 1774179093131 + }, + { + "x": 574.7593383789062, + "y": 256.61468505859375, + "pressure": 2448, + "timestamp": 1774179093132 + }, + { + "x": 575.1555786132812, + "y": 256.61468505859375, + "pressure": 2445, + "timestamp": 1774179093136 + }, + { + "x": 575.9480590820312, + "y": 256.61468505859375, + "pressure": 2444, + "timestamp": 1774179093137 + }, + { + "x": 576.7406005859375, + "y": 256.61468505859375, + "pressure": 2446, + "timestamp": 1774179093139 + }, + { + "x": 577.5330810546875, + "y": 256.41656494140625, + "pressure": 2445, + "timestamp": 1774179093143 + }, + { + "x": 577.9293212890625, + "y": 256.21844482421875, + "pressure": 2440, + "timestamp": 1774179093145 + }, + { + "x": 577.9293212890625, + "y": 256.21844482421875, + "pressure": 2437, + "timestamp": 1774179093148 + }, + { + "x": 577.9293212890625, + "y": 256.21844482421875, + "pressure": 2436, + "timestamp": 1774179093148 + }, + { + "x": 578.3255615234375, + "y": 256.0203857421875, + "pressure": 2435, + "timestamp": 1774179093150 + }, + { + "x": 579.1180419921875, + "y": 255.6241455078125, + "pressure": 2437, + "timestamp": 1774179093152 + }, + { + "x": 579.9105834960938, + "y": 255.22796630859375, + "pressure": 2436, + "timestamp": 1774179093153 + }, + { + "x": 580.3068237304688, + "y": 255.02984619140625, + "pressure": 2435, + "timestamp": 1774179093155 + }, + { + "x": 580.3068237304688, + "y": 255.02984619140625, + "pressure": 2431, + "timestamp": 1774179093163 + }, + { + "x": 580.7030639648438, + "y": 254.6336669921875, + "pressure": 2430, + "timestamp": 1774179093165 + }, + { + "x": 581.4955444335938, + "y": 253.84124755859375, + "pressure": 2432, + "timestamp": 1774179093166 + }, + { + "x": 582.0899658203125, + "y": 253.04888916015625, + "pressure": 2431, + "timestamp": 1774179093168 + }, + { + "x": 582.4862060546875, + "y": 252.65264892578125, + "pressure": 2430, + "timestamp": 1774179093170 + }, + { + "x": 582.684326171875, + "y": 252.2564697265625, + "pressure": 2431, + "timestamp": 1774179093175 + }, + { + "x": 583.2786865234375, + "y": 251.46405029296875, + "pressure": 2429, + "timestamp": 1774179093177 + }, + { + "x": 583.873046875, + "y": 250.671630859375, + "pressure": 2428, + "timestamp": 1774179093179 + }, + { + "x": 584.269287109375, + "y": 249.8792724609375, + "pressure": 2427, + "timestamp": 1774179093180 + }, + { + "x": 584.4674072265625, + "y": 249.4830322265625, + "pressure": 2429, + "timestamp": 1774179093182 + }, + { + "x": 584.66552734375, + "y": 249.08685302734375, + "pressure": 2427, + "timestamp": 1774179093185 + }, + { + "x": 585.061767578125, + "y": 248.29443359375, + "pressure": 2429, + "timestamp": 1774179093187 + }, + { + "x": 585.2599487304688, + "y": 247.89825439453125, + "pressure": 2428, + "timestamp": 1774179093191 + }, + { + "x": 585.2599487304688, + "y": 247.89825439453125, + "pressure": 2436, + "timestamp": 1774179093193 + }, + { + "x": 585.2599487304688, + "y": 247.89825439453125, + "pressure": 2440, + "timestamp": 1774179093195 + }, + { + "x": 585.2599487304688, + "y": 247.89825439453125, + "pressure": 2443, + "timestamp": 1774179093198 + }, + { + "x": 585.4580688476562, + "y": 247.50201416015625, + "pressure": 2446, + "timestamp": 1774179093203 + }, + { + "x": 585.6561889648438, + "y": 246.70965576171875, + "pressure": 2445, + "timestamp": 1774179093207 + }, + { + "x": 585.8543090820312, + "y": 246.31341552734375, + "pressure": 2472, + "timestamp": 1774179093209 + }, + { + "x": 585.8543090820312, + "y": 246.31341552734375, + "pressure": 2485, + "timestamp": 1774179093211 + }, + { + "x": 585.8543090820312, + "y": 246.31341552734375, + "pressure": 2492, + "timestamp": 1774179093212 + }, + { + "x": 585.8543090820312, + "y": 246.31341552734375, + "pressure": 2495, + "timestamp": 1774179093214 + }, + { + "x": 585.8543090820312, + "y": 246.31341552734375, + "pressure": 2498, + "timestamp": 1774179093219 + }, + { + "x": 585.8543090820312, + "y": 246.31341552734375, + "pressure": 2500, + "timestamp": 1774179093219 + }, + { + "x": 585.8543090820312, + "y": 246.31341552734375, + "pressure": 2539, + "timestamp": 1774179093225 + }, + { + "x": 585.8543090820312, + "y": 246.31341552734375, + "pressure": 2559, + "timestamp": 1774179093227 + }, + { + "x": 585.8543090820312, + "y": 246.31341552734375, + "pressure": 2569, + "timestamp": 1774179093228 + }, + { + "x": 585.8543090820312, + "y": 246.31341552734375, + "pressure": 2574, + "timestamp": 1774179093230 + }, + { + "x": 585.8543090820312, + "y": 246.31341552734375, + "pressure": 2577, + "timestamp": 1774179093232 + }, + { + "x": 585.8543090820312, + "y": 246.31341552734375, + "pressure": 2581, + "timestamp": 1774179093239 + }, + { + "x": 585.8543090820312, + "y": 246.31341552734375, + "pressure": 2575, + "timestamp": 1774179093241 + }, + { + "x": 585.8543090820312, + "y": 246.31341552734375, + "pressure": 2572, + "timestamp": 1774179093243 + }, + { + "x": 585.6561889648438, + "y": 246.70965576171875, + "pressure": 2561, + "timestamp": 1774179093257 + }, + { + "x": 585.061767578125, + "y": 247.50201416015625, + "pressure": 2556, + "timestamp": 1774179093259 + }, + { + "x": 584.4674072265625, + "y": 248.29443359375, + "pressure": 2553, + "timestamp": 1774179093261 + }, + { + "x": 584.269287109375, + "y": 248.69061279296875, + "pressure": 2552, + "timestamp": 1774179093262 + }, + { + "x": 584.0711669921875, + "y": 249.08685302734375, + "pressure": 2551, + "timestamp": 1774179093264 + }, + { + "x": 583.476806640625, + "y": 249.8792724609375, + "pressure": 2553, + "timestamp": 1774179093266 + }, + { + "x": 583.2786865234375, + "y": 250.27545166015625, + "pressure": 2552, + "timestamp": 1774179093268 + }, + { + "x": 583.08056640625, + "y": 250.671630859375, + "pressure": 2551, + "timestamp": 1774179093271 + }, + { + "x": 582.8824462890625, + "y": 251.46405029296875, + "pressure": 2535, + "timestamp": 1774179093273 + }, + { + "x": 582.684326171875, + "y": 252.2564697265625, + "pressure": 2527, + "timestamp": 1774179093276 + }, + { + "x": 582.684326171875, + "y": 252.65264892578125, + "pressure": 2523, + "timestamp": 1774179093277 + }, + { + "x": 582.684326171875, + "y": 252.65264892578125, + "pressure": 2520, + "timestamp": 1774179093280 + }, + { + "x": 582.684326171875, + "y": 253.04888916015625, + "pressure": 2519, + "timestamp": 1774179093282 + }, + { + "x": 582.684326171875, + "y": 253.84124755859375, + "pressure": 2521, + "timestamp": 1774179093283 + }, + { + "x": 582.8824462890625, + "y": 254.23748779296875, + "pressure": 2520, + "timestamp": 1774179093288 + }, + { + "x": 582.8824462890625, + "y": 254.23748779296875, + "pressure": 2499, + "timestamp": 1774179093289 + }, + { + "x": 582.8824462890625, + "y": 254.23748779296875, + "pressure": 2489, + "timestamp": 1774179093292 + }, + { + "x": 582.8824462890625, + "y": 254.6336669921875, + "pressure": 2484, + "timestamp": 1774179093293 + }, + { + "x": 583.2786865234375, + "y": 255.6241455078125, + "pressure": 2481, + "timestamp": 1774179093294 + }, + { + "x": 583.6749267578125, + "y": 256.41656494140625, + "pressure": 2480, + "timestamp": 1774179093296 + }, + { + "x": 583.873046875, + "y": 256.812744140625, + "pressure": 2479, + "timestamp": 1774179093298 + }, + { + "x": 583.873046875, + "y": 256.812744140625, + "pressure": 2465, + "timestamp": 1774179093305 + }, + { + "x": 584.269287109375, + "y": 257.208984375, + "pressure": 2457, + "timestamp": 1774179093307 + }, + { + "x": 585.2599487304688, + "y": 257.80328369140625, + "pressure": 2453, + "timestamp": 1774179093309 + }, + { + "x": 586.2505493164062, + "y": 258.199462890625, + "pressure": 2451, + "timestamp": 1774179093311 + }, + { + "x": 587.0430297851562, + "y": 258.595703125, + "pressure": 2450, + "timestamp": 1774179093312 + }, + { + "x": 587.8355712890625, + "y": 258.99188232421875, + "pressure": 2452, + "timestamp": 1774179093314 + }, + { + "x": 588.6280517578125, + "y": 259.3880615234375, + "pressure": 2451, + "timestamp": 1774179093315 + }, + { + "x": 589.4205322265625, + "y": 259.7843017578125, + "pressure": 2450, + "timestamp": 1774179093319 + }, + { + "x": 590.2130126953125, + "y": 259.98236083984375, + "pressure": 2441, + "timestamp": 1774179093321 + }, + { + "x": 591.0055541992188, + "y": 259.98236083984375, + "pressure": 2436, + "timestamp": 1774179093323 + }, + { + "x": 591.7980346679688, + "y": 259.98236083984375, + "pressure": 2434, + "timestamp": 1774179093325 + }, + { + "x": 592.5905151367188, + "y": 259.98236083984375, + "pressure": 2433, + "timestamp": 1774179093326 + }, + { + "x": 593.383056640625, + "y": 259.98236083984375, + "pressure": 2432, + "timestamp": 1774179093328 + }, + { + "x": 594.175537109375, + "y": 259.98236083984375, + "pressure": 2434, + "timestamp": 1774179093330 + }, + { + "x": 594.57177734375, + "y": 259.98236083984375, + "pressure": 2433, + "timestamp": 1774179093332 + }, + { + "x": 594.57177734375, + "y": 259.98236083984375, + "pressure": 2403, + "timestamp": 1774179093337 + }, + { + "x": 594.968017578125, + "y": 259.98236083984375, + "pressure": 2389, + "timestamp": 1774179093340 + }, + { + "x": 595.760498046875, + "y": 259.7843017578125, + "pressure": 2382, + "timestamp": 1774179093341 + }, + { + "x": 596.5530395507812, + "y": 259.586181640625, + "pressure": 2378, + "timestamp": 1774179093342 + }, + { + "x": 596.9492797851562, + "y": 259.3880615234375, + "pressure": 2376, + "timestamp": 1774179093344 + }, + { + "x": 597.3455200195312, + "y": 259.3880615234375, + "pressure": 2377, + "timestamp": 1774179093347 + }, + { + "x": 598.1380004882812, + "y": 259.19000244140625, + "pressure": 2376, + "timestamp": 1774179093352 + }, + { + "x": 598.5343017578125, + "y": 258.99188232421875, + "pressure": 2367, + "timestamp": 1774179093353 + }, + { + "x": 598.5343017578125, + "y": 258.99188232421875, + "pressure": 2363, + "timestamp": 1774179093356 + }, + { + "x": 598.9305419921875, + "y": 258.79376220703125, + "pressure": 2360, + "timestamp": 1774179093358 + }, + { + "x": 599.7230224609375, + "y": 258.3975830078125, + "pressure": 2359, + "timestamp": 1774179093360 + }, + { + "x": 600.1192626953125, + "y": 258.199462890625, + "pressure": 2361, + "timestamp": 1774179093362 + }, + { + "x": 600.1192626953125, + "y": 258.199462890625, + "pressure": 2372, + "timestamp": 1774179093369 + }, + { + "x": 600.5155029296875, + "y": 258.00140380859375, + "pressure": 2378, + "timestamp": 1774179093372 + }, + { + "x": 601.3079833984375, + "y": 257.60516357421875, + "pressure": 2381, + "timestamp": 1774179093373 + }, + { + "x": 601.7042846679688, + "y": 257.4071044921875, + "pressure": 2383, + "timestamp": 1774179093374 + }, + { + "x": 601.7042846679688, + "y": 257.4071044921875, + "pressure": 2386, + "timestamp": 1774179093378 + }, + { + "x": 601.7042846679688, + "y": 257.4071044921875, + "pressure": 2409, + "timestamp": 1774179093385 + }, + { + "x": 601.7042846679688, + "y": 257.4071044921875, + "pressure": 2420, + "timestamp": 1774179093387 + }, + { + "x": 601.7042846679688, + "y": 257.4071044921875, + "pressure": 2425, + "timestamp": 1774179093390 + }, + { + "x": 602.1005249023438, + "y": 257.208984375, + "pressure": 2428, + "timestamp": 1774179093390 + }, + { + "x": 602.8930053710938, + "y": 256.61468505859375, + "pressure": 2429, + "timestamp": 1774179093392 + }, + { + "x": 603.2892456054688, + "y": 256.21844482421875, + "pressure": 2430, + "timestamp": 1774179093394 + }, + { + "x": 603.2892456054688, + "y": 256.21844482421875, + "pressure": 2448, + "timestamp": 1774179093401 + }, + { + "x": 603.2892456054688, + "y": 256.21844482421875, + "pressure": 2457, + "timestamp": 1774179093404 + }, + { + "x": 603.2892456054688, + "y": 256.21844482421875, + "pressure": 2461, + "timestamp": 1774179093405 + }, + { + "x": 603.4873657226562, + "y": 255.822265625, + "pressure": 2464, + "timestamp": 1774179093409 + }, + { + "x": 604.081787109375, + "y": 255.02984619140625, + "pressure": 2465, + "timestamp": 1774179093410 + }, + { + "x": 604.47802734375, + "y": 254.23748779296875, + "pressure": 2467, + "timestamp": 1774179093412 + }, + { + "x": 604.6761474609375, + "y": 253.84124755859375, + "pressure": 2466, + "timestamp": 1774179093416 + }, + { + "x": 604.6761474609375, + "y": 253.84124755859375, + "pressure": 2469, + "timestamp": 1774179093417 + }, + { + "x": 604.6761474609375, + "y": 253.84124755859375, + "pressure": 2472, + "timestamp": 1774179093421 + }, + { + "x": 604.874267578125, + "y": 253.445068359375, + "pressure": 2474, + "timestamp": 1774179093423 + }, + { + "x": 605.0723876953125, + "y": 252.65264892578125, + "pressure": 2473, + "timestamp": 1774179093424 + }, + { + "x": 605.2705078125, + "y": 251.8602294921875, + "pressure": 2475, + "timestamp": 1774179093426 + }, + { + "x": 605.4686279296875, + "y": 251.46405029296875, + "pressure": 2474, + "timestamp": 1774179093428 + }, + { + "x": 605.666748046875, + "y": 251.06787109375, + "pressure": 2473, + "timestamp": 1774179093442 + }, + { + "x": 606.2611083984375, + "y": 250.27545166015625, + "pressure": 2475, + "timestamp": 1774179093444 + }, + { + "x": 606.459228515625, + "y": 249.8792724609375, + "pressure": 2474, + "timestamp": 1774179093448 + }, + { + "x": 606.459228515625, + "y": 249.8792724609375, + "pressure": 2471, + "timestamp": 1774179093449 + }, + { + "x": 606.6573486328125, + "y": 249.4830322265625, + "pressure": 2471, + "timestamp": 1774179093455 + }, + { + "x": 607.2517700195312, + "y": 248.69061279296875, + "pressure": 2470, + "timestamp": 1774179093457 + }, + { + "x": 607.4498901367188, + "y": 248.29443359375, + "pressure": 2469, + "timestamp": 1774179093458 + }, + { + "x": 607.6480102539062, + "y": 247.89825439453125, + "pressure": 2470, + "timestamp": 1774179093469 + }, + { + "x": 608.2423706054688, + "y": 247.1058349609375, + "pressure": 2469, + "timestamp": 1774179093471 + }, + { + "x": 608.6386108398438, + "y": 246.70965576171875, + "pressure": 2468, + "timestamp": 1774179093472 + }, + { + "x": 608.8367309570312, + "y": 246.31341552734375, + "pressure": 2471, + "timestamp": 1774179093485 + }, + { + "x": 609.43115234375, + "y": 245.52105712890625, + "pressure": 2470, + "timestamp": 1774179093487 + }, + { + "x": 609.827392578125, + "y": 245.12481689453125, + "pressure": 2469, + "timestamp": 1774179093488 + }, + { + "x": 609.827392578125, + "y": 245.12481689453125, + "pressure": 2472, + "timestamp": 1774179093500 + }, + { + "x": 610.0255126953125, + "y": 244.7286376953125, + "pressure": 2474, + "timestamp": 1774179093501 + }, + { + "x": 610.4217529296875, + "y": 244.3323974609375, + "pressure": 2473, + "timestamp": 1774179093503 + }, + { + "x": 610.619873046875, + "y": 243.93621826171875, + "pressure": 2473, + "timestamp": 1774179093508 + }, + { + "x": 611.2142333984375, + "y": 243.143798828125, + "pressure": 2475, + "timestamp": 1774179093512 + }, + { + "x": 611.412353515625, + "y": 242.74761962890625, + "pressure": 2477, + "timestamp": 1774179093514 + }, + { + "x": 611.6104736328125, + "y": 242.3514404296875, + "pressure": 2475, + "timestamp": 1774179093523 + }, + { + "x": 612.204833984375, + "y": 241.55902099609375, + "pressure": 2477, + "timestamp": 1774179093524 + }, + { + "x": 612.6011352539062, + "y": 241.162841796875, + "pressure": 2476, + "timestamp": 1774179093528 + }, + { + "x": 612.7992553710938, + "y": 240.7666015625, + "pressure": 2477, + "timestamp": 1774179093532 + }, + { + "x": 613.3936157226562, + "y": 239.97418212890625, + "pressure": 2479, + "timestamp": 1774179093533 + }, + { + "x": 613.9879760742188, + "y": 239.18182373046875, + "pressure": 2478, + "timestamp": 1774179093535 + }, + { + "x": 614.1860961914062, + "y": 238.78558349609375, + "pressure": 2477, + "timestamp": 1774179093537 + }, + { + "x": 614.3842163085938, + "y": 238.389404296875, + "pressure": 2478, + "timestamp": 1774179093540 + }, + { + "x": 614.780517578125, + "y": 237.59698486328125, + "pressure": 2477, + "timestamp": 1774179093544 + }, + { + "x": 615.1767578125, + "y": 236.80462646484375, + "pressure": 2476, + "timestamp": 1774179093546 + }, + { + "x": 615.572998046875, + "y": 236.01220703125, + "pressure": 2475, + "timestamp": 1774179093548 + }, + { + "x": 615.7711181640625, + "y": 235.615966796875, + "pressure": 2477, + "timestamp": 1774179093549 + }, + { + "x": 615.96923828125, + "y": 235.21978759765625, + "pressure": 2475, + "timestamp": 1774179093553 + }, + { + "x": 616.5635986328125, + "y": 234.4273681640625, + "pressure": 2477, + "timestamp": 1774179093555 + }, + { + "x": 616.9598388671875, + "y": 233.635009765625, + "pressure": 2476, + "timestamp": 1774179093556 + }, + { + "x": 617.3560791015625, + "y": 232.84259033203125, + "pressure": 2475, + "timestamp": 1774179093561 + }, + { + "x": 617.7523193359375, + "y": 232.0501708984375, + "pressure": 2473, + "timestamp": 1774179093562 + }, + { + "x": 618.1486206054688, + "y": 231.25775146484375, + "pressure": 2472, + "timestamp": 1774179093564 + }, + { + "x": 618.5448608398438, + "y": 230.46539306640625, + "pressure": 2474, + "timestamp": 1774179093565 + }, + { + "x": 618.9411010742188, + "y": 229.6729736328125, + "pressure": 2473, + "timestamp": 1774179093568 + }, + { + "x": 619.3373413085938, + "y": 228.88058471679688, + "pressure": 2472, + "timestamp": 1774179093569 + }, + { + "x": 619.5354614257812, + "y": 228.08816528320312, + "pressure": 2474, + "timestamp": 1774179093570 + }, + { + "x": 619.7335815429688, + "y": 227.2957763671875, + "pressure": 2473, + "timestamp": 1774179093572 + }, + { + "x": 619.9317016601562, + "y": 226.50335693359375, + "pressure": 2472, + "timestamp": 1774179093576 + }, + { + "x": 620.3280029296875, + "y": 225.71096801757812, + "pressure": 2473, + "timestamp": 1774179093578 + }, + { + "x": 620.526123046875, + "y": 224.91854858398438, + "pressure": 2474, + "timestamp": 1774179093580 + }, + { + "x": 620.92236328125, + "y": 224.12615966796875, + "pressure": 2476, + "timestamp": 1774179093582 + }, + { + "x": 621.1204833984375, + "y": 223.72994995117188, + "pressure": 2475, + "timestamp": 1774179093583 + }, + { + "x": 621.318603515625, + "y": 223.333740234375, + "pressure": 2476, + "timestamp": 1774179093586 + }, + { + "x": 621.71484375, + "y": 222.54135131835938, + "pressure": 2475, + "timestamp": 1774179093588 + }, + { + "x": 621.9129638671875, + "y": 221.74893188476562, + "pressure": 2477, + "timestamp": 1774179093593 + }, + { + "x": 622.111083984375, + "y": 221.35275268554688, + "pressure": 2469, + "timestamp": 1774179093594 + }, + { + "x": 622.111083984375, + "y": 221.35275268554688, + "pressure": 2465, + "timestamp": 1774179093596 + }, + { + "x": 622.111083984375, + "y": 221.35275268554688, + "pressure": 2462, + "timestamp": 1774179093599 + }, + { + "x": 622.3092041015625, + "y": 220.95654296875, + "pressure": 2464, + "timestamp": 1774179093601 + }, + { + "x": 622.50732421875, + "y": 220.16412353515625, + "pressure": 2463, + "timestamp": 1774179093602 + }, + { + "x": 622.7054443359375, + "y": 219.7679443359375, + "pressure": 2462, + "timestamp": 1774179093604 + }, + { + "x": 622.7054443359375, + "y": 219.7679443359375, + "pressure": 2471, + "timestamp": 1774179093610 + }, + { + "x": 622.7054443359375, + "y": 219.7679443359375, + "pressure": 2475, + "timestamp": 1774179093612 + }, + { + "x": 622.7054443359375, + "y": 219.7679443359375, + "pressure": 2478, + "timestamp": 1774179093615 + }, + { + "x": 622.7054443359375, + "y": 219.7679443359375, + "pressure": 2481, + "timestamp": 1774179093620 + }, + { + "x": 622.7054443359375, + "y": 219.7679443359375, + "pressure": 2498, + "timestamp": 1774179093626 + }, + { + "x": 622.7054443359375, + "y": 219.7679443359375, + "pressure": 2507, + "timestamp": 1774179093628 + }, + { + "x": 622.7054443359375, + "y": 219.7679443359375, + "pressure": 2511, + "timestamp": 1774179093630 + }, + { + "x": 622.7054443359375, + "y": 219.7679443359375, + "pressure": 2514, + "timestamp": 1774179093633 + }, + { + "x": 622.7054443359375, + "y": 219.7679443359375, + "pressure": 2517, + "timestamp": 1774179093636 + }, + { + "x": 622.7054443359375, + "y": 219.7679443359375, + "pressure": 2498, + "timestamp": 1774179093642 + }, + { + "x": 622.7054443359375, + "y": 219.7679443359375, + "pressure": 2489, + "timestamp": 1774179093644 + }, + { + "x": 622.7054443359375, + "y": 219.7679443359375, + "pressure": 2485, + "timestamp": 1774179093645 + }, + { + "x": 622.3092041015625, + "y": 220.16412353515625, + "pressure": 2482, + "timestamp": 1774179093649 + }, + { + "x": 621.71484375, + "y": 221.15463256835938, + "pressure": 2481, + "timestamp": 1774179093651 + }, + { + "x": 620.92236328125, + "y": 222.1451416015625, + "pressure": 2483, + "timestamp": 1774179093652 + }, + { + "x": 619.9317016601562, + "y": 223.333740234375, + "pressure": 2482, + "timestamp": 1774179093656 + }, + { + "x": 619.1392211914062, + "y": 224.5223388671875, + "pressure": 2466, + "timestamp": 1774179093658 + }, + { + "x": 618.3467407226562, + "y": 225.71096801757812, + "pressure": 2458, + "timestamp": 1774179093660 + }, + { + "x": 617.55419921875, + "y": 227.09765625, + "pressure": 2454, + "timestamp": 1774179093662 + }, + { + "x": 616.76171875, + "y": 228.2862548828125, + "pressure": 2452, + "timestamp": 1774179093663 + }, + { + "x": 615.96923828125, + "y": 229.6729736328125, + "pressure": 2451, + "timestamp": 1774179093665 + }, + { + "x": 615.3748779296875, + "y": 230.861572265625, + "pressure": 2453, + "timestamp": 1774179093667 + }, + { + "x": 614.780517578125, + "y": 232.0501708984375, + "pressure": 2452, + "timestamp": 1774179093668 + }, + { + "x": 614.1860961914062, + "y": 233.23876953125, + "pressure": 2451, + "timestamp": 1774179093672 + }, + { + "x": 613.5917358398438, + "y": 234.4273681640625, + "pressure": 2443, + "timestamp": 1774179093674 + }, + { + "x": 612.9973754882812, + "y": 235.615966796875, + "pressure": 2439, + "timestamp": 1774179093676 + }, + { + "x": 612.204833984375, + "y": 237.002685546875, + "pressure": 2437, + "timestamp": 1774179093678 + }, + { + "x": 611.6104736328125, + "y": 238.1912841796875, + "pressure": 2436, + "timestamp": 1774179093679 + }, + { + "x": 611.2142333984375, + "y": 239.3798828125, + "pressure": 2435, + "timestamp": 1774179093681 + }, + { + "x": 610.8179931640625, + "y": 240.37042236328125, + "pressure": 2437, + "timestamp": 1774179093683 + }, + { + "x": 610.4217529296875, + "y": 241.55902099609375, + "pressure": 2436, + "timestamp": 1774179093684 + }, + { + "x": 610.0255126953125, + "y": 242.74761962890625, + "pressure": 2435, + "timestamp": 1774179093688 + }, + { + "x": 609.6292724609375, + "y": 243.93621826171875, + "pressure": 2424, + "timestamp": 1774179093690 + }, + { + "x": 609.2330322265625, + "y": 244.9267578125, + "pressure": 2419, + "timestamp": 1774179093692 + }, + { + "x": 608.8367309570312, + "y": 245.917236328125, + "pressure": 2416, + "timestamp": 1774179093694 + }, + { + "x": 608.4404907226562, + "y": 246.70965576171875, + "pressure": 2415, + "timestamp": 1774179093695 + }, + { + "x": 608.2423706054688, + "y": 247.50201416015625, + "pressure": 2414, + "timestamp": 1774179093697 + }, + { + "x": 608.0442504882812, + "y": 248.29443359375, + "pressure": 2416, + "timestamp": 1774179093699 + }, + { + "x": 608.0442504882812, + "y": 248.69061279296875, + "pressure": 2415, + "timestamp": 1774179093700 + }, + { + "x": 608.0442504882812, + "y": 249.08685302734375, + "pressure": 2414, + "timestamp": 1774179093705 + }, + { + "x": 608.0442504882812, + "y": 249.8792724609375, + "pressure": 2400, + "timestamp": 1774179093706 + }, + { + "x": 608.0442504882812, + "y": 250.27545166015625, + "pressure": 2393, + "timestamp": 1774179093709 + }, + { + "x": 608.0442504882812, + "y": 250.27545166015625, + "pressure": 2389, + "timestamp": 1774179093710 + }, + { + "x": 608.0442504882812, + "y": 250.671630859375, + "pressure": 2387, + "timestamp": 1774179093712 + }, + { + "x": 608.0442504882812, + "y": 251.66217041015625, + "pressure": 2386, + "timestamp": 1774179093713 + }, + { + "x": 608.0442504882812, + "y": 252.45452880859375, + "pressure": 2388, + "timestamp": 1774179093715 + }, + { + "x": 608.2423706054688, + "y": 252.85076904296875, + "pressure": 2387, + "timestamp": 1774179093716 + }, + { + "x": 608.2423706054688, + "y": 252.85076904296875, + "pressure": 2380, + "timestamp": 1774179093722 + }, + { + "x": 608.2423706054688, + "y": 253.2469482421875, + "pressure": 2377, + "timestamp": 1774179093724 + }, + { + "x": 608.4404907226562, + "y": 254.03936767578125, + "pressure": 2376, + "timestamp": 1774179093726 + }, + { + "x": 608.6386108398438, + "y": 254.831787109375, + "pressure": 2375, + "timestamp": 1774179093727 + }, + { + "x": 608.8367309570312, + "y": 255.6241455078125, + "pressure": 2377, + "timestamp": 1774179093729 + }, + { + "x": 609.0348510742188, + "y": 256.41656494140625, + "pressure": 2376, + "timestamp": 1774179093731 + }, + { + "x": 609.2330322265625, + "y": 257.208984375, + "pressure": 2375, + "timestamp": 1774179093732 + }, + { + "x": 609.43115234375, + "y": 257.60516357421875, + "pressure": 2377, + "timestamp": 1774179093736 + }, + { + "x": 609.6292724609375, + "y": 258.00140380859375, + "pressure": 2377, + "timestamp": 1774179093747 + }, + { + "x": 610.2236328125, + "y": 258.79376220703125, + "pressure": 2376, + "timestamp": 1774179093749 + }, + { + "x": 610.619873046875, + "y": 259.19000244140625, + "pressure": 2378, + "timestamp": 1774179093753 + }, + { + "x": 610.619873046875, + "y": 259.19000244140625, + "pressure": 2383, + "timestamp": 1774179093754 + }, + { + "x": 611.01611328125, + "y": 259.586181640625, + "pressure": 2386, + "timestamp": 1774179093756 + }, + { + "x": 611.80859375, + "y": 260.18048095703125, + "pressure": 2387, + "timestamp": 1774179093758 + }, + { + "x": 612.6011352539062, + "y": 260.57666015625, + "pressure": 2388, + "timestamp": 1774179093760 + }, + { + "x": 612.9973754882812, + "y": 260.7747802734375, + "pressure": 2390, + "timestamp": 1774179093761 + }, + { + "x": 613.3936157226562, + "y": 260.972900390625, + "pressure": 2394, + "timestamp": 1774179093770 + }, + { + "x": 614.1860961914062, + "y": 261.56719970703125, + "pressure": 2396, + "timestamp": 1774179093772 + }, + { + "x": 614.5823364257812, + "y": 261.76531982421875, + "pressure": 2397, + "timestamp": 1774179093775 + }, + { + "x": 614.5823364257812, + "y": 261.76531982421875, + "pressure": 2399, + "timestamp": 1774179093775 + }, + { + "x": 614.9786376953125, + "y": 261.96337890625, + "pressure": 2400, + "timestamp": 1774179093779 + }, + { + "x": 615.7711181640625, + "y": 262.1614990234375, + "pressure": 2399, + "timestamp": 1774179093781 + }, + { + "x": 616.5635986328125, + "y": 262.55767822265625, + "pressure": 2398, + "timestamp": 1774179093785 + }, + { + "x": 616.9598388671875, + "y": 262.55767822265625, + "pressure": 2397, + "timestamp": 1774179093786 + }, + { + "x": 617.3560791015625, + "y": 262.55767822265625, + "pressure": 2397, + "timestamp": 1774179093791 + }, + { + "x": 618.1486206054688, + "y": 262.55767822265625, + "pressure": 2396, + "timestamp": 1774179093793 + }, + { + "x": 618.9411010742188, + "y": 262.55767822265625, + "pressure": 2398, + "timestamp": 1774179093795 + }, + { + "x": 619.7335815429688, + "y": 262.55767822265625, + "pressure": 2397, + "timestamp": 1774179093797 + }, + { + "x": 620.1298828125, + "y": 262.359619140625, + "pressure": 2396, + "timestamp": 1774179093801 + }, + { + "x": 620.1298828125, + "y": 262.359619140625, + "pressure": 2391, + "timestamp": 1774179093802 + }, + { + "x": 620.526123046875, + "y": 262.359619140625, + "pressure": 2388, + "timestamp": 1774179093806 + }, + { + "x": 621.318603515625, + "y": 262.1614990234375, + "pressure": 2387, + "timestamp": 1774179093807 + }, + { + "x": 622.111083984375, + "y": 261.96337890625, + "pressure": 2389, + "timestamp": 1774179093809 + }, + { + "x": 622.903564453125, + "y": 261.56719970703125, + "pressure": 2388, + "timestamp": 1774179093811 + }, + { + "x": 623.2998046875, + "y": 261.17095947265625, + "pressure": 2387, + "timestamp": 1774179093813 + }, + { + "x": 623.6961059570312, + "y": 260.7747802734375, + "pressure": 2386, + "timestamp": 1774179093820 + }, + { + "x": 624.4885864257812, + "y": 259.98236083984375, + "pressure": 2385, + "timestamp": 1774179093822 + }, + { + "x": 625.2810668945312, + "y": 259.19000244140625, + "pressure": 2387, + "timestamp": 1774179093823 + }, + { + "x": 626.0736083984375, + "y": 258.3975830078125, + "pressure": 2386, + "timestamp": 1774179093826 + }, + { + "x": 626.66796875, + "y": 257.60516357421875, + "pressure": 2385, + "timestamp": 1774179093827 + }, + { + "x": 627.2623291015625, + "y": 256.812744140625, + "pressure": 2387, + "timestamp": 1774179093829 + }, + { + "x": 627.46044921875, + "y": 256.41656494140625, + "pressure": 2386, + "timestamp": 1774179093833 + }, + { + "x": 627.6585693359375, + "y": 256.0203857421875, + "pressure": 2387, + "timestamp": 1774179093837 + }, + { + "x": 628.2529296875, + "y": 255.02984619140625, + "pressure": 2386, + "timestamp": 1774179093838 + }, + { + "x": 628.8472900390625, + "y": 254.23748779296875, + "pressure": 2388, + "timestamp": 1774179093840 + }, + { + "x": 629.4417114257812, + "y": 253.445068359375, + "pressure": 2387, + "timestamp": 1774179093841 + }, + { + "x": 630.0360717773438, + "y": 252.65264892578125, + "pressure": 2386, + "timestamp": 1774179093843 + }, + { + "x": 630.6304321289062, + "y": 251.8602294921875, + "pressure": 2388, + "timestamp": 1774179093845 + }, + { + "x": 631.224853515625, + "y": 251.06787109375, + "pressure": 2387, + "timestamp": 1774179093849 + }, + { + "x": 631.62109375, + "y": 250.671630859375, + "pressure": 2378, + "timestamp": 1774179093850 + }, + { + "x": 631.62109375, + "y": 250.671630859375, + "pressure": 2374, + "timestamp": 1774179093853 + }, + { + "x": 631.8192138671875, + "y": 250.27545166015625, + "pressure": 2372, + "timestamp": 1774179093854 + }, + { + "x": 632.41357421875, + "y": 249.4830322265625, + "pressure": 2371, + "timestamp": 1774179093856 + }, + { + "x": 633.0079345703125, + "y": 248.69061279296875, + "pressure": 2370, + "timestamp": 1774179093857 + }, + { + "x": 633.602294921875, + "y": 247.89825439453125, + "pressure": 2372, + "timestamp": 1774179093859 + }, + { + "x": 633.99853515625, + "y": 247.50201416015625, + "pressure": 2371, + "timestamp": 1774179093861 + }, + { + "x": 633.99853515625, + "y": 247.50201416015625, + "pressure": 2364, + "timestamp": 1774179093867 + }, + { + "x": 634.1966552734375, + "y": 247.1058349609375, + "pressure": 2361, + "timestamp": 1774179093870 + }, + { + "x": 634.7910766601562, + "y": 246.31341552734375, + "pressure": 2360, + "timestamp": 1774179093870 + }, + { + "x": 635.1873168945312, + "y": 245.917236328125, + "pressure": 2359, + "timestamp": 1774179093872 + }, + { + "x": 635.3854370117188, + "y": 245.52105712890625, + "pressure": 2359, + "timestamp": 1774179093877 + }, + { + "x": 635.9797973632812, + "y": 244.7286376953125, + "pressure": 2361, + "timestamp": 1774179093881 + }, + { + "x": 636.1779174804688, + "y": 244.3323974609375, + "pressure": 2356, + "timestamp": 1774179093883 + }, + { + "x": 636.1779174804688, + "y": 244.3323974609375, + "pressure": 2353, + "timestamp": 1774179093886 + }, + { + "x": 636.1779174804688, + "y": 244.3323974609375, + "pressure": 2358, + "timestamp": 1774179093899 + }, + { + "x": 636.1779174804688, + "y": 244.3323974609375, + "pressure": 2361, + "timestamp": 1774179093902 + }, + { + "x": 636.1779174804688, + "y": 244.3323974609375, + "pressure": 2364, + "timestamp": 1774179093907 + }, + { + "x": 636.1779174804688, + "y": 244.3323974609375, + "pressure": 2369, + "timestamp": 1774179093914 + }, + { + "x": 636.1779174804688, + "y": 244.3323974609375, + "pressure": 2373, + "timestamp": 1774179093917 + }, + { + "x": 636.1779174804688, + "y": 244.3323974609375, + "pressure": 2376, + "timestamp": 1774179093920 + }, + { + "x": 636.1779174804688, + "y": 244.3323974609375, + "pressure": 2379, + "timestamp": 1774179093925 + }, + { + "x": 636.1779174804688, + "y": 244.3323974609375, + "pressure": 2358, + "timestamp": 1774179093931 + }, + { + "x": 636.1779174804688, + "y": 244.3323974609375, + "pressure": 2348, + "timestamp": 1774179093933 + }, + { + "x": 636.1779174804688, + "y": 244.3323974609375, + "pressure": 2343, + "timestamp": 1774179093934 + }, + { + "x": 636.1779174804688, + "y": 244.3323974609375, + "pressure": 2340, + "timestamp": 1774179093938 + }, + { + "x": 636.1779174804688, + "y": 244.3323974609375, + "pressure": 2332, + "timestamp": 1774179093946 + }, + { + "x": 636.1779174804688, + "y": 244.3323974609375, + "pressure": 2328, + "timestamp": 1774179093949 + }, + { + "x": 636.1779174804688, + "y": 244.3323974609375, + "pressure": 2325, + "timestamp": 1774179093952 + }, + { + "x": 635.9797973632812, + "y": 244.7286376953125, + "pressure": 2325, + "timestamp": 1774179093957 + }, + { + "x": 635.7816772460938, + "y": 245.7191162109375, + "pressure": 2327, + "timestamp": 1774179093961 + }, + { + "x": 635.3854370117188, + "y": 246.70965576171875, + "pressure": 2323, + "timestamp": 1774179093963 + }, + { + "x": 634.9891967773438, + "y": 247.70013427734375, + "pressure": 2321, + "timestamp": 1774179093965 + }, + { + "x": 634.5929565429688, + "y": 248.69061279296875, + "pressure": 2320, + "timestamp": 1774179093966 + }, + { + "x": 634.1966552734375, + "y": 249.68115234375, + "pressure": 2322, + "timestamp": 1774179093968 + }, + { + "x": 633.8004150390625, + "y": 250.47357177734375, + "pressure": 2321, + "timestamp": 1774179093970 + }, + { + "x": 633.4041748046875, + "y": 251.26593017578125, + "pressure": 2320, + "timestamp": 1774179093971 + }, + { + "x": 633.2060546875, + "y": 252.058349609375, + "pressure": 2322, + "timestamp": 1774179093973 + }, + { + "x": 633.0079345703125, + "y": 252.85076904296875, + "pressure": 2321, + "timestamp": 1774179093977 + }, + { + "x": 633.0079345703125, + "y": 253.2469482421875, + "pressure": 2313, + "timestamp": 1774179093979 + }, + { + "x": 633.0079345703125, + "y": 253.2469482421875, + "pressure": 2309, + "timestamp": 1774179093981 + }, + { + "x": 632.809814453125, + "y": 253.6431884765625, + "pressure": 2307, + "timestamp": 1774179093982 + }, + { + "x": 632.6116943359375, + "y": 254.435546875, + "pressure": 2306, + "timestamp": 1774179093984 + }, + { + "x": 632.6116943359375, + "y": 255.22796630859375, + "pressure": 2308, + "timestamp": 1774179093986 + }, + { + "x": 632.6116943359375, + "y": 256.0203857421875, + "pressure": 2307, + "timestamp": 1774179093987 + }, + { + "x": 632.6116943359375, + "y": 256.812744140625, + "pressure": 2306, + "timestamp": 1774179093989 + }, + { + "x": 632.6116943359375, + "y": 257.60516357421875, + "pressure": 2308, + "timestamp": 1774179093993 + }, + { + "x": 632.6116943359375, + "y": 258.00140380859375, + "pressure": 2294, + "timestamp": 1774179093995 + }, + { + "x": 632.6116943359375, + "y": 258.00140380859375, + "pressure": 2287, + "timestamp": 1774179093997 + }, + { + "x": 632.6116943359375, + "y": 258.00140380859375, + "pressure": 2284, + "timestamp": 1774179093999 + }, + { + "x": 632.6116943359375, + "y": 258.3975830078125, + "pressure": 2281, + "timestamp": 1774179094002 + }, + { + "x": 632.809814453125, + "y": 259.19000244140625, + "pressure": 2283, + "timestamp": 1774179094003 + }, + { + "x": 633.0079345703125, + "y": 259.586181640625, + "pressure": 2282, + "timestamp": 1774179094005 + }, + { + "x": 633.0079345703125, + "y": 259.586181640625, + "pressure": 2269, + "timestamp": 1774179094011 + }, + { + "x": 633.0079345703125, + "y": 259.586181640625, + "pressure": 2263, + "timestamp": 1774179094013 + }, + { + "x": 633.0079345703125, + "y": 259.586181640625, + "pressure": 2260, + "timestamp": 1774179094014 + }, + { + "x": 633.0079345703125, + "y": 259.586181640625, + "pressure": 2255, + "timestamp": 1774179094027 + }, + { + "x": 633.2060546875, + "y": 259.98236083984375, + "pressure": 2253, + "timestamp": 1774179094030 + }, + { + "x": 633.4041748046875, + "y": 260.37860107421875, + "pressure": 2255, + "timestamp": 1774179094032 + }, + { + "x": 633.8004150390625, + "y": 260.57666015625, + "pressure": 2258, + "timestamp": 1774179094048 + }, + { + "x": 634.5929565429688, + "y": 260.972900390625, + "pressure": 2257, + "timestamp": 1774179094050 + }, + { + "x": 635.3854370117188, + "y": 261.36907958984375, + "pressure": 2256, + "timestamp": 1774179094052 + }, + { + "x": 635.7816772460938, + "y": 261.36907958984375, + "pressure": 2258, + "timestamp": 1774179094053 + }, + { + "x": 636.1779174804688, + "y": 261.36907958984375, + "pressure": 2248, + "timestamp": 1774179094059 + }, + { + "x": 636.970458984375, + "y": 261.36907958984375, + "pressure": 2244, + "timestamp": 1774179094061 + }, + { + "x": 637.36669921875, + "y": 261.17095947265625, + "pressure": 2242, + "timestamp": 1774179094063 + }, + { + "x": 637.762939453125, + "y": 260.972900390625, + "pressure": 2240, + "timestamp": 1774179094066 + }, + { + "x": 638.555419921875, + "y": 260.57666015625, + "pressure": 2242, + "timestamp": 1774179094068 + }, + { + "x": 639.347900390625, + "y": 260.18048095703125, + "pressure": 2241, + "timestamp": 1774179094069 + }, + { + "x": 640.1404418945312, + "y": 259.586181640625, + "pressure": 2240, + "timestamp": 1774179094073 + }, + { + "x": 640.9329223632812, + "y": 258.99188232421875, + "pressure": 2234, + "timestamp": 1774179094075 + }, + { + "x": 641.7254028320312, + "y": 258.3975830078125, + "pressure": 2231, + "timestamp": 1774179094077 + }, + { + "x": 642.5179443359375, + "y": 257.80328369140625, + "pressure": 2230, + "timestamp": 1774179094079 + }, + { + "x": 643.3104248046875, + "y": 257.208984375, + "pressure": 2229, + "timestamp": 1774179094080 + }, + { + "x": 644.1029052734375, + "y": 256.61468505859375, + "pressure": 2231, + "timestamp": 1774179094082 + }, + { + "x": 644.4991455078125, + "y": 256.21844482421875, + "pressure": 2230, + "timestamp": 1774179094084 + }, + { + "x": 644.8953857421875, + "y": 256.0203857421875, + "pressure": 2231, + "timestamp": 1774179094089 + }, + { + "x": 645.6879272460938, + "y": 255.42608642578125, + "pressure": 2232, + "timestamp": 1774179094091 + }, + { + "x": 646.4804077148438, + "y": 254.831787109375, + "pressure": 2234, + "timestamp": 1774179094093 + }, + { + "x": 647.2728881835938, + "y": 254.23748779296875, + "pressure": 2233, + "timestamp": 1774179094095 + }, + { + "x": 647.669189453125, + "y": 253.84124755859375, + "pressure": 2235, + "timestamp": 1774179094096 + }, + { + "x": 648.0654296875, + "y": 253.6431884765625, + "pressure": 2235, + "timestamp": 1774179094101 + }, + { + "x": 648.85791015625, + "y": 252.85076904296875, + "pressure": 2234, + "timestamp": 1774179094105 + }, + { + "x": 649.254150390625, + "y": 252.45452880859375, + "pressure": 2237, + "timestamp": 1774179094107 + }, + { + "x": 649.254150390625, + "y": 252.45452880859375, + "pressure": 2240, + "timestamp": 1774179094111 + }, + { + "x": 649.650390625, + "y": 252.2564697265625, + "pressure": 2241, + "timestamp": 1774179094114 + }, + { + "x": 650.44287109375, + "y": 251.66217041015625, + "pressure": 2243, + "timestamp": 1774179094116 + }, + { + "x": 650.8391723632812, + "y": 251.26593017578125, + "pressure": 2242, + "timestamp": 1774179094117 + }, + { + "x": 650.8391723632812, + "y": 251.26593017578125, + "pressure": 2238, + "timestamp": 1774179094123 + }, + { + "x": 650.8391723632812, + "y": 251.26593017578125, + "pressure": 2235, + "timestamp": 1774179094127 + }, + { + "x": 650.8391723632812, + "y": 251.26593017578125, + "pressure": 2239, + "timestamp": 1774179094142 + }, + { + "x": 650.8391723632812, + "y": 251.26593017578125, + "pressure": 2245, + "timestamp": 1774179094155 + }, + { + "x": 650.8391723632812, + "y": 251.26593017578125, + "pressure": 2248, + "timestamp": 1774179094157 + }, + { + "x": 650.8391723632812, + "y": 251.26593017578125, + "pressure": 2252, + "timestamp": 1774179094162 + }, + { + "x": 650.8391723632812, + "y": 251.26593017578125, + "pressure": 2237, + "timestamp": 1774179094171 + }, + { + "x": 650.8391723632812, + "y": 251.26593017578125, + "pressure": 2229, + "timestamp": 1774179094173 + }, + { + "x": 650.8391723632812, + "y": 251.26593017578125, + "pressure": 2225, + "timestamp": 1774179094175 + }, + { + "x": 650.8391723632812, + "y": 251.26593017578125, + "pressure": 2222, + "timestamp": 1774179094178 + }, + { + "x": 650.8391723632812, + "y": 251.26593017578125, + "pressure": 2230, + "timestamp": 1774179094203 + }, + { + "x": 650.8391723632812, + "y": 251.26593017578125, + "pressure": 2233, + "timestamp": 1774179094205 + }, + { + "x": 650.8391723632812, + "y": 251.26593017578125, + "pressure": 2236, + "timestamp": 1774179094208 + }, + { + "x": 650.8391723632812, + "y": 251.26593017578125, + "pressure": 2239, + "timestamp": 1774179094214 + }, + { + "x": 650.8391723632812, + "y": 251.26593017578125, + "pressure": 2242, + "timestamp": 1774179094223 + }, + { + "x": 650.8391723632812, + "y": 251.26593017578125, + "pressure": 2245, + "timestamp": 1774179094228 + }, + { + "x": 650.8391723632812, + "y": 251.26593017578125, + "pressure": 2248, + "timestamp": 1774179094237 + }, + { + "x": 650.8391723632812, + "y": 251.26593017578125, + "pressure": 2251, + "timestamp": 1774179094241 + }, + { + "x": 650.8391723632812, + "y": 251.26593017578125, + "pressure": 2244, + "timestamp": 1774179094251 + }, + { + "x": 650.8391723632812, + "y": 251.26593017578125, + "pressure": 2241, + "timestamp": 1774179094254 + }, + { + "x": 650.8391723632812, + "y": 251.26593017578125, + "pressure": 2231, + "timestamp": 1774179094268 + }, + { + "x": 650.8391723632812, + "y": 251.26593017578125, + "pressure": 2226, + "timestamp": 1774179094270 + }, + { + "x": 650.8391723632812, + "y": 251.26593017578125, + "pressure": 2223, + "timestamp": 1774179094273 + }, + { + "x": 650.8391723632812, + "y": 251.26593017578125, + "pressure": 2220, + "timestamp": 1774179094302 + }, + { + "x": 650.8391723632812, + "y": 251.26593017578125, + "pressure": 2208, + "timestamp": 1774179094316 + }, + { + "x": 650.8391723632812, + "y": 251.26593017578125, + "pressure": 2202, + "timestamp": 1774179094318 + }, + { + "x": 650.8391723632812, + "y": 251.26593017578125, + "pressure": 2199, + "timestamp": 1774179094319 + }, + { + "x": 650.8391723632812, + "y": 251.26593017578125, + "pressure": 2184, + "timestamp": 1774179094331 + }, + { + "x": 650.8391723632812, + "y": 251.26593017578125, + "pressure": 2178, + "timestamp": 1774179094334 + }, + { + "x": 650.8391723632812, + "y": 251.26593017578125, + "pressure": 2175, + "timestamp": 1774179094335 + }, + { + "x": 650.8391723632812, + "y": 251.26593017578125, + "pressure": 2172, + "timestamp": 1774179094339 + }, + { + "x": 650.8391723632812, + "y": 251.26593017578125, + "pressure": 2164, + "timestamp": 1774179094348 + }, + { + "x": 650.8391723632812, + "y": 251.26593017578125, + "pressure": 2160, + "timestamp": 1774179094350 + }, + { + "x": 650.8391723632812, + "y": 251.26593017578125, + "pressure": 2157, + "timestamp": 1774179094353 + }, + { + "x": 651.2354125976562, + "y": 251.06787109375, + "pressure": 2159, + "timestamp": 1774179094362 + }, + { + "x": 652.0278930664062, + "y": 250.47357177734375, + "pressure": 2150, + "timestamp": 1774179094364 + }, + { + "x": 652.4241333007812, + "y": 250.27545166015625, + "pressure": 2146, + "timestamp": 1774179094366 + }, + { + "x": 652.4241333007812, + "y": 250.27545166015625, + "pressure": 2143, + "timestamp": 1774179094369 + }, + { + "x": 652.8203735351562, + "y": 250.07733154296875, + "pressure": 2142, + "timestamp": 1774179094371 + }, + { + "x": 653.6129150390625, + "y": 249.4830322265625, + "pressure": 2144, + "timestamp": 1774179094372 + }, + { + "x": 654.4053955078125, + "y": 249.08685302734375, + "pressure": 2143, + "timestamp": 1774179094374 + }, + { + "x": 654.8016357421875, + "y": 248.88873291015625, + "pressure": 2142, + "timestamp": 1774179094379 + }, + { + "x": 654.8016357421875, + "y": 248.88873291015625, + "pressure": 2159, + "timestamp": 1774179094380 + }, + { + "x": 655.1978759765625, + "y": 248.69061279296875, + "pressure": 2167, + "timestamp": 1774179094382 + }, + { + "x": 655.9903564453125, + "y": 248.29443359375, + "pressure": 2171, + "timestamp": 1774179094383 + }, + { + "x": 656.7828979492188, + "y": 248.0963134765625, + "pressure": 2173, + "timestamp": 1774179094385 + }, + { + "x": 657.1791381835938, + "y": 247.89825439453125, + "pressure": 2174, + "timestamp": 1774179094387 + }, + { + "x": 657.1791381835938, + "y": 247.89825439453125, + "pressure": 2177, + "timestamp": 1774179094390 + }, + { + "x": 657.1791381835938, + "y": 247.89825439453125, + "pressure": 2174, + "timestamp": 1774179094396 + }, + { + "x": 657.5753784179688, + "y": 247.70013427734375, + "pressure": 2175, + "timestamp": 1774179094399 + }, + { + "x": 658.367919921875, + "y": 247.303955078125, + "pressure": 2174, + "timestamp": 1774179094401 + }, + { + "x": 658.76416015625, + "y": 247.1058349609375, + "pressure": 2173, + "timestamp": 1774179094403 + }, + { + "x": 658.76416015625, + "y": 247.1058349609375, + "pressure": 2176, + "timestamp": 1774179094412 + }, + { + "x": 658.76416015625, + "y": 247.1058349609375, + "pressure": 2180, + "timestamp": 1774179094417 + }, + { + "x": 658.76416015625, + "y": 247.1058349609375, + "pressure": 2162, + "timestamp": 1774179094428 + }, + { + "x": 658.76416015625, + "y": 247.1058349609375, + "pressure": 2153, + "timestamp": 1774179094430 + }, + { + "x": 658.76416015625, + "y": 247.1058349609375, + "pressure": 2149, + "timestamp": 1774179094431 + }, + { + "x": 658.76416015625, + "y": 247.1058349609375, + "pressure": 2146, + "timestamp": 1774179094435 + }, + { + "x": 658.76416015625, + "y": 247.1058349609375, + "pressure": 2153, + "timestamp": 1774179094444 + }, + { + "x": 658.76416015625, + "y": 247.1058349609375, + "pressure": 2157, + "timestamp": 1774179094446 + }, + { + "x": 658.76416015625, + "y": 247.1058349609375, + "pressure": 2160, + "timestamp": 1774179094449 + }, + { + "x": 658.76416015625, + "y": 247.1058349609375, + "pressure": 2163, + "timestamp": 1774179094454 + }, + { + "x": 658.76416015625, + "y": 247.1058349609375, + "pressure": 2169, + "timestamp": 1774179094460 + }, + { + "x": 658.76416015625, + "y": 247.1058349609375, + "pressure": 2172, + "timestamp": 1774179094463 + }, + { + "x": 658.9622802734375, + "y": 247.50201416015625, + "pressure": 2174, + "timestamp": 1774179094463 + }, + { + "x": 659.556640625, + "y": 248.29443359375, + "pressure": 2175, + "timestamp": 1774179094465 + }, + { + "x": 659.7547607421875, + "y": 248.69061279296875, + "pressure": 2177, + "timestamp": 1774179094467 + }, + { + "x": 659.952880859375, + "y": 249.08685302734375, + "pressure": 2179, + "timestamp": 1774179094476 + }, + { + "x": 660.34912109375, + "y": 249.8792724609375, + "pressure": 2180, + "timestamp": 1774179094478 + }, + { + "x": 660.5472412109375, + "y": 250.27545166015625, + "pressure": 2182, + "timestamp": 1774179094480 + }, + { + "x": 660.5472412109375, + "y": 250.27545166015625, + "pressure": 2186, + "timestamp": 1774179094492 + }, + { + "x": 660.5472412109375, + "y": 250.671630859375, + "pressure": 2190, + "timestamp": 1774179094497 + }, + { + "x": 660.745361328125, + "y": 251.46405029296875, + "pressure": 2189, + "timestamp": 1774179094499 + }, + { + "x": 660.9434814453125, + "y": 251.8602294921875, + "pressure": 2191, + "timestamp": 1774179094501 + }, + { + "x": 660.9434814453125, + "y": 251.8602294921875, + "pressure": 2179, + "timestamp": 1774179094524 + }, + { + "x": 660.9434814453125, + "y": 251.8602294921875, + "pressure": 2174, + "timestamp": 1774179094526 + }, + { + "x": 660.9434814453125, + "y": 251.8602294921875, + "pressure": 2171, + "timestamp": 1774179094528 + }, + { + "x": 660.9434814453125, + "y": 251.8602294921875, + "pressure": 2160, + "timestamp": 1774179094540 + }, + { + "x": 660.9434814453125, + "y": 251.8602294921875, + "pressure": 2156, + "timestamp": 1774179094543 + }, + { + "x": 660.9434814453125, + "y": 251.8602294921875, + "pressure": 2153, + "timestamp": 1774179094545 + }, + { + "x": 660.9434814453125, + "y": 251.8602294921875, + "pressure": 1993, + "timestamp": 1774179094556 + }, + { + "x": 660.9434814453125, + "y": 251.8602294921875, + "pressure": 1914, + "timestamp": 1774179094558 + }, + { + "x": 660.9434814453125, + "y": 251.8602294921875, + "pressure": 1874, + "timestamp": 1774179094560 + }, + { + "x": 660.9434814453125, + "y": 251.8602294921875, + "pressure": 1854, + "timestamp": 1774179094561 + }, + { + "x": 660.9434814453125, + "y": 251.8602294921875, + "pressure": 1844, + "timestamp": 1774179094563 + }, + { + "x": 660.9434814453125, + "y": 251.8602294921875, + "pressure": 1839, + "timestamp": 1774179094565 + }, + { + "x": 660.9434814453125, + "y": 251.8602294921875, + "pressure": 1836, + "timestamp": 1774179094571 + }, + { + "x": 660.9434814453125, + "y": 251.8602294921875, + "pressure": 1828, + "timestamp": 1774179094572 + }, + { + "x": 660.9434814453125, + "y": 251.8602294921875, + "pressure": 1824, + "timestamp": 1774179094575 + }, + { + "x": 660.9434814453125, + "y": 251.8602294921875, + "pressure": 1821, + "timestamp": 1774179094577 + }, + { + "x": 660.9434814453125, + "y": 251.8602294921875, + "pressure": 1901, + "timestamp": 1774179094588 + }, + { + "x": 660.9434814453125, + "y": 251.8602294921875, + "pressure": 1941, + "timestamp": 1774179094591 + }, + { + "x": 660.9434814453125, + "y": 251.8602294921875, + "pressure": 1961, + "timestamp": 1774179094592 + }, + { + "x": 660.5472412109375, + "y": 251.66217041015625, + "pressure": 1971, + "timestamp": 1774179094593 + }, + { + "x": 659.7547607421875, + "y": 251.26593017578125, + "pressure": 1976, + "timestamp": 1774179094595 + }, + { + "x": 659.3585205078125, + "y": 251.06787109375, + "pressure": 1979, + "timestamp": 1774179094597 + }, + { + "x": 658.9622802734375, + "y": 250.8697509765625, + "pressure": 1980, + "timestamp": 1774179094598 + }, + { + "x": 658.1697387695312, + "y": 250.47357177734375, + "pressure": 1981, + "timestamp": 1774179094602 + }, + { + "x": 657.3772583007812, + "y": 250.27545166015625, + "pressure": 2009, + "timestamp": 1774179094604 + }, + { + "x": 656.9810180664062, + "y": 250.07733154296875, + "pressure": 2023, + "timestamp": 1774179094607 + }, + { + "x": 656.9810180664062, + "y": 250.07733154296875, + "pressure": 2030, + "timestamp": 1774179094608 + }, + { + "x": 656.9810180664062, + "y": 250.07733154296875, + "pressure": 2034, + "timestamp": 1774179094610 + }, + { + "x": 656.5847778320312, + "y": 250.07733154296875, + "pressure": 2036, + "timestamp": 1774179094611 + }, + { + "x": 655.5941162109375, + "y": 249.8792724609375, + "pressure": 2037, + "timestamp": 1774179094613 + }, + { + "x": 654.8016357421875, + "y": 249.68115234375, + "pressure": 2039, + "timestamp": 1774179094615 + }, + { + "x": 654.0091552734375, + "y": 249.4830322265625, + "pressure": 2038, + "timestamp": 1774179094619 + }, + { + "x": 653.6129150390625, + "y": 249.4830322265625, + "pressure": 2037, + "timestamp": 1774179094620 + }, + { + "x": 653.2166748046875, + "y": 249.68115234375, + "pressure": 2037, + "timestamp": 1774179094626 + }, + { + "x": 652.2260131835938, + "y": 249.8792724609375, + "pressure": 2039, + "timestamp": 1774179094627 + }, + { + "x": 651.4335327148438, + "y": 250.27545166015625, + "pressure": 2038, + "timestamp": 1774179094629 + }, + { + "x": 650.6409912109375, + "y": 250.671630859375, + "pressure": 2037, + "timestamp": 1774179094631 + }, + { + "x": 650.2447509765625, + "y": 250.8697509765625, + "pressure": 2039, + "timestamp": 1774179094636 + }, + { + "x": 650.2447509765625, + "y": 250.8697509765625, + "pressure": 2025, + "timestamp": 1774179094636 + }, + { + "x": 650.2447509765625, + "y": 250.8697509765625, + "pressure": 2018, + "timestamp": 1774179094639 + }, + { + "x": 650.2447509765625, + "y": 250.8697509765625, + "pressure": 2014, + "timestamp": 1774179094640 + }, + { + "x": 649.8485107421875, + "y": 251.26593017578125, + "pressure": 2012, + "timestamp": 1774179094642 + }, + { + "x": 649.0560302734375, + "y": 251.8602294921875, + "pressure": 2011, + "timestamp": 1774179094643 + }, + { + "x": 648.2635498046875, + "y": 252.65264892578125, + "pressure": 2013, + "timestamp": 1774179094645 + }, + { + "x": 648.0654296875, + "y": 253.04888916015625, + "pressure": 2012, + "timestamp": 1774179094647 + }, + { + "x": 647.669189453125, + "y": 253.445068359375, + "pressure": 1989, + "timestamp": 1774179094652 + }, + { + "x": 647.0747680664062, + "y": 254.435546875, + "pressure": 1978, + "timestamp": 1774179094655 + }, + { + "x": 646.6785278320312, + "y": 255.42608642578125, + "pressure": 1973, + "timestamp": 1774179094656 + }, + { + "x": 646.2822875976562, + "y": 256.61468505859375, + "pressure": 1970, + "timestamp": 1774179094657 + }, + { + "x": 645.8860473632812, + "y": 257.4071044921875, + "pressure": 1969, + "timestamp": 1774179094659 + }, + { + "x": 645.6879272460938, + "y": 257.80328369140625, + "pressure": 1968, + "timestamp": 1774179094661 + }, + { + "x": 645.4898071289062, + "y": 258.199462890625, + "pressure": 1969, + "timestamp": 1774179094667 + }, + { + "x": 645.2916259765625, + "y": 258.99188232421875, + "pressure": 1940, + "timestamp": 1774179094669 + }, + { + "x": 645.2916259765625, + "y": 259.7843017578125, + "pressure": 1925, + "timestamp": 1774179094671 + }, + { + "x": 645.2916259765625, + "y": 260.57666015625, + "pressure": 1918, + "timestamp": 1774179094672 + }, + { + "x": 645.2916259765625, + "y": 260.972900390625, + "pressure": 1914, + "timestamp": 1774179094674 + }, + { + "x": 645.2916259765625, + "y": 261.36907958984375, + "pressure": 1911, + "timestamp": 1774179094677 + }, + { + "x": 645.4898071289062, + "y": 262.1614990234375, + "pressure": 1913, + "timestamp": 1774179094679 + }, + { + "x": 645.6879272460938, + "y": 262.95391845703125, + "pressure": 1912, + "timestamp": 1774179094683 + }, + { + "x": 645.8860473632812, + "y": 263.35009765625, + "pressure": 1894, + "timestamp": 1774179094684 + }, + { + "x": 645.8860473632812, + "y": 263.35009765625, + "pressure": 1885, + "timestamp": 1774179094687 + }, + { + "x": 645.8860473632812, + "y": 263.35009765625, + "pressure": 1880, + "timestamp": 1774179094688 + }, + { + "x": 646.2822875976562, + "y": 263.74627685546875, + "pressure": 1878, + "timestamp": 1774179094690 + }, + { + "x": 647.0747680664062, + "y": 264.5386962890625, + "pressure": 1877, + "timestamp": 1774179094691 + }, + { + "x": 647.8673095703125, + "y": 265.33111572265625, + "pressure": 1876, + "timestamp": 1774179094693 + }, + { + "x": 648.6597900390625, + "y": 265.9254150390625, + "pressure": 1878, + "timestamp": 1774179094695 + }, + { + "x": 649.650390625, + "y": 266.51971435546875, + "pressure": 1877, + "timestamp": 1774179094699 + }, + { + "x": 650.8391723632812, + "y": 266.9158935546875, + "pressure": 1892, + "timestamp": 1774179094700 + }, + { + "x": 652.0278930664062, + "y": 267.3121337890625, + "pressure": 1899, + "timestamp": 1774179094703 + }, + { + "x": 653.2166748046875, + "y": 267.70831298828125, + "pressure": 1903, + "timestamp": 1774179094704 + }, + { + "x": 654.4053955078125, + "y": 268.1044921875, + "pressure": 1905, + "timestamp": 1774179094706 + }, + { + "x": 655.5941162109375, + "y": 268.3026123046875, + "pressure": 1906, + "timestamp": 1774179094707 + }, + { + "x": 656.9810180664062, + "y": 268.69879150390625, + "pressure": 1908, + "timestamp": 1774179094709 + }, + { + "x": 658.367919921875, + "y": 268.89691162109375, + "pressure": 1907, + "timestamp": 1774179094711 + }, + { + "x": 659.7547607421875, + "y": 268.89691162109375, + "pressure": 1909, + "timestamp": 1774179094715 + }, + { + "x": 661.1416015625, + "y": 268.89691162109375, + "pressure": 1894, + "timestamp": 1774179094716 + }, + { + "x": 662.5285034179688, + "y": 268.89691162109375, + "pressure": 1887, + "timestamp": 1774179094719 + }, + { + "x": 664.113525390625, + "y": 268.89691162109375, + "pressure": 1883, + "timestamp": 1774179094721 + }, + { + "x": 665.698486328125, + "y": 268.69879150390625, + "pressure": 1881, + "timestamp": 1774179094722 + }, + { + "x": 667.2835083007812, + "y": 268.500732421875, + "pressure": 1880, + "timestamp": 1774179094724 + }, + { + "x": 668.8684692382812, + "y": 268.3026123046875, + "pressure": 1882, + "timestamp": 1774179094725 + }, + { + "x": 670.4534912109375, + "y": 268.1044921875, + "pressure": 1881, + "timestamp": 1774179094727 + }, + { + "x": 672.0384521484375, + "y": 267.90643310546875, + "pressure": 1880, + "timestamp": 1774179094731 + }, + { + "x": 673.6234741210938, + "y": 267.70831298828125, + "pressure": 1666, + "timestamp": 1774179094733 + }, + { + "x": 675.20849609375, + "y": 267.51019287109375, + "pressure": 1559, + "timestamp": 1774179094735 + }, + { + "x": 676.79345703125, + "y": 267.3121337890625, + "pressure": 1506, + "timestamp": 1774179094736 + }, + { + "x": 678.1803588867188, + "y": 267.114013671875, + "pressure": 1479, + "timestamp": 1774179094738 + }, + { + "x": 679.5671997070312, + "y": 266.9158935546875, + "pressure": 1466, + "timestamp": 1774179094739 + }, + { + "x": 680.7559814453125, + "y": 266.51971435546875, + "pressure": 1459, + "timestamp": 1774179094741 + }, + { + "x": 681.9447021484375, + "y": 266.12353515625, + "pressure": 1456, + "timestamp": 1774179094743 + }, + { + "x": 682.935302734375, + "y": 265.727294921875, + "pressure": 1454, + "timestamp": 1774179094747 + }, + { + "x": 683.9259643554688, + "y": 265.33111572265625, + "pressure": 843, + "timestamp": 1774179094749 + }, + { + "x": 684.9165649414062, + "y": 264.93487548828125, + "pressure": 537, + "timestamp": 1774179094751 + }, + { + "x": 685.9072265625, + "y": 264.5386962890625, + "pressure": 384, + "timestamp": 1774179094752 + }, + { + "x": 686.8978271484375, + "y": 264.14251708984375, + "pressure": 308, + "timestamp": 1774179094754 + }, + { + "x": 687.888427734375, + "y": 263.74627685546875, + "pressure": 270, + "timestamp": 1774179094755 + }, + { + "x": 688.8790283203125, + "y": 263.35009765625, + "pressure": 251, + "timestamp": 1774179094757 + }, + { + "x": 689.8696899414062, + "y": 262.95391845703125, + "pressure": 241, + "timestamp": 1774179094759 + }, + { + "x": 690.6621704101562, + "y": 262.55767822265625, + "pressure": 236, + "timestamp": 1774179094763 + }, + { + "x": 690.6621704101562, + "y": 262.55767822265625, + "pressure": 236, + "timestamp": 1774179094764 + } + ] + }, + { + "index": 7, + "pointCount": 43, + "points": [ + { + "x": 588.03369140625, + "y": 235.02166748046875, + "pressure": 887, + "timestamp": 1774179095008 + }, + { + "x": 588.03369140625, + "y": 235.02166748046875, + "pressure": 1330, + "timestamp": 1774179095009 + }, + { + "x": 588.03369140625, + "y": 235.02166748046875, + "pressure": 1552, + "timestamp": 1774179095011 + }, + { + "x": 588.03369140625, + "y": 235.02166748046875, + "pressure": 1663, + "timestamp": 1774179095013 + }, + { + "x": 588.03369140625, + "y": 235.02166748046875, + "pressure": 1718, + "timestamp": 1774179095014 + }, + { + "x": 588.03369140625, + "y": 235.02166748046875, + "pressure": 1746, + "timestamp": 1774179095016 + }, + { + "x": 588.03369140625, + "y": 235.02166748046875, + "pressure": 1760, + "timestamp": 1774179095019 + }, + { + "x": 588.03369140625, + "y": 235.02166748046875, + "pressure": 1763, + "timestamp": 1774179095021 + }, + { + "x": 588.03369140625, + "y": 235.02166748046875, + "pressure": 1766, + "timestamp": 1774179095025 + }, + { + "x": 588.03369140625, + "y": 235.02166748046875, + "pressure": 1769, + "timestamp": 1774179095030 + }, + { + "x": 588.03369140625, + "y": 235.02166748046875, + "pressure": 1889, + "timestamp": 1774179095037 + }, + { + "x": 588.03369140625, + "y": 235.02166748046875, + "pressure": 1950, + "timestamp": 1774179095039 + }, + { + "x": 589.0242919921875, + "y": 235.21978759765625, + "pressure": 1981, + "timestamp": 1774179095041 + }, + { + "x": 591.2036743164062, + "y": 235.41790771484375, + "pressure": 1996, + "timestamp": 1774179095042 + }, + { + "x": 593.5811767578125, + "y": 235.41790771484375, + "pressure": 2004, + "timestamp": 1774179095044 + }, + { + "x": 595.9586181640625, + "y": 235.41790771484375, + "pressure": 2008, + "timestamp": 1774179095046 + }, + { + "x": 598.336181640625, + "y": 235.41790771484375, + "pressure": 2010, + "timestamp": 1774179095048 + }, + { + "x": 601.10986328125, + "y": 235.41790771484375, + "pressure": 2011, + "timestamp": 1774179095052 + }, + { + "x": 603.8836669921875, + "y": 235.21978759765625, + "pressure": 2008, + "timestamp": 1774179095053 + }, + { + "x": 606.6573486328125, + "y": 235.02166748046875, + "pressure": 2006, + "timestamp": 1774179095056 + }, + { + "x": 609.43115234375, + "y": 234.8236083984375, + "pressure": 2005, + "timestamp": 1774179095057 + }, + { + "x": 612.204833984375, + "y": 234.62548828125, + "pressure": 2007, + "timestamp": 1774179095059 + }, + { + "x": 614.780517578125, + "y": 234.4273681640625, + "pressure": 2006, + "timestamp": 1774179095060 + }, + { + "x": 617.3560791015625, + "y": 234.22930908203125, + "pressure": 2005, + "timestamp": 1774179095062 + }, + { + "x": 619.9317016601562, + "y": 234.03118896484375, + "pressure": 2007, + "timestamp": 1774179095064 + }, + { + "x": 622.3092041015625, + "y": 233.83306884765625, + "pressure": 2006, + "timestamp": 1774179095068 + }, + { + "x": 624.6867065429688, + "y": 233.635009765625, + "pressure": 1990, + "timestamp": 1774179095069 + }, + { + "x": 627.064208984375, + "y": 233.4368896484375, + "pressure": 1982, + "timestamp": 1774179095072 + }, + { + "x": 629.4417114257812, + "y": 233.23876953125, + "pressure": 1978, + "timestamp": 1774179095073 + }, + { + "x": 631.62109375, + "y": 233.04071044921875, + "pressure": 1976, + "timestamp": 1774179095074 + }, + { + "x": 633.8004150390625, + "y": 232.84259033203125, + "pressure": 1975, + "timestamp": 1774179095076 + }, + { + "x": 635.7816772460938, + "y": 232.84259033203125, + "pressure": 1974, + "timestamp": 1774179095078 + }, + { + "x": 637.762939453125, + "y": 232.84259033203125, + "pressure": 1976, + "timestamp": 1774179095080 + }, + { + "x": 639.744140625, + "y": 232.64447021484375, + "pressure": 1975, + "timestamp": 1774179095084 + }, + { + "x": 641.5272827148438, + "y": 232.4464111328125, + "pressure": 1444, + "timestamp": 1774179095085 + }, + { + "x": 643.3104248046875, + "y": 232.248291015625, + "pressure": 1178, + "timestamp": 1774179095087 + }, + { + "x": 645.093505859375, + "y": 232.0501708984375, + "pressure": 1045, + "timestamp": 1774179095089 + }, + { + "x": 646.4804077148438, + "y": 231.85205078125, + "pressure": 979, + "timestamp": 1774179095091 + }, + { + "x": 647.8673095703125, + "y": 231.65399169921875, + "pressure": 946, + "timestamp": 1774179095092 + }, + { + "x": 649.254150390625, + "y": 231.45587158203125, + "pressure": 929, + "timestamp": 1774179095094 + }, + { + "x": 650.6409912109375, + "y": 231.25775146484375, + "pressure": 921, + "timestamp": 1774179095096 + }, + { + "x": 651.8297729492188, + "y": 231.0596923828125, + "pressure": 917, + "timestamp": 1774179095100 + }, + { + "x": 651.8297729492188, + "y": 231.0596923828125, + "pressure": 917, + "timestamp": 1774179095101 + } + ] + }, + { + "index": 8, + "pointCount": 28, + "points": [ + { + "x": 654.999755859375, + "y": 218.97552490234375, + "pressure": 1032, + "timestamp": 1774179095248 + }, + { + "x": 654.999755859375, + "y": 218.97552490234375, + "pressure": 1548, + "timestamp": 1774179095249 + }, + { + "x": 654.999755859375, + "y": 218.97552490234375, + "pressure": 1806, + "timestamp": 1774179095251 + }, + { + "x": 654.999755859375, + "y": 218.97552490234375, + "pressure": 1935, + "timestamp": 1774179095253 + }, + { + "x": 654.999755859375, + "y": 218.97552490234375, + "pressure": 1999, + "timestamp": 1774179095254 + }, + { + "x": 654.999755859375, + "y": 218.97552490234375, + "pressure": 2031, + "timestamp": 1774179095256 + }, + { + "x": 654.999755859375, + "y": 218.97552490234375, + "pressure": 2047, + "timestamp": 1774179095260 + }, + { + "x": 654.999755859375, + "y": 218.97552490234375, + "pressure": 2099, + "timestamp": 1774179095262 + }, + { + "x": 654.999755859375, + "y": 218.97552490234375, + "pressure": 2125, + "timestamp": 1774179095264 + }, + { + "x": 654.999755859375, + "y": 218.97552490234375, + "pressure": 2138, + "timestamp": 1774179095265 + }, + { + "x": 654.999755859375, + "y": 218.97552490234375, + "pressure": 2144, + "timestamp": 1774179095267 + }, + { + "x": 654.999755859375, + "y": 218.97552490234375, + "pressure": 2147, + "timestamp": 1774179095269 + }, + { + "x": 654.999755859375, + "y": 218.97552490234375, + "pressure": 2150, + "timestamp": 1774179095272 + }, + { + "x": 654.999755859375, + "y": 218.97552490234375, + "pressure": 2158, + "timestamp": 1774179095278 + }, + { + "x": 654.999755859375, + "y": 218.97552490234375, + "pressure": 2161, + "timestamp": 1774179095280 + }, + { + "x": 654.999755859375, + "y": 218.97552490234375, + "pressure": 2165, + "timestamp": 1774179095285 + }, + { + "x": 654.999755859375, + "y": 218.97552490234375, + "pressure": 2156, + "timestamp": 1774179095294 + }, + { + "x": 654.999755859375, + "y": 218.97552490234375, + "pressure": 2152, + "timestamp": 1774179095296 + }, + { + "x": 654.999755859375, + "y": 218.97552490234375, + "pressure": 2149, + "timestamp": 1774179095299 + }, + { + "x": 654.999755859375, + "y": 218.97552490234375, + "pressure": 1769, + "timestamp": 1774179095310 + }, + { + "x": 654.999755859375, + "y": 218.97552490234375, + "pressure": 1580, + "timestamp": 1774179095312 + }, + { + "x": 654.999755859375, + "y": 218.97552490234375, + "pressure": 1485, + "timestamp": 1774179095313 + }, + { + "x": 654.999755859375, + "y": 218.97552490234375, + "pressure": 1438, + "timestamp": 1774179095315 + }, + { + "x": 654.999755859375, + "y": 218.97552490234375, + "pressure": 1414, + "timestamp": 1774179095317 + }, + { + "x": 654.999755859375, + "y": 218.97552490234375, + "pressure": 1402, + "timestamp": 1774179095319 + }, + { + "x": 654.999755859375, + "y": 218.97552490234375, + "pressure": 1396, + "timestamp": 1774179095320 + }, + { + "x": 654.999755859375, + "y": 218.97552490234375, + "pressure": 1393, + "timestamp": 1774179095324 + }, + { + "x": 654.999755859375, + "y": 218.97552490234375, + "pressure": 1393, + "timestamp": 1774179095326 + } + ] + } + ] +} \ No newline at end of file diff --git a/docs/VISION.md b/docs/VISION.md new file mode 100644 index 0000000..f341b53 --- /dev/null +++ b/docs/VISION.md @@ -0,0 +1,170 @@ +# InkUp — Product Vision + +> A note-taking system that respects the physicality of handwriting while unlocking the composability of digital. + +*Draft · March 2026* + +--- + +## Product thesis + +Every note-taking app forces a choice: capture naturally with a stylus, or capture in a format that's useful later. Handwriting apps produce dead-end image files. Structured apps demand you type and organize in real time, breaking the flow of thought. The gap between *capturing an idea* and *composing with it later* is where most note-taking tools fail. + +InkUp bridges this gap. It treats handwritten strokes, diagrams, and embedded media as first-class objects—not raster images—that preserve spatial layout, support semantic classification, and reflow intelligently across surfaces. The result is a note that lives beyond the moment of capture: it can be prettified, projected, annotated, archived, cross-referenced, and exported into downstream tools like NotebookLM. + +> **Core bet:** The gap between "fast enough to capture a thought" and "structured enough to use later" is a real, unsolved problem. E-ink is the right surface for bridging it—it removes the screen's distraction tax while enabling digital persistence. + +### Conceptual architecture + +![InkUp conceptual architecture](./architecture.svg) + +--- + +## User journeys + +These journeys define the critical paths that every design and engineering decision must serve. Each journey has a **capture phase** (real-time, low-friction), a **composition phase** (post-capture enrichment), and an **output phase** (the note becomes useful beyond itself). + +### Journey 1: The learner + +**Persona:** A conference attendee, grad student, or professional in a lecture or presentation. They think with their hands. They need to capture fast and refine later. + +#### Capture + +The user is handwriting key phrases from a speaker. A slide flashes by with a Venn diagram that resonates. Without switching modes or opening a menu, they sketch the diagram directly into the same note, spatially adjacent to their text. The note captures both the words and the visual artifact in their original spatial relationship—exactly as they would appear on a physical notepad. + +#### Composition + +- **Prettify:** At home, the user triggers a prettification pass. Handwriting is replaced with legible typeset text, preserving the original spatial layout. The sketch is kept as-is (or optionally cleaned up). The result looks like a well-formatted page, not a scan of messy notes. +- **Reflow:** The user projects the note onto a larger screen. The reflow engine adapts the layout: text and drawings are resized and repositioned to make judicious use of the extra real estate, while preserving the conceptual groupings from the original note. +- **Annotate:** The user adds post-hoc annotations: correcting a term now that they understand the full presentation, starring key points, adding marginal commentary. These annotations are versioned—the original capture is never destroyed. + +#### Output + +The entire note—strokes, prettified text, diagrams, annotations—is exported to NotebookLM alongside the original slides or video. The downstream tool generates a structured summary, audio recap, or study guide. The note has traveled from a fleeting moment of capture to a durable learning artifact. + +### Journey 2: The spontaneous idea + +**Persona:** A product designer, founder, or engineer who thinks visually and iterates on ideas by drawing them. They need seamless text+diagram integration and the ability to return to an idea later. + +#### Capture + +An idea strikes. The user opens InkUp and begins sketching a user journey: a cartoon-style storyboard of UI screens connected by text descriptions. The tool doesn't force a choice between "drawing mode" and "text mode." Strokes are strokes. The system classifies them semantically after the fact. + +#### Composition + +- **Archive and retrieve:** The note is filed in the user's archive. It's indexable by semantic content (not just filename), cross-referenceable with other notes, and retrievable months later when the idea becomes relevant again. +- **Surface transition:** The user opens the note on a larger writing surface. The storyboard reflows cleanly into the larger canvas—frames expand, text becomes more readable, the spatial narrative is preserved. +- **Iterate:** The user rearranges storyboard frames, cuts whole frames, inserts new elements. The note is a living document, not a frozen snapshot. Frame-level manipulation—drag, reorder, delete, insert—is the core editing primitive for this journey. + +--- + +## Interaction philosophy + +The interaction model is the product's deepest differentiator. Hardware can be copied. Features can be replicated. But an interaction model that feels *inevitable*—where the user never wonders "how do I do this?"—is extremely hard to replicate because it requires saying no to hundreds of clever ideas that would individually be impressive but collectively create cognitive overhead. + +### The two instruments + +Every interaction on the device maps to exactly one of two physical instruments. There is no third mode. + +| | The Hand (finger) | The Stylus | +|----------------|-------------------|------------| +| **Primary role** | Navigate. Push, pull, scroll the page. Select and manipulate objects at a coarse level. | Create. Draw strokes for letters, lines, shapes. The stylus always writes—no mode switch needed. | +| **Mental model** | Like holding a piece of paper and moving it around on a desk. | Like holding a pen. Whatever you do with the pen goes on the paper. | +| **Invariant** | The hand never creates content. It never accidentally deletes content. | The stylus always creates content. Its strokes are never silently consumed by gesture recognition. | + +### Design principles + +These principles are ordered by priority. When two principles conflict, the higher-numbered one yields. + +1. **No accidental data loss.** No gesture, touch, or stylus action should be capable of silently destroying user work. If a destructive action occurs (collapse, delete, clear), it must be immediately visible and trivially reversible. The vertical-line-collapse pattern in the current InkUp is a cautionary example: it removes content from view and an inattentive user may not notice the loss. + +2. **Discoverable essentials, learnable shortcuts.** Every essential function must be discoverable without a tutorial. This means visible affordances (icons, buttons, labeled controls) for actions the user needs to find within their first session. Advanced gestures may exist as *accelerators* for already-discovered functions—never as the *only* path to a needed action. The test: if a new user can't find the feature in 30 seconds, it needs a visible affordance. + +3. **Minimize cognitive state.** The user should never need to track which "mode" the app is in. Every mode toggle—finger vs. stylus, draw vs. select, navigate vs. edit—is a piece of invisible state the user must maintain in consciousness. Each one adds cognitive load that competes with the actual thinking they're trying to capture. Eliminate modes wherever possible. Where modes are unavoidable, make the current state persistently visible. + +4. **Convenience must not cannibalize creation.** Gesture shortcuts must never intercept strokes that a user might genuinely intend to draw. An L-shaped stroke being captured as undo/redo is a direct violation: the system is eating the user's drawing to provide a shortcut. The rule is simple—if a gesture shape overlaps with any plausible drawing stroke, it cannot be used as a gesture unless the user has explicitly opted in. + +### Anti-pattern catalog + +Every new interaction proposal should be checked against this catalog before implementation. + +| Anti-pattern | Example | Violates | +|---|---|:---:| +| Undiscoverable essential | Squiggle-then-downstroke to insert a diagram. No visual cue exists. User can't draw diagrams without reading docs. | #1, #2 | +| Gesture-only undo | Two-finger tap or special stroke to undo with no visible undo button. User must learn app-specific gesture vocabulary. | #2 | +| Mode toggle | A switch between "finger mode" and "stylus mode." User must remember current state, and wrong-mode interactions produce unexpected results. | #3 | +| Stroke consumption | L-shaped stroke intercepted as undo/redo. User drawing an L, an angle, or a box corner has their work eaten. | #4 | +| Silent destruction | Vertical line-up collapsing space between lines and vanishing content. Reversible, but damage is invisible to an unaware user. | #1 | + +--- + +## The note object model + +The central technical and conceptual insight of InkUp is that a note is not an image, not a document, and not a canvas—it is a **structured spatial object** with four properties that existing tools only partially provide. + +### Spatial fidelity + +Stroke data preserves the spatial relationships of the original capture. Text written next to a diagram stays next to it. The note remembers *where* things are relative to each other, not just *what* they contain. + +### Semantic classification + +Strokes are classified after the fact—not during capture—into semantic categories: text, diagram, annotation, embedded media. This classification enables downstream operations (prettify text but preserve diagrams, index text for search, treat annotations as a separate layer). + +### Surface independence + +The note is not bound to the surface it was captured on. A reflow engine can adapt the layout to any target surface—a larger tablet, a projected screen, a phone, a printed page—while preserving the conceptual groupings and spatial relationships of the original. + +### Version persistence + +Every state of the note is preserved. The original capture, the prettified version, each round of annotations—these are layers in a version stack, not destructive overwrites. The user can always return to any previous state. + +--- + +## Product layers + +From a product architecture perspective, InkUp has four distinct layers, each of which represents a separable engineering investment and a distinct source of user value. + +### Layer 1: Capture + +The e-ink canvas with stylus and finger input. This must be *zero-friction*: the user should feel no difference between reaching for a pen and paper vs. reaching for InkUp. Latency, palm rejection, and stroke rendering quality are table stakes. The differentiator is the interaction model—two instruments (hand and stylus) with clear, non-overlapping roles and no mode switching. + +### Layer 2: The note object + +The structured spatial data model described above. This is where the core IP lives. Competing products stop at "strokes on a canvas"—InkUp promotes strokes to classified, spatially-aware, versionable objects. This layer enables everything above it. + +### Layer 3: Composition + +The post-capture enrichment tools: prettify (OCR + layout-preserving typesetting), reflow (surface-adaptive layout engine), annotate (versioned layer additions), and frame-level manipulation (for storyboard-style notes). Each of these is a meaningful feature in its own right, but they compound dramatically when they all operate on the same structured note object. + +### Layer 4: Output and archive + +The note's lifecycle beyond InkUp. This includes: indexing and full-text search across the note archive, cross-referencing between notes, and export to downstream tools (NotebookLM, slide generators, document editors). The archive turns InkUp from a capture tool into a personal knowledge system. The export pipeline turns it into a node in the user's broader workflow. + +--- + +## What this is not + +Clarity about what InkUp is *not* is as important as what it is. Scope discipline is what separates a product with a clear identity from a feature soup. + +- **Not a drawing app.** InkUp is not competing with Procreate or Concepts. It does not need layers, blend modes, pressure curves, or color wheels. It needs strokes that capture thinking accurately. +- **Not a document editor.** InkUp is not competing with Notion or Google Docs. It does not need rich text formatting, database views, or collaborative editing. It needs notes that become useful after capture. +- **Not a whiteboarding tool.** InkUp is not competing with Miro or FigJam. It is not optimized for real-time multi-user collaboration on an infinite canvas. It is optimized for one person capturing and refining their thinking. +- **Not a general-purpose e-ink OS.** InkUp does one thing—note-taking—and does it in a way that no general-purpose e-ink tablet does today. Breadth is the enemy of the interaction model. + +--- + +## Open questions + +These are the decisions that will shape the product's trajectory and should be resolved through prototyping, not committee. + +1. **Where does semantic classification happen?** On-device in real time (lower latency, works offline, but limited model capacity) vs. cloud-based post-capture (better accuracy, but introduces sync lag and connectivity dependency). Hybrid approaches exist but add complexity. + +2. **What is the minimum viable reflow?** Full responsive layout is an enormous engineering investment. The minimum might be: scale uniformly to fill the target surface, with manual adjustment of individual elements. The question is whether users will tolerate the manual step. + +3. **How does the archive scale?** Hundreds of notes with handwritten content require either very good OCR-based indexing, embedding-based semantic search, or both. The indexing strategy determines the archive's usefulness at scale. + +4. **What is the export contract?** NotebookLM is a specific integration. The broader question is: what format does InkUp export? A proprietary format with rich metadata? Markdown with embedded images? PDF? The answer determines how freely notes travel through the user's broader workflow. + +5. **Hardware-coupled or hardware-agnostic?** Is InkUp a software product that runs on existing e-ink tablets (Boox, reMarkable), or is it tightly coupled to specific hardware for optimal interaction? The answer has massive implications for who can use it, how fast the team can iterate, and how deep the experience can go. + +6. **What is the frame model for storyboards?** The "spontaneous idea" journey implies discrete, reorderable frames. How are frame boundaries detected—explicit user action, spatial heuristics, or AI classification? Each has different failure modes. diff --git a/docs/architecture.svg b/docs/architecture.svg new file mode 100644 index 0000000..3c93691 --- /dev/null +++ b/docs/architecture.svg @@ -0,0 +1,90 @@ + + + + + + + + + + Capture + Stylus + finger on e-ink canvas + + + Handwriting + Text strokes + + + Sketches + Diagrams, shapes + + + Embedded media + Slide clips, photos + + + Annotations + Stars, emphasis + + + + + + The note object + Spatial, semantic, persistent + + + Unified canvas + Stroke data · Spatial layout · Semantic classification · Version history + + + + + + + Composition + + + Reflow engine + Adapt to surface + + + Prettify + OCR + layout match + + + Archive + Index, search, link + + + Export + NotebookLM, etc. + + + + + + Output surfaces + + + E-ink tablet + + + Large display + + + Phone + + + Print + diff --git a/docs/code-review.md b/docs/code-review.md new file mode 100644 index 0000000..030677c --- /dev/null +++ b/docs/code-review.md @@ -0,0 +1,53 @@ +# Local Code Review + +Run Claude Code reviews locally using your already-authenticated `claude` CLI, then optionally post results to a PR. No CI secrets or OAuth tokens needed. + +## Prerequisites + +- `claude` CLI installed and logged in +- `gh` CLI authenticated with your GitHub account + +## Interactive use + +```bash +# Review local changes (prompts to post if a PR exists) +./scripts/review-pr.sh + +# Review a specific PR +./scripts/review-pr.sh 42 + +# Check addressed items +./scripts/review-check.sh [pr-number] +``` + +## Non-interactive / agent use + +Both scripts accept `--local`, `--post`, `--no-post`, and `--base ` flags: + +```bash +# Review local branch diff, no remote needed +REVIEW=$(./scripts/review-pr.sh --local --no-post) + +# Review against a different base branch +REVIEW=$(./scripts/review-pr.sh --local --no-post --base develop) + +# After fixing issues, verify locally (use same --base if non-default) +./scripts/review-check.sh --local --no-post "$REVIEW" + +# Once ready, create PR and post results +gh pr create --draft +./scripts/review-pr.sh --post +./scripts/review-check.sh --post "$REVIEW" +``` + +## Automated PR cycle (used by Claude agent) + +The full self-review cycle is documented in `CLAUDE.md` under **PR Workflow**. +Claude will automatically: + +1. Review local changes with `--local --no-post` (no remote needed) +2. Address all actionable items found +3. Verify fixes with `review-check.sh --local --no-post` +4. Iterate until all items are resolved +5. Create the PR and post the final review + checklist +6. Mark the PR as ready diff --git a/docs/engineering-design.md b/docs/engineering-design.md new file mode 100644 index 0000000..1be3904 --- /dev/null +++ b/docs/engineering-design.md @@ -0,0 +1,352 @@ +# InkUp — Engineering Design + +## Overview + +InkUp is an Android application targeting Onyx Boox e-ink tablets. It provides a ruled handwriting canvas where the user writes naturally with a stylus; as lines scroll off the top of the canvas they are recognized using Google ML Kit Digital Ink Recognition and displayed as formatted text in a panel above. The result is a distraction-free note-taking workflow where ink becomes text without any explicit recognition step. + +**Target platform:** Android 10+ (API 29+), Onyx Boox devices with e-ink display +**Language:** Kotlin +**Build system:** Gradle (Kotlin DSL) +**Min SDK:** 29 · Target/Compile SDK: 34 + +--- + +## High-Level Architecture + +The app is organized into four layers: + +``` +┌─────────────────────────────────────────────────────┐ +│ UI Layer │ +│ WritingActivity · DocumentListActivity · SaveAs │ +├─────────────────────────────────────────────────────┤ +│ Coordinator Layer │ +│ WritingCoordinator · GestureHandler │ +│ ParagraphBuilder · UndoManager · TutorialManager │ +├─────────────────────────────────────────────────────┤ +│ Recognition & View Layer │ +│ GoogleMLKitTextRecognizer · LineSegmenter │ +│ StrokeClassifier · ModelManager │ +│ HandwritingCanvasView · RecognizedTextView │ +├─────────────────────────────────────────────────────┤ +│ Model & Storage Layer │ +│ DocumentModel · DocumentData · InkStroke │ +│ StrokePoint · InkLine · DocumentStorage │ +└─────────────────────────────────────────────────────┘ +``` + +--- + +## Package Structure + +``` +com.writer +├── model/ +│ ├── StrokePoint.kt — x, y, pressure, timestamp for a single pen sample +│ ├── InkStroke.kt — ordered list of StrokePoints, UUID, strokeWidth +│ ├── StrokeExtensions.kt — computed properties: bounds, pathLength, diagonal, shiftY +│ ├── InkLine.kt — group of strokes on one ruled line + bounding box +│ ├── DocumentModel.kt — runtime document state (active strokes, language) +│ └── DocumentData.kt — serializable snapshot used for persistence +│ +├── recognition/ +│ ├── ModelManager.kt — downloads/caches ML Kit language models +│ ├── GoogleMLKitTextRecognizer.kt — wraps ML Kit, recognizes InkLine → text +│ ├── LineSegmenter.kt — maps strokes to line indices, builds InkLines +│ └── StrokeClassifier.kt — detects list-marker and underline (heading) strokes +│ +├── ui/ +│ ├── writing/ +│ │ ├── WritingActivity.kt — single main activity; lifecycle, menus, doc ops +│ │ ├── WritingCoordinator.kt — orchestrates recognition, scroll, text sync, undo +│ │ ├── GestureHandler.kt — strikethrough-to-delete and heading-underline logic +│ │ ├── ParagraphBuilder.kt — groups lines into paragraphs with formatting hints +│ │ ├── UndoManager.kt — stack-based undo/redo with gesture-scrub API +│ │ ├── TutorialManager.kt — interactive tutorial overlay lifecycle +│ │ ├── TutorialContent.kt — generates tutorial strokes and annotations +│ │ └── SaveAsActivity.kt — handwriting-based document rename dialog +│ └── documents/ +│ └── DocumentListActivity.kt — launcher stub (currently redirects to WritingActivity) +│ +├── view/ +│ ├── HandwritingCanvasView.kt — SurfaceView ink canvas with Onyx SDK integration +│ ├── RecognizedTextView.kt — custom View for formatted recognized-text display +│ ├── CanvasTheme.kt — paint/color constants for e-ink rendering +│ └── HandwritingNameInput.kt — handwriting input widget for document naming +│ +├── storage/ +│ └── DocumentStorage.kt — JSON CRUD, sync folder export (SAF), migration +│ +└── WriterApplication.kt — Application subclass (Onyx hidden-API bypass init) +``` + +--- + +## Core Data Model + +### StrokePoint +Single digitizer sample: `x`, `y`, `pressure` (Float), `timestamp` (Long ms). Coordinates are in **document space** — the absolute position in the infinite scrollable document, not screen space. + +### InkStroke +An immutable sequence of `StrokePoint`s representing one pen-down to pen-up motion. Has a UUID `strokeId` for identity tracking, `strokeWidth`, and derived `startTime`/`endTime`. Extension properties (`minX`, `maxX`, `minY`, `maxY`, `xRange`, `yRange`, `pathLength`, `diagonal`) are computed lazily. + +### InkLine +A group of strokes that belong to the same ruled line, plus a bounding box computed from all their points. Used as the unit of input to the recognizer. + +### DocumentModel +Runtime-only state: the mutable list of `activeStrokes` currently in the document, plus the language tag for recognition. + +### DocumentData +The full serializable snapshot of a document: +- `strokes` — all ink strokes +- `scrollOffsetY` — current scroll position +- `lineTextCache` — `Map` +- `everHiddenLines` — set of line indices that have scrolled above the viewport +- `highestLineIndex`, `currentLineIndex` — cursor tracking +- `userRenamed` — whether the user has manually named the document + +--- + +## Line-Based Canvas Model + +The canvas is a vertically-infinite ruled sheet. Lines are evenly spaced: + +``` +TOP_MARGIN = 40px +LINE_SPACING = 128px (~0.43" at 300 ppi) +GUTTER_WIDTH = 144px (right-edge scroll strip) +``` + +A stroke's **line index** is determined solely by the Y coordinate of its **first point**: + +``` +lineIndex = floor((firstPoint.y - TOP_MARGIN) / LINE_SPACING) +``` + +Using the starting point (not the centroid or bounding-box top) means descenders (g, y, p) on one line don't bleed into the line below. + +Coordinate transforms: +- Screen Y → Document Y: add `scrollOffsetY` +- Document Y → Screen Y: subtract `scrollOffsetY` +- Line index → Document Y (top of line): `TOP_MARGIN + lineIndex * LINE_SPACING` + +--- + +## Input Pipeline + +### Onyx SDK Path (Boox devices) +`HandwritingCanvasView` uses the Onyx `TouchHelper` / `RawInputCallback` API for hardware-accelerated e-ink pen rendering. The SDK renders strokes directly to the display at low latency without involving the Android canvas pipeline. The app receives callbacks: + +1. `onBeginRawDrawing` — pen down; clears in-progress buffer +2. `onRawDrawingTouchPointMoveReceived` — per-point move; gesture detection runs here +3. `onEndRawDrawing` — pen up; assembles `InkStroke`, fires `onStrokeCompleted` + +The SDK is paused/resumed around scroll operations and interactive gestures to avoid conflicts. The limit rect excludes the right gutter so gutter touches are handled by `onTouchEvent` instead. + +### Fallback Path (non-Boox / emulator) +Standard `MotionEvent` in `onTouchEvent`. Points are collected, gestures checked, and strokes assembled identically to the SDK path. The current stroke is drawn to the Canvas directly during the move phase. + +### Gutter Touch +Pen or mouse in the right `GUTTER_WIDTH` strip is handled separately: vertical drag scrolls the canvas (`scrollOffsetY`), and once at maximum canvas scroll, further downward drag overscrolls into the text pane (`textOverscroll`). + +--- + +## Gesture System + +Three gesture types are detected inside `HandwritingCanvasView` during stroke collection, before the stroke is committed to the document: + +### Gutter Scroll +Continuous vertical drag in the gutter. No threshold — activates immediately on `ACTION_DOWN` within the gutter. Snaps to line boundaries on `ACTION_UP`. + +### Line-Drag Gesture +- **Trigger:** vertical stroke spanning ≥ 1 line spacing with horizontal drift ≤ 30% of vertical span +- **Effect:** lifts all strokes from the anchor line downward and repositions them vertically by whole-line increments. Upward drag can delete lines by merging them. +- Implementation: SDK is disabled during the gesture; `onTouchEvent` receives subsequent moves. `WritingCoordinator` receives `onLineDragStart/Step/End` callbacks and updates `DocumentModel` directly. + +### Undo/Redo Scrub Gesture +- **Phase 1 (horizontal):** stroke spanning ≥ 1.5 line spacings horizontally with vertical drift ≤ 20% sets `undoGestureReady` +- **Phase 2 (vertical):** subsequent vertical movement ≥ 0.75 line spacings activates `undoScrubActive` +- **Effect:** vertical position maps linearly to a timeline position in `UndoManager.scrubTimeline`. Moving down undoes; moving up redoes. Releasing commits the chosen position. + +### Strikethrough (via GestureHandler) +Detected *after* stroke completion (in `WritingCoordinator`, before adding to `DocumentModel`): +- Wide flat stroke (xRange ≥ 100px, yRange < 30% of xRange) +- Horizontal — starts and ends on same line index +- Not a heading underline (which starts in bottom 20% of line and spans ≥ 80% of text width) +- **Effect:** all overlapping strokes on that line are removed from `DocumentModel` and canvas + +--- + +## Recognition Pipeline + +### Model Lifecycle +`ModelManager` uses `RemoteModelManager` (ML Kit) to download language models on demand. `GoogleMLKitTextRecognizer` holds one `DigitalInkRecognizer` instance per language session and reuses it across all recognition calls. + +### Eager Recognition +Recognition is triggered eagerly — not on demand. The coordinator uses these triggers: + +| Event | Action | +|---|---| +| User moves pen to a different line | Recognize previous line (`eagerRecognizeLine`) | +| Idle timeout (2 s after last stroke) | Recognize current line | +| Stroke on a line that has been rendered to text | Re-recognize that line immediately | +| App startup with existing strokes | Recognize all lines with missing/failed cache entries | +| Line scrolls above viewport | Recognize if not yet cached | + +Recognition runs on `Dispatchers.IO` (`recognizer.recognizeLine`), but all cache mutations happen on the main thread (`Dispatchers.Main` via `lifecycleScope`). A `recognizingLines` set prevents duplicate concurrent recognitions; `pendingRerecognize` queues a follow-up if a line changes while it is being recognized. + +### Pre-Context +Each recognition call includes up to 20 characters of preceding recognized text as `preContext`. This improves accuracy for words that depend on prior context (e.g. proper nouns, punctuation). + +### Stroke Classification (pre-recognition filtering) +Before passing strokes to ML Kit, `StrokeClassifier` identifies and removes two special stroke types: + +**List Marker:** a short, flat, simple horizontal stroke on the far left (within 10.5% of writing width) with a gap of ≥ 20px before the next stroke. Indicates a list item; filtered out to prevent the recognizer from seeing it as a letter. + +**Underline (Heading):** a long, flat horizontal stroke in the lower half of the line spanning ≥ 80% of the text width. Indicates a heading. Filtered from recognition, but preserved in `DocumentData` for paragraph formatting. + +Both checks use a **path simplicity** gate: `pathLength / diagonal ≤ 2.0`. This rejects strokes that trace back on themselves (e.g. a letter "s") that would otherwise match the geometric criteria. + +--- + +## Text Display Synchronization + +### Viewport-Based Reveal +Text is only shown for lines that have **ever** scrolled above the viewport midpoint (`everHiddenLines`). This set is monotonically growing (except when strokes are deleted). As the user scrolls down, lines disappear from canvas and appear as text in the text panel above. + +### Scroll Offset Sync +The text panel scroll offset (`textScrollOffset`) is computed so that the text panel scrolls smoothly in sync with the canvas, creating the illusion that ink flows up into text. For each written line, its rendered text height contributes to the offset proportionally based on how far its successor line has scrolled below the viewport. + +### Text Overscroll +When the canvas is already scrolled to its maximum useful position, further downward gutter drag increases `textOverscroll`, which shifts the text content upward inside `RecognizedTextView` so the user can read earlier text. + +### Paragraph Formation +`ParagraphBuilder` groups `LineInfo` objects (classified lines) into paragraphs: + +A paragraph break occurs when: +- The line is a list item (`isList`) +- The line is a heading (`isHeading`) +- The previous line was a heading (heading always stands alone) +- The line is indented (leftmost X > 10.5% of writing width) and the previous was not a list +- The previous paragraph was a list and this line is not + +`RecognizedTextView` renders paragraphs as `StaticLayout` instances with: +- Body text: 64px, first-line indent 80px +- List items: bullet prefix `•`, hanging indent +- Headings: 1.3× size, bold, no indent +- Dimming: lines not yet in `notYetVisible` (i.e. partially visible) render in a light grey + +--- + +## Undo/Redo + +`UndoManager` maintains two `ArrayDeque` stacks (max 50 entries). A `Snapshot` captures the full document state: all strokes, scroll offset, and the recognized text cache. Snapshots are saved: +- Before any stroke is added to the model +- Before a gesture mutation (strikethrough, line-drag) + +The **scrub API** flattens both stacks into a single timeline for gesture-based navigation: + +``` +[ oldest_undo, ..., newest_undo, CURRENT, nearest_redo, ..., furthest_redo ] +``` + +On `endScrub`, the stacks are rebuilt from the timeline split at the final scrub position. + +--- + +## Document Storage + +### Format +Documents are stored as JSON files in `/documents/.json`. The JSON schema includes: + +```json +{ + "strokes": [{ "strokeId", "strokeWidth", "points": [{ "x", "y", "pressure", "timestamp" }] }], + "scrollOffsetY": 0.0, + "highestLineIndex": 5, + "currentLineIndex": 5, + "userRenamed": false, + "lineTextCache": { "0": "Hello", "1": "World" }, + "everHiddenLines": [0, 1] +} +``` + +### Document Naming +New documents get a date-based name (`Document YYYY-MM-DD`). If the first line has an underline (heading marker) and the user has not manually renamed the document, `WritingCoordinator` fires `onHeadingDetected` and the activity renames the file to the heading text (sanitized, max 80 chars). + +### Sync Folder Export +Via the Storage Access Framework (SAF), users can designate a folder for export. On every save, two files are written: +- `.writer` — the full JSON (same as internal storage) +- `.md` — markdown export: headings prefixed `## `, list items prefixed `- `, body text joined with spaces + +### Migration +On first launch, the legacy single-file `document.json` is migrated to `documents/Document 1.json`. + +--- + +## UI Layout + +``` +┌─────────────────────────────────────────┬───────┐ +│ │ │ +│ RecognizedTextView │ │ +│ (recognized text, bottom-aligned) │ │ +│ │ Gutter│ +├─────────────────────────────────────────┤ │ +│ │ drag │ +│ HandwritingCanvasView │ to │ +│ (ruled ink canvas, SurfaceView) │ resize│ +│ │ │ +└─────────────────────────────────────────┴───────┘ +``` + +The split between text and canvas is adjustable by dragging the gutter vertically. The text panel height ranges from its natural size to consuming the full screen. Default split: canvas takes 75% of screen height (configurable by weight in the layout XML). + +The app runs in fully-immersive fullscreen mode (status bar and navigation bar hidden, swiped in transiently). + +--- + +## Tutorial System + +On first launch `TutorialManager` takes over the full screen: +1. Saves current document state and stops the coordinator +2. Expands the text panel to fit the tutorial text +3. Loads pre-built tutorial strokes and annotation overlays into the canvas +4. Shows a "Close Tutorial" button in the text panel and arrow annotations pointing at the gutter +5. On close: restores the original document state, layout, and restarts the coordinator + +Tutorial content is generated programmatically by `TutorialContent` rather than hardcoded assets, so it adapts to the device's screen dimensions. + +--- + +## Key Dependencies + +| Dependency | Version | Purpose | +|---|---|---| +| `com.onyx.android.sdk:onyxsdk-pen` | 1.5.2 | Low-latency e-ink pen input on Boox devices | +| `com.onyx.android.sdk:onyxsdk-device` | 1.3.3 | Device-level Onyx APIs | +| `org.lsposed.hiddenapibypass` | 4.3 | Bypass hidden API restrictions on Android 14+ (required by Onyx SDK) | +| `com.google.mlkit:digital-ink-recognition` | 19.0.0 | On-device handwriting recognition | +| `androidx.room` | 2.6.1 | (Included in deps, not yet used for active storage) | +| `kotlinx-coroutines-android` | 1.8.1 | Async recognition and scroll animation | +| `kotlinx-coroutines-play-services` | 1.8.1 | Converts ML Kit `Task` to coroutine `await()` | +| `androidx.documentfile` | 1.0.1 | SAF sync folder export | + +--- + +## Threading Model + +All mutable state (`lineTextCache`, `everHiddenLines`, `recognizingLines`, `DocumentModel.activeStrokes`) lives on the **main thread**. Recognition work itself runs on `Dispatchers.IO` via `withContext`. The coordinator uses `lifecycleScope` as its coroutine scope, so all work is automatically cancelled when the activity is destroyed. + +There is no ViewModel; `WritingCoordinator` is owned directly by `WritingActivity` and survives only within the activity lifecycle. + +--- + +## Known Design Constraints / Technical Debt + +- **Room dependency declared but unused.** `DocumentStorage` uses plain JSON files; Room is included as a dependency but has no entities or DAOs defined yet. +- **`DocumentListActivity` is a stub.** The launcher activity immediately redirects to `WritingActivity`. Multi-document management is handled via a popup menu inside `WritingActivity`. +- **No background save.** Documents save synchronously on the main thread during `onStop`. Large documents with many strokes may cause a perceptible pause. +- **Pre-context is approximate.** The 20-character pre-context is built from previously recognized lines in the same session. If a line was never recognized (e.g. first launch, no model yet), it contributes nothing to context. +- **Line-drag deletes overwritten content.** When dragging lines upward, any existing strokes in the overwritten zone are silently discarded. There is no conflict resolution. +- **Single language per document.** `DocumentModel.language` is set once at startup (`en-US`) with no UI to change it. diff --git a/scripts/rebuild-integration.sh b/scripts/rebuild-integration.sh new file mode 100644 index 0000000..d42e93b --- /dev/null +++ b/scripts/rebuild-integration.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# scripts/rebuild-integration.sh +# Rebuilds the integrate branch by merging all feature/fix/dev branches. +# Uses Claude to auto-resolve merge conflicts when they occur. +# +# Usage: ./scripts/rebuild-integration.sh [base-branch] +# base-branch: branch to start from (default: master) + +set -euo pipefail +BASE="${1:-master}" + +git branch -D integrate 2>/dev/null || true +git checkout -B integrate "$BASE" + +for branch in $(git branch --format='%(refname:short)' | grep -E "^(feature|fix|dev)/"); do + echo "Merging $branch..." + if git merge "$branch" --no-edit; then + continue + fi + + echo "Conflict merging $branch — asking Claude to resolve..." + conflicted=$(git diff --name-only --diff-filter=U) + + if [ -z "$conflicted" ]; then + echo "ERROR: merge failed but no conflicted files found. Aborting." + git merge --abort + exit 1 + fi + + echo "Conflicted files:" + echo "$conflicted" + + # Ask Claude to resolve each conflicted file + claude --print \ + --allowedTools 'Read,Edit,Bash(git add *)' \ + -p "You are resolving merge conflicts on the integrate branch. +We are merging branch '$branch' into integrate (based on '$BASE'). +The following files have conflicts: +$conflicted + +For each file: +1. Read it to see the conflict markers (<<<<<<< ======= >>>>>>>) +2. Resolve by keeping the intent of BOTH sides — do not drop changes from either branch +3. Remove all conflict markers +4. Run: git add + +Do NOT commit. Just resolve and stage." + + # Verify no conflicts remain + remaining=$(git diff --name-only --diff-filter=U) + if [ -n "$remaining" ]; then + echo "ERROR: Claude failed to resolve all conflicts. Remaining:" + echo "$remaining" + git merge --abort + exit 1 + fi + + git commit --no-edit + echo "Resolved and committed merge of $branch." +done + +echo "Integration branch ready." diff --git a/scripts/review-check.sh b/scripts/review-check.sh new file mode 100644 index 0000000..7043e05 --- /dev/null +++ b/scripts/review-check.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# Check which review items have been addressed by subsequent changes. +# Usage: ./scripts/review-check.sh [--post] [--no-post] [--local] [--base ] [pr-number] +# +# Options: +# --base Base branch to diff against (default: master) +# --local Compare against local diff (no PR needed) +# --post Post update to PR without prompting (non-interactive) +# --no-post Save update locally without posting (non-interactive) +# (default) Prompt whether to post + +set -euo pipefail + +POST_MODE="" +LOCAL_MODE="" +BASE_BRANCH="master" +POSITIONAL=() + +while [ $# -gt 0 ]; do + case "$1" in + --post) POST_MODE="yes" ;; + --no-post) POST_MODE="no" ;; + --local) LOCAL_MODE="yes" ;; + --base) BASE_BRANCH="${2:?--base requires a branch name}"; shift ;; + --base=*) BASE_BRANCH="${1#--base=}" ;; + *) POSITIONAL+=("$1") ;; + esac + shift +done + +REVIEW_FILE="${POSITIONAL[0]:?Usage: review-check.sh [options] [pr-number]}" +PR="${POSITIONAL[1]:-}" + +if [ ! -f "$REVIEW_FILE" ]; then + echo "Error: review file not found: ${REVIEW_FILE}" >&2 + exit 1 +fi + +# Determine if we're working locally or with a PR +if [ "$LOCAL_MODE" = "yes" ]; then + PR="" +elif [ -z "$PR" ]; then + PR=$(gh pr view --json number -q .number 2>/dev/null) || { + LOCAL_MODE="yes" + } +fi + +# Get the current diff +if [ "$LOCAL_MODE" = "yes" ] || [ -z "$PR" ]; then + # Include committed + staged + unstaged changes vs base branch + DIFF=$(git diff "${BASE_BRANCH}") +else + DIFF=$(gh pr diff "$PR") +fi + +REVIEW=$(cat "$REVIEW_FILE") +REPO_NAME=$(basename "$(git rev-parse --show-toplevel)") + +echo "Checking which review items have been addressed..." >&2 + +# Use printf to avoid shell interpretation of diff/review content +UPDATE=$({ + printf 'You were given this code review for %s:\n\n' "$REPO_NAME" + printf '%s\n' "$REVIEW" + printf '\nHere is the current diff after the author made changes:\n\n```diff\n' + printf '%s\n' "$DIFF" + printf '```\n\n' + printf '%s\n' \ + "For each item in the original review, determine whether it has been addressed" \ + "by the current changes. Output a checklist in markdown:" \ + "" \ + "- [x] Item — addressed (brief explanation)" \ + "- [ ] Item — still open (brief explanation)" \ + "" \ + "Be concise. Only list items that were actual action items from the review." +} | claude --print --output-format text) + +echo "$UPDATE" >&2 + +# Post to PR if applicable +if [ -n "$PR" ]; then + if [ -z "$POST_MODE" ]; then + read -r -p "Post update to PR #${PR}? [y/N] " POST_MODE + [[ "$POST_MODE" =~ ^[Yy] ]] && POST_MODE="yes" || POST_MODE="no" + fi + + if [ "$POST_MODE" = "yes" ]; then + gh pr comment "$PR" --body "## Review Update + +${UPDATE} + +--- +*Checked via \`scripts/review-check.sh\`*" + echo "Posted update to PR #${PR}." >&2 + fi +elif [ "$POST_MODE" = "yes" ]; then + echo "Warning: --post ignored, no PR exists for this branch." >&2 +fi diff --git a/scripts/review-pr.sh b/scripts/review-pr.sh new file mode 100644 index 0000000..4a44242 --- /dev/null +++ b/scripts/review-pr.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# Run Claude Code review on the current branch's changes. +# Usage: ./scripts/review-pr.sh [--post] [--no-post] [--local] [--base ] [pr-number] +# +# Options: +# --base Base branch to diff against (default: master) +# --local Review local diff against base branch (no PR or remote needed) +# --post Post review to PR without prompting (non-interactive) +# --no-post Save review locally without posting (non-interactive) +# (default) --local if no PR exists, prompts to post if PR exists +# +# Review output is saved to .claude/reviews/-.md +# Prints the review file path as the last line of stdout. + +set -euo pipefail + +POST_MODE="" +LOCAL_MODE="" +BASE_BRANCH="master" +PR="" + +while [ $# -gt 0 ]; do + case "$1" in + --post) POST_MODE="yes" ;; + --no-post) POST_MODE="no" ;; + --local) LOCAL_MODE="yes" ;; + --base) BASE_BRANCH="${2:?--base requires a branch name}"; shift ;; + --base=*) BASE_BRANCH="${1#--base=}" ;; + *) PR="$1" ;; + esac + shift +done + +BRANCH=$(git rev-parse --abbrev-ref HEAD) + +# Determine if we're working locally or with a PR +if [ "$LOCAL_MODE" = "yes" ]; then + PR="" +elif [ -z "$PR" ]; then + PR=$(gh pr view --json number -q .number 2>/dev/null) || { + LOCAL_MODE="yes" + } +fi + +REVIEW_DIR=".claude/reviews" +mkdir -p "$REVIEW_DIR" +TIMESTAMP=$(date +%Y%m%d-%H%M%S) +SAFE_BRANCH=$(echo "$BRANCH" | tr '/' '-') +REVIEW_FILE="$REVIEW_DIR/${SAFE_BRANCH}-${TIMESTAMP}.md" + +# Get the diff +if [ "$LOCAL_MODE" = "yes" ]; then + echo "Reviewing local branch '${BRANCH}' against '${BASE_BRANCH}'..." >&2 + # Include committed + staged + unstaged changes vs base branch + DIFF=$(git diff "${BASE_BRANCH}") + CONTEXT="Local branch: ${BRANCH} (vs ${BASE_BRANCH})" +else + echo "Reviewing PR #${PR} on branch '${BRANCH}'..." >&2 + DIFF=$(gh pr diff "$PR") + CONTEXT="PR #${PR} on branch: ${BRANCH}" +fi + +if [ -z "$DIFF" ]; then + echo "No changes found to review." >&2 + exit 0 +fi + +REPO_NAME=$(basename "$(git rev-parse --show-toplevel)") + +# Use printf to avoid shell interpretation of diff content +{ + printf 'PROJECT: %s\n%s\n\nHere is the diff:\n\n```diff\n' "$REPO_NAME" "$CONTEXT" + printf '%s\n' "$DIFF" + printf '```\n\n' + printf '%s\n' \ + "Please review these changes and provide feedback on:" \ + "- Code quality and best practices" \ + "- Potential bugs or issues" \ + "- Performance considerations" \ + "- Security concerns" \ + "- Test coverage" \ + "" \ + "Format your review as a markdown document with sections for each concern found." \ + "If everything looks good, say so briefly." \ + "Do NOT post any GitHub comments — just output the review text." +} | claude --print --output-format text > "$REVIEW_FILE" + +echo "Review saved to ${REVIEW_FILE}" >&2 + +# Post to PR if applicable and requested +if [ -n "$PR" ]; then + if [ -z "$POST_MODE" ]; then + read -r -p "Post review to PR #${PR}? [y/N] " POST_MODE + [[ "$POST_MODE" =~ ^[Yy] ]] && POST_MODE="yes" || POST_MODE="no" + fi + + if [ "$POST_MODE" = "yes" ]; then + BODY=$(cat "$REVIEW_FILE") + gh pr comment "$PR" --body "## Claude Code Review + +${BODY} + +--- +*Local review via \`scripts/review-pr.sh\`*" + echo "Posted review to PR #${PR}." >&2 + fi +elif [ "$POST_MODE" = "yes" ]; then + echo "Warning: --post ignored, no PR exists for this branch." >&2 +fi + +# Output the review file path (for piping to review-check.sh) +echo "$REVIEW_FILE"