コンテンツにスキップ
Documents for MorningStatusApp
Esc
↑↓移動↵開く⌘Jプレビュー
このページの内容

メンバー関係性マップ データ設計書

最終更新: 2026-07-07 関連 Issue:

  • #1197(メンバ関係性マップ)/ #1208(データ管理)/ #1210(レイアウト操作・永続化)/ #1236(共演エッジのラベル表示)/ #1263(集計とラベルの関連付け見直し)/ #1266(加入時リーダーによるチルドレン分類)
  • #1198(関係性スコアリングマップ)/ #1268(関係性スコアリング設計書作成)/ #1269(関係性スコアリングロジックの実装)
  • #1245(初版作成)/ #1283(加入ルートのデータ設計追加)/ #1284(加入ルート軸のフィルタ・グルーピング仕様)/ #1286(愛称データのMember.nickname配列化)

1. 概要

メンバー関係性マップ機能は、モーニング娘。現役メンバー間の関係性(同期・同郷・師弟など)を React Flow でビジュアライズする画面。データは Neon(PostgreSQL)上の2テーブルで管理する。


2. テーブル設計

2-1. member_relationships(MemberRelationship)

メンバー間の関係性を1件1レコードとして保持する。

カラム 型 制約 説明
id INT PK, AUTO INCREMENT 関係性 ID
fromMemberId VARCHAR(50) NOT NULL 関係の起点メンバー ID
toMemberId VARCHAR(50) NOT NULL 関係の終点メンバー ID
type VARCHAR(50) NOT NULL 関係種別(下記参照)
weight INT NOT NULL, DEFAULT 1 関係の強さ(エッジの太さに反映)
label VARCHAR(255) NULL 補足ラベル(same-unit ではユニット名、live-coappearance/radio-coappearance/event-coappearance では最多共演ライブ名・番組名・イベント名)
  • ユニーク制約: (fromMemberId, toMemberId, type)
  • インデックス: fromMemberId・toMemberId(各単列インデックス)
  • Prisma モデル名: MemberRelationship(@@map("member_relationships"))

関係種別(type)一覧

type 値 表示ラベル 有向エッジ フェーズ1現役マップ表示
same-gen 同期 なし ✅ 表示
same-hometown 同郷 なし ✅ 表示
same-unit label(ユニット名) なし ❌ 非表示
leader-succession リーダー継承 あり(→後任) ✅ 表示
mentor-student 師弟 あり(→弟子) ✅ 表示
live-coappearance ライブ共演 なし ❌ 非表示
radio-coappearance ラジオ共演 なし ❌ 非表示
event-coappearance イベント共演 なし ❌ 非表示
overlapping 同在籍 なし ❌ 非表示(ラベルも非表示)

エッジ生成元(静的エッジ / 動的エッジ)

member_relationships は生成元スクリプトが異なる2種類のエッジで構成される。

区分 対象 type 生成スクリプト 実行契機
静的エッジ(STATIC_EDGE_TYPES) same-gen・same-hometown・same-unit・overlapping・leader-succession・mentor-student scripts/console/seed-member-relationships.ts メンバー名簿・ユニット構成の変更時に手動実行(コンソールスクリプト)
動的エッジ(DYNAMIC_EDGE_TYPES) live-coappearance・radio-coappearance・event-coappearance scripts/workflow/sync-relationships.ts Sync Events ワークフローから定期実行

「静的」「動的」は生成元スクリプトの実行契機による分類であり、値そのものの不変性を意味しない(例: overlapping は静的エッジだが、現役メンバーの在籍月数は時間経過で変わる。ただし weight/label への反映は手動実行時点のスナップショットであり、自動で追従はしない)。

現役マップ表示対象 (ACTIVE_MEMBER_ALLOWED_RELATIONSHIP_TYPES):

// lib/member-map-utils.ts
export const ACTIVE_MEMBER_ALLOWED_RELATIONSHIP_TYPES = new Set([
  'same-gen',
  'same-hometown',
  'leader-succession',
  'mentor-student',
]);

GET /api/member-relationships は全種別を返す。クライアント側の filterDisplayRelationships() で絞り込む(「OGを含む」OFF 時はこの4種別かつ両端が表示対象メンバーの関係のみ、ON 時は全種別)。

