prepareMapPreview.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482
  1. import { type BackendPreviewSummary } from './backendApi'
  2. import { lonLatToWorldTile, type LonLatPoint } from './projection'
  3. import { isTileWithinBounds, type RemoteMapConfig } from './remoteMapConfig'
  4. import { buildTileUrl } from './tile'
  5. export interface PreparePreviewTile {
  6. url: string
  7. x: number
  8. y: number
  9. leftPx: number
  10. topPx: number
  11. sizePx: number
  12. }
  13. export interface PreparePreviewControl {
  14. kind: 'start' | 'control' | 'finish'
  15. label: string
  16. x: number
  17. y: number
  18. }
  19. export interface PreparePreviewLeg {
  20. fromX: number
  21. fromY: number
  22. toX: number
  23. toY: number
  24. }
  25. export interface PreparePreviewScene {
  26. width: number
  27. height: number
  28. zoom: number
  29. tiles: PreparePreviewTile[]
  30. controls: PreparePreviewControl[]
  31. legs: PreparePreviewLeg[]
  32. overlayAvailable: boolean
  33. }
  34. interface PreviewPointSeed {
  35. kind: 'start' | 'control' | 'finish'
  36. label: string
  37. point: LonLatPoint
  38. }
  39. function resolvePreviewTileTemplate(tileBaseUrl: string): string {
  40. if (tileBaseUrl.indexOf('{z}') >= 0 && tileBaseUrl.indexOf('{x}') >= 0 && tileBaseUrl.indexOf('{y}') >= 0) {
  41. return tileBaseUrl
  42. }
  43. const normalizedBase = tileBaseUrl.replace(/\/+$/, '')
  44. return `${normalizedBase}/{z}/{x}/{y}.png`
  45. }
  46. function clamp(value: number, min: number, max: number): number {
  47. return Math.max(min, Math.min(max, value))
  48. }
  49. function collectCoursePoints(config: RemoteMapConfig): LonLatPoint[] {
  50. if (!config.course) {
  51. return []
  52. }
  53. const points: LonLatPoint[] = []
  54. config.course.layers.starts.forEach((item) => {
  55. points.push(item.point)
  56. })
  57. config.course.layers.controls.forEach((item) => {
  58. points.push(item.point)
  59. })
  60. config.course.layers.finishes.forEach((item) => {
  61. points.push(item.point)
  62. })
  63. return points
  64. }
  65. function collectPreviewPointSeeds(items: Array<{
  66. kind?: string | null
  67. label?: string | null
  68. lon?: number | null
  69. lat?: number | null
  70. }>): PreviewPointSeed[] {
  71. const seeds: PreviewPointSeed[] = []
  72. items.forEach((item, index) => {
  73. if (typeof item.lon !== 'number' || typeof item.lat !== 'number') {
  74. return
  75. }
  76. const kind = item.kind === 'start' || item.kind === 'finish' ? item.kind : 'control'
  77. seeds.push({
  78. kind,
  79. label: item.label || String(index + 1),
  80. point: {
  81. lon: item.lon,
  82. lat: item.lat,
  83. },
  84. })
  85. })
  86. return seeds
  87. }
  88. function computePointBounds(points: LonLatPoint[]): { minLon: number; minLat: number; maxLon: number; maxLat: number } | null {
  89. if (!points.length) {
  90. return null
  91. }
  92. let minLon = points[0].lon
  93. let maxLon = points[0].lon
  94. let minLat = points[0].lat
  95. let maxLat = points[0].lat
  96. points.forEach((point) => {
  97. minLon = Math.min(minLon, point.lon)
  98. maxLon = Math.max(maxLon, point.lon)
  99. minLat = Math.min(minLat, point.lat)
  100. maxLat = Math.max(maxLat, point.lat)
  101. })
  102. return {
  103. minLon,
  104. minLat,
  105. maxLon,
  106. maxLat,
  107. }
  108. }
  109. function resolvePreviewZoom(config: RemoteMapConfig, width: number, height: number, points: LonLatPoint[]): number {
  110. const upperZoom = clamp(config.defaultZoom > 0 ? config.defaultZoom : config.maxZoom, config.minZoom, config.maxZoom)
  111. if (!points.length) {
  112. return clamp(upperZoom - 1, config.minZoom, config.maxZoom)
  113. }
  114. const bounds = computePointBounds(points)
  115. if (!bounds) {
  116. return clamp(upperZoom - 1, config.minZoom, config.maxZoom)
  117. }
  118. let fittedZoom = config.minZoom
  119. for (let zoom = upperZoom; zoom >= config.minZoom; zoom -= 1) {
  120. const northWest = lonLatToWorldTile({ lon: bounds.minLon, lat: bounds.maxLat }, zoom)
  121. const southEast = lonLatToWorldTile({ lon: bounds.maxLon, lat: bounds.minLat }, zoom)
  122. const widthPx = Math.abs(southEast.x - northWest.x) * config.tileSize
  123. const heightPx = Math.abs(southEast.y - northWest.y) * config.tileSize
  124. if (widthPx <= width * 0.9 && heightPx <= height * 0.9) {
  125. fittedZoom = zoom
  126. break
  127. }
  128. }
  129. return clamp(fittedZoom, config.minZoom, config.maxZoom)
  130. }
  131. function resolvePreviewCenter(config: RemoteMapConfig, zoom: number, points: LonLatPoint[]): { x: number; y: number } {
  132. const bounds = computePointBounds(points)
  133. if (bounds) {
  134. const center = lonLatToWorldTile(
  135. {
  136. lon: (bounds.minLon + bounds.maxLon) / 2,
  137. lat: (bounds.minLat + bounds.maxLat) / 2,
  138. },
  139. zoom,
  140. )
  141. return {
  142. x: center.x,
  143. y: center.y,
  144. }
  145. }
  146. return {
  147. x: config.initialCenterTileX,
  148. y: config.initialCenterTileY,
  149. }
  150. }
  151. function buildPreviewTiles(
  152. config: RemoteMapConfig,
  153. zoom: number,
  154. width: number,
  155. height: number,
  156. centerWorldX: number,
  157. centerWorldY: number,
  158. ): PreparePreviewTile[] {
  159. const halfWidthInTiles = width / 2 / config.tileSize
  160. const halfHeightInTiles = height / 2 / config.tileSize
  161. const minTileX = Math.floor(centerWorldX - halfWidthInTiles) - 1
  162. const maxTileX = Math.ceil(centerWorldX + halfWidthInTiles) + 1
  163. const minTileY = Math.floor(centerWorldY - halfHeightInTiles) - 1
  164. const maxTileY = Math.ceil(centerWorldY + halfHeightInTiles) + 1
  165. const tiles: PreparePreviewTile[] = []
  166. for (let tileY = minTileY; tileY <= maxTileY; tileY += 1) {
  167. for (let tileX = minTileX; tileX <= maxTileX; tileX += 1) {
  168. if (!isTileWithinBounds(config.tileBoundsByZoom, zoom, tileX, tileY)) {
  169. continue
  170. }
  171. tiles.push({
  172. url: buildTileUrl(config.tileSource, zoom, tileX, tileY),
  173. x: tileX,
  174. y: tileY,
  175. leftPx: Math.round(width / 2 + (tileX - centerWorldX) * config.tileSize),
  176. topPx: Math.round(height / 2 + (tileY - centerWorldY) * config.tileSize),
  177. sizePx: config.tileSize,
  178. })
  179. }
  180. }
  181. return tiles
  182. }
  183. function applyFitTransform(
  184. scene: PreparePreviewScene,
  185. paddingRatio: number,
  186. ): PreparePreviewScene {
  187. if (!scene.controls.length) {
  188. return scene
  189. }
  190. let minX = scene.controls[0].x
  191. let maxX = scene.controls[0].x
  192. let minY = scene.controls[0].y
  193. let maxY = scene.controls[0].y
  194. scene.controls.forEach((control) => {
  195. minX = Math.min(minX, control.x)
  196. maxX = Math.max(maxX, control.x)
  197. minY = Math.min(minY, control.y)
  198. maxY = Math.max(maxY, control.y)
  199. })
  200. const boundsWidth = Math.max(1, maxX - minX)
  201. const boundsHeight = Math.max(1, maxY - minY)
  202. const targetWidth = scene.width * paddingRatio
  203. const targetHeight = scene.height * paddingRatio
  204. const scale = Math.max(1, Math.min(targetWidth / boundsWidth, targetHeight / boundsHeight))
  205. const centerX = (minX + maxX) / 2
  206. const centerY = (minY + maxY) / 2
  207. const transformX = (value: number) => ((value - centerX) * scale) + scene.width / 2
  208. const transformY = (value: number) => ((value - centerY) * scale) + scene.height / 2
  209. return {
  210. ...scene,
  211. tiles: scene.tiles.map((tile) => ({
  212. ...tile,
  213. leftPx: transformX(tile.leftPx),
  214. topPx: transformY(tile.topPx),
  215. sizePx: tile.sizePx * scale,
  216. })),
  217. controls: scene.controls.map((control) => ({
  218. ...control,
  219. x: transformX(control.x),
  220. y: transformY(control.y),
  221. })),
  222. legs: scene.legs.map((leg) => ({
  223. fromX: transformX(leg.fromX),
  224. fromY: transformY(leg.fromY),
  225. toX: transformX(leg.toX),
  226. toY: transformY(leg.toY),
  227. })),
  228. }
  229. }
  230. export function buildPreparePreviewScene(
  231. config: RemoteMapConfig,
  232. width: number,
  233. height: number,
  234. overlayEnabled: boolean,
  235. ): PreparePreviewScene {
  236. const normalizedWidth = Math.max(240, Math.round(width))
  237. const normalizedHeight = Math.max(140, Math.round(height))
  238. const points = collectCoursePoints(config)
  239. const zoom = resolvePreviewZoom(config, normalizedWidth, normalizedHeight, points)
  240. const center = resolvePreviewCenter(config, zoom, points)
  241. const tiles = buildPreviewTiles(config, zoom, normalizedWidth, normalizedHeight, center.x, center.y)
  242. const controls: PreparePreviewControl[] = []
  243. const legs: PreparePreviewLeg[] = []
  244. if (overlayEnabled && config.course) {
  245. const projectPoint = (point: LonLatPoint) => {
  246. const world = lonLatToWorldTile(point, zoom)
  247. return {
  248. x: normalizedWidth / 2 + (world.x - center.x) * config.tileSize,
  249. y: normalizedHeight / 2 + (world.y - center.y) * config.tileSize,
  250. }
  251. }
  252. config.course.layers.legs.forEach((leg) => {
  253. const from = projectPoint(leg.fromPoint)
  254. const to = projectPoint(leg.toPoint)
  255. legs.push({
  256. fromX: from.x,
  257. fromY: from.y,
  258. toX: to.x,
  259. toY: to.y,
  260. })
  261. })
  262. config.course.layers.starts.forEach((item) => {
  263. const point = projectPoint(item.point)
  264. controls.push({
  265. kind: 'start',
  266. label: item.label,
  267. x: point.x,
  268. y: point.y,
  269. })
  270. })
  271. config.course.layers.controls.forEach((item) => {
  272. const point = projectPoint(item.point)
  273. controls.push({
  274. kind: 'control',
  275. label: item.label,
  276. x: point.x,
  277. y: point.y,
  278. })
  279. })
  280. config.course.layers.finishes.forEach((item) => {
  281. const point = projectPoint(item.point)
  282. controls.push({
  283. kind: 'finish',
  284. label: item.label,
  285. x: point.x,
  286. y: point.y,
  287. })
  288. })
  289. }
  290. const baseScene: PreparePreviewScene = {
  291. width: normalizedWidth,
  292. height: normalizedHeight,
  293. zoom,
  294. tiles,
  295. controls,
  296. legs,
  297. overlayAvailable: overlayEnabled && !!config.course,
  298. }
  299. return applyFitTransform(baseScene, 0.88)
  300. }
  301. export function buildPreparePreviewSceneFromVariantControls(
  302. config: RemoteMapConfig,
  303. width: number,
  304. height: number,
  305. controlsInput: Array<{
  306. kind?: string | null
  307. label?: string | null
  308. lon?: number | null
  309. lat?: number | null
  310. }>,
  311. ): PreparePreviewScene | null {
  312. const seeds = collectPreviewPointSeeds(controlsInput)
  313. if (!seeds.length) {
  314. return null
  315. }
  316. const normalizedWidth = Math.max(240, Math.round(width))
  317. const normalizedHeight = Math.max(140, Math.round(height))
  318. const points = seeds.map((item) => item.point)
  319. const zoom = resolvePreviewZoom(config, normalizedWidth, normalizedHeight, points)
  320. const center = resolvePreviewCenter(config, zoom, points)
  321. const tiles = buildPreviewTiles(config, zoom, normalizedWidth, normalizedHeight, center.x, center.y)
  322. const controls: PreparePreviewControl[] = seeds.map((item) => {
  323. const world = lonLatToWorldTile(item.point, zoom)
  324. return {
  325. kind: item.kind,
  326. label: item.label,
  327. x: normalizedWidth / 2 + (world.x - center.x) * config.tileSize,
  328. y: normalizedHeight / 2 + (world.y - center.y) * config.tileSize,
  329. }
  330. })
  331. const scene: PreparePreviewScene = {
  332. width: normalizedWidth,
  333. height: normalizedHeight,
  334. zoom,
  335. tiles,
  336. controls,
  337. legs: [],
  338. overlayAvailable: true,
  339. }
  340. return applyFitTransform(scene, 0.88)
  341. }
  342. export function buildPreparePreviewSceneFromBackendPreview(
  343. preview: BackendPreviewSummary,
  344. width: number,
  345. height: number,
  346. variantId?: string | null,
  347. tileUrlTemplateOverride?: string | null,
  348. ): PreparePreviewScene | null {
  349. if (!preview.baseTiles || !preview.viewport || !preview.baseTiles.tileBaseUrl || typeof preview.baseTiles.zoom !== 'number') {
  350. return null
  351. }
  352. const viewport = preview.viewport
  353. if (
  354. typeof viewport.minLon !== 'number'
  355. || typeof viewport.minLat !== 'number'
  356. || typeof viewport.maxLon !== 'number'
  357. || typeof viewport.maxLat !== 'number'
  358. ) {
  359. return null
  360. }
  361. const normalizedWidth = Math.max(240, Math.round(width))
  362. const normalizedHeight = Math.max(140, Math.round(height))
  363. const zoom = Math.round(preview.baseTiles.zoom)
  364. const tileSize = typeof preview.baseTiles.tileSize === 'number' && preview.baseTiles.tileSize > 0
  365. ? preview.baseTiles.tileSize
  366. : 256
  367. const template = resolvePreviewTileTemplate(tileUrlTemplateOverride || preview.baseTiles.tileBaseUrl)
  368. const center = lonLatToWorldTile(
  369. {
  370. lon: (viewport.minLon + viewport.maxLon) / 2,
  371. lat: (viewport.minLat + viewport.maxLat) / 2,
  372. },
  373. zoom,
  374. )
  375. const northWest = lonLatToWorldTile({ lon: viewport.minLon, lat: viewport.maxLat }, zoom)
  376. const southEast = lonLatToWorldTile({ lon: viewport.maxLon, lat: viewport.minLat }, zoom)
  377. const boundsWidthPx = Math.max(1, Math.abs(southEast.x - northWest.x) * tileSize)
  378. const boundsHeightPx = Math.max(1, Math.abs(southEast.y - northWest.y) * tileSize)
  379. const scale = Math.min(normalizedWidth / boundsWidthPx, normalizedHeight / boundsHeightPx)
  380. const minTileX = Math.floor(Math.min(northWest.x, southEast.x)) - 1
  381. const maxTileX = Math.ceil(Math.max(northWest.x, southEast.x)) + 1
  382. const minTileY = Math.floor(Math.min(northWest.y, southEast.y)) - 1
  383. const maxTileY = Math.ceil(Math.max(northWest.y, southEast.y)) + 1
  384. const tiles: PreparePreviewTile[] = []
  385. for (let tileY = minTileY; tileY <= maxTileY; tileY += 1) {
  386. for (let tileX = minTileX; tileX <= maxTileX; tileX += 1) {
  387. const leftPx = ((tileX - center.x) * tileSize * scale) + normalizedWidth / 2
  388. const topPx = ((tileY - center.y) * tileSize * scale) + normalizedHeight / 2
  389. tiles.push({
  390. url: buildTileUrl(template, zoom, tileX, tileY),
  391. x: tileX,
  392. y: tileY,
  393. leftPx,
  394. topPx,
  395. sizePx: tileSize * scale,
  396. })
  397. }
  398. }
  399. const normalizedVariantId = variantId || preview.selectedVariantId || ''
  400. const previewVariant = (preview.variants || []).find((item) => {
  401. const candidateId = item.variantId || item.id || ''
  402. return candidateId === normalizedVariantId
  403. }) || (preview.variants && preview.variants[0] ? preview.variants[0] : null)
  404. const controls: PreparePreviewControl[] = []
  405. if (previewVariant && previewVariant.controls && previewVariant.controls.length) {
  406. previewVariant.controls.forEach((item, index) => {
  407. if (typeof item.lon !== 'number' || typeof item.lat !== 'number') {
  408. return
  409. }
  410. const world = lonLatToWorldTile({ lon: item.lon, lat: item.lat }, zoom)
  411. const x = ((world.x - center.x) * tileSize * scale) + normalizedWidth / 2
  412. const y = ((world.y - center.y) * tileSize * scale) + normalizedHeight / 2
  413. const normalizedKind = item.kind === 'start' || item.kind === 'finish' ? item.kind : 'control'
  414. controls.push({
  415. kind: normalizedKind,
  416. label: item.label || String(index + 1),
  417. x,
  418. y,
  419. })
  420. })
  421. }
  422. return {
  423. width: normalizedWidth,
  424. height: normalizedHeight,
  425. zoom,
  426. tiles,
  427. controls,
  428. legs: [],
  429. overlayAvailable: controls.length > 0,
  430. }
  431. }