ホーム 記事一覧 ブック DH週間トピックス 検索 このサイトについて
English

最新の記事

300万点超の点群データをブラウザで快適に表示する - Potree LODビューアの構築

300万点超の点群データをブラウザで快適に表示する - Potree LODビューアの構築

大規模な点群データ(LiDAR/LAZ)をWebブラウザで表示しようとすると、メモリ不足でクラッシュしてしまうことがあります。本記事では、Potree のLOD(Level of Detail)技術を使って、数百万点の点群をストレスなく表示する方法を紹介します。 https://3dtiles-viewer.vercel.app/potree-lod-viewer.html 使用データ データ名 : Utah State Capitol(ユタ州議事堂) 出典 : OpenTopography ダウンロードURL : https://object.cloud.sdsc.edu/v1/AUTH_opentopography/www/education/MatlabTopo/Utah_state_capitol.laz ファイルサイズ : 15MB(LAZ圧縮) 点数 : 3,481,512点 位置 : Salt Lake City, Utah, USA 課題 このデータをそのままThree.jsなどで読み込もうとすると、ブラウザがフリーズする可能性があります。 解決策:Potree Potreeは、大規模点群データのためのWebGLベースのビューアです。**LOD(Level of Detail)**により、カメラに近い部分は詳細に、遠い部分は粗く表示することで、数十億点のデータでもスムーズに動作します。 仕組み 点群をオクトリー構造 で空間分割 各ノードに異なる詳細度のデータを格納 視点に応じて必要なノードのみ動的に読み込み 手順 1. LAZファイルのダウンロード curl -L -o utah_capitol.laz \ "https://object.cloud.sdsc.edu/v1/AUTH_opentopography/www/education/MatlabTopo/Utah_state_capitol.laz" 2. LAZからPotree形式への変換 PotreeConverterをDockerで実行します。 # Dockerイメージの取得 docker pull synth3d/potreeconverter # 変換実行 docker run --rm \ -v $(pwd):/data \ -v $(pwd)/output:/output \ synth3d/potreeconverter \ PotreeConverter /data/utah_capitol.laz -o /output/utah_capitol 出力結果 : ...

mirador-annotations を Mirador 4.x へ移行した記録

mirador-annotations を Mirador 4.x へ移行した記録

背景 mirador-annotations は、IIIF ビューア Mirador にアノテーション機能を追加するプラグインです。 従来のプロジェクトは以下の構成でした: ビルドツール : nwb (Create React App ベース) UI ライブラリ : Material-UI v4 Mirador : 3.x React : 17.x しかし、以下の問題が発生していました: nwb のメンテナンス停止 - nwb は長期間更新されておらず、依存関係の競合が頻発 npm install の失敗 - 古い依存関係により、新しい環境でのセットアップが困難に セキュリティ脆弱性 - 古いパッケージに多数の脆弱性警告 これらの問題を解決するため、以下への移行を決定しました: ビルドツール : Vite UI ライブラリ : MUI v7 Mirador : 4.x React : 18.x 移行作業の概要 1. ビルドツールの移行 (nwb → Vite) nwb の設定ファイルを削除し、vite.config.js を新規作成しました。 主なポイント: // vite.config.js export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), ''); return { // draft-js が global を参照するため define: { global: 'globalThis', }, // 重複パッケージの解決 resolve: { dedupe: [ '@emotion/react', '@emotion/styled', 'react', 'react-dom', ], }, }; }); 2. Material-UI の移行 (v4 → v7) @material-ui/* を @mui/* に変更 makeStyles を sx prop に置き換え Grid コンポーネントの API 変更に対応 (item と xs props が size に統合) // 変更前 (MUI v4) Grid item xs={12}> // 変更後 (MUI v7) Grid size={12}> 3. Mirador 4.x への対応 Mirador 4.x では、アクションやセレクターのインポート方法が変更されました: ...

mirador-rotation-plugin 機能拡張

mirador-rotation-plugin 機能拡張

概要 mirador-rotation-pluginに以下の機能を追加しました: 90度単位の回転ボタン URLパラメータによるマニフェスト・回転角度の指定 UIの改善(リセットボタンのアイコン変更) ヘルプ機能(使い方を説明するダイアログ) 新機能の詳細 1. 90度単位の回転ボタン 従来は1度単位のスライダーのみでしたが、90度単位で素早く回転できるボタンを追加しました。 実装内容 src/plugins/MiradorRotation.js に以下の変更を加えました: import RotateLeftIcon from '@mui/icons-material/RotateLeft'; import RotateRightIcon from '@mui/icons-material/RotateRight'; // 90度回転のハンドラー const handleRotate90 = (direction) => { const newRotation = rotation + (direction * 90); updateViewport(windowId, { rotation: newRotation }); }; UIには2つのボタンを追加: 左回転ボタン : 反時計回りに90度回転 右回転ボタン : 時計回りに90度回転 翻訳対応 src/translations.js に英語・日本語の翻訳を追加: { en: { rotateLeft: 'Rotate 90° left', rotateRight: 'Rotate 90° right', }, ja: { rotateLeft: '左に90度回転', rotateRight: '右に90度回転', }, } 2. URLパラメータ対応 デモページでURLパラメータからマニフェストと回転角度を指定できるようになりました。 対応パラメータ パラメータ 説明 区切り文字 manifest IIIFマニフェストURL ;(セミコロン) rotation 初期回転角度(度) ;(セミコロン) デモページURL https://nakamura196.github.io/mirador-rotation-plugin/ 使用例 # 単一マニフェスト https://nakamura196.github.io/mirador-rotation-plugin/?manifest=https://example.com/manifest.json&rotation=180 # 複数マニフェスト(同じ回転角度) https://nakamura196.github.io/mirador-rotation-plugin/?manifest=url1;url2&rotation=90 # 複数マニフェスト(異なる回転角度) https://nakamura196.github.io/mirador-rotation-plugin/?manifest=url1;url2&rotation=90;180 実装内容 demo/src/index.js の主な変更: ...

Next.js + next-auth で GakuNin RDM と OAuth2 連携する

Next.js + next-auth で GakuNin RDM と OAuth2 連携する

