Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
260 changes: 260 additions & 0 deletions docs/viewer/components/loading/netcdf/SliceTester.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
'use client';
import React from 'react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Terminal, ChevronRight, ChevronDown } from 'lucide-react';
import { slice as ncSlice } from '@earthyscience/netcdf4-wasm';
import { VariableInfo, VariableArrayData } from './types';

// Types
export type SelectionMode = 'all' | 'scalar' | 'slice';

export interface SliceSelectionState {
mode: SelectionMode;
scalar: string;
start: string;
stop: string;
step: string;
}

export function defaultSelection(): SliceSelectionState {
return { mode: 'all', scalar: '0', start: '0', stop: '', step: '1' };
}

// buildSelection — converts UI state → DimSelection[] for dataset.get()
export function buildSelection(
sels: SliceSelectionState[],
shape: Array<number | bigint>
): Array<null | number | ReturnType<typeof ncSlice>> {
return sels.map((s, i) => {
const dimSize = Number(shape[i]);

if (s.mode === 'all') return ncSlice(0, dimSize, 1);

if (s.mode === 'scalar') {
let idx = parseInt(s.scalar);
if (Number.isNaN(idx)) idx = 0;
if (idx < 0) idx = dimSize + idx;
if (idx < 0 || idx >= dimSize) {
throw new Error(`index ${idx} out of bounds for dim ${i} size ${dimSize}`);
}
return idx;
}

let start = s.start !== '' ? parseInt(s.start) : 0;
let stop = s.stop !== '' ? parseInt(s.stop) : dimSize;
let step = s.step !== '' ? parseInt(s.step) : 1;

if (Number.isNaN(start)) start = 0;
if (Number.isNaN(stop)) stop = dimSize;
if (Number.isNaN(step)) step = 1;

if (start < 0) start = dimSize + start;
if (stop < 0) stop = dimSize + stop;

return ncSlice(start, stop, step);
});
}

interface SliceTesterSectionProps {
info: VariableInfo;
sliceSelections: SliceSelectionState[];
setSliceSelections: React.Dispatch<React.SetStateAction<SliceSelectionState[]>>;
expandedSliceTester: boolean;
setExpandedSliceTester: (v: boolean) => void;
sliceResult: VariableArrayData | null;
sliceError: string | null;
loadingSlice: boolean;
onRun: () => void;
}

