今回の記事では、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点アイコンよりメニューを開き、ViewのShow filesを実行します。
Filesのパネルが開き、データファイルであるflare.jsonが含まれていることが確認できます。3点アイコンのボタンをクリックし、Downloadを実行します。
ファイルをダウンロードしたら、作成したAPEXアプリケーションを実行し対話モード・レポートのページを開きます。
作成をクリックします。
Nameにファイル名であるflare.json、Graph 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コードとして以下を記述します。
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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で動作するように若干の修正を加えています。
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<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です。
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<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です。
<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を作成しています。
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<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> |