2023年11月2日木曜日

熊の出没を報告するアプリを作成する

熊の出没を報告するAPEXアプリケーションを試作してみました。入力の手間を少なくすることを、第一の目標にしています。以下の機能を実装しています。
  1. ブラウザから取得できる現在位置の座標から住所を取得する。
  2. 写真のアップロードができる。
  3. 状況は音声で入力する。
  4. OpenAI WhisperのAPIを呼び出して、音声から文字起こしをする。
  5. OpenAI ChatGPTのAPIを呼び出して、状況を説明した文章からファセット検索に使う単語のJSON配列を生成する。
1の現在位置から住所を求めるために、こちらの記事「国土交通省のデータを使って簡易的な逆ジオコーディングを実装する」の実装を使っています。また、4の音声のレコーディングには、こちらの記事「OpenAI Whisperを使った文字起こしアプリの作成(5) - ブラウザからの音声入力」の実装を使っています。OpenAIのAPIを呼び出すために使用するWeb資格証明の作成方法については、こちらの記事「OpenAIのChatGPTのAPIを呼び出すAPEXアプリを作る」で紹介しています。

OpenAIのWhisperとChatGPTのAPI呼び出しは、アカウント作成時に与えられる$40のクレジットを使い切る(もしくは期限切れになる)と、使用量に応じて課金されます。

今回作成するアプリケーションは以下のように動作します。


以下より、アプリケーションの作成手順を紹介します。

最初に、クイックSQLの以下のモデルを元に、出没情報を保存する表を作成します。表BEAR2_SIGHTINGSには出没情報、表BEAR2_PHOTOSには、出没情報に紐づく写真、表BEAR2_VOICESには、音声による報告が保存されます。

SQLの生成SQLスクリプトを保存レビューおよび実行を順次実行します。

# prefix: bear2
# auditcols: true
# apex: true
voices
meta_data vc80
content_type vc200
voice blob
sightings
report_date date /default sysdate
latitude num /nn
longitude num /nn
address vc200
photos
meta_data vc80
filename vc200
mime_type vc200
photo blob
voice_id /fk voices
report_text vc4000
attributes json


SQLスクリプトの編集画面が開くので、そのまま実行します。確認画面が開くので、即時実行をクリックします。その後、生成されたDDLが実行されて上記の3つの表が作成されます。


この画面からはアプリケーションの作成は行いません。

アプリケーション作成ウィザードを起動し、空のアプリケーションを作成します。アプリケーションの名前熊出没情報とします。スマートフォンにインストールできるように、機能プログレッシブWebアプリケーションのインストールチェックを入れます。

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


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

報告した内容を表示するページを作成します。ページの作成をクリックし、ページ作成ウィザードを起動します。


クラシック・レポートを選択します。


ページ番号2名前報告内容とします。データ・ソース表/ビューの名前としてBEAR2_SIGHTINGSを指定します。このページは、出没情報の送信後に遷移する画面なので、このページを直接開くことはありません。そのため、ナビゲーションブレッドクラムの使用ナビゲーションの使用ともにオフにします。


ページが作成されます。

このページは登録された1件の出没情報だけを表示します。表BEAR2_SIGHTINGSで表示対象とする行の主キーの値を保持するページ・アイテムを作成します。

識別名前P2_IDタイプ非表示とします。


クラシック・レポートのリージョン報告内容を選択し、ソースWHERE句に以下を記述し、表示する対象を1つに絞ります。

id = :P2_ID


プロパティ・エディタ属性タブを開き、外観テンプレートValue Attribute Pair - Columnに変更します。横並びだった列を縦方向に並べます。


出没情報に紐づけられた写真を表示するために、カード・リージョンを作成します。

識別タイトル写真タイプカードを選択します。ソース表名としてBEAR2_PHOTOSを指定し、WHERE句に以下を記述します。

sighting_id = :P2_ID


プロパティ・エディタ属性タブを開きます。

