2024年8月23日金曜日

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

今までにObservable PlotPlotly.jsbillboard.jsといったD3をチャート描画に使用しているライブラリを、Oracle APEXのアプリケーションに組み込んでみました。これらのライブラリを使うとチャートを簡単に表示できますが、やはりD3でなければ表示できないチャートはあります。

今回の記事では、Oracle APEXのアプリケーションでD3を使ってチャートを描画してみます。テンプレート・コンポーネントの作成はせず(D3によるチャート描画のユースケースで、テンプレート・コンポーネントが必要なケースは無いと思います)、D3のExamplesにあるチャートをいくつか、Oracle APEXのページに実装してみます。

最初にチャート描画に使うJSONデータを保存する表を作成し、その表を元に初期のAPEXアプリケーションを作成します。

SQLワークショップクイックSQLの以下のモデルより、表EBAJ_D3_GRAPHSを作成します。データの名前となる列NAMEと、JSON形式のデータを保持する列GRAPH_DATAを含みます。
# prefix: ebaj
d3_graphs
    name vc80 /nn
    graph_data json

レビューおよび実行をクリックします。


EBAJ_D3_GRAPHSを作成するDDLを確認します。列GRAPH_DATAのデータ型はCLOBになっています。効率の面からいうと、JSONやBLOB型が望ましいのですが、APEXのアプリケーション作成ウィザードはデータ型がCLOBのときはタイプテキスト領域のページ・アイテムを作成し、BLOBのときはファイル選択のページ・アイテムを作成します。今回の用途ではテキスト領域を作成して欲しいので、CLOBのまま変更しません。

スクリプト名を設定し、実行をクリックします。確認画面が表示されるので、即時実行をクリックします。


表が作成されます。続けてアプリケーションの作成をクリックします。確認画面が表示されるので、再度アプリケーションの作成をクリックします。


アプリケーション作成ウィザードが開きます。名前Integrating D3に変更します。デフォルトで表EBAJ_D3_GRAPHSをソースとしたフォーム付き対話モード・レポートのページが作成されます。

アプリケーションの作成をクリックします。


アプリケーションが作成されます。



D3のExamplesに含まれるHierarchical edge bundling IIのチャートを、Oracle APEXのページに実装します。

画面右上にある3点アイコンよりメニューを開き、ViewShow filesを実行します。


Filesのパネルが開き、データファイルであるflare.jsonが含まれていることが確認できます。3点アイコンのボタンをクリックし、Downloadを実行します。


ファイルをダウンロードしたら、作成したAPEXアプリケーションを実行し対話モード・レポートのページを開きます。

作成をクリックします。


Nameにファイル名であるflare.jsonGraph Dataにダウンロードしたflare.jsonの内容をコピペします。

作成をクリックします。


対話モード・レポートのページに戻ると、ORA-6502が発生しています。これは、登録したJSONデータが長すぎて対話モード・レポートの1行に表示できる文字数の制限(32k)を超えたために発生しています。

今回はエラーを回避できれば良いので、アクションを開き、Graph Dataを表示対象の列から外します。


flare.jsonが登録されていることが確認できます。


チャートのデータを保存できたので、これからチャートを実装します。

空白のページを作成します。名前Hierarchical edge bundling IIとします。


HTMLヘッダーに以下のimportmapを記述します。
<script type="importmap">
    {
        "imports": {
            "d3": "https://cdn.jsdelivr.net/npm/d3/+esm"
        }
    }
</script>

データベースからJSONデータを取り出す際に呼び出すAjaxコールバックを作成します。

識別名前GET_DATAタイプコードの実行です。ソースPL/SQLコードとして以下を記述します。

declare
l_name ebaj_d3_graphs.name%type;
l_data clob;
/* Generous number of characters that can be stored in l_buffer */
l_amount integer := 4000;
l_offset integer := 1;
l_total integer;
l_buffer varchar2(32767);
begin
l_name := apex_application.g_x01;
select graph_data into l_data from ebaj_d3_graphs
where name = l_name;
l_total := dbms_lob.getlength( l_data );
apex_debug.info('data found for %s, %s', l_name, l_total);
while ( l_offset < l_total )
loop
dbms_lob.read( l_data, l_amount, l_offset, l_buffer );
htp.prn( l_buffer );
l_offset := l_offset + l_amount;
end loop;
exception
when others then
apex_debug.info('no data found for %s, %s', l_name, SQLERRM );
htp.prn('{}');
end;


