2024年12月13日金曜日

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

Uber Technologies社が開発および公開しているhubble.glをOracle APEXのアプリケーションに組み込んでみます。

hubble.glのGitHubのリポジトリに含まれている、以下のサンプルを組み込む対象とします。

作成したアプリケーションは以下のように動作します。リージョンやボタンの配置は調整の余地はありますが、概ね動作はしています。


空のAPEXアプリケーションを作成し、空白のページをページ番号およびとして作成します。

ページ番号scriptingのサンプル、ページ番号pure-jsのサンプルを実装します。


最初に以下のscriptingのサンプルから実装します。


index.htmlの内容を、APEXのページの適切なプロパティに設定し直します。

最初にページ・プロパティを設定します。

ページ・プロパティJavaScriptファイルURLに以下を記述します。index.htmlにはMapBoxのライブラリとCSSのロードが含まれていますが、使われていないので除いています。

https://unpkg.com/deck.gl@8.5.10/dist.min.js
https://unpkg.com/hubble.gl@1.3.8/dist.min.js

ページ・ロード時に実行に以下を記述します。index.htmlに記載されているスクリプトですが、Re-Renderボタンを押した時に(APEXのデフォルト動作である)ページの送信が行われないように、preventDeault()を呼び出しています。

// Original: https://github.com/visgl/hubble.gl/blob/master/examples/get-started/pure-js/index.html
function createPoints(count = 10) {
const points = [];
for (let x = 0; x < count; x++) {
for (let y = 0; y < count; y++) {
for (let z = 0; z < count; z++) {
points.push({
position: [x - count / 2, y - count / 2, z - count / 2],
color: [(x / count) * 255, (y / count) * 255, (z / count) * 255]
});
}
}
}
return points;
}
const LAYER_ID = 'point-cloud';
const DATA_ID = 'point-data';
function smoothstep(value) {
const x = Math.max(0, Math.min(1, value));
return x * x * (3 - 2 * x);
}
const animation = new hubble.DeckAnimation({
getLayers: ani => {
const dataFrame = ani.layerKeyframes[DATA_ID].getFrame();
return [
new deck.PointCloudLayer({
id: LAYER_ID,
coordinateSystem: deck.COORDINATE_SYSTEM.IDENTITY,
opacity: 0.8,
data: createPoints(dataFrame.pointCount),
getPosition: d => d.position,
getColor: d => d.color,
getNormal: [0, 0, 1],
pointSize: 4
})
];
},
layerKeyframes: [
{
id: DATA_ID,
keyframes: [{ pointCount: 1 }, { pointCount: 30 }],
timings: [0, 3000],
easings: smoothstep
}
]
});
const timecode = {
start: 0,
end: 3000,
framerate: 60
};
const filename = 'non-geo-example';
const animationManager = new hubble.AnimationManager({ animations: [animation] });
const adapter = new hubble.DeckAdapter({ animationManager });
const nonGeoExample = new deck.DeckGL({
container: document.getElementById('non-geo'),
mapbox: false /* disable map */,
views: [new deck.OrbitView()],
initialViewState: { distance: 1, fov: 50, rotationX: 10, rotationOrbit: 160, zoom: 3.5 },
controller: false,
parameters: {
clearColor: [255, 255, 255, 1]
},
// retina displays will double resolution
useDevicePixels: false
});
adapter.setDeck(nonGeoExample);
const setProps = () => {
nonGeoExample.setProps(adapter.getProps({ onNextFrame: setProps }));
};
const embedVideo = blob => {
document.getElementById('render-status').innerText = 'Render complete!';
const resultElement = document.getElementById('result');
resultElement.style.display = 'block';
const videoElement = document.getElementById('video-render');
videoElement.setAttribute('controls', true);
videoElement.setAttribute('autoplay', true);
videoElement.src = URL.createObjectURL(blob);
videoElement.addEventListener('canplaythrough', () => {
videoElement.play();
});
};
const render = () => {
// adapter.seek({timeMs: 0});
adapter.render({
Encoder: hubble.WebMEncoder,
timecode,
filename,
onComplete: setProps,
onSave: embedVideo
});
nonGeoExample.redraw(true);
};
nonGeoExample.setProps({
...adapter.getProps({ onNextFrame: setProps }),
onLoad: render
});
animation.setOnLayersUpdate(layers => nonGeoExample.setProps({ layers }));
const reRenderElement = document.getElementById('re-render');
// APEXのデフォルト動作のSubmit抑止する。
reRenderElement.onclick = (event) => {
render();
event.preventDefault();
};
CSSインラインに以下を記述します。