今回は写真が表示される最低限の変更のみを実施します。メディアソースとしてBLOB列を選び、BLOB列としてPHOTOを選択します。その後にカード主キー列1IDを指定します。

変更を保存します。


報告内容を表示するページは、以上で完成です。

熊出没情報を入力するフォームのページを作成します。

メニューよりページを実行し、ページ作成ウィザードを開始します。


フォームを選択します。


ページ番号3名前熊出没報告とします。データ・ソース表/ビューの名前としてBEAR2_SIGHTINGSを指定します。このページも直接開くことはない(現在位置の座標を受け取るため、つねにホーム・ページからリダイレクトされます)ため、ナビゲーションブレッドクラムの使用ナビゲーションの使用ともにオフとします。

へ進みます。


主キー列1としてID (Number)を選択します。ブランチ・ページ送信時にここにブランチは、先ほど作成した報告内容を表示するページを遷移先とするため2を指定します。取り消してページに移動2としますが、取消ボタンは削除するため、取り消し処理が行われることはありません。

ページの作成をクリックします。


ページが作成されます。

ページ・プロパティ識別別名reportに変更します。ホーム・ページからリダイレクトする宛先として、この別名を使用します。外観ページ・テンプレートとしてMinimal(No Navigation)を選択し、不要な装飾を除きます。保存されていない変更の警告オフにします。


セキュリティページ・アクセス保護を、引数にチェックサムが必要から制限なしに変更します。ホーム・ページからリダイレクトする際に、チェックサムの計算を省略しているためです。


このページが日本時間で動作するように、レンダリング前/ヘッダーの前にプロセスを作成し、タイムゾーンを設定します。

識別名前日本時間を強制タイプコードの実行を選択します。ソースPL/SQLコードとして以下を記述し、タイムゾーンAsia/Tokyoに設定します。

apex_util.set_session_time_zone('Asia/Tokyo');


ページ・アイテムP3_VOICE_IDタイプ非表示に変更し、設定保護された値オフにします。この値は、音声ファイルのアップロードを行った後に、JavaScriptのコードから設定されます。


ページ・アイテムP3_REPORT_DATEを選択します。

ラベル出没日時とします。設定時間の表示オン外観書式マスクYYYY-MM-DD HH24:MI:SSデフォルトタイプとしてを選択し、PL/SQL式に以下を記述します。

to_char(current_timestamp, 'YYYY-MM-DD HH24:MI:SS')

出没日時の初期値として現在の時刻を設定しています。


ページ・アイテムP3_LATITUDEP3_LONGITUDEを選択します。これらの座標値は地図上をクリックして設定します。ユーザーが入力することはないので、タイプ非表示にします。JavaScriptから値を設定するため、保護された値オフにします。セッション・ステートストレージとしてセッションごと(永続)に変更します。


ページ・アイテムP3_ADDRESSを選択します。このページ・アイテムは逆ジオコーディングを行った住所を、選択リストにします。

タイプ選択リストに変更し、ラベル出没した場所とします。LOVタイプSQL問合せSQL問合せとして以下を記述します。

select address d, address r
from (
select region || ' ' || municipality || ' ' || street || ' ' || sec_unit address
from table(do_reverse_geocoding(
p_latitude => to_number(:P3_LATITUDE)
,p_longitude => to_number(:P3_LONGITUDE)
,p_count => 5
))
)
view raw address-lov.sql hosted with ❤ by GitHub
追加値の表示オフNULL値の表示オフです。カスケードLOV親アイテムP3_LATITUDEP3_LONGITUDEを設定し、これらの値が変更されたときに、選択リストがその座標に近い住所に更新されるようにします。


ページ・アイテムP3_REPORT_TEXTP3_ATTRIBUTESP3_CREATEDP3_CREATED_BYP3_UPDATEDP3_UPDATED_BYは、APEXのプロセスやトリガーによりサーバー側で設定されます。ユーザーが扱うことはありません。

これらのページ・アイテムをすべて選択し、構成ビルド・オプションを使ってコメント・アウトします。


フォームのリージョン熊出没注意を選択します。

