// Astronomy Engine 2.1.19 API adapter. UTC is used internally. // All display/ruling thresholds are read from fiqh_time_registry.json. globalThis.SolarEngine = (() => { const H=3600000, D=24*H, rad=Math.PI/180; const ready=()=>Boolean(globalThis.Astronomy?.Equator&&globalThis.Astronomy?.Horizon); function position(ms,loc){ if(!ready())return null; const A=globalThis.Astronomy,date=new Date(ms),observer=new A.Observer(loc.lat,loc.lng,loc.height||0); const equator=A.Equator(A.Body.Sun,date,observer,true,true); // No extra refraction here: the registry's -0.833 degree horizon already // incorporates the standard apparent limb/refraction allowance. const horizon=A.Horizon(date,observer,equator.ra,equator.dec,null); return {decl:equator.dec,ra:equator.ra,altitude:horizon.altitude,azimuth:horizon.azimuth}; } function altitude(ms,loc){ return position(ms,loc)?.altitude??null; } function maximum(day,loc){ const guess=day+12*H-loc.lng/15*H;let lo=guess-6*H,hi=guess+6*H; for(let i=0;i<60;i++){const a=lo+(hi-lo)/3,b=hi-(hi-lo)/3;if(altitude(a,loc)0&&sample>0))return null; const guess=day+12*H-loc.lng/15*H; const rate=t=>altitude(t+sample/2,loc)-altitude(t-sample/2,loc); let lo=guess-6*H,hi=guess+6*H; if(!(rate(lo)>0&&rate(hi)<0))return null; for(let i=0;i<80&&hi-lo>tolerance;i++){ const mid=(lo+hi)/2;if(rate(mid)<0)hi=mid;else lo=mid; } if(!(rate(hi)<0))return null; return {at:(lo+hi)/2,after:hi,before:lo,uncertainty_ms:hi-lo}; } function crossing(lo,hi,target,direction,loc){ const rising=direction==='ascending';let prev=lo,pv=altitude(lo,loc)-target; for(let t=lo+5*60000;t<=hi+1;t=Math.min(t+5*60000,hi)){ const val=altitude(t,loc)-target; if(rising?pv<=0&&val>=0:pv>=0&&val<=0){ let a=prev,b=t;for(let j=0;j<36;j++){const m=(a+b)/2,v=altitude(m,loc)-target;if(rising?v<0:v>0)a=m;else b=m;}return(a+b)/2; } if(t===hi)break;prev=t;pv=val; } return null; } function day(day,loc,profile,worship,madhhab){ if(!ready())return {day,boundaries:{},engineUnavailable:true}; const transition=transitTransition(day,loc,profile.solar_solver); const noon=transition?.at??maximum(day,loc), out={}, defs=profile.boundaries; const basic=(id,def)=>{ if(def.op==='maximum')return noon; if(def.op==='post_transit_transition')return transition?.after??null; if(def.op==='altitude')return crossing(def.direction==='ascending'?noon-12*H:noon,def.direction==='ascending'?noon:noon+12*H,def.degrees,def.direction,loc); if(def.op==='altitude_drop')return crossing(noon,noon+12*H,altitude(noon,loc)-def.degrees,'descending',loc); if(def.op==='shadow'){ const peak=altitude(noon,loc);if(peak<=0)return null; const target=Math.atan(1/(def.factor+1/Math.tan(peak*rad)))/rad; return crossing(noon,noon+12*H,target,'descending',loc); } return null; }; Object.entries(defs).forEach(([id,def])=>{out[id]=basic(id,def)}); const nextNoon=maximum(day+D,loc); for(let pass=0;pass<5;pass++)Object.entries(defs).forEach(([id,def])=>{ if(def.op==='alias')out[id]=out[def.boundary]??null; if(def.op==='next_day'){ const target=defs[def.boundary];out[id]=target?.op==='altitude'?crossing(nextNoon-12*H,nextNoon,target.degrees,target.direction,loc):null; } if(def.op==='rule_start'){ const rule=worship.rules.find(r=>r.id===def.rule_id),choice=rule?.madhhab?.[madhhab]; out[id]=out[choice?.start||rule?.common?.start]??null; } if(def.op==='fraction')out[id]=out[def.from]!=null&&out[def.to]!=null?out[def.from]+def.fraction*(out[def.to]-out[def.from]):null; }); return {day,boundaries:out,solar_transition:transition}; } function periods(days,profile,worship,madhhab,styles,scheduleBindings=null){ const out=[]; for(const d of days){const b=d.boundaries; for(const rule of worship.rules){ const state=styles.category_states[rule.category];if(!state)continue; const choice=rule.madhhab?.[madhhab];const common={...rule.common,...(typeof choice==='object'?choice:{}),...profile.rule_time_bindings?.[rule.id]}; if(scheduleBindings&&d.methodUnavailable&&state!==styles.category_states.solar_prayer_restriction)continue; const scheduled=scheduleBindings?.[rule.id];const windows=scheduled?[scheduled]:(typeof choice==='object'&&choice.windows)||rule.windows||[common]; for(const w of windows){ const weekday=['sunday','monday','tuesday','wednesday','thursday','friday','saturday'][new Date(d.day).getUTCDay()]; if((w.exclude_weekdays||common.exclude_weekdays||[]).includes(weekday))continue; const requires=w.requires||common.requires||rule.trigger?.requires; const activation=w.activation||common.activation||(requires?'after_prayer':'boundary'); const startId=w.display_start||common.display_start||w.start||common.start||rule.trigger?.start||profile.relation_bindings[w.start_relation||common.start_relation]; // Preserve the chosen school's outer limit, not a fabricated universal end. const endId=w.end||common.end||w.outer_end||common.outer_end||w.necessity_end||common.necessity_end||rule.trigger?.end; let start=b[startId],end=b[endId]; const estimate=worship.display_estimates?.[w.display_estimate_id]||rule.window_display_estimates?.[w.display_estimate_id]; if(estimate){const center=b[estimate.boundary];if(center!=null){start=center-Number(estimate.before_minutes||0)*60000;end=center+Number(estimate.after_minutes||0)*60000;}} const endEstimate=worship.display_estimates?.[w.display_end_estimate_id||common.display_end_estimate_id]; if(endEstimate){const center=b[endEstimate.boundary];if(center!=null)end=center-Number(endEstimate.before_minutes||0)*60000;} if(start!=null&&end!=null&&end>=start){ const row={id:rule.id,start,end,point:!estimate&&!endEstimate&&(end===start||Boolean(profile.boundaries[endId]?.transition_only)),startId,endId,state,rule,day:d.day,calculationMethod:scheduled?d.calculationMethod:null,conditional:rule.category==='prayer_restriction'||activation==='after_prayer'||Boolean(requires),requires,activation,displayEstimate:estimate||null,displayEndEstimate:endEstimate||null};out.push(row); for(const binding of styles.preference_bindings||[]){ const value=common[binding.field];if(!value)continue; const pair=binding.mode==='end'?[startId,value]:profile.relation_bindings[value]; if(!Array.isArray(pair))continue; const a=b[pair[0]],z=b[pair[1]]; if(a!=null&&z!=null&&z>a&&a>=start&&z<=end)out.push({...row,start:a,end:z,point:false,startId:pair[0],endId:pair[1],state:binding.state,preference:binding.field}); } } } } } return out; } return {ready,position,altitude,maximum,transitTransition,crossing,day,periods}; })();