2024年12月16日月曜日

Oracle APEXのアプリケーションにKepler.glを組み込む

Oracle APEXのアプリケーションにKepler.glを組み込んでみます。Kepler.glは、Uberが開発したオープンソースの位置情報データ可視化ツールです。

Kepler.glはReactのコンポーネントとして開発されていますが、GitHubのリポジトリのexamplesumd-clientの実装が含まれています。

上記のumd-clientindex.htmlを参考にして、Kepler.glのOracle APEXへの組み込みを行います。

Kepler.glに表示させるデータセットは、データベースへ保存できるようにします。また、データベースに保存したデータセットを表示できるようにします。これらの実装はAPI ReferenceのAdvanced usagesのSaving and Loading Maps with Schema Managerのセクションを参考に実装します。

作成したAPEXアプリケーションは以下のように動作します。データベースにロードしているサンプル・データは以下より取得しています。
https://github.com/uber-web/kepler.gl-data


最初にKepler.glで扱うデータセットを保存する表KEPLER_DATASETSを作成します。以下のDDLを実行します。列CONFIGにはKeplerGLSchema.getCofigToSave()を呼び出して得られる値、列DATASETにはKeplerGLSchema.getDatasetToSave()を呼び出して得られる値を保存します。
create table kepler_datasets (
    id         number generated by default on null as identity
               constraint kepler_datasets_id_pk primary key,
    name       varchar2(80 char) not null,
    config     clob check (config is json),
    dataset    clob check (dataset is json)
);
空白のページをベースにKepler.glを組み込みます。以下よりページ番号を2として説明を進めます。ページ番号が異なる場合は、P2といった接頭辞を置き換えてください。

ボタンは3つ作成します。ボタンLOADにより、ページ・アイテムP2_NAMEで指定したデータセットをKepler.glに読み込みます。ボタンSAVEはKepler.glで扱っているデータセットをデータベースに保存します。同名のデータセットがある場合は上書きします。ボタンDELETEで保存されているデータセットを削除します。ページ遷移が発生するとKepler.glは初期化されるため、これらはすべてページ遷移が発生しない動的アクションとして実装します(DELETEは例外で、Kepler.glを初期化するためJavaScript中でページ遷移を呼び出しています)。


ページ・プロパティJavaScriptファイルURLに以下を記述します。umd-clientのindex.htmlに準じていますが、バージョンについては新しいものを選んでいます。
https://unpkg.com/react@18.3.1/umd/react.production.min.js
https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js
https://unpkg.com/redux@4.2.1/dist/redux.js
https://unpkg.com/react-redux@8.1.3/dist/react-redux.min.js
https://unpkg.com/styled-components@6.1.13/dist/styled-components.min.js
https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js
https://unpkg.com/kepler.gl@3.1.0-alpha.1/umd/keplergl.min.js
Kepler.glの3.0以降はMapBoxの代わりにMapLibreを使います。そのため、MapLibreのライブラリをロード対象に含めています。


ファンクションおよびグローバル変数の宣言に以下を記述します。概ねindex.htmlのscript要素に記述されているコードと同じですが、MapBox関連のコードを削除しています。また、Kepler.glを表示する幅と高さは、APEXのリージョンに収まるように調整しています。

