remoteMapConfig.ts 17 KB

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