control/Sun.tstypescript
import { Control } from "./Control";
import type { IControlParams } from "./Control";
import { Clock } from "../Clock";
import { getSunPosition } from "../astro/earth";
import { DateToUTC } from "../astro/jd";
import type { JulianDate } from "../astro/jd";
import { Quat } from "../math/Quat";
import { Vec3 } from "../math/Vec3";
import * as math from "../math";
import type { PlanetCamera } from "../camera/PlanetCamera";
import type { LonLat } from "../LonLat";
import tzlookup from "@photostructure/tz-lookup";

/**
 * Minimal julian date change that moves the sunlight position, about 30 seconds.
 * @const {number}
 */
const SUN_DATE_THRESHOLD = 0.00034;

/** Returns an IANA time zone name for the point, or null for the solar reading. */
export type TimeZoneProviderFn = (lonLat: LonLat) => string | null;

/**
 * Object form of the provider, e.g. a TimeZoneProvider instance: lookup by the point,
 * with an optional lazy load the Sun kicks off on first use.
 */
export interface ITimeZoneLookup {
    lookup(lon: number, lat: number): string | null;
    load?: () => Promise<unknown>;
}

export type TimeZoneProviderLike = TimeZoneProviderFn | ITimeZoneLookup;

interface ISunParams extends IControlParams {
    activationHeight?: number;
    offsetVertical?: number;
    offsetHorizontal?: number;
    stopped?: boolean;
    localDateTime?: Date | null;
    dateTime?: Date | null;
    useTimeZones?: boolean;
    timeZoneProvider?: TimeZoneProviderLike | null;
}

/**
 * Real Sun geocentric position control that place the Sun on the right place by the Earth.
 * @class
 *
 * @example <caption>Lighting frozen at 21:30 on the local clock under the camera</caption>
 * new Sun({ localDateTime: new Date(Date.UTC(2026, 7, 3, 21, 30)) })
 *
 * @param {ISunParams} [options] - Options:
 * @param {number} [options.activationHeight=12079000.0] - Camera height above which the Sun takes its real position by the clock.
 * @param {number} [options.offsetVertical=-5000000] - Vertical offset of the camera following light.
 * @param {number} [options.offsetHorizontal=5000000] - Horizontal offset of the camera following light.
 * @param {boolean} [options.stopped=false] - Stops the control, leaving the Sun on its real position by the clock.
 * @param {Date} [options.localDateTime] - Local clock time under the camera — wall-clock numbers,
 * not an instant: build it with Date.UTC. Civil time with useTimeZones, solar time otherwise.
 * @param {Date} [options.dateTime] - Instant in time the Sun takes its real position at.
 * @param {boolean} [options.useTimeZones=false] - Reads localDateTime by the time zone of the point.
 * Leave off on bodies without civil time.
 * @param {TimeZoneProviderLike} [options.timeZoneProvider] - Time zone source for the point under
 * the camera: a function, or an object like TimeZoneProvider — its lazy load is kicked off on
 * first use, and the built-in lookup answers until the data arrives.
 */
export class Sun extends Control {
    public activationHeight: number;
    public offsetVertical: number;
    public offsetHorizontal: number;

    /**
     * Local clock time under the camera, read by its UTC clock,
     * or null for the camera following light.
     * @public
     * @type {Date | null}
     */
    public localDateTime: Date | null;

    /**
     * Instant in time the Sun takes its real position at,
     * or null for the camera following light.
     * @public
     * @type {Date | null}
     */
    public dateTime: Date | null;

    protected _useTimeZones: boolean;

    protected _timeZoneProvider: TimeZoneProviderLike | null;
    protected _timeZoneProviderReady: boolean;
    protected _timeZoneProviderLoading: boolean;

    protected _currDate: number;
    protected _prevDate: number;

    protected _redrawDate: number;

