2023年9月1日金曜日

Oracle JETのPicto Chartを使って東京の最高気温を表示する

Oracle JETのCookbookではCalendarとなっていますが、Picto Chart(oj-picto-chart)を使ってカレンダー形式で東京の最高気温を表示してみます。

今までOracle JET Cookbookに載っているサンプルをOracle APEXに実装してきましたが、概ねOracle JET Cookbookによるビジュアリゼーション自体は変えていません。今回はOracle JET Cookbookのサンプルが扱っているデータが米国ニューヨーク市の2015年の気温で、少々面白くありません。そして、データを変更するには(色々とハードコードされていて)、コードの変更が必要でした。

それで今回はコードに手を加えて、指定した期間の東京の最高気温を表示するようにJET Picto Chartを実装しました。以下は、作成したアプリケーションによる最高気温の表示です。

2023年3月から8月までの東京都の最高気温をカレンダーに表示しています(取得できた履歴データが8月26日までなので、8月の表示は完全ではありません)。


Weather data by Open-Meteo.com

2022年3月から8月までの東京の最高気温の表示は以下なので、今年は暑いことがよくわかります。


Oracle JET Cookbookの以下の実装を元にしています。


最高気温のデータは、Open-Meteo.comのFree Weather APIを呼び出して取得しています。使用にあたっては、ライセンスおよびPricingについて、ご自身で確認していただくようお願いします。Terms & Privacyに説明がありますが、非商用であれば無料で利用できます。


以下より実装手順を説明します。

最初にOpen-Meteo.comを呼び出して取得した、最高気温のデータを保存する表HMT_TEMPERATURESを作成します。表の作成には、以下のクイックSQLのモデルを使用します。
# prefix: hmt
temperatures
    tag vc20 /nn
    date_rec date /nn
    temperature_2m_max num
SQLの作成SQLスクリプトを保存レビューおよび実行を順次実行します。表の作成までを行い、アプリケーションは作成しません。


続いてアプリケーション作成ウィザードを起動します。

アプリケーションの名前JET Picto Chartとします。デフォルトで作成されているホーム・ページ削除し、表HMT_TEMPERATURESをソース表とした対話グリッドのページを追加します。


追加する対話グリッドのページは、ページ名Temperatures表またはビューとしてHMT_TEMPERATURESを指定します。今回は対話グリッドからデータを更新することは想定していませんが、編集を許可を選択しています。


以上でアプリケーションの作成を実行します。

作成されたアプリケーションのページ番号、ページ名Temperaturesに今回の実装を行います。


アプリケーション定義を開き、置換文字列G_MONTHとして一度に表示する月数を設定します。今回は置換値6を設定しています。


ページ・デザイナでページTemperaturesを開きます。

Breadcrumb BarにあるリージョンJET Picto Chartを削除します。JET Picto Chart上でコンテキスト・メニューを表示させ、削除を実行します。


主にOpen MeteoのAPI呼び出しの引数となる値を保持するページ・アイテムを作成します。これらのページ・アイテムを配置するリージョンを作成します。

識別タイトルMaximum Temperatures (2m)とし、タイプとして静的コンテンツを選択します。対話グリッドHmt Temperaturesの上に配置します。


作成したリージョンにページ・アイテムを作成します。

1度のAPI呼び出しで得られたデータにつけるタグを指定するページ・アイテムを作成します。

識別名前P1_TAGタイプテキスト・フィールドラベルTagとします。デフォルトタイプ静的を選び、静的値としてTokyo (2023)を設定します。


データの取得および表示を開始する年を指定するページ・アイテムを作成します。

識別名前P1_YEARタイプ選択リストラベルYearとします。ページ・アイテムP1_TAGの右隣に配置するため、レイアウト新規行の開始オフにします。検証必須の値オンです。

LOVタイプSQL問合せを選択し、SQL問合せとして以下を記述します。現在の年から過去30年を表示の対象として選択できます。

select y as d, y as r from (
select (extract(year from sysdate) - l + 1) as y from (
select level as l from dual connect by level <= 30
)
)
view raw year-source.sql hosted with ❤ by GitHub
追加値の表示NULL値の表示オフとします。


デフォルトタイプとして静的を選択し、静的値として2023を設定します。


データの取得および表示を開始する月を指定するページ・アイテムを作成します。これはページ・アイテムP1_YEARを重複させて作成します。

識別名前P1_MONTHラベルMonthLOVSQL問合せを以下に変更します。