D3のチャートを描画するリージョンを作成します。

識別名前D3タイプ静的コンテンツとします。ソースHTMLコードに以下を記述します。ほとんどD3のExamplesそのままですが、書式を素のJavaScriptで動作するように若干の修正を加えています。

<div id="myChart""></div>
<script type="module">
import * as d3 from 'd3';
/*
  * 以下はD3のHierarchical edge bundling IIのExamplesより
* Observable Templateではなく、素のJavaScriptで動作するように
* コードは若干改変している。
*
* 出典元:
* https://observablehq.com/@d3/hierarchical-edge-bundling/2
*/
// constを加えてファンクション化
const color = t => d3.interpolateRdBu(1 - t);
// BezierCurve は実行形式でなくclass定義に変更
// BezierCurbe = {
const l1 = [4 / 8, 4 / 8, 0 / 8, 0 / 8];
const l2 = [2 / 8, 4 / 8, 2 / 8, 0 / 8];
const l3 = [1 / 8, 3 / 8, 3 / 8, 1 / 8];
const r1 = [0 / 8, 2 / 8, 4 / 8, 2 / 8];
const r2 = [0 / 8, 0 / 8, 4 / 8, 4 / 8];
function dot([ka, kb, kc, kd], {a, b, c, d}) {
return [
ka * a[0] + kb * b[0] + kc * c[0] + kd * d[0],
ka * a[1] + kb * b[1] + kc * c[1] + kd * d[1]
];
}
// returnは不要
class BezierCurve {
constructor(a, b, c, d) {
this.a = a;
this.b = b;
this.c = c;
this.d = d;
}
split() {
const m = dot(l3, this);
return [
new BezierCurve(this.a, dot(l1, this), dot(l2, this), m),
new BezierCurve(m, dot(r1, this), dot(r2, this), this.d)
];
}
toString() {
return `M${this.a}C${this.b},${this.c},${this.d}`;
}
};
// Lineはそのまま
class Line {
constructor(a, b) {
this.a = a;
this.b = b;
}
split() {
const {a, b} = this;
const m = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
return [new Line(a, m), new Line(m, b)];
}
toString() {
return `M${this.a}L${this.b}`;
}
};
// Pathもそのまま
class Path {
constructor(_) {
this._ = _;
this._m = undefined;
}
moveTo(x, y) {
this._ = [];
this._m = [x, y];
}
lineTo(x, y) {
this._.push(new Line(this._m, this._m = [x, y]));
}
bezierCurveTo(ax, ay, bx, by, x, y) {
this._.push(new BezierCurve(this._m, [ax, ay], [bx, by], this._m = [x, y]));
}
*split(k = 0) {
const n = this._.length;
const i = Math.floor(n / 2);
const j = Math.ceil(n / 2);
const a = new Path(this._.slice(0, i));
const b = new Path(this._.slice(j));
if (i !== j) {
const [ab, ba] = this._[i].split();
a._.push(ab);
b._.unshift(ba);
}
if (k > 1) {
yield* a.split(k - 1);
yield* b.split(k - 1);
} else {
yield a;
yield b;
}
}
toString() {
return this._.join("");
}
};
// idもそのまま
function id(node) {
return `${node.parent ? id(node.parent) + "." : ""}${node.data.name}`;
};
// bilinkもそのまま
function bilink(root) {
const map = new Map(root.leaves().map(d => [id(d), d]));
for (const d of root.leaves()) d.incoming = [], d.outgoing = d.data.imports.map(i => [d, map.get(i)]);
for (const d of root.leaves()) for (const o of d.outgoing) o[1].incoming.push(o);
return root;
};
// hierarchyもそのまま
function hierarchy(data, delimiter = ".") {
let root;
const map = new Map;
data.forEach(function find(data) {
const {name} = data;
if (map.has(name)) return map.get(name);
const i = name.lastIndexOf(delimiter);
map.set(name, data);
if (i >= 0) {
find({name: name.substring(0, i), children: []}).children.push(data);
data.name = name.substring(i + 1);
} else {
root = data;
}
return data;
});
return root;
};
// chartはファンクションとするためconst chart = (data) => {に変更
const chart = (data) => {
const width = 954;
const radius = width / 2;
const k = 6; // 2^k colors segments per curve
const tree = d3.cluster()
.size([2 * Math.PI, radius - 100]);
const root = tree(bilink(d3.hierarchy(data)
.sort((a, b) => d3.ascending(a.height, b.height) || d3.ascending(a.data.name, b.data.name))));
const svg = d3.create("svg")
.attr("width", width)
.attr("height", width)
.attr("viewBox", [-width / 2, -width / 2, width, width])
.attr("style", "max-width: 100%; height: auto; font: 10px sans-serif;");
const node = svg.append("g")
.selectAll()
.data(root.leaves())
.join("g")
.attr("transform", d => `rotate(${d.x * 180 / Math.PI - 90}) translate(${d.y},0)`)
.append("text")
.attr("dy", "0.31em")
.attr("x", d => d.x < Math.PI ? 6 : -6)
.attr("text-anchor", d => d.x < Math.PI ? "start" : "end")
.attr("transform", d => d.x >= Math.PI ? "rotate(180)" : null)
.text(d => d.data.name)
.call(text => text.append("title").text(d => `${id(d)}
${d.outgoing.length} outgoing
${d.incoming.length} incoming`));
const line = d3.lineRadial()
.curve(d3.curveBundle)
.radius(d => d.y)
.angle(d => d.x);
const path = ([source, target]) => {
const p = new Path;
line.context(p)(source.path(target));
return p;
};
svg.append("g")
.attr("fill", "none")
.selectAll()
.data(d3.transpose(root.leaves()
.flatMap(leaf => leaf.outgoing.map(path))
.map(path => Array.from(path.split(k)))))
.join("path")
.style("mix-blend-mode", "darken")
.attr("stroke", (d, i) => color(d3.easeQuad(i / ((1 << k) - 1))))
.attr("d", d => d.join(""));
return svg.node();
};
/*
* D3のExamplesより転記したチャートの実装は以上で終了。
*
* 以下はOracle APEXでのチャート表示。
*/
apex.server.process(
"GET_DATA",
{
x01: 'flare.json' // apex_application.g_x01にファイル名を渡す
},
{
success: function( response ) {
// チャート描画のJSONをresponseとして受け取る。
let data = hierarchy(response);
document.getElementById("myChart").append(chart(data));
}
}
);
</script>


