Openlayers10.2.1最新版在安卓Compose中使用的一个例子

devtools/2024/11/15 4:48:10/

题目

这是一个中小公司的面试题:

Openlayers 是一个功能完善的地图引擎,能在WEB页面上显示瓦片地图或者矢量地图,官方网址是https://openlayers.org/。
1、尝试做一个安卓App,使用Openlayers显示高德或者百度在线地图,能实现基本的平移、缩放、旋转功能。
2、App原生界面上有两个输入框,一个输入平行线的数量,默认是10条,另一个输入平行线的间距,默认是10米。
3、用户可以在地图上点击设置起点A和终点B,然后把A和B用直线连接显示出来。
4、在AB线的两侧绘制根据用户输入的平行线数量绘制平行线,相邻平行线的间隔按用户输入的间距处理。
5、把AB线和所有平行线的坐标以CSV格式保存到文件内,每行描述一条线,分别是A点经度,A点纬度,B点经度, B点纬度。

深圳、招安卓的,薪资能给到 5k。。。

web端效果图:
在这里插入图片描述

Android端效果图:

在这里插入图片描述在这里插入图片描述

在这里插入图片描述

1、技术点拆解

1)Openlayers 是一个JS库,一般只是开发Web,所以第一个技术点是Nodejs,也就是 Web 开发。
2)要在安卓中实现,所以需要使用WebView组件。在Compose中使用WebView我们可以使用AndroiView来包装。
3)因为需要在Android端保存web端的平行线数据到CSV文件,所以此处需要Android端与JS交互。

2、Android Compose端实现:

2.1 新建compose项目(使用基础模板创建)

1)添加相关hilt插件、依赖(app模块下添加):
因为我们使用了文件夹图标、hilt依赖项注入

plugins {alias(libs.plugins.android.application)alias(libs.plugins.kotlin.android)alias(libs.plugins.kotlin.compose)// 添加这两个hiltid("kotlin-kapt")id("com.google.dagger.hilt.android")
}
	// 引入扩展图标,用于一些文件夹图标之类的//implementation(libs.androidx.compose.material.iconsExtended)implementation("androidx.compose.material:material-icons-extended")// hiltimplementation("com.google.dagger:hilt-android:2.44")kapt("com.google.dagger:hilt-android-compiler:2.44")// hilt + compose导航implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
// Allow references to generated code
kapt {correctErrorTypes = true
}

2)顶部built添加hilt插件

// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {alias(libs.plugins.android.application) apply falsealias(libs.plugins.kotlin.android) apply falsealias(libs.plugins.kotlin.compose) apply false// 依赖项注入 hiltid("com.google.dagger.hilt.android") version "2.44" apply false
}

说明:上面工作做完之后同步我们就可以在compose里面愉快使用一些图标和hilt依赖项注入了。

2.2 使用 hilt的补充步骤

1)新建一个 Application ,然后添加hilt注解,并把相关类添加到 AndroidMainifest.xml里面:

import android.app.Application
import dagger.hilt.android.HiltAndroidApp// 使用 hilt 注入,必须提供一个注入的 Application
@HiltAndroidApp
class OLApp : Application() {}
<applicationandroid:allowBackup="true"android:name=".OLApp"

2)我们的 MainActivity 也需要加入hilt注解:

@AndroidEntryPoint
class MainActivity : ComponentActivity() {。。。。
}

3)使用 hilt 制作ViewModel:

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject@HiltViewModel
class OLViewModel @Inject constructor() : ViewModel() {// 保存平行线数量、间距的状态var linesCount by mutableIntStateOf(10)var linesSpace by mutableIntStateOf(10)
}

4)在compose组件中使用 ViewModel 示例:

@Composable
fun OpenLayersWebView(modifier: Modifier = Modifier,navController: NavController,viewModel: OLViewModel = hiltViewModel(),// 使用 hilt 注入
) {

以上就是 hilt 在 Android Compose里面使用的简单基础步骤。

2.2 项目结构设计

1)项目整体结构设计:

在这里插入图片描述
2)页面数量,此项目我们可以使用两个页面来制作:

  • 页面1:地图网页页面
  • 页面2:CSV文件预览页面
    在这里插入图片描述

2.3 开始编码:此处我直接粘贴代码了

0)导航相关OLNavHost.kt

package lcppx.android.openlayers_compose.navigationimport androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import lcppx.android.openlayers_compose.screens.csvscreen.BrowseCsvFileScreen
import lcppx.android.openlayers_compose.screens.webscreen.OpenLayersWebViewconst val MapRoute = "map"
const val CsvRoute = "csv"
@Composable
fun OLNavHost(modifier: Modifier = Modifier,navController: NavHostController,
) {NavHost(navController = navController,// 主页面startDestination = MapRoute,modifier = modifier,) {composable(MapRoute) {OpenLayersWebView(navController = navController)}composable(CsvRoute) {BrowseCsvFileScreen(navController)}}
}

1)OpenLayersWebView.kt

