本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。

デモ

実際に動作するページ:

ビューア画面右上のセレクタで、地図ラスタのレンダリング元(自前 @allmaps/render / allmaps.xyz)と透明度を切り替えられます。

概要

IIIF(International Image Interoperability Framework、画像配信の国際相互運用仕様)で配信されている古地図画像を、OGC WMTS(Open Geospatial Consortium の Web Map Tile Service:タイル画像配信の標準仕様)互換のタイルサーバとして再配信する仕組みを Next.js(App Router、SSR = Server-Side Rendering)で実装し、ポートフォリオサイト nakamura196-projects の 1 プロジェクトとして統合しました。本記事はその記録です。

地理座標モード(OSM + 古地図ラスタ + POI マーカー):

Geo mode: 本郷キャンパスの古地図を OSM に重ねた表示

ピクセル空間モード(IIIF 画像のピクセル座標で表示、POI(point of interest:関心地点)マーカー付き):

Pixel mode: IIIF 画像のピクセル空間表示

この領域でやるべきことの大部分は Allmaps チームが既に解決しているように見えます。アノテーション編集(Allmaps Editor)、タイル配信(allmaps.xyz)、レンダリング(@allmaps/render)、IIIF パーサ(@allmaps/iiif-parser)、座標変換(@allmaps/project / @allmaps/transform)— いずれも IIIF + 地理参照のデファクトとして広く使われています。

それでも自前で薄い層を書きました。理由は次の 4 点です。

  1. 一部の IIIF サーバ(CloudFront 配下の Cantaloupe など)が User-Agent フィルタを掛けていて、allmaps.xyz から画像を取得できないケースがある
  2. OGC WMTS の正規仕様(Capabilities / GetFeatureInfo)を求めるクライアント(QGIS など)に対応したい
  3. POI として manifest 内の annotation(commenting / Linked Places Format)も合わせて配信したい
  4. ローカル開発時にも動作する形にしたい(allmaps.xyz は公開 URL からしか IIIF を fetch できないので、localhost の検証では空タイルになります)

デフォルトのデモデータには nakamura196/iiif_geomanifest.json(東京大学本郷キャンパス建物鳥瞰図に 27 点の GCP(ground control point:既知の画像座標と地理座標の対応点)と地名 Point を付与)を使っています。

API 一覧

ベースパスは /api/iiif-wmts/。Swagger UI でも対話的に確認できます(/[locale]/iiif-georef/docs)。

パスパラメータの {id}{slug} は実装上の歴史的な使い分けで、内容としてはほぼ同じものです。具体的には次のとおりです。

  • ピクセル WMTS 系の info / capabilities / タイル本体 (/wmts/{id}/...) — {id} は registry に登録された slug(例:iiif-geo)に加えて、生の IIIF image id(URL エンコード済み)も指定できます。info.json が引ければ動くので、manifest が無いリソースでも利用できます。
  • それ以外のすべて(GetFeatureInfo / features / geo-wmts / geo-info / georef) — registry に登録された slug が必要です。manifest からアノテーションや georef を引くため、登録済みのもの限定になります。

API 表で {id} と書かれているものは前者、{slug} と書かれているものは後者の意味です。

