Source

backend/src/routes/stiples.ts

/**
 * @fileoverview Router handling stipple generation endpoints
 * @module backend/routes/stiples
 */

import express, { Request, Response, Router } from "express";
import pool from "../config/db";
import {
  QueryParams,
  ValidatedParams,
  ValidationResult,
  StipplesRow,
  Stipple,
  PostgresError,
  DatasetType,
  DATASET_CONFIGS,
} from "./interfaces";

const router: Router = express.Router();

/**
 * Validates and parses query parameters for stipple generation
 * @param {Partial<QueryParams>} params - Raw query parameters from the request
 * @returns {ValidationResult} Validation result with either parsed parameters or error message
 */
const validateQueryParams = (
  params: Partial<QueryParams>
): ValidationResult => {
  const { minLat, maxLat, minLng, maxLng, w, h, total_stiples } = params;

  if (!minLat || !maxLat || !minLng || !maxLng || !w || !h) {
    return {
      isValid: false,
      error:
        "Missing required parameters: minLat, maxLat, minLng, maxLng, w, h",
    };
  }

  const numParams: ValidatedParams = {
    minLat: Number.parseFloat(minLat),
    maxLat: Number.parseFloat(maxLat),
    minLng: Number.parseFloat(minLng),
    maxLng: Number.parseFloat(maxLng),
    w: Number.parseInt(w),
    h: Number.parseInt(h),
    total_stiples: total_stiples ? Number.parseInt(total_stiples) : undefined,
  };

  // Validate latitude range (-90 to 90)
  if (
    numParams.minLat < -90 ||
    numParams.maxLat > 90 ||
    numParams.minLat > numParams.maxLat
  ) {
    return {
      isValid: false,
      error:
        "Invalid latitude range. Must be between -90 and 90, and minLat must be less than maxLat",
    };
  }

  // Validate longitude range (-180 to 180)
  if (
    numParams.minLng < -180 ||
    numParams.maxLng > 180 ||
    numParams.minLng > numParams.maxLng
  ) {
    return {
      isValid: false,
      error:
        "Invalid longitude range. Must be between -180 and 180, and minLng must be less than maxLng",
    };
  }

  // Validate width and height dimensions
  if (numParams.w <= 0 || numParams.h <= 0) {
    return {
      isValid: false,
      error:
        "Invalid dimensions. Width and height must be positive and w*h must not exceed 10000",
    };
  }

  return { isValid: true, params: numParams };
};

/**
 * Assigns points to a uniform grid based on closest distance
 * @param {StipplesRow[]} stipples - Array of raw stipple points from database
 * @param {number} w - Width of the output grid
 * @param {number} h - Height of the output grid
 * @param {number} minLng - Minimum longitude of the bounding box
 * @param {number} maxLng - Maximum longitude of the bounding box
 * @param {number} minLat - Minimum latitude of the bounding box
 * @param {number} maxLat - Maximum latitude of the bounding box
 * @returns {Stipple[][]} 2D array of stipples assigned to grid cells
 */
const assignPointsToGrid = (
  stipples: StipplesRow[],
  w: number,
  h: number,
  minLng: number,
  maxLng: number,
  minLat: number,
  maxLat: number
): Stipple[][] => {
  const cellWidth = (maxLng - minLng) / w;
  const cellHeight = (maxLat - minLat) / h;

  // w x h grid
  const grid: Stipple[][] = Array.from({ length: h }, () =>
    Array(w).fill(null)
  );

  //  assign the closest point to uniform grid cells
  for (let row = 0; row < h; row++) {
    for (let col = 0; col < w; col++) {
      // centar of the current grid cell
      const cellCenterLng = minLng + col * cellWidth + cellWidth / 2;
      const cellCenterLat = maxLat - row * cellHeight - cellHeight / 2;

      let closestPoint: Stipple | null = null;
      let closestDistance = Infinity;

      stipples.forEach((stipple) => {
        const distance = Math.sqrt(
          Math.pow(parseFloat(stipple.lng) - cellCenterLng, 2) +
            Math.pow(parseFloat(stipple.lat) - cellCenterLat, 2)
        );
        if (distance < closestDistance) {
          closestDistance = distance;
          closestPoint = {
            lat: parseFloat(stipple.lat),
            lng: parseFloat(stipple.lng),
            val: stipple.val !== null ? parseFloat(stipple.val) : 0, //  null to 0
          };
        }
      });

      if (closestPoint) {
        grid[row][col] = {
          //@ts-ignore
          lat: closestPoint.lat,
          //@ts-ignore
          lng: closestPoint.lng,
          //@ts-ignore
          val: closestPoint.val,
        };
      }
    }
  }

  return grid;
};

