remoteMapConfig.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463
  1. import { lonLatToWorldTile, webMercatorToLonLat, type LonLatPoint } from './projection'
  2. import { parseOrienteeringCourseKml, type OrienteeringCourseData } from './orienteeringCourse'
  3. export interface TileZoomBounds {
  4. minX: number
  5. maxX: number
  6. minY: number
  7. maxY: number
  8. }
  9. export interface RemoteMapConfig {
  10. tileSource: string
  11. minZoom: number
  12. maxZoom: number
  13. defaultZoom: number
  14. initialCenterTileX: number
  15. initialCenterTileY: number
  16. projection: string
  17. projectionModeText: string
  18. magneticDeclinationDeg: number
  19. magneticDeclinationText: string
  20. tileFormat: string
  21. tileSize: number
  22. bounds: [number, number, number, number] | null
  23. tileBoundsByZoom: Record<number, TileZoomBounds>
  24. mapMetaUrl: string
  25. mapRootUrl: string
  26. courseUrl: string | null
  27. course: OrienteeringCourseData | null
  28. courseStatusText: string
  29. cpRadiusMeters: number
  30. }
  31. interface ParsedGameConfig {
  32. mapRoot: string
  33. mapMeta: string
  34. course: string | null
  35. cpRadiusMeters: number
  36. declinationDeg: number
  37. }
  38. interface ParsedMapMeta {
  39. tileSize: number
  40. minZoom: number
  41. maxZoom: number
  42. projection: string
  43. tileFormat: string
  44. tilePathTemplate: string
  45. bounds: [number, number, number, number] | null
  46. }
  47. function requestTextViaRequest(url: string): Promise<string> {
  48. return new Promise((resolve, reject) => {
  49. wx.request({
  50. url,
  51. method: 'GET',
  52. responseType: 'text' as any,
  53. success: (response) => {
  54. if (response.statusCode !== 200) {
  55. reject(new Error(`request失败: ${response.statusCode} ${url}`))
  56. return
  57. }
  58. if (typeof response.data === 'string') {
  59. resolve(response.data)
  60. return
  61. }
  62. resolve(JSON.stringify(response.data))
  63. },
  64. fail: () => {
  65. reject(new Error(`request失败: ${url}`))
  66. },
  67. })
  68. })
  69. }
  70. function requestTextViaDownload(url: string): Promise<string> {
  71. return new Promise((resolve, reject) => {
  72. const fileSystemManager = wx.getFileSystemManager()
  73. wx.downloadFile({
  74. url,
  75. success: (response) => {
  76. if (response.statusCode !== 200 || !response.tempFilePath) {
  77. reject(new Error(`download失败: ${response.statusCode} ${url}`))
  78. return
  79. }
  80. fileSystemManager.readFile({
  81. filePath: response.tempFilePath,
  82. encoding: 'utf8',
  83. success: (readResult) => {
  84. if (typeof readResult.data === 'string') {
  85. resolve(readResult.data)
  86. return
  87. }
  88. reject(new Error(`read失败: ${url}`))
  89. },
  90. fail: () => {
  91. reject(new Error(`read失败: ${url}`))
  92. },
  93. })
  94. },
  95. fail: () => {
  96. reject(new Error(`download失败: ${url}`))
  97. },
  98. })
  99. })
  100. }
  101. async function requestText(url: string): Promise<string> {
  102. try {
  103. return await requestTextViaRequest(url)
  104. } catch (requestError) {
  105. try {
  106. return await requestTextViaDownload(url)
  107. } catch (downloadError) {
  108. const requestMessage = requestError instanceof Error ? requestError.message : 'request失败'
  109. const downloadMessage = downloadError instanceof Error ? downloadError.message : 'download失败'
  110. throw new Error(`${requestMessage}; ${downloadMessage}`)
  111. }
  112. }
  113. }
  114. function clamp(value: number, min: number, max: number): number {
  115. return Math.max(min, Math.min(max, value))
  116. }
  117. function resolveUrl(baseUrl: string, relativePath: string): string {
  118. if (/^https?:\/\//i.test(relativePath)) {
  119. return relativePath
  120. }
  121. const originMatch = baseUrl.match(/^(https?:\/\/[^/]+)/i)
  122. const origin = originMatch ? originMatch[1] : ''
  123. if (relativePath.startsWith('/')) {
  124. return `${origin}${relativePath}`
  125. }
  126. const baseDir = baseUrl.slice(0, baseUrl.lastIndexOf('/') + 1)
  127. const normalizedRelativePath = relativePath.replace(/^\.\//, '')
  128. return `${baseDir}${normalizedRelativePath}`
  129. }
  130. function formatDeclinationText(declinationDeg: number): string {
  131. const suffix = declinationDeg < 0 ? 'W' : 'E'
  132. return `${Math.abs(declinationDeg).toFixed(2)}° ${suffix}`
  133. }
  134. function parseDeclinationValue(rawValue: unknown): number {
  135. const numericValue = Number(rawValue)
  136. return Number.isFinite(numericValue) ? -Math.abs(numericValue) : -6.91
  137. }
  138. function parsePositiveNumber(rawValue: unknown, fallbackValue: number): number {
  139. const numericValue = Number(rawValue)
  140. return Number.isFinite(numericValue) && numericValue > 0 ? numericValue : fallbackValue
  141. }
  142. function parseLooseJsonObject(text: string): Record<string, unknown> {
  143. const parsed: Record<string, unknown> = {}
  144. const pairPattern = /"([^"]+)"\s*:\s*("([^"]*)"|-?\d+(?:\.\d+)?|true|false|null)/g
  145. let match: RegExpExecArray | null
  146. while ((match = pairPattern.exec(text))) {
  147. const rawValue = match[2]
  148. let value: unknown = rawValue
  149. if (rawValue === 'true' || rawValue === 'false') {
  150. value = rawValue === 'true'
  151. } else if (rawValue === 'null') {
  152. value = null
  153. } else if (rawValue.startsWith('"') && rawValue.endsWith('"')) {
  154. value = match[3] || ''
  155. } else {
  156. const numericValue = Number(rawValue)
  157. value = Number.isFinite(numericValue) ? numericValue : rawValue
  158. }
  159. parsed[match[1]] = value
  160. }
  161. return parsed
  162. }
  163. function parseGameConfigFromJson(text: string): ParsedGameConfig {
  164. let parsed: Record<string, unknown>
  165. try {
  166. parsed = JSON.parse(text)
  167. } catch {
  168. parsed = parseLooseJsonObject(text)
  169. }
  170. const normalized: Record<string, unknown> = {}
  171. const keys = Object.keys(parsed)
  172. for (const key of keys) {
  173. normalized[key.toLowerCase()] = parsed[key]
  174. }
  175. const mapRoot = typeof normalized.map === 'string' ? normalized.map : ''
  176. const mapMeta = typeof normalized.mapmeta === 'string' ? normalized.mapmeta : ''
  177. if (!mapRoot || !mapMeta) {
  178. throw new Error('game.json 缺少 map 或 mapmeta 字段')
  179. }
  180. return {
  181. mapRoot,
  182. mapMeta,
  183. course: typeof normalized.course === 'string' ? normalized.course : null,
  184. cpRadiusMeters: parsePositiveNumber(normalized.cpradius, 5),
  185. declinationDeg: parseDeclinationValue(normalized.declination),
  186. }
  187. }
  188. function parseGameConfigFromYaml(text: string): ParsedGameConfig {
  189. const config: Record<string, string> = {}
  190. const lines = text.split(/\r?\n/)
  191. for (const rawLine of lines) {
  192. const line = rawLine.trim()
  193. if (!line || line.startsWith('#')) {
  194. continue
  195. }
  196. const match = line.match(/^([A-Za-z0-9_-]+)\s*(?:=|:)\s*(.+)$/)
  197. if (!match) {
  198. continue
  199. }
  200. config[match[1].trim().toLowerCase()] = match[2].trim()
  201. }
  202. const mapRoot = config.map
  203. const mapMeta = config.mapmeta
  204. if (!mapRoot || !mapMeta) {
  205. throw new Error('game.yaml 缺少 map 或 mapmeta 字段')
  206. }
  207. return {
  208. mapRoot,
  209. mapMeta,
  210. course: typeof config.course === 'string' ? config.course : null,
  211. cpRadiusMeters: parsePositiveNumber(config.cpradius, 5),
  212. declinationDeg: parseDeclinationValue(config.declination),
  213. }
  214. }
  215. function parseGameConfig(text: string, gameConfigUrl: string): ParsedGameConfig {
  216. const trimmedText = text.trim()
  217. const isJson =
  218. trimmedText.startsWith('{') ||
  219. trimmedText.startsWith('[') ||
  220. /\.json(?:[?#].*)?$/i.test(gameConfigUrl)
  221. return isJson ? parseGameConfigFromJson(trimmedText) : parseGameConfigFromYaml(trimmedText)
  222. }
  223. function extractStringField(text: string, key: string): string | null {
  224. const pattern = new RegExp(`"${key}"\\s*:\\s*"([^"]+)"`)
  225. const match = text.match(pattern)
  226. return match ? match[1] : null
  227. }
  228. function extractNumberField(text: string, key: string): number | null {
  229. const pattern = new RegExp(`"${key}"\\s*:\\s*(-?\\d+(?:\\.\\d+)?)`)
  230. const match = text.match(pattern)
  231. if (!match) {
  232. return null
  233. }
  234. const value = Number(match[1])
  235. return Number.isFinite(value) ? value : null
  236. }
  237. function extractNumberArrayField(text: string, key: string): number[] | null {
  238. const pattern = new RegExp(`"${key}"\\s*:\\s*\\[([^\\]]+)\\]`)
  239. const match = text.match(pattern)
  240. if (!match) {
  241. return null
  242. }
  243. const numberMatches = match[1].match(/-?\d+(?:\.\d+)?/g)
  244. if (!numberMatches || !numberMatches.length) {
  245. return null
  246. }
  247. const values = numberMatches
  248. .map((item) => Number(item))
  249. .filter((item) => Number.isFinite(item))
  250. return values.length ? values : null
  251. }
  252. function parseMapMeta(text: string): ParsedMapMeta {
  253. const tileSizeField = extractNumberField(text, 'tileSize')
  254. const tileSize = tileSizeField === null ? 256 : tileSizeField
  255. const minZoom = extractNumberField(text, 'minZoom')
  256. const maxZoom = extractNumberField(text, 'maxZoom')
  257. const projectionField = extractStringField(text, 'projection')
  258. const projection = projectionField === null ? 'EPSG:3857' : projectionField
  259. const tilePathTemplate = extractStringField(text, 'tilePathTemplate')
  260. const tileFormatFromField = extractStringField(text, 'tileFormat')
  261. const boundsValues = extractNumberArrayField(text, 'bounds')
  262. if (!Number.isFinite(minZoom) || !Number.isFinite(maxZoom) || !tilePathTemplate) {
  263. throw new Error('meta.json 缺少必要字段')
  264. }
  265. let tileFormat = tileFormatFromField || ''
  266. if (!tileFormat) {
  267. const extensionMatch = tilePathTemplate.match(/\.([A-Za-z0-9]+)$/)
  268. tileFormat = extensionMatch ? extensionMatch[1].toLowerCase() : 'png'
  269. }
  270. return {
  271. tileSize,
  272. minZoom: minZoom as number,
  273. maxZoom: maxZoom as number,
  274. projection,
  275. tileFormat,
  276. tilePathTemplate,
  277. bounds: boundsValues && boundsValues.length >= 4
  278. ? [boundsValues[0], boundsValues[1], boundsValues[2], boundsValues[3]]
  279. : null,
  280. }
  281. }
  282. function getBoundsCorners(
  283. bounds: [number, number, number, number],
  284. projection: string,
  285. ): { northWest: LonLatPoint; southEast: LonLatPoint; center: LonLatPoint } {
  286. if (projection === 'EPSG:3857') {
  287. const minX = bounds[0]
  288. const minY = bounds[1]
  289. const maxX = bounds[2]
  290. const maxY = bounds[3]
  291. return {
  292. northWest: webMercatorToLonLat({ x: minX, y: maxY }),
  293. southEast: webMercatorToLonLat({ x: maxX, y: minY }),
  294. center: webMercatorToLonLat({ x: (minX + maxX) / 2, y: (minY + maxY) / 2 }),
  295. }
  296. }
  297. if (projection === 'EPSG:4326') {
  298. const minLon = bounds[0]
  299. const minLat = bounds[1]
  300. const maxLon = bounds[2]
  301. const maxLat = bounds[3]
  302. return {
  303. northWest: { lon: minLon, lat: maxLat },
  304. southEast: { lon: maxLon, lat: minLat },
  305. center: { lon: (minLon + maxLon) / 2, lat: (minLat + maxLat) / 2 },
  306. }
  307. }
  308. throw new Error(`暂不支持的投影: ${projection}`)
  309. }
  310. function buildTileBoundsByZoom(
  311. bounds: [number, number, number, number] | null,
  312. projection: string,
  313. minZoom: number,
  314. maxZoom: number,
  315. ): Record<number, TileZoomBounds> {
  316. const boundsByZoom: Record<number, TileZoomBounds> = {}
  317. if (!bounds) {
  318. return boundsByZoom
  319. }
  320. const corners = getBoundsCorners(bounds, projection)
  321. for (let zoom = minZoom; zoom <= maxZoom; zoom += 1) {
  322. const northWestWorld = lonLatToWorldTile(corners.northWest, zoom)
  323. const southEastWorld = lonLatToWorldTile(corners.southEast, zoom)
  324. const minX = Math.floor(Math.min(northWestWorld.x, southEastWorld.x))
  325. const maxX = Math.ceil(Math.max(northWestWorld.x, southEastWorld.x)) - 1
  326. const minY = Math.floor(Math.min(northWestWorld.y, southEastWorld.y))
  327. const maxY = Math.ceil(Math.max(northWestWorld.y, southEastWorld.y)) - 1
  328. boundsByZoom[zoom] = {
  329. minX,
  330. maxX,
  331. minY,
  332. maxY,
  333. }
  334. }
  335. return boundsByZoom
  336. }
  337. function getProjectionModeText(projection: string): string {
  338. return `${projection} -> XYZ Tile -> Camera -> Screen`
  339. }
  340. export function isTileWithinBounds(
  341. tileBoundsByZoom: Record<number, TileZoomBounds> | null | undefined,
  342. zoom: number,
  343. x: number,
  344. y: number,
  345. ): boolean {
  346. if (!tileBoundsByZoom) {
  347. return true
  348. }
  349. const bounds = tileBoundsByZoom[zoom]
  350. if (!bounds) {
  351. return true
  352. }
  353. return x >= bounds.minX && x <= bounds.maxX && y >= bounds.minY && y <= bounds.maxY
  354. }
  355. export async function loadRemoteMapConfig(gameConfigUrl: string): Promise<RemoteMapConfig> {
  356. const gameConfigText = await requestText(gameConfigUrl)
  357. const gameConfig = parseGameConfig(gameConfigText, gameConfigUrl)
  358. const mapMetaUrl = resolveUrl(gameConfigUrl, gameConfig.mapMeta)
  359. const mapRootUrl = resolveUrl(gameConfigUrl, gameConfig.mapRoot)
  360. const courseUrl = gameConfig.course ? resolveUrl(gameConfigUrl, gameConfig.course) : null
  361. const mapMetaText = await requestText(mapMetaUrl)
  362. const mapMeta = parseMapMeta(mapMetaText)
  363. let course: OrienteeringCourseData | null = null
  364. let courseStatusText = courseUrl ? '路线待加载' : '未配置路线'
  365. if (courseUrl) {
  366. try {
  367. const courseText = await requestText(courseUrl)
  368. course = parseOrienteeringCourseKml(courseText)
  369. courseStatusText = `路线已载入 (${course.layers.controls.length} controls)`
  370. } catch (error) {
  371. const message = error instanceof Error ? error.message : '未知错误'
  372. courseStatusText = `路线加载失败: ${message}`
  373. }
  374. }
  375. const defaultZoom = clamp(17, mapMeta.minZoom, mapMeta.maxZoom)
  376. const boundsCorners = mapMeta.bounds ? getBoundsCorners(mapMeta.bounds, mapMeta.projection) : null
  377. const centerWorldTile = boundsCorners
  378. ? lonLatToWorldTile(boundsCorners.center, defaultZoom)
  379. : { x: 0, y: 0 }
  380. return {
  381. tileSource: resolveUrl(mapRootUrl, mapMeta.tilePathTemplate),
  382. minZoom: mapMeta.minZoom,
  383. maxZoom: mapMeta.maxZoom,
  384. defaultZoom,
  385. initialCenterTileX: Math.round(centerWorldTile.x),
  386. initialCenterTileY: Math.round(centerWorldTile.y),
  387. projection: mapMeta.projection,
  388. projectionModeText: getProjectionModeText(mapMeta.projection),
  389. magneticDeclinationDeg: gameConfig.declinationDeg,
  390. magneticDeclinationText: formatDeclinationText(gameConfig.declinationDeg),
  391. tileFormat: mapMeta.tileFormat,
  392. tileSize: mapMeta.tileSize,
  393. bounds: mapMeta.bounds,
  394. tileBoundsByZoom: buildTileBoundsByZoom(mapMeta.bounds, mapMeta.projection, mapMeta.minZoom, mapMeta.maxZoom),
  395. mapMetaUrl,
  396. mapRootUrl,
  397. courseUrl,
  398. course,
  399. courseStatusText,
  400. cpRadiusMeters: gameConfig.cpRadiusMeters,
  401. }
  402. }