Skip to content
niryuu edited this page May 25, 2026 · 1 revision

SimpleStyle / SimpleStyleVector — GeoJSON / ベクトルタイルの可視化

embed は SimpleStyle 仕様 (mapbox の GeoJSON style 規約) を実装した 2 クラスを提供する:

  • SimpleStyle (src/lib/simplestyle.ts, 435 行) — GeoJSON 入力
  • SimpleStyleVector (src/lib/simplestyle-vector.ts, 257 行) — Vector Tile 入力

両者はほぼ同じレイヤ構造を持つが、ソース (geojson vs vector) と一部の機能 (cluster は GeoJSON のみ) が異なる。

SimpleStyle (GeoJSON 版)

公開 API

export class SimpleStyle {
  public _loadingPromise: Promise<unknown>;
  private callFitBounds: boolean = false;
  private geojson: any;
  private map: maplibregl.Map;
  private options: {
    id: string;
    cluster: boolean;
    heatmap: boolean;
    clusterColor: string;
    [key: string]: any;
  };
  private _eventHandlers: Array<{ event: string; layer: string; handler: Function }>;

  constructor(geojson: string | GeoJSON, options?: Partial<typeof this.options>);

  setGeoJSON(geojson: string | GeoJSON): void;
  updateData(geojson: GeoJSON): this;
  addTo(map: maplibregl.Map): this;
  fitBounds(options?: maplibregl.FitBoundsOptions): this;
  remove(): this;
  // private helpers
  setPolygonGeometries(): void;
  setLineGeometries(): void;
  setPointGeometries(): void;
  setPopup(map, source): Promise<void>;
  setCluster(): void;
}

Constructor (simplestyle.ts:26-36)

constructor(geojson, options?) {
  this.setGeoJSON(geojson);
  this.options = {
    id: 'geolonia-simple-style',
    cluster: true,
    heatmap: false, // TODO: It should support heatmap.
    clusterColor: '#ff0000',
    ...options,
  };
}
  • setGeoJSON(geojson) を呼び出し
  • 既定 options は id, cluster, heatmap, clusterColor
  • options は spread で上書き

setGeoJSON(geojson) (simplestyle.ts:410-434)

setGeoJSON(geojson) {
  if (typeof geojson === 'string' && isURL(geojson)) {
    this.geojson = template;  // 空の FeatureCollection

    const fetchGeoJSON = async () => {
      try {
        const response = await window.fetch(geojson);
        const data = response.ok ? await response.json() : {};
        this.geojson = data;
        this.updateData(data);

        if (this.callFitBounds) {
          this.fitBounds();
          this.callFitBounds = false;
        }
      } catch (error) {
        console.error('[Geolonia] Failed to load GeoJSON:', error);
      }
    };

    this._loadingPromise = fetchGeoJSON();
  } else {
    this.geojson = geojson;
  }
}
  • 文字列で URL ならば fetch (非同期)、template (空 FeatureCollection) を仮データとして使用
  • 取得完了後 updateData で source を更新
  • それ以外 (オブジェクト) はそのまま設定

template (simplestyle.ts:13-16):

const template = {
  type: 'FeatureCollection',
  features: [],
};

addTo(map) (simplestyle.ts:62-146)

  1. this.map = map
  2. features を polygon/linestringpoint に振り分け
  3. source ${id} (polygon/linestring 用、cluster なし) を追加
  4. setPolygonGeometries() で fill レイヤ + popup
  5. setLineGeometries() で line レイヤ + popup
  6. source ${id}-points (point + cluster) を追加 (clusterMaxZoom: 14, clusterRadius: 50)
  7. polygon-symbol レイヤ追加 (テキストラベル)
  8. linestring-symbol レイヤ追加 (line に沿ったテキスト)
  9. setPointGeometries() で circle / symbol レイヤ + popup
  10. setCluster() で cluster 円 + count + cluster click handler

レイヤ構造 (順序順)

ID type source filter 役割
${id}-polygon fill ${id} $type == Polygon ポリゴン塗り
${id}-linestring line ${id} $type == LineString
${id}-polygon-symbol symbol ${id} $type == Polygon ポリゴンラベル
${id}-linestring-symbol symbol ${id} $type == LineString 線ラベル
${id}-circle-points circle ${id}-points !point_count && !marker-symbol ポイント円
${id}-symbol-points symbol ${id}-points !point_count ポイントアイコン + ラベル
${id}-clusters circle ${id}-points has point_count クラスタ円
${id}-cluster-count symbol ${id}-points has point_count クラスタ数表示

