# 3D Image Viewer: Reconstruction and Synchronization Mechanism

# 目標

3D CT 影像瀏覽器在多視角（coronal, sagittal, axial）下，如何透過滑桿 (slider) 操作與影像點擊，同步更新影像顯示與輔助線 (crosshair) 位置。

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1742436775276/89fabff7-ebc3-4c72-88a5-4230e0070a04.png align="center")

# 核心步驟

```mermaid
flowchart TD
    A[使用者互動：點擊或滑桿操作]
    B[判斷互動類型]
    C[若為滑桿：直接取得滑桿數值作為基礎索引]
    D[若為點擊：取得點擊位置於影像內的相對位置]
    E[計算點擊位置的比例（normalized 值）]
    F[根據設定決定是否反轉該比例]
    G[利用預設範圍（bounds）將比例映射到物理座標範圍]
    H[限制並捨入結果，得到有效影像索引]
    I[更新 globalCoord 中對應軸的數值]
    J[呼叫 updateAllViews 進行視圖更新]
    K[對每個視圖：更新滑桿數值同步 globalCoord]
    L[對每個視圖：依據 globalCoord 與 bounds 計算輔助線位置]
    M[更新輔助線的 CSS 屬性，顯示正確位置]
    N[對每個視圖：依據 imageIndexSource 選擇正確影像索引]
    O[更新影像的 src 屬性以顯示正確圖片]

    A --> B
    B -- 滑桿操作 --> C
    B -- 點擊操作 --> D
    D --> E
    E --> F
    F --> G
    G --> H
    C --> I
    H --> I
    I --> J
    J --> K
    J --> L
    L --> M
    J --> N
    N --> O

```



1. **建立全域座標系統**: 定義 `globalCoord` 物件儲存三個軸向 (x, y, z) 的影像索引，並根據影像數量初始化。
    
2. **配置視圖參數**: 定義每個視圖 (view) 的設定，包含影像來源、路徑、軸向設定 (對應的全域軸、物理範圍 `bounds`、是否反轉 `reverse`、滑桿 ID、輔助線 ID)。
    
3. **綁定滑桿事件**: 初始化滑桿範圍與初始值，並綁定 `input` 事件，使滑桿數值變更時更新 `globalCoord` 並觸發視圖更新。
    
4. **綁定點擊事件**: 綁定影像 `click` 事件，計算點擊位置相對於影像尺寸的比例，經過反轉 (若有)、映射至物理範圍、限制與捨入後，更新 `globalCoord` 並觸發視圖更新。
    
5. **同步更新視圖**: 建立 `updateAllViews()` 函數，遍歷所有視圖並執行其 `update()` 方法，同步更新滑桿值、輔助線位置與顯示影像。
    

# 步驟實現

* 環境設置：
    
    * 準備 HTML 結構，包含三個視角的 `<div>` (內含 `<img>` 與輔助線 `<div>`) 以及對應的滑桿 `<input type="range">`。
        
    * 準備 CSS 樣式，設定影像、滑桿與輔助線的基本樣式與佈局。
        
    * 準備 JavaScript 程式碼，包含影像資料陣列、全域座標變數、視圖設定物件與 `CTView` 類別。
        
* 全域座標系統 `globalCoord`：
    

```javascript
    // 影像資料 (檔名依實際情況修改)
    let coronalImages = [...];
    let sagittalImages = [...];
    let axialImages = [...];

    // 全域軸範圍
    const NX = sagittalImages.length;
    const NY = coronalImages.length;
    const NZ = axialImages.length;

    // 全域座標 (儲存影像索引)，初始置中
    let globalCoord = {
      x: Math.floor(NX / 2),
      y: Math.floor(NY / 2),
      z: Math.floor(NZ / 2)
    };
```

* 視圖設定 `viewConfigs`：
    

```javascript
    const viewConfigs = [
      {
        name: "coronal",
        containerId: "coronal-view",
        imageElementId: "coronal-image",
        imageIndexSource: "y", // 以 global.y 選圖
        path: "images/coronal/",
        dataset: coronalImages,
        axes: {
          horizontal: {
            globalAxis: "x",
            bounds: [-40, 125], // 依實際資料調整
            reverse: false,
            sliderId: "coronal-slider-horizontal",
            crosshairId: "coronal-crosshair-v"
          },
          vertical: {
            globalAxis: "z",
            bounds: [-30, 90],  // 依實際資料調整
            reverse: true,
            sliderId: "coronal-slider-vertical",
            crosshairId: "coronal-crosshair-h",
            sliderDirection: "rtl" // 預設 rtl，可個別覆寫
          }
        }
      },
      // sagittal, axial 視圖設定 (類似 coronal)
    ];
```