外観テンプレートBlank with Attributesに変更し、見かけをシンプルにします。


プロパティ・エディタ属性タブを開きます。

編集実行可能な操作から行の更新行の削除チェックを外します。この後、ボタンSAVE、DELETE、CANCELを削除しますが、ボタンを削除してもJavaScriptコンソールからコードを実行すると編集や削除を呼び出すことができます。この設定により、そのようなリスクからデータを守ることができます。


ボタンCANCELDELETESAVEを選択し、コンテキスト・メニューを表示させて削除を実行します。


ボタンCREATEを選択し、ページ・アイテムP3_UPDATED_BYの下に配置します。

識別ラベル報告に変更し、動作アクション動的アクションで定義に変更します。


外観テンプレート・オプションを開き、詳細WidthStretchSpacing TopLargeに変更します。


地図を表示するリージョンをRegion Bodyに作成します。

Region Body上でコンテキスト・メニューを表示し、リージョンの作成を実行します。

作成したリージョンをページ・アイテムP3_ADDRESSの下に配置します。識別タイトル地図タイプマップにします。外観テンプレートBlank with Attributes詳細静的IDとしてmapを設定します。


レイヤーを選択します。

識別名前現在位置とします。ソースタイプSQL問合せに変更し、SQL問合せとして以下を記述します。
select
    to_number(:P3_LATITUDE) as lat
    ,to_number(:P3_LONGITUDE) as lon
from dual
送信するページ・アイテムとしてP3_LATITUDEP3_LONGITUDEを指定します。

列のマッピングジオメトリ列のデータ型として経度/緯度を選択し、経度列LON緯度列LATを選択します。


マップ・リージョン地図に動的アクションを作成します。

識別名前地図上をクリックとします。タイミングイベントとしてマップがクリックされました[マップ]を選択します。


TRUEアクションJavaScriptコードの実行に変更します。設定コードとして以下を記述します。
apex.items.P3_LATITUDE.setValue(this.data.lat);
apex.items.P3_LONGITUDE.setValue(this.data.lng);
apex.region("map").refresh();

リージョン地図を選択し、プロパティ・エディタ属性タブを開きます。

マップ高さ240に変更します。初期位置およびズームタイプSQL問合せに変更し、SQL問合せとして以下を記述します。ホーム・ページから熊出没報告へ遷移する際にページ・アイテムP3_LATITUDEとP3_LONGITUDEに現在位置の座標値が渡されます。そのため、地図が初期化される際の初期位置現在位置ズーム・レベル12になります。
select
    to_number(:P3_LATITUDE) as lat
    ,to_number(:P3_LONGITUDE) as lon
    ,12 zoom
from dual
ジオメトリ列のデータ型として経度/緯度を選択します。初期経度列LON初期緯度列LAT初期ズーム・レベルZOOMとします。

レイヤーは1つだけなので、凡例表示オフにします。


写真を選択するページ・アイテムを作成します。作成したページ・アイテムはリージョン地図の下に配置します。

識別名前P3_PHOTOSタイプとしてファイル参照...を選択します。ラベル写真とします。

