| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482 |
- import { type BackendPreviewSummary } from './backendApi'
- import { lonLatToWorldTile, type LonLatPoint } from './projection'
- import { isTileWithinBounds, type RemoteMapConfig } from './remoteMapConfig'
- import { buildTileUrl } from './tile'
- export interface PreparePreviewTile {
- url: string
- x: number
- y: number
- leftPx: number
- topPx: number
- sizePx: number
- }
- export interface PreparePreviewControl {
- kind: 'start' | 'control' | 'finish'
- label: string
- x: number
- y: number
- }
- export interface PreparePreviewLeg {
- fromX: number
- fromY: number
- toX: number
- toY: number
- }
- export interface PreparePreviewScene {
- width: number
- height: number
- zoom: number
- tiles: PreparePreviewTile[]
- controls: PreparePreviewControl[]
- legs: PreparePreviewLeg[]
- overlayAvailable: boolean
- }
- interface PreviewPointSeed {
- kind: 'start' | 'control' | 'finish'
- label: string
- point: LonLatPoint
- }
- function resolvePreviewTileTemplate(tileBaseUrl: string): string {
- if (tileBaseUrl.indexOf('{z}') >= 0 && tileBaseUrl.indexOf('{x}') >= 0 && tileBaseUrl.indexOf('{y}') >= 0) {
- return tileBaseUrl
- }
- const normalizedBase = tileBaseUrl.replace(/\/+$/, '')
- return `${normalizedBase}/{z}/{x}/{y}.png`
- }
- function clamp(value: number, min: number, max: number): number {
- return Math.max(min, Math.min(max, value))
- }
- function collectCoursePoints(config: RemoteMapConfig): LonLatPoint[] {
- if (!config.course) {
- return []
- }
- const points: LonLatPoint[] = []
- config.course.layers.starts.forEach((item) => {
- points.push(item.point)
- })
- config.course.layers.controls.forEach((item) => {
- points.push(item.point)
- })
- config.course.layers.finishes.forEach((item) => {
- points.push(item.point)
- })
- return points
- }
- function collectPreviewPointSeeds(items: Array<{
- kind?: string | null
- label?: string | null
- lon?: number | null
- lat?: number | null
- }>): PreviewPointSeed[] {
- const seeds: PreviewPointSeed[] = []
- items.forEach((item, index) => {
- if (typeof item.lon !== 'number' || typeof item.lat !== 'number') {
- return
- }
- const kind = item.kind === 'start' || item.kind === 'finish' ? item.kind : 'control'
- seeds.push({
- kind,
- label: item.label || String(index + 1),
- point: {
- lon: item.lon,
- lat: item.lat,
- },
- })
- })
- return seeds
- }
- function computePointBounds(points: LonLatPoint[]): { minLon: number; minLat: number; maxLon: number; maxLat: number } | null {
- if (!points.length) {
- return null
- }
- let minLon = points[0].lon
- let maxLon = points[0].lon
- let minLat = points[0].lat
- let maxLat = points[0].lat
- points.forEach((point) => {
- minLon = Math.min(minLon, point.lon)
- maxLon = Math.max(maxLon, point.lon)
- minLat = Math.min(minLat, point.lat)
- maxLat = Math.max(maxLat, point.lat)
- })
- return {
- minLon,
- minLat,
- maxLon,
- maxLat,
- }
- }
- function resolvePreviewZoom(config: RemoteMapConfig, width: number, height: number, points: LonLatPoint[]): number {
- const upperZoom = clamp(config.defaultZoom > 0 ? config.defaultZoom : config.maxZoom, config.minZoom, config.maxZoom)
- if (!points.length) {
- return clamp(upperZoom - 1, config.minZoom, config.maxZoom)
- }
- const bounds = computePointBounds(points)
- if (!bounds) {
- return clamp(upperZoom - 1, config.minZoom, config.maxZoom)
- }
- let fittedZoom = config.minZoom
- for (let zoom = upperZoom; zoom >= config.minZoom; zoom -= 1) {
- const northWest = lonLatToWorldTile({ lon: bounds.minLon, lat: bounds.maxLat }, zoom)
- const southEast = lonLatToWorldTile({ lon: bounds.maxLon, lat: bounds.minLat }, zoom)
- const widthPx = Math.abs(southEast.x - northWest.x) * config.tileSize
- const heightPx = Math.abs(southEast.y - northWest.y) * config.tileSize
- if (widthPx <= width * 0.9 && heightPx <= height * 0.9) {
- fittedZoom = zoom
- break
- }
- }
- return clamp(fittedZoom, config.minZoom, config.maxZoom)
- }
- function resolvePreviewCenter(config: RemoteMapConfig, zoom: number, points: LonLatPoint[]): { x: number; y: number } {
- const bounds = computePointBounds(points)
- if (bounds) {
- const center = lonLatToWorldTile(
- {
- lon: (bounds.minLon + bounds.maxLon) / 2,
- lat: (bounds.minLat + bounds.maxLat) / 2,
- },
- zoom,
- )
- return {
- x: center.x,
- y: center.y,
- }
- }
- return {
- x: config.initialCenterTileX,
- y: config.initialCenterTileY,
- }
- }
- function buildPreviewTiles(
- config: RemoteMapConfig,
- zoom: number,
- width: number,
- height: number,
- centerWorldX: number,
- centerWorldY: number,
- ): PreparePreviewTile[] {
- const halfWidthInTiles = width / 2 / config.tileSize
- const halfHeightInTiles = height / 2 / config.tileSize
- const minTileX = Math.floor(centerWorldX - halfWidthInTiles) - 1
- const maxTileX = Math.ceil(centerWorldX + halfWidthInTiles) + 1
- const minTileY = Math.floor(centerWorldY - halfHeightInTiles) - 1
- const maxTileY = Math.ceil(centerWorldY + halfHeightInTiles) + 1
- const tiles: PreparePreviewTile[] = []
- for (let tileY = minTileY; tileY <= maxTileY; tileY += 1) {
- for (let tileX = minTileX; tileX <= maxTileX; tileX += 1) {
- if (!isTileWithinBounds(config.tileBoundsByZoom, zoom, tileX, tileY)) {
- continue
- }
- tiles.push({
- url: buildTileUrl(config.tileSource, zoom, tileX, tileY),
- x: tileX,
- y: tileY,
- leftPx: Math.round(width / 2 + (tileX - centerWorldX) * config.tileSize),
- topPx: Math.round(height / 2 + (tileY - centerWorldY) * config.tileSize),
- sizePx: config.tileSize,
- })
- }
- }
- return tiles
- }
- function applyFitTransform(
- scene: PreparePreviewScene,
- paddingRatio: number,
- ): PreparePreviewScene {
- if (!scene.controls.length) {
- return scene
- }
- let minX = scene.controls[0].x
- let maxX = scene.controls[0].x
- let minY = scene.controls[0].y
- let maxY = scene.controls[0].y
- scene.controls.forEach((control) => {
- minX = Math.min(minX, control.x)
- maxX = Math.max(maxX, control.x)
- minY = Math.min(minY, control.y)
- maxY = Math.max(maxY, control.y)
- })
- const boundsWidth = Math.max(1, maxX - minX)
- const boundsHeight = Math.max(1, maxY - minY)
- const targetWidth = scene.width * paddingRatio
- const targetHeight = scene.height * paddingRatio
- const scale = Math.max(1, Math.min(targetWidth / boundsWidth, targetHeight / boundsHeight))
- const centerX = (minX + maxX) / 2
- const centerY = (minY + maxY) / 2
- const transformX = (value: number) => ((value - centerX) * scale) + scene.width / 2
- const transformY = (value: number) => ((value - centerY) * scale) + scene.height / 2
- return {
- ...scene,
- tiles: scene.tiles.map((tile) => ({
- ...tile,
- leftPx: transformX(tile.leftPx),
- topPx: transformY(tile.topPx),
- sizePx: tile.sizePx * scale,
- })),
- controls: scene.controls.map((control) => ({
- ...control,
- x: transformX(control.x),
- y: transformY(control.y),
- })),
- legs: scene.legs.map((leg) => ({
- fromX: transformX(leg.fromX),
- fromY: transformY(leg.fromY),
- toX: transformX(leg.toX),
- toY: transformY(leg.toY),
- })),
- }
- }
- export function buildPreparePreviewScene(
- config: RemoteMapConfig,
- width: number,
- height: number,
- overlayEnabled: boolean,
- ): PreparePreviewScene {
- const normalizedWidth = Math.max(240, Math.round(width))
- const normalizedHeight = Math.max(140, Math.round(height))
- const points = collectCoursePoints(config)
- const zoom = resolvePreviewZoom(config, normalizedWidth, normalizedHeight, points)
- const center = resolvePreviewCenter(config, zoom, points)
- const tiles = buildPreviewTiles(config, zoom, normalizedWidth, normalizedHeight, center.x, center.y)
- const controls: PreparePreviewControl[] = []
- const legs: PreparePreviewLeg[] = []
- if (overlayEnabled && config.course) {
- const projectPoint = (point: LonLatPoint) => {
- const world = lonLatToWorldTile(point, zoom)
- return {
- x: normalizedWidth / 2 + (world.x - center.x) * config.tileSize,
- y: normalizedHeight / 2 + (world.y - center.y) * config.tileSize,
- }
- }
- config.course.layers.legs.forEach((leg) => {
- const from = projectPoint(leg.fromPoint)
- const to = projectPoint(leg.toPoint)
- legs.push({
- fromX: from.x,
- fromY: from.y,
- toX: to.x,
- toY: to.y,
- })
- })
- config.course.layers.starts.forEach((item) => {
- const point = projectPoint(item.point)
- controls.push({
- kind: 'start',
- label: item.label,
- x: point.x,
- y: point.y,
- })
- })
- config.course.layers.controls.forEach((item) => {
- const point = projectPoint(item.point)
- controls.push({
- kind: 'control',
- label: item.label,
- x: point.x,
- y: point.y,
- })
- })
- config.course.layers.finishes.forEach((item) => {
- const point = projectPoint(item.point)
- controls.push({
- kind: 'finish',
- label: item.label,
- x: point.x,
- y: point.y,
- })
- })
- }
- const baseScene: PreparePreviewScene = {
- width: normalizedWidth,
- height: normalizedHeight,
- zoom,
- tiles,
- controls,
- legs,
- overlayAvailable: overlayEnabled && !!config.course,
- }
- return applyFitTransform(baseScene, 0.88)
- }
- export function buildPreparePreviewSceneFromVariantControls(
- config: RemoteMapConfig,
- width: number,
- height: number,
- controlsInput: Array<{
- kind?: string | null
- label?: string | null
- lon?: number | null
- lat?: number | null
- }>,
- ): PreparePreviewScene | null {
- const seeds = collectPreviewPointSeeds(controlsInput)
- if (!seeds.length) {
- return null
- }
- const normalizedWidth = Math.max(240, Math.round(width))
- const normalizedHeight = Math.max(140, Math.round(height))
- const points = seeds.map((item) => item.point)
- const zoom = resolvePreviewZoom(config, normalizedWidth, normalizedHeight, points)
- const center = resolvePreviewCenter(config, zoom, points)
- const tiles = buildPreviewTiles(config, zoom, normalizedWidth, normalizedHeight, center.x, center.y)
- const controls: PreparePreviewControl[] = seeds.map((item) => {
- const world = lonLatToWorldTile(item.point, zoom)
- return {
- kind: item.kind,
- label: item.label,
- x: normalizedWidth / 2 + (world.x - center.x) * config.tileSize,
- y: normalizedHeight / 2 + (world.y - center.y) * config.tileSize,
- }
- })
- const scene: PreparePreviewScene = {
- width: normalizedWidth,
- height: normalizedHeight,
- zoom,
- tiles,
- controls,
- legs: [],
- overlayAvailable: true,
- }
- return applyFitTransform(scene, 0.88)
- }
- export function buildPreparePreviewSceneFromBackendPreview(
- preview: BackendPreviewSummary,
- width: number,
- height: number,
- variantId?: string | null,
- tileUrlTemplateOverride?: string | null,
- ): PreparePreviewScene | null {
- if (!preview.baseTiles || !preview.viewport || !preview.baseTiles.tileBaseUrl || typeof preview.baseTiles.zoom !== 'number') {
- return null
- }
- const viewport = preview.viewport
- if (
- typeof viewport.minLon !== 'number'
- || typeof viewport.minLat !== 'number'
- || typeof viewport.maxLon !== 'number'
- || typeof viewport.maxLat !== 'number'
- ) {
- return null
- }
- const normalizedWidth = Math.max(240, Math.round(width))
- const normalizedHeight = Math.max(140, Math.round(height))
- const zoom = Math.round(preview.baseTiles.zoom)
- const tileSize = typeof preview.baseTiles.tileSize === 'number' && preview.baseTiles.tileSize > 0
- ? preview.baseTiles.tileSize
- : 256
- const template = resolvePreviewTileTemplate(tileUrlTemplateOverride || preview.baseTiles.tileBaseUrl)
- const center = lonLatToWorldTile(
- {
- lon: (viewport.minLon + viewport.maxLon) / 2,
- lat: (viewport.minLat + viewport.maxLat) / 2,
- },
- zoom,
- )
- const northWest = lonLatToWorldTile({ lon: viewport.minLon, lat: viewport.maxLat }, zoom)
- const southEast = lonLatToWorldTile({ lon: viewport.maxLon, lat: viewport.minLat }, zoom)
- const boundsWidthPx = Math.max(1, Math.abs(southEast.x - northWest.x) * tileSize)
- const boundsHeightPx = Math.max(1, Math.abs(southEast.y - northWest.y) * tileSize)
- const scale = Math.min(normalizedWidth / boundsWidthPx, normalizedHeight / boundsHeightPx)
- const minTileX = Math.floor(Math.min(northWest.x, southEast.x)) - 1
- const maxTileX = Math.ceil(Math.max(northWest.x, southEast.x)) + 1
- const minTileY = Math.floor(Math.min(northWest.y, southEast.y)) - 1
- const maxTileY = Math.ceil(Math.max(northWest.y, southEast.y)) + 1
- const tiles: PreparePreviewTile[] = []
- for (let tileY = minTileY; tileY <= maxTileY; tileY += 1) {
- for (let tileX = minTileX; tileX <= maxTileX; tileX += 1) {
- const leftPx = ((tileX - center.x) * tileSize * scale) + normalizedWidth / 2
- const topPx = ((tileY - center.y) * tileSize * scale) + normalizedHeight / 2
- tiles.push({
- url: buildTileUrl(template, zoom, tileX, tileY),
- x: tileX,
- y: tileY,
- leftPx,
- topPx,
- sizePx: tileSize * scale,
- })
- }
- }
- const normalizedVariantId = variantId || preview.selectedVariantId || ''
- const previewVariant = (preview.variants || []).find((item) => {
- const candidateId = item.variantId || item.id || ''
- return candidateId === normalizedVariantId
- }) || (preview.variants && preview.variants[0] ? preview.variants[0] : null)
- const controls: PreparePreviewControl[] = []
- if (previewVariant && previewVariant.controls && previewVariant.controls.length) {
- previewVariant.controls.forEach((item, index) => {
- if (typeof item.lon !== 'number' || typeof item.lat !== 'number') {
- return
- }
- const world = lonLatToWorldTile({ lon: item.lon, lat: item.lat }, zoom)
- const x = ((world.x - center.x) * tileSize * scale) + normalizedWidth / 2
- const y = ((world.y - center.y) * tileSize * scale) + normalizedHeight / 2
- const normalizedKind = item.kind === 'start' || item.kind === 'finish' ? item.kind : 'control'
- controls.push({
- kind: normalizedKind,
- label: item.label || String(index + 1),
- x,
- y,
- })
- })
- }
- return {
- width: normalizedWidth,
- height: normalizedHeight,
- zoom,
- tiles,
- controls,
- legs: [],
- overlayAvailable: controls.length > 0,
- }
- }
|