|
|
@@ -0,0 +1,409 @@
|
|
|
+import fs from "fs/promises"
|
|
|
+import path from "path"
|
|
|
+import sharp from "sharp"
|
|
|
+import {
|
|
|
+ getCleanPath,
|
|
|
+ slugifyString,
|
|
|
+ writeFile,
|
|
|
+} from "../../util/index.js"
|
|
|
+
|
|
|
+const WEB_MERCATOR_MAX_LAT = 85.05112878
|
|
|
+
|
|
|
+function escapeXmlText(value) {
|
|
|
+ return String(value)
|
|
|
+ .replace(/&/g, "&")
|
|
|
+ .replace(/</g, "<")
|
|
|
+ .replace(/>/g, ">")
|
|
|
+ .replace(/"/g, """)
|
|
|
+ .replace(/'/g, "'")
|
|
|
+}
|
|
|
+
|
|
|
+function parsePointsFromTag(gpxContent, tagName) {
|
|
|
+ const points = []
|
|
|
+ const regex = new RegExp(`<${tagName}\\b([^>]*)>`, "gi")
|
|
|
+ for (const match of gpxContent.matchAll(regex)) {
|
|
|
+ const attrs = match[1] || ""
|
|
|
+ const latMatch = attrs.match(/\blat\s*=\s*["']([^"']+)["']/i)
|
|
|
+ const lonMatch = attrs.match(/\blon\s*=\s*["']([^"']+)["']/i)
|
|
|
+ if (!latMatch || !lonMatch) {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ const lat = Number.parseFloat(latMatch[1])
|
|
|
+ const lon = Number.parseFloat(lonMatch[1])
|
|
|
+ if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ if (lat < -90 || lat > 90 || lon < -180 || lon > 180) {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ points.push({ lat, lon })
|
|
|
+ }
|
|
|
+ return points
|
|
|
+}
|
|
|
+
|
|
|
+function parseGpxPoints(gpxContent) {
|
|
|
+ const trackPoints = parsePointsFromTag(gpxContent, "trkpt")
|
|
|
+ if (trackPoints.length > 0) {
|
|
|
+ return trackPoints
|
|
|
+ }
|
|
|
+ return parsePointsFromTag(gpxContent, "rtept")
|
|
|
+}
|
|
|
+
|
|
|
+function clamp(value, min, max) {
|
|
|
+ return Math.max(min, Math.min(max, value))
|
|
|
+}
|
|
|
+
|
|
|
+function modulo(value, modulus) {
|
|
|
+ return ((value % modulus) + modulus) % modulus
|
|
|
+}
|
|
|
+
|
|
|
+function lonToWorldX(lon, zoom) {
|
|
|
+ const worldSize = 256 * (2 ** zoom)
|
|
|
+ return ((lon + 180) / 360) * worldSize
|
|
|
+}
|
|
|
+
|
|
|
+function latToWorldY(lat, zoom) {
|
|
|
+ const clampedLat = clamp(lat, -WEB_MERCATOR_MAX_LAT, WEB_MERCATOR_MAX_LAT)
|
|
|
+ const latRadians = (clampedLat * Math.PI) / 180
|
|
|
+ const worldSize = 256 * (2 ** zoom)
|
|
|
+ const mercator = Math.log(Math.tan((Math.PI / 4) + (latRadians / 2)))
|
|
|
+ return (worldSize / 2) - (worldSize * mercator) / (2 * Math.PI)
|
|
|
+}
|
|
|
+
|
|
|
+function unwrapProjectedPoints(projectedPoints, worldSize) {
|
|
|
+ if (projectedPoints.length === 0) {
|
|
|
+ return []
|
|
|
+ }
|
|
|
+ const unwrapped = [{ ...projectedPoints[0] }]
|
|
|
+ for (let index = 1; index < projectedPoints.length; index++) {
|
|
|
+ const point = projectedPoints[index]
|
|
|
+ const previous = unwrapped[index - 1]
|
|
|
+ let x = point.x
|
|
|
+ while (x - previous.x > worldSize / 2) {
|
|
|
+ x -= worldSize
|
|
|
+ }
|
|
|
+ while (previous.x - x > worldSize / 2) {
|
|
|
+ x += worldSize
|
|
|
+ }
|
|
|
+ unwrapped.push({ x, y: point.y })
|
|
|
+ }
|
|
|
+ return unwrapped
|
|
|
+}
|
|
|
+
|
|
|
+function getBounds(points) {
|
|
|
+ return points.reduce(
|
|
|
+ (bounds, point) => ({
|
|
|
+ minX: Math.min(bounds.minX, point.x),
|
|
|
+ maxX: Math.max(bounds.maxX, point.x),
|
|
|
+ minY: Math.min(bounds.minY, point.y),
|
|
|
+ maxY: Math.max(bounds.maxY, point.y),
|
|
|
+ }),
|
|
|
+ {
|
|
|
+ minX: Infinity,
|
|
|
+ maxX: -Infinity,
|
|
|
+ minY: Infinity,
|
|
|
+ maxY: -Infinity,
|
|
|
+ },
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function getProjectedRoute(points, zoom) {
|
|
|
+ const worldSize = 256 * (2 ** zoom)
|
|
|
+ const projected = points.map(point => ({
|
|
|
+ x: lonToWorldX(point.lon, zoom),
|
|
|
+ y: latToWorldY(point.lat, zoom),
|
|
|
+ }))
|
|
|
+ const unwrapped = unwrapProjectedPoints(projected, worldSize)
|
|
|
+ const bounds = getBounds(unwrapped)
|
|
|
+ return {
|
|
|
+ points: unwrapped,
|
|
|
+ bounds,
|
|
|
+ worldSize,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function buildSanitisedGpx(points, name) {
|
|
|
+ const pointsXml = points
|
|
|
+ .map(({ lat, lon }) => {
|
|
|
+ return ` <trkpt lat="${lat.toFixed(6)}" lon="${lon.toFixed(6)}"></trkpt>`
|
|
|
+ })
|
|
|
+ .join("\n")
|
|
|
+
|
|
|
+ return [
|
|
|
+ "<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
|
|
|
+ "<gpx version=\"1.1\" creator=\"Rhedyn\" xmlns=\"http://www.topografix.com/GPX/1/1\">",
|
|
|
+ " <trk>",
|
|
|
+ ` <name>${escapeXmlText(name)}</name>`,
|
|
|
+ " <trkseg>",
|
|
|
+ pointsXml,
|
|
|
+ " </trkseg>",
|
|
|
+ " </trk>",
|
|
|
+ "</gpx>",
|
|
|
+ "",
|
|
|
+ ].join("\n")
|
|
|
+}
|
|
|
+
|
|
|
+function chooseZoom(points, actionConfig, width, height, padding) {
|
|
|
+ const minZoom = actionConfig.minZoom ?? 3
|
|
|
+ const maxZoom = actionConfig.maxZoom ?? 15
|
|
|
+ const drawableWidth = Math.max(1, width - (padding * 2))
|
|
|
+ const drawableHeight = Math.max(1, height - (padding * 2))
|
|
|
+ for (let zoom = maxZoom; zoom >= minZoom; zoom--) {
|
|
|
+ const projected = getProjectedRoute(points, zoom)
|
|
|
+ const routeWidth = projected.bounds.maxX - projected.bounds.minX
|
|
|
+ const routeHeight = projected.bounds.maxY - projected.bounds.minY
|
|
|
+ if (routeWidth <= drawableWidth && routeHeight <= drawableHeight) {
|
|
|
+ return { zoom, projected }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ zoom: minZoom,
|
|
|
+ projected: getProjectedRoute(points, minZoom),
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function toTileUrl(template, zoom, x, y) {
|
|
|
+ return template
|
|
|
+ .replace("{z}", String(zoom))
|
|
|
+ .replace("{x}", String(x))
|
|
|
+ .replace("{y}", String(y))
|
|
|
+}
|
|
|
+
|
|
|
+async function fetchTileBuffer({
|
|
|
+ zoom,
|
|
|
+ x,
|
|
|
+ y,
|
|
|
+ actionConfig,
|
|
|
+ fetchImpl,
|
|
|
+}) {
|
|
|
+ const tileUrlTemplate = actionConfig.tileUrlTemplate || "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
|
+ const userAgent = actionConfig.userAgent || "rhedyn-route-generator/1.0"
|
|
|
+ const timeoutMs = actionConfig.tileRequestTimeoutMs || 12000
|
|
|
+ const controller = new AbortController()
|
|
|
+ const timeout = setTimeout(() => controller.abort(), timeoutMs)
|
|
|
+ try {
|
|
|
+ const response = await fetchImpl(toTileUrl(tileUrlTemplate, zoom, x, y), {
|
|
|
+ headers: {
|
|
|
+ "User-Agent": userAgent,
|
|
|
+ },
|
|
|
+ signal: controller.signal,
|
|
|
+ })
|
|
|
+ if (!response.ok) {
|
|
|
+ return null
|
|
|
+ }
|
|
|
+ const arrayBuffer = await response.arrayBuffer()
|
|
|
+ return Buffer.from(arrayBuffer)
|
|
|
+ } catch {
|
|
|
+ return null
|
|
|
+ } finally {
|
|
|
+ clearTimeout(timeout)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function buildBasemap({
|
|
|
+ width,
|
|
|
+ height,
|
|
|
+ zoom,
|
|
|
+ topLeftX,
|
|
|
+ topLeftY,
|
|
|
+ actionConfig,
|
|
|
+}) {
|
|
|
+ const tileSize = 256
|
|
|
+ const tileCount = 2 ** zoom
|
|
|
+ const fetchImpl = actionConfig.fetchImpl || globalThis.fetch
|
|
|
+ if (typeof fetchImpl !== "function") {
|
|
|
+ throw new Error("No fetch implementation available for OpenStreetMap tile requests.")
|
|
|
+ }
|
|
|
+ const minTileX = Math.floor(topLeftX / tileSize)
|
|
|
+ const maxTileX = Math.floor((topLeftX + width) / tileSize)
|
|
|
+ const minTileY = Math.floor(topLeftY / tileSize)
|
|
|
+ const maxTileY = Math.floor((topLeftY + height) / tileSize)
|
|
|
+
|
|
|
+ const tileDescriptors = []
|
|
|
+ for (let tileY = minTileY; tileY <= maxTileY; tileY++) {
|
|
|
+ if (tileY < 0 || tileY >= tileCount) {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ for (let tileX = minTileX; tileX <= maxTileX; tileX++) {
|
|
|
+ tileDescriptors.push({
|
|
|
+ tileX,
|
|
|
+ tileY,
|
|
|
+ tileXWrapped: modulo(tileX, tileCount),
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const fetchedTiles = await Promise.all(
|
|
|
+ tileDescriptors.map(async tile => {
|
|
|
+ const buffer = await fetchTileBuffer({
|
|
|
+ zoom,
|
|
|
+ x: tile.tileXWrapped,
|
|
|
+ y: tile.tileY,
|
|
|
+ actionConfig,
|
|
|
+ fetchImpl,
|
|
|
+ })
|
|
|
+ if (!buffer) {
|
|
|
+ return null
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ input: buffer,
|
|
|
+ left: Math.round((tile.tileX * tileSize) - topLeftX),
|
|
|
+ top: Math.round((tile.tileY * tileSize) - topLeftY),
|
|
|
+ }
|
|
|
+ }),
|
|
|
+ )
|
|
|
+ const composites = fetchedTiles.filter(Boolean)
|
|
|
+ if (composites.length === 0) {
|
|
|
+ throw new Error(
|
|
|
+ "Could not load OpenStreetMap basemap tiles. Check network access or provide actionConfig.tileUrlTemplate.",
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ const fallbackColor = actionConfig.backgroundColor || "#e5e7eb"
|
|
|
+ return sharp({
|
|
|
+ create: {
|
|
|
+ width,
|
|
|
+ height,
|
|
|
+ channels: 4,
|
|
|
+ background: fallbackColor,
|
|
|
+ },
|
|
|
+ })
|
|
|
+ .composite(composites)
|
|
|
+ .png()
|
|
|
+ .toBuffer()
|
|
|
+}
|
|
|
+
|
|
|
+function buildRouteOverlay({
|
|
|
+ pointCoords,
|
|
|
+ width,
|
|
|
+ height,
|
|
|
+ actionConfig,
|
|
|
+}) {
|
|
|
+ const strokeWidth = actionConfig.strokeWidth || Math.max(4, Math.round(width * 0.0075))
|
|
|
+ const lineColor = actionConfig.strokeColor || "#0f766e"
|
|
|
+ const startColor = actionConfig.startMarkerColor || "#16a34a"
|
|
|
+ const endColor = actionConfig.endMarkerColor || "#dc2626"
|
|
|
+ const markerRadius = Math.max(6, Math.round(strokeWidth * 1.2))
|
|
|
+ const polylinePoints = pointCoords
|
|
|
+ .map(({ x, y }) => `${x.toFixed(2)},${y.toFixed(2)}`)
|
|
|
+ .join(" ")
|
|
|
+ const startPoint = pointCoords[0]
|
|
|
+ const endPoint = pointCoords[pointCoords.length - 1]
|
|
|
+ return [
|
|
|
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-label="Route map">`,
|
|
|
+ " <defs>",
|
|
|
+ " <filter id=\"route-shadow\" x=\"-20%\" y=\"-20%\" width=\"140%\" height=\"140%\">",
|
|
|
+ " <feDropShadow dx=\"0\" dy=\"1\" stdDeviation=\"2\" flood-opacity=\"0.5\"/>",
|
|
|
+ " </filter>",
|
|
|
+ " </defs>",
|
|
|
+ " <g filter=\"url(#route-shadow)\">",
|
|
|
+ ` <polyline points="${polylinePoints}" fill="none" stroke="${lineColor}" stroke-width="${strokeWidth}" stroke-linecap="round" stroke-linejoin="round" />`,
|
|
|
+ " </g>",
|
|
|
+ ` <circle cx="${startPoint.x.toFixed(2)}" cy="${startPoint.y.toFixed(2)}" r="${markerRadius}" fill="${startColor}" />`,
|
|
|
+ ` <circle cx="${endPoint.x.toFixed(2)}" cy="${endPoint.y.toFixed(2)}" r="${markerRadius}" fill="${endColor}" />`,
|
|
|
+ "</svg>",
|
|
|
+ ].join("\n")
|
|
|
+}
|
|
|
+
|
|
|
+async function renderRouteMap(points, actionConfig) {
|
|
|
+ const width = actionConfig.renderWidth || 1600
|
|
|
+ const height = actionConfig.renderHeight || 1000
|
|
|
+ const padding = actionConfig.renderPadding || Math.round(width * 0.08)
|
|
|
+ const { zoom, projected } = chooseZoom(points, actionConfig, width, height, padding)
|
|
|
+ const routeWidth = projected.bounds.maxX - projected.bounds.minX
|
|
|
+ const routeHeight = projected.bounds.maxY - projected.bounds.minY
|
|
|
+ const centerX = projected.bounds.minX + routeWidth / 2
|
|
|
+ const centerY = projected.bounds.minY + routeHeight / 2
|
|
|
+ const topLeftX = centerX - (width / 2)
|
|
|
+ const maxTopLeftY = Math.max(0, projected.worldSize - height)
|
|
|
+ const topLeftY = clamp(centerY - (height / 2), 0, maxTopLeftY)
|
|
|
+
|
|
|
+ const baseMapBuffer = await buildBasemap({
|
|
|
+ width,
|
|
|
+ height,
|
|
|
+ zoom,
|
|
|
+ topLeftX,
|
|
|
+ topLeftY,
|
|
|
+ actionConfig,
|
|
|
+ })
|
|
|
+ const pointCoords = projected.points.map(point => ({
|
|
|
+ x: point.x - topLeftX,
|
|
|
+ y: point.y - topLeftY,
|
|
|
+ }))
|
|
|
+ const routeOverlay = buildRouteOverlay({
|
|
|
+ pointCoords,
|
|
|
+ width,
|
|
|
+ height,
|
|
|
+ actionConfig,
|
|
|
+ })
|
|
|
+ const mapBuffer = await sharp(baseMapBuffer)
|
|
|
+ .composite([{ input: Buffer.from(routeOverlay) }])
|
|
|
+ .png()
|
|
|
+ .toBuffer()
|
|
|
+
|
|
|
+ return {
|
|
|
+ mapBuffer,
|
|
|
+ width,
|
|
|
+ height,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function parseSize(size) {
|
|
|
+ const sizeString = typeof size === "string" ? size : String(size)
|
|
|
+ const width = Number.parseInt(sizeString.replace("w", ""), 10)
|
|
|
+ if (!Number.isFinite(width) || width <= 0) {
|
|
|
+ throw new Error(`Invalid image size: ${sizeString}`)
|
|
|
+ }
|
|
|
+ return { width, sizeString }
|
|
|
+}
|
|
|
+
|
|
|
+export async function generateRouteAssets({ meta, config: actionConfig }) {
|
|
|
+ const filePath = actionConfig.filePath
|
|
|
+ const routeName = path.basename(filePath, path.extname(filePath))
|
|
|
+ const fileOutputPath = actionConfig.fileOutputPath
|
|
|
+ const fileOutputDir = actionConfig.fileOutputDir
|
|
|
+ await fs.mkdir(fileOutputDir, { recursive: true })
|
|
|
+
|
|
|
+ const gpxContent = await fs.readFile(filePath, "utf8")
|
|
|
+ const points = parseGpxPoints(gpxContent)
|
|
|
+ if (points.length < 2) {
|
|
|
+ throw new Error(`Could not find enough track points in GPX file: ${filePath}`)
|
|
|
+ }
|
|
|
+
|
|
|
+ const sanitisedGpx = buildSanitisedGpx(points, routeName)
|
|
|
+ await writeFile(fileOutputPath, sanitisedGpx)
|
|
|
+
|
|
|
+ const imageSizes = Array.isArray(actionConfig.imageSizes) ? actionConfig.imageSizes : []
|
|
|
+ if (imageSizes.length === 0) {
|
|
|
+ throw new Error(`generateRouteAssets: imageSizes must be a non-empty array for ${filePath}`)
|
|
|
+ }
|
|
|
+ const { mapBuffer, width, height } = await renderRouteMap(points, actionConfig)
|
|
|
+ const outputFiles = [fileOutputPath]
|
|
|
+ const srcSet = await Promise.all(
|
|
|
+ imageSizes.map(async size => {
|
|
|
+ const { width: imageWidth, sizeString } = parseSize(size)
|
|
|
+ const imageOutputPath = path.join(fileOutputDir, `${routeName}-${imageWidth}.webp`)
|
|
|
+ await sharp(mapBuffer)
|
|
|
+ .resize(imageWidth)
|
|
|
+ .webp({ quality: actionConfig.quality })
|
|
|
+ .toFile(imageOutputPath)
|
|
|
+ outputFiles.push(imageOutputPath)
|
|
|
+ return [getCleanPath(imageOutputPath, meta), sizeString]
|
|
|
+ }),
|
|
|
+ )
|
|
|
+
|
|
|
+ return {
|
|
|
+ paths: outputFiles,
|
|
|
+ detail: {
|
|
|
+ name: routeName,
|
|
|
+ gpxHref: getCleanPath(fileOutputPath, meta),
|
|
|
+ srcSet,
|
|
|
+ aspectRatio: width / height,
|
|
|
+ attribution: actionConfig.attribution || "© OpenStreetMap contributors",
|
|
|
+ },
|
|
|
+ deps: {
|
|
|
+ paths: [filePath],
|
|
|
+ },
|
|
|
+ ref: slugifyString(routeName),
|
|
|
+ }
|
|
|
+}
|