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, } }