カテゴリエンドポイント役割
ピクセル WMTSGET /wmts/{id}/infoOpenLayers の WMTSTileGrid 初期化用メタ JSON
ピクセル WMTSGET /wmts/{id}/capabilitiesOGC WMTS GetCapabilities XML(QGIS など用)
ピクセル WMTSGET /wmts/{id}/{z}/{x}/{y}ピクセル空間 JPEG タイル
ピクセル WMTSGET /wmts/{id}/{z}/{x}/{y}/{i}/{j}.geojsonWMTS GetFeatureInfo(タイル内 i,j → 該当 annotation)
地理 WMTSGET /geo-wmts/{slug}/{z}/{x}/{y}.pngWeb Mercator PNG タイル(allmaps.xyz 経由、本番向け)
地理 WMTSGET /geo-wmts-self/{slug}/{z}/{x}/{y}.pngWeb Mercator PNG タイル(@allmaps/render 自前実行、localhost OK)
地理 WMTSGET /geo-wmts/{slug}/{z}/{x}/{y}/{i}/{j}.geojson地理座標 GetFeatureInfo(i,j → lonlat → 画像ピクセル → annotation)
地理メタGET /geo-info/{id}GCP から center / bounds を計算(allmaps.xyz tiles.json 代替)
FeaturesGET /features/pixel/{slug}全 annotation を画像ピクセル GeoJSON で返却
FeaturesGET /features/geo/{slug}全 annotation を WGS84(緯度経度の世界測地系)GeoJSON で返却
GeorefGET /georef/{id}manifest から georef annotation を抽出し standalone Annotation を返却
IIIFGET /iiif/{...path}IIIF reverse proxy(UA 注入で CloudFront 等を通過)+ info.json の @id 書換
メタGET /openapi上記すべての OpenAPI 3.0 仕様

Swagger UI 画面:

Swagger UI for IIIF→WMTS Proxy

3つのレイヤーを区別する

[A] IIIF Image API
    画像をピクセル座標で配信
    /{id}/{region}/{size}/{rot}/{quality}.{format}

[B] WMTS (Web Map Tile Service)
    タイルピラミッドで配信
    /{layer}/{tilematrixset}/{z}/{y}/{x}.{format}

[C] IIIF Georeference Extension
    AとBを橋渡し。pixel↔projected geoの対応をannotationで宣言
    motivation: "georeferencing"
    body.transformation: { type: "polynomial", options: { order: 1 } }

「[A] のピクセル空間と [B] の地理座標系を [C] で繋ぐ」というのが、Allmaps の出発点であり、本実装の基本構造でもあります。

Allmaps をまず使う

allmaps.xyz は次のような呼び方で動きます。

https://allmaps.xyz/{z}/{x}/{y}.png?url=<georeference annotation URL>

annotation URL は manifest 内の georef annotation を指せばよいです。多くの場合はこれで足ります。タイルは Cloudflare Workers の @allmaps/render(IntArrayRenderer)によって生成され、CDN 経由で 200ms 程度で返ってくる、というのが公開ドキュメントから読み取れる挙動です。

Allmaps はデータを抱え込まない

allmaps.xyz は IIIF 画像も annotation もキャッシュ以外で保存しないようです。リクエストごとに元の URL から fetch し、レンダリングして返します。「Allmaps に画像をアップロードする必要があるか」という問いの答えは No で、これは Allmaps の設計上の利点でもあります。

ローカル開発で空タイルになる

allmaps.xyz に任せる前提でローカル開発をしていると、次のような事象に当たります。

[browser] Failed to fetch tiles.json: error code: 1003
[browser] GET /api/.../geo-wmts/iiif-geo/16/58210/25799.png → 200 (129 bytes, transparent PNG)

129 byte の PNG はパレット色 1bit の完全透明画像です。原因は、allmaps.xyz の Cloudflare Workers から http://localhost:3000/... の annotation URL に到達できないことのようです。本番では問題ありませんが、ローカルで検証できないと細かな調整が難しくなります。

そこで本実装では @allmaps/render を Node Runtime で直接呼ぶ自前レンダリングをデフォルトにしました。allmaps.xyz 経由は UI からオプションで切り替えられるようにしています。

// app/api/iiif-wmts/geo-wmts-self/[id]/[z]/[x]/[y]/route.ts
import { Viewport } from '@allmaps/render';
import { IntArrayRenderer } from '@allmaps/render/intarray';
import { parseAnnotation } from '@allmaps/annotation';
import { bboxToRectangle } from '@allmaps/stdlib';

export const runtime = 'nodejs';