* `CTView` 類別：
    

```javascript
    class CTView {
      constructor(config) {
        // ... (初始化程式碼，參考完整對話) ...
        this.initSliders();
        this.addEvents();
      }

      initSliders() {
        for (const key in this.axes) {
          const axis = this.axes[key];
          let range = (axis.globalAxis === 'x') ? NX : ((axis.globalAxis === 'y') ? NY : NZ);
          axis.sliderElement.min = 0;
          axis.sliderElement.max = range - 1;
          axis.sliderElement.value = globalCoord[axis.globalAxis];
        }
      }

      addEvents() {
        for (const key in this.axes) {
          const axis = this.axes[key];
          axis.sliderElement.addEventListener('input', (e) => {
            globalCoord[axis.globalAxis] = parseInt(e.target.value);
            updateAllViews();
          });
        }

        this.container.addEventListener('click', (e) => {
          // ... (點擊事件處理，參考完整對話) ...
          updateAllViews();
        });
      }

      update() {
        // ... (更新輔助線與影像，參考完整對話) ...
      }
    }
```

* 滑桿事件處理：在 `CTView` 的 `addEvents()` 方法中，為每個滑桿綁定 `input` 事件：
    

```javascript
    axis.sliderElement.addEventListener('input', (e) => {
      globalCoord[axis.globalAxis] = parseInt(e.target.value);
      updateAllViews();
    });
```

* 點擊事件處理：在 `CTView` 的 `addEvents()` 方法中，為影像容器綁定 `click` 事件：
    

```javascript
      this.container.addEventListener('click', (e) => {
        const rect = this.imageElement.getBoundingClientRect();
        const clickX = e.clientX - rect.left;
        const clickY = e.clientY - rect.top;

        for (const key in this.axes) {
          const axis = this.axes[key];
          let norm = (key === 'horizontal') ? (clickX / rect.width) : (clickY / rect.height);
          if (axis.reverse) norm = 1 - norm;
          let range = (axis.globalAxis === 'x') ? NX : ((axis.globalAxis === 'y') ? NY : NZ);
          let newVal = axis.bounds[0] + norm * (axis.bounds[1] - axis.bounds[0]);
          globalCoord[axis.globalAxis] = Math.max(0, Math.min(Math.round(newVal), range - 1));
        }
        updateAllViews();
      });
```

可能錯誤： 
- `rect` 計算錯誤：確保使用 `getBoundingClientRect()` 取得相對於視窗的位置，而非相對於父元素。
- 比例計算錯誤：`norm` 應為點擊位置與影像寬度或高度的比例。 
- `bounds` 映射錯誤：使用正確的公式 `newVal = bounds[0] + norm * (bounds[1] - bounds[0])`。
- 範圍限制錯誤：使用 `Math.max(0, Math.min(Math.round(newVal), range - 1))` 確保索引在有效範圍內。

* 視圖更新 `update()` 方法：
    

```javascript
    update() {
      const rect = this.imageElement.getBoundingClientRect();
      for (const key in this.axes) {
        const axis = this.axes[key];
        let norm = (globalCoord[axis.globalAxis] - axis.bounds[0]) / (axis.bounds[1] - axis.bounds[0]);
        if (axis.reverse) norm = 1 - norm;
        if (key === 'horizontal') {
          axis.crosshairElement.style.left = (norm * rect.width) + 'px';
        } else {
          axis.crosshairElement.style.top = (norm * rect.height) + 'px';
        }
        axis.sliderElement.value = globalCoord[axis.globalAxis];
      }
      const idx = globalCoord[this.config.imageIndexSource];
      this.imageElement.src = this.config.path + this.config.dataset[idx];
    }
```

* `updateAllViews()` 函數：
    

```javascript
    const views = viewConfigs.map(config => new CTView(config));

    function updateAllViews() {
      views.forEach(view => view.update());
    }
```

* 完整實現

