で、これクリックできるの?→できました。nebula.jsの自作チャートにQlikの「選択・連想・ドリルダウン」を通してみた

前回の記事「[BIでパックマンを作ったnebula.jsは、実務でこそ効く。Qlik標準の表を11通りのリッチUIにしてみた]」の最後で、こんなことを書きました。

> で、これクリックできるの?

見た目はいくらでも派手にできる。でも Qlik の本体は見た目じゃなくて、クリックして選ぶと全部が絞り込まれる「連想」のほうです。そこが動かないなら、ただの綺麗な静止画です。

「たぶんできるんだと思います」と逃げていたので、今回はちゃんとやりました。結論から言うと、できました。しかも、思っていたよりずっとコードは少なかったです。

今回やったのはこの3つ。

1. 自作チャートの行をクリックしたら、Qlik の「選択」になる

2. 別の場所で選択したら、自作チャートが勝手に絞り込まれる(連想)

3. カテゴリを選んだら、商品の階層に降りる(ドリルダウン)

データは前回と同じトイデータ(カテゴリ × 月 × 売上)です。見た目の数字も前回から1円も変わっていません。

目次

1. クリックを Qlik の「選択」に変える

前回のおさらいですが、nebula.js のチャートは Qlik のハイパーキューブからデータを受け取って、あとは DOM で自由に描いていました。

クリックを「選択」にするために必要なのは、ハイパーキューブの各セルに付いてくる **`qElemNumber`** です。これが「この値は Qlik 内部で何番の要素か」を示す ID で、選択するときはこの番号を渡します。

なので、描画するときに行の要素へ `data-elem` として埋め込んでおきます。

const rows = matrix.map((r) => ({
  category: r[0].qText,
  elem: r[0].qElemNumber, // 選択に使う要素ID
  state: r[0].qState,     // 選択状態(S:選択 / X,A:除外 / O:未選択)
  month: r[1].qText,
  sales: r[2].qNum,
}));

// 描画側
`<div class="row hit" data-elem="${row.elem}">…</div>`

クリックを受けたら、nebula.js の useSelections() で取れる selections に渡すだけです。

function onClick(evt) {
  const hitEl = evt.target.closest('[data-elem]');
  if (!hitEl) return;
  selections.select({
    method: 'selectHyperCubeValues',
    params: ['/qHyperCubeDef', 0, [Number(hitEl.dataset.elem)], false],
  });
}
element.addEventListener('click', onClick);

ポイントは2つあります。

イベントは要素そのものではなく、外側の elementで受ける。 前回の実装は innerHTMLを丸ごと書き換えて描いているので、行ひとつひとつに addEventListener しても、次にデータが来た瞬間に消えます。外枠の element は生き残るので、そこでイベント委譲で受けるのが楽です。

select() の前に begin() は要らない。 selections.select() は内部で選択モードに入ってくれます。ただし params[0] がそのまま begin に渡されるので、第1引数は必ず '/qHyperCubeDef' のようなパスにしておく必要があります。

選ばれていない行を薄くする

Qlik っぽさを出すなら、選択中は「選ばれていないもの」をグレーアウトしたいところです。これはさっきの qState を見るだけです。

const anySelected = table.some((t) => t.state === 'S');
const selClass = (t) => (!anySelected ? '' : t.state === 'S' ? ' sel-on' : ' sel-off');
.sel-off { opacity: .28; filter: saturate(.25); }

13パターン全部が同じデータ整形を通っているので、この数行で全パターンがまとめて選択対応になりました。前回、データ整形を共通化しておいて本当によかった。

2. 連想フィルタ:コードは1行も書いていない

次は「別の場所で選んだら、自作チャートが絞り込まれる」です。

これを試すために、マッシュアップのページを1枚作りました。左に顧客・性別・年齢のリストボックス、右に今回の自作チャートを並べただけのページです。トイデータには顧客(12人)を足して、売上を顧客単位に分解しています(カテゴリ × 月の合計は前回と完全に一致することを検算済み)。

で、肝心の「リストボックスと自作チャートを繋ぐコード」ですが、1行もありません。

const nuked = window.stardust.embed(app, {
  types: [{ name: 'rich-table', load: () => Promise.resolve(window['rich-table']) }],
});

// リストボックスは stardust の組み込み。自作していない
const fi = await nuked.field('CustomerName');
await fi.mount(document.getElementById('lb-customer'), { title: '顧客', search: true });

// 自作チャート
await nuked.render({ element: document.getElementById('viz'), type: 'rich-table' });

同じ app に両方をぶら下げているだけです。

これが Qlik の連想エンジンの強いところで、選択は「このチャートの選択」ではなくアプリ全体のフィールドの選択になります。すると、同じアプリに紐づいているオブジェクトはエンジンが全部再計算して、新しい layout を送り直してくれます。自作チャートはそれを受けて描き直しているだけ。

つまり、1章で「クリックを選択に変える」さえやっておけば、連想は勝手についてきます。逆方向(自作チャートで選ぶ → リストボックスが絞られる)も、当然そのまま動きます。

3. ドリルダウン:降りるAPIは存在しない

最後がドリルダウン。カテゴリ(化粧品、玩具…)をクリックしたら、その中の商品(ゲーム、フィギュア…)に降りる、あれです。

