コレクターのお供にkintone

Advent Calendar

kintone Advent Calendar 2025の24日目の記事です。

  Topへ↓

今年もさまざまなコミュニティに参加しました。そして何度も登壇しました。
数えてみたら、2025年中にkintone Caféには9回参加し、そのうちの7回で登壇してます。

その7回の登壇のうち、3回のkintone Caféで地図ネタを投下しました。

同じネタは二度と話さないことを心がけている私にしては、3回も同じネタなのは異例です。そろそろ賞味期限が切れかけているのでしょうか。私。

まずは五月のkintone Café 和歌山 Vol.3。このときは、オープンデータをkintone上で展開しました。オープンデータとは、農林水産省が公開で提供する圃場ポリゴンデータです。これを用いて、国土地理院の地理院タイルマップ上にOpenLayerライブラリで重ねた田辺や白浜、みなべ町のポリゴンデータを表示させました。
kintoneを田んぼと畑で埋め尽くしてやりました。



好評だったので、三週間後のkintone Café 山口 Vol.9ではLT枠で新山口駅周辺の圃場ポリゴンデータを披露しました。マップの対象場所を変えたため、同じ見栄えとはいわせない!とはいうものの、裏側で用意したオープンデータとJavaScriptの初期表示座標を変えただけ。幸いなことに、和歌山と山口の両方で私の登壇を見た方がいなかったため、メッキは剥がれずに済みました。
とはいえ、私にしては芸がなかったと反省しています。



九月にkintone Café 和歌山 Vol.4にお呼ばれした時は、テーマはお任せいただきました。同じネタで行くべきか迷いましたが、前回が好評だったことと、田辺の街に興味を持ったことを伝えたかったので、地図上にピンを立て、フィーチャーした場所の新旧写真で時間の移り変わりを比べられるようにしました。
オープンデータのポリゴンではなく、地図上で任意の地点をクリックするとポップアップが開き、ポップアップ上で写真とレコードをアップロードする機能を実装し、既存のピンからもkintoneのレコードを写真も含めて更新できることを示しました。



仏の顔も三度まで。もう、これ以上地図データを用いたネタで登壇するつもりはありません。このネタでの四度目の登壇はなさそうです。



ででで、年末です。
走りすぎて、常に宙に浮いている最近ですが、アドベントカレンダーは待ってくれません。
アドベントカレンダーで何をのたまうか。
そうや!このアドベントカレンダーを賞味期間切れのネタのアウトレット放出の場にできへんやろか!?

とはいえ、三度の登壇で用いたネタをそのまま使いまわすことは、私の矜持に反します。常に新しいことをしたい。
地図ネタを活かしたアウトレット放出。しかも私自身にとって実になる何かを出そう。

そこでひらめいたのが、以前のスナックジョイゾーで中さんが披露していたマンホールカードのコレクションです。このネタを組み合わせてみようと思いつきました。

あまり私の記憶は定かでは無いのですが、中さんの事例はおそらくGoogle Mapsと連動していたはず。

これを私が登壇した国土地理院の地理院タイルマップに置き換え、すでに発表されたマンホールカードの全データに私が持っているマンホールカードのデータを組み合わせれば何か書けそうな気がします。

と言うわけで、中さんに許可をいただきまして、ネタ公開!ついでに私が持っているささやかなマンホールカードのコレクションをご披露します。
年末のお掃除が近づく中、コレクションが家族に捨てられないか?と戦々恐々としている全国津々浦々のコレクターたち!私からクリスマスプレゼント!

2.マンホールカードコレクターの聖地

  Topへ↑

マンホールカードは、下水道広報プラットホームが繰り出したコレクター魂をくすぐる渾身の企画です。随所にコレクター魂をくすぐる仕掛けが炸裂しています。なんとウェブサイトまで。しかも都道府県ごとに検索機能まであるからたまらん。
https://www.gk-p.jp/mhcard/



ちなみに全くの余談ですが、私が初めて入手したマンホールカードは2016/9/30に福島県郡山市の開成館でもらったもの。実はもらった1時間後に、郡山商工会議所でkintoneについて講演しました。私の初講演です。私にとってマンホールカードとkintoneは切っても切れないご縁があるのです。

閑話休題。
今回は、12月20日時点のマンホールカードのデータを使いました。

まずはそのデータを吸い上げる作業です。ここは筋斗雲の出る幕はありません。代わりにPython蛇の魔法を召喚しました。いわゆるスクレイピングです。
あまり頻繁に取り込むとサーバー神に叱られるので、マンホールカードのリストを全国で表示し、ソースコードを表示します。そして、それをHTMLで保存します。