package lcppx.android.openlayers_compose.screens.webscreenimport android.annotation.SuppressLint
import android.graphics.Rect
import android.webkit.WebSettings
import android.webkit.WebView
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.navigation.NavController
import lcppx.android.openlayers_compose.R
import lcppx.android.openlayers_compose.navigation.CsvRoute
import lcppx.android.openlayers_compose.ui.components.MapTopAppbar
import lcppx.android.openlayers_compose.ui.components.MyOutlinedTextField
import lcppx.android.openlayers_compose.screens.webscreen.api.CustomWebViewClient
import lcppx.android.openlayers_compose.screens.webscreen.api.JsBridgeInterface
import lcppx.android.openlayers_compose.util.ToastUtil
import androidx.hilt.navigation.compose.hiltViewModel@OptIn(ExperimentalMaterial3Api::class)
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun OpenLayersWebView(modifier: Modifier = Modifier,navController: NavController,viewModel: OLViewModel = hiltViewModel(),
) {// 移动到 viewModel
//    // 用于存储平行线数量的变量
//    var linesCount by remember { mutableIntStateOf(10) }
//    // 用于存储平行线间距的变量
//    var linesSpacing by remember { mutableIntStateOf(10) }val context = LocalContext.currentval density = LocalDensity.currentval webView = remember { WebView(context) }Scaffold(modifier = Modifier.windowInsetsPadding(TopAppBarDefaults.windowInsets).fillMaxSize(),// 顶部导航栏topBar = {MapTopAppbar() {navController.navigate(CsvRoute)}}) { innerPadding ->Column(modifier = modifier.fillMaxSize().padding(innerPadding)) {val url = "file:///android_asset/index.html"// 1 在compose中实现安卓WebViewAndroidView(modifier = Modifier.fillMaxWidth().weight(1f),factory = { context ->webView.apply {// 设置WebViewClient以防止外部浏览器打开链接webViewClient = CustomWebViewClient()// 使用自定义的,在发生错误的时候显示错误页面// 启用JavaScriptsettings.javaScriptEnabled = true// 通过loadUrl调用JavaScript函数// loadUrl("javascript:callJs()")// 是否支持缩放settings.setSupportZoom(true)/*settings.domStorageEnabled = truesettings.allowContentAccess = true*/// 允许WebView加载本地文件,此类设置存在安全问题,所以一般就是测试使用settings.allowFileAccess = true// 允许WebView加载来自文件系统的JavaScript(必须提供其中一个)settings.allowUniversalAccessFromFileURLs = truesettings.allowFileAccessFromFileURLs = true// 设置混合内容模式(HTTPS和HTTP)settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW// 加载OpenLayers页面loadUrl(url)}},// 涉及到安卓端的变量更新,需要在 update 中才能更新update = {// 暴露一个名为 Android 的 JavaScript 全局对象it.addJavascriptInterface(JsBridgeInterface(context, viewModel.linesCount, viewModel.linesSpace),"Android")// 名为Android的JavaScript对象ToastUtil.showToast(context, "网页状态重置了")// 每次状态更新,都重新加载 js 网页:才能将新的状态传给 js 代码// 但是在此处重新加载也会导致js的网页状态重置,比如键盘弹起的时候都会导致js网页状态重置。。。。// 所以移动到外部加载//it.loadUrl(url)})// 每次 数量、间距更新的时候再重新加载jsLaunchedEffect(viewModel.linesCount, viewModel.linesSpace){webView.loadUrl(url)}/// Compose中使用 AndroidView 的缺点是输入框聚焦的时候无法弹起 AndroidView(里面的内容/// 如果你想实现弹起也可以自己编写键盘监听逻辑实现弹起,或者不弹起 AndroidView 部分// 记录键盘当前高度(px)var imCurrentHeightPx by remember { mutableIntStateOf(0) }// 监听高度变化(监听View)val view = LocalView.current// 当前的Viewview.viewTreeObserver.addOnGlobalLayoutListener {val rect = run {val rect = Rect()view.getWindowVisibleDisplayFrame(rect)rect}val heightDiff = view.rootView.height - rect.bottom//页面高度差( = 键盘高度)imCurrentHeightPx = if (heightDiff > 0) {// 注意,键盘收起的时候,他不是0,而是保持和底部安全距离。heightDiff} else {0}}// 2 底部的两个输入框(悬浮在最上层)Column(modifier = Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.background).padding(horizontal = 16.dp).padding(bottom = with(density) { imCurrentHeightPx.toDp() }),// 跟随键盘弹起而弹起) {// 平行线数量输入框MyOutlinedTextField(number = viewModel.linesCount,setNumber = {viewModel.linesCount = it},labelStr = stringResource(R.string.label_lines_count)//"平行线数量(默认10条)")// 平行线间距输入框MyOutlinedTextField(number = viewModel.linesSpace,setNumber = {viewModel.linesSpace = it},labelStr = stringResource(R.string.label_lines_spacing)//"平行线间距(默认10米)")}}}}

OLViewModel.kt

package lcppx.android.openlayers_compose.screens.webscreenimport androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject@HiltViewModel
class OLViewModel @Inject constructor() : ViewModel() {// 保存平行线数量、间距的状态var linesCount by mutableIntStateOf(10)var linesSpace by mutableIntStateOf(10)
}

CustomWebViewClient.kt

package lcppx.android.openlayers_compose.screens.webscreen.apiimport android.graphics.Bitmap
import android.os.Build
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient/*** 自定义 WebViewClient,实现发生错误的时候显示错误页面* */
class CustomWebViewClient : WebViewClient() {// 重写shouldOverrideUrlLoading方法(带 url 的),打开网络链接的时候走我们自己app// 你也可以在此做一些初始化操作@Deprecated("Deprecated in Java")override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {view.loadUrl(url)return true}// 开始载入页面的时候,你可以设置一个 Loding 页面// 页面加载结束,你可以重写 onPageFinished()方法,进行一些页面加载完成后的操作override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {super.onPageStarted(view, url, favicon)// todo Loading...}// 当发生错误的时候,打印错误信息,并且加载至错误页面override fun onReceivedError(view: WebView?,request: WebResourceRequest?,error: WebResourceError?) {super.onReceivedError(view, request, error)if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {println("发生错误,错误信息:${error?.description},错误码:${error?.errorCode}")}// 加载自定义错误页面//view?.loadUrl("file:///android_asset/dist/error.html")}
}

JsBridgeInterface.kt

package lcppx.android.openlayers_compose.screens.webscreen.apiimport android.annotation.SuppressLint
import android.content.Context
import android.webkit.JavascriptInterface
import lcppx.android.openlayers_compose.util.CsvUtils
import lcppx.android.openlayers_compose.util.ToastUtil
import org.json.JSONArray
import org.json.JSONException// 定义JavaScript 可以调用 kotlin 的接口
class JsBridgeInterface(private val context: Context,private val linesCount: Int,private val linesSpace: Int,
) {@JavascriptInterfacefun showToast(message: String) {// todo 测试————尝试在js里面显示安卓 TToastUtil.showToast(context, message)}@SuppressLint("DefaultLocale")@JavascriptInterfacefun sendParallelLinesCoordinates(coordinateArrayJson: String) {try {// 一行数据的格式为:[[114.05769097420503,22.54319555952454],[114.0601532420807,22.543603255294805]],// 也就是起点坐标、终点坐标,然后两者组成的Listval jsonArray = JSONArray(coordinateArrayJson)val lines = mutableListOf<Pair<Pair<String, String>, Pair<String, String>>>()for (i in 0 until jsonArray.length()) {val coordinateJson = jsonArray.getJSONArray(i)val startJsonArray = coordinateJson.getJSONArray(0)val endJsonArray = coordinateJson.getJSONArray(1)// 保留 5 位小数val startLat = String.format("%.5f", startJsonArray.getDouble(1))val startLng = String.format("%.5f", startJsonArray.getDouble(0))val endLat = String.format("%.5f", endJsonArray.getDouble(1))val endLng = String.format("%.5f", endJsonArray.getDouble(0))val start = Pair(startLng, startLat)val end = Pair(endLng, endLat)/*val start = Pair(startJsonArray.getString(0), startJsonArray.getString(1))val end = Pair(endJsonArray.getString(0), endJsonArray.getString(1))*/lines.add(Pair(start, end))}// 处理coordinatesList,例如保存或显示// 将坐标保存到CSV文件CsvUtils.saveLinesToCsv(lines,"parallel_lines.csv",context)} catch (e: JSONException) {e.printStackTrace()// 处理错误}}// 在 js 里面调用这个函数,传回来两个// aPointX: String, aPointY: String,bPointX: String, bPointY: String,// 只能接收基本数据类型(如 String、int、boolean 等)或者 JSONArray 和 JSONObject 作为参数,而不能直接接收 Kotlin 的 List 类型。@JavascriptInterfacefun sendCoordinatesToKotlin(jsonArray: JSONArray) {//lines:List<List<String>>ToastUtil.showToast(context,"发送坐标到kotlin")// 解析字符串并获取坐标// 将 JSONArray 转换为 List<List<String>>val lines = jsonArray.toKotlinList()}/* JS 端使用说明:// 发送所有行的坐标到 kotlinfunction sendCoordinatesToKotlin(coordinates) {// JavaScript中,我们通常使用JavaScript原生的数组(Array)来处理类似JSON数组的数据结构。const jsonArray = [["element1", "element2", "element3"],["element1", "element2", "element3"],...];coordinates.forEach(function(line) {const lineArray = [];line.forEach(function(coord) {lineArray.put(coord);});jsonArray.put(lineArray);});Android.sendCoordinatesToKotlin(jsonArray);}*/// js 里面需要获取 安卓原生两个输入框的值,所以此处给出所需的接口@JavascriptInterfacefun getLinesCount():Int {return linesCount}@JavascriptInterfacefun getLinesSpace():Int {return linesSpace}
}fun JSONArray.toKotlinList(): List<List<String>> {// 创建了一个MutableList来存储最终的结果val list = mutableListOf<List<String>>()try {// 遍历JSONArray中的每个元素,每个元素本身也是一个JSONArrayfor (i in 0 until this.length()) {val innerArray = this.getJSONArray(i)// 创建了一个MutableList来存储字符串,并将其添加到外部列表中val innerList = mutableListOf<String>()for (j in 0 until innerArray.length()) {innerList.add(innerArray.getString(j))}list.add(innerList)}} catch (e: JSONException) {e.printStackTrace()}return list
}// 简单测试
fun toKotlinListTest(){val jsonArray = JSONArray("[[\"A1\",\"A2\",\"A3\"],[\"B1\",\"B2\",\"B3\"]]")val kotlinList = jsonArray.toKotlinList()println(kotlinList) // 输出: [[A1, A2, A3], [B1, B2, B3]]
}

2)BrowseCsvFileScreen.kt

package lcppx.android.openlayers_compose.screens.csvscreenimport androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.FileOpen
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FabPosition
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import lcppx.android.openlayers_compose.ui.components.RowVCenter
import lcppx.android.openlayers_compose.util.CsvUtils.readCsvFile@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun BrowseCsvFileScreen(navController: NavController,
) {val context = LocalContext.currentvar csvFilePath by remember { mutableStateOf(context.filesDir.path + "/parallel_lines.csv") }val csvContent = remember(csvFilePath) { mutableStateOf<List<List<String>?>>(emptyList()) }// 读取CSV文件内容LaunchedEffect(key1 = csvFilePath) {csvContent.value = readCsvFile(context, csvFilePath)println("读取到的csv文件内容: ${csvContent.value.size}:${csvContent.value}")}val listState = rememberLazyListState()val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }val sheetState = rememberModalBottomSheetState()val scope = rememberCoroutineScope()var showBottomSheet by remember { mutableStateOf(false) }// 获取屏幕大小val configuration = LocalConfiguration.currentval screenHeight = configuration.screenHeightDp.dp// 底部文件列表if (showBottomSheet) {ModalBottomSheet(onDismissRequest = {showBottomSheet = false},sheetState = sheetState) {var onlyCsvFile by remember { mutableStateOf(true) }Box(modifier = Modifier.fillMaxWidth().combinedClickable { onlyCsvFile = !onlyCsvFile },) {RowVCenter(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),horizontalArrangement = Arrangement.SpaceBetween) {Text(text = if (onlyCsvFile) "仅显示Csv文件" else "显示所有文件")Switch(checked = onlyCsvFile,onCheckedChange = {onlyCsvFile = it})}}FileListScreen(modifier = Modifier.fillMaxWidth().height(screenHeight * 0.5f),onClickCsvFile = { selectedFilePath ->csvFilePath = selectedFilePathshowBottomSheet = false},onlyCsvFile = onlyCsvFile,)}}Scaffold(floatingActionButton = {ExtendedFloatingActionButton(onClick = { showBottomSheet = true },expanded = expandedFab,icon = { Icon(Icons.Filled.FileOpen, "Localized Description") },text = { Text(text = "文件列表") },)},floatingActionButtonPosition = FabPosition.End,) {// UI部分Column(modifier = Modifier.fillMaxSize().padding(it),horizontalAlignment = Alignment.CenterHorizontally,verticalArrangement = Arrangement.Top) {// 顶部导航栏RowVCenter(modifier = Modifier.fillMaxWidth().height(66.dp)) {// 返回按钮IconButton(onClick = {// 返回上一级navController.popBackStack()}) {Icon(imageVector = Icons.Filled.ArrowBack,contentDescription = "返回上一级",modifier = Modifier.size(24.dp))}// 标题、文件路径名Column(modifier = Modifier.fillMaxWidth().padding(6.dp),horizontalAlignment = Alignment.CenterHorizontally) {Text(text = "CSV文件简单预览测试",style = MaterialTheme.typography.headlineSmall)Text(text = csvFilePath, fontSize = 11.sp)}}HorizontalDivider()Spacer(modifier = Modifier.height(16.dp))// 显示CSV文件内容(所有行)LazyColumn(state = listState,) {itemsIndexed(csvContent.value ?: emptyList()) { index, line ->Row(modifier = Modifier.fillMaxWidth().padding(8.dp),horizontalArrangement = Arrangement.SpaceBetween) {// 1、前面的序号Text(text = "${if (index > 0)index-1 else ""}")// 2、后面的数据line?.forEach { item ->Text(item,maxLines = 1,// 第一行数据标红,表示这是原来的线条ABcolor = if (index == 1) Color.Red else Color.Black,overflow = TextOverflow.Ellipsis)}}}}}}
}

FileListScreen.kt

package lcppx.android.openlayers_compose.screens.csvscreenimport androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.FileOpen
import androidx.compose.material.icons.rounded.Folder
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import lcppx.android.openlayers_compose.ui.components.RowVCenter
import lcppx.android.openlayers_compose.util.FileUtils
import java.io.File// 用于测试的简单文件列表,用于显示存储的 csv 文件
@Composable
fun FileListScreen(modifier: Modifier = Modifier,onClickCsvFile: (String) -> Unit,onlyCsvFile: Boolean = true
) {val context = LocalContext.currentval fileList = remember(onlyCsvFile) {var fl = context.filesDir.listFiles()?.toList()?: emptyList()// 是否仅显示 csv 文件fl = if(onlyCsvFile) fl.filter { it.isFile && it.extension == "csv" } else fl// 按照时间逆向排序fl.sortedByDescending { it.lastModified() }}LazyColumn(modifier = modifier) {items(fileList) { file ->FileLazyItem(file,onClickCsvFile)}}
}// 每一项文件UI:
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun FileLazyItem(file: File,onClickCsvFile: (String) -> Unit
) {val isFile = if(file.isFile) true else falseColumn {RowVCenter(modifier = Modifier.fillMaxWidth().combinedClickable {onClickCsvFile(file.absolutePath)}) {// 1、文件图标Icon(imageVector = if (isFile) Icons.Rounded.FileOpen else Icons.Rounded.Folder,contentDescription = "",modifier = Modifier.padding(horizontal = 13.dp).size(33.dp))// 2、文件信息Column(modifier = Modifier.weight(1f).padding(8.dp)) {// 2.1 文件名Text(text = file.name,maxLines = 3,fontSize = 18.sp,fontWeight = FontWeight.Bold)// 2.2 文件创建时间、文件大小RowVCenter {Text(text = FileUtils.formatDateTime(file.lastModified()))Text(text = "     ${FileUtils.formatSize(file.length())}")}}}HorizontalDivider()}
}

组件相关
1)顶部导航栏
MapTopAppbar.kt

package lcppx.android.openlayers_compose.ui.componentsimport androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.PlaylistAddCircle
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import lcppx.android.openlayers_compose.ui.theme.Typography// 顶部导航栏
@Composable
fun MapTopAppbar(onClickCsv:()->Unit
) {RowVCenter(modifier = Modifier.fillMaxWidth().height(50.dp).background(Color.White.copy(0.33f)),horizontalArrangement = Arrangement.SpaceBetween) {@Composablefun topIcon(imageVector: ImageVector?,onClick: () -> Unit = {},){IconButton(onClick = onClick) {if (imageVector != null) {Icon(imageVector = imageVector,contentDescription = "")}}}// 1 左侧菜单按钮
//        topIcon(Icons.Default.Menu){
//
//        }topIcon(null)// 占位// 2 中间标题Text("Openlayers测试", style = Typography.titleLarge)// 3 右侧更多按钮topIcon(Icons.Default.PlaylistAddCircle){onClickCsv()}}
}

2)输入框组件
MyOutlinedTextField.kt

package lcppx.android.openlayers_compose.ui.componentsimport androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp// 输入框组件
@Composable
fun MyOutlinedTextField(number:Int,setNumber:(Int)->Unit,labelStr:String,
){OutlinedTextField(modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),value = "$number",onValueChange = { newValue ->// 添加一个检查,以确保用户输入的是有效的正数。// 如果输入无效,我们可以忽略该输入或将其重置为默认值try {val parseInt = newValue.toInt()if (parseInt > 0) {setNumber(parseInt)}} catch (e: NumberFormatException) {// 如果输入不是数字,则不更新状态}},label = { Text(labelStr) },singleLine = true)
}

辅助的布局组件
RowVCenter.kt

package lcppx.android.openlayers_compose.ui.componentsimport androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier// 在垂直方向上中心对齐的Compose组件,经常用于顶部导航栏等常见场景
@Composable
fun RowVCenter(modifier: Modifier = Modifier,horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,verticalAlignment: Alignment.Vertical = Alignment.CenterVertically,// 垂直方向上默认中心对齐content: @Composable() (RowScope.() -> Unit),
) {Row(modifier = modifier,horizontalArrangement = horizontalArrangement,verticalAlignment = verticalAlignment,content = content,)
}

工具类
1)CsvUtils.kt

package lcppx.android.openlayers_compose.utilimport android.content.Context
import java.io.BufferedReader
import java.io.File
import java.io.FileReader
import java.io.FileWriter
import java.io.IOExceptionobject CsvUtils {/*** 保存坐标数据到CSV文件* @param lines 包含线的坐标数据列表,每一行都有一对坐标A、B。然后每个坐标都包含经度、纬度一对值* @param fileName 要保存的CSV文件名* @param context 上下文,用于访问文件系统*/fun saveLinesToCsv(lines: List<Pair<Pair<String,String>,Pair<String,String>>>, fileName: String, context: Context) {try {// /data/data/your.package.name/files/xxx//val folderFile = File("/storage/emulated/0")// val file = File(folderFile, fileName)val file = File(context.filesDir, fileName)val writer = FileWriter(file)// 写入CSV文件头部writer.append("A点经度,A点纬度,B点经度,B点纬度\n")// 写入每条线的坐标数据for (line in lines) {writer.append("${line.first.first},${line.first.second},${line.second.first},${line.second.second}")writer.append("\n")}// 关闭文件写入器writer.close()} catch (e: IOException) {e.printStackTrace()}}// 读取CSV文件的辅助函数fun readCsvFile(context: Context, filePath: String): List<List<String>?> {val lines = mutableListOf<List<String>?>()val file = File(filePath)try {BufferedReader(FileReader(file)).useLines { linesIterator ->linesIterator.forEach { line ->val values = line.split(",")lines.add(values)}}} catch (e: Exception) {e.printStackTrace()}return lines}}// 简单的测试
fun CsvSaverTest(context: Context){println("测试CSV保存")val linesData = listOf(Pair(Pair("116.397428", "39.90923"), Pair("116.400428", "39.90123")),Pair(Pair("116.405428", "39.91523"), Pair("116.407428", "39.91723")))val linesData2 = listOf(Pair(Pair("116.397428", "39.90923"), Pair("116.400428", "39.90123")),Pair(Pair("116.397428", "39.90923"), Pair("116.400428", "39.90123")),Pair(Pair("116.397428", "39.90923"), Pair("116.400428", "39.90123")),Pair(Pair("116.397428", "39.90923"), Pair("116.400428", "39.90123")),Pair(Pair("116.405428", "39.91523"), Pair("116.407428", "39.91723")))CsvUtils.saveLinesToCsv(linesData, "lines.csv", context)CsvUtils.saveLinesToCsv(linesData2, "lines2.csv", context)
}

FileUtils.kt

package lcppx.android.openlayers_compose.utilimport android.annotation.SuppressLint
import java.text.SimpleDateFormat
import java.util.Date
import kotlin.math.log10
import kotlin.math.powobject FileUtils {// 大小:比较简单的写法@SuppressLint("DefaultLocale")fun formatSize(size: Long): String {if (size<=0L) return "0B"// 仅需处理 大于 0 的情况val units = arrayOf("B", "KB", "MB", "GB", "TB")val digitGroups = (log10(size.toDouble()) / log10(1024.0)).toInt()return String.format("%.2f %s", size / (1024.0.pow(digitGroups.toDouble())), units[digitGroups.coerceIn(0,4)])}// 时间@SuppressLint("SimpleDateFormat")fun formatDateTime(timestamp: Long,pattern:String = "yy-MM-dd HH:mm"): String {val patterns = listOf("yyyy-MM-dd HH:mm:ss","yyyy-MM-dd HH:mm","yyyy-MM-dd","yyyy-MM","yyyy","HH:mm:ss","HH:mm",)// DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(Date(timestamp))// 使用中、短格式,可以自动适应系统语言//return SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(Date(timestamp))// 自定义,固定return SimpleDateFormat(pattern).format(Date(timestamp))// 自定义,固定// return SimpleDateFormat("yy/MM-dd  HH:mm").format(Date(timestamp))// 自定义,固定
//        return SimpleDateFormat("yy/MM/dd HH:mm").format(Date(timestamp))// 自定义,固定}
}

权限相关:
备注:不一定需要,因为如果只保存至应用目录,可能只需要简单的存储权限(甚至不需要—我还没测试过)
StoragePermissionUtils.kt

package lcppx.android.openlayers_compose.utilimport android.Manifest
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.Settings
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompatobject StoragePermissionUtils {/** Android 存储权限请求情况:* 1、Android 6.0(23) ~ Android10(29)申请权限* */// 1 定义存储相关权限数组(3个字符串)val perms = arrayOf(// 2 个与存储相关的Manifest.permission.READ_EXTERNAL_STORAGE,Manifest.permission.WRITE_EXTERNAL_STORAGE,//Manifest.permission.READ_PHONE_STATE// 手机设备相关)/*** 检查是否已经获取存储权限或全部文件访问权限,没有就获取* @param context 应用程序或活动的上下文。* @return 如果已经获取所需的存储权限,则返回true;否则返回false。*/fun requestStorageOrAllFilePermission(activity: Activity): Boolean {// 安卓 11 以及以上,只需要申请所有文件访问权限if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {// 如果没有管理外部存储的权限,就申请// 所有文件访问权限requestAllFilesAccessPermission(activity)return Environment.isExternalStorageManager()}else// >= Android 6.0 才需要动态申请if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {// 遍历字符串数组,逐一检查权限,如果没有,就跳转去设置页面手动设置权限for (p in perms) {val ret = ContextCompat.checkSelfPermission(activity, p)// 如果权限未获取,就先申请获取权限if (ret != PackageManager.PERMISSION_GRANTED) {//TODO 跳转到权限页,手动设置权限//goAppDetailsSettings(context)// 请求存储权限ActivityCompat.requestPermissions(activity,perms,PERMISSIONS_REQUEST_CODE)return true}}}return false}private const val PERMISSIONS_REQUEST_CODE = 100 // 你自定义的请求码const val REQUEST_CODE_ALL_FILES_ACCESS = 108/*** Android 11及以上版本请求所有文件访问权限的方法。* 需要在调用此方法的Activity中重写onActivityResult方法来处理用户的选择结果。* @param activity 当前Activity实例。*/private fun requestAllFilesAccessPermission(activity: Activity) {if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && !Environment.isExternalStorageManager()) {//val intent = Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)// 这个请求会跳到全部应用页面,体验不好val appIntent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)// 直接跳到当前app授权页面appIntent.setData(Uri.parse("package:" + activity.packageName))activity.startActivityForResult(appIntent, REQUEST_CODE_ALL_FILES_ACCESS)}}/** 跳转到“应用信息”页面:* 安卓默认只能跳转到 "应用信息"页面,* 但是国内手机厂商大多支持各自自定义的Intent,直接跳到应用程序权限页面* 当前应用详情页面(在该页面单击权限,进入的是权限组页面)*/private fun goAppDetailsSettings(context: Context) {val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)intent.setData(Uri.fromParts("package", context.packageName, null))context.startActivity(intent)}}

ToastUtil.kt

package lcppx.android.openlayers_compose.utilimport android.content.Context
import android.widget.Toast
/*
* 快速连续点击了五次按钮,Toast就触发了五次。这样的体验其实是不好的,因为也许用户是手抖了一下多点了几次,
* 导致Toast就长时间关闭不掉了。又或者我们其实已在进行其他操作了,应该弹出新的Toast提示,而上一个Toast却还没显示结束。
* 因此,最佳的做法是将Toast的调用封装成一个接口,写在一个公共的类当中,如下所示:
*
* 这样就相当于共用一个全局Toast,当他是空的才新建。
* */object ToastUtil {private var toast: Toast? = null// 适用于短暂的、用户可能频繁点击的提示fun showToast(context: Context?,content: String?,) {// 取消之前的Toasttoast?.cancel()// 创建并显示新的Toasttoast = Toast.makeText(context, content, Toast.LENGTH_SHORT).apply {show()}}// 常规的,不取消之前的fun showToast2(context: Context?,content: String?,) {Toast.makeText(context,content,Toast.LENGTH_SHORT)!!.show()}
}

MainyActivity.kt

package lcppx.android.openlayers_composeimport android.annotation.SuppressLint
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.compose.rememberNavController
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import lcppx.android.openlayers_compose.navigation.CsvRoute
import lcppx.android.openlayers_compose.navigation.OLNavHost
import lcppx.android.openlayers_compose.ui.components.MapTopAppbar
import lcppx.android.openlayers_compose.screens.csvscreen.BrowseCsvFileScreen
import lcppx.android.openlayers_compose.ui.theme.OpenlayerscomposeTheme
import lcppx.android.openlayers_compose.util.CsvSaverTest
import lcppx.android.openlayers_compose.util.StoragePermissionUtils
import lcppx.android.openlayers_compose.util.ToastUtil@AndroidEntryPoint
class MainActivity : ComponentActivity() {@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")override fun onCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)val that = this // 保存当前 Activity 引用lifecycleScope.launch {// 生命周期处理:Activity启动的时候,检查并申请存储、或者所有文件访问权限lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {//ToastUtil.showToast(that,"start")// 请求存储权限、或者所有文件访问权限,用于保存 CSV 文件StoragePermissionUtils.requestStorageOrAllFilePermission(that)}}enableEdgeToEdge()setContent {val navController = rememberNavController()OpenlayerscomposeTheme {//CsvSaverTest(this)// 测试使用OLNavHost(navController = navController,)}}}
}

3、web端js、html、css实现(nodejs)

项目结构和核心代码:
在这里插入图片描述

3.1 web项目构建:

一般需要你先搭建 nodejs 运行环境,还有vite构建工具,此处不多赘述,相信看这个教程的你已经不是web小白。
然后运行 openlayers 推荐的项目基本模板:

npx create-ol-app my-app --template vite

测试的时候你可以使用如下命令:

    cd my-appnpm start

构建的时候你需要使用如下命令,此命令会打包项目到根目录的 dist目录下,这就是我们最终放置到Android端 assets 目录下的文件

3.3 js目录核心代码实现

1)externalAPI.js

/** 其他实验性api,比如调用 Android 原生代码** */// js 调用 安卓 kotlin 代码的示例
export function callAndroidMethods(str) {const lineCount = Android.getLinesCount();Android.showToast(`JavaScript:${lineCount}==>${str}`);
}// 向 安卓提供的 js 函数,由 安卓kotlin 调用
export function provideJsMethods() {return "你执行了Js函数的代码"
}// 发送所有行的坐标到 kotlin
// export function sendCoordinatesToKotlin(coordinates) {
//     // JavaScript中,我们通常使用JavaScript原生的数组(Array)来处理类似JSON数组的数据结构。
//     //const coordinates = [["element1", "element2", "element3"], ["element1", "element2", "element3"],];
//     const jsonArray = [];
//     coordinates.forEach(function (line) {
//         const lineArray = [];
//         line.forEach(function (coord) {
//             lineArray.put(coord);
//         });
//         jsonArray.put(lineArray);
//     });
// }
// 发送所有行的坐标到 kotlin
export function sendCoordinatesToKotlin(coordinates) {// 将parallelLinesCoordinates数组转换为JSON字符串const parallelLinesCoordinatesJson = JSON.stringify(coordinates);console.log(`测试转换后的json字符串为 ${parallelLinesCoordinatesJson}`)/** 测试结果记录:每一行有2个点的坐标测试转换后的json字符串为[[[114.05769097420503,22.54319555952454],[114.0601532420807,22.543603255294805]],[[114.05776434556483,22.542752435127806],[114.0602266134405,22.54316013089807]],[[114.05774967129288,22.542841060007156],[114.06021193916855,22.54324875577742]],[[114.05773499702092,22.542929684886502],[114.06019726489659,22.543337380656766]],[[114.05772032274895,22.54301830976585],[114.06018259062462,22.543426005536112]],[[114.057705648477,22.543106934645195],[114.06016791635267,22.54351463041546]],[[114.05767629993306,22.543284184403888],[114.06013856780874,22.54369188017415]],[[114.05766162566111,22.543372809283234],[114.06012389353678,22.543780505053498]],[[114.05764695138915,22.54346143416258],[114.06010921926482,22.543869129932844]],[[114.05763227711718,22.543550059041927],[114.06009454499285,22.54395775481219]],[[114.05761760284523,22.543638683921277],[114.0600798707209,22.54404637969154]]]*/Android.sendParallelLinesCoordinates(parallelLinesCoordinatesJson);
}

2)buttonEvents.js

/** 按钮事件处理* 定义按钮事件处理逻辑。* */import {fromLonLat} from 'ol/proj';
import {gaodeTileLayer, testTileLayer} from "./mapLayers";///=====================平移、旋转、缩放按钮事件=============================
// 定义常量
// const MOVE_STEP = 5200; // 移动步数,单位与地图视图坐标系一致
const MOVE_STEP = 5; // 移动步数,单位与地图视图坐标系一致,使用经纬度参考系需要使用较小的值
const ROTATION_ANGLE = 10; // 旋转角度,单位为度
const ZOOM_LEVEL_CHANGE = 1; // 缩放级别变化量// 获取这些定义在 html 里面的按钮列表
const btns = document.querySelectorAll(".btns button")
const moveTopButton = btns[0]; // 上移按钮
const moveBottomButton = btns[1]; // 下移按钮
const moveLeftButton = btns[2]; // 左移按钮
const moveRightButton = btns[3]; // 右移按钮
const rotateClockwiseButton = btns[4]; // 顺时针旋转按钮
const rotateCounterClockwiseButton = btns[5]; // 逆时针旋转按钮
const zoomInButton = btns[6]; // 放大按钮
const zoomOutButton = btns[7]; // 缩小按钮
const changeLayerButton = btns[8]; // 切换地图数据图层// 按钮事件处理
export function setupButtonEvents(map) {// 上移moveTopButton.onclick = () => {const view = map.getView()// todo 注意,使用经纬度参考坐标系,需修改移动步数。因为经纬度的范围会相对很小const viewCenter = view.getCenter()viewCenter[1] -= MOVE_STEP;view.setCenter(viewCenter);map.render();// 移动后触发地图重新渲染};// 下移moveBottomButton.onclick = () => {const view = map.getView()// todo 注意,使用经纬度参考坐标系,需修改移动步数。因为经纬度的范围会相对很小const viewCenter = view.getCenter()viewCenter[1] += MOVE_STEPview.setCenter(viewCenter)map.render()// 移动后触发地图重新渲染}// 左移moveLeftButton.onclick = () => {const view = map.getView()// todo 注意,使用经纬度参考坐标系,需修改移动步数。因为经纬度的范围会相对很小const viewCenter = view.getCenter()viewCenter[0] -= MOVE_STEPview.setCenter(viewCenter)map.render()// 移动后触发地图重新渲染}// 右移moveRightButton.onclick = () => {const view = map.getView()// todo 注意,使用经纬度参考坐标系,需修改移动步数。因为经纬度的范围会相对很小const viewCenter = view.getCenter()viewCenter[0] += MOVE_STEPview.setCenter(viewCenter)map.render()// 移动后触发地图重新渲染}// 顺时针旋转rotateClockwiseButton.onclick = () => {const view = map.getView()const rotation = view.getRotation() || 0; // 如果当前没有旋转,则默认为0view.setRotation(rotation + Math.PI / 180 * ROTATION_ANGLE); // 旋转10度map.render(); // 旋转后触发地图重新渲染};// 逆时针旋转rotateCounterClockwiseButton.onclick = () => {const view = map.getView()const rotation = view.getRotation() || 0; // 如果当前没有旋转,则默认为0view.setRotation(rotation - Math.PI / 180 * ROTATION_ANGLE); // 旋转-10度map.render(); // 旋转后触发地图重新渲染};// 放大zoomInButton.onclick = () => {const view = map.getView()const zoom = view.getZoom();view.setZoom(zoom + ZOOM_LEVEL_CHANGE); // 增加1级的缩放map.render(); // 缩放后触发地图重新渲染};// 缩小zoomOutButton.onclick = () => {const view = map.getView();const zoom = view.getZoom();view.setZoom(zoom - ZOOM_LEVEL_CHANGE); // 减少1级的缩放map.render(); // 缩放后触发地图重新渲染};changeLayerButton.onclick = () => {// 此处并未实现切换功能,只是简单提供切换到测试数据,因为我的高德地图居然在手机上无法演示运行map.removeLayer(gaodeTileLayer)map.addLayer(testTileLayer)map.render(); // 缩放后触发地图重新渲染};
}
///=====================平移、旋转、缩放按钮事件=============================

3)mapInteractions.js

/** 地图交互* 定义地图的交互行为,如平移、旋转、缩放。* */import {DragRotateAndZoom, defaults as defaultInteractions,} from 'ol/interaction';
import {FullScreen, defaults as defaultControls} from 'ol/control';// 添加【拖动、旋转、缩放】快捷交互(电脑上按住 shift + 鼠标操作)
const quickInteractions = defaultInteractions().extend([new DragRotateAndZoom()]);// 添加全屏控制(如果地图上没有按钮,则表示您的浏览器不支持全屏 API)
const controls = defaultControls().extend([new FullScreen()]);export {quickInteractions, controls};

4)mapLayers.js

/* 地图图层
* 定义和初始化各种地图图层,包括瓦片图层和矢量图层。
* 测试使用的 OSM瓦片地图、百度地图瓦片图层、高德地图瓦片图层
* */import {Tile as TileLayer, Vector as VectorLayer} from 'ol/layer';
import {OSM, ImageTile} from 'ol/source';
import {fromLonLat, get as getProj} from 'ol/proj';
import {TileGrid} from "ol/tilegrid";///=========================连接公共地图图层==========================
/// 1、测试使用的瓦片地图(此处使用OSM作为在线地图数据源进行测试),大部分情况可能需要科学上网
const testTileLayer = new TileLayer({source: new OSM()})/// 2、百度地图瓦片图层// todo 百度地图实在无法显示,不知道是否url已经被禁用...暂时发现百度瓦片地图真是一个坑待填...
// 百度瓦片图层对象//todo:暂时用不了...
const baiduTileLayer = new TileLayer({// 连接百度地图的瓦片地图数据源地址source: new ImageTile(//TileImage({projection: getProj("EPSG:3857"),// 设置为坐标参考系(而不是经纬度参考系)// 分辨率(瓦片网格)tileGrid: new TileGrid({origin: [0, 0],// 计算分辨率数组resolutions: Array.from({length: 19}, (_, i) => Math.pow(2, 18 - i))}),tileUrlFunction: function (tileCoord, pixelRadio, proj) {// 处理百度地图的瓦片地图请求地址,需要手动构造部分参数...const z = tileCoord[0];let x = tileCoord[1];let y = -tileCoord[2] - 1;if (x < 0) x = 'M' + (-x);if (y < 0) y = 'M' + (-y);return `http://online3.map.bdimg.com/onlinelabel/?qt=tile&x=${x}&y=${y}&z=${z}&styles=pl&udt=20151021&scaler=1&p=1`}})
})/// 3、高德地图瓦片图层。可用
const gaodeTileLayer = new TileLayer({// source: new XYZ( url),//todo 好像高版本的XYZ里面没有url这个了,所以此处改用 ImageTile 试试source: new ImageTile({url: "http://wprd0{1-4}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&style=7&x={x}&y={y}&z={z}",wrapX: false}),
})
///=========================连接公共地图图层==========================export {testTileLayer, baiduTileLayer, gaodeTileLayer};

5)vectorDrawing.js

/** 矢量图形绘制* 定义绘制点、线矢量图形的逻辑。* */import {Map, Feature} from 'ol';
import {Vector as VectorSource} from 'ol/source';
import {Tile as TileLayer, Vector as VectorLayer} from "ol/layer";
import {fromLonLat, get as getProj} from 'ol/proj';
// 图形相关
import {LineString, Point} from "ol/geom";
// 样式相关
import {Circle, Fill, Icon, Stroke, Style} from 'ol/style';
import {sendCoordinatesToKotlin} from "./helper/externalAPI";const vectorSource = new VectorSource({wrapX: false});
const vectorLayer = new VectorLayer({source: vectorSource,style: new Style({stroke: new Stroke({color: 'blue',width: 3})})
});/*** 在地图上绘制一个点要素,并最终添加到地图上* @param {Map} map - OpenLayers 地图实例* @param {Coordinate} coordinate - 点的坐标,格式为 [经度, 纬度]* @param {Style} style - 点的样式** @returns {Feature} 返回创建的点要素对象*/
function drawPointOnMap(map, coordinate, style) {console.log(`在地图上开始绘制点...coordinate = ${coordinate} ===》${fromLonLat(coordinate)}`)/** 绘制点要素的流程算法:* 1、通过“几何信息” 来 new 一个 Feature 对象* 2、为这个 Feature 对象设置样式*/// 1、创建一个点几何点对象// const pointGeometry = new Point(fromLonLat(coordinate));// 提示:不需要转换!const pointGeometry = new Point(coordinate);// 2、创建一个特征对象,包含几何对象和样式const pointFeature = new Feature({geometry: pointGeometry,// 可以添加其他属性,例如点的名称等name: 'Point'});// 3、设置该点的样式// 点的默认样式const defaultStyle = new Style({image: new Circle({radius: 6,// 半径// 填充颜色fill: new Fill({color: "#ff2d51"}),// 描边效果stroke: new Stroke({width: 1,color: "#233"})})});pointFeature.setStyle(!style ? defaultStyle : style);// 4、创建一个矢量源,并将要素添加到矢量数据源中:const vectorSource = new VectorSource({features: [pointFeature]});// 5、创建一个矢量图层,并设置矢量源const vectorLayer = new VectorLayer({source: vectorSource});// 6、将矢量图层添加到地图map.addLayer(vectorLayer);// 最后返回点要素对象//return pointFeature;return vectorLayer;
}// 创建一个数组来保存所有平行线的起点和终点坐标
const allParallelLinesCoordinates = [];/*** 在地图上绘制一条直线要素,并最终添加到地图上* @param {Map} map - OpenLayers 地图实例* @param {ol.Coordinate} coordinateA - 起点的坐标,格式为 [经度, 纬度]* @param {ol.Coordinate} coordinateB - 终点的坐标,格式为 [经度, 纬度]* @param {Style} style - 线的样式* @returns {Feature} 返回创建的线要素对象*/
function drawLineOnMap(map, coordinateA, coordinateB, style) {console.log(`在地图上开始绘制线...coordinateA = ${coordinateA}, coordinateB = ${coordinateB}`);// 1、创建一个线几何对象const lineGeometry = new LineString([coordinateA, coordinateB]);// 2、创建一个特征对象,包含几何对象和样式const lineFeature = new Feature({geometry: lineGeometry,// 可以添加其他属性,例如线的名称等name: 'Line'});// 3、设置该线的样式const defaultStyle = new Style({stroke: new Stroke({color: '#ff2d51', // 线的颜色width: 3 // 线的宽度})});lineFeature.setStyle(!style ? defaultStyle : style);// 4、创建一个矢量源,并将要素添加到矢量数据源中:const vectorSource = new VectorSource({features: [lineFeature]});// 5、创建一个矢量图层,并设置矢量源const vectorLayer = new VectorLayer({source: vectorSource});// 6、将矢量图层添加到地图map.addLayer(vectorLayer);// 最后返回线要素对象//return lineFeature;return vectorLayer;
}/*** 在AB线两侧绘制平行线* @param {Map} map - OpenLayers 地图实例* @param {ol.Coordinate} coordinateA - 起点的坐标,格式为 [经度, 纬度]* @param {ol.Coordinate} coordinateB - 终点的坐标,格式为 [经度, 纬度]* @param {number} lineCount - 平行线的数量* @param {number} lineSpacing - 平行线之间的间隔(单位:米)* @param {Style} style - 平行线的样式*/
function drawLineAndParallelLines(map,coordinateA, coordinateB,lineCount, lineSpacing,style
) {// 创建AB线的几何对象//const lineGeometry = new LineString([coordinateA, coordinateB]);console.log(`进入平行线绘制: \n coordinateA = ${coordinateA}\n coordinateB = ${coordinateB}`)// 计算AB线的方向向量const dx = coordinateB[0] - coordinateA[0];const dy = coordinateB[1] - coordinateA[1];// 计算AB线的长度const length = Math.sqrt(dx * dx + dy * dy);// 计算AB线的方向向量单位向量const unitVector = [dx / length, dy / length];// 计算垂直于AB线方向向量的单位向量(用于平行线的偏移)const perpendicularUnitVector = [-dy / length, dx / length];// 创建平行线的矢量源const parallelLinesSource = new VectorSource({features: []});// 获取当前参考系下每米的单位转换系数let metersPerUnit = map.getView().getProjection().getMetersPerUnit();// 计算平行线的起点和终点偏移坐标辅助函数function calculateOffsetCoordinate(coordinate, perpendicularUnitVector, offset) {return [coordinate[0] + perpendicularUnitVector[0] * offset,coordinate[1] + perpendicularUnitVector[1] * offset];}console.log(`每米对应的单位系数为: metersPerUnit = ${metersPerUnit}`)// 计算并添加所有平行线for (let i = -lineCount / 2; i <= lineCount / 2; i++) {if (i === 0) continue; // 跳过中间的AB线// 计算偏移量(将米转换成当前参考系的坐标值)const offset = i * (lineSpacing / metersPerUnit);console.log(`每米对应的单位系数为: offset = ${offset}`)// 计算平行线的起点和终点偏移坐标const startAOffset = calculateOffsetCoordinate(coordinateA, perpendicularUnitVector, offset);const endBOffset = calculateOffsetCoordinate(coordinateB, perpendicularUnitVector, offset);// 保存起点和终点坐标allParallelLinesCoordinates.push([startAOffset, endBOffset]);console.log(`当前循环:i = ${i}\n, 偏移起点 startAOffset=${startAOffset}\n, 偏移终点 endBOffset=${endBOffset}`)// 创建平行线的几何对象,传入起点、终点值const parallelLineGeometry = new LineString([startAOffset, endBOffset]);// 创建平行线的特征对象const parallelLineFeature = new Feature({geometry: parallelLineGeometry});// 设置平行线的样式parallelLineFeature.setStyle(style);// 将平行线特征添加到矢量源中parallelLinesSource.addFeature(parallelLineFeature);}// 创建平行线的矢量图层const parallelLinesLayer = new VectorLayer({source: parallelLinesSource});// 将平行线的矢量图层添加到地图map.addLayer(parallelLinesLayer);return parallelLinesLayer// 返回图层,方便后续删除等操作}///=========================需要绘制的矢量图层==========================
// 存储起点A、终点B 要素、AB直线
let startA, endB, lineString;
// 存储A、B坐标
let coordinateA, coordinateB
let parallelLinesLayer// 平行线图层export function drawVector(map) {// 单击地图,绘制起点A、终点B// 1、绘制点Amap.on('singleclick', function (evt) {// 如果点A不存在,才调用函数绘制点Aif (!startA) {console.log(`单击绘制起点A测试:${evt.coordinate} ---》${fromLonLat(evt.coordinate)}`)coordinateA = evt.coordinatestartA = drawPointOnMap(map, coordinateA, null);} else if (!endB) {console.log('单击绘制终点B测试')coordinateB = evt.coordinate// 新建一个特征要素,并传入要创建的图形(指定坐标上的点)endB = drawPointOnMap(map, coordinateB, null);// 绘制B点之后,绘制 AB直线console.log(`单击绘制AB直线测试:coordinateA=${coordinateA} ---》coordinateB=${coordinateB}`)drawLineOnMap(map, coordinateA, coordinateB, null)// 先保存 AB 直线的两个端点坐标allParallelLinesCoordinates.push([coordinateA, coordinateB])// 绘制AB直线之后,绘制平行线// 调试的时候,是无法使用安卓接口的!// let lineCount = 10// 平行线数量// let lineSpacing = 10// 平行线间距let lineCount = Android.getLinesCount()//10// 平行线数量let lineSpacing = Android.getLinesSpace()//10// 平行线间距//Android.showToast(`js端:lineCount = ${lineCount}\n==>lineSpacing = ${lineSpacing}`);// 绘制所有平行线,返回图层对象parallelLinesLayer = drawLineAndParallelLines(map, coordinateA, coordinateB, lineCount, lineSpacing, null);// 绘制完成,同时所有平行线坐标也记录完成,此时需要传给 Android 端保存sendCoordinatesToKotlin(allParallelLinesCoordinates)} else {// 否则将:// todo 也可以在此时 先删除旧的图形图层map.removeLayer(startA)map.removeLayer(endB)map.removeLayer(parallelLinesLayer)// 重置startA = endB = null// 并开始重新绘制点 AcoordinateA = evt.coordinatestartA = drawPointOnMap(map, coordinateA, null);}});// 计算平行线的功能function offsetLine(line, distance, side, lineCoordinates) {const coords = line.getCoordinates();const coord = coords[0];const nextCoord = coords[1];const dx = nextCoord[0] - coord[0];const dy = nextCoord[1] - coord[1];const length = Math.sqrt(dx * dx + dy * dy);const offsetX = (dy / length) * distance;const offsetY = -(dx / length) * distance;const offsetCoords = coords.map((coord, index) => {console.log('Line coordinates:', offsetX, offsetY, ol.proj.toLonLat([coord[0] + offsetX, coord[1] + offsetY]),);if (side === 'left') {return [coord[0] + offsetX, coord[1] + offsetY];} else {return [coord[0] - offsetX, coord[1] - offsetY];}});console.log('Line coordinates:', offsetCoords, "start", ol.proj.toLonLat(offsetCoords[0]), "end", ol.proj.toLonLat(offsetCoords[1]));lineCoordinates.push({start: ol.proj.toLonLat(offsetCoords[0]), end: ol.proj.toLonLat(offsetCoords[1])});return new LineString(offsetCoords);}// 绘制线条的函数function drawLines(lonA, latA, lonB, latB, numLines, distanceBetweenLines) {const pointA = ol.proj.fromLonLat([lonA, latA]);const pointB = ol.proj.fromLonLat([lonB, latB]);const mainLine = new LineString([pointA, pointB]);const vectorSource = new ol.source.Vector();let lineCoordinates = [];// 循环创建平行线for (let i = 1; i <= numLines; i++) {const offset = i * distanceBetweenLines;// 偏移左侧线const leftLine = offsetLine(mainLine, offset, 'left', lineCoordinates);const leftFeature = new Feature(leftLine);vectorSource.addFeature(leftFeature);// 偏移右侧线const rightLine = offsetLine(mainLine, offset, 'right', lineCoordinates);const rightFeature = new Feature(rightLine);vectorSource.addFeature(rightFeature);}// 主线const mainFeature = new Feature(mainLine);vectorSource.addFeature(mainFeature);// 创建矢量图层const vectorLayer = new ol.layer.Vector({source: vectorSource,style: new Style({stroke: new Stroke({color: '#ffcc33',width: 2})})});map.setLayers([vectorLayer])// 返回线条的坐标return lineCoordinates;}// 初始化并调用 drawLines 函数function initDrawLines() {const latA = 39.9087;const lonA = 116.3974;const latB = 40;const lonB = 110;const numLines = 6;const distanceBetweenLines = 10;const coordinates = drawLines(lonA, latA, lonB, latB, numLines, distanceBetweenLines);console.log('Line coordinates:', coordinates);}
}==========================绘制点、线矢量图形==========================export {vectorLayer, startA, endB, lineString};

3.3 根目录核心js、html、css代码实现

1)index.html

<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><title>OpenLayers测试</title><link rel="stylesheet" href="node_modules/ol/ol.css"></head><body><!-- 地图 Map 挂载在这个视图上 --><div id="map"></div><!-- 添加一组控制 旋转、平移、缩放 的自定义按钮进行测试--><div class="btns"><button onclick="move2Top()">上移</button><button onclick="move2Bottom()">下移</button><button onclick="move2Left()">左移</button><button onclick="move2Right()">右移</button><button onclick="">顺时针旋转</button><button onclick="">逆时针旋转</button><button onclick="">放大</button><button onclick="">缩小</button><button onclick="">切换数据源(OSM或高德)</button></div><script type="module" src="main.js"></script></body>
</html>

2)main.js


