@@ -55,6 +55,7 @@ document.getElementById('themeToggle').addEventListener('click', toggleTheme);
5555 Form: Enter to submit
5656 ========================================== */
5757document . getElementById ( 'formCard' ) . addEventListener ( 'keydown' , function ( e ) {
58+ if ( e . target && e . target . id === 'citySearch' ) return ;
5859 if ( e . key === 'Enter' ) {
5960 e . preventDefault ( ) ;
6061 go ( ) ;
@@ -78,6 +79,177 @@ function updateHourHint(){
7879document . getElementById ( 'hourConfirmed' ) . addEventListener ( 'change' , updateHourHint ) ;
7980updateHourHint ( ) ;
8081
82+ /* ==========================================
83+ Birth place and time basis
84+ ========================================== */
85+ let LocationState = {
86+ city : null ,
87+ citySearchTimer : null ,
88+ previewTimer : null ,
89+ requestId : 0 ,
90+ previewRequestId : 0 ,
91+ } ;
92+
93+ function getApiBase ( ) {
94+ return document . getElementById ( 'apiUrl' ) . value . replace ( / \/ $ / , '' ) ;
95+ }
96+
97+ function getLocationPayload ( ) {
98+ let longitude = document . getElementById ( 'longitude' ) . value . trim ( ) ;
99+ let timezoneOffset = document . getElementById ( 'timezoneOffset' ) . value . trim ( ) ;
100+ return {
101+ city_id : LocationState . city ? LocationState . city . id : null ,
102+ longitude : longitude === '' ? null : Number ( longitude ) ,
103+ timezone_offset_minutes : timezoneOffset === '' ? null : Number ( timezoneOffset ) ,
104+ requested_time_mode : 'auto' ,
105+ time_accuracy : document . getElementById ( 'timeAccuracy' ) . value ,
106+ } ;
107+ }
108+
109+ function setTimeBasisNote ( text ) {
110+ let note = document . getElementById ( 'timeBasisNote' ) ;
111+ if ( note ) note . textContent = text ;
112+ }
113+
114+ function hideCityOptions ( ) {
115+ let options = document . getElementById ( 'cityOptions' ) ;
116+ let search = document . getElementById ( 'citySearch' ) ;
117+ options . hidden = true ;
118+ search . setAttribute ( 'aria-expanded' , 'false' ) ;
119+ }
120+
121+ function selectCity ( city ) {
122+ LocationState . city = city ;
123+ document . getElementById ( 'citySearch' ) . value = city . name ;
124+ hideCityOptions ( ) ;
125+ queueTimePreview ( ) ;
126+ }
127+
128+ function clearCity ( ) {
129+ LocationState . city = null ;
130+ document . getElementById ( 'citySearch' ) . value = '' ;
131+ hideCityOptions ( ) ;
132+ queueTimePreview ( ) ;
133+ }
134+
135+ function renderCityOptions ( items ) {
136+ let options = document . getElementById ( 'cityOptions' ) ;
137+ let search = document . getElementById ( 'citySearch' ) ;
138+ if ( ! items . length ) {
139+ options . hidden = true ;
140+ search . setAttribute ( 'aria-expanded' , 'false' ) ;
141+ return ;
142+ }
143+ let html = '' ;
144+ for ( let i = 0 ; i < items . length ; i ++ ) {
145+ let city = items [ i ] ;
146+ html += '<button type="button" class="city-option" role="option" aria-selected="false" data-city-id="' + esc ( city . id ) + '">' ;
147+ html += '<span>' + esc ( city . name ) + '</span><small>' + esc ( city . province || '' ) + '</small></button>' ;
148+ }
149+ options . innerHTML = html ;
150+ options . hidden = false ;
151+ search . setAttribute ( 'aria-expanded' , 'true' ) ;
152+ let buttons = options . querySelectorAll ( '.city-option' ) ;
153+ for ( let i = 0 ; i < buttons . length ; i ++ ) {
154+ buttons [ i ] . addEventListener ( 'click' , function ( ) {
155+ for ( let j = 0 ; j < items . length ; j ++ ) {
156+ if ( items [ j ] . id === this . dataset . cityId ) {
157+ selectCity ( items [ j ] ) ;
158+ return ;
159+ }
160+ }
161+ } ) ;
162+ }
163+ }
164+
165+ function searchCities ( ) {
166+ let search = document . getElementById ( 'citySearch' ) ;
167+ let query = search . value . trim ( ) ;
168+ if ( ! query ) {
169+ hideCityOptions ( ) ;
170+ return ;
171+ }
172+ let requestId = ++ LocationState . requestId ;
173+ fetch ( getApiBase ( ) + '/api/locations?q=' + encodeURIComponent ( query ) )
174+ . then ( function ( response ) { return response . ok ? response . json ( ) : { items : [ ] } ; } )
175+ . then ( function ( data ) {
176+ if ( requestId !== LocationState . requestId ) return ;
177+ renderCityOptions ( Array . isArray ( data . items ) ? data . items : [ ] ) ;
178+ } )
179+ . catch ( function ( ) { hideCityOptions ( ) ; } ) ;
180+ }
181+
182+ function scheduleCitySearch ( ) {
183+ let search = document . getElementById ( 'citySearch' ) ;
184+ if ( LocationState . city && search . value !== LocationState . city . name ) LocationState . city = null ;
185+ window . clearTimeout ( LocationState . citySearchTimer ) ;
186+ LocationState . citySearchTimer = window . setTimeout ( searchCities , 160 ) ;
187+ queueTimePreview ( ) ;
188+ }
189+
190+ function timeFieldsAreComplete ( ) {
191+ let fields = [ 'year' , 'month' , 'day' , 'hour' , 'minute' ] ;
192+ for ( let i = 0 ; i < fields . length ; i ++ ) {
193+ let value = document . getElementById ( fields [ i ] ) . value ;
194+ if ( value === '' ) return false ;
195+ }
196+ return true ;
197+ }
198+
199+ function formatTimeBasisPreview ( preview ) {
200+ if ( preview . effective_time_mode !== 'true_solar' ) {
201+ setTimeBasisNote ( '出生地未知:按输入时间排盘,默认采用中国标准时间(UTC+8)。' ) ;
202+ return ;
203+ }
204+ let correction = Number ( preview . solar_correction_minutes || 0 ) ;
205+ let signed = ( correction >= 0 ? '+' : '' ) + correction . toFixed ( 1 ) ;
206+ let city = preview . city ? preview . city . label : '手动经度' ;
207+ let rollover = preview . day_pillar_uses_next_date ? ';23 点后按次日子时取日柱' : '' ;
208+ setTimeBasisNote ( city + ':真太阳时 ' + preview . pillar_time + '(校正 ' + signed + ' 分钟)' + rollover + '。' ) ;
209+ }
210+
211+ function requestTimePreview ( ) {
212+ if ( ! timeFieldsAreComplete ( ) ) return ;
213+ let previewRequestId = ++ LocationState . previewRequestId ;
214+ let payload = getLocationPayload ( ) ;
215+ payload . year = Number ( document . getElementById ( 'year' ) . value ) ;
216+ payload . month = Number ( document . getElementById ( 'month' ) . value ) ;
217+ payload . day = Number ( document . getElementById ( 'day' ) . value ) ;
218+ payload . hour = Number ( document . getElementById ( 'hour' ) . value ) ;
219+ payload . minute = Number ( document . getElementById ( 'minute' ) . value ) ;
220+ fetch ( getApiBase ( ) + '/api/time/preview' , {
221+ method : 'POST' , headers : { 'Content-Type' : 'application/json' } , body : JSON . stringify ( payload ) ,
222+ } ) . then ( function ( response ) {
223+ return response . ok ? response . json ( ) : null ;
224+ } ) . then ( function ( preview ) {
225+ if ( preview && previewRequestId === LocationState . previewRequestId ) formatTimeBasisPreview ( preview ) ;
226+ } ) . catch ( function ( ) {
227+ if ( previewRequestId === LocationState . previewRequestId ) {
228+ setTimeBasisNote ( '出生地未知:按输入时间排盘,默认采用中国标准时间(UTC+8)。' ) ;
229+ }
230+ } ) ;
231+ }
232+
233+ function queueTimePreview ( ) {
234+ window . clearTimeout ( LocationState . previewTimer ) ;
235+ LocationState . previewTimer = window . setTimeout ( requestTimePreview , 180 ) ;
236+ }
237+
238+ document . getElementById ( 'citySearch' ) . addEventListener ( 'input' , scheduleCitySearch ) ;
239+ document . getElementById ( 'citySearch' ) . addEventListener ( 'keydown' , function ( event ) {
240+ if ( event . key !== 'Enter' ) return ;
241+ let options = document . querySelectorAll ( '#cityOptions .city-option' ) ;
242+ if ( ! options . length ) return ;
243+ event . preventDefault ( ) ;
244+ options [ 0 ] . click ( ) ;
245+ } ) ;
246+ document . getElementById ( 'citySearch' ) . addEventListener ( 'blur' , function ( ) { window . setTimeout ( hideCityOptions , 120 ) ; } ) ;
247+ document . getElementById ( 'cityClear' ) . addEventListener ( 'click' , clearCity ) ;
248+ [ 'year' , 'month' , 'day' , 'hour' , 'minute' , 'longitude' , 'timezoneOffset' , 'timeAccuracy' ] . forEach ( function ( id ) {
249+ document . getElementById ( id ) . addEventListener ( 'input' , queueTimePreview ) ;
250+ document . getElementById ( id ) . addEventListener ( 'change' , queueTimePreview ) ;
251+ } ) ;
252+
81253/* ==========================================
82254 Shared app state
83255 ========================================== */
@@ -309,6 +481,14 @@ function _buildChartParams(){
309481 hour_confirmed : document . getElementById ( 'hourConfirmed' ) . checked ,
310482 practical : true , // 公网只显示白话解读,不暴露技术推导
311483 } ) ;
484+ let location = getLocationPayload ( ) ;
485+ if ( location . city_id ) params . set ( 'city_id' , location . city_id ) ;
486+ if ( location . longitude !== null && Number . isFinite ( location . longitude ) ) params . set ( 'longitude' , String ( location . longitude ) ) ;
487+ if ( location . timezone_offset_minutes !== null && Number . isFinite ( location . timezone_offset_minutes ) ) {
488+ params . set ( 'timezone_offset_minutes' , String ( location . timezone_offset_minutes ) ) ;
489+ }
490+ params . set ( 'requested_time_mode' , location . requested_time_mode ) ;
491+ params . set ( 'time_accuracy' , location . time_accuracy ) ;
312492 if ( lnFrom ) params . set ( 'liunian_from' , lnFrom ) ;
313493 if ( lnTo ) params . set ( 'liunian_to' , lnTo ) ;
314494 let lsOverride = sessionStorage . getItem ( 'bazi-life-stage' ) ;
@@ -352,7 +532,7 @@ async function go(){
352532 document . getElementById ( 'copyBtn' ) . style . display = 'none' ;
353533 document . getElementById ( 'reportActions' ) . classList . remove ( 'active' ) ;
354534
355- let api = document . getElementById ( 'apiUrl' ) . value . replace ( / \/ $ / , '' ) ;
535+ let api = getApiBase ( ) ;
356536 let payload = _buildChartRequest ( ) ;
357537 let controller = beginStreamRequest ( 'chart' ) ;
358538
@@ -1437,7 +1617,18 @@ function render(d){
14371617 let daySource = traceability . day_pillar_source === 'override' ? '用户提供的日柱覆盖' : '公式计算' ;
14381618 h += '<details class="evidence-details" style="margin-bottom:16px"><summary>报告依据与边界</summary>' ;
14391619 h += '<div class="evidence-scale-content">' ;
1440- h += '<div>出生时间:' + esc ( input . birth_time || d . birth || '' ) + '(分钟精度)</div>' ;
1620+ let accuracyLabels = { minute : '精确到分钟' , hour : '仅确认时辰' , unknown : '时间不确定' } ;
1621+ let accuracy = accuracyLabels [ input . time_accuracy || input . time_precision ] || '未说明' ;
1622+ let isTrueSolar = input . effective_time_mode === 'true_solar' ;
1623+ let cityLabel = input . city && input . city . label ? input . city . label : '未知' ;
1624+ h += '<div>出生时间:' + esc ( input . birth_time || d . birth || '' ) + '(' + esc ( accuracy ) + ')</div>' ;
1625+ if ( isTrueSolar ) {
1626+ let correction = Number ( input . solar_correction_minutes || 0 ) ;
1627+ let signed = ( correction >= 0 ? '+' : '' ) + correction . toFixed ( 1 ) ;
1628+ h += '<div>排盘时间:' + esc ( input . pillar_time || '' ) + '(' + esc ( cityLabel ) + '真太阳时,校正 ' + esc ( signed ) + ' 分钟)</div>' ;
1629+ } else {
1630+ h += '<div>排盘时间:按输入时间;出生地:' + esc ( cityLabel ) + '(默认 UTC+8)</div>' ;
1631+ }
14411632 h += '<div>日柱来源:' + esc ( daySource ) + ';流年信号:' + esc ( signalSources ) + '</div>' ;
14421633 h += '<div>' + esc ( uncertainty . scope || '传统文化参考,不构成对具体事件的确定性判断' ) + '</div>' ;
14431634 h += '</div></details>' ;
0 commit comments