阅读视图

发现新文章,点击刷新页面。

我们是怎么用 TanStack 全家桶的

这篇文章讲的是 TanStack Query、TanStack Store、TanStack Router 三个库的实际使用思路,结合真实业务场景说明"为什么这么用",以及跟其他同类方案比有什么不同。


先说说这三个包是干什么的

"@tanstack/react-query": "^5.x",
"@tanstack/react-store": "^0.x",
"@tanstack/react-router": "^1.x"

分工非常清晰:

  • react-query:管"服务器状态",也就是从接口拿来的数据
  • react-store:管"客户端状态",纯前端的 UI 状态
  • react-router:管路由,基于文件系统自动生成

一、TanStack Query:接口数据管理

从"手写请求"说起

大多数人第一次写数据请求,差不多是这样:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => {
        setUser(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err);
        setLoading(false);
      });
  }, [userId]);

  if (loading) return <Spin />;
  if (error) return <div>出错了</div>;
  return <div>{user?.name}</div>;
}

写一次还好,写多了就会发现几个问题一直在重复出现:

  1. 同一个接口在两个组件里各请求了一次,明明数据一样,却发了两次请求
  2. 切换路由回来,数据已经过期,但没有任何机制去刷新
  3. loading / error / data 这套模板,每个接口都得写一遍
  4. 组件卸载后 setState 报错,要手动加 cleanup

这些其实都是"服务器状态管理"的问题,不是 React 本身的问题。

换成 TanStack Query

function UserProfile({ userId }) {
  const { data: user, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });

  if (isLoading) return <Spin />;
  if (error) return <div>出错了</div>;
  return <div>{user?.name}</div>;
}

代码少了,但做的事情更多了:

  • 相同 queryKey 的请求全局只发一次,多个组件共享同一份缓存
  • 窗口重新聚焦时自动后台刷新(可配置)
  • 组件卸载时自动取消,不会有 setState 警告
  • 内置 loading / error / data 状态,不用手动维护

和 SWR 比有什么不同

SWR 是 Vercel 出的,功能和 TanStack Query 有很多重叠。主要区别:

对比项 TanStack Query SWR
Mutation 支持 useMutation,完善 需要手动实现或用第三方
开发者工具 官方 Devtools,可视化缓存状态 无官方 Devtools
缓存控制粒度 极细,可以按 key 精准失效 相对粗粒度
重试策略 自定义函数,按错误类型决定 配置项较少
包体积 较大 更小
使用场景 复杂应用,需要精细控制 简单场景,快速上手

如果项目比较简单、主要是 GET 请求展示数据,SWR 完全够用。但我们的项目涉及大量 mutation、复杂的缓存失效逻辑、自定义错误处理,TanStack Query 的控制粒度更合适。

Query Key:数据的"身份证"

queryKey 是 TanStack Query 的核心概念。它相当于每份缓存数据的身份证,同一个 key 对应同一份数据。

// 静态 key:数据和参数无关
useQuery({ queryKey: ['currentUser'], queryFn: fetchCurrentUser });

// 动态 key:参数不同,缓存独立存储
useQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId) });
useQuery({ queryKey: ['posts', { page, status }], queryFn: () => fetchPosts(page, status) });

key 设计得好,缓存就清晰。比如删除一篇文章后,让 ['posts'] 相关的缓存失效,列表会自动刷新:

const deleteMutation = useMutation({
  mutationFn: deletePost,
  onSuccess: () => {
    // 精准失效:以 ['posts'] 开头的所有缓存都失效
    queryClient.invalidateQueries({ queryKey: ['posts'] });
  }
});

用 query-key-factory 统一管理 key

项目大了之后,key 散落在各处很难维护。我们用 @lukemorales/query-key-factory 把所有 key 集中管理:

import { createQueryKeys, mergeQueryKeys } from '@lukemorales/query-key-factory';

const userKeys = createQueryKeys('users', {
  // 无参数的静态 key:对象形式
  current: {
    queryKey: null,
    queryFn: fetchCurrentUser,
  },
  // 有参数的动态 key:函数形式
  detail: (id: string) => ({
    queryKey: [id],
    queryFn: () => fetchUser(id),
  }),
  list: (params: UserListParams) => ({
    queryKey: [params],
    queryFn: () => fetchUsers(params),
  }),
});

const postKeys = createQueryKeys('posts', {
  list: (params: PostListParams) => ({
    queryKey: [params],
    queryFn: () => fetchPosts(params),
  }),
});

// 合并成统一入口
export const queries = mergeQueryKeys(userKeys, postKeys);

在组件里:

// 静态 key,直接传(不加括号)
const { data: currentUser } = useQuery(queries.users.current);

// 动态 key,传参调用(加括号)
const { data: user } = useQuery(queries.users.detail(userId));
const { data: posts } = useQuery(queries.posts.list({ page, status }));

静态和动态 key 的写法差异很关键,一定要区分:

  • 无参数 → 写成对象,用时不加括号
  • 有参数 → 写成函数,用时加括号传参

在 React 之外读缓存

有时候需要在非 React 环境(工具函数、事件回调等)里读接口数据,不能用 hook。把 QueryClient 做成单例,在任何地方都能访问:

// query-client.ts(单例)
export const queryClient = new QueryClient({ ... });

// 工具函数里
import { queryClient } from './query-client';

export function getCurrentUserId() {
  // 直接从缓存读,同步返回,不发请求
  const user = queryClient.getQueryData(queries.users.current.queryKey);
  return user?.id;
}

getQueryDatauseQuery 的区别:

  • useQuery:建立订阅,数据变化时组件重渲染,必要时会自动发请求
  • getQueryData:只读一次当前缓存值,不订阅、不请求、不触发渲染

全局错误处理

不需要每个 useQuery 都写 onError,在创建 QueryClient 时统一处理:

const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError: (error) => {
      if (error instanceof UnauthorizedError) {
        // 401 → 跳登录页
        router.navigate({ to: '/login' });
        return;
      }
      if (error instanceof BusinessError) {
        // 业务错误 → 弹提示
        notification.error(error.message);
        return;
      }
      // 其他错误 → 通用提示
      notification.error('网络异常,请稍后重试');
    }
  }),
});

如果某个请求不想走全局处理,在 meta 里打个标记就行:

useQuery({
  queryKey: ['some-data'],
  queryFn: fetchSomeData,
  meta: { skipGlobalError: true }, // 这个请求自己处理错误
});

二、TanStack Store:客户端状态管理

和 Redux、Zustand、Jotai 的区别

说到 React 状态管理,常见选项很多,先对比一下:

Redux(含 Redux Toolkit)

优点是生态成熟、Devtools 强大、适合大型团队统一规范。缺点是心智负担重——即便用了 RTK,一个状态需要定义 slice、action、selector,模板代码还是偏多。Redux 的更新是"全局 dispatch 一个 action",不够直接。

Zustand

比 Redux 轻很多,API 极简:

const useStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
}));

缺点是 selector 的精准订阅需要手动处理,写不好容易有不必要的重渲染。另外它是 hook-based,在 React 之外读取状态需要额外处理。

Jotai

原子化状态,每个 atom 独立管理,组合灵活。适合状态之间关系复杂、需要细粒度订阅的场景。但原子数量多了之后管理起来也有心智成本。

TanStack Store

import { Store, useStore } from '@tanstack/store';

const counterStore = new Store({ count: 0 });

// 组件里:selector 决定订阅哪部分
function Counter() {
  const count = useStore(counterStore, (s) => s.count);
  return <div>{count}</div>;
}

// React 之外:直接读 .state
function getCount() {
  return counterStore.state.count;
}

// 修改
counterStore.setState((s) => ({ ...s, count: s.count + 1 }));

TanStack Store 的设计哲学很简单:Store 是数据容器,useStore + selector 是订阅机制。没有 action、没有 reducer,直接改。

最大的特点是在 React 内外都能用同一个 store:React 组件用 useStore,非 React 环境读 .state,改状态用 setState。这让我们可以用一套数据流贯穿 React 组件和普通 JS 逻辑。

对比项 Redux Toolkit Zustand Jotai TanStack Store
模板代码 中等 极少
精准订阅 需配合 reselect 手动 天然原子化 selector 函数
React 外访问 store.getState() useStore.getState() getDefaultStore() store.state
包体积 较大 极小
适合场景 大型应用 通用 原子化状态 简单~中型

我们选 TanStack Store 的原因是:项目里状态的读写场景横跨 React 组件和各种命令处理逻辑,需要一个在两种场景里用法对称的方案。

queries / observe:读数据的两种模式

我们把读状态的方法按使用场景分成两组:

在 React 组件里(响应式):

// 封装 useStore,组件订阅,状态变就重渲染
const editorQueries = {
  useSelectedIds: () =>
    useStore(editorStore, (s) => s.selectedIds),

  useClipById: (id: string) =>
    useStore(editorStore, (s) => s.clips[id]),

  // selector 里可以做派生计算
  useSortedClips: () =>
    useStore(editorStore, (s) =>
      Object.values(s.clips).sort((a, b) => a.startTime - b.startTime)
    ),
};

// 用法
function ClipItem({ id }) {
  const clip = editorQueries.useClipById(id);
  return <div>{clip.name}</div>;
}

在事件处理器 / 工具函数里(直接读):

const editorObserve = {
  getSelectedIds: () => editorStore.state.selectedIds,
  getClipById: (id: string) => editorStore.state.clips[id],
};

// 用法
function handleKeyDown(e) {
  if (e.key === 'Delete') {
    const ids = editorObserve.getSelectedIds();
    deleteClips(ids);
  }
}

这个分法的好处:命名本身就是文档。useXxx 的前缀告诉你它是 Hook,只能在组件里用;getXxx 是普通函数,哪里都行。

写状态:setState

TanStack Store 更新状态只有一个 API:setState,接收一个函数,入参是当前 state,返回值是新 state。

const todoStore = new Store({
  items: [] as Todo[],
  filter: 'all' as 'all' | 'active' | 'done',
});

// 添加一条
todoStore.setState((s) => ({
  ...s,
  items: [...s.items, { id: Date.now(), text: '买菜', done: false }],
}));

// 修改某一条
todoStore.setState((s) => ({
  ...s,
  items: s.items.map(item =>
    item.id === targetId ? { ...item, done: true } : item
  ),
}));

// 切换过滤条件
todoStore.setState((s) => ({ ...s, filter: 'active' }));

setState 在 React 组件内外都能调,没有任何限制:

// 组件里
function AddTodo() {
  const handleAdd = () => {
    todoStore.setState((s) => ({
      ...s,
      items: [...s.items, { id: Date.now(), text: '新任务', done: false }],
    }));
  };
  return <button onClick={handleAdd}>添加</button>;
}

// 普通函数里,完全一样
function markAllDone() {
  todoStore.setState((s) => ({
    ...s,
    items: s.items.map(item => ({ ...item, done: true })),
  }));
}

封装 setter:让写操作有名字

直接暴露 setState 给组件用没什么问题,但随着状态变复杂,每次内联写更新逻辑会让组件很臃肿。常见的做法是把写操作封装成有名字的函数,集中管理:

// store.ts
export const cartStore = new Store({
  items: [] as CartItem[],
  coupon: null as string | null,
});

// 封装写操作,像 API 一样暴露出去
export const cartActions = {
  addItem(item: CartItem) {
    cartStore.setState((s) => ({
      ...s,
      items: [...s.items, item],
    }));
  },
  removeItem(id: string) {
    cartStore.setState((s) => ({
      ...s,
      items: s.items.filter(i => i.id !== id),
    }));
  },
  applyCoupon(code: string) {
    cartStore.setState((s) => ({ ...s, coupon: code }));
  },
  clear() {
    cartStore.setState((s) => ({ ...s, items: [], coupon: null }));
  },
};

组件里调用就很干净:

function CartItem({ item }) {
  return (
    <div>
      {item.name}
      <button onClick={() => cartActions.removeItem(item.id)}>删除</button>
    </div>
  );
}

这只是一种组织方式,TanStack Store 本身没有强制要求。你也可以直接在组件里调 setState,取决于项目规模和团队习惯。

派生状态:在 selector 里算

不需要额外引入 computed/derived 概念,派生计算直接放在 useStore 的 selector 里:

// 直接在 selector 里过滤、计算
const activeTodos = useStore(todoStore, (s) =>
  s.items.filter(item => !item.done)
);

const stats = useStore(todoStore, (s) => ({
  total: s.items.length,
  done: s.items.filter(i => i.done).length,
  active: s.items.filter(i => !i.done).length,
}));

需要注意的是,返回对象或数组时,每次 selector 执行都会生成新引用,TanStack Store 的浅比较会认为值变了,导致不必要的重渲染。可以用 shallow 比较函数解决:

import { useStore, shallow } from '@tanstack/store';

// 第三个参数传入 shallow,对象/数组用浅比较
const stats = useStore(todoStore, (s) => ({
  total: s.items.length,
  done: s.items.filter(i => i.done).length,
}), shallow);

