File size: 10,019 Bytes
bfbbc01
 
a27290c
bfbbc01
 
 
 
 
 
 
e8b8982
 
 
bfbbc01
e8b8982
bfbbc01
e8b8982
 
 
a27290c
 
e8b8982
a27290c
bfbbc01
 
 
 
a27290c
 
 
 
bfbbc01
 
 
 
 
 
a27290c
bfbbc01
a27290c
bfbbc01
a27290c
 
 
 
 
bfbbc01
 
 
 
b6d4fb0
a27290c
 
bfbbc01
a27290c
 
 
 
 
9c0437f
 
 
bfbbc01
 
 
 
 
 
 
9c0437f
 
 
 
 
 
a27290c
 
 
9c0437f
bfbbc01
a27290c
 
 
bfbbc01
a27290c
 
 
 
bfbbc01
 
a27290c
 
9c0437f
a27290c
 
b83d0c2
a27290c
 
 
 
bfbbc01
 
9c0437f
a27290c
bfbbc01
 
 
b83d0c2
bfbbc01
 
 
 
 
b83d0c2
bfbbc01
a27290c
 
 
bfbbc01
 
 
 
 
b83d0c2
a27290c
 
 
bfbbc01
a27290c
 
 
 
 
 
 
bfbbc01
 
 
 
a27290c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bfbbc01
 
a27290c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
// Astronomy Engine 2.1.19 API adapter. UTC is used internally.
// All display/ruling thresholds are read from fiqh_time_registry.json.
globalThis.SolarEngine = (() => {
 // Unit conversions only. Every tolerance, step and iteration count comes from
 // profile.solar_solver in fiqh_time_registry.json (see configure()).
 const H=3600000, D=24*H, rad=Math.PI/180, MIN=60000;
 const WEEKDAYS=['sunday','monday','tuesday','wednesday','thursday','friday','saturday'];
 let solver=null,api=null;
 const need=key=>{const v=Number(solver?.[key]);if(!(v>0))throw new Error('solar_solver.'+key+' is missing in fiqh_time_registry.json (SolarEngine.configure(profile.solar_solver))');return v;};
 const configure=config=>{solver=config||null;return api;};
 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 half=need('half_day_hours')*H,bracket=need('noon_bracket_hours')*H;
  const guess=day+half-loc.lng/need('longitude_degrees_per_hour')*H;let lo=guess-bracket,hi=guess+bracket;
  for(let i=0;i<need('maximum_iterations');i++){const a=lo+(hi-lo)/3,b=hi-(hi-lo)/3;if(altitude(a,loc)<altitude(b,loc))lo=a;else hi=b;}
  return (lo+hi)/2;
 }
 // Resolve the sign change of the solar altitude rate. The bracket width is
 // numerical uncertainty, never a religious duration or an offset after noon.
 function transitTransition(day,loc,config){
  if(config)configure(config);
  const tolerance=Number(solver?.time_tolerance_ms),sample=Number(solver?.derivative_sample_ms);
  if(!(tolerance>0&&sample>0))return null;
  const half=need('half_day_hours')*H,bracket=need('noon_bracket_hours')*H;
  const guess=day+half-loc.lng/need('longitude_degrees_per_hour')*H;
  const rate=t=>altitude(t+sample/2,loc)-altitude(t-sample/2,loc);
  let lo=guess-bracket,hi=guess+bracket;
  if(!(rate(lo)>0&&rate(hi)<0))return null;
  for(let i=0;i<need('transit_iterations')&&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 step=need('scan_step_ms'),iterations=need('bisect_iterations');
  const rising=direction==='ascending';let prev=lo,pv=altitude(lo,loc)-target;
  // hi+1: one millisecond of numerical slack so the final bracket end is tested.
  for(let t=lo+step;t<=hi+1;t=Math.min(t+step,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<iterations;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;
 }
 // options.boundaryDegrees: {boundaryId: degrees}. Accepted only when the value is listed in that boundary's
 // allowed_degrees in the registry; the chosen values are returned in degreesUsed.
 function day(day,loc,profile,worship,madhhab,options={}){
  if(!ready())return {day,boundaries:{},engineUnavailable:true};
  configure(profile.solar_solver);
  if(!worship?.madhhabs?.[madhhab])throw new Error('Unknown madhhab: '+madhhab);
  const half=need('half_day_hours')*H;
  const transition=transitTransition(day,loc,profile.solar_solver);
  const noon=transition?.at??maximum(day,loc), out={}, defs=profile.boundaries, warnings=[];
  if(!transition)warnings.push('transit_unresolved');
  const degreesUsed={};
  const chooseDegrees=(id,def)=>{
   const want=options?.boundaryDegrees?.[id];
   const degrees=Number.isFinite(want)&&Array.isArray(def.allowed_degrees)&&def.allowed_degrees.includes(want)?want:def.degrees;
   degreesUsed[id]=degrees;return degrees;
  };
  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-half:noon,def.direction==='ascending'?noon:noon+half,chooseDegrees(id,def),def.direction,loc);
   if(def.op==='altitude_drop')return crossing(noon,noon+half,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+half,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<need('dependency_passes');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-half,nextNoon,chooseDegrees(def.boundary,target),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;
  });
  // Boundaries that could not be solved (for example twilight at high latitude) are reported, never invented.
  const unresolved=Object.keys(defs).filter(id=>out[id]==null);
  return {day,boundaries:out,solar_transition:transition,unresolved,warnings,degreesUsed};
 }
 // options.ruleActive(rule, dayStamp): decides rules with a Hijri calendar_rule (the engine itself
 // resolves weekly/daily rules). options.displayOverrides: {estimateId:{before_minutes}} accepted only
 // when the value is listed in that estimate's allowed_before_minutes.
 function periods(days,profile,worship,madhhab,styles,scheduleBindings=null,options={}){
  const {ruleActive=null,displayOverrides=null}=options||{};
  const lookup=(id,rule)=>id?(worship.display_estimates?.[id]||rule.window_display_estimates?.[id]||null):null;
  const beforeMinutes=(id,estimate)=>{
   const want=displayOverrides?.[id]?.before_minutes,allowed=estimate.allowed_before_minutes;
   return Number.isFinite(want)&&Array.isArray(allowed)&&allowed.includes(want)?want:Number(estimate.before_minutes||0);
  };
  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 calendar=rule.calendar_rule;
    if(calendar){
     if(calendar.type==='weekly'){if(calendar.weekday!==WEEKDAYS[new Date(d.day).getUTCDay()])continue;}
     else if(calendar.type!=='daily'&&!(typeof ruleActive==='function'&&ruleActive(rule,d.day)))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=WEEKDAYS[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 estimateId=w.display_estimate_id,estimate=lookup(estimateId,rule);
     if(estimate){const center=b[estimate.boundary];if(center!=null){start=center-beforeMinutes(estimateId,estimate)*MIN;end=center+Number(estimate.after_minutes||0)*MIN;}}
     const endEstimateId=w.display_end_estimate_id||common.display_end_estimate_id,endEstimate=lookup(endEstimateId,rule);
     if(endEstimate){const center=b[endEstimate.boundary];if(center!=null)end=center-beforeMinutes(endEstimateId,endEstimate)*MIN;}
     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;
 }
 api=Object.freeze({ready,configure,position,altitude,maximum,transitTransition,crossing,day,periods});
 return api;
})();