/*
* Original: https://github.com/visgl/hubble.gl/blob/master/examples/get-started/scripting/index.html
*/
/*
body {margin: 0; padding: 0; font-family: Helvetica, Arial, sans-serif;}
*/
#geo, #non-geo {
position: absolute;
width: 50vw;
height: 50vh;
}
/*
#non-geo {left: 50vw; top: 0;}
*/
.render-result {
position: absolute;
z-index: 1;
top: 0;
right: 0;
background-color: #FFF;
margin: 24px;
padding: 10px 24px;
box-shadow: rgba(0, 0, 0, 0.16) 0px 1px 4px;
min-width: 300px;
}
.render-result h3 { font-size: 16px; margin: 8px 0; }
#re-render { margin-top: 8px; }
#result {
display: none;
}

描画に使用する静的コンテンツのリージョンを作成します。ソースHTMLコードに以下を記述します。外観テンプレートにはBlank with Attributes (No Grid)を選択します。

<!-- Original: https://github.com/visgl/hubble.gl/blob/master/examples/get-started/scripting/index.html -->
<div id="non-geo" ></div>
<div class="render-result">
<h3 id="render-status">Rendering video...</h3>
<div id="result">
<div class="video">
<video id="video-render"></video>
</div>
<button id="re-render">Re-Render</button>
</div>
</div>


以上でscriptingのサンプルについては完了です。

次にpure-jsのサンプルを実装します。


サンプルではGeoJSONのデータをファイルから読み込んでいます。この部分はデータベースから読み込むように変更します。

GeoJSONのデータを保持する表HUBBLE_GEOJSONを作成します。以下のDDLを実行します。
create table hubble_geojson (
    id         number generated by default on null as identity
               constraint hubble_geojson_id_pk primary key,
    name       varchar2(20 char) not null,
    feature    clob check (feature is json)
);
以下はSQLコマンドでの実行例です。


描画に使用するデータファイルの内容を、表HUBBLE_GEOJSONに投入します。以下のスクリプトを実行します。

declare
/* 確認用 */
l_count number;
/*
* FeatureCollectionを表HUBBLE_GEOJSONに投入する。
*/
procedure store_feature_collection(
p_url in varchar2
,p_name in varchar2
)
as
l_response clob;
l_feature_collection json_object_t;
l_features json_array_t;
l_feature json_object_t;
l_feature_clob clob;
e_get_failed exception;
begin
apex_web_service.clear_request_headers();
l_response := apex_web_service.make_rest_request(
p_url => p_url
,p_http_method => 'GET'
);
if apex_web_service.g_status_code <> 200 then
raise e_get_failed;
end if;
l_feature_collection := json_object_t(l_response);
l_features := l_feature_collection.get_array('features');
for i in 1..l_features.get_size()
loop
l_feature := treat(l_features.get(i-1) as json_object_t);
l_feature_clob := l_feature.to_clob();
insert into hubble_geojson(name, feature) values(p_name, l_feature_clob);
end loop;
end store_feature_collection;
begin
/* 取得済みのデータを削除 */
delete from hubble_geojson;
/* COUNTRIESの取得 */
store_feature_collection(
'https://d2ad6b4ur7yvpq.cloudfront.net/naturalearth-3.3.0/ne_50m_admin_0_scale_rank.geojson',
'COUNTRIES'
);
select count(*) into l_count from hubble_geojson where name = 'COUNTRIES';
dbms_output.put_line(apex_string.format('COUNTRIES = %s', l_count));
/* AIR_PORTSの取得 */
store_feature_collection(
'https://d2ad6b4ur7yvpq.cloudfront.net/naturalearth-3.3.0/ne_10m_airports.geojson',
'AIR_PORTS'
);
select count(*) into l_count from hubble_geojson where name = 'AIR_PORTS';
dbms_output.put_line(apex_string.format('AIR_PORTS = %s', l_count));
commit;
end;
COUNTRIESとして1634個のFeature、AIR_PORTSとして891個のFeatureが保存されます。


ページ・プロパティ JavaScriptファイルURLに以下を記述します。

https://unpkg.com/deck.gl@9.0.38/dist.min.js
https://unpkg.com/hubble.gl@1.3.8/dist.min.js
https://unpkg.com/popmotion@11.0.5/dist/popmotion.min.js

ファンクションおよびグローバル変数の宣言に、データベース・サーバーから非同期ではなく同期でデータを取得するファンクションgetGeojsonを定義します。

/*
* データの同期取得。
*/
async function getGeojson(name) {
let data = await apex.server.process(
"GET_GEOJSON",
{ x01: name }
);
return data;
}
view raw getGeojson.js hosted with ❤ by GitHub

ページ・ロード時に実行に以下を記述します。pure-jsのサンプルに含まれるapp.jsとほぼ同じですが、データをデータベースから取得したり、ボタンのクリック時にページ送信がされないような変更を加えています。