Cluster (simplestyle.ts:314-373)

this.map.addLayer({
  id: `${this.options.id}-clusters`,
  type: 'circle',
  source: `${this.options.id}-points`,
  filter: ['has', 'point_count'],
  paint: {
    'circle-radius': 20,
    'circle-color': this.options.clusterColor,
    'circle-opacity': 1.0,
  },
});

this.map.addLayer({
  id: `${this.options.id}-cluster-count`,
  type: 'symbol',
  source: `${this.options.id}-points`,
  filter: ['has', 'point_count'],
  layout: {
    'text-field': '{point_count_abbreviated}',
    'text-size': 14,
    'text-font': ['Noto Sans Regular'],
  },
});

Cluster click handler (simplestyle.ts:341-354) で queryRenderedFeaturesgetClusterExpansionZoomeaseTo

Popup 設定 (setPopup) (simplestyle.ts:273-309)

async setPopup(map, source) {
  const clickHandler = async (e) => {
    const center = turfCenter(e.features[0]).geometry.coordinates;
    const description = e.features[0].properties.description;
    if (description) {
      const sanitizedDescription = await sanitizeDescription(description);
      new maplibregl.Popup()
        .setLngLat(center)
        .setHTML(sanitizedDescription)
        .addTo(map);
    }
  };

  const mouseEnterHandler = (e) => {
    if (e.features[0].properties.description) {
      map.getCanvas().style.cursor = 'pointer';
    }
  };

  const mouseLeaveHandler = () => {
    map.getCanvas().style.cursor = '';
  };

  map.on('click', source, clickHandler);
  map.on('mouseenter', source, mouseEnterHandler);
  map.on('mouseleave', source, mouseLeaveHandler);

  this._eventHandlers.push(
    { event: 'click', layer: source, handler: clickHandler },
    { event: 'mouseenter', layer: source, handler: mouseEnterHandler },
    { event: 'mouseleave', layer: source, handler: mouseLeaveHandler },
  );
}
  • features[0] の中心 (turfCenter) を popup 位置に
  • description プロパティを sanitize して HTML に
  • カーソル変更
  • _eventHandlers 配列に登録し、remove() 時に off できる

remove() (simplestyle.ts:375-408)

remove() {
  if (!this.map) return this;
  const id = this.options.id;

  for (const { event, layer, handler } of this._eventHandlers) {
    this.map.off(event, layer, handler);
  }
  this._eventHandlers = [];

  const layerIds = [
    `${id}-polygon-symbol`,
    `${id}-linestring-symbol`,
    `${id}-circle-points`,
    `${id}-symbol-points`,
    `${id}-polygon`,
    `${id}-linestring`,
    `${id}-clusters`,
    `${id}-cluster-count`,
  ];
  for (const layerId of layerIds) {
    if (this.map.getLayer(layerId)) {
      this.map.removeLayer(layerId);
    }
  }
  for (const sourceId of [id, `${id}-points`]) {
    if (this.map.getSource(sourceId)) {
      this.map.removeSource(sourceId);
    }
  }

  this.map.getCanvas().style.cursor = '';
  return this;
}

完全クリーンアップ:

  • イベントハンドラ off
  • 全レイヤ削除
  • ソース削除
  • カーソルリセット

fitBounds(options) (simplestyle.ts:148-165)

fitBounds(options = {}) {
  this.callFitBounds = true;

  const _options = {
    duration: 3000,
    padding: 30,
    ...options,
  };

  const bounds = geojsonExtent(this.geojson);
  if (bounds) {
    window.requestAnimationFrame(() => {
      this.map.fitBounds(bounds, _options);
    });
  }
  return this;
}
  • @mapbox/geojson-extent で bbox 計算
  • requestAnimationFrame 内で map.fitBounds 呼び出し
  • this.callFitBounds = true を立てるのは、URL fetch 完了後の setGeoJSON 側で再度 fitBounds を呼ぶための fallback (simplestyle.ts:421-424)

updateData (simplestyle.ts:38-60)

updateData(geojson) {
  this.setGeoJSON(geojson);

  const features = this.geojson.features;
  const polygonAndLines = features.filter(...);
  const points = features.filter(...);

  this.map.getSource(this.options.id).setData({...});
  this.map.getSource(`${this.options.id}-points`).setData({...});
  return this;
}