select l as d, l as r from (
select level as l from dual connect by level <= 12
)

デフォルト静的値に変更します。


観測地点の緯度を指定するページ・アイテムを作成します。

識別名前P1_LATタイプ数値フィールドラベルLatitudeとします。レイアウト新規行の開始オフです。検証必須の値オンにします。

デフォルトタイプとして静的を選択し、静的値として東京の緯度である35.6895を設定します。


観測地点の経度を指定するページ・アイテムを作成します。これはページ・アイテムP1_LATを重複させて作成します。

識別名前P1_LONラベルLongitudeデフォルト静的値を東京の緯度である139.6917を設定します。


タイムゾーンを指定するページ・アイテムを作成します。

識別名前P1_TIMEZONEタイプテキスト・フィールドラベルTimezoneとします。レイアウト新規行の開始オフです。検証必須の値オンにします。

デフォルトタイプとして静的を選択し、静的値としてAsia/Tokyoを設定します。


ページ・アイテムP1_LATP1_LONP1_TIMEZONEは観測地点のマスター・データを準備してポップアップLOVを使うと、もっと扱いやすい実装が可能です。今回は主に東京のデータを取得することを前提として、このような実装は省略しています。

Open-Meteo.comにAPIを発行してデータを取得し、表HMT_TEMPERATURESに保存するボタンを作成します。

識別ボタン名LOADラベルLoad from Open-Meteo.comとします。外観テンプレート・オプションWidthとしてStretchを選択します。

動作アクションはデフォルトのページの送信から変更しません。


プロセス・ビューを開き、ボタンを押した時に実行されるプロセスを作成します。

識別名前Load from Open-Meteo.comとします。タイプとしてコードを実行を選びます。ソースPL/SQLコードに以下を記述します。

declare
/* データ取得期間 */
l_start_date_str varchar2(10);
l_end_date_str varchar2(10);
l_start_date date;
l_end_date date;
l_months constant number := :G_MONTH; -- 表示する月数は固定。
/* API呼び出し */
l_response clob;
l_response_json json_object_t;
l_parm_name constant varchar2(400) := 'latitude:longitude:start_date:end_date:daily:temperature_unit:timezone';
l_parm_value varchar2(400);
/* レスポンスから日付と最高気温を取り出す。 */
l_daily json_object_t;
l_count pls_integer;
l_date_arr json_array_t;
l_temp_arr json_array_t;
l_date date;
l_temp number;
e_open_meteo_get_failed exception;
begin
/* データ取得期間を決める。 */
l_start_date_str := :P1_YEAR || '-' || case when length(:P1_MONTH) = 1 then '0' else '' end || :P1_MONTH || '-01';
l_start_date := to_date(l_start_date_str, 'YYYY-MM-DD');
l_end_date := add_months(l_start_date, l_months) - 1;
/* 終了日は今日の日付を超えてはいけない。 */
if l_end_date > trunc(sysdate) then
l_end_date := trunc(sysdate);
end if;
l_end_date_str := to_char(l_end_date, 'YYYY-MM-DD');
/* Open Meteoの呼び出しパラメータ */
l_parm_value := :P1_LAT || ':' || :P1_LON || ':' || l_start_date_str || ':' || l_end_date_str || ':temperature_2m_max:celsius:' || :P1_TIMEZONE;
apex_debug.info(l_parm_value);
apex_web_service.set_request_headers('Content-Type','application/json');
l_response := apex_web_service.make_rest_request(
p_url => 'https://archive-api.open-meteo.com/v1/archive'
,p_http_method => 'GET'
,p_parm_name => apex_string.string_to_table(l_parm_name)
,p_parm_value => apex_util.string_to_table(l_parm_value)
);
if apex_web_service.g_status_code <> 200 then
apex_debug.info(l_response);
raise e_open_meteo_get_failed;
end if;
l_response_json := json_object_t(l_response);
/* 本当はレスポンスの検証は必要 */
l_daily := l_response_json.get_object('daily');
l_date_arr := l_daily.get_array('time');
l_temp_arr := l_daily.get_array('temperature_2m_max');
l_count := l_date_arr.get_size(); -- l_temp_arrのサイズも同じ。
/* 同じタグのデータは消去する。 */
delete from hmt_temperatures where tag = :P1_TAG;
for i in 1..l_count
loop
l_date := to_date(l_date_arr.get_string(i-1), 'YYYY-MM-DD');
l_temp := l_temp_arr.get_number(i-1);
insert into hmt_temperatures(tag, date_rec, temperature_2m_max)
values(:P1_TAG, l_date, l_temp);
end loop;
end;
サーバー側の条件ボタン押下時としてLOADを選択します。