export async function GET(req, { params }) {
  // 1. registry から slug を引いて、manifest から georef annotation を取り出す
  const georef = await fetchGeoref(entry);

  // 2. annotation を組み立てて @allmaps/annotation でパース
  const georeferencedMaps = parseAnnotation(annotation);

  // 3. IntArrayRenderer で UA 付き fetch しつつ warp
  const renderer = new IntArrayRenderer(getImageData, ..., {
    fetchFn: fetchWithUa,
  });
  for (const gm of georeferencedMaps) await renderer.addGeoreferencedMap(gm);

  // 4. タイル座標 (z, x, y) → Web Mercator BBOX → Viewport
  const bbox = xyzTileToProjectedGeoBbox(z, xn, yn);
  const viewport = Viewport.fromSizeAndProjectedGeoPolygon(
    [256, 256], [bboxToRectangle(bbox)], { devicePixelRatio: 1 },
  );

  // 5. レンダリングして PNG エンコード
  const warped = await renderer.render(viewport);
  const png = encodePng([warped.buffer], 256, 256, 0);
  return new Response(png, { headers: { 'Content-Type': 'image/png' } });
}

allmaps.xyz の tiles.json も代替する

GeoViewer 起動時に必要な center / bounds は、本来 allmaps.xyz の tiles.json?url=... から取得しますが、これも localhost からは到達できません。そこで、registry の georef annotation から GCP の min/max を計算する自前エンドポイントを置きました。

// /api/iiif-wmts/geo-info/[id]/route.ts (Edge Runtime)
const { gcps } = await fetchGeoref(entry);
const lons = gcps.map(g => g.lonlat[0]);
const lats = gcps.map(g => g.lonlat[1]);
return Response.json({
  center: [(min(lons) + max(lons)) / 2, (min(lats) + max(lats)) / 2],
  bounds: [min(lons), min(lats), max(lons), max(lats)],
  gcpCount: gcps.length,
});

これで OpenLayers の初期 View(中心 / ズーム)も localhost で決められます。

UA フィルタと reverse proxy

iiif_geo のデフォルトデータは iiif.dl.itc.u-tokyo.ac.jp 上の公開 IIIF サーバで、UA フィルタは確認した範囲では掛かっていません。一方で、史料編纂所の Cantaloupe(CloudFront 配下)のように UA で 403 を返すサーバも存在します。

UA='Cloudflare-Workers'              → 200
UA='Mozilla/5.0'                     → 200
UA='*'                               → 200
UA=''  (ヘッダ無し / 一部のサーバレス) → 403

そういう IIIF を allmaps.xyz に渡したい場合のため、registry エントリに useReverseProxy: true を立てると、annotation の target.source.id を本サイトの /api/iiif-wmts/iiif/{...path} 経由に書き換えるようになっています。

// app/api/iiif-wmts/iiif/[...path]/route.ts
export const runtime = 'edge';

const FORWARD_UA = 'Mozilla/5.0 (compatible; iiif-wmts-proxy)';