source の setData で既存レイヤを再利用しつつデータ更新。

SimpleStyleVector (Vector Tile 版)

公開 API

class SimpleStyleVector {
  private sourceName: string = 'vt-geolonia-simple-style';
  constructor(private url: string);

  addTo(map: maplibregl.Map): void;
  // private
  setPolygonGeometries(map): void;
  setLineGeometries(map): void;
  setPointGeometries(map): void;
  setPopup(map, source): Promise<void>;
}

export default SimpleStyleVector;

Constructor (simplestyle-vector.ts:15-17)

constructor(private url) {
  this.sourceName = 'vt-geolonia-simple-style';
}

urlparseSimpleVector を通った後の値 (geolonia://tiles/custom/{id} または http(s)://...)。addSource 時に maplibre が TileJSON エンドポイントとして使用する。

addTo(map) (simplestyle-vector.ts:19-113)

  1. data-lng / data-lat が両方未指定の時のみ sourcedata イベントで fitBounds(source.bounds, { duration: 0, padding: 30 }) を 1 回実行 (initialZoomDone フラグで再入防止) (simplestyle-vector.ts:22-53)
  2. addSource('vt-geolonia-simple-style', { type: 'vector', url: this.url }) (simplestyle-vector.ts:55-58)
  3. setPolygonGeometries, setLineGeometries (simplestyle-vector.ts:60-61)
  4. polygon-symbol レイヤ追加
  5. linestring-symbol レイヤ追加
  6. setPointGeometries

source-layer ハードコード

全レイヤの source-layer'g-simplestyle-v1' 固定 (simplestyle-vector.ts:67,91,125,147,173,195)。サーバ側のベクトルタイル仕様に依存。

レイヤ ID 規則

GeoJSON 版と微妙に異なる:

ID type source filter
vt-geolonia-simple-style-polygon fill sourceName $type == Polygon
vt-geolonia-simple-style-linestring line sourceName $type == LineString
vt-geolonia-simple-style-polygon-symbol symbol sourceName $type == Polygon
vt-geolonia-simple-style-linestring-symbol symbol sourceName $type == LineString
vt-circle-simple-style-points circle sourceName $type == Point && !marker-symbol
vt-geolonia-simple-style-points symbol sourceName $type == Point

vt-circle-simple-style-points のみ prefix が vt-circle- で他と非対称。なぜ統一されていないかは不明 (Suspicious-Logic 参照)。

remove() メソッドの欠落

SimpleStyleVector には remove()実装されていないaddTo 内で map.on('sourcedata', ...) で listener を永続登録するため、マップが remove されても listener はリーク。

GeoJSON 版とは対照的 (こちらは remove() あり)。

setPopup の差異

GeoJSON 版と同じ logic だが、_eventHandlers 配列に push しない (simplestyle-vector.ts:230-254)。remove が無いので意味が無い → 無いまま、という設計。

SimpleStyle GeoJSON Properties 規約

両クラスとも以下の properties を読む (詳細は Hidden-Spec):

  • marker-color, marker-size ('small'/'large'/その他), marker-symbol
  • fill, fill-opacity
  • stroke, stroke-width, stroke-opacity
  • text-color, text-halo-color
  • title, description

色定数 (両ファイルで独立定義)

simplestyle.ts:8-11simplestyle-vector.ts:7-10同じ定数が別々に定義されている (DRY 違反):

const textColor = '#000000';
const textHaloColor = '#FFFFFF';
const backgroundColor = 'rgba(255, 0, 0, 0.4)';
const strokeColor = '#FFFFFF';

書き直し時には共通モジュールに集約可能。

怪しい点 (詳細は Suspicious-Logic)

  • SimpleStyleVectorremove() 欠落
  • レイヤ ID の prefix 非対称 (vt-circle- vs vt-geolonia-)
  • geolonia-simple-style ID prefix で複数インスタンス衝突
  • SimpleStyle.heatmap が TODO
  • 色定数の二重定義

書き直し時の指針

  • maps-core (lib) 側に置く
  • 共通レイヤ生成ロジックを base class または mixin にまとめる
  • SimpleStyleVector の lifecycle 管理 (remove) を追加
  • id を constructor 引数で受け取り、複数インスタンス共存を保証
  • 色定数を共通モジュールへ
  • heatmap を実装するか TODO を削除するか判断

Clone this wiki locally