📑

WebFOCUSのHTML5拡張グラフを開発する(カード型ダッシュボード)

2025/02/21に公開

WebFOCUSのHTML5拡張グラフでカード型ダッシュボード

今回はHTML5拡張グラフの原点回帰でD3を使ったグラフも利用していきます。

また、WebFOCUS環境にデプロイしないで動作確認できるHTMLも作って開発が捗るようになりましたのでそれも紹介します。

D3.jsの利用

com.ibi.simple-barと同じようにlib/d3.min.jsを配置します。

properties.jsonでもこのライブラリを使う記述を追加します。

properties.json
{
    "info": {
        "version": "1.0.0",  
        "implements_api_version": 1.0,
        "author": "Shimokado Masataka",
        "copyright": "Shimokado Masataka",
        "url": "",
        "icons": {
            "medium": "icons/card-simple.png"
        }
    },

    "properties": {
		"chartHeadroom": 50,
		"external_library": "lib/d3.min.js",
        "tableStyle": {
            "fontSize": "20px",
            "color": "#663300"
        }
    },

    "propertyAnnotations": {
		"chartHeadroom": "number",
		"external_library": "str",
        "tableStyle": {
            "fontSize": "str",
            "color": "str"
        }
    },

    "dataBuckets": {
        "tooltip": true,
        "matrix": false,
        "data_page": false,
        "series_break": false,

        "buckets": [
            {
                "id": "value",
                "type": "measure",
                "count": {
                    "min": 1,
                    "max": 1
                }
            },
            {
                "id": "labels",
                "type": "dimension",
                "count": {
                    "min": 1,
                    "max": 1
                }
            }
        ]
    },

    "translations": {
        "en": {
            "name": "Card Dashboard",
            "description": "Display a responsive card dashboard.",
            "icon_tooltip": "Card Dashboard",
            "value_name": "Value",
            "value_tooltip": "Drop a measure here",
            "labels_name": "Labels",
            "labels_tooltip": "Drop a dimension here"
        },
        "ja": {
            "name": "カード型ダッシュボード",
            "description": "レスポンシブルなカード型ダッシュボードを表示します。",
            "icon_tooltip": "カード型ダッシュボード",
            "value_name": "値",
            "value_tooltip": "ここにメジャーをドロップ",
            "labels_name": "ラベル",
            "labels_tooltip": "ここにディメンションをドロップ"
        }
    }
}

実行結果

card-dashboard

これを出力したWebFOCUSプログラムは以下の通りです.

GRAPH FILE baseapp/car
SUM CAR.BODY.DEALER_COST
BY CAR.ORIGIN.COUNTRY
ON GRAPH PCHOLD FORMAT JSCHART
ON GRAPH SET LOOKGRAPH EXTENSION
ON GRAPH SET AUTOFIT ON
ON GRAPH SET STYLE *
TYPE=DATA, COLUMN=N1, BUCKET= >labels, $
TYPE=DATA, COLUMN=N2, BUCKET= >value, $
*GRAPH_SCRIPT
*GRAPH_JS_FINAL
"chartType": "com.shimokado.card_dashboard",
*END
ENDSTYLE
END

色のプロパティとか無視してるからこうなりますね。

実際には、背景色とか文字サイズとか表示するグラフを選べたりすると良いと思います。

テスト用HTMLの話

用途

test.htmlは、com.shimokado.card_dashboard.jsの動作をローカル環境でテストするためのHTMLファイルです。デプロイせずにブラウザで直接確認できるため、開発中の迅速なフィードバックが得られるようになります。

各部の役割

  1. スクリプトの読み込み: com.shimokado.card_dashboard.jsと必要なライブラリ(d3.js)を読み込みます。
  2. コンテナの作成: チャートを描画するためのHTML要素(例:<div id="chart-container">)を定義します。
  3. データの提供: テスト用のデータをJavaScriptで定義し、チャート描画関数に渡します。

何をしているのか

test.htmlは、WebFOCUSの環境を模倣して、com.shimokado.card_dashboard.jsの機能をローカルでテストします。これにより、WebFOCUSにデプロイする前に、チャートの動作や表示を確認し、問題を早期に発見・修正することができます。

テスト用HTMLコード

test.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5, user-scalable=1">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <title>Card Dashboard Test</title>
    <link rel="stylesheet" href="css/style.css">