export async function GET(req: NextRequest) {
  const reqUrl = new URL(req.url);
  const subpath = reqUrl.pathname.replace(/^\/api\/iiif-wmts\/iiif\//, '');
  const upstreamUrl = `${UPSTREAM_BASE}/${subpath}${reqUrl.search}`;

  const upstream = await fetch(upstreamUrl, {
    headers: { 'User-Agent': FORWARD_UA },
    next: { revalidate: 86400 },
  });

  // info.json の @id を proxy URL に書き換える
  if (subpath.endsWith('info.json') && upstream.ok) {
    const json = await upstream.json();
    const proxyBase = `${reqUrl.origin}/api/iiif-wmts/iiif/${subpath.replace(/\/info\.json$/, '')}`;
    json['@id'] = proxyBase;
    json['id'] = proxyBase;
    return Response.json(json);
  }
  return new Response(upstream.body);
}

ポイントは 2 つあります。

  1. info.json@id を proxy URL に書き換える。Allmaps は info.json の @id を以後のリクエストの起点として使うため、書き換えないと Allmaps が直接元サーバを叩きに行ってしまいます。
  2. /api/iiif-wmts/georef/{id} が annotation の target.source.id も proxy URL に書き換える。これで allmaps.xyz は本サイトの proxy 経由で IIIF 画像を取得し、UA フィルタを通過できます。

エンドポイント詳細

ベースパス /api/iiif-wmts/ 配下に、すべて Next.js App Router の Route Handler として実装しています。slug は src/lib/iiif-wmts/registry.ts のレジストリで管理します。

ピクセル空間モード(非ジオ)

非ジオリファレンス画像でも使える、画像のピクセル座標系を WMTS 互換 URL で配信するルート群です。古地図に限らず、任意の IIIF リソースが対象になります。

WMTS Capabilities XML

GET /api/iiif-wmts/wmts/{id}/capabilities

QGIS などの WMTS クライアントが最初に取得するメタデータです。この XML には次が含まれます。

  • 利用可能なレイヤーの id とタイトル
  • TileMatrixSet(ズームレベルごとの ScaleDenominatorTileWidthTileHeightMatrixWidthMatrixHeight
  • 対応フォーマット(image/jpeg
  • 対応 InfoFormatapplication/geo+jsontext/html
  • タイル取得用 ResourceURL テンプレート
  • GetFeatureInfo 用 ResourceURL テンプレート

QGIS の「データソース → WMS/WMTS → 新規 → URL にこの Capabilities を指定」とすればレイヤーとして読み込めます。

WMTS タイル

GET /api/iiif-wmts/wmts/{id}/{z}/{x}/{y}

JPEG タイル本体です。WMTS の慣例に従って {TileMatrix}/{TileCol}/{TileRow} の順番。内部では IIIF Image API の region/size リクエストに変換し、UA 付きで上流サーバから取得します。z=0 が最縮小(1 タイル全体)、z=最大が原寸(1 pixel = 画像 1 pixel)です。

WMTS GetFeatureInfo

GET /api/iiif-wmts/wmts/{id}/{z}/{x}/{y}/{i}/{j}.geojson

WMTS の標準操作で、「タイル内の (i, j) ピクセルに対応する画像座標に何があるか」を問い合わせるエンドポイントです。

レスポンス例:

{
  "type": "FeatureCollection",
  "features": [{
    "type": "Feature",
    "id": "https://example.org/places/gcp-1",
    "properties": {
      "text": "GCP 1",
      "properties": { "resourceCoords": [6690, 7517], "title": "GCP 1" }
    },
    "geometry": { "type": "Point", "coordinates": [6690, 7517] }
  }],
  "queryPixel": [6700, 7500]
}

text が地名(LPF=Linked Places Formatproperties.title)、coordinates が画像ピクセル座標です。xywh を持つ annotation は Polygon として、resourceCoords だけのものは Point として返します(iiif_geo の Linked Places Format に対応)。

メタ情報

GET /api/iiif-wmts/wmts/{id}/info

クライアントの初期化用に proxy 独自のメタ JSON を返します。OpenLayers の WMTSTileGrid コンストラクタにそのまま渡せる形に整形しています。

{
  "id": "iiif-geo",
  "width": 18415,
  "height": 12911,
  "tileSize": 512,
  "maxZ": 7,
  "resolutions": [128, 64, 32, 16, 8, 4, 2, 1],
  "matrices": [
    { "identifier": "0", "matrixWidth": 1, "matrixHeight": 1, "scale": 128 }
  ]
}

全アノテーションの GeoJSON

GET /api/iiif-wmts/features/pixel/{slug}

クリック判定をクライアント側で行う場合に便利な、annotation 全件を GeoJSON FeatureCollection で返すエンドポイントです。WMTS GetFeatureInfo が「クリックごとにサーバ問い合わせ」なのに対し、こちらは「最初に 1 度全件取得」です。OpenLayers の VectorLayer にそのまま流し込めば、ホバー / クリックでポップアップ表示できます。

座標は画像ピクセル空間のままなので、ピクセル空間カスタム投影法のマップに重ねます。

地理座標モード(要ジオリファレンス)

IIIF Georeference Annotation が付いた古地図を、Web Mercator (EPSG:3857) の WMTS タイルとして配信するルート群です。OpenStreetMap などの現代地図と重ねて表示できます。

地理座標タイル(自前レンダリング、デフォルト)

GET /api/iiif-wmts/geo-wmts-self/{id}/{z}/{x}/{y}.png

@allmaps/render を Node Runtime で直接呼ぶ実装です。localhost でもそのまま動くのが利点。手元の測定ではコールドスタート時に 10 秒前後、温まれば 200–500ms ほどでした。

地理座標タイル(allmaps.xyz 経由)

GET /api/iiif-wmts/geo-wmts/{id}/{z}/{x}/{y}.png

Allmaps のレンダリングをそのまま利用するエンドポイントです。内部で allmaps.xyz に ?url=<我々のannotation URL> を付けて呼び、結果の PNG をキャッシュして返します。本番デプロイ後は速度面で有利ですが、ローカル開発では使えません。

地理座標 GetFeatureInfo

GET /api/iiif-wmts/geo-wmts/{id}/{z}/{x}/{y}/{i}/{j}.geojson

地理座標版の WMTS GetFeatureInfo です。tile + (i, j) → Web Mercator → WGS84 → image pixel の逆変換を経て、該当 annotation を返します。GeoJSON は WGS84 座標で返すので QGIS などで正しく描画できます。

全アノテーションの GeoJSON(WGS84 変換済)

GET /api/iiif-wmts/features/geo/{slug}

annotation を forward 変換で WGS84 にプロジェクトした GeoJSON です。OpenLayers に dataProjection: 'EPSG:4326'featureProjection: 'EPSG:3857' で読み込めば古地図ラスタの上に重なります。iiif_geo のように Point だけのアノテーションは Point として、xywh を持つものは Polygon として返します。

Georef Annotation(書き換え済)

GET /api/iiif-wmts/georef/{id}

manifest 内の georeferencing annotation を抽出し、target.source.id を整えた standalone Annotation です。useReverseProxy: true のエントリでは IIIF reverse-proxy URL に書き換えます。allmaps.xyz に ?url=... で渡すための入力になります。

Geo Info(center / bounds)

GET /api/iiif-wmts/geo-info/{id}

allmaps.xyz の tiles.json の代替。GCP の min/max から center と bounds を返します。これで OpenLayers の初期 View も自前で決められます。

補助エンドポイント

IIIF reverse proxy

GET /api/iiif-wmts/iiif/{...path}

UA フィルタ対策の本丸です。User-Agent を付与して上流の IIIF サーバに転送し、レスポンスをそのまま返します。info.json に対しては @id の書き換えも行います。Allmaps から見ると「普通の IIIF サーバ」に見える、いわばラッパーです。

WMTS GetFeatureInfo と GeoJSON 一括取得の使い分け

WMTS 標準の GetFeatureInfo はクリックごとにサーバを叩く設計ですが、ブラウザ用途では「最初に全件を GeoJSON で取得 → クライアント側でクリック判定」のほうが扱いやすい場面が多いです。

GetFeatureInfoGeoJSON 一括取得
サーバ処理クリックごと1 回のみ
必要な変換inverse(geo → pixel)forward(pixel → geo)のみ
ネット往復クリックごと初期化時 1 回
クライアントカスタム実装標準の VectorLayer

両方を残し、QGIS などには GetFeatureInfo を、ブラウザには GeoJSON を使い分けるのが実用的なようです。

自前レンダリングの性能比較

X-Render-Time ヘッダで測定した結果です(参考値)。

エンドポイント動作環境コールドスタートキャッシュ後
allmaps.xyz proxy (geo-wmts)Cloudflare Workers~25 ms80–200 ms
自前 self-render (geo-wmts-self)Node Runtime~10 秒200–900 ms

コールドスタート時の差が大きいのは、Node Runtime 関数で

  • IIIF region 取得(~1 秒)
  • JPEG decode + ポリゴンワープ + PNG encode(~400 ms)
  • 関数のコールドスタート自体(~3 秒)
  • 必要に応じて複数 region を直列で

をシリアルにこなしているためです。一方で Cloudflare Workers は常時温まった状態にあり、IIIF 画像も Cloudflare の CDN で予熱できます。

本番では速度面で allmaps.xyz が優位、ローカル開発と自己完結性が要るなら self-render、というのが現状の落としどころです。

自前ホストの位置付け

整理すると、自前ホストの価値は速度ではなく次の点にあります。

観点allmaps.xyz自前
速度
データ主権外部経由自組織完結
認証 / UA フィルタ下の IIIF対象外可(UA 注入)
WMTS Capabilities (QGIS)対象外
WMTS GetFeatureInfo対象外
POI / アノテーション統合対象外
ピクセル空間 WMTS(非ジオ)対象外
ローカル開発での検証対象外
SLA 設定コミュニティ運用自組織で設定
開発工数0

ジオリファレンス済みの公開古地図をできるだけ早くタイル配信したいだけなら、allmaps.xyz だけで十分です。WMTS 互換、認証下の IIIF、組織内データ完結、POI 統合、ローカル検証などの要件があれば、allmaps.xyz への薄いラッパー+必要に応じた自前レンダリングを足す形が現実的に思えます。

ハマりどころ:アノテーションが古地図ラスタとずれる

実装中に出会った非自明な座標変換のバグを記録します。

lon/lat 空間でフィットしていた

最初に書いた自前 fitAffine は、GCP の WGS84 座標をいったん Web Mercator に投影してから fit していました。これを @allmaps/transformGcpTransformer と比較すると不一致だったので、lon/lat 空間 fit に直しました。これで GcpTransformer(lonlat)と数値一致するようになりました。

ここで一旦終わったと思ったのですが、別の落とし穴が残っていました。

render は ProjectedGcpTransformer(Mercator 空間 fit)を使っていた

「アノテーションが 4 隅にいくほど古地図ラスタとずれる」という現象を再調査したところ、@allmaps/renderWarpedMap は次のようにインポートしていました。

import { ProjectedGcpTransformer } from "@allmaps/project";

@allmaps/transformGcpTransformer ではなく、@allmaps/projectProjectedGcpTransformer を使っていたのです。ProjectedGcpTransformer のデフォルトオプションは次のとおりです。

{
  internalProjection: webMercatorProjection,  // ← GCP を Mercator に投影してから fit
  projection: webMercatorProjection,
}

つまり GCP を Web Mercator に投影してから polynomial fit する設計でした。これは render のタイル出力側 (= Mercator 空間) と一貫しているためです。

アノテーション側は GcpTransformer(lonlat fit)の結果を返していたのに対し、render は ProjectedGcpTransformer(Mercator fit)を使っていた、というずれです。両者は GCP の重心付近では一致しますが、4 隅にいくほど発散します(今回のデータでは 4 隅で ~0.0016° / ~150m)。これが「4 隅にずれる」現象の主因でした。

対応として、fitAffine ラッパーを ProjectedGcpTransformer を直接使う実装に切り替えました。

import { ProjectedGcpTransformer, webMercatorToLonLat, lonLatToWebMercator } from "@allmaps/project";

const transformer = new ProjectedGcpTransformer(
  gcps.map(g => ({ resource: g.pixel, geo: g.lonlat })),
  "polynomial",
);

ここで得られた教訓は次のとおりです。

  • @allmaps/transform.GcpTransformer@allmaps/project.ProjectedGcpTransformer は似て非なるものなので、render が後者を使っているなら、annotation 側でも後者を使う
  • ライブラリの README やドキュメントだけでは判別が難しい場合があり、呼び出し元のソースを追って確認するのが確実
  • 中央で一致していても、4 隅・端で誤差が累積することがあるので、広域でテストする

forward と inverse は互いの数学的逆ではない

ProjectedGcpTransformer に揃えても、まだ 4 隅でずれが残りました。デバッグの末に分かった原因はもう一段深いところにありました。

ProjectedGcpTransformer には次の 2 つのメソッドがあります。

  • transformToGeo(pixel) → mercator(forward の最小二乗フィット)
  • transformToResource(mercator) → pixel(inverse の最小二乗フィット)

この 2 つは独立にフィットされていて、互いの数学的逆ではありません。GCP にノイズがあるため、最小二乗で fit すると順方向と逆方向で最適解が異なります。検証するとラウンドトリップで大きなずれが出ます(今回のデータでは 4 隅で 300+ px)。

そして render は transformToResource のほうを使っています。@allmaps/render のソースを追うと、次のような構造になっています。

// renderers/IntArrayRenderer.ts (相当)
for each canvasPixel in viewport:
  projectedGeoPoint = canvas → mercator
  resourcePoint = warpedMap.projectedTransformer.transformToResource(projectedGeoPoint)
  // ↑ inverse の fit を使う
  sample image at resourcePoint

つまり、

  • render(古地図ラスタ)は transformToResource の fit に従って描画される
  • annotation overlay は transformToGeo の fit を使って forward していた

両者は別の最小二乗解です。GCP の重心付近では小さくしかずれませんが、4 隅にいくほど発散します。これが残っていたずれの正体でした。

対応として、transformToResource を「正」と定め、その数学的逆行列を annotation の forward に使うことにしました。order 1 (affine) なので 3 点サンプリングで係数を取り出して逆行列を計算できます。

// 1. 3 サンプル点 (mercator) → transformToResource → pixel で affine 係数を抽出
//    pixel = M * mercator + t  (M は 2x2 行列, t は 2D ベクトル)
const m0 = [cx, cy], m1 = [cx + D, cy], m2 = [cx, cy + D];
const p0 = transformer.transformToResource(m0);
const p1 = transformer.transformToResource(m1);
const p2 = transformer.transformToResource(m2);
const a = (p1[0] - p0[0]) / D, b = (p2[0] - p0[0]) / D;
const c = (p1[1] - p0[1]) / D, d = (p2[1] - p0[1]) / D;
const tx = p0[0] - (a * m0[0] + b * m0[1]);
const ty = p0[1] - (c * m0[0] + d * m0[1]);

// 2. 数学的逆: mercator = M^(-1) * (pixel - t)
const det = a * d - b * c;
const ia = d / det, ib = -b / det, ic = -c / det, id = a / det;
const itx = -(ia * tx + ib * ty), ity = -(ic * tx + id * ty);

// 3. annotation forward (pixel → mercator)
function forward(px, py) {
  return [ia * px + ib * py + itx, ic * px + id * py + ity];
}

これで transformToResource(forward(P)) = P が数学的に成立します(round-trip diff = 0)。レンダリング側と annotation 側が同じ変換を使うようになり、4 隅まで整合するようになりました。

xywh AABB から SVG path へ

ついでに annotation の selector も見直しました。manifest によっては、同じ annotation に 2 形式の selector が付いていることがあります。

selector内容
FragmentSelectorxywh=21223,10870,96,123(軸平行な bounding box)
SvgSelectorM 21223,10877 L 21270,10994 L 21319,10984 L 21269,10870 Z(傾いた 4 角形)

xywh は古地図上で斜めに書かれた文字に対する軸平行な外接矩形で、実際の文字領域より大きめになります。SVG path は文字に沿った 4 角形で、文字領域に近い形になります。

xywh で描いていると、annotation 矩形が文字より一回り大きく見えて、ずれている印象が増幅します。SvgSelector を優先する実装に切り替えて、annotation polygon が古地図上の文字領域に近くなるようにしました。

iiif_geo の Point だけのアノテーション

iiif_geo の manifest.json は、georeferencing annotation の features 配列に Linked Places Format 互換の Point アノテーション(properties.title + resourceCoords)を埋めるスタイルでした。xywh を持たないので Polygon にはできません。これに対応するため、

  • properties.xywh がある場合は Polygon
  • properties.resourceCoords だけある場合は Point

の両方を CommentingAnnotation として扱う実装にし、/api/iiif-wmts/features/{pixel,geo}/{slug} の出力でも Point / Polygon を切り替えるようにしました。OpenLayers 側は VectorLayer の image: Circle({...}) でマーカーを描画しています。

まとめ(座標ずれ系の修正)

不具合原因修正
全体で fit 残差レベルにずれるrender が Mercator 空間 fit、annotation が lon/lat 空間 fitProjectedGcpTransformer に統一
4 隅で 300+ px ずれるtransformToGeotransformToResource が独立な最小二乗 fittransformToResource の数学的逆を annotation forward に使う
矩形が文字より大きいxywh AABB を使っていたSVG path に切り替え
iiif_geo の Point が表示されないxywh 前提だったresourceCoords を Point として扱う

OSM 背景とのずれは別問題で、polynomial order 1 自体の fit 残差として残ります(古地図側に内在する性質)。

React 19 + OpenLayers + Turbopack の小さなハマり

App Router + React 19 + Turbopack で MapuseEffect 内で作ると、モード切替時(geo → pixel など)に次のエラーが出ることがありました。

NotFoundError: Failed to execute 'removeChild' on 'Node':
The node to be removed is not a child of this node.

調べた限りでは、new Overlay({ element: popupRef.current }) のように、React がレンダリングした DOM ノードを OpenLayers の Overlay 要素として渡したのが原因のようです。OpenLayers は受け取った element を自分の DOM ツリーに付け替えるため、unmount のときに React が「自分が描画した親」から removeChild しようとして失敗します。

対策として、OpenLayers に渡す DOM を React 管理外にしました。target 用の div も popup 用の div も document.createElement で imperative に生やし、cleanup で自分で外します。

useEffect(() => {
  if (!ref.current) return;
  const container = ref.current;

  const olTarget = document.createElement('div');
  olTarget.style.position = 'absolute';
  olTarget.style.inset = '0';
  container.appendChild(olTarget);

  const popupEl = document.createElement('div');
  popupEl.style.cssText = '...';
  container.appendChild(popupEl);

  let map: Map | null = null;
  // fetch info / build layers ...
  map = new Map({
    target: olTarget,
    overlays: [new Overlay({ element: popupEl })],
  });
  // popup の表示は popupEl.textContent / style.display で直接操作(React state を介さない)

  return () => {
    try { map?.setTarget(undefined); } catch {}
    for (const el of [popupEl, olTarget]) {
      try { el.parentNode?.removeChild(el); } catch {}
    }
  };
}, []);

return <div ref={ref} className="absolute inset-0" />;

これで React の virtual DOM と OpenLayers の DOM 操作が直交するようになり、モード切替でも removeChild エラーは出なくなりました。

まとめ

  • @allmaps/render / @allmaps/project は IIIF + georef + タイル配信のデファクトで、まずこれを試すのが順当そうです。
  • 配信元 IIIF サーバが認証 / UA フィルタなどの保護下にある場合は、薄い UA 注入プロキシを置けば allmaps.xyz から到達できます。
  • WMTS 正規仕様(Capabilities / GetFeatureInfo)や POI / アノテーション統合が必要なら、Allmaps の上に薄いラッパーを置きます。Allmaps を再実装する必要はありません。
  • ローカル開発まで動かしたい場合は、@allmaps/render を Node Runtime で直接呼ぶ手があります(geo-wmts-self)。allmaps.xyz の tiles.json も GCP から自前計算(geo-info)すれば代替できます。本番では速度面で allmaps.xyz に劣りますが、データ主権・SLA・検証性は自組織で握れます。
  • polynomial order 1 fit では、annotation と古地図ラスタの整合性は ProjectedGcpTransformer.transformToResource の数学的逆を annotation forward に使えば 4 隅まで保てます。
  • App Router + React 19 + OpenLayers の cleanup は、OpenLayers 用の DOM を imperative に生やし、React の virtual DOM 管理から外すと安定します。

成果物 / 参照:

本実装の中核機能(アノテーション解析・座標変換・タイルレンダリング)は @allmaps/* パッケージそのものです。本記事は Allmaps を自組織のインフラに組み込むためのメモという位置付けになります。