// Original: https://github.com/visgl/hubble.gl/blob/master/examples/get-started/pure-js/app.js
// source: Natural Earth http://www.naturalearthdata.com/ via geojson.xyz
/*
const COUNTRIES =
'https://d2ad6b4ur7yvpq.cloudfront.net/naturalearth-3.3.0/ne_50m_admin_0_scale_rank.geojson'; //eslint-disable-line
const AIR_PORTS =
'https://d2ad6b4ur7yvpq.cloudfront.net/naturalearth-3.3.0/ne_10m_airports.geojson';
*/
const COUNTRIES = getGeojson("COUNTRIES");
const AIR_PORTS = getGeojson("AIR_PORTS");
const POSITION_1 = {
latitude: 53.43908116042187,
longitude: 12.287845233145143,
zoom: 4.874450321621229,
bearing: 72.28125,
pitch: 86.69082968902045,
maxZoom: 20,
minZoom: 0,
maxPitch: 90,
minPitch: 0
};
const POSITION_2 = {
latitude: 30.024522739828146,
longitude: -43.62384043774613,
zoom: 1.1306811261830276,
bearing: 8.71875,
pitch: 30.662291717091527,
maxZoom: 20,
minZoom: 0,
maxPitch: 90,
minPitch: 0
};
const filename = 'pure-js';
const timecode = {
start: 0,
end: 2000,
framerate: 60
};
const resolution = {
width: 640,
height: 480
};
const animation = new hubble.DeckAnimation({
cameraKeyframe: {
width: resolution.width,
height: resolution.height,
timings: [0, timecode.end - 250],
keyframes: [POSITION_1, POSITION_2],
easings: popmotion.easeInOut
}
});
const animationManager = new hubble.AnimationManager({ animations: [animation] });
const adapter = new hubble.DeckAdapter({ animationManager });
const formatConfigs = {
webm: {
quality: 0.8
},
jpeg: {
quality: 0.8
},
gif: {
sampleInterval: 1000
}
};
const deckObj = new deck.Deck({
canvas: 'deck-canvas',
width: resolution.width,
height: resolution.height,
viewState: POSITION_1,
onViewStateChange: ({ viewState }) => {
deckObj.setProps({ viewState });
},
controller: true,
layers: [
new deck.GeoJsonLayer({
id: 'base-map',
data: COUNTRIES,
// Styles
stroked: true,
filled: true,
lineWidthMinPixels: 2,
opacity: 0.4,
getLineColor: [60, 60, 60],
getFillColor: [200, 200, 200]
}),
new deck.GeoJsonLayer({
id: 'airports',
data: AIR_PORTS,
// Styles
filled: true,
pointRadiusMinPixels: 2,
pointRadiusScale: 2000,
getPointRadius: f => 11 - f.properties.scalerank,
getFillColor: [200, 0, 80, 180],
// Interactive props
pickable: false
}),
new deck.ArcLayer({
id: 'arcs',
data: AIR_PORTS,
dataTransform: d => d.features.filter(f => f.properties.scalerank < 4),
// Styles
getSourcePosition: f => [-0.4531566, 51.4709959], // London
getTargetPosition: f => f.geometry.coordinates,
getSourceColor: [0, 128, 200],
getTargetColor: [200, 0, 80],
getWidth: 1
})
],
// most video formats don't fully support transparency
parameters: {
clearColor: [255, 255, 255, 1]
},
// retina displays will double resolution
useDevicePixels: false
});
adapter.setDeck(deckObj);
const setProps = () => {
deckObj.setProps(adapter.getProps({ onNextFrame: setProps }));
};
const embedVideo = blob => {
document.getElementById('render-status').innerText = 'Render complete!';
const resultElement = document.getElementById('result');
resultElement.style.display = 'block';
const videoElement = document.getElementById('video-render');
videoElement.setAttribute('controls', true);
videoElement.setAttribute('autoplay', true);
videoElement.src = URL.createObjectURL(blob);
videoElement.addEventListener('canplaythrough', () => {
videoElement.play();
});
};
const render = () => {
adapter.render({
Encoder: hubble.WebMEncoder,
formatConfigs,
timecode,
filename,
onComplete: setProps,
onSave: embedVideo
});
deckObj.redraw(true);
};
deckObj.setProps({
...adapter.getProps({ onNextFrame: setProps }),
onLoad: render
});
animation.setOnCameraUpdate(viewState => {
deckObj.setProps({ viewState });
});
// read keyframes
const printCamera = () => {
const cameraOne = document.getElementById('camera-1');
const cameraTwo = document.getElementById('camera-2');
const { cameraKeyframe } = adapter.animationManager.getKeyframes('deck');
cameraOne.innerText = JSON.stringify(cameraKeyframe.keyframes[0], undefined, 2);
cameraTwo.innerText = JSON.stringify(cameraKeyframe.keyframes[1], undefined, 2);
};
printCamera();
// update camera keyframes using buttons
function filterCamera(viewState) {
// TODO: we shouldn't need to exclude in application
const exclude = ['width', 'height', 'altitude', 'position', 'normalize'];
return Object.keys(viewState)
.filter(key => !exclude.includes(key))
.reduce((obj, key) => {
obj[key] = viewState[key];
return obj;
}, {});
}
const updateCamera = index => {
const { cameraKeyframe } = adapter.animationManager.getKeyframes('deck');
const keyframe = filterCamera(deckObj.viewManager.viewState);
const keyframes = [...cameraKeyframe.keyframes];
keyframes[index] = keyframe;
adapter.animationManager.setKeyframes('deck', {
cameraKeyframe: {
...cameraKeyframe,
keyframes
}
});
printCamera();
};
const updateOne = document.getElementById('update-1');
updateOne.onclick = (event) => {
updateCamera(0);
event.preventDefault();
};
const updateTwo = document.getElementById('update-2');
updateTwo.onclick = (event) => {
updateCamera(1);
event.preventDefault();
};
// For automated test cases
document.body.style.margin = '0px';
const reRenderElement = document.getElementById('re-render');
reRenderElement.onclick = (event) => {
render();
event.preventDefault();
};
view raw app.js hosted with ❤ by GitHub
CSSインラインに以下を記述します。