或者把派生计算拆出来,selector 只返回原始数据,组件里再算:

const items = useStore(todoStore, (s) => s.items);
const activeTodos = useMemo(() => items.filter(i => !i.done), [items]);

三、TanStack Router:文件系统路由

和 React Router 比有什么不同

React Router v6 是目前最流行的路由库,TanStack Router 是后来者,但在类型安全和数据预加载上走得更远。

类型安全

React Router 的 useParamsuseSearchParams 返回的是 string | undefined,你得自己转换类型:

// React Router
const { id } = useParams(); // id: string | undefined
const numericId = Number(id); // 手动转换,没有类型保证

// TanStack Router
const { id } = Route.useParams(); // id: number(根据路由定义自动推断)

TanStack Router 的类型是从路由定义一路推导下来的,paramssearchloaderData 全部有类型,不需要手动断言。

数据预加载

TanStack Router 在路由层面原生支持数据预加载,和 TanStack Query 配合非常自然:

// 路由文件里定义 loader
export const Route = createFileRoute('/users/$id')({
  // 路由匹配时自动执行,数据准备好了再渲染
  loader: ({ context: { queryClient }, params }) =>
    queryClient.ensureQueryData(queries.users.detail(params.id)),

  component: UserDetail,
});

function UserDetail() {
  // loader 保证数据一定在缓存里,不会有 loading 状态
  const { data: user } = useQuery(queries.users.detail(Route.useParams().id));
  return <div>{user.name}</div>;
}

React Router 的 loader 也有类似功能,但和 React Query 配合需要额外处理,类型推断也没有 TanStack Router 顺畅。

文件系统路由

这是 TanStack Router 的另一个亮点。路由不需要手动配置,文件结构就是路由结构:

routes/
  index.tsx          → /
  about.tsx          → /about
  users/
    index.tsx        → /users
    $id.tsx          → /users/:id
    $id.edit.tsx     → /users/:id/edit
  _layout.tsx        → 布局路由(不影响 URL)

构建时插件自动生成路由树,新增路由只需要新建文件,不用去路由配置文件里注册。

对比项 React Router v6 TanStack Router
类型安全 较弱,params 是 string 完全类型推断
数据预加载 有 loader,但与 RQ 整合需配置 原生与 TanStack Query 集成
文件系统路由 无(需手动配置) 插件支持,自动生成
Search Params 手动 parse/stringify 类型安全,支持 schema 校验
生态成熟度 极成熟 较新,快速迭代
上手难度 中等(类型系统较复杂)

React Router 的优势是生态和稳定性,TanStack Router 的优势是类型安全和与 TanStack Query 的深度集成。如果项目新起,且已经用了 TanStack Query,选 TanStack Router 可以获得最顺滑的开发体验。

在 Electron 里用 Hash History

在 Electron 里,页面通过 file:// 协议加载,使用 Browser History 会导致路径被操作系统按文件路径解析,出各种奇怪的问题。改用 Hash History 就没这个问题:

import { createHashHistory, createRouter } from '@tanstack/react-router';

const router = createRouter({
  routeTree,
  history: createHashHistory(),
  context: { queryClient }, // 把 queryClient 注入,loader 里可以用
});

URL 会变成 file:///path/to/app#/users/123 这种形式,# 后面的部分由前端路由处理,不会触发文件系统访问。


四、整体数据流

三个库协作起来,每层职责清晰:

用户操作
  │
  ├─ 接口数据(服务器状态)
  │    └─ useQuery / useMutation
  │         ├─ 结果进 QueryClient 缓存
  │         ├─ 所有订阅同一个 key 的组件自动更新
  │         └─ 错误统一走 QueryCache.onError 处理
  │
  ├─ 客户端状态变更
  │    └─ store.setState() / 封装的 actions
  │         └─ useStore selector 订阅的组件自动重渲染
  │
  └─ 路由切换
       └─ TanStack Router 匹配路由
            └─ loader 预加载数据到 QueryClient 缓存
                 └─ 组件渲染时数据已就绪,无 loading 闪烁

五、一些使用心得

query key 要设计成层级结构

// 好的设计:层级清晰
['users']                    // 所有 user 相关
['users', 'list', params]    // user 列表
['users', 'detail', id]      // 某个 user 详情

// 失效时可以精准控制范围
queryClient.invalidateQueries({ queryKey: ['users'] });          // 失效所有
queryClient.invalidateQueries({ queryKey: ['users', 'list'] });  // 只失效列表

selector 写法影响渲染性能

// 差:每次渲染都返回新数组,浅比较失败,组件永远重渲染
useStore(store, (s) => Object.values(s.items));

// 好:在 selector 外部记住引用,或者用稳定的数据结构
useStore(store, (s) => s.itemIds); // 只订阅 id 数组,变化频率低

staleTime 不是越大越好

// staleTime: 0(默认)→ 每次 mount 都重新请求,数据始终最新,但请求频繁
// staleTime: 3000   → 3 秒内不重新请求,适合变化不频繁的数据
// staleTime: Infinity → 永不过期,适合字典、枚举等几乎不变的数据

根据数据的更新频率设置合适的 staleTime,可以在"数据新鲜度"和"请求次数"之间取得平衡。

从零构建神经影像可视化库:neuroviz 的架构设计与实现

前言

不知道掘金有多少在神经影像行业工作的开发,但是我是,之前用过的一个影像展示是一款年代很久远的库,曾一直想重构但是影像这方面的资料少之甚少,现在得益于ai的发展,资料检索起来方便了很多,所以我就开发了这么一款神经影像可视化库。 当然,业内也有比较成熟的VTK.js和Cornerstone等成熟的库,但是都太过于繁重,没有轻量的现代语法的库,可以嵌入到任意 Web项目。

neuroviz 正是为了填补这个空白而设计的:一个基于 TypeScript + Three.js + Canvas 2D 的浏览器端神经影像可视化库,支持 GIfTI、FreeSurfer、MNI OBJ 格式的三维脑表面渲染,以及 NIfTI 格式的三切面体积查看,提供完整的 TypeScript 类型定义。

本文将系统介绍 neuroviz 的整体架构、两大子系统的实现细节、关键技术决策以及在开发过程中遇到的难点与解决方案。


一、整体架构

1.1 两个独立的子系统

neuroviz 在设计上分为两个相对独立的子系统,分别对应神经影像可视化的两种主流场景:

neuroviz
├── Surface 子系统(三维表面渲染)
│   ├── SurfaceViewer     门面类,对外暴露所有公共 API
│   ├── Scene             Three.js 场景封装
│   ├── Interaction       鼠标交互(旋转/平移/缩放)
│   ├── MeshBuilder       几何体构建与颜色映射
│   └── AnnotationManager 顶点标记点管理
│
└── Volume 子系统(二维切片渲染)
    ├── VolumeViewer      组合三个切面,统一管理
    └── SliceRenderer     单轴切片渲染(可独立使用)

两个子系统完全独立,可以单独使用,也可以联动——例如点击三维表面上的某个顶点,通过 tkRas 坐标变换矩阵自动跳转到体积查看器对应的位置,实现表面与体积的坐标联动。

1.2 共享基础设施

两个子系统共享以下基础模块:

模块 位置 作用
EventEmitter src/core/event-emitter.ts 轻量级事件系统,两个 Viewer 都继承自它
PathOrFile src/types/index.ts 统一的文件输入接口(URL 或 ArrayBuffer)
公共类型定义 src/types/index.ts VolumeDataModelDataTkRas 等共享数据结构

这种划分让两个子系统保持了 API 的一致性,同时避免了相互耦合。


二、Surface 子系统:三维脑表面渲染

Surface 子系统是 neuroviz 的核心功能,基于 Three.js WebGL 渲染引擎,支持加载不同格式的大脑皮层表面模型,并在其上叠加功能数据(Overlay)的颜色映射。

2.1 文件格式支持

神经影像领域有多种脑表面文件格式,neuroviz 通过 Web Worker 并行解析,支持以下三种:

GIfTI(.gii.gii.gz

GIfTI 是神经影像社区广泛使用的通用脑成像文件格式。其内部结构是 XML,顶点坐标和面索引以 Base64 编码的二进制数据嵌入在 XML 节点中,同时支持 zlib 压缩(.gii.gz)。解析流程:

  1. 如果是 .gz 文件,先用 pako 进行 zlib 解压
  2. DOMParser 解析 XML
  3. 找到 <DataArray> 节点,提取 Base64 数据并解码
  4. 根据 DataType 字段(NIFTI_TYPE_FLOAT32 等)转换为对应的 TypedArray

由于 XML 解析和大量字符串操作在大型模型(数十万顶点)上耗时可能达到数秒,GIfTI 解析必须放在 Web Worker 中执行,避免阻塞主线程。

FreeSurfer 二进制格式

FreeSurfer 是神经影像分析领域最常用的软件套件,其输出的表面文件没有固定的文件扩展名(通常命名为 lh.pialrh.white 等)。neuroviz 通过读取文件头部的魔数(Magic Number)自动识别:

  • 0xFF 0xFF 0xFE(16进制):三角形表面文件(Triangle Surface)
  • 0xFF 0xFF 0xFF:曲率文件(Curvature)

三角形表面文件的结构:魔数(3字节)→ 注释字符串 → 顶点数 → 面数 → 顶点坐标数组(Float32,大端序) → 面索引数组(Int32,大端序)。

由于 FreeSurfer 的真实皮层表面通常有 15 万到 30 万顶点,加载时间较长,同样放在 Worker 中处理。

MNI OBJ 格式(.obj.obj.gz

这是 McGill Neurological Institute(蒙特利尔神经学研究所)的自定义 OBJ 格式,与标准 Wavefront OBJ 格式不同,不能互换使用。其文本格式包含顶点坐标、法线、颜色和面索引。neuroviz 内置了专用的 MNI OBJ 解析器处理此格式。

2.2 Web Worker 架构

Surface 子系统的文件解析全部在 Web Worker 中进行,主线程只负责接收解析结果并构建 Three.js 几何体。架构示意:

主线程                          Worker
──────────────────────────────────────────────────────
SurfaceViewer.load()
  │
  ├── new Worker(gifti.worker.js)  ──────────→ 解析 XML + Base64
  ├── new Worker(overlay.worker.js) ─────────→ 解析文本 + 解压
  │                                             │
  │   ←──── postMessage(ModelData) ────────────┘
  │         Transferable: Float32Array/Uint32Array
  │
  └── MeshBuilder.build(modelData)
      └── new THREE.BufferGeometry()

Transferable 对象传输

Worker 和主线程之间交换的主要数据是 Float32Array(顶点坐标、法线、颜色)和 Uint32Array(面索引)。这些 TypedArray 通过 Transferable 接口传输,而不是序列化拷贝:

// Worker 内部
self.postMessage(
  { vertices, indices, normals },
  [vertices.buffer, indices.buffer, normals.buffer]  // 转移所有权
);

Transferable 传输会将 ArrayBuffer 的所有权从 Worker 转移给主线程,整个过程是零拷贝的,耗时在 1ms 以内。而如果采用默认的结构化克隆(Structured Clone),30 万顶点的 Float32Array(约 3.6MB)需要完整复制,耗时可能达到数十毫秒。

Worker 路径解析

new Worker(url) 需要一个可访问的脚本 URL,这在不同的部署环境下是个挑战:直接引用 dist 目录、通过 CDN 加载、集成到 Vite/Webpack 项目中,Worker 文件的路径都不一样。

neuroviz 通过 worker-config.ts 实现了两层路径解析策略:

let _baseUrl: URL | null = null;

export function getWorkerUrl(filename: string): URL {
  if (_baseUrl) return new URL(filename, _baseUrl);       // 用户配置优先
  return new URL(`./${filename}`, import.meta.url);       // 默认:相对当前模块
}

import.meta.url 在 ESM 环境下指向当前模块文件的 URL,默认情况下 Worker 文件会被解析为与主 bundle 同级目录,覆盖了最常见的 npm 包使用场景。对于特殊环境,用户在应用初始化时调用一次即可:

import { setWorkerBaseUrl } from 'neuroviz';
setWorkerBaseUrl('https://cdn.example.com/workers/');

Worker bundle 格式选择 IIFE 而非 ESM,原因是兼容性:new Worker(url) 默认加载经典脚本(classic script),不支持 ESM 的 import 语句。Safari 对 Module Worker 的支持也较晚。IIFE 格式将所有依赖(pako、gifti-reader-js 等)内联进单个文件,加载后直接可用,不需要额外的网络请求。

2.3 MeshBuilder:从数据到 Three.js 几何体

MeshBuilder 负责将 Worker 解析好的原始数据构建成 Three.js 可以渲染的 BufferGeometry

几何体构建

const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.Float32BufferAttribute(vertices, 3));
geometry.setIndex(new THREE.BufferAttribute(new Uint32Array(indices), 1));

if (normals && normals.length > 0) {
  geometry.setAttribute("normal", new THREE.Float32BufferAttribute(normals, 3));
} else {
  geometry.computeVertexNormals();  // 从面索引自动计算法线
  geometry.normalizeNormals();
}

geometry.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3));