/* LOADとSAVE時に画面操作をブロックする。 */
var spinner;
/*
* Original: https://github.com/keplergl/kepler.gl/blob/master/examples/umd-client/index.html
* KeplerGl 3.0以降はMapLibreがデフォルトなので、MapBox関連のコードは削除した。
*/
/** STORE **/
const reducers = (function createReducers(redux, keplerGl) {
return redux.combineReducers({
// mount keplerGl reducer
keplerGl: keplerGl.keplerGlReducer
});
})(Redux, KeplerGl);
const middleWares = (function createMiddlewares(keplerGl) {
return keplerGl.enhanceReduxMiddleware([
// Add other middlewares here
]);
})(KeplerGl);
const enhancers = (function craeteEnhancers(redux, middles) {
return redux.applyMiddleware(...middles);
})(Redux, middleWares);
const store = (function createStore(redux, enhancers) {
const initialState = {};
return redux.createStore(reducers, initialState, redux.compose(enhancers));
})(Redux, enhancers);
/** END STORE **/
/** COMPONENTS **/
const KeplerElement = (function (react, keplerGl) {
return function () {
const element = document.getElementById('app');
/*
* KeplerGlの高さは、現時点ではコンテンツがないためheightが0になる。
* そのため、ヘッダー、フッター、アイテム・コンテナの高さを
* 180pxとして、innerHeightから減らした値を高さとしている。
*/
const props = element.getBoundingClientRect();
if ( props.height === 0 ) {
props.height = window.innerHeight - 180;
}
return react.createElement(
'div',
props,
react.createElement(keplerGl.KeplerGl, {
id: 'map',
width: props.width,
height: props.height
})
);
};
})(React, KeplerGl);
const app = (function createReactReduxProvider(react, reactRedux, KeplerElement) {
return react.createElement(
reactRedux.Provider,
{ store },
react.createElement(KeplerElement, null)
);
})(React, ReactRedux, KeplerElement);
/** END COMPONENTS **/


ページ・ロード時に実行に以下を記述します。Kepler.glであるReactコンポーネントを、IDappのDIV要素に描画します。

/*
* Original: https://github.com/keplergl/kepler.gl/blob/master/examples/umd-client/index.html
*/
/** Render **/
(function render(react, reactDOM, app) {
const container = document.getElementById('app');
const root = reactDOM.createRoot(container);
root.render(app);
})(React, ReactDOM, app);
/*
* 以下のカスタマイズ用のコードは未使用。
*/
/**
* Customize map.
* Interact with map store to customize data and behavior
*/
(function customize(keplerGl, store) {
// store.dispatch(keplerGl.toggleSplitMap());
})(KeplerGl, store);


CSSファイルURLに以下を記述します。
https://d1a3f4spazzrp4.cloudfront.net/kepler.gl/uber-fonts/4.0.0/superfine.css
https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css
https://unpkg.com/kepler.gl@3.1.0-alpha.1/umd/keplergl.min.css


Kepler.glを描画する静的コンテンツのリージョンを作成します。ソースHTMLコードに以下を記述します。

<div id="app"></div>

余計な装飾を省くために、外観テンプレートとしてBlank with Attributes(No Grid)を選択します。


以上でKepler.glがページに描画され、単体で使えるようになります。

これから、Kepler.glとデータベースを連携させる実装を追加します。

Kepler.glが扱う構成データとデータセットを、それぞれ保持するページ・アイテムを作成します。構成データはP2_CONFIG、データセットはP2_DATASETに保持します。ブラウザのJavaScriptからデータベースにこれらの値を送信する時、逆にデータベース・サーバーから取り出した値をブラウザに送信する時に、これらのページ・アイテムに値を保存します。

これらのページ・アイテムのタイプ非表示とします。動的アクションで値を設定するため、設定保護された値オフにします。セッション・ステートデータ型CLOBストレージリクエストごと(メモリーのみ)を選択します。


ボタンLOADの動的アクションとして、以下の処理が行われます。

最初のTRUEアクションで画面にスピナーを表示させ、画面操作のブロックを開始します。以下のJavaScriptを実行します。
/* スピナーを開始し、画面操作をブロックする */
spinner = apex.widget.waitPopup();

続いてボタンLOAD無効化します。


サーバー側のコードとして以下を実行し、ページ・アイテムP2_NAMEで指定された名前の構成データとデータセットを、ページ・アイテムP2_CONFIGP2_DATASETに取り出します。データを取り出すまで後続の処理を待たせるため、結果を待機オンにします。
select config, dataset into :P2_CONFIG, :P2_DATASET
from kepler_datasets where name = :P2_NAME;
送信するアイテムとしてP2_NAME戻すアイテムとしてP2_CONFIGP2_DATASETを指定します。


ページ・アイテムP2_CONFIGP2_DATASETに読み込んだデータを、Kepler.glに渡します。以下のJavaScriptコードを実行します。

