Internal standard training

Chart.js × FileMaker 実装ガイド

FileMaker開発者が、Chart.jsを標準的な方法として実案件に組み込み、基本的なエラーを切り分けられるようになるための教材です。

Overview

教材の目的

この教材は、HTML/CSS/JavaScriptを専門的に学ぶものではありません。FileMaker案件でChart.jsを再利用しやすく、安全に組み込むための標準実装を学びます。

01

コピーして組み込む

標準テンプレートをFileMaker Webビューアへ組み込める。

02

JSONで更新する

FileMakerから標準JSONを渡してグラフを更新できる。

03

設定を変える

グラフ種別、横棒、積み上げ、単位、複合グラフを設定できる。

04

原因を切り分ける

表示されない原因をHTML、JSON、Chart.js、FileMaker設定に分けて確認できる。

テストカテゴリとの対応

既存のテストWebページへ組み込みやすいように、教材を5カテゴリに整理しています。カテゴリ名は「HTML実装」「FileMakerデータ連携」「グラフ設定」「スクリプト連携」「トラブルシューティング」です。

Category 1

HTML実装

HTMLは、Webビューア内に表示する部品を置く場所です。Chart.jsでは、グラフの描画先としてcanvasを使います。

最低限必要な構造

<div class="chart-shell">
  <canvas id="lessonChart"></canvas>
</div>

canvasはグラフを描く領域です。FileMaker側から直接canvasへ値を入れるのではなく、JavaScript関数へJSONを渡して描画します。

CSSで高さを確保する

.chart-shell {
  position: relative;
  width: 100%;
  height: 360px;
}

グラフが表示されない場合、canvasそのものではなく、親要素の高さが不足していることがあります。

確認項目標準理由
canvasのidlessonChartJavaScriptから描画先を特定するため。
親要素.chart-shell表示サイズを安定させるため。
Chart.js読み込み学習時はCDN、本番は埋め込み社内環境やオフライン環境でも動かすため。

Category 2

FileMakerデータ連携

FileMakerは、集計したデータを標準JSONに変換し、Webビューア内のJavaScript関数へ渡します。

標準的なJSON

{
  "type": "bar",
  "title": "月別売上",
  "labels": ["1月", "2月", "3月"],
  "datasets": [
    {
      "label": "売上",
      "data": [1200000, 1450000, 1380000]
    }
  ],
  "options": {
    "unit": "円",
    "indexAxis": "x",
    "stacked": false
  }
}

FileMaker計算式例

JSONSetElement ( "{}" ;
  [ "type" ; "bar" ; JSONString ] ;
  [ "title" ; "月別売上" ; JSONString ] ;
  [ "labels[0]" ; "1月" ; JSONString ] ;
  [ "labels[1]" ; "2月" ; JSONString ] ;
  [ "datasets[0].label" ; "売上" ; JSONString ] ;
  [ "datasets[0].data[0]" ; 1200000 ; JSONNumber ] ;
  [ "datasets[0].data[1]" ; 1450000 ; JSONNumber ] ;
  [ "options.unit" ; "円" ; JSONString ] ;
  [ "options.indexAxis" ; "x" ; JSONString ]
)

標準ルール

数値はJSONStringではなくJSONNumberで渡します。文字列で渡すと、グラフ上は表示できても、軸・ツールチップ・計算で意図しない結果になる場合があります。

Category 3

グラフ設定

Chart.jsの設定は、標準的なJSONのtypeoptionsで切り替えます。

目的typeoptions使いどころ
縦棒グラフbarindexAxis: "x"月別売上、カテゴリ別件数
横棒グラフbarindexAxis: "y"担当者別ランキング、項目名が長い集計
折れ線グラフlineunit: "%"など売上推移、粗利率推移
ドーナツグラフdoughnutunit: "%"など構成比、内訳
複合グラフbarmixed: true売上と達成率など、棒と線を同時表示

下の「ライブエディタ」でプリセットを選んで実際にグラフを触ってみてください。

Live Editor

ライブコードエディタ

JSONを編集するとグラフがリアルタイムで更新されます。FileMakerから渡す感覚でコードを触ってみてください。

プリセット:
📝 JSONを編集 自動更新: ON
📊 グラフプレビュー type: bar
💡 ヒント:type を変えるとグラフ種別が切り替わります

このJSONはFileMakerでこう作る

JSONSetElement ( "{}" ;
  [ "type"                    ; "bar"     ; JSONString  ] ;
  [ "data.labels[0]"          ; "1月"     ; JSONString  ] ;
  [ "data.datasets[0].data[0]"; 120       ; JSONNumber  ] ;
  [ "data.datasets[0].label"  ; "売上"   ; JSONString  ] ;
  [ "options.responsive"      ; True      ; JSONBoolean ] ;
  [ "options.maintainAspectRatio" ; False ; JSONBoolean ]
)

Category 4

スクリプト連携

FileMakerとJavaScriptは、関数名と引数を決めて連携します。標準的な実装では、グラフ更新用の関数名を固定します。

