はじめに

本章では、前章で構築したDTS APIを利用するビューア「DTS Viewer」の開発について解説します。DTS Viewerは、DTSのエンドポイントにアクセスしてテキストコレクションのブラウジング、TEI/XMLテキストのレンダリング、およびIIIF画像の統合表示を実現するWebアプリケーションです。

DTS Viewerの概要

DTS Viewerは、以下の機能を持つWebアプリケーションです:

  • コレクションブラウジング: DTS Collection APIを通じて、テキストコレクションの階層構造をナビゲーション
  • テキスト表示: DTS Document APIから取得したTEI/XMLをブラウザ上でレンダリング
  • ページネーション: DTS Navigation APIを使った巻・章段単位のページ送り
  • IIIF画像統合: テキストに関連するIIIF画像の並列表示

技術スタック

DTS Viewerは以下の技術で構築しています:

  • フレームワーク: Next.js(React)
  • TEIレンダリング: CETEIcean
  • IIIF画像表示: Mirador / OpenSeadragon
  • スタイリング: CSS Modules
  • デプロイ: Vercel

プロジェクトの構成

dts-vpclsnpiaoiteaegmbyxcwep/ltkesicdoCNTIPdet.arnoonoaEIatsecgdlcelvIIgsioeelunliVFi..n.xe[m[tegiVnjcfj.cieiscaeiassisjtdnd/ttwetsgosi]t]iiewi.no./.ooreojnjjnn.rns/ssL.j..ijsjjsssst.js##########DTTITEEISIIIFAPI

DTS APIクライアントの実装

DTS APIとの通信を行うクライアントモジュールを作成します。

// lib/dts.js
const DTS_BASE_URL = process.env.NEXT_PUBLIC_DTS_API_URL;

export async function getCollection(id = null) {
  const url = id
    ? `${DTS_BASE_URL}/collection?id=${encodeURIComponent(id)}`
    : `${DTS_BASE_URL}/collection`;

  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`Collection API error: ${response.status}`);
  }
  return response.json();
}

export async function getNavigation(id, level = 1) {
  const url = `${DTS_BASE_URL}/navigation?id=${encodeURIComponent(id)}&level=${level}`;

  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`Navigation API error: ${response.status}`);
  }
  return response.json();
}

export async function getDocument(id, ref = null) {
  let url = `${DTS_BASE_URL}/document?id=${encodeURIComponent(id)}`;
  if (ref) {
    url += `&ref=${encodeURIComponent(ref)}`;
  }

  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`Document API error: ${response.status}`);
  }
  return response.text();
}

CETEIceanによるTEIレンダリング

CETEIceanの導入

CETEIceanは、TEI/XMLをHTML Custom Elementsに変換してブラウザ上で表示するJavaScriptライブラリです。TEIのタグ構造を保持したまま、CSSでスタイリングすることができます。

npm install CETEIcean

TEIViewerコンポーネントの実装

// components/TEIViewer.js
import { useEffect, useRef } from 'react';
import CETEI from 'CETEIcean';
import styles from '../styles/tei.module.css';

export default function TEIViewer({ teiXml }) {
  const containerRef = useRef(null);

  useEffect(() => {
    if (!teiXml || !containerRef.current) return;

    const ct = new CETEI();

    // TEI要素に対するカスタムビヘイビアを定義
    ct.addBehaviors({
      "tei": {
        // app要素(校異情報)のハイライト表示
        "app": function(el) {
          el.style.backgroundColor = "#fff3cd";
          el.style.cursor = "pointer";
          el.addEventListener('click', () => {
            // 校異情報のポップアップ表示
            const rdgs = el.querySelectorAll('tei-rdg');
            let text = '異文:\n';
            rdgs.forEach(rdg => {
              const wit = rdg.getAttribute('wit');
              text += `${wit}: ${rdg.textContent}\n`;
            });
            alert(text);
          });
        },
        // lem要素(底本の読み)
        "lem": function(el) {
          el.style.fontWeight = "bold";
        },
        // lb要素(改行)
        "lb": function(el) {
          el.insertAdjacentHTML('beforebegin', '<br/>');
        }
      }
    });

    // TEI/XMLの文字列をパースして表示
    ct.makeHTML5(teiXml, (data) => {
      containerRef.current.innerHTML = '';
      containerRef.current.appendChild(data);
    });
  }, [teiXml]);

  return (
    <div ref={containerRef} className={styles.teiContainer} />
  );
}

TEI用のCSSスタイル

/* styles/tei.module.css */
.teiContainer {
  font-family: "Noto Serif JP", serif;
  line-height: 2;
  padding: 20px;
}

.teiContainer tei-app {
  background-color: #fff3cd;
  padding: 2px 4px;
  border-radius: 3px;
  cursor: pointer;
}