</head>
<body>
    <div class="chart" id="jschart_HOLD_0" style="vertical-align:text-bottom; overflow:hidden"></div>
    <script type="text/javascript">
        // グローバルオブジェクトの定義
        window.tdgchart = {
            extensionManager: {
                register: function(config) {
                    window.cardDashboardConfig = config;
                }
            }
        };
    </script>
    <script type="text/javascript" src="lib/d3.min.js"></script>
    <script type="text/javascript" src="com.shimokado.card_dashboard.js"></script>
    <script type="text/javascript">
        // テストデータ
        const testData = [
            ['100 LS 2 DOOR AUTO', 5063],
            ['2000 4 DOOR BERLINA', 4915],
            ['2000 GT VELOCE', 5660],
            ['2000 SPIDER VELOCE', 5660],
            ['2002 2 DOOR', 5800],
            ['2002 2 DOOR AUTO', 6000],
            ['3.0 SI 4 DOOR', 10000],
            ['3.0 SI 4 DOOR AUTO', 11000],
            ['504 4 DOOR', 4631],
            ['530I 4 DOOR', 8300],
            ['530I 4 DOOR AUTO', 8400],
            ['B210 2 DOOR AUTO', 2626],
            ['COROLLA 4 DOOR DIX AUTO', 2886],
            ['DORA 2 DOOR', 25000],
            ['INTERCEPTOR III', 14940],
            ['TR7', 4292],
            ['V12XKE AUTO', 7427],
            ['XJ12L AUTO', 11194]
        ];

        // データを整形
        const formattedData = testData.map(row => ({
            labels: row[0],
            value: row[1]
        }));

        // レンダリング設定
        const renderConfig = {
            moonbeamInstance: {
                formatNumber: (value, format) => value.toLocaleString()
            },
            properties: {
                tableStyle: {
                    fontSize: "20px",
                    color: "#663300"
                }
            },
            container: document.getElementById('jschart_HOLD_0'),
            data: formattedData,
            dataBuckets: {
                buckets: {
                    labels: {
                        title: 'MODEL',
                        fieldName: 'CAR.CARREC.MODEL'
                    },
                    value: {
                        title: 'DEALER_COST',
                        fieldName: 'CAR.BODY.DEALER_COST',
                        numberFormat: '#,###'
                    }
                }
            },
            renderComplete: () => console.log('Rendering complete')
        };

        // 拡張機能の初期化とレンダリング
        window.cardDashboardConfig.initCallback(() => {
            window.cardDashboardConfig.renderCallback(renderConfig);
        }, {});
    </script>
</body>
</html>

com.shimokado.card_dashboard.jsの内容

カード型の表示に加えて以下の3つのカードを作ります。

  1. 円グラフ(カード2枚分の幅)
  2. Value上位3件レポート
  3. 棒グラフ(カード2枚分の幅)

とは言っても、D3グラフの話に脱線してしまうので特に追加で説明することはありません。

com.shimokado.card_dashboard.js
/* Copyright (C) 2025. Shimokado Masataka. All rights reserved. */