    protected _clockPtr: Clock | null;
    protected _lightOn: boolean;
    protected _stopped: boolean;
    protected _f: number;
    protected _k: number;
    protected _sunlightPosition: Vec3;

    protected _localLon: number;
    protected _localLat: number;
    protected _localJd: number;

    constructor(options: ISunParams = {}) {
        super({ autoActivate: true, ...options });

        this._name = "sun";

        this.activationHeight = options.activationHeight || 12079000.0;

        this.offsetVertical = options.offsetVertical || -5000000;

        this.offsetHorizontal = options.offsetHorizontal || 5000000;

        this.localDateTime = options.localDateTime || null;

        this.dateTime = options.dateTime || null;

        this._useTimeZones = options.useTimeZones || false;

        this._timeZoneProvider = options.timeZoneProvider || null;
        this._timeZoneProviderReady = false;
        this._timeZoneProviderLoading = false;
        this._resetTimeZoneProviderState();

        this._localLon = NaN;
        this._localLat = NaN;
        this._localJd = NaN;

        this._sunlightPosition = new Vec3();

        /**
         * Current frame handler clock date and time.
         * @private
         * @type {Number}
         */
        this._currDate = 0;

        /**
         * Previous frame handler clock date and time.
         * @private
         * @type {Number}
         */
        this._prevDate = 0;

        this._redrawDate = 0;

        this._clockPtr = null;

        this._lightOn = false;

        this._f = 0;
        this._k = 0;

        this._stopped = options.stopped || false;
    }

    public override oninit() {
        // sunlight initialization
        const renderer = this.renderer!;
        renderer._lightPosition.set([this._sunlightPosition.x, this._sunlightPosition.y, this._sunlightPosition.z]);

        this.renderer!.events.on("predraw", this._draw, this);

        if (!this._clockPtr) {
            this._clockPtr = this.renderer!.handler.defaultClock;
        }

        this._redrawDate = this._clockPtr.currentDate;

        this._clockPtr.events.on("tick", this._onClockTick, this);
    }

    protected _onClockTick = () => {
        if (!this._clockPtr) return;

        if (Math.abs(this._clockPtr.currentDate - this._redrawDate) > SUN_DATE_THRESHOLD) {
            this._redrawDate = this._clockPtr.currentDate;
            this.renderer!.requestRedraw();
        }
    };

    public stop() {
        this._stopped = true;
        this.deactivate();
    }

    public start() {
        this._stopped = false;
        this.activate();
    }

    public override onactivate() {
        super.onactivate();
        this._stopped = false;
    }

    public bindClock(clock: Clock) {
        this._clockPtr = clock;
    }

    public getPosition(): Vec3 {
        return this._sunlightPosition.clone();
    }

    /**
     * Reads localDateTime by the time zone of the point instead of the solar clock.
     * @public
     * @type {boolean}
     */
    public get useTimeZones(): boolean {
        return this._useTimeZones;
    }

    public set useTimeZones(useTimeZones: boolean) {
        if (this._useTimeZones !== useTimeZones) {
            this._useTimeZones = useTimeZones;
            this.renderer && this.renderer.requestRedraw();
        }
    }

    /**
     * Time zone source for the point under the camera: a function, or an object like
     * TimeZoneProvider — its lazy load is kicked off on first use, and the built-in
     * lookup answers until the data arrives. The built-in lookup when null.
     * @public
     * @type {TimeZoneProviderLike | null}
     */
    public get timeZoneProvider(): TimeZoneProviderLike | null {
        return this._timeZoneProvider;
    }

    public set timeZoneProvider(provider: TimeZoneProviderLike | null) {
        this._timeZoneProvider = provider;
        this._resetTimeZoneProviderState();
        this._localLon = NaN;
        this._localLat = NaN;
        this._localJd = NaN;
        this.renderer && this.renderer.requestRedraw();
    }

    protected _resetTimeZoneProviderState() {
        const p = this._timeZoneProvider;
        this._timeZoneProviderReady = !!p && (typeof p === "function" || !p.load);
        this._timeZoneProviderLoading = false;
    }

