/* app.js (Final Clean Version) */
// =========================================================
// 1. グローバル変数・状態管理 (必ずファイルの一番上に記述)
// =========================================================
let lastPersonalData = null;
let currentPersonalKeyType = null;
let currentPersonalKeyValue = null;
let currentWikiContent = ''; // 再代入するため let 必須
let lastSearchState = null;
let lastRMState = null;
let lastBackState = null;
// 月間ランキング用
let monthlyOptionsLoaded = false;
let monthlyOffset = 0;
// 村タブ用
let village_loadedOnce = false;
// 名前チェイン用
let nameChainCurrentPage = 1;
let nameChainCurrentOrder = 'last_ts_desc';
let nameChainIsLoading = false;
// 掲示板用
let bbsOffset = 0;
// ログイン状態
let loginState = { logged_in: false, trip: null, name: null };
const LOGIN_IDENTITY_STORAGE_KEY = 'zinro_login_identity';
// タブ制御フラグ
let isProgrammaticTabChange = false;
// =========================================================
// 2. ユーティリティ関数
// =========================================================
function qs(id) { return document.getElementById(id); }
function ce(tag) { return document.createElement(tag); }
function qsAny(ids) {
for (const id of ids) {
const el = document.getElementById(id);
if (el) return el;
}
return null;
}
// HTMLエスケープ
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// 共通API呼び出し
async function apiGet(params) {
const p = new URLSearchParams(params);
const res = await fetch('api.php?' + p.toString(), { credentials: 'include' });
const json = await res.json();
if (!json.ok) throw new Error(json.error || 'API error');
return json;
}
// タブ切り替え
function switchTab(tabId) {
const btn = document.querySelector(`.tab-btn[data-tab="${tabId}"]`);
if (!btn) return;
isProgrammaticTabChange = true;
btn.click();
isProgrammaticTabChange = false;
}
// トリップ正規化(◆などを除去して整形)
function normalizeTripForPersonal(trip) {
return String(trip || '').trim(); // ★付けない・剥がさない
}
// 日付フォーマット (YYYY-MM-DD)
function nh_formatDate(ts) {
if (!ts) return '';
const d = new Date(ts * 1000);
const y = d.getFullYear();
const m = ('0' + (d.getMonth() + 1)).slice(-2);
const day = ('0' + d.getDate()).slice(-2);
return `${y}-${m}-${day}`;
}
// =========================================================
// 3. UI操作・ロジック関数定義
// =========================================================
async function saveWiki(e) {
if (e?.preventDefault) e.preventDefault();
if (!loginState.logged_in) {
alert('ログインしてください。');
return;
}
const wikiBox = qs('wikiBox');
const keyType =
(String(currentPersonalKeyType ?? '').trim() || String(wikiBox?.dataset.keyType ?? '').trim() || String(qs('key_type')?.value ?? '').trim()) || null;
const keyValue =
(String(currentPersonalKeyValue ?? '').trim() || String(wikiBox?.dataset.keyValue ?? '').trim() || String(qs('key_value')?.value ?? '').trim()) || null;
if (!keyType || !keyValue) {
alert('先に検索をしてください。');
return;
}
const body = qs('wikiBody')?.value ?? '';
try {
const res = await fetch('api.php?mode=wiki_save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ key_type: keyType, key_value: keyValue, body })
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) {
alert(data.error || 'Wiki保存に失敗しました');
return;
}
// 表示更新
currentWikiContent = body;
const wikiViewBody = qs('wikiViewBody') || qs('wikiView'); // wikiViewBodyが無くても動く
if (wikiViewBody) {
wikiViewBody.innerHTML = currentWikiContent
? `
${escapeHtml(currentWikiContent)}
`
: 'まだWikiは登録されていません。';
}
setWikiEditing(false);
const action = qs("wikiEditToggleArea"); // ★ここ!
if (action) action.style.display = loginState.logged_in ? 'block' : 'none';
} catch (err) {
console.error(err);
alert('通信エラー');
}
}
function setWikiEditing(flag) {
const wikiView = qs("wikiView");
const wikiEdit = qs("wikiEdit");
const editBtn = qs("wikiEditToggleBtn");
const saveBtn = qs("wikiSaveBtn");
const cancelBtn= qs("wikiEditCancelBtn");
if (!wikiView || !wikiEdit) return;
if (flag) {
wikiView.style.display = "none";
wikiEdit.style.display = "block";
// ボタン表示
if (editBtn) editBtn.style.display = "none";
if (saveBtn) saveBtn.style.display = "inline-block";
if (cancelBtn) cancelBtn.style.display = "inline-block";
const wikiBody = qs("wikiBody");
if (wikiBody) wikiBody.value = currentWikiContent || '';
} else {
wikiView.style.display = "block";
wikiEdit.style.display = "none";
// ボタン表示(ログイン中のみ編集を出す)
if (editBtn) editBtn.style.display = loginState.logged_in ? "inline-block" : "none";
if (saveBtn) saveBtn.style.display = "none";
if (cancelBtn) cancelBtn.style.display = "none";
}
}
function initWikiEditUI() {
const toggleBtn = qs('wikiEditToggleBtn');
const cancelBtn = qs('wikiEditCancelBtn');
if (toggleBtn) {
toggleBtn.addEventListener('click', () => {
const edit = qs('wikiEdit');
const isEditing = edit && edit.style.display !== 'none';
setWikiEditing(!isEditing);
});
}
if (cancelBtn) cancelBtn.addEventListener('click', () => setWikiEditing(false));
// 初期状態をセット
setWikiEditing(false);
}
// --- 履歴管理(名前/トリップ別、各5件) ---
const HISTORY_MAX = 5;
function historyKey(type) {
return (type === 'trip') ? 'personalHistory_trip' : 'personalHistory_name';
}
function saveSearchHistory(type, q) {
const t = (type === 'trip') ? 'trip' : 'name';
const v = String(q || '').trim();
if (!v) return;
let list = JSON.parse(localStorage.getItem(historyKey(t)) || '[]');
list = list.filter(x => x !== v);
list.unshift(v);
list = list.slice(0, HISTORY_MAX);
localStorage.setItem(historyKey(t), JSON.stringify(list));
}
function getSearchHistory(type) {
const t = (type === 'trip') ? 'trip' : 'name';
return JSON.parse(localStorage.getItem(historyKey(t)) || '[]');
}
function ensureHistoryBoxFor(btnId, boxId) {
const btn = qs(btnId);
if (!btn) return null;
let box = qs(boxId);
if (!box) {
box = ce('span');
box.id = boxId;
box.style.marginLeft = '10px';
box.style.fontSize = '.85rem';
box.style.color = 'var(--muted)';
btn.insertAdjacentElement('afterend', box);
}
return box;
}
function renderSearchHistoryButtons() {
// 名前
const nameBox = ensureHistoryBoxFor('personalNameSearchBtn', 'historyNameBox');
if (nameBox) {
const list = getSearchHistory('name');
nameBox.innerHTML = list.length
? `最近の検索:` + list.map(v =>
``
).join(' ')
: '';
}
// トリップ
const tripBox = ensureHistoryBoxFor('personalTripSearchBtn', 'historyTripBox');
if (tripBox) {
const list = getSearchHistory('trip');
tripBox.innerHTML = list.length
? `最近の検索:` + list.map(v =>
``
).join(' ')
: '';
}
}
// --- 画面遷移ヘルパー ---
function jumpToPersonalByTrip(rawTrip) {
const trip = normalizeTripForPersonal(rawTrip);
if (!trip) return;
const activeTab = document.querySelector('.tab-btn.active')?.dataset.tab || 'tab-personal';
lastBackState = { tab: activeTab, scrollY: window.scrollY || 0 };
switchTab('tab-personal');
if (qs('personalTripQuery')) qs('personalTripQuery').value = trip;
if (qs('personalNameQuery')) qs('personalNameQuery').value = '';
runPersonalSearch('trip', trip);
}
function jumpToPersonalByName(name) {
const v = String(name || '').trim();
if (!v) return;
const activeTab = document.querySelector('.tab-btn.active')?.dataset.tab || 'tab-personal';
lastBackState = { tab: activeTab, scrollY: window.scrollY || 0 };
switchTab('tab-personal');
if (qs('personalNameQuery')) qs('personalNameQuery').value = v;
if (qs('personalTripQuery')) qs('personalTripQuery').value = '';
runPersonalSearch('name', v);
}
function jumpToPersonalByLabel(label) {
const s = String(label || '').trim();
if (!s) return;
if (s.startsWith('◆')) jumpToPersonalByTrip(s);
else jumpToPersonalByName(s);
}
// --- 戻るボタン表示 ---
function showBackGeneral() {
const box = qs('personalBackGeneral');
if (!box) return;
if (!lastSearchState) {
box.style.display = 'none';
box.innerHTML = '';
return;
}
box.style.display = 'block';
box.innerHTML = ``;
qs('backGeneralBtn').addEventListener('click', () => {
const state = lastSearchState;
lastSearchState = null;
if (!state) return;
switchTab(state.tab);
setTimeout(() => {
if (state.type === 'personal') {
if (qs('personalQuery')) qs('personalQuery').value = state.params.q || '';
loadPersonal({ skipHistory: true });
} else if (state.type === 'monthly') {
// パラメータ復元
const p = state.params || {};
if (qs('monthlyScope')) qs('monthlyScope').value = p.scope || 'all';
updateMonthlyScopeUI();
if (qs('monthlyYm') && p.ym) qs('monthlyYm').value = p.ym;
if (qs('monthlyYear') && p.year) qs('monthlyYear').value = p.year;
if (qs('monthlyKind')) qs('monthlyKind').value = p.kind || 'games';
if (qs('monthlyKeyType')) qs('monthlyKeyType').value = p.keyType || 'trip';
if (qs('monthlyMinCount')) qs('monthlyMinCount').value = String(p.minCount || 0);
updateMonthlyKeyTypeUI();
refreshPillGroups();
monthlyOffset = 0;
loadMonthly({ skipHistory: true });
} else if (state.type === 'rm') {
if (qs('multiTripInput')) qs('multiTripInput').value = state.params.raw || '';
loadMultiTrip({ skipHistory: true });
}
window.scrollTo(0, state.scrollY || 0);
}, 0);
});
}
function showBackToRM() {
const box = qs('personalBackFromRm');
if (!box) return;
if (!lastRMState || !lastRMState.fromRM) {
box.style.display = 'none';
box.innerHTML = '';
return;
}
box.style.display = 'block';
box.innerHTML = '';
const backBtn = qs('backToRmBtn');
if (backBtn) {
backBtn.addEventListener('click', () => {
switchTab('tab-rm');
setTimeout(() => window.scrollTo(0, lastRMState.scrollY || 0), 0);
});
}
}
// --- 個人戦績検索実行 ---
function runPersonalSearch(type, rawValue) {
const t = (type === 'trip') ? 'trip' : 'name';
let v = (rawValue != null)
? String(rawValue).trim()
: (
t === 'trip'
? (qs('personalTripQuery')?.value ?? qs('personalQuery')?.value ?? '')
: (qs('personalNameQuery')?.value ?? qs('personalQuery')?.value ?? '')
).trim();
if (!v) {
alert(t === 'trip' ? 'トリップを入力してください。' : '名前を入力してください。');
return;
}
if (t === 'trip') {
v = normalizeTripForPersonal(v);
if (!v) { alert('トリップ形式が不正です。'); return; }
if (qs('personalTripQuery')) qs('personalTripQuery').value = v;
} else {
if (qs('personalNameQuery')) qs('personalNameQuery').value = v;
}
// hidden設定
if (qs('key_type')) qs('key_type').value = t;
if (qs('key_value')) qs('key_value').value = v;
// 互換用
if (qs('personalQuery')) qs('personalQuery').value = v;
loadPersonal({ keyType: t, keyValue: v });
}
// --- 個人戦績データロード ---
async function loadPersonal(opts = {}) {
if (qs('personalHaiyakuTable')) qs('personalHaiyakuTable').querySelector('tbody').innerHTML = '';
if (qs('personalRoleTable')) qs('personalRoleTable').querySelector('tbody').innerHTML = '';
if (qs('personalVillageTable')) qs('personalVillageTable').querySelector('tbody').innerHTML = '';
if (qs('personalPartnerTable')) qs('personalPartnerTable').querySelector('tbody').innerHTML = '';
// ★ ここが重要!「逆引き」や「最近のログ」もクリアする
if (qs('personalRecent')) qs('personalRecent').innerHTML = '';
if (qs('personalReverseInline')) qs('personalReverseInline').innerHTML = '';
if (qs('personalReverseHeadline')) qs('personalReverseHeadline').innerHTML = '';
if (qs('personalCalendar')) qs('personalCalendar').innerHTML = ''; // カレンダーも重ねないように
const forcedType = opts.keyType || qs('key_type')?.value || '';
const forcedValue = opts.keyValue || qs('key_value')?.value || '';
const q = String(forcedValue || (qs('personalQuery')?.value.trim() ?? '')).trim();
const resultMain = qs('personalResultMain');
const resultSide = qs('personalResultSide');
const loadingEl = qs('personalLoading');
const summaryEl = qs('personalSummary');
const queryLabelEl = qs('personalQueryLabel');
if (resultMain) resultMain.style.display = 'none';
if (resultSide) resultSide.style.display = 'none';
if (loadingEl) loadingEl.style.display = 'none';
// テーブルクリア
if (qs('personalHaiyakuTable')) qs('personalHaiyakuTable').querySelector('tbody').innerHTML = '';
if (qs('personalRoleTable')) qs('personalRoleTable').querySelector('tbody').innerHTML = '';
if (qs('personalVillageTable')) qs('personalVillageTable').querySelector('tbody').innerHTML = '';
if (qs('personalPartnerTable')) qs('personalPartnerTable').querySelector('tbody').innerHTML = '';
if (qs('personalRecent')) qs('personalRecent').innerHTML = '';
if (summaryEl) summaryEl.textContent = '';
if (queryLabelEl) queryLabelEl.textContent = '';
if (!q) {
if (summaryEl) summaryEl.textContent = '検索語を入力してください。';
return;
}
if (loadingEl) loadingEl.style.display = 'block';
try {
let url = `api.php?mode=personal&q=${encodeURIComponent(q)}`;
if (forcedType) url += `&key_type=${encodeURIComponent(forcedType)}`;
const res = await fetch(url, { credentials: 'include' });
if (!res.ok) throw new Error('Server Error');
const data = await res.json();
if (!data.ok) {
if (summaryEl) summaryEl.textContent = data.msg || data.error || 'エラー';
return;
}
if (!data.found) {
if (summaryEl) summaryEl.textContent = `「${data.query}」の戦績はありません。`;
return;
}
// データ格納
currentPersonalKeyType = data.key_type;
currentPersonalKeyValue = data.query; // ★ここが本体
if (queryLabelEl) {
const kind = data.key_type === 'trip' ? 'トリップ' : '名前';
const shareUrl = buildPersonalShareUrl(data.key_type, data.query);
queryLabelEl.innerHTML = `
検索語:${escapeHtml(data.query)}(${kind}で集計)
`;
const b = qs('personalShareBtn');
if (b) {
b.addEventListener('click', async () => {
const wrap = qs('personalShareWrap');
if (!wrap) return;
// 既存アラートを消す(連打対策)
wrap.querySelectorAll('.share-alert').forEach(e => e.remove());
try {
await copyToClipboard(shareUrl);
const alert = document.createElement('span');
alert.className = 'share-alert';
alert.textContent = 'コピーしました';
// ★ボタンの左に出す(wrapの先頭へ)
wrap.prepend(alert);
setTimeout(() => alert.remove(), 1200);
} catch (e) {
prompt('共有URL(コピーしてね)', shareUrl);
}
});
}
}
qs('personalShareBtn')?.addEventListener('click', async () => {
const wrap = qs('personalShareWrap');
if (!wrap) return;
// 既存アラートを消す(連打対策)
wrap.querySelectorAll('.share-alert').forEach(e => e.remove());
try {
await navigator.clipboard.writeText(shareUrl);
const alert = document.createElement('span');
alert.className = 'share-alert';
alert.textContent = 'コピーしました';
wrap.appendChild(alert);
setTimeout(() => alert.remove(), 1200);
} catch {
prompt('共有URL', shareUrl);
}
});
// 概要表示
window._lastPersonalData = data;
const s = data.summary;
let ratingHtml = '';
if (data.rating && data.rating.games > 0) {
ratingHtml = `レート:${data.rating.rating} (対象戦数 ${data.rating.games})
`;
} else if (data.key_type !== 'trip') {
ratingHtml = `※レートはトリップ検索で表示されます。
`;
} else {
ratingHtml = `レート対象の対戦がまだありません。
`;
}
// 割れ酉(同一トリップを多数の名前で使用)に該当する場合の注記
const sharedHtml = data.is_shared_trip
? `⚠️ このトリップは「割れ酉」判定です(同じトリップが20人以上の異なる名前で使われた実績あり)。複数人での共有のほか、意図的に酉を公開・使い回ししている有名プレイヤーも該当します。不正の断定ではありません。
`
: '';
const statsUpdatedAt = Number(data.stats_updated_at || 0);
const statsCheckedAt = Number(data.stats_checked_at || 0);
const statsUpdatedEl = qs('personalStatsUpdatedAt');
if (statsUpdatedEl) {
const updatedText = statsUpdatedAt > 0
? new Date(statsUpdatedAt * 1000).toLocaleString('ja-JP')
: '取得できません';
const checkedText = statsCheckedAt > 0
? new Date(statsCheckedAt * 1000).toLocaleString('ja-JP')
: '取得できません';
statsUpdatedEl.textContent = `戦績データ更新:${updatedText} / 確認:${checkedText}`;
}
if (summaryEl) {
summaryEl.innerHTML =
`総戦数 ${s.total_games}(勝ち ${s.wins} / 勝率 ${s.win_rate}%)
` +
ratingHtml +
`生存日あたり発言数:${s.avg_talk_per_day} 回/日` +
sharedHtml;
}
// 履歴保存(名前/トリップ別)
if (!opts.skipHistory) {
saveSearchHistory(data.key_type, data.query); // data.key_type は 'name' or 'trip'
renderSearchHistoryButtons();
}
if (resultMain) resultMain.style.display = 'block';
if (resultSide) resultSide.style.display = 'block';
// 変名の系譜図(トリップ検索でチェーンデータがある場合のみ)
renderChainFig(data.trip_chain);
// AI解析(トリップ検索時・解析済みの場合のみ)
await renderPersonalAiTrial(data.key_type, data.query);
// カレンダー
if (data.calendar) {
initPlayCalendar(qs('personalCalendar'), data.calendar);
}
// テーブル描画
const haiBody = qs('personalHaiyakuTable')?.querySelector('tbody');
(data.haiyaku_stats || []).forEach(r => {
const tr = ce('tr');
tr.innerHTML = `${escapeHtml(r.haiyaku)} | ${r.games} | ${r.wins} | ${r.games ? r.win_rate + '%' : '-'} | `;
haiBody?.appendChild(tr);
});
const roleBody = qs('personalRoleTable')?.querySelector('tbody');
(data.role_stats || []).forEach(r => {
const tr = ce('tr');
tr.innerHTML = `${escapeHtml(r.role)} | ${r.games} | ${r.wins} | ${r.games ? r.win_rate + '%' : '-'} | `;
roleBody?.appendChild(tr);
});
const vilBody = qs('personalVillageTable')?.querySelector('tbody');
(data.village_top || []).forEach(r => {
const tr = ce('tr');
tr.innerHTML = `${escapeHtml(r.village)} | ${r.games} | `;
vilBody?.appendChild(tr);
});
const parBody = qs('personalPartnerTable')?.querySelector('tbody');
(data.partners_top || []).forEach(r => {
const tr = ce('tr');
const td1 = ce('td');
const b = ce('button');
b.type = 'button';
b.className = 'name-trip-link-button';
b.dataset.label = r.key || '';
b.textContent = r.key || '';
td1.appendChild(b);
const td2 = ce('td');
td2.className = 'num';
td2.textContent = String(r.games ?? 0);
tr.appendChild(td1); tr.appendChild(td2);
parBody?.appendChild(tr);
});
// 最近ログ
const recentEl = qs('personalRecent');
const recentParts = (data.recent_logs || []).map(r => {
const d = new Date((r.started_at + 9 * 3600) * 1000);
const ym = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
return ``;
});
if (recentEl) recentEl.innerHTML = recentParts.join('');
renderProfileWikiComments(data);
renderReverseLookup(data);
} catch (e) {
console.error(e);
if (summaryEl) summaryEl.textContent = 'エラー: ' + (e?.message || e);
} finally {
if (loadingEl) loadingEl.style.display = 'none';
}
showBackGeneral();
showBackToRM();
}
function renderReverseLookup(data) {
const box = qs('personalReverseInline') || qs('personalReverseBox');
if (!box) return;
const hbox = qs('personalReverseHeadline'); // 見出し差し込み先(任意)
const clearAll = () => {
box.innerHTML = '';
if (hbox) hbox.innerHTML = '';
};
const rev = data.reverse || null;
if (!rev) { clearAll(); return; }
const hasN2T = Array.isArray(rev.names_to_trips) && rev.names_to_trips.length > 0;
const hasT2N = Array.isArray(rev.trips_to_names) && rev.trips_to_names.length > 0;
if (!hasN2T && !hasT2N) { clearAll(); return; }
// 見出しモード決定(両方あるなら「名前→トリップ&トリップ→名前」でもOK)
let headlineLabel = '';
if (hasN2T && !hasT2N) headlineLabel = '名前 → トリップ';
else if (!hasN2T && hasT2N) headlineLabel = 'トリップ → 名前';
else headlineLabel = '名前 → トリップ / トリップ → 名前';
if (hbox) {
hbox.innerHTML = `逆引き(${headlineLabel})
`;
}
let html = '';
// 名前→トリップ
if (hasN2T) {
rev.names_to_trips.slice(0, 10).forEach(row => {
const trips = row.trips ?? [];
const tripLinks = trips.map(t => {
const tripDisp = String(t.trip ?? '').trim(); // ★DB尊重(◆も含めてそのまま)
const g = (t.games != null) ? ` (${t.games})` : '';
return `${g}`;
}).join(' ');
html += `${tripLinks || 'なし'}
`;
});
}
// トリップ→名前
if (hasT2N) {
rev.trips_to_names.slice(0, 10).forEach(row => {
const tripDisp = String(row.trip ?? '').trim(); // ★DB尊重(normalizeしない)
const names = row.names ?? [];
const nameLinks = names.map(n => {
const g = (n.games != null) ? ` (${n.games})` : '';
return `${g}`;
}).join(' ');
html += `${nameLinks || 'なし'}
`;
});
}
box.innerHTML = html;
}
function normTrip(t) {
return String(t || '').trim().replace(/^◆+/, '');
}
// --- Wiki/プロフィール/コメント描画 ---
function renderProfileWikiComments(data) {
lastPersonalData = data;
currentPersonalKeyType = data.key_type;
currentPersonalKeyValue = data.query ?? data.key_value ?? qs('key_value')?.value ?? null;
if (qs('key_type')) qs('key_type').value = currentPersonalKeyType || '';
if (qs('key_value')) qs('key_value').value = currentPersonalKeyValue || '';
const profileView = qs('profileView');
const profileEdit = qs('profileEdit');
const wikiView = qs('wikiView');
const wikiBody = qs('wikiBody');
const commentList = qs('commentList');
const commentForm = qsAny(['commentForm', 'comment-form', 'commentFormBox']);
const selfIntroEl = qs('profileSelfIntro');
const xIdEl = qs('profileXId');
const xDispEl = qs('profileXDisplay');
const wikiBox = qs('wikiBox'); // wiki全体の親要素
if (wikiBox) {
wikiBox.dataset.keyType = data.key_type;
wikiBox.dataset.keyValue = (data.query ?? data.key_value ?? '').trim();
}
// プロフィール
if (data.key_type === 'trip' && data.profile) {
const p = data.profile;
let html = '';
if (p.self_intro) {
html += `${escapeHtml(p.self_intro)}
`;
} else {
html += `自己紹介はまだ登録されていません。
`;
}
if (p.x_id) {
const raw = p.x_id.trim();
const id = raw.replace(/^@/, '');
const label = p.x_display && p.x_display.trim() ? p.x_display.trim() : '@' + id;
html += ``;
}
if (profileView) profileView.innerHTML = html;
if (selfIntroEl) selfIntroEl.value = p.self_intro || '';
if (xIdEl) xIdEl.value = p.x_id || '';
if (xDispEl) xDispEl.value = p.x_display || '';
if (profileEdit) {
profileEdit.style.display = (loginState.logged_in && p.editable) ? 'block' : 'none';
}
} else {
if (profileView) profileView.innerHTML = 'プロフィールはトリップ検索時のみ表示されます。';
if (profileEdit) profileEdit.style.display = 'none';
}
// Wiki
const w = data.wiki || { body: '', updated_at: null, editable: false };
currentWikiContent = w.body || ''; // ★再代入 (constだとエラーになる)
const wikiViewBody = qs('wikiViewBody') || qs('wikiView');
if (wikiViewBody) {
wikiViewBody.innerHTML = currentWikiContent
? `${escapeHtml(currentWikiContent)}
`
: 'まだWikiは登録されていません。';
}
if (wikiBody) wikiBody.value = currentWikiContent;
// 編集モードリセット
setWikiEditing(false);
const toggleArea = qs('wikiEditToggleArea');
if (toggleArea) {
toggleArea.style.display = loginState.logged_in ? 'block' : 'none';
}
// コメント
if (commentList) {
const comments = data.comments || [];
if (!comments.length) {
commentList.innerHTML = 'コメントはまだありません。';
} else {
const parts = comments.map(c => {
const d = new Date(c.created_at * 1000);
const ym = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const who = c.author_name || '(名無し)';
const trip = c.author_trip ? ` ${c.author_trip}` : '';
return `
${ym} ${hh}:${mm} / ${escapeHtml(who)}${escapeHtml(trip)}
${escapeHtml(c.body)}
`;
});
commentList.innerHTML = parts.join('');
}
}
if (commentForm) commentForm.style.display = loginState.logged_in ? 'block' : 'none';
}
// --- カレンダー描画 ---
function renderCalendar(parent, calendar, viewYear = null) {
const days = calendar.days || {};
if (!calendar.start || !calendar.end) return;
const allStart = new Date(calendar.start + 'T00:00:00+09:00');
const allEnd = new Date(calendar.end + 'T00:00:00+09:00');
let startDate = new Date(allStart);
let endDate = new Date(allEnd);
if (viewYear !== null) {
startDate = new Date(`${viewYear}-01-01T00:00:00+09:00`);
endDate = new Date(`${viewYear}-12-31T00:00:00+09:00`);
if (startDate < allStart) startDate = new Date(allStart);
if (endDate > allEnd) endDate = new Date(allEnd);
}
const card = ce('div'); card.className = 'calendar-card';
const title = ce('div'); title.className = 'calendar-title';
title.innerHTML = `プレイ日カレンダー${startDate.getFullYear()}年`;
card.appendChild(title);
const grid = ce('div'); grid.className = 'calendar-grid';
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const key = `${y}-${m}-${day}`;
const info = days[key];
const cell = ce('div'); cell.className = 'calendar-day';
if (info && info.games > 0) {
cell.classList.add('has-games');
const g = info.games;
let level = 1;
if (g >= 6) level = 4;
else if (g >= 4) level = 3;
else if (g >= 2) level = 2;
cell.classList.add(`calendar-level-${level}`);
cell.title = `${key}\n${g}戦 (${info.wins ?? 0}勝)`;
} else {
cell.title = `${key}\n対戦なし`;
}
grid.appendChild(cell);
}
card.appendChild(grid);
parent.appendChild(card);
}
function initPlayCalendar(parent, calendar) {
if (!calendar || !calendar.start || !calendar.end) return;
parent.innerHTML = '';
const wrapper = ce('div'); wrapper.className = 'calendar-wrapper';
const firstYear = parseInt(calendar.start.slice(0, 4), 10);
const lastYear = parseInt(calendar.end.slice(0, 4), 10);
let currentYear = lastYear;
const nav = ce('div'); nav.className = 'calendar-year-nav';
nav.innerHTML = `
${currentYear}年
`;
const container = ce('div'); container.className = 'calendar-container';
wrapper.appendChild(nav);
wrapper.appendChild(container);
parent.appendChild(wrapper);
const label = nav.querySelector('.calendar-year-label');
const update = () => {
label.textContent = currentYear + '年';
container.innerHTML = '';
renderCalendar(container, calendar, currentYear);
};
nav.querySelector('#calPrev').addEventListener('click', () => {
if (currentYear > firstYear) { currentYear--; update(); }
});
nav.querySelector('#calNext').addEventListener('click', () => {
if (currentYear < lastYear) { currentYear++; update(); }
});
update();
}
// =========================================================
// 4. データ保存・ログイン系
// =========================================================
async function doLogin() {
const name = qs('loginNameInput')?.value.trim() ?? '';
const tripKey = qs('loginTripKeyInput')?.value.trim() ?? '';
if (!name) { alert('名前を入力してください。'); qs('loginNameInput')?.focus(); return; }
if (!tripKey) { alert('トリップキーを入力してください。'); return; }
try {
const res = await fetch('api.php?mode=login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, trip_key: tripKey }),
credentials: 'include',
});
const data = await res.json();
if (!res.ok || !data.ok) { alert(data.error || 'ログイン失敗'); return; }
loginState.logged_in = true;
loginState.trip = data.trip;
loginState.name = data.name || name || '';
saveLoginIdentity(name, tripKey);
updateLoginUI();
loadProfileForLoginTab();
if (currentPersonalKeyType && currentPersonalKeyValue) loadPersonal();
} catch (e) { alert('通信エラー'); }
}
function restoreLoginIdentity() {
try {
const saved = JSON.parse(localStorage.getItem(LOGIN_IDENTITY_STORAGE_KEY) || 'null');
if (!saved || typeof saved !== 'object') return;
if (qs('loginNameInput')) qs('loginNameInput').value = String(saved.name || '');
if (qs('loginTripKeyInput')) qs('loginTripKeyInput').value = String(saved.tripKey || '');
if (qs('loginRememberInput')) qs('loginRememberInput').checked = true;
} catch {
localStorage.removeItem(LOGIN_IDENTITY_STORAGE_KEY);
}
}
function saveLoginIdentity(name, tripKey) {
if (qs('loginRememberInput')?.checked) {
localStorage.setItem(LOGIN_IDENTITY_STORAGE_KEY, JSON.stringify({ name, tripKey }));
} else {
localStorage.removeItem(LOGIN_IDENTITY_STORAGE_KEY);
}
}
async function doLogout() {
try {
await fetch('api.php?mode=logout', { credentials: 'include' });
loginState = { logged_in: false, trip: null, name: null };
updateLoginUI();
if (currentPersonalKeyType && currentPersonalKeyValue) loadPersonal();
} catch (e) { }
}
function updateLoginUI() {
const statusEl = qs('loginStatus');
const logoutArea = qs('logoutArea');
const loginForm = qs('loginForm');
const personalInfo = qs('personalLoginInfo');
if (statusEl) {
if (loginState.logged_in) {
statusEl.textContent = `ログイン中:${loginState.name || '(名前未設定)'} / ${loginState.trip}`;
} else {
statusEl.textContent = '(未ログイン)';
}
}
if (logoutArea) logoutArea.style.display = loginState.logged_in ? 'block' : 'none';
if (loginForm) loginForm.style.opacity = loginState.logged_in ? 0.5 : 1;
if (personalInfo) {
if (loginState.logged_in) {
personalInfo.style.display = 'none';
} else {
personalInfo.style.display = 'block';
personalInfo.innerHTML = ``;
}
}
if (lastPersonalData) renderProfileWikiComments(lastPersonalData);
}
async function refreshLoginUI() {
let j = null;
try {
const r = await fetch('api.php?mode=me', { credentials: 'include' });
j = await r.json();
} catch (e) {}
const loggedIn = !!(j && (j.ok === true) && j.logged_in);
const name = loggedIn ? (j.name || '(名無し)') : '(未ログイン)';
const trip = loggedIn ? (j.trip || '') : '';
// 1) 「ログイン状態を確認中…」の表示場所を総当りで潰す
const infoEl = qsAny([
'personalLoginInfo',
'personal-login-info',
'loginInfo',
'login-info',
'loginStateInfo'
]);
if (infoEl) {
infoEl.textContent = loggedIn ? `ログイン中:${name} / ${trip}` : '未ログイン(Wiki/コメントの編集はできません)';
}
// 2) コメント投稿者名
const authorEl = qsAny([
'comment-author-name',
'commentAuthorName',
'commentAuthor',
'comment-author',
'commentAuthorLabel'
]);
if (authorEl) authorEl.textContent = name;
}
async function fetchMe() {
try {
const res = await fetch('api.php?mode=me', { credentials: 'include' });
if (!res.ok) return;
const data = await res.json();
if (data.ok) {
loginState.logged_in = !!data.logged_in;
loginState.trip = data.trip || null;
loginState.name = data.name || null;
updateLoginUI();
}
} catch (e) { }
}
async function loadProfileForLoginTab() {
if (!loginState.logged_in) return;
try {
const res = await fetch('api.php?mode=profile_get', { credentials: 'include' });
if (!res.ok) return;
const data = await res.json();
if (data.ok && data.profile) {
if (qs('profileSelfIntro')) qs('profileSelfIntro').value = data.profile.self_intro || '';
if (qs('profileXId')) qs('profileXId').value = data.profile.x_id || '';
if (qs('profileXDisplay')) qs('profileXDisplay').value = data.profile.x_display || '';
}
} catch (e) { }
}
async function saveProfile() {
if (!loginState.logged_in) return;
const selfIntro = qs('profileSelfIntro')?.value ?? '';
const xId = qs('profileXId')?.value ?? '';
const xDisp = qs('profileXDisplay')?.value ?? '';
try {
const res = await fetch('api.php?mode=profile_update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ self_intro: selfIntro, x_id: xId, x_display: xDisp })
});
const data = await res.json();
if (!data.ok) { alert(data.error); return; }
alert('保存しました');
if (currentPersonalKeyType && currentPersonalKeyValue) loadPersonal();
} catch (e) { alert('通信エラー'); }
}
async function sendComment(e) {
if (e?.preventDefault) e.preventDefault();
if (!loginState.logged_in) {
alert('ログインしてください。');
return;
}
const wikiDs = qs('wikiBox')?.dataset || {};
const keyType =
(wikiDs.keyType && wikiDs.keyType.trim())
|| (qs('key_type')?.value || '').trim()
|| (currentPersonalKeyType || '').trim()
|| null;
const keyValue =
(wikiDs.keyValue && wikiDs.keyValue.trim())
|| (qs('key_value')?.value || '').trim()
|| (currentPersonalKeyValue || '').trim()
|| null;
if (!keyType || !keyValue) {
alert('先に個人検索をしてください。');
return;
}
const bodyEl = qsAny(['commentBody', 'comment-body']);
const bodyText = (bodyEl?.value ?? '').trim();
if (!bodyText) { alert('コメント本文が空です。'); return; }
try {
const res = await fetch('api.php?mode=comment_add', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
key_type: keyType,
key_value: keyValue,
body: bodyText,
}),
});
const text = await res.text();
let data = {};
try { data = JSON.parse(text); } catch (_) {}
if (!res.ok || !data.ok) {
if (!res.ok) {
alert(`HTTP ${res.status}\n${text.slice(0, 500)}`);
return;
}
if (!data.ok) {
alert((data && data.error) ? data.error : `API ok=false\n${text.slice(0, 500)}`);
return;
}
//alert((data && data.error) ? data.error : `コメント投稿に失敗しました (HTTP ${res.status})\n${text.slice(0, 300)}`);
return;
}
if (bodyEl) bodyEl.value = '';
loadPersonal();
} catch (err) {
console.error(err);
alert('通信エラー');
}
}
// =========================================================
// 5. ランキング・その他タブ
// =========================================================
// --- 月間ランキング ---
function updateMonthlyScopeUI() {
const scope = qs('monthlyScope')?.value || 'month';
const mBox = qs('monthlyMonthBox');
const yBox = qs('monthlyYearBox');
if (mBox && yBox) {
mBox.style.display = (scope === 'month') ? 'flex' : 'none';
yBox.style.display = (scope === 'year') ? 'flex' : 'none';
}
}
function updateMonthlyKeyTypeUI() {
const kind = qs('monthlyKind')?.value || '';
const box = qs('monthlyKeyTypeBox');
const keySel = qs('monthlyKeyType');
if (box && keySel) {
// 検索種別は必須項目として常時表示(トリップ別が使えない種別は名前別に戻す)
box.style.display = 'flex';
if ((kind === 'talk' || kind === 'days') && keySel.value === 'trip') keySel.value = 'name';
}
// 配役選択は「配役別プレイヤー」のときだけ表示
const hyBox = qs('monthlyHaiyakuBox');
if (hyBox) hyBox.style.display = (kind === 'haiyaku_player') ? 'flex' : 'none';
// レート・投票精度は全期間固定なので期間UIを隠す
const isRating = (kind === 'rating' || kind === 'vote_acc');
const scopeSel = qs('monthlyScope');
if (scopeSel && scopeSel.parentElement) scopeSel.parentElement.style.display = isRating ? 'none' : 'flex';
if (isRating) {
const mBox = qs('monthlyMonthBox');
const yBox = qs('monthlyYearBox');
if (mBox) mBox.style.display = 'none';
if (yBox) yBox.style.display = 'none';
} else {
updateMonthlyScopeUI();
}
}
function setMonthlyLoading(isLoading) {
const l = qs('monthlyLoading');
if (l) l.style.display = isLoading ? 'block' : 'none';
}
function renderMonthlyPager(total, limit) {
const el = qs('monthlyPager');
if (!el) return;
const start = monthlyOffset + 1;
const end = Math.min(monthlyOffset + limit, total);
const more = (monthlyOffset + limit < total);
el.innerHTML = total ? `${start}〜${end} / ${total}件` + (more ? ` ` : '') : '';
if (more) {
qs('monthlyMoreBtn').addEventListener('click', () => { monthlyOffset += limit; loadMonthly(); });
}
}
async function loadMonthly(opts = {}) {
const kind = qs('monthlyKind')?.value;
if (!kind) return;
setMonthlyLoading(true);
const tbody = qs('monthlyTable')?.querySelector('tbody');
if (monthlyOffset === 0) tbody.innerHTML = '';
const wrap = qs('monthlyTableWrap');
if (wrap) wrap.style.display = 'block';
// パラメータ構築
const scope = qs('monthlyScope')?.value || 'month';
let ymParam = 'all';
if (scope === 'month') ymParam = qs('monthlyYm')?.value;
if (scope === 'year') ymParam = qs('monthlyYear')?.value;
const keyType = qs('monthlyKeyType')?.value || 'name';
let mode = '';
// (mode決定ロジックは簡略化して記述)
if (kind === 'games') mode = (keyType === 'trip') ? 'monthly_trip_rank' : 'monthly_name_rank';
else if (kind === 'win_rate') mode = (keyType === 'trip') ? 'monthly_trip_win_rank' : 'monthly_name_win_rank';
else if (kind === 'talk') mode = 'monthly_name_talk_rank';
else if (kind === 'days') mode = 'monthly_name_days_rank';
else if (kind === 'role_win') mode = 'monthly_role_win_rank';
else if (kind === 'haiyaku_win') mode = 'monthly_haiyaku_win_rank';
else if (kind === 'village') mode = 'monthly_village_rank';
else if (kind === 'haiyaku') mode = 'monthly_haiyaku_rank';
else if (kind === 'rm') mode = 'monthly_rm_rank';
else if (kind === 'tsuiho') mode = 'monthly_tsuiho_rank';
else if (kind === 'career') mode = 'monthly_career_rank';
else if (kind === 'haiyaku_player') mode = 'haiyaku_player_rank';
else if (kind === 'rating') mode = 'rating_rank';
else if (kind === 'vote_acc') mode = 'vote_acc_rank';
const minCount = Math.max(0, parseInt(qs('monthlyMinCount')?.value || '0', 10) || 0);
let url = `api.php?mode=${mode}&limit=100&offset=${monthlyOffset}`;
if (kind === 'vote_acc') {
if (minCount > 0) url += `&min_count=${minCount}`;
} else if (kind !== 'rating') {
url += `&ym=${encodeURIComponent(ymParam)}&key_type=${keyType}`;
if (minCount > 0) url += `&min_count=${minCount}`;
if (kind === 'haiyaku_player') {
const hy = qs('monthlyHaiyaku')?.value || '';
if (!hy) { setMonthlyLoading(false); return; }
url += `&haiyaku=${encodeURIComponent(hy)}`;
}
// トリップなし除外(名前別のプレイヤー集計のみ意味を持つ)
if (qs('monthlyNoTrip')?.value === '1' && keyType === 'name' &&
['games', 'win_rate', 'talk', 'career', 'haiyaku_player'].includes(kind)) {
url += '&exclude_no_trip=1';
}
} else if (minCount > 0) {
url += `&min_games=${minCount}`;
}
// ヘッダー更新
const labelTh = qs('monthlyLabelTh');
const winsTh = qs('monthlyWinsTh');
const rateTh = qs('monthlyRateTh');
const valueTh = qs('monthlyValueTh');
winsTh.textContent = (kind === 'rating' || kind === 'career') ? '戦数' : ((kind === 'vote_acc') ? '票数' : '勝ち');
if (kind === 'rating' || kind === 'vote_acc') {
labelTh.textContent = '名前 / トリップ';
winsTh.style.display = '';
rateTh.style.display = 'none';
} else if (kind === 'career') {
labelTh.textContent = ((keyType === 'trip') ? 'トリップ' : '名前') + '(初登場〜最終)';
winsTh.style.display = '';
rateTh.style.display = 'none';
} else if (['games', 'win_rate', 'talk', 'days', 'haiyaku_player'].includes(kind)) {
labelTh.textContent = (keyType === 'trip') ? 'トリップ' : '名前';
const showWin = (kind === 'games' || kind === 'win_rate' || kind === 'haiyaku_player');
winsTh.style.display = showWin ? '' : 'none';
rateTh.style.display = showWin ? '' : 'none';
} else {
labelTh.textContent = '項目';
winsTh.style.display = (kind.includes('win') && kind !== 'win_rate') ? '' : 'none';
rateTh.style.display = (kind.includes('win') && kind !== 'win_rate') ? '' : 'none';
}
valueTh.textContent = (kind === 'talk') ? '発言密度' : ((kind === 'days') ? '日数' : ((kind === 'rating') ? 'レート' : ((kind === 'vote_acc') ? '対狼投票率' : ((kind === 'career') ? '日数' : '件数'))));
try {
const res = await fetch(url, { credentials: 'include' });
const data = await res.json();
if (data.ok) {
// 値(ラベル)が空白の行は表示しない
const noTripExcluded = qs('monthlyNoTrip')?.value === '1';
const rowsData = (data.rows || []).filter(r => {
// トリップ未設定はデータ上 trip='なし' で入っているため、除外指定時は落とす
if (noTripExcluded && r.trip === 'なし') return false;
if (kind === 'rating') return !!(r.trip || r.main_name);
const lb = r.name || r.trip || r.village_name || r.village || r.haiyaku || r.role || r.rm_name || '';
return String(lb).trim() !== '';
});
rowsData.forEach((r, i) => {
const tr = ce('tr');
const rank = monthlyOffset + i + 1;
// 行生成ロジック
let label = r.name || r.trip || r.village_name || r.village || r.haiyaku || r.role || r.rm_name || '';
if (keyType === 'trip' && r.trip) label = '◆' + r.trip.replace(/^◆/, '');
// リンクボタン化するかどうか
let labelHtml = escapeHtml(label);
if (kind === 'rating' || kind === 'vote_acc') {
const trip = '◆' + (r.trip || '').replace(/^◆/, '');
labelHtml = ` `;
} else if (kind === 'career') {
let btn;
if (keyType === 'trip' && r.trip) {
const trip = '◆' + r.trip.replace(/^◆/, '');
btn = ``;
} else {
btn = ``;
}
labelHtml = `${btn} ${escapeHtml(r.first_day || '')}〜${escapeHtml(r.last_day || '')}`;
} else if (['games', 'win_rate', 'talk', 'days', 'haiyaku_player'].includes(kind)) {
const cls = (keyType === 'trip') ? 'trip-link-button' : 'name-trip-link-button';
const dataAttr = (keyType === 'trip') ? `data-trip="${escapeHtml(r.trip)}"` : `data-label="${escapeHtml(label)}"`;
labelHtml = ``;
}
const val = (kind === 'rating')
? Math.round(r.rating ?? 0)
: ((kind === 'vote_acc')
? (r.accuracy ?? 0) + '%'
: (r.span_days ?? r.times ?? r.unique_days ?? r.density ?? r.players ?? r.games ?? r.count ?? 0));
const win = (kind === 'rating' || kind === 'career') ? (r.games ?? 0) : ((kind === 'vote_acc') ? (r.votes ?? 0) : (r.wins ?? 0));
const rate = r.win_rate ?? 0;
let html = `${rank} | ${labelHtml} | ${val} | `;
if (winsTh.style.display !== 'none') {
html += `${win} | `;
if (rateTh.style.display !== 'none') html += `${rate}% | `;
}
tr.innerHTML = html;
tbody.appendChild(tr);
});
renderMonthlyPager(data.total, data.limit);
if (monthlyOffset === 0 && !opts.skipHistory) {
lastSearchState = {
tab: 'tab-monthly', type: 'monthly',
params: { scope, kind, keyType, ym: ymParam, minCount },
scrollY: window.scrollY
};
saveMonthlyPrefs({
scope, kind, keyType, minCount,
ym: qs('monthlyYm')?.value || '',
year: qs('monthlyYear')?.value || '',
haiyaku: qs('monthlyHaiyaku')?.value || '',
noTrip: qs('monthlyNoTrip')?.value || '0'
});
}
}
} catch (e) { console.error(e); }
finally { setMonthlyLoading(false); }
}
function buildFallbackMonths() {
const months = [];
const now = new Date();
for (let y = 2016; y <= now.getFullYear(); y++) {
for (let m = 1; m <= 12; m++) {
if (y === now.getFullYear() && m > now.getMonth() + 1) break;
months.unshift(`${y}-${String(m).padStart(2, '0')}`);
}
}
return months;
}
function fillMonthYearSelects(monthSelect, yearSelect, months) {
if (monthSelect) monthSelect.innerHTML = '';
if (yearSelect) yearSelect.innerHTML = '';
const years = [...new Set(months.map(ym => ym.slice(0, 4)))];
months.forEach(ym => {
if (!monthSelect) return;
const optM = ce('option');
optM.value = ym;
optM.textContent = ym;
monthSelect.appendChild(optM);
});
years.forEach(y => {
if (!yearSelect) return;
const optY = ce('option');
optY.value = y;
optY.textContent = y + '年';
yearSelect.appendChild(optY);
});
if (monthSelect) monthSelect.selectedIndex = 0;
if (yearSelect) yearSelect.selectedIndex = 0;
}
async function loadMonths() {
let months = [];
try {
const data = await apiGet({ mode: 'months' });
months = Array.isArray(data.months) ? data.months : [];
} catch (e) {
console.error(e);
}
if (!months.length) months = buildFallbackMonths();
fillMonthYearSelects(qs('monthlyYm'), qs('monthlyYear'), months);
fillMonthYearSelects(qs('classStatsYm'), qs('classStatsYear'), months);
// Cookieに保存された前回の対象月/年を復元
['monthlyYm', 'monthlyYear'].forEach(id => {
const el = qs(id);
if (el && el.dataset.pref && [...el.options].some(o => o.value === el.dataset.pref)) el.value = el.dataset.pref;
});
}
// 配役別プレイヤー用の配役一覧(件数降順)を読み込んでピル化する
async function loadHaiyakuOptions() {
const sel = qs('monthlyHaiyaku');
if (!sel) return;
try {
const res = await fetch('api.php?mode=monthly_haiyaku_rank&ym=all&limit=100', { credentials: 'include' });
const data = await res.json();
if (!data.ok) throw new Error(data.error || 'API error');
sel.innerHTML = '';
(data.rows || []).forEach(r => {
const hy = (r.haiyaku || '').trim();
if (!hy) return;
const opt = ce('option');
opt.value = hy;
opt.textContent = hy;
sel.appendChild(opt);
});
setupPillGroup('monthlyHaiyaku');
// Cookieに保存された前回の配役を復元
if (sel.dataset.pref && [...sel.options].some(o => o.value === sel.dataset.pref)) {
sel.value = sel.dataset.pref;
refreshPillGroups();
}
} catch (e) {
console.error(e);
}
}
function resetMonthlyControls() {
monthlyOffset = 0;
qs('monthlyTable').querySelector('tbody').innerHTML = '';
qs('monthlyPager').textContent = '';
const wrap = qs('monthlyTableWrap');
if (wrap) wrap.style.display = 'none';
updateMonthlyScopeUI();
updateMonthlyKeyTypeUI();
updateClassStatsScopeUI();
}
function updateClassStatsScopeUI() {
const scope = qs('classStatsScope')?.value || 'all';
const mBox = qs('classStatsMonthBox');
const yBox = qs('classStatsYearBox');
if (mBox) mBox.style.display = (scope === 'month') ? 'block' : 'none';
if (yBox) yBox.style.display = (scope === 'year') ? 'block' : 'none';
}
async function loadClassStats() {
const box = qs('classStatsBox');
if (!box) return;
const scope = qs('classStatsScope')?.value || 'all';
let ym = 'all';
if (scope === 'month') ym = qs('classStatsYm')?.value || 'all';
if (scope === 'year') ym = qs('classStatsYear')?.value || 'all';
box.textContent = '読み込み中…';
try {
const data = await apiGet({ mode: 'stats_by_class', ym });
const rows = [
['全体', data.all],
['通常', data.normal],
['カオス', data.chaos],
];
let html = '| 分類 | ゲーム数 | 勝ち | 勝率 |
';
rows.forEach(([label, r]) => {
const games = r?.games ?? 0;
const wins = r?.wins ?? 0;
const rate = r?.win_rate ?? 0;
html += `| ${label} | ${games} | ${wins} | ${rate}% |
`;
});
html += '
';
box.innerHTML = html;
} catch (e) {
console.error(e);
box.textContent = '統計の取得に失敗しました。';
}
}
// --- 戦績カード画像(PNG保存) ---
async function savePlayerCard() {
const d = window._lastPersonalData;
if (!d || !d.summary) { alert('先に個人戦績を検索してください'); return; }
const s = d.summary;
const name = d.query || '';
const isTrip = d.key_type === 'trip';
const rating = (d.rating && d.rating.games > 0) ? Math.round(d.rating.rating) : null;
const roles = Object.entries(d.role_stats || {}).map(([k, v]) => ({role: k, g: (v && v.games) || 0, w: (v && v.wins) || 0}))
.sort((a, b) => b.g - a.g).slice(0, 3);
const esc = t => String(t).replace(/&/g, '&').replace(//g, '>');
const fy = d.first_ym || '';
const W = 800, H = 420;
const rolesSvg = roles.map((r, i) =>
`${esc(r.role)}` +
`${r.g}戦 ${r.g ? Math.round(r.w / r.g * 100) : 0}%勝`).join('');
const svg = ``;
const img = new Image();
const blob = new Blob([svg], {type: 'image/svg+xml;charset=utf-8'});
const url = URL.createObjectURL(blob);
await new Promise((res, rej) => { img.onload = res; img.onerror = rej; img.src = url; });
const cv = document.createElement('canvas');
cv.width = W * 2; cv.height = H * 2;
const ctx = cv.getContext('2d');
ctx.scale(2, 2); ctx.drawImage(img, 0, 0);
URL.revokeObjectURL(url);
cv.toBlob(b => {
const a = document.createElement('a');
a.href = URL.createObjectURL(b);
a.download = 'zinro-card.png';
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
}, 'image/png');
}
document.addEventListener('click', e => {
if (e.target && e.target.id === 'personalCardBtn') savePlayerCard();
});
// --- AI解析(個人戦績内) ---
let aiTrialDataPromise = null;
let aiTrialScoreStats = null;
function fetchAiTrialData() {
if (!aiTrialDataPromise) {
aiTrialDataPromise = fetch('ai-trial.json', { cache: 'no-store' }).then(response => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}).catch(error => {
aiTrialDataPromise = null;
throw error;
});
}
return aiTrialDataPromise;
}
function comparableTrip(value) {
return String(value || '').trim().replace(/^◆/, '');
}
let aiPerspectiveSerial = 0;
const AI_PERSPECTIVE_LABELS = [
['overall', '総合・勝利貢献'],
['influence', '場への影響'],
['reasoning', '推理・情報整理'],
['collaboration', '説得・協働'],
['decisions', '局面判断']
];
function aiPerspectiveTabs(perspectives, fallbacks = {}) {
const source = {...fallbacks, ...(perspectives || {})};
const group = `ai-perspective-${++aiPerspectiveSerial}`;
const buttons = AI_PERSPECTIVE_LABELS.map(([key, label], index) =>
``
).join('');
const panels = AI_PERSPECTIVE_LABELS.map(([key, label], index) =>
`${label}
${escapeHtml(source[key] || 'この視点の解析は次回更新時に追加されます。')}
`
).join('');
return `${panels}
`;
}
function aiTrialHistory(row) {
const history = Array.isArray(row.history) ? row.history.slice().reverse() : [];
if (!history.length) return '';
const items = history.map(old => {
const analyzed = old.analyzed_at ? new Date(old.analyzed_at).toLocaleString('ja-JP') : '初回解析';
const score = Number.isFinite(Number(old.influence_score)) ? `${Number(old.influence_score)} / 1000` : '採点なし';
const logs = (old.log_ids || []).map(id => `${Number(id)}`).join(' / ');
const phase = [
old.personality_analysis ? `人となり
${escapeHtml(old.personality_analysis)}
` : '',
old.postgame_analysis ? `感想戦
${escapeHtml(old.postgame_analysis)}
` : '',
old.rm_analysis ? `RM評価
${escapeHtml(old.rm_analysis)}
` : ''
].join('');
return `${escapeHtml(analyzed)}会話影響力 ${score}ゲーム中チャット
${escapeHtml(old.analysis || '')}
${old.influence_reason ? `
${escapeHtml(old.influence_reason)}
` : ''}${phase}
確認ログ:${logs || '記録なし'} `;
}).join('');
return `過去のAI解析(${history.length}件)
${items} `;
}
function aiPhaseAnalysis(row) {
if (!row.personality_analysis && !row.postgame_analysis) return '';
const games = Number(row.phase_games_sampled || 0);
const pregameLines = Number(row.phase_pregame_lines || 0);
const reviewLines = Number(row.phase_review_lines || 0);
const rmGames = Number(row.phase_rm_games || 0);
const rmLines = Number(row.phase_rm_lines || 0);
const rmEvaluation = row.rm_analysis ? `RM評価 村管理時の開始前チャット
${escapeHtml(row.rm_analysis)}
` : '';
return `
人となり ゲーム開始前のチャット
${escapeHtml(row.personality_analysis || '判断材料がありません。')}
感想戦 ゲーム終了後のチャット
${escapeHtml(row.postgame_analysis || '判断材料がありません。')}
${rmEvaluation}
異なるゲームID ${games}件を参照(開始前の本人発言 ${pregameLines}件・終了後 ${reviewLines}件${rmGames ? `・うちRM ${rmGames}件/RM時発言 ${rmLines}件` : ''})。待ち時間・立て直し・RMの管理発言を考慮した参考解析です。
`;
}
async function renderPersonalAiTrial(keyType, keyValue) {
const wrap = qs('personalAiEval');
const body = qs('personalAiEvalBody');
if (!wrap || !body) return;
wrap.style.display = 'none';
body.innerHTML = '';
if (keyType !== 'trip') return;
try {
const data = await fetchAiTrialData();
const target = comparableTrip(keyValue);
const row = (data.results || []).find(item => comparableTrip(item.trip) === target);
if (!row) return;
const raw = row.objective?.raw || {};
const rawLabels = [['発言数/戦',''],['盤面語率%','%'],['質問率%','%'],['被質問応答率%','%'],['他者名言及率%','%'],['根拠語率%','%']];
const metrics = rawLabels.map(([key, unit]) => `${escapeHtml(key)}${Number(raw[key] || 0)}${unit}
`).join('');
const logs = (row.log_ids || []).map(id => `${Number(id)}`).join(' / ');
aiTrialScoreStats = aiTrialScoreStats || calculateAiScoreStats(data.results || []);
const influenceValue = Number(row.influence_score);
const deviationValue = calculateAiDeviation(influenceValue, aiTrialScoreStats);
const influence = Number.isFinite(influenceValue) ? `${influenceValue}/ 1000 会話影響力偏差値 ${deviationValue.toFixed(1)}${escapeHtml(row.influence_reason || '')}
` : '';
body.innerHTML = `${influence}
${aiTrialRadar(row.objective?.radar || {})}
${metrics}
${aiPerspectiveTabs(row.perspectives, {
overall: row.analysis,
influence: row.influence_reason || row.analysis,
reasoning: row.analysis,
collaboration: row.personality_analysis || row.analysis,
decisions: row.postgame_analysis || row.analysis
})}
${aiPhaseAnalysis(row)}
ゲーム中チャットのみをQwen 3で参考解析。レーダーは機械集計値です。確認標本 ${Number(row.games_sampled || 0)}戦・本人発言${Number(row.target_lines || 0)}件。
${logs}
${aiTrialHistory(row)}
`;
wrap.style.display = 'block';
} catch (error) {
console.error('AI解析の読み込みに失敗しました。', error);
}
}
// --- 変名の系譜図(タイムラインSVG) ---
function renderChainFig(tc) {
const wrap = qs('personalChainFig');
const body = qs('personalChainFigBody');
if (!wrap || !body) return;
const namesAll = (tc && Array.isArray(tc.names)) ? tc.names.filter(n => n.first_ts && n.last_ts) : [];
if (namesAll.length === 0) { wrap.style.display = 'none'; body.innerHTML = ''; return; }
// 図が縦に伸びすぎないよう、戦数上位40名までに絞って表示する
const MAX_FIG_NAMES = 40;
const names = [...namesAll].sort((a, b) => b.games_total - a.games_total).slice(0, MAX_FIG_NAMES);
const omitted = namesAll.length - names.length;
const sorted = [...names].sort((a, b) => a.first_ts - b.first_ts);
const t0 = Math.min(...sorted.map(n => n.first_ts));
const t1 = Math.max(...sorted.map(n => n.last_ts));
const span = Math.max(t1 - t0, 86400);
const labelW = 150, chartW = 640, rowH = 22, padTop = 20, padRight = 60;
const W = labelW + chartW + padRight;
const H = padTop + sorted.length * rowH + 8;
const x = ts => labelW + (ts - t0) / span * chartW;
const escSvg = s => String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
let g = '';
// 年目盛り
const y0 = new Date(t0 * 1000).getFullYear(), y1 = new Date(t1 * 1000).getFullYear();
for (let y = y0; y <= y1 + 1; y++) {
const ts = Date.UTC(y, 0, 1) / 1000 - 9 * 3600;
if (ts < t0 || ts > t1) continue;
g += ``;
g += `${y}`;
}
sorted.forEach((n, i) => {
const y = padTop + i * rowH;
const bx = x(n.first_ts), bw = Math.max(x(n.last_ts) - bx, 3);
g += `${escSvg(n.name)}`;
g += `${escSvg(n.name)}: ${n.games_total}戦`;
g += `${n.games_total}戦`;
});
body.innerHTML = `` +
`同一トリップ系譜で使われた名前(${tc.name_count}種 / 計${tc.games_total}戦 / 期間 ${tc.span_days}日)。棒は各名前の初出〜最終使用。${omitted > 0 ? `戦数上位${names.length}名のみ表示(他${omitted}名省略)。` : ''}
`;
wrap.style.display = 'block';
}
// --- 変名図タブ(トリップ⇄名前の連鎖系譜) ---
let chainsOffset = 0;
let chainsLoadedOnce = false;
function _fmtDay(ts) {
if (!ts) return '-';
const d = new Date(ts * 1000);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
async function loadChainsList(opts = {}) {
const tbody = qs('chainsTable')?.querySelector('tbody');
if (!tbody) return;
if (!opts.append) { chainsOffset = 0; tbody.innerHTML = ''; }
const l = qs('chainsLoading');
if (l) l.style.display = 'block';
try {
const res = await fetch(`api.php?mode=trip_lineage_index&limit=50&offset=${chainsOffset}`, { credentials: 'include' });
const data = await res.json();
if (!data.ok) throw new Error(data.error || 'API error');
(data.rows || []).forEach((r, i) => {
const tr = ce('tr');
const rank = chainsOffset + i + 1;
const disp = '◆' + String(r.trip || '').replace(/^◆/, '');
tr.innerHTML = `
${rank} |
${escapeHtml(r.main_name || '')} |
|
${r.games} |
${_fmtDay(r.last_ts)} |
${_fmtDay(r.first_ts)} |
| `;
tr.querySelector('.lineage-btn').addEventListener('click', () => loadLineage(r.trip));
tbody.appendChild(tr);
});
const got = (data.rows || []).length;
const pager = qs('chainsPager');
if (pager) {
pager.innerHTML = `${chainsOffset + 1}〜${chainsOffset + got} / ${data.total}件` +
(chainsOffset + got < data.total ? ` ` : '');
qs('chainsMoreBtn')?.addEventListener('click', () => { chainsOffset += 50; loadChainsList({ append: true }); });
}
} catch (e) {
console.error(e);
const pager = qs('chainsPager');
if (pager) pager.textContent = '読み込みに失敗しました。';
} finally {
if (l) l.style.display = 'none';
}
}
async function searchChainsByName() {
const nm = (qs('chainsNameQuery')?.value || '').trim();
const box = qs('chainsNameResults');
if (!box) return;
if (!nm) { box.innerHTML = ''; return; }
box.innerHTML = '検索中…
';
try {
const res = await fetch(`api.php?mode=trip_lineage_search&name=${encodeURIComponent(nm)}&limit=50`, { credentials: 'include' });
const data = await res.json();
if (!data.ok) throw new Error(data.error || 'API error');
const rows = data.rows || [];
if (rows.length === 0) { box.innerHTML = `「${escapeHtml(nm)}」を名乗ったトリップは見つかりませんでした。
`; return; }
let html = `「${escapeHtml(nm)}」を名乗ったトリップ ${data.total}件(最新参戦順、最大50件表示)。系譜を選んでください。
`;
html += '| トリップ | 主な名前 | この名前での戦数 | 総戦数 | 最終参戦 | |
';
rows.forEach(r => {
const disp = '◆' + String(r.trip || '').replace(/^◆/, '');
html += `
|
${escapeHtml(r.main_name || '')} |
${r.name_games} |
${r.total_games || '-'} |
${_fmtDay(r.last_ts)} |
|
`;
});
html += '
';
box.innerHTML = html;
box.querySelectorAll('.lineage-btn').forEach(b => b.addEventListener('click', () => loadLineage(b.dataset.trip)));
} catch (e) {
console.error(e);
box.innerHTML = '検索に失敗しました。
';
}
}
const LINEAGE_DEPTH_COLORS = ['#e2537a', '#4f74d9', '#3aa675', '#c78f3d', '#8a63c9', '#5a9bbf', '#888888'];
// 個別系譜ビューと一覧ビューの切り替え(個別表示中は他の人のリストを出さない)
function showLineageView(on) {
const ids = ['chainsSearchCard', 'chainsListCard', 'chainsHelpCard'];
ids.forEach(id => { const el = qs(id); if (el) el.style.display = on ? 'none' : ''; });
const card = qs('lineageFigCard');
if (card) card.style.display = on ? 'block' : 'none';
}
async function loadLineage(trip) {
const card = qs('lineageFigCard');
const body = qs('lineageFigBody');
const title = qs('lineageFigTitle');
if (!card || !body) return;
showLineageView(true);
body.innerHTML = '系譜を辿っています…
';
if (title) title.textContent = `系譜図:◆${String(trip).replace(/^◆/, '')}`;
// 固定URL化+共有リンク提示
const url = new URL(window.location.href);
url.searchParams.set('tab', 'tab-chains');
url.searchParams.set('lineage', trip);
['key_type', 'q'].forEach(k => url.searchParams.delete(k));
history.replaceState(null, '', url);
const shareInput = qs('lineageShareUrl');
if (shareInput) shareInput.value = url.toString();
card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
try {
const res = await fetch(`api.php?mode=trip_lineage&trip=${encodeURIComponent(trip)}&max_nodes=80`, { credentials: 'include' });
const data = await res.json();
if (!data.ok) throw new Error(data.msg || data.error || 'API error');
renderLineageFig(data, body);
} catch (e) {
console.error(e);
body.innerHTML = `読み込みに失敗しました: ${escapeHtml(String(e.message || e))}
`;
}
}
function renderLineageFig(data, body) {
const nodes = [...(data.nodes || [])].sort((a, b) => (b.first_ts || 0) - (a.first_ts || 0));
if (nodes.length === 0) { body.innerHTML = '系譜データがありません。
'; return; }
const t0 = Math.min(...nodes.map(n => n.first_ts || Infinity));
const t1 = Math.max(...nodes.map(n => n.last_ts || 0));
const span = Math.max(t1 - t0, 86400);
const labelW = 230, chartW = 620, rowH = 21, padTop = 20, padRight = 56;
const W = labelW + chartW + padRight;
const H = padTop + nodes.length * rowH + 8;
const x = ts => labelW + (ts - t0) / span * chartW;
const escSvg = s => String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
let g = '';
const y0 = new Date(t0 * 1000).getFullYear(), y1 = new Date(t1 * 1000).getFullYear();
for (let y = y0; y <= y1 + 1; y++) {
const ts = Date.UTC(y, 0, 1) / 1000 - 9 * 3600;
if (ts < t0 || ts > t1) continue;
g += ``;
g += `${y}`;
}
nodes.forEach((n, i) => {
const y = padTop + i * rowH;
const bx = x(n.first_ts), bw = Math.max(x(n.last_ts) - bx, 3);
const col = LINEAGE_DEPTH_COLORS[Math.min(n.depth, LINEAGE_DEPTH_COLORS.length - 1)];
const disp = '◆' + String(n.trip).replace(/^◆/, '');
const label = `${n.main_name || ''} ${disp}`;
g += `${escSvg(label.length > 30 ? label.slice(0, 29) + '…' : label)}`;
g += `${escSvg(disp)}(主な名前: ${escSvg(n.main_name || '-')}) ${n.games}戦 / ${_fmtDay(n.first_ts)}〜${_fmtDay(n.last_ts)}${n.via_name ? ' / 「' + escSvg(n.via_name) + '」経由で関連' : ' / 起点'}`;
g += `${n.games}戦`;
});
const legend = LINEAGE_DEPTH_COLORS.slice(0, 4).map((c, i) =>
`${i === 0 ? '起点' : `関連${i}段目`}`).join('');
body.innerHTML = `` +
`${legend}
` +
`起点トリップと名前の共有で連鎖的に関連づいたトリップ ${data.count}件(棒=各トリップの初参戦〜最終参戦、上ほど新しく下ほど古い)。${data.truncated ? '規模が大きいため戦数上位の一部のみ表示しています。' : ''}バーにマウスを載せると経由した名前が見えます。
`;
}
// --- セレクトを全選択肢表示のピル選択に置き換える ---
const _pillRefreshers = [];
function setupPillGroup(selectId) {
const sel = qs(selectId);
if (!sel || sel.dataset.pillified) return;
sel.dataset.pillified = '1';
sel.style.display = 'none';
const prev = sel.previousElementSibling;
if (prev && prev.tagName === 'BR') prev.remove();
const group = ce('div');
group.className = 'pill-group';
const refresh = () => {
[...group.children].forEach(b => b.classList.toggle('active', b.dataset.value === sel.value));
};
[...sel.options].forEach(opt => {
const b = ce('button');
b.type = 'button';
b.className = 'pill';
b.dataset.value = opt.value;
b.textContent = opt.textContent;
b.addEventListener('click', () => {
if (sel.value !== opt.value) {
sel.value = opt.value;
sel.dispatchEvent(new Event('change'));
}
refresh();
});
group.appendChild(b);
});
sel.insertAdjacentElement('afterend', group);
sel.addEventListener('change', refresh);
refresh();
_pillRefreshers.push(refresh);
}
function refreshPillGroups() { _pillRefreshers.forEach(fn => fn()); }
// --- ランキング検索設定のCookie保存・復元(直前の検索条件をデフォルトにする) ---
const MONTHLY_PREF_COOKIE = 'zinro_rank_pref';
function saveMonthlyPrefs(p) {
try {
document.cookie = `${MONTHLY_PREF_COOKIE}=${encodeURIComponent(JSON.stringify(p))};path=/;max-age=31536000;SameSite=Lax`;
} catch (e) { /* Cookie不可環境では保存しない */ }
}
function loadMonthlyPrefs() {
try {
const c = document.cookie.split('; ').find(x => x.startsWith(MONTHLY_PREF_COOKIE + '='));
if (!c) return null;
return JSON.parse(decodeURIComponent(c.slice(MONTHLY_PREF_COOKIE.length + 1)));
} catch (e) { return null; }
}
function applyMonthlyPrefs() {
const p = loadMonthlyPrefs();
if (!p) return;
const setVal = (id, v) => {
const el = qs(id);
if (el && v != null && [...el.options].some(o => o.value === String(v))) el.value = String(v);
};
setVal('monthlyScope', p.scope);
setVal('monthlyKind', p.kind);
setVal('monthlyKeyType', p.keyType);
setVal('monthlyMinCount', p.minCount);
setVal('monthlyNoTrip', p.noTrip);
// 対象月/年・配役の選択肢は非同期で入るため、読込完了後にも適用できるよう控えておく
if (p.ym && qs('monthlyYm')) qs('monthlyYm').dataset.pref = p.ym;
if (p.year && qs('monthlyYear')) qs('monthlyYear').dataset.pref = p.year;
if (p.haiyaku && qs('monthlyHaiyaku')) qs('monthlyHaiyaku').dataset.pref = p.haiyaku;
updateMonthlyScopeUI();
updateMonthlyKeyTypeUI();
refreshPillGroups();
}
// --- RM一斉検索 ---
async function loadSharedTripsCount() {
const box = qs('sharedTripsCountBox');
if (!box) return;
try {
const data = await apiGet({ mode: 'shared_trips_count' });
box.textContent = `共有トリップ登録数: ${data.count ?? 0}件`;
} catch (e) {
console.error(e);
box.textContent = '';
}
}
async function fetchSharedTripSet(trips) {
if (!trips.length) return new Set();
try {
const p = new URLSearchParams({ mode: 'shared_trips', trips: trips.join('\n') });
const res = await fetch('api.php?' + p.toString(), { credentials: 'include' });
const data = await res.json();
if (!data.ok) return new Set();
return new Set((data.shared_trips || []).map(t => String(t || '').replace(/^◆/, '')));
} catch (e) {
console.error(e);
return new Set();
}
}
async function fetchMultiSummary(keyType, keys) {
if (!keys.length) return {};
try {
const p = new URLSearchParams({
mode: 'multi_summary',
key_type: keyType,
trips: keys.join('\n')
});
const res = await fetch('api.php?' + p.toString(), { credentials: 'include' });
const data = await res.json();
return data.ok ? (data.results || {}) : {};
} catch (e) {
console.error(e);
return {};
}
}
async function loadMultiTrip(opts = {}) {
const raw = qs('multiTripInput')?.value || '';
const container = qs('multiTripResult');
if (!raw) return;
container.innerHTML = '読み込み中…';
const trips = raw.split(/[\n◆]+/).map(s => s.trim()).filter(s => s).map(s => s.replace(/^◆/, ''));
lastRMState = { fromRM: true, scrollY: window.scrollY };
try {
const [sharedSet, summaryMap] = await Promise.all([
fetchSharedTripSet(trips),
fetchMultiSummary('trip', trips)
]);
const res = await fetch('api.php?mode=multi_trip', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ trips: trips.join('\n') }),
credentials: 'include'
});
const data = await res.json();
if (data.ok) {
let html = '| 名前 | トリップ | 共有 | 通常 | カオス | 初登場 | 戦数 | 同トリ数 | ログ |
';
data.results.forEach(r => {
const plainTrip = String(r.trip || '').replace(/^◆/, '');
const tDisp = '◆' + plainTrip;
const shared = sharedSet.has(plainTrip);
const summary = summaryMap[plainTrip] || {};
const normal = summary.normal || {};
const chaos = summary.chaos || {};
const normalText = `${normal.games ?? 0}戦 ${normal.win_rate ?? 0}%`;
const chaosText = `${chaos.games ?? 0}戦 ${chaos.win_rate ?? 0}%`;
html += `
| ${escapeHtml(r.main_name || '')} |
|
${shared ? '登録あり' : ''} |
${escapeHtml(normalText)} |
${escapeHtml(chaosText)} |
${escapeHtml(summary.first_ym || '')} |
${r.total_games} |
${r.unique_names} |
${(r.logs || []).map(id => `${id}`).join('/')} |
`;
});
html += '
';
container.innerHTML = html;
if (!opts.skipHistory) {
lastSearchState = { tab: 'tab-rm', type: 'rm', params: { raw }, scrollY: window.scrollY };
}
} else { container.textContent = data.error; }
} catch (e) { container.textContent = '通信エラー'; }
}
// --- 名前チェイン ---
function initNameChainTab() {
// 必要ならイベント設定
}
async function loadNameChains(page) {
if (nameChainIsLoading) return;
nameChainIsLoading = true;
nameChainCurrentPage = page;
const order = qs('name-chain-order')?.value || 'last_ts_desc';
const box = qs('name-chain-box');
const pager = qs('name-chain-pager');
const busy = qs('name-chain-busy');
if (busy) busy.style.display = 'block';
if (box) box.innerHTML = '';
try {
const res = await fetch(`api.php?mode=name_chain_list&page=${page}&limit=50&order=${order}`, { credentials: 'include' });
const data = await res.json();
if (data.ok) {
const tbl = ce('table'); tbl.className = 'name-chain-table';
tbl.innerHTML = `| 最新名 | 最新トリップ | 代表トリップ | 初登場 | 最終 | 期間 | 戦数 | トリ数 |
`;
const tb = tbl.querySelector('tbody');
(data.rows || []).forEach(r => {
const tr = ce('tr');
tr.innerHTML = `
${escapeHtml(r.latest_name)} |
${escapeHtml(r.latest_trip)} |
${escapeHtml(r.representative_trip)} |
${nh_formatDate(r.first_ts)} |
${nh_formatDate(r.last_ts)} |
${r.span_days} | ${r.games_total} | ${r.trip_count} | `;
tb.appendChild(tr);
});
box.appendChild(tbl);
// ページャー実装(簡易)
if (pager) {
const maxP = Math.ceil(data.total / 50);
pager.innerHTML = `Page ${page} / ${maxP}
${page > 1 ? `` : ''}
${page < maxP ? `` : ''}`;
}
}
} catch (e) { if(box) box.textContent = 'Error'; }
finally { nameChainIsLoading = false; if(busy) busy.style.display = 'none'; }
}
async function loadTripChainRank(page) {
// 名前変遷の実装(必要に応じて追加)
const tbody = qs('trip-chain-tbody');
if(!tbody) return;
const minGames = qs('chain-min-games')?.value || 10;
const minSpan = qs('chain-min-span')?.value || 30;
const both = qs('chain-both-modes')?.checked ? 1 : 0;
qs('namehistoryLoading').style.display = 'block';
tbody.innerHTML = '';
try {
const res = await fetch(`api.php?mode=trip_chain_rank&limit=50&offset=${page*50}&min_games=${minGames}&min_span_days=${minSpan}&both_modes=${both}`, { credentials: 'include' });
const data = await res.json();
if(data.ok) {
data.rows.forEach((r, i) => {
const tr = ce('tr');
tr.innerHTML = `${i+1} | ${escapeHtml(r.main_name)} | ◆${escapeHtml(r.plain_trip)} |
${r.span_days} | ${r.games_total} | ${r.name_count} |
| `;
tbody.appendChild(tr);
});
}
} catch(e){}
finally { qs('namehistoryLoading').style.display = 'none'; }
}
async function showTripChainDetail(plain) {
const box = qs('trip-chain-detail');
box.textContent = '読み込み中...';
try {
const res = await fetch(`api.php?mode=trip_chain_detail&plain_trip=${encodeURIComponent(plain)}`, { credentials: 'include' });
const data = await res.json();
if(data.ok) {
box.innerHTML = `◆${escapeHtml(plain)} の変遷
期間: ${nh_formatDate(data.summary.first_ts)} 〜 ${nh_formatDate(data.summary.last_ts)} (${data.summary.span_days}日)
`;
const tbl = ce('table'); tbl.className = 'sub-table';
tbl.innerHTML = `| 名前 | 初 | 終 | 戦数 |
`;
const tb = tbl.querySelector('tbody');
data.names.forEach(n => {
tb.innerHTML += `| ${escapeHtml(n.name)} | ${nh_formatDate(n.first_ts)} | ${nh_formatDate(n.last_ts)} | ${n.games_total} |
`;
});
box.appendChild(tbl);
}
} catch(e) { box.textContent = 'エラー'; }
}
// --- 村タブ ---
function initVillageTab(parent) {
const wrapper = ce('div'); wrapper.className = 'village-tab-wrapper';
wrapper.innerHTML = `最近の村
`;
parent.appendChild(wrapper);
loadVillageList(wrapper.querySelector('.village-list-box'), wrapper.querySelector('.village-detail-box'));
}
async function loadVillageList(listBox, detailBox) {
listBox.textContent = '読み込み中…';
try {
const res = await fetch('api.php?mode=village_recent&limit=10', { credentials: 'include' });
const data = await res.json();
if(data.ok) {
listBox.innerHTML = '';
const tbl = ce('table'); tbl.className = 'village-list-table';
tbl.innerHTML = `| 村名 | 日時 | 配役 |
`;
data.rows.forEach(r => {
const tr = ce('tr');
const dt = new Date(r.started_at*1000).toLocaleString('ja-JP');
tr.innerHTML = ` | ${dt} | ${escapeHtml(r.haiyaku)} | `;
tr.querySelector('button').addEventListener('click', () => loadVillageDetailInto(r.id, detailBox));
tbl.querySelector('tbody').appendChild(tr);
});
listBox.appendChild(tbl);
if(data.rows.length) loadVillageDetailInto(data.rows[0].id, detailBox);
}
} catch(e) { listBox.textContent = 'エラー'; }
}
async function loadVillageDetailInto(id, detailBox) {
detailBox.textContent = '読み込み中...';
try {
const res = await fetch(`api.php?mode=village_detail&id=${id}`, { credentials: 'include' });
const data = await res.json();
if(data.ok) renderVillageDetail(data, detailBox);
} catch(e) { detailBox.textContent = 'エラー'; }
}
function renderVillageDetail(data, box) {
const v = data.village;
const dt = new Date(v.started_at*1000).toLocaleString('ja-JP');
box.innerHTML = `${escapeHtml(v.village_name)}
`;
const tbl = ce('table'); tbl.className = 'village-player-table';
tbl.innerHTML = `| 名前 | 役職 | 勝敗 |
`;
data.players.forEach(p => {
const tr = ce('tr');
const label = p.name + (p.trip ? ' ◆'+p.trip : '');
tr.innerHTML = ` |
${p.role} | ${p.win ? '勝' : '負'} | `;
tbl.querySelector('tbody').appendChild(tr);
});
box.appendChild(tbl);
}
function buildPersonalShareUrl(keyType, q) {
const base = location.origin + location.pathname; // /zinro/
const u = new URL(base);
u.searchParams.set('tab', 'tab-personal');
u.searchParams.set('key_type', keyType);
u.searchParams.set('q', q);
return u.toString();
}
async function copyToClipboard(text) {
// https環境なら基本これでOK
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return;
}
// フォールバック
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.focus();
ta.select();
document.execCommand('copy');
ta.remove();
}
function toastMini(msg) {
const t = document.createElement('div');
t.textContent = msg;
Object.assign(t.style, {
position: 'fixed',
left: '50%',
bottom: '18px',
transform: 'translateX(-50%)',
padding: '8px 12px',
borderRadius: '999px',
background: 'rgba(0,0,0,0.75)',
color: '#fff',
fontSize: '12px',
zIndex: 999999,
});
document.body.appendChild(t);
setTimeout(() => t.remove(), 1200);
}
// =========================================================
// 6. DOMContentLoaded (初期化エントリーポイント)
// =========================================================
document.addEventListener('DOMContentLoaded', async () => {
loadStatsUpdatedAt();
// イベントデリゲーション
document.addEventListener('click', (e) => {
const btn = e.target.closest('.name-trip-link-button');
if (btn) {
jumpToPersonalByLabel(btn.dataset.label ?? btn.textContent);
return;
}
const hbtn = e.target.closest('.hist-mini');
if (hbtn) {
const type = hbtn.dataset.histType === 'trip' ? 'trip' : 'name';
const q = hbtn.dataset.q ?? hbtn.textContent;
runPersonalSearch(type, q);
return;
}
const tbtn = e.target.closest('.trip-link-button');
if (tbtn) {
jumpToPersonalByTrip(tbtn.dataset.trip ?? tbtn.textContent);
return;
}
});
// タブボタン
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
const tabId = btn.dataset.tab;
if (!isProgrammaticTabChange) { lastSearchState = null; showBackGeneral(); }
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.style.display = 'none');
btn.classList.add('active');
const panel = qs(tabId);
if (panel) panel.style.display = 'block';
// タブごとの初期化
if (tabId === 'tab-village' && !village_loadedOnce) {
village_loadedOnce = true;
initVillageTab(qs('tab-village'));
}
if (tabId === 'tab-monthly') {
if (!monthlyOptionsLoaded) { monthlyOptionsLoaded = true; loadMonths(); loadHaiyakuOptions(); }
resetMonthlyControls();
}
if (tabId === 'tab-chains' && !chainsLoadedOnce) { chainsLoadedOnce = true; loadChainsList(); }
if (tabId === 'tab-chains' && !isProgrammaticTabChange) showLineageView(false);
if (tabId === 'tab-personal') renderSearchHistoryButtons();
if (tabId === 'tab-login') loadProfileForLoginTab();
if (tabId === 'tab-rm-stats' && !window._rmStatsLoaded) { window._rmStatsLoaded = true; loadRmStatsList(); }
if (tabId === 'tab-ai-trial') loadAiTrial();
if (tabId === 'tab-rm') loadSharedTripsCount();
if (tabId === 'tab-bbs') loadBbs(0);
const url = new URL(window.location.href);
url.searchParams.set('tab', tabId);
history.replaceState(null, '', url);
});
});
// UIイベントバインド
if (qs('personalSearchBtn')) {
qs('personalSearchBtn').addEventListener('click', () => {
const type = (qs('key_type')?.value === 'trip') ? 'trip' : 'name';
runPersonalSearch(type);
});
}
if (qs('personalNameSearchBtn')) qs('personalNameSearchBtn').addEventListener('click', () => runPersonalSearch('name'));
if (qs('personalTripSearchBtn')) qs('personalTripSearchBtn').addEventListener('click', () => runPersonalSearch('trip'));
if (qs('monthlyLoadBtn')) qs('monthlyLoadBtn').addEventListener('click', () => { monthlyOffset = 0; loadMonthly(); });
if (qs('classStatsLoadBtn')) qs('classStatsLoadBtn').addEventListener('click', loadClassStats);
const ymSel = qs('monthlyYm');
if (ymSel) ymSel.addEventListener('change', () => { monthlyOffset = 0; });
const kindSel = qs('monthlyKind');
if (kindSel) {
kindSel.addEventListener('change', () => {
monthlyOffset = 0;
const tbody = qs('monthlyTable')?.querySelector('tbody');
if (tbody) tbody.innerHTML = ''; // 明示的に空にする
const pager = qs('monthlyPager');
if (pager) pager.textContent = '';
const wrap = qs('monthlyTableWrap');
if (wrap) wrap.style.display = 'none';
updateMonthlyKeyTypeUI();
});
}
const scopeSel = qs('monthlyScope');
if (scopeSel) {
scopeSel.value = 'all';
scopeSel.addEventListener('change', () => { monthlyOffset = 0; updateMonthlyScopeUI(); });
updateMonthlyScopeUI();
}
const classStatsScopeSel = qs('classStatsScope');
if (classStatsScopeSel) {
classStatsScopeSel.value = 'all';
classStatsScopeSel.addEventListener('change', updateClassStatsScopeUI);
updateClassStatsScopeUI();
}
if(qs('monthlyKeyType')) {
qs('monthlyKeyType').value = 'trip';
updateMonthlyKeyTypeUI();
}
// ランキングタブの選択肢はドロップダウンでなく全表示ピルで選ぶ
setupPillGroup('monthlyScope');
setupPillGroup('monthlyKind');
setupPillGroup('monthlyKeyType');
setupPillGroup('monthlyNoTrip');
setupPillGroup('monthlyMinCount');
// 前回の検索条件(Cookie)をデフォルトとして反映
applyMonthlyPrefs();
if (qs('multiTripBtn')) qs('multiTripBtn').addEventListener('click', loadMultiTrip);
restoreLoginIdentity();
if (qs('loginBtn')) qs('loginBtn').addEventListener('click', doLogin);
if (qs('loginRememberInput')) qs('loginRememberInput').addEventListener('change', (e) => {
if (!e.target.checked) localStorage.removeItem(LOGIN_IDENTITY_STORAGE_KEY);
});
if (qs('logoutBtn')) qs('logoutBtn').addEventListener('click', doLogout);
if (qs('profileSaveBtn')) qs('profileSaveBtn').addEventListener('click', saveProfile);
if (qs('wikiSaveBtn')) qs('wikiSaveBtn').addEventListener('click', saveWiki);
restoreBbsIdentity();
if (qs('bbsNameInput')) qs('bbsNameInput').addEventListener('input', saveBbsIdentity);
if (qs('bbsTripKeyInput')) qs('bbsTripKeyInput').addEventListener('input', saveBbsIdentity);
if (qs('bbsPostBtn')) qs('bbsPostBtn').addEventListener('click', postBbs);
//if (qs('commentSendBtn')) qs('commentSendBtn').addEventListener('click', sendComment);
const cbtn = qsAny(['commentSendBtn', 'comment-send']);
if (cbtn) cbtn.addEventListener('click', sendComment);
if (qs('chainsTripSearchBtn')) qs('chainsTripSearchBtn').addEventListener('click', () => {
const t = (qs('chainsTripQuery')?.value || '').trim();
if (t) loadLineage(t);
});
if (qs('chainsNameSearchBtn')) qs('chainsNameSearchBtn').addEventListener('click', searchChainsByName);
if (qs('chainsNameQuery')) qs('chainsNameQuery').addEventListener('keydown', e => { if (e.key === 'Enter') searchChainsByName(); });
if (qs('chainsTripQuery')) qs('chainsTripQuery').addEventListener('keydown', e => { const t = (e.target.value || '').trim(); if (e.key === 'Enter' && t) loadLineage(t); });
if (qs('lineageBackBtn')) qs('lineageBackBtn').addEventListener('click', () => {
showLineageView(false);
const url = new URL(window.location.href);
url.searchParams.delete('lineage');
history.replaceState(null, '', url);
if (!chainsLoadedOnce) { chainsLoadedOnce = true; loadChainsList(); }
});
if (qs('lineageShareCopyBtn')) qs('lineageShareCopyBtn').addEventListener('click', async () => {
const v = qs('lineageShareUrl')?.value || '';
try { await navigator.clipboard.writeText(v); qs('lineageShareCopyBtn').textContent = 'コピー済み'; setTimeout(() => { qs('lineageShareCopyBtn').textContent = 'コピー'; }, 1500); }
catch (e) { qs('lineageShareUrl')?.select(); }
});
if (qs('chain-reload-btn')) qs('chain-reload-btn').addEventListener('click', () => loadTripChainRank(0));
if (qs('name-chain-run')) qs('name-chain-run').addEventListener('click', () => loadNameChains(1));
initNameChainTab();
initWikiEditUI(); // ここで呼び出し(関数定義より後なので安全)
renderSearchHistoryButtons();
await fetchMe();
refreshLoginUI();
// URL初期タブ
const url = new URL(window.location.href);
const initialTabId = url.searchParams.get('tab') || 'tab-about';
switchTab(initialTabId);
// ★ 共有URLから個別系譜を直接表示
const lineageParam = (url.searchParams.get('lineage') || '').trim();
if (lineageParam && initialTabId === 'tab-chains') {
loadLineage(lineageParam);
}
// ★ 外部URLから個人戦績を直接表示(自動検索)
const kt = (url.searchParams.get('key_type') || '').trim(); // 'name' or 'trip'
const q = (url.searchParams.get('q') || '').trim();
if (q && (kt === 'name' || kt === 'trip')) {
// 個人タブを開く(tab指定がなくてもここで個人へ誘導)
switchTab('tab-personal');
// 入力欄に反映
if (kt === 'trip') {
if (qs('personalTripQuery')) qs('personalTripQuery').value = q;
if (qs('personalNameQuery')) qs('personalNameQuery').value = '';
} else {
if (qs('personalNameQuery')) qs('personalNameQuery').value = q;
if (qs('personalTripQuery')) qs('personalTripQuery').value = '';
}
// hiddenにも反映してから実行
if (qs('key_type')) qs('key_type').value = kt;
if (qs('key_value')) qs('key_value').value = q;
// 検索実行(あなたの既存ルートに乗せる)
runPersonalSearch(kt, q);
}
});
// ─── RM集計タブ ───
let _rmStatsOffset = 0;
let _rmStatsQuery = "";
function _rmYmd(ts) {
if (!ts) return "";
const d = new Date((ts + 9*3600) * 1000);
return d.getUTCFullYear() + "/" + String(d.getUTCMonth()+1).padStart(2,"0") + "/" + String(d.getUTCDate()).padStart(2,"0");
}
async function loadRmStatsList(query, offset) {
_rmStatsQuery = (query ?? _rmStatsQuery) || "";
_rmStatsOffset = offset ?? 0;
const el = document.getElementById("rm-stats-content");
if (!el) return;
el.innerHTML = '読み込み中...
';
try {
const params = new URLSearchParams({mode:"rm_list",limit:"50",offset:String(_rmStatsOffset)});
if (_rmStatsQuery) params.set("q", _rmStatsQuery);
const r = await fetch("mtdk_api.php?" + params.toString(), { credentials: "include" });
const j = await r.json();
if (!j.ok) { el.innerHTML = '取得失敗
'; return; }
const rows = j.rows || [];
if (!rows.length) { el.innerHTML = '該当するRMがありません
'; return; }
const total = j.total || 0;
let html = '';
html += '| # | RM名 | ゲーム数 | 初回 | 最終 |
';
rows.forEach((r, i) => {
const n = _rmStatsOffset + i + 1;
html += '';
html += '| ' + n + ' | ';
html += '' + _escH(r.rm_name) + ' | ';
html += '' + r.games + ' | ';
html += '' + _rmYmd(r.first_ts) + ' | ';
html += '' + _rmYmd(r.last_ts) + ' | ';
html += '
';
});
html += '
';
// ページネーション
const canPrev = _rmStatsOffset > 0;
const canNext = _rmStatsOffset + 50 < total;
html += '';
html += '';
html += '' + (_rmStatsOffset+1) + '〜' + Math.min(_rmStatsOffset+50, total) + ' / ' + total + '件';
html += '';
html += '
';
el.innerHTML = html;
// イベント
el.querySelectorAll(".rm-stats-row").forEach(tr => {
tr.addEventListener("click", () => loadRmStatsDetail(tr.dataset.rm));
});
el.querySelector("#rm-stats-prev")?.addEventListener("click", () => { if(canPrev) loadRmStatsList(null, _rmStatsOffset - 50); });
el.querySelector("#rm-stats-next")?.addEventListener("click", () => { if(canNext) loadRmStatsList(null, _rmStatsOffset + 50); });
} catch(e) { el.innerHTML = 'エラー: ' + e.message + '
'; }
}
async function loadRmStatsDetail(rmName) {
const el = document.getElementById("rm-stats-content");
if (!el) return;
el.innerHTML = '読み込み中...
';
let detailOffset = 0;
async function render(offset) {
detailOffset = offset;
try {
const params = new URLSearchParams({mode:"rm_detail",rm_name:rmName,limit:"20",offset:String(offset)});
const r = await fetch("mtdk_api.php?" + params.toString(), { credentials: "include" });
const j = await r.json();
if (!j.ok || !j.found) { el.innerHTML = 'データがありません
'; return; }
let participants = null;
try {
const pp = new URLSearchParams({mode:"rm_participants",rm_name:rmName,limit:"30"});
const pr = await fetch("mtdk_api.php?" + pp.toString(), { credentials: "include" });
const pj = await pr.json();
if (pj.ok) participants = pj.players || [];
} catch (e) {
console.error(e);
}
let html = ' ' + _escH(j.rm_name) + '
';
// サマリー
html += '';
html += '
' + j.total_games + '
総ゲーム数
';
html += '
' + _rmYmd(j.first_ts) + '
初回
';
html += '
' + _rmYmd(j.last_ts) + '
最終
';
html += '
';
// 村名Top
if (j.village_top?.length) {
html += '🏘 よく建てる村名
';
html += '';
j.village_top.forEach(v => {
html += '' + _escH(v.village_name) + ' ' + v.games + '';
});
html += '
';
}
// 配役Top
if (j.haiyaku_top?.length) {
html += '🎭 よく使う配役
';
html += '';
j.haiyaku_top.forEach(h => {
html += '' + _escH(h.haiyaku || "不明") + ' ' + h.games + '';
});
html += '
';
}
if (participants?.length) {
html += '参加者ランキング
';
html += '';
participants.slice(0, 30).forEach((p, i) => {
const trip = p.trip ? '◆' + String(p.trip).replace(/^◆/, '') : '';
html += '';
html += '| ' + (i + 1) + ' | ';
html += ' | ';
html += '' + (trip ? '' : '') + ' | ';
html += '' + (p.count || 0) + ' | ';
html += '' + (p.log_ids || []).map(id => '' + id + '').join('/') + ' | ';
html += '
';
});
html += '
';
}
// ゲーム一覧
html += 'ゲーム一覧
';
html += '';
j.games.forEach((g,i) => {
const n = offset + i + 1;
const href = "https://zinro.net/m/log.php?id=" + g.log_id;
html += '';
html += '| ' + n + ' | ';
html += '' + _escH(g.village_name) + ' | ';
html += '' + _escH(g.haiyaku || "") + ' | ';
html += '' + _rmYmd(g.started_at) + ' | ';
html += '
';
});
html += '
';
// ページネーション
const canPrev = offset > 0;
const canNext = offset + 20 < j.total_games;
html += '';
html += '';
html += '' + (offset+1) + '〜' + Math.min(offset+20,j.total_games) + ' / ' + j.total_games + '';
html += '';
html += '
';
el.innerHTML = html;
el.querySelector("#rm-detail-back")?.addEventListener("click", () => loadRmStatsList());
el.querySelector("#rm-detail-prev")?.addEventListener("click", () => { if(canPrev) render(offset-20); });
el.querySelector("#rm-detail-next")?.addEventListener("click", () => { if(canNext) render(offset+20); });
// URL更新
const url = new URL(window.location.href);
url.searchParams.set("tab", "tab-rm-stats");
url.searchParams.set("rm", rmName);
history.replaceState(null, "", url);
} catch(e) { el.innerHTML = 'エラー: ' + e.message + '
'; }
}
render(0);
}
function _escH(s) { return String(s??"").replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); }
function formatBbsDate(ts) {
if (!ts) return '';
return new Date(ts * 1000).toLocaleString('ja-JP');
}
async function loadStatsUpdatedAt() {
const personalEl = qs('personalStatsUpdatedAt');
const rankingEl = qs('rankingStatsUpdatedAt');
if (!personalEl && !rankingEl) return;
try {
const data = await apiGet({ mode: 'stats_updated_at' });
const statsTimestamp = Number(data.stats_updated_at || 0);
const checkedTimestamp = Number(data.stats_checked_at || 0);
const rankingTimestamp = Number(data.ranking_built_at || 0);
const checkedText = checkedTimestamp > 0
? new Date(checkedTimestamp * 1000).toLocaleString('ja-JP')
: '取得できません';
if (personalEl) {
const updatedText = statsTimestamp > 0
? new Date(statsTimestamp * 1000).toLocaleString('ja-JP')
: '取得できません';
personalEl.textContent = `戦績データ更新:${updatedText} / 確認:${checkedText}`;
}
if (rankingEl) {
const rankingText = rankingTimestamp > 0
? new Date(rankingTimestamp * 1000).toLocaleString('ja-JP')
: '取得できません';
rankingEl.textContent = `ランキング集計:${rankingText} / 戦績確認:${checkedText}`;
}
} catch (error) {
console.error(error);
if (personalEl) personalEl.textContent = '戦績データ更新:取得できません / 確認:取得できません';
if (rankingEl) rankingEl.textContent = 'ランキング集計:取得できません / 戦績確認:取得できません';
}
}
const BBS_IDENTITY_STORAGE_KEYS = {
name: 'mtdk_bbs_name',
tripKey: 'mtdk_bbs_trip_key'
};
function restoreBbsIdentity() {
try {
const nameEl = qs('bbsNameInput');
const tripEl = qs('bbsTripKeyInput');
if (nameEl) nameEl.value = localStorage.getItem(BBS_IDENTITY_STORAGE_KEYS.name) || '';
if (tripEl) tripEl.value = localStorage.getItem(BBS_IDENTITY_STORAGE_KEYS.tripKey) || '';
} catch (e) {
console.warn('掲示板の入力情報を復元できませんでした。', e);
}
}
function saveBbsIdentity() {
try {
localStorage.setItem(BBS_IDENTITY_STORAGE_KEYS.name, qs('bbsNameInput')?.value || '');
localStorage.setItem(BBS_IDENTITY_STORAGE_KEYS.tripKey, qs('bbsTripKeyInput')?.value || '');
} catch (e) {
console.warn('掲示板の入力情報を保存できませんでした。', e);
}
}
async function loadBbs(offset = 0) {
bbsOffset = Math.max(0, offset);
const list = qs('bbsList');
const pager = qs('bbsPager');
if (!list) return;
list.textContent = '読み込み中…';
if (pager) pager.textContent = '';
try {
const data = await apiGet({ mode: 'bbs', limit: 20, offset: bbsOffset });
const posts = data.posts || [];
if (!posts.length) {
list.textContent = '投稿はまだありません。';
} else {
list.innerHTML = posts.map(p => {
const trip = p.trip ? ' ' + p.trip : '';
const name = p.name || '名無しさん';
const nameUrl = buildPersonalShareUrl('name', name);
const tripUrl = p.trip ? buildPersonalShareUrl('trip', p.trip) : '';
return `
${escapeHtml(p.body || '')}
`;
}).join('');
}
if (pager) {
const total = data.total || 0;
const canPrev = bbsOffset > 0;
const canNext = !!data.has_more;
pager.innerHTML = total
? `${bbsOffset + 1}〜${Math.min(bbsOffset + 20, total)} / ${total}件 `
+ ` `
+ ``
: '';
qs('bbsPrevBtn')?.addEventListener('click', () => { if (canPrev) loadBbs(bbsOffset - 20); });
qs('bbsNextBtn')?.addEventListener('click', () => { if (canNext) loadBbs(bbsOffset + 20); });
}
} catch (e) {
console.error(e);
list.textContent = '掲示板の読み込みに失敗しました。';
}
}
async function postBbs() {
const nameEl = qs('bbsNameInput');
const tripEl = qs('bbsTripKeyInput');
const bodyEl = qs('bbsBodyInput');
const msg = qs('bbsPostMsg');
const btn = qs('bbsPostBtn');
const name = (nameEl?.value || '').trim();
const trip_key = (tripEl?.value || '').trim();
const body = (bodyEl?.value || '').trim();
saveBbsIdentity();
if (!name || !body) {
if (msg) msg.textContent = '名前と本文を入力してください。';
return;
}
if (btn) btn.disabled = true;
if (msg) msg.textContent = '投稿中…';
try {
const res = await fetch('api.php?mode=bbs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ name, trip_key, body })
});
const data = await res.json();
if (!data.ok) {
if (msg) msg.textContent = data.error || '投稿に失敗しました。';
return;
}
if (bodyEl) bodyEl.value = '';
if (msg) msg.textContent = data.trip ? `投稿しました ${data.trip}` : '投稿しました。';
loadBbs(0);
} catch (e) {
console.error(e);
if (msg) msg.textContent = '通信エラー';
} finally {
if (btn) btn.disabled = false;
}
}
let aiTrialLoaded = false;
let aiTrialData = null;
let aiTrialPage = 0;
const AI_TRIAL_PAGE_SIZE = 30;
let aiGameLoaded = false;
let aiGameData = null;
let aiGameSelectedKey = '';
function selectAiSubtab(name) {
const game = name === 'game';
const playerPanel = qs('aiSubPlayer');
const gamePanel = qs('aiSubGame');
if (playerPanel) playerPanel.style.display = game ? 'none' : '';
if (gamePanel) gamePanel.style.display = game ? '' : 'none';
document.querySelectorAll('[data-ai-subtab]').forEach(button => {
const active = button.dataset.aiSubtab === name;
button.classList.toggle('is-active', active);
button.setAttribute('aria-selected', active ? 'true' : 'false');
});
if (game) loadAiGameAnalysis();
}
async function loadAiGameAnalysis() {
const list = qs('aiGameList');
const meta = qs('aiGameMeta');
if (!list) return;
try {
if (meta) meta.textContent = '最新の解析結果を確認中…';
const response = await fetch(`ai-game-analysis.json?t=${Date.now()}`, { cache: 'no-store' });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
aiGameLoaded = true;
aiGameData = data;
const created = data.created_at ? new Date(data.created_at).toLocaleString('ja-JP') : '不明';
if (meta) meta.textContent = `最終反映: ${created} / モデル: ${data.model || 'Qwen'} / ${data.results?.length || 0}試合`;
const search = qs('aiGameSearch');
if (search) search.oninput = renderAiGameAnalysis;
list.onclick = event => {
const button = event.target.closest('[data-ai-game-key]');
if (!button) return;
aiGameSelectedKey = button.dataset.aiGameKey || '';
renderAiGameAnalysis();
};
renderAiGameAnalysis();
} catch (error) {
console.error(error);
list.innerHTML = 'ゲーム解析を読み込めませんでした。
';
}
}
function aiGamePlayerLink(participant, fallbackName = '') {
const name = participant?.name || fallbackName;
const trip = String(participant?.trip || '').trim();
const nameLink = name ? `${escapeHtml(name)}` : '名前不明';
const tripLink = trip && trip !== 'なし' ? ` ${escapeHtml(trip)}` : '';
return `${nameLink}${tripLink}`;
}
function renderAiGameAnalysis() {
const list = qs('aiGameList');
if (!list || !aiGameData) return;
const query = (qs('aiGameSearch')?.value || '').trim().toLowerCase();
const rows = (aiGameData.results || []).filter(game => {
const players = (game.participants || []).map(player => `${player.name || ''} ${player.trip || ''}`).join(' ');
const notable = (game.notable_players || []).map(player => `${player.name || ''} ${player.comment || ''}`).join(' ');
const perspectiveText = Object.values(game.perspectives || {}).join(' ');
return !query || `${game.log_id} ${game.village_name || ''} ${game.haiyaku || ''} ${game.title || ''} ${game.mvp_name || ''} ${game.mvp_reason || ''} ${game.analysis || ''} ${perspectiveText} ${players} ${notable}`.toLowerCase().includes(query);
});
const count = qs('aiGameFilteredCount');
if (count) count.textContent = `${rows.length}試合`;
if (!rows.length) {
aiGameSelectedKey = '';
list.innerHTML = '検索条件に一致するゲーム解析はありません。
';
return;
}
const gameKey = game => `${Number(game.log_id)}:${Number(game.game_index || 0)}`;
if (!rows.some(game => gameKey(game) === aiGameSelectedKey)) aiGameSelectedKey = gameKey(rows[0]);
const selected = rows.find(game => gameKey(game) === aiGameSelectedKey) || rows[0];
const indexHtml = rows.map(game => {
const played = game.started_at ? new Date(Number(game.started_at) * 1000).toLocaleString('ja-JP') : '日時不明';
const key = gameKey(game);
return ``;
}).join('');
const detailHtml = [selected].map(game => {
const played = game.started_at ? new Date(Number(game.started_at) * 1000).toLocaleString('ja-JP') : '日時不明';
const participants = Array.isArray(game.participants) ? game.participants : [];
const participantByName = new Map(participants.map(player => [player.name, player]));
const evaluationByName = new Map((game.player_evaluations || []).map(item => [item.name, item]));
const notable = (game.notable_players || []).map(player => `${aiGamePlayerLink(participantByName.get(player.name), player.name)}${escapeHtml(player.comment || '')}`).join('');
const participantList = participants.map(player => {
const evaluation = evaluationByName.get(player.name) || {};
const score = Math.max(0, Math.min(100, Number(evaluation.score ?? 50)));
const mvpBadge = player.name === game.mvp_name ? 'MVP' : '';
const role = player.role ? `${escapeHtml(player.role)}` : '役職不明';
return `${aiGamePlayerLink(player)}${role}${mvpBadge}${score}点
${escapeHtml(evaluation.comment || 'この試合での評価材料が限られています。')}
`;
}).join('');
const mvp = participantByName.get(game.mvp_name);
return `
${played}${escapeHtml(game.village_name || '村名不明')}RM ${escapeHtml(game.rm_name || '不明')}${escapeHtml(game.haiyaku || '配役不明')}ゲームID ${Number(game.log_id)}
${participantList ? `
` : ''}
${escapeHtml(game.title || game.village_name || 'ゲーム解析')}
ゲームログを見る
MVP${aiGamePlayerLink(mvp, game.mvp_name || '選出なし')}
${escapeHtml(game.mvp_reason || '')}
${aiPerspectiveTabs(game.perspectives, {
overall: game.analysis,
influence: game.interaction_analysis || game.analysis,
reasoning: game.strategic_structure || game.analysis,
collaboration: game.interaction_analysis || game.analysis,
decisions: [game.turning_point_analysis, game.counterfactual].filter(Boolean).join('\n\n') || game.analysis
})}
${notable ? `
` : ''}
`;
}).join('');
list.innerHTML = `${detailHtml}
`;
}
function calculateAiScoreStats(rows) {
const scores = (Array.isArray(rows) ? rows : [])
.map(row => Number(row.influence_score))
.filter(Number.isFinite);
if (!scores.length) return { count: 0, mean: 0, standardDeviation: 0 };
const mean = scores.reduce((sum, score) => sum + score, 0) / scores.length;
const variance = scores.reduce((sum, score) => sum + (score - mean) ** 2, 0) / scores.length;
return { count: scores.length, mean, standardDeviation: Math.sqrt(variance) };
}
function calculateAiDeviation(score, stats) {
if (!Number.isFinite(score)) return NaN;
if (!stats || !Number.isFinite(stats.standardDeviation) || stats.standardDeviation === 0) return 50;
return 50 + 10 * (score - stats.mean) / stats.standardDeviation;
}
function aiTrialRadar(axes) {
const labels = ['発言', '盤面', '質問', '回答', '他者', '根拠'];
const keys = ['発言数/戦', '盤面語率', '質問率', '被質問応答率', '他者名言及率', '根拠語率'];
const center = 90;
const radius = 61;
const point = (index, scale) => {
const angle = -Math.PI / 2 + index * Math.PI / 3;
return `${(center + Math.cos(angle) * radius * scale).toFixed(1)},${(center + Math.sin(angle) * radius * scale).toFixed(1)}`;
};
const rings = [0.25, 0.5, 0.75, 1].map(scale =>
``
).join('');
const values = keys.map((key, index) => point(index, Math.max(0, Math.min(1000, Number(axes[key]) || 0)) / 1000)).join(' ');
const labelText = labels.map((label, index) => {
const angle = -Math.PI / 2 + index * Math.PI / 3;
const x = center + Math.cos(angle) * 78;
const y = center + Math.sin(angle) * 78 + 4;
return `${label}`;
}).join('');
return ``;
}
function renderAiScoreDistribution(rows) {
const root = qs('aiScoreDistribution');
const summary = qs('aiScoreDistributionSummary');
if (!root) return;
const scores = (Array.isArray(rows) ? rows : []).map(row => Number(row.influence_score))
.filter(Number.isFinite).map(score => Math.max(0, Math.min(1000, score)));
if (!scores.length) {
root.innerHTML = '表示できるスコアがありません。
';
if (summary) summary.textContent = '0人';
return;
}
const stats = aiTrialScoreStats || calculateAiScoreStats(rows);
aiTrialScoreStats = stats;
const deviations = scores.map(score => calculateAiDeviation(score, stats));
const scoreBinStep = 50, deviationBinStep = 5;
const scoreBins = Array.from({ length: 21 }, () => 0);
const deviationBins = Array.from({ length: 21 }, () => 0);
scores.forEach(score => { scoreBins[Math.min(20, Math.round(score / scoreBinStep))] += 1; });
deviations.forEach(value => { deviationBins[Math.min(20, Math.round(Math.max(0, Math.min(100, value)) / deviationBinStep))] += 1; });
const width = 1000, height = 318;
const margin = { top: 58, right: 24, bottom: 50, left: 72 };
const plotWidth = width - margin.left - margin.right, plotHeight = height - margin.top - margin.bottom;
const maxCount = Math.max(...scoreBins, ...deviationBins);
const roundedMax = Math.max(1, Math.ceil(maxCount / 100) * 100);
const x = (index, length) => margin.left + (index / (length - 1)) * plotWidth;
const y = count => margin.top + plotHeight - (count / roundedMax) * plotHeight;
const scorePoints = scoreBins.map((count, index) => `${x(index, scoreBins.length).toFixed(1)},${y(count).toFixed(1)}`).join(' ');
const deviationPoints = deviationBins.map((count, index) => `${x(index, deviationBins.length).toFixed(1)},${y(count).toFixed(1)}`).join(' ');
const areaPoints = `${margin.left},${margin.top + plotHeight} ${scorePoints} ${margin.left + plotWidth},${margin.top + plotHeight}`;
const scoreTicks = [0, 500, 1000].map(value => { const tickX = margin.left + value / 1000 * plotWidth; return `${value}`; }).join('');
const deviationTicks = [0, 50, 100].map(value => { const tickX = margin.left + value / 100 * plotWidth; return `偏差値 ${value}`; }).join('');
const yTicks = [0, Math.round(roundedMax / 2), roundedMax].map(value => { const tickY = y(value); return `${value.toLocaleString('ja-JP')}`; }).join('');
root.innerHTML = ``;
const sorted = [...scores].sort((a, b) => a - b), middle = Math.floor(sorted.length / 2);
const median = sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
if (summary) summary.innerHTML = `点数偏差値${scores.length.toLocaleString('ja-JP')}人・点数中央値 ${Math.round(median)}`;
}
async function loadAiTrial() {
if (aiTrialLoaded) return;
const list = qs('aiTrialList');
const meta = qs('aiTrialMeta');
if (!list) return;
try {
const data = await fetchAiTrialData();
aiTrialLoaded = true;
aiTrialData = data;
aiTrialScoreStats = calculateAiScoreStats(data.results || []);
renderAiScoreDistribution(data.results || []);
const created = data.created_at ? new Date(data.created_at).toLocaleString('ja-JP') : '';
if (meta) meta.textContent = `最終反映: ${created} / モデル: ${data.model || 'Qwen'} / 解析済み: ${data.results?.length || 0}人`;
['aiTrialSearch', 'aiTrialMinGames', 'aiTrialSort'].forEach(id => qs(id)?.addEventListener(id === 'aiTrialSearch' ? 'input' : 'change', () => { aiTrialPage = 0; renderAiTrial(); }));
renderAiTrial();
} catch (error) {
console.error(error);
list.innerHTML = '解析結果を読み込めませんでした。
';
}
}
function renderAiTrial() {
const list = qs('aiTrialList');
const pager = qs('aiTrialPager');
if (!list || !aiTrialData) return;
const query = (qs('aiTrialSearch')?.value || '').trim().toLowerCase();
const minGames = Number(qs('aiTrialMinGames')?.value || 0);
const sort = qs('aiTrialSort')?.value || 'latest';
const rawValue = (row, key) => Number(row.objective?.raw?.[key] || 0);
const rows = (aiTrialData.results || []).filter(row => {
const perspectiveText = Object.values(row.perspectives || {}).join(' ');
const haystack = `${row.display_name || ''} ${row.trip || ''} ${(row.names || []).join(' ')} ${row.analysis || ''} ${perspectiveText}`.toLowerCase();
return (!query || haystack.includes(query)) && Number(row.games_sampled || 0) >= minGames;
});
const sorters = {
latest: (a, b) => Number(b.last_played || 0) - Number(a.last_played || 0),
answers: (a, b) => rawValue(b, '被質問応答率%') - rawValue(a, '被質問応答率%'),
talk: (a, b) => rawValue(b, '発言数/戦') - rawValue(a, '発言数/戦'),
questions: (a, b) => rawValue(b, '質問率%') - rawValue(a, '質問率%'),
reasons: (a, b) => rawValue(b, '根拠語率%') - rawValue(a, '根拠語率%'),
influence: (a, b) => Number(b.influence_score ?? -1) - Number(a.influence_score ?? -1)
};
rows.sort(sorters[sort] || sorters.latest);
const count = qs('aiTrialFilteredCount');
if (count) count.textContent = `${rows.length}人`;
const pages = Math.max(1, Math.ceil(rows.length / AI_TRIAL_PAGE_SIZE));
aiTrialPage = Math.min(aiTrialPage, pages - 1);
const visibleRows = rows.slice(aiTrialPage * AI_TRIAL_PAGE_SIZE, (aiTrialPage + 1) * AI_TRIAL_PAGE_SIZE);
list.innerHTML = visibleRows.map(row => {
const raw = row.objective?.raw || {};
const rawLabels = [['発言数/戦',''],['盤面語率%','%'],['質問率%','%'],['被質問応答率%','%'],['他者名言及率%','%'],['根拠語率%','%']];
const axisRows = rawLabels.map(([key, unit]) => `${escapeHtml(key)}${Number(raw[key] || 0)}${unit}
`).join('');
const logs = (row.log_ids || []).map(id => `${Number(id)}`).join(' / ');
const analysis = typeof row.analysis === 'string' ? row.analysis : '';
const influenceValue = Number(row.influence_score);
const deviationValue = calculateAiDeviation(influenceValue, aiTrialScoreStats);
const influence = Number.isFinite(influenceValue) ? `${influenceValue}/ 1000 会話影響力偏差値 ${deviationValue.toFixed(1)}${escapeHtml(row.influence_reason || '')}
` : '';
const displayName = row.display_name || '名前不明';
const nameUrl = buildPersonalShareUrl('name', displayName);
const tripUrl = row.trip ? buildPersonalShareUrl('trip', row.trip) : '';
return `
最終参加: ${row.last_played ? new Date(Number(row.last_played) * 1000).toLocaleString('ja-JP') : '不明'}
${influence}${aiTrialRadar(row.objective?.radar || {})}
${axisRows}
${aiPerspectiveTabs(row.perspectives, {
overall: analysis,
influence: row.influence_reason || analysis,
reasoning: analysis,
collaboration: row.personality_analysis || analysis,
decisions: row.postgame_analysis || analysis
})}
${aiPhaseAnalysis(row)}
確認標本 ${Number(row.games_sampled || 0)}戦・本人発言${Number(row.target_lines || 0)}件
${logs}
${aiTrialHistory(row)}
`;
}).join('') || '条件に一致する解析結果はありません。
';
if (pager) {
pager.innerHTML = `${aiTrialPage + 1} / ${pages}`;
qs('aiTrialPrev')?.addEventListener('click', () => { if (aiTrialPage > 0) { aiTrialPage--; renderAiTrial(); scrollTo({ top: qs('tab-ai-trial').offsetTop, behavior: 'smooth' }); } });
qs('aiTrialNext')?.addEventListener('click', () => { if (aiTrialPage + 1 < pages) { aiTrialPage++; renderAiTrial(); scrollTo({ top: qs('tab-ai-trial').offsetTop, behavior: 'smooth' }); } });
}
}
// RM検索ボタン・Enter
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll('[data-ai-subtab]').forEach(button => button.addEventListener('click', () => selectAiSubtab(button.dataset.aiSubtab)));
document.addEventListener('click', event => {
const button = event.target.closest('.ai-perspective-tab');
if (!button) return;
const wrap = button.closest('.ai-perspectives');
if (!wrap) return;
const selected = button.dataset.aiPerspective;
wrap.querySelectorAll('.ai-perspective-tab').forEach(tab => {
const active = tab === button;
tab.classList.toggle('is-active', active);
tab.setAttribute('aria-selected', active ? 'true' : 'false');
});
wrap.querySelectorAll('[data-ai-perspective-panel]').forEach(panel => {
panel.hidden = panel.dataset.aiPerspectivePanel !== selected;
});
});
const btn = document.getElementById("rm-stats-search-btn");
const inp = document.getElementById("rm-stats-search");
if (btn && inp) {
btn.addEventListener("click", () => { window._rmStatsLoaded = true; loadRmStatsList(inp.value.trim(), 0); });
inp.addEventListener("keydown", (e) => { if(e.key==="Enter") { window._rmStatsLoaded = true; loadRmStatsList(inp.value.trim(), 0); } });
}
// URLからRM詳細を復元
const params = new URLSearchParams(window.location.search);
if (params.get("tab") === "tab-rm-stats" && params.get("rm")) {
setTimeout(() => {
const tabBtn = document.querySelector('[data-tab="tab-rm-stats"]');
if (tabBtn) tabBtn.click();
loadRmStatsDetail(params.get("rm"));
}, 200);
}
});