Islamic_clock / solar_engine.js
Asem75's picture
Upload 3 files
e8b8982 verified
Raw History Blame Contribute Delete
7.13 kB
// 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)<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){
const tolerance=Number(solver?.time_tolerance_ms),sample=Number(solver?.derivative_sample_ms);
if(!(tolerance>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};
})();