設定表示形式Native File Browseを選択し、モバイル・デバイスでの最適な表示形式を優先します。記憶域タイプTable APEX_APPLICATION_TEMP_FILESファイルをパージするタイミングとしてEnd of Requestを選択します。アップロードした写真ファイルは表BEAR2_PHOTOSに保存するため、一時表に残しておく必要はありません。複数ファイルの許可オンにし、複数のファイルを一度にアップロード可能にします。ファイル・タイプとしてimage/*を指定し、アップロード対象をイメージに限定します。最大ファイル・サイズ10000KB(=10MB)とします。

セッション・ステートストレージリスエストごと(メモリーのみ)を設定します。


音声を録音するためのプレーヤーを配置します。

ページ・アイテムP3_PHOTOS上でコンテキスト・メニューを表示させ、下にリージョンを作成を実行します。


作成したリージョンの識別タイトル録音とします。タイプ静的コンテンツソースのHTMLコードとして以下を記述します。

<audio id="player" controls></audio>

外観テンプレートとしてBlank with Attributesを選択します。


リージョン録音上でコンテキスト・メニューを表示させ、ボタンの作成を実行します。


作成したボタンのボタン名STARTラベル録音開始とします。

レイアウト位置Previous動作アクションとして動的アクションで定義を指定し、詳細カスタム属性として以下を記述します。

data-action="#action$start-audio-recording"


同様の手順でもう一つボタンを作成します。ボタン名STOPラベル録音終了とします。

レイアウト位置Next動作アクションとして動的アクションで定義を指定し、詳細カスタム属性として以下を記述します。

data-action="#action$stop-audio-recording"


ページ・プロパティJavaScriptファンクションおよびグローバル変数の宣言に以下を記述します。残念なことに、このコードではiOS上のSafariでは録音が機能しません。AudioContextを使うとiOSのSafariでもWeb Audio APIが使えるとの情報もありますが、本題から外れるので今回は対応していません。

var mediaRecorder = null;
var mimeType = '';
var chunks = [];
var recording = null; // 録音のBlobデータ
const CONSTRAINTS = {"video": false, "audio": true};
/*
* 録音を開始する。
*/
const START_AUDIO_RECORDING = {
name: "start-audio-recording",
action: function(event, element, args) {
if (mediaRecorder.state == "inactive") {
console.log("start audio recording");
mediaRecorder.start();
}
}
};
/*
* 録音を停止する。
*/
const STOP_AUDIO_RECORDING = {
name: "stop-audio-recording",
action: function(event, element, args) {
if (mediaRecorder.state == "recording") {
console.log("stop audio recording");
mediaRecorder.stop();
}
}
};
/*
* ページ・ロード時に実行する。
*/
window.onload = () => {
/* アクションの初期化 */
apex.actions.add([START_AUDIO_RECORDING,STOP_AUDIO_RECORDING]);
/*
* 音声レコーダーの初期化。
*/
navigator.mediaDevices
.getUserMedia(CONSTRAINTS)
.then((stream) => {
mediaRecorder = new MediaRecorder(stream);
/* 再生の準備ができたときに呼ばれる */
mediaRecorder.ondataavailable = (e) => {
mimeType = e.data.type;
chunks.push(e.data);
console.log("data available", e.data);
};
/* 録音を停止したときに呼ばれる。 */
mediaRecorder.onstop = () => {
recording = new Blob(chunks, {'type': mimeType});
chunks = []; // 今までの録音は削除。
player.src = window.URL.createObjectURL(recording);
console.log("audio recorder stopped.");
};
})
.catch(function (e) {
alert(e);
}
);
/* onload終了 */
}


音声データのアップロードを受け付けて、表BEAR2_VOICESに保存するRESTサービスを作成します。

SQLワークショップからRESTfulサービスを開きます。


モジュールとしてuploadを作成し、そのモジュールにテンプレートとしてvoiceを作成します。voiceのPOSTハンドラとして、ソースに以下のPL/SQLコードを記述します。送信されたファイルを受け付けて表BEAR2_VOICESに保存します。音声ファイルを保存した行の主キーの値を呼び出し元に返します。この返却されたIDは、ページ・アイテムP3_VOICE_IDに設定されます。

declare
l_voice_id bear2_voices.id%type;
begin
insert into bear2_voices(content_type, voice)
values(:content_type, :body)
returning id into l_voice_id;
:status := 201;
htp.p('{ "id": ' || l_voice_id || ' }');
end;
本来であればApex-Sessionヘッダーなどを使ってRESTサービスを保護する必要がありますが、今回の手順では省略しています。保護の方法については、こちらの記事を参照してください。

完全なURLのホスト名以下のパスは、APEXから音声ファイルのアップロードを行う際にURLとして指定します。メモを取っておいてください。


APEXのページ・デザイナに戻ります。

ボタンCREATE動的アクションを作成します。

識別名前録音を保存してから送信とします。タイミングイベントはボタンのデフォルトであるクリックになります。