import './style.css';import {testTileLayer, baiduTileLayer, gaodeTileLayer} from './js/mapLayers.js';
import {vectorLayer, startA, endB, lineString} from './js/vectorDrawing.js';
import {quickInteractions, controls} from './js/mapInteractions.js';
import {setupButtonEvents} from './js/buttonEvents.js';
import {drawVector} from './js/vectorDrawing.js';
import {callAndroidMethods, sendCoordinatesToKotlin} from './js/helper/externalAPI.js';
import {Map, View} from 'ol';
import {fromLonLat} from "ol/proj";
import {Tile as TileLayer} from "ol/layer";
import {OSM} from "ol/source";///==========================地图对象配置==========================
// 定义深圳的经纬度常量
const SHENZHEN_COORDINATE = [114.057868, 22.543099];const map = new Map({/// 1、图层://  - 瓦片地图,用于显示地图//  - 矢量图层,用于绘制矢量图形layers: [// testTileLayer,// 测试的瓦片地图图层,一般需要科学上网...// baiduTileLayer,// 百度// todo 暂时测试不通过:无法使用!gaodeTileLayer,// 高德//vectorLayer,// 要绘制的矢量图图层,在下面动态添加],/// 2、挂载在 id 为 ‘map’ 的视图上target: 'map',/// 3、视图对象,用于控制地图的中心、缩放级别和投影等内容//  - 中心点经纬度,需要转换成投影坐标值//  - 初始缩放级别//  - 投影体系:默认是 EPSG:3857 ,如果设置为 EPSG:4326 (经纬度体系) 就不需要 fromLonLat 进行转换view: new View({// projection: "EPSG:3857",// 此处依然使用默认值,一般使用默认值,就需要 fromLonLat 函数转换(否则可能导致地图无法显示)// center: fromLonLat(SHENZHEN_COORDINATE), // 深圳的经纬度,此处使用fromLonLat将 经纬度值 转换为 地图投影坐标projection: "EPSG:4326",// 此处(经纬度体系)center: SHENZHEN_COORDINATE, // 深圳的经纬度,需要使用 "EPSG:4326" 经纬度参考系zoom: 18,// 因为涉及米单位间距,地图太小了无法进行测试(10米在地图上是非常小的)}),// todo 警告:我在使用的时候,发现电脑上两个图标重叠或者没有重置旋转按钮(重置旋转和全屏图标),但是在openlayers官网示例并不重叠!// 添加全屏控制(如果地图上没有按钮,则表示您的浏览器不支持全屏 API)//controls: controls,// 添加【拖动、旋转、缩放】快捷交互(电脑上按住 shift + 鼠标操作)interactions: quickInteractions,
});// 绘制矢量图形
drawVector(map);// 按钮事件处理
setupButtonEvents(map);// 调用Android 代码测试
// callAndroidMethods();

