mcpField
A
app-studio-pro-code

app-studio-pro-code

github.com/stahura開発2438 回インストール26 週間前に更新
リポジトリ

app-studio-pro-code について

app-studio-pro-code は、標準カードで要件を満たせない場合に、Domo App Studio のページへ JavaScript 製カスタムアプリカードを組み込む手順を案内します。ページ構成やカード配置に加え、ページフィルターや変数との連携も扱います。

主な機能

  1. 01標準カードと Pro Code の使い分け
  2. 02ページ構成とカード配置の計画
  3. 03カスタムアプリカードの構築と配置
  4. 04ページフィルターや変数の変更を受信
  5. 05App Studio 変数の読み取りと更新
  6. 06ビジュアライゼーション用パレットの利用

活用シーン

  1. 01ガントチャートやヒートマップなどの作成
  2. 02複数ステップのフォームや複雑な操作の構築
  3. 03ページフィルターによるアプリデータの更新
  4. 04変数によるアプリ状態の制御や更新

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/stahura/domo-ai-vibe-rules --skill app-studio-pro-code

インストール後、Claude Code で/app-studio-pro-codeを入力して読み込まれたか確認します。

同梱ファイルreferences/app-studio-integration.mdreferences/design-skills.md

詳細

App Studio でのプロコードカスタムアプリ

カスタムパレットが必須です:Domo のネイティブ/デフォルトのカラーパレットは使用しないでください。必ず domo-app-theme/references/color-palettes.md からプロジェクト用に選定されたパレットを使用してください。プロコードの CSS では OKLCH 値を直接使用し、ネイティブカードのオーバーライドには HEX 値に変換してください。すべてのチャート系列、テキスト、グリッド、ステータスの色に、選定したパレットを使用してください。

App Studio の Pro Code Editor では、JavaScript のカスタムアプリ全体を App Studio のキャンバス上にカードとして埋め込めます。このスキルは、カスタムアプリの構築(basic-custom-app-build)と、App Studio のページへのカードの配置(basic-app-studio/advanced-app-studio)という、既存の 2 つのワークフローをつなぎます。

使用するタイミング

ユーザーの要件が Domo のネイティブカードで実現できる範囲を超える場合に、このスキルを使用してください。ネイティブカードで対応できる場合は、app-studio のみを使用してください。

選択方法:ネイティブカードとプロコード

ユーザーの要件にある各コンポーネントを、次の表に照らして評価してください。プロコードに該当する要件が 1 つでもあれば、そのコンポーネントはカスタムアプリにしてください。

要件ネイティブカードプロコード
標準的なチャート(棒、折れ線、円、ドーナツ、ファネル)X
KPI/比較付き単一値X
並べ替え可能なシンプルなデータテーブルX
ドロップダウン/日付ピッカーのフィルターX
カスタムビジュアライゼーション(ガント、ヒートマップ、組織図、カレンダー、ネットワークグラフ)X
複数ステップのフォームやウィザードのフローX
リアルタイムのアニメーションやトランジションX
カードのルールを超える複雑な条件付き書式X
カード間のインタラクティブ性(あるカードでの選択が別のカードを動かす)X
カスタム UI を使った AppDB ドキュメントの CRUDX
AI 機能(チャット、生成、テキストから SQL への変換)X
ca1–ca8 スタイルプリセットを超えるカスタムスタイルX
サードパーティライブラリの埋め込み(D3、Mapbox、AG Grid)X

| グラデーション、テキスト、ブランディングを含むヘッダー/バナー画像 | | X | | 計画/実績/予測と信頼帯を含む複数系列の時系列チャート | | X |

1 つの App Studio ページにネイティブカードとプロコードカードを混在させることができます。ページ単位ではなく、コンポーネントごとに判断してください。

カードインスタンスの作成:domo publish は Asset Library にデザインを作成します。カードインスタンスは、2 段階の API を使ってプログラムから作成します。全体の流れについては、app-studio の「カスタムアプリカードインスタンスの作成」セクションを参照してください(POST /domoapps/apps/v2/contexts → POST /domoapps/apps/v2)。各カードインスタンスには、独立したデータセットマッピングを持つ専用のコンテキストが割り当てられます。

構築ワークフロー

ステップ 1 — ページを計画する

対象ページ上のすべてのコンポーネントを列挙した配置計画を作成してください。

Page: "Vendor Performance"
  1. Region filter         → native dropdown card
  2. Date range filter     → native date picker card
  3. Revenue KPI           → native KPI card
  4. Cost KPI              → native KPI card
  5. Margin KPI            → native KPI card
  6. Revenue trend chart   → native line chart card
  7. Gantt timeline        → PRO-CODE (custom Gantt, no native equivalent)
  8. Vendor scorecard      → PRO-CODE (interactive table with inline editing via AppDB)
ステップ 2 — ネイティブカードを構築する

app-studio を使用して、ネイティブカード(上記の項目 1~6)を作成して配置してください。間隔、行の調和、密度については、app-studio のデザインを考慮したレイアウトルールに従ってください。

ステップ 3 — プロコードアプリを構築する

プロコードコンポーネントごとに、次のスキルスタックを事前に読み込んでから、basic-custom-app-build のプレイブックを実行してください。

必ず読み込む:

  • domo-app-theme/references/color-palettes.md のカスタムパレット — 用途に合った、厳選された OKLCH パレットを選択します
  • make-interfaces-feel-better のデザイン原則 — design-skills.md を参照してください

該当する場合に読み込む:

  • writing-better — アプリにユーザー向けのテキスト(ラベル、ツールチップ、エラーメッセージ、空の状態、ヘルプテキスト)が多く含まれる場合に読み込みます。

basic-custom-app-build の各フェーズはすべて適用されます:マニフェスト、アプリシェル、データアクセス、ストレージ、ツールキット、機能スキル、パフォーマンスレビュー、ビルド、公開。

ステップ 4 — カードインスタンスを作成し、App Studio ページに配置する

domo publish が成功したら、2 段階の API を使ってデザインからカードインスタンスを作成します。

