🌟

Mirador 4でキャンバス指定と検索語ハイライトを同時に実現する方法

に公開

はじめに

IIIF(International Image Interoperability Framework)ビューアとして広く使われているMiradorで、以下の要件を満たす実装を行いました:

  1. URLパラメータで指定したキャンバス(ページ)を初期表示する
  2. 指定したキャンバス内の検索語をハイライト表示する

本記事では、この要件を実現するためのアプローチと実装方法を共有します。

アプローチの検討

defaultSearchQueryオプション

Mirador 4では、ウィンドウ設定に defaultSearchQuery オプションを指定することで、初期化時に自動的に検索を実行できます:

const miradorViewer = Mirador.viewer({
  windows: [{
    manifestId: manifestUrl,
    canvasId: canvasId,
    defaultSearchQuery: '検索語',
  }],
});

このオプションは検索を自動実行する便利な機能ですが、今回の要件では以下の点を考慮する必要がありました:

  1. ページ遷移の制御 - 検索実行時、最初のヒットのページに自動遷移する仕様のため、canvasId で指定したページに留まりたい場合は追加の制御が必要
  2. 検索完了タイミングの把握 - 非同期で検索が実行されるため、完了後に処理を行いたい場合はRedux状態の監視が必要

より直接的なアプローチ

検討の結果、receiveSearch アクションを直接使用するアプローチを採用しました。このアプローチでは:

  1. Search APIを直接呼び出して、指定キャンバスに該当するヒットのみを取得
  2. IIIF Search API形式のレスポンスを構築
  3. receiveSearch アクションで検索結果としてMiradorに登録

これにより、指定キャンバスの表示を維持しながら、Miradorの検索ハイライト機能をそのまま活用できます。

実装

1. Search APIからヒットを取得する関数

const getSearchHitsForCanvas = async (
  manifestUrl: string,
  canvasId: string,
  query: string
): Promise<{ id: string; chars: string; xywh: string }[]> => {
  try {
    // manifestからsearch serviceのURLを取得
    const manifestResponse = await fetch(manifestUrl);
    if (!manifestResponse.ok) return [];

    const manifest = await manifestResponse.json();

    // IIIF Search API serviceを探す
    const services = manifest.service || [];
    const searchService = (Array.isArray(services) ? services : [services]).find(
      (s: { profile?: string }) => s.profile?.includes('search')
    );

    if (!searchService) return [];

    const searchBaseUrl = searchService['@id'] || searchService.id;
    const searchUrl = `${searchBaseUrl}?q=${encodeURIComponent(query)}`;

    const response = await fetch(searchUrl);
    if (!response.ok) return [];

    const data = await response.json();
    const resources = data.resources || [];

    // 指定キャンバスに該当するヒットのみを抽出
    const hits: { id: string; chars: string; xywh: string }[] = [];
    for (const resource of resources) {
      const [resourceCanvas, fragment] = resource.on.split('#');
      if (resourceCanvas === canvasId && fragment) {
        hits.push({
          id: resource['@id'] || resource.id,
          chars: resource.resource?.chars || query,
          xywh: fragment.replace('xywh=', ''),
        });
      }
    }
    return hits;
  } catch (error) {
    console.error('Failed to fetch search hits:', error);
    return [];
  }
};

この実装では、manifestのserviceプロパティからIIIF Search APIのエンドポイントを動的に取得しています。これにより、任意のIIIF準拠サーバーで動作します。

2. Mirador初期化とハイライト登録

// Miradorを初期化
const miradorViewer = window.Mirador.viewer({
  id: 'mirador-viewer',
  windows: [{
    id: 'window-1',
    manifestId: manifestUrl,
    canvasId: canvasId,  // 指定キャンバスを表示
    thumbnailNavigationPosition: 'far-right',
  }],
  window: {
    allowFullscreen: true,
    allowClose: false,
    sideBarOpen: true,
  },
  workspaceControlPanel: {
    enabled: false,
  },
});