FileMaker → JavaScript

関数名: updateChartFromFileMaker
引数: JSON文字列
  1. FileMakerで標準JSONを作成する。
  2. Webビューアオブジェクト名を確認する。
  3. 「JavaScriptをWebビューアで実行」で関数を呼び出す。
  4. 戻り値がOKか、ERROR:で始まる文字列かを確認する。

JavaScript → FileMaker

FileMaker.PerformScriptWithOption(
  "ChartJS|WebViewerイベント受信",
  JSON.stringify(payload),
  "0"
);

クリック結果やエラー通知をFileMakerへ戻す場合に使います。Webビューア側でJavaScriptからFileMakerスクリプトを実行できる設定にします。

Category 5

トラブルシューティング

表示されない時は、いきなりコード全体を疑わず、どの層で止まっているかを順番に確認します。

症状確認する場所よくある原因対処
何も表示されないHTML / Chart.jsChart.js本体が未読み込み、canvas id違いHTML内のscriptとcanvas idを確認する。
枠だけ表示されるCSS親要素の高さ不足.chart-shellに高さを指定する。
エラー表示が出るJSONlabelsとdataの数不一致、JSON型違いJSONFormatElementsで構造を確認する。
FileMakerから更新できないWebビューア / スクリプトWebビューア未読み込み、関数名違い、オブジェクト名違いWebビューアの表示完了後に実行し、関数名を確認する。
横棒が意図通りでないグラフ設定indexAxis未指定options.indexAxis = "y"を指定する。

FileMaker Example

実装例:ドーナツグラフ(達成率)

FileMakerのグローバル変数($$変数)でデータ・色・設定を制御し、Webビューアに渡す計算式の実例です。

この計算式でできること

  • $$変数で予算・実績・ランク別データを受け取りドーナツグラフを描画
  • 達成率に応じてグラフ色を赤・黄・緑に自動切替(しきい値は$$変数で変更可)
  • 中央に達成率%をアニメーション表示(スイッチでON/OFF切替)
  • ホバー時のツールチップ・グラフ動作・テキスト表示もスイッチで制御
//OK7

