feat: 重构地图
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -23,6 +24,9 @@ android {
|
|||||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
buildFeatures {
|
||||||
|
viewBinding = true
|
||||||
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
targetCompatibility = JavaVersion.VERSION_11
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
@@ -38,7 +42,12 @@ dependencies {
|
|||||||
implementation(libs.androidx.appcompat)
|
implementation(libs.androidx.appcompat)
|
||||||
implementation(libs.material)
|
implementation(libs.material)
|
||||||
implementation(project(":maps-view"))
|
implementation(project(":maps-view"))
|
||||||
|
implementation(project(":math"))
|
||||||
|
implementation(libs.androidx.activity)
|
||||||
|
implementation(libs.androidx.constraintlayout)
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
// https://mvnrepository.com/artifact/com.google.code.gson/gson
|
||||||
|
implementation("com.google.code.gson:gson:2.13.1")
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
|||||||
@@ -11,6 +11,16 @@
|
|||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Maps"
|
android:theme="@style/Theme.Maps"
|
||||||
tools:targetApi="31" />
|
tools:targetApi="31">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
1
app/src/main/assets/BucketData_1749810551032.json
Normal file
1
app/src/main/assets/BucketData_1749810551032.json
Normal file
File diff suppressed because one or more lines are too long
113
app/src/main/java/com/icegps/maps/MainActivity.kt
Normal file
113
app/src/main/java/com/icegps/maps/MainActivity.kt
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package com.icegps.maps
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.icegps.maps.databinding.ActivityMainBinding
|
||||||
|
import com.icegps.maps.ktx.TAG
|
||||||
|
import com.icegps.maps.layer.BucketTips
|
||||||
|
import com.icegps.maps.model.MapMode
|
||||||
|
import com.icegps.math.geometry.Vector3D
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||||
|
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
|
||||||
|
import kotlinx.serialization.descriptors.element
|
||||||
|
import kotlinx.serialization.encoding.CompositeDecoder
|
||||||
|
import kotlinx.serialization.encoding.Decoder
|
||||||
|
import kotlinx.serialization.encoding.Encoder
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.decodeFromStream
|
||||||
|
import kotlinx.serialization.modules.SerializersModule
|
||||||
|
|
||||||
|
class MainActivity : AppCompatActivity() {
|
||||||
|
private lateinit var binding: ActivityMainBinding
|
||||||
|
val json = Json {
|
||||||
|
serializersModule = SerializersModule {
|
||||||
|
contextual(
|
||||||
|
Vector3D::class,
|
||||||
|
Vector3DSerializer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||||
|
setContentView(binding.root)
|
||||||
|
|
||||||
|
val mapView = binding.mapView
|
||||||
|
val gridLayer = mapView.createGridLayer()
|
||||||
|
mapView.addLayer(gridLayer)
|
||||||
|
val boundaryLayer = mapView.createBoundaryLayer()
|
||||||
|
mapView.addLayer(boundaryLayer)
|
||||||
|
val bucketLayer = mapView.createBucketLayer()
|
||||||
|
mapView.addLayer(bucketLayer)
|
||||||
|
|
||||||
|
mapView.mapMode = MapMode.NorthUp
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val points = json.decodeFromStream<List<Triple<Vector3D, Vector3D, Vector3D>>>(assets.open("BucketData_1749810551032.json"))
|
||||||
|
var index = 0
|
||||||
|
while (isActive) {
|
||||||
|
val triple = points[index++ % points.size]
|
||||||
|
bucketLayer.updateTipPositions(
|
||||||
|
BucketTips(
|
||||||
|
left = triple.first,
|
||||||
|
center = triple.second,
|
||||||
|
right = triple.third
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Log.d(TAG, "updateTipPositions: ${triple}")
|
||||||
|
delay(1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object Vector3DSerializer : KSerializer<Vector3D> {
|
||||||
|
// 描述序列化结构
|
||||||
|
override val descriptor: SerialDescriptor =
|
||||||
|
buildClassSerialDescriptor("Vector3D") {
|
||||||
|
element<Double>("x")
|
||||||
|
element<Double>("y")
|
||||||
|
element<Double>("z")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 序列化(对象 → JSON)
|
||||||
|
override fun serialize(encoder: Encoder, value: Vector3D) {
|
||||||
|
encoder.beginStructure(descriptor).run {
|
||||||
|
encodeDoubleElement(descriptor, 0, value.x)
|
||||||
|
encodeDoubleElement(descriptor, 1, value.y)
|
||||||
|
encodeDoubleElement(descriptor, 2, value.z)
|
||||||
|
endStructure(descriptor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 反序列化(JSON → 对象)
|
||||||
|
override fun deserialize(decoder: Decoder): Vector3D {
|
||||||
|
return decoder.beginStructure(descriptor).run {
|
||||||
|
var x = 0.0
|
||||||
|
var y = 0.0
|
||||||
|
var z = 0.0
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
when (val index = decodeElementIndex(descriptor)) {
|
||||||
|
0 -> x = decodeDoubleElement(descriptor, 0)
|
||||||
|
1 -> y = decodeDoubleElement(descriptor, 1)
|
||||||
|
2 -> z = decodeDoubleElement(descriptor, 2)
|
||||||
|
CompositeDecoder.DECODE_DONE -> break
|
||||||
|
else -> error("Unexpected index: $index")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
endStructure(descriptor)
|
||||||
|
Vector3D(x, y, z)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
app/src/main/res/layout/activity_main.xml
Normal file
14
app/src/main/res/layout/activity_main.xml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:id="@+id/main"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical"
|
||||||
|
tools:context=".MainActivity">
|
||||||
|
|
||||||
|
<com.icegps.maps.MapView
|
||||||
|
android:id="@+id/map_view"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent" />
|
||||||
|
</LinearLayout>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
<resources>
|
||||||
<!-- Base application theme. -->
|
<!-- Base application theme. -->
|
||||||
<style name="Theme.Maps" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
<style name="Theme.Maps" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||||
<!-- Primary brand color. -->
|
<!-- Primary brand color. -->
|
||||||
<item name="colorPrimary">@color/purple_200</item>
|
<item name="colorPrimary">@color/purple_200</item>
|
||||||
<item name="colorPrimaryVariant">@color/purple_700</item>
|
<item name="colorPrimaryVariant">@color/purple_700</item>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
<!-- Base application theme. -->
|
<!-- Base application theme. -->
|
||||||
<style name="Theme.Maps" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
<style name="Theme.Maps" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||||
<!-- Primary brand color. -->
|
<!-- Primary brand color. -->
|
||||||
<item name="colorPrimary">@color/purple_500</item>
|
<item name="colorPrimary">@color/purple_500</item>
|
||||||
<item name="colorPrimaryVariant">@color/purple_700</item>
|
<item name="colorPrimaryVariant">@color/purple_700</item>
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ plugins {
|
|||||||
alias(libs.plugins.kotlin.android) apply false
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
alias(libs.plugins.kotlin.jvm) apply false
|
alias(libs.plugins.kotlin.jvm) apply false
|
||||||
alias(libs.plugins.android.library) apply false
|
alias(libs.plugins.android.library) apply false
|
||||||
|
alias(libs.plugins.kotlin.serialization) apply false
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,9 @@ espressoCore = "3.6.1"
|
|||||||
appcompat = "1.7.1"
|
appcompat = "1.7.1"
|
||||||
material = "1.12.0"
|
material = "1.12.0"
|
||||||
jetbrainsKotlinJvm = "2.0.21"
|
jetbrainsKotlinJvm = "2.0.21"
|
||||||
|
activity = "1.10.1"
|
||||||
|
constraintlayout = "2.1.4"
|
||||||
|
kotlin-serialization = "1.8.0"
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
@@ -16,9 +18,12 @@ androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "j
|
|||||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||||
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
||||||
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
||||||
|
androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
|
||||||
|
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
|
||||||
|
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlin-serialization" }
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||||
android-library = { id = "com.android.library", version.ref = "agp" }
|
android-library = { id = "com.android.library", version.ref = "agp" }
|
||||||
|
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ dependencies {
|
|||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.appcompat)
|
implementation(libs.androidx.appcompat)
|
||||||
implementation(libs.material)
|
implementation(libs.material)
|
||||||
|
implementation(project(":math"))
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
|||||||
12
maps-view/src/main/java/com/icegps/maps/BaseMapView.kt
Normal file
12
maps-view/src/main/java/com/icegps/maps/BaseMapView.kt
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package com.icegps.maps
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author tabidachinokaze
|
||||||
|
* @date 2025/6/13
|
||||||
|
*/
|
||||||
|
interface BaseMapView {
|
||||||
|
val coordinateManager: CoordinateManager
|
||||||
|
fun getContext(): Context
|
||||||
|
}
|
||||||
160
maps-view/src/main/java/com/icegps/maps/CoordinateManager.kt
Normal file
160
maps-view/src/main/java/com/icegps/maps/CoordinateManager.kt
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
package com.icegps.maps
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.icegps.maps.ktx.TAG
|
||||||
|
import com.icegps.maps.model.ENUPoint
|
||||||
|
import com.icegps.maps.model.MapMode
|
||||||
|
import com.icegps.maps.model.ScreenPoint
|
||||||
|
import com.icegps.maps.model.Viewport
|
||||||
|
import com.icegps.maps.model.WorldPoint
|
||||||
|
import com.icegps.math.geometry.Scale
|
||||||
|
import com.icegps.math.geometry.SizeInt
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.sin
|
||||||
|
import kotlin.properties.Delegates
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author tabidachinokaze
|
||||||
|
* @date 2025/6/13
|
||||||
|
*/
|
||||||
|
class CoordinateManager : CoordinateTransform {
|
||||||
|
private val globalScale: Scale = Scale(50f, 50f)
|
||||||
|
var viewport: Viewport = Viewport.Empty
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun update(block: (Viewport) -> Viewport) {
|
||||||
|
val old = viewport
|
||||||
|
val new = block(old)
|
||||||
|
viewport = new
|
||||||
|
|
||||||
|
Log.d(TAG, "old: $old\nnew: $new")
|
||||||
|
}
|
||||||
|
|
||||||
|
private val listeners = mutableSetOf<Listener>()
|
||||||
|
|
||||||
|
interface Listener {
|
||||||
|
fun onBucketPointChange(point: ENUPoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addListener(listener: Listener) {
|
||||||
|
listeners.add(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeListener(listener: Listener) {
|
||||||
|
listeners.remove(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
val mapMode: MapMode
|
||||||
|
get() = viewport.mapMode
|
||||||
|
val scale: Scale
|
||||||
|
get() = viewport.scale
|
||||||
|
val mapSize: SizeInt
|
||||||
|
get() = viewport.mapSize
|
||||||
|
val gridSize: Float
|
||||||
|
get() = viewport.gridSize
|
||||||
|
val nowPoint: ScreenPoint
|
||||||
|
get() = viewport.nowPoint
|
||||||
|
val angle: Float
|
||||||
|
get() = viewport.angle
|
||||||
|
val degrees: Float
|
||||||
|
get() = viewport.degrees
|
||||||
|
val centerX: Float
|
||||||
|
get() = viewport.centerX
|
||||||
|
val centerY: Float
|
||||||
|
get() = viewport.centerY
|
||||||
|
val direction: Float
|
||||||
|
get() = viewport.direction
|
||||||
|
|
||||||
|
var bucketPoint: ENUPoint by Delegates.observable(ENUPoint()) { _, old, new ->
|
||||||
|
if (old != new) {
|
||||||
|
listeners.forEach {
|
||||||
|
it.onBucketPointChange(new)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun worldToScreen(point: WorldPoint): ScreenPoint {
|
||||||
|
val (offsetX, offsetY) = mapSize / 2
|
||||||
|
val (scaleX, scaleY) = globalScale
|
||||||
|
|
||||||
|
// 计算屏幕坐标
|
||||||
|
val screenX = (point.x * scaleX + offsetX)
|
||||||
|
val screenY = (-point.y * scaleY + offsetY) // 注意 Y轴取反
|
||||||
|
|
||||||
|
return getScreenPointFromDataPoint(ScreenPoint(screenX, screenY))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun screenToWorld(point: ScreenPoint): WorldPoint {
|
||||||
|
TODO()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun rotateRadians(angleRadians: Float, centerX: Float, centerY: Float) {
|
||||||
|
update {
|
||||||
|
it.copy(
|
||||||
|
angle = angleRadians,
|
||||||
|
degrees = Math.toDegrees(angleRadians.toDouble()).toFloat(),
|
||||||
|
centerX = centerX,
|
||||||
|
centerY = centerY
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun scale(scale: Float, centerX: Float, centerY: Float) {
|
||||||
|
val dataCenter = getDataPointFromScreenPoint(centerX, centerY)
|
||||||
|
|
||||||
|
update { it.copy(scale = Scale(scaleX = scale, scaleY = scale)) }
|
||||||
|
|
||||||
|
val newScreenPoint = getScreenPointFromDataPoint(dataCenter)
|
||||||
|
|
||||||
|
val dx = centerX - newScreenPoint.x
|
||||||
|
val dy = centerY - newScreenPoint.y
|
||||||
|
|
||||||
|
translate(dx, dy)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun translate(dx: Float, dy: Float) {
|
||||||
|
update {
|
||||||
|
it.copy(
|
||||||
|
nowPoint = ScreenPoint(
|
||||||
|
x = it.nowPoint.x + dx,
|
||||||
|
y = it.nowPoint.y + dy
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getDataPointFromScreenPoint(screenX: Float, screenY: Float): ScreenPoint {
|
||||||
|
val angle = angle
|
||||||
|
// 反向平移
|
||||||
|
val translatedX = screenX - nowPoint.x
|
||||||
|
val translatedY = screenY - nowPoint.y
|
||||||
|
|
||||||
|
// 反向旋转
|
||||||
|
val rotatedX = translatedX * cos(-angle) - translatedY * sin(-angle)
|
||||||
|
val rotatedY = translatedX * sin(-angle) + translatedY * cos(-angle)
|
||||||
|
|
||||||
|
// 反向缩放
|
||||||
|
val point = ScreenPoint(x = rotatedX / scale.scaleX, rotatedY / scale.scaleY)
|
||||||
|
|
||||||
|
return point
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getScreenPointFromDataPoint(dataPoint: ScreenPoint): ScreenPoint {
|
||||||
|
val scale = scale
|
||||||
|
val angle = angle
|
||||||
|
val nowPoint = nowPoint
|
||||||
|
|
||||||
|
// 应用缩放
|
||||||
|
val scaledX = dataPoint.x * scale.scaleX
|
||||||
|
val scaledY = dataPoint.y * scale.scaleY
|
||||||
|
|
||||||
|
// 应用旋转
|
||||||
|
val rotatedX = scaledX * cos(angle) - scaledY * sin(angle)
|
||||||
|
val rotatedY = scaledX * sin(angle) + scaledY * cos(angle)
|
||||||
|
|
||||||
|
// 应用平移
|
||||||
|
val point = ScreenPoint(x = rotatedX + nowPoint.x, y = rotatedY + nowPoint.y)
|
||||||
|
|
||||||
|
return point
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.icegps.maps
|
||||||
|
|
||||||
|
import com.icegps.maps.model.ScreenPoint
|
||||||
|
import com.icegps.maps.model.WorldPoint
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author tabidachinokaze
|
||||||
|
* @date 2025/6/13
|
||||||
|
*/
|
||||||
|
interface CoordinateTransform {
|
||||||
|
fun worldToScreen(point: WorldPoint): ScreenPoint
|
||||||
|
fun screenToWorld(point: ScreenPoint): WorldPoint
|
||||||
|
}
|
||||||
328
maps-view/src/main/java/com/icegps/maps/MapView.kt
Normal file
328
maps-view/src/main/java/com/icegps/maps/MapView.kt
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
package com.icegps.maps
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.PointF
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import android.view.VelocityTracker
|
||||||
|
import android.widget.Scroller
|
||||||
|
import com.icegps.maps.ktx.TAG
|
||||||
|
import com.icegps.maps.layer.LayeredView
|
||||||
|
import com.icegps.maps.model.ENUPoint
|
||||||
|
import com.icegps.maps.model.MapMode
|
||||||
|
import com.icegps.maps.model.ScreenPoint
|
||||||
|
import com.icegps.maps.utils.CoordinateAndTouchUtils
|
||||||
|
import com.icegps.math.geometry.Scale
|
||||||
|
import com.icegps.math.geometry.toVector2D
|
||||||
|
import com.icegps.math.geometry.toVector2F
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author tabidachinokaze
|
||||||
|
* @date 2025/6/13
|
||||||
|
*/
|
||||||
|
class MapView @JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null,
|
||||||
|
defStyleAttr: Int = 0
|
||||||
|
) : LayeredView(context, attrs, defStyleAttr), CoordinateManager.Listener {
|
||||||
|
private val mapGestureDetector = MapGestureDetector(
|
||||||
|
context = context,
|
||||||
|
mapView = this,
|
||||||
|
coordinateManager = coordinateManager,
|
||||||
|
requestInvalidate = { invalidate() }
|
||||||
|
)
|
||||||
|
var mapMode: MapMode
|
||||||
|
get() = coordinateManager.mapMode
|
||||||
|
set(value) {
|
||||||
|
coordinateManager.update { it.copy(mapMode = value) }
|
||||||
|
switchMapMode(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAttachedToWindow() {
|
||||||
|
super.onAttachedToWindow()
|
||||||
|
coordinateManager.addListener(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||||
|
return mapGestureDetector.onTouchEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun switchMapMode(mapMode: MapMode) {
|
||||||
|
// 获取当前铲斗方向(弧度)
|
||||||
|
val directionRadians = coordinateManager.direction
|
||||||
|
when (mapMode) {
|
||||||
|
MapMode.HeadingForward -> updateHeadingRadians(directionRadians)
|
||||||
|
|
||||||
|
MapMode.NorthUp -> updateHeadingRadians(0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
val bucketPoint = coordinateManager.bucketPoint
|
||||||
|
|
||||||
|
mapGestureDetector.centerTo(bucketPoint)
|
||||||
|
|
||||||
|
// 更新图层
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateHeadingRadians(angleRadians: Float) {
|
||||||
|
val centerX = width / 2f
|
||||||
|
val centerY = height / 2f
|
||||||
|
|
||||||
|
coordinateManager.rotateRadians(angleRadians, centerX, centerY)
|
||||||
|
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBucketPointChange(point: ENUPoint) {
|
||||||
|
mapGestureDetector.centerTo(point)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetachedFromWindow() {
|
||||||
|
super.onDetachedFromWindow()
|
||||||
|
coordinateManager.removeListener(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MapGestureDetector(
|
||||||
|
context: Context,
|
||||||
|
private val mapView: MapView,
|
||||||
|
private val coordinateManager: CoordinateManager,
|
||||||
|
private val requestInvalidate: () -> Unit,
|
||||||
|
) {
|
||||||
|
// 是否禁用自动居中
|
||||||
|
private var disableAutoCenter = false
|
||||||
|
|
||||||
|
// 延迟任务处理器
|
||||||
|
private val autoCenterHandler = Handler(Looper.getMainLooper())
|
||||||
|
private val enableAutoCenterRunnable = Runnable {
|
||||||
|
disableAutoCenter = false
|
||||||
|
Log.d(TAG, "自动居中已恢复")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 惯性滚动
|
||||||
|
private val scroller: Scroller = Scroller(context)
|
||||||
|
private var vTracker: VelocityTracker? = null
|
||||||
|
private var xVelocity = 0f
|
||||||
|
private var yVelocity = 0f
|
||||||
|
|
||||||
|
// 触摸相关变量
|
||||||
|
private var downX = 0f
|
||||||
|
private var downY = 0f
|
||||||
|
private var lastX = 0f
|
||||||
|
private var lastY = 0f
|
||||||
|
private var touchMode = TOUCH_MODE_SINGLE
|
||||||
|
private var scaleTouchBaseLineLen = 0f
|
||||||
|
private var rotateTouchBaseAngle = 0f
|
||||||
|
private val touchCenterPoint = PointF()
|
||||||
|
private var canMove = false
|
||||||
|
private val touchSlop = 8f
|
||||||
|
private var configLastX = 0f
|
||||||
|
private var configLastY = 0f
|
||||||
|
private var configScale = 1f
|
||||||
|
private var distanceX = 0f
|
||||||
|
private var distanceY = 0f
|
||||||
|
|
||||||
|
fun onTouchEvent(event: MotionEvent): Boolean {
|
||||||
|
when (event.actionMasked) {
|
||||||
|
MotionEvent.ACTION_DOWN -> {
|
||||||
|
// 触摸开始,禁用自动居中
|
||||||
|
autoCenterHandler.removeCallbacks(enableAutoCenterRunnable)
|
||||||
|
disableAutoCenter = true
|
||||||
|
|
||||||
|
downX = event.x
|
||||||
|
downY = event.y
|
||||||
|
lastX = downX
|
||||||
|
lastY = downY
|
||||||
|
canMove = false
|
||||||
|
|
||||||
|
scroller.forceFinished(true)
|
||||||
|
scroller.abortAnimation()
|
||||||
|
|
||||||
|
touchMode = TOUCH_MODE_SINGLE
|
||||||
|
|
||||||
|
val nowPoint = coordinateManager.nowPoint
|
||||||
|
configLastX = nowPoint.x
|
||||||
|
configLastY = nowPoint.y
|
||||||
|
configScale = coordinateManager.scale.scaleX
|
||||||
|
|
||||||
|
if (vTracker == null) {
|
||||||
|
vTracker = VelocityTracker.obtain()
|
||||||
|
} else {
|
||||||
|
vTracker?.clear()
|
||||||
|
}
|
||||||
|
xVelocity = 0f
|
||||||
|
yVelocity = 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
MotionEvent.ACTION_POINTER_DOWN -> {
|
||||||
|
if (event.pointerCount == 2) {
|
||||||
|
rotateTouchBaseAngle = CoordinateAndTouchUtils.rotation(event)
|
||||||
|
scaleTouchBaseLineLen = CoordinateAndTouchUtils.lineLen(event)
|
||||||
|
CoordinateAndTouchUtils.centerPoint(touchCenterPoint, event)
|
||||||
|
touchMode = TOUCH_MODE_MORE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MotionEvent.ACTION_MOVE -> {
|
||||||
|
if (touchMode == TOUCH_MODE_MORE) {
|
||||||
|
// 多点触控 - 处理缩放和旋转
|
||||||
|
if (event.pointerCount > 1) {
|
||||||
|
// 处理缩放
|
||||||
|
val scale = (CoordinateAndTouchUtils.lineLen(event) - scaleTouchBaseLineLen) / scaleTouchBaseLineLen
|
||||||
|
val newScale = configScale * (1 + scale)
|
||||||
|
setScale(newScale, touchCenterPoint.x, touchCenterPoint.y)
|
||||||
|
|
||||||
|
// 处理手势旋转 (如果不是车头朝前模式,去掉注释就能处理双指旋转了)
|
||||||
|
if (coordinateManager.mapMode != MapMode.HeadingForward) {
|
||||||
|
val rotate = CoordinateAndTouchUtils.rotation(event) - rotateTouchBaseAngle
|
||||||
|
val rotateRad = Math.toRadians(rotate.toDouble()).toFloat()
|
||||||
|
val nowPoint = coordinateManager.nowPoint
|
||||||
|
rotateData(
|
||||||
|
rotate,
|
||||||
|
nowPoint.x - touchCenterPoint.x,
|
||||||
|
nowPoint.y - touchCenterPoint.y,
|
||||||
|
touchCenterPoint.x,
|
||||||
|
touchCenterPoint.y
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
requestInvalidate()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 单点触控 - 处理平移
|
||||||
|
distanceX = event.x - downX
|
||||||
|
distanceY = event.y - downY
|
||||||
|
|
||||||
|
if (!canMove && CoordinateAndTouchUtils.lineLen(0f, 0f, distanceX, distanceY) > touchSlop) {
|
||||||
|
canMove = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canMove) {
|
||||||
|
vTracker?.addMovement(event)
|
||||||
|
vTracker?.computeCurrentVelocity(1000)
|
||||||
|
updateCurrentPointByDistance(distanceX, distanceY)
|
||||||
|
xVelocity = vTracker?.xVelocity ?: 0f
|
||||||
|
yVelocity = vTracker?.yVelocity ?: 0f
|
||||||
|
|
||||||
|
requestInvalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
lastX = event.x
|
||||||
|
lastY = event.y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||||
|
// 触摸结束,延迟2秒后恢复自动居中
|
||||||
|
autoCenterHandler.removeCallbacks(enableAutoCenterRunnable)
|
||||||
|
autoCenterHandler.postDelayed(enableAutoCenterRunnable, 2000)
|
||||||
|
|
||||||
|
// 处理惯性滚动
|
||||||
|
inertiaScroll()
|
||||||
|
|
||||||
|
// 重置状态
|
||||||
|
canMove = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun centerTo(point: ENUPoint, animate: Boolean = false, resetTransforms: Boolean = false) {
|
||||||
|
// 如果禁用了自动居中,则不执行居中操作
|
||||||
|
if (disableAutoCenter) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算屏幕中心点
|
||||||
|
val screenCenterX = mapView.width / 2f
|
||||||
|
val screenCenterY = mapView.height / 2f
|
||||||
|
|
||||||
|
// 重置缩放
|
||||||
|
if (resetTransforms) {
|
||||||
|
coordinateManager.update { it.copy(scale = Scale(1f, 1f)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取点的当前屏幕坐标(考虑当前的缩放和旋转)
|
||||||
|
val pointScreenPoint = coordinateManager.worldToScreen(point.toVector2D().toVector2F())
|
||||||
|
|
||||||
|
// 计算需要平移的距离,使点位于屏幕中心
|
||||||
|
val translateX = screenCenterX - pointScreenPoint.x
|
||||||
|
val translateY = screenCenterY - pointScreenPoint.y
|
||||||
|
|
||||||
|
// 更新 nowPoint,平移视图使点居中
|
||||||
|
val nowPoint = coordinateManager.nowPoint
|
||||||
|
|
||||||
|
if (animate) {
|
||||||
|
// 如果需要动画,使用 scroller 进行平滑滚动
|
||||||
|
scroller.forceFinished(true)
|
||||||
|
scroller.startScroll(
|
||||||
|
nowPoint.x.toInt(), nowPoint.y.toInt(),
|
||||||
|
translateX.toInt(), translateY.toInt(),
|
||||||
|
300 // 动画持续时间(毫秒)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// 直接更新位置
|
||||||
|
coordinateManager.update {
|
||||||
|
it.copy(nowPoint = ScreenPoint(nowPoint.x + translateX, nowPoint.y + translateY))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
requestInvalidate()
|
||||||
|
Log.d(TAG, "将点 (${point.x}, ${point.y}) 居中,平移: (${translateX}, ${translateY}), 缩放: ${coordinateManager.scale}")
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据距离更新当前点
|
||||||
|
*/
|
||||||
|
private fun updateCurrentPointByDistance(distanceX: Float, distanceY: Float) {
|
||||||
|
val nowPoint = ScreenPoint(configLastX + distanceX, configLastY + distanceY)
|
||||||
|
coordinateManager.update {
|
||||||
|
it.copy(nowPoint = nowPoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置缩放
|
||||||
|
*/
|
||||||
|
private fun setScale(scale: Float, centerX: Float, centerY: Float): Boolean {
|
||||||
|
coordinateManager.scale(scale, centerX, centerY)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 旋转数据
|
||||||
|
*/
|
||||||
|
private fun rotateData(degrees: Float, dX: Float, dY: Float, centerX: Float, centerY: Float) {
|
||||||
|
val degreesRad = Math.toRadians(degrees.toDouble()).toFloat()
|
||||||
|
coordinateManager.rotateRadians(degreesRad, centerX, centerY)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 惯性滚动
|
||||||
|
*/
|
||||||
|
private fun inertiaScroll(): Boolean {
|
||||||
|
if (touchMode == TOUCH_MODE_MORE) return false
|
||||||
|
if ((distanceX != 0f && xVelocity != 0f) || (distanceY != 0f && yVelocity != 0f)) {
|
||||||
|
scroller.fling(
|
||||||
|
distanceX.toInt(), distanceY.toInt(),
|
||||||
|
xVelocity.toInt(), yVelocity.toInt(),
|
||||||
|
if (distanceX >= 0) 0 else (distanceX - 5000).toInt(),
|
||||||
|
if (distanceX >= 0) 5000 else distanceX.toInt(),
|
||||||
|
if (distanceY >= 0) 0 else (distanceY - 5000).toInt(),
|
||||||
|
if (distanceY >= 0) 5000 else distanceY.toInt()
|
||||||
|
)
|
||||||
|
requestInvalidate()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TOUCH_MODE_SINGLE = 1
|
||||||
|
private const val TOUCH_MODE_MORE = 2
|
||||||
|
}
|
||||||
|
}
|
||||||
7
maps-view/src/main/java/com/icegps/maps/ktx/Any.kt
Normal file
7
maps-view/src/main/java/com/icegps/maps/ktx/Any.kt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package com.icegps.maps.ktx
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author tabidachinokaze
|
||||||
|
* @date 2025/6/13
|
||||||
|
*/
|
||||||
|
val Any.TAG: String get() = this::class.java.simpleName
|
||||||
22
maps-view/src/main/java/com/icegps/maps/layer/BaseLayer.kt
Normal file
22
maps-view/src/main/java/com/icegps/maps/layer/BaseLayer.kt
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package com.icegps.maps.layer
|
||||||
|
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Paint
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author tabidachinokaze
|
||||||
|
* @date 2025/6/13
|
||||||
|
*/
|
||||||
|
abstract class BaseLayer(
|
||||||
|
override val zIndex: Int
|
||||||
|
) : Layer {
|
||||||
|
protected val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
|
||||||
|
override val visible: Boolean = true
|
||||||
|
|
||||||
|
override fun draw(canvas: Canvas) {
|
||||||
|
if (visible) onDraw(canvas)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract fun onDraw(canvas: Canvas)
|
||||||
|
}
|
||||||
157
maps-view/src/main/java/com/icegps/maps/layer/BoundaryLayer.kt
Normal file
157
maps-view/src/main/java/com/icegps/maps/layer/BoundaryLayer.kt
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
package com.icegps.maps.layer
|
||||||
|
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.DashPathEffect
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.util.Log
|
||||||
|
import com.icegps.maps.CoordinateManager
|
||||||
|
import com.icegps.maps.ktx.TAG
|
||||||
|
import com.icegps.maps.model.ScreenPoint
|
||||||
|
import com.icegps.math.geometry.Line
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author tabidachinokaze
|
||||||
|
* @date 2025/6/13
|
||||||
|
*/
|
||||||
|
class BoundaryLayer(
|
||||||
|
private val coordinateManager: CoordinateManager,
|
||||||
|
paintBuilder: (Paint) -> Unit = {
|
||||||
|
it.color = Color.BLACK
|
||||||
|
it.strokeWidth = 2f
|
||||||
|
}
|
||||||
|
) : BaseLayer(zIndex = 1) {
|
||||||
|
private val lines = mutableListOf<Line>()
|
||||||
|
var extendLines = true // 是否延长边界线
|
||||||
|
|
||||||
|
var lineStyle: LineStyle = LineStyle.SOLID
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
when (value) {
|
||||||
|
LineStyle.SOLID -> {
|
||||||
|
paint.style = Paint.Style.STROKE
|
||||||
|
paint.pathEffect = null
|
||||||
|
}
|
||||||
|
|
||||||
|
LineStyle.DASHED -> {
|
||||||
|
paint.style = Paint.Style.STROKE
|
||||||
|
paint.pathEffect = DashPathEffect(floatArrayOf(20f, 10f), 0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
LineStyle.DOTTED -> {
|
||||||
|
paint.style = Paint.Style.STROKE
|
||||||
|
paint.pathEffect = DashPathEffect(floatArrayOf(5f, 10f), 0f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
paintBuilder(paint)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDraw(canvas: Canvas) {
|
||||||
|
val (width, height) = coordinateManager.mapSize
|
||||||
|
if (width <= 0 || height <= 0) return
|
||||||
|
|
||||||
|
lines.forEachIndexed { index, it ->
|
||||||
|
val start = coordinateManager.worldToScreen(it.a)
|
||||||
|
val end = coordinateManager.worldToScreen(it.b)
|
||||||
|
val line = if (extendLines) extendLine(start, end) else it
|
||||||
|
canvas.drawLine(
|
||||||
|
line.a.x,
|
||||||
|
line.a.y,
|
||||||
|
line.b.x,
|
||||||
|
line.b.y,
|
||||||
|
paint
|
||||||
|
)
|
||||||
|
Log.d(TAG, "绘制边界线: 线$index(${line.a.x}, ${line.a.y}) - (${line.b.x}, ${line.b.y})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setBoundaryLines(lines: List<Line>) {
|
||||||
|
this.lines.clear()
|
||||||
|
this.lines.addAll(lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 延长线段至屏幕边缘
|
||||||
|
*/
|
||||||
|
private fun extendLine(start: ScreenPoint, end: ScreenPoint): Line {
|
||||||
|
val mapSize = coordinateManager.mapSize
|
||||||
|
val width = mapSize.width.toFloat()
|
||||||
|
val height = mapSize.height.toFloat()
|
||||||
|
// 计算线段方向向量
|
||||||
|
val dx = end.x - start.x
|
||||||
|
val dy = end.y - start.y
|
||||||
|
|
||||||
|
// 如果线段是垂直的
|
||||||
|
if (dx == 0f) {
|
||||||
|
return Line(
|
||||||
|
ScreenPoint(start.x, 0f),
|
||||||
|
ScreenPoint(start.x, height)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果线段是水平的
|
||||||
|
if (dy == 0f) {
|
||||||
|
return Line(
|
||||||
|
ScreenPoint(0f, start.y),
|
||||||
|
ScreenPoint(width, start.y)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算线段与屏幕边界的交点
|
||||||
|
val slope = dy / dx
|
||||||
|
val yIntercept = start.y - slope * start.x
|
||||||
|
|
||||||
|
// 计算与左边界的交点
|
||||||
|
val leftX = 0f
|
||||||
|
val leftY = yIntercept
|
||||||
|
|
||||||
|
// 计算与右边界的交点
|
||||||
|
val rightX = width
|
||||||
|
val rightY = slope * width + yIntercept
|
||||||
|
|
||||||
|
// 计算与上边界的交点
|
||||||
|
val topY = 0f
|
||||||
|
val topX = (topY - yIntercept) / slope
|
||||||
|
|
||||||
|
// 计算与下边界的交点
|
||||||
|
val bottomY = height
|
||||||
|
val bottomX = (bottomY - yIntercept) / slope
|
||||||
|
|
||||||
|
// 收集所有在屏幕内的交点
|
||||||
|
val intersections = mutableListOf<ScreenPoint>()
|
||||||
|
|
||||||
|
if (leftY in 0f..height) {
|
||||||
|
intersections.add(ScreenPoint(leftX, leftY))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rightY in 0f..height) {
|
||||||
|
intersections.add(ScreenPoint(rightX, rightY))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (topX in 0f..width) {
|
||||||
|
intersections.add(ScreenPoint(topX, topY))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bottomX in 0f..width) {
|
||||||
|
intersections.add(ScreenPoint(bottomX, bottomY))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果找到了两个交点,返回这两个点
|
||||||
|
if (intersections.size >= 2) {
|
||||||
|
return Line(intersections[0], intersections[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有找到足够的交点,返回原始线段
|
||||||
|
return Line(start, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class LineStyle {
|
||||||
|
SOLID,
|
||||||
|
DASHED,
|
||||||
|
DOTTED,
|
||||||
|
}
|
||||||
405
maps-view/src/main/java/com/icegps/maps/layer/BucketLayer.kt
Normal file
405
maps-view/src/main/java/com/icegps/maps/layer/BucketLayer.kt
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
package com.icegps.maps.layer
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.DashPathEffect
|
||||||
|
import android.graphics.Matrix
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.PointF
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.icegps.maps.CoordinateManager
|
||||||
|
import com.icegps.maps.R
|
||||||
|
import com.icegps.maps.ktx.TAG
|
||||||
|
import com.icegps.maps.model.ENUPoint
|
||||||
|
import com.icegps.maps.model.ScreenPoint
|
||||||
|
import com.icegps.math.geometry.Vector2F
|
||||||
|
import com.icegps.math.geometry.toVector2D
|
||||||
|
import com.icegps.math.geometry.toVector2F
|
||||||
|
import kotlin.math.atan2
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 铲斗图标数据
|
||||||
|
*
|
||||||
|
* @property drawable 铲斗图标
|
||||||
|
* @property width 铲斗图标的宽
|
||||||
|
* @property height 铲斗图标的高
|
||||||
|
* @property leftTip 左边斗齿
|
||||||
|
* @property centerTip 中间斗齿
|
||||||
|
* @property rightTip 右边斗齿
|
||||||
|
*/
|
||||||
|
data class BucketDrawable(
|
||||||
|
val drawable: Drawable,
|
||||||
|
val width: Float,
|
||||||
|
val height: Float,
|
||||||
|
val leftTip: Vector2F,
|
||||||
|
val centerTip: Vector2F,
|
||||||
|
val rightTip: Vector2F
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取默认的铲斗图标
|
||||||
|
*/
|
||||||
|
fun defaultBucketDrawable(context: Context): BucketDrawable {
|
||||||
|
val drawable = ContextCompat.getDrawable(context, R.drawable.bucket_top)
|
||||||
|
return BucketDrawable(
|
||||||
|
drawable = drawable!!,
|
||||||
|
width = 722f,
|
||||||
|
height = 722f,
|
||||||
|
leftTip = Vector2F(208.72f, 70.92f),
|
||||||
|
centerTip = Vector2F(362.02f, 70.92f),
|
||||||
|
rightTip = Vector2F(514.32f, 70.92f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author tabidachinokaze
|
||||||
|
* @date 2025/6/13
|
||||||
|
*/
|
||||||
|
class BucketLayer(
|
||||||
|
private val coordinateManager: CoordinateManager,
|
||||||
|
private val bucketDrawable: BucketDrawable,
|
||||||
|
private val tipRadius: Float = 5f,
|
||||||
|
private val selectedTipRadius: Float = 8f,
|
||||||
|
private val crossLineWidth: Float = 3f,
|
||||||
|
private val crossLineColor: Int = Color.GREEN,
|
||||||
|
private val antiAlias: Boolean = true
|
||||||
|
) : BaseLayer(zIndex = 3) {
|
||||||
|
private var bucketTips: BucketTips = BucketTips()
|
||||||
|
private var tipScreenPoints: TipScreenPoints = TipScreenPoints()
|
||||||
|
|
||||||
|
var selectedBucketTip: BucketTip = BucketTip.LEFT
|
||||||
|
|
||||||
|
init {
|
||||||
|
updateTipPositions(bucketTips)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateTipPositions(bucketTips: BucketTips) {
|
||||||
|
this.bucketTips = bucketTips
|
||||||
|
|
||||||
|
val directionRadians = calculateDirectionFromTips(bucketTips.left, bucketTips.right)
|
||||||
|
|
||||||
|
coordinateManager.update {
|
||||||
|
it.copy(direction = directionRadians)
|
||||||
|
}
|
||||||
|
|
||||||
|
coordinateManager.bucketPoint = bucketTips.center
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从左右铲尖计算方向角度(弧度)
|
||||||
|
* @return 方向角度(弧度)
|
||||||
|
*/
|
||||||
|
private fun calculateDirectionFromTips(leftTip: ENUPoint, rightTip: ENUPoint): Float {
|
||||||
|
val dx = rightTip.x - leftTip.x
|
||||||
|
val dy = rightTip.y - leftTip.y
|
||||||
|
|
||||||
|
// 直接计算弧度角
|
||||||
|
val angleRad = atan2(dy, dx)
|
||||||
|
|
||||||
|
// 方向现在直接基于铲尖之间的角度(弧度)
|
||||||
|
var directionRad = angleRad.toFloat()
|
||||||
|
|
||||||
|
// 标准化到[-π, π]范围
|
||||||
|
while (directionRad > Math.PI) directionRad -= (2 * Math.PI).toFloat()
|
||||||
|
while (directionRad <= -Math.PI) directionRad += (2 * Math.PI).toFloat()
|
||||||
|
|
||||||
|
// 记录日志(同时显示弧度和角度,便于调试)
|
||||||
|
val directionDeg = Math.toDegrees(directionRad.toDouble())
|
||||||
|
Log.d(TAG, "铲尖方向计算: dx=$dx, dy=$dy, 弧度=${directionRad}, 角度=${directionDeg}°")
|
||||||
|
|
||||||
|
return directionRad
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDraw(canvas: Canvas) {
|
||||||
|
val (width, height) = coordinateManager.mapSize
|
||||||
|
if (width <= 0 || height <= 0) return
|
||||||
|
with(coordinateManager) {
|
||||||
|
tipScreenPoints = TipScreenPoints(
|
||||||
|
left = worldToScreen(bucketTips.left.toVector2D().toVector2F()),
|
||||||
|
center = worldToScreen(bucketTips.center.toVector2D().toVector2F()),
|
||||||
|
right = worldToScreen(bucketTips.right.toVector2D().toVector2F()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
drawCrossLineAtSelectedTip(canvas)
|
||||||
|
|
||||||
|
drawBucket(canvas, tipScreenPoints)
|
||||||
|
|
||||||
|
drawTipMarkers(canvas, tipScreenPoints)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在选中的铲尖位置绘制十字线
|
||||||
|
*/
|
||||||
|
private fun drawCrossLineAtSelectedTip(canvas: Canvas) {
|
||||||
|
val tipPoint = when (selectedBucketTip) {
|
||||||
|
BucketTip.LEFT -> tipScreenPoints.left
|
||||||
|
BucketTip.CENTER -> tipScreenPoints.center
|
||||||
|
BucketTip.RIGHT -> tipScreenPoints.right
|
||||||
|
}
|
||||||
|
paint.apply {
|
||||||
|
color = crossLineColor
|
||||||
|
style = Paint.Style.STROKE
|
||||||
|
strokeWidth = crossLineWidth
|
||||||
|
isAntiAlias = true
|
||||||
|
pathEffect = DashPathEffect(floatArrayOf(10f, 10f), 0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算铲斗方向向量(从左铲尖到右铲尖)
|
||||||
|
|
||||||
|
val directionX = bucketTips.left.x - bucketTips.right.x
|
||||||
|
val directionY = bucketTips.left.y - bucketTips.right.y
|
||||||
|
val length = sqrt(directionX * directionX + directionY * directionY)
|
||||||
|
|
||||||
|
if (length > 0) {
|
||||||
|
// 获取屏幕上的方向向量
|
||||||
|
val rightTip = tipScreenPoints.right
|
||||||
|
val leftTip = tipScreenPoints.left
|
||||||
|
val screenDirectionX = rightTip.x - leftTip.x
|
||||||
|
val screenDirectionY = rightTip.y - leftTip.y
|
||||||
|
val screenLength = sqrt(
|
||||||
|
(screenDirectionX * screenDirectionX +
|
||||||
|
screenDirectionY * screenDirectionY).toDouble()
|
||||||
|
).toFloat()
|
||||||
|
|
||||||
|
if (screenLength > 0) {
|
||||||
|
val screenUnitX = screenDirectionX / screenLength
|
||||||
|
val screenUnitY = screenDirectionY / screenLength
|
||||||
|
|
||||||
|
// 垂直向量(顺时针旋转90度)
|
||||||
|
val screenPerpX = -screenUnitY
|
||||||
|
val screenPerpY = screenUnitX
|
||||||
|
|
||||||
|
// 计算线与视图边缘的交点
|
||||||
|
val lineIntersections1 = calculateViewIntersections(
|
||||||
|
tipPoint.x, tipPoint.y, screenUnitX, screenUnitY
|
||||||
|
)
|
||||||
|
|
||||||
|
// 绘制与铲尖线重合的线(无限长)
|
||||||
|
canvas.drawLine(
|
||||||
|
lineIntersections1.first.x, lineIntersections1.first.y,
|
||||||
|
lineIntersections1.second.x, lineIntersections1.second.y,
|
||||||
|
paint
|
||||||
|
)
|
||||||
|
|
||||||
|
// 计算垂直线与视图边缘的交点
|
||||||
|
val lineIntersections2 = calculateViewIntersections(
|
||||||
|
tipPoint.x, tipPoint.y, screenPerpX, screenPerpY
|
||||||
|
)
|
||||||
|
|
||||||
|
// 绘制垂直于铲尖线的线(无限长)
|
||||||
|
canvas.drawLine(
|
||||||
|
lineIntersections2.first.x, lineIntersections2.first.y,
|
||||||
|
lineIntersections2.second.x, lineIntersections2.second.y,
|
||||||
|
paint
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// 如果无法计算方向,则使用默认的水平和垂直线
|
||||||
|
drawDefaultCrossLine(canvas, tipPoint)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 如果无法计算方向,则使用默认的水平和垂直线
|
||||||
|
drawDefaultCrossLine(canvas, tipPoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算从给定点沿给定方向延伸的直线与视图边缘的交点
|
||||||
|
* @return 两个交点,分别是线的两个方向与视图边缘的交点
|
||||||
|
*/
|
||||||
|
private fun calculateViewIntersections(
|
||||||
|
x: Float, y: Float, dirX: Float, dirY: Float
|
||||||
|
): Pair<PointF, PointF> {
|
||||||
|
val mapSize = coordinateManager.mapSize
|
||||||
|
val width = mapSize.width.toFloat()
|
||||||
|
val height = mapSize.height.toFloat()
|
||||||
|
|
||||||
|
// 计算线与四条边的交点
|
||||||
|
val intersections = mutableListOf<PointF>()
|
||||||
|
|
||||||
|
// 与左边界的交点
|
||||||
|
if (dirX != 0f) {
|
||||||
|
val t = -x / dirX
|
||||||
|
val intersectY = y + t * dirY
|
||||||
|
if (intersectY in 0f..height) {
|
||||||
|
intersections.add(PointF(0f, intersectY))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 与右边界的交点
|
||||||
|
if (dirX != 0f) {
|
||||||
|
val t = (width - x) / dirX
|
||||||
|
val intersectY = y + t * dirY
|
||||||
|
if (intersectY in 0f..height) {
|
||||||
|
intersections.add(PointF(width, intersectY))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 与上边界的交点
|
||||||
|
if (dirY != 0f) {
|
||||||
|
val t = -y / dirY
|
||||||
|
val intersectX = x + t * dirX
|
||||||
|
if (intersectX in 0f..width) {
|
||||||
|
intersections.add(PointF(intersectX, 0f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 与下边界的交点
|
||||||
|
if (dirY != 0f) {
|
||||||
|
val t = (height - y) / dirY
|
||||||
|
val intersectX = x + t * dirX
|
||||||
|
if (intersectX in 0f..width) {
|
||||||
|
intersections.add(PointF(intersectX, height))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保有两个交点
|
||||||
|
if (intersections.size < 2) {
|
||||||
|
// 如果找不到两个交点,使用默认值(这种情况不应该发生,但为了健壮性)
|
||||||
|
return Pair(PointF(0f, y), PointF(width, y))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回两个交点
|
||||||
|
return Pair(intersections[0], intersections[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绘制默认的水平和垂直十字线
|
||||||
|
*/
|
||||||
|
private fun drawDefaultCrossLine(canvas: Canvas, tipPoint: ScreenPoint) {
|
||||||
|
val mapSize = coordinateManager.mapSize
|
||||||
|
val width = mapSize.width.toFloat()
|
||||||
|
val height = mapSize.height.toFloat()
|
||||||
|
|
||||||
|
// 绘制水平线(从左边缘到右边缘)
|
||||||
|
canvas.drawLine(
|
||||||
|
0f, tipPoint.y,
|
||||||
|
width, tipPoint.y,
|
||||||
|
paint
|
||||||
|
)
|
||||||
|
|
||||||
|
// 绘制垂直线(从上边缘到下边缘)
|
||||||
|
canvas.drawLine(
|
||||||
|
tipPoint.x, 0f,
|
||||||
|
tipPoint.x, height,
|
||||||
|
paint
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun drawBucket(canvas: Canvas, tipScreenPoints: TipScreenPoints) {
|
||||||
|
// 计算SVG中左右铲尖之间的距离
|
||||||
|
val svgTipDistance = bucketDrawable.rightTip.x - bucketDrawable.leftTip.x
|
||||||
|
|
||||||
|
// 计算实际屏幕上左右铲尖之间的距离
|
||||||
|
val rightTip = tipScreenPoints.right
|
||||||
|
val leftTip = tipScreenPoints.left
|
||||||
|
val screenTipDistanceX = rightTip.x - leftTip.x
|
||||||
|
val screenTipDistanceY = rightTip.y - leftTip.y
|
||||||
|
val screenTipDistance = sqrt(
|
||||||
|
(screenTipDistanceX * screenTipDistanceX +
|
||||||
|
screenTipDistanceY * screenTipDistanceY).toDouble()
|
||||||
|
).toFloat()
|
||||||
|
|
||||||
|
val scaleFactor = 1.0f // 增加这个值可以放大图标,减小则缩小图标
|
||||||
|
val scale = (screenTipDistance / svgTipDistance) * scaleFactor
|
||||||
|
|
||||||
|
// 计算旋转角度(从左铲尖到右铲尖的方向)
|
||||||
|
val angle = Math.toDegrees(
|
||||||
|
atan2(
|
||||||
|
screenTipDistanceY.toDouble(),
|
||||||
|
screenTipDistanceX.toDouble()
|
||||||
|
)
|
||||||
|
).toFloat()
|
||||||
|
|
||||||
|
// 计算SVG中铲尖的中心点
|
||||||
|
val svgCenterX = (bucketDrawable.rightTip.x + bucketDrawable.leftTip.x) / 2
|
||||||
|
val svgCenterY = bucketDrawable.leftTip.y
|
||||||
|
|
||||||
|
// 计算屏幕上铲尖的中心点
|
||||||
|
val screenCenterX = (leftTip.x + rightTip.x) / 2
|
||||||
|
val screenCenterY = (leftTip.y + rightTip.y) / 2
|
||||||
|
|
||||||
|
// 创建变换矩阵
|
||||||
|
val matrix = Matrix()
|
||||||
|
matrix.postTranslate(-svgCenterX, -svgCenterY)
|
||||||
|
matrix.postScale(scale, scale)
|
||||||
|
matrix.postRotate(angle)
|
||||||
|
matrix.postTranslate(screenCenterX, screenCenterY)
|
||||||
|
|
||||||
|
// 应用变换并绘制
|
||||||
|
canvas.save()
|
||||||
|
try {
|
||||||
|
// 设置抗锯齿
|
||||||
|
val oldPaint = paint.isAntiAlias
|
||||||
|
paint.isAntiAlias = antiAlias
|
||||||
|
|
||||||
|
// 应用矩阵变换
|
||||||
|
canvas.concat(matrix)
|
||||||
|
|
||||||
|
// 设置drawable边界并绘制
|
||||||
|
with(bucketDrawable) {
|
||||||
|
drawable.setBounds(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
width.toInt(),
|
||||||
|
height.toInt()
|
||||||
|
)
|
||||||
|
drawable.draw(canvas)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复原来的抗锯齿设置
|
||||||
|
paint.isAntiAlias = oldPaint
|
||||||
|
} finally {
|
||||||
|
canvas.restore()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绘制铲尖标记
|
||||||
|
*/
|
||||||
|
private fun drawTipMarkers(canvas: Canvas, tipScreenPoints: TipScreenPoints) {
|
||||||
|
BucketTip.entries.forEach { tip ->
|
||||||
|
paint.apply {
|
||||||
|
color = tip.color
|
||||||
|
style = if (tip == selectedBucketTip) Paint.Style.FILL else Paint.Style.STROKE
|
||||||
|
strokeWidth = 2f
|
||||||
|
isAntiAlias = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用实际铲尖的屏幕坐标绘制标记
|
||||||
|
when (tip) {
|
||||||
|
BucketTip.LEFT -> tipScreenPoints.left
|
||||||
|
BucketTip.CENTER -> tipScreenPoints.center
|
||||||
|
BucketTip.RIGHT -> tipScreenPoints.right
|
||||||
|
}.let { tipScreen ->
|
||||||
|
canvas.drawCircle(
|
||||||
|
tipScreen.x,
|
||||||
|
tipScreen.y,
|
||||||
|
if (tip == selectedBucketTip) selectedTipRadius else tipRadius,
|
||||||
|
paint
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class BucketTip(val color: Int) {
|
||||||
|
LEFT(Color.RED),
|
||||||
|
CENTER(Color.GREEN),
|
||||||
|
RIGHT(Color.BLUE),
|
||||||
|
}
|
||||||
|
|
||||||
|
data class BucketTips(
|
||||||
|
val left: ENUPoint = ENUPoint(-1.0, -1.0, 0.0),
|
||||||
|
val center: ENUPoint = ENUPoint(0.0, 0.0, 0.0),
|
||||||
|
val right: ENUPoint = ENUPoint(1.0, 1.0, 0.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
data class TipScreenPoints(
|
||||||
|
val left: ScreenPoint = ScreenPoint(),
|
||||||
|
val center: ScreenPoint = ScreenPoint(),
|
||||||
|
val right: ScreenPoint = ScreenPoint(),
|
||||||
|
)
|
||||||
122
maps-view/src/main/java/com/icegps/maps/layer/GridLayer.kt
Normal file
122
maps-view/src/main/java/com/icegps/maps/layer/GridLayer.kt
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
package com.icegps.maps.layer
|
||||||
|
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.util.Log
|
||||||
|
import com.icegps.maps.CoordinateManager
|
||||||
|
import com.icegps.maps.ktx.TAG
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author tabidachinokaze
|
||||||
|
* @date 2025/6/13
|
||||||
|
*/
|
||||||
|
class GridLayer(
|
||||||
|
private val coordinateManager: CoordinateManager,
|
||||||
|
paintBuilder: (Paint) -> Unit = {
|
||||||
|
it.color = Color.LTGRAY
|
||||||
|
it.strokeWidth = 1f
|
||||||
|
it.style = Paint.Style.STROKE
|
||||||
|
}
|
||||||
|
) : BaseLayer(zIndex = 0) {
|
||||||
|
init {
|
||||||
|
paintBuilder(paint)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDraw(canvas: Canvas) {
|
||||||
|
// 获取地图尺寸
|
||||||
|
val (mapWidth, mapHeight) = coordinateManager.mapSize
|
||||||
|
|
||||||
|
// 获取当前缩放比例
|
||||||
|
val (scale) = coordinateManager.scale
|
||||||
|
|
||||||
|
// 获取当前偏移量
|
||||||
|
val nowPoint = coordinateManager.nowPoint
|
||||||
|
|
||||||
|
// 获取旋转角度(弧度)和旋转中心
|
||||||
|
val angle = coordinateManager.angle
|
||||||
|
val centerX = coordinateManager.centerX
|
||||||
|
val centerY = coordinateManager.centerY
|
||||||
|
|
||||||
|
// 计算网格线缩放单位
|
||||||
|
var bgLineScaleUnit = scale
|
||||||
|
while (bgLineScaleUnit > 2) {
|
||||||
|
bgLineScaleUnit /= 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应用网格线缩放单位
|
||||||
|
val scaledGridSize = coordinateManager.gridSize * bgLineScaleUnit
|
||||||
|
|
||||||
|
// 计算旋转变换的正弦和余弦值
|
||||||
|
val cosAngle = cos(angle)
|
||||||
|
val sinAngle = sin(angle)
|
||||||
|
|
||||||
|
// 计算网格原点(考虑平移)
|
||||||
|
val gridOriginX = nowPoint.x
|
||||||
|
val gridOriginY = nowPoint.y
|
||||||
|
|
||||||
|
// 计算网格线起始位置
|
||||||
|
var lineStartX = gridOriginX % scaledGridSize
|
||||||
|
var lineStartY = gridOriginY % scaledGridSize
|
||||||
|
|
||||||
|
// 确保起始位置为正值
|
||||||
|
lineStartX = if (lineStartX < 0) lineStartX + scaledGridSize else lineStartX
|
||||||
|
lineStartY = if (lineStartY < 0) lineStartY + scaledGridSize else lineStartY
|
||||||
|
|
||||||
|
// 计算需要绘制的额外线条数量,确保旋转后仍能覆盖整个视图
|
||||||
|
val diagonalLength = Math.sqrt((mapWidth * mapWidth + mapHeight * mapHeight).toDouble()).toFloat()
|
||||||
|
val extraLines = (diagonalLength / scaledGridSize).toInt() + 2
|
||||||
|
|
||||||
|
// 计算网格线的起始和结束点
|
||||||
|
val startX = -extraLines * scaledGridSize + lineStartX
|
||||||
|
val endX = mapWidth + extraLines * scaledGridSize
|
||||||
|
val startY = -extraLines * scaledGridSize + lineStartY
|
||||||
|
val endY = mapHeight + extraLines * scaledGridSize
|
||||||
|
|
||||||
|
// 绘制水平线
|
||||||
|
var y = startY
|
||||||
|
while (y <= endY) {
|
||||||
|
// 计算旋转后的线条起点和终点
|
||||||
|
val x1 = startX
|
||||||
|
val y1 = y
|
||||||
|
val x2 = endX
|
||||||
|
val y2 = y
|
||||||
|
|
||||||
|
// 计算旋转后的坐标
|
||||||
|
val rotatedX1 = centerX + (x1 - centerX) * cosAngle - (y1 - centerY) * sinAngle
|
||||||
|
val rotatedY1 = centerY + (x1 - centerX) * sinAngle + (y1 - centerY) * cosAngle
|
||||||
|
val rotatedX2 = centerX + (x2 - centerX) * cosAngle - (y2 - centerY) * sinAngle
|
||||||
|
val rotatedY2 = centerY + (x2 - centerX) * sinAngle + (y2 - centerY) * cosAngle
|
||||||
|
|
||||||
|
// 绘制旋转后的线条
|
||||||
|
canvas.drawLine(rotatedX1, rotatedY1, rotatedX2, rotatedY2, paint)
|
||||||
|
|
||||||
|
y += scaledGridSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制垂直线
|
||||||
|
var x = startX
|
||||||
|
while (x <= endX) {
|
||||||
|
// 计算旋转后的线条起点和终点
|
||||||
|
val x1 = x
|
||||||
|
val y1 = startY
|
||||||
|
val x2 = x
|
||||||
|
val y2 = endY
|
||||||
|
|
||||||
|
// 计算旋转后的坐标
|
||||||
|
val rotatedX1 = centerX + (x1 - centerX) * cosAngle - (y1 - centerY) * sinAngle
|
||||||
|
val rotatedY1 = centerY + (x1 - centerX) * sinAngle + (y1 - centerY) * cosAngle
|
||||||
|
val rotatedX2 = centerX + (x2 - centerX) * cosAngle - (y2 - centerY) * sinAngle
|
||||||
|
val rotatedY2 = centerY + (x2 - centerX) * sinAngle + (y2 - centerY) * cosAngle
|
||||||
|
|
||||||
|
// 绘制旋转后的线条
|
||||||
|
canvas.drawLine(rotatedX1, rotatedY1, rotatedX2, rotatedY2, paint)
|
||||||
|
|
||||||
|
x += scaledGridSize
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "Grid drawn with size: $scaledGridSize, scale: $scale, rotation: ${Math.toDegrees(angle.toDouble())}°")
|
||||||
|
}
|
||||||
|
}
|
||||||
13
maps-view/src/main/java/com/icegps/maps/layer/Layer.kt
Normal file
13
maps-view/src/main/java/com/icegps/maps/layer/Layer.kt
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package com.icegps.maps.layer
|
||||||
|
|
||||||
|
import android.graphics.Canvas
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author tabidachinokaze
|
||||||
|
* @date 2025/6/13
|
||||||
|
*/
|
||||||
|
interface Layer {
|
||||||
|
val visible: Boolean
|
||||||
|
val zIndex: Int
|
||||||
|
fun draw(canvas: Canvas)
|
||||||
|
}
|
||||||
63
maps-view/src/main/java/com/icegps/maps/layer/LayeredView.kt
Normal file
63
maps-view/src/main/java/com/icegps/maps/layer/LayeredView.kt
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package com.icegps.maps.layer
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.View
|
||||||
|
import com.icegps.maps.BaseMapView
|
||||||
|
import com.icegps.maps.CoordinateManager
|
||||||
|
import com.icegps.math.geometry.SizeInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author tabidachinokaze
|
||||||
|
* @date 2025/6/13
|
||||||
|
*/
|
||||||
|
abstract class LayeredView @JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null,
|
||||||
|
defStyleAttr: Int = 0
|
||||||
|
) : View(context, attrs, defStyleAttr), BaseMapView {
|
||||||
|
final override val coordinateManager: CoordinateManager = CoordinateManager()
|
||||||
|
private val layers = ArrayList<BaseLayer>()
|
||||||
|
|
||||||
|
fun getLayers(): List<BaseLayer> {
|
||||||
|
return layers.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addLayer(layer: BaseLayer) {
|
||||||
|
layers.add(layer)
|
||||||
|
layers.sortBy { it.zIndex }
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createGridLayer(): GridLayer {
|
||||||
|
return GridLayer(coordinateManager = coordinateManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createBoundaryLayer(): BoundaryLayer {
|
||||||
|
return BoundaryLayer(coordinateManager = coordinateManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createBucketLayer(): BucketLayer {
|
||||||
|
return BucketLayer(
|
||||||
|
coordinateManager = coordinateManager,
|
||||||
|
bucketDrawable = defaultBucketDrawable(context)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDraw(canvas: Canvas) {
|
||||||
|
super.onDraw(canvas)
|
||||||
|
|
||||||
|
layers.forEach { layer ->
|
||||||
|
if (layer.visible) layer.draw(canvas)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||||
|
super.onSizeChanged(w, h, oldw, oldh)
|
||||||
|
|
||||||
|
coordinateManager.update {
|
||||||
|
it.copy(mapSize = SizeInt(width = w, height = h))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.icegps.maps.model
|
||||||
|
|
||||||
|
import com.icegps.math.geometry.Point3
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author tabidachinokaze
|
||||||
|
* @date 2025/6/13
|
||||||
|
*/
|
||||||
|
typealias ENUPoint = Point3
|
||||||
10
maps-view/src/main/java/com/icegps/maps/model/MapMode.kt
Normal file
10
maps-view/src/main/java/com/icegps/maps/model/MapMode.kt
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package com.icegps.maps.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author tabidachinokaze
|
||||||
|
* @date 2025/6/13
|
||||||
|
*/
|
||||||
|
enum class MapMode {
|
||||||
|
HeadingForward,
|
||||||
|
NorthUp,
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.icegps.maps.model
|
||||||
|
|
||||||
|
import com.icegps.math.geometry.Vector2F
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author tabidachinokaze
|
||||||
|
* @date 2025/6/13
|
||||||
|
*/
|
||||||
|
typealias ScreenPoint = Vector2F
|
||||||
36
maps-view/src/main/java/com/icegps/maps/model/Viewport.kt
Normal file
36
maps-view/src/main/java/com/icegps/maps/model/Viewport.kt
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package com.icegps.maps.model
|
||||||
|
|
||||||
|
import com.icegps.math.geometry.Scale
|
||||||
|
import com.icegps.math.geometry.SizeInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author tabidachinokaze
|
||||||
|
* @date 2025/6/13
|
||||||
|
*/
|
||||||
|
data class Viewport(
|
||||||
|
val mapMode: MapMode,
|
||||||
|
val scale: Scale,
|
||||||
|
val mapSize: SizeInt,
|
||||||
|
val gridSize: Float,
|
||||||
|
val nowPoint: ScreenPoint,
|
||||||
|
val angle: Float,
|
||||||
|
val degrees: Float,
|
||||||
|
val centerX: Float,
|
||||||
|
val centerY: Float,
|
||||||
|
val direction: Float
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
val Empty = Viewport(
|
||||||
|
mapMode = MapMode.HeadingForward,
|
||||||
|
scale = Scale(1f, 1f),
|
||||||
|
mapSize = SizeInt(),
|
||||||
|
gridSize = 50f,
|
||||||
|
nowPoint = ScreenPoint(),
|
||||||
|
angle = 0f,
|
||||||
|
degrees = 0f,
|
||||||
|
centerX = 0f,
|
||||||
|
centerY = 0f,
|
||||||
|
direction = 0f
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.icegps.maps.model
|
||||||
|
|
||||||
|
import com.icegps.math.geometry.Vector2F
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author tabidachinokaze
|
||||||
|
* @date 2025/6/13
|
||||||
|
*/
|
||||||
|
typealias WorldPoint = Vector2F
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package com.icegps.maps.utils
|
||||||
|
|
||||||
|
import android.graphics.PointF
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import java.lang.Math.toDegrees
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.atan
|
||||||
|
import kotlin.math.atan2
|
||||||
|
import kotlin.math.pow
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工具类,用于处理触摸事件中的几何计算,包括计算距离、中点、旋转角度和夹角等。
|
||||||
|
*
|
||||||
|
* @author linmiao
|
||||||
|
* @date 2025/4/14
|
||||||
|
*/
|
||||||
|
object CoordinateAndTouchUtils {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据触摸事件中的两个点计算线段长度。
|
||||||
|
*
|
||||||
|
* @param event 触摸事件
|
||||||
|
* @return 两点之间的线段长度
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun lineLen(event: MotionEvent): Float {
|
||||||
|
return lineLen(event.getX(0), event.getY(0), event.getX(1), event.getY(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据两点坐标计算线段长度。
|
||||||
|
*
|
||||||
|
* @param x1 第一个点的 x 坐标
|
||||||
|
* @param y1 第一个点的 y 坐标
|
||||||
|
* @param x2 第二个点的 x 坐标
|
||||||
|
* @param y2 第二个点的 y 坐标
|
||||||
|
* @return 两点之间的线段长度
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun lineLen(x1: Float, y1: Float, x2: Float, y2: Float): Float {
|
||||||
|
return sqrt((x1 - x2).pow(2) + (y1 - y2).pow(2)).toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据触摸事件中的两个点计算它们的中点,并将结果存储到 PointF 对象中。
|
||||||
|
*
|
||||||
|
* @param point 用于存储中点的 PointF 对象
|
||||||
|
* @param event 触摸事件
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun centerPoint(point: PointF, event: MotionEvent) {
|
||||||
|
val x = event.getX(0) + event.getX(1)
|
||||||
|
val y = event.getY(0) + event.getY(1)
|
||||||
|
point.set(x / 2, y / 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据触摸事件中的两个点计算它们之间的旋转角度。
|
||||||
|
*
|
||||||
|
* @param event 触摸事件
|
||||||
|
* @return 两点之间的旋转角度(度)
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun rotation(event: MotionEvent): Float {
|
||||||
|
val deltaX = event.getX(0) - event.getX(1)
|
||||||
|
val deltaY = event.getY(0) - event.getY(1)
|
||||||
|
|
||||||
|
val radians = atan2(deltaY, deltaX)
|
||||||
|
return toDegrees(radians.toDouble()).toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算两点之间的夹角。
|
||||||
|
*
|
||||||
|
* @param x1 第一个点的 x 坐标
|
||||||
|
* @param y1 第一个点的 y 坐标
|
||||||
|
* @param x2 第二个点的 x 坐标
|
||||||
|
* @param y2 第二个点的 y 坐标
|
||||||
|
* @return 两点之间的夹角(度)
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun getIntersectionAngleFrom2Point(x1: Float, y1: Float, x2: Float, y2: Float): Double {
|
||||||
|
if (x1 == x2 && y1 == y2) return 0.0
|
||||||
|
|
||||||
|
val dX = x2 - x1
|
||||||
|
val dY = y2 - y1
|
||||||
|
|
||||||
|
if (x1 == x2) return if (dY > 0) 0.0 else 180.0
|
||||||
|
if (y1 == y2) return if (dX > 0) 0.0 else 180.0
|
||||||
|
|
||||||
|
var angle = abs(atan(dX / dY) / PI * 180)
|
||||||
|
|
||||||
|
if (dX > 0 && dY < 0) {
|
||||||
|
angle += 90
|
||||||
|
} else if (dX < 0 && dY < 0) {
|
||||||
|
angle += 180
|
||||||
|
} else if (dX < 0 && dY > 0) {
|
||||||
|
angle += 270
|
||||||
|
}
|
||||||
|
|
||||||
|
return angle
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算两点之间的欧几里得距离。
|
||||||
|
*
|
||||||
|
* @param x 第一个点的 x 坐标
|
||||||
|
* @param y 第一个点的 y 坐标
|
||||||
|
* @param x1 第二个点的 x 坐标
|
||||||
|
* @param y1 第二个点的 y 坐标
|
||||||
|
* @return 两点之间的距离
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun calculateDistance(x: Double, y: Double, x1: Double, y1: Double): Double {
|
||||||
|
return sqrt((x - x1).pow(2) + (y - y1).pow(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算两点之间的方向角度。
|
||||||
|
*
|
||||||
|
* @param x 第一个点的 x 坐标
|
||||||
|
* @param y 第一个点的 y 坐标
|
||||||
|
* @param x1 第二个点的 x 坐标
|
||||||
|
* @param y1 第二个点的 y 坐标
|
||||||
|
* @return 从点 (x1, y1) 到点 (x, y) 的方向角度(度),范围为 [0, 360)
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun calculateAngle(x: Double, y: Double, x1: Double, y1: Double): Double {
|
||||||
|
var degrees = toDegrees(atan2(y - y1, x - x1))
|
||||||
|
degrees = (degrees + 270) % 360
|
||||||
|
return degrees
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user