後はPython蛇の魔法でこのHTMLをCSVに変換するだけです。とはいえ、魔法に若干の手直しが必要でした。例えば、配布場所の住所情報をピンで表示させるため、座標に変換する必要があります。ところが、このサイトの検索結果の配布場所は、平日と休日に分かれています。そのため、最初に出てきた場所をピンの座標場所として認定しました。この制御やら変換やら。また、住所を座標に転換するためには、国土地理院API(https://msearch.gsi.go.jp/address-search/AddressSearch)が有用でした。これでかなりの精度で住所を緯度と経度に変換しました。
なお、Pythonコードはkintoneのテーマには余計なので割愛します。興味があればお声がけください。

さらに各マンホールの画像ですが、これは全て裏側でURLとして保存されています。それを一括でダウンロードし、ローカルフォルダーに保管するロジックを組みました。AIが。

あとは、このCSVをkintoneアプリにアップロードするだけ。これで1220レコードが爆誕しました。
さらにローカルに保存した画像は、cli-kintoneを用いてレコードの添付ファイルフィールドに設定します。
添付ファイル系の扱いはcli-kintoneが使えるとめちゃ便利ですので、ぜひマスターしてくださいね。ちなみに上のCSVはレコード取り込みの際に使ったCSVで、下のCSVは、cli-kintoneで紐づける際に使ったCSVです。


3.マンホールカードアプリをデコる

  Topへ↑



さて、これでkintone上にはデータが溜まりました。後は自分でフィールドを追加しましょう。
「入手場所」「入手日」「入手のいきさつ」です。この辺は皆さんのコレクターセンスが問われます。ぜひ、コレクター魂を炸裂させた入魂のフィールドを加えましょう。ちなみに私はあっさり系です。


なお、偉そうにコレクター魂を語る私。
この記事を書いた時点で私が持っているマンホールカードの枚数は160枚。一方全てのマンホールカードは、検索すると1220枚+英語版が複数枚。といったところでしょうか。すんまへん。一割強しか集めてません。最近あんまりゲットできてないんです。

さて、お待たせしました。いよいよ画面を出してみましょう。
パッと画像に出してみましょうか。パッと。
赤枠の丸は持ってないカード。青い丸は持っているカード。丸の中にマンホールカードの柄がそのまま現れていますね。ん?見えない?
そう、全体を出すにはブラウザのズームも下げないと見えない。


では、もうちっと拡大してみます。ん~。まだ見にくいか?


今、私が住んでるところらへんと、初めてマンホールカードをもらった郡山も入るようにしてみました。


まだ? では私の実家あたりをもう少し拡大してみましょう。なんか丸の中に絵柄が見えますね。


もうちょっと行けますよ。ほら!これ、全部配布場所にプロットしています。もう一度お伝えすると、赤枠の丸は持ってないカード。青い丸は持っているカード。丸の中にマンホールカードの柄がそのまま現れていますね。


任意の丸をクリックすると、csvとcli-kintoneで登録したレコードがポップアップで出てきます。


このポップアップで画像の入れ替えも可能です。今適当にカード裏を選んでみました。すると、こうです。本来はマンホールカードの裏を自分でスキャンして保存することが目的なんですけど、水も滴るいい男の写真を出してしまいました。


これ、もちろん、新規でレコード登録も可能です。地図のどこかを適当に押せば、ポップアップが表示され、ここからレコードや画像の登録も可能です。

4.コードのご紹介の前に

  Topへ↑


コードのご紹介をする前に、上の一覧のマンホール画像について説明します。
これらの画像は、全てkintoneの添付ファイルフィールドに格納されています。そして、技術者の方は知っている通り、kintoneの添付ファイルフィールドに添付されたファイルを取得するには、レコードの添付ファイルフィールドの値に格納されたFileKeyをいったん取得し、そのFileKeyを用いて、一つ一つ添付ファイルをダウンロードする必要があります。
要するに、一覧を毎回表示させるたびに、かなりの処理が走るのです。1220件を表示するのに約1分半ほど。

なお、マンホールカードの検索ページのカード画像は、そのウェブサイト上の画像アドレスが指定されています。つまり、この画像アドレスを参照させれば、kintoneのレコードの添付ファイルフィールドに対する面倒な処理は不要なのです。実際、画像のURLもkintoneのレコードには登録しています。

確かに、マンホール検索サイトのQAページ(https://www.gk-p.jp/mc-qa/)には、スクレイピングや画像利用についての制約の記述は見当たりませんでした。ただ、検索サイトのサーバーに負荷をかけるとイエローカードが出ないとも限りません。なので、一旦、kintoneにアップした画像を出力する方式にしています。ただ、時間がかかる・・・
キャッシュやローカルフォルダの利用もしていません。そもそもこの記事はkintoneがテーマです。なので、あえてkintoneからダウンロードするやり方にしています。

5.コードのご紹介

  Topへ↑

ここからロジックの説明に移ります。使用するライブラリやデータセットは以下の通り。
・国土地理院のタイルマップ
 サイト https://maps.gsi.go.jp/development/ichiran.html
 利用規約 https://www.digital.go.jp/resources/open_data/public_data_license_v1.0
・OpenLayerのライブラリ
 利用規約 https://openlayers.org/two/ccla.txt
・REST api clientやSweetAlart2など、Cybozu CDNの力も借ります。
 https://cybozu.dev/ja/kintone/sdk/library/cybozu-cdn/
これらをアプリのJavaScriptに設定しましょう。


こんな感じで。


メインのJavaScript(3_getphotodata.js)はこちらです。なお、AIに頼んでコメントを追記してもらいました。また、セキュリティリスクとなる情報は入っていません。皆さんも、ご自身で使う際は、セキュリティリスクには注意してくださいね。これがクリスマスプレゼントです。

(function() {
  'use strict';

  // Kintone REST APIクライアントを初期化
  const client = new KintoneRestAPIClient();

  // ============================================
  // カスタムアイコン設定
  // ============================================
  // 各レコードの「カード表」フィールドの画像をアイコンとして使用
  const ICON_FIELD_CODE = 'カード表'; // アイコンとして使用するフィールドコード
  
  // アイコンのサイズ設定(ピクセル単位)
  const ICON_SIZE = 40; // アイコンの直径
  const BORDER_WIDTH = 3; // 枠線の太さ
  
  // 所持状態による表示スタイルの設定
  const STYLE_CONFIG = {
    owned: {
      opacity: 1.0,        // 所持している場合: 不透明(はっきり表示)
      borderColor: '#007bff', // 枠線の色: 青
      borderWidth: BORDER_WIDTH
    },
    notOwned: {
      opacity: 0.3,        // 所持していない場合: 半透明(薄く表示)
      borderColor: '#dc3545', // 枠線の色: 赤
      borderWidth: BORDER_WIDTH
    }
  };
  // ============================================

  // アイコン画像のキャッシュ(同じ画像を何度も処理しないための保存場所)
  const iconCache = new Map();

  // ユニークなIDを生成する関数(レコードを識別するため)
  function generatePolygonUuid() {
    const timestamp = Date.now(); // 現在の時刻
    const random = Math.random().toString(36).substring(2, 15); // ランダムな文字列1
    const moreRandom = Math.random().toString(36).substring(2, 15); // ランダムな文字列2
    return `polygon_${timestamp}_${random}${moreRandom}`; // 組み合わせてユニークなIDを作成
  }

  // ファイル名を安全な形式に変換する関数(特殊文字を除去)
  function sanitizeFileName(originalName) {
    // 英数字、ハイフン、アンダースコア、ピリオド以外を_に置換
    const sanitized = originalName
      .replace(/[^\w\-_.]/g, '_')
      .replace(/_{2,}/g, '_') // 連続する_を1つにまとめる
      .replace(/^_+|_+$/g, ''); // 先頭と末尾の_を削除
    
    // ファイルの拡張子を取得
    const extension = originalName.split('.').pop().toLowerCase();
    // 拡張子を除いたファイル名を取得(なければ'photo'を使用)
    const nameWithoutExtension = sanitized.split('.')[0] || 'photo';
    // タイムスタンプを追加してファイル名の重複を防ぐ
    const timestamp = Date.now();
    return `${nameWithoutExtension}_${timestamp}.${extension}`;
  }

  // 画像を拡大表示するモーダルウィンドウを表示する関数
  window.showImageModal = function(url, label) {
    Swal.fire({
      title: label, // 画像のタイトル
      imageUrl: url, // 表示する画像のURL
      imageWidth: window.innerWidth * 0.9, // 画面幅の90%のサイズで表示
      imageHeight: 'auto', // 高さは自動調整
      imageAlt: label, // 画像の代替テキスト
      showCloseButton: true, // 閉じるボタンを表示
      background: '#fff', // 背景色は白
      confirmButtonText: '閉じる',
      customClass: {
        popup: 'wide-modal' // カスタムスタイル用のクラス
      }
    });
  };

  // Kintoneからファイルをダウンロードして表示用のURLを作成する関数
  async function getFileBlobUrl(fileKey) {
    try {
      // ファイルキーを使ってファイルをダウンロード
      const res = await client.file.downloadFile({ fileKey });
      // ダウンロードしたデータをBlob(バイナリデータ)として扱う
      const blob = new Blob([res], { type: "image/jpeg" });
      // Blobをブラウザで表示できるURLに変換
      return URL.createObjectURL(blob);
    } catch (e) {
      // エラーが発生した場合は空文字を返す
      return "";
    }
  }

  // カスタムアイコンを作成する関数(円形、枠線付き、透過度設定)
  function createCustomIconWithBorder(iconUrl, isOwned) {
    return new Promise((resolve, reject) => {
      // 画像オブジェクトを作成
      const img = new Image();
      img.crossOrigin = 'anonymous'; // 別ドメインの画像も読み込めるように設定
      
      // 画像の読み込みが完了したときの処理
      img.onload = function() {
        // Canvasを作成(画像を加工するための描画領域)
        const canvas = document.createElement('canvas');
        const size = ICON_SIZE;
        canvas.width = size;
        canvas.height = size;
        const ctx = canvas.getContext('2d'); // 描画コンテキストを取得
        
        // 所持状態に応じた設定を取得
        const config = isOwned ? STYLE_CONFIG.owned : STYLE_CONFIG.notOwned;
        
        // 円形の枠線を描画
        ctx.strokeStyle = config.borderColor; // 枠線の色を設定
        ctx.lineWidth = config.borderWidth; // 枠線の太さを設定
        ctx.beginPath(); // 描画パスを開始
        // 円を描く(中心座標、半径、開始角度、終了角度)
        ctx.arc(size / 2, size / 2, (size / 2) - (config.borderWidth / 2), 0, 2 * Math.PI);
        ctx.stroke(); // 枠線を実際に描画
        
        // 画像を円形に切り抜くための設定
        ctx.save(); // 現在の描画状態を保存
        ctx.beginPath();
        // 円形のクリッピングパスを設定(この範囲内だけ描画される)
        ctx.arc(size / 2, size / 2, (size / 2) - config.borderWidth, 0, 2 * Math.PI);
        ctx.clip(); // クリッピングを適用
        
        // 透過度を設定
        ctx.globalAlpha = config.opacity;
        
        // 画像を円形いっぱいに描画(余白なし)
        const drawSize = size - (config.borderWidth * 2); // 枠線分を除いたサイズ
        const imgAspect = img.width / img.height; // 画像のアスペクト比
        
        let drawWidth, drawHeight, offsetX, offsetY;
        
        // 画像のアスペクト比に応じて描画サイズと位置を計算
        if (imgAspect > 1) {
          // 横長画像の場合
          drawHeight = drawSize;
          drawWidth = drawSize * imgAspect;
          offsetX = -(drawWidth - drawSize) / 2 + config.borderWidth; // 左右を中央揃え
          offsetY = config.borderWidth;
        } else {
          // 縦長画像の場合
          drawWidth = drawSize;
          drawHeight = drawSize / imgAspect;
          offsetX = config.borderWidth;
          offsetY = -(drawHeight - drawSize) / 2 + config.borderWidth; // 上下を中央揃え
        }
        
        // 画像を描画(中央にトリミングされた状態で円形に収まる)
        ctx.drawImage(img, offsetX, offsetY, drawWidth, drawHeight);
        ctx.restore(); // 保存した描画状態を復元
        
        // CanvasをData URL(画像データのテキスト表現)に変換
        const dataUrl = canvas.toDataURL('image/png');
        resolve(dataUrl); // 完成したアイコンのURLを返す
      };
      
      // 画像の読み込みに失敗したときの処理
      img.onerror = function() {
        reject(new Error('Icon loading failed'));
      };
      
      // 画像の読み込みを開始
      img.src = iconUrl;
    });
  }

  // カスタムアイコンのスタイルを取得する関数(キャッシュ機能付き)
  async function getCustomIconStyle(fileKey, iconUrl, isOwned) {
    try {
      // 画像がない場合はデフォルトのGoogleマップピンを使用
      if (!iconUrl) {
        const pinColor = isOwned ? 'blue-dot.png' : 'red-dot.png';
        return new ol.style.Style({
          image: new ol.style.Icon({
            src: `https://maps.google.com/mapfiles/ms/icons/${pinColor}`,
            scale: 1
          })
        });
      }
      
      // キャッシュキーを生成(ファイルキー + 所持状態の組み合わせ)
      const cacheKey = `${fileKey}_${isOwned ? 'owned' : 'notOwned'}`;
      
      // キャッシュに保存されていれば、それを再利用(処理を高速化)
      if (iconCache.has(cacheKey)) {
        return iconCache.get(cacheKey);
      }
      
      // 新しくアイコンを生成
      const iconDataUrl = await createCustomIconWithBorder(iconUrl, isOwned);
      
      // OpenLayersのスタイルオブジェクトを作成
      const style = new ol.style.Style({
        image: new ol.style.Icon({
          src: iconDataUrl, // 生成したアイコン画像
          scale: 1, // 拡大縮小率
          anchor: [0.5, 0.5], // アイコンの基準点(中心)
          anchorXUnits: 'fraction', // X座標の単位(0~1の比率)
          anchorYUnits: 'fraction' // Y座標の単位(0~1の比率)
        })
      });
      
      // 次回のために生成したスタイルをキャッシュに保存
      iconCache.set(cacheKey, style);
      
      return style;
    } catch (error) {
      // エラーが発生した場合はデフォルトのピンを使用
      const pinColor = isOwned ? 'blue-dot.png' : 'red-dot.png';
      return new ol.style.Style({
        image: new ol.style.Icon({
          src: `https://maps.google.com/mapfiles/ms/icons/${pinColor}`,
          scale: 1
        })
      });
    }
  }

  // カード画像をKintoneにアップロードする関数
  async function uploadPhoto(file) {
    try {
      // ファイルサイズをチェック(10MBまで)
      const maxSize = 10 * 1024 * 1024;
      if (file.size > maxSize) {
        throw new Error(`ファイルサイズが大きすぎます(${Math.round(file.size / 1024 / 1024)}MB > 10MB)`);
      }
      
      // 許可されているファイル形式をチェック
      const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/jpg'];
      if (!allowedTypes.includes(file.type)) {
        throw new Error(`サポートされていないファイル形式です: ${file.type}`);
      }
      
      // Kintone APIを使ってファイルをアップロード
      const resp = await client.file.uploadFile({
        file: {
          name: file.name,
          data: file
        }
      });
      
      // アップロードが成功したかチェック
      if (!resp || !resp.fileKey) {
        throw new Error('ファイルキーが取得できませんでした');
      }
      
      // ファイルキーを返す(このキーでファイルを識別する)
      return resp.fileKey;
      
    } catch (error) {
      throw error; // エラーを上位の関数に伝える
    }
  }

  // 代替アップロード方法(通常のアップロードが失敗した場合に使用)
  async function uploadPhotoDirect(file) {
    try {
      // FormDataオブジェクトを作成(ファイルを送信するためのフォーマット)
      const formData = new FormData();
      formData.append('file', file);
      
      // Fetch APIを使って直接Kintoneのファイルアップロードエンドポイントに送信
      const response = await fetch('/k/v1/file.json', {
        method: 'POST',
        headers: {
          'X-Requested-With': 'XMLHttpRequest'
        },
        body: formData
      });
      
      // レスポンスが正常でない場合はエラーを投げる
      if (!response.ok) {
        const errorText = await response.text();
        throw new Error(`HTTP ${response.status}: ${errorText}`);
      }
      
      // レスポンスをJSONとして解析
      const result = await response.json();
      return result.fileKey; // ファイルキーを返す
      
    } catch (error) {
      throw error; // エラーを上位の関数に伝える
    }
  }

  // 既存のレコードを更新する関数(カード画像を置き換える)
  async function updateRecord(recordId, oldPhotoFile, newPhotoFile) {
    try {
      // ユーザーに進捗を表示(レコード情報取得中)
      Swal.fire({
        title: '更新中...',
        text: '現在のレコード情報を取得中...',
        allowOutsideClick: false,
        didOpen: () => {
          Swal.showLoading(); // ローディングアニメーションを表示
        }
      });
      
      // 現在のレコード情報を取得(楽観的ロックのためリビジョン番号が必要)
      const currentRecord = await client.record.getRecord({
        app: kintone.app.getId(),
        id: recordId
      });
      
      // 現在の「カード有」チェックボックスの値を取得
      let checkboxValues = [...(currentRecord.record.カード有?.value || [])];
      const updateData = {}; // 更新するデータを格納するオブジェクト
      
      // カード表がアップロードされた場合の処理
      if (oldPhotoFile) {
        Swal.update({ text: 'カード表をアップロード中...' });
        
        // ファイル名を安全な形式に変換
        const sanitizedName = sanitizeFileName(oldPhotoFile.name);
        // 新しいファイル名でFileオブジェクトを作り直す
        const renamedFile = new File([oldPhotoFile], sanitizedName, {
          type: oldPhotoFile.type,
          lastModified: oldPhotoFile.lastModified
        });
        
        let oldPhotoKey;
        try {
          // 通常のアップロード方法を試す
          oldPhotoKey = await uploadPhoto(renamedFile);
        } catch (error) {
          // 失敗したら代替方法を試す
          Swal.update({ text: 'カード表をアップロード中(代替方法)...' });
          oldPhotoKey = await uploadPhotoDirect(renamedFile);
        }
        
        // アップロードしたファイルの情報を更新データに追加
        updateData['カード表'] = { 
          value: [{
            fileKey: oldPhotoKey,
            name: sanitizedName,
            contentType: renamedFile.type,
            size: renamedFile.size.toString()
          }]
        };
        
        // チェックボックスに「表」を追加(まだなければ)
        if (!checkboxValues.includes('表')) {
          checkboxValues.push('表');
        }
      }
      
      // カード裏がアップロードされた場合の処理
      if (newPhotoFile) {
        Swal.update({ text: 'カード裏をアップロード中...' });
        
        // ファイル名を安全な形式に変換
        const sanitizedName = sanitizeFileName(newPhotoFile.name);
        // 新しいファイル名でFileオブジェクトを作り直す
        const renamedFile = new File([newPhotoFile], sanitizedName, {
          type: newPhotoFile.type,
          lastModified: newPhotoFile.lastModified
        });
        
        let newPhotoKey;
        try {
          // 通常のアップロード方法を試す
          newPhotoKey = await uploadPhoto(renamedFile);
        } catch (error) {
          // 失敗したら代替方法を試す
          Swal.update({ text: 'カード裏をアップロード中(代替方法)...' });
          newPhotoKey = await uploadPhotoDirect(renamedFile);
        }
        
        // アップロードしたファイルの情報を更新データに追加
        updateData['カード裏'] = { 
          value: [{
            fileKey: newPhotoKey,
            name: sanitizedName,
            contentType: renamedFile.type,
            size: renamedFile.size.toString()
          }]
        };
        updateData['カード裏説明'] = { value: newPhotoFile.name };
        
        // チェックボックスに「裏」を追加(まだなければ)
        if (!checkboxValues.includes('裏')) {
          checkboxValues.push('裏');
        }
      }
      
      // 「カード有」チェックボックスの値を更新データに追加
      updateData['カード有'] = { value: checkboxValues };
      
      // 実際にレコードを更新
      Swal.update({ text: 'レコードを更新中です...' });
      
      await client.record.updateRecord({
        app: kintone.app.getId(),
        id: recordId,
        record: updateData,
        revision: currentRecord.record.$revision.value // 楽観的ロック用のリビジョン番号
      });
      
      // 成功メッセージを表示
      Swal.fire({
        title: '成功',
        text: 'カードが更新されました',
        icon: 'success',
        timer: 1500
      });
      
      // 画面をリロードして変更を反映
      setTimeout(() => {
        location.reload();
      }, 1000);
      
    } catch (error) {
      // エラーメッセージを表示
      Swal.fire({
        title: 'エラー',
        text: 'カードの更新に失敗しました\n詳細: ' + (error.message || ''),
        icon: 'error'
      });
    }
  }

  // 新しいレコードを作成する関数
  async function createNewRecord(lat, lng, oldPhotoFile, newPhotoFile, description) {
    try {
      // ユニークなIDを生成
      const polygonUuid = generatePolygonUuid();
      
      // ユーザーに進捗を表示
      Swal.fire({
        title: '作成中...',
        text: 'カードをアップロード中です',
        allowOutsideClick: false,
        didOpen: () => {
          Swal.showLoading();
        }
      });
      
      // 新規レコードのデータを準備
      const recordData = {
        point_lat: { value: lat.toString() }, // 緯度
        point_lng: { value: lng.toString() }, // 経度
        場所説明: { value: description || '' } // 場所の説明
      };
      
      const checkboxValues = []; // 「カード有」チェックボックスの値を格納
      
      // カード表がアップロードされた場合の処理
      if (oldPhotoFile) {
        Swal.update({ text: 'カード表をアップロード中...' });
        
        // ファイル名を安全な形式に変換
        const sanitizedName = sanitizeFileName(oldPhotoFile.name);
        const renamedFile = new File([oldPhotoFile], sanitizedName, {
          type: oldPhotoFile.type,
          lastModified: oldPhotoFile.lastModified
        });
        
        let oldPhotoKey;
        try {
          // 通常のアップロード方法を試す
          oldPhotoKey = await uploadPhoto(renamedFile);
        } catch (error) {
          // 失敗したら代替方法を試す
          Swal.update({ text: 'カード表をアップロード中(代替方法)...' });
          oldPhotoKey = await uploadPhotoDirect(renamedFile);
        }
        
        // アップロードしたファイルの情報をレコードデータに追加
        recordData['カード表'] = { 
          value: [{ 
            fileKey: oldPhotoKey,
            name: sanitizedName,
            contentType: renamedFile.type,
            size: renamedFile.size.toString()
          }] 
        };
        recordData['カード表説明'] = { value: oldPhotoFile.name };
        checkboxValues.push('表'); // チェックボックスに「表」を追加
      }
      
      // カード裏がアップロードされた場合の処理
      if (newPhotoFile) {
        Swal.update({ text: 'カード裏をアップロード中...' });
        
        // ファイル名を安全な形式に変換
        const sanitizedName = sanitizeFileName(newPhotoFile.name);
        const renamedFile = new File([newPhotoFile], sanitizedName, {
          type: newPhotoFile.type,
          lastModified: newPhotoFile.lastModified
        });
        
        let newPhotoKey;
        try {
          // 通常のアップロード方法を試す
          newPhotoKey = await uploadPhoto(renamedFile);
        } catch (error) {
          // 失敗したら代替方法を試す
          Swal.update({ text: 'カード裏をアップロード中(代替方法)...' });
          newPhotoKey = await uploadPhotoDirect(renamedFile);
        }
        
        // アップロードしたファイルの情報をレコードデータに追加
        recordData['カード裏'] = { 
          value: [{ 
            fileKey: newPhotoKey,
            name: sanitizedName,
            contentType: renamedFile.type,
            size: renamedFile.size.toString()
          }] 
        };
        recordData['カード裏説明'] = { value: newPhotoFile.name };
        checkboxValues.push('裏'); // チェックボックスに「裏」を追加
      }
      
      // チェックボックスの値が1つ以上あればレコードデータに追加
      if (checkboxValues.length > 0) {
        recordData['カード有'] = { value: checkboxValues };
      }
      
      // 実際にレコードを作成
      Swal.update({ text: 'レコードを作成中です...' });
      
      await client.record.addRecord({
        app: kintone.app.getId(),
        record: recordData
      });
      
      // 成功メッセージを表示
      Swal.fire({
        title: '成功',
        html: `新しいレコードが作成されました<br><small>UUID: ${polygonUuid}</small>`,
        icon: 'success',
        timer: 2000
      });
      
      // 画面をリロードして変更を反映
      setTimeout(() => {
        location.reload();
      }, 1500);
      
    } catch (error) {
      // UUID重複エラーの場合はリトライ(最大3回まで)
      if (error.message && (error.message.includes('重複') || error.message.includes('duplicate')) || 
          error.code === 'GAIA_DA02') {
        if (!createNewRecord._retryCount) createNewRecord._retryCount = 0;
        if (createNewRecord._retryCount < 3) {
          createNewRecord._retryCount++;
          // 100ミリ秒後に再試行
          setTimeout(() => {
            createNewRecord(lat, lng, oldPhotoFile, newPhotoFile, description);
          }, 100);
          return;
        }
      }
      
      // エラーメッセージを表示
      Swal.fire({
        title: 'エラー',
        text: 'レコードの作成に失敗しました\n詳細: ' + (error.message || ''),
        icon: 'error'
      });
    } finally {
      // リトライカウンターをリセット
      createNewRecord._retryCount = 0;
    }
  }

  // 新規レコード作成用のモーダルウィンドウを表示する関数
  function showNewRecordModal(coordinate) {
    // クリックされた座標を経度・緯度に変換
    const lonLat = ol.proj.toLonLat(coordinate);
    const [lng, lat] = lonLat;
    // 新しいレコード用のUUIDを生成(プレビュー用)
    const previewUuid = generatePolygonUuid();

    // モーダルウィンドウを表示
    Swal.fire({
      title: '新しいレコードを作成',
      html: `
        <div style="text-align: left;">
          <div style="margin-bottom: 10px;">
            <strong>位置:</strong> 緯度 ${lat.toFixed(6)}, 経度 ${lng.toFixed(6)}
          </div>
          <div style="margin-bottom: 10px; padding: 8px; background-color: #f8f9fa; border-radius: 4px; font-size: 12px;">
            <strong>生成されるUUID:</strong><br>
            <code style="word-break: break-all;">${previewUuid}</code>
          </div>
          <div style="margin-bottom: 15px;">
            <label style="display: block; font-weight: bold; margin-bottom: 5px;">場所の説明:</label>
            <input type="text" id="location-description" style="width: 100%; padding: 5px;" placeholder="場所についての説明を入力">
          </div>
          <div style="display: flex; gap: 15px; margin-bottom: 15px;">
            <div style="flex: 1;">
              <label style="display: block; font-weight: bold; margin-bottom: 5px;">カード表:</label>
              <input type="file" id="modal-old-photo" accept="image/jpeg,image/png,image/gif,image/webp" style="width: 100%;">
              <div style="font-size: 10px; color: #666;">JPEG, PNG, GIF, WebP (最大10MB)</div>
            </div>
            <div style="flex: 1;">
              <label style="display: block; font-weight: bold; margin-bottom: 5px;">カード裏:</label>
              <input type="file" id="modal-new-photo" accept="image/jpeg,image/png,image/gif,image/webp" style="width: 100%;">
              <div style="font-size: 10px; color: #666;">JPEG, PNG, GIF, WebP (最大10MB)</div>
            </div>
          </div>
          <div style="font-size: 12px; color: #666;">
            ※ 少なくとも1つのカードをアップロードしてください
          </div>
        </div>
      `,
      showCancelButton: true,
      confirmButtonText: 'レコード作成',
      cancelButtonText: 'キャンセル',
      confirmButtonColor: '#007bff',
      width: '500px',
      // 確認ボタンが押されたときの処理
      preConfirm: async () => {
        // 入力された値を取得
        const description = document.getElementById('location-description').value;
        const oldPhotoFile = document.getElementById('modal-old-photo').files[0];
        const newPhotoFile = document.getElementById('modal-new-photo').files[0];
        
        // カードが1つも選択されていない場合はエラー
        if (!oldPhotoFile && !newPhotoFile) {
          Swal.showValidationMessage('少なくとも1つのカードをアップロードしてください');
          return false;
        }
        
        // 入力値を返す
        return { description, oldPhotoFile, newPhotoFile };
      }
    }).then(async (result) => {
      // 確認ボタンが押された場合
      if (result.isConfirmed) {
        const { description, oldPhotoFile, newPhotoFile } = result.value;
        // 新規レコードを作成
        await createNewRecord(lat, lng, oldPhotoFile, newPhotoFile, description);
      }
    });
  }

  // ポップアップ用のHTML要素を作成する関数
  function createPopupElements() {
    // 既存の要素を取得
    let container = document.getElementById('popup');
    let content = document.getElementById('popup-content');
    let closer = document.getElementById('popup-closer');

    // 要素がまだ存在しない場合は新規作成
    if (!container) {
      // ポップアップのコンテナ(外枠)を作成
      container = document.createElement('div');
      container.id = 'popup';
      container.className = 'ol-popup';
      
      // 閉じるボタンを作成
      closer = document.createElement('a');
      closer.id = 'popup-closer';
      closer.href = '#';
      closer.className = 'ol-popup-closer';
      
      // ポップアップの内容を表示する要素を作成
      content = document.createElement('div');
      content.id = 'popup-content';
      
      // 要素を組み立てる
      container.appendChild(closer);
      container.appendChild(content);
      document.body.appendChild(container);
    }

    // 作成した要素を返す
    return { container, content, closer };
  }

  // プログレスバーを表示する関数(アイコン生成の進捗を表示)
  function showProgressBar(current, total) {
    // パーセンテージを計算
    const percent = Math.round((current / total) * 100);
    const message = `アイコン生成中... ${current}/${total} (${percent}%)`;
    
    // まだプログレスバーが表示されていない場合は新規表示
    if (!Swal.isVisible()) {
      Swal.fire({
        title: '地図を読み込み中',
        html: `<div style="margin: 20px 0;">${message}</div><div class="swal2-progress-steps" style="width: 100%; background: #e0e0e0; height: 20px; border-radius: 10px; overflow: hidden;"><div style="width: ${percent}%; background: linear-gradient(90deg, #007bff, #0056b3); height: 100%; transition: width 0.3s;"></div></div>`,
        allowOutsideClick: false,
        showConfirmButton: false
      });
    } else {
      // 既に表示されている場合は内容を更新
      Swal.update({
        html: `<div style="margin: 20px 0;">${message}</div><div class="swal2-progress-steps" style="width: 100%; background: #e0e0e0; height: 20px; border-radius: 10px; overflow: hidden;"><div style="width: ${percent}%; background: linear-gradient(90deg, #007bff, #0056b3); height: 100%; transition: width 0.3s;"></div></div>`
      });
    }
  }

  // メイン処理(アプリケーションの初期化)
  const createshow = async function(event) {
    // 指定されたビューIDの場合のみ処理を実行
    if (event.viewId !== 7442979) {
      return;
    }

    // 地図上のポイント(ピン)を管理するためのVectorSourceを作成
    const vectorSource = new ol.source.Vector();

    try {
      // Kintoneからすべてのレコードを取得
      const resp = await client.record.getAllRecords({
        app: kintone.app.getId(),
        condition: 'カード有 in ("表") or カード有 in ("裏")', // カードを持っているレコードのみ
        fields: [
          '$id', // レコードID
          'point_lat', // 緯度
          'point_lng', // 経度
          'カード表', // カード表の画像
          'カード裏', // カード裏の画像
          'カード表説明', 
          'カード裏説明', 
          '場所説明', 
          '市町村', 
          '住所', 
          'カード有', // カードの有無
          '所持' // 所持状態
        ]
      });

      // プログレスバーを表示開始
      showProgressBar(0, resp.length);

      // 進捗を追跡するための変数
      let processedCount = 0; // 処理済みレコード数
      let iconSuccessCount = 0; // アイコン生成成功数
      let iconFailCount = 0; // アイコン生成失敗数
      let cacheHitCount = 0; // キャッシュヒット数

      const startTime = Date.now(); // 処理開始時刻

      // 各レコードを処理
      for (const record of resp) {
        // 緯度・経度を取得
        const lat = parseFloat(record.point_lat.value);
        const lng = parseFloat(record.point_lng.value);
        // 無効な座標の場合はスキップ
        if (isNaN(lat) || isNaN(lng)) {
          continue;
        }

        // 所持状態を確認
        const shojiValue = record.所持?.value ?? [];
        const isOwned = shojiValue.includes('所持');

        // カード表の画像を取得
        const cardImages = record[ICON_FIELD_CODE]?.value ?? [];
        let iconUrl = null;
        let fileKey = null;
        
        if (cardImages && cardImages.length > 0) {
          fileKey = cardImages[0].fileKey;
          
          // キャッシュキーを生成
          const cacheKey = `${fileKey}_${isOwned ? 'owned' : 'notOwned'}`;
          
          // キャッシュにない場合のみ画像をダウンロード
          if (!iconCache.has(cacheKey)) {
            try {
              iconUrl = await getFileBlobUrl(fileKey);
              iconSuccessCount++;
            } catch (error) {
              iconFailCount++;
            }
          } else {
            cacheHitCount++; // キャッシュがあった場合
          }
        } else {
          iconFailCount++;
        }

        // 地図上のポイント(Feature)を作成
        const feature = new ol.Feature({
          geometry: new ol.geom.Point(ol.proj.fromLonLat([lng, lat])), // 座標を地図の投影法に変換
          properties: {
            "recordId": record.$id.value,
            "市町村": record.市町村?.value ?? "",
            "住所": record.住所?.value ?? "",
            "場所説明": record.場所説明?.value ?? "",
            "カード表": record.カード表?.value ?? [],
            "カード裏": record.カード裏?.value ?? [],
            "カード表説明": record.カード表説明?.value ?? "",
            "カード裏説明": record.カード裏説明?.value ?? "",
            "カード有": record.カード有?.value ?? [],
            "所持": shojiValue
          }
        });

        // カスタムアイコンのスタイルを設定
        const style = await getCustomIconStyle(fileKey, iconUrl, isOwned);
        feature.setStyle(style);
        
        // ポイントをVectorSourceに追加
        vectorSource.addFeature(feature);
        
        processedCount++;
        
        // 50件ごとにプログレスバーを更新
        if (processedCount % 50 === 0 || processedCount === resp.length) {
          showProgressBar(processedCount, resp.length);
        }
      }
      
      const endTime = Date.now(); // 処理終了時刻
      
      // プログレスバーを閉じる
      Swal.close();
      
    } catch (e) {
      // エラーが発生した場合はプログレスバーを閉じて警告を表示
      Swal.close();
      alert("レコード取得失敗: " + e.message);
      return;
    }

    // ポイントを表示するためのVectorLayerを作成
    const vectorLayer = new ol.layer.Vector({
      source: vectorSource
    });

    // ポップアップ用のHTML要素を取得または作成
    const { container, content, closer } = createPopupElements();

    // ポップアップをオーバーレイとして地図に追加
    const overlay = new ol.Overlay({
      element: container,
      autoPan: { animation: { duration: 250 } } // ポップアップが画面外に出ないように自動調整
    });
    
    // 閉じるボタンのクリックイベント
    closer.onclick = function () {
      overlay.setPosition(undefined); // オーバーレイを非表示にする
      closer.blur(); // フォーカスを外す
      return false;
    };

    // 地図を表示するコンテナを取得または作成
    let mapContainer = document.getElementById('map');
    if (!mapContainer) {
      mapContainer = document.createElement('div');
      mapContainer.id = 'map';
      mapContainer.style.cssText = 'width: 100%; height: 600px; border: 1px solid #ccc;';
      
      // Kintoneのヘッダースペースに地図を追加
      const recordListSpace = kintone.app.getHeaderSpaceElement();
      if (recordListSpace) {
        recordListSpace.appendChild(mapContainer);
      } else {
        document.body.appendChild(mapContainer);
      }
    }

    // OpenLayersの地図オブジェクトを作成
    const map = new ol.Map({
      target: "map", // 地図を表示する要素
      overlays: [overlay], // ポップアップオーバーレイ
      layers: [
        // 背景地図(地理院タイル)
        new ol.layer.Tile({
          source: new ol.source.XYZ({
            attributions: "<a href='https://maps.gsi.go.jp/development/ichiran.html' target='_blank'>地理院タイル</a>",
            url: "https://cyberjapandata.gsi.go.jp/xyz/std/{z}/{x}/{y}.png",
            projection: "EPSG:3857"
          })
        }),
        // ポイント(ピン)を表示するレイヤー
        vectorLayer
      ],
      view: new ol.View({
        projection: "EPSG:3857", // Web メルカトル図法
        center: ol.proj.transform([139.75224909771373,35.68524268608703], "EPSG:4326", "EPSG:3857"), // 初期表示位置(東京周辺)
        zoom: 8 // 初期ズームレベル
      }) 
    });

    // 地図上のクリックイベント
    map.on('click', async function(evt) {
      let featureFound = false; // ピンがクリックされたかどうか
      
      // クリックされた位置にピン(Feature)があるかチェック
      map.forEachFeatureAtPixel(evt.pixel, async function(feature) {
        featureFound = true;
        
        // ピンのプロパティ(レコード情報)を取得
        const prop = feature.get('properties');

        // 画像を表示するHTMLタグを生成する関数
        async function getImageTag(fileArr, label) {
          // 画像がない場合
          if (!fileArr || !fileArr.length) {
            return `
              <div style="flex: 1; min-width: 200px; max-width: 260px;">
                <div style="font-size: small; font-weight: bold; margin-bottom: 4px;">${label}:</div>
                <div style="text-align: center; padding: 40px; background-color: #f8f9fa; border-radius: 4px; color: #666;">
                  画像なし
                </div>
              </div>
            `;
          }
          
          // 画像がある場合はURLを取得して表示
          const url = await getFileBlobUrl(fileArr[0].fileKey);
          return `
            <div style="flex: 1; min-width: 200px; max-width: 260px;">
              <div style="font-size: small; font-weight: bold; margin-bottom: 4px;">${label}</div>
              <img src="${url}" style="width: 100%; height: auto; cursor: pointer; border-radius: 4px;" onclick="showImageModal('${url}', '${label}')">
            </div>
          `;
        }

        // カード表とカード裏の画像HTMLを生成
        const oldImgTag = await getImageTag(prop["カード表"], prop["カード表説明"] || 'カード表');
        const newImgTag = await getImageTag(prop["カード裏"], prop["カード裏説明"] || 'カード裏');

        // 所持状態の表示を生成
        const shojiStatus = prop["所持"] && prop["所持"].includes('所持') 
          ? '<span style="color: blue; font-weight: bold;">✓ 所持</span>' 
          : '<span style="color: #999;">未所持</span>';

        // ポップアップの内容を作成
        content.innerHTML = `
          <div style="display: flex; gap: 12px; flex-wrap: wrap;">
            ${oldImgTag}
            ${newImgTag}
          </div>
          <div style="font-size: small; margin-top: 12px;">
            <div><strong>所持状態:</strong> ${shojiStatus}</div>
            <div>場所についての説明: ${prop["場所説明"] ?? ""}</div>
            <div>場所: ${prop["市町村"] ?? ""}${prop["住所"] ?? ""}</div>
          </div>
          
          <div style="border-top: 1px solid #ccc; margin-top: 12px; padding-top: 12px;">
            <div style="font-weight: bold; margin-bottom: 8px;">カードを更新</div>
            <div style="display: flex; gap: 12px; margin-bottom: 12px;">
              <div style="flex: 1;">
                <label style="font-size: small; display: block; margin-bottom: 4px;">カード表:</label>
                <input type="file" id="old-photo-upload" accept="image/jpeg,image/png,image/gif,image/webp" style="font-size: small; width: 100%; border: 1px solid #ddd; padding: 4px; border-radius: 4px;">
              </div>
              <div style="flex: 1;">
                <label style="font-size: small; display: block; margin-bottom: 4px;">カード裏:</label>
                <input type="file" id="new-photo-upload" accept="image/jpeg,image/png,image/gif,image/webp" style="font-size: small; width: 100%; border: 1px solid #ddd; padding: 4px; border-radius: 4px;">
              </div>
            </div>
            
            <button id="update-photos-btn" style="background: #dc3545; color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; font-size: small; width: 100%;">
              カードを置き換え
            </button>
          </div>
        `;

        // 「カードを置き換え」ボタンのクリックイベント
        document.getElementById('update-photos-btn').addEventListener('click', async function() {
          // 選択されたファイルを取得
          const oldPhotoFile = document.getElementById('old-photo-upload').files[0];
          const newPhotoFile = document.getElementById('new-photo-upload').files[0];
          
          // ファイルが1つも選択されていない場合はエラー
          if (!oldPhotoFile && !newPhotoFile) {
            Swal.fire({
              title: 'エラー',
              text: '更新するカードを選択してください',
              icon: 'warning'
            });
            return;
          }
          
          // 確認ダイアログを表示
          const result = await Swal.fire({
            title: 'カードを置き換えますか?',
            html: `${oldPhotoFile ? 'カード表' : ''}${oldPhotoFile && newPhotoFile ? 'と' : ''}${newPhotoFile ? 'カード裏' : ''}を置き換えます<br><small style="color: #dc3545;">※既存のカードは削除されます</small>`,
            icon: 'warning',
            showCancelButton: true,
            confirmButtonText: '置き換える',
            cancelButtonText: 'キャンセル',
            confirmButtonColor: '#dc3545'
          });
          
          // 確認ボタンが押された場合
          if (result.isConfirmed) {
            overlay.setPosition(undefined); // ポップアップを閉じる
            await updateRecord(prop.recordId, oldPhotoFile, newPhotoFile); // レコードを更新
          }
        });

        // ポップアップをクリックされた位置に表示
        overlay.setPosition(evt.coordinate);
      });
      
      // ピンがクリックされなかった場合は新規レコード作成モーダルを表示
      if (!featureFound) {
        showNewRecordModal(evt.coordinate);
      }
    });
  };

  // Kintoneのイベントにメイン処理を登録(一覧画面が表示されたときに実行)
  kintone.events.on(['app.record.index.show'], createshow);

})();

なお、今後、Cybozu CDNも変わります。OpenLayerのライブラリも変わります。国土地理院のタイルもいつまで使えるかは知りません。つまり、定期的な保守が必要です。
もし業務で使いたいのなら、悪いことは言いません。カンタンマップ(https://kantanmap.jp/)やカスタマーコンパス(https://samurai-sys.com/kintone/)などのプラグインを使いましょう。

あと、ポップアップのスタイルにもCSSを使っています。デコりたい方はご自身でどうぞ。

6.まとめ

  Topへ↑

どうです?

これ、マンホールカードだけではなく、ダムカードや歴史カード、御朱印や御城印、御墳印、鉄印、魚朱印、御酒印、御宿場印、ロゲットカード、記念切符、風景印、なんでも適用できます。攻城の印に、あらうる郵便局の前のぬん活写真を、顔ハメスポットの貴方を、灯台の前で逆立ちする旦那を、N尾さんの出没情報、各地に生息するゆるキャラとのツーショット、kintoneにコレクションしてくのはいかがでしょうか?

全国のkintone Caféをくまなく巡った証しの集合写真、全国のちいクラでゆるキャラとツーショットを撮った写真、何でもありです!あらゆるコレクターのお供にkintone!

コメント

タイトルとURLをコピーしました