Spaces:
Running
Running
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};
})();
|