.teiContainer tei-app:hover {
  background-color: #ffc107;
}

.teiContainer tei-lem {
  font-weight: bold;
}

.teiContainer tei-rdg {
  display: none;
}

.teiContainer tei-head {
  font-size: 1.2em;
  font-weight: bold;
  display: block;
  margin: 1em 0 0.5em;
}

ページネーション機能の実装

DTS Navigation APIを利用して、巻や章段単位でのページ送りを実装します。

// components/Pagination.js
import { useState, useEffect } from 'react';
import { getNavigation } from '../lib/dts';
import Link from 'next/link';

export default function Pagination({ resourceId, currentRef }) {
  const [members, setMembers] = useState([]);

  useEffect(() => {
    async function fetchNavigation() {
      try {
        const nav = await getNavigation(resourceId);
        setMembers(nav.member || []);
      } catch (err) {
        console.error('Navigation fetch error:', err);
      }
    }
    fetchNavigation();
  }, [resourceId]);

  const currentIndex = members.findIndex(m => m.ref === currentRef);
  const prevRef = currentIndex > 0 ? members[currentIndex - 1].ref : null;
  const nextRef = currentIndex < members.length - 1
    ? members[currentIndex + 1].ref : null;

  return (
    <nav className="pagination">
      {prevRef && (
        <Link href={`/document/${resourceId}?ref=${prevRef}`}>
          前の章段へ
        </Link>
      )}

      <select
        value={currentRef || ''}
        onChange={(e) => {
          window.location.href =
            `/document/${resourceId}?ref=${e.target.value}`;
        }}
      >
        {members.map((m, i) => (
          <option key={i} value={m.ref}>
            {m.ref}
          </option>
        ))}
      </select>

      {nextRef && (
        <Link href={`/document/${resourceId}?ref=${nextRef}`}>
          次の章段へ
        </Link>
      )}
    </nav>
  );
}

IIIF画像との統合表示

テキストに関連するIIIF画像を並列表示することで、原本画像とテキストの対照閲覧が可能になります。

// components/IIIFViewer.js
import { useEffect, useRef } from 'react';

export default function IIIFViewer({ manifestUrl }) {
  const viewerRef = useRef(null);

  useEffect(() => {
    if (!manifestUrl || !viewerRef.current) return;

    // Miradorの初期化
    const mirador = window.Mirador.viewer({
      id: viewerRef.current.id,
      windows: [
        {
          manifestId: manifestUrl,
          thumbnailNavigationPosition: 'far-bottom'
        }
      ],
      window: {
        allowClose: false,
        allowMaximize: false,
        defaultSideBarPanel: 'info'
      }
    });

    return () => {
      // クリーンアップ
      if (viewerRef.current) {
        viewerRef.current.innerHTML = '';
      }
    };
  }, [manifestUrl]);

  return (
    <div
      id="mirador-viewer"
      ref={viewerRef}
      style={{ width: '100%', height: '600px' }}
    />
  );
}

テキストとIIIF画像の対照表示ページ

// pages/document/[id].js
import { useRouter } from 'next/router';
import { useState, useEffect } from 'react';
import { getDocument, getCollection } from '../../lib/dts';
import TEIViewer from '../../components/TEIViewer';
import IIIFViewer from '../../components/IIIFViewer';
import Pagination from '../../components/Pagination';

export default function DocumentPage() {
  const router = useRouter();
  const { id, ref } = router.query;
  const [teiXml, setTeiXml] = useState(null);
  const [metadata, setMetadata] = useState(null);

  useEffect(() => {
    if (!id) return;

    async function fetchData() {
      const [doc, col] = await Promise.all([
        getDocument(id, ref),
        getCollection(id)
      ]);
      setTeiXml(doc);
      setMetadata(col);
    }
    fetchData();
  }, [id, ref]);

  return (
    <div className="document-page">
      <h1>{metadata?.title || '読み込み中...'}</h1>

      <Pagination resourceId={id} currentRef={ref} />

      <div className="content-grid">
        <div className="text-panel">
          <TEIViewer teiXml={teiXml} />
        </div>
        {metadata?.extensions?.iiifManifest && (
          <div className="image-panel">
            <IIIFViewer
              manifestUrl={metadata.extensions.iiifManifest}
            />
          </div>
        )}
      </div>
    </div>
  );
}

まとめ

本章では、DTS APIを利用するビューア「DTS Viewer」をNext.jsで開発する手順を解説しました。CETEIceanによるTEI/XMLのブラウザ上でのレンダリング、DTS Navigation APIを使ったページネーション、そしてMiradorを使ったIIIF画像との統合表示を実現しました。次章では、DTS APIとビューアの更新・改善について解説します。

関連記事