重要:コンテキスト/カード作成エンドポイントでは、/api/ プレフィックスではなく、ルートドメイン(https://{instance}.domo.com/domoapps/...)を使用します。/api/domoapps/... を使用すると、404 "No static resource" が返されます。

DOMO = f"https://{instance}.domo.com"  # NOT {BASE} which has /api/

# Step 4a: Create a context for the custom app
ctx_body = {
    "designId": design_id,  # from manifest.json id field
    "mapping": [
        {"alias": alias, "dataSetId": ds_guid, "fields": [], "dql": None}
    ],
    "collections": [],
    "accountMapping": [], "actionMapping": [],
    "workflowMapping": [], "packageMapping": [],
    "isDisabled": False
}
resp = requests.post(f"{DOMO}/domoapps/apps/v2/contexts", headers=HEADERS, json=ctx_body)
context_id = resp.json()[0]["id"]

# Step 4b: Create the card on the target page
import urllib.parse
title = urllib.parse.quote("My Custom Chart")
resp = requests.post(
    f"{DOMO}/domoapps/apps/v2?fullpage=false&pageId={page_id}&cardTitle={title}",
    headers=HEADERS, json={"contextId": context_id, "id": context_id})

# Step 4c: Find the numeric card ID on the page
cards = requests.get(f"{BASE}/content/v1/pages/{page_id}/cards", headers=HEADERS).json()
card_id = next(c["id"] for c in cards if c["title"] == "My Custom Chart")

データセットのないアプリ(バナー)では、"mapping": [] を使用します。ページ固有のバナーについては、ページごとに個別のカードインスタンスを作成します。

カードは自動的にページに追加されます。レイアウトテンプレートを更新して、付録からキャンバスに移動します(ネイティブカードと同じ手順です。app-studio を参照してください)。

ステップ 5 — プロコードカードのレイアウトを設定する

プロコードカードには通常、ネイティブカードよりも広いキャンバス領域が必要です。推奨サイズは次のとおりです。

プロコードアプリの種類幅高さ備考
全幅のインタラクティブ(ガントチャート、タイムライン、地図)6030–40行全体を使用
半幅のビジュアライゼーション(カスタムチャート、スコアカード)3022–30別のカードと並べて配置
コンパクトなウィジェット(ステータスインジケーター、ミニフォーム)2015–20横に 3 つ配置
縦長のインタラクティブ(チャット、複数ステップのフォーム)20–3035–45縦方向のスペースが必要

プロコードカードのコンテンツエントリプロパティを設定します。

{
  "hideTitle": true,
  "hideDescription": true,
  "hideFooter": true,
  "hideBorder": false,
  "hideMargins": false,
  "fitToFrame": true,
  "acceptFilters": true,
  "acceptDateFilter": true,
  "acceptSegments": true
}

カスタムアプリは独自のヘッダーとフッターを描画するため、プロコードカードでは通常、hideTitle: true と hideFooter: true を指定します。fitToFrame: true を指定すると、アプリがグリッドセル全体に表示されるように拡大縮小されます。

App Studio との統合:フィルターと変数

App Studio に埋め込まれたプロコードカードは、外部入力として 2 種類の情報を受け取ります。ネイティブフィルターカードからのページレベルのフィルターと、App Studio の変数コントロールからの変数です。どちらも、トップレベルでリスナーを明示的に登録する必要があります。詳細なパターンと本番環境での例については、app-studio-integration.md を参照してください。

ページレベルのフィルター

domo.onFiltersUpdated(過去形)を、コンポーネントのライフサイクルや useEffect の外側にあるトップレベルで登録します。同じ App Studio ページ上のネイティブフィルターカードをユーザーが変更するたびに呼び出されます。

domo.onFiltersUpdated((filters) => {
  if (!Array.isArray(filters) || filters.length === 0) return;

  filters.forEach((filter) => {
    const { column, operand, values, dataType } = filter;
    // Map column to internal state, then refetch data
  });

  fetchData();
});

フィルターオブジェクトの形式 — 配列の各要素は次のとおりです。

フィールド型説明
columnstringデータセットの列名(例:"YEAR_OF_EVENT")
operandstringoperator ではありません。BETWEEN、IN、GREATER_THAN_EQUAL、LESS_THAN_EQUAL、EQUALS のいずれか
valuesarrayフィルター値。BETWEEN の場合は 2 要素 [min, max]、IN の場合は選択された値の配列、スカラーオペランドの場合は 1 要素です。
dataTypestring列のデータ型(例:"LONG"、"STRING"、"DATE")

オペランドの処理パターン:

オペランド値マッピング方法
BETWEEN[min, max]範囲フィルター:{ min: Number(values[0]), max: Number(values[1]) }
IN["val1", "val2"]カテゴリ値:配列として保存します。空の配列は「すべて」(フィルターなし)を意味します。
GREATER_THAN_EQUAL[threshold]範囲の .min を更新
LESS_THAN_EQUAL[threshold]範囲の .max を更新

列名の違いへの対応:Domo の列名は異なる形式で届くことがあります(例:YEAR_OF_EVENT、YEAROFEVENT、Year of Event)。column?.toUpperCase() で正規化し、switch/case で一般的な表記の違いに対応します。

これを機能させるには、次の条件を満たす必要があります。

  • コンテンツエントリで acceptFilters: true が設定されている
  • カスタムアプリが、フィルターの対象と同じデータセットを使用している
  • データセットが App Studio ページのカード設定とカスタムアプリの manifest.json の両方でマッピングされている
  • フィルターの処理後、直ちにデータを再取得する。ページフィルターは明示的な「適用」操作ではなく、変更時に適用されます
App Studio の変数
API による変数の作成

プロコードアプリで変数を使用する前に、変数を作成しておく必要があります。variable: true を指定して、関数テンプレート API から作成します。

resp = requests.post(
    f"{BASE}/query/v1/functions/template?strict=false",
    headers={"X-Domo-Authentication": SID, "Content-Type": "application/json"},
    json={
        "name": "Selected Metric",
        "locked": False,
        "global": True,
        "expression": "'Revenue'",     # Default value as SQL literal
        "links": [],
        "aggregated": False,
        "analytic": False,
        "nonAggregatedColumns": [],
        "dataType": "STRING",           # STRING, DECIMAL, LONG, DATE
        "cacheWindow": "non_dynamic",
        "columnPositions": [],
        "functions": [],
        "functionTemplateDependencies": [],
        "archived": False,
        "hidden": False,
        "variable": True,               # CRITICAL — makes this a Variable
    },
    timeout=15,
)
data = resp.json()
function_id = data["id"]     # e.g., 115511 — use this numeric ID in pro-code

返された id は、プロコードアプリが domo.onVariablesUpdated と domo.requestVariablesUpdate で使用する数値の functionId です。この ID を保存してください。API で作成した変数とプロコードアプリをつなぐものです。

変数コントロールは App Studio エディターで手動で追加する必要があります。この操作に対応する API はありません。変数を作成した後、エディターでバインドするようユーザーに案内してください。アプリを開く → 左側のツールバーにある[Controls]アイコンをクリック → コントロールをキャンバスにドラッグ → バインドする変数を選択します。UI の手順については、app-studio スキルを参照してください。

プロコードでの変数の使用

domo.onVariablesUpdated(過去形)をトップレベルで登録します。App Studio の変数コントロールの値が変更されるたびに呼び出されます。変数は数値文字列 ID(例:"858"、"860")で識別されます。

domo.onVariablesUpdated((variables) => {
  if (!variables || typeof variables !== "object") return;

  const typeVar = variables["860"];
  if (typeVar?.parsedExpression?.value) {
    const label = typeVar.parsedExpression.value;
    const mapped = LABEL_TO_VALUE_MAP[label];
    if (mapped) state.pendingValues.someField = { label, value: mapped };
  }

  updateUI();
});

変数オブジェクトの形式 — variables はキー付きオブジェクトで、各キーは変数の数値の function ID です。

{
  "858": { "parsedExpression": { "exprType": "LITERAL", "value": "Year Sold" } },
  "860": { "parsedExpression": { "exprType": "LITERAL", "value": "Cumulative" } }
}

保留/確定パターン:変数はキー入力や選択のたびに呼び出されます。次のように、2 段階で処理します。

  1. onVariablesUpdated で値を pendingValues(ステージング領域)に保存します
  2. ユーザーが[Apply]ボタンをクリックし、保留中の値を確定済みの状態に反映します
  3. [Apply]の操作でデータを再取得します

これにより、ユーザーが複数の変数コントロールを調整している間に、短時間で何度も再取得されるのを防げます。

ラベルから値へのマッピング:変数の値は表示ラベル(例:"Days"、"Cumulative")として渡されます。カスタムアプリでは、ルックアップテーブルを使ってこれらを内部の列挙値にマッピングする必要があります。

const RETENTION_TYPE_MAP = {
  "Cumulative": "CUMULATIVE",
  "Marginal": "ADJUSTED_MARGINAL_RETENTION",
  "Lifetime": "LIFETIME",
};
変数の書き戻し

プロコードアプリでは、domo.requestVariablesUpdate を使って App Studio の変数を更新できます。これにより、双方向の通信が可能になります。たとえば、イベントタイプを選択したときに、依存するイベントサブタイプ変数を自動的に更新できます。

domo.requestVariablesUpdate(
  [{ functionId: 873, value: "Initial Service" }],
  (ackResponse) => { /* acknowledged */ },
  (replyResponse) => { /* completed */ }
);

ループの防止:変数を更新すると、onVariablesUpdated が再度呼び出されます。フラグを使って制御してください。

state.isUpdatingVariable = true;
domo.requestVariablesUpdate(updates, onAck, (reply) => {
  state.isUpdatingVariable = false;
});

// In the onVariablesUpdated handler:
if (state.isUpdatingVariable) return;
統合の概要
仕組みAPI呼び出されるタイミング再取得の方法
ページフィルターdomo.onFiltersUpdatedネイティブのフィルターカードが変更されたとき即時 — フィルターが変更されるたびに再取得
変数domo.onVariablesUpdated変数コントロールが変更されたときステージング — 保留中の値は[Apply]のクリック時に確定
変数の書き戻しdomo.requestVariablesUpdateアプリが変数を更新する必要があるときisUpdatingVariable フラグで制御

テーマとの調整

プロコードカードは独自の CSS を持つ iframe 内で描画されるため、App Studio アプリのテーマを継承しません。外観の一貫性を保つには、次の点に従ってください。

  1. Domo ネイティブのカラーパレットは絶対に使用しないでください。 必ず domo-app-theme/references/color-palettes.md から選んだ厳選パレットを使用してください。CSS カスタムプロパティでは OKLCH 値を直接使うか、インライン JS で色を参照する場合は 16 進数に変換します。すべてのグラフ系列、テキスト、グリッド線、ステータスカラーを App Studio テーマのカスタムパレットに合わせてください。プロコードでのパレットのマッピング例:
    • 系列 1(プライマリ):カスタムパレットの brand-500
    • 系列 2:カスタムパレットのセカンダリ ORDER_SOURCE(c22)
    • 系列 3:カスタムパレットのターシャリ ORDER_SOURCE(c29)
    • 系列 4:カスタムパレットのクォータナリ ORDER_SOURCE(c36)
    • グリッド線:neutral-200
    • プライマリテキスト:neutral-950
    • セカンダリテキスト:neutral-500
    • 成功:success-600 | エラー:error-600
  2. ページの背景に合わせます。周囲のキャンバスに自然になじむよう、カスタムアプリの body の背景を transparent に設定します。
  3. カードのスタイルに合わせます。プロコードのコンテナは background: transparent とし、box-shadow と border-radius を指定しません。カードの外枠は App Studio がすべて提供します。
  4. プロコードのコンテナにドロップシャドウを付けないでください。 カードのスタイル(ca1、1px の境界線、シャドウなし)でカードの縁が表示されます。iframe 内にシャドウを追加すると、二重の境界線のように見えます。
Pro-Code アプリ向けのカラーパレットのワークフロー

Pro-Code アプリを構築する際は、App Studio のテーマに適用したものと同じ厳選されたパレットを必ず使用してください。パレットの参照元は、プロジェクトの domo-app-theme/references/themes/ にある DESIGN.md です。Pro-Code アプリでは、CSS カスタムプロパティを使って OKLCH 値を使用するか、インライン JS で参照する場合は HEX に変換してください。各 Pro-Code の app.js の先頭に、プロジェクトのパレット値を設定した COLORS オブジェクトを定義します。

重要 — テーマカラーの継承:Pro-Code コンポーネントは iframe 内でレンダリングされるため、App Studio のテーマカラーは継承されません。Pro-Code の CSS と JS で使用するすべての色は、App Studio のテーマに使用したものと同じ DESIGN.md から明示的に導出する必要があります。これには以下が含まれます。

  • バナーのグラデーション背景、アクセントのオーバーレイ、ブランドテキストの色、上部の境界線
  • チャートのプライマリカラー(P)、グリッド線、軸のテキスト、ツールチップの背景
  • 複数系列のチャートの線/棒の色(C1、C2、C3...)
  • すべてのテキストカラー(タイトル、サブタイトル、ラベル、凡例)

テーマパレットが変更された場合(例:グリーンからカッパーへ)、新しい色を反映してすべての Pro-Code コンポーネントを更新し、再公開する必要があります。自動的に継承されることはありません。これを忘れると、ネイティブの Domo カード(テーマに従う)と Pro-Code カード(ハードコードされた値を使用)の間に、目立つ不一致が生じます。

時間軸の目盛り密度

数か月/数年にわたる日次データを使って時系列チャートを構築する場合、日次の目盛りをそのまま表示すると重なり、判読できなくなります。「N 日ごと」の間隔戦略を使用してください。

const calcTickInterval = (dataLength) => {
  if (dataLength <= 30) return 0;          // show every tick
  if (dataLength <= 90) return 6;          // ~weekly
  if (dataLength <= 180) return 13;        // ~biweekly
  return Math.ceil(dataLength / 18) - 1;   // ~18 visible ticks
};

// In XAxis:
// interval={aggregation === 'day' ? calcTickInterval(data.length) : 'preserveStartEnd'}

どのビューポート幅でも、表示される目盛りラベルが約 15~20 個になるようにします。データが密集している場合は、ラベルを傾けて angle: -35、textAnchor: 'end' を使用してください。

Pro-Code カードの影と境界線の処理

App Studio に埋め込む Pro-Code カードでは、最外部のコンテナに独自の box-shadow や border-radius を設定しないでください。カードの枠のスタイルは、App Studio がコンテンツエントリーの style.sourceId を介して処理します。カスタムアプリのコンテナには background: transparent; border-radius: 0; padding: 20px 24px 16px; を設定し、カードのスタイルに枠の装飾を任せます。

境界半径 0 は必須です — すべてのカードスタイルで borderRadius: 0 を使用するため、Pro-Code のコンテナも border-radius: 0 にする必要があります。角を丸くしないでください。

レスポンシブ動作

App Studio は、standard と compact のテンプレートを使い、2 つのブレークポイントで Pro-Code カードをレンダリングします。カスタムアプリ独自のレスポンシブ CSS は、その iframe 内で独立して動作します。

コンパクト(モバイル)テンプレートの場合:

  • Pro-Code カードの幅を 12(モバイルの全幅)に設定します
  • コンテンツが縦に積み重なるため、高さはデスクトップ版より高くします
  • 狭いビューポートには、カスタムアプリ内部のレスポンシブブレークポイントで対応します

デザインスキルとの統合

Pro-Code アプリにデザインの仕上げを適用する詳しいガイダンスについては、design-skills.md を参照してください。

ワークフローの完全な例

ネイティブ KPI カード 4 枚と Pro-Code のガントチャート 1 つを含む App Studio ページを構築します。

import requests

BASE = "https://instance.domo.com/api"
HEADERS = {"X-DOMO-Developer-Token": TOKEN, "Content-Type": "application/json"}

# 1. Create the App Studio app
app = requests.post(f"{BASE}/content/v1/dataapps",
    headers=HEADERS, json={"title": "Project Tracker"}).json()
page_id = app["landingViewId"]

# 2. Create 4 native KPI cards on the page
kpi_ids = []
for card_def in kpi_definitions:
    resp = requests.put(f"{BASE}/content/v3/cards/kpi?pageId={page_id}",
        headers=HEADERS, json=card_def)
    kpi_ids.append(resp.json()["id"])

# 3. Build and publish the Gantt custom app (via basic-custom-app-build skill)
# ... scaffold, code, npm run build, cd dist, domo publish ...
gantt_design_id = "abc12345-..."  # from manifest.json id field

# 4. Create a card instance from the published design
# NOTE: Use root domain (DOMO), NOT {BASE} which has /api/ prefix
DOMO = f"https://{instance}.domo.com"
import urllib.parse
ctx = requests.post(f"{DOMO}/domoapps/apps/v2/contexts", headers=HEADERS, json={
    "designId": gantt_design_id,
    "mapping": [{"alias": "tasks", "dataSetId": dataset_id, "fields": [], "dql": None}],
    "collections": [], "accountMapping": [], "actionMapping": [],
    "workflowMapping": [], "packageMapping": [], "isDisabled": False
}).json()[0]
context_id = ctx["id"]
requests.post(
    f"{DOMO}/domoapps/apps/v2?fullpage=false&pageId={page_id}&cardTitle={urllib.parse.quote('Project Gantt')}",
    headers=HEADERS, json={"contextId": context_id, "id": context_id})
gantt_card_id = next(c["id"] for c in requests.get(
    f"{BASE}/content/v1/pages/{page_id}/cards", headers=HEADERS).json()
    if "Gantt" in c.get("title", ""))

# 5. Get and update the layout
layout = requests.get(f"{BASE}/content/v4/pages/{page_id}/layouts",
    headers=HEADERS).json()
layout_id = layout["layoutId"]

# Acquire write lock
requests.put(f"{BASE}/content/v4/pages/layouts/{layout_id}/writelock",
    headers=HEADERS, json={})

# Build template: KPIs on row 1, Gantt below
# (Read actual contentKeys from layout["content"] first)
std_template = [
    {"type": "SPACER", "contentKey": 0, "x": 0, "y": 0,
     "width": 60, "height": 3, "virtualAppendix": False,
     "virtual": False, "children": None},
    # ... KPI cards at y=3, width 15 each, height 10 ...
    # ... SPACER at y=13, height 3 ...
    # ... Gantt card at y=16, width 60, height 35 ...
]

# Set Gantt card content properties
for c in layout["content"]:
    if c.get("cardId") == gantt_card_id:
        c["hideTitle"] = True
        c["hideFooter"] = True
        c["fitToFrame"] = True

layout["standard"]["template"] = std_template
# ... build compact template similarly ...

requests.put(f"{BASE}/content/v4/pages/layouts/{layout_id}",
    headers=HEADERS, json=layout)
requests.delete(f"{BASE}/content/v4/pages/layouts/{layout_id}/writelock",
    headers=HEADERS)

動作確認済みの内容

App Studio で稼働する本番環境の Pro-Code アプリに基づいています。

  1. フィルターの伝播は動作します:同じページ上のネイティブフィルターカードが変更されると、Pro-Code カードで domo.onFiltersUpdated が発火します。コンテンツエントリーには acceptFilters: true が必要です。フィルターオブジェクトでは operator ではなく operand を使用します。
  2. 変数の伝播は動作します:App Studio の変数コントロールが変更されると、domo.onVariablesUpdated が発火します。変数は数値の関数 ID で識別されます。domo.requestVariablesUpdate を使うと、変数を更新できます。
  3. テーマの分離を確認済み:Pro-Code カードは分離された iframe 内でレンダリングされます。App Studio から継承される CSS はありません。カスタムアプリ側でスタイルをすべて指定する必要があります。
  4. domo.get はページフィルターを暗黙的に適用します:domo.get("/data/v1/alias") を使う場合、プラットフォームはアクティブなページフィルターをデータリクエストに自動的に適用します。ただし、これは Data API v1 のクエリでのみ機能します。Code Engine の呼び出し、SQL クエリ、その他のデータアクセス方法では、onFiltersUpdated を使ってフィルターを明示的に処理する必要があります。

プロコードパターン:CSS グラデーションバナー

ブランドイメージに合わせたページヘッダーを描画する軽量なカスタムアプリです。JavaScript フレームワークもデータセットも必要ありません。

ファイル:index.html、app.css、manifest.json


<div class="banner">
  <div class="banner-geo"></div>
  <div class="banner-content">
    <span class="banner-brand">COMPANY NAME</span>
    <h1 class="banner-title" id="bannerTitle">PAGE TITLE</h1>
    <p class="banner-sub" id="bannerSub">Contextual subheader describing this page's focus</p>
  </div>
</div>
/* app.css — dark gradient with subtle background pattern (see Banner Background Patterns section) */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 100%; height: 100%; overflow: hidden;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; }
.banner {
  width: 100%; height: 100%;
  background:
    radial-gradient(circle, rgba(91, 153, 213, 0.07) 1px, transparent 1px),  /* dot grid pattern */
    linear-gradient(135deg, #23272E 0%, #3A4352 100%);
  background-size: 20px 20px, 100% 100%;
  display: flex; align-items: center;
}
.banner-content { padding: 0 40px; }
.banner-brand { font-size: 11px; font-weight: 600; letter-spacing: 0.14em; color: #5B99D5; display: block; margin-bottom: 6px; } /* brand-400 */
.banner-title { font-size: 26px; font-weight: 600; color: #FFFFFF; letter-spacing: 0.02em; margin-bottom: 4px; }
.banner-sub { font-size: 13px; font-weight: 400; color: #B1BAC8; max-width: 600px; line-height: 1.4; } /* neutral-300 */
// manifest.json — no datasets
{"name": "Banner", "version": "1.0.0", "size": {"width": 10, "height": 1}, "mapping": []}

配置:幅 60、高さ 14、y=0。コンテンツの設定:hideTitle、hideFooter、hideBorder、hideMargins、hideWrench はすべて true、fitToFrame: true、スタイルは ca8。高さを 7 ではなく 14 にすることで、サブヘッダーのテキストを収め、より意図的な視覚的なアクセントを作り出します。

ページごとのタイトル:各ページに個別のバナーデザイン(例:mfg-banner-overview/、mfg-banner-production/)を作成し、それぞれの index.html にタイトルとサブタイトルをハードコードします。1 つのデザインを複数のページで共有しないでください。iframe カードは App Studio ホストから URL パラメーターを受け取れません。各デザインを個別に公開してから、コンテキスト API を使って、正しいページにデザインごとのカードインスタンスを 1 つずつ作成します。

カラーパレットの適合:すべてのプロコードコンポーネントで、domo-app-theme/references/color-palettes.md の厳選されたパレットを使用します。Domo のネイティブカラーやデフォルトカラーは絶対に使用しないでください。チャートのシリーズカラーは、App Studio テーマの ORDER_SOURCE 値(c8、c22、c29、c36)に合わせます。

バナーの背景パターン

バナーには、ベースのグラデーションに重ねた控えめな背景パターンを使用します。テキストの邪魔をせずに視覚的な奥行きを加え、洗練されたエディトリアルな印象を生み出します。パターンは外部画像を使わず CSS のみで作成し、アクセントカラーを非常に低い不透明度で使用します。また、パレットに合わせて調整します。

**必ずパターンを追加してください。**データのドメインに基づいてアプリごとに 1 つ選択します(統一感を保つため、すべてのページで同じパターンを使います)。.banner の単色の linear-gradient を、レイヤー化した background プロパティに置き換えてパターンを適用します。

パターン適した用途視覚的な印象
ドットグリッド企業、分析、SaaSクリーンで、データを重視し、構造的
斜線製造、エンジニアリング、エネルギー動き、精密さ、産業的
放射状グローエグゼクティブ、概要、ヒーローページスポットライト、焦点、プレミアム
地形図サプライチェーン、物流、地理技術的、奥行き、複雑さ
クロスハッチ金融、コンプライアンス、医療織物のような質感、重厚さ
ドットグリッド

アクセントカラーの小さなドットを繰り返すパターンです。控えめで構造的であり、幅広い用途に適しています。

.banner {
  background:
    radial-gradient(circle, var(--accent-dot) 1px, transparent 1px),
    linear-gradient(135deg, var(--bg-start) 0%, var(--bg-end) 100%);
  background-size: 20px 20px, 100% 100%;
}

ライトモードの例:--accent-dot: rgba(58, 105, 131, 0.07) ダークモードの例:--accent-dot: rgba(232, 122, 32, 0.08)

斜線

動きと精密さを感じさせる、細い斜線のパターンです。

.banner {
  background:
    repeating-linear-gradient(
      -45deg,
      transparent,
      transparent 40px,
      var(--line-color) 40px,
      var(--line-color) 41px
    ),
    linear-gradient(135deg, var(--bg-start) 0%, var(--bg-end) 100%);
}

ライトモード:--line-color: rgba(58, 105, 131, 0.04) ダークモード:--line-color: rgba(232, 122, 32, 0.05)

放射状グロー

テキスト領域の背後に、柔らかいスポットライトを作ります。焦点を生み出し、上質な奥行きを加えます。

.banner {
  background:
    radial-gradient(ellipse at 30% 50%, var(--glow-color) 0%, transparent 60%),
    linear-gradient(135deg, var(--bg-start) 0%, var(--bg-end) 100%);
}

ライトモード:--glow-color: rgba(58, 105, 131, 0.08) ダークモード:--glow-color: rgba(232, 122, 32, 0.12)

地形図

等高線図を思わせる同心円のパターンです。技術的で、重なりのある印象です。

.banner {
  background:
    radial-gradient(ellipse at 75% 40%, transparent 30%, var(--topo-line) 30.5%, transparent 31%),
    radial-gradient(ellipse at 75% 40%, transparent 45%, var(--topo-line) 45.5%, transparent 46%),
    radial-gradient(ellipse at 75% 40%, transparent 60%, var(--topo-line) 60.5%, transparent 61%),
    radial-gradient(ellipse at 75% 40%, transparent 75%, var(--topo-line) 75.5%, transparent 76%),
    linear-gradient(135deg, var(--bg-start) 0%, var(--bg-end) 100%);
}

ライトモード:--topo-line: rgba(58, 105, 131, 0.05) ダークモード:--topo-line: rgba(232, 122, 32, 0.06)

クロスハッチ

細かく織り込まれたグリッドパターンです。重厚で、質感があります。

.banner {
  background:
    linear-gradient(0deg, var(--hatch-color) 1px, transparent 1px),
    linear-gradient(90deg, var(--hatch-color) 1px, transparent 1px),
    linear-gradient(135deg, var(--bg-start) 0%, var(--bg-end) 100%);
  background-size: 24px 24px, 24px 24px, 100% 100%;
}

ライトモード:--hatch-color: rgba(58, 105, 131, 0.03) ダークモード:--hatch-color: rgba(232, 122, 32, 0.04)

実装上の注意点
  • 不透明度の範囲:パターンの要素は、不透明度を 0.03~0.12 に保ってください。それより高いと、テキストより目立ってしまいます。
  • CSS カスタムプロパティ:例では var(--...) のプレースホルダーを使用しています。実際のバナー app.css では、プロジェクトのパレットにある色の値(例:Charcoal Ember のアクセント色には rgba(232, 122, 32, 0.08))に置き換えてください。
  • 一貫性:アプリ内のすべてのページバナーで、同じパターンを使用してください。ページごとにパターンを変えると、まとまりがなくなります。
  • テキストの読みやすさ:バナーのテキスト領域(バナー左側の 40%)に、最も強いグラデーションを配置してください。放射状/焦点パターンは、テキストがないバナーの右側に配置してください。
  • パフォーマンス:CSS グラデーションのみを使用すれば、読み込みコストはゼロです。バナーのパターンに <canvas>、JavaScript のパーティクルシステム、外部画像ファイルは絶対に使用しないでください。

ダークモードのプロコードアプリ:ダークモードのパレット(domo-app-theme/references/color-palettes.md の D1 Emerald、D2 Neon Magenta、D3 Charcoal Ember)を使用する場合、プロコードの CSS では、明るいテキスト色、暗いコンテナの背景色、ダークモードに適したグリッド/軸の色を明示的に設定する必要があります。プロコードアプリは、設計上、ダークモードを正しく処理します(CSS で明示的に指定)。重大な問題が起きるのは、App Studio のネイティブテーマです。テーマに暗い背景色を適用した後、カード、ナビゲーション、ヘッダー、コンポーネント全体の c60 フォント色への参照をすべて c58 に置き換える必要があります。これを行わないと、ネイティブ要素(ヒーローカード、フィルタードロップダウン、ナビゲーションテキスト、セクションヘッダー)のテキストが暗い背景に暗い色で表示され、見えなくなります。必須の修正については、app-studio スキルの「ダークモードテーマ」セクションを参照してください。

プロコードのチャートパターン(明示的に依頼された場合のみ)

プロコードのチャートは任意です。プロコード、カスタムチャート、Recharts、D3、カスタムビジュアライゼーションをユーザーが明示的に依頼した場合にのみ作成してください。 デフォルトのビルドでは、Domo のネイティブチャートを使用してください。

プロコードのチャートを依頼された場合は、次の実績があり、本番環境でテスト済みのパターンを使用してください。以下の参照アプリはすべて、Domo App Studio の iframe でデプロイされ、動作確認済みです。

デフォルトのライブラリ選択

プロコードのチャートが必要な場合は、次の 2 つのスタックのいずれかを必ずデフォルトで使用してください。ユーザーが明示的に依頼しない限り、ほかのチャートライブラリを導入しないでください。

スタック使用する場面参照プロジェクト
React + Recharts(パターン 1)ほとんどのチャートのデフォルトです。チャートで React の状態管理、コンポジション、複雑なインタラクションが必要な場合に使用します。forecast line recharts/
Chart.js vanilla(パターン 3)よりシンプルなチャート、React を使うと不要な負荷が加わる場合、またはアプリがすでに vanilla JS を使用している場合(例:Code Engine/AI 連携)に使用します。temp-apt-vanilla/customer-retention-aptive/src/vanilla/

ユーザーが希望を指定しない場合は、パターン 1(React + Recharts)をデフォルトで使用してください。

実証済みのリファレンスパターン 1:React + Recharts + Import Maps(デフォルト)

このパターンは、複数の本番アプリで使用されています。主なアーキテクチャ:

  • index.html で <script src="https://unpkg.com/ryuu.js"></script> を使用して ryuu.js を明示的に読み込みます
  • Import maps で esm.sh から React 18.2.0 と Recharts 2.12.7 を読み込みます(npm のビルド手順は不要です)
  • app.js は React.createElement() を使用する ES モジュールです(JSX のトランスパイルは不要です)
  • try/catch を使用して domo.get('/data/v1/{alias}') でデータを取得します
  • フィルターリスナー:利用可能な場合(ryuu.js の新しいバージョン)は domo.onFiltersUpdate を使用し、古いバージョンでは domo.onFiltersUpdated にフォールバックします。初期化時のエッジケースに対応するため、リスナーの登録は必ず try { ... } catch (_) {} で囲んでください。
  • TODO:サポート対象の各バージョンで ryuu.js のリスナー名が安定したら、すべてのスキル/サンプルを単一の互換性ヘルパーに統一します。

動作確認済みのサンプル(コピーして調整してください。いずれも動作確認済みです):

  • _archive_prior_build/mfg-production-chart/ — Actual/Plan/Forecast と信頼区間を含む複数折れ線、および集計コントロール(日次/週次/月次)
  • _archive_prior_build/mfg-quality-chart/ — 面塗りを含む2本の折れ線(不良率+スクラップ率)、週次集計
  • forecast line recharts/ — MAE の信頼区間を含む来訪予測チャート
実証済みのリファレンスパターン 2:D3 + Observable Plot(バニラ JavaScript)

より特殊な可視化(ビースウォーム、フォースレイアウト、カスタム SVG)向け:

  • CDN から d3.min.js と @observablehq/plot を使用します
  • バニラの <script> タグを使用します(ES モジュール/import maps は不要です)
  • @domoinc/query ヘルパーを介して domo.get() を使用します
  • 動作確認済みのサンプル:_archive_prior_build/ddx-snowflake_/ — 詳細パネル付きの D3 フォース・ビースウォーム
実績のある参照パターン 3:Chart.js(vanilla JS)

よりシンプルなグラフを作成する場合や、React が不要な場合に使用します。これはデフォルトの vanilla(非 React)スタックです。

  • jsDelivr CDN の Chart.js 4.4.1 を使用します
  • vanilla JavaScript、フレームワークは不要です
  • ryuu.js は unpkg から読み込みます(パターン 1 と同じです)
  • 動作するサンプル:temp-apt-vanilla/customer-retention-aptive/src/vanilla/ — Code Engine と AI を統合したリテンションチャート

CDN URL(以下の正確なバージョンを固定してください):

<script src="https://unpkg.com/ryuu.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>

追加の CDN ライブラリは、特定の機能に必要な場合にのみ追加してください(例:AI の Markdown レンダリングには marked、展開には pako)。上記の 2 つのスクリプトが vanilla の基本構成です。

Chart.js vanilla のスタイル設定リファレンス(customer-retention-aptive より):

// Chart.js configuration matching the reference app
const chart = new Chart(ctx, {
  type: 'line',
  data: { datasets: datasets },
  options: {
    responsive: true,
    maintainAspectRatio: false,
    animation: false,
    interaction: { mode: 'nearest', intersect: false },
    scales: {
      x: {
        type: 'linear',
        title: { display: true, text: 'X Label', color: tickColor, font: { size: 12, weight: 500 } },
        grid: { color: gridColor, drawBorder: false },
        ticks: { color: tickColor, font: { size: 11 }, callback: v => v.toLocaleString() }
      },
      y: {
        min: 0, max: 100,
        title: { display: true, text: 'Percent (%)', color: tickColor, font: { size: 12, weight: 500 } },
        grid: { color: gridColor, drawBorder: false },
        ticks: { color: tickColor, font: { size: 11 }, callback: v => `${v}%` }
      }
    },
    plugins: {
      legend: {
        position: 'right',
        labels: { color: legendColor, font: { size: 11 }, usePointStyle: true, pointStyle: 'line' }
      },
      tooltip: { enabled: false, external: externalTooltipHandler }
    }
  }
});

データセットごとの線の設定:

// Line style options — match reference app patterns
const getLineConfig = (color) => ({
  tension: 0.4,
  borderWidth: 2.5,
  borderColor: color,
  backgroundColor: color,
  pointRadius: 0,
  pointHoverRadius: 5,
  pointHoverBackgroundColor: color,
  pointHoverBorderColor: '#e8ebe8',  // or '#344D38' for dark theme
  pointHoverBorderWidth: 2,
});

フォントファミリー:プロジェクトの DESIGN.md に記載されている App Studio テーマのフォントファミリー設定と一致させる必要があります。次のように対応させます。

テーマのフォントファミリーPro-code の CSS font-family
Sans-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif
SerifGeorgia, "Times New Roman", "Palatino Linotype", serif
Slab"Roboto Slab", "Rockwell", "Courier New", serif

App Studio テーマと一致しないフォントスタックをハードコードしてはいけません。 テーマで Serif フォントを使用している場合は、すべての pro-code コンポーネントでも Serif を使用する必要があります。ネイティブカードと pro-code カードのフォントが一致しないと、視覚的な統一感が損なわれます。

コンテナーのレイアウト:.app-container は height: 100vh、.chart-container は flex: 1; min-height: 0; padding: 16px にします。Canvas は width: 100% !important; height: 100% !important に設定します。

重要:各サブページでは、必ず異なる種類のグラフを使用してください。 すべてのページで折れ線グラフを繰り返し使用してはいけません。概要ページでは時系列の折れ線グラフまたは面グラフを使用します。サブページでは必ず種類を変え、棒グラフ、積み上げ棒グラフ、横棒グラフ、散布図、ビースウォーム、ヒートマップ、ツリーマップなどを使用してください。ページのデータ形状に応じてグラフの種類を選択します。

ページのデータ形状グラフの種類Recharts コンポーネント
時間経過に伴う傾向折れ線/面ComposedChart + Line / Area
カテゴリーの比較縦棒BarChart + Bar
構成/内訳積み上げ棒BarChart + Bar(積み上げ)
ランキング/分布横棒BarChart layout="vertical" + Bar
相関/密度散布図ScatterChart + Scatter
全体に対する内訳ドーナツ/円PieChart + Pie

これは静的 HTML アプリ(ビルド手順なし)です。index.html は ryuu.js を明示的に読み込み、ブラウザー標準のインポートマップを使って esm.sh から React と Recharts を読み込みます。アプリのディレクトリから domo publish を実行して、そのまま公開してください。

ファイル:index.html、app.css、app.js、manifest.json、thumbnail.png


<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <link rel="stylesheet" href="app.css" />
</head>
<body>
  <div id="app"></div>
  <script src="https://unpkg.com/ryuu.js"></script>
  <script type="importmap">
  {
    "imports": {
      "react": "https://esm.sh/[email protected]",
      "react-dom/client": "https://esm.sh/[email protected]/client",
      "recharts": "https://esm.sh/[email protected][email protected],[email protected]"
    }
  }
  </script>
  <script type="module" src="app.js"></script>
</body>
</html>
/* app.css — styling matched to forecast-line-recharts reference app */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 100%; height: 100%; overflow: hidden;
  background: #F8F8F9;
  color: #1F2937;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; }
#app { width: 100%; height: 100%; display: flex; flex-direction: column; padding: 12px; }
.chart-container { display: flex; flex-direction: column; flex: 1; min-height: 0;
  background: #F1F1F3; padding: 16px; }
.chart-header { margin-bottom: 12px; display: flex; justify-content: space-between;
  align-items: flex-start; gap: 8px; flex-wrap: wrap; flex-shrink: 0; }
.chart-title-section h1 { font-size: 16px; font-weight: 600; color: #111827; margin-bottom: 2px; }
.chart-subtitle { font-size: 12px; color: #6B7280; }
.chart-wrapper { flex: 1; min-height: 0; background: #F1F1F3; padding: 8px; }

/* Dropdown — matches reference .aggregation-select exactly */
.aggregation-select {
  appearance: none; background: #FFFFFF;
  border: 1px solid #D1D5DB; border-radius: 3px;
  padding: 3px 18px 3px 6px; font-size: 10px;
  color: #374151; cursor: pointer; min-width: 50px;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 12 12'%3E%3Cpath d='M3 5l3 3 3-3' fill='none' stroke='%236B7280' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E");
  background-repeat: no-repeat; background-position: right 4px center;
}
.aggregation-select:hover { border-color: #9CA3AF; }
.aggregation-select:focus { outline: none; border-color: #3A6983;
  box-shadow: 0 0 0 2px rgba(58, 105, 131, 0.1); }

/* Confidence band toggle — matches reference .confidence-toggle */
.confidence-toggle {
  display: flex; align-items: center; gap: 3px;
  background: #FFFFFF; border: 1px solid #D1D5DB; border-radius: 3px;
  padding: 3px 6px; font-size: 10px; color: #6B7280;
  cursor: pointer; transition: all 0.15s ease;
}
.confidence-toggle:hover { border-color: #99A9BD; }
.confidence-toggle.active { background: #CAE1F0; border-color: #5E92CE; color: #3A6983; }
.toggle-icon { font-size: 8px; }

/* Tooltip — matches reference .custom-tooltip */
.custom-tooltip {
  background: #FFFFFF; border: 1px solid #E5E7EB; border-radius: 6px;
  padding: 12px 14px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); font-size: 13px;
}
.tooltip-date { font-weight: 600; color: #111827; margin-bottom: 8px;
  padding-bottom: 8px; border-bottom: 1px solid #F3F4F6; }
.custom-tooltip p { margin: 4px 0; }

/* Legend footer — matches reference .chart-footer */
.chart-footer { display: flex; justify-content: center; gap: 16px; margin-top: 8px;
  padding-top: 8px; border-top: 1px solid #F3F4F6; flex-wrap: wrap; flex-shrink: 0; }
.legend-item { display: flex; align-items: center; gap: 6px; font-size: 11px; color: #4B5563; }
.legend-line { width: 24px; height: 2px; border-radius: 1px; }
.legend-line.dashed { background: repeating-linear-gradient(
  to right, currentColor 0, currentColor 4px, transparent 4px, transparent 7px); }

/* Loading state — matches reference .loading-container */
.loading-container { display: flex; flex-direction: column; align-items: center;
  justify-content: center; flex: 1; color: #6B7280; font-size: 12px; }
.loading-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0;
  width: 24px; height: 24px; margin-bottom: 8px; }
.loading-cell { background: #5E92CE; border-radius: 1px;
  animation: gridPulse 1.2s ease-in-out infinite; }
@keyframes gridPulse {
  0%, 100% { opacity: 0.3; transform: scale(0.85); }
  50% { opacity: 1; transform: scale(1); }
}

/* Error state — matches reference */
.error-container { display: flex; flex-direction: column; align-items: center;
  justify-content: center; flex: 1; text-align: center; padding: 24px; }
.error-container h2 { color: #DC2626; font-size: 18px; margin-bottom: 8px; }
.error-container p { color: #6B7280; font-size: 14px; margin-bottom: 4px; }

/* Grid line override — reference uses lighter horizontal grid */
.recharts-cartesian-grid-horizontal line { stroke: #F3F4F6; }
.recharts-legend-wrapper { display: none !important; }

@media (max-width: 768px) {
  #app { padding: 16px; }
  .legend-item { font-size: 12px; }
}
/* app.js — PRODUCTION-GRADE React + Recharts chart (ES module)
   Styling matched to forecast-line-recharts reference app.
   Copy and customize COLORS, ALIAS, column names, TITLE, Y_LABEL per page. */
import React, { useState, useEffect, useMemo } from 'react';
import { createRoot } from 'react-dom/client';
import {
  ComposedChart, Area, Line, Bar, XAxis, YAxis,
  CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine
} from 'recharts';

// ── CUSTOMIZE THESE PER PAGE ──────────────────────────────────────────
const COLORS = {
  primary:   '#3A6983',  // main series — replace with palette brand hex
  secondary: '#5E92CE',  // second series (prediction / forecast)
  muted:     '#99A9BD',  // confidence band labels in tooltip
  bandTop:   '#B2D5F3',  // confidence gradient top (opacity 0.4)
  bandBot:   '#CAE1F0',  // confidence gradient bottom (opacity 0.15)
  grid:      '#E5E7EB',  // CartesianGrid stroke (CSS overrides to #F3F4F6)
  axisLine:  '#E5E7EB',  // X-axis line
  text:      '#111827',  // heading text
  textSec:   '#6B7280',  // subtitle, axis ticks, labels
  today:     '#9CA3AF',  // ReferenceLine stroke
  todayLabel:'#6B7280',  // ReferenceLine label fill
};
const ALIAS    = 'DATASET_ALIAS';
const DATE_COL = 'DATE_COL';
const VAL_COLS = ['VAL_COL_1'];
const TITLE    = 'Chart Title';
const Y_LABEL  = 'Units';
// Set true if the dataset has upper/lower confidence columns
const HAS_CONFIDENCE = false;
const CONF_UPPER = 'UPPER_COL';
const CONF_LOWER = 'LOWER_COL';
// ─────────────────────────────────────────────────────────────────────

// ── DATA PARSING ─────────────────────────────────────────────────────
const parseData = (raw) => {
  if (!raw?.length) return [];
  let headers, rows;
  if (Array.isArray(raw[0])) {
    headers = raw[0].map(h => String(h).toUpperCase().replace(/\./g, '_'));
    rows = raw.slice(1);
  } else {
    headers = Object.keys(raw[0]).map(h => h.toUpperCase().replace(/\./g, '_'));
    rows = raw.map(r => Object.keys(r).map(k => r[k]));
  }
  const find = (n) => headers.findIndex(h => h === n.toUpperCase() || h.includes(n.toUpperCase()));
  const di = find(DATE_COL);
  if (di === -1) { console.error('Date column not found. Headers:', headers); return []; }
  const valIndices = VAL_COLS.map(c => ({ key: c, idx: find(c) })).filter(v => v.idx !== -1);
  const upperIdx = HAS_CONFIDENCE ? find(CONF_UPPER) : -1;
  const lowerIdx = HAS_CONFIDENCE ? find(CONF_LOWER) : -1;

  return rows.map(r => {
    const obj = { date: new Date(r[di]) };
    valIndices.forEach(({ key, idx }) => { obj[key] = parseFloat(r[idx]) || null; });
    if (upperIdx !== -1 && lowerIdx !== -1) {
      const lo = parseFloat(r[lowerIdx]), hi = parseFloat(r[upperIdx]);
      if (!isNaN(lo) && !isNaN(hi)) obj.confidenceRange = [lo, hi];
    }
    return obj;
  }).filter(d => !isNaN(d.date.getTime()));
};

// ── AGGREGATION (Daily / Weekly / Monthly) ───────────────────────────
const aggregateData = (data, period) => {
  const grouped = {};
  data.forEach(item => {
    let key;
    const d = item.date;
    switch (period) {
      case 'week': {
        const s = new Date(d); s.setDate(d.getDate() - d.getDay());
        key = s.toISOString().split('T')[0]; break;
      }
      case 'month':
        key = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-01`; break;
      default:
        key = d.toISOString().split('T')[0];
    }
    if (!grouped[key]) grouped[key] = { date: key, _counts: {} };
    const g = grouped[key];
    VAL_COLS.forEach(c => {
      if (item[c] != null) {
        g[c] = (g[c] || 0) + item[c];
        g._counts[c] = (g._counts[c] || 0) + 1;
      }
    });
    if (item.confidenceRange) g.confidenceRange = item.confidenceRange;
  });
  return Object.values(grouped)
    .map(g => { const { _counts, ...rest } = g; return rest; })
    .sort((a, b) => new Date(a.date) - new Date(b.date));
};

// ── FORMATTING ───────────────────────────────────────────────────────
const fmtNum = v => {
  if (v >= 1e6) return `${(v/1e6).toFixed(1)}M`;
  if (v >= 1e3) return `${(v/1e3).toFixed(0)}K`;
  return `${Math.round(v)}`;
};
const fmtDate = (s, period) => {
  const d = new Date(s);
  if (period === 'month') return d.toLocaleDateString('en-US',{ month:'short', year:'numeric' });
  return d.toLocaleDateString('en-US',{ month:'short', day:'numeric' });
};
const calcTickInterval = (len) => {
  if (len <= 30) return 0;
  if (len <= 90) return 6;
  if (len <= 180) return 13;
  return Math.ceil(len / 18) - 1;
};

// ── CUSTOM TOOLTIP (uses .custom-tooltip CSS class) ──────────────────
const ChartTooltip = ({ active, payload, label }) => {
  if (!active || !payload?.length) return null;
  const d = new Date(label);
  const fmt = d.toLocaleDateString('en-US',{ weekday:'short', month:'short', day:'numeric', year:'numeric' });
  const pt = payload[0]?.payload;
  return React.createElement('div', { className: 'custom-tooltip' },
    React.createElement('p', { className: 'tooltip-date' }, fmt),
    VAL_COLS.map((c, i) =>
      pt?.[c] != null && React.createElement('p', { key: c,
        style: { color: i === 0 ? COLORS.primary : COLORS.secondary, margin: 0 } },
        `${c}: ${fmtNum(pt[c])}`)
    ),
    HAS_CONFIDENCE && pt?.confidenceRange && [
      React.createElement('p', { key:'lo', style:{ color: COLORS.muted, margin: 0 } },
        `Lower: ${fmtNum(pt.confidenceRange[0])}`),
      React.createElement('p', { key:'hi', style:{ color: COLORS.muted, margin: 0 } },
        `Upper: ${fmtNum(pt.confidenceRange[1])}`)
    ]
  );
};

// ── LOADING CELL DELAYS (row-major 3×3 grid) ────────────────────────
const LOAD_DELAYS = ['0s','0.1s','0.2s','0.1s','0.2s','0.3s','0.2s','0.3s','0.4s'];

// ── MAIN CHART COMPONENT ─────────────────────────────────────────────
const Chart = () => {
  const [rawData, setRawData] = useState([]);
  const [aggregation, setAggregation] = useState('day');
  const [showBand, setShowBand] = useState(true);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  const now = new Date();
  const today = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`;

  const fetchAndParse = async () => {
    try {
      setLoading(true);
      const data = await domo.get('/data/v1/' + ALIAS);
      setRawData(parseData(data));
      setError(null);
    } catch (e) { setError(e.message || String(e)); }
    finally { setLoading(false); }
  };

  useEffect(() => { fetchAndParse(); }, []);

  useEffect(() => {
    try {
      if (typeof domo !== 'undefined' && domo.onFiltersUpdate) {
        domo.onFiltersUpdate(() => fetchAndParse());
      }
    } catch (_) {}
  }, []);

  const chartData = useMemo(() => aggregateData(rawData, aggregation), [rawData, aggregation]);
  const todayInRange = chartData.length > 0 &&
    new Date(chartData[0].date) <= new Date(today) &&
    new Date(chartData[chartData.length - 1].date) >= new Date(today);

  if (loading) return React.createElement('div', { className: 'loading-container' },
    React.createElement('div', { className: 'loading-grid' },
      ...[0,1,2,3,4,5,6,7,8].map(i =>
        React.createElement('div', { key: i, className: 'loading-cell',
          style: { animationDelay: LOAD_DELAYS[i] } })
      )
    ),
    React.createElement('p', null, 'Loading…')
  );

  if (error) return React.createElement('div', { className: 'error-container' },
    React.createElement('h2', null, 'Error'),
    React.createElement('p', null, error)
  );

  const dateRange = chartData.length > 0
    ? `${new Date(chartData[0].date).toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'})} – ${new Date(chartData[chartData.length-1].date).toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'})}`
    : '';

  const colorKeys = [COLORS.primary, COLORS.secondary];

  return React.createElement('div', { className: 'chart-container' },
    React.createElement('div', { className: 'chart-header' },
      React.createElement('div', { className: 'chart-title-section' },
        React.createElement('h1', null, TITLE),
        React.createElement('p', { className: 'chart-subtitle' },
          `${dateRange}, by ${aggregation === 'day' ? 'Day' : aggregation === 'week' ? 'Week' : 'Month'}`)
      ),
      React.createElement('div', { style: { display:'flex', alignItems:'center', gap: 6 } },
        HAS_CONFIDENCE && React.createElement('button', {
          className: `confidence-toggle${showBand ? ' active' : ''}`,
          onClick: () => setShowBand(b => !b)
        },
          React.createElement('span', { className: 'toggle-icon' }, '◉'),
          'Band'
        ),
        React.createElement('select', {
          className: 'aggregation-select',
          value: aggregation, onChange: e => setAggregation(e.target.value)
        },
          React.createElement('option', { value:'day' }, 'Daily'),
          React.createElement('option', { value:'week' }, 'Weekly'),
          React.createElement('option', { value:'month' }, 'Monthly')
        )
      )
    ),

    React.createElement('div', { className: 'chart-wrapper', key: `w-${aggregation}` },
      React.createElement(ResponsiveContainer, { width: '100%', height: '100%' },
        React.createElement(ComposedChart, { data: chartData, margin: { top:10, right:20, left:10, bottom:20 } },
          React.createElement('defs', null,
            React.createElement('linearGradient', { id: 'confidenceGradient', x1:'0', y1:'0', x2:'0', y2:'1' },
              React.createElement('stop', { offset:'0%', stopColor: COLORS.bandTop, stopOpacity: 0.4 }),
              React.createElement('stop', { offset:'100%', stopColor: COLORS.bandBot, stopOpacity: 0.15 })
            )
          ),
          React.createElement(CartesianGrid, { strokeDasharray:'3 3', stroke: COLORS.grid, vertical: false }),
          React.createElement(XAxis, {
            dataKey: 'date', tickFormatter: d => fmtDate(d, aggregation),
            stroke: COLORS.textSec, fontSize: 10, tickLine: false,
            axisLine: { stroke: COLORS.axisLine },
            height: aggregation === 'day' ? 40 : 30,
            interval: aggregation === 'day' ? calcTickInterval(chartData.length) : 'preserveStartEnd'
          }),
          React.createElement(YAxis, {
            tickFormatter: fmtNum, stroke: COLORS.textSec, fontSize: 12,
            tickLine: false, axisLine: false,
            label: { value: Y_LABEL, angle: -90, position: 'insideLeft',
              style: { textAnchor: 'middle', fill: COLORS.textSec, fontSize: 12 } }
          }),
          React.createElement(Tooltip, { content: React.createElement(ChartTooltip) }),
          todayInRange && React.createElement(ReferenceLine, {
            x: today, stroke: COLORS.today, strokeDasharray: '4 4',
            label: { value: 'Today', position: 'top', fill: COLORS.todayLabel, fontSize: 11 }
          }),
          HAS_CONFIDENCE && showBand && React.createElement(Area, {
            type: 'monotone', dataKey: 'confidenceRange',
            stroke: 'none', fill: 'url(#confidenceGradient)', fillOpacity: 1,
            name: 'Confidence Band', legendType: 'none'
          }),
          ...VAL_COLS.map((c, i) => React.createElement(Line, {
            key: c, type: 'monotone', dataKey: c,
            stroke: colorKeys[i % colorKeys.length], strokeWidth: 2,
            dot: false, activeDot: { r: 4, fill: colorKeys[i % colorKeys.length] },
            name: c, connectNulls: false,
            ...(i > 0 ? { strokeDasharray: '4 3' } : {})
          }))
        )
      )
    ),

    React.createElement('div', { className: 'chart-footer' },
      ...VAL_COLS.map((c, i) =>
        React.createElement('div', { key: c, className: 'legend-item' },
          React.createElement('span', { className: `legend-line${i > 0 ? ' dashed' : ''}`,
            style: { background: i > 0 ? undefined : colorKeys[i], color: colorKeys[i] } }),
          React.createElement('span', null, c)
        )
      ),
      HAS_CONFIDENCE && React.createElement('div', { className: 'legend-item' },
        React.createElement('span', { style: {
          display: 'inline-block', width: 16, height: 12, borderRadius: 2,
          background: `linear-gradient(180deg, rgba(178,213,243,0.4) 0%, rgba(202,225,240,0.15) 100%)`,
          border: '1px solid rgba(94,146,206,0.4)'
        } }),
        React.createElement('span', null, 'Confidence Band')
      )
    )
  );
};

createRoot(document.getElementById('app')).render(React.createElement(Chart));

配置:幅 60、高さ 30、hideTitle: true、fitToFrame: true、スタイル ca1。HEADER のコンテンツ項目の下に配置します。

ページごとのカスタマイズ:ページごとに個別のアプリディレクトリを作成します。各 app.js の ALIAS、DATE_COL、VAL_COL、TITLE、SUBTITLE、COLORS を変更します。

マニフェスト:列名の不一致を避けるため、fields: [] を使用します。thumbnail.png(最小 300×300)を含めてください。含めない場合、Domo は DA0087 を返します。

代替チャートタイプのテンプレート(サブページ)

上記の折れ線/エリアテンプレートは概要ページ用です。サブページでは、必ず異なるチャートタイプを使用してください。以下に、代替タイプの主要な Recharts createElement スニペットを示します。折れ線チャートテンプレートの index.html、app.css、データの解析、読み込み/エラー状態、ツールチップのパターンはすべて共通で、チャートのレンダリング部分のみが変わります。

縦棒チャート(カテゴリ比較 — 例:工場別の生産量、地域別の売上):

import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts';

// In render — replace ComposedChart+Line with:
React.createElement(ResponsiveContainer, { width: '100%', height: '100%' },
  React.createElement(BarChart, { data: chartData, margin: { top: 10, right: 20, left: 10, bottom: 20 } },
    React.createElement(CartesianGrid, { strokeDasharray: '3 3', stroke: COLORS.grid, vertical: false }),
    React.createElement(XAxis, {
      dataKey: 'category', stroke: COLORS.textSec, fontSize: 10,
      tickLine: false, axisLine: { stroke: COLORS.axisLine }
    }),
    React.createElement(YAxis, {
      tickFormatter: fmtNum, stroke: COLORS.textSec, fontSize: 12,
      tickLine: false, axisLine: false,
      label: { value: Y_LABEL, angle: -90, position: 'insideLeft',
        style: { textAnchor: 'middle', fill: COLORS.textSec, fontSize: 12 } }
    }),
    React.createElement(Tooltip, { content: React.createElement(ChartTooltip) }),
    React.createElement(Bar, { dataKey: 'value', fill: COLORS.primary, radius: [2, 2, 0, 0], maxBarSize: 40 })
  )
)

積み上げ棒チャート(構成 — 例:種類別の不良、コストの内訳):

// Same imports as vertical bar. Data must have multiple value keys.
React.createElement(BarChart, { data: chartData, margin: { top: 10, right: 20, left: 10, bottom: 20 } },
  React.createElement(CartesianGrid, { strokeDasharray: '3 3', stroke: COLORS.grid, vertical: false }),
  React.createElement(XAxis, { dataKey: 'category', stroke: COLORS.textSec, fontSize: 10, tickLine: false }),
  React.createElement(YAxis, { tickFormatter: fmtNum, stroke: COLORS.textSec, fontSize: 12, tickLine: false, axisLine: false }),
  React.createElement(Tooltip, { content: React.createElement(ChartTooltip) }),
  // One Bar per series, all stackId="stack"
  React.createElement(Bar, { dataKey: 'series1', stackId: 'stack', fill: COLORS.primary, maxBarSize: 40 }),
  React.createElement(Bar, { dataKey: 'series2', stackId: 'stack', fill: COLORS.secondary, maxBarSize: 40 }),
  React.createElement(Bar, { dataKey: 'series3', stackId: 'stack', fill: COLORS.muted, radius: [2, 2, 0, 0], maxBarSize: 40 })
)

横棒チャート(ランキング — 例:主要サプライヤー、不良原因、工場のパフォーマンス):

// BarChart with layout="vertical" flips axes
React.createElement(BarChart, {
  data: chartData, layout: 'vertical',
  margin: { top: 10, right: 20, left: 80, bottom: 10 }  // left margin for category labels
},
  React.createElement(CartesianGrid, { strokeDasharray: '3 3', stroke: COLORS.grid, horizontal: false }),
  React.createElement(XAxis, {
    type: 'number', tickFormatter: fmtNum, stroke: COLORS.textSec, fontSize: 10
  }),
  React.createElement(YAxis, {
    type: 'category', dataKey: 'category', stroke: COLORS.textSec, fontSize: 11,
    width: 70, tickLine: false
  }),
  React.createElement(Tooltip, { content: React.createElement(ChartTooltip) }),
  React.createElement(Bar, { dataKey: 'value', fill: COLORS.primary, radius: [0, 2, 2, 0], maxBarSize: 24 })
)

散布図(相関 — 例:サイクルタイムと不良率、コストとリードタイム):

import { ScatterChart, Scatter, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ZAxis } from 'recharts';

React.createElement(ResponsiveContainer, { width: '100%', height: '100%' },
  React.createElement(ScatterChart, { margin: { top: 10, right: 20, left: 10, bottom: 20 } },
    React.createElement(CartesianGrid, { strokeDasharray: '3 3', stroke: COLORS.grid }),
    React.createElement(XAxis, {
      dataKey: 'x', name: 'X Metric', stroke: COLORS.textSec, fontSize: 10,
      label: { value: 'X Label', position: 'bottom', fill: COLORS.textSec, fontSize: 12 }
    }),
    React.createElement(YAxis, {
      dataKey: 'y', name: 'Y Metric', stroke: COLORS.textSec, fontSize: 12,
      label: { value: 'Y Label', angle: -90, position: 'insideLeft',
        style: { textAnchor: 'middle', fill: COLORS.textSec, fontSize: 12 } }
    }),
    React.createElement(ZAxis, { dataKey: 'z', range: [40, 400], name: 'Size' }),
    React.createElement(Tooltip, { content: React.createElement(ChartTooltip) }),
    React.createElement(Scatter, { data: chartData, fill: COLORS.primary, fillOpacity: 0.7 })
  )
)

各チャートタイプのデータ形式:データ解析関数は、正しい形式のデータを生成する必要があります。

  • 縦棒/積み上げ棒:[{ category: 'Plant A', value: 1234 }, ...] — ディメンションごとにグループ化し、メトリックを集計します
  • 横棒:縦棒と同じ形式で、値の降順に並べ替えます
  • 散布図:[{ x: 45.2, y: 3.1, z: 100, label: 'Plant A' }, ...] — 2 つの数値軸と、任意のサイズを指定します

既知の問題と注意点

デザインキャッシュの破損

domo publish で既存のデザインを更新すると(manifest.json の id が同じ場合)、CDN に古いファイルがキャッシュされることがあります。症状:HTML は正しく読み込まれ(<div id="app"> が存在)、ryuu.js も読み込まれますが、esm.sh からの ESM モジュールのインポートが実行されず、#app の innerHTML が空になります。

診断:正常なチャートの iframe のネットワークリクエスト(esm.sh への 200 リクエストが数十件)と、壊れたチャートのリクエスト(esm.sh へのリクエストがゼロ)を比較します。どちらも importmap を含む同一の index.html を使用していますが、壊れたデザインでは ESM の解決がエラーを表示せずに失敗します。

修正方法:manifest.json から id フィールドを削除し、再度 domo publish を実行します。これにより、新しい CDN キャッシュを持つ新しいデザインが作成されます。その後、新しいデザイン ID を使って新しいコンテキストとカードインスタンスを作成します。古いカードは DELETE /content/v1/cards/{cardId} で削除できます。

Context API のパス

/domoapps/apps/v2/contexts と /domoapps/apps/v2 のエンドポイントには、/api/ プレフィックスを使用しません。これらはルートドメインにあります。

  • POST https://{instance}.domo.com/domoapps/apps/v2/contexts
  • POST https://{instance}.domo.com/domoapps/apps/v2?fullpage=false&pageId=...

https://{instance}.domo.com/api/domoapps/... を使用すると、404 "No static resource" が返されます。

ページ ID は変わることがあります

App Studio アプリを再構築または変更すると、ページ ID(viewId)が変わることがあります。必ず GET /content/v1/dataapps/{appId}?includeHiddenViews=true で現在のアプリ構造を読み込み、views[].viewId を使って現在のページ ID を取得してください。セッションをまたいでページ ID をキャッシュしないでください。

未解決の質問

  1. コンパクトテンプレートとの相互作用:コンパクトテンプレートの寸法は、独自のレスポンシブブレークポイントを持つカスタムアプリとどのように相互作用しますか?
  2. カスタムアプリカードのスタイル ID:sourceId のスタイル(ca1–ca8)は、カスタムアプリのカードコンテナにも視覚的な効果がありますか。それともネイティブカードにのみ適用されますか?
  3. acceptDateFilter と acceptFilters:日付フィルターは同じ onFiltersUpdated コールバック経由で渡されますか。それとも別の方法で渡されますか?

コメント