/*
* Original: https://github.com/visgl/hubble.gl/blob/master/examples/get-started/pure-js/index.html
*/
body {
margin: 0;
padding: 0;
font-family: Helvetica, Arial, sans-serif;
}
#container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
#container>* {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.render-result {
position: absolute;
z-index: 1;
top: 0;
right: 0;
background-color: #FFF;
margin: 24px;
padding: 10px 24px;
box-shadow: rgba(0, 0, 0, 0.16) 0px 1px 4px;
min-width: 300px;
}
.render-result h3 {
font-size: 16px;
margin: 8px 0;
}
.render-result h4 {
font-size: 14px;
margin: 8px 0;
}
.render-result p {
font-size: 14px;
margin: 8px 0;
}
.render-result .video {
display: flex;
align-items: center;
margin-top: 8px;
}
.render-result .keyframes {
display: flex;
gap: 8px;
}
.render-result .instruction {
margin-top: 8px;
}
#re-render {
margin-top: 8px;
}
#result {
display: none;
}


描画に使用する静的コンテンツのリージョンを作成します。ソースHTMLコードに以下を記述します。外観テンプレートにはBlank with Attributes (No Grid)を選択します。

<!-- Original: https://github.com/visgl/hubble.gl/blob/master/examples/get-started/pure-js/index.html -->
<div id="container">
<canvas id="deck-canvas"></canvas>
</div>
<div class="render-result">
<h3 id="render-status">Rendering video...</h3>
<div id="result">
<div class="video">
<video id="video-render"></video>
</div>
<p class="instruction">Interact with the map, update camera keyframes, then re-render.</p>
<div class="keyframes">
<div>
<h4>1st Camera Position</h4>
<pre id="camera-1"></pre>
<button id="update-1">Update</button>
</div>
<div>
<h4>2nd Camera Position</h4>
<pre id="camera-2"></pre>
<button id="update-2">Update</button>
</div>
</div>
<button id="re-render">Re-Render</button>
</div>
</div>

ブラウザからリクエストされたGeoJSONのデータを返すプロセスを、Ajaxコールバックとして作成します。

識別名前GET_GEOJSONソースPL/SQLコードとして以下を記述します。

declare
l_feature json_object_t;
l_features json_array_t;
l_features_clob clob;
l_feature_collection json_object_t;
l_feature_collection_clob clob;
l_name hubble_geojson.name%type;
begin
l_feature_collection := json_object_t();
l_feature_collection.put('type','FeatureCollection');
l_features := json_array_t();
l_name := apex_application.g_x01;
for r in (
select feature from hubble_geojson
where name = l_name order by id asc
)
loop
l_feature := json_object_t(r.feature);
l_features.append(l_feature);
end loop;
l_feature_collection.put('features', l_features);
l_feature_collection_clob := l_feature_collection.to_clob();
apex_http.download(
p_clob => l_feature_collection_clob
,p_content_type => 'application/json'
,p_is_inline => true
);
end;
view raw get_geojson.sql hosted with ❤ by GitHub

以上で実装は完了です。

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

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