PageSourceSearch

https://sen6.jp/common.js?v=20260929e

js sen6.jp collected 2026-10-02 20:49:21 UTC 43,856 bytes, 955 lines download raw bytes

1/* ===========================================================
2   仙台六大学野球連盟 公式サイト  共通スクリプト
3   ------------------------------------------------------------
4   ★設定はこの2か所だけです★
5
6   ① GAS_URL … 公開サイトがデータを読み込むためのURL
7   ② TOOL_URL … 運営用ページ(staff.html)から開く更新ツールのURL
8   ③ GA_ID   … Googleアナリティクス(GA4)の「測定ID」(任意。2026-09-16 追加)
9
10   ①②は、更新ツールの「管理」タブ →「05 サイトとの接続設定」で
11   コピーできます(同じURLです)。
12   デプロイをやり直すとURLが変わりますので、
13   その際はこの2か所を書き換えて、common.js を再アップロードしてください。
14
15   ③の取得方法は、この下の「Googleアナリティクス」の説明をご覧ください。
16   空欄のままなら、アクセス解析は何も送信されません。
17   =========================================================== */
18var GAS_URL  = 'https://script.google.com/macros/s/AKfycbzEpvmHBbNxh4ivOMG9zlm8LNzZat0OggVLtHvXfzAgnNC03tviBy9PEbreUjgpSq6EaQ/exec';
19var TOOL_URL = 'https://script.google.com/macros/s/AKfycbzEpvmHBbNxh4ivOMG9zlm8LNzZat0OggVLtHvXfzAgnNC03tviBy9PEbreUjgpSq6EaQ/exec';
20var GA_ID    = 'G-XPXB5VWL09';   // 例:'G-XXXXXXXXXX'(Googleアナリティクスの「測定ID」)
21
22/* ===========================================================
23   Googleアナリティクス(GA4)2026-09-16 追加
24   ------------------------------------------------------------
25   上の GA_ID に測定ID(G-から始まる文字列)を入れると、
26   訪問者数・よく見られているページなどのデータが取れるようになります。
27
28   取得方法は 3_資料 の手順書、または下記の要約をご覧ください:
29   1. https://analytics.google.com を、連盟で使うGoogleアカウントで開く
30   2. 「プロパティを作成」→ 名前「仙台六大学野球連盟」、タイムゾーン日本、通貨円
31   3. 業種・ビジネスの目的は、近いものを選んで進める(あとで変更可)
32   4. データストリームで「ウェブ」を選び、URLに https://www.sen6.jp を入力
33   5. 表示された「測定ID」(G-XXXXXXXXXX)をコピーし、上の GA_ID に貼り付けて
34      common.js をアップロードし直す
35
36   ・運営用ページ(staff.html・manual.html)は、皆さんの作業がデータに
37     混ざらないよう、計測の対象から外しています。
38   ・GA_ID が空欄のうちは、外部への通信は一切発生しません。
39   =========================================================== */
40(function(){
41  if(!GA_ID) return;
42  var page = location.pathname.split('/').pop();
43  if(page === 'staff.html' || page === 'manual.html') return;   // 運営用ページは計測しません
44
45  var s = document.createElement('script');
46  s.async = true;
47  s.src = 'https://www.googletagmanager.com/gtag/js?id=' + GA_ID;
48  document.head.appendChild(s);
49
50  window.dataLayer = window.dataLayer || [];
51  function gtag(){ dataLayer.push(arguments); }
52  window.gtag = gtag;
53  gtag('js', new Date());
54  gtag('config', GA_ID);
55
56  /* 各種様式(/forms/ のファイル)がダウンロードされた回数を数えます(2026-09-27)
57     Googleアナリティクスに「form_download」という名前で記録され、どの様式かは form_name でわかります。
58     アナリティクスの管理画面で「キーイベント」に指定すると、成果としてレポートに出せます。 */
59  document.addEventListener('click', function(e){
60    var a = e.target && e.target.closest ? e.target.closest('a[href*="/forms/"]') : null;
61    if(!a) return;
62    var file = a.getAttribute('href').split('/').pop();
63    gtag('event', 'form_download', { form_name: file });
64  });
65})();
66
67/* ===========================================================
68   デザイン(テーマ)と豆知識の自動読み込み
69   ------------------------------------------------------------
70   更新ツールで設定した内容を反映します。
71
72   ★表示を待たせない工夫★
73   前回覚えた内容をすぐ使い、最新版は「裏で」取りに行きます。
74   そのため、この処理で画面の表示が遅くなることはありません。
75   =========================================================== */
76(function(){
77  var THEMES = ['theme-aoba','theme-night','theme-tanabata'];
78
79  function apply(name){
80    if(THEMES.indexOf(name) < 0) return;
81    var link = document.querySelector('link[href*="theme-"]');
82    if(!link) return;
83    var want = name + '.css';
84    if(link.getAttribute('href') === want) return;   // すでに同じなら何もしません
85    link.setAttribute('href', want);
86  }
87
88  // ① 前回覚えた設定をすぐ反映します(画面のちらつきを防ぎます)
89  try{
90    var saved = localStorage.getItem('sen6_theme');
91    if(saved) apply(saved);
92
93    var savedTips = localStorage.getItem('sen6_tips');
94    if(savedTips){
95      var arr = JSON.parse(savedTips);
96      if(Array.isArray(arr) && arr.length) window.TIPS = arr;
97    }
98  }catch(e){}
99
100  // ② 最新の設定を、裏で確認しにいきます
101  //
102  //    ★ページを開いた直後には実行しません★
103  //    本体のデータ取得と同時にGASを叩くと、混み合ったときに
104  //    どちらかが失敗することがあります。表示に使うのは①の
105  //    覚えておいた内容なので、こちらは後回しで支障ありません。
106  if(!GAS_URL) return;
107  var url = GAS_URL + (GAS_URL.indexOf('?')>=0?'&':'?') + 'type=theme';
108  var later = function(){ setTimeout(go, 2500); };
109  if(document.readyState === 'complete') later();
110  else window.addEventListener('load', later);
111  return;
112
113  function go(){
114  fetch(url).then(function(r){ return r.json(); }).then(function(j){
115    if(!j) return;
116    if(j.theme){
117      apply(j.theme);
118      try{ localStorage.setItem('sen6_theme', j.theme); }catch(e){}
119    }
120    if(Array.isArray(j.tips) && j.tips.length){
121      window.TIPS = j.tips;   // 次回の読み込みから、この内容が使われます
122      try{ localStorage.setItem('sen6_tips', JSON.stringify(j.tips)); }catch(e){}
123    }
124  }).catch(function(){ /* 取得できなければ、いま手元にある内容のままです */ });
125  }
126})();
127
128/* =========================================================
129   通信まわり
130   ========================================================= */
131var SEN6 = (function(){
132'use strict';
133
134var LASTERR = '';
135var CACHE = {};
136
137function $(s){ return document.querySelector(s); }
138/** 値が入っているか(0 も「入っている」と数えます。空欄・未入力だけが false)*/
139function filled(v){ return v !== null && v !== undefined && String(v).trim() !== ''; }
140
141/** PDFなどのURL(http(s):// か、pdf/ などサイト内の場所だけを許可します) */
142function pdfUrl(g){
143  var u = String((g && g['詳細URL']) || '').trim();
144  if(!u) return '';
145  if(/^https?:\/\//i.test(u)) return u;
146  if(/^[\w\-\/.%]+$/.test(u) && !/^\/\//.test(u)) return u;
147  return '';
148}
149/** 試合詳細へのリンク先(2026-09-13 第2段階)
150    ・打撃成績などの詳細が登録されている試合 → 試合詳細ページ(game.html)
151    ・詳細は無く、PDFのURLだけある試合      → そのPDF */
152function detailUrl(g){
153  if(g && g['詳細あり'] === true && g.id) return 'game.html?id=' + encodeURIComponent(g.id);
154  return pdfUrl(g);
155}
156function detailLink(g){
157  var u = detailUrl(g);
158  if(!u) return '';
159  var inSite = u.indexOf('game.html?') === 0;
160  return '<a class="det" href="'+esc(u)+'"'+(inSite ? '' : ' target="_blank" rel="noopener"')+'>詳細 &raquo;</a>';
161}
162
163/** 勝率・打率を小数点以下3桁で表示します(1 → 1.000 / 0.75 → 0.750 / 0 → 0.000 / .4 → 0.400)
164    2026-09-27:連盟のご要望により、頭の0を省かない表記に変更 */
165function pct(v){ return rate(v, 3); }
166/** 率の表示をそろえます(digits=小数点以下の桁数。打率・勝率は3、防御率は2)
167    「.409」「0.4」「1」など入力の書き方が違っても、同じ形で表示します。数字でなければそのまま */
168function rate(v, digits){
169  if(!filled(v)) return '';
170  var t = String(v).trim().replace(/[0-9.]/g, function(c){ return c === '.' ? '.' : String.fromCharCode(c.charCodeAt(0) - 0xFEE0); });
171  if(!/^-?\d*\.?\d+$/.test(t)) return String(v);
172  return Number(t).toFixed(digits == null ? 3 : digits);
173}
174/** 得点を数字で読みます。サヨナラの「7x」なども 7 として扱います(読めなければ NaN) */
175function runs(v){
176  var m = String(v == null ? '' : v).replace(/[0-9]/g, function(c){ return String.fromCharCode(c.charCodeAt(0) - 0xFEE0); }).match(/\d+/);
177  return m ? Number(m[0]) : NaN;
178}
179
180function esc(s){
181  return String(s==null?'':s).replace(/[&<>"]/g, function(c){
182    return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c];
183  });
184}
185
186/** type=… のURLを組み立てます */
187function buildUrl(params){
188  var u = GAS_URL + (GAS_URL.indexOf('?') >= 0 ? '&' : '?');
189  var parts = [];
190  Object.keys(params||{}).forEach(function(k){
191    if(params[k] !== undefined && params[k] !== null && params[k] !== '')
192      parts.push(k + '=' + encodeURIComponent(params[k]));
193  });
194  return u + parts.join('&');
195}
196
197/**
198 * JSONPで取得します。
199 * ファイルを直接開いた場合(file://)でもブラウザの制限を受けません。
200 */
201function viaJsonp(url, ms){
202  return new Promise(function(resolve, reject){
203    var cb = 'sen6cb_' + Date.now() + Math.floor(Math.random()*1000);
204    var sc = document.createElement('script');
205    var done = false;
206    var timer = setTimeout(function(){ cleanup(); reject(new Error('タイムアウト')); }, ms || 12000);
207    function cleanup(){
208      done = true;
209      clearTimeout(timer);
210      try{ delete window[cb]; }catch(e){ window[cb] = undefined; }
211      if(sc.parentNode) sc.parentNode.removeChild(sc);
212    }
213    window[cb] = function(data){ cleanup(); resolve(data); };
214    sc.onerror = function(){ cleanup(); reject(new Error('スクリプト読み込み失敗')); };
215    /* GAS側でエラーが起きるとJSONではなくHTMLのエラー画面が返ります。
216       その場合 onerror は起きず、コールバックも呼ばれないため、
217       読み込み完了だけを検知してすぐ次の手に進みます。 */
218    sc.onload = function(){
219      if(done) return;
220      setTimeout(function(){
221        if(!done){ cleanup(); reject(new Error('応答がJSONではありません')); }
222      }, 300);
223    };
224    sc.src = url + (url.indexOf('?')>=0?'&':'?') + 'callback=' + cb;
225    document.head.appendChild(sc);
226  });
227}
228
229/**
230 * データを取りに行きます。
231 * まず通常の方法(fetch)を試し、失敗したらJSONPで再試行します。
232 * @param {Object} params 例: {type:'data', season:'2026-spring'}
233 * @param {*} fallback 取得できなかったときに使うサンプルデータ
234 */
235/* 失敗した取得を覚えておき、あとでやり直せるようにします。
236   スマートフォンでは、読み込み中に画面がロックされたり別のアプリに
237   切り替わったりすると通信が止められます。戻ってきたときに
238   自動でやり直すのは、そのための仕組みです。 */
239var RETRYQ = [];
240
241/** 1回ぶんの取得。fetch → だめならJSONP。 */
242function fetchOnce(url, ms){
243  var ctl = (typeof AbortController !== 'undefined') ? new AbortController() : null;
244  var t = ctl ? setTimeout(function(){ ctl.abort(); }, ms) : null;
245  var opt = {redirect:'follow'};
246  if(ctl) opt.signal = ctl.signal;
247
248  return fetch(url, opt)
249    .then(function(r){
250      if(t) clearTimeout(t);
251      if(!r.ok) throw new Error('HTTP ' + r.status);
252      return r.text();
253    })
254    .then(function(txt){
255      /* GASがエラー画面(HTML)を返したときは、ここで気づきます */
256      if(!txt || txt.charAt(0) === '<') throw new Error('応答がJSONではありません');
257      return JSON.parse(txt);
258    })
259    .catch(function(e1){
260      if(t) clearTimeout(t);
261      console.warn('通常の取得に失敗。JSONPで試します:', e1.message);
262      return viaJsonp(url, ms).catch(function(e2){
263        throw new Error('fetch=' + e1.message + ' / jsonp=' + e2.message);
264      });
265    });
266}
267
268/**
269 * データを取りに行きます。
270 * 1回目でだめなら、少し待って2回目・3回目まで自動でやり直します。
271 * GASは久しぶりの呼び出しだと起動に数秒かかるため、
272 * 1回目の失敗は「落ちた」ではなく「間に合わなかった」ことがほとんどです。
273 * @param {Object} params 例: {type:'data', season:'2026-spring'}
274 * @param {*} fallback 取得できなかったときに使うサンプルデータ
275 */
276function load(params, fallback){
277  if(!GAS_URL){
278    LASTERR = 'GAS_URL が空欄です';
279    return Promise.resolve(fallback);
280  }
281  var key = JSON.stringify(params);
282  if(CACHE[key]) return Promise.resolve(CACHE[key]);
283
284  // すぐ返ってくる場合に画面がちらつかないよう、
285  // 350ミリ秒たっても返ってこないときだけ読み込み画面を出します。
286  var showTimer = setTimeout(bootShow, 350);
287  var finish = function(){ clearTimeout(showTimer); bootHide(); };
288
289  var url = buildUrl(params);
290  var WAITS = [9000, 13000, 18000];   // 試すたびに待つ時間を延ばします
291  var errs = [];
292
293  function attempt(i){
294    return fetchOnce(url, WAITS[i]).catch(function(e){
295      errs.push('#' + (i+1) + ' ' + e.message);
296      if(i + 1 >= WAITS.length) throw new Error(errs.join(' / '));
297      // 少し間をあけてからやり直します(連打するとGAS側の負荷になります)
298      return new Promise(function(r){ setTimeout(r, 900 * (i+1)); })
299        .then(function(){ return attempt(i+1); });
300    });
301  }
302
303  return attempt(0)
304    .then(function(j){
305      if(!j) throw new Error('データが空です');
306      CACHE[key] = j;
307      LASTERR = '';
308      finish();
309      return j;
310    })
311    .catch(function(e){
312      LASTERR = e.message;
313      console.error('データ取得に失敗しました:', e);
314      RETRYQ.push(key);
315      finish();
316      return fallback;
317    });
318}
319
320/** 失敗したあと、もう一度だけ取りに行って画面を作り直します */
321function retryAll(){
322  if(!RETRYQ.length || !GAS_URL) return;
323  RETRYQ = [];
324  location.reload();
325}
326
327/* 画面が戻ってきたとき、前回失敗していたら自動でやり直します。
328   (iPhoneでは画面ロック中に通信が止まるため、これがないと
329     戻ってきてもサンプル表示のままになります) */
330if(typeof document !== 'undefined'){
331  document.addEventListener('visibilitychange', function(){
332    if(document.visibilityState === 'visible' && RETRYQ.length) retryAll();
333  });
334  window.addEventListener('online', retryAll);
335}
336
337/** サンプル表示中であることを画面上に明示します */
338function showState(){
339  var box = $('#datastate');
340  if(!box) return;
341  if(!LASTERR){ box.classList.remove('on'); return; }
342
343  var local = location.protocol === 'file:';
344  var msg;
345  if(!GAS_URL){
346    msg = '<b>サンプルを表示しています。</b> common.js の <code>var GAS_URL</code> に、'
347        + 'GASのウェブアプリURL(末尾が /exec)を貼り付けてください。';
348  }else if(local){
349    msg = '<b>サンプルを表示しています。</b> このファイルはパソコン上で直接開かれているため'
350        + '(アドレスが <code>file://</code> になっています)、ブラウザの制限でデータを取得できません。'
351        + 'サーバーにアップロードすると解消します。';
352  }else{
353    /* 一般の来訪者にも意味が通る文にしています。
354       原因の詳細は、運営の方が確認できるよう末尾に小さく残します。 */
355    msg = '<b>最新のデータを読み込めませんでした。</b> '
356        + '通信の状況によっては時間がかかることがあります。'
357        + '<button type="button" class="reload">もう一度読み込む</button>'
358        + '<code>' + String(LASTERR).replace(/[<>&]/g,'') + '</code>';
359  }
360  var inner = box.querySelector('.in');
361  inner.innerHTML = msg;
362  var btn = inner.querySelector('.reload');
363  if(btn) btn.onclick = function(){ location.reload(); };
364  box.classList.add('on');
365}
366
367/* =========================================================
368   ローディング画面
369   ---------------------------------------------------------
370   読み込みに数秒かかることがあるため、その間の画面を用意します。
371   豆知識を切り替えながらお待ちいただく作りです。
372   ========================================================= */
373/* 読み込み中に表示する豆知識(初期セット)
374   更新ツールの「豆知識」タブで編集した内容がある場合は、そちらが優先されます。
375   ここは「初めてサイトを訪れた方の1回目」だけに使われます。 */
376window.TIPS = window.TIPS || [
377  '仙台六大学野球連盟は1970年4月1日に創立されました。加盟は仙台大学、東北学院大学、東北福祉大学、東北工業大学、東北大学、宮城教育大学の6校です。',
378  '東北福祉大学は1991年、全日本大学野球選手権大会で優勝。東北のリーグとして全国にその名が届いた年でした。',
379  '観戦チケットは大人800円、学生500円、シニア300円です。中学生以下は無料でご観戦いただけます。',
380  '連盟では審判員・運営委員・配信部員を募集しています。野球経験がない方も歓迎です。',
381  '全試合をYouTubeで無料配信しています。球場に来られない日も、試合の様子をご覧いただけます。',
382  '打率の「.333」は、3打数に1本の割合で安打を打っているという意味です。3割を超えると好打者と言われます。',
383  '防御率は「9イニングを投げたとき、平均で何点取られるか」を表します。数字が小さいほど良い投手です。',
384  'イニングスコアの「-」は、その回に攻撃がなかったことを表します。後攻チームが勝っている場合、9回裏は行われません。',
385  '勝ち点制のリーグでは、同じ相手と2勝すると勝ち点1が入ります。1勝1敗の場合は3回戦を行います。',
386  'スコアボードの「R」は Runs(得点)の頭文字です。'
387];
388
389var _bootTimer = null, _tipTimer = null, _slowTimer = null;
390
391/** 読み込み画面を出します */
392function bootShow(){
393  if(document.getElementById('boot')) return;
394  var el = document.createElement('div');
395  el.id = 'boot';
396  el.setAttribute('role','status');
397  el.setAttribute('aria-live','polite');
398  el.innerHTML =
399    '<div class="title">SENDAI BIG-SIX</div>'
400    + '<div class="pitch" aria-hidden="true">'
401    +   '<span class="plate"></span><span class="line"></span>'
402    +   '<span class="ball"></span><span class="mitt"></span>'
403    + '</div>'
404    + '<div class="status">読み込んでいます…</div>'
405    + '<div class="bar"><i></i></div>'
406    + '<div class="tips"><div class="lb">BASEBALL NOTES</div><div class="tx" id="bootTip"></div></div>'
407    + '<p class="slow" id="bootSlow">最新のデータを取りにいっています。もう少しだけお待ちください。</p>';
408  document.body.appendChild(el);
409
410  // 豆知識をランダムな順で切り替えます
411  // (更新ツールで編集された最新の内容が、ここで使われます)
412  var src = (window.TIPS && window.TIPS.length) ? window.TIPS : [];
413  var pool = src.slice().sort(function(){ return Math.random() - 0.5; });
414  if(!pool.length) pool = ['読み込んでいます…'];
415  var i = 0;
416  var tip = document.getElementById('bootTip');
417  function nextTip(){
418    tip.style.animation = 'none';
419    void tip.offsetWidth;          // アニメーションを再生し直すための操作です
420    tip.style.animation = '';
421    tip.textContent = pool[i % pool.length];
422    i++;
423  }
424  nextTip();
425  _tipTimer = setInterval(nextTip, 4200);
426
427  // 6秒以上かかったら、ひとこと添えます
428  _slowTimer = setTimeout(function(){
429    var s = document.getElementById('bootSlow');
430    if(s) s.classList.add('on');
431  }, 6000);
432}
433
434/** 読み込み画面を消します */
435function bootHide(){
436  var el = document.getElementById('boot');
437  if(!el) return;
438  clearInterval(_tipTimer); clearTimeout(_slowTimer); clearTimeout(_bootTimer);
439  el.classList.add('done');
440  setTimeout(function(){ if(el.parentNode) el.parentNode.removeChild(el); }, 600);
441}
442
443/* =========================================================
444   共通の描画パーツ
445   ========================================================= */
446
447/** 緊急告知 */
448function drawAlert(settings){
449  var a = $('#alert');
450  if(!a) return;
451  var s = settings || {};
452  if((s['緊急告知']||'').trim()){
453    a.textContent = s['緊急告知'];
454    a.dataset.lv = s['緊急告知レベル'] || 'info';
455    a.classList.add('on');
456  }else{
457    a.classList.remove('on');
458  }
459}
460
461/** フッター */
462function drawFooter(settings){
463  var s = settings || {};
464  var off = $('#office');
465  if(off){
466    off.innerHTML = '仙台六大学野球連盟 事務局<br>' + esc(s['事務局住所']||'')
467      + (s['事務局TEL'] ? '<br>TEL / FAX ' + esc(s['事務局TEL']) : '')
468      + (s['事務局メール'] ? '<br>' + esc(s['事務局メール']) : '');
469  }
470  var sns = $('#sns');
471  if(sns){
472    var out = [];
473    ['X','Instagram','YouTube'].forEach(function(k){
474      if(s[k]) out.push('<li><a href="'+esc(s[k])+'" target="_blank" rel="noopener">'+k+'</a></li>');
475    });
476    sns.innerHTML = out.join('');
477  }
478  var mail = $('#ctaMail');
479  if(mail && s['事務局メール']) mail.href = 'mailto:' + s['事務局メール'];
480
481  /* 募集の応募先(Instagram)。
482     「設定」シートのURLが空欄の区分は、ボタンごと出しません。 */
483  var cta = $('#ctaLinks');
484  if(cta){
485    [['運営委員','Instagram'],['審判部','審判部Instagram'],['配信部','配信部Instagram']]
486      .forEach(function(pair){
487        if(!s[pair[1]]) return;
488        cta.insertAdjacentHTML('beforeend',
489          '<a href="'+esc(s[pair[1]])+'" target="_blank" rel="noopener">'
490          + esc(pair[0]) + '</a>');
491      });
492  }
493}
494
495/** 広告
496 *  「広告」シートに加えて、「画像」シートで用途=スポンサーのものも表示します。
497 *  画像シート側は掲載期間を持たないため、常時表示になります。 */
498function drawAds(list, images){
499  var strip = $('#adStrip');
500  if(!strip) return;
501
502  var items = (list||[]).slice();
503
504  /* スポンサーは「広告」シートだけで管理します(2026-09 変更)
505     以前は「画像」シートの用途=スポンサーも拾っていましたが、
506     二重管理で分かりにくかったため、広告シートに一本化しました。 */
507
508  if(!items.length){ strip.style.display = 'none'; return; }
509  strip.style.display = '';
510  $('#adGrid').innerHTML = items.map(function(a){
511    var inner = a.img ? '<img src="'+esc(a.img)+'" alt="'+esc(a.name||'')+'" loading="lazy">'
512                      : '<span class="noimg">'+esc(a.name||'')+'</span>';
513    var ext = a.url && a.url !== '#';
514    return '<a href="'+esc(a.url||'#')+'"'+(ext?' target="_blank" rel="noopener sponsored"':'')+'>'+inner+'</a>';
515  }).join('');
516}
517
518/** モバイルメニュー */
519function initMenu(){
520  var bg = document.getElementById('burger'), nv = document.getElementById('nav');
521  if(!bg || !nv) return;
522  bg.onclick = function(){
523    var open = nv.classList.toggle('open');
524    bg.setAttribute('aria-expanded', String(open));
525    bg.textContent = open ? 'CLOSE' : 'MENU';
526  };
527  nv.addEventListener('click', function(e){
528    if(e.target.tagName === 'A'){ nv.classList.remove('open'); bg.textContent = 'MENU'; }
529  });
530}
531
532/** 更新日時の表示 */
533function drawGen(at){
534  var g = $('#gen');
535  if(g) g.textContent = 'DATA UPDATED ' + (at || '');
536}
537
538/* =========================================================
539   小物
540   ========================================================= */
541
542/**
543 * 開始時刻を「10:00」の形に整えます。
544 * スプレッドシートで時刻だけを入力すると
545 * 「1899-12-30 10:00」のような日時として保存されるため、
546 * どんな形で入っていても時刻部分だけを取り出します。
547 */
548function htime(v){
549  var s = String(v == null ? '' : v).trim();
550  if(!s) return '';
551  // 文字列の中から「時:分」を探します(1899-12-30 10:00 なども拾えます)
552  var m = s.match(/(\d{1,2})\s*[::]\s*(\d{2})/);
553  if(m){
554    var hh = String(Number(m[1])).padStart(2,'0');
555    return hh + ':' + m[2];
556  }
557  // 「1000」「10時」のような書き方にも一応対応します
558  var m2 = s.match(/^(\d{1,2})\s*時\s*(\d{1,2})?\s*分?$/);
559  if(m2) return String(Number(m2[1])).padStart(2,'0') + ':' + (m2[2] ? String(Number(m2[2])).padStart(2,'0') : '00');
560  var m3 = s.match(/^(\d{1,2})(\d{2})$/);
561  if(m3) return String(Number(m3[1])).padStart(2,'0') + ':' + m3[2];
562  return s;
563}
564
565/**
566 * 節の表記を整えます。
567 * 「2」とだけ入力されていても「第2節」と表示します。
568 * 「準決勝」「決勝」などの文字はそのままです。
569 */
570function hround(v){
571  var s = String(v == null ? '' : v).trim();
572  if(!s) return '';
573  if(/^\d+$/.test(s)) return '第' + s + '節';           // 2 → 第2節
574  if(/^第?\d+$/.test(s)) return '第' + s.replace(/^第/,'') + '節';
575  return s;
576}
577
578/**
579 * 日付を「YYYY-MM-DD」に揃えます。
580 * スプレッドシートに手で入力されると
581 * 「2026/8/30」「2026.8.30」「2026年8月30日」など書き方が揺れます。
582 * どれも同じ日として扱えるようにするための関数です。
583 * 判別できないときは空文字を返します。
584 */
585function ymd(v){
586  var s = String(v==null?'':v).trim();
587  if(!s) return '';
588  var m = s.match(/(\d{4})\s*[-\/.年]\s*(\d{1,2})\s*[-\/.月]\s*(\d{1,2})/);
589  if(!m) return '';
590  return m[1] + '-'
591       + ('0'+m[2]).slice(-2) + '-'
592       + ('0'+m[3]).slice(-2);
593}
594function fdate(v){
595  var d = ymd(v);
596  return d ? d.replace(/-/g,'.') : String(v||'');
597}
598function md(v){
599  var d = ymd(v);
600  return d ? Number(d.slice(5,7))+'/'+Number(d.slice(8,10)) : String(v||'');
601}
602/** きょうの日付を「YYYY-MM-DD」で返します */
603function todayYmd(){
604  var t = new Date();
605  return t.getFullYear() + '-'
606       + ('0'+(t.getMonth()+1)).slice(-2) + '-'
607       + ('0'+t.getDate()).slice(-2);
608}
609function byRank(a,b){ return (Number(a['順位'])||99)-(Number(b['順位'])||99); }
610
611/** タブの切り替えを有効にします */
612function bindTabs(tabSel, scope){
613  document.querySelectorAll(tabSel + ' button').forEach(function(b){
614    b.onclick = function(){
615      document.querySelectorAll(tabSel + ' button').forEach(function(x){
616        x.setAttribute('aria-selected', String(x === b));
617      });
618      document.querySelectorAll(scope + ' .pane').forEach(function(p){
619        p.classList.toggle('on', p.dataset.p === b.dataset.p);
620      });
621    };
622  });
623}
624
625/**
626 * 1試合分のイニングスコアを描きます。
627 * 回ごとの得点が入っていない試合は、合計だけのシンプルな表示になります。
628 * スマートフォンでは大学名を略称にして、9回+合計が画面に収まるようにしています。
629 * @param {Object} g 試合データ
630 * @param {Object} short 正式名称→略称の対応表(省略可)
631 */
632/* ページ内リンク(「#」で始まるリンク)を押したときの位置合わせ(2
632026-09-16)
633   写真などが後から読み込まれて高さが変わると、押した直後の位置からずれてしまうため、
634   少し時間をおいて2回、位置を合わせ直します。 */
635document.addEventListener('click', function(e){
636  var a = e.target && e.target.closest ? e.target.closest('a[href^="#"]') : null;
637  if(!a) return;
638  var id = a.getAttribute('href');
639  if(!id || id.length < 2) return;
640  var el;
641  try{ el = document.querySelector(id); }catch(err){ return; }
642  if(!el) return;
643  e.preventDefault();
644  try{ history.replaceState(null, '', id); }catch(err){}
645  var go = function(){ el.scrollIntoView(); };
646  go();
647  setTimeout(go, 300);
648  setTimeout(go, 900);
649});
650
651/** イニングごとの表示用の値(2026-09-15)
652    0 が空欄で保存されていても、試合が進んだ回は「0」にします。
653    試合終了で後攻が勝っていて9回裏が空欄なら「x」にします。まだ行っていない回は空欄のまま。 */
654function innings(g){
655  var done = (g['状態'] === '試合終了');
656  var hasEx = filled(g['先延長']) || filled(g['後延長']);
657  function lastOf(pre){ var l = 0; for(var i=1;i<=9;i++) if(filled(g[pre+i])) l = i; return l; }
658  var la = lastOf('先'), lb = lastOf('後');
659  var both = Math.max(la, lb);
660  // 試合終了は9回まで(備考に「7回コールド」などとあれば、その回まで)
661  if(done){
662    var called = /(\d+)\s*回\s*コールド/.exec(String(g['備考'] || ''));
663    both = called ? Math.max(both, Math.min(9, Number(called[1]))) : 9;
664  }
665  function row(pre, last){
666    var out = [];
667    for(var i=1;i<=9;i++){
668      var v = g[pre+i];
669      if(filled(v)) out.push(String(v).trim());
670      else if(i <= last){
671        var homeWin = runs(g['後攻得点']) > runs(g['先攻得点']);   // 「7x」も数える(2026-09-27)
672        out.push(pre === '後' && i === 9 && done && !hasEx && homeWin ? 'x' : '0');
673      }
674      else out.push('');
675    }
676    return out;
677  }
678  return { a: row('先', done ? both : la), b: row('後', done ? both : lb), hasEx: hasEx };
679}
680
681/** 延長の表示用の値(2026-09-27)
682    「0 1」のように回ごとに区切って入力されていれば、10回・11回…と1回ずつの列にします。
683    数字1つだけ(以前の入力方法)の場合は、これまでどおり「延」の1列にまとめて表示します。
684    戻り値:{ cols:['10','11'], a:['0','0'], b:['0','1'] }/延長なしは cols が空 */
685function extras(g){
686  function split(v){
687    if(!filled(v)) return [];
688    return String(v).replace(/[0-9]/g, function(c){ return String.fromCharCode(c.charCodeAt(0) - 0xFEE0); })
689      .trim().split(/[\s,、,・\/]+/).filter(function(t){ return t !== ''; });
690  }
691  var a = split(g['先延長']), b = split(g['後延長']);
692  var n = Math.max(a.length, b.length);
693  if(!n) return { cols: [], a: [], b: [] };
694  if(n === 1) return { cols: ['å»¶'], a: [a[0] || '-'], b: [b[0] || '-'] };
695  // 後攻がサヨナラ勝ちして最終回の裏がない場合は「x」にします
696  var homeWin = g['状態'] === '試合終了' && runs(g['後攻得点']) > runs(g['先攻得点']);
697  var cols = [], ra = [], rb = [];
698  for(var i = 0; i < n; i++){
699    cols.push(String(10 + i));
700    ra.push(i < a.length ? a[i] : '');
701    rb.push(i < b.length ? b[i] : (homeWin && i === n - 1 ? 'x' : ''));
702  }
703  return { cols: cols, a: ra, b: rb };
704}
705
706function lineScore(g, short, articles){
707  short = short || {};
708
709  /* この試合に紐づく新聞記事のリンクを組み立てます */
710  var press = '';
711  if(articles && articles.length){
712    var mine = articles.filter(function(a){ return String(a['試合ID']||a.gameId||'') === String(g.id); });
713    if(mine.length){
714      press = '<div class="press">' + mine.map(function(a){
715        var url = a['リンク'] || a.url || '';
716        var md  = a['媒体']   || a.media || '記事';
717        var tt  = a['見出し'] || a.title || '関連記事';
718        return '<a href="'+esc(url)+'" target="_blank" rel="noopener"'+(isPdf(url)?' data-pdf="'+esc(tt)+'"':'')+'>'
719             + '<span class="md">'+esc(md)+'</span>'
720             + '<span class="tt">'+esc(tt)+(isPdf(url)?' <b class="pdfTag">PDF</b>':'')+'</span></a>';
721      }).join('') + '</div>';
722    }
723  }
724  var st = g['状態'] || '';
725  var isX = (st === '中止' || st === '順延');
726  var hasInnings = false;
727  for(var n=1;n<=9;n++){
728    if(filled(g['先'+n]) || filled(g['後'+n])){ hasInnings = true; break; }
729  }
730  var hasEx = filled(g['先延長']) || filled(g['後延長']);
731
732  /* 正式名称と略称の両方を書き出し、画面幅で切り替えます */
733  function teamName(name){
734    var ab = short[name] || name;
735    return '<span class="tn"><span class="full">' + esc(name) + '</span>'
736         + '<span class="abbr">' + esc(ab) + '</span></span>';
737  }
738
739  var head = '<div class="cap"><span class="st'+(isX?' x':'')+'">'
740    + esc(st) + ' / ' + esc(fdate(g['日付'])) + (g['節'] ? '('+esc(hround(g['節']))+')' : '')
741    + '</span><span>' + esc(g['球場']||'') + detailLink(g) + '</span></div>';
742
743  if(isX || !hasInnings){
744    var sc = (g['先攻得点'] !== '' && g['先攻得点'] != null)
745      ? esc(g['先攻得点']) + ' - ' + esc(g['後攻得点']) : '—';
746    return '<div class="linescore">' + head
747      + '<div style="padding:18px 15px;display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap">'
748      + '<span style="font-weight:700;font-size:14px">'+esc(g['先攻'])+' 対 '+esc(g['後攻'])+'</span>'
749      + '<span style="font-family:var(--cond);font-size:26px;color:'+(isX?'var(--ng)':'var(--amber)')+'">'
750      + (isX ? esc(st) : sc) + '</span></div>'
751      + (g['備考'] ? '<div class="pitchers">'+esc(g['備考'])+'</div>' : '')
752      + press
753      + '</div>';
754  }
755
756  var cols = '';
757  var EX = extras(g);
758  for(var i=1;i<=9;i++) cols += '<th>'+i+'</th>';
759  EX.cols.forEach(function(c){ cols += '<th>'+esc(c)+'</th>'; });
760
761  var INN = innings(g);
762  function rowFor(prefix, team, total){
763    var tds = '', vals = prefix === '先' ? INN.a : INN.b;
764    for(var i=1;i<=9;i++){
765      // 空欄で保存された0も「0」と表示します。行っていない回(コールドの8・9回など)は空欄
766      tds += '<td>' + esc(vals[i-1]) + '</td>';
767    }
768    (prefix === '先' ? EX.a : EX.b).forEach(function(v){ tds += '<td>' + esc(v) + '</td>'; });
769    return '<tr><td class="tmcell">'+teamName(team)+'</td>'+tds+'<td class="run">'+esc(total)+'</td></tr>';
770  }
771
772  var pitchers = '';
773  if(g['先攻投手'] || g['後攻投手']){
774    pitchers = '<div class="pitchers">'
775      + (g['先攻投手'] ? '<span>先発('+esc(short[g['先攻']]||g['先攻'])+'):<b>'+esc(g['先攻投手'])+'</b></span>' : '')
776      + (g['後攻投手'] ? '<span>先発('+esc(short[g['後攻']]||g['後攻'])+'):<b>'+esc(g['後攻投手'])+'</b></span>' : '')
777      + '</div>';
778  }
779
780  return '<div class="linescore'+(hasEx?' hasEx':'')+'">' + head
781    + '<div class="scroll"><table><thead><tr><th class="tmcell"></th>'+cols+'<th class="run">R</th></tr></thead><tbody>'
782    + rowFor('先', g['先攻'], g['先攻得点'])
783    + rowFor('後', g['後攻'], g['後攻得点'])
784    + '</tbody></table></div>'
785    + pitchers
786    + (g['備考'] ? '<div class="pitchers">'+esc(g['備考'])+'</div>' : '')
787    + press
788    + '</div>';
789}
790
791/** リンク先がPDFか(Googleドライブに登録したPDFも含みます)2026-09-27 */
792function isPdf(u){
793  var s = String(u||'');
794  return /\.pdf(\?|#|$)/i.test(s) || /drive\.google\.com\/file\/d\//.test(s);
795}
796
797/**
798 * 星取表(大学どうしの対戦結果の一覧)2026-09-27
799 * 加盟校どうしの「試合終了」で得点が入っている試合だけを数えます。
800 * 行の大学から見て ○勝ち ●負け △引き分け。同じ相手との試合は日付順に並べます。
801 * 加盟校以外との試合(全日本選手権など)は含めません。
802 * @param {Array} games 試合
803 * @param {Array} teams 加盟校({name, short, order})
804 * @param {Array} standings 順位表(あれば、その順に並べます)
805 */
806function hoshitori(games, teams, standings){
807  var names = (teams||[]).slice().sort(function(a,b){ return (Number(a.order)||99)-(Number(b.order)||99); })
808    .map(function(t){ return t.name; });
809  var rank = (standings||[]).slice().sort(byRank).map(function(r){ return r['大学']; })
810    .filter(function(n){ return names.indexOf(n) >= 0; });
811  names = rank.concat(names.filter(function(n){ return rank.indexOf(n) < 0; }));
812  var short = {}; (teams||[]).forEach(function(t){ short[t.name] = t.short || t.name; });
813
814  var cell = {}, win = {}, lose = {}, draw = {}, n = 0;
815  names.forEach(function(x){ win[x] = lose[x] = draw[x] = 0; });
816  (games||[]).slice().sort(function(x,y){
817    return String(ymd(x['日付'])).localeCompare(String(ymd(y['日付'])))
818        || String(x['開始時刻']).localeCompare(String(y['開始時刻']));
819  }).forEach(function(g){
820    var A = g['先攻'], B = g['後攻'];
821    if(names.indexOf(A) < 0 || names.indexOf(B) < 0) return;
822    if(g['状態'] === '中止' || g['状態'] === '順延') return;
823    if(!filled(g['先攻得点']) || !filled(g['後攻得点'])) return;
824    // サヨナラの「7x」のような得点も数えます(2026-09-27 修正:以前は数えずに飛ばしていました)
825    var a = runs(g['先攻得点']), b = runs(g['後攻得点']);
826    if(isNaN(a) || isNaN(b)) return;
827    var ra = a > b ? '○' : a < b ? '●' : '△', rb = a > b ? '●' : a < b ? '○' : '△';
828    (cell[A+'|'+B] = cell[A+'|'+B] || []).push(ra);
829    (cell[B+'|'+A] = cell[B+'|'+A] || []).push(rb);
830    if(a > b){ win[A]++; lose[B]++; } else if(a < b){ win[B]++; lose[A]++; } else { draw[A]++; draw[B]++; }
831    n++;
832  });
833  if(!n) return '';
834
835  var mk = function(list){
836    return (list||[]).map(function(m){
837      return '<i class="'+(m==='○'?'w':m==='●'?'l':'d')+'">'+m+'</i>';
838    }).join('');
839  };
840  return '<div class="tw"><table class="hoshi"><thead><tr><th class="l">大学</th>'
841    + names.map(function(x){ return '<th>'+esc(short[x])+'</th>'; }).join('')
842    + '<th>勝</th><th>負</th><th>分</th></tr></thead><tbody>'
843    + names.map(function(x){
844        return '<tr><td class="l">'+esc(short[x])+'</td>'
845          + names.map(function(y){
846              return x === y ? '<td class="self"></td>' : '<td>'+mk(cell[x+'|'+y])+'</td>';
847            }).join('')
848          + '<td class="sm">'+win[x]+'</td><td class="sm">'+lose[x]+'</td><td class="sm">'+draw[x]+'</td></tr>';
849      }).join('')
850    + '</tbody></table></div>'
851    + '<p class="hoshiNote">○勝ち ●負け △引き分け(行の大学から見た結果・試合順)</p>';
852}
853
854/**
855 * 表彰選手の一覧 2026-09-27
856 * @param {Array} list 「表彰」シートの行(入力した順に並べます)
857 * @param {Object} short 大学名 → 略称
858 */
859function awards(list, short){
860  list = (list||[]).filter(function(r){ return String(r['賞']||'').trim() || String(r['氏名']||'').trim(); });
861  if(!list.length) return '';
862  short = short || {};
863  return '<div class="awGrid">' + list.map(function(r){
864    var nm = r['選手ID']
865      ? '<a href="player.html?id='+encodeURIComponent(r['選手ID'])+'">'+esc(r['氏名'])+'</a>'
866      : esc(r['氏名']);
867    return '<div class="awItem"><span class="aw">'+esc(r['賞']||'')+'</span>'
868      + '<span class="nm">'+nm+'</span>'
869      + '<span class="tm">'+esc(short[r['大学']]||r['大学']||'')+(r['備考']?' / '+esc(r['備考']):'')+'</span></div>';
870  }).join('') + '</div>';
871}
872
873/**
874 * PDFの記事をサイトの中で大きく表示します(2026-09-27)
875 * Googleドライブのファイルは、余計なボタンのない「表示専用」の画面で開きます。
876 * data-pdf の付いたリンクを押すと、この画面が開きます。
877 */
878function pdfSrc(u){
879  var m = String(u||'').match(/drive\.google\.com\/(?:file\/d\/|open\?id=|uc\?[^#]*id=)([\w-]{20,})/);
880  return m ? 'https://drive.google.com/file/d/' + m[1] + '/preview' : String(u||'');
881}
882function pdfView(url, title){
883  var box = document.getElementById('pdfView');
884  if(!box){
885    box = document.createElement('div');
886    box.id = 'pdfView';
887    box.innerHTML = '<div class="pv-in" role="dialog" aria-modal="true">'
888      + '<div class="pv-hd"><span class="pv-t"></span>'
889      + '<a class="pv-open" target="_blank" rel="noopener">新しいタブで開く ↗</a>'
890      + '<button type="button" class="pv-x" aria-label="閉じる">閉じる ✕</button></div>'
891      + '<iframe class="pv-f" title="記事PDF" allow="autoplay"></iframe></div>';
892    document.body.appendChild(box);
893    box.addEventListener('click', function(e){ if(e.target === box) closePdf(); });
894    box.querySelector('.pv-x').onclick = closePdf;
895    document.addEventListener('keydown', function(e){ if(e.key === 'Escape') closePdf(); });
896  }
897  box.querySelector('.pv-t').textContent = title || '記事';
898  box.querySelector('.pv-open').href = url;
899  box.querySelector('.pv-f').src = pdfSrc(url);
900  box.classList.add('on');
901  document.documentElement.style.overflow = 'hidden';
902}
903function closePdf(){
904  var box = document.getElementById('pdfView'); if(!box) return;
905  box.classList.remove('on'); box.querySelector('.pv-f').src = 'about:blank';
906  document.documentElement.style.overflow = '';
907}
908/* PDFの記事リンク(data-pdf付き)は、ページを移動せずにこの画面で開きます */
909document.addEventListener('click', function(e){
910  var a = e.target && e.target.closest ? e.target.closest('a[data-pdf]') : null;
911  if(!a || e.ctrlKey || e.metaKey || e.shiftKey) return;
912  e.preventDefault();
913  pdfView(a.getAttribute('href'), a.getAttribute('data-pdf'));
914});
915
916/* 見出し帯の写真(2026-09-29)
917   各ページ上部の見出し帯に、サーバーの img/up/ フォルダの写真を背景として入れます。
918     img/up/ページ名.jpg … そのページだけ(例:過去の大会=img/up/archive.jpg)
919     img/up/common.jpg   … ページごとの写真が無いときに、全ページ共通で使います
920   どちらも無ければ、これまでどおりの見出し帯のままです。 */
921var HEAD_DIR = 'img/up/';
922var HEAD_PAGES = ['archive','player','press','league','records','game','interview'];
923function pageHeadPhoto(){
924  var ph = document.querySelector('.pagehead');
925  var m = location.pathname.match(/([\w-]+)\.html$/);
926  if(!ph || !m || HEAD_PAGES.indexOf(m[1]) < 0) return;
927  var list = [HEAD_DIR + m[1] + '.jpg', HEAD_DIR + 'common.jpg'];
928  (function next(i){
929    if(i >= list.length) return;
930    var im = new Image();
931    im.onload = function(){
932      ph.style.backgroundImage = 'linear-gradient(rgba(18,42,78,.74),rgba(18,42,78,.82)),url("' + list[i] + '")';
933      ph.classList.add('hasPhoto');
934    };
935    im.onerror = function(){ next(i + 1); };
936    im.src = list[i];
937  })(0);
938}
939if(document.readyState === 'loading') document.addEventListener('DOMContentLoaded', pageHeadPhoto);
940else pageHeadPhoto();
941
942/* 公開する関数 */
943return {
944  $: $, esc: esc,
945  load: load, showState: showState, viaJsonp: viaJsonp, buildUrl: buildUrl,
946  bootShow: bootShow, bootHide: bootHide,
947  drawAlert: drawAlert, drawFooter: drawFooter, drawAds: drawAds,
948  initMenu: initMenu, drawGen: drawGen,
949  fdate: fdate, md: md, pct: pct, filled: filled, innings: innings, extras: extras, detailLink: detailLink, pdfUrl: pdfUrl, htime: htime, hround: hround,
950  ymd: ymd, todayYmd: todayYmd,
951  byRank: byRank, bindTabs: bindTabs, lineScore: lineScore,
952  isPdf: isPdf, hoshitori: hoshitori, awards: awards, rate: rate, runs: runs, pdfView: pdfView,
953  get lastError(){ return LASTERR; }
954};
955})();

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.