材质使用 MeshPhongMaterial 并开启 vertexColors: true,让每个顶点的颜色从 color attribute 中读取——这是 Overlay 颜色映射的基础。DoubleSide 确保表面内外都能被渲染,对于半透明模式尤其重要。

Overlay 颜色映射

Overlay 是一维的浮点数数组,每个值对应一个顶点,表示该顶点处的功能数据(如激活强度、皮层厚度等)。颜色映射的过程:

  1. 遍历 Overlay 数据,找出最小值 lo 和最大值 hi(若用户指定了 range 则使用指定范围)
  2. 对每个顶点值,将其归一化到 [0, 1] 区间
  3. 乘以颜色映射表的长度,取对应颜色条目(RGB 三通道,范围 0–1)
  4. 写入 BufferGeometry 的 color attribute

这个过程中,computeOverlayParamsmapValueToColor 被提取为静态方法,由 build()updateColors() 两处共用,避免逻辑重复。

顶点空间索引

几何体构建完成后,MeshBuilder 立即建立一个空间位置索引:

private buildPositionIndex(): void {
  const attr = this.mesh.geometry.getAttribute("position");
  for (let i = 0; i < attr.count; i++) {
    const key = `${attr.getX(i)},${attr.getY(i)},${attr.getZ(i)}`;
    if (!this.positionIndex.has(key)) {
      this.positionIndex.set(key, i);
    }
  }
}

Map<string, number>"x,y,z" 字符串为键,将 getIndexByPosition 的时间复杂度从 O(n) 降至 O(1)。对于 30 万顶点的模型,这是必要的优化。

当传入坐标存在浮点精度误差时(射线求交得到的交点坐标往往有微小偏差),精确字符串匹配会失败,此时降级为带 epsilon 容差的 O(n) 线性搜索作为保底。

2.4 交互系统

Interaction 类负责鼠标交互:左键拖拽旋转、右键拖拽平移、滚轮缩放。它接受任意 THREE.Object3D 作为操作目标,完全不依赖 Scene

旋转使用 rotateOnWorldAxis——在世界坐标系中旋转,而非对象自身坐标系——这样无论当前旋转状态如何,拖拽方向与模型旋转方向始终对应,避免了万向锁导致的反直觉操作。

性能细节

mousemove 事件频率极高(60fps+)。旋转计算需要两个轴向量 (0,1,0)(1,0,0),早期版本每次事件都创建 new THREE.Vector3(0, 1, 0),产生不必要的对象分配。优化后提取为 static readonly 类常量:

private static readonly AXIS_Y = new THREE.Vector3(0, 1, 0);
private static readonly AXIS_X = new THREE.Vector3(1, 0, 0);

类常量只创建一次,整个生命周期内所有事件复用,减少了 GC 触发频率。

区分点击与拖拽

旋转结束时,浏览器也会触发 click 事件。如果不加区分,用户旋转完模型后松开鼠标,就会误触发顶点拾取(Raycasting)。

Interactionmousedown 时记录起始坐标,mouseup 时记录结束坐标:

readonly mousemovePosition: [THREE.Vector2, THREE.Vector2] = [
  new THREE.Vector2(0, 0),   // mousedown 时记录
  new THREE.Vector2(0, 0),   // mouseup 时记录
];

SurfaceViewerclick 事件处理器里检查两点距离,距离大于 0 说明是拖拽,直接 return:

if (mp[0].distanceTo(mp[1]) > 0) return;  // 是拖拽,忽略

2.5 顶点拾取(Raycasting)

点击表面时,通过 Three.js 的 Raycaster 将屏幕坐标转换为 3D 射线,找到射线与网格的交叉点,取出对应的顶点索引。

这里有一个隐蔽的 bug:线框模式(Wireframe)的实现是在原始 mesh 上挂载一个 THREE.LineSegments 子对象。如果 Raycasting 使用 recursive: true 递归检测子对象,LineSegments 会参与检测,且由于线段没有面积,它比 Mesh 更容易被射线命中,导致点击表面时实际拾取的是不可见的线框而非网格。

解决方案是双重过滤:

const intersects = raycaster
  .intersectObjects(this.scene.modelGroup.children, false)  // 非递归,跳过子对象
  .filter((i) => i.object instanceof THREE.Mesh);           // 只接受 Mesh,排除 LineSegments

const hit = intersects.find((i) => this.handles.has(i.object.name));  // 只认注册的模型

2.6 标记点系统(AnnotationManager)

AnnotationManager 随每个 load() 调用一起返回,允许在任意顶点处放置球形标记。每个标记是一个 THREE.SphereGeometry 构建的小球,作为对应 mesh 的子对象挂载在场景中,自动跟随模型旋转和缩放。

生命周期守卫

当用户调用 viewer.removeModel(name) 后,模型的 mesh 从场景中移除(mesh.parent === null),但外部代码可能仍持有该模型的 AnnotationManager 引用并继续调用 add()。没有守卫的情况下,标记球会被挂到一个"孤岛" mesh 上——它不在场景里,不被渲染,但占用内存,且后续行为不可预期。