対話グリッドHmt Temperetures属性を開き、ヘッダー固定としてリージョンを選択します。固定のレポートの高さを300ピクセルとして、対話グリッドの高さを制限します。


以上でアプリケーションを実行し、ボタンLoad from Open-Meteo.comをクリックします。最高気温のデータが読み込まれることを確認します。


プロセス・ビューを開き、Picto Chartに読み込むデータを作成するプロセスをAjaxコールバックとして作成します。

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

declare
l_response_json json_object_t;
l_days json_array_t;
l_day pls_integer;
l_date_str varchar2(7);
l_start_date date;
l_end_date date;
begin
/* 表示に使用する期間 */
l_start_date := to_date(:P1_YEAR || case when length(:P1_MONTH) = 1 then '0' else '' end || :P1_MONTH, 'YYYYMM');
l_end_date := add_months(l_start_date, :G_MONTH) - 1;
/* JET Picto Chartが扱う形式で出力 */
l_response_json := json_object_t();
for r in (
select
trunc(date_rec, 'MONTH') year_month
,json_arrayagg(
json_object(
key 'date' value extract(day from date_rec)
,key 'value' value temperature_2m_max
)
order by date_rec asc
) as value
from hmt_temperatures where tag = :P1_TAG
and date_rec between l_start_date and l_end_date
and temperature_2m_max is not null
group by trunc(date_rec, 'MONTH')
order by trunc(date_rec, 'MONTH')
)
loop
l_date_str := to_char(r.year_month, 'YYYY-MM');
l_days := json_array_t(r.value);
/* 日曜日から1日までの穴埋めをする */
l_day := to_number(to_char(r.year_month,'D'));
for i in 1..(l_day-1)
loop
l_days.put(0, json_object_t('{ "date": 0, "value": null }'));
end loop;
l_response_json.put(l_date_str, l_days);
end loop;
htp.p(l_response_json.to_clob());
end;


レンダリング・ビューを開き、Oracle JETのPicto Chartを表示するリージョンを作成します。

識別タイトルTemperaturesタイプとして動的コンテンツを選択します。リージョンMaximum Temperatures (2m)と対話グリッドHmt Temperaturesの間に配置します。

ソースのCLOBを返すPL/SQLファンクション本体として以下を記述します。

declare
l_chart clob;
l_month_str varchar2(7);
l_month date;
l_months constant number := :G_MONTH; -- 表示する月数は固定。
begin
l_chart := q'~
<div id="chart-container" class="oj-sm-padding-2x-horizontal">
<div class="oj-typography-body-lg oj-typography-bold">Daily Temperatures For ~' || :P1_TAG || q'~</div>
<div class="oj-flex oj-sm-flex-items-initial">~';
/* 最初に表示する月を設定する。 */
l_month_str := :P1_YEAR || '-' || case when length(:P1_MONTH) = 1 then '0' else '' end || :P1_MONTH;
for i in 1..l_months -- 月数は固定
loop
l_chart := l_chart || q'~
<div class="oj-flex-item oj-sm-margin-4x-end">
<div class="oj-typography-body-sm oj-typography-bold oj-sm-margin-4x-vertical">~' || l_month_str || q'~</div>
<div class="oj-sm-margin-1x-start demo-datavisualizations-blockcalendar-wordspacing">
S M T W T F S
</div>
<oj-picto-chart
id="pictochart1"
data="[[m~' || i || q'~DataProvider]]"
layout="horizontal"
row-height="20"
column-count="7">
<template slot="itemTemplate" data-oj-as="item">
<oj-picto-chart-item
short-desc='[[getTooltip("~' || l_month_str || q'~", item.data.date, item.data.value)]]'
color="[[getColor(item.data.value)]]">
</oj-picto-chart-item>
</template>
</oj-picto-chart>
</div>~';
/* 次に表示する月を設定する. */
l_month := to_date(l_month_str, 'YYYY-MM');
l_month := add_months(l_month, 1);
l_month_str := to_char(l_month, 'YYYY-MM');
end loop;
/* レジェンドの表示領域 */
l_chart := l_chart || q'~
</div>
<oj-legend
id="legend1"
class="oj-sm-padding-6x-horizontal demo-datavisualizations-blockcalendar-style"
orientation="horizontal"
data="[[legendDataProvider]]"
symbol-width="15"
symbol-height="15">
<template slot="itemTemplate" data-oj-as="item">
<oj-legend-item text="[[item.data.text]]" color="[[item.data.color]]"></oj-legend-item>
</template>
</oj-legend>
</div>~';
return l_chart;
end;
余計な装飾を省くため、外観テンプレートとして、Blank with Attributes (No Grid)を選択します。


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