TRUEアクションJavaScriptコードの実行に変更し、設定コードに以下を記述します。最初に録音データをアップロードし、その後にページの送信を行います。コード中のrequest.openに与えるURLは環境に合わせて変更します。

// 音声を送信してP3_VOICE_IDに主キーを保存する。
if (recording !== null) {
let formData = new FormData();
formData.append("file", recording);
let request = new XMLHttpRequest();
request.onreadystatechange = () => {
if (request.readyState === XMLHttpRequest.DONE) {
const status = request.status;
if (status === 0 || (status >= 200 && status < 400)) {
console.log("send success");
let response_json = JSON.parse(request.response);
$s("P3_VOICE_ID", response_json.id);
apex.page.submit( "CREATE" );
}
else
{
console.log("status = ", status);
};
}
};
/*
* 注意!!!!
* openするURLは、環境に合わせて変更すること。特にワークスペース名は違うはず。
*/
request.open("POST", "/ords/apexdev/upload/voice");
request.send(formData);
}

左ペインでプロセス・ビューを開き、アップロードされた写真ファイルを保存するプロセスを作成します。

新規にプロセスを作成し、プロセスプロセス・フォーム熊出没報告の下に配置します。識別名前写真を保存タイプコードの実行です。ソースPL/SQLコードに以下を記述します。

begin
for c in (select column_value from apex_string.split(:P3_PHOTOS,':'))
loop
for bc in (
select * from apex_application_temp_files
where name = c.column_value
)
loop
insert into bear2_photos(sighting_id, filename, mime_type, photo)
values(:P3_ID, bc.filename, bc.mime_type, bc.blob_content);
end loop;
end loop;
end;
サーバー側の条件ボタン押下時CREATEを指定します。


アップロードされた音声ファイルを、OpenAIのWhisperを呼び出して文字起こしを行い、その文字列からファセット検索に使用する単語を抽出するために、ChatGPTのAPIを呼び出します。ChatGPTのモデルとしてgpt-3.5-turboを指定しています。プロンプトをもっとチューニングすると、より適切な結果が得られると思います。

以下のコードを実行し、パッケージUTL_BEAR_OPENAIを作成します。SQLワークショップSQLスクリプトを使って実行できます。