はじめに 研究データ管理基盤「GakuNin RDM」と Next.js アプリケーションを OAuth2 で連携する方法を解説します。GakuNin RDM は OSF(Open Science Framework)互換の API を提供しているため、OSF の OAuth2 フローを参考に実装できます。 本記事では、next-auth を使用した実装方法と、アクセストークンの自動リフレッシュ というハマりポイントについて詳しく説明します。 GakuNin RDM とは GakuNin RDM(Research Data Management)は、国立情報学研究所(NII)が提供する研究データ管理サービスです。 URL : https://rdm.nii.ac.jp/ API : OSF 互換 REST API(https://api.rdm.nii.ac.jp/v2/) 認証 : OAuth2(https://accounts.rdm.nii.ac.jp/) 研究者が研究データを安全に保存・共有・公開できるプラットフォームで、学認(GakuNin)認証との連携により、日本の大学・研究機関のユーザーが利用できます。 事前準備 1. OAuth アプリケーションの登録 GakuNin RDM の設定画面から OAuth アプリケーションを登録します。 https://rdm.nii.ac.jp/settings/applications/ にアクセス 「Developer application を登録する」をクリック 以下を設定: Application name : アプリ名 Application homepage URL : http://localhost:3000(開発時) Application description : 説明 Authorization callback URL : http://localhost:3000/api/auth/callback/gakunin 登録後、Client ID と Client Secret が発行されます。 ...

【AWS Amplify】さくらのドメインでカスタムドメイン設定時にハマったポイント

【AWS Amplify】さくらのドメインでカスタムドメイン設定時にハマったポイント

はじめに AWS Amplifyでホスティングしているアプリに、さくらインターネットで管理しているドメインのサブドメインを設定しようとしたところ、「ドメインの所有権を検証中…」のまま進まない問題に遭遇しました。 原因はさくらのDNS特有の仕様 でした。同じ問題でハマっている方の参考になれば幸いです。 環境 AWS Amplify Hosting さくらインターネット ドメインコントロールパネル ネームサーバー: ns1.dns.ne.jp / ns2.dns.ne.jp 症状 Amplifyのカスタムドメイン設定画面で指示されたCNAMEレコードを設定したが、いつまで経っても「ドメインの所有権を検証中…」から進まない。 Amplifyからの指示内容 Amplifyからは以下のようなDNSレコード設定を求められます: 1. SSL証明書検証用 ホスト名 タイプ 値 _abc123.your-subdomain.example.com. CNAME _def456.xyz.acm-validations.aws. 2. サブドメイン転送用 ホスト名 タイプ 値 your-subdomain CNAME xxxxx.cloudfront.net 原因 digコマンドで確認したところ、CNAMEの値にドメイン名が二重に追加されていました。 $ dig your-subdomain.example.com CNAME # 期待する結果 your-subdomain.example.com. IN CNAME xxxxx.cloudfront.net. # 実際の結果(誤り) your-subdomain.example.com. IN CNAME xxxxx.cloudfront.net.example.com. さくらのDNSでは、CNAMEの値に末尾ドット(.)がない場合、自動的にゾーン名(ドメイン名)が補完される仕様 になっています。 解決方法 さくらのドメインコントロールパネルでCNAMEレコードを設定する際、値(データ)の末尾に必ずドット(.)を付ける 必要があります。 正しい設定例 エントリ名 タイプ データ _abc123.your-subdomain CNAME _def456.xyz.acm-validations.aws. ← 末尾に. your-subdomain CNAME xxxxx.cloudfront.net. ← 末尾に. ⚠️ 注意 : Amplifyの指示画面では2つ目のレコード(cloudfront.net)に末尾ドットが表示されていない場合がありますが、さくらのDNSでは両方とも末尾ドットが必要 です。 ...

Dydra JSON-LDシリアライゼーションの挙動と回避策

Dydra JSON-LDシリアライゼーションの挙動と回避策