[require jet]

ページ・ロード時の実行に以下を記述します。

require(["require", "exports", "knockout", "ojs/ojbootstrap", "ojs/ojarraydataprovider", "ojs/ojpalette", "ojs/ojpaletteutils", "ojs/ojknockout", "ojs/ojpictochart", "ojs/ojlegend"], function (require, exports, ko, ojbootstrap_1, ArrayDataProvider, ojpalette_1, ojpaletteutils_1) {
"use strict";
class PictoChartModel {
constructor(data) {
this.data = data;
/* データ・プロバイダをobservableArrayで初期化 */
Object.keys(this.data).forEach( (element, index) => {
let pname = "m" + (index+1) + "DataProvider";
this[pname] = ko.observableArray();
this[pname](new ArrayDataProvider(this.data[element], {
keyAttributes: "date"
}));
});
/* ツールチップの表示形式を決める */
this.colors = (0, ojpalette_1.getColorValuesFromPalette)("viridis", 7);
this.getTooltip = (month, date, value) => {
return date === 0
? ""
: `${month}-${date.toString()} (${value.toString()})°C`;
};
/* 気温による表示色を決める */
this.getColor = (value) => {
return value === null
? "rgba(0,0,0,0)"
: (0, ojpaletteutils_1.getColorValue)(this.colors, (value + 30) / 70);
};
/* レジェンドの初期化 - 摂氏に変更 */
this.legendItems = [];
this.temp = [
"-30〜-20\xB0C",
"-20〜-10\xB0C",
"-10〜0\xB0C",
"0〜10\xB0C",
"10〜20\xB0C",
"20〜30\xB0C",
"30〜40\xB0C",
];
this.legendDataProvider = ko.observableArray();
/* レジェンドとして表示する項目の生成 */
for (let i = 0; i < this.temp.length; i++) {
this.legendItems.push({ text: this.temp[i], color: this.colors[i] });
}
this.legendDataProvider(new ArrayDataProvider(this.legendItems, {
keyAttributes: "text",
}));
}
}
(0, ojbootstrap_1.whenDocumentReady)().then(() => {
apex.server.process ( "GET_DATA", {
pageItems: ["P1_TAG","P1_YEAR","P1_MONTH"]
},
{
success: (data) => {
ko.applyBindings(new PictoChartModel(data), document.getElementById("chart-container"));
}
}
);
});
});
CSSファイルURLに以下を記述します。

#JET_CSS_DIRECTORY#redwood/oj-redwood-notag-min.css

インラインは、Oracle JET Cookbookにdemo.cssとして記載されている内容を元に、若干の変更を加えています。
.demo-datavisualizations-blockcalendar-wordspacing {
    word-spacing: 0.375rem;
}

.demo-datavisualizations-blockcalendar-style {
    height: 3.125rem;
    max-width: 67.5rem;
}


以上でアプリケーションは完成です。アプリケーションを実行すると、記事の先頭にある画面が表示されます。

対話グリッドからのデータの修正は想定していないため、動的アクションは作成しません。

今回のAPEXアプリケーションではOracle JETのチャートとなるHTMLは、動的コンテンツとしてPL/SQLコードによって生成しています。動的コンテンツのリージョンには遅延ロードのオプションがあります。今回の実装で遅延ロードをオンにすると、チャートが描画されなくなります。これはHTMLがすべて生成される前に、以下のハンドラが呼び出されるためです。
(0, ojbootstrap_1.whenDocumentReady)().then(() => {
        apex.server.process ( "GET_DATA", {
                pageItems: ["P1_TAG","P1_YEAR","P1_MONTH"]
            },
            {
                success: (data) =>  {
                    ko.applyBindings(new PictoChartModel(data), document.getElementById("chart-container"));
                }
            }
        );
    });
動的イベントのリフレッシュ後のタイミングで上記のハンドラを呼び出すと正常に動くかもしれませんが、そもそもknockoutによりチャートの描画は非同期になっています。動的コンテンツの遅延ロードの設定は2重の遅延ロードになるため、設定は不要です

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

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