const SliceTester: React.FC<SliceTesterSectionProps> = ({
info,
sliceSelections,
setSliceSelections,
expandedSliceTester,
setExpandedSliceTester,
sliceResult,
sliceError,
loadingSlice,
onRun,
}) => {
if (!info?.shape || info.shape.length === 0) return null;

const shape: number[] = info.shape.map(Number);

const updateSel = (i: number, patch: Partial<SliceSelectionState>) =>
setSliceSelections(prev => prev.map((s, idx) => idx === i ? { ...s, ...patch } : s));

const resultPreview = sliceResult
? (() => {
const len = sliceResult.length ?? 0;
const count = Math.min(30, len);
const items: string[] = [];
for (let i = 0; i < count; i++) {
const v = sliceResult[i] as number | bigint | string;
items.push(typeof v === 'number' ? v.toFixed(4) : String(v));
}
const suffix = len > 30 ? `, … (${len} total)` : '';
return `[${items.join(', ')}${suffix}]`;
})()
: null;

const elementCount = sliceResult ? sliceResult.length ?? 0 : 0;

const selectionPreview = sliceSelections.map((s, i) => {
if (s.mode === 'all') return 'null';
if (s.mode === 'scalar') return s.scalar || '0';
const parts: string[] = [s.start || '0', s.stop || String(shape[i])];
if (s.step && s.step !== '1') parts.push(s.step);
return `slice(${parts.join(', ')})`;
}).join(', ');

return (
<div className="border-[0.1px] rounded-lg overflow-hidden mt-2">
{/* Header */}
<button
onClick={() => setExpandedSliceTester(!expandedSliceTester)}
className="w-full flex items-center justify-between p-3 hover:bg-accent/50 transition-colors cursor-pointer"
>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold">Slice &amp; Index Tester</span>
<span className="text-xs text-muted-foreground font-mono">
shape: [{shape.join(', ')}]
</span>
</div>
{expandedSliceTester
? <ChevronDown className="h-4 w-4 flex-shrink-0" />
: <ChevronRight className="h-4 w-4 flex-shrink-0" />
}
</button>

{expandedSliceTester && (
<div className="px-3 pb-3 space-y-3">

{/* Dimension rows */}
<div className="space-y-2">
{shape.map((dimSize, i) => {
const dimName = info.dimensions?.[i] ?? `dim_${i}`;
const sel = sliceSelections[i] ?? defaultSelection();

return (
<div key={i} className="border rounded-md p-2 space-y-2 bg-muted/30">
{/* Label + mode tabs */}
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono text-xs text-muted-foreground w-24 shrink-0 truncate">
{dimName}
<span className="text-muted-foreground/60"> [{dimSize}]</span>
</span>
<div className="flex rounded-md border overflow-hidden text-xs">
{(['all', 'scalar', 'slice'] as SelectionMode[]).map(m => (
<button
key={m}
onClick={() => updateSel(i, { mode: m })}
className={`px-2 py-1 transition-colors ${
sel.mode === m
? 'bg-primary text-primary-foreground font-semibold'
: 'hover:bg-accent/50'
}`}
>
{m === 'all' ? 'null' : m}
</button>
))}
</div>
</div>

{/* Scalar input */}
{sel.mode === 'scalar' && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground w-10">index</span>
<Input
type="number"
min={-dimSize}
max={dimSize - 1}
value={sel.scalar}
onChange={e => updateSel(i, { scalar: e.target.value })}
className="h-7 text-xs w-28 font-mono"
placeholder="0"
/>
<span className="text-xs text-muted-foreground">
(0 … {dimSize - 1}, or negative)
</span>
</div>
)}

{/* Slice inputs */}
{sel.mode === 'slice' && (
<div className="flex items-center gap-2 flex-wrap">
{[
{ label: 'start', key: 'start' as const, placeholder: '0' },
{ label: 'stop', key: 'stop' as const, placeholder: String(dimSize) },
{ label: 'step', key: 'step' as const, placeholder: '1' },
].map(({ label, key, placeholder }) => (
<div key={key} className="flex items-center gap-1">
<span className="text-xs text-muted-foreground w-8">{label}</span>
<Input
type="number"
value={sel[key]}
onChange={e => updateSel(i, { [key]: e.target.value })}
className="h-7 text-xs w-20 font-mono"
placeholder={placeholder}
/>
</div>
))}
</div>
)}
</div>
);
})}
</div>

{/* Selection preview */}
<div className="text-xs font-mono text-muted-foreground bg-muted/50 rounded px-2 py-1.5 break-all">
{`dataset.get("${info.name}", [${selectionPreview}])`}
</div>

{/* Run + result count */}
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={onRun}
disabled={loadingSlice}
style={{ backgroundColor: '#644FF0', color: 'white' }}
className="flex-shrink-0"
>
{loadingSlice
? <><Spinner className="h-3 w-3 mr-2" />Running…</>
: 'Run'
}
</Button>
{sliceResult && (
<span className="text-xs text-muted-foreground">
{elementCount} elements
</span>
)}
</div>

{/* Error */}
{sliceError && (
<Alert variant="destructive" className="py-2">
<Terminal className="h-4 w-4 flex-shrink-0" />
<AlertDescription className="text-xs break-words">{sliceError}</AlertDescription>
</Alert>
)}

{/* Result preview */}
{resultPreview && (
<pre className="bg-muted p-2 rounded font-mono text-xs overflow-x-auto whitespace-pre-wrap break-all">
{resultPreview}
</pre>
)}
</div>
)}
</div>
);
};

export { SliceTester };
export default SliceTester;
27 changes: 25 additions & 2 deletions docs/viewer/components/loading/netcdf/Viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ import {
SearchBar,
VariableDetails,
VariableDataLoader,
SliceTester,
DimensionsCard,
AttributesCard,
} from '@/components/loading/netcdf';

import { useViewerState } from '@/components/loading/netcdf';
import { useViewerState } from '@/components/loading/netcdf/useViewerState';

