Skip to content
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo } from 'react';
import React, { useState, useEffect } from 'react';
import { useSelector } from 'react-redux';
import { Box, Typography, useMediaQuery } from '@mui/material';
import { useTheme } from '@mui/material/styles';
Expand All @@ -8,6 +8,7 @@ const NEUROGLASS_URL = import.meta.env.NEUROGLASS_URL ?? '';

export default function NeuroglassViewer() {
const [debouncedSrc, setDebouncedSrc] = useState('');
const [iframeSrc, setIframeSrc] = useState('');

const allLoadedInstances = useSelector(state => state.instances?.allLoadedInstances);
const focusedInstance = useSelector(state => state.instances?.focusedInstance);
Expand All @@ -16,16 +17,43 @@ export default function NeuroglassViewer() {
const theme = useTheme();
const isMobile = !useMediaQuery(theme.breakpoints.up('lg'));

// Rebuilds whenever instances, focused item, layout preference, or viewport size changes.
const iframeSrc = useMemo(() => {
const layout = resolveNeuroglassLayout(neuroglassView, isMobile);
const state = buildNeuroglassState(
allLoadedInstances,
focusedInstance?.metadata?.Id,
layout,
);
if (!state || !NEUROGLASS_URL) return '';
return `${NEUROGLASS_URL}/embed#!${encodeURIComponent(JSON.stringify(state))}`;
useEffect(() => {
let cancelled = false;
const abortController = new AbortController();
async function buildSrc() {
const layout = resolveNeuroglassLayout(neuroglassView, isMobile);
try{
const state = await buildNeuroglassState(
allLoadedInstances,
focusedInstance?.metadata?.Id,
layout,
abortController.signal
);

if (cancelled || abortController.signal.aborted) return;

if (!state || !NEUROGLASS_URL) {
setIframeSrc('');
return;
}

setIframeSrc(
`${NEUROGLASS_URL}/embed#!${encodeURIComponent(JSON.stringify(state))}`
);
} catch (error) {
if (error?.name === 'AbortError' || abortController.signal.aborted) {
return;
}
throw error;
}
}

buildSrc();

return () => {
cancelled = true;
abortController.abort();
};
}, [allLoadedInstances, focusedInstance?.metadata?.Id, neuroglassView, isMobile]);

useEffect(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { KNOWN_NG_VIEWS, NG_DEFAULT_LAYOUT, NG_DEFAULT_MOBILE_LAYOUT } from './constants';

const DEFAULT_CONTRAST_RANGE = [0, 123];
const neuroglassLayerUrlCache = new Map();

// ─── Coordinate space shared by all VFB instances ────────────────────────────
const SHARED_VIEWPORT = {
Expand All @@ -21,29 +22,117 @@ const SHARED_VIEWPORT = {
projectionScale: 1024,
};

function buildNeuroglassLayerUrl(protocol, baseUrl, instanceId) {
const path = instanceId;
if (protocol === 'neuroglancer-precomputed' || protocol === 'n5') {
// GCS / S3 require Neuroglancer's pipe notation
return `${baseUrl}/${path}/|${protocol}:`;
}
// HTTP fileservers use a precomputed:// prefix
return `precomputed://${baseUrl}/${path}`;
}

// Datasource configuration for Datasource
export const NEUROGLASS_DATASOURCE = {
protocol: import.meta.env.NEUROGLASS_DATA_PROTOCOL,
baseUrl: import.meta.env.NEUROGLASS_DATA_BASE_URL,
buildUrl(instanceId) {
return buildNeuroglassLayerUrl(
async buildUrl(instanceId) {
if (neuroglassLayerUrlCache.has(instanceId)) {
return neuroglassLayerUrlCache.get(instanceId);
}

let instancePath = instanceId.replace(
/^VFB_(\d{4})([a-zA-Z0-9]+)$/i,
'VFB/i/$1/$2/'
);

const layerURLPromise = buildNeuroglassLayerUrl(
this.protocol,
this.baseUrl,
instanceId,
instancePath
);
},

neuroglassLayerUrlCache.set(instanceId, layerURLPromise);

try {
const layerURL = await layerURLPromise;
neuroglassLayerUrlCache.set(instanceId, layerURL);
return layerURL;
} catch (error) {
neuroglassLayerUrlCache.delete(instanceId);
throw error;
}
}
};

function isObjectStoreUrl(baseUrl = '') {
return baseUrl.startsWith('gs://') || baseUrl.startsWith('s3://');
}

function isHttpUrl(baseUrl = '') {
return baseUrl.startsWith('http://') || baseUrl.startsWith('https://');
}

async function buildNeuroglassLayerUrl(protocol, baseUrl, instanceId) {
let path = instanceId;

// Check if the protocol is 'neuroglancer-precomputed'
if (protocol === 'neuroglancer-precomputed') {
if (isObjectStoreUrl(baseUrl)) {
return `${baseUrl}/${path}/|${protocol}:`;
}

if (isHttpUrl(baseUrl)) {
const url = `${baseUrl}/${path}`;
try {
// Fetch the folder contents of the URL
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch URL: ${url}`);
}

// Check if the response is HTML
const contentType = response.headers.get('Content-Type');
if (contentType && contentType.includes('text/html')) {
// Parse the HTML response
const html = await response.text();
const folderNames = extractFolderNamesFromHtml(html);

// Find the folder starting with VFB_
const vfbFolder = folderNames.find((name) => name.startsWith('VFB_'));

if (vfbFolder) {
// Check if the VFB_ folder contains a 'neuroglancer' folder
const vfbFolderUrl = `${url}/${vfbFolder}`;
const vfbResponse = await fetch(vfbFolderUrl);
if (!vfbResponse.ok) {
throw new Error(`Failed to fetch VFB folder: ${vfbFolderUrl}`);
}

const vfbHtml = await vfbResponse.text();
const vfbFolderNames = extractFolderNamesFromHtml(vfbHtml);
const neuroglancerFolder = vfbFolderNames.find(
(name) => name === 'neuroglancer/'
);

if (neuroglancerFolder) {
return `${vfbFolderUrl}${neuroglancerFolder}|${protocol}:`;
}
}
} else {
throw new Error(`Unexpected Content-Type: ${contentType}`);
}
} catch (error) {
console.error(`[buildNeuroglassLayerUrl] Error: ${error.message}`);
}
}
return `${baseUrl}/${path}/|${protocol}:`;
}

if (protocol === 'gs' || protocol === 'n5') {
return `${baseUrl}/${path}/|${protocol}:`;
}
}

function extractFolderNamesFromHtml(html) {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const links = doc.querySelectorAll('a'); // Select all <a> tags
const folderNames = Array.from(links)
.map((link) => link.textContent.trim()) // Extract text content
.filter((name) => name && !name.startsWith('..')); // Exclude parent directory links
return folderNames;
}

// Fixed GLSL shader template : Only shaderControls.color and layer.opacity change per instance.
export const LAYER_SHADER = [
'#uicontrol invlerp contrast',
Expand Down Expand Up @@ -117,10 +206,12 @@ function normalizeContrast(inst) {
}

// Per-instance layer builder: converts a VFB instance into a Neuroglancer layer config.
function buildSingleInstanceLayer(inst) {
async function buildSingleInstanceLayer(inst) {
const source = await NEUROGLASS_DATASOURCE.buildUrl(inst.metadata.Id);

const layer = {
type: 'image',
source: NEUROGLASS_DATASOURCE.buildUrl(inst.metadata.Id),
source: source,
tab: 'rendering',
opacity: alphaToOpacity(inst.color?.a),
blend: 'additive',
Expand All @@ -138,18 +229,21 @@ function buildSingleInstanceLayer(inst) {

return layer;
}
// Main state builder: converts loaded VFB instances + UI state into a Neuroglass viewer state object.
export function buildNeuroglassState(allLoadedInstances, focusedInstanceId, layout) {

export async function buildNeuroglassState(allLoadedInstances, focusedInstanceId, layout) {
const instances = allLoadedInstances || [];
const layers = instances
.filter(inst => {
if (!inst?.metadata?.Id) {
console.warn(`[buildNeuroglassState] Instance missing metadata ID:`, inst);
return false;
}
return true;
})
.map(inst => buildSingleInstanceLayer(inst));

const layers = await Promise.all(
instances
.filter(inst => {
if (!inst?.metadata?.Id) {
console.warn(`[buildNeuroglassState] Instance missing metadata ID:`, inst);
return false;
}
return true;
})
.map(inst => buildSingleInstanceLayer(inst))
);
Comment thread
jrmartin marked this conversation as resolved.

if (layers.length === 0) return null;

Expand Down
Loading