create or replace package utl_bear_openai
as
/**
* Whisperによる文字起こしを実行する。
*
* @param p_voice_id BEAR2_VOICESの主キー値。
* @param p_text          Whisperによって、音声より文字起こしされた文字列。
*/
procedure transcribe(
p_voice_id in number
,p_text out clob
,p_model in varchar2 default 'whisper-1'
,p_prompt in varchar2 default null
,p_credential_static_id in varchar2 default 'OPENAI_API_KEY'
);
/**
* 熊の出没報告を受け取って、特徴的な単語を抽出してJSON配列で返す。
*
* @param p_text 出没報告
* @param p_attributes 特徴的な単語を含むJSON配列
*/
procedure get_attributes(
p_text in clob
,p_attributes out clob
,p_prompt_system in clob default null
,p_prompt_user in clob default null
,p_credential_static_id in varchar2 default 'OPENAI_API_KEY'
);
end utl_bear_openai;
/
create or replace package body utl_bear_openai
as
procedure transcribe(
p_voice_id in number
,p_text out clob
,p_model in varchar2
,p_prompt in varchar2
,p_credential_static_id in varchar2
)
as
l_content_type bear2_voices.content_type%type;
l_blob_content blob;
l_multipart apex_web_service.t_multipart_parts;
l_multipart_request blob;
l_response clob;
l_response_json json_object_t;
e_api_call_failed exception;
begin
/* アップロードされた音声データをBLOBに取り出す。 */
select voice, content_type into l_blob_content, l_content_type
from bear2_voices
where id = p_voice_id;
/*
* 取り出した音声データをmultipart/form-dataとして、WhisperのAPIを呼び出す。
*/
apex_web_service.clear_request_headers;
/* 音声データの指定。
  *  おそらくWhisper APIはファイル名の拡張子で音声エンコーディングを
* 認識しているみたい。なので、p_filenameを指定しないとエラーが発生する。
*/
apex_web_service.append_to_multipart(
p_multipart => l_multipart
,p_name => 'file'
,p_filename => 'audio.webm'
,p_content_type => l_content_type
,p_body_blob => l_blob_content
);
/* modelの指定。 */
apex_web_service.append_to_multipart(
p_multipart => l_multipart
,p_name => 'model'
,p_content_type => 'text/plain'
,p_body => 'whisper-1'
);
/* 言語の指定 */
apex_web_service.append_to_multipart(
p_multipart => l_multipart
,p_name => 'language'
,p_content_type => 'text/plain'
,p_body => 'ja'
);
/* イニシャル・プロンプトの設定 */
if p_prompt is not null then
apex_web_service.append_to_multipart(
p_multipart => l_multipart
,p_name => 'prompt'
,p_content_type => 'text/plain'
,p_body => p_prompt
);
end if;
l_multipart_request := apex_web_service.generate_request_body(l_multipart);
l_response := apex_web_service.make_rest_request(
p_url => 'https://api.openai.com/v1/audio/transcriptions'
,p_http_method => 'POST'
,p_body_blob => l_multipart_request
,p_credential_static_id => p_credential_static_id
);
if apex_web_service.g_status_code <> 200 then
apex_debug.info('status_code = %s, response = %s', apex_web_service.g_status_code, l_response);
raise e_api_call_failed;
end if;
/* デバッグでJSON自体を見たいときは、l_responseを確認する。 */
-- p_text := l_response;
l_response_json := json_object_t(l_response);
/*
* Whisperを自力で実装したときはsegmentsで分割されたtextが返されたような覚えがあるが、
* OpenAIのAPIは、素直にtextだけを返している。
*/
p_text := l_response_json.get_string('text');
end transcribe;
procedure get_attributes(
p_text in clob
,p_attributes out clob
,p_prompt_system in clob
,p_prompt_user in clob
,p_credential_static_id in varchar2
)
as
l_prompt_system clob;
l_prompt_user clob;
l_request json_object_t;
l_request_clob clob;
l_messages json_array_t;
l_message json_object_t;
l_response_clob clob;
l_response json_object_t;
l_choices json_array_t;
l_role varchar2(80);
l_content clob;
l_usage json_object_t;
e_api_call_failed exception;
begin
if p_prompt_system is null then
l_prompt_system := 'あなたは日本語を話すアシスタントです。';
else
l_prompt_system := p_prompt_system;
end if;
if p_prompt_user is null then
l_prompt_user := q'~これから与える文章から、熊に関係している特徴的な単語を抽出してJSON配列にしてください。JSON配列の名前はwordsにしてください。~' || chr(13) || chr(10)
|| '--------------' || chr(13) || chr(10);
else
l_prompt_user := p_prompt_user;
end if;
l_messages := json_array_t();
/* systemメッセージ */
l_message := json_object_t();
l_message.put('role','system');
l_message.put('content', l_prompt_system);
l_messages.append(l_message);
l_message := json_object_t();
l_message := json_object_t();
l_message.put('role','user');
l_message.put('content', l_prompt_user || p_text);
l_messages.append(l_message);
/* ChatGPTのリクエストを作る */
l_request := json_object_t();
l_request.put('model','gpt-3.5-turbo');
l_request.put('temperature', 0);
l_request.put('messages', l_messages);
/*
* temprature, top_pなどのパラメータを設定するとしたら、ここでputする。
*/
l_request_clob := l_request.to_clob();
/*
* OpenAIのChatGPTのAPIを呼び出す。
*
* 参照: https://openai.com/blog/introducing-chatgpt-and-whisper-apis
* API Ref: https://platform.openai.com/docs/api-reference/chat
*/
apex_web_service.clear_request_headers;
apex_web_service.set_request_headers('Content-Type','application/json',p_reset => false);
l_response_clob := apex_web_service.make_rest_request(
p_url => 'https://api.openai.com/v1/chat/completions'
,p_http_method => 'POST'
,p_body => l_request_clob
,p_credential_static_id => p_credential_static_id
);
if apex_web_service.g_status_code <> 200 then
apex_debug.info('status_code = %s, response = %s', apex_web_service.g_status_code, l_response_clob);
raise e_api_call_failed;
end if;
l_response := json_object_t(l_response_clob);
l_choices := l_response.get_array('choices');
l_message := treat(l_choices.get(0) as json_object_t).get_object('message');
l_role := l_message.get_string('role');
l_content := l_message.get_clob('content');
p_attributes := l_content;
end get_attributes;
end utl_bear_openai;
/