style.css

@import "node_modules/ol/ol.css";html, body {margin: 0;width: 100%;height: 100vh;
}#map {position: absolute;top: 0;bottom: 0;width: 100%;/* todo 我测试的过程中,给定确定的高度(不能是比例之类的),否则在安卓上高度拿不到...*/height: 600px;/* 100%!important;bug 提示:不设置地图高度,可能会导致在安卓上无法显示!*/
}.btns {position: fixed;display: flex; /* 使用 Flexbox 布局 */flex-direction: column; /* 设置为列布局,即竖向排列 */align-items: center; /* 居中对齐 */padding: 20px; /* 增加一些内边距 */
}.btns button {margin: 5px; /* 按钮之间的间距 */padding: 10px; /* 按钮内部的填充 */cursor: pointer; /* 鼠标悬停时显示指针 */
}

package.json

{"name": "my-app","version": "1.0.0","scripts": {"start": "vite","build": "vite build","serve": "vite preview"},"devDependencies": {"vite": "^5.4.10"},"dependencies": {"ol": "10.2.1"}
}

最后:

在这里插入图片描述


http://www.ppmy.cn/devtools/133392.html

相关文章

Springboot启动流程之ApplicationContext 创建

在文章 Springboot3.3.5 启动流程&#xff08;源码分析&#xff09; 中介绍了关键流程&#xff0c;本文详细介绍 AnnotationConfigServletWebServerApplicationContext 的创建。 备注&#xff1a; 本文未作任何申明时&#xff0c;默认 springboot 版本为 3.3.5 AnnotationCon…