まずハイパーキューブの次元を「階層グループ」にします。qGrouping: 'H' を付けて、qFieldDefs に上から順にフィールドを並べるだけです。

qDimensions: [
  {
    qDef: {
      qGrouping: 'H',
      qFieldDefs: ['Category', 'Product'],
      qFieldLabels: ['カテゴリ', '商品'],
    },
  },
  { qDef: { qFieldDefs: ['Month'] } },
],

さて、ここからが今回いちばん驚いたところです。「降りる」ための API は存在しません。 drillDown みたいなメソッドを探したんですが、エンジン側にありませんでした。

じゃあどうやって降りるのかというと、エンジンには

階層グループでは、可能値が2つ以上ある最初のフィールドを使う

というルールがあります。カテゴリで「玩具」を1つ選ぶと、Category の可能値が1つになる。するとエンジンは Category を飛ばして、次の Product を使い始める。つまり降りるのは「選択」の副作用で、クライアントからは何もしていません。1章のクリック処理がそのままドリルダウンになっていた、というオチです。

ちなみに、クリックした瞬間にはまだ降りません。クリック直後は「選択モード」の途中で、選択が確定していないからです。選択バーの ✓ で確定したタイミングで、可能値が1つになって階層が切り替わります。このあたりも「クリックに反応しているわけじゃない」ことがよく分かります。

一方で、上がるときだけは明示的に呼ぶ必要があります。

model.drillUp('/qHyperCubeDef', 0, 1); // 次元0を1段上がる

なので、今どの階層にいるかを layout の qGroupPos(0始まりの現在位置)と qGroupFieldDefs(階層の一覧)から読んで、パンくずを出しました。上の階層のパンくずをクリックすると drillUp します。

これも13パターン共通の処理なので、全パターンが一斉にドリル対応になりました。3D風の棒グラフでも、ピクセルアートでも、ちゃんと商品に降ります。

ハマったポイント(毎度おなじみ)

1. ハイパーキューブの定義を変えても反映されない

qGrouping: 'H' を足したのに、何度リロードしても階層グループになりません。エンジンに無視されているのかと思いました。

犯人は nebula serve の右パネルにある Enable property cache でした。これが既定で有効で、localStorage に保存された古いプロパティが、object-properties.js の新しい定義を後から上書きしていました。つまり、変更がそもそもエンジンに届いていなかった。

チェックを外すか、それでもダメならキャッシュ本体を消します。

localStorage.removeItem('nebula-dev'); location.reload();

切り分けのために、model.getProperties() で「エンジンに実際に登録されている定義」を画面に出す診断表示も作りました。ここに qGrouping: 'H' が無ければ、定義が届いていない(キャッシュ側の問題)。有れば、エンジン側の問題。これで一発で分かるようになりました。

2. 顧客を選んだだけでドリルダウンしてしまう

3章のルールは「クリックしたら降りる」ではなく「可能値が1つになったら降りる」です。

なので、1カテゴリしか買っていない顧客をリストボックスで選ぶと、その瞬間にカテゴリの可能値が1つになって、勝手に商品階層に降ります。挙動としては正しいんですが、見ている側からすると「なんで今降りた?」になります。

今回はトイデータ側で、全顧客が3カテゴリ以上を買うようにして回避しました。実データでは起こり得るので、「そういうもの」と思っておくのが大事です。

3. Qlik Cloud の WebSocket が繋がらない

マッシュアップから Qlik Cloud に繋ぐとき、Web Integration ID だけでは通りませんでした。

GET /api/v1/csrf-token を叩いて、レスポンスヘッダの qlik-csrf-token を取り、Web Integration ID と一緒にクエリへ付ける必要があります。

const res = await fetch(`https://${host}/api/v1/csrf-token`, {
  credentials: 'include',
  headers: { 'qlik-web-integration-id': wid },
});
const csrf = res.headers.get('qlik-csrf-token');
const url = `wss://${host}/app/${appId}?qlik-web-integration-id=${wid}&qlik-csrf-token=${csrf}`;

あと地味に、URL をコピペしたときに Web Integration ID に全角スペースが混入していたことがありました。見た目では絶対に分かりません。今は空白類を問答無用で除去しています。

まとめ:クリックさえ Qlik に返せば、あとはエンジンがやってくれる

やってみて分かったのは、自作チャート側でやることは本当に少ない、ということでした。

  • 選択:qElemNumber を selectHyperCubeValues に渡すだけ
  • 連想:コード0行。同じ app にぶら下げればエンジンが再計算する
  • ドリルダウン:降りるのは選択の副作用。書くのは drillUp だけ

前回の結論は「データさえ取れれば、あとは Web の表現力そのまま」でした。今回はその続きで、**「クリックさえ Qlik に返せば、あとは Qlik の連想エンジンそのまま」**です。見た目は Web、頭脳は Qlik。いい分担だと思います。

次回予告:トイデータを卒業します

ここまで、カテゴリ8つ × 12か月のかわいいトイデータでやってきました。次回は日本版スーパーストア(1万行・4年分)に載せ替えて、

  • 製品(カテゴリ → サブカテゴリ → 製品名)と時間(年 → 四半期 → 月)の2軸ドリル
  • 「割引しているのに利益が出ていない」を見つける利益分析
  • クリックすると全チャートが連動するダッシュボード

あたりをやる予定です。実データは、たぶんまた殴ってきます。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

目次