Files
cmr-mini/miniprogram/utils/prepareMapPreview.ts

483 lines
14 KiB
TypeScript

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