Skip to content

Commit aa1fe5f

Browse files
that-github-userdavclaude
authored
Density viz: CDF-rank normalization for sharp contrast (#10)
The previous sqrt(density/globalMax) compression made the entire forecast region look uniformly highlighted — cells at 10% peak density got 32% of max opacity, washing out structure. Switch to per-column CDF-rank normalization: each cell's density is mapped to its rank within the column (like topographic contours). Combined with a d^2 * 0.7 opacity curve, this gives sharp contrast: - Peak (rank 1.0): 0.70 opacity - Median cell (rank 0.5): 0.175 opacity - Tail (rank 0.3): 0.063 opacity - Below rank 0.05: culled Asymmetry shows through where contour boundaries fall, not through subtle opacity differences. Horizon dimming (colPeak/globalPeak)^0.4 preserves uncertainty growth visibility. Co-authored-by: dav <dav@ORACLE.gpu-prophet.lan> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e03b243 commit aa1fe5f

File tree

1 file changed

+50
-18
lines changed

1 file changed

+50
-18
lines changed

src/components/charts/FanChart.tsx

Lines changed: 50 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*/
113117
function 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

Comments
 (0)