Skip to content

Commit 5139840

Browse files
committed
Heatmap legend: density-at-quantile ticks + q toggles free rot
Two related changes: 1. Legend tick labels now show raw density at the quantile position of each color band when equalize is on. Voxel distribution drives the labels rather than linear-interp of [min, max]; e.g. for mp-1000020 the bar now reads `3.29 / 9.14 / 19.59 / 52.23 / 3746.6` instead of `0 / 936 / 1873 / 2810 / 3746` — accurately reflecting that 50% of voxels lie below ~20 and only the top ~25% spans the heavy-tail to 3746. Extracted CDF/quantile logic to `utils/density-quantile.ts`: `computeSortedSamples` runs once per volume in `DensityViewer`, shared by `HeatmapRenderer` (CDF LUT for the shader) and `HeatmapLegend` (`densityAtQuantile` for ticks) so colors and labels stay in sync. 2. `q` toggles between `rot=orbit` and `rot=free`. `shift+r` still cycles through all three; `q` is the fast-switch for the two modes most users will actually pick between.
1 parent 7c3db35 commit 5139840

5 files changed

Lines changed: 109 additions & 40 deletions

File tree

pkgs/core/src/components/DensityViewer.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type { CameraSnapTarget } from './CameraController.tsx'
1818
import { SlicePlane3D } from './SlicePlane3D.tsx'
1919
import { fracToCart } from '../utils/lattice.ts'
2020
import { computeTiles } from '../utils/tiling.ts'
21+
import { computeSortedSamples } from '../utils/density-quantile.ts'
2122
import { volumeMinMax } from '../utils/volume-stats.ts'
2223
import { HeatmapLegend } from './HeatmapLegend.tsx'
2324

@@ -146,6 +147,11 @@ export function DensityViewer({
146147
return computeTiles(volume.lattice, tilePadding, tileFade)
147148
}, [volume.lattice, tilePadding, tileFade])
148149