/**
 * Fetches and processes stipple data for a specific dataset
 * @param {DatasetType} dataset - Type of dataset to query (air_pollution, temperature, or earth_relief)
 * @param {ValidatedParams} params - Validated query parameters
 * @param {Response} res - Express response object
 */
const getStiplesForDataset = async (
  dataset: DatasetType,
  params: ValidatedParams,
  res: Response
) => {
  const { tableName, valueColumn } = DATASET_CONFIGS[dataset];
  const { minLat, maxLat, minLng, maxLng, w, h, total_stiples } = params;

  const totalPoints = total_stiples || 10_000;
  const aspectRatio = w / h;
  const cols = Math.round(Math.sqrt(totalPoints * aspectRatio));
  const rows = Math.ceil(totalPoints / cols);
  const total_points_needed = rows * cols;

  const query = `
    WITH bounds AS (
      SELECT ST_MakeEnvelope($1, $2, $3, $4, 4326) AS geom
    ),
    generated_points AS (
      SELECT (ST_Dump(ST_GeneratePoints(bounds.geom, $5))).geom AS point
      FROM bounds
    ),
    sample_points AS (
      SELECT
        ST_X(point) AS lng,
        ST_Y(point) AS lat,
        ST_Value(${valueColumn}, point) AS val
      FROM
        generated_points
      LEFT JOIN
        ${tableName}
      ON
        ST_Intersects(${valueColumn}, point)
    )
    SELECT lat, lng, val
    FROM sample_points
    ORDER BY lat DESC, lng ASC
  `;

  try {
    const result = await pool.query<StipplesRow & { rn: number }>(query, [
      minLng,
      minLat,
      maxLng,
      maxLat,
      total_points_needed,
    ]);
    console.log("Raw stipples from the database:", result.rows);

    console.log("Query result length:", result.rows.length);

    const grid = assignPointsToGrid(
      result.rows,
      w,
      h,
      minLng,
      maxLng,
      minLat,
      maxLat
    );

    res.json({ stiples: grid });
  } catch (error) {
    console.error("Error in /stiples endpoint:", error);

    const pgError = error as PostgresError;

    if (pgError.code === "42P01") {
      return res.status(500).json({
        error:
          "Table not found. Please ensure the air_pollution table exists in the database.",
      });
    }

    if (pgError.code === "28P01") {
      return res.status(500).json({
        error: "Database authentication failed. Please check your credentials.",
      });
    }

    res.status(500).json({
      error: "Internal server error",
      message: "An error occurred while processing your request.",
    });
  }
};

/**
 * GET /api/stiples/air_pollution
 * Retrieves stipple data for air pollution dataset
 * @route GET /api/stiples/air_pollution
 * @param {QueryParams} req.query - Query parameters for stipple generation
 * @returns {Object} JSON object containing grid of stipples
 */
router.get("/stiples/air_pollution", async (req: Request, res: Response) => {
  const validation = validateQueryParams(req.query as Partial<QueryParams>);
  if (!validation.isValid) {
    return res.status(400).json({ error: validation.error });
  }
  await getStiplesForDataset("air_pollution", validation.params, res);
});

/**
 * GET /api/stiples/temperature
 * Retrieves stipple data for temperature dataset
 * @route GET /api/stiples/temperature
 * @param {QueryParams} req.query - Query parameters for stipple generation
 * @returns {Object} JSON object containing grid of stipples
 */
router.get("/stiples/temperature", async (req: Request, res: Response) => {
  const validation = validateQueryParams(req.query as Partial<QueryParams>);
  if (!validation.isValid) {
    return res.status(400).json({ error: validation.error });
  }
  await getStiplesForDataset("temperature", validation.params, res);
});

/**
 * GET /api/stiples/earth_relief
 * Retrieves stipple data for earth relief dataset
 * @route GET /api/stiples/earth_relief
 * @param {QueryParams} req.query - Query parameters for stipple generation
 * @returns {Object} JSON object containing grid of stipples
 */
router.get("/stiples/earth_relief", async (req: Request, res: Response) => {
  const validation = validateQueryParams(req.query as Partial<QueryParams>);
  if (!validation.isValid) {
    return res.status(400).json({ error: validation.error });
  }
  await getStiplesForDataset("earth_relief", validation.params, res);
});

/**
 * GET /api/stiples/health
 * Health check endpoint for the stipples API
 * @route GET /api/stiples/health
 * @returns {Object} JSON object containing health status
 */
router.get("/stiples/health", async (_req: Request, res: Response) => {
  try {
    await pool.query("SELECT 1");
    res.json({ status: "healthy", database: "connected" });
  } catch (error) {
    const pgError = error as PostgresError;
    res.status(500).json({
      status: "unhealthy",
      database: "disconnected",
      error: pgError.message,
    });
  }
});

export default router;