File size: 10,791 Bytes
b83d0c2
 
 
a27290c
b83d0c2
 
 
 
e8b8982
b83d0c2
 
 
 
 
 
 
 
 
e8b8982
 
b83d0c2
e8b8982
b83d0c2
e8b8982
 
 
a27290c
 
e8b8982
a27290c
b83d0c2
 
 
 
a27290c
 
 
 
b83d0c2
 
 
 
a27290c
b83d0c2
a27290c
b83d0c2
a27290c
 
 
 
 
b83d0c2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b6d4fb0
a27290c
 
b83d0c2
a27290c
 
 
 
 
b83d0c2
 
 
 
 
 
 
 
b6d4fb0
a27290c
b83d0c2
 
 
 
 
 
a27290c
 
 
b83d0c2
 
a27290c
 
 
b83d0c2
a27290c
 
 
 
b83d0c2
 
a27290c
 
b83d0c2
a27290c
 
b83d0c2
a27290c
 
 
 
b83d0c2
 
 
a27290c
b83d0c2
 
 
 
a27290c
b83d0c2
 
 
 
 
 
 
 
 
 
a27290c
 
 
b83d0c2
 
a27290c
 
 
 
 
 
 
 
 
 
 
b83d0c2
 
 
 
a27290c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b83d0c2
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
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
// Astronomy Engine API adapter. UTC is used internally.
// Every threshold and every numeric solver setting is read from fiqh_time_registry.json
// (calculation_profile.solar_solver). Only unit conversions live in this file.
globalThis.SolarEngine = (() => {
 const H=3600000, D=24*H, MIN=60000, TURN=360, rad=Math.PI/(TURN/2);
 const REQUIRED_SOLVER=['time_tolerance_ms','derivative_sample_ms','scan_step_ms','bisect_iterations','maximum_iterations',
  'transition_iterations','noon_search_half_window_ms','crossing_window_ms','alias_passes'];
 let solverCfg=null;
 const ready=()=>Boolean(globalThis.Astronomy?.Equator&&globalThis.Astronomy?.Horizon);
 function readSolver(solver){
  if(!solver)return null;
  return REQUIRED_SOLVER.every(key=>Number(solver[key])>0)?solver:null;
 }
 // Call once with profile.solar_solver; day() also does this automatically.
 function configure(solver){solverCfg=readSolver(solver);return Boolean(solverCfg);}
 const noonGuess=(day,loc)=>day+D/2-loc.lng/TURN*D;
 const observerFor=loc=>new globalThis.Astronomy.Observer(loc.lat,loc.lng,loc.height||0);

 function position(ms,loc){
  if(!ready())return null;
  const A=globalThis.Astronomy,date=new Date(ms),observer=observerFor(loc);
  const equator=A.Equator(A.Body.Sun,date,observer,true,true);
  // No extra refraction here: the registry's sunrise/sunset altitude 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,cfg=solverCfg){
  if(!cfg)return null;
  const guess=noonGuess(day,loc);let lo=guess-cfg.noon_search_half_window_ms,hi=guess+cfg.noon_search_half_window_ms;
  for(let i=0;i<cfg.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,solver=solverCfg){
  const cfg=readSolver(solver);if(!cfg)return null;
  const tolerance=cfg.time_tolerance_ms,sample=cfg.derivative_sample_ms;
  const guess=noonGuess(day,loc);
  const rate=t=>altitude(t+sample/2,loc)-altitude(t-sample/2,loc);
  let lo=guess-cfg.noon_search_half_window_ms,hi=guess+cfg.noon_search_half_window_ms;
  if(!(rate(lo)>0&&rate(hi)<0))return null;
  for(let i=0;i<cfg.transition_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};
 }
 // Fast exact search through Astronomy.SearchAltitude (geometric altitude, same
 // convention as position()); the sampled scan below remains the reference path.
 function searchAltitude(lo,hi,target,direction,loc){
  const A=globalThis.Astronomy;
  try{
   const hit=A.SearchAltitude(A.Body.Sun,observerFor(loc),direction==='ascending'?1:-1,new Date(lo),(hi-lo)/D,target);
   const at=hit?hit.date.getTime():null;
   return {ok:true,at:at!=null&&at>=lo&&at<=hi?at:null};
  }catch{return {ok:false,at:null};}
 }
 function crossing(lo,hi,target,direction,loc,cfg=solverCfg){
  if(!cfg)return null;
  if(cfg.use_astronomy_search_functions&&typeof globalThis.Astronomy?.SearchAltitude==='function'){
   const fast=searchAltitude(lo,hi,target,direction,loc);
   if(fast.ok)return fast.at;
  }
  const step=cfg.scan_step_ms,rising=direction==='ascending';let prev=lo,pv=altitude(lo,loc)-target;
  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<cfg.bisect_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;
 }
 // normal | always_up | always_down, from the registry's sunrise altitude. Lets the UI
 // explain why twilight/sunrise boundaries are unavailable instead of showing nothing.
 function polarState(noon,loc,profile){
  const horizon=profile?.boundaries?.sunrise?.degrees;
  if(!Number.isFinite(horizon)||!Number.isFinite(noon))return null;
  const top=altitude(noon,loc),bottom=altitude(noon+D/2,loc);
  if(top==null||bottom==null)return null;
  return bottom>horizon?'always_up':top<horizon?'always_down':'normal';
 }
 function day(day,loc,profile,worship,madhhab){
  if(!ready())return {day,boundaries:{},engineUnavailable:true,reason:'astronomy_engine_missing'};
  const cfg=readSolver(profile.solar_solver);
  if(!cfg)return {day,boundaries:{},engineUnavailable:true,reason:'solver_config_missing'};
  solverCfg=cfg;
  const transition=transitTransition(day,loc,cfg);
  const noon=transition?.at??maximum(day,loc,cfg), out={}, defs=profile.boundaries, win=cfg.crossing_window_ms;
  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-win:noon,def.direction==='ascending'?noon:noon+win,def.degrees,def.direction,loc,cfg);
   if(def.op==='altitude_drop')return crossing(noon,noon+win,altitude(noon,loc)-def.degrees,'descending',loc,cfg);
   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+win,target,'descending',loc,cfg);
   }
   return null;
  };
  Object.entries(defs).forEach(([id,def])=>{out[id]=basic(id,def)});
  const nextNoon=maximum(day+D,loc,cfg);
  for(let pass=0;pass<cfg.alias_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-win,nextNoon,target.degrees,target.direction,loc,cfg):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;
  });
  const unavailable={};
  Object.entries(defs).forEach(([id,def])=>{if(out[id]==null)unavailable[id]=def.op==='altitude'||def.op==='shadow'||def.op==='next_day'||def.op==='altitude_drop'?'no_crossing':'dependency_missing';});
  return {day,boundaries:out,solar_transition:transition,polar:polarState(noon,loc,profile),unavailable};
 }
 // options.display_estimate_overrides: {[estimateId]:{before_minutes,after_minutes}} (user choice; honoured
 // only for values listed in the estimate's user_choices_before_minutes).
 // options.isRuleActive(rule,dayStamp): lets the calendar layer hide rules that apply only on certain dates.
 function periods(days,profile,worship,madhhab,styles,scheduleBindings=null,options={}){
  const out=[];
  const resolveEstimate=(id,rule)=>{
   if(!id)return null;
   const table={...(worship.display_estimates||{}),...(rule.window_display_estimates||{})};
   let key=id,estimate=table[key];
   if(estimate?.deprecated_alias_of){key=estimate.deprecated_alias_of;estimate=table[key];}
   if(!estimate)return null;
   const override=options.display_estimate_overrides?.[key];
   if(override&&(!estimate.user_choices_before_minutes||estimate.user_choices_before_minutes.includes(Number(override.before_minutes))))return {...estimate,...override,id:key};
   return {...estimate,id:key};
  };
  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;
    if(options.isRuleActive&&!options.isRuleActive(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=['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=resolveEstimate(w.display_estimate_id,rule);
     if(estimate){const center=b[estimate.boundary];if(center!=null){start=center-Number(estimate.before_minutes||0)*MIN;end=center+Number(estimate.after_minutes||0)*MIN;}}
     const endEstimate=resolveEstimate(w.display_end_estimate_id||common.display_end_estimate_id,rule);
     if(endEstimate){const center=b[endEstimate.boundary];if(center!=null)end=center-Number(endEstimate.before_minutes||0)*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;
 }
 return {version:'2.0.0',ready,configure,position,altitude,maximum,transitTransition,crossing,polarState,day,periods};
})();