150+
// Sample-based sorted density distribution. Shared by HeatmapRenderer (CDF
151+
// LUT for histogram equalization) and HeatmapLegend (density-at-quantile
152+
// ticks) so the colorbar labels and shader colors stay in sync.
153+
const sortedSamples = useMemo(() => computeSortedSamples(volume.grid.data), [volume.grid.data])
154+
149155
const center = useMemo(() => {
150156
const c = fracToCart(volume.lattice, [0.5, 0.5, 0.5])
151157
return new Vector3(...c)
@@ -193,7 +199,7 @@ export function DensityViewer({
193199
{glbUrl
194200
? <GlbPreviewRenderer url={glbUrl} opacity={surfaceOpacityOverride ?? opacity} color={surfaceColor ?? undefined} />
195201
: useHeatmap
196-
? <HeatmapRenderer volume={volume} dataMin={dataRange.min} dataMax={dataRange.max} signed={heatmapSigned} opacity={surfaceOpacityOverride ?? heatmapOpacity ?? opacity} gamma={heatmapGamma} lowCutoff={heatmapLowCutoff} stepCount={heatmapStepCount} equalize={heatmapEqualize} clipAtoms={showAtoms} tiles={tiles} tilePadding={tilePadding} tileFade={tileFade} />
202+
? <HeatmapRenderer volume={volume} dataMin={dataRange.min} dataMax={dataRange.max} signed={heatmapSigned} opacity={surfaceOpacityOverride ?? heatmapOpacity ?? opacity} gamma={heatmapGamma} lowCutoff={heatmapLowCutoff} stepCount={heatmapStepCount} equalize={heatmapEqualize} sortedSamples={sortedSamples} clipAtoms={showAtoms} tiles={tiles} tilePadding={tilePadding} tileFade={tileFade} />
197203
: useGpuVolume
198204
? <VolumeRenderer volume={volume} isoLevel={isoLevel} opacity={surfaceOpacityOverride ?? opacity} tiles={tiles} tilePadding={tilePadding} tileFade={tileFade} color={surfaceColor ?? undefined} />
199205
: <IsosurfaceRenderer volume={volume} isoLevel={isoLevel} opacity={surfaceOpacityOverride ?? opacity} tiles={tiles} tilePadding={tilePadding} tileFade={tileFade} color={surfaceColor ?? undefined} />
@@ -230,6 +236,8 @@ export function DensityViewer({
230236
gamma={heatmapGamma ?? 2.5}
231237
lowCutoff={heatmapLowCutoff ?? 0}
232238
units={heatmapUnits}
239+
equalize={heatmapEqualize}
240+
sortedSamples={sortedSamples}
233241
/>
234242
)}
235243
</div>

pkgs/core/src/components/HeatmapLegend.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useMemo } from 'react'
22
import { turbo, diverging } from '../utils/colormap.ts'
3+
import { densityAtQuantile } from '../utils/density-quantile.ts'
34

45
interface HeatmapLegendProps {
56
dataMin: number
@@ -9,6 +10,11 @@ interface HeatmapLegendProps {
910
gamma: number
1011
lowCutoff: number
1112
units?: string
13+
/** When true (and `sortedSamples` provided), tick labels show density at the
14+
* quantile position of the colorbar (so each color band spans equal voxel
15+
* count). When false, ticks are linear interpolation of `[dataMin, dataMax]`. */
16+
equalize?: boolean
17+
sortedSamples?: Float32Array
1218
}
1319

1420
const BAR_WIDTH = 22
@@ -25,7 +31,7 @@ function formatValue(v: number): string {
2531
return v.toFixed(1)
2632
}
2733

28-
export function HeatmapLegend({ dataMin, dataMax, signed, gamma, lowCutoff, units }: HeatmapLegendProps) {
34+
export function HeatmapLegend({ dataMin, dataMax, signed, gamma, lowCutoff, units, equalize, sortedSamples }: HeatmapLegendProps) {
2935
const gradient = useMemo(() => {
3036
// CSS linear-gradient: bottom (0%, low) → top (100%, high). Alpha follows the
3137
// shader's `pow(v, gamma)` curve so the legend visually matches the rendering.
@@ -44,12 +50,19 @@ export function HeatmapLegend({ dataMin, dataMax, signed, gamma, lowCutoff, unit
4450

4551
const ticks = useMemo(() => {
4652
const out: { fraction: number; value: number }[] = []
53+
// In equalize mode, each visual fraction `f` corresponds to "voxel at
54+
// quantile f" — so tick labels are the raw density at that quantile.
55+
// In linear mode, labels are the linear interpolation across [min, max].
56+
const useQuantile = equalize && sortedSamples && !signed
4757
for (let i = 0; i < N_TICKS; i++) {
4858
const fraction = i / (N_TICKS - 1)
49-
out.push({ fraction, value: dataMin + fraction * (dataMax - dataMin) })
59+
const value = useQuantile
60+
? densityAtQuantile(sortedSamples, fraction)
61+
: dataMin + fraction * (dataMax - dataMin)
62+
out.push({ fraction, value })
5063
}
5164
return out
52-
}, [dataMin, dataMax])
65+
}, [dataMin, dataMax, equalize, sortedSamples, signed])
5366

5467
return (
5568
<div

pkgs/core/src/components/HeatmapRenderer.tsx

Lines changed: 12 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { VolumeData } from '../types.ts'
99
import { fracToCart } from '../utils/lattice.ts'
1010
import { getElement } from '../utils/elements.ts'
1111
import type { TileInfo } from '../utils/tiling.ts'
12+
import { buildCDFLUT, identityLUT } from '../utils/density-quantile.ts'
1213

1314
interface HeatmapRendererProps {
1415
volume: VolumeData
@@ -35,6 +36,10 @@ interface HeatmapRendererProps {
3536
* density linear mapping (legacy behavior). Unsigned mode only; signed
3637
* (diff) keeps its diverging mapping. */
3738
equalize?: boolean
39+
/** Sorted samples of raw density values (pre-computed in `DensityViewer`
40+
* via `computeSortedSamples`). Shared with `HeatmapLegend` so tick labels
41+
* match the equalized colormap exactly. */
42+
sortedSamples?: Float32Array
3843
/**
3944
* When true (default), terminate rays that enter an atom sphere. Prevents glow from
4045
* accumulating past atoms (visible artifact when atoms are opaque). Set false to render
@@ -263,36 +268,9 @@ void main() {
263268
}
264269
`
265270

266-
/** Build a 256-entry CDF LUT from the raw density samples. Maps normalized val
267-
* in [0, 1] to its quantile position. Sample-based for perf (deterministic
268-
* stride; LUT depends only on `data` and the [dataMin, dataMax] window). */
269-
function computeCDFLUT(data: Float32Array, dataMin: number, dataMax: number, lutSize = 256, sampleTarget = 16384): Float32Array {
270-
const range = dataMax - dataMin || 1
271-
const stride = Math.max(1, Math.floor(data.length / sampleTarget))
272-
const nSamples = Math.floor(data.length / stride)
273-
const samples = new Float32Array(nSamples)
274-
for (let i = 0, j = 0; i < nSamples; i++, j += stride) {
275-
samples[i] = (data[j] - dataMin) / range
276-
}
277-
samples.sort()
278-
const lut = new Float32Array(lutSize)
279-
for (let i = 0; i < lutSize; i++) {
280-
const v = i / (lutSize - 1)
281-
// Binary search for first sample >= v.
282-
let lo = 0, hi = nSamples
283-
while (lo < hi) {
284-
const mid = (lo + hi) >>> 1
285-
if (samples[mid] < v) lo = mid + 1
286-
else hi = mid
287-
}
288-
lut[i] = lo / Math.max(nSamples - 1, 1)
289-
}
290-
return lut
291-
}
292-
293271
export function HeatmapRenderer({
294272
volume, dataMin, dataMax, signed = false, opacity, gamma = 2.5, lowCutoff = 0, stepCount = 256,
295-
clipAtoms = true, equalize = true,
273+
clipAtoms = true, equalize = true, sortedSamples,
296274
tiles: _tiles, tilePadding = 0, tileFade = 1,
297275
}: HeatmapRendererProps) {
298276
const { camera } = useThree()
@@ -316,19 +294,17 @@ export function HeatmapRenderer({
316294
return tex
317295
}, [data, dims, dataMin, dataMax])
318296

319-
// CDF LUT for histogram equalization. Computed once per dataset; safe to
320-
// skip if `equalize=false` (uniform-identity LUT is cheap to keep around).
297+
// CDF LUT for histogram equalization. Uses externally-computed sortedSamples
298+
// (shared with HeatmapLegend) so tick labels and shader colors stay in sync.
321299
const lutTexture = useMemo(() => {
322-
const lut = equalize ? computeCDFLUT(data, dataMin, dataMax) : (() => {
323-
const id = new Float32Array(256)
324-
for (let i = 0; i < 256; i++) id[i] = i / 255
325-
return id
326-
})()
300+
const lut = (equalize && sortedSamples)
301+
? buildCDFLUT(sortedSamples, dataMin, dataMax)
302+
: identityLUT()
327303
const tex = new DataTexture(lut, lut.length, 1, RedFormat, FloatType, UVMapping,
328304
ClampToEdgeWrapping, ClampToEdgeWrapping, LinearFilter, LinearFilter)
329305
tex.needsUpdate = true
330306
return tex
331-
}, [data, dataMin, dataMax, equalize])
307+
}, [sortedSamples, dataMin, dataMax, equalize])
332308

333309
const cartToFrac = useMemo(() => {
334310
const L = volume.lattice
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/** Sample-based density-distribution analysis for histogram equalization
2+
* (shader CDF LUT) and the colorbar legend (density-at-quantile ticks). Both
3+
* consume the same sorted-samples array so the legend's tick labels exactly
4+
* correspond to the colors the shader paints. */
5+
6+
/** Stride-sample the volume and return raw density values sorted ascending.
7+
* Cheap to call (~16k samples by default, one sort, no normalization). */
8+
export function computeSortedSamples(data: Float32Array, sampleTarget = 16384): Float32Array {
9+
const stride = Math.max(1, Math.floor(data.length / sampleTarget))
10+
const n = Math.max(1, Math.floor(data.length / stride))
11+
const samples = new Float32Array(n)
12+
for (let i = 0, j = 0; i < n && j < data.length; i++, j += stride) {
13+
samples[i] = data[j]
14+
}
15+
samples.sort()
16+
return samples
17+
}
18+
19+
/** Build a 256-entry CDF LUT from sorted raw-density samples. Maps
20+
* normalized val (in `[0, 1]` over the `[dataMin, dataMax]` window) to its
21+
* quantile position in the empirical distribution. */
22+
export function buildCDFLUT(
23+
sortedSamples: Float32Array,
24+
dataMin: number,
25+
dataMax: number,
26+
lutSize = 256,
27+
): Float32Array {
28+
const range = dataMax - dataMin || 1
29+
const lut = new Float32Array(lutSize)
30+
const n = sortedSamples.length
31+
for (let i = 0; i < lutSize; i++) {
32+
const v = i / (lutSize - 1)
33+
const rawV = v * range + dataMin
34+
let lo = 0, hi = n
35+
while (lo < hi) {
36+
const mid = (lo + hi) >>> 1
37+
if (sortedSamples[mid] < rawV) lo = mid + 1
38+
else hi = mid
39+
}
40+
lut[i] = lo / Math.max(n - 1, 1)
41+
}
42+
return lut
43+
}
44+
45+
/** Identity LUT — used when histogram equalization is disabled. */
46+
export function identityLUT(lutSize = 256): Float32Array {
47+
const lut = new Float32Array(lutSize)
48+
for (let i = 0; i < lutSize; i++) lut[i] = i / (lutSize - 1)
49+
return lut
50+
}
51+
52+
/** Density value at the given quantile position `q ∈ [0, 1]` in the
53+
* sorted-samples distribution. Linear interpolation between adjacent samples. */
54+
export function densityAtQuantile(sortedSamples: Float32Array, q: number): number {
55+
const n = sortedSamples.length
56+
if (n === 0) return 0
57+
if (q <= 0) return sortedSamples[0]
58+
if (q >= 1) return sortedSamples[n - 1]
59+
const idx = q * (n - 1)
60+
const lo = Math.floor(idx)
61+
const hi = Math.min(lo + 1, n - 1)
62+
const t = idx - lo
63+
return sortedSamples[lo] * (1 - t) + sortedSamples[hi] * t
64+
}

pkgs/static/src/App.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1113,6 +1113,14 @@ export default function App() {
11131113
setRotMode(next)
11141114
},
11151115
})
1116+
useAction('nav:toggle-quat-rot', {
1117+
label: 'Toggle quaternion rotation',
1118+
description: 'Toggle between orbit (default) and free (quaternion) rotation mode',
1119+
keywords: ['rotate', 'rotation', 'quaternion', 'free', 'pole', 'q'],
1120+
group: 'Camera',
1121+
defaultBindings: ['q'],
1122+
handler: () => setRotMode(rotMode === 'free' ? 'orbit' : 'free'),
1123+
})
11161124

11171125
// Auto-restore on mount: ?m= param (OPFS cache → fetch) or last active OPFS volume
11181126
const initialMaterialId = useRef(materialId)

0 commit comments

Comments
 (0)