/* ساعة المسلم - محاكاة الشمس والقمر والظل والقبلة نسخة خفيفة مجمعة للاستخدام المباشر في المتصفح. */ /* ===== 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 });