@@ -959,6 +959,15 @@ function renderOwnBody() {
959959 <div class="chart-wrap" id="ownModelChart"></div>
960960 </div>
961961 </div>
962+ ${ d . hourly ? `
963+ <div class="charts-grid" style="grid-template-columns:1fr" data-toc="小时预测">
964+ <div class="panel chart-card">
965+ <div class="chart-card-head"><div><h3>未来 24 小时预测</h3><div class="chart-sub">今天已消费 ${ cny ( d . hourly . todaySoFar * rate ) } · 全天预计 ≈${ cny ( d . hourly . todayEst * rate ) } · 未来 24h 合计 ≈${ cny ( d . hourly . next24Total * rate ) } ${
966+ d . hourly . backtestWapePct != null ? ` · 24h 总量回测偏差 ±${ d . hourly . backtestWapePct } %` : ""
967+ } </div></div></div>
968+ <div class="chart-wrap" id="ownHourlyChart"></div>
969+ </div>
970+ </div>` : "" }
962971 <div class="charts-grid" data-toc="消费预测">
963972 <div class="panel chart-card">
964973 <div class="chart-card-head"><div><h3>消费预测</h3><div class="chart-sub">${ esc ( fcSub ) } </div></div></div>
@@ -1000,11 +1009,62 @@ function renderOwnBody() {
10001009 drawUsageTrend ( $ ( "#ownTrendChart" ) , buckets ) ;
10011010 drawUsageModels ( $ ( "#ownModelChart" ) , models ) ;
10021011 drawOwnUsers ( $ ( "#ownUserChart" ) , users ) ;
1012+ if ( d . hourly ) {
1013+ drawHourlyChart ( $ ( "#ownHourlyChart" ) ,
1014+ d . hourly . past . map ( ( p ) => ( { ...p , cost : p . cost * rate } ) ) ,
1015+ d . hourly . next . map ( ( p ) => ( { ...p , cost : p . cost * rate , lo : p . lo * rate , hi : p . hi * rate } ) ) ) ;
1016+ }
10031017 drawForecast ( $ ( "#ownForecastChart" ) , d . daily . map ( ( x ) => ( { t : x . t , cost : x . cost * rate } ) ) ,
10041018 fc ? fc . points . map ( ( p ) => ( { ...p , cost : p . cost * rate , lo : p . lo * rate , hi : p . hi * rate } ) ) : null ) ;
10051019 buildOwnToc ( ) ;
10061020}
10071021
1022+ // 小时级:过去 24h 实际(实心柱)+ 未来 24h 预测(浅色柱),中间分界线
1023+ function drawHourlyChart ( wrap , past , next ) {
1024+ const all = [ ...past . map ( ( p ) => ( { ...p , kind : "h" } ) ) , ...next . map ( ( p ) => ( { ...p , kind : "f" } ) ) ] ;
1025+ if ( all . length < 4 ) { wrap . innerHTML = '<div class="chart-empty">小时数据不足</div>' ; return ; }
1026+ const W = 900 , H = 200 , L = 52 , R = 12 , T = 12 , B = 26 ;
1027+ const iw = W - L - R , ih = H - T - B ;
1028+ const n = all . length ;
1029+ const slot = iw / n ;
1030+ const bw = Math . max ( 2 , Math . min ( 16 , slot - 2 ) ) ;
1031+ const maxV = Math . max ( ...all . map ( ( p ) => p . kind === "f" ? Math . max ( p . cost , p . hi || 0 ) : p . cost ) , 0.01 ) ;
1032+ const step = niceStep ( maxV / 3 ) ;
1033+ const yMax = Math . max ( step * Math . ceil ( ( maxV * 1.05 ) / step ) , step ) ;
1034+ const y = ( v ) => T + ( 1 - v / yMax ) * ih ;
1035+
1036+ let grid = "" , labels = "" ;
1037+ for ( let i = 0 ; i * step <= yMax + 1e-9 ; i ++ ) {
1038+ const v = + ( i * step ) . toFixed ( 6 ) ;
1039+ grid += `<line class="chart-grid" x1="${ L } " y1="${ y ( v ) } " x2="${ W - R } " y2="${ y ( v ) } "/>` ;
1040+ labels += `<text class="chart-axis-label" x="${ L - 6 } " y="${ y ( v ) + 3 } " text-anchor="end">¥${ v >= 100 ? Math . round ( v ) : v } </text>` ;
1041+ }
1042+ const hourLabel = ( t ) => `${ String ( new Date ( t ) . getHours ( ) ) . padStart ( 2 , "0" ) } :00` ;
1043+ const cols = all . map ( ( p , i ) => {
1044+ const cx = L + slot * i + slot / 2 ;
1045+ const x0 = cx - bw / 2 ;
1046+ const yTop = y ( p . cost ) ;
1047+ const h = T + ih - yTop ;
1048+ const r = Math . min ( 3 , bw / 2 , h ) ;
1049+ const bar = h <= 0.4 ? "" :
1050+ `<path class="${ p . kind === "f" ? "chart-bar-future" : "chart-bar" } " d="M${ x0 } ,${ ( yTop + r ) . toFixed ( 1 ) } a${ r } ,${ r } 0 0 1 ${ r } ,-${ r } h${ ( bw - 2 * r ) . toFixed ( 1 ) } a${ r } ,${ r } 0 0 1 ${ r } ,${ r } v${ ( h - r ) . toFixed ( 1 ) } h-${ bw } z"/>` ;
1051+ const lb = i % 4 === 0 ? `<text class="chart-axis-label" x="${ cx } " y="${ H - 8 } " text-anchor="middle">${ hourLabel ( p . t ) } </text>` : "" ;
1052+ return `<g>${ bar } ${ lb } <rect class="u-hit" data-i="${ i } " x="${ L + slot * i } " y="${ T } " width="${ slot } " height="${ ih } " fill="transparent"/></g>` ;
1053+ } ) . join ( "" ) ;
1054+ // 「现在」分界线
1055+ const nowX = L + slot * past . length ;
1056+ const divider = `<line class="chart-crosshair" x1="${ nowX } " y1="${ T } " x2="${ nowX } " y2="${ T + ih } "/>
1057+ <text class="chart-axis-label" x="${ nowX + 4 } " y="${ T + 10 } ">现在</text>` ;
1058+
1059+ wrap . innerHTML = `<svg viewBox="0 0 ${ W } ${ H } " role="img" aria-label="未来 24 小时消费预测">${ grid } ${ labels } ${ cols } ${ divider } </svg><div class="chart-tip"></div>` ;
1060+ attachUsageTip ( wrap , ( i ) => {
1061+ const p = all [ i ] ;
1062+ return p . kind === "h"
1063+ ? `<div class="t">${ hourLabel ( p . t ) } (实际)</div><div class="v">${ cny4 ( p . cost ) } </div>`
1064+ : `<div class="t">${ hourLabel ( p . t ) } (预测)</div><div class="v">${ cny4 ( p . cost ) } </div><div class="r"><span>区间</span><b>${ cny ( p . lo ) } ~ ${ cny ( p . hi ) } </b></div>` ;
1065+ } ) ;
1066+ }
1067+
10081068// 右侧悬浮目录:扫描 [data-toc] 区块生成跳转项,滚动时高亮当前位置
10091069function buildOwnToc ( ) {
10101070 document . querySelector ( ".own-toc" ) ?. remove ( ) ;
0 commit comments