add(vertex: number, options = {}): Annotation | null {
  if (!this.#mesh.parent) return null;  // mesh 已从场景移除,拒绝操作
  // ...
}

返回 null 让调用方可以明确感知到操作失败。

激活状态

activate(vertex) 将指定标记高亮(切换到 activeColor),其他标记恢复原色:

activate(vertex: number): void {
  this.annotations.forEach((annotation) => {
    const mat = annotation.marker.material as THREE.MeshPhongMaterial;
    mat.color.setHex(
      annotation.vertex === vertex ? this.activeColor : annotation.color
    );
  });
}

每次添加标记时会自动 activate 新标记,提供视觉反馈。

2.7 多模型与交互目标切换

SurfaceViewer 使用 Map<string, ModelHandle> 管理所有已加载的模型。ModelHandle 是句柄模式的体现——它封装了"对哪个模型操作"的上下文,使得多模型场景下的操作自然简洁:

const { handle: lh } = await viewer.load({ model: { url: 'lh.pial.gii', name: 'lh' } });
const { handle: rh } = await viewer.load({ model: { url: 'rh.pial.gii', name: 'rh' } });

lh.setTransparency(0.5);    // 只影响左脑半球
rh.setVisible(false);        // 只隐藏右脑半球
viewer.setTransparency(0.8); // 同时影响所有模型

setInteractionTarget 允许把鼠标交互目标从整个模型组切换到单个模型:

viewer.setInteractionTarget(lh);  // 只旋转左脑,右脑静止
viewer.setInteractionTarget('group');  // 恢复所有模型联动

实现上,每次切换都是销毁旧 Interaction 实例(通过 AbortController.abort() 一次性移除所有事件监听),再 new 一个新的绑定新目标——不需要 Interaction 自身感知"切换"这件事,没有状态残留风险。


三、Volume 子系统:NIfTI 体积数据三切面渲染

Volume 子系统基于 Canvas 2D API,以轴向(Axial)、冠状(Coronal)、矢状(Sagittal)三个正交切面展示 NIfTI 格式的脑体积数据。

3.1 NIfTI 解析

NIfTI(Neuroimaging Informatics Technology Initiative)是神经影像领域最常用的体积数据格式,有 .nii(单文件)和 .nii.gz(gzip 压缩)两种形式。

为什么不用 Worker?

与 GIfTI 不同,NIfTI 是纯二进制格式:

  • 头部:固定 348 字节(NIfTI-1)或 544 字节(NIfTI-2),包含维度、数据类型、体素尺寸等元信息
  • 数据体:紧跟头部之后,是连续的体素数值数组

解析过程完全基于 DataView 读取头部字段,再将数据体直接 cast 成对应的 TypedArray(Int16ArrayFloat32ArrayUint8Array 等)。整个过程没有 XML 解析、没有 Base64 解码,对于 100MB 的 NIfTI 文件,解析时间通常在 50–100ms 以内——主线程完全可以接受。

如果用 Worker,反而会增加 ArrayBuffer 序列化传输的额外开销,得不偿失。

轴向信息(AxisInfo)

NIfTI 头部包含每个空间轴的完整信息:

type AxisInfo = {
  name: AxisName;              // "xspace" | "yspace" | "zspace"
  space_length: number;         // 该轴的体素数量
  step: number;                 // 体素物理尺寸(mm),可为负(表示方向翻转)
  start: number;                // 起始坐标(mm)
  direction_cosines: [number, number, number]; // 方向余弦
  offset: number;               // 在一维 data 数组中的步长
};

offset 是关键字段。NIfTI 数据在内存中以一维数组存储,给定体素坐标 (x, y, z),其在数组中的索引为:

idx = x * header.xspace.offset + y * header.yspace.offset + z * header.zspace.offset

offset 由文件的维度顺序决定(慢轴到快轴),neuroviz 在解析头部时自动计算。

3.2 SliceRenderer:单轴切片渲染

SliceRenderer 是体积渲染的核心,负责将三维体素数据中的一个二维切面渲染到 <canvas> 元素上。

轴布局

每个轴的切面由两个正交轴构成:

private static readonly LAYOUT: Record<SliceAxis, { col: AxisName; row: AxisName }> = {
  xspace: { col: "yspace", row: "zspace" },  // 矢状面
  yspace: { col: "xspace", row: "zspace" },  // 冠状面
  zspace: { col: "xspace", row: "yspace" },  // 轴向面
};

col 轴对应 canvas 的水平方向,row 轴对应垂直方向。

渲染流程

draw(): void {
  const { col, row } = SliceRenderer.LAYOUT[this.axis];
  const colLen = header[col].space_length;
  const rowLen = header[row].space_length;

  const imageData = this.ctx.createImageData(colLen, rowLen);
  const sliceOff = header[this.axis].offset * this.sliceIndex;

  for (let r = 0; r < rowLen; r++) {
    for (let c = 0; c < colLen; c++) {
      const voxIdx = sliceOff + header[row].offset * r + header[col].offset * c;
      const raw = this.volume.data[voxIdx];
      // 窗宽窗位映射
      const normalized = clamp((raw - lower) / (upper - lower), 0, 1);
      // LUT 查找
      const lutIdx = Math.round(normalized * 255) * 3;
      pixels[...] = lut[lutIdx], lut[lutIdx+1], lut[lutIdx+2];
    }
  }

  this.ctx.putImageData(imageData, 0, 0);
  if (this.showCursor) this.drawCursor(colLen, rowLen);
}

窗宽窗位(Window/Level)

窗宽窗位是医学影像显示的标准调参方式:

  • Window Level(窗位):显示范围的中心值
  • Window Width(窗宽):显示范围的宽度

体素值在 [level - width/2, level + width/2] 范围内线性映射到 [0, 1],范围外的值截断到 0 或 1。这允许用户针对感兴趣的组织类型(灰质、白质、脑脊液)调整最佳对比度。

各向异性体素的显示矫正

这是开发过程中发现并修复的一个实际 bug。

SliceRenderer 把每个体素渲染为一个 canvas 像素,canvas 的 widthheight 设置为体素数量(如 256×160)。CSS 再把 canvas 拉伸填满容器,如果容器宽高比与体素数量比不一致,图像就会变形。

更深层的问题是各向异性体素:如果 X 方向的体素物理尺寸(step)是 1mm,Z 方向是 2mm,那么一个 256×160 体素的切面物理上是 256mm × 320mm,而不是 256×160 的正方形。渲染时每个体素用一个像素表示,纵轴被"压缩"了一半。

修复方案:在 setVolume() 时,根据物理尺寸设置 CSS 的 aspect-ratio

const physW = header[col].space_length * Math.abs(header[col].step);
const physH = header[row].space_length * Math.abs(header[row].step);
this.canvas.style.aspectRatio = `${physW} / ${physH}`;

canvas 的内部像素分辨率(width/height 属性)保持体素数量不变,aspect-ratio 只影响 CSS 显示尺寸,不影响渲染逻辑和坐标换算——优雅地解耦了"渲染精度"和"显示比例"两个关注点。

坐标换算

点击 canvas 时,需要将 CSS 像素坐标转换为体素坐标。canvas 有两套坐标系:CSS 像素(受 CSS 缩放影响)和 canvas 像素(绘制分辨率),两者比例不同:

// VolumeViewer 处理点击
const rect = canvas.getBoundingClientRect();
const px = e.clientX - rect.left;  // CSS 像素
const py = e.clientY - rect.top;

const scaleX = canvas.width / rect.width;   // canvas 像素 / CSS 像素
const scaleY = canvas.height / rect.height;

const voxel = renderer.canvasToVoxel(px * scaleX, py * scaleY);

这样无论 canvas 被 CSS 如何缩放(大屏、高 DPR 设备),体素坐标换算都是准确的。

3.3 VolumeViewer:三切面联动

VolumeViewer 是三个 SliceRenderer 的组合器,负责:

  1. 加载 NIfTI 文件,解析后分发给三个渲染器
  2. 点击任意切面时,更新三个切面的位置和游标,并广播 positionchange 事件
  3. 全局的窗宽窗位、颜色映射、游标显示控制同步到三个渲染器

三切面联动的游标同步逻辑:

private syncCursors(): void {
  // 矢状面(x 轴切面):canvas x=yspace, y=zspace
  this.renderers.xspace.setCursor(this.position.yspace, this.position.zspace);
  // 冠状面(y 轴切面):canvas x=xspace, y=zspace
  this.renderers.yspace.setCursor(this.position.xspace, this.position.zspace);
  // 轴向面(z 轴切面):canvas x=xspace, y=yspace
  this.renderers.zspace.setCursor(this.position.xspace, this.position.yspace);
}

SliceRenderer 单独暴露的意义

SliceRenderer 作为公共 API 单独导出,而不仅仅是 VolumeViewer 的内部实现。原因是灵活性:并非所有场景都需要三切面联动。有人可能只需要一个轴向切面,或者需要四宫格布局(三切面 + 一个 3D 视图)。单独暴露后,用户可以自由组合任意数量的 SliceRenderer,构建自定义的影像查看器。


四、共享基础设施

4.1 EventEmitter

SurfaceViewerVolumeViewer 都继承自 EventEmitter,统一的事件接口让两者可以以相同的方式被使用:

surfaceViewer
  .on('load', ({ handle, annotations }) => { ... })
  .on('vertexClick', ({ index, point, volCoord }) => { ... });

volumeViewer
  .on('load', (volume) => { ... })
  .on('positionchange', ({ xspace, yspace, zspace }) => { ... });

EventEmitter 自实现,仅 30 行代码,不依赖任何外部库。选择自实现而非 Node.js events polyfill 的原因:这是一个浏览器库,不应引入 Node.js 环境依赖。选择自实现而非 RxJS 的原因:RxJS 约 50KB,对于仅需 on/off/emit/once 四个方法的场景是严重过度设计。

once 的实现利用闭包实现触发后自移除:

once<T>(event: string, handler: EventHandler<T>): this {
  const wrapper: EventHandler<T> = (data) => {
    handler(data);
    this.off(event, wrapper);  // 执行后从监听器列表移除自身
  };
  return this.on(event, wrapper);
}

4.2 PathOrFile 判别联合类型

神经影像的工作流多样:数据可能来自服务器(URL)、本地文件上传(FileArrayBuffer)、或者内存中已有的缓冲区。为了统一处理,neuroviz 定义了 PathOrFile 判别联合类型:

export type PathOrFile =
  | { url: string; file?: never }
  | { file: ArrayBuffer; url?: never };

never 标记互斥字段——两者不能同时存在——TypeScript 可以在条件分支内精确收窄类型,无需非空断言:

function resolveSource(source: PathOrFile, fromURL, fromFile) {
  return source.url
    ? fromURL(source.url)    // 此处 url 类型是 string,不是 string | undefined
    : fromFile(source.file!);
}

如果用简单的可选字段 { url?: string; file?: ArrayBuffer }{} 也是合法值,且 TypeScript 无法自动收窄类型,需要到处写 source.url!

这种设计还统一了 Surface 和 Volume 的加载 API——两者都接受同一个 PathOrFile,用户可以在两者之间自由切换,不需要记忆不同的方法名。


五、内存管理与资源释放

WebGL 和 DOM 事件监听器是浏览器中最常见的两类内存泄漏来源。neuroviz 对此进行了系统性的处理。

5.1 WebGL 资源释放

SurfaceViewer.dispose() 形成完整的资源清理链:

dispose(): void {
  this.abortController.abort();      // 一次性清理所有 DOM 事件监听
  this.interaction.dispose();         // Interaction 的事件监听
  this.handles.forEach((handle) => {
    const mesh = handle.meshBuilder.mesh;
    mesh.geometry.dispose();          // 释放 GPU 顶点缓冲区(VBO)
    mesh.material.dispose();          // 释放 GPU 材质资源
  });
  this.handles.clear();
  this.scene.dispose();               // 取消 rAF + 断开 ResizeObserver + 销毁 WebGL context
}

THREE.BufferGeometry.dispose()THREE.Material.dispose() 释放的是 GPU 端的内存(顶点缓冲区 VBO、纹理等),这部分内存不受 JavaScript GC 管理,必须手动释放。

5.2 AbortController 统一管理事件监听

传统的事件监听清理方式需要保存每个 handler 的引用才能调用 removeEventListener,繁琐且容易遗漏。neuroviz 统一使用 AbortControllersignal 机制:

// 注册事件
element.addEventListener('mousemove', handler, {
  signal: this.abortController.signal
});

// 一次性清理所有注册在这个 signal 上的监听器
this.abortController.abort();

AbortController 是浏览器原生 API,无需任何 polyfill。调用一次 abort(),所有带该 signal 的监听器全部自动移除,不需要逐个手动 removeEventListener,也不需要保存 handler 引用。

5.3 ResizeObserver

Surface 查看器需要随容器尺寸变化更新渲染器和相机参数。传统方式是监听 window.resize,但这在容器尺寸变化不涉及窗口大小时(如 flex 布局中面板拖拽调整)无效。neuroviz 使用 ResizeObserver 直接观测容器元素:

private bindResize(): void {
  this.resizeObserver = new ResizeObserver(() => {
    const w = this.container.offsetWidth;
    const h = this.container.offsetHeight || 1;
    this.renderer.setSize(w, h);
    this.camera.aspect = w / h;
    this.camera.updateProjectionMatrix();
  });
  this.resizeObserver.observe(this.container);
}

dispose() 时通过 resizeObserver.disconnect() 停止观测,避免泄漏。


六、构建系统

6.1 为什么选 Rollup

neuroviz 选择 Rollup 而非 Webpack、Vite 或 tsup 的核心原因是多入口 IIFE 打包需求

export default [
  // 主入口:ESM + UMD 双格式
  {
    input: 'src/index.ts',
    output: [{ format: 'esm' }, { format: 'umd', name: 'neuroviz' }]
  },
  // 四个 Worker 入口:各自打成 IIFE
  ...['gifti', 'mni-obj', 'freesurfer', 'overlay'].map(name => ({
    input: `src/worker/${name}.worker.ts`,
    output: { file: `dist/${name}.worker.js`, format: 'iife' }
  }))
];

Worker 文件必须打成 IIFE(立即执行函数表达式),因为 new Worker(url) 默认加载经典脚本,不支持 ESM 的 import 语句。Rollup 对多入口、多格式的控制最直接,且 tree-shaking 效果是同类工具中最好的——对于库来说,最小化 bundle 体积是首要目标。

6.2 双 tsconfig 方案

构建时需要两个 tsconfig 文件,解决 vendor 文件与类型声明生成之间的冲突:

tsconfig.json(Rollup 编译时使用)

  • allowJs: true:允许处理 src/vendor/three.r154.js
  • 在 Rollup 的 typescript 插件中传入 { declaration: false, declarationMap: false } 覆盖,禁止 Rollup 生成类型声明(它无法正确生成合并后 bundle 的类型)

tsconfig.types.json(单独生成类型时使用)

  • 继承 tsconfig.json
  • allowJs: false + exclude: ["src/vendor"]:排除 vendor 目录

three.r154.js 没有类型注释,tsc 无法为它生成 .d.ts(报 TS9005 错误),必须在生成类型时排除。

构建脚本分两阶段:

"build": "rollup -c && tsc -p tsconfig.types.json"
  1. rollup -c:编译 TypeScript + 打包(禁用 declaration)
  2. tsc -p tsconfig.types.json:按导出图生成单入口 dist/index.d.ts

6.3 Three.js 的 Vendor 策略

neuroviz 将 Three.js r154 以本地文件形式放入 src/vendor/,而非通过 npm 安装。理由是版本固定:神经影像可视化对 Three.js 的渲染行为有较强依赖,本地 vendor 确保任何环境下行为完全一致,不受用户项目升级 Three.js 的影响。

代价是 bundle 体积较大(Three.js 压缩后约 500KB),以及 @types/three 版本需要手动与 r154 对齐(@types/three@0.157.x 对应 r157,差距不大,关键 API 类型一致)。


七、API 设计哲学

7.1 门面模式与句柄模式

SurfaceViewer 是门面(Facade),对外隐藏了 SceneInteractionMeshBuilder 等内部实现细节;ModelHandle 是句柄(Handle),封装了"对哪个模型操作"的上下文,让多模型管理不需要到处传递 model name。

7.2 渐进式复杂度

API 设计遵循"简单场景简单,复杂场景可行"的原则:

  • 最简单的用法:3 行代码创建 Surface 查看器并加载模型
  • 需要多模型?loads([...]) 批量加载,每个返回独立的 ModelHandle
  • 需要自定义布局?SliceRenderer 可以独立使用,不必绑定 VolumeViewer
  • 需要自定义 Worker 路径?一次 setWorkerBaseUrl() 全局生效

7.3 一致性

Surface 和 Volume 的加载 API 使用相同的 PathOrFile 类型,事件接口使用相同的 on/off/once 方法,资源释放使用相同的 dispose() 方法。用户在两个子系统之间切换时,不需要重新学习新的接口约定。


八、使用示例

Surface Viewer 完整示例

import { SurfaceViewer } from 'neuroviz';

const viewer = new SurfaceViewer(document.getElementById('container'));

// 加载左右脑半球
const [left, right] = await viewer.loads([
  {
    model:    { url: '/lh.pial.gii', name: 'lh' },
    overlay:  { url: '/lh.activation.txt.gz' },
    colorMap: { url: '/hot.txt' },
    range:    { min: -3, max: 3 },
  },
  {
    model:    { url: '/rh.pial.gii', name: 'rh' },
    overlay:  { url: '/rh.activation.txt.gz' },
    colorMap: { url: '/hot.txt' },
    range:    { min: -3, max: 3 },
  },
]);

// 设置视角
viewer.setView('lateral');

// 点击顶点时添加标记并打印坐标
viewer.on('vertexClick', ({ index, point, volCoord }) => {
  left.annotations.add(index, {
    color: 0x00aaff,
    name: `ROI-${index}`,
    data: { activation: left.handle.getPositionByIndex(index) }
  });
  console.log('World coord:', point);
  console.log('Volume coord:', volCoord);  // 需要加载 tkRas
});

// 独立控制左右脑
left.handle.setTransparency(0.6);
right.handle.setVisible(false);

// 导出截图
const png = viewer.canvasDataURL();

// 清理
viewer.dispose();

Volume Viewer 完整示例

import { VolumeViewer } from 'neuroviz';

const viewer = new VolumeViewer(
  document.getElementById('axial')    as HTMLCanvasElement,
  document.getElementById('coronal')  as HTMLCanvasElement,
  document.getElementById('sagittal') as HTMLCanvasElement,
  { highlightColor: '#00ff88', backgroundColor: '#111111' }
);

// 从文件输入加载(本地文件)
const [file] = input.files;
await viewer.load({ file: await file.arrayBuffer() });

// 加载完成后读取元信息
viewer.on('load', (volume) => {
  console.log('Dimensions:', volume.header.xspace.space_length,
    volume.header.yspace.space_length, volume.header.zspace.space_length);
});

// 跟踪当前位置
viewer.on('positionchange', (pos) => {
  const world = viewer.getWorldPosition();
  const val   = viewer.getVoxelValue();
  console.log(`Voxel(${pos.xspace}, ${pos.yspace}, ${pos.zspace})`);
  console.log(`World: ${world.x.toFixed(1)}, ${world.y.toFixed(1)}, ${world.z.toFixed(1)} mm`);
  console.log(`Intensity: ${val}`);
});

// 跳转到特定位置
viewer.setPosition({ xspace: 128, yspace: 128, zspace: 90 });

// 调整显示
viewer.setWindowLevel(500, 800);
viewer.setColormap('viridis');

viewer.dispose();

九、总结

neuroviz 是一个专注于神经影像可视化的浏览器端 JavaScript 库,核心设计理念是:

  1. 职责分离:每个类只做一件事,内部模块高内聚低耦合
  2. 性能优先:Web Worker 异步解析、Transferable 零拷贝传输、顶点空间索引、高频事件对象复用
  3. 资源安全:统一的 dispose() 清理链,AbortController 管理事件监听,防悬空引用守卫
  4. API 一致:Surface 和 Volume 使用相同的文件输入接口、事件接口和生命周期方法
  5. 类型安全:完整的 TypeScript 类型定义,判别联合类型消除运行时错误

对于需要在 Web 应用中嵌入神经影像可视化能力的开发者,neuroviz 提供了一个开箱即用、可深度定制的解决方案。

目前作者的npm账号已经丢失了所以还没有发布到npm上,所以有需要的开发可以先研究代码,着急可以拉下代码本地build进行嵌入,后续账号找回会第一时间发到npm上

❌