PageSourceSearch

https://ru-palmu.github.io/meter/js/navi.js?v=25

js ru-palmu.github.io collected 2026-10-03 09:52:18 UTC 43,561 bytes, 1,296 lines download raw bytes

1// ヘッダ部のナビゲーションを管理するスクリプト
2// 共通関数的なものも管理
3
4const COMMON_PREFIX = 'meter_common_';
5
6const RANK_CUSTOM = "Z";
7
8const cand_rank = ["D", "C1", "C2", "C3", "B1", "B2", "B3", "A1", "A2", "A3", "A4", "A5", "S", "SS", RANK_CUSTOM];
9
10const RANK_DIC = {}
11for (let i = 0; i < cand_rank.length; i++) {
12	RANK_DIC[cand_rank[i]] = i;
13}
14
15// preset から最新の日付を取得. meter.js 読み込み済みと仮定
16const latestDate = Object.keys(presets).sort().reverse()[0];
17
18function getCandRank(group = '') {
19	// RANK_CUSTOM 以外のランクを返す
20	return cand_rank.filter(r => r !== RANK_CUSTOM && (group == '' || group.includes(r[0])));
21}
22
23
24//////////////////////////////////////////////////
25// 共通関数
26//////////////////////////////////////////////////
27
28window.clamp = function(value, min, max) {
29	return Math.min(Math.max(value, min), max);
30}
31
32// startColor, endColor: [r,g,b] 配列
33// t: 0 ~ 1 の割合
34//
35// e.g.: 黒→赤
36//    let color = lerpColor([0,0,0], [217,83,79], 0.5); // 半分の赤
37window.lerpColor = function (startColor, endColor, t) {
38    const rgb = startColor.map((start, i) => {
39		const end = endColor[i];
40		return window.clamp(Math.round(start + (end - start) * t), 0, 255);
41	});
42    return `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
43}
44
45// キロ表示
46function formatAsK(value) {
47	if (value < 100000) {
48		return (Math.floor(value / 100) / 10).toFixed(1);    // 小数第1位(切り捨て)
49	} else {
50		return Math.floor(value / 1000);            // 整数(千単位)
51	}
52}
53
54function formatPalmu(value) {
55	if (value < 10000) {
56		// カンマ区切りで
57		return value.toLocaleString();
58	} else if (value < 1000000) {
59		return formatAsK(value) + "K";
60	} else {
61		return (Math.floor(value / 10000) / 100).toFixed(2)+ "M";
62	}
63}
64
65const RU_MODEL_BA = [
66	// 2025-11-06 〜
67	// [0, 3.040719436589159],
68	// [15531.441501008434, 2.6950897058012684],
69	// [41958.68833499996, 2.5483011271896387],
70
71	// 2026-07-25 〜
72	[4.50766479e+03, 2.90153431e+00],  // _15k .. 50k  0.99930
73	[1.25295033e+04, 2.74063787e+00],  // _50k .. 80k  0.99846 3249
74	[1.71157423e+04, 2.68423434e+00],  // _80k .. 150k 0.99941 2534
75	[3.14932929e+04, 2.59226539e+00],  // 150k .. 300k 0.99774 1592
76	[5.86271843e+04, 2.50818833e+00],  // 300k ..      0.99967 480
77];
78
79const RU_MODEL_LOW = [
80	// 2026-07-25 〜
81	// [b, a, 右端, 次モデルの左端]
82	[100.0000000000, 3.16084360e+00],  // __0k ..  5k  0.80576 1649
83	[471.5818090200, 3.08652724e+00],  // __5k .. 10k  0.93238 1756
84	[2332.077706820, 2.97314307e+00],  // _10k .. 15k  0.86712  927
85	// 49704 46929, 48030
86];
87
88{
89    // RU_MODEL_LOW の右端と次モデルの左端を計算して追加
90    for (let i = 0; i < RU_MODEL_LOW.length; i++) {
91        const [b, a] = RU_MODEL_LOW[i];
92        const x = (i + 1) * 5000;
93        const [b2, a2] = (i + 1 < RU_MODEL_LOW.length) ? RU_MODEL_LOW[i + 1] : RU_MODEL_BA[0];
94        RU_MODEL_LOW[i].push(Math.floor(b + a * x));	// 右端
95        RU_MODEL_LOW[i].push(Math.floor(b2 + a2 * x));  // 次モデルの左端
96    }
97}
98
99function score2coin_orig(score) {
100	// ライブスコアからコイン数算出
101	if (score <= RU_MODEL_LOW[2][3]) {
102		// 15000 コイン以下のモデル.
103		// 連続でない点に注意
104		for (let i = 0; i < RU_MODEL_LOW.length; i++) {
105			const [b, a, max_score, next_model] = RU_MODEL_LOW[i];
106			if (score <= next_model) {
107				if (score <= max_score) {
108					const p = Math.floor((score - b) / a);
109					return Math.max(i * 5000, p);
110				} else {
111					// もう少しだけたりない...
112					return (i + 1) * 5000 + 10;
113				}
114			}
115		}
116	}
117	const model = RU_MODEL_BA;
118	return Math.max(...model.map(([b, a]) => (score - b) / a));
119}
120
121function coin2score(coin) {
122	// コイン数からライブスコア算出
123	if (coin <= 15000) {
124		const model = RU_MODEL_LOW;
125		let m;
126		if (coin <= 5000) {
127			m = model[0];
128		} else if (coin <= 10000) {
129			m = model[1];
130		} else {
131			m = model[2];
132		}
133		return coin * m[1] + m[0];
134	} else {
135		const model = RU_MODEL_BA;
136		return Math.min(...model.map(([b, a]) => coin * a + b));
137	}
138}
139
140// ライブスコアに相当するコイン数を算出する.
141// ギフトの最小値が 10 のため, 1の位を切り上げ.
142function score2coin(goal_score, current_score, algorithm='normal') {
143  let coin = 0;
144  if (algorithm === 'per3') {
145    coin = (goal_score - current_score) / 3;
146  } else {
147    coin = score2coin_orig(goal_score) - score2coin_orig(current_score);
148  }
149
150  if (coin <= 0) {
151    if (goal_score > current_score) {
152      coin = 10;
153    } else {
154      coin = 0;
155    }
156  }
157  return Math.ceil(coin / 10) * 10;
158}
159
160function scoreOrCoin(val, metrics, format) {
161	if (metrics.startsWith('coin')) {
162		const s2calgo = (metrics.endsWith('_per3')) ? 'per3' : 'normal';
163		val = score2coin(val, 0, s2calgo);
164	}
165
166	return scoreToString(val, format);
167}
168
169function scoreToString(val, format) {
170	if (format === "comma") {
171		// カンマ区切り
172		return val.toLocaleString();
173	} else if (format === "short") {
174		return formatPalmu(val);
175	} else {
176		return val;
177	}
178}
179
180function plan2scoreOrcoin(plan, preset, metrics) {
181	return [...plan].reduce((acc, ch) => {
182		const score = preset[parseInt(ch)] ?? 0;
183		const val = scoreOrCoin(score, metrics, "raw");
184		return acc + val;
185	}, 0);
186}
187
188
189// 現在時刻取得.未使用
190function __getCurrentTime() {
191  const now = new Date();
192  const h = String(now.getHours()).padStart(2, '0');
193  const m = String(now.getMinutes()).padStart(2, '0');
194  return `${h}:${m}`;
195}
196
197
198function setRankText(rank, elementId, prefix, suffix) {
199  const sp = document.getElementById(elementId);
200  if (sp) {
201    sp.textContent = prefix + rank + suffix;
202  }
203}
204
205
206// session からデフォルト値を獲得する
207function loadDefaultValues(prefix, table) {
208	table.forEach(([session_id, elem_id]) => {
209		const elem = document.getElementById(elem_id);
210		if (elem) {
211			const value = sessionStorage.getItem(prefix + session_id);
212			if (elem?.type === "checkbox") {
213				elem.checked = (value != "0" && value != 0);
214			} else {
215				if (value) {
216					elem.value = value;
217				}
218			}
219		}
220	});
221}
222
223function saveSessionArgs(prefix, table) {
224	table.forEach(([key, value]) => {
225		sessionStorage.setItem(prefix + key, value);
226	});
227}
228
229window.redirectWithScroll = function(url) {
230  // スクロール位置を保存
231  sessionStorage.setItem('scrollY', window.scrollY);
232
233  // 更新したクエリでリダイレクト
234  window.location.href = url;
235
236}
237
238function updateUrl(table) {
239    // セレクタを選んだらリダイレクトする
240  const params = new URLSearchParams(window.location.search);
241
242  table.forEach((item) => {
243      const elemId = item[0];
244      const paramName = item[1];
245
246      const elem = document.getElementById(elemId);
247      if (!elem) {
248          console.log(`Element not found: ${elemId}`);
249          return ;
250      }
251      if (elem.value === "") {
252          params.delete(paramName);
253      } else {
254          params.set(paramName, elem.value);
255      }
256  });
257
258  // 更新したクエリでリダイレクト
259  const url = window.location.pathname + "?" + params.toString();
260  window.redirectWithScroll(url);
261}
262
263// スクロール位置を復元
264window.addEventListener('load', () => {
265	const y = sessionStorage.getItem('scrollY');
266	if (y !== null) {
267		window.scrollTo(0, parseInt(y, 10));
268		sessionStorage.removeItem('scrollY'); // 一度使ったら消す
269	}
270});
271
272function applyParamToElement(elemId, paramName, params) {
273	const val = params.get(paramName) || '';
274	if (val) {
275		const el = document.getElementById(elemId);
276		if (!el) {
277			return;
278		}
279
280		switch (el.tagName) {
281		case 'SELECT':
282			// 妥当な値なら選択状態にする
283			if ([...el.options].some((op) => op.value === val)) {
284				el.value = val;
285			}
286			break;
287		case 'INPUT':
288		case 'TEXTAREA':
289			el.value = val;
290			break;
291		}
292	}
293}
294
295function applyParamsToFormControls(table) {
296	const params = new URLSearchParams(window.location.search);
297	table.forEach((item) => {
298		applyParamToElement(item[0], item[1], params);
299	});
300}
301
302
303//////////////////////////////////////////////////
304// 確定スコアの描画
305//////////////////////////////////////////////////
306//
307function updateGuaranteedScore(selector_id, rank) {
308	const select = document.getElementById(selector_id);
309	if (!select) {
310		return;
311	}
312	const ymd = select.value;
313	if (!ymd || !window.presets[ymd]) {
314		return;
315	}
316	const data = window.presets[ymd];
317	const r = data[rank];   // 指定ランクの辞書
318
319	document.getElementById("a2").value = r[2];
320	document.getElementById("a4").value = r[4];
321	document.getElementById("a6").value = r[6];
322	document.getElementById("a2").dispatchEvent(new Event("input"));
323}
324
325function _getCustomGuaranteedScore(label, default_value) {
326	const v = Number(localStorage.getItem('CustomLiveScore' + label));
327	return Number.isInteger(v) && v > 0 ? v : default_value;
328}
329
330
331function updatePresetsCustomRank(values) {
332	// 辞書を更新
333	const child = presets[latestDate];
334	child[RANK_CUSTOM] = {
335		2: values[2],
336		4: values[4],
337		6: values[6],
338	}
339}
340
341function _loadCustomGuaranteedScore() {
342	const values = {
343		2: _getCustomGuaranteedScore('+2', 43950),
344		4: _getCustomGuaranteedScore('+4', 84990),
345		6: _getCustomGuaranteedScore('+6', 172990),
346	}
347	updatePresetsCustomRank(values);
348}
349
350_loadCustomGuaranteedScore();	// 初期化時に読み込み
351
352// 「保証ボーダーをコピー」機能用の設定
353function setForGuaranteedScoreCopy(score_id, rank = '', a = null) {
354  const target = document.getElementById(score_id);
355  if (!target) {
356    return;
357  }
358  const label = labelGuaranteedScore(rank);
359  const ret = `${label} +2=${formatAsK(a[2])}k, +4=${formatAsK(a[4])}k, +6=${formatAsK(a[6])}k`;
360  target.value = ret;
361}
362
363function saveCustomGuaranteedScores(rank, values) {
364	if (rank === RANK_CUSTOM) {
365		Object.entries(values).forEach(([k, v]) => {
366			// 正の整数だったら保存する
367			if (Number.isInteger(v) && v > 0) {
368				localStorage.setItem('CustomLiveScore+' + k, v);
369			}
370		});
371		updatePresetsCustomRank(values);
372	}
373}
374
375function labelGuaranteedScore(rank) {
376	if (rank === RANK_CUSTOM) {
377		return 'カスタムスコア';
378	} else {
379		return rank + '保証ボーダー';
380	}
381}
382
383// 保証ボーダーの入力欄とコピーエリアを挿入
384function insertGuaranteedScore(targetId, rank) {
385    const container = document.getElementById(targetId);
386    if (!container) {
387        return '';
388    }
389
390    // 必要なら中身を初期化
391    container.innerHTML = '';
392
393    const outerDiv = document.createElement('div');
394    outerDiv.className = 'guaranteed-score';
395
396    // タイトル
397    const titleSpan = document.createElement('span');
398
399    let select_id = '';
400    let title_copy = '';
401
402    title_copy = titleSpan.textContent = labelGuaranteedScore(rank);
403
404    if (rank !== RANK_CUSTOM) {
405      const small = document.createElement('small');
406
407      // 保証ボーダーの日付セレクトボックス
408      const select = document.createElement('select');
409      select_id = select.id = 'date-select';
410
411      for (const ymd of Object.keys(presets).sort().reverse()) {
412          // rank が存在しない日付はスキップ
413          if (!presets[ymd][rank]) {
414              continue;
415          }
416
417          const formatted = `${ymd.slice(0, 4)}/${ymd.slice(4, 6)}/${ymd.slice(6, 8)}`;
418          const op = document.createElement('option');
419          op.value = ymd;
420          op.textContent = formatted;
421          select.appendChild(op);
422      }
423
424      small.appendChild(document.createTextNode('(確定値 '));
425      small.appendChild(select);
426      small.appendChild(document.createTextNode(' ver.)'));
427
428      titleSpan.appendChild(small);
429    }
430
431    outerDiv.appendChild(titleSpan);
432
433    // 保証ボーダーの入力欄
434    _renderGuaranteedScoreInputs(outerDiv, rank != RANK_CUSTOM);
435
436    // 保証ボーダーのコピーエリア
437    renderGuaranteedScoreCopyArea(outerDiv, title_copy);
438
439    container.appendChild(outerDiv);
440
441    return select_id;
442}
443
444function _renderGuaranteedScoreInputs(outerDiv, readonly) {
445    // 入力行
446    const values = [
447        { id: 'a2', label: '+2', value: _getCustomGuaranteedScore('+2', 43950) },
448        { id: 'a4', label: '+4', value: _getCustomGuaranteedScore('+4', 84990) },
449        { id: 'a6', label: '+6', value: _getCustomGuaranteedScore('+6', 172990) },
450    ];
451
452    values.forEach(item => {
453        const row = document.createElement('div');
454        row.className = 'input-row';
455
456        const label = document.createElement('label');
457        label.setAttribute('for', item.id);
458        label.textContent = item.label;
459
460        const input = document.createElement('input');
461        input.type = 'number';
462        input.id = item.id;
463        input.value = item.value;
464        input.readOnly = readonly;
465        if (readonly) {
466            input.className = 'guaranteed-score-readonly';
467        }
468
469        row.appendChild(label);
470        row.appendChild(input);
471        outerDiv.appendChild(row);
472    });
473}
474
475// コピーボタン
476async function copyResult(id) {
477  const text = document.getElementById(id).value
478    ?? document.getElementById(id).innerText;
479
480  await navigator.clipboard.writeText(text);
481}
482
483async function onCopyAndRedirect(id, redirectFunc) {
484	await copyResult(id);
485	window.gtag('event', 'copy_and_redirect', {
486		event_label: id,
487		user_rank: selectedRank("undefined"),
488	});
489	return redirectFunc();
490}
491
492
493function renderGuaranteedScoreCopyArea(outerDiv, title_copy) {
494    // コピーボタン
495    const button = document.createElement('button');
496    button.className = 'copy-button';
497    button.textContent = '📋' + title_copy + 'をコピー';
498    button.setAttribute('onclick', "copyResult('scores')");
499    outerDiv.appendChild(button);
500
501    // テキストエリア
502    const textarea = document.createElement('textarea');
503    textarea.className = 'copy-output';
504    textarea.id = 'scores';
505    textarea.readOnly = true;
506    textarea.cols = 40;
507    textarea.rows = 1;
508    textarea.textContent = '(スコア)';
509    outerDiv.appendChild(textarea);
510}
511
512
513//////////////////////////////////////////////////
514// ナビゲーションのレンダリング
515//////////////////////////////////////////////////
516
517function renderNavis(navi_func, navi_rank, __footer) {
518	const page = _getCurrentPage();
519	_renderNaviFunc(page, navi_func);
520	const rank = selectedRank();
521	localStorage.setItem(COMMON_PREFIX + "selected_rank", rank);
522	_renderNaviRank(rank, navi_rank);
523
524	appendCurrentQueryToLinks('append-query')
525	_renderFooter();
526	if (typeof PALMU_NOTICES !== "undefined") {
527		_renderNotices('notice-banner', PALMU_NOTICES);
528	}
529
530	renderDescriptionRankCustom(rank);
531	return rank;
532}
533
534function renderDescriptionRankCustom(rank) {
535	if (rank !== RANK_CUSTOM) {
536		return ;
537	}
538	const spanCustom = document.getElementById("description-rank-custom");
539	if (!spanCustom) {
540		return ;
541	}
542
543	const label = labelGuaranteedScore(rank);
544	spanCustom.innerHTML = `ランク <strong>${RANK_CUSTOM}</strong> は,保証ボーダーが誤っていた場合などで使用することを想定しています.
545	${label}で設定した値は,ローカルストレージに保存され,次回以降も利用できます.`
546}
547
548function _getCurrentPage() {
549  const path = location.pathname;
550  let filename = path.split('/').pop(); // 最後の要素を取得
551
552  // ルート(/)や末尾がスラッシュだけのパスなら
553  if (!filename || filename === '') {
554    return 'index';
555  }
556
557  // クエリパラメータやアンカーがある場合は無視して,拡張子を除く
558  filename = filename.split('?')[0].split('#')[0].split('.')[0];
559
560  return filename || 'index';
561}
562
563
564function _getQueryParam(name) {
565  const url = new URL(window.location.href);
566  return url.searchParams.get(name);
567}
568
569function isValidRank(rank, date) {
570	return presets[date] && presets[date][rank];
571}
572
573// 選択されているランクを取得
574function selectedRank(key = default_rank) {
575  const presetFromURL = _getQueryParam("r");
576  if (isValidRank(presetFromURL, latestDate)) {
577    key = presetFromURL;
578  } else {
579    const kk = localStorage.getItem(COMMON_PREFIX + "selected_rank");
580    if (isValidRank(kk, latestDate)) {
581      key = kk;
582    }
583  }
584  return key;
585}
586
587function _marshmallow() {
588	const li = document.createElement('li');
589	li.className = 'sub-tab';
590
591	const a = document.createElement('a');
592	a.href = 'https://marshmallow-qa.com/fcbapahukbveobw?t=mZ9AzD&utm_medium=url_text&utm_source=promotion';
593	a.textContent = '質問箱';
594	a.dataset.track = 'link_click'
595	a.dataset.to = 'marshmallow';
596	a.dataset.from = 'navi';
597	li.appendChild(a);
598	return li
599}
600
601
602// X へのシェアボタンを生成
603function _shareX() {
604  // Xでシェアボタンを追加
605  const shareLi = document.createElement('li');
606  shareLi.className = 'sub-tab share-btn';
607
608  const shareA = document.createElement('a');
609  shareA.href = `https://x.com/ru_palmu/status/1979435586030329963`;
610  shareA.target = '_blank';
611  shareA.rel = 'noopener noreferrer';
612  shareA.style.display = 'flex';
613  shareA.style.alignItems = 'center';
614  shareA.style.gap = '4px';
615  shareA.dataset.track = 'link_click'
616  shareA.dataset.to = 'share_x';
617  shareA.dataset.from = 'navi';
618
619  // SVGアイコン(公式Xロゴ)
620  const svgNS = "http://www.w3.org/2000/svg";
621  const svg = document.createElementNS(svgNS, 'svg');
622  svg.setAttribute('xmlns', svgNS);
623  svg.setAttribute('viewBox', '0 0 1200 1227');
624  svg.setAttribute('width', '14');
625  svg.setAttribute('height', '14');
626  svg.setAttribute('fill', 'currentColor');
627  svg.setAttribute('aria-hidden', 'true');
628
629  const path = document.createElementNS(svgNS, 'path');
630  path.setAttribute('d', 'M714.163 519.284L1160.89 0H1040.49L667.137 432.009L370.906 0H0l469.746 682.897L0 1226.75h120.406l396.57-458.77 311.26 458.77H1200L714.163 519.284zM562.78 698.065l-45.933-65.932-367.02-527.611h157.662l295.635 424.871 45.932 65.932 379.115 544.369H870.51L562.78 698.065z');
631
632
633  svg.appendChild(path);
634
635  // テキスト
636  const textNode = document.createTextNode('シェア');
637
638  shareA.appendChild(svg);
639  shareA.appendChild(textNode);
640  shareLi.appendChild(shareA);
641  return shareLi;
642}
643
644// 機能に関するナビゲーションをレンダリング
645// page:現在のページID
646// target:ナビゲーションを表示する要素のID
647function _renderNaviFunc(page, target) {
648	const container = document.getElementById(target);
649	if (!container) {
650		return;
651	}
652
653	const pages = [
654		{ id: "index", name: "確定値", url: "index.html" },
655		{ id: "border", name: "変動値", url: "border.html" },
656		{ id: "plan", name: "プラン", url: "plan.html" },
657		{ id: "history", name: "履歴", url: "history.html" },
658		{ id: "schedule", name: "予定表", url: "schedule.html" },
659		{ id: "event", name: "イベント", url: "event.html" },
660		{ id: "watchtime", name: "累積時間", url: "watchtime.html" },
661		{ id: "about", name: "使い方", url: "about.html" },
662	];
663
664	const query = window.location.search;
665	const params = new URLSearchParams(query);
666
667	const picks = new URLSearchParams();
668	['r'].forEach(k => {
669		const v = params.get(k);
670		if (v) {
671			picks.set(k, v);
672		}
673	});
674	const query_str = picks.toString() ? `?${picks.toString()}` : '';
675
676	const ul = document.createElement('ul');
677	ul.className = 'sub-tab-nav';
678
679	for (const p of pages) {
680		const li = document.createElement('li');
681		li.className = 'sub-tab';
682		if (p.id === page) {
683			li.classList.add('active');
684		}
685		const a = document.createElement('a');
686		a.href = `${p.url}${query_str}`;
687		a.textContent = p.name;
688		li.appendChild(a);
689		ul.appendChild(li);
690	}
691
692	// マシュマロ, X へのシェアボタン
693	[_marshmallow(), _shareX()].forEach(li => {
694		ul.appendChild(li);
695	});
696
697	container.innerHTML = '';
698	_renderTabNaviWrapper(container, ul);
699}
700
701function _renderTabNaviWrapper(container, ul) {
702  const wrapper = document.createElement("div");
703  wrapper.className = "tab-nav-wrapper";
704
705  const leftHint = document.createElement("div");
706  leftHint.className = "scroll-hint left";
707  leftHint.textContent = "←";
708
709  const rightHint = document.createElement("div");
710  rightHint.className = "scroll-hint right";
711  rightHint.textContent = "→";
712
713  wrapper.appendChild(leftHint);
714  wrapper.appendChild(ul);
715  wrapper.appendChild(rightHint);
716
717  container.appendChild(wrapper);
718  // 矢印の表示制御
719  function updateHints() {
720    const atStart = ul.scrollLeft <= 0;
721    const atEnd = ul.scrollLeft + ul.clientWidth >= ul.scrollWidth - 1;
722    leftHint.classList.toggle("hidden", atStart);
723    rightHint.classList.toggle("hidden", atEnd);
724  }
725
726  ul.addEventListener("scroll", updateHints);
727  window.addEventListener("resize", updateHints);
728  updateHints();
729}
730
731// 各ページ上部のランク選択タブの描画
732function _renderNaviRank(selected_rank, target_id) {
733  const container = document.getElementById(target_id);
734  if (!container) {
735    return ;
736  }
737  container.innerHTML = ""; // クリア
738
739  const available = presets[latestDate];
740
741  const ul = document.createElement("ul");
742  ul.className = "tab-nav";
743
744  const params = new URLSearchParams(window.location.search);
745
746  cand_rank.forEach(rank => {
747    if (available && available[rank]) {
748      const li = document.createElement("li");
749      if (rank === selected_rank) {
750        li.className = "active";
751      } else {
752        li.className = rank[0];
753      }
754      const a = document.createElement("a");
755      params.set("r", rank);
756      a.href = `?${params.toString()}`;
757      a.textContent = rank;
758      li.appendChild(a);
759      ul.appendChild(li)
760    }
761  });
762
763  _renderTabNaviWrapper(container, ul);
764
765}
766
767//////////////////////////////////////////////////
768// お知らせ
769//////////////////////////////////////////////////
770
771function __noticeDate(date, before) {
772    // 日付だけの場合は0時に設定されるため、時刻情報がない場合は1日後の23:59に設定
773	const d = new Date(date);
774	if (before != 0) {
775		// before 日前にする
776		d.setDate(d.getDate() - before);
777	}
778	if (!date.includes('T')) {
779		d.setHours(23, 59, 59, 999);
780	}
781	return d;
782}
783
784function __renderNoticeArchive() {
785	const a = document.createElement('a');
786	a.href = 'news.html';
787	a.className = 'notice-archive-link';
788	a.textContent = '過去のお知らせ';
789	return a;
790}
791
792window.setGtagLinkClick = function(n, a, from) {
793  a.dataset.track = 'link_click'
794  a.dataset.from = from;
795  if (n.to) {
796    a.dataset.track = n.to;
797  } else {
798    a.dataset.to = n.kind.code;
799  }
800}
801
802/**
803 * 指定した要素に1週間以内のお知らせを表示
804 * 日付だけ、または日時(YYYY-MM-DD or YYYY-MM-DDTHH:MM)に対応
805 * @param {string} elementId - バナーを挿入する要素のID
806 * @param {Array} notices - 日付・テキスト・URLを含む通知配列
807 */
808function _renderNotices(elementId, notices) {
809  const banner = document.getElementById(elementId);
810  if (!banner) {
811    return ;
812  }
813
814  const now = new Date();
815
816  // 表示する項目を絞り込み
817  const upcoming = notices.filter(n => {
818  const end = n.end ? new Date(n.end) : __noticeDate(n.date, 0);
819  const start = n.start ? new Date(n.start) : __noticeDate(n.date, 7);
820
821  // console.log([start.toISOString(), now.toISOString(), end.toISOString(), n.text, start <= now, now <= end]);
822    return start <= now && now <= end;
823  });
824  if (upcoming.length === 0) {
825    const a = __renderNoticeArchive();
826    banner.appendChild(a);
827    return ;
828  }
829
830
831  banner.style.display = 'block'; // お知らせがある
832
833  const ul = document.createElement('ul');
834  ul.className = 'notice-list';
835
836  upcoming.forEach(n => {
837    const li = document.createElement('li');
838    // 日付を YYYY/MM/DD の形式に変換
839    const date = new Date(n.date);
840    const yyyy = date.getFullYear();
841    const mm = String(date.getMonth() + 1).padStart(2, '0'); // 月は0から始まるので+1
842    const dd = String(date.getDate()).padStart(2, '0');
843    const formattedDate = `${yyyy}/${mm}/${dd}: `;
844    li.appendChild(document.createTextNode(formattedDate));
845
846    let displayText = n.text;
847    if (n.time) displayText += ` (${n.time})`; // 時間情報を表示
848
849    if (n.url) {
850      const a = document.createElement('a');
851      a.href = n.url;
852      a.textContent = displayText;
853      window.setGtagLinkClick(n, a, 'notice');
854
855      li.appendChild(a);
856    } else {
857      li.appendChild(document.createTextNode(displayText));
858    }
859
860    if (n.links && n.links.length > 0) {
861        li.appendChild(document.createTextNode(' '));
862        n.links.forEach(l => {
863            li.appendChild(document.createTextNode('['));
864            if (l.file) {
865                const a = document.createElement('a');
866                a.href = `../data/${l.file}`;
867                a.textContent = l.label;
868                li.appendChild(a);
869            } else if (l.url) {
870                const a = document.createElement('a');
871                a.href = l.url;
872                a.textContent = l.label;
873                li.appendChild(a);
874            }
875            li.appendChild(document.createTextNode(']'));
876        });
877    }
878    ul.appendChild(li);
879  });
880
881  banner.appendChild(ul);
882  const a = __renderNoticeArchive();
883  banner.appendChild(a);
884}
885
886
887//////////////////////////////////////////////////
888// フッタ部
889//////////////////////////////////////////////////
890
891function _renderFooter() {
892  const footer = document.getElementById("footer");
893  if (!footer) return;
894
895  const year = new Date().getFullYear(); // ← 現在の年を取得
896
897  const aopt = 'target="_blank" rel="noopener" data-track="link_click" data-from="footer"';
898  footer.innerHTML = `
899    <div class="footer-content">
900      © ${year} (る) |
901      <a href="https://github.com/ru-palmu/meter/" ${aopt} data-to="github">GitHub</a>, <a href="https://x.com/ru_palmu" ${aopt} data-to="x">X</a>
902    </div>
903  `;
904}
905
906//////////////////////////////////////////////////
907// ヘルプ用のツールチップ
908//////////////////////////////////////////////////
909const glossary = {
910	coin: {
911		msg: "コイン数は,ライブスコアから本ツール独自のモデルを利用して計算しています.コメント数や視聴者数などにも応じて変動するため参考値としてご利用ください.",
912		page: "coin",
913		index: "コインスウ",
914		dt: "コイン数 <small>(ギフト数)</small>",
915		dd: "コイン数は,<span class='term' data-term='score'>ライブスコア</span>から本ツール独自のモデルを利用して計算しています.ライブスコアの算出方法は公開されていないこと,また,コメント数や視聴者数などにも応じて変動するため参考値としてご利用ください." +
916"<p>ライブスコアの算出方法は公開されていないものの,ギフトがライブスコアに最も寄与することは間違いありません.また,コイン人数・コメント人数など応援に参加した人数が寄与することも分かっています." +
917"",
918
919	},
920	score: {
921		msg: "ライブスコアとは,ライバーさんがリスナーさんから受け取る応援を示す指標で,コメントの数や視聴人数,スーパーライクの回数などもスコアに含まれる.",
922		page: "score",
923		index: "ライブスコア",
924		dt: "ライブスコア",
925		dd: "ライブスコアとは,ライバーがリスナーから受け取る応援を示す指標です.1分ごとに更新され,24時(00:00)にリセットされます.1日の間にだんだんと大きくなる数値で,小さくなることはありません." +
926		"<p>ライブスコアは,「<span class='term' data-term='coin'>コイン数</span>」「コメント数」「スーパーライク数」「視聴人数」などの応援要素を総合して算出されていますが,そのロジックは公開されていません.なお,ライクはライブスコアには反映されません." +
927"<p>ライブスコアは,配信画面の左上に矢印と共に表示されています.下の画像では,現在のライブスコアが 79,782 であることを表しています." +
928"<p><div class='img'><img src='img/score.png' alt='ライブスコアの例' width='20%'></div>" +
929"<p>多くの場合,ライバーが目標となる<span class='term' data-term='point'>デイリーランクポイント</span>を設定し,それに対応するライブスコアを目指すことになります." +
930"<p>より詳細な情報は,『<a href='https://note.com/palmu/n/nc853285c3db3'>【たいむず(2024/10)】ライブスコアの導入</a> <a href='https://note.com/palmu/n/nc853285c3db3#f469e53e-1e96-4c56-8de5-476f1ed9d6f8'>6. ライブスコアの算出ロジックについて</a>』『FAQ: <a href='https://intercom.help/light-inc/ja/articles/9941027-%E3%83%A9%E3%82%A4%E3%83%96%E3%82%B9%E3%82%B3%E3%82%A2%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6'>ライブスコアについて</a>』を参照ください." +
931		"",
932	},
933	border: {
934		msg: "保証ボーダーとは,締め時間に関係なく,その日のライブスコアが規定値を上回ると,デイリーランクポイントの「+2」「+4」「+6」の獲得が保証される仕組みです.別名:確定値,確定スコア",
935		page: "gborder",
936		index: "ホショウボーダー",
937		dt: "保証ボーダー <small>(確定スコア,確定値)</small>",
938		dd: "保証ボーダーとは,締め時間に関係なく,その日のライブスコアが規定値を上回ると,<span class='term' data-term='point'>デイリーランクポイント</span>の「+2」「+4」「+6」の獲得が保証される仕組みです.ライバーのプロフィールに表示される「<span class='term' data-term='rank'>ユーザーランク</span>」から,保証ボーダーの値を確認できます.下の場合では,「+2」「+4」「+6」の確定値は,それぞれ,「8,425」「20.2K」「51.4K」です." +
939		"ここで,「K」は「キロ」,つまり,1,000倍の値を表します.「51.4K」の場合,51,400 から 51,499 の間の
939値を表します.本ツールでは,中央値である 51,450 を採用しています." +
940		"<p><div class='img'><img src='img/border.png' alt='保証ボーダーの例' width='70%'></div>" +
941		"<p>なお,本ツールでは,保証ボーダーの値を手動で登録しているため,最新になっていなかったり,間違った値になっている可能性があります." +
942		"不具合報告は,<a href=https://github.com/ru-palmu/meter/issues>GitHub</a> または <a href='https://x.com/ru_palmu'>X</a> までお願いします." +
943"<p>より詳細な情報は,『<a href='https://note.com/palmu/n/n4eb0cf1c4aa8'>【たいむず(2024/6)】保証ボーダーとスキップカードについて</a>』『FAQ: <a href='https://intercom.help/light-inc/ja/articles/9418444-%E4%BF%9D%E8%A8%BC%E3%83%9C%E3%83%BC%E3%83%80%E3%83%BC%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6'>保証ボーダーについて</a>』『ヘルプ:<a href='https://intercom.help/light-inc/ja/articles/10004958-%E4%BF%9D%E8%A8%BC%E3%83%9C%E3%83%BC%E3%83%80%E3%83%BC%E3%81%AE%E8%A1%A8%E8%A8%98%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6'>保証ボーダーの表記について</a>』を参照ください." +
944"",
945	},
946	rank: {
947		msg: "ユーザーランクは,ライバーのプロフィールから確認できます.D〜S までの11段階に分かれています.",
948		page: "rank",
949		index: "ユーザーランク",
950		dt: "ユーザーランク",
951		dd: "" +
952"<p>ユーザーランクは,D, C1, C2, C3, B1, B2, B3, A1, A2, A3, A4, A5, S, SS の全部で14ランクに分かれています." +
953"D が最も低いランクで,SS が最も高いランクです." +
954"<p>ライバーのユーザーランクはプロフィール(配信画面の左上,ライバーの名前をタップ)から確認できます." +
955"<p><div class='img'><img src='img/profile.png' alt='プロフィールの例' width='70%'></div>" +
956"<p>ユーザーランクは,ランク更新期間である 7 日感に獲得した<span class='term' data-term='point'>デイリーランクポイント</span>の合計に応じて" +
957"「ランクアップ」「ランクキープ」「ランクダウン」のいずれかの状態になります." +
958"「ランクアップ」には 18 ポイント,「ランクキープ」には 12 ポイントが必要です." +
959"<p>下の画像は,上の画像の「ユーザーランク」を表す「B2ランク」をタップすると表示される画面です." +
960"2日と13時間9分で, +2 を獲得するとランクキープ,+8 を獲得するとランクアップできることを表しています." +
961"ランクアップをめざす場合には,本ツール「<a href='plan.html' class='append-query'>プラン</a>」では <strong>3日</strong>と<strong>8ポイント</strong>を入力してください." +
962"<p><div class='img'><img src='img/rankup.png' alt='保証ボーダーの例' width='70%'></div>" +
963"より詳細な情報は,『<a href='https://note.com/palmu/n/nff989241c2bf'>「全てのライバーさんへの配信機能の開放」と,「ユーザーランク」機能について</a>』を参照ください." +
964"",
965	},
966	point: {
967		msg: "デイリーランクポイントは,ライバーがライブスコアに応じて獲得するポイントで,ランクキープ・ランクアップの基準となります.",
968		page: "point",
969		index: "デイリーランクポイント",
970		dt: "デイリーランクポイント",
971		dd: "デイリーランクポイントは,ライバーがライブスコアに応じて獲得するポイントです." +
972		"配信を行わなければ「+0」,配信を行うと少なくとも「+1」が獲得できます." +
973		"締め時間である 00:00 までにボーダー,または,<span class='term' data-term='border'>保証ボーダー</span>を超えると,「+2」「+4」「+6」が獲得できます." +
974"<p>下の画像では,保証ボーダーの 83.6K を超えているため,「+2」が獲得できることを表しています." +
975"また,ボーダー(変動スコア)の 118K を超えているため,緑色で「+4」と表示されていますが," +
976"残り 13時間9分の間でボーダーは上がっていく可能性があり,「+4」の獲得は保証されていません." +
977"<p><div class='img'><img src='img/point.png' alt='保証ボーダーの例' width='70%'></div>" +
978"より詳細な情報は,『<a href='https://note.com/palmu/n/nff989241c2bf'>アップデート便り(2024/03)</a>』『FAQ:<a href='https://intercom.help/light-inc/ja/articles/9053402-%E3%83%87%E3%82%A4%E3%83%AA%E3%83%BC%E3%83%A9%E3%83%B3%E3%82%AF%E3%83%9D%E3%82%A4%E3%83%B3%E3%83%88%E3%81%A8%E3%81%AF'>デイリーランクポイントとは</a>』を参照ください." +
979"",
980	},
981}
982
983function setupTooltips() {
984  // ① 全ての .term 要素を探して
985  document.querySelectorAll('.term').forEach(term => {
986
987    const key = term.dataset.term;
988    const tooltipText = glossary[key];
989    if (!tooltipText || tooltipText['msg'] === undefined) {
990      return;
991    }
992
993    // ② それぞれにクリックイベントをつける
994    term.addEventListener('click', (__e) => {
995      // ③ 既にツールチップが表示されていたら,消す(トグル)
996      const existing = term.querySelector('.tooltip-box');
997      if (existing) {
998        existing.remove();
999        return;
1000      }
1001
1002      // ④ 他のツールチップがあれば消す
1003      document.querySelectorAll('.tooltip-box').forEach(b => b.remove());
1004
1005      // ⑤ ツールチップを作って中身を設定し,DOMに追加
1006      const tooltip = document.createElement('div');
1007      tooltip.className = 'tooltip-box';
1008
1009      let msg = tooltipText['msg'];
1010      if (tooltipText['page']) {
1011        const currentParams = window.location.search;
1012        const href = `about.html${currentParams}#${tooltipText['page']}`;
1013        msg += `<br><a href="${href}">詳細</a>`;
1014      }
1015
1016      tooltip.innerHTML = msg;
1017      term.appendChild(tooltip);
1018    });
1019  });
1020
1021  // ⑥ ページ全体にクリックイベント
1022  // .term の外をクリックしたらツールチップを消す
1023  document.addEventListener('click', (e) => {
1024    if (!e.target.closest('.term')) {
1025      document.querySelectorAll('.tooltip-box').forEach(b => b.remove());
1026    }
1027  });
1028}
1029
1030
1031// ヘルプ:用語集の描画
1032function hashChangeGlossary() {
1033  const hash = location.hash;
1034  if (!hash) {
1035    return;
1036  }
1037
1038  // ハッシュが変更されたら,該当する用語をハイライト
1039  const dt = document.querySelector(`dl#glossary dt${hash}`);
1040  if (dt) {
1041    dt.classList.add('highlighted');
1042
1043    setTimeout(() => {
1044      dt.classList.remove('highlighted');
1045    }, 5000); // 5秒後にハイライトを消す
1046  }
1047}
1048
1049
1050// ヘルプ:用語集の描画
1051function renderGlossary() {
1052  const dl = document.getElementById("glossary");
1053  if (!dl) {
1054    return;
1055  }
1056
1057  const hash = location.hash;
1058
1059  const sortedTerms = Object.values(glossary).sort((a, b) => {
1060    if (a.index < b.index) return -1;
1061    if (a.index > b.index) return 1;
1062    return 0;
1063  });
1064
1065  for (const term of sortedTerms) {
1066    const dt = document.createElement("dt");
1067    dt.id = term.page;
1068
1069    const a = document.createElement("a");
1070    const currentParams = window.location.search;
1071    a.href = `${currentParams}#${term.page}`;
1072    a.innerHTML = term.dt;
1073    dt.appendChild(a);
1074    if (hash == `#${term.page}`) {
1075      dt.className = 'highlighted';
1076
1077      setTimeout(() => {
1078        dt.className = '';
1079      }, 5000);
1080    }
1081
1082    const dd = document.createElement("dd");
1083    dd.innerHTML = term.dd;  // HTML挿入可
1084
1085    // 追加処理: dd の中の append-query を探して href を修正
1086    dd.querySelectorAll('a.append-query').forEach(link => {
1087      const href = link.getAttribute('href');
1088
1089      if (!href || href.startsWith('javascript:') || href.startsWith('#')) return;
1090
1091      const newHref = href.includes('?')
1092        ? href + '&' + currentParams.slice(1)
1093        : href + currentParams;
1094
1095      link.setAttribute('href', newHref);
1096    });
1097
1098
1099    dl.appendChild(dt);
1100    dl.appendChild(dd);
1101  }
1102}
1103
1104function appendCurrentQueryToLinks(className) {
1105  const currentParams = window.location.search;
1106  if (!currentParams) return;
1107
1108  document.querySelectorAll('a.' + className).forEach(link => {
1109    const href = link.getAttribute('href');
1110    if (!href || href.startsWith('#') || href.startsWith('javascript:')) return;
1111
1112    const newHref = href.includes('?')
1113      ? href + '&' + currentParams.slice(1)
1114      : href + currentParams;
1115
1116    link.setAttribute('href', newHref);
1117  });
1118}
1119
1120function renderPagination(data_size, page, page_size, range = 3) {
1121  const pagination = document.getElementById("pagination");
1122  pagination.innerHTML = "";
1123
1124  const last_page = Math.ceil(data_size / page_size);
1125  if (last_page <= 1) return;
1126
1127  const buttons = [];
1128
1129  // ▲▲ 先頭へ: ≪
1130  // buttons.push(_makePageBtn("≪", 1, page === 1));
1131
1132  // ▲ 前へ: <
1133  buttons.push(_makePageBtn("<", page - 1, page === 1));
1134
1135  // ▼ ページ番号生成用
1136  let start = Math.max(1, page - range);
1137  let end = Math.min(last_page, page + range);
1138
1139  // 左寄せ
1140  if (page <= range + 1) {
1141    start = 1;
1142    end = Math.min(last_page, 1 + range * 2);
1143  }
1144  // 右寄せ
1145  if (page >= last_page - range) {
1146    end = last_page;
1147    start = Math.max(1, last_page - range * 2);
1148  }
1149
1150  // ▼ 最初のページが範囲外なら 1 を表示 + 省略記号 …
1151  if (start > 1) {
1152    buttons.push(_makePageBtn(1, 1, false));
1153    if (start > 2) buttons.push(_makeEllipsis());
1154  }
1155
1156  // ▼ 現在ページの前後 range を追加
1157  for (let i = start; i <= end; i++) {
1158    const btn = _makePageBtn(i, i, false, i === page);
1159    buttons.push(btn);
1160  }
1161
1162  // ▼ 最後のページが範囲外なら … + 最後のページ
1163  if (end < last_page) {
1164    if (end < last_page - 1) buttons.push(_makeEllipsis());
1165    buttons.push(_makePageBtn(last_page, last_page, false));
1166  }
1167
1168  // ▲ 次へ: >
1169  buttons.push(_makePageBtn(">", page + 1, page === last_page));
1170
1171  // ▲▲ 最後へ: ≫
1172  // buttons.push(_makePageBtn("≫", last_page, page === last_page));
1173
1174  // ▼ DOM追加
1175  buttons.forEach(btn => pagination.appendChild(btn));
1176}
1177
1178
1179function _makePageBtn(label, targetPage, disabled, active=false) {
1180  const btn = document.createElement("button");
1181  btn.className = "page-btn";
1182  btn.textContent = label;
1183  btn.disabled = disabled;
1184
1185  if (active) {
1186    btn.classList.add("active");
1187  }
1188
1189  btn.dataset.page = targetPage;
1190
1191  if (!disabled) {
1192    btn.addEventListener("click", () => {
1193      const params = new URLSearchParams(window.location.search);
1194      params.set("page", targetPage);
1195      window.location.href = window.location.pathname + "?" + params.toString();
1196    });
1197  }
1198
1199  return btn;
1200}
1201
1202function _makeEllipsis() {
1203  const span = document.createElement("button");
1204  span.textContent = "…";
1205  span.className = "page-ellipsis";
1206  return span;
1207}
1208
1209function tableHeaderFixer() {
1210	const theadRows = document.querySelectorAll(`table thead tr`);
1211	let offset = 0;
1212	theadRows.forEach((row) => {
1213		const ths = row.querySelectorAll('th');
1214		ths.forEach((th) => {
1215			th.style.top = `${offset}px`;
1216			th.style.zIndex = 100; // 重なり順を調整
1217		});
1218		row.style.top = `${offset}px`;
1219		offset += row.getBoundingClientRect().height;
1220	});
1221}
1222
1223async function copyToClipboard(text) {
1224  if (navigator.clipboard) {
1225    try {
1226      await navigator.clipboard.writeText(text);
1227      return true;
1228    } catch {
1229      // フォールバックへ
1230    }
1231  }
1232
1233  const textarea = document.createElement("textarea");
1234  textarea.value = text;
1235  textarea.style.position = "fixed";
1236  textarea.style.top = "0";
1237  textarea.style.opacity = "0";       // 透明化(念のため)
1238  textarea.style.pointerEvents = "none";  // 操作できないように
1239
1240  document.body.appendChild(textarea);
1241  textarea.select();
1242
1243  try {
1244    return document.execCommand("copy");
1245  } finally {
1246    textarea.remove();
1247  }
1248}
1249
1250document.addEventListener("click", (e) => {
1251
1252  const a = e.target.closest("a[data-track]");
1253  if (!a) return;
1254
1255  const opt = {}
1256  if (a.dataset.from) {
1257    opt.from = a.dataset.from;
1258  }
1259  if (a.dataset.to) {
1260    opt.to = a.dataset.to;
1261  }
1262  opt.user_rank = selectedRank("undefined");
1263
1264
1265  window.gtag("event", a.dataset.track, opt);
1266});
1267
1268
1269window.tableHeaderFixer = tableHeaderFixer;
1270window.renderPagination = renderPagination;
1271window.formatPalmu = formatPalmu;
1272window.coin2score = coin2score;
1273window.score2coin = score2coin;
1274window.scoreOrCoin = scoreOrCoin;
1275window.scoreToString = scoreToString;
1276window.renderNavis = renderNavis;
1277window.setRankText = setRankText;
1278window.loadDefaultValues = loadDefaultValues;
1279window.saveSessionArgs = saveSessionArgs;
1280window.setupTooltips = setupTooltips;
1281window.renderGlossary = renderGlossary;
1282window.hashChangeGlossary = hashChangeGlossary;
1283window.saveCustomGuaranteedScores = saveCustomGuaranteedScores;
1284window.getCandRank = getCandRank;
1285window.RANK_CUSTOM = RANK_CUSTOM;
1286window.copyResult = copyResult;
1287window.onCopyAndRedirect = onCopyAndRedirect;
1288window.setForGuaranteedScoreCopy = setForGuaranteedScoreCopy;
1289window.insertGuaranteedScore = insertGuaranteedScore;
1290window.updateGuaranteedScore = updateGuaranteedScore;
1291window.updateUrl = updateUrl;
1292window.applyParamsToFormControls = applyParamsToFormControls;
1293window.plan2scoreOrcoin = plan2scoreOrcoin;
1294window.copyToClipboard = copyToClipboard;
1295
1296// vim:set ts=4 sw=4 sts=4 et:

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.