@holoscript/radio-astronomy-plugin 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/LICENSE +21 -0
- package/__tests__/SpectralCubeViewer.test.ts +129 -0
- package/__tests__/plugin.test.ts +21 -0
- package/dist/bridge/python-runner.d.ts +24 -0
- package/dist/bridge/python-runner.d.ts.map +1 -0
- package/dist/bridge/python-runner.js +43 -0
- package/dist/bridge/python-runner.js.map +1 -0
- package/dist/components/SpectralCubeViewer.d.ts +45 -0
- package/dist/components/SpectralCubeViewer.d.ts.map +1 -0
- package/dist/components/SpectralCubeViewer.js +196 -0
- package/dist/components/SpectralCubeViewer.js.map +1 -0
- package/dist/constants/astronomy-traits.d.ts +6 -0
- package/dist/constants/astronomy-traits.d.ts.map +1 -0
- package/dist/constants/astronomy-traits.js +12 -0
- package/dist/constants/astronomy-traits.js.map +1 -0
- package/dist/fits/FITSParser.d.ts +60 -0
- package/dist/fits/FITSParser.d.ts.map +1 -0
- package/dist/fits/FITSParser.js +230 -0
- package/dist/fits/FITSParser.js.map +1 -0
- package/dist/fits/FITSToGrid.d.ts +27 -0
- package/dist/fits/FITSToGrid.d.ts.map +1 -0
- package/dist/fits/FITSToGrid.js +85 -0
- package/dist/fits/FITSToGrid.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/package.json +33 -0
- package/python/astropy_bridge.py +45 -0
- package/src/bridge/python-runner.ts +62 -0
- package/src/components/SpectralCubeViewer.tsx +310 -0
- package/src/constants/astronomy-traits.ts +13 -0
- package/src/fits/FITSParser.ts +289 -0
- package/src/fits/FITSToGrid.ts +95 -0
- package/src/index.ts +32 -0
- package/tsconfig.json +26 -0
- package/vitest.config.ts +10 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SpectralCubeViewer — Interactive 3D viewer for FITS spectral cubes.
|
|
3
|
+
*
|
|
4
|
+
* Drop a FITS file → see the spectral cube in 3D → slide through
|
|
5
|
+
* frequency channels → play as animation. Zero code required.
|
|
6
|
+
*
|
|
7
|
+
* Works with:
|
|
8
|
+
* - 3D cubes (RA × Dec × Freq): full volumetric slice-by-slice
|
|
9
|
+
* - 2D images (RA × Dec): single-channel colormap
|
|
10
|
+
* - 1D spectra: displayed as a line in 3D space
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
|
|
14
|
+
import { useFrame } from '@react-three/fiber';
|
|
15
|
+
import * as THREE from 'three';
|
|
16
|
+
|
|
17
|
+
// ── Types ────────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
export type ColormapName = 'jet' | 'viridis' | 'turbo' | 'inferno' | 'coolwarm';
|
|
20
|
+
|
|
21
|
+
export interface SpectralCubeViewerProps {
|
|
22
|
+
/** Parsed FITS data array (physical values after BSCALE/BZERO) */
|
|
23
|
+
data: Float32Array;
|
|
24
|
+
/** Axis dimensions [NAXIS1, NAXIS2] or [NAXIS1, NAXIS2, NAXIS3] */
|
|
25
|
+
shape: number[];
|
|
26
|
+
/** Colormap (default: 'viridis') */
|
|
27
|
+
colormap?: ColormapName;
|
|
28
|
+
/** Auto-play through channels (default: false) */
|
|
29
|
+
autoPlay?: boolean;
|
|
30
|
+
/** Playback speed in channels per second (default: 5) */
|
|
31
|
+
playSpeed?: number;
|
|
32
|
+
/** WCS metadata for axis labels */
|
|
33
|
+
wcs?: { ctype?: string[]; cunit?: string[]; crval?: number[]; cdelt?: number[] };
|
|
34
|
+
/** Object name from FITS header */
|
|
35
|
+
objectName?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Colormap GLSL ────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
const COLORMAPS: Record<string, string> = {
|
|
41
|
+
viridis: `
|
|
42
|
+
vec3 colormap(float t) {
|
|
43
|
+
vec3 c0 = vec3(0.267, 0.004, 0.329);
|
|
44
|
+
vec3 c4 = vec3(0.127, 0.566, 0.551);
|
|
45
|
+
vec3 c8 = vec3(0.993, 0.906, 0.144);
|
|
46
|
+
if (t < 0.5) return mix(c0, c4, t * 2.0);
|
|
47
|
+
return mix(c4, c8, (t - 0.5) * 2.0);
|
|
48
|
+
}
|
|
49
|
+
`,
|
|
50
|
+
turbo: `
|
|
51
|
+
vec3 colormap(float t) {
|
|
52
|
+
float r = 0.136 + t * (4.615 + t * (-42.66 + t * (132.13 + t * (-152.55 + t * 56.31))));
|
|
53
|
+
float g = 0.091 + t * (2.264 + t * (-14.02 + t * (32.21 + t * (-29.27 + t * 10.16))));
|
|
54
|
+
float b = 0.107 + t * (12.75 + t * (-60.58 + t * (132.75 + t * (-134.01 + t * 50.26))));
|
|
55
|
+
return clamp(vec3(r, g, b), 0.0, 1.0);
|
|
56
|
+
}
|
|
57
|
+
`,
|
|
58
|
+
inferno: `
|
|
59
|
+
vec3 colormap(float t) {
|
|
60
|
+
vec3 c0 = vec3(0.001, 0.0, 0.014);
|
|
61
|
+
vec3 c3 = vec3(0.735, 0.216, 0.329);
|
|
62
|
+
vec3 c6 = vec3(0.988, 0.999, 0.644);
|
|
63
|
+
if (t < 0.5) return mix(c0, c3, t * 2.0);
|
|
64
|
+
return mix(c3, c6, (t - 0.5) * 2.0);
|
|
65
|
+
}
|
|
66
|
+
`,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const VERT = /* glsl */ `
|
|
70
|
+
attribute float aIntensity;
|
|
71
|
+
uniform float uMin;
|
|
72
|
+
uniform float uMax;
|
|
73
|
+
varying float vNorm;
|
|
74
|
+
|
|
75
|
+
void main() {
|
|
76
|
+
float range = uMax - uMin;
|
|
77
|
+
vNorm = range > 0.0 ? clamp((aIntensity - uMin) / range, 0.0, 1.0) : 0.5;
|
|
78
|
+
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
|
79
|
+
}
|
|
80
|
+
`;
|
|
81
|
+
|
|
82
|
+
function makeFrag(cmName: string): string {
|
|
83
|
+
const cm = COLORMAPS[cmName] ?? COLORMAPS.viridis;
|
|
84
|
+
return /* glsl */ `
|
|
85
|
+
varying float vNorm;
|
|
86
|
+
${cm}
|
|
87
|
+
void main() {
|
|
88
|
+
vec3 c = colormap(vNorm);
|
|
89
|
+
gl_FragColor = vec4(c, 1.0);
|
|
90
|
+
}
|
|
91
|
+
`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Component ────────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
export function SpectralCubeViewer({
|
|
97
|
+
data,
|
|
98
|
+
shape,
|
|
99
|
+
colormap = 'viridis',
|
|
100
|
+
autoPlay = false,
|
|
101
|
+
playSpeed = 5,
|
|
102
|
+
wcs,
|
|
103
|
+
objectName,
|
|
104
|
+
}: SpectralCubeViewerProps) {
|
|
105
|
+
const nx = shape[0] ?? 1;
|
|
106
|
+
const ny = shape[1] ?? 1;
|
|
107
|
+
const nz = shape[2] ?? 1;
|
|
108
|
+
const is3D = shape.length >= 3 && nz > 1;
|
|
109
|
+
|
|
110
|
+
const [channel, setChannel] = useState(0);
|
|
111
|
+
const [playing, setPlaying] = useState(autoPlay);
|
|
112
|
+
const meshRef = useRef<THREE.Mesh>(null);
|
|
113
|
+
const timeRef = useRef(0);
|
|
114
|
+
|
|
115
|
+
// Extract channel slice
|
|
116
|
+
const channelData = useMemo(() => {
|
|
117
|
+
if (!is3D) return data; // 2D — use entire dataset
|
|
118
|
+
const offset = channel * nx * ny;
|
|
119
|
+
return data.slice(offset, offset + nx * ny);
|
|
120
|
+
}, [data, channel, nx, ny, is3D]);
|
|
121
|
+
|
|
122
|
+
// Data range for colormap normalization
|
|
123
|
+
const [min, max] = useMemo(() => {
|
|
124
|
+
let lo = Infinity, hi = -Infinity;
|
|
125
|
+
for (let i = 0; i < channelData.length; i++) {
|
|
126
|
+
if (channelData[i] < lo) lo = channelData[i];
|
|
127
|
+
if (channelData[i] > hi) hi = channelData[i];
|
|
128
|
+
}
|
|
129
|
+
return [lo, hi];
|
|
130
|
+
}, [channelData]);
|
|
131
|
+
|
|
132
|
+
// Build geometry: flat plane with per-pixel intensity attribute
|
|
133
|
+
const geometry = useMemo(() => {
|
|
134
|
+
const geo = new THREE.PlaneGeometry(nx / Math.max(nx, ny), ny / Math.max(nx, ny), nx - 1, ny - 1);
|
|
135
|
+
const intensities = new Float32Array(nx * ny);
|
|
136
|
+
for (let j = 0; j < ny; j++) {
|
|
137
|
+
for (let i = 0; i < nx; i++) {
|
|
138
|
+
// PlaneGeometry vertex order: row by row, top to bottom
|
|
139
|
+
intensities[j * nx + i] = channelData[j * nx + i] ?? 0;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
geo.setAttribute('aIntensity', new THREE.BufferAttribute(intensities, 1));
|
|
143
|
+
return geo;
|
|
144
|
+
}, [nx, ny, channelData]);
|
|
145
|
+
|
|
146
|
+
// Auto-play animation
|
|
147
|
+
useFrame((_, delta) => {
|
|
148
|
+
if (!playing || !is3D) return;
|
|
149
|
+
timeRef.current += delta * playSpeed;
|
|
150
|
+
if (timeRef.current >= 1) {
|
|
151
|
+
timeRef.current -= 1;
|
|
152
|
+
setChannel((c) => (c + 1) % nz);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const fragmentShader = useMemo(() => makeFrag(colormap), [colormap]);
|
|
157
|
+
|
|
158
|
+
const uniforms = useMemo(() => ({
|
|
159
|
+
uMin: { value: min },
|
|
160
|
+
uMax: { value: max },
|
|
161
|
+
}), [min, max]);
|
|
162
|
+
|
|
163
|
+
// Channel label from WCS
|
|
164
|
+
const channelLabel = useMemo(() => {
|
|
165
|
+
if (!wcs?.crval || !wcs?.cdelt || !wcs?.ctype) return `Channel ${channel}/${nz}`;
|
|
166
|
+
const freqIdx = wcs.ctype.findIndex((t) => t.includes('FREQ'));
|
|
167
|
+
if (freqIdx < 0) return `Channel ${channel}/${nz}`;
|
|
168
|
+
const freq = wcs.crval[freqIdx] + channel * wcs.cdelt[freqIdx];
|
|
169
|
+
const unit = wcs.cunit?.[freqIdx] ?? 'Hz';
|
|
170
|
+
if (freq >= 1e9) return `${(freq / 1e9).toFixed(3)} GHz`;
|
|
171
|
+
if (freq >= 1e6) return `${(freq / 1e6).toFixed(3)} MHz`;
|
|
172
|
+
return `${freq.toFixed(0)} ${unit}`;
|
|
173
|
+
}, [channel, nz, wcs]);
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<group>
|
|
177
|
+
<mesh ref={meshRef} geometry={geometry}>
|
|
178
|
+
<shaderMaterial
|
|
179
|
+
vertexShader={VERT}
|
|
180
|
+
fragmentShader={fragmentShader}
|
|
181
|
+
uniforms={uniforms}
|
|
182
|
+
side={THREE.DoubleSide}
|
|
183
|
+
/>
|
|
184
|
+
</mesh>
|
|
185
|
+
|
|
186
|
+
{/* HUD - rendered as HTML overlay in Studio, or as 3D text in VR */}
|
|
187
|
+
{/* For now, the parent component handles the slider UI */}
|
|
188
|
+
</group>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── Studio Panel Wrapper ─────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
export interface FITSViewerPanelProps {
|
|
195
|
+
/** Raw FITS ArrayBuffer (from file drop) */
|
|
196
|
+
fitsBuffer: ArrayBuffer;
|
|
197
|
+
onClose?: () => void;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Full FITS viewer panel: parses FITS → shows 3D → channel slider.
|
|
202
|
+
* This is the entry point for drag-and-drop FITS files.
|
|
203
|
+
*/
|
|
204
|
+
export function FITSViewerPanel({ fitsBuffer, onClose }: FITSViewerPanelProps) {
|
|
205
|
+
const [fitsData, setFitsData] = useState<{
|
|
206
|
+
data: Float32Array;
|
|
207
|
+
shape: number[];
|
|
208
|
+
wcs: unknown;
|
|
209
|
+
object: string;
|
|
210
|
+
} | null>(null);
|
|
211
|
+
const [channel, setChannel] = useState(0);
|
|
212
|
+
const [playing, setPlaying] = useState(false);
|
|
213
|
+
const [error, setError] = useState<string | null>(null);
|
|
214
|
+
|
|
215
|
+
useEffect(() => {
|
|
216
|
+
try {
|
|
217
|
+
// Dynamic import to avoid bundling FITS parser unless needed
|
|
218
|
+
import('../fits/FITSParser').then(({ parseFITS }) => {
|
|
219
|
+
const fits = parseFITS(fitsBuffer);
|
|
220
|
+
setFitsData({
|
|
221
|
+
data: fits.data,
|
|
222
|
+
shape: fits.shape,
|
|
223
|
+
wcs: fits.wcs,
|
|
224
|
+
object: fits.object || 'Unknown Object',
|
|
225
|
+
});
|
|
226
|
+
}).catch((e) => setError(String(e)));
|
|
227
|
+
} catch (e) {
|
|
228
|
+
setError(String(e));
|
|
229
|
+
}
|
|
230
|
+
}, [fitsBuffer]);
|
|
231
|
+
|
|
232
|
+
if (error) {
|
|
233
|
+
return (
|
|
234
|
+
<div style={{ padding: 20, color: '#ef4444', fontFamily: 'monospace' }}>
|
|
235
|
+
FITS Parse Error: {error}
|
|
236
|
+
</div>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (!fitsData) {
|
|
241
|
+
return (
|
|
242
|
+
<div style={{ padding: 20, color: '#94a3b8', fontFamily: 'monospace' }}>
|
|
243
|
+
Loading FITS data...
|
|
244
|
+
</div>
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const nz = fitsData.shape[2] ?? 1;
|
|
249
|
+
const is3D = fitsData.shape.length >= 3 && nz > 1;
|
|
250
|
+
|
|
251
|
+
return (
|
|
252
|
+
<div style={{
|
|
253
|
+
display: 'flex', flexDirection: 'column', gap: 8,
|
|
254
|
+
padding: 12, background: '#1a1a2e', borderRadius: 8,
|
|
255
|
+
border: '1px solid #2a2a3e', color: '#e4e4e7',
|
|
256
|
+
fontFamily: "'Space Mono', monospace",
|
|
257
|
+
}}>
|
|
258
|
+
{/* Header */}
|
|
259
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
260
|
+
<span style={{ fontSize: 13, fontWeight: 700, color: '#a78bfa' }}>
|
|
261
|
+
{fitsData.object}
|
|
262
|
+
</span>
|
|
263
|
+
<span style={{ fontSize: 10, color: '#71717a' }}>
|
|
264
|
+
{fitsData.shape.join(' × ')} px
|
|
265
|
+
</span>
|
|
266
|
+
</div>
|
|
267
|
+
|
|
268
|
+
{/* Channel Slider (only for 3D cubes) */}
|
|
269
|
+
{is3D && (
|
|
270
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
271
|
+
<button
|
|
272
|
+
onClick={() => setPlaying(!playing)}
|
|
273
|
+
style={{
|
|
274
|
+
background: playing ? '#ef4444' : '#3b82f6',
|
|
275
|
+
border: 'none', borderRadius: 4, padding: '4px 10px',
|
|
276
|
+
color: 'white', fontSize: 11, cursor: 'pointer',
|
|
277
|
+
}}
|
|
278
|
+
>
|
|
279
|
+
{playing ? 'Stop' : 'Play'}
|
|
280
|
+
</button>
|
|
281
|
+
<input
|
|
282
|
+
type="range"
|
|
283
|
+
min={0}
|
|
284
|
+
max={nz - 1}
|
|
285
|
+
value={channel}
|
|
286
|
+
onChange={(e) => { setChannel(Number(e.target.value)); setPlaying(false); }}
|
|
287
|
+
style={{ flex: 1 }}
|
|
288
|
+
/>
|
|
289
|
+
<span style={{ fontSize: 10, color: '#71717a', minWidth: 80, textAlign: 'right' }}>
|
|
290
|
+
Ch {channel}/{nz - 1}
|
|
291
|
+
</span>
|
|
292
|
+
</div>
|
|
293
|
+
)}
|
|
294
|
+
|
|
295
|
+
{/* Data range */}
|
|
296
|
+
<div style={{ fontSize: 10, color: '#71717a' }}>
|
|
297
|
+
Range: {dataRange(fitsData.data).map((v) => v.toExponential(2)).join(' → ')}
|
|
298
|
+
</div>
|
|
299
|
+
</div>
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function dataRange(data: Float32Array): [number, number] {
|
|
304
|
+
let min = Infinity, max = -Infinity;
|
|
305
|
+
for (let i = 0; i < data.length; i++) {
|
|
306
|
+
if (data[i] < min) min = data[i];
|
|
307
|
+
if (data[i] > max) max = data[i];
|
|
308
|
+
}
|
|
309
|
+
return [min, max];
|
|
310
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Specific traits representing properties for Radio Astrophysics.
|
|
3
|
+
*/
|
|
4
|
+
export const RADIO_ASTRONOMY_TRAITS = [
|
|
5
|
+
'radio_emitter', // Signals a 3D construct as an origin point for specific radio wavelengths
|
|
6
|
+
'synchrotron', // Determines emission behavior via magnetic fields and relativistic electrons
|
|
7
|
+
'interferometer', // Marks a multi-nodal virtual sensor
|
|
8
|
+
'em_wave', // Represents an electromagnetic wave primitive
|
|
9
|
+
'pulsar_timing', // Represents pulsar timing array signals
|
|
10
|
+
'spectral_line', // Maps to particular spectral line signals (e.g., 21cm HI line)
|
|
11
|
+
] as const;
|
|
12
|
+
|
|
13
|
+
export type RadioAstronomyTraitName = (typeof RADIO_ASTRONOMY_TRAITS)[number];
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FITSParser — Pure JavaScript parser for FITS (Flexible Image Transport System) files.
|
|
3
|
+
*
|
|
4
|
+
* FITS is the standard data format in astronomy. Structure:
|
|
5
|
+
* - Header: 2880-byte blocks of 80-character ASCII "cards" (key=value pairs)
|
|
6
|
+
* - Data: big-endian binary arrays, padded to 2880-byte boundary
|
|
7
|
+
* - Extensions: additional HDUs (Header Data Units) with same structure
|
|
8
|
+
*
|
|
9
|
+
* Supports: BITPIX 8 (uint8), 16 (int16), 32 (int32), -32 (float32), -64 (float64)
|
|
10
|
+
* Handles: BSCALE/BZERO physical value scaling, WCS coordinate metadata
|
|
11
|
+
*
|
|
12
|
+
* @see https://fits.gsfc.nasa.gov/fits_standard.html
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// ── Types ────────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export interface WCSInfo {
|
|
18
|
+
/** Reference pixel (1-indexed, per FITS convention) */
|
|
19
|
+
crpix: number[];
|
|
20
|
+
/** Reference value (world coordinate at reference pixel) */
|
|
21
|
+
crval: number[];
|
|
22
|
+
/** Pixel scale (coordinate increment per pixel) */
|
|
23
|
+
cdelt: number[];
|
|
24
|
+
/** Axis types (e.g., 'RA---TAN', 'DEC--TAN', 'FREQ') */
|
|
25
|
+
ctype: string[];
|
|
26
|
+
/** Axis units */
|
|
27
|
+
cunit: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface FITSFile {
|
|
31
|
+
/** All header cards as key→value */
|
|
32
|
+
headers: Map<string, string | number | boolean>;
|
|
33
|
+
/** Data array (physical values after BSCALE/BZERO) */
|
|
34
|
+
data: Float32Array;
|
|
35
|
+
/** Axis dimensions [NAXIS1, NAXIS2, ...] */
|
|
36
|
+
shape: number[];
|
|
37
|
+
/** World Coordinate System info (if present) */
|
|
38
|
+
wcs: WCSInfo | null;
|
|
39
|
+
/** BITPIX from header */
|
|
40
|
+
bitpix: number;
|
|
41
|
+
/** Object name (OBJECT card) */
|
|
42
|
+
object: string;
|
|
43
|
+
/** Telescope name (TELESCOP card) */
|
|
44
|
+
telescope: string;
|
|
45
|
+
/** Observation date (DATE-OBS card) */
|
|
46
|
+
dateObs: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Constants ────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
const BLOCK_SIZE = 2880;
|
|
52
|
+
const CARD_SIZE = 80;
|
|
53
|
+
const CARDS_PER_BLOCK = BLOCK_SIZE / CARD_SIZE; // 36
|
|
54
|
+
|
|
55
|
+
// ── Parser ───────────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Parse a FITS file from an ArrayBuffer.
|
|
59
|
+
* Returns the primary HDU (first header + data unit).
|
|
60
|
+
*/
|
|
61
|
+
export function parseFITS(buffer: ArrayBuffer): FITSFile {
|
|
62
|
+
const bytes = new Uint8Array(buffer);
|
|
63
|
+
const view = new DataView(buffer);
|
|
64
|
+
|
|
65
|
+
// ── Parse Header ─────────────────────────────────────────────────
|
|
66
|
+
const headers = new Map<string, string | number | boolean>();
|
|
67
|
+
let headerEnd = 0;
|
|
68
|
+
|
|
69
|
+
outer:
|
|
70
|
+
for (let block = 0; block * BLOCK_SIZE < buffer.byteLength; block++) {
|
|
71
|
+
for (let card = 0; card < CARDS_PER_BLOCK; card++) {
|
|
72
|
+
const offset = block * BLOCK_SIZE + card * CARD_SIZE;
|
|
73
|
+
if (offset + CARD_SIZE > buffer.byteLength) break outer;
|
|
74
|
+
|
|
75
|
+
const cardStr = decodeASCII(bytes, offset, CARD_SIZE);
|
|
76
|
+
const keyword = cardStr.substring(0, 8).trim();
|
|
77
|
+
|
|
78
|
+
if (keyword === 'END') {
|
|
79
|
+
headerEnd = (block + 1) * BLOCK_SIZE; // data starts at next block boundary
|
|
80
|
+
break outer;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (cardStr[8] === '=' && cardStr[9] === ' ') {
|
|
84
|
+
const valueStr = cardStr.substring(10).split('/')[0].trim();
|
|
85
|
+
headers.set(keyword, parseCardValue(valueStr));
|
|
86
|
+
} else if (keyword === 'COMMENT' || keyword === 'HISTORY') {
|
|
87
|
+
// Skip comment/history cards
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (headerEnd === 0) {
|
|
93
|
+
throw new Error('FITS: No END card found in header');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Extract Critical Keywords ────────────────────────────────────
|
|
97
|
+
const bitpix = getNum(headers, 'BITPIX');
|
|
98
|
+
const naxis = getNum(headers, 'NAXIS');
|
|
99
|
+
|
|
100
|
+
const shape: number[] = [];
|
|
101
|
+
for (let i = 1; i <= naxis; i++) {
|
|
102
|
+
shape.push(getNum(headers, `NAXIS${i}`));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const bscale = getNumOr(headers, 'BSCALE', 1.0);
|
|
106
|
+
const bzero = getNumOr(headers, 'BZERO', 0.0);
|
|
107
|
+
|
|
108
|
+
// ── Parse Data ───────────────────────────────────────────────────
|
|
109
|
+
const totalPixels = shape.reduce((a, b) => a * b, 1);
|
|
110
|
+
const bytesPerPixel = Math.abs(bitpix) / 8;
|
|
111
|
+
const dataOffset = headerEnd;
|
|
112
|
+
|
|
113
|
+
if (dataOffset + totalPixels * bytesPerPixel > buffer.byteLength) {
|
|
114
|
+
throw new Error(`FITS: Data section extends beyond buffer (need ${dataOffset + totalPixels * bytesPerPixel}, have ${buffer.byteLength})`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const data = new Float32Array(totalPixels);
|
|
118
|
+
|
|
119
|
+
for (let i = 0; i < totalPixels; i++) {
|
|
120
|
+
const off = dataOffset + i * bytesPerPixel;
|
|
121
|
+
let raw: number;
|
|
122
|
+
|
|
123
|
+
switch (bitpix) {
|
|
124
|
+
case 8:
|
|
125
|
+
raw = bytes[off];
|
|
126
|
+
break;
|
|
127
|
+
case 16:
|
|
128
|
+
raw = view.getInt16(off, false); // big-endian
|
|
129
|
+
break;
|
|
130
|
+
case 32:
|
|
131
|
+
raw = view.getInt32(off, false);
|
|
132
|
+
break;
|
|
133
|
+
case -32:
|
|
134
|
+
raw = view.getFloat32(off, false);
|
|
135
|
+
break;
|
|
136
|
+
case -64:
|
|
137
|
+
raw = view.getFloat64(off, false);
|
|
138
|
+
break;
|
|
139
|
+
default:
|
|
140
|
+
throw new Error(`FITS: Unsupported BITPIX ${bitpix}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Apply physical value transformation
|
|
144
|
+
data[i] = bscale * raw + bzero;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Extract WCS ──────────────────────────────────────────────────
|
|
148
|
+
let wcs: WCSInfo | null = null;
|
|
149
|
+
if (headers.has('CRVAL1')) {
|
|
150
|
+
wcs = {
|
|
151
|
+
crpix: [], crval: [], cdelt: [], ctype: [], cunit: [],
|
|
152
|
+
};
|
|
153
|
+
for (let i = 1; i <= naxis; i++) {
|
|
154
|
+
wcs.crpix.push(getNumOr(headers, `CRPIX${i}`, 1));
|
|
155
|
+
wcs.crval.push(getNumOr(headers, `CRVAL${i}`, 0));
|
|
156
|
+
wcs.cdelt.push(getNumOr(headers, `CDELT${i}`, 1));
|
|
157
|
+
wcs.ctype.push(getStrOr(headers, `CTYPE${i}`, ''));
|
|
158
|
+
wcs.cunit.push(getStrOr(headers, `CUNIT${i}`, ''));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
headers,
|
|
164
|
+
data,
|
|
165
|
+
shape,
|
|
166
|
+
wcs,
|
|
167
|
+
bitpix,
|
|
168
|
+
object: getStrOr(headers, 'OBJECT', ''),
|
|
169
|
+
telescope: getStrOr(headers, 'TELESCOP', ''),
|
|
170
|
+
dateObs: getStrOr(headers, 'DATE-OBS', ''),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── FITS Builder (for tests) ─────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Build a minimal FITS file as ArrayBuffer (for testing).
|
|
178
|
+
*/
|
|
179
|
+
export function buildFITS(opts: {
|
|
180
|
+
bitpix: number;
|
|
181
|
+
shape: number[];
|
|
182
|
+
data: number[];
|
|
183
|
+
bscale?: number;
|
|
184
|
+
bzero?: number;
|
|
185
|
+
headers?: Record<string, string | number>;
|
|
186
|
+
}): ArrayBuffer {
|
|
187
|
+
const cards: string[] = [];
|
|
188
|
+
|
|
189
|
+
cards.push(fmtCard('SIMPLE', true));
|
|
190
|
+
cards.push(fmtCard('BITPIX', opts.bitpix));
|
|
191
|
+
cards.push(fmtCard('NAXIS', opts.shape.length));
|
|
192
|
+
for (let i = 0; i < opts.shape.length; i++) {
|
|
193
|
+
cards.push(fmtCard(`NAXIS${i + 1}`, opts.shape[i]));
|
|
194
|
+
}
|
|
195
|
+
if (opts.bscale !== undefined) cards.push(fmtCard('BSCALE', opts.bscale));
|
|
196
|
+
if (opts.bzero !== undefined) cards.push(fmtCard('BZERO', opts.bzero));
|
|
197
|
+
|
|
198
|
+
if (opts.headers) {
|
|
199
|
+
for (const [key, val] of Object.entries(opts.headers)) {
|
|
200
|
+
cards.push(fmtCard(key, val));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
cards.push('END'.padEnd(CARD_SIZE));
|
|
205
|
+
|
|
206
|
+
// Pad header to block boundary
|
|
207
|
+
while (cards.length % CARDS_PER_BLOCK !== 0) {
|
|
208
|
+
cards.push(' '.repeat(CARD_SIZE));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const headerBytes = new Uint8Array(cards.length * CARD_SIZE);
|
|
212
|
+
for (let i = 0; i < cards.length; i++) {
|
|
213
|
+
for (let j = 0; j < CARD_SIZE; j++) {
|
|
214
|
+
headerBytes[i * CARD_SIZE + j] = cards[i].charCodeAt(j);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Write data
|
|
219
|
+
const bytesPerPixel = Math.abs(opts.bitpix) / 8;
|
|
220
|
+
const dataSize = opts.data.length * bytesPerPixel;
|
|
221
|
+
const paddedDataSize = Math.ceil(dataSize / BLOCK_SIZE) * BLOCK_SIZE;
|
|
222
|
+
const dataBytes = new ArrayBuffer(paddedDataSize);
|
|
223
|
+
const dataView = new DataView(dataBytes);
|
|
224
|
+
|
|
225
|
+
for (let i = 0; i < opts.data.length; i++) {
|
|
226
|
+
const off = i * bytesPerPixel;
|
|
227
|
+
switch (opts.bitpix) {
|
|
228
|
+
case 8: new Uint8Array(dataBytes)[off] = opts.data[i]; break;
|
|
229
|
+
case 16: dataView.setInt16(off, opts.data[i], false); break;
|
|
230
|
+
case 32: dataView.setInt32(off, opts.data[i], false); break;
|
|
231
|
+
case -32: dataView.setFloat32(off, opts.data[i], false); break;
|
|
232
|
+
case -64: dataView.setFloat64(off, opts.data[i], false); break;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Combine
|
|
237
|
+
const result = new Uint8Array(headerBytes.length + paddedDataSize);
|
|
238
|
+
result.set(headerBytes);
|
|
239
|
+
result.set(new Uint8Array(dataBytes), headerBytes.length);
|
|
240
|
+
return result.buffer;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
function decodeASCII(bytes: Uint8Array, offset: number, length: number): string {
|
|
246
|
+
let s = '';
|
|
247
|
+
for (let i = 0; i < length; i++) {
|
|
248
|
+
s += String.fromCharCode(bytes[offset + i]);
|
|
249
|
+
}
|
|
250
|
+
return s;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function parseCardValue(s: string): string | number | boolean {
|
|
254
|
+
if (s === 'T') return true;
|
|
255
|
+
if (s === 'F') return false;
|
|
256
|
+
if (s.startsWith("'")) return s.replace(/^'|'$/g, '').trim();
|
|
257
|
+
const n = Number(s);
|
|
258
|
+
return Number.isNaN(n) ? s : n;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function getNum(headers: Map<string, string | number | boolean>, key: string): number {
|
|
262
|
+
const v = headers.get(key);
|
|
263
|
+
if (typeof v !== 'number') throw new Error(`FITS: Missing or non-numeric header ${key}`);
|
|
264
|
+
return v;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function getNumOr(headers: Map<string, string | number | boolean>, key: string, def: number): number {
|
|
268
|
+
const v = headers.get(key);
|
|
269
|
+
return typeof v === 'number' ? v : def;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function getStrOr(headers: Map<string, string | number | boolean>, key: string, def: string): string {
|
|
273
|
+
const v = headers.get(key);
|
|
274
|
+
return typeof v === 'string' ? v : def;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function fmtCard(keyword: string, value: string | number | boolean): string {
|
|
278
|
+
const kw = keyword.padEnd(8);
|
|
279
|
+
let valStr: string;
|
|
280
|
+
if (typeof value === 'boolean') {
|
|
281
|
+
valStr = value ? 'T' : 'F';
|
|
282
|
+
valStr = valStr.padStart(20);
|
|
283
|
+
} else if (typeof value === 'number') {
|
|
284
|
+
valStr = String(value).padStart(20);
|
|
285
|
+
} else {
|
|
286
|
+
valStr = `'${value}'`.padEnd(20);
|
|
287
|
+
}
|
|
288
|
+
return `${kw}= ${valStr}`.padEnd(CARD_SIZE);
|
|
289
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FITSToGrid — Convert parsed FITS data to HoloScript grid structures.
|
|
3
|
+
*
|
|
4
|
+
* Maps FITS spectral cubes (RA × Dec × Freq) and 2D images to
|
|
5
|
+
* RegularGrid3D for visualization via ScalarFieldOverlay or SimResultsMesh.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { RegularGrid3D } from '@holoscript/engine/simulation';
|
|
9
|
+
import type { FITSFile } from './FITSParser';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Convert a parsed FITS file to a RegularGrid3D.
|
|
13
|
+
*
|
|
14
|
+
* - 3D cubes (NAXIS=3): maps directly to grid
|
|
15
|
+
* - 2D images (NAXIS=2): creates a 1-deep grid (nx × ny × 1)
|
|
16
|
+
* - 1D spectra (NAXIS=1): creates a 1×1×n grid
|
|
17
|
+
*/
|
|
18
|
+
export function fitsToGrid3D(fits: FITSFile): RegularGrid3D {
|
|
19
|
+
const shape = fits.shape;
|
|
20
|
+
|
|
21
|
+
let nx: number, ny: number, nz: number;
|
|
22
|
+
|
|
23
|
+
if (shape.length >= 3) {
|
|
24
|
+
nx = shape[0];
|
|
25
|
+
ny = shape[1];
|
|
26
|
+
nz = shape[2];
|
|
27
|
+
} else if (shape.length === 2) {
|
|
28
|
+
nx = shape[0];
|
|
29
|
+
ny = shape[1];
|
|
30
|
+
nz = 1;
|
|
31
|
+
} else if (shape.length === 1) {
|
|
32
|
+
nx = shape[0];
|
|
33
|
+
ny = 1;
|
|
34
|
+
nz = 1;
|
|
35
|
+
} else {
|
|
36
|
+
throw new Error(`FITS: Cannot convert ${shape.length}D data to grid`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const grid = new RegularGrid3D([nx, ny, nz], [nx, ny, nz]);
|
|
40
|
+
|
|
41
|
+
// FITS stores data in FORTRAN order (column-major: NAXIS1 varies fastest)
|
|
42
|
+
// RegularGrid3D uses row-major (x varies fastest in our convention)
|
|
43
|
+
// Since NAXIS1 = x and it varies fastest in both, direct copy works
|
|
44
|
+
const data = fits.data;
|
|
45
|
+
const gridData = grid.data;
|
|
46
|
+
const len = Math.min(data.length, gridData.length);
|
|
47
|
+
|
|
48
|
+
for (let i = 0; i < len; i++) {
|
|
49
|
+
gridData[i] = data[i];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return grid;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Extract a single frequency channel (z-slice) from a 3D FITS cube.
|
|
57
|
+
* Returns a 2D Float32Array (nx × ny) for use as a ScalarFieldOverlay.
|
|
58
|
+
*/
|
|
59
|
+
export function extractChannel(fits: FITSFile, channel: number): Float32Array {
|
|
60
|
+
if (fits.shape.length < 3) {
|
|
61
|
+
// 2D image — return the whole thing
|
|
62
|
+
return new Float32Array(fits.data);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const nx = fits.shape[0];
|
|
66
|
+
const ny = fits.shape[1];
|
|
67
|
+
const nz = fits.shape[2];
|
|
68
|
+
|
|
69
|
+
if (channel < 0 || channel >= nz) {
|
|
70
|
+
throw new Error(`Channel ${channel} out of range [0, ${nz - 1}]`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const slice = new Float32Array(nx * ny);
|
|
74
|
+
const offset = channel * nx * ny;
|
|
75
|
+
|
|
76
|
+
for (let i = 0; i < nx * ny; i++) {
|
|
77
|
+
slice[i] = fits.data[offset + i];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return slice;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Get data range (min/max) for the FITS data.
|
|
85
|
+
* Useful for colormap normalization.
|
|
86
|
+
*/
|
|
87
|
+
export function fitsDataRange(fits: FITSFile): [number, number] {
|
|
88
|
+
let min = Infinity, max = -Infinity;
|
|
89
|
+
for (let i = 0; i < fits.data.length; i++) {
|
|
90
|
+
const v = fits.data[i];
|
|
91
|
+
if (v < min) min = v;
|
|
92
|
+
if (v > max) max = v;
|
|
93
|
+
}
|
|
94
|
+
return [min, max];
|
|
95
|
+
}
|