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));
}
}
}