Islamic_clock / celestial_simulation.js
Asem75's picture
Rename celestial_simulation_js to celestial_simulation.js
4f6eabc verified
Raw History Blame Contribute Delete
27.7 kB
/* ساعة المسلم - محاكاة الشمس والقمر والظل والقبلة
نسخة خفيفة مجمعة للاستخدام المباشر في المتصفح. */
/* ===== celestial_core.js ===== */
const CELESTIAL_CORE_VERSION = "1.0.0";
const KAABA_LOCATION = Object.freeze({
lat: 21.422487,
lng: 39.826206
});
function toRadians(deg) {
return deg * Math.PI / 180;
}
function toDegrees(rad) {
return rad * 180 / Math.PI;
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function normalizeDegrees(deg) {
return ((deg % 360) + 360) % 360;
}
function shortestAngleDelta(fromDeg, toDeg) {
return ((toDeg - fromDeg + 540) % 360) - 180;
}
function lerp(a, b, t) {
return a + (b - a) * clamp(t, 0, 1);
}
function inverseLerp(a, b, value) {
if (a === b) return 0;
return clamp((value - a) / (b - a), 0, 1);
}
function dayStart(date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0);
}
function addMinutes(date, minutes) {
return new Date(date.getTime() + minutes * 60000);
}
function localMinutes(date) {
return date.getHours() * 60 + date.getMinutes() + date.getSeconds() / 60 + date.getMilliseconds() / 60000;
}
function minutesToDate(day, minutes) {
return addMinutes(dayStart(day), minutes);
}
function julianDay(date) {
return date.getTime() / 86400000 + 2440587.5;
}
function julianCentury(date) {
return (julianDay(date) - 2451545.0) / 36525;
}
function decimalYearDay(date) {
const start = new Date(date.getFullYear(), 0, 0);
return Math.floor((dayStart(date) - start) / 86400000);
}
function interpolateColor(stops, value) {
if (!Array.isArray(stops) || stops.length === 0) return "#000000";
const ordered = [...stops].sort((a, b) => a.at - b.at);
if (value <= ordered[0].at) return ordered[0].color;
if (value >= ordered[ordered.length - 1].at) return ordered[ordered.length - 1].color;
for (let i = 0; i < ordered.length - 1; i += 1) {
const left = ordered[i];
const right = ordered[i + 1];
if (value >= left.at && value <= right.at) {
return mixHex(left.color, right.color, inverseLerp(left.at, right.at, value));
}
}
return ordered[ordered.length - 1].color;
}
function mixHex(a, b, t) {
const ac = parseHex(a);
const bc = parseHex(b);
const r = Math.round(lerp(ac.r, bc.r, t));
const g = Math.round(lerp(ac.g, bc.g, t));
const bl = Math.round(lerp(ac.b, bc.b, t));
return `#${toHex(r)}${toHex(g)}${toHex(bl)}`;
}
function parseHex(hex) {
const clean = String(hex).replace("#", "").trim();
const full = clean.length === 3
? clean.split("").map((c) => c + c).join("")
: clean.padEnd(6, "0").slice(0, 6);
return {
r: parseInt(full.slice(0, 2), 16),
g: parseInt(full.slice(2, 4), 16),
b: parseInt(full.slice(4, 6), 16)
};
}
function toHex(value) {
return clamp(value, 0, 255).toString(16).padStart(2, "0");
}
function projectHorizontalToView({ azimuthDeg, altitudeDeg, headingDeg = 0 }) {
const relativeAz = shortestAngleDelta(headingDeg, azimuthDeg);
const x = clamp(50 + relativeAz / 180 * 50, 0, 100);
const y = clamp(82 - (altitudeDeg + 10) / 100 * 72, 6, 92);
const visible = altitudeDeg > -10 && Math.abs(relativeAz) <= 180;
return { xPercent: x, yPercent: y, relativeAzimuthDeg: relativeAz, visible };
}
function assertLocation(location) {
if (!location || !Number.isFinite(location.lat) || !Number.isFinite(location.lng)) {
throw new Error("A valid location with lat and lng is required.");
}
}
/* ===== solar_engine.js ===== */
class SolarEngine {
static calculate(date = new Date(), location) {
assertLocation(location);
const solar = this.solarTerms(date);
const tzHours = -date.getTimezoneOffset() / 60;
const minutes = localMinutes(date);
const trueSolarTime = normalizeDayMinutes(minutes + solar.equationOfTimeMinutes + 4 * location.lng - 60 * tzHours);
const hourAngleDeg = trueSolarTime / 4 < 0
? trueSolarTime / 4 + 180
: trueSolarTime / 4 - 180;
const latRad = toRadians(location.lat);
const declRad = toRadians(solar.declinationDeg);
const hourRad = toRadians(hourAngleDeg);
const cosZenith = Math.sin(latRad) * Math.sin(declRad)
+ Math.cos(latRad) * Math.cos(declRad) * Math.cos(hourRad);
const zenithDeg = toDegrees(Math.acos(clamp(cosZenith, -1, 1)));
const altitudeDeg = 90 - zenithDeg;
const azNumerator = Math.sin(hourRad);
const azDenominator = Math.cos(hourRad) * Math.sin(latRad) - Math.tan(declRad) * Math.cos(latRad);
const azimuthDeg = normalizeDegrees(toDegrees(Math.atan2(azNumerator, azDenominator)) + 180);
const solarNoonMinutes = this.solarNoonMinutes(date, location, solar.equationOfTimeMinutes);
return {
date,
latitudeDeg: location.lat,
longitudeDeg: location.lng,
altitudeDeg,
azimuthDeg,
zenithDeg,
declinationDeg: solar.declinationDeg,
hourAngleDeg,
equationOfTimeMinutes: solar.equationOfTimeMinutes,
apparentLongitudeDeg: solar.apparentLongitudeDeg,
solarNoon: minutesToDate(date, solarNoonMinutes),
daylight: this.daylightBoundaries(date, location, solar.equationOfTimeMinutes, solar.declinationDeg)
};
}
static solarTerms(date) {
const t = julianCentury(date);
const geomMeanLong = normalizeDegrees(280.46646 + t * (36000.76983 + t * 0.0003032));
const geomMeanAnomaly = 357.52911 + t * (35999.05029 - 0.0001537 * t);
const eccentricity = 0.016708634 - t * (0.000042037 + 0.0000001267 * t);
const meanObliquity = 23 + (26 + ((21.448 - t * (46.815 + t * (0.00059 - t * 0.001813)))) / 60) / 60;
const obliquityCorrection = meanObliquity + 0.00256 * Math.cos(toRadians(125.04 - 1934.136 * t));
const y = Math.tan(toRadians(obliquityCorrection) / 2) ** 2;
const l0 = toRadians(geomMeanLong);
const m = toRadians(geomMeanAnomaly);
const equation = y * Math.sin(2 * l0)
- 2 * eccentricity * Math.sin(m)
+ 4 * eccentricity * y * Math.sin(m) * Math.cos(2 * l0)
- 0.5 * y * y * Math.sin(4 * l0)
- 1.25 * eccentricity * eccentricity * Math.sin(2 * m);
const center = Math.sin(m) * (1.914602 - t * (0.004817 + 0.000014 * t))
+ Math.sin(2 * m) * (0.019993 - 0.000101 * t)
+ Math.sin(3 * m) * 0.000289;
const trueLongitude = geomMeanLong + center;
const apparentLongitude = trueLongitude - 0.00569 - 0.00478 * Math.sin(toRadians(125.04 - 1934.136 * t));
const declination = toDegrees(Math.asin(
Math.sin(toRadians(obliquityCorrection)) * Math.sin(toRadians(apparentLongitude))
));
return {
equationOfTimeMinutes: toDegrees(equation) * 4,
declinationDeg: declination,
apparentLongitudeDeg: normalizeDegrees(apparentLongitude)
};
}
static solarNoonMinutes(date, location, equationOfTimeMinutes) {
const eq = Number.isFinite(equationOfTimeMinutes)
? equationOfTimeMinutes
: this.solarTerms(date).equationOfTimeMinutes;
const tzHours = -date.getTimezoneOffset() / 60;
return 720 - 4 * location.lng - eq + tzHours * 60;
}
static daylightBoundaries(date, location, equationOfTimeMinutes, declinationDeg) {
const decl = Number.isFinite(declinationDeg)
? declinationDeg
: this.solarTerms(date).declinationDeg;
const noon = this.solarNoonMinutes(date, location, equationOfTimeMinutes);
const hourAngle = this.hourAngleForAltitude(location.lat, decl, -0.833);
if (!Number.isFinite(hourAngle)) {
return { sunrise: null, sunset: null, solarNoon: minutesToDate(date, noon), polarState: hourAngle };
}
return {
sunrise: minutesToDate(date, noon - hourAngle * 4),
sunset: minutesToDate(date, noon + hourAngle * 4),
solarNoon: minutesToDate(date, noon),
polarState: "normal"
};
}
static hourAngleForAltitude(latitudeDeg, declinationDeg, altitudeDeg) {
const latRad = toRadians(latitudeDeg);
const declRad = toRadians(declinationDeg);
const altRad = toRadians(altitudeDeg);
const cosH = (Math.sin(altRad) - Math.sin(latRad) * Math.sin(declRad))
/ (Math.cos(latRad) * Math.cos(declRad));
if (cosH > 1) return "never_rises_to_altitude";
if (cosH < -1) return "always_above_altitude";
return toDegrees(Math.acos(clamp(cosH, -1, 1)));
}
static findAltitudeCrossing(date, location, altitudeDeg, direction = "rising") {
const start = dayStart(date);
const step = 5;
let previousTime = start;
let previousValue = this.calculate(previousTime, location).altitudeDeg - altitudeDeg;
for (let minute = step; minute <= 1440; minute += step) {
const currentTime = addMinutes(start, minute);
const currentValue = this.calculate(currentTime, location).altitudeDeg - altitudeDeg;
const rising = currentValue > previousValue;
const directionOk = direction === "any"
|| (direction === "rising" && rising)
|| (direction === "setting" && !rising);
if (directionOk && previousValue * currentValue <= 0) {
return this.refineCrossing(previousTime, currentTime, location, altitudeDeg);
}
previousTime = currentTime;
previousValue = currentValue;
}
return null;
}
static refineCrossing(leftDate, rightDate, location, altitudeDeg) {
let left = leftDate.getTime();
let right = rightDate.getTime();
const leftInitial = this.calculate(new Date(left), location).altitudeDeg - altitudeDeg;
for (let i = 0; i < 28; i += 1) {
const mid = (left + right) / 2;
const midValue = this.calculate(new Date(mid), location).altitudeDeg - altitudeDeg;
if (leftInitial * midValue <= 0) right = mid;
else left = mid;
}
return new Date((left + right) / 2);
}
}
function normalizeDayMinutes(minutes) {
return ((minutes % 1440) + 1440) % 1440;
}
/* ===== moon_engine.js ===== */
const SYNODIC_MONTH_DAYS = 29.530588853;
const KNOWN_NEW_MOON_UTC = Date.UTC(2000, 0, 6, 18, 14, 0);
class MoonEngine {
static calculate(date = new Date(), location) {
assertLocation(location);
const ageDays = this.lunarAgeDays(date);
const phaseFraction = ageDays / SYNODIC_MONTH_DAYS;
const phaseAngleDeg = normalizeDegrees(phaseFraction * 360);
const illumination = (1 - Math.cos(toRadians(phaseAngleDeg))) / 2;
const waxing = ageDays < SYNODIC_MONTH_DAYS / 2;
const equatorial = this.equatorialPosition(date);
const horizontal = this.horizontalPosition(date, location, equatorial);
return {
date,
ageDays,
lunation: phaseFraction,
phaseAngleDeg,
illuminationFraction: clamp(illumination, 0, 1),
waxing,
phaseNameAr: this.phaseName(ageDays),
phaseKey: this.phaseKey(ageDays),
rightAscensionDeg: equatorial.rightAscensionDeg,
declinationDeg: equatorial.declinationDeg,
eclipticLongitudeDeg: equatorial.eclipticLongitudeDeg,
eclipticLatitudeDeg: equatorial.eclipticLatitudeDeg,
altitudeDeg: horizontal.altitudeDeg,
azimuthDeg: horizontal.azimuthDeg,
distanceKmApprox: equatorial.distanceKmApprox
};
}
static lunarAgeDays(date) {
return positiveModulo((date.getTime() - KNOWN_NEW_MOON_UTC) / 86400000, SYNODIC_MONTH_DAYS);
}
static phaseName(ageDays) {
const key = this.phaseKey(ageDays);
return {
new_moon: "محاق",
waxing_crescent: "هلال متزايد",
first_quarter: "تربيع أول",
waxing_gibbous: "أحدب متزايد",
full_moon: "بدر",
waning_gibbous: "أحدب متناقص",
last_quarter: "تربيع أخير",
waning_crescent: "هلال متناقص"
}[key];
}
static phaseKey(ageDays) {
const p = ageDays / SYNODIC_MONTH_DAYS;
if (p < 0.03 || p >= 0.97) return "new_moon";
if (p < 0.22) return "waxing_crescent";
if (p < 0.28) return "first_quarter";
if (p < 0.47) return "waxing_gibbous";
if (p < 0.53) return "full_moon";
if (p < 0.72) return "waning_gibbous";
if (p < 0.78) return "last_quarter";
return "waning_crescent";
}
static equatorialPosition(date) {
const days = julianDay(date) - 2451545.0;
const meanLongitude = normalizeDegrees(218.316 + 13.176396 * days);
const meanAnomaly = normalizeDegrees(134.963 + 13.064993 * days);
const argumentLatitude = normalizeDegrees(93.272 + 13.229350 * days);
const elongation = normalizeDegrees(297.850 + 12.190749 * days);
const longitude = normalizeDegrees(
meanLongitude
+ 6.289 * Math.sin(toRadians(meanAnomaly))
+ 1.274 * Math.sin(toRadians(2 * elongation - meanAnomaly))
+ 0.658 * Math.sin(toRadians(2 * elongation))
+ 0.214 * Math.sin(toRadians(2 * meanAnomaly))
- 0.186 * Math.sin(toRadians(SolarEngine.solarTerms(date).apparentLongitudeDeg))
);
const latitude = 5.128 * Math.sin(toRadians(argumentLatitude))
+ 0.280 * Math.sin(toRadians(meanAnomaly + argumentLatitude))
+ 0.277 * Math.sin(toRadians(meanAnomaly - argumentLatitude));
const distance = 385001 - 20905 * Math.cos(toRadians(meanAnomaly));
const obliquity = 23.439291 - 0.00000036 * days;
const lonRad = toRadians(longitude);
const latRad = toRadians(latitude);
const epsRad = toRadians(obliquity);
const ra = normalizeDegrees(toDegrees(Math.atan2(
Math.sin(lonRad) * Math.cos(epsRad) - Math.tan(latRad) * Math.sin(epsRad),
Math.cos(lonRad)
)));
const dec = toDegrees(Math.asin(
Math.sin(latRad) * Math.cos(epsRad) + Math.cos(latRad) * Math.sin(epsRad) * Math.sin(lonRad)
));
return {
rightAscensionDeg: ra,
declinationDeg: dec,
eclipticLongitudeDeg: longitude,
eclipticLatitudeDeg: latitude,
distanceKmApprox: distance
};
}
static horizontalPosition(date, location, equatorial) {
const lst = localSiderealTimeDeg(date, location.lng);
const hourAngleDeg = normalizeDegrees(lst - equatorial.rightAscensionDeg);
const hourAngleRad = toRadians(hourAngleDeg > 180 ? hourAngleDeg - 360 : hourAngleDeg);
const latRad = toRadians(location.lat);
const decRad = toRadians(equatorial.declinationDeg);
const altitudeRad = Math.asin(
Math.sin(latRad) * Math.sin(decRad)
+ Math.cos(latRad) * Math.cos(decRad) * Math.cos(hourAngleRad)
);
const azimuthDeg = normalizeDegrees(toDegrees(Math.atan2(
Math.sin(hourAngleRad),
Math.cos(hourAngleRad) * Math.sin(latRad) - Math.tan(decRad) * Math.cos(latRad)
)) + 180);
return {
altitudeDeg: toDegrees(altitudeRad),
azimuthDeg
};
}
}
function localSiderealTimeDeg(date, longitudeDeg) {
const jd = julianDay(date);
const d = jd - 2451545.0;
const t = d / 36525;
const gmst = 280.46061837
+ 360.98564736629 * d
+ 0.000387933 * t * t
- t * t * t / 38710000;
return normalizeDegrees(gmst + longitudeDeg);
}
function positiveModulo(value, modulo) {
return ((value % modulo) + modulo) % modulo;
}
/* ===== qibla_engine.js ===== */
class QiblaEngine {
static calculate(location, headingDeg = null, options = {}) {
assertLocation(location);
const qiblaAzimuthDeg = this.bearingToKaaba(location);
const alignmentThresholdDeg = options.alignmentThresholdDeg ?? 4;
const hasHeading = Number.isFinite(headingDeg);
const deltaDeg = hasHeading ? shortestAngleDelta(headingDeg, qiblaAzimuthDeg) : null;
return {
qiblaAzimuthDeg,
headingDeg: hasHeading ? normalizeDegrees(headingDeg) : null,
deltaDeg,
turnDirection: !hasHeading || Math.abs(deltaDeg) <= alignmentThresholdDeg
? "aligned"
: deltaDeg > 0 ? "right" : "left",
aligned: hasHeading ? Math.abs(deltaDeg) <= alignmentThresholdDeg : false,
alignmentThresholdDeg
};
}
static bearingToKaaba(location) {
assertLocation(location);
const lat1 = toRadians(location.lat);
const lat2 = toRadians(KAABA_LOCATION.lat);
const deltaLng = toRadians(KAABA_LOCATION.lng - location.lng);
const y = Math.sin(deltaLng);
const x = Math.cos(lat1) * Math.tan(lat2) - Math.sin(lat1) * Math.cos(deltaLng);
return normalizeDegrees(toDegrees(Math.atan2(y, x)));
}
}
/* ===== shadow_engine.js ===== */
class ShadowEngine {
static calculate(date = new Date(), location, objectHeight = 1) {
assertLocation(location);
const solar = SolarEngine.calculate(date, location);
const altitudeRad = solar.altitudeDeg * Math.PI / 180;
const rawRatio = solar.altitudeDeg <= 0 ? Infinity : 1 / Math.tan(altitudeRad);
const shadowLength = Number.isFinite(rawRatio) ? rawRatio * objectHeight : Infinity;
const noonShadowRatio = this.noonShadowRatio(date, location);
const extraAfterNoonRatio = Number.isFinite(rawRatio) && Number.isFinite(noonShadowRatio)
? Math.max(0, rawRatio - noonShadowRatio)
: Infinity;
return {
date,
objectHeight,
sunAltitudeDeg: solar.altitudeDeg,
sunAzimuthDeg: solar.azimuthDeg,
shadowAzimuthDeg: normalizeDegrees(solar.azimuthDeg + 180),
shadowLengthRatio: rawRatio,
shadowLength,
noonShadowRatio,
extraAfterNoonRatio,
markers: this.shadowMilestones(date, location, noonShadowRatio)
};
}
static noonShadowRatio(date, location) {
const solarNoon = SolarEngine.calculate(date, location).solarNoon;
const noonSolar = SolarEngine.calculate(solarNoon, location);
if (noonSolar.altitudeDeg <= 0) return Infinity;
return 1 / Math.tan(noonSolar.altitudeDeg * Math.PI / 180);
}
static shadowMilestones(date, location, noonShadowRatio = this.noonShadowRatio(date, location)) {
return [1, 2].map((factor) => {
const targetShadowRatio = noonShadowRatio + factor;
const targetAltitudeDeg = toDegrees(Math.atan(1 / targetShadowRatio));
const time = SolarEngine.findAltitudeCrossing(date, location, targetAltitudeDeg, "setting");
return {
factor,
labelAr: factor === 1 ? "ظل المثل" : "ظل المثلين",
targetShadowRatio,
targetAltitudeDeg,
time
};
});
}
}
/* ===== sky_simulation_renderer.js ===== */
class SkySimulationRenderer {
constructor(targets = {}, registry = {}) {
this.targets = targets;
this.registry = registry;
}
render(state) {
if (!state) return;
this.renderSky(state);
this.renderObject("sun", state.sun, state.headingDeg);
this.renderMoon(state.moon, state.headingDeg);
this.renderQibla(state.qibla);
this.renderShadow(state.shadow);
this.renderInfo(state);
}
renderSky(state) {
const container = this.targets.container;
if (!container) return;
const color = this.skyColor(state.sun.altitudeDeg);
const horizon = this.horizonColor(state.sun.altitudeDeg);
container.style.background = `linear-gradient(180deg, ${color} 0%, ${mixHex(color, horizon, 0.55)} 58%, ${horizon} 100%)`;
container.dataset.skyPhase = this.skyPhase(state.sun.altitudeDeg);
}
skyColor(altitudeDeg) {
const stops = this.registry?.sky_color_model?.gradient_stops;
return interpolateColor(stops, altitudeDeg);
}
horizonColor(altitudeDeg) {
const warmth = clamp(1 - Math.abs(altitudeDeg - 2) / 18, 0, 1);
return mixHex("#18345c", "#f1a05c", warmth);
}
skyPhase(altitudeDeg) {
if (altitudeDeg < -12) return "night";
if (altitudeDeg < -1) return "twilight";
if (altitudeDeg < 8) return "horizon";
return "day";
}
renderObject(kind, objectState, headingDeg = 0) {
const element = this.targets[kind];
if (!element || !objectState) return;
const projection = projectHorizontalToView({
azimuthDeg: objectState.azimuthDeg,
altitudeDeg: objectState.altitudeDeg,
headingDeg
});
const objectConfig = this.registry?.objects?.[kind] || {};
const visibleLimit = objectConfig.visible_below_horizon_deg ?? -5;
const visible = objectState.altitudeDeg >= visibleLimit && projection.visible;
element.style.left = `${projection.xPercent}%`;
element.style.top = `${projection.yPercent}%`;
element.style.opacity = visible ? "1" : "0";
element.style.transform = "translate(-50%, -50%)";
element.style.width = `${this.objectSize(kind, objectState.altitudeDeg)}px`;
element.style.height = `${this.objectSize(kind, objectState.altitudeDeg)}px`;
element.hidden = false;
}
renderMoon(moon, headingDeg = 0) {
this.renderObject("moon", moon, headingDeg);
const element = this.targets.moon;
if (!element || !moon) return;
element.title = `${moon.phaseNameAr} - إضاءة ${(moon.illuminationFraction * 100).toFixed(0)}%`;
element.dataset.phase = moon.phaseKey;
element.style.borderRadius = "50%";
element.style.background = this.moonBackground(moon);
element.style.boxShadow = "0 0 18px rgba(255,255,255,.38)";
}
moonBackground(moon) {
const lit = clamp(moon.illuminationFraction, 0, 1);
const light = "#f7f4d8";
const dark = "#1b2340";
const edge = clamp(Math.abs(lit - 0.5) * 100, 6, 48);
if (lit < 0.03) return dark;
if (lit > 0.97) return light;
if (moon.waxing) {
return `radial-gradient(circle at ${50 + edge}% 50%, ${light} 0 48%, ${dark} 52% 100%)`;
}
return `radial-gradient(circle at ${50 - edge}% 50%, ${light} 0 48%, ${dark} 52% 100%)`;
}
renderQibla(qibla) {
const element = this.targets.kaaba;
if (!element || !qibla) return;
const threshold = qibla.alignmentThresholdDeg;
const near = this.registry?.objects?.kaaba?.near_alignment_threshold_deg ?? 12;
const absDelta = qibla.deltaDeg == null ? 999 : Math.abs(qibla.deltaDeg);
element.style.opacity = absDelta <= near ? "1" : ".35";
element.style.transform = `translate(-50%, -50%) rotate(${qibla.deltaDeg ?? 0}deg)`;
element.dataset.aligned = String(qibla.aligned);
element.title = qibla.aligned
? "أنت متجه نحو القبلة"
: `حرّك الهاتف ${qibla.turnDirection === "right" ? "يمينًا" : "يسارًا"} ${(absDelta - threshold).toFixed(0)}° تقريبًا`;
}
renderShadow(shadow) {
const element = this.targets.shadow;
if (!element || !shadow) return;
const length = Number.isFinite(shadow.shadowLengthRatio)
? clamp(shadow.shadowLengthRatio * 38, 6, 220)
: 0;
element.style.width = `${length}px`;
element.style.transform = `rotate(${shadow.shadowAzimuthDeg}deg)`;
element.style.opacity = shadow.sunAltitudeDeg > 0 ? ".42" : "0";
element.title = Number.isFinite(shadow.shadowLengthRatio)
? `طول الظل ${(shadow.shadowLengthRatio).toFixed(2)} من طول الجسم`
: "الشمس تحت الأفق";
}
renderInfo(state) {
const info = this.targets.info;
if (!info) return;
const moonPercent = (state.moon.illuminationFraction * 100).toFixed(0);
const qiblaText = state.qibla.headingDeg == null
? `القبلة ${state.qibla.qiblaAzimuthDeg.toFixed(0)}°`
: state.qibla.aligned
? "القبلة أمامك"
: `فرق القبلة ${Math.abs(state.qibla.deltaDeg).toFixed(0)}°`;
info.textContent = [
`الشمس ${state.sun.altitudeDeg.toFixed(1)}°`,
`القمر ${state.moon.phaseNameAr} ${moonPercent}%`,
qiblaText
].join(" | ");
}
objectSize(kind, altitudeDeg) {
const conf = this.registry?.objects?.[kind] || {};
const min = conf.size_min_px ?? 24;
const max = conf.size_max_px ?? 44;
return lerp(min, max, clamp((altitudeDeg + 5) / 70, 0, 1));
}
}
/* ===== celestial_simulation_controller.js ===== */
class CelestialSimulationController {
constructor({ location, registry = {}, renderer = null, headingProvider = null } = {}) {
this.location = location || null;
this.registry = registry;
this.renderer = renderer instanceof SkySimulationRenderer ? renderer : null;
this.headingProvider = headingProvider;
this.headingDeg = null;
this.lastState = null;
this.timer = null;
}
setLocation(location) {
this.location = location;
return this;
}
setHeading(headingDeg) {
this.headingDeg = Number.isFinite(headingDeg) ? ((headingDeg % 360) + 360) % 360 : null;
return this;
}
snapshot(date = new Date()) {
if (!this.location) throw new Error("Location is required before creating a celestial snapshot.");
const headingDeg = this.headingProvider ? this.headingProvider() : this.headingDeg;
const sun = SolarEngine.calculate(date, this.location);
const moon = MoonEngine.calculate(date, this.location);
const qibla = QiblaEngine.calculate(
this.location,
headingDeg,
{ alignmentThresholdDeg: this.registry?.objects?.kaaba?.alignment_threshold_deg ?? 4 }
);
const shadow = ShadowEngine.calculate(
date,
this.location,
this.registry?.objects?.shadow?.object_height_unit ?? 1
);
const state = {
date,
location: this.location,
headingDeg: Number.isFinite(headingDeg) ? headingDeg : 0,
compassEnabled: Number.isFinite(headingDeg),
sun,
moon,
qibla,
shadow,
view: {
sun: projectHorizontalToView({ azimuthDeg: sun.azimuthDeg, altitudeDeg: sun.altitudeDeg, headingDeg: headingDeg ?? 0 }),
moon: projectHorizontalToView({ azimuthDeg: moon.azimuthDeg, altitudeDeg: moon.altitudeDeg, headingDeg: headingDeg ?? 0 })
}
};
this.lastState = state;
return state;
}
update(date = new Date()) {
const state = this.snapshot(date);
if (this.renderer) this.renderer.render(state);
return state;
}
start({ intervalMs = 60000, dateProvider = () => new Date() } = {}) {
this.stop();
this.update(dateProvider());
this.timer = setInterval(() => this.update(dateProvider()), intervalMs);
return this;
}
stop() {
if (this.timer) clearInterval(this.timer);
this.timer = null;
return this;
}
async enableCompass(onStatus = () => {}) {
if (!window.isSecureContext) {
onStatus("الحساسات تحتاج اتصال HTTPS آمن.");
return false;
}
if (typeof DeviceOrientationEvent === "undefined") {
onStatus("هذا المتصفح لا يوفر حساس اتجاه للجافاسكربت على هذا الجهاز.");
return false;
}
try {
if (typeof DeviceOrientationEvent.requestPermission === "function") {
const permission = await DeviceOrientationEvent.requestPermission();
if (permission !== "granted") {
onStatus("لم يتم منح إذن حساس الاتجاه.");
return false;
}
}
const handler = (event) => {
const heading = readHeading(event);
if (Number.isFinite(heading)) this.setHeading(heading);
};
window.addEventListener("deviceorientationabsolute", handler, true);
window.addEventListener("deviceorientation", handler, true);
onStatus("تم تفعيل اتجاه الهاتف.");
return true;
} catch (error) {
onStatus("تعذر تفعيل حساس الاتجاه.");
return false;
}
}
}
function readHeading(event) {
if (Number.isFinite(event.webkitCompassHeading)) return event.webkitCompassHeading;
if (event.absolute && Number.isFinite(event.alpha)) return 360 - event.alpha;
if (Number.isFinite(event.alpha)) return 360 - event.alpha;
return null;
}
window.CelestialSimulation = Object.freeze({
CELESTIAL_CORE_VERSION,
KAABA_LOCATION,
SolarEngine,
MoonEngine,
QiblaEngine,
ShadowEngine,
SkySimulationRenderer,
CelestialSimulationController
});