Source: GeoreferenceManager.js

import { CoordinateSystem } from "./CoordinateSystem.js";

/**
 * GeoreferenceManager for working with geographic coordinates in OpenLIME
 * Handles conversions between geographic coordinates (EPSG:4326/WGS84) and 
 * Web Mercator (EPSG:3857) and managing map navigation.
 */
class GeoreferenceManager {
  /**
   * Creates a new GeoreferenceManager
   * @param {Object} viewer - OpenLIME Viewer instance
   * @param {Object} layer - Layer containing the geographic image
   */
  constructor(viewer, layer) {
    if (!viewer || !layer) {
      throw new Error('Viewer and layer are required');
    }

    this.viewer = viewer;
    this.camera = viewer.camera;
    this.layer = layer;
    this.earthRadius = 6378137; // Earth radius in meters (WGS84)
    this.imageSize = Math.max(this.layer.width, this.layer.height);
    
    // Define zoom constraints
    this.minZoom = 0;  // Minimum zoom level
    this.maxZoom = 19; // Maximum zoom level (from OSM)

    // Set up camera position methods
    this.setupViewer();
  }

  /**
   * Configures the viewer with geographic navigation methods
   * @private
   */
  setupViewer() {
    // Add method to navigate to geographic coordinates
    this.camera.setGeoPosition = (lat, lon, zoom) => {
      const sceneCoord = this.geoToScene(lat, lon);
      
      // Constrain zoom to valid range
      const constrainedZoom = Math.min(this.maxZoom, Math.max(this.minZoom, zoom));
      const z = constrainedZoom !== undefined ? 1 / Math.pow(2, constrainedZoom) : this.camera.getCurrentTransform(performance.now()).z;

      // Notice we need to negate the coordinates and scale by z
      this.camera.setPosition(250, -sceneCoord.x * z, -sceneCoord.y * z, z, 0);
    };

    // Add method to get current geographic position
    this.camera.getGeoPosition = () => {
      const transform = this.camera.getCurrentTransform(performance.now());

      // Need to negate and unscale coordinates before converting to geo
      const geo = this.sceneToGeo(-transform.x / transform.z, -transform.y / transform.z);
      
      // Calculate zoom level and ensure it's within valid range
      const rawZoom = Math.log2(1 / transform.z);
      const constrainedZoom = Math.min(this.maxZoom, Math.max(this.minZoom, rawZoom));

      return {
        lat: geo.lat,
        lon: geo.lon,
        zoom: constrainedZoom
      };
    };
  }

  /**
   * Converts WGS84 (EPSG:4326) coordinates to Web Mercator (EPSG:3857)
   * @param {number} lat - Latitude in degrees
   * @param {number} lon - Longitude in degrees
   * @returns {Object} Point in Web Mercator coordinates {x, y}
   */
  geoToWebMercator(lat, lon) {
    // Clamp latitude to avoid singularity at poles
    lat = Math.max(Math.min(lat, 85.051129), -85.051129);

    // Convert latitude and longitude to radians
    const latRad = lat * Math.PI / 180;
    const lonRad = lon * Math.PI / 180;

    // Calculate Web Mercator coordinates
    const x = this.earthRadius * lonRad;
    const y = this.earthRadius * Math.log(Math.tan(Math.PI / 4 + latRad / 2));

    return { x, y };
  }

  /**
   * Converts Web Mercator (EPSG:3857) coordinates to WGS84 (EPSG:4326)
   * @param {number} x - X coordinate in Web Mercator
   * @param {number} y - Y coordinate in Web Mercator
   * @returns {Object} Geographic coordinates {lat, lon} in degrees
   */
  webMercatorToGeo(x, y) {
    // Convert Web Mercator coordinates to latitude and longitude
    const lonRad = x / this.earthRadius;
    const latRad = 2 * Math.atan(Math.exp(y / this.earthRadius)) - Math.PI / 2;

    // Convert radians to degrees
    const lon = lonRad * 180 / Math.PI;
    const lat = latRad * 180 / Math.PI;

    return { lat, lon };
  }