/*
* データベースから取り出したconfigとdatasetsをKeplerGlに適用する。
*/
const KeplerGlSchema = KeplerGl.KeplerGlSchema;
const savedDatasets = JSON.parse(apex.item("P2_DATASET").getValue());
const savedConfig = JSON.parse(apex.item("P2_CONFIG").getValue());
const mapToLoad = KeplerGlSchema.load(savedDatasets, savedConfig);
const data = KeplerGl.addDataToMap(mapToLoad);
store.dispatch(data);
/*
* ボタンLOADを有効化する。
* 動的アクションで無効化しているので、CSSクラスを削除する必要がある。
*/
const loadButton = document.getElementById("B_LOAD");
loadButton.classList.remove("is-disabled","apex_disabled");
loadButton.disabled = false;
/* スピナーを削除 */
spinner.remove();


データベースからKepler.glへの、データセットのロード処理の実装は以上です。

ボタンSAVEで実行されるTRUEアクションは、JavaScriptコードの実行のみです。以下のコードを実行します。

/*
* スピナーを開始し、画面操作をブロックする。
*/
spinner = apex.widget.waitPopup();
/*
* ボタンSAVEを無効化する。
*/
const saveButton = document.getElementById("B_SAVE");
saveButton.disabled = true;
/*
* KeplerGlからマップのconfigとdatasetsを取り出す。
*/
const KeplerGlSchema = KeplerGl.KeplerGlSchema;
const state = store.getState().keplerGl.map;
const dataToSave = JSON.stringify(KeplerGlSchema.getDatasetToSave(state));
const configToSave = JSON.stringify(KeplerGlSchema.getConfigToSave(state));
/*
* サーバーに送信するため、ページ・アイテムに保存する。
*/
apex.item("P2_DATASET").setValue(dataToSave);
apex.item("P2_CONFIG").setValue(configToSave);
/*
* name, config, datasetsをサーバーに送信し、データベースに保存する。
*/
apex.server.process( "UPSERT_DATASET", {
pageItems: ["P2_NAME","P2_CONFIG","P2_DATASET"]
}, {
success: function(data) {
if ( data.success ) {
apex.message.showPageSuccess("マップが保存されました。");
apex.item("P2_NAME").refresh();
} else {
apex.message.clearErrors();
apex.message.showErrors([
{
type: "error",
location: "page",
message: "マップの保存に失敗しました。",
unsafe: false
}
]);
};
saveButton.disabled = false;
/* スピナーの削除 */
spinner.remove();
},
error: function( jqXHR, textStatus, errorThrown ) {
apex.message.clearErrors();
apex.message.showErrors([
{
type: "error",
location: "page",
message: "マップの保存に失敗しました。",
unsafe: false
}
]);
saveButton.disabled = false;
/* スピナーの削除 */
spinner.remove();
}
});

構成データとデータセットをデータベースに書き込むために、AjaxコールバックUPSERT_DATASETを呼び出しています。

AjaxコールバックのPL/SQLコードとして以下を記述します。

begin
merge into kepler_datasets t
using
(
select :P2_NAME as name, :P2_CONFIG as config, :P2_DATASET as dataset from dual
) s
on ( t.name = s.name )
when matched then
update set
t.config = s.config
,t.dataset = s.dataset
when not matched then
insert (name, config, dataset)
values (s.name, s.config, s.dataset);
htp.p('{ "success": true }');
exception
when others then
htp.p(apex_string.format('{ "success": false, "error": "%s" }', SQLERRM ));
end;


Kepler.glからデータベースへの、データセットの保存処理の実装は以上です。

ボタンDELETEの処理では、最初にPL/SQLコードとして以下を実行し、ページ・アイテムP2_NAMEのデータを削除します。

delete from kepler_datasets where name = :P2_NAME;


データを削除したのち、ページを再描画して初期化します。以下のJavaScriptコードを実行します。
const thisPage = 'f?p=' + apex.env.APP_ID + ':' + apex.env.APP_PAGE_ID + ':' + apex.env.APP_SESSION + ':::::';
apex.navigation.redirect(thisPage, true);

ボタンDELETEの実装は以上で完了です。

以上でAPEXアプリケーションへのKepler.glの組み込みは完了です。

今回作成したAPEXアプリケーションのエクスポートを以下に置きました。
https://github.com/ujnak/apexapps/blob/master/exports/sample-kepler-gl-on-apex.zip

Oracle APEXのアプリケーション作成の参考になれば幸いです。