以上で実装は完了です。ページを実行すると、以下のようにチャートが表示されます。


作成したページをコピーし、Force-directed graphを実装してみます。

ダウンロードしてデータベースに取り込むファイルはmiserable.jsonです。


静的コンテンツのリージョンのHTMLコードとして、以下を記述します。invalidationの呼び出しをコメントアウトしたくらいで、ほとんどコードは改変していません。

<div id="myChart""></div>
<script type="module">
import * as d3 from 'd3';
/*
  * 以下はD3のForce-directred graphのExamplesより
*
* 出典元:
* https://observablehq.com/@d3/force-directed-graph/2
*/
// chartはファンクションとするためconst chart = (data) => {に変更
const chart = (data) => {
// Specify the dimensions of the chart.
const width = 928;
const height = 600;
// Specify the color scale.
const color = d3.scaleOrdinal(d3.schemeCategory10);
// The force simulation mutates links and nodes, so create a copy
// so that re-evaluating this cell produces the same result.
const links = data.links.map(d => ({...d}));
const nodes = data.nodes.map(d => ({...d}));
// Create a simulation with several forces.
const simulation = d3.forceSimulation(nodes)
.force("link", d3.forceLink(links).id(d => d.id))
.force("charge", d3.forceManyBody())
.force("center", d3.forceCenter(width / 2, height / 2))
.on("tick", ticked);
// Create the SVG container.
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", [0, 0, width, height])
.attr("style", "max-width: 100%; height: auto;");
// Add a line for each link, and a circle for each node.
const link = svg.append("g")
.attr("stroke", "#999")
.attr("stroke-opacity", 0.6)
.selectAll()
.data(links)
.join("line")
.attr("stroke-width", d => Math.sqrt(d.value));
const node = svg.append("g")
.attr("stroke", "#fff")
.attr("stroke-width", 1.5)
.selectAll()
.data(nodes)
.join("circle")
.attr("r", 5)
.attr("fill", d => color(d.group));
node.append("title")
.text(d => d.id);
// Add a drag behavior.
node.call(d3.drag()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended));
// Set the position attributes of links and nodes each time the simulation ticks.
function ticked() {
link
.attr("x1", d => d.source.x)
.attr("y1", d => d.source.y)
.attr("x2", d => d.target.x)
.attr("y2", d => d.target.y);
node
.attr("cx", d => d.x)
.attr("cy", d => d.y);
}
// Reheat the simulation when drag starts, and fix the subject position.
function dragstarted(event) {
if (!event.active) simulation.alphaTarget(0.3).restart();
event.subject.fx = event.subject.x;
event.subject.fy = event.subject.y;
}
// Update the subject (dragged node) position during drag.
function dragged(event) {
event.subject.fx = event.x;
event.subject.fy = event.y;
}
// Restore the target alpha so the simulation cools after dragging ends.
// Unfix the subject position now that it’s no longer being dragged.
function dragended(event) {
if (!event.active) simulation.alphaTarget(0);
event.subject.fx = null;
event.subject.fy = null;
}
// When this cell is re-run, stop the previous simulation. (This doesn’t
// really matter since the target alpha is zero and the simulation will
// stop naturally, but it’s a good practice.)
// 以下はOracle APEXのページには実装されていない。
// invalidation.then(() => simulation.stop());
return svg.node();
};
/*
* D3のExamplesより転記したチャートの実装は以上で終了。
*
* 以下はOracle APEXでのチャート表示。
*/
apex.server.process(
"GET_DATA",
{
x01: 'miserable.json' // apex_application.g_x01にファイル名を渡す
},
{
success: function( response ) {
// チャート描画のJSONをresponseとして受け取る。
document.getElementById("myChart").append(chart(response));
}
}
);
</script>