(function() {
	/**
	 * チャートの初期化処理
	 * @param {function} successCallback - 初期化成功時に呼び出すコールバック関数
	 * @param {object} initConfig - 初期化設定
	 */
	function initCallback(successCallback, initConfig) {
		successCallback(true);
	}

	/**
	 * データがない場合の事前レンダリングコールバック
	 * @param {object} preRenderConfig - 事前レンダリング設定
	 */
	function noDataPreRenderCallback(preRenderConfig) {
	}

	/**
	 * データがない場合のレンダリングコールバック
	 * @param {object} renderConfig - レンダリング設定
	 */
	function noDataRenderCallback(renderConfig) {
	}

	/**
	 * プリレンダリングコールバック
	 * @param {object} preRenderConfig - 事前レンダリング設定
	 */
	function preRenderCallback(preRenderConfig) {
	}

	/**
	 * パイチャートの作成
	 * @param {object} data - チャートデータ
	 * @param {string} containerId - コンテナのID
	 * @param {object} chart - Moonbeamインスタンス
	 * @param {object} numberFormat - 数値フォーマット
	 */
	function createPieChart(data, containerId, chart, numberFormat) {
		const width = 200;
		const height = 200;
		const radius = Math.min(width, height) / 2;

		const color = d3.scaleOrdinal(d3.schemeCategory10);

		const svg = d3.select('#' + containerId)
			.append('svg')
			.attr('class', 'pie-chart')
			.attr('width', width)
			.attr('height', height)
			.append('g')
			.attr('transform', `translate(${width / 2},${height / 2})`);

		const pie = d3.pie()
			.value(d => d.value)
			.sort(null);

		const arc = d3.arc()
			.innerRadius(0)
			.outerRadius(radius - 20);

		const tooltip = d3.select('body').append('div')
			.attr('class', 'pie-tooltip')
			.style('opacity', 0);

		const arcs = svg.selectAll('arc')
			.data(pie(data))
			.enter()
			.append('g')
			.attr('class', 'arc');

		arcs.append('path')
			.attr('d', arc)
			.style('fill', (d, i) => color(i))
			.on('mouseover', function(event, d) {
				tooltip.transition()
					.duration(200)
					.style('opacity', .9);
				tooltip.html(d.data.labels + ': ' + chart.formatNumber(d.data.value, numberFormat))
					.style('left', (event.pageX) + 'px')
					.style('top', (event.pageY - 28) + 'px');
			})
			.on('mouseout', function(d) {
				tooltip.transition()
					.duration(500)
					.style('opacity', 0);
			});
	}

	/**
	 * 棒グラフの作成
	 * @param {object} data - チャートデータ
	 * @param {string} containerId - コンテナのID
	 * @param {object} chart - Moonbeamインスタンス
	 * @param {string} numberFormat - 数値フォーマット
	 */
	function createBarChart(data, containerId, chart, numberFormat) {
		const margin = {top: 20, right: 20, bottom: 30, left: 60};
		const width = document.getElementById(containerId).clientWidth - margin.left - margin.right;
		const height = 200 - margin.top - margin.bottom;

		const svg = d3.select('#' + containerId)
			.append('svg')
			.attr('class', 'bar-chart')
			.attr('width', width + margin.left + margin.right)
			.attr('height', height + margin.top + margin.bottom)
			.append('g')
			.attr('transform', `translate(${margin.left},${margin.top})`);

		const x = d3.scaleBand()
			.range([0, width])
			.padding(0.1);

		const y = d3.scaleLinear()
			.range([height, 0]);

		x.domain(data.map(d => d.labels));
		y.domain([0, d3.max(data, d => d.value)]);

		svg.append('g')
			.attr('class', 'bar-axis')
			.attr('transform', `translate(0,${height})`)
			.call(d3.axisBottom(x))
			.selectAll('text')
			.style('text-anchor', 'end')
			.attr('transform', 'rotate(-45)');

		svg.append('g')
			.attr('class', 'bar-axis')
			.call(d3.axisLeft(y));

		svg.selectAll('.bar')
			.data(data)
			.enter().append('rect')
			.attr('x', d => x(d.labels))
			.attr('width', x.bandwidth())
			.attr('y', d => y(d.value))
			.attr('height', d => height - y(d.value))
			.on('mouseover', function(event, d) {
				d3.select(this).attr('opacity', 0.8);
			})
			.on('mouseout', function() {
				d3.select(this).attr('opacity', 1);
			});
	}

	/**
	 * レンダリングコールバック
	 * @param {object} renderConfig - レンダリング設定
	 * @param {object} renderConfig.moonbeamInstance - Moonbeamインスタンス
	 * @param {object} renderConfig.properties - プロパティ
	 * @param {object} renderConfig.container - コンテナ
	 * @param {object} renderConfig.data - データ
	 * @param {object} renderConfig.dataBuckets - データバケット
	 * @param {object} renderConfig.dataBuckets.labels - ラベルデータバケット
	 * @param {object} renderConfig.dataBuckets.value - 値データバケット
	 * @param {function} renderConfig.renderComplete - レンダリング完了時に呼び出すコールバック関数
	 */
	function renderCallback(renderConfig) {
		var chart = renderConfig.moonbeamInstance;
		var props = renderConfig.properties;
		var container = renderConfig.container;
		var data = renderConfig.data;
		var dataBuckets = renderConfig.dataBuckets.buckets;

		// データを値の降順でソート
		data.sort(function(a, b) {
			return b.value - a.value;
		});

		// カードコンテナの作成
		var cardContainer = document.createElement('div');
		cardContainer.className = 'card-grid-container';
		container.appendChild(cardContainer);

		// パイチャートカードの作成(最初のカード)
		var pieCard = document.createElement('div');
		pieCard.className = 'data-card pie-card';
		
		var pieLabel = document.createElement('div');
		pieLabel.className = 'card-label';
		pieLabel.textContent = 'Data Distribution';
		
		var pieContainer = document.createElement('div');
		pieContainer.className = 'pie-container';
		pieContainer.id = 'pie-' + Date.now(); // ユニークなID

		pieCard.appendChild(pieLabel);
		pieCard.appendChild(pieContainer);
		cardContainer.appendChild(pieCard);

		// パイチャートの作成
		createPieChart(data, pieContainer.id, chart, dataBuckets.value.numberFormat || '###');

		 // トップ3カードの作成(2枚目のカード)
		var topCard = document.createElement('div');
		topCard.className = 'data-card top-values-card';

		var topLabel = document.createElement('div');
		topLabel.className = 'card-label';
		topLabel.textContent = 'Top 3 Values';
		topCard.appendChild(topLabel);

		var topList = document.createElement('div');
		topList.className = 'top-values-list';

		// トップ3のデータを表示
		data.slice(0, 3).forEach((row, index) => {
			var item = document.createElement('div');
			item.className = 'top-value-item';

			var rank = document.createElement('span');
			rank.className = 'top-value-rank';
			rank.textContent = '#' + (index + 1);

			var label = document.createElement('span');
			label.className = 'top-value-label';
			label.textContent = row.labels;

			var value = document.createElement('span');
			value.className = 'top-value-number';
			value.textContent = chart.formatNumber(row.value, dataBuckets.value.numberFormat || '###');

			item.appendChild(rank);
			item.appendChild(label);
			item.appendChild(value);
			topList.appendChild(item);
		});

		topCard.appendChild(topList);
		cardContainer.appendChild(topCard);

		 // 棒グラフカードの作成(3枚目のカード)
		var barCard = document.createElement('div');
		barCard.className = 'data-card bar-chart-card';
		
		var barLabel = document.createElement('div');
		barLabel.className = 'card-label';
		barLabel.textContent = 'Value Distribution';
		
		var barContainer = document.createElement('div');
		barContainer.className = 'bar-container';
		barContainer.id = 'bar-' + Date.now();

		barCard.appendChild(barLabel);
		barCard.appendChild(barContainer);
		cardContainer.appendChild(barCard);

		// 棒グラフの作成
		createBarChart(data, barContainer.id, chart, dataBuckets.value.numberFormat || '###');

		// 残りのカードの生成
		data.forEach(function(row) {
			var card = document.createElement('div');
			card.className = 'data-card';
			
			var label = document.createElement('div');
			label.className = 'card-label';
			label.textContent = row.labels;
			
			var value = document.createElement('div');
			value.className = 'card-value';
			value.textContent = chart.formatNumber(row.value, dataBuckets.value.numberFormat || '###');
			
			card.appendChild(label);
			card.appendChild(value);
			cardContainer.appendChild(card);
		});

		renderConfig.renderComplete();
	}

	var config = {
		id: 'com.shimokado.card_dashboard',	// エクステンションID
		containerType: 'html',	// コンテナタイプ(html/svg)
		initCallback: initCallback,	// 拡張機能の初期化直前に呼び出される関数への参照。必要に応じてMonbeamインスタンスを設定するために使用
		preRenderCallback: preRenderCallback,  // 拡張機能のレンダリング直前に呼び出される関数への参照。preRenderConfigオブジェクトが渡されます
		renderCallback: renderCallback,  // 実際のチャートを描画する関数への参照。renderConfigオブジェクトが渡されます
		noDataPreRenderCallback: noDataPreRenderCallback, // データがない場合のレンダリング直前に呼び出される関数への参照
		noDataRenderCallback: noDataRenderCallback, // データがない場合のチャート描画関数への参照
		resources: {
			script: ['lib/d3.min.js'], // 読み込むリソースのリスト。.jsまたは.cssファイルを指定可能
			css: ['css/style.css'] // 読み込むリソースのリスト。.jsまたは.cssファイルを指定可能
		},
		modules: {
			dataSelection: {
				supported: true,  // データ選択を有効にする場合はtrueに設定
				needSVGEventPanel: true, // HTMLコンテナを使用するか、SVGコンテナを変更する場合はtrueに設定
				svgNode: function(arg){}  // HTMLコンテナを使用するか、SVGコンテナを変更する場合、ルートSVGノードへの参照を返す
			},
			eventHandler: {
				supported: true // イベントハンドラを有効にする場合はtrueに設定
			},
			tooltip: {
				supported: true,  // HTMLツールチップを有効にする場合はtrueに設定
				// デフォルトのツールチップコンテンツが渡されない場合に呼び出されるコールバック
				// 指定されたターゲット、ID、データに対して適切なデフォルトツールチップを定義
				// 戻り値は文字列(HTMLを含む)、HTMLノード、またはMoonbeamツールチップAPIオブジェクト
				autoContent: function(target, s, g, d) {
					return d.labels + ': ' + d.value; // 単純な文字列を返す
				}
			}
		}
	};
	// エクステンションをtdgchartエクステンションマネージャに登録
	tdgchart.extensionManager.register(config);
}());
style.css
/* カードグリッドコンテナ */
.card-grid-container {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
    gap: 1.5rem;
    padding: 1.5rem;
    width: 100%;
    background: #fff5f5;
}