```html
<!DOCTYPE html>
<html lang="zh-TW">
<head>
  <meta charset="UTF-8">
  <title>3D CT Viewer - Refactored & Unified with New Vertical Slider</title>
  <style>
    /* 全域樣式與 slider 樣式 */
    body {
      display: flex;
      flex-wrap: wrap;
      font-family: sans-serif;
      margin: 0;
    }
    .view-container {
      margin: 10px;
      display: inline-block;
    }
    .view-content {
      display: flex;
      align-items: flex-start;
      justify-content: flex-start;
      position: relative;
    }
    .image-wrapper {
      position: relative;
    }
    .view-image {
      max-width: 300px;
      display: block;
    }
    :root {
      --crosshair-thickness: 0.5px;
      --crosshair-color: red;
      --crosshair-opacity: 0.7;
    }
    .crosshair-line {
      position: absolute;
      background-color: var(--crosshair-color);
      opacity: var(--crosshair-opacity);
    }
    .crosshair-h {
      height: var(--crosshair-thickness);
    }
    .crosshair-v {
      width: var(--crosshair-thickness);
    }
    .slider-container {
      display: flex;
      flex-direction: column;
      align-items: center;
      margin-top: 0;
    }
    .slider-horizontal {
      width: 300px;
    }
    .slider-container-vertical {
      display: flex;
      flex-direction: column;
      align-items: center;
      margin-left: 10px;
    }
    /* 新 vertical slider 樣式：使用 writing-mode 與 direction CSS 變數
       預設 direction 為 rtl，但可透過 --slider-direction 個別覆寫 */
    .slider-vertical {
      writing-mode: vertical-lr;
      direction: var(--slider-direction, rtl);
      width: 8px;
      margin: 0;
    }
  </style>
</head>
<body>
  <!-- coronal view -->
  <div class="view-container" id="coronal-container">
    <div class="view-content">
      <div class="image-wrapper" id="coronal-wrapper">
        <img id="coronal-img" class="view-image" src="" alt="Coronal View">
        <!-- 輔助線：crosshair-h 決定垂直位置，crosshair-v 決定水平位置 -->
        <div id="coronal-crosshair-h" class="crosshair-line crosshair-h"></div>
        <div id="coronal-crosshair-v" class="crosshair-line crosshair-v"></div>
      </div>
      <div class="slider-container-vertical">
        <input type="range" id="coronal-slider-v" class="slider-vertical" step="1">
      </div>
    </div>
    <div class="slider-container">
      <input type="range" id="coronal-slider-h" class="slider-horizontal" step="1">
    </div>
  </div>

  <!-- sagittal view -->
  <div class="view-container" id="sagittal-container">
    <div class="view-content">
      <div class="image-wrapper" id="sagittal-wrapper">
        <img id="sagittal-img" class="view-image" src="" alt="Sagittal View">
        <div id="sagittal-crosshair-h" class="crosshair-line crosshair-h"></div>
        <div id="sagittal-crosshair-v" class="crosshair-line crosshair-v"></div>
      </div>
      <div class="slider-container-vertical">
        <input type="range" id="sagittal-slider-v" class="slider-vertical" step="1">
      </div>
    </div>
    <div class="slider-container">
      <input type="range" id="sagittal-slider-h" class="slider-horizontal" step="1">
    </div>
  </div>

  <!-- axial view -->
  <div class="view-container" id="axial-container">
    <div class="view-content">
      <div class="image-wrapper" id="axial-wrapper">
        <img id="axial-img" class="view-image" src="" alt="Axial View">
        <div id="axial-crosshair-h" class="crosshair-line crosshair-h"></div>
        <div id="axial-crosshair-v" class="crosshair-line crosshair-v"></div>
      </div>
      <div class="slider-container-vertical">
        <input type="range" id="axial-slider-v" class="slider-vertical" step="1">
      </div>
    </div>
    <div class="slider-container">
      <input type="range" id="axial-slider-h" class="slider-horizontal" step="1">
    </div>
  </div>

  <script>
    /**********************
     * 影像資料與全域軸設定
     **********************/
    // 影像資料（請依實際檔名修改）
 let coronalImages = [
        "Screenshot 2025-03-17 at 9.27.35.png",
        "Screenshot 2025-03-17 at 9.27.45.png",
        "Screenshot 2025-03-17 at 9.27.48.png",

    ];
    let sagittalImages = [
        "Screenshot 2025-03-17 at 9.26.23.png",
        "Screenshot 2025-03-17 at 9.26.16.png",
        "Screenshot 2025-03-17 at 9.26.14.png",
    ];
    let axialImages = [
      "Screenshot 2025-03-17 at 9.20.44.png",
      "Screenshot 2025-03-17 at 9.20.41.png",
      "Screenshot 2025-03-17 at 9.20.39.png",
    ];
    // 全域軸範圍依影像數量決定
    const NX = sagittalImages.length; // 用於 global.x
    const NY = coronalImages.length;   // 用於 global.y
    const NZ = axialImages.length;     // 用於 global.z

    // 全域座標（儲存影像索引），初始置中
    let globalCoord = {
      x: Math.floor(NX / 2),
      y: Math.floor(NY / 2),
      z: Math.floor(NZ / 2)
    };

    /**********************
     * 統一配置設定
     *
     * 每個 view 的設定分為兩部分：
     * 1. 影像選擇設定： imageIndexSource 指定用哪個全域軸決定顯示哪張影像，
     *    path 與 dataset 分別為影像路徑與檔案名稱陣列。
     * 2. 軸向操作設定：每個 view 的 axes 為一物件，包含 horizontal 與 vertical，
     *    每個軸定義：
     *      - globalAxis：對應全域座標 ("x", "y", "z")
     *      - bounds：該軸在物理座標下的上下界
     *      - reverse：是否反轉 normalized 值（true 表示要 1 - normalized）
     *      - sliderId：該軸對應的 slider 元素 ID
     *      - crosshairId：該軸對應的輔助線元素 ID
     *      - (vertical 軸可選) sliderDirection：控制 slider 的方向，預設為 "rtl"
     **********************/
    const viewConfigs = [
      {
        name: "coronal",
        containerId: "coronal-wrapper",
        imageElementId: "coronal-img",
        imageIndexSource: "y",  // 以 global.y 選圖
        path: "coronal/",
        dataset: coronalImages,
        axes: {
          horizontal: {
            globalAxis: "x",
            bounds: [-40, 125],
            reverse: false,
            sliderId: "coronal-slider-h",
            crosshairId: "coronal-crosshair-v"
          },
          vertical: {
            globalAxis: "z",
            bounds: [-15.5, 81],
            reverse: true,
            sliderId: "coronal-slider-v",
            crosshairId: "coronal-crosshair-h",
            sliderDirection: "rtl"  // 預設 rtl，可個別覆寫
          }
        }
      },
      {
        name: "sagittal",
        containerId: "sagittal-wrapper",
        imageElementId: "sagittal-img",
        imageIndexSource: "x",  // 以 global.x 選圖
        path: "sagittal/",
        dataset: sagittalImages,
        axes: {
          horizontal: {
            globalAxis: "y",
            bounds: [-33, 94],
            reverse: false,
            sliderId: "sagittal-slider-h",
            crosshairId: "sagittal-crosshair-v"
          },
          vertical: {
            globalAxis: "z",
            bounds: [-6.3, 73],
            reverse: true,
            sliderId: "sagittal-slider-v",
            crosshairId: "sagittal-crosshair-h",
            sliderDirection: "rtl"  // 此處設定為 ltr
          }
        }
      },
      {
        name: "axial",
        containerId: "axial-wrapper",
        imageElementId: "axial-img",
        imageIndexSource: "z",  // 以 global.z 選圖
        path: "axial/",
        dataset: axialImages,
        axes: {
          horizontal: {
            globalAxis: "x",
            bounds: [-40, 126],
            reverse: false,
            sliderId: "axial-slider-h",
            crosshairId: "axial-crosshair-v"
          },
          vertical: {
            globalAxis: "y",
            bounds: [-12, 78.6],
            reverse: false,
            sliderId: "axial-slider-v",
            crosshairId: "axial-crosshair-h",
            sliderDirection: "ltr"
          }
        }
      }
    ];

    /**********************
     * CTView 類別：根據統一設定初始化、綁定事件與更新畫面
     **********************/
    class CTView {
      constructor(config) {
        this.config = config;
        this.container = document.getElementById(config.containerId);
        this.imageElement = document.getElementById(config.imageElementId);
        this.axes = {};
        for (const key in config.axes) {
          const axisConfig = config.axes[key];
          axisConfig.sliderElement = document.getElementById(axisConfig.sliderId);
          axisConfig.crosshairElement = document.getElementById(axisConfig.crosshairId);
          // 若為 vertical 軸，設定 slider 的 CSS 變數 --slider-direction
          if (key === "vertical") {
            axisConfig.sliderElement.style.setProperty("--slider-direction", axisConfig.sliderDirection || "rtl");
          }
          this.axes[key] = axisConfig;
        }
        this.initSliders();
        this.addEvents();
      }
      initSliders() {
        // 為每個軸設定 slider 範圍與初始值
        for (const key in this.axes) {
          const axis = this.axes[key];
          let range = (axis.globalAxis === "x") ? NX :
                      (axis.globalAxis === "y") ? NY :
                      (axis.globalAxis === "z") ? NZ : 0;
          axis.sliderElement.min = 0;
          axis.sliderElement.max = range - 1;
          axis.sliderElement.value = globalCoord[axis.globalAxis];
        }
      }
      addEvents() {
        // 為每個軸的 slider 綁定事件
        for (const key in this.axes) {
          const axis = this.axes[key];
          axis.sliderElement.addEventListener("input", (e) => {
            globalCoord[axis.globalAxis] = parseInt(e.target.value);
            updateAllViews();
          });
        }
        // 點擊事件：根據點擊位置更新每個軸的值
        this.container.addEventListener("click", (e) => {
          const rect = this.imageElement.getBoundingClientRect();
          const clickX = e.clientX - rect.left;
          const clickY = e.clientY - rect.top;
          ["horizontal", "vertical"].forEach((axisKey) => {
            const axis = this.axes[axisKey];
            let norm = (axisKey === "horizontal") ? clickX / rect.width : clickY / rect.height;
            if (axis.reverse) norm = 1 - norm;
            const newVal = axis.bounds[0] + norm * (axis.bounds[1] - axis.bounds[0]);
            let range = (axis.globalAxis === "x") ? NX :
                        (axis.globalAxis === "y") ? NY :
                        (axis.globalAxis === "z") ? NZ : 0;
            globalCoord[axis.globalAxis] = Math.max(0, Math.min(Math.round(newVal), range - 1));
          });
          updateAllViews();
        });
      }
      update() {
        const rect = this.imageElement.getBoundingClientRect();
        // 更新每個軸的輔助線與 slider 顯示
        for (const key in this.axes) {
          const axis = this.axes[key];
          let norm = (globalCoord[axis.globalAxis] - axis.bounds[0]) / (axis.bounds[1] - axis.bounds[0]);
          if (axis.reverse) norm = 1 - norm;
          if (key === "horizontal") {
            const pos = norm * rect.width;
            axis.crosshairElement.style.left = pos + "px";
            axis.crosshairElement.style.top = "0px";
            axis.crosshairElement.style.width = "var(--crosshair-thickness)";
            axis.crosshairElement.style.height = rect.height + "px";
          } else if (key === "vertical") {
            const pos = norm * rect.height;
            axis.crosshairElement.style.top = pos + "px";
            axis.crosshairElement.style.left = "0px";
            axis.crosshairElement.style.width = rect.width + "px";
            axis.crosshairElement.style.height = "var(--crosshair-thickness)";
          }
          // 同步 slider 顯示
          axis.sliderElement.value = globalCoord[axis.globalAxis];
        }
        // 依據 imageIndexSource 從 globalCoord 選圖
        const idx = globalCoord[this.config.imageIndexSource];
        this.imageElement.src = this.config.path + this.config.dataset[idx];
      }
    }

    // 建立所有 view 實例
    const views = viewConfigs.map(config => new CTView(config));

    function adjustSliderSizes() {
      views.forEach(view => {
        const rect = view.imageElement.getBoundingClientRect();
        view.axes.horizontal.sliderElement.style.width = rect.width + "px";
        view.axes.vertical.sliderElement.style.height = rect.height + "px";
      });
    }
    function updateAllViews() {
      views.forEach(view => view.update());
      adjustSliderSizes();
    }
    window.addEventListener("load", updateAllViews);
    window.addEventListener("resize", updateAllViews);
  </script>
</body>
</html>


```

# 結論

簡易的 3D 影像網頁瀏覽器實現，透過全域座標系統同步多個視角的影像顯示、滑桿操作與輔助線定位。

* **全域座標**: 使用 `globalCoord` 物件統一管理各個軸向的影像索引。
    
* **配置驅動**: 透過 `viewConfigs` 物件集中管理每個視圖的設定，提高程式碼的可維護性。
    
* **事件驅動**: 利用滑桿的 `input` 事件與影像的 `click` 事件觸發全域座標更新與視圖同步。
    
* **比例映射**: 將點擊位置轉換為比例，並利用 `bounds` 映射到實際座標範圍，確保索引的準確性。