ページを実行すると以下のように表示されます。


同様にページをコピーしWorld tourを実装してみます。

ダウンロードしてデータベースの取り込むファイルはcountries-110m.jsonです。


World tourでtopojsonのクライアント・ライブラリを使用しています。そのため、importmapを以下に変更します。
<script type="importmap">
    {
        "imports": {
            "d3": "https://cdn.jsdelivr.net/npm/d3/+esm",
            "topojson-client": "https://cdn.jsdelivr.net/npm/topojson-client/+esm"
        }
    }
</script>
フォーカスが当たっている国の名前を表示するページ・アイテムとして、P6_NAMEを作成しています。


静的コンテンツ
のリージョンのHTMLコードとして、以下を記述します。countries-110m.jsonをデータベースから読み込むコードや、canvasのコードをdiv要素(myChart)の子要素となるように、コードを追加しています。

<div id="myChart""></div>
<script type="module">
import * as d3 from 'd3';
import * as topojson from 'topojson-client';
/*
* read countries-110m.json from the server
*/
var world;
async function getData( fileName ) {
return apex.server.process(
"GET_DATA",
{
x01: fileName
}
).done( ( response ) => {
world = response;
})
};
await getData ( 'countries-110m.json' );
apex.debug.info( world );
/*
  * 以下はD3のWorld tourのExamplesより
*
* 出典元:
* https://observablehq.com/@d3/world-tour
*/
const land = topojson.feature(world, world.objects.land);
const borders = topojson.mesh(world, world.objects.countries, (a, b) => a !== b);
const countries = topojson.feature(world, world.objects.countries).features;
var name = null;
class Versor {
static fromAngles([l, p, g]) {
l *= Math.PI / 360;
p *= Math.PI / 360;
g *= Math.PI / 360;
const sl = Math.sin(l), cl = Math.cos(l);
const sp = Math.sin(p), cp = Math.cos(p);
const sg = Math.sin(g), cg = Math.cos(g);
return [
cl * cp * cg + sl * sp * sg,
sl * cp * cg - cl * sp * sg,
cl * sp * cg + sl * cp * sg,
cl * cp * sg - sl * sp * cg
];
}
static toAngles([a, b, c, d]) {
return [
Math.atan2(2 * (a * b + c * d), 1 - 2 * (b * b + c * c)) * 180 / Math.PI,
Math.asin(Math.max(-1, Math.min(1, 2 * (a * c - d * b)))) * 180 / Math.PI,
Math.atan2(2 * (a * d + b * c), 1 - 2 * (c * c + d * d)) * 180 / Math.PI
];
}
static interpolateAngles(a, b) {
const i = Versor.interpolate(Versor.fromAngles(a), Versor.fromAngles(b));
return t => Versor.toAngles(i(t));
}
static interpolateLinear([a1, b1, c1, d1], [a2, b2, c2, d2]) {
a2 -= a1, b2 -= b1, c2 -= c1, d2 -= d1;
const x = new Array(4);
return t => {
const l = Math.hypot(x[0] = a1 + a2 * t, x[1] = b1 + b2 * t, x[2] = c1 + c2 * t, x[3] = d1 + d2 * t);
x[0] /= l, x[1] /= l, x[2] /= l, x[3] /= l;
return x;
};
}
static interpolate([a1, b1, c1, d1], [a2, b2, c2, d2]) {
let dot = a1 * a2 + b1 * b2 + c1 * c2 + d1 * d2;
if (dot < 0) a2 = -a2, b2 = -b2, c2 = -c2, d2 = -d2, dot = -dot;
if (dot > 0.9995) return Versor.interpolateLinear([a1, b1, c1, d1], [a2, b2, c2, d2]);
const theta0 = Math.acos(Math.max(-1, Math.min(1, dot)));
const x = new Array(4);
const l = Math.hypot(a2 -= a1 * dot, b2 -= b1 * dot, c2 -= c1 * dot, d2 -= d1 * dot);
a2 /= l, b2 /= l, c2 /= l, d2 /= l;
return t => {
const theta = theta0 * t;
const s = Math.sin(theta);
const c = Math.cos(theta);
x[0] = a1 * c + a2 * s;
x[1] = b1 * c + b2 * s;
x[2] = c1 * c + c2 * s;
x[3] = d1 * c + d2 * s;
return x;
};
}
}
/*
* Adding code for APEX. -- start
* リージョンmyChartの幅を取得する。
*/
const div = document.getElementById("myChart");
const width = div.clientWidth;
console.log('width of myChart', width);
/*
* Adding code for APEX. -- end
*/
// Specify the chart’s dimensions.
const height = Math.min(width, 720); // Observable sets a responsive *width*
// Prepare a canvas.
const dpr = window.devicePixelRatio ?? 1;
const canvas = d3.create("canvas")
.attr("width", dpr * width)
.attr("height", dpr * height)
.style("width", `${width}px`);
const context = canvas.node().getContext("2d");
context.scale(dpr, dpr);
// Create a projection and a path generator.
const projection = d3.geoOrthographic().fitExtent([[10, 10], [width - 10, height - 10]], {type: "Sphere"});
const path = d3.geoPath(projection, context);
const tilt = 20;
function render(country, arc) {
context.clearRect(0, 0, width, height);
context.beginPath(), path(land), context.fillStyle = "#ccc", context.fill();
context.beginPath(), path(country), context.fillStyle = "#f00", context.fill();
context.beginPath(), path(borders), context.strokeStyle = "#fff", context.lineWidth = 0.5, context.stroke();
context.beginPath(), path({type: "Sphere"}), context.strokeStyle = "#000", context.lineWidth = 1.5, context.stroke();
context.beginPath(), path(arc), context.stroke();
return context.canvas;
}
let p1, p2 = [0, 0], r1, r2 = [0, 0, 0];
for (const country of countries) {
// mutable name = country.properties.name;
name = country.properties.name;
// yield render(country);
render(country);
/*
* Adding code for APEX -- start
* myChartにcanvasのノードを設定し、国名をP6_NAMEに設定する。
*/
div.appendChild(canvas.node());
apex.item("P6_NAME").setValue(name);
/*
* Adding code for APEX -- end
*/
p1 = p2, p2 = d3.geoCentroid(country);
r1 = r2, r2 = [-p2[0], tilt - p2[1], 0];
const ip = d3.geoInterpolate(p1, p2);
const iv = Versor.interpolateAngles(r1, r2);
await d3.transition()
.duration(1250)
.tween("render", () => t => {
projection.rotate(iv(t));
render(country, {type: "LineString", coordinates: [p1, ip(t)]});
})
.transition()
.tween("render", () => t => {
render(country, {type: "LineString", coordinates: [ip(t), p2]});
})
.end();
}
</script>
view raw World tour.html hosted with ❤ by GitHub


ページを実行すると以下のように表示されます。


今回の記事は以上になります。

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

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