概要 Dydraは優れたクラウドベースのRDFトリプルストアですが、JSON-LDシリアライゼーションにおいて、一部のケースで期待と異なる出力が得られることがあります。このブログでは、その挙動と、我々が実装した回避策について解説します。 確認された挙動 期待される出力 JSON-LD仕様では、URI参照は以下のようにオブジェクト形式で出力されることが一般的です: { "@id": "https://example.com/item/1", "@type": ["prov:Entity"], "prov:wasAttributedTo": { "@id": "https://sepolia.etherscan.io/address/0x1234..." }, "prov:wasGeneratedBy": { "@id": "https://sepolia.etherscan.io/tx/0xabcd..." } } Dydraで確認された出力 DydraのJSON-LDエンドポイントでは、一部のURI参照が単なる文字列として出力されるケースが確認されました: { "@id": "https://example.com/item/1", "@type": ["prov:Entity"], "prov:wasAttributedTo": "https://sepolia.etherscan.io/address/0x1234...", "prov:wasGeneratedBy": "https://sepolia.etherscan.io/tx/0xabcd..." } 注意 : この挙動は全てのプロパティで発生するわけではなく、@contextの定義やプロパティの種類によって異なる場合があります。 挙動の違いによる影響 形式 JSON-LDパーサーの解釈 { "@id": "..." } URI参照(他ノードへのリンク) "..." リテラル文字列 この違いにより、以下の影響が生じる可能性があります: グラフ構造のトラバーサルに影響 一部のSPARQLクエリ結果に影響 JSON-LDフレーミング処理に影響 型付きリテラルについて 同様に、xsd:dateTime などの型付きリテラルでも型情報が省略されるケースがあります。 期待される出力 : { "prov:startedAtTime": { "@value": "2025-01-15T10:30:00Z", "@type": "xsd:dateTime" } } 確認された出力 : { "prov:startedAtTime": "2025-01-15T10:30:00Z" } 回避策 アプローチ:TTL形式で取得してJSON-LDを構築 DydraはTurtle (TTL) 形式では正確にシリアライズするため、以下の戦略を採用しました: [クライアント] │ │ Accept: text/turtle v [Dydra SPARQL Endpoint] │ │ TTL形式で返却 v [n3パーサー] │ │ Quadsに変換 v [JSON-LD構築ロジック] │ │ 正しいJSON-LD v [アプリケーション] 実装 import { Parser } from "n3"; /** * TTLをパースしてJSON-LDに変換 * DydraのJSON-LDシリアライゼーションの挙動を回避 */ function turtleToJsonLd(turtle: string): RDFGraph { const parser = new Parser(); const quads = parser.parse(turtle); // Subject別にトリプルをグループ化 const subjects = new Map<string, Map<string, unknown[]>>(); for (const quad of quads) { const subjectId = quad.subject.value; if (!subjects.has(subjectId)) { subjects.set(subjectId, new Map()); } const predicates = subjects.get(subjectId)!; const predicateId = quad.predicate.value; if (!predicates.has(predicateId)) { predicates.set(predicateId, []); } // オブジェクトの値を型情報付きで構築 let objectValue: unknown; if (quad.object.termType === "NamedNode") { // URI参照: { "@id": "..." } ← ここがポイント objectValue = { "@id": quad.object.value }; } else if (quad.object.termType === "Literal") { const literal = quad.object; if (literal.language) { // 言語タグ付きリテラル objectValue = { "@value": literal.value, "@language": literal.language }; } else if (literal.datatype && literal.datatype.value !== "http://www.w3.org/2001/XMLSchema#string") { // 型付きリテラル(xsd:string以外) objectValue = { "@value": literal.value, "@type": literal.datatype.value }; } else { // プレーンリテラル objectValue = literal.value; } } else if (quad.object.termType === "BlankNode") { objectValue = { "@id": `_:${quad.object.value}` }; } else { objectValue = quad.object.value; } predicates.get(predicateId)!.push(objectValue); } // JSON-LD @graphを構築 const graph: Array<Record<string, unknown>> = []; for (const [subjectId, predicates] of subjects) { const node: Record<string, unknown> = { "@id": subjectId }; for (const [predicateId, objects] of predicates) { if (predicateId === "http://www.w3.org/1999/02/22-rdf-syntax-ns#type") { // @typeは特別扱い node["@type"] = objects.map((o) => { if (typeof o === "object" && o !== null && "@id" in o) { return (o as { "@id": string })["@id"]; } return o; }); } else { // 単一値の場合は配列から取り出す node[predicateId] = objects.length === 1 ? objects[0] : objects; } } graph.push(node); } return { "@context": JSONLD_CONTEXT, "@graph": graph, }; } 使用例 // TTL形式で取得して変換 const response = await fetch(`${DYDRA_ENDPOINT}/sparql`, { method: "POST", headers: { "Accept": "text/turtle", // TTLで取得 }, body: query, }); const turtle = await response.text(); const jsonld = turtleToJsonLd(turtle); // JSON-LDに変換 依存ライブラリ この回避策には n3 ライブラリが必要です: ...

GakuNin RDM Waterbutler API でアップロードしたファイルの詳細画面に遷移する方法

GakuNin RDM Waterbutler API でアップロードしたファイルの詳細画面に遷移する方法

Waterbutler API とは Waterbutler は、Center for Open Science (COS) が開発したファイルストレージ抽象化レイヤーです。OSF (Open Science Framework) および GakuNin RDM で使用されており、様々なストレージプロバイダー(OSF Storage、Amazon S3、Google Drive、Dropbox など)に対して統一的なAPIでファイル操作を行うことができます。 主な機能 ファイルのアップロード・ダウンロード ファイル/フォルダの作成・削除・移動・コピー メタデータの取得 エンドポイント GakuNin RDM : https://files.rdm.nii.ac.jp/v1 OSF : https://files.osf.io/v1 参考リンク Waterbutler GitHub OSF API Documentation 問題 GakuNin RDM の Waterbutler API を使用してファイルをアップロードした後、そのファイルの詳細画面に直接遷移したい場合があります。 しかし、RDM上ではプロジェクトの短縮URL(例:https://rdm.nii.ac.jp/qv3xf/)が表示されるため、アップロードしたファイルの詳細画面URLをどのように構成すればよいかわかりにくい状況でした。 Waterbutler API のレスポンス ファイルアップロード時の Waterbutler API レスポンス例: { "data": { "id": "osfstorage/67dacaa816000900109e1da3", "type": "files", "attributes": { "name": "nft-43-provenance-2025-12-29T13-08-47.zip", "kind": "file", "path": "/67dacaa816000900109e1da3" }, "links": { "download": "https://files.rdm.nii.ac.jp/v1/resources/wzv9g/providers/osfstorage/67dacaa816000900109e1da3", "move": "https://files.rdm.nii.ac.jp/v1/resources/wzv9g/providers/osfstorage/67dacaa816000900109e1da3" } } } 解決策:ファイル詳細URLの構成 ファイル詳細画面のURLは以下の形式で構成します: ...

Pinata V3 API グループ機能の実装ガイド

Pinata V3 API グループ機能の実装ガイド

Pinata の Files API v3 でグループ機能を使用する際のはまりポイントと解決策をまとめます。 背景 Pinata でアップロードしたファイルをグループで管理し、特定のグループに属するファイルのみを取得したいケースがあります。例えば、NFT登録フォームで使用する入力画像を「input」グループに格納し、そのグループからのみ画像を選択できるようにする場合などです。 はまりポイント 1. レガシーAPI と V3 API のファイル管理は分離されている 問題 : レガシーAPI(pinFileToIPFS)でアップロードしたファイルは、V3 API(/v3/files)では取得できません。逆も同様です。 レガシーAPI (pinList) → レガシーでアップロードしたファイルのみ表示 V3 API (/v3/files) → V3でアップロードしたファイルのみ表示 解決策 : どちらかのAPIに統一する。V3 APIに移行する場合は、新規アップロードからV3を使用し、既存ファイルは手動でグループに追加するか、マイグレーションを検討。 2. V3 API のエンドポイントには {network} パラメータが必要 問題 : V3 API のエンドポイントは {network} パラメータ(public または private)が必須。 ❌ GET /v3/files?group={group_id} ✅ GET /v3/files/public?group={group_id} 解決策 : 通常のIPFSファイルには public を使用。 3. グループフィルタのパラメータ名が異なる 問題 : アップロード時とリスト取得時でパラメータ名が異なる。 操作 パラメータ名 アップロード group_id リスト取得 group 4. JWT の種類による認証の違い 問題 : Scoped Key で生成した JWT は V3 API で動作しない場合がある。 ...

さくらレンタルサーバー Drupal 更新手順

さくらレンタルサーバー Drupal 更新手順

さくらのレンタルサーバーでDrupal 10.1.5から10.6.1へアップデートした際の手順をまとめます。 環境 サーバー:さくらのレンタルサーバー Drupal:10.1.5 → 10.6.1 インストール形式:従来型(tarball、web/ディレクトリなし) 事前準備 作業用ディレクトリの作成 バックアップは www 外に保存します(Webからアクセスできないようにするため)。 mkdir -p /home/[ユーザー名]/backups/drupal ファイルのバックアップ cd /home/[ユーザー名]/www tar -czvf /home/[ユーザー名]/backups/drupal/drupal_backup_$(date +%Y%m%d).tar.gz [drupalディレクトリ]/ データベースのバックアップ さくらのレンタルサーバーでは --no-tablespaces オプションが必要です。 cd /home/[ユーザー名]/www/[drupalディレクトリ] ./vendor/bin/drush sql-dump --extra-dump="--no-tablespaces" > /home/[ユーザー名]/backups/drupal/db_backup_$(date +%Y%m%d).sql Drupalコアのアップデート 権限の変更 sites/default ディレクトリは書き込み禁止になっていることが多いため、一時的に変更します。 chmod 755 /home/[ユーザー名]/www/[drupalディレクトリ]/sites/default chmod 644 /home/[ユーザー名]/www/[drupalディレクトリ]/sites/default/default.services.yml Composerでアップデート さくらのレンタルサーバーでは composer コマンドが使えないため、composer.phar を使用します。 cd /home/[ユーザー名]/www/[drupalディレクトリ] php composer.phar require drupal/core-recommended:^10 drupal/core-composer-scaffold:^10 --update-with-dependencies データベース更新とキャッシュクリア ./vendor/bin/drush updatedb -y ./vendor/bin/drush cache:rebuild 権限を戻す chmod 555 /home/[ユーザー名]/www/[drupalディレクトリ]/sites/default chmod 444 /home/[ユーザー名]/www/[drupalディレクトリ]/sites/default/settings.php トラブルシューティング Rulesモジュールの互換性エラー アップデート後に以下のようなエラーが出る場合: ...

IIIF Manifestから各巻の冒頭ページを抽出するツールを作成しました

IIIF Manifestから各巻の冒頭ページを抽出するツールを作成しました

はじめに IIIF(International Image Interoperability Framework)を利用したデジタルアーカイブでは、複数巻や複数章で構成される資料を1つのManifestにまとめることがあります。このような場合、各巻・各章の冒頭ページへのリンクを作成したいというニーズがあります。 今回、IIIF Manifestから各巻(range/structure)のlabel と最初のCanvas URL を抽出するシンプルなWebツールを作成しました。 ツールURL : https://nakamura196.github.io/iiif-manifest-extractor/ GitHub : https://github.com/nakamura196/iiif-manifest-extractor 機能 複数のManifest URLを一括処理(1行に1つのURL) 各巻・各章のlabelと最初のCanvas URLを一覧表示 CSV/JSON形式でのエクスポート 処理進捗のリアルタイム表示 使い方 ツールを開く Manifest URLをテキストエリアに入力(複数行可) 「抽出」ボタンをクリック 結果が表形式で表示される 必要に応じてCSV/JSONでダウンロード サンプル 以下のManifest URLで動作を確認できます。複数URLを入力することで、一括処理の動作も確認できます。 国立国会図書館デジタルコレクション「校異源氏物語」: https://dl.ndl.go.jp/api/iiif/3437686/manifest.json https://dl.ndl.go.jp/api/iiif/3437687/manifest.json これらのManifestは源氏物語の各帖(きりつほ、ははきゝ、うつせみ、わかむらさき…など)がstructuresに定義されており、各帖の冒頭ページを抽出できます。 2つのManifest URLを入力することで、複数のManifestを一括で処理し、結果をまとめてCSV出力できることを確認できます。 技術的な仕組み IIIF Presentation API v2のstructures IIIF Presentation API v2では、structuresプロパティを使って論理的な構造(目次)を定義できます。 { "structures": [ { "@type": "sc:Range", "label": "目次", "ranges": ["range1", "range2", ...] }, { "@id": "range1", "@type": "sc:Range", "label": "きりつほ", "canvases": [ "https://example.com/canvas/p1", "https://example.com/canvas/p2", ... ] } ] } 本ツールでは、structures内の各rangeから: label(巻名・章名など) canvases配列の最初の要素(冒頭ページのCanvas URL) を抽出しています。 ...

CloudFront + App Runner で 404 エラーが発生する問題の調査記録

CloudFront + App Runner で 404 エラーが発生する問題の調査記録

はじめに AWS App Runner で Cantaloupe(IIIF画像サーバー)をホストし、その前段に CloudFront を配置しようとしたところ、CloudFront 経由でアクセスすると全てのリクエストが 404 エラーになる問題に遭遇しました。 本記事では、問題の原因調査から試した解決策、そして結論までを記録します。 環境 アプリケーション : Cantaloupe 5.0.5(IIIF画像サーバー) ホスティング : AWS App Runner CDN : Amazon CloudFront リージョン : ap-northeast-1(東京) 問題の概要 症状 アクセス方法 結果 App Runner に直接アクセス 200 OK CloudFront 経由でアクセス 404 Not Found 確認したこと CloudFront 経由で 404 が返る際、レスポンスヘッダーに server: envoy が含まれていました。これは App Runner の内部プロキシ(Envoy)に到達していることを示しています。 $ curl -I https://xxxxx.cloudfront.net/ HTTP/2 404 server: envoy x-cache: Error from cloudfront つまり、CloudFront → App Runner の通信は成功しているが、App Runner 内部でリクエストがアプリケーション(Cantaloupe)に転送されていないことがわかりました。 ...

Nuxt 4 SSGでローカルJSONファイルを正しく読み込む方法

Nuxt 4 SSGでローカルJSONファイルを正しく読み込む方法

はじめに Nuxt 4でStatic Site Generation (SSG) を行う際、ローカルのJSONファイルからデータを読み込んで静的ページを生成したいケースがあります。しかし、Next.jsのgetStaticPropsのようにシンプルにはいかず、いくつかのハマりポイントがあります。 本記事では、試行錯誤の末に見つけた正しいアプローチを紹介します。 問題:なぜ単純なfsの読み込みでは動かないのか 最初に試したアプローチ(失敗) // ❌ これは動かない const fetchLocalData = async (filePath: string) => { if (import.meta.server) { const fs = await import('fs'); const path = await import('path'); const fullPath = path.resolve(process.cwd(), 'public/data', filePath); const data = fs.readFileSync(fullPath, 'utf-8'); return JSON.parse(data); } // クライアントサイド const response = await fetch(`/data/${filePath}`); return await response.json(); }; このアプローチには以下の問題があります: process.cwd()がビルド環境で異なる: ローカル開発とVercel等のビルド環境では作業ディレクトリが異なる Nitroのプリレンダリング時にファイルが見つからない : SSG時、Nitroは独自のコンテキストで動作する useAsyncDataなしでは、クライアントサイドでも実行される : SSGの意味がなくなる 解決策:Nitro Storage API + Server APIルート + useAsyncData アーキテクチャ [SSGビルド時] Page Component ↓ useAsyncData Server API Route (/api/local-data/[...path]) ↓ useStorage (Nitro Storage API) public/data/*.json [生成されたHTML] _payload.json にデータが埋め込まれる → クライアントサイドでのJSON読み込み不要 Step 1: nuxt.config.tsでserverAssetsを設定 // nuxt.config.ts export default defineNuxtConfig({ nitro: { prerender: { crawlLinks: true, failOnError: false, }, // Nitro Storage APIでpublic/dataをサーバーアセットとしてマウント serverAssets: [{ baseName: 'data', dir: './public/data' }], }, routeRules: { '/**': { prerender: true }, }, }); Step 2: Server APIルートを作成(Nitro Storage + fsフォールバック) // server/api/local-data/[...path].ts import { readFileSync, existsSync } from 'fs'; import { resolve } from 'path'; export default defineEventHandler(async (event) => { const pathParam = getRouterParam(event, 'path'); if (!pathParam) { throw createError({ statusCode: 400, message: 'Path is required' }); } const filePath = Array.isArray(pathParam) ? pathParam.join('/') : pathParam; // セキュリティ: パストラバーサル防止 if (filePath.includes('..')) { throw createError({ statusCode: 400, message: 'Invalid path' }); } // Nitro Storage APIを使用 // nuxt.config.tsのserverAssetsで定義した'data'ストレージにアクセス const storage = useStorage('assets:data'); // ファイルパスをストレージキーに変換(/を:に置換) const storageKey = filePath.replace(/\//g, ':'); // Storage APIで取得を試みる if (await storage.hasItem(storageKey)) { const data = await storage.getItem(storageKey); return data; } // フォールバック: 開発環境向けにfsで直接読み込み const fsPath = resolve(process.cwd(), 'public/data', filePath); if (existsSync(fsPath)) { const data = readFileSync(fsPath, 'utf-8'); return JSON.parse(data); } throw createError({ statusCode: 404, message: `File not found: ${filePath}` }); }); Nitro Storage APIを使うメリット: ...

MapLibre GL JS + れきちず で多言語対応の歴史地図を実装する

MapLibre GL JS + れきちず で多言語対応の歴史地図を実装する

歴史地図サービス「れきちず」が多言語対応(日本語・ひらがな・英語)したことを受けて、MapLibre GL JS で言語切り替えに対応した地図を実装する方法を紹介します。 れきちずとは れきちずは、江戸時代後期(1800〜1840年ごろ)の地図を現代風のデザインで閲覧できるWebサービスです。2025年11月に多言語対応が追加され、以下の3つのスタイルが提供されています。 言語 スタイルURL 日本語 https://mierune.github.io/rekichizu-style/styles/street/style.json ひらがな https://mierune.github.io/rekichizu-style/styles/street/style_hira.json 英語 https://mierune.github.io/rekichizu-style/styles/street/style_en.json シンプルなHTML版 フレームワークを使わず、素のHTML + JavaScriptだけで実装する例です。GETパラメータ(?lang=en)に応じて表示言語を切り替えます。 完全なHTMLファイル <!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>れきちず 多言語対応デモ</title> <script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script> <link href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css" rel="stylesheet" /> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: sans-serif; } #map { width: 100%; height: calc(100vh - 50px); } .controls { height: 50px; display: flex; align-items: center; justify-content: center; gap: 8px; background: #f5f5f5; border-bottom: 1px solid #ddd; } .lang-btn { padding: 8px 16px; border: 1px solid #ccc; border-radius: 4px; background: white; cursor: pointer; font-size: 14px; transition: all 0.2s; } .lang-btn:hover { background: #e0e0e0; } .lang-btn.active { background: #2196F3; color: white; border-color: #2196F3; } </style> </head> <body> <div class="controls"> <button class="lang-btn" data-lang="ja">日本語</button> <button class="lang-btn" data-lang="ja-Hira">ひらがな</button> <button class="lang-btn" data-lang="en">English</button> </div> <div id="map"></div> <script> // れきちずのスタイルURL const STYLES = { 'ja': 'https://mierune.github.io/rekichizu-style/styles/street/style.json', 'ja-Hira': 'https://mierune.github.io/rekichizu-style/styles/street/style_hira.json', 'en': 'https://mierune.github.io/rekichizu-style/styles/street/style_en.json' }; // GETパラメータから言語を取得 function getLangFromUrl() { const params = new URLSearchParams(window.location.search); const lang = params.get('lang'); return STYLES[lang] ? lang : 'ja'; // デフォルトは日本語 } // 現在の言語 let currentLang = getLangFromUrl(); // 地図を初期化 const map = new maplibregl.Map({ container: 'map', style: STYLES[currentLang], center: [139.7671, 35.6812], // 東京駅 zoom: 12 }); map.addControl(new maplibregl.NavigationControl(), 'top-right'); // ボタンのアクティブ状態を更新 function updateButtons() { document.querySelectorAll('.lang-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.lang === currentLang); }); } // URLを更新(履歴に追加) function updateUrl(lang) { const url = new URL(window.location); url.searchParams.set('lang', lang); window.history.pushState({}, '', url); } // 言語切り替え function switchLang(lang) { if (lang === currentLang) return; currentLang = lang; map.setStyle(STYLES[lang]); updateUrl(lang); updateButtons(); } // ボタンのクリックイベント document.querySelectorAll('.lang-btn').forEach(btn => { btn.addEventListener('click', () => switchLang(btn.dataset.lang)); }); // ブラウザの戻る/進むに対応 window.addEventListener('popstate', () => { const lang = getLangFromUrl(); if (lang !== currentLang) { currentLang = lang; map.setStyle(STYLES[lang]); updateButtons(); } }); // 初期状態を設定 updateButtons(); </script> </body> </html> 使い方 HTMLファイルをWebサーバーで配信(ローカルファイルでは動作しません) アクセス例: index.html → 日本語(デフォルト) index.html?lang=en → 英語 index.html?lang=ja-Hira → ひらがな ポイント GETパラメータの取得 ...

TEI ODDによるIIIF対応ファクシミリ記述の制約設計

TEI ODDによるIIIF対応ファクシミリ記述の制約設計

はじめに TEI(Text Encoding Initiative)でデジタル画像のメタデータを記述する際、facsimile要素を使用します。特にIIIF(International Image Interoperability Framework)対応のデジタルアーカイブでは、マニフェストやキャンバス、Image APIへの参照を適切に記述することが重要です。 本記事では、ODD(One Document Does it all)を使用して、ファクシミリ記述に必要な制約をスキーマとして定義する方法を紹介します。 準拠するガイドライン 本ODDは、日本語TEIガイドラインで紹介されている「IIIF画像とのリンク」仕様をベースにしています: IIIF画像とのリンク(2024年度版)- TEI-EAJ このガイドラインに準拠したデータを作成することで、TEI Viewer for EAJでの画像表示が可能になります。TEI Viewerは、TEIテキストとIIIF画像を連携して表示できるビューアであり、facsimile要素の情報を利用してテキストと画像の対応付けを行います。 設計目標 以下の要件を満たすスキーマを設計しました: 必須情報の明確化 : 画像の座標情報や識別子など、最低限必要な情報を必須属性として定義 IIIF対応 : マニフェスト、キャンバス、Image APIへの参照をオプショナルに記述可能 再利用性 : 独立したODDファイルとして、複数プロジェクトで共有可能 ビューア互換性 : TEI Viewer for EAJでの表示に必要な情報を確実に記録 最小限の記述例 <facsimile sameAs="https://example.org/iiif/manifest.json"> <surface ulx="0" uly="0" lrx="5600" lry="4000" xml:id="p1"> <graphic sameAs="https://example.org/image/001.tif"/> </surface> </facsimile> 完全な記述例(IIIF参照を含む) <facsimile sameAs="https://example.org/iiif/manifest.json"> <surface ulx="0" uly="0" lrx="5600" lry="4000" sameAs="https://example.org/canvas/p1" xml:id="p1"> <graphic url="https://example.org/image/001.tif/full/full/0/default.jpg" sameAs="https://example.org/image/001.tif"/> </surface> </facsimile> ODD定義の解説 1. facsimile要素 <elementSpec ident="facsimile" mode="change"> <desc>ファクシミリ画像情報。IIIFマニフェストへの参照を含む。</desc> <classes mode="replace"/> <content> <elementRef key="surface" minOccurs="1" maxOccurs="unbounded"/> </content> <attList mode="replace"> <attDef ident="sameAs" mode="replace" usage="opt"> <desc>IIIFマニフェストへの参照URL</desc> <datatype> <dataRef key="teidata.pointer"/> </datatype> </attDef> </attList> </elementSpec> ポイント : ...

ODD Chain チュートリアル

ODD Chain チュートリアル

TEI ODDの「チェーン」機能を使ってスキーマをカスタマイズする方法を学ぶチュートリアルです。 ODD Chainとは ODD chainには2つの方式があります: 1. 継承型(縦のチェーン) source属性で親ODDを参照し、カスタマイズを継承します。 TEI_all → ベースODD → 派生ODD → さらなる派生... 2. 組み合わせ型(横のチェーン) specGrpとspecGrpRefを使って、複数のODDを統合します。 ヘッダー用ODD ─┬─→ 統合されたスキーマ 本文用ODD ─────┘ フォルダ構成 tutorials/ ├── 01-inheritance/ # 継承型の例 │ ├── base.odd # ベースとなるODD │ └── derived.odd # base.oddを継承する派生ODD ├── 02-chain/ # 組み合わせ型の例 │ ├── header-specs.odd # ヘッダー関連のカスタマイズ │ ├── text-specs.odd # 本文関連のカスタマイズ │ ├── main.odd # 統合用メインODD │ └── merge-specs.xsl # specGrpRefを展開するXSLT ├── output/ # 生成されたファイル │ ├── base.rng # 01のベースODDから生成 │ ├── base.html # 同上のHTMLドキュメント │ ├── derived.rng # 01の派生ODDから生成 │ ├── derived.html # 同上のHTMLドキュメント │ ├── combined.rng # 02の統合ODDから生成 │ ├── combined.html # 同上のHTMLドキュメント │ └── intermediate/ # 中間ファイル │ ├── base.compiled.odd │ ├── derived.compiled.odd │ ├── combined.merged.odd │ └── combined.compiled.odd ├── build.sh # ビルドスクリプト └── README.md # このファイル 前提条件 Saxon(XSLT 2.0プロセッサ) TEI Stylesheets(../scripts/Stylesheetsにインストール済み) ビルド方法 cd tutorials ./build.sh 生成されるファイル ソースODD RNG HTML 01-inheritance/base.odd output/base.rng output/base.html 01-inheritance/derived.odd output/derived.rng output/derived.html 02-chain/main.odd(統合後) output/combined.rng output/combined.html 各ファイルの説明 01-inheritance(継承型) base.odd ベースとなるODD。最小限のモジュールと基本的なカスタマイズを含む。 ...

TEI古典籍ビューワをカスタマイズして判読不能箇所(gap)を表示する

TEI古典籍ビューワをカスタマイズして判読不能箇所(gap)を表示する

はじめに 東アジアの古典籍をデジタル化する際、TEI(Text Encoding Initiative)ガイドラインに準拠したXMLでマークアップすることが一般的になっています。一般財団法人人文情報学研究所が開発した「TEI古典籍ビューワ」は、このようなTEI/XMLファイルをブラウザで簡単に表示できる便利なツールです。 公式サイト: https://tei.dhii.jp/teiviewer4eaj Web版: https://candra.dhii.jp/nagasaki/tei/tei_viewer/ 今回、このビューワをカスタマイズして、判読不能箇所を示す<gap>タグの表示に対応しました。本記事では、そのカスタマイズ方法を紹介します。 デモ: https://nakamura196.github.io/custom-tei-viewer/?file=sample_gap.xml&height=1800 課題:gapタグが表示されない 古典籍のデジタル化において、虫損や破損などにより判読できない箇所は<gap>タグでマークアップします。 <gap reason="illegible" quantity="2" unit="character"/> しかし、標準のTEI古典籍ビューワでは、このタグが適切に表示されません。そこで、判読不能な文字数分の黒四角(■)を表示し、マウスホバーで理由を確認できるようにカスタマイズしました。 カスタマイズの方針 TEI古典籍ビューワは以下のファイル構成になっています。 ├── index.html ├── app.min.js ← ビューワ本体(minify済み) ├── app.min.css ├── app_conf.js ← 設定ファイル └── lib/ ← 依存ライブラリ app.min.jsを直接編集すると、本体のアップデート時に変更が失われてしまいます。そこで、app_conf.jsのみを編集することで、本体との互換性を保ちながらカスタマイズを実現しました。 実装方法 1. MutationObserverによるDOM監視 TEI古典籍ビューワはXMLをパースしてDOMに変換します。この変換後のタイミングで<gap>タグを処理するため、MutationObserverを使用してDOM変更を監視します。 // MutationObserverでDOM変更を監視 const observer = new MutationObserver(mutations => { mutations.forEach(mutation => { mutation.addedNodes.forEach(node => { if (node.nodeType === Node.ELEMENT_NODE) { processGapElements(node); } }); }); }); // body_resultの監視を開始 document.addEventListener('DOMContentLoaded', () => { const bodyResult = document.getElementById('body_result'); if (bodyResult) { observer.observe(bodyResult, { childList: true, subtree: true }); } }); 2. gapタグの処理 <gap>タグを検出したら、quantity属性の値に応じて黒四角を表示し、reason属性をツールチップとして設定します。 function processGapElements(container) { const gaps = container.querySelectorAll('gap, .gap, [data-original-tag-name="gap"]'); gaps.forEach(gap => { // 既に処理済みの場合はスキップ if (gap.dataset.gapProcessed) return; gap.dataset.gapProcessed = 'true'; // 属性から値を取得 const quantity = parseInt(gap.getAttribute('quantity') || '1', 10); const reason = gap.getAttribute('reason') || ''; // ■をquantity分だけ生成 const placeholder = '■'.repeat(quantity); gap.textContent = placeholder; // ツールチップの設定(日本語化) const reasonMap = { 'illegible': '判読不能', 'damage': '破損', 'worm': '虫損', 'omitted': '省略', 'cancelled': '抹消', 'lost': '欠損' }; const reasonText = reasonMap[reason] || reason; if (reasonText) { gap.setAttribute('title', reasonText); } gap.style.color = '#333'; gap.style.cursor = 'help'; }); } ポイント:属性の取得方法 TEI古典籍ビューワがXMLをHTMLに変換する際、属性の扱いは要素によって異なります。<gap>タグの場合、XML属性がそのまま保持されるため、getAttribute()で直接取得できます。 ...

Mirador 4で任意の領域をハイライト表示する方法

Mirador 4で任意の領域をハイライト表示する方法

はじめに IIIFビューアのMiradorには検索機能があり、IIIF Search APIに対応したマニフェストでは検索結果をハイライト表示できます。しかし、Search APIに非対応のマニフェストでも、任意の領域をハイライト表示したいケースがあります。 本記事では、Miradorの内部APIを利用して、外部データソースからのアノテーション情報を基にハイライト表示を実現する方法を紹介します。 デモ Highlight Generator Form - フォームからハイライトを生成 ユースケース 独自のOCRシステムで抽出したテキスト領域のハイライト 機械学習で検出したオブジェクトの領域表示 外部データベースに保存されたアノテーションの可視化 Search API非対応のIIIFサーバーでの検索結果表示 実装方法 基本的な仕組み Miradorは内部でReduxを使用しており、receiveSearchアクションを通じて検索結果を登録できます。このアクションにIIIF Search API形式のJSONを渡すことで、任意のデータソースからのハイライトを表示できます。 必要な情報 ハイライトを表示するために必要な情報は以下の3つです: キャンバスURI - ハイライトを表示するページのURI 座標(xywh) - ハイライト領域の位置とサイズ(x, y, width, height) テキスト - ハイライトに関連付けるテキスト(検索パネルに表示される) サンプルコード 以下は、国立国会図書館デジタルコレクションの源氏物語で「いつれの御時にか…」の冒頭部分をハイライト表示するサンプルです。 <!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Mirador Custom Highlight Sample</title> <style> body { margin: 0; padding: 0; } #mirador-viewer { width: 100%; height: 100vh; } </style> </head> <body> <div id="mirador-viewer"></div> <script src="https://unpkg.com/mirador@4.0.0-alpha.15/dist/mirador.min.js"></script> <script> // 設定パラメータ const config = { manifestUrl: 'https://dl.ndl.go.jp/api/iiif/3437686/manifest.json', canvasId: 'https://dl.ndl.go.jp/api/iiif/3437686/canvas/22', highlights: [ { xywh: '3095,694,97,2051', text: 'いつれの御時にか女御更衣あまたさふらひ給けるなかにいとやむことなきゝは', }, ], }; // Miradorを初期化 const miradorViewer = Mirador.viewer({ id: 'mirador-viewer', selectedTheme: 'light', language: 'ja', windows: [{ id: 'window-1', manifestId: config.manifestUrl, canvasId: config.canvasId, thumbnailNavigationPosition: 'far-right', }], window: { allowFullscreen: true, allowClose: false, allowMaximize: false, sideBarOpen: true, }, workspaceControlPanel: { enabled: false, }, }); // ハイライトを追加する関数 function addHighlights(viewer, canvasId, highlights) { // IIIF Search API形式のレスポンスを構築 const searchResponse = { '@context': 'http://iiif.io/api/search/1/context.json', '@id': canvasId + '/search', '@type': 'sc:AnnotationList', within: { '@type': 'sc:Layer', total: highlights.length, }, resources: highlights.map((highlight, index) => ({ '@id': canvasId + '/highlight-' + index, '@type': 'oa:Annotation', motivation: 'sc:painting', resource: { '@type': 'cnt:ContentAsText', chars: highlight.text, }, on: canvasId + '#xywh=' + highlight.xywh, })), }; // 検索パネルを右側に追加 const addAction = Mirador.addCompanionWindow('window-1', { content: 'search', position: 'right', }); viewer.store.dispatch(addAction); // companionWindowIdを取得 const state = viewer.store.getState(); const searchCompanionWindowId = Object.keys(state.companionWindows).find( id => state.companionWindows[id].content === 'search' ); if (searchCompanionWindowId) { // 検索結果を登録 const searchAction = Mirador.receiveSearch( 'window-1', searchCompanionWindowId, canvasId + '/search', searchResponse ); viewer.store.dispatch(searchAction); } } // マニフェストの読み込み完了を監視してハイライトを追加 let highlightAdded = false; const unsubscribe = miradorViewer.store.subscribe(() => { if (highlightAdded) return; const state = miradorViewer.store.getState(); const manifests = state.manifests || {}; const manifest = manifests[config.manifestUrl]; // マニフェストが読み込み完了したらハイライトを追加 if (manifest && !manifest.isFetching && manifest.json) { highlightAdded = true; unsubscribe(); addHighlights(miradorViewer, config.canvasId, config.highlights); } }); </script> </body> </html> コードの解説 1. 設定パラメータ const config = { manifestUrl: 'https://dl.ndl.go.jp/api/iiif/3437686/manifest.json', canvasId: 'https://dl.ndl.go.jp/api/iiif/3437686/canvas/22', highlights: [ { xywh: '3095,694,97,2051', text: 'いつれの御時にか...', }, ], }; manifestUrl: IIIFマニフェストのURL canvasId: ハイライトを表示するキャンバスのURI highlights: ハイライト情報の配列。複数のハイライトを追加可能 2. IIIF Search API形式のレスポンス構築 const searchResponse = { '@context': 'http://iiif.io/api/search/1/context.json', '@type': 'sc:AnnotationList', resources: highlights.map((highlight, index) => ({ '@type': 'oa:Annotation', motivation: 'sc:painting', resource: { '@type': 'cnt:ContentAsText', chars: highlight.text, }, on: canvasId + '#xywh=' + highlight.xywh, })), }; ポイントは on プロパティで、キャンバスURI#xywh=x,y,width,height の形式でハイライト領域を指定します。 ...

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

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

はじめに IIIF(International Image Interoperability Framework)ビューアとして広く使われているMiradorで、以下の要件を満たす実装を行いました: URLパラメータで指定したキャンバス(ページ)を初期表示する 指定したキャンバス内の検索語をハイライト表示する 本記事では、この要件を実現するためのアプローチと実装方法を共有します。 アプローチの検討 defaultSearchQueryオプション Mirador 4では、ウィンドウ設定に defaultSearchQuery オプションを指定することで、初期化時に自動的に検索を実行できます: const miradorViewer = Mirador.viewer({ windows: [{ manifestId: manifestUrl, canvasId: canvasId, defaultSearchQuery: '検索語', }], }); このオプションは検索を自動実行する便利な機能ですが、今回の要件では以下の点を考慮する必要がありました: ページ遷移の制御 - 検索実行時、最初のヒットのページに自動遷移する仕様のため、canvasId で指定したページに留まりたい場合は追加の制御が必要 検索完了タイミングの把握 - 非同期で検索が実行されるため、完了後に処理を行いたい場合はRedux状態の監視が必要 より直接的なアプローチ 検討の結果、receiveSearch アクションを直接使用するアプローチを採用しました。このアプローチでは: Search APIを直接呼び出して、指定キャンバスに該当するヒットのみを取得 IIIF Search API形式のレスポンスを構築 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準拠サーバーで動作します。 ...

RAWGraphs 2.0 の日本語化

RAWGraphs 2.0 の日本語化

はじめに データ可視化ツール RAWGraphs を日本語化し、公開しました。 https://rawgraphs-ja.vercel.app/ RAWGraphsは、複雑なデータを美しいビジュアライゼーションに変換できるオープンソースのWebアプリケーションです。コーディング不要で、CSVやJSONデータをドラッグ&ドロップするだけで、様々なチャートを作成できます。 RAWGraphsとは RAWGraphsは、イタリアのDensityDesign Research Labが開発したデータ可視化ツールです。 https://www.rawgraphs.io/ 主な特徴: コーディング不要 : ブラウザ上でデータをペーストするだけ 多様なチャート : Alluvial Diagram、Treemap、Voronoi Tessellationなど30種類以上 SVGエクスポート : 作成したチャートをSVGやPNG形式でダウンロード可能 プライバシー保護 : データはサーバーに送信されず、ブラウザ内で処理 使用ライブラリ { "i18next": "^21.10.0", "react-i18next": "^11.18.6" } ! 最新版のi18nextではなく、互換性のある旧バージョンを使用しています。RAWGraphsのビルド環境(react-scripts 4.x)がオプショナルチェーニング(?.)構文を完全にサポートしていないためです。 実装手順 1. i18nの初期化 src/i18n.js を作成し、多言語対応の基盤を構築しました。 import i18n from 'i18next' import { initReactI18next } from 'react-i18next' import translationEN from './locales/en/translation.json' import translationJA from './locales/ja/translation.json' const resources = { en: { translation: translationEN }, ja: { translation: translationJA } } const savedLanguage = localStorage.getItem('i18nextLng') || 'ja' i18n .use(initReactI18next) .init({ resources, lng: savedLanguage, fallbackLng: 'en', interpolation: { escapeValue: false } }) i18n.on('languageChanged', (lng) => { localStorage.setItem('i18nextLng', lng) }) export default i18n 2. 翻訳ファイルの構造 src/locales/ ├── en/ │ └── translation.json └── ja/ └── translation.json 翻訳ファイルは、セクションごとに整理しました: ...

Next.js + next-intl での言語切り替え実装ガイド

Next.js + next-intl での言語切り替え実装ガイド

Next.js App Router と next-intl を使用した多言語対応アプリケーションで、リロードなしの言語切り替えを実装する方法をまとめます。 環境 Next.js 16 (App Router) next-intl TypeScript 設定概要 localePrefix: ‘as-needed’ とは next-intl の localePrefix: 'as-needed' 設定を使用すると、デフォルト言語ではURLにプレフィックスが付かず、その他の言語のみプレフィックスが付きます。 例(デフォルト言語が日本語の場合): 日本語: /, /gallery, /viewer 英語: /en, /en/gallery, /en/viewer 実装手順 1. Middleware設定(重要) next-intl は middleware を使用してロケールのルーティングを処理します。静的ファイルが middleware によってリダイレクトされないよう、matcher の設定が重要です。 // middleware.ts import createMiddleware from 'next-intl/middleware' import { routing } from './src/i18n/routing' export default createMiddleware(routing) export const config = { // Skip middleware for static files and API routes matcher: ['/((?!api|_next|.*\\..*).*)'] } 注意 : .*\\..* は「ドットを含むパス」を除外します。これにより /og-image.png などの静的ファイルがmiddlewareをスキップし、正常にアクセスできるようになります。 2. ルーティング設定 // src/i18n/routing.ts import { defineRouting } from 'next-intl/routing' export const routing = defineRouting({ locales: ['ja', 'en'], defaultLocale: 'ja', localePrefix: 'as-needed' }) 3. ナビゲーションヘルパーの作成 next-intl が提供する createNavigation を使用して、ロケール対応のナビゲーションヘルパーを作成します。 ...