在Photoshop中填充图层颜色

一、使用油漆桶工具 选择油漆桶工具&#xff1a;在工具栏中找到并选择油漆桶工具&#xff08;快捷键G&#xff09;。选择图层&#xff1a;在图层面板中选择你想要填充颜色的图层。设置填充属性&#xff1a;在工具选项栏中&#xff0c;可以选择合适的填充模式、不透明度和容差值…

WPF+MVVM案例实战与特效(二十八)- 自定义WPF ComboBox样式:打造个性化下拉菜单

文章目录 1. 引言案例效果3. ComboBox 基础4. 自定义 ComboBox 样式4.1 定义 ComboBox 样式4.2 定义 ComboBoxItem 样式4.3 定义 ToggleButton 样式4.4 定义 Popup 样式5. 示例代码6. 结论1. 引言 在WPF应用程序中,ComboBox控件是一个常用的输入控件,用于从多个选项中选择一…

没有数据库也能用 SQL

手头有些 csv/xls 文件&#xff0c;比如这样的&#xff1a; 这种数据很适合用 SQL 做查询&#xff0c;但可惜 SQL 只能用在数据库&#xff0c;要安装个数据库并把这些文件导入&#xff0c;为这么个目标搞的整个应用系统都臃肿很多&#xff0c;实在是划不来。要是有什么技术能直…