/* カード */
.data-card {
    background: linear-gradient(135deg, #fff, #ffe4e4);
    border-radius: 12px;
    box-shadow: 0 4px 15px rgba(220, 20, 60, 0.1);
    padding: 2rem;
    display: flex;
    flex-direction: column;
    align-items: center;
    transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
    min-height: 150px;
    border: 1px solid rgba(220, 20, 60, 0.1);
}

/* カードホバー効果 */
.data-card:hover {
    transform: translateY(-8px) scale(1.02);
    box-shadow: 0 8px 25px rgba(220, 20, 60, 0.2);
    background: linear-gradient(135deg, #fff, #ffd5d5);
}

/* ラベル */
.card-label {
    font-size: 1rem;
    color: #dc143c;
    margin-bottom: 1.2rem;
    text-align: center;
    width: 100%;
    font-weight: 500;
    text-transform: uppercase;
    letter-spacing: 0.5px;
}

/* 値 */
.card-value {
    font-size: 2.5rem;
    font-weight: bold;
    background: linear-gradient(45deg, #dc143c, #ff4d6d);
    -webkit-background-clip: text;
    background-clip: text;
    color: transparent;
    text-align: center;
    text-shadow: 2px 2px 4px rgba(220, 20, 60, 0.1);
    transition: all 0.3s ease;
}

/* 値のホバー効果 */
.data-card:hover .card-value {
    transform: scale(1.1);
}

/* パイチャートカード特別設定 */
.pie-card {
    min-height: 300px;
    grid-column: span 2;
}

.pie-container {
    width: 100%;
    height: 200px;
    margin-top: 1rem;
    display: flex;
    justify-content: center;
}

.pie-chart {
    max-width: 100%;
    height: auto;
}

/* パイチャートのツールチップ */
.pie-tooltip {
    position: absolute;
    padding: 8px;
    background: rgba(255, 255, 255, 0.9);
    border: 1px solid rgba(220, 20, 60, 0.2);
    border-radius: 4px;
    pointer-events: none;
    font-size: 0.875rem;
    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

/* トップ値カード特別設定 */
.top-values-card {
    grid-column: span 2;
}

.top-values-list {
    width: 100%;
    margin-top: 1rem;
}

.top-value-item {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 0.5rem;
    margin: 0.5rem 0;
    background: rgba(255, 255, 255, 0.6);
    border-radius: 8px;
    transition: all 0.3s ease;
}

.top-value-item:hover {
    background: rgba(255, 255, 255, 0.8);
    transform: translateX(5px);
}

.top-value-rank {
    font-weight: bold;
    color: #dc143c;
    margin-right: 1rem;
}

.top-value-label {
    flex-grow: 1;
    color: #333;
}

.top-value-number {
    font-weight: bold;
    background: linear-gradient(45deg, #dc143c, #ff4d6d);
    -webkit-background-clip: text;
    background-clip: text;
    color: transparent;
}

/* 棒グラフカード特別設定 */
.bar-chart-card {
    grid-column: span 2;
    min-height: 300px;
}

.bar-container {
    width: 100%;
    height: 200px;
    margin-top: 1rem;
}

.bar-chart rect {
    fill: #dc143c;
    transition: all 0.3s ease;
}

.bar-chart rect:hover {
    fill: #ff4d6d;
}

.bar-chart text {
    font-size: 12px;
    fill: #333;
}

.bar-axis path,
.bar-axis line {
    stroke: #999;
}

まとめ

提供されているグラフを利用しても良いのですが、フィットするものが無かったりします。

D3を使ったグラフを自分で作ったり、自由なHTML記述の出力が出来ると表現の幅が広がります。

WebFOCUSをAPIとしてCSVやJSONを出力させてグラフやレポートを表示するのも良いですが、EUCツールとしての良さが発揮できません。

利用者が簡単な操作でこのようなレポートを気持ちよく作成できれば、さらなる情報活用にも期待できますね!

Discussion