上記のパッケージに含まれるプロシージャを呼び出すプロセスを作成します。

新規にプロセスを作成します。作成したプロセスは写真を保存の下に配置します。

識別名前WhisperとChatGPT呼び出しタイプコードを実行です。ソースPL/SQLコードに以下を記述します。

declare
l_text clob;
l_attributes clob;
l_json json_object_t;
l_array json_array_t;
l_array_clob clob;
begin
/*
* OpenAI Whisperによる文字起こし。
*/
utl_bear_openai.transcribe(
p_voice_id => :P3_VOICE_ID
,p_text => l_text
,p_prompt => '野毛山 ツキノワグマ'
);
update bear2_sightings set report_text = l_text where id = :P3_ID;
/*
* ChatGPTによる単語抽出
*/
utl_bear_openai.get_attributes(
p_text => l_text
,p_attributes => l_attributes
);
l_json := json_object_t(l_attributes);
l_array := l_json.get_array('words');
l_array_clob := l_array.to_clob();
update bear2_sightings set attributes = l_array_clob where id = :P3_ID;
end;
サーバー側の条件ボタン押下時としてCREATEを設定します。


プロセスプロセス・フォーム熊出没報告を選択します。成功メッセージに「目撃報告が送信されました。」を設定し、デフォルトのメッセージを変更します。


ブランチページに移動2を選択し、動作ターゲットを開きます。


アイテムの設定として、名前P2_ID&P3_ID.を追加します。今回作成された出没情報のIDの値が&P3_ID.なので、その値がページ・アイテムP2_IDに渡されます。結果として、今回作成された出没情報が遷移先のページで表示されます。


ページ・デザイナホーム・ページを開きます。

ページ・プロパティ外観ページ・テンプレートMinimal(No Navigation)に変更します。ホーム・ページはアプリケーションにサインインした直後に開きますが、現在地の座標を取得した後すぐにページ番号3の熊出没報告のページに移動します。そのため、ナビゲーションは不要です。


ブレッドクラム熊出没情報の上でコンテキスト・メニューを表示させ、削除を実行します。ホーム・ページにはデフォルトでブレッドクラムが作成されますが、今回の用途ではブレッドクラムは不要です。


ページ・プロパティJavaScriptページ・ロード時に実行に、現在の位置の座標を取得した後、ページ番号3へリダイレクトするコードを記述します。コード中のワークスペース名やアプリケーション名は作成したアプリケーションに合わせて変更が必要です。

/*
* 現在位置を取得し、ページ3の熊出没報告へ渡す。
*/
navigator.geolocation.getCurrentPosition(
(position) => {
const lat = position.coords.latitude;
const lon = position.coords.longitude;
let url = "";
url = url.concat(
/*
* 注意!!!
* ワークスペース名やアプリケーション名は変更します。
*/
"/ords/r/apexdev/bear2-report/report?session="
,apex.env.APP_SESSION
,"&p3_latitude="
,lat
,"&p3_longitude="
,lon
);
console.log(url);
apex.navigation.redirect(url);
}
);


アプリケーション定義に含まれるアプリケーション別名英語名にしておくと、上記の設定が容易になります。


以上でアプリケーションは完成です。アプリケーションを実行すると、記事の先頭のGIF動画のように動作します。

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

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