ラベル表示ルール (getEdgeLabel):

  • overlapping: 常に null(ラベルを表示しない)
  • same-unit: r.label(ユニット名)を使用。null の場合は 'ユニット'
  • live-coappearance/radio-coappearance/event-coappearance: r.label(最多共演ライブ名・番組名・イベント名)を使用。null(同期前の旧データ等)の場合は固定ラベル(「ライブ共演」「ラジオ共演」「イベント共演」)にフォールバック
  • その他: RELATIONSHIP_LABEL_MAP[type] の固定ラベルを使用

live-coappearance/radio-coappearance/event-coappearance の label 算出(scripts/workflow/sync-relationships.ts):

sync-relationships.ts の同期処理で、メンバーペアごとに以下を集計する。

  1. ライブ共演: member_lives → lives → tours(LEFT JOIN)を辿り、Tour.title → (null の場合)Live.title → (両方 null の場合)固定文言「ライブ共演」の順でフォールバックしたラベルをペアごとに取得
  2. ラジオ共演: radio_members → radio_episodes → radio_shows を JOIN し、ペア×番組名ごとの共演回数を取得
  3. イベント共演: events.title を JOIN 不要でそのまま参照し、ペア×イベント名ごとの共演回数を取得。type='live'(ライブ種別イベント)はライブ共演で別途集計するため除外する
  4. ペアごとに、weight(全ラベル共通の合計共演回数)とは別に、ラベルごとの出現回数を集計
  5. 出現回数が最多のラベルを label に設定する。同数の場合は文字列順(昇順)で決定的に選ぶ

2-2. member_map_layouts(MemberMapLayout)

メンバーのノード座標を永続化する。

カラム 型 制約 説明
memberId VARCHAR(50) PK メンバー ID(member.id と対応)
x FLOAT NOT NULL React Flow キャンバス上の X 座標
y FLOAT NOT NULL React Flow キャンバス上の Y 座標
  • Prisma モデル名: MemberMapLayout(@@map("member_map_layouts"))
  • 座標未登録のメンバーは円形配置アルゴリズムで仮配置される(DB には保存されない)
  • 「配置を保存」ボタン押下時に PUT /api/member-map-layout で UPSERT される

3. TypeScript 型定義

lib/member-map-utils.ts に定義。

export interface MemberRelationship {
  id: number;
  fromMemberId: string;
  toMemberId: string;
  type: string;
  weight: number;
  label: string | null;
}

export interface MemberMapLayoutPoint {
  memberId: string;
  x: number;
  y: number;
}

export type ControlMode = 'filter' | 'group';
export type ControlAxis = 'generation' | 'hometown' | 'relationship' | 'leader' | 'joiningRoute';

4. API エンドポイント

4-1. GET /api/member-map-layout

保存済の全ノード座標を返す。

  • 実装: app/api/member-map-layout/route.ts
  • レスポンス: MemberMapLayoutPoint[]
[
  { "memberId": "oda-sakura", "x": 120.5, "y": -80.0 },
  { "memberId": "ishida-ayumi", "x": -200.0, "y": 150.0 }
]
  • エラー: 500 → { "error": "メンバーマップレイアウトデータの取得に失敗しました。" }

4-2. PUT /api/member-map-layout

ノード座標を保存(UPSERT)する。

  • 実装: app/api/member-map-layout/route.ts
  • リクエストボディ:
{
  "layouts": [
    { "memberId": "oda-sakura", "x": 120.5, "y": -80.0 }
  ]
}
  • 処理: Prisma $transaction 内で各レコードを upsert(where: { memberId } で一致時 update、不一致時 create)
  • レスポンス: { "updated": <更新件数> }
  • エラー: 400 → { "error": "layouts は空でない配列である必要があります。" }(layouts が空配列または非配列の場合)
  • エラー: 500 → { "error": "レイアウトデータの保存に失敗しました。" }

4-3. GET /api/member-relationships

全関係性データを返す(全 type・全ステータスのメンバー間を含む)。

  • 実装: app/api/member-relationships/route.ts
  • ソート: type 昇順、id 昇順
  • レスポンス: MemberRelationship[]