  /**
   * Converts Web Mercator coordinates to Scene coordinates
   * @param {number} x - X coordinate in Web Mercator
   * @param {number} y - Y coordinate in Web Mercator
   * @returns {Object} Scene coordinates {x, y}
   */
  webMercatorToScene(x, y) {
    // Scale from Web Mercator to the scene coordinate system
    // Map is centered at 0,0 in scene coordinates
    const maxMercator = Math.PI * this.earthRadius;
    const scaleFactor = this.layer.width / (2 * maxMercator);

    return {
      x: x * scaleFactor,
      y: y * scaleFactor
    };
  }

  /**
   * Converts scene coordinates to Web Mercator
   * @param {number} x - X coordinate in scene space
   * @param {number} y - Y coordinate in scene space
   * @returns {Object} Web Mercator coordinates {x, y}
   */
  sceneToWebMercator(x, y) {
    // Scale from scene coordinate system to Web Mercator
    const maxMercator = Math.PI * this.earthRadius;
    const scaleFactor = (2 * maxMercator) / this.layer.width;

    return {
      x: x * scaleFactor,
      y: y * scaleFactor
    };
  }

  /**
   * Converts WGS84 coordinates to scene coordinates
   * @param {number} lat - Latitude in degrees
   * @param {number} lon - Longitude in degrees
   * @returns {Object} Scene coordinates {x, y}
   */
  geoToScene(lat, lon) {
    // Convert from WGS84 to Web Mercator
    const mercator = this.geoToWebMercator(lat, lon);
    // Convert from Web Mercator to scene coordinates
    return this.webMercatorToScene(mercator.x, mercator.y);
  }

  /**
   * Converts scene coordinates to WGS84 (EPSG:4326) coordinates
   * @param {number} x - X coordinate in scene space
   * @param {number} y - Y coordinate in scene space
   * @returns {Object} Geographic coordinates {lat, lon} in degrees
   */
  sceneToGeo(x, y) {
    // Convert from scene coordinates to Web Mercator
    const mercator = this.sceneToWebMercator(x, y);
    // Convert from Web Mercator to WGS84
    return this.webMercatorToGeo(mercator.x, mercator.y);
  }

  /**
   * Converts canvas HTML coordinates to WGS84 coordinates
   * @param {number} x - X coordinate in canvas
   * @param {number} y - Y coordinate in canvas
   * @returns {Object} Geographic coordinates {lat, lon} in degrees
   */
  canvasToGeo(x, y) {
    // Convert canvas coordinates to scene coordinates
    const sceneCoord = CoordinateSystem.fromCanvasHtmlToScene(
      { x, y },
      this.camera,
      true
    );
    // Convert scene coordinates to geographic coordinates
    return this.sceneToGeo(sceneCoord.x, sceneCoord.y);
  }

  /**
   * Navigate to a geographic position with animation
   * @param {number} lat - Latitude in degrees
   * @param {number} lon - Longitude in degrees
   * @param {number} [zoom] - Zoom level (optional)
   * @param {number} [duration=250] - Animation duration in ms
   * @param {string} [easing='linear'] - Easing function
   */
  flyTo(lat, lon, zoom, duration = 500, easing = 'linear') {
    if (!this.viewer || !this.camera) {
      throw new Error('Viewer not initialized');
    }
    const sceneCoord = this.geoToScene(lat, lon);
    
    // Constrain zoom to valid range
    const constrainedZoom = Math.min(this.maxZoom, Math.max(this.minZoom, zoom));
    const z = 1.0 / Math.pow(2, constrainedZoom);

    // Note that we use negative coordinates because the camera transform works that way
    this.camera.setPosition(duration, -sceneCoord.x * z, -sceneCoord.y * z, z, 0, easing);
  }

  /**
   * Gets the current geographic position and zoom
   * @returns {Object} Current position {lat, lon, zoom}
   */
  getCurrentPosition() {
    const transform = this.camera.getCurrentTransform(performance.now());
    const geo = this.sceneToGeo(-transform.x / transform.z, -transform.y / transform.z);
    
    // Calculate zoom level and ensure it's within valid range
    const rawZoom = Math.log2(1 / transform.z);
    const constrainedZoom = Math.min(this.maxZoom, Math.max(this.minZoom, rawZoom));

    return {
      lat: geo.lat,
      lon: geo.lon,
      zoom: constrainedZoom
    };
  }
}

export { GeoreferenceManager }