代码随想录刷题记录(二十七)——55. 右旋字符串

&#xff08;一&#xff09;问题描述 55. 右旋字符串&#xff08;第八期模拟笔试&#xff09;https://kamacoder.com/problempage.php?pid1065字符串的右旋转操作是把字符串尾部的若干个字符转移到字符串的前面。给定一个字符串 s 和一个正整数 k&#xff0c;请编写一个函数&…

JavaScript如何操作HTML:动态网页构建指南

JavaScript如何操作HTML&#xff1a;动态网页构建指南 在现代网页开发中&#xff0c;JavaScript不仅是实现网页交互性的关键技术&#xff0c;也是动态操作HTML文档对象模型&#xff08;DOM&#xff09;的重要工具。通过JavaScript&#xff0c;开发者可以在运行时修改网页的内容…

[C++]——位图与布隆过滤器

目录 一、前言 二、正文 1.位图 1.1 位图概念 1.2 位图的实现 1.2.1 Set 1.2.2 ReSet 1.2.3 Text 1.3 位图的应用 2.布隆过滤器 2.1布隆过滤器的提出 2.2 布隆过滤器概念 2.3 布隆过滤器的实现 2.3.1布隆过滤器的插入 2.3.2 布隆过滤器的查找 2.3.3 布隆过滤器…

021_SSH_Mysql校园播客系统(视频播放 评论)_lwplus87

摘 要 Internet是一个蕴藏着无穷资源的宝库&#xff0c;在资源共享和信息交换方面具有得天独厚的优势。21世纪的今天&#xff0c;上网已经成为很多人工作、生活中必不可少的一部分&#xff0c;这很大程度上是由于网页承载了任何一种媒介都无法比拟的丰富资源&#xff0c;网页…