    protected _lookupTimeZone(lonLat: LonLat): string | null {
        const p = this._timeZoneProvider;

        if (typeof p === "function") {
            return p(lonLat);
        }

        if (p) {
            if (this._timeZoneProviderReady) {
                return p.lookup(lonLat.lon, lonLat.lat);
            }
            this._loadTimeZoneProvider(p);
        }

        return tzlookup(lonLat.lat, lonLat.lon);
    }

    protected _loadTimeZoneProvider(p: ITimeZoneLookup) {
        if (this._timeZoneProviderLoading) return;

        this._timeZoneProviderLoading = true;

        p.load!()
            .then(() => {
                if (this._timeZoneProvider === p) {
                    this._timeZoneProviderReady = true;
                    this._localLon = NaN;
                    this._localLat = NaN;
                    this._localJd = NaN;
                    this.renderer && this.renderer.requestRedraw();
                }
            })
            .catch((err) => {
                console.warn("Sun: time zone provider failed to load, keeping the built-in lookup.", err);
            });
    }

    /**
     * Sets the local clock time under the camera, read by its UTC clock.
     * @public
     * @param {Date | null} localDateTime - Local date and time, or null to restore the camera following light.
     */
    public setLocalDateTime(localDateTime: Date | null) {
        this.localDateTime = localDateTime;
        this.dateTime = null;
        this._localLon = NaN;
        this._localLat = NaN;
        this._localJd = NaN;
    }

    /**
     * Sets the instant in time the Sun takes its real position at.
     * @public
     * @param {Date | null} dateTime - Instant in time, or null to restore the camera following light.
     */
    public setDateTime(dateTime: Date | null) {
        this.dateTime = dateTime;
        this.localDateTime = null;
    }

    protected _setSunPosition3v(position: Vec3) {
        this._sunlightPosition.copy(position);
        this.renderer!._lightPosition[0] = position.x;
        this.renderer!._lightPosition[1] = position.y;
        this.renderer!._lightPosition[2] = position.z;
    }

    /**
     * Returns a light position offset from the camera along its own up and right axes,
     * so that nearby terrain is lit regardless of the real Sun direction.
     * @protected
     * @param {PlanetCamera} cam - Planet camera.
     * @returns {Vec3} -
     */
    protected _getCameraFollowingPosition(cam: PlanetCamera): Vec3 {
        let n = cam.eye.normal(),
            u = cam.getForward();

        u.scale(Math.sign(cam.getUp().dot(n))); // up

        if (cam.slope > 0.99) {
            u = cam.getUp();
        }

        let tu = Vec3.proj_b_to_plane(u, n, u).normalize().scale(this.offsetVertical);
        let tr = Vec3.proj_b_to_plane(cam.getRight(), n, cam.getRight()).normalize().scale(this.offsetHorizontal); // right

        let d = tu.add(tr);
        return cam.eye.add(d);
    }

    /**
     * Returns the julian date at which the clock of the given one, read as UTC, is the local apparent
     * solar time at lon. Local mean solar time is the first guess, then the measured subsolar longitude
     * corrects it; that point drifts -360 degrees a day, so a residual of d degrees is worth -d / 360
     * of a day.
     * @protected
     * @param {JulianDate} utc - Julian date to take the clock of.
     * @param {number} lon - Longitude under the camera, degrees.
     * @returns {JulianDate} -
     */
    protected _getSolarJulian(utc: JulianDate, lon: number): JulianDate {
        let hours = ((utc + 0.5) % 1.0) * 24.0;

        let jd = utc - lon / 360.0;

        let subsolarLon = lon - (hours - 12.0) * 15.0;

        for (let i = 0; i < 2; i++) {
            let sun = getSunPosition(jd);
            jd -= math.norm_lon(subsolarLon - Math.atan2(sun.y, sun.x) * math.DEGREES) / 360.0;
        }

        return jd;
    }

