@@ -104,11 +104,15 @@ function computeGradientLayers(
104104/**
105105 * Compute kernel density at each horizon for the density heatmap.
106106 *
107- * Uses 0.6x Silverman bandwidth to preserve multimodal/skewed structure
108- * that the standard rule oversmooths. Normalization is global across all
109- * horizons so the density spreading with horizon is visible. A power-law
110- * (sqrt) mapping compresses the dynamic range so low-density tails are
111- * visible while peaks aren't saturated.
107+ * Uses 0.6x Silverman bandwidth to preserve multimodal/skewed structure.
108+ * Per-column CDF normalization: each cell's density is mapped to its rank
109+ * within the column (like a topographic contour map). This guarantees
110+ * strong contrast — the peak is always 1.0, the tails always fade to 0,
111+ * and the shape of the density (skew, bimodality) is visible through
112+ * where the contour boundaries fall, not through subtle opacity differences.
113+ *
114+ * A global dimming factor scales each column by its peak density relative
115+ * to the global max, so uncertainty growth with horizon is still visible.
112116 */
113117function computeDensityGrid (
114118 samplePaths : number [ ] [ ] ,
@@ -117,20 +121,21 @@ function computeDensityGrid(
117121 gridRes : number = 60 ,
118122) : { price : number ; density : number } [ ] [ ] {
119123 const numHorizons = samplePaths [ 0 ] ?. length ?? 0 ;
120- const result : { price : number ; density : number } [ ] [ ] = [ ] ;
124+ const rawColumns : { price : number ; density : number } [ ] [ ] = [ ] ;
121125 const step = ( yMax - yMin ) / gridRes ;
122126
123127 let globalMax = 0 ;
128+ const colMaxes : number [ ] = [ ] ;
124129
125130 for ( let h = 0 ; h < numHorizons ; h ++ ) {
126131 const vals = samplePaths . map ( ( p ) => p [ h ] ) ;
127132 const mean = vals . reduce ( ( a , b ) => a + b , 0 ) / vals . length ;
128133 const std = Math . sqrt (
129134 vals . reduce ( ( s , v ) => s + ( v - mean ) ** 2 , 0 ) / vals . length ,
130135 ) ;
131- // 0.6x Silverman: narrower kernel preserves skew, bimodality, heavy tails
132136 const bandwidth = 0.6 * 1.06 * std * Math . pow ( vals . length , - 0.2 ) ;
133137 const column : { price : number ; density : number } [ ] = [ ] ;
138+ let colMax = 0 ;
134139 for ( let i = 0 ; i <= gridRes ; i ++ ) {
135140 const price = yMin + i * step ;
136141 let density = 0 ;
@@ -139,20 +144,42 @@ function computeDensityGrid(
139144 density += Math . exp ( - 0.5 * u * u ) ;
140145 }
141146 column . push ( { price, density } ) ;
142- if ( density > globalMax ) globalMax = density ;
147+ if ( density > colMax ) colMax = density ;
143148 }
144- result . push ( column ) ;
149+ rawColumns . push ( column ) ;
150+ colMaxes . push ( colMax ) ;
151+ if ( colMax > globalMax ) globalMax = colMax ;
145152 }
146153
147- // Global normalization + power-law compression (sqrt)
148- // Global: density spreading with horizon is visible (near-term is brighter)
149- // Sqrt: compresses dynamic range so tails are visible, peaks aren't saturated
150- if ( globalMax > 0 ) {
151- for ( const column of result ) {
152- for ( const cell of column ) {
153- cell . density = Math . sqrt ( cell . density / globalMax ) ;
154+ // Per-column CDF normalization: rank each cell within its column.
155+ // Then scale by column's peak relative to global max (horizon dimming).
156+ const result : { price : number ; density : number } [ ] [ ] = [ ] ;
157+ for ( let h = 0 ; h < rawColumns . length ; h ++ ) {
158+ const column = rawColumns [ h ] ;
159+ const colMax = colMaxes [ h ] ;
160+
161+ // Collect non-zero densities, sort ascending for CDF lookup
162+ const nonZero = column . map ( ( c ) => c . density ) . filter ( ( d ) => d > 0 ) . sort ( ( a , b ) => a - b ) ;
163+
164+ // Horizon dimming: columns with less peaked density (far horizons) get dimmer
165+ const horizonScale = globalMax > 0 ? Math . pow ( colMax / globalMax , 0.4 ) : 1 ;
166+
167+ const normalized : { price : number ; density : number } [ ] = [ ] ;
168+ for ( const cell of column ) {
169+ if ( cell . density <= 0 || nonZero . length === 0 ) {
170+ normalized . push ( { price : cell . price , density : 0 } ) ;
171+ continue ;
172+ }
173+ // CDF rank: fraction of non-zero cells with density <= this cell
174+ let rank = 0 ;
175+ for ( let k = 0 ; k < nonZero . length ; k ++ ) {
176+ if ( nonZero [ k ] <= cell . density ) rank = ( k + 1 ) / nonZero . length ;
177+ else break ;
154178 }
179+ // Apply horizon dimming
180+ normalized . push ( { price : cell . price , density : rank * horizonScale } ) ;
155181 }
182+ result . push ( normalized ) ;
156183 }
157184
158185 return result ;
@@ -754,13 +781,18 @@ export function FanChart({
754781 const children : Record < string , unknown > [ ] = [ ] ;
755782 for ( let ci = 0 ; ci < column . length - 1 ; ci ++ ) {
756783 const d = column [ ci ] . density ;
757- if ( d < 0.02 ) continue ; // skip near-zero density
784+ if ( d < 0.05 ) continue ; // skip low-rank cells for cleaner edges
758785 const priceLo = column [ ci ] . price ;
759786 const priceHi = column [ ci + 1 ] . price ;
760787 const topLeft = api . coord ( [ xIdx , priceHi ] ) ;
761788 const bottomRight = api . coord ( [ xIdx , priceLo ] ) ;
762789 const h = Math . abs ( bottomRight [ 1 ] - topLeft [ 1 ] ) ;
763790
791+ // Steep ramp: d^2 maps CDF rank to opacity.
792+ // Rank 0.5 (median density) → 0.25 * 0.7 = 0.175 opacity
793+ // Rank 1.0 (peak) → 1.0 * 0.7 = 0.70 opacity
794+ // Gives sharp contrast between core and tails.
795+ const opacity = d * d * 0.7 ;
764796 children . push ( {
765797 type : "rect" as const ,
766798 shape : {
@@ -770,7 +802,7 @@ export function FanChart({
770802 height : Math . max ( h , 1 ) ,
771803 } ,
772804 style : {
773- fill : bandColor + `${ ( d * 0.55 ) . toFixed ( 3 ) } )` ,
805+ fill : bandColor + `${ opacity . toFixed ( 3 ) } )` ,
774806 } ,
775807 } ) ;
776808 }
0 commit comments