[
  { "id": 1, "fromMemberId": "oda-sakura", "toMemberId": "nonaka-miki", "type": "same-gen", "weight": 1, "label": null }
]
  • エラー: 500 → { "error": "メンバー関係性データの取得に失敗しました。" }

注意: API はフィルタリングを行わない。表示対象メンバー間・許可 type への絞り込みはクライアント側(filterDisplayRelationships)で実施する。


5. クライアント側ユーティリティ(lib/member-map-utils.ts)

5-1. データ変換

関数 用途
buildActiveMemberSet(members) Active メンバーの ID セットを生成
buildDisplayMemberSet(members, includeOg) 表示対象メンバーの ID セットを生成(includeOg=false は Active のみ、true は全メンバー)
filterDisplayRelationships(relationships, displayMemberIds, includeOg) 表示対象メンバー間の関係を抽出(includeOg=false 時は許可 type のみ、true 時は全種別)
buildMemberColorMap(members) memberId → color のマップを生成
buildLayoutMap(layouts) memberId → {x, y} のマップを生成

5-2. フィルタリング(mode = ‘filter’)

getFilteredMemberIds(axis, selectedValue, members, activeRelationships, activeMemberIds)

axis 返却内容
generation 選択期(parseInt(selectedValue))の表示対象メンバー ID セット
hometown member.profile.birthplace === selectedValue の表示対象メンバー ID セット
relationship r.type === selectedValue の関係に含まれる両端メンバー ID セット
leader 加入時のリーダーが選択したリーダーだった表示対象メンバー ID セット(deriveLeaderPeriods、#1266)
joiningRoute member.joiningRoute === selectedValue の表示対象メンバー ID セット(#1284)

getFilteredEdgeIds(axis, selectedValue, activeRelationships, highlightedMemberIds)

axis 返却内容
relationship r.type === selectedValue のエッジ ID セット(edge-${r.id})
その他 両端が highlightedMemberIds に含まれるエッジ ID セット

5-3. グルーピング(mode = ‘group’)

buildGroupInfos(axis, members, activeRelationships, activeMemberIds, selectedRelType)

axis グループキー ラベル
generation String(member.generation) ${key}期
hometown member.profile.birthplace 出身地名(null メンバーはグループ外)
relationship r.label ?? r.type 関係種別ラベル(selectedRelType 一致の関係のみ)
leader 加入時のリーダーの memberId(getMemberLeaderId、#1266) getLeaderChildrenLabel による「(愛称|氏名)チルドレン」形式(member.nickname 配列の先頭(1件目)が登録されていれば愛称、未登録なら氏名)
joiningRoute member.joiningRoute 加入ルート名(undefined メンバーはグループ外、#1284)

buildGroupedLayout(activeMemberIds, groups, layoutMap)

  1. グループ内メンバーの座標(layoutMap)から min/max を求める
  2. GROUP_PADDING = 36 を四辺に加えて包含矩形を算出
  3. グループ同士の重なりを resolveGroupOverlaps で解消(重なりがなくなった時点で終了、最大 100 回)
  4. 子ノードに parentId・相対座標(relX = absX - groupX・relY = absY - groupY)を付与
  5. いずれのグループにも属さないメンバーは ungroupedIds として管理(絶対座標)

5-4. 並列エッジ計算

computeParallelEdgeGroups(edges)

同一ノードペア(source・target をソートしたキー)に複数エッジが存在する場合、各エッジに parallelIndex(0-based)・parallelTotal を付与する。

GradientEdge 側でこれらを使って垂直方向 EDGE_SEPARATION = 18 px ずつオフセットする。


6. 円形配置アルゴリズム

getCirclePosition(index, total) — DB 未登録メンバーの仮配置に使用。

radius = max(200, total × 60)
angle = 2π × index / total
x = round(radius × cos(angle))
y = round(radius × sin(angle))
  • total: 座標未登録メンバー(unsaved、現役+OG 全メンバーが対象)の総数
  • index: unsaved 配列中での順序(unsaved.forEach のインデックス)
  • 仮配置の座標は DB に保存しない。「配置を保存」ボタン押下時に初めて DB へ書き込まれる

7. エッジ線幅計算

getEdgeStrokeWidth(weight) — weight を 1〜5px の線幅に変換。

strokeWidth = max(1, min(1 + weight / 20, 5))
weight strokeWidth
1 1.05
20 2.0
80 5.0
100 5.0(上限)

8. 関係性スコアリング(実装: #1269)

メンバー間の関係性を4軸で数値化し、総合スコア(0〜100pt)として算出する機能。既存の相関図(React Flow)とは独立した機能であり、member_relationships テーブルを置き換えるものではなく拡張する(§8-6)。

8-1. スコア軸と配点

軸 配点 算出元
在籍期間重なり 0〜40pt member.profile.joinDate / gradDate
師弟関係 0〜30pt member_relationships(静的エッジ、type='mentor-student')
メンバーカラー距離 0〜20pt member.color
出身地距離 0〜10pt member.profile.birthplace

総合スコア = 4軸の合計(0〜100pt)。

実装: lib/member-map-utils.ts の getOverlapScore・getMentorStudentScore・getColorDistanceScore・getBirthplaceDistanceScore・getRelationshipScore(4軸合算)。既存の相関図と同じくクライアントから import 可能な純粋関数として実装し、DB・API 呼び出しは行わない(§8-6)。

8-2. 在籍期間重なり(0〜40pt)

overlapMonths = 2メンバーの在籍期間(joinDate〜gradDate:現役は現在まで)が重なる月数
score = min(40, round(40 × min(overlapMonths, 120) / 120))

10年(120ヶ月)で満点(40pt)とし、10年以上は40ptで頭打ちとする。

overlapMonths はカレンダー年月の差分(年×12+月)で算出し、日にちは見ない(例: 1/25〜2/10の重なりも実日数16日ではなく1ヶ月として扱う)。スコアリング用途の粗い指標であり、日単位の精度は求めない。

8-3. 師弟関係(0〜30pt)

既存の mentor-student エッジ(静的エッジ、profile.instructor 由来、方向: 師匠→弟子)を direct/indirect の2段階に分けてスコア化する。

区分 判定 score
direct profile.instructor 由来の直接エッジ(depth=1) 30pt
indirect profile.instructor の連鎖を depth 2 以上たどって到達できるメンバー(師匠の師匠…) 15pt
上記以外 無関係 0pt
  • 区別方法: 新規カラムは追加せず、member_relationships.label に 'direct' / 'indirect' を格納して表現する。same-unit(ユニット名)・radio-coappearance(番組名)等と同様、type ごとに label の意味を再定義する既存パターンを踏襲する
  • indirect 算出: profile.instructor の連鎖を depth 2 以上たどって機械的に算出する。新規データ収集は不要(既存の direct エッジをグラフ探索するのみ)
  • 循環参照防止: 連鎖探索時は訪問済メンバー ID を Set で管理し、既訪問メンバーには再度たどらない
  • 将来拡張: 連鎖では拾えない関係(本人の発言による影響表明等)を追加できるよう、data/inputs/manual-releases.json と同様のパターンで data/inputs/manual-mentor-relationships.json(手動追記ファイル、初期状態は空)を用意し、静的エッジ生成スクリプト実行時にマージする方針とする
  • 実装: direct/indirect の生成・手動登録マージは静的エッジ生成スクリプト scripts/console/seed-member-relationships.ts の generateEdges(+ loadManualMentorRelationships)で行う。動的エッジ生成スクリプト scripts/workflow/sync-relationships.ts(live/radio/event共演のみを扱う)は対象外

8-4. メンバーカラー距離(0〜20pt)

hueDiff = |hueA - hueB| を 0〜180 度に正規化(円環距離: min(|a-b|, 360-|a-b|))
score = round(20 × (1 − hueDiff / 180))

member.color(#RRGGBB)を RGB → HSV 変換して色相(hue、0〜360°)を算出する。hue は既存データに保存されておらず、スコア計算時に都度変換する(新規カラム追加なし)。

8-5. 出身地距離(0〜10pt)

条件 score
同一都道府県(profile.birthplace 完全一致) 10pt
同一地方(都道府県は異なる) 5pt
上記以外・birthplace 未設定 0pt

地方判定は既存インフラを再利用する(新規マッピング不要):

  1. prefecture_codes テーブル(aliases に birthplace の表記ゆれを含む)で birthplace → prefectureCode を解決する
  2. types/live.ts の REGION_PREFECTURE_MAP(prefectureCode → Region)で地方ブロックを解決する
  3. 両メンバーの Region が一致すれば同地方と判定する

birthplace が未設定、または prefecture_codes に一致するエイリアスがない場合は出身地距離を 0pt とする。

8-6. member_relationships テーブルとの関係

スコアリングは既存の member_relationships テーブルを置き換えるのではなく拡張する。

  • 在籍期間重なり・メンバーカラー距離・出身地距離は、スコア計算時に members データから都度算出する(DB に新規テーブル・カラムを追加しない)
  • 師弟関係のみ、既存 mentor-student エッジ(静的エッジ)の label を書き換えて direct/indirect を表現する(§8-3)。動的エッジ(live/radio/event共演)はスコアリングの対象外であり変更しない
  • 4軸の計算結果(総合スコア・軸別内訳)はリクエスト時にサーバー側で算出し、永続化しない(メンバー数規模ではキャッシュテーブルを追加するほどの計算コストではないため)

9. メンバー愛称(Member.nickname、実装: #1266・#1286)

グルーピング軸 leader(加入時リーダーによるチルドレン分類、§5-3)で、グループラベルに使うリーダーの愛称。当初はリーダー限定の leaderNickname フィールドだったが、#1286 で汎用化し、メンバー詳細画面(MemberDetailView)での表示にも用いる。

9-1. データ形状

  • フィールド: member.nickname: string[](types/member.ts の MemberSchema に定義。任意フィールド)。愛称は複数バリエーションを持つメンバーがいるため配列とする
  • 値: メンバーの愛称(例: iida-kaori → ['かおりん'])。登録対象は愛称が判明しているメンバーのみで、data/inputs/member-nicknames.json に列挙された分だけ存在する
  • 利用箇所:
    • lib/member-map-utils.ts の getLeaderChildrenLabel。member.nickname が登録されていれば配列の先頭(1件目)を、未登録なら member.name(氏名)を使って「(愛称|氏名)チルドレン」ラベルを組み立てる(§5-3)
    • components/MemberDetailView.tsx。設定されていれば「愛称」欄に配列を連結して表示する

9-2. データ投入

デプロイなしにBlobデータへ反映できるよう、以下の3点セットで構成する。

ファイル 役割
types/member.ts の MemberSchema.nickname Member のスキーマフィールド(任意の string[])
data/inputs/member-nicknames.json 入力データ。{ "nicknames": [{ "memberId": string, "nicknames": string[] }] }
scripts/patch/patch-member-nicknames.ts 入力 JSON を読み込み、Blob の members.json の該当メンバーへ nickname をセットして書き戻す。入力に含まれないメンバーは既存の nickname を削除する。書き戻し先は Blob 本体(put())と data/work/members.json(作業用の一時コピー。確認後に削除する)の両方

9-3. 実装の経緯

当初は lib/member-map-utils.ts から data/leader-nicknames.json を静的import(resolveJsonModule)する実装だった。これは Next.js のビルド時にバンドルされるため、JSONを編集しただけでは本番に反映されずコミット+デプロイが必要になり、「コンテンツはBlob、コードはデプロイ」という設計原則に反していた。上記の3点セット方式に変更し、lib/member-map-utils.ts 側は member.leaderNickname を直接参照するだけにした(#1266)。

その後、愛称はリーダーに限らず多くのメンバーが持つものであり、メンバー詳細画面にも表示価値があることから、leaderNickname(単一・リーダー限定)を汎用的な nickname(配列・全メンバー対象)に置き換えた(#1286)。

10. 加入ルート(Member.joiningRoute、実装: #1283)

メンバーの加入経緯(オーディション合格・キッズ/研修生からの昇格 等)を表す属性。

10-1. データ形状

  • フィールド: member.joiningRoute: string(types/member.ts の MemberSchema に追加。任意フィールド)
  • 値: 生データをそのまま保持する(正規化・3値への集約は行わない)。収集済の値は以下の6種類:
    • 初期メンバー(オーディションを経ない最初期の指名メンバー)
    • オーディション
    • キッズ昇格(ハロプロキッズからの昇格)
    • 研修生昇格(ハロプロ研修生からの昇格)
    • 留学生(海外からの留学生としての加入)
    • その他
  • 設計判断: #1267 のコメントでは表示用に「オーディション合格/キッズ・研修生からの昇格/その他」の3値への集約が提案されていたが、生データの粒度(キッズ昇格と研修生昇格の区別、初期メンバー・留学生の区別)を失わないよう、Member 側は生データを保持する方針とした
  • 対象: 全メンバー(現役+OG)。未収集のメンバーは joiningRoute を持たない(undefined)

10-2. データ投入

§9-2 の nickname と同型の3点セットを踏襲する。

ファイル 役割
types/member.ts の MemberSchema.joiningRoute Member のスキーマフィールド(任意の string)
data/inputs/member-joining-routes.json 入力データ。{ "joiningRoutes": [{ "memberId": string, "route": string }] }
scripts/patch/patch-member-joining-routes.ts 入力 JSON を読み込み、Blob の members.json の該当メンバーへ joiningRoute をセットして書き戻す。入力に含まれないメンバーは既存の joiningRoute を削除する。書き戻し先は Blob 本体と data/work/members.json(作業用の一時コピー。確認後に削除する)の両方

改訂履歴(バージョン降順に記載)

版 更新日 変更内容
1.10 2026-07-07 §9 リーダー限定の Member.leaderNickname(単一)を汎用的な Member.nickname(配列)に置き換え。data/leader-nicknames.json → data/member-nicknames.json、scripts/patch/patch-leader-nicknames.ts → scripts/patch/patch-member-nicknames.ts にリネーム。MemberDetailView での愛称表示への利用を追記(#1286)
1.9 2026-07-06 §3 ControlAxis に加入ルート軸(joiningRoute)を追加。§5-2/§5-3 に加入ルート軸のフィルタ・グルーピング仕様(member.joiningRoute をそのままキーに使う)を追記(#1284)
1.8 2026-07-06 §9 リーダー愛称(Member.leaderNickname)のデータ設計を新規セクションとして追加(実装済の#1266の内容を明文化)。§10 加入ルート(Member.joiningRoute)のデータ設計を新規追加。生データ(6種類の値)をそのまま保持する方針、data/member-joining-routes.json → scripts/patch/patch-member-joining-routes.ts によるBlobマージ方式を記載(#1283)
1.7 2026-07-06 愛称データの管理方式を修正。data/leader-nicknames.json の静的importをやめ、leaderGenerationNumber と同様に Member の leaderNickname フィールド(Blob の members.json)で管理する方式に変更。scripts/patch/patch-leader-nicknames.ts を新設し、デプロイ不要で愛称を反映できるようにした(#1266)
1.6 2026-07-06 §3 ControlAxis にリーダー軸(leader)を追加。§5-2/§5-3 にリーダー軸のフィルタ・グルーピング仕様(getMemberLeaderId・getLeaderChildrenLabel)を追記。旧関数名 filterActiveRelationships を現行の filterDisplayRelationships に修正し、「Active メンバー」表記を実装(includeOg 対応)に合わせて「表示対象メンバー」に修正。§6 円形配置アルゴリズムの対象範囲・変数名の記述を実装(unsaved、現役+OG 全メンバー)に合わせて修正(#1266)
1.5 2026-07-05 §2-1 静的エッジ/動的エッジの生成元スクリプトの区分を新規追加し、§8 の実装参照先(scripts/console/seed-member-relationships.ts・lib/member-map-utils.ts)を明記(#1269)
1.4 2026-07-04 §8 関係性スコアリングの計算式・direct/indirect ラベル運用・出身地距離の既存インフラ再利用方針を新規追加(#1268)
1.3 2026-07-04 §2-1 live-coappearance の label 算出(Tour.title→Live.title→固定文言フォールバック)を追記し、event-coappearance からライブ種別イベントを除外する仕様を反映(#1263)
1.2 2026-07-02 §2-1 label カラムの説明・ラベル表示ルールを更新し、radio-coappearance/event-coappearance の最多共演番組名・イベント名算出方法を追記(#1236)
1.1 2026-06-29 §4-3 エラーレスポンス追記・§5-3 resolveGroupOverlaps の早期収束に関する補足追加
1.0 2026-06-29 初版作成(#1245)

このページは役に立ちましたか?