Let ( [
  // --- FileMaker 変数設定 ---
  budget = GetAsNumber ( $$S粗利予算 ) ;
  data1 = GetAsNumber ( $$S粗利 ) ;
  data2 = GetAsNumber ( $$S予定S ) ;
  data3 = GetAsNumber ( $$S予定A ) ;
  data4 = GetAsNumber ( $$S予定B ) ;
  data5 = GetAsNumber ( $$S予定C ) ;
  data6 = GetAsNumber ( $$S予定D ) ;

  redThreshold = If ( IsEmpty ( $$red ) ; 50 / 100 ; GetAsNumber ( $$red ) / 100 ) ;
  yellowThreshold = If ( IsEmpty ( $$Yellow ) ; 80 / 100 ; GetAsNumber ( $$Yellow ) / 100 ) ;

  colorRed    = If ( IsEmpty ( $$赤色 ) ; "#f44336" ; $$赤色 ) ;
  colorYellow = If ( IsEmpty ( $$黄色 ) ; "#FDB912" ; $$黄色 ) ;
  colorGreen  = If ( IsEmpty ( $$緑色 ) ; "#4caf50" ; $$緑色 ) ;
  colorYoteiS = If ( IsEmpty ( $$予定色S ) ; "#2EA7E0" ; $$予定色S ) ;
  colorYoteiA = If ( IsEmpty ( $$予定色A ) ; "#3EB7E0" ; $$予定色A ) ;
  colorYoteiB = If ( IsEmpty ( $$予定色B ) ; "#4EC7E0" ; $$予定色B ) ;
  colorYoteiC = If ( IsEmpty ( $$予定色C ) ; "#5ED7E0" ; $$予定色C ) ;
  colorYoteiD = If ( IsEmpty ( $$予定色D ) ; "#6EE7E0" ; $$予定色D ) ;

  borderWidth = If ( $$区切線 = "OFF" ; 0 ; 1 ) ;

  htmlData =
    "data:text/html;charset=utf-8," &
    "<!DOCTYPE html><html><head><meta charset='UTF-8'>" &
    "<script src='https://cdn.jsdelivr.net/npm/chart.js'></script>" &
    "<style>" &
      "body{margin:0;display:flex;justify-content:center;align-items:center;height:100vh;background:#FFF;}" &
      ".container{position:relative;width:200px;height:200px;}" &
      ".center-text{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;pointer-events:none;}" &
      ".center-text h2{margin:0;font-size:22px;font-weight:bold;}" &
    "</style></head><body>" &
    "<div class='container'>" &
      "<canvas id='realValueDoughnutChart'></canvas>" &
      "<div class='center-text'><h2>0%</h2></div>" &
    "</div>" &
    "<script>(function(){" &
      "const FREEZE_CHART_ON_HOVER  = true;" &
      "const ANIMATE_COUNT_UP       = true;" &
      "const ENABLE_TOOLTIP_ON_HOVER = true;" &
      "const HIDE_TEXT_ON_HOVER     = false;" &
      "let isInitialAnimation = true;" &
      "const budget = " & budget & ";" &
      "const borderWidth = " & borderWidth & ";" &
      "const dataSources = [" &
        "{label:'受注済', value:" & data1 & "}," &
        "{label:'ランクS', value:" & data2 & "}," &
        "{label:'ランクA', value:" & data3 & "}," &
        "{label:'ランクB', value:" & data4 & "}," &
        "{label:'ランクC', value:" & data5 & "}," &
        "{label:'ランクD', value:" & data6 & "}" &
      "];" &
      "const redThreshold = " & redThreshold & ";" &
      "const yellowThreshold = " & yellowThreshold & ";" &
      "const colorRed    = '" & colorRed    & "';" &
      "const colorYellow = '" & colorYellow & "';" &
      "const colorGreen  = '" & colorGreen  & "';" &
      "const yoteiColorMap = {" &
        "'ランクS':'" & colorYoteiS & "'," &
        "'ランクA':'" & colorYoteiA & "'," &
        "'ランクB':'" & colorYoteiB & "'," &
        "'ランクC':'" & colorYoteiC & "'," &
        "'ランクD':'" & colorYoteiD & "'" &
      "};" &
      "const activeData = dataSources.filter(d => d.value > 0);" &
      "const dataLabels = activeData.map(d => d.label);" &
      "const dataValues = activeData.map(d => d.value);" &
      "const sJuchuRatio = budget > 0 ? " & data1 & " / budget : 0;" &
      "let sJuchuColor = colorGreen;" &
      "if (sJuchuRatio < redThreshold) { sJuchuColor = colorRed; }" &
      "else if (sJuchuRatio < yellowThreshold) { sJuchuColor = colorYellow; }" &
      "const backgroundColors = activeData.map(d => {" &
        "if (d.label === '受注済') return sJuchuColor;" &
        "return yoteiColorMap[d.label] || '#ccc';" &
      "});" &
      "const totalAchieved = dataValues.reduce((sum, v) => sum + v, 0);" &
      "const totalAchievementRatio = budget > 0 ? totalAchieved / budget : 0;" &
      "const remainder = Math.max(0, budget - totalAchieved);" &
      "if (remainder > 0 || totalAchieved === 0) {" &
        "dataLabels.push('残り');" &
        "dataValues.push(remainder > 0 ? remainder : budget);" &
        "backgroundColors.push('#eee');" &
      "}" &
      "const config = { type:'doughnut', data:{ labels:dataLabels, datasets:[{" &
        "data:dataValues, backgroundColor:backgroundColors," &
        "borderWidth:borderWidth, borderColor:'#FFF'," &
        "cutout:'60%', hoverOffset: FREEZE_CHART_ON_HOVER ? 0 : 4" &
      "}]}, options:{ responsive:true, maintainAspectRatio:false," &
        "animation: ANIMATE_COUNT_UP ? {" &
          "duration:1200," &
          "onProgress: function(a){ if(isInitialAnimation){" &
            "const pct=(a.currentStep/a.numSteps*totalAchievementRatio*100).toFixed(2);" &
            "const h2=a.chart.canvas.parentNode.querySelector('.center-text h2');" &
            "if(h2) h2.textContent=pct+'%';" &
          "}}," &
          "onComplete: function(a){" &
            "const h2=a.chart.canvas.parentNode.querySelector('.center-text h2');" &
            "if(h2) h2.textContent=(totalAchievementRatio*100).toFixed(2)+'%';" &
            "isInitialAnimation=false;" &
          "}," &
          "transitions:{active:{animation:{duration:0}}}" &
        "} : false," &
        "plugins:{legend:{display:false}," &
          "tooltip:{enabled:ENABLE_TOOLTIP_ON_HOVER," &
            "callbacks:{" &
              "label:function(t){return ' '+(t.label==='残り'?'予算残':'金額')+': ¥'+t.raw.toLocaleString();}," &
              "title:function(t){return t[0].label==='残り'?'予算残':t[0].label;}" &
          "}}}" &
      "}};" &
      "new Chart(document.getElementById('realValueDoughnutChart'), config);" &
      "if(HIDE_TEXT_ON_HOVER){" &
        "const cv=document.getElementById('realValueDoughnutChart');" &
        "const ct=document.querySelector('.center-text');" &
        "if(cv&&ct){" &
          "cv.addEventListener('mouseenter',function(){ct.style.display='none';});" &
          "cv.addEventListener('mouseleave',function(){ct.style.display='flex';});" &
        "}" &
      "}" &
      "if(!ANIMATE_COUNT_UP){" &
        "const h2=document.querySelector('.center-text h2');" &
        "if(h2) h2.textContent=(totalAchievementRatio*100).toFixed(2)+'%';" &
      "}" &
    "})();</script></body></html>"
];
  htmlData
)