【第2回】「JSONファイルだけで動く」4階層ドリルダウンUI — PHP + Highcharts + Swiper のミニマル構成
第1回のおさらい
第1回では、複数拠点に設置したCOMET T6540センサーから Zabbix → JSON ファイル → さくらレンタルサーバ へとデータを流し込むパイプラインを構築しました。
設計の核心は、「データ収集サーバー(Zabbix)を内部ネットワークに閉じ込めたまま、JSONファイルだけをさくらレンタルサーバーに渡す」 という責務分離。フロントエンド公開のセキュリティは共用ホスティング側に任せ、データ収集側は社内に守られる構造的な防御です。
第2回では、その JSON を受け取って表示する側、つまり 「事業所選択 → フロア選択 → エリアマップ → 各測定ポイントのグラフ」 という4階層ドリルダウンUI の実装を掘り下げます。技術スタックは敢えてシンプルに、PHP + jQuery + Highcharts + Swiper の組み合わせです。
なお続編の 第3回 では、この PHP モノリス構成を維持基盤(さくらレンタル共用プラン)ごと引き継いだまま React + PHP API 構成へモダン化する設計を扱います。
本記事は、筆者が所属するクイックイタレート株式会社で構築・運用しているオフィス環境モニタリングシステムをベースにした事例紹介シリーズの第2回です。同種の構成は弊社公開事例 温湿度CO2の測定と直感的なUIの開発 でも紹介しています。
なぜモダンなSPAフレームワークではなく、あえて PHP + jQuery なのか?理由は明快で、
- さくらレンタルサーバーで動くこと が第一要件
- 5分粒度のデータ であって、リアルタイム性は不要(WebSocketは過剰)
- 保守する人が将来変わっても読める スタックを選びたい
- JSONさえ正しい場所に置けば動く、シンプルな疎結合を保ちたい
からです。「枯れた技術の正しい組み合わせ」がベストな選択になる、典型的なケースです。
全体構成のおさらい
センサー側から見ると、フロントエンドは
「JSONファイルを受け取って表示するだけのレイヤー」です。
センサー側とフロント側の 唯一の契約 は、JSONファイルの 配置場所・命名規則・スキーマ だけです。これさえ守られていれば、片方を改修しても影響は伝播しません。
JSONフォーマット仕様(フロントとバックの契約)
設計の核心は「JSONフォーマットだけがフロントとバックの唯一の契約」 であること。命名規則とスキーマさえ守られていれば、収集側は Zabbix でも自社実装でも何でも構いませんし、フロント側を将来 React に作り直しても影響は伝播しません。
疎結合を強制するインターフェースとして、ファイルシステム上のJSONが機能している のがこの構成の肝です。
ファイル配置
| 場所 | 用途 |
|---|---|
environment_data/environmental_data_YYYYMMDD_HHMM00.json |
5分刻みのアーカイブ |
tmp/environmental_data_new.json |
最新スナップショット(PHP側で自動コピー) |
ファイル名規則
environmental_data_YYYYMMDD_HHMM00.json
- 秒は常に
00固定 - 分は
00, 05, 10, 15, ..., 55のいずれか - 1時間グラフは12ファイル、24時間グラフは24ファイル(毎時00分のもの)を取得
JSON本体
トップレベルは6拠点をキーに持つオブジェクトで、値は測定レコードの配列です:
{
"hokkaido": [],
"tohoku": [],
"tokyo": [
{
"data_area_name": "36F_C1",
"data_date": 1745059500,
"temperature": 23.27,
"humidity": 33.96,
"co2": 482.33
},
{
"data_area_name": "36F_C2",
"data_date": 1745059500,
"temperature": 22.97,
"humidity": 33.60,
"co2": 480.55
}
],
"nagoya": [],
"osaka": [],
"fukuoka": []
}
各フィールドの仕様:
| フィールド | 型 | 説明 |
|---|---|---|
| トップレベルキー | string | 拠点コード。hokkaido / tohoku / tokyo / nagoya / osaka / fukuoka の6種 |
data_area_name |
string | 測定ポイント識別子。書式 {36|37|38}F_{A-F}{1-4}(例:36F_C1 = 36階Cエリア1番) |
data_date |
number | Unixタイムスタンプ(秒単位、ミリ秒ではない) |
temperature |
number | 温度(°C) |
humidity |
number | 相対湿度(%RH) |
co2 |
number | CO2濃度(ppm) |
1拠点あたり最大 3フロア × 6エリア × 4ポイント = 72ポイント を収容できます。
data_area_name がそのまま HTML側のCSSクラス名 として使われるのが、この設計の小さな工夫です。後述しますが、これによってJS側のDOM操作が驚くほどシンプルになります。
4階層ドリルダウンUI
ユーザーは以下の4階層を辿ります:
各階層の実装ポイントを順に見ていきます。
[1] 事業所選択(office.php)
setting/setting.php に定義された $area_array を foreach で回してカード表示するだけ。
<?php foreach ($area_array as $area) : ?>
<a href="floor.php?area=<?= h($area['key']) ?>">
<div class="office-card">
<img src="img/map/<?= h($area['key']) ?>.png">
<h2><?= h($area['display_name']) ?></h2>
<p><?= h($area['description']) ?></p>
</div>
</a>
<?php endforeach; ?>
$area_array に1要素追加すれば、自動的にカードが1枚増えます。
「設定駆動」 な実装にしておくのは、地味ですが効きます。
[2] フロア選択(floor.php)
選択された拠点(?area=tokyo)について、$floor_array1(36F/37F/38F)をタブとして展開。これも配列駆動。
[3] エリアマップ(area.php):信号機式CO2可視化の本丸
ここが本システムのハイライトです。フロア図の上に各測定ポイントを「丸印」で配置し、CO2濃度に応じて4色で塗り分けます。
PHP側でのHTML生成は、フロア・エリアの組み合わせから測定ポイント識別子(36F_C1 など)を組み立て、それを そのままCSSクラス名として使う のがポイントです。
// area.php より抜粋(HTML生成部)
for ($i = 1; $i <= $floorarea_num; $i++) {
// クラス名 = "{フロア番号}F_{エリア記号}{ポイント番号}"
// 例: "36F_C1" "36F_C2" ... "38F_F4"
$point_class = $floor_data[1] . 'F_' . $floorarea_title . $i;
$floor_map_inner .= '
<div class="' . $point_class . ' area_right_box_container">
<div class="container_maru">
<span class="maru"></span> <!-- 色判定用の丸 -->
</div>
<div class="container_num">
<span class="co2">―</span><span class="small">ppm</span>
</div>
<div><span class="temperature bold">―</span><span class="small">℃</span></div>
<div><span class="humidity bold">―</span><span class="small">%</span></div>
<div><span class="data_date bold">―</span></div>
</div>
';
}
生成されるHTMLはこんな形です。
<div class="36F_C1 area_right_box_container">
<span class="maru"></span>
<span class="co2">―</span> ppm
<span class="temperature">―</span> ℃
<span class="humidity">―</span> %
</div>
ここで重要なのは、JSON側の "data_area_name": "36F_C1" と CSSクラス .36F_C1 が完全一致する ように設計されていること。これによってJS側の処理がここまで簡潔になります。
function applyFloorAreaValues(data, areaName) {
// data["tokyo"] のような形でアクセス
$.each(data[areaName], function(idx, rec) {
var $el = $('.' + rec.data_area_name); // ".36F_C1" を直接取得
if ($el.length === 0) return;
// 値を表示
$el.find('.temp').text(rec.temperature.toFixed(1) + '°C');
$el.find('.humid').text(rec.humidity.toFixed(1) + '%');
$el.find('.co2').text(Math.round(rec.co2) + 'ppm');
// 色判定(信号機式)
$el.removeClass('maru_green maru_yellow maru_orange maru_red');
var co2 = rec.co2;
if (co2 <= 600) $el.addClass('maru_green');
else if (co2 <= 1000) $el.addClass('maru_yellow');
else if (co2 <= 1500) $el.addClass('maru_orange');
else $el.addClass('maru_red');
});
}
色判定の閾値は、事務所衛生基準規則・ビル管理法・学校環境衛生基準 を参考に
| 色 | CO2 (ppm) | 状態 |
|---|---|---|
| 🟢 緑 | 〜600 | 良好 |
| 🟡 黄 | 〜1000 | 注意(学校基準上限) |
| 🟠 橙 | 〜1500 | 換気推奨 |
| 🔴 赤 | 1500超 | 即時換気 |
CSSは border-radius: 50% で円を作るだけ。
.point { display: inline-block; width: 40px; height: 40px;
border-radius: 50%; border: 2px solid #333; }
.maru_green { background: #4caf50; }
.maru_yellow { background: #ffeb3b; }
.maru_orange { background: #ff9800; }
.maru_red { background: #f44336; animation: blink 1.5s infinite; }
data_area_name をそのままクラス名に流用する設計 が、この「JSONを取ってきて、対応する場所に値と色を流し込む」処理を、たった十数行のJSで実現させてくれます。
[3.5] Swiperでエリア間スワイプ
1フロアあたり最大6エリア(A〜F)あるため、PCではタブ、スマホでは横スワイプで切り替えられるよう Swiper を使います。
new Swiper('.swiper-container', {
slidesPerView: 1,
pagination: { el: '.swiper-pagination', clickable: true },
navigation: { nextEl: '.swiper-button-next', prevEl: '.swiper-button-prev' },
loop: false,
});
これで、タブレットを片手に持って現場を歩きながら見る、という運用にも自然に対応できます。
[4] グラフ表示(graph.php):Highchartsで2軸折れ線
各測定ポイントをタップすると、
1時間ビュー(5分刻み12点)と24時間ビュー(1時間刻み24点)
の切替可能な時系列グラフへ遷移します。
Highchartsで温度(左軸)と湿度(右軸)の2軸折れ線、そしてCO2の単独グラフを描画。
Highcharts.chart('chart-temp-humid', {
chart: { type: 'spline' },
title: { text: '温度・湿度(直近1時間)' },
xAxis: { categories: timeLabels },
yAxis: [
{ title: { text: '温度 (°C)' } },
{ title: { text: '湿度 (%)' }, opposite: true }
],
series: [
{ name: '温度', data: tempArr, yAxis: 0, color: '#e91e63' },
{ name: '湿度', data: humidArr, yAxis: 1, color: '#2196f3' }
]
});
Highchartsの商用利用には有償ライセンスが必要です。
グラフ描画の肝:欠損値の前後平均補間
5分周期でデータが取れているとはいえ、ネットワーク瞬断やZabbixの一時停止 などで
「ある時刻だけJSONが存在しない/値がnull」というケースは必ず起きます。
そのまま描画すると線が途切れてしまうので、null値を 前後の値の平均で補間 します。
function interpolation_null(arr) {
for (var i = 0; i < arr.length; i++) {
if (arr[i] !== null) continue;
// 前の有効値を探す
var prev = null;
for (var j = i - 1; j >= 0; j--) {
if (arr[j] !== null) { prev = arr[j]; break; }
}
// 後の有効値を探す
var next = null;
for (var k = i + 1; k < arr.length; k++) {
if (arr[k] !== null) { next = arr[k]; break; }
}
if (prev !== null && next !== null) {
arr[i] = (prev + next) / 2;
} else if (prev !== null) {
arr[i] = prev;
} else if (next !== null) {
arr[i] = next;
}
// 両方nullなら諦める(連続欠損は補間しない)
}
return arr;
}
「連続欠損は補間しない」のが小さなこだわりです。1点だけの欠損は補間してきれいな線を見せて、長時間欠損は素直に「データなし」を示す、という方針。
可視化の信頼性を保つ上で地味に重要です。
5分間隔の自動リフレッシュ
ブラウザを開きっぱなしにしても、5分ごとに最新データに更新したい。
だが「5分タイマー」を素朴に組むと、ページを19:02に開くと19:07に更新…
という気持ちの悪い挙動になります。
そこで、毎分現在時刻を見て、分が5の倍数になった瞬間だけ更新 する方式を取ります。
setInterval(function() {
var now = new Date();
if (now.getMinutes() % 5 === 0 && now.getSeconds() < 10) {
fetchAndApplyLatest();
}
}, 1000);
これで、JSONがアップロードされた直後(19:00:00, 19:05:00, ...)に確実に取りに行く動作になります。ファイル生成の時刻と画面更新の時刻が同期する のは、運用上の納得感に直結します。
PHP実装の核心:ファイルベース設計を支える4つの工夫
JSONファイルを契約としてフロントとバックを疎結合にした本システムですが、PHP側にも 「DBなし・外部ライブラリ最小・ファイルだけで完結」 という設計思想を貫くための工夫が詰まっています。
この思想が効いてくる場面は明確で、
- 共用ホスティングで動く可搬性:MySQL等のDBが使えない環境でも稼働
- ビルドステップ不要:FTPで上げるだけでデプロイ完了
- 保守者が変わっても読める:素のPHPと素のJSだけで構成
ここでは、その思想を支える特に効いている4つのテクニックを紹介します。
工夫1:階層をPHPの多次元配列に閉じ込める
「拠点 → フロア → エリア → ポイント」という4階層は、RDBMSではなく PHPの多次元配列 として setting/setting.php に定義しています。
配列の最後の要素に下の階層の配列そのものをぶら下げる のがミソです。
// setting/setting.php より抜粋
// エリア(A〜F)定義
// [表示名, パラメータ, アクティブclass, 幅style, 背景色, ポイント数]
$floorarea_array1 = array(
array("A","a","now_floorarea","width:33.33%;","background:#DC515B","4"),
array("B","b","", "width:33.33%;","background:#0E8240","4"),
array("C","c","", "width:33.33%;","background:#2B6BAC","4"),
// ...
);
// フロア定義(最後に上記エリア配列を丸ごと持たせる)
$floor_array1 = array(
array("36F","36","now_floor", $floorarea_array1),
array("37F","37","", $floorarea_array1),
array("38F","38","", $floorarea_array1),
);
// 拠点定義(最後に上記フロア配列を丸ごと持たせる)
$area_array = array(
array("北海道","HOKKAIDO","hokkaidou","office_hover","北海道","","支社", $floor_array1),
array("東京本社","TOKYO","tokyo", "area_select","東京","新宿","本社", $floor_array1),
// ...
);
ドリルダウン時には foreach($area_data[7] as $floor_data) のように、最後の要素を辿るだけで自然に下の階層に降りられる 設計です。階層構造をDBの中間テーブルで表現する代わりに、配列のネストで表現してしまう割り切り。オフィス監視のように 階層数が固定(4層)で動的変更が稀 な用途では、このシンプルさが効いてきます。
新しい拠点・フロア・エリアを追加するときも、対応する配列に1要素追加するだけ。
マスタDBもマイグレーションも不要です。
工夫2:URLクエリ=パンくず、それをそのままドリルダウン解決に使う
階層を解決するURLは area.php?area=tokyo&floor=36&floorarea=c のように、左から順にパンくず的に並んで います。PHP側はこのクエリを順番に検査して、ヒットした階層の配列を次のループ変数に差し替えていく定型パターンを取ります。
// area.php より抜粋
// 拠点を解決
if (array_key_exists("area", $_GET) && $_GET["area"] != "") {
foreach ($area_array as $area_data) {
if ($area_data[2] == $_GET["area"]) {
$area_name = $area_data[2];
$floor_array = $area_data[7]; // 次の階層を取り出す
}
}
}
// フロアを解決
if (array_key_exists("floor", $_GET) && $_GET["floor"] != "") {
foreach ($floor_array as $floor_data) {
if ($floor_data[1] == $_GET["floor"]) {
$floor_main = $floor_data[0];
$floorarea_array = $floor_data[3]; // さらに次の階層へ
}
}
}
// エリアを解決
if (array_key_exists("floorarea", $_GET) && $_GET["floorarea"] != "") {
foreach ($floorarea_array as $floorarea_data) {
if ($floorarea_data[1] == $_GET["floorarea"]) {
$floorarea_json = $floorarea_data[0];
$floorarea_color = $floorarea_data[4];
$floorarea_num = $floorarea_data[5];
}
}
}
URLを見ればどの階層にいるかが一目瞭然で、PHP側もそれを順に解決するだけ。
ルーティングライブラリも MVCフレームワークも不要で、リクエストパラメータ駆動で動く素直な設計です。URLが仕様書を兼ねる、と言ってもいいかもしれません。
工夫3:最新JSONファイルの自動検出とスナップショット生成
センサー側は environment_data/ に5分ごとに新しいJSONを書き込み続けますが、画面表示用に必要なのは 「いま現在の最新の1ファイル」 だけ。
これを conf/103_head.php が 各リクエストのたびに filemtime を線形比較して 解決し、
固定パス tmp/environmental_data_new.json にコピーします。
// conf/103_head.php より抜粋
$this_target_dir = $json_dir; // "environment_data"
$new_target_file = $fixd_filename . "new.json"; // "environmental_data_new.json"
$new_fullpath = $tmp_path . $new_target_file;
if (file_exists($full_dir . $this_target_dir)) {
if (!file_exists($new_fullpath)) {
touch($new_fullpath);
}
// environment_data/*.json を全件取得
$files = glob($full_dir . $this_target_dir . "/*.json");
$new = 0; $NewFile = "";
foreach ($files as $dat) {
$t = filemtime($dat);
if ($t > $new) {
$new = $t;
$NewFile = $dat;
}
}
if ($NewFile != "") {
copy($NewFile, $new_fullpath);
}
}
これによって、ブラウザ側は常に 固定パス tmp/environmental_data_new.json を叩けば最新値が得られる。JSは時刻計算を意識する必要がなく、過去データ参照(グラフ描画)のときだけJS側がファイル名を組み立てます。
「動的にディレクトリを舐めて最新を見つけてコピーする」という処理は一見泥臭いですが、ファイルが数千個程度までなら現実的に十分速く(一晩で288個、1ヶ月でも約8,640個)、運用上の問題はまず出ません。inotify でイベント駆動にしたりせず、リクエスト駆動で完結させる潔さもこの設計の魅力です。
工夫4:PHP→JavaScript への変数受け渡し(古典的SSR)
PHPで解決した階層情報(拠点コード・フロア番号・エリア配列・時間単位など)は、<head> 内で インラインscriptに埋め込んでJSのグローバル変数として 渡しています。
// conf/103_head.php より抜粋
?>
<script>
// json.js から参照されるグローバル変数群
area_name = "<?php echo $area_name; ?>"; // 拠点コード(JSONトップレベルキー)
let type_flg = "<?php echo $page_type; ?>"; // "floorarea" / "graph"
let target_floor = "<?php echo $floor_main; ?>"; // "36F"
let target_area = "<?php echo $floorarea_json; ?>"; // "C"
let jsonurl = "<?php echo $jsonurl; ?>"; // JSONベースURL
let fixd_filename = "<?php echo $fixd_filename; ?>"; // "environmental_data_"
let json_dir = "<?php echo $json_dir; ?>"; // "environment_data"
let defo_m = <?php echo $defo_m; ?>; // 5(分間隔)
let be_h = <?php echo $be_h; ?>; // 24(日軸時間数)
</script>
setting.php の値を変更すれば、PHP側だけでなく JS側にも自動で反映される 流れになっています。APIエンドポイントを別途用意せず「SSRでJSへ変数注入する」古典的パターンですが、設定値の単一情報源(Single Source of Truth)が setting.php 1ファイルに集約され、ビルドステップ不要で動く という、地味ながら実用的なテクニックです。
設計思想として一貫しているのは「DBなし・ビルドなし・依存最小・ファイルだけで完結」というミニマリズム。これによって、共用ホスティングのようなFTPで上げるだけの環境でも動く、可搬性の高いシステムが組み上がっています。
拡張余地
このシンプルな構成は、将来の拡張にも素直に応えてくれます。
-
拠点追加:
$area_arrayに1要素 + 画像配置のみ - センサー追加:気圧(hPa)や VOC など、JSONに新フィールドを追加するだけ
-
API化:JSONそのものがAPIなので、別アプリから
fetch('/environment_data/...')で再利用可能 - Grafana連携:Zabbixデータソースをそのまま使えば、ダッシュボード派にも対応
- アラート:閾値超過時のメール通知はZabbix側に任せれば、フロント実装は不要
第2回まとめ
第2回では、Zabbixが生成したJSONを受け取って表示する PHP + Highcharts + Swiper ベースのフロントエンドを見てきました。
設計上の重要なポイント
- JSONファイルだけがフロントとバックの契約 ── 命名規則とスキーマさえ守れば疎結合
-
data_area_nameを HTMLクラス名に流用 ── DOM操作が劇的にシンプルになる - CO2を信号機式4色 で可視化 ── 数字より直感的、衛生基準にも準拠
- null値の前後平均補間(連続欠損は除く) ── 信頼性と見た目を両立
- 5分単位の自動リフレッシュ ── ファイル生成タイミングと画面更新を同期
- 階層を多次元配列で表現+URLクエリでドリルダウン解決 ── DB不要・ルーティング不要
- 103_head.php がリクエスト駆動で最新JSONを検出 ── ブラウザ側は固定パスを叩くだけ
-
PHP→JS の変数受け渡しは古典的SSR ── 設定値の単一情報源を
setting.phpに集約
役割の整理
本システムでは、それぞれのレイヤーが「自分の専門領域だけ」に集中している 構造が一貫しています。
| 担う層 | 利用者 | 何に専念しているか |
|---|---|---|
| 一般社員 | さくら上のWebダッシュボード | 自分のフロアの状況を見るだけ |
| 現地管理者 | 内部Zabbix UI | 閾値調整・アラート対応 |
| 開発者 | 全レイヤー | データ収集パイプライン・フロント保守 |
| COMET T6540 | — | 値を出すだけ |
| Zabbix | — | 蓄積・アラート・現地管理者UIの提供だけ |
| 整形スクリプト | — | JSON生成とアップロードだけ |
| さくらレンタルサーバ | — | 公開HTTPSとファイル配信だけ |
| フロントPHP/JS | — | JSONを受け取って表示するだけ |
構造的な優位性
センサー〜可視化までの全体を通じて、「枯れた技術の正しい組み合わせ」と「責務の分離」、そして「ファイルベースで完結させるミニマリズム」 の3点が本構成の核心です。
これによって、
- 商用クラウド並みのUX を月額数百円のレンタルサーバー上で実現
- データ収集サーバーが攻撃面に晒されない 構造的安全性
- 拠点追加が配列1要素 + 画像配置だけ という拡張容易性
- DBもビルドツールもフレームワークも入れず、PHPと素のJavaScriptだけで4階層UIを成立
を同時に達成しています。
「自社のオフィスでも環境可視化を入れたいが、商用SaaSは高い、自前のVPSは運用が重い」──そんな状況にある方の参考になれば幸いです。
続く 第3回では、本記事で作った PHP モノリス + jQuery 構成 を、「さくらレンタル共用プラン」という基盤は一切変えずに React + PHP API 構成へモダン化した経緯と設計判断を扱います。本記事で確立した JSON 契約をインターフェースとして残したまま、フロントだけを別技術スタックに置き換える 流れをご覧いただけます。
→次回:【#3】オフィスCO2監視を「内部 Zabbix × 公開フロント」に分離する — COMET T6540 × Zabbix × さくらレンタルサーバの構成
関連記事
関連リンク
本記事は、筆者が所属するクイックイタレート株式会社での開発実績をもとに執筆しています。関連する公開事例は casestudy/201:温湿度CO2の測定と直感的なUIの開発 を、その他の事例は 事例一覧 をご覧ください。

