feat: 重构地图

This commit is contained in:
2025-06-13 21:03:42 +08:00
parent b384446fc6
commit 9889efc754
27 changed files with 1672 additions and 7 deletions

View File

@@ -1,6 +1,7 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.serialization)
}
android {
@@ -23,6 +24,9 @@ android {
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
buildFeatures {
viewBinding = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
@@ -38,7 +42,12 @@ dependencies {
implementation(libs.androidx.appcompat)
implementation(libs.material)
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)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)

View File

@@ -11,6 +11,16 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
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>

File diff suppressed because one or more lines are too long

View 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)
}
}
}

View 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>

View File

@@ -1,6 +1,6 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<resources>
<!-- Base application theme. -->
<style name="Theme.Maps" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<style name="Theme.Maps" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_200</item>
<item name="colorPrimaryVariant">@color/purple_700</item>

View File

@@ -1,6 +1,6 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.Maps" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<style name="Theme.Maps" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryVariant">@color/purple_700</item>

View File

@@ -4,4 +4,5 @@ plugins {
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.jvm) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.serialization) apply false
}

View File

@@ -8,7 +8,9 @@ espressoCore = "3.6.1"
appcompat = "1.7.1"
material = "1.12.0"
jetbrainsKotlinJvm = "2.0.21"
activity = "1.10.1"
constraintlayout = "2.1.4"
kotlin-serialization = "1.8.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
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-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
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]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

View File

@@ -34,6 +34,8 @@ dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.material)
implementation(project(":math"))
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)

View 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
}

View 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
}
}

View File

@@ -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
}

View 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
}
}

View 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

View 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)
}

View 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,
}

View 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(),
)

View 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())}°")
}
}

View 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)
}

View 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))
}
}
}

View File

@@ -0,0 +1,9 @@
package com.icegps.maps.model
import com.icegps.math.geometry.Point3
/**
* @author tabidachinokaze
* @date 2025/6/13
*/
typealias ENUPoint = Point3

View File

@@ -0,0 +1,10 @@
package com.icegps.maps.model
/**
* @author tabidachinokaze
* @date 2025/6/13
*/
enum class MapMode {
HeadingForward,
NorthUp,
}

View File

@@ -0,0 +1,9 @@
package com.icegps.maps.model
import com.icegps.math.geometry.Vector2F
/**
* @author tabidachinokaze
* @date 2025/6/13
*/
typealias ScreenPoint = Vector2F

View 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
)
}
}

View File

@@ -0,0 +1,9 @@
package com.icegps.maps.model
import com.icegps.math.geometry.Vector2F
/**
* @author tabidachinokaze
* @date 2025/6/13
*/
typealias WorldPoint = Vector2F

View File

@@ -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
}
}