const Viewer = () => {
const {
Expand Down Expand Up @@ -72,6 +72,14 @@ const Viewer = () => {
handleToggleGroupExpand,
handleSearchInputChange,
selectSearchResult,
sliceSelections,
setSliceSelections,
expandedSliceTester,
setExpandedSliceTester,
sliceResult,
sliceError,
loadingSlice,
handleRunSlice,
// Derived
breadcrumbs,
groupSummary,
Expand Down Expand Up @@ -246,6 +254,21 @@ const Viewer = () => {
/>
)}

{/* Slice Tester */}
{selectedVariable && variables[selectedVariable]?.info && (
<SliceTester
info={variables[selectedVariable].info!}
sliceSelections={sliceSelections}
setSliceSelections={setSliceSelections}
expandedSliceTester={expandedSliceTester}
setExpandedSliceTester={setExpandedSliceTester}
sliceResult={sliceResult}
sliceError={sliceError}
loadingSlice={loadingSlice}
onRun={handleRunSlice}
/>
)}

<DimensionsCard
dimensions={dimensions}
expanded={expandedDimensions}
Expand Down
1 change: 1 addition & 0 deletions docs/viewer/components/loading/netcdf/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export { VariableMenuTrigger, VariableMenuPanel } from './VariableMenu';
export { SearchBar } from './SearchBar';
export { VariableDetails } from './VariableDetails';
export { VariableDataLoader } from './VariableDataLoader';
export { SliceTester } from './SliceTester';
export { DimensionsCard } from './DimensionsCard';
export { AttributesCard } from './AttributesCard';
export type { VariableData, VariableInfo, VariableArrayData, Dimension, EnumType } from './types';
48 changes: 48 additions & 0 deletions docs/viewer/components/loading/netcdf/useViewerState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { ChangeEvent, useState, useEffect, useCallback } from 'react';
import { NetCDF4, DataTree } from '@earthyscience/netcdf4-wasm';
import { VariableData, VariableInfo, VariableArrayData, Dimension } from './types';
import { SliceSelectionState, defaultSelection, buildSelection } from './SliceTester';

const NETCDF_EXT_REGEX = /\.(nc|netcdf|nc3|nc4)$/i;

Expand Down Expand Up @@ -252,6 +253,44 @@ export const useViewerState = () => {
setSearchResults([]);
};

// Slice tester state
const [sliceSelections, setSliceSelections] = useState<SliceSelectionState[]>([]);
const [expandedSliceTester, setExpandedSliceTester] = useState(true);
const [sliceResult, setSliceResult] = useState<VariableArrayData | null>(null);
const [sliceError, setSliceError] = useState<string | null>(null);
const [loadingSlice, setLoadingSlice] = useState(false);

// Reset slice tester when selected variable changes
useEffect(() => {
if (!selectedVariable) return;
const info = variables[selectedVariable]?.info;
if (!info?.shape) return;
setSliceSelections(info.shape.map(() => defaultSelection()));
setSliceResult(null);
setSliceError(null);
}, [selectedVariable, variables]);

const handleRunSlice = useCallback(async () => {
if (!dataset || !selectedVariable) return;
const info = variables[selectedVariable]?.info;
if (!info?.shape) return;
setLoadingSlice(true);
setSliceError(null);
try {
const selection = buildSelection(sliceSelections, info.shape);
const data = await (dataset as any).get(
selectedVariable,
selection,
currentGroupPath === '/' ? undefined : currentGroupPath
) as VariableArrayData;
setSliceResult(data);
} catch (err) {
setSliceError(err instanceof Error ? err.message : String(err));
} finally {
setLoadingSlice(false);
}
}, [dataset, selectedVariable, variables, sliceSelections, currentGroupPath]);

// Derived values

const breadcrumbs = tree ? tree.getBreadcrumbs(currentGroupPath) : [];
Expand Down Expand Up @@ -310,6 +349,15 @@ export const useViewerState = () => {
handleToggleGroupExpand,
handleSearchInputChange,
selectSearchResult,
// Slice tester
sliceSelections,
setSliceSelections,
expandedSliceTester,
setExpandedSliceTester,
sliceResult,
sliceError,
loadingSlice,
handleRunSlice,
// Derived
breadcrumbs,
groupSummary,
Expand Down
Loading