// 検索語とキャンバスが指定されている場合、ハイライトを追加
if (searchQuery && canvasId) {
  const hits = await getSearchHitsForCanvas(manifestUrl, canvasId, searchQuery);

  if (hits.length > 0) {
    const M = window.Mirador as Record<string, unknown>;
    const receiveSearch = M.receiveSearch as (
      windowId: string,
      companionWindowId: string,
      searchId: string,
      searchJson: unknown
    ) => Record<string, unknown>;

    if (receiveSearch) {
      // IIIF Search API形式のレスポンスを構築
      const searchResponse = {
        '@context': 'http://iiif.io/api/search/1/context.json',
        '@id': `${canvasId}/search?q=${encodeURIComponent(searchQuery)}`,
        '@type': 'sc:AnnotationList',
        within: {
          '@type': 'sc:Layer',
          total: hits.length,
        },
        resources: hits.map((hit, index) => ({
          '@id': `${canvasId}/search-result-${index}`,
          '@type': 'oa:Annotation',
          motivation: 'sc:painting',
          resource: {
            '@type': 'cnt:ContentAsText',
            chars: hit.chars,
          },
          on: `${canvasId}#xywh=${hit.xywh}`,
        })),
      };

      // 検索パネルを右側に追加
      const addCompanionWindow = M.addCompanionWindow as (
        windowId: string,
        payload: { content: string; position: string }
      ) => Record<string, unknown>;

      const addAction = addCompanionWindow('window-1', {
        content: 'search',
        position: 'right',
      });
      miradorViewer.store.dispatch(addAction);

      // companionWindowIdを取得
      const state = miradorViewer.store.getState();
      const searchCompanionWindowId = Object.keys(state.companionWindows).find(
        id => state.companionWindows[id].content === 'search'
      );

      if (searchCompanionWindowId) {
        // 検索結果を登録
        const searchAction = receiveSearch(
          'window-1',
          searchCompanionWindowId,
          `${canvasId}/search?q=${encodeURIComponent(searchQuery)}`,
          searchResponse
        );
        miradorViewer.store.dispatch(searchAction);
      }
    }
  }
}

ポイント

1. Miradorのアクション関数へのアクセス

Mirador 4では、アクション関数がグローバルオブジェクトから直接アクセスできます:

const M = window.Mirador as Record<string, unknown>;
const receiveSearch = M.receiveSearch;
const addCompanionWindow = M.addCompanionWindow;

Mirador.actions ではなく、Mirador.receiveSearch のように直接アクセスする点に注意してください。

2. IIIF Search API形式のレスポンス

receiveSearch に渡すJSONは、IIIF Search API 1.0の形式に準拠する必要があります:

{
  "@context": "http://iiif.io/api/search/1/context.json",
  "@type": "sc:AnnotationList",
  "resources": [
    {
      "@type": "oa:Annotation",
      "motivation": "sc:painting",
      "resource": {
        "@type": "cnt:ContentAsText",
        "chars": "ヒットしたテキスト"
      },
      "on": "キャンバスURI#xywh=x,y,width,height"
    }
  ]
}

3. CompanionWindowの配置

検索パネルの配置は position パラメータで制御できます:

  • left: 左側(メタデータパネルと同じ位置)
  • right: 右側

左側にメタデータ、右側に検索結果という配置が自然です。

結論

receiveSearch アクションを直接呼び出すことで、以下のメリットが得られました:

  1. キャンバス表示の制御 - 指定したキャンバスを初期表示しながらハイライトを追加できる
  2. ハイライトの即時表示 - 検索結果を直接登録するため、ハイライトが即座に表示される
  3. パネル配置の制御 - 検索パネルの位置を自由に設定できる

この方法はMiradorのReduxストアの仕組みを活用しており、柔軟なカスタマイズが可能です。

参考リンク

Discussion