Spaces:
Running
Running
Download celestial_simulation.js from Asem75/Islamic_clock: direct link, hf CLI and curl.
- Browser
- Download file 27.7 kB
-
https://huggingface.co/spaces/Asem75/Islamic_clock/resolve/main/celestial_simulation.js
- Command line
-
hf download hf://spaces/Asem75/Islamic_clock/celestial_simulation.js
-
curl -L -o celestial_simulation.js https://huggingface.co/spaces/Asem75/Islamic_clock/resolve/main/celestial_simulation.js
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 | |
| }); | |