    protected _localDateTimeToUtc(lonLat: LonLat): Date | null {
        const zone = this._lookupTimeZone(lonLat);

        if (!zone) return null;

        const parts = new Intl.DateTimeFormat("en-US", {
            timeZone: zone,
            timeZoneName: "longOffset"
        }).formatToParts(this.localDateTime!);

        const name = parts.find((p) => p.type === "timeZoneName")?.value;

        if (name === "GMT") {
            return this.localDateTime;
        }

        const m = name?.match(/GMT([+-])(\d{1,2}):?(\d{2})?/);

        if (!m) {
            return null;
        }

        const offsetMs = (m[1] === "-" ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3] || 0)) * 60000;

        return new Date(this.localDateTime!.getTime() - offsetMs);
    }

    /**
     * Returns the Sun position for localDateTime at the location under the camera:
     * the real position at the civil instant with useTimeZones, the solar reading otherwise.
     * @protected
     * @param {PlanetCamera} cam - Planet camera.
     * @returns {Vec3} -
     */
    protected _getLocalDateTimePosition(cam: PlanetCamera): Vec3 {
        const lonLat = cam.getLonLat();

        if (this._useTimeZones) {
            const lon = Math.round(lonLat.lon * 4) / 4;
            const lat = Math.round(lonLat.lat * 4) / 4;

            if (lon !== this._localLon || lat !== this._localLat) {
                this._localLon = lon;
                this._localLat = lat;
                const utc = this._localDateTimeToUtc(lonLat);
                this._localJd = utc ? DateToUTC(utc) : NaN;
            }

            if (!Number.isNaN(this._localJd)) {
                return getSunPosition(this._localJd);
            }
        }

        return getSunPosition(this._getSolarJulian(DateToUTC(this.localDateTime!), lonLat.lon));
    }

    protected _draw() {
        if (!this._clockPtr) return;
        this._currDate = this._clockPtr.currentDate;

        if (this.dateTime) {
            this._setSunPosition3v(getSunPosition(DateToUTC(this.dateTime)));
            return;
        }

        if (this.localDateTime) {
            this._setSunPosition3v(this._getLocalDateTimePosition(this.planet!.camera));
            return;
        }

        if (!this._stopped) {
            let cam = this.planet!.camera;
            if (cam.getHeight() < this.activationHeight || !this._active) {
                this._lightOn = true;
                this._f = 1;

                let pos = this._getCameraFollowingPosition(cam);

                if (this._k > 0) {
                    this.renderer!.requestRedraw();
                    this._k -= 0.001;
                    let rot = Quat.getRotationBetweenVectors(this._sunlightPosition.normal(), pos.normal());
                    let r = rot.slerp(Quat.IDENTITY, this._k).normalize();
                    this._setSunPosition3v(r.mulVec3(this._sunlightPosition));
                } else {
                    this._setSunPosition3v(pos);
                }
            } else {
                this._k = 1;
                if (this._f > 0) {
                    this.renderer!.requestRedraw();
                    this._f -= 0.001;
                    let rot = Quat.getRotationBetweenVectors(
                        this._sunlightPosition.normal(),
                        getSunPosition(this._currDate).normal()
                    );
                    let r = rot.slerp(Quat.IDENTITY, this._f).normalize();
                    this._setSunPosition3v(r.mulVec3(this._sunlightPosition));
                } else {
                    if (
                        (Math.abs(this._currDate - this._prevDate) > SUN_DATE_THRESHOLD && this._active) ||
                        this._lightOn
                    ) {
                        this._lightOn = false;
                        this._prevDate = this._currDate;
                        this._setSunPosition3v(getSunPosition(this._currDate));
                        this._f = 0;
                    }
                }
            }
        } else {
            this._setSunPosition3v(getSunPosition(this._currDate));
        }
    }
}