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

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

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

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

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)
  • インデックス: fromMemberIdtoMemberId(各単列インデックス)
  • 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-gensame-hometownsame-unitoverlappingleader-successionmentor-student scripts/console/seed-member-relationships.ts メンバー名簿・ユニット構成の変更時に手動実行(コンソールスクリプト)
動的エッジ(DYNAMIC_EDGE_TYPES live-coappearanceradio-coappearanceevent-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-coappearancelabel 算出(scripts/workflow/sync-relationships.ts):

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

  1. ライブ共演: member_liveslivestours(LEFT JOIN)を辿り、Tour.title → (null の場合)Live.title → (両方 null の場合)固定文言「ライブ共演」の順でフォールバックしたラベルをペアごとに取得
  2. ラジオ共演: radio_membersradio_episodesradio_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 内で各レコードを upsertwhere: { 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 加入時のリーダーの memberIdgetMemberLeaderId、#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 - groupXrelY = absY - groupY)を付与
  5. いずれのグループにも属さないメンバーは ungroupedIds として管理(絶対座標)

5-4. 並列エッジ計算

computeParallelEdgeGroups(edges)

同一ノードペア(sourcetarget をソートしたキー)に複数エッジが存在する場合、各エッジに 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.tsgetOverlapScoregetMentorStudentScoregetColorDistanceScoregetBirthplaceDistanceScoregetRelationshipScore(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.tsgenerateEdges(+ 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 テーブル(aliasesbirthplace の表記ゆれを含む)で birthplaceprefectureCode を解決する
  2. types/live.tsREGION_PREFECTURE_MAPprefectureCodeRegion)で地方ブロックを解決する
  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.tsMemberSchema に定義。任意フィールド)。愛称は複数バリエーションを持つメンバーがいるため配列とする
  • 値: メンバーの愛称(例: iida-kaori['かおりん'])。登録対象は愛称が判明しているメンバーのみで、data/inputs/member-nicknames.json に列挙された分だけ存在する
  • 利用箇所:
    • lib/member-map-utils.tsgetLeaderChildrenLabelmember.nickname が登録されていれば配列の先頭(1件目)を、未登録なら member.name(氏名)を使って「(愛称|氏名)チルドレン」ラベルを組み立てる(§5-3)
    • components/MemberDetailView.tsx。設定されていれば「愛称」欄に配列を連結して表示する

9-2. データ投入

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

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

10-2. データ投入

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

ファイル 役割
types/member.tsMemberSchema.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.jsondata/member-nicknames.jsonscripts/patch/patch-leader-nicknames.tsscripts/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.jsonscripts/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 にリーダー軸のフィルタ・グルーピング仕様(getMemberLeaderIdgetLeaderChildrenLabel)を追記。旧関数名 filterActiveRelationships を現行の filterDisplayRelationships に修正し、「Active メンバー」表記を実装(includeOg 対応)に合わせて「表示対象メンバー」に修正。§6 円形配置アルゴリズムの対象範囲・変数名の記述を実装(unsaved、現役+OG 全メンバー)に合わせて修正(#1266)
1.5 2026-07-05 §2-1 静的エッジ/動的エッジの生成元スクリプトの区分を新規追加し、§8 の実装参照先(scripts/console/seed-member-relationships.tslib/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)

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