(v === '' || v == null ? null : +v);
// ---------- data model ----------
const D = {};
function buildData(names) {
const T = {};
for (const n of names) { T[n] = loadCSV(n); if (T[n] === null) return false; }
const m = T.pub_meta[0] || {};
D.meta = m; D.curFY = +m.current_fy_be; D.compM = +m.complete_fy_month_no; D.minG = +m.min_group_size || 10;
D.date = {}; D.fyMonths = {};
for (const r of T.pub_dim_date) D.date[r.month_key] = { fy: +r.fy_be, fm: +r.fy_month_no, mth: r.month_th, lab: r.month_label, ybe: +r.year_be, fq: +r.fy_quarter };
D.res = {}; for (const r of T.pub_dim_resource) D.res[r.resource_code] = r;
D.acq = {}; D.acqList = T.pub_dim_acq.sort((a, b) => a.sort_order - b.sort_order);
for (const r of D.acqList) D.acq[r.acq_group] = r;
D.plat = {}; for (const r of T.pub_dim_platform) D.plat[r.platform_code] = r;
D.group = T.pub_dim_group;
const dm = k => D.date[k] || { fy: 0, fm: 0 };
D.use = T.pub_usage_month.map(r => ({ mk: r.month_key, fy: +r.fy_be, fm: dm(r.month_key).fm, rc: r.resource_code, acq: r.acq_group, use: N(r.use_value) || 0, sea: N(r.search_value), basis: r.value_basis }));
D.cov = T.pub_coverage.map(r => ({ mk: r.month_key, fy: dm(r.month_key).fy, fm: dm(r.month_key).fm, rc: r.resource_code, st: r.status_code, sth: r.status_th }));
D.users = new Map();
for (const r of T.pub_users_period) D.users.set([r.fy_be, r.period_type, r.period_no, r.platform_code, r.breakdown, r.group_code].join('|'), { u: N(r.users), a: N(r.accesses), sup: r.suppressed === 'Y' });
D.usersMaxYTD = {}; for (const r of T.pub_users_period) if (r.period_type === 'YTD' && r.platform_code === 'ALL' && r.breakdown === 'ALL') D.usersMaxYTD[r.fy_be] = Math.max(D.usersMaxYTD[r.fy_be] || 0, +r.period_no);
D.cost = {}; for (const r of T.pub_cost_fy) D.cost[+r.fy_be] = { fy: +r.fy_be, n: +r.n_resources, list: r.resources_in_cost, budget: N(r.budget_thb), mu: +r.months_used, done: r.is_complete_fy === 'Y', use: N(r.use_value), cpu: N(r.cost_per_use), puse: N(r.projected_use), pcpu: N(r.projected_cost_per_use), note: r.price_note };
D.cp = {}; for (const r of (T.pub_cost_period || [])) { const pj = r.is_projected === 'Y';
(D.cp[+r.fy_be] = D.cp[+r.fy_be] || {})[r.period_code] = { fy: +r.fy_be, code: r.period_code, n: +r.n_resources, list: r.resources_in_cost, cost: N(r.cost_thb), use: N(r.use_value), puse: pj ? N(r.use_value) : null, cpu: N(r.cost_per_use), proj: pj }; }
D.subj = T.pub_subject_month.map(r => ({ fy: dm(r.month_key).fy, fm: dm(r.month_key).fm, rc: r.resource_code, sc: r.subject_code, sn: r.subject_name, kf: r.kmutt_field, kfn: r.kmutt_field_name, v: N(r.use_value) || 0 }));
D.scopus = T.pub_scopus_month.map(r => ({ fy: dm(r.month_key).fy, fm: dm(r.month_key).fm, code: r.metric_code, name: r.metric_name, g: r.activity_group, gth: r.activity_group_th, go: +r.group_order, show: r.show_in_chart, v: N(r.value) || 0 }));
D.notes = T.pub_notes.sort((a, b) => (a.month_key < b.month_key ? 1 : -1));
D.firstFY = +(m.public_first_fy || 2564);
const fys = new Set(D.use.map(r => r.fy)); D.fys = [...fys].filter(f => f >= D.firstFY && f <= D.curFY).sort();
return true;
}
// ---------- state ----------
const S = { tab: 'overview', res: new Set(), acq: new Set(), fy: null, trend: 'same', ltype: 'all', lq: '', lsort: { k: 'use', d: -1 }, deep: null, uq: 'Q', cper: 'AUTO', cmeth: 'PA' };
const fy = () => S.fy || D.curFY;
const endM = f => (f === D.curFY ? D.compM : 12);
const MTH = ['', 'ต.ค.', 'พ.ย.', 'ธ.ค.', 'ม.ค.', 'ก.พ.', 'มี.ค.', 'เม.ย.', 'พ.ค.', 'มิ.ย.', 'ก.ค.', 'ส.ค.', 'ก.ย.'];
const yy = n => String(n).slice(-2);
function periodLabel(f, e) { f = f || fy(); e = e || endM(f); return `ต.ค. ${yy(f - 1)} – ${MTH[e]} ${yy(e <= 3 ? f - 1 : f)}`; }
function periodShort(e) { return `ต.ค.–${MTH[e || endM(fy())]}`; }
const passRes = rc => S.res.size === 0 || S.res.has(rc);
const passAcq = a => S.acq.size === 0 || S.acq.has(a);
const pass = r => passRes(r.rc) && passAcq(r.acq);
const filtered = () => S.res.size > 0 || S.acq.size > 0;
function sumUse(f, e, pred = pass, field = 'use') {
let s = 0, any = false;
for (const r of D.use) if (r.fy === f && r.fm <= e && pred(r)) { const v = r[field]; if (v != null) { s += v; any = true; } }
return any ? s : null;
}
const chg = (a, b) => (a != null && b != null && b > 0 ? (a - b) / b : null);
function resAcq(rc, f) {
let g = null, mk = '';
for (const r of D.use) if (r.rc === rc && r.fy === f && r.mk > mk) { mk = r.mk; g = r.acq; }
if (g) return g;
const d = D.res[rc]; if (!d) return null;
if (d.acq_base === 'KMUTT_SUB') return d.include_in_pa === 'Y' ? 'KMUTT_SUB_PRICED' : 'KMUTT_SUB_NOPRICE';
return d.acq_base;
}
const ACQPILL = { KMUTT_SUB_PRICED: ['p-sub', 'บอกรับรายปี'], KMUTT_SUB_NOPRICE: ['p-sub', 'บอกรับรายปี*'], KMUTT_UNCONF: ['p-unc', 'รอยืนยันรูปแบบ'], KMUTT_PERP: ['p-perp', 'ซื้อขาด'], MHESI: ['p-mhesi', 'สป.อว.'] };
const acqPill = g => (ACQPILL[g] ? `${ACQPILL[g][1]}` : '');
const hasUnc = () => Object.values(D.res).some(r => r.acq_base === 'KMUTT_UNCONF'); // the "to be confirmed" group only shows while a resource is in it
const ACQCOL = { KMUTT_SUB_PRICED: '#FA4616', KMUTT_SUB_NOPRICE: '#FDC3AE', KMUTT_UNCONF: '#F4845A', KMUTT_PERP: '#CFA537', MHESI: '#A7ADB4' };
const ACQLG = { KMUTT_SUB_PRICED: 'มจธ. บอกรับรายปี · มีราคา (คิดต้นทุน)', KMUTT_SUB_NOPRICE: 'มจธ. บอกรับรายปี · ยังไม่มีราคา', KMUTT_UNCONF: 'มจธ. รอยืนยันรูปแบบ (Springer eBooks)', KMUTT_PERP: 'มจธ. ซื้อขาด (Nature, 2eLibrary)', MHESI: 'สป.อว. จัดให้ · มจธ. ไม่จ่าย' };
// users: one platform at a time (distinct people cannot be added up across platforms)
function userPlat() {
if (S.res.size === 0) return 'ALL';
const ps = new Set([...S.res].map(rc => (D.res[rc] || {}).oa_platform_code).filter(Boolean));
return ps.size === 1 ? [...ps][0] : null;
}
const platOf = rc => (D.res[rc] || {}).oa_platform_code || null;
const platShared = p => Object.values(D.res).filter(r => r.oa_platform_code === p).length > 1;
function U(f, pt, pn, plat, bd = 'ALL', g = 'ALL') { return D.users.get([f, pt, pn, plat, bd, g].join('|')) || null; }
// coverage status of a resource in a period
function covStatus(rc, f, e) {
let miss = 0, gap = 0, man = 0, pend = 0, nos = 0, nsub = 0;
for (const c of D.cov) if (c.rc === rc && c.fy === f && c.fm <= e) {
if (c.st === 'NOT_RECEIVED') miss++; else if (c.st === 'PENDING') pend++;
else if (c.st === 'VENDOR_GAP' || c.st === 'PARTIAL' || c.st === 'SYSTEM_CHANGE') gap++; else if (c.st === 'NO_SOURCE') nos++; else if (c.st === 'NOT_SUBSCRIBED') nsub++; else if (c.st === 'MANUAL') man++;
}
if (miss) return { cls: 's-miss', t: `ขาด ${miss} เดือน`, lvl: 3 };
if (pend) return { cls: 's-gap', t: `ยังไม่มีช่องทาง ${pend} เดือน`, lvl: 2 };
if (gap) return { cls: 's-gap', t: `ข้อมูลหาย ${gap} เดือน`, lvl: 2 };
if (nos) return { cls: 's-gap', t: `ต้นทางไม่มีข้อมูล ${nos} เดือน`, lvl: 2 };
if (nsub) return { cls: 's-note', t: `ไม่ได้บอกรับ ${nsub} เดือน`, lvl: 1 };
if (man) return { cls: 's-note', t: 'ค่าปรับปรุง', lvl: 1 };
return { cls: 's-ok', t: 'ครบ', lvl: 0 };
}
// ---------- formatting ----------
const fmt = (v, d = 0) => (v == null || isNaN(v) ? '—' : (+v).toLocaleString('en-US', { minimumFractionDigits: d, maximumFractionDigits: d }));
const fmtC = v => (v == null ? '—' : v >= 1e6 ? fmt(v / 1e6, v >= 1e7 ? 0 : 1) + ' ล้าน' : v >= 1e4 ? fmt(v / 1e3, 0) + 'k' : fmt(v));
function pctTxt(p) { return p == null ? '' : (p >= 0 ? '▲ ' : '▼ ') + Math.abs(p * 100).toFixed(1) + '%'; }
function pctSpan(p, bad = false) { // bad=true: going up is the unwanted direction (cost)
if (p == null) return '—';
const cls = bad ? (p > 0 ? 'wr' : 'up') : (p >= 0 ? 'up' : 'dn');
return `${pctTxt(p)}`;
}
function pctPill(p, bad = false) {
if (p == null) return '—';
const cls = bad ? (p > 0 ? 'wr' : 'up') : (p >= 0 ? 'up' : 'dn');
return `${pctTxt(p)}`;
}
const vs = (p, b, unit = '', bad = false) => (p == null ? 'ไม่มีตัวเลขปีก่อนให้เทียบ' : `${pctPill(p, bad)} เทียบ ${fmt(b)}${unit}`);
// tiny trend line for KPI cards (last point highlighted)
function spark(vals, bad = false) {
const v = vals.map(x => (x == null || isNaN(x) ? null : x)); const ok = v.filter(x => x != null); if (ok.length < 2) return '';
const w = 84, h = 30, mx = Math.max(...ok), mn = Math.min(...ok), rg = mx - mn || 1, n = v.length;
const pts = v.map((x, i) => (x == null ? null : [2 + (w - 6) * i / (n - 1), h - 4 - (h - 8) * (x - mn) / rg]));
const d = pts.filter(Boolean).map((p, i) => (i ? 'L' : 'M') + p[0].toFixed(1) + ',' + p[1].toFixed(1)).join('');
const l = pts.filter(Boolean).pop();
return ``;
}
// ---------- tooltip ----------
function initTip() {
const tip = document.createElement('div'); tip.id = 'tip'; document.body.appendChild(tip);
document.addEventListener('mousemove', ev => {
const t = ev.target.closest && ev.target.closest('[data-tip]');
if (!t) { tip.style.display = 'none'; return; }
tip.innerHTML = t.getAttribute('data-tip'); tip.style.display = 'block';
const w = tip.offsetWidth, h = tip.offsetHeight;
let x = ev.clientX + 14, y = ev.clientY + 14;
if (x + w > innerWidth - 8) x = ev.clientX - w - 14;
if (y + h > innerHeight - 8) y = ev.clientY - h - 14;
tip.style.left = x + 'px'; tip.style.top = y + 'px';
});
}
// ---------- SVG charts ----------
const SVGNS = 'http://www.w3.org/2000/svg';
let _pid = 0;
function niceMax(v, n = 4) {
if (!(v > 0)) return { max: 1, step: 1 };
const raw = v / n, p = Math.pow(10, Math.floor(Math.log10(raw))), m = raw / p;
const step = (m <= 1 ? 1 : m <= 2 ? 2 : m <= 2.5 ? 2.5 : m <= 5 ? 5 : 10) * p;
return { max: Math.ceil(v / step) * step, step };
}
const tickFmt = v => (v >= 1e6 ? fmt(v / 1e6, v % 1e6 ? 1 : 0) + 'M' : fmt(v));
function measure(txt, size = 11, weight = 400) {
const c = measure.c || (measure.c = document.createElement('canvas').getContext('2d'));
c.font = `${weight} ${size}px Plex, 'Leelawadee UI', sans-serif`; return c.measureText(txt).width;
}
function leftM(max, title, f) { return Math.ceil(measure((f || tickFmt)(max), 10.5) + (title ? 30 : 12)); }
function svgWrap(w, h, inner, defs = '') { return ``; }
function hatchDef(id, color) { return ``; }
function axesSVG(o) {
// o: {w,h,L,R,T,B,max,step,xTitle,yTitle,yFmt}
const { w, h, L, R, T, B } = o; const ph = h - T - B; let s = '';
for (let v = 0; v <= o.max + 1e-9; v += o.step) {
const y = T + ph - (v / o.max) * ph;
s += ``;
if (o.yAxis !== false) s += `${(o.yFmt || tickFmt)(v)}`;
}
if (o.yTitle) s += `${esc(o.yTitle)}`;
if (o.xTitle) s += `${esc(o.xTitle)}`;
return s;
}
/* stacked / single columns
cfg: {cats:[{k,lab,note}], series:[{k,name,color,hatch}], val:(cat,serKey)=>num, xTitle,yTitle, totals, labelFmt, unit, onClick, active, fade:(cat)=>bool } */
function columns(el, cfg) {
const w = Math.max(260, el.clientWidth), h = cfg.h || el.clientHeight || 300;
const tot = cfg.cats.map(c => cfg.series.reduce((s, se) => s + (cfg.val(c, se.k) || 0), 0));
const { max, step } = niceMax(Math.max(...tot, 1) * 1.02);
const L = cfg.yAxis === false ? 8 : leftM(max, cfg.yTitle), R = 8, T = cfg.totals ? 22 : 14, B = cfg.xTitle ? 38 : 22;
const pw = w - L - R, ph = h - T - B, n = cfg.cats.length, bw = Math.min(64, (pw / n) * 0.62);
let defs = '', s = axesSVG({ w, h, L, R, T, B, max, step, xTitle: cfg.xTitle, yTitle: cfg.yTitle, yAxis: cfg.yAxis });
cfg.series.forEach(se => { if (se.hatch) { se._pid = 'h' + (++_pid); defs += hatchDef(se._pid, se.color); } });
cfg.cats.forEach((c, i) => {
const cx = L + pw * (i + 0.5) / n; let y0 = T + ph;
const op = cfg.fade && cfg.fade(c) ? 0.35 : 1;
const parts = [];
cfg.series.forEach(se => {
const v = cfg.val(c, se.k) || 0; if (v <= 0) return;
const bh = (v / max) * ph; y0 -= bh;
parts.push(`${esc(se.name)}: ${fmt(v)}${cfg.unit || ''}`);
s += ``;
});
const tip = `${esc(c.lab)}${c.note ? ' · ' + esc(c.note) : ''}
` + (cfg.series.length > 1 ? parts.join('
') + `
รวม ${fmt(tot[i])}${cfg.unit || ''}` : parts.join(''));
s += ``;
if (cfg.totals && tot[i] > 0) s += `${(pw / n < 62 && tot[i] >= 1e4 ? fmtC : (cfg.labelFmt || fmt))(tot[i])}`;
const act = cfg.active && cfg.active(c);
s += `${esc(c.lab)}`;
});
el.innerHTML = svgWrap(w, h, s, defs);
if (cfg.onClick) $$('[data-i]', el).forEach(r => r.addEventListener('click', () => cfg.onClick(cfg.cats[+r.dataset.i])));
}
/* lines: cfg {cats:[{k,lab}], lines:[{name,color,vals:[], dashFrom:idx|null, dash:bool, labels:bool, width}], xTitle,yTitle, unit, yFmt, labelFmt} */
function lines(el, cfg) {
const w = Math.max(240, el.clientWidth), h = cfg.h || el.clientHeight || 260;
const all = cfg.lines.flatMap(l => l.vals).filter(v => v != null);
const { max, step } = niceMax(Math.max(...all, 1) * 1.08);
const L = leftM(max, cfg.yTitle, cfg.yFmt), R = 14, T = 18, B = cfg.xTitle ? 38 : 22;
const pw = w - L - R, ph = h - T - B, n = cfg.cats.length;
const ins = n > 1 ? 16 : 0, X = i => L + ins + (n === 1 ? pw / 2 : (pw - 2 * ins) * i / (n - 1)), Y = v => T + ph - (v / max) * ph;
let s = axesSVG({ w, h, L, R, T, B, max, step, xTitle: cfg.xTitle, yTitle: cfg.yTitle, yFmt: cfg.yFmt });
cfg.cats.forEach((c, i) => { s += `${esc(c.lab)}`; });
cfg.lines.forEach(l => {
const pts = l.vals.map((v, i) => (v == null ? null : [X(i), Y(v)]));
const seg = (a, b, dash) => { let d = ''; for (let i = a; i <= b; i++) if (pts[i]) d += (d ? 'L' : 'M') + pts[i][0].toFixed(1) + ',' + pts[i][1].toFixed(1); return d ? `` : ''; };
const df = l.dashFrom == null ? n : l.dashFrom;
s += seg(0, Math.min(df, n - 1), !!l.dash);
if (df < n - 1) s += seg(df, n - 1, true);
pts.forEach((p, i) => {
if (!p) return; const proj = i > df || (l.dashFrom != null && i === n - 1 && df === n - 1 && l.projLast);
s += ``;
const pv = l.vals[i - 1], nv = l.vals[i + 1], v0 = l.vals[i], below = pv != null && pv > v0 && (nv == null || nv < v0);
if (l.labels) s += `${(cfg.labelFmt || fmt)(l.vals[i])}${proj ? '*' : ''}`;
});
});
cfg.cats.forEach((c, i) => {
const tip = `${esc(c.lab)}` + cfg.lines.map(l => `
${esc(l.name)}: ${(cfg.labelFmt || fmt)(l.vals[i])}${cfg.unit || ''}`).join('') + (c.note ? '
' + esc(c.note) : '');
const x0 = n === 1 ? L : X(i) - pw / (n - 1) / 2;
s += ``;
});
el.innerHTML = svgWrap(w, h, s);
}
/* bars + line on a 2nd axis (budget & cost per use) */
function combo(el, cfg) {
const w = Math.max(300, el.clientWidth), h = cfg.h || el.clientHeight || 360;
const L = 52, R = 56, T = 24, B = 38;
const b = niceMax(Math.max(...cfg.bars.vals.filter(v => v != null), 1) * 1.1), l = niceMax(Math.max(...cfg.line.vals.filter(v => v != null), 1) * 1.1);
const pw = w - L - R, ph = h - T - B, n = cfg.cats.length, bw = Math.min(50, pw / n * 0.58);
const Yb = v => T + ph - (v / b.max) * ph, Yl = v => T + ph - (v / l.max) * ph, X = i => L + pw * (i + 0.5) / n;
const pid = 'h' + (++_pid);
let s = axesSVG({ w, h, L, R, T, B, max: b.max, step: b.step, xTitle: cfg.xTitle, yTitle: cfg.y1, yFmt: v => fmt(v, b.step < 1 ? 1 : 0) });
for (let v = 0; v <= l.max + 1e-9; v += l.step) s += `${fmt(v)}`;
s += `${esc(cfg.y2)}`;
cfg.cats.forEach((c, i) => {
const v = cfg.bars.vals[i]; if (v != null) s += ``;
s += `${esc(c.lab)}`;
});
const pts = cfg.line.vals.map((v, i) => (v == null ? null : [X(i), Yl(v)]));
let d1 = '', d2 = '';
pts.forEach((p, i) => { if (!p) return; if (!cfg.cats[i].proj) d1 += (d1 ? 'L' : 'M') + p[0] + ',' + p[1]; });
const lastSolid = pts.map((p, i) => (p && !cfg.cats[i].proj ? i : -1)).filter(i => i >= 0).pop();
pts.forEach((p, i) => { if (p && cfg.cats[i].proj && lastSolid != null) d2 = `M${pts[lastSolid][0]},${pts[lastSolid][1]}L${p[0]},${p[1]}`; });
s += `` + (d2 ? `` : '');
pts.forEach((p, i) => { if (!p) return; const pr = cfg.cats[i].proj;
s += ``;
s += `฿${fmt(cfg.line.vals[i])}${pr ? '*' : ''}`; });
cfg.cats.forEach((c, i) => { s += ``; });
el.innerHTML = svgWrap(w, h, s, ``);
}
// ---------- HTML mini charts ----------
function hbars(items, o = {}) {
// items [{l, v, segs:[{v,c,n}], sub, tip}]
const mx = Math.max(...items.map(i => i.v), 1);
return `` + items.map(it => {
const segs = it.segs || [{ v: it.v, c: o.color || '#FA4616' }];
const bars = segs.map(sg => `
' + fmt(sg.v) + '' + (o.unit || ''))}"` : ''}>`).join('');
return `
${esc(it.l)}
${bars}
${fmt(it.v)}${it.sub ? `${it.sub}` : ''}
`;
}).join('') + `
`;
}
function pctbar(items, unit = ' คน') {
const tot = items.reduce((s, i) => s + (i.v || 0), 0) || 1;
const bar = `` + items.filter(i => i.v > 0).map(i => ``).join('') + `
`;
const lg = `` + items.map(i => `${esc(i.l)} ${i.v == null ? '<10' : Math.round(i.v / tot * 100) + '%'} (${i.v == null ? 'ไม่แสดง' : fmt(i.v) + unit})`).join('') + `
`;
return bar + lg;
}
// charts are drawn after layout, and redrawn when their box changes size
const DRAW = new Map();
const RO = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(es => { for (const e of es) { const f = DRAW.get(e.target); if (f && e.target.isConnected) f(); } }) : null;
function mount(el, fn) { if (!el) return; DRAW.set(el, () => fn(el)); fn(el); if (RO) RO.observe(el); }
function download(name, text) {
const blob = new Blob(['' + text.replace(/^/, '')], { type: 'text/csv;charset=utf-8' });
const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 500);
}
const csvCell = v => (/[",\n]/.test(String(v)) ? '"' + String(v).replace(/"/g, '""') + '"' : String(v));
/* ===== pages (public) ===== */
'use strict';
const TABS = [['overview', 'ภาพรวม'], ['trend', 'แนวโน้ม'], ['lookup', 'ตัวเลขรายฐาน'], ['deep', 'เจาะลึกรายฐาน'], ['value', 'ความคุ้มค่า'], ['users', 'ผู้ใช้'], ['subjects', 'สาขาวิชา'], ['method', 'ที่มาและนิยาม']];
// fixed method notes (edit here when the counting method changes)
const METHOD_CHANGES = [
['ต.ค. 68', 'เริ่มระบบรวบรวมข้อมูลใหม่ — ตัวเลขก่อนหน้านี้ใช้ค่าที่ล็อกจากสมุดสถิติเดิม'],
['เม.ย. 68', 'ผู้ใช้ OpenAthens นับจากรหัสสมาชิก (ก่อนหน้านับจากชื่อบัญชี) — เทียบรายไตรมาสด้วยวิธีเดียวกัน'],
['ก.พ. 68', 'ASME เปลี่ยนแพลตฟอร์ม — การใช้ช่วงรอยต่ออาจต่ำกว่าจริง'],
];
const card = (inner, cls = '', style = '') => ``;
const head = (t, sub, right = '') => `${t}
${sub ? `
${sub}
` : ''}
${right}
`;
const go = (tab, label) => ``;
const acqLegend = keys => `` + keys.map(k => `${ACQLG[k]}`).join('') + `
`;
const ACQ_ORDER = ['KMUTT_SUB_PRICED', 'KMUTT_SUB_NOPRICE', 'KMUTT_UNCONF', 'KMUTT_PERP', 'MHESI'];
const acqSeries = () => ACQ_ORDER.filter(k => passAcq(k) && D.use.some(r => r.acq === k && r.fy >= D.firstFY && passRes(r.rc) && r.use > 0)).map(k => ({ k, name: ACQLG[k], color: ACQCOL[k], hatch: k === 'KMUTT_UNCONF' }));
function fyCats(from) { return D.fys.filter(f => f >= from).map(f => ({ k: f, lab: String(f) })); }
const filterNote = () => (filtered() ? `ตามตัวกรอง` : '');
const mkLabel = mk => (D.date[mk] ? D.date[mk].lab : mk);
// ---------------------------------------------------------------- overview
function pgOverview() {
const f = fy(), e = endM(f);
const a = sumUse(f, e), b = sumUse(f - 1, e);
const kp = r => pass(r) && (D.res[r.rc] || {}).funding_source === 'KMUTT';
const ka = sumUse(f, e, kp), kb = sumUse(f - 1, e, kp);
const ks = r => kp(r) && r.rc !== 'SPRINGER_B';
const ksp = chg(sumUse(f, e, ks), sumUse(f - 1, e, ks));
const pp = r => pass(r) && r.acq === 'KMUTT_SUB_PRICED';
const pa = sumUse(f, e, pp), pb = sumUse(f - 1, e, pp);
const plat = userPlat();
const ua = plat && U(f, 'YTD', e, plat), ub = plat && U(f - 1, 'YTD', e, plat);
const q = Math.ceil(e / 3), qa = plat && U(f, 'Q', q, plat), qb = plat && U(f - 1, 'Q', q, plat);
const qp = qa && qb ? chg(qa.u, qb.u) : null;
const cfy = Math.max(...Object.values(D.cost).filter(c => c.done && c.fy <= f).map(c => c.fy));
const cc = D.cost[cfy], cpc = cpCode(), ca = cpAt(f, cpc, 'PA'), cb = cpAt(f - 1, cpc, 'PA');
const uVal = !plat ? '—' : ua && ua.u != null ? fmt(ua.u) : ua && ua.sup ? '<10' : '—';
const K = [
['ใช้มากขึ้นหรือน้อยลง', 'การใช้ทุกฐาน', fmt(a), 'ครั้ง', vs(chg(a, b), b), filtered() ? 'ตามตัวกรองที่เลือก' : `${periodLabel(f, e)}`],
['ฐานที่หอสมุดจ่ายเอง', 'บอกรับ + ซื้อขาด' + (hasUnc() ? ' + รอยืนยัน' : ''), fmt(ka), 'ครั้ง', vs(chg(ka, kb), kb), ksp == null ? '' : `ไม่รวม Springer eBooks ${pctSpan(ksp)}`],
['ตัวชี้วัด PA', `${cc ? cc.n : 6} ฐานบอกรับรายปีที่มีราคา`, fmt(pa), 'ครั้ง', vs(chg(pa, pb), pb), ''],
['ใช้กี่คน', `ผู้ใช้ไม่ซ้ำ OpenAthens${plat && plat !== 'ALL' ? ' · ' + esc((D.plat[plat] || {}).platform_name || plat) : ''}`, uVal, 'คน',
!plat ? 'เลือกฐานที่อยู่แพลตฟอร์มเดียวกันเพื่อดูผู้ใช้' : vs(ua && ub ? chg(ua.u, ub.u) : null, ub && ub.u),
qp == null ? (S.acq.size ? 'ผู้ใช้ไม่แยกตามรูปแบบการได้มา' : '') : `ไตรมาสล่าสุด (วิธีนับเดียวกัน) ${pctSpan(qp)}`],
['คุ้มค่าไหม', `ต้นทุนต่อการใช้ ${ca ? ca.n : ''} ฐาน${useInt() ? ' (ไม่รวม Scopus)' : ''}`, ca ? '฿' + fmt(ca.cpu, 1) : '—', 'บาท/ครั้ง',
ca && cb ? `${CPER[ca.code][0]} ปีงบ ${f} · ${pctSpan(chg(ca.cpu, cb.cpu), true)} จาก ${f - 1}` : ca ? `${CPER[ca.code][0]} ปีงบ ${f}` : '',
ca && ca.proj ? 'คาดการณ์ ไม่ขึ้นกับตัวกรอง' : 'ไม่ขึ้นกับตัวกรอง'],
];
const ys = D.fys.filter(y => y >= D.firstFY && y <= f);
const SP = [ys.map(y => sumUse(y, e)), ys.map(y => sumUse(y, e, kp)), ys.map(y => sumUse(y, e, pp)), ys.map(y => { const r = plat && U(y, 'YTD', e, plat); return r && r.u; }), cpYears().filter(y => y <= f).map(y => (cpAt(y, cpc, 'PA') || {}).cpu)];
const kpis = K.map((k, i) => card(`${k[0]}
${k[1]} (${k[3]})
${k[2]}${k[3]}
${spark(SP[i])}
${k[4]}
${k[5] ? `
${k[5]}
` : ''}
`)).join('');
const lastY = D.usersMaxYTD[D.curFY];
const html = `
${kpis}
${card(head(`การใช้ ${periodShort(D.compM)} ของทุกปีงบ (ครั้ง)${filterNote()}`, 'แยกรูปแบบการได้มา · ไม่นำการใช้ของกลุ่มอื่นมาเฉลี่ยต้นทุน · คลิกแท่งเพื่อเลือกปี', go('trend', 'ดูแนวโน้ม')) + `
` + acqLegend(acqSeries().map(s => s.k)))}
${card(head('ต้นทุนต่อการใช้ (บาท/ครั้ง)', `${ca ? ca.n : ''} ฐาน${useInt() ? ' ไม่รวม Scopus' : ''} · ${cpLab(cpc)} · ทุกปีช่วงเดียวกัน`, go('value', 'ความคุ้มค่า')) + `
ต้นทุนต่อการใช้ = ค่าใช้จ่ายของช่วง ÷ การใช้ช่วงเดียวกัน (แบบรายงาน PA) · เลือกช่วงอื่นได้ที่หน้าความคุ้มค่า
`)}
${card(head('ผู้ใช้ไม่ซ้ำ (คน)', `ทั้งปีงบ · * ${D.curFY} ถึง ${MTH[lastY] || ''}`, go('users', 'ผู้ใช้')) + `
ใครใช้ · ${periodShort(e)} ${yy(f)}
`)}
${card(``, '', 'margin-top:12px;padding:10px 18px')}`;
return { html, after: () => {
mount($('#c_stack'), el => columns(el, { cats: fyCats(D.firstFY).map(c => ({ ...c, note: periodShort(D.compM) })), series: acqSeries(), val: (c, k) => sumUse(c.k, D.compM, r => pass(r) && r.acq === k), totals: true, xTitle: `ปีงบประมาณ (พ.ศ.) · ช่วง ${periodShort(D.compM)}`, yTitle: 'การใช้ (ครั้ง)', unit: ' ครั้ง', active: c => c.k === f, onClick: c => setFY(c.k) }));
const cf = cpYears().filter(y => cpAt(y, cpc, 'PA')), pj = cf.findIndex(y => cpAt(y, cpc, 'PA').proj);
mount($('#c_cost'), el => lines(el, { cats: cf.map(y => ({ k: y, lab: yy(y), note: cpLab(cpAt(y, cpc, 'PA').code) })), lines: [{ name: 'ต้นทุนต่อการใช้', color: '#1F2124', vals: cf.map(y => cpAt(y, cpc, 'PA').cpu), dashFrom: pj - 1 < 0 ? null : pj - 1, labels: true, projLast: pj >= 0 }], xTitle: 'ปีงบประมาณ (พ.ศ. 25xx)', yTitle: 'บาท/ครั้ง', unit: ' บาท/ครั้ง', labelFmt: v => '฿' + fmt(v) }));
const uf = Object.keys(D.usersMaxYTD).map(Number).sort();
const up = userPlat() || 'ALL';
mount($('#c_users'), el => columns(el, { cats: uf.map(y => ({ k: y, lab: y === D.curFY ? y + '*' : String(y), note: y === D.curFY ? 'ถึง ' + MTH[D.usersMaxYTD[y]] : 'ทั้งปีงบ' })), series: [{ k: 'u', name: 'ผู้ใช้ไม่ซ้ำ', color: '#1F2124' }], val: c => { const r = U(c.k, 'YTD', D.usersMaxYTD[c.k], up); return r && r.u; }, totals: true, yAxis: false, xTitle: 'ปีงบประมาณ (พ.ศ.) · ผู้ใช้ (คน)', unit: ' คน', fade: c => c.k === D.curFY, h: 190 }));
$('#c_who').innerHTML = whoBar(f, e, up, 'TYPE');
$('#insights').innerHTML = insights(f, e);
} };
}
const TYPECOL = { STUDENT: '#FA4616', STAFF: '#1F2124', OTHER: '#C4C7CC' };
const DEGCOL = { '05': '#1F2124', '07': '#FA4616', '08': '#CFA537', OTHER: '#C4C7CC' };
function whoBar(f, e, plat, bd) {
if (!plat) return 'เลือกฐานที่อยู่แพลตฟอร์มเดียวกันเพื่อดูว่าใครใช้
';
const gs = D.group.filter(g => g.breakdown === bd).sort((a, b) => a.sort_order - b.sort_order);
let hidden = 0;
const items = gs.map(g => { const r = U(f, 'YTD', e, plat, bd, g.group_code); if (r && r.sup) hidden++; return { l: g.group_name_th, v: r ? r.u : null, c: (bd === 'TYPE' ? TYPECOL : DEGCOL)[g.group_code] || '#C4C7CC', sup: r && r.sup }; }).filter(i => i.v != null || i.sup);
if (!items.length) return 'ไม่มีข้อมูลผู้ใช้ในช่วงนี้
';
return pctbar(items.filter(i => i.v != null)) + (hidden ? `กลุ่มที่น้อยกว่า ${D.minG} คนไม่แสดงตัวเลข
` : '');
}
function insights(f, e) {
const out = [];
const per = {};
for (const r of D.use) if (pass(r) && (r.fy === f || r.fy === f - 1) && r.fm <= e) { const o = per[r.rc] || (per[r.rc] = { a: 0, b: 0 }); if (r.fy === f) o.a += r.use; else o.b += r.use; }
const arr = Object.entries(per).map(([rc, o]) => ({ rc, ...o, p: chg(o.a, o.b), n: (D.res[rc] || {}).resource_name || rc }));
const top = arr.sort((x, y) => y.a - x.a)[0];
if (top) out.push(`${esc(top.n)} ${pctSpan(top.p)} · ฐานที่ใช้มากที่สุด (${fmt(top.a)} ครั้ง)`);
const paDown = arr.filter(x => resAcq(x.rc, f) === 'KMUTT_SUB_PRICED' && x.p != null && x.p <= -0.1).sort((x, y) => x.p - y.p).slice(0, 3);
if (paDown.length) out.push(`ฐาน PA ที่ลดลง ` + paDown.map(x => `${esc(x.n.split(' (')[0])} ${pctSpan(x.p)}`).join(' · ') + ``);
const plat = userPlat(); const q = Math.ceil(e / 3);
if (plat) { const qa = U(f, 'Q', q, plat), qb = U(f - 1, 'Q', q, plat); if (qa && qb && qa.u && qb.u) out.push(`ผู้ใช้ไตรมาส ${q} (วิธีนับเดียวกัน) ${pctSpan(chg(qa.u, qb.u))}`); }
const miss = Object.keys(D.res).filter(rc => passRes(rc) && covStatus(rc, f, e).lvl === 3).map(rc => D.res[rc].resource_name.split(' (')[0]);
if (miss.length) out.push(`ข้อมูลยังไม่ครบ ${esc(miss.slice(0, 4).join(' · '))}${miss.length > 4 ? ` และอีก ${miss.length - 4} ฐาน` : ''}`);
return `เรื่องที่ควรรู้` + out.join('');
}
// ---------------------------------------------------------------- trend
function pgTrend() {
const f = fy(), e = endM(f), full = S.trend === 'full';
const rows = ACQ_ORDER.filter(passAcq).map(k => { const a = sumUse(f, e, r => pass(r) && r.acq === k), b = sumUse(f - 1, e, r => pass(r) && r.acq === k); return { k, a, b, p: chg(a, b) }; }).filter(r => r.a || r.b);
const tb = `| รูปแบบ | การใช้ (ครั้ง) | ±ปีก่อน | คิดต้นทุน |
` +
rows.map(r => `| ${ACQLG[r.k]} | ${fmt(r.a)} | ${pctSpan(r.p)} | ${D.acq[r.k] && D.acq[r.k].in_cost === 'Y' ? 'ใช่' : 'ไม่'} |
`).join('') + `
`;
const restated = D.meta.data_note ? `| ${esc(D.meta.restated_on || '')} | ปรับโครงสร้างข้อมูล: ${esc(D.meta.data_note)} |
` : '';
const notes = restated + METHOD_CHANGES.filter(m => !(D.meta.data_note && m[0] === 'ต.ค. 68')).map(m => `| ${m[0]} | ${esc(m[1])} |
`).join('') +
D.notes.filter(n => n.resource_code !== 'ALL' && passRes(n.resource_code)).map(n => `| ${n.month_key ? mkLabel(n.month_key) : esc((D.res[n.resource_code] || {}).resource_name || n.resource_code)} | ${esc(n.note)} |
`).join('');
const html = `
${card(head(`การใช้${full ? 'ทั้งปีงบ' : ' ' + periodShort(D.compM) + ' ของทุกปีงบ'} แยกรูปแบบการได้มา (ครั้ง)${filterNote()}`, full ? `ปีงบ ${D.curFY} ยังไม่จบ (ถึง ${MTH[D.compM]}) — ใช้โหมด "ช่วงเดียวกัน" เมื่อต้องการเทียบกันอย่างยุติธรรม` : 'เทียบเฉพาะเดือนที่ปีล่าสุดมีข้อมูลครบ จึงเทียบข้ามปีได้ตรง',
`
`) +
`
` + acqLegend(acqSeries().map(s => s.k)))}
${card(head(`แยกตามรูปแบบ · ${periodLabel(f, e)}`, 'ไม่เอาการใช้ของกลุ่มอื่นมาเฉลี่ยต้นทุน · ± เทียบช่วงเดียวกันปีก่อน') + tb)}
${card(head('จุดที่วิธีนับเปลี่ยน / หมายเหตุ', '') + `
`)}
`;
return { html, after: () => {
const cats = fyCats(D.firstFY).map(c => ({ ...c, lab: full && c.k === D.curFY ? c.k + '*' : c.lab, note: full ? (c.k === D.curFY ? 'ถึง ' + MTH[D.compM] : 'ทั้งปีงบ') : periodShort(D.compM) }));
mount($('#c_trend'), el => columns(el, { cats, series: acqSeries(), val: (c, k) => sumUse(c.k, full ? 12 : D.compM, r => pass(r) && r.acq === k), totals: true, xTitle: `ปีงบประมาณ (พ.ศ.)${full ? '' : ' · ช่วง ' + periodShort(D.compM)}`, yTitle: 'การใช้ (ครั้ง)', unit: ' ครั้ง', active: c => c.k === f, onClick: c => setFY(c.k), fade: c => full && c.k === D.curFY }));
$$('[data-trend]').forEach(b => b.onclick = () => { S.trend = b.dataset.trend; render(); });
} };
}
// ---------------------------------------------------------------- lookup
function lookupRows(f, e) {
const per = {};
for (const r of D.use) if ((r.fy === f || r.fy === f - 1) && r.fm <= e && pass(r)) {
const o = per[r.rc] || (per[r.rc] = { a: 0, b: 0, sa: 0, sb: 0, hs: false });
if (r.fy === f) { o.a += r.use; if (r.sea != null) { o.sa += r.sea; if (r.sea > 0) o.hs = true; } } else { o.b += r.use; if (r.sea != null) { o.sb += r.sea; if (r.sea > 0) o.hs = true; } }
}
return Object.entries(per).filter(([rc, o]) => o.a > 0 || o.b > 0).map(([rc, o]) => {
const d = D.res[rc] || {}, p = platOf(rc), u = p ? U(f, 'YTD', e, p) : null;
const hs = o.hs && searchInfo(rc).show;
return { rc, name: d.resource_name || rc, ct: d.content_type_th || '', acq: resAcq(rc, f), use: o.a, up: chg(o.a, o.b), sea: hs ? o.sa : null, sp: hs && o.sa > 0 ? chg(o.sa, o.sb) : null, users: u ? u.u : null, usup: u && u.sup, shared: p && platShared(p), st: covStatus(rc, f, e) };
});
}
function pgLookup() {
const f = fy(), e = endM(f);
let rows = lookupRows(f, e);
const types = [...new Set(rows.map(r => r.ct))].filter(Boolean).sort();
if (S.ltype !== 'all') rows = rows.filter(r => r.ct === S.ltype);
if (S.lq) rows = rows.filter(r => r.name.toLowerCase().includes(S.lq.toLowerCase()));
const k = S.lsort.k, dsc = S.lsort.d;
rows.sort((x, y) => { const a = x[k], b = y[k]; if (a == null && b == null) return 0; if (a == null) return 1; if (b == null) return -1; return (typeof a === 'string' ? a.localeCompare(b, 'th') : a - b) * dsc; });
const cols = [['name', 'ฐานข้อมูล', ''], ['acq', 'รูปแบบ', ''], ['use', 'การใช้ (ครั้ง)', 'r'], ['up', '±ปีก่อน', 'r'], ['sea', 'การค้น/เข้าดู (ครั้ง)', 'r'], ['sp', '±ปีก่อน', 'r'], ['users', 'ผู้ใช้ (คน)', 'r'], ['st', 'ข้อมูลในช่วง', '']];
const th = cols.map(c => `${c[1]} ${k === c[0] ? `${dsc < 0 ? '▼' : '▲'}` : ''} | `).join('');
const tot = rows.reduce((s, r) => s + r.use, 0);
const totB = rows.reduce((s, r) => s + (r.up != null ? r.use / (1 + r.up) : 0), 0);
const body = rows.map(r => `| ${esc(r.name)} | ${acqPill(r.acq)} | ${fmt(r.use)} | ${pctSpan(r.up)} | ${r.sea == null ? '—' : fmt(r.sea)} | ${r.sea == null ? '' : pctSpan(r.sp)} | ${r.users != null ? fmt(r.users) + (r.shared ? '†' : '') : r.usup ? '<10' : '—'} | ${r.st.t} |
`).join('');
const segs = [['all', 'ทั้งหมด'], ...types.map(t => [t, t])].map(([k2, l]) => ``).join('');
const html = card(head(`ทุกฐาน · ${periodLabel(f, e)}${filterNote()}`, 'คลิกชื่อฐาน = เจาะลึกรายฐาน · คลิกหัวคอลัมน์เพื่อเรียง · ± เทียบช่วงเดียวกันปีก่อน · * บอกรับรายปี ยังไม่มีราคาในระบบ · † ผู้ใช้นับรวมทั้งแพลตฟอร์ม (เช่น Scopus + ScienceDirect)',
``) +
`${th}
${body || `| ไม่มีฐานที่ตรงกับตัวกรอง |
`}
| รวม ${rows.length} ฐาน | | ${fmt(tot)} | ${pctSpan(chg(tot, totB))} | |
`);
return { html, after: () => {
$$('[data-sort]').forEach(h => h.onclick = () => { const kk = h.dataset.sort; S.lsort = { k: kk, d: S.lsort.k === kk ? -S.lsort.d : (kk === 'name' ? 1 : -1) }; render(); });
$$('[data-lt]').forEach(b => b.onclick = () => { S.ltype = b.dataset.lt; render(); });
$$('tbody tr[data-rc]').forEach(tr => tr.onclick = () => { S.deep = tr.dataset.rc; setTab('deep'); });
const inp = $('#lq'); inp.oninput = () => { S.lq = inp.value; const pos = inp.selectionStart; render(); const i2 = $('#lq'); i2.focus(); i2.setSelectionRange(pos, pos); };
$('#lexp').onclick = () => {
const h = ['ฐานข้อมูล', 'รูปแบบ', 'การใช้ (ครั้ง)', 'เปลี่ยนแปลงจากปีก่อน (%)', 'การค้น/เข้าดู (ครั้ง)', 'เปลี่ยนแปลงจากปีก่อน (%)', 'ผู้ใช้ (คน)', 'ข้อมูลในช่วง'];
const lines = [h.join(',')].concat(rows.map(r => [r.name, (ACQPILL[r.acq] || ['', ''])[1], r.use, r.up == null ? '' : (r.up * 100).toFixed(1), r.sea ?? '', r.sp == null ? '' : (r.sp * 100).toFixed(1), r.users ?? (r.usup ? '<10' : ''), r.st.t].map(csvCell).join(',')));
download(`ตัวเลขรายฐาน_ปีงบ${f}_${MTH[e]}.csv`, lines.join('\r\n'));
};
} };
}
// ---------------------------------------------------------------- deep dive
// document types reported by standards platforms (shown instead of subjects)
const DOCTYPE_TH = { 'ASTM-ACTIVE': 'มาตรฐานฉบับปัจจุบัน (Active)', 'ASTM-REDLINE': 'ฉบับเทียบการแก้ไข (Redline)', 'ASTM-HISTORICAL': 'มาตรฐานฉบับเก่า (Historical)', 'ASTM-WITHDRAWN': 'มาตรฐานที่ยกเลิกแล้ว (Withdrawn)', 'ASTM-JOURNALS': 'วารสารของ ASTM (Journals)' };
function ensureDeep() {
const f = fy(), e = endM(f);
if (!S.deep || !D.res[S.deep]) {
if (S.res.size === 1) S.deep = [...S.res][0];
else { const rows = lookupRows(f, e).sort((a, b) => b.use - a.use); S.deep = rows.length ? rows[0].rc : Object.keys(D.res)[0]; }
}
}
// search/views are shown only when the provider reports them in a meaningful amount (not for e.g. standards platforms where people open documents directly)
function searchInfo(rc) {
const yrs = D.fys.filter(y => y >= D.firstFY);
const su = yrs.map(y => sumUse(y, 12, r => r.rc === rc, 'sea')), us = yrs.map(y => sumUse(y, 12, r => r.rc === rc));
const S1 = su.reduce((a, b) => a + (b || 0), 0), U1 = us.reduce((a, b) => a + (b || 0), 0);
if (!S1) return { show: false, why: 'ผู้ให้บริการไม่รายงานการค้น/เข้าดูของฐานนี้ มีเฉพาะการใช้งานจริง จึงแสดงเฉพาะการใช้' };
const ratio = S1 / (U1 || 1);
if (ratio < 0.05) return { show: false, why: `การค้น/เข้าดูมีน้อยมาก (ราว ${(ratio * 100).toFixed(1)}% ของการใช้) เพราะผู้ใช้เปิดเอกสารได้โดยตรงโดยไม่ต้องค้น จึงไม่แสดงเป็นกราฟ` };
return { show: true };
}
function pgDeep() {
const f = fy(), e = endM(f);
ensureDeep();
const rc = S.deep, d = D.res[rc], isSc = rc === 'SCOPUS';
const si = searchInfo(rc);
const one = r => r.rc === rc;
const a = sumUse(f, e, one), b = sumUse(f - 1, e, one);
const sa = sumUse(f, e, one, 'sea'), sb = sumUse(f - 1, e, one, 'sea');
const p = platOf(rc), ua = p && U(f, 'YTD', e, p), ub = p && U(f - 1, 'YTD', e, p), sh = p && platShared(p);
const pn = p && D.plat[p] ? D.plat[p].platform_name : '';
const uTxt = ua && ua.u != null ? fmt(ua.u) : ua && ua.sup ? '<10' : '—';
const K = [
[isSc ? 'การใช้หลัก: Informetrics (ครั้ง)' : 'การใช้ (ครั้ง)', fmt(a), '', vs(chg(a, b), b)],
[isSc ? 'การค้นหา (ครั้ง)' : 'การค้น/เข้าดู (ครั้ง)', si.show && sa ? fmt(sa) : '—', '', si.show && sa ? vs(chg(sa, sb), sb) : 'ไม่นำเสนอสำหรับฐานนี้ (ดูหมายเหตุ)'],
[`ผู้ใช้ไม่ซ้ำ (คน)`, uTxt + (sh ? '†' : ''), '', ua && ub && ua.u && ub.u ? vs(chg(ua.u, ub.u), ub.u) : sh ? `† รวมทั้งแพลตฟอร์ม ${esc(pn)}` : 'ไม่มีข้อมูลผ่าน OpenAthens'],
['เข้าใช้ต่อคน', ua && ua.u ? fmt(ua.a / ua.u, 1) : '—', 'ครั้ง/คน', ua && ua.a ? `เข้าผ่าน OpenAthens ${fmt(ua.a)} ครั้ง` : ''],
];
if (CFG.internal && typeof deepCost === 'function') { const x = deepCost(rc, f); if (x) K.push(x); }
const kp = K.map(k => card(`${k[0]}
${k[1]}${k[2] ? `${k[2]}` : ''}
${k[3]}
`)).join('');
const notes = D.notes.filter(n => n.resource_code === rc);
// left card
let left;
if (isSc) {
const grp = {};
for (const r of D.scopus) if (r.show === 'Y' && r.fm <= e && (r.fy === f || r.fy === f - 1)) { const k = r.code; const o = grp[k] || (grp[k] = { n: r.name, g: r.gth, go: r.go, a: 0, b: 0 }); if (r.fy === f) o.a += r.v; else o.b += r.v; }
const items = Object.values(grp).filter(o => o.a > 0 || o.b > 0).sort((x, y) => x.go - y.go || y.a - x.a);
const mx = Math.max(...items.map(i => Math.max(i.a, i.b)), 1);
let cur = '', h = '';
items.slice(0, 40).forEach(i => {
if (i.g !== cur) { cur = i.g; h += `${esc(cur)}
`; }
h += `${esc(i.n)}${fmt(i.a)} ${pctSpan(chg(i.a, i.b))}
`;
});
left = card(head(`กิจกรรมบน Scopus · ${periodShort(e)}`, `หน่วย: ครั้ง · แท่งบน = ${f} แท่งล่าง = ${f - 1}`) + ``);
} else {
const sm = {}; let tot = 0;
for (const r of D.subj) if (r.rc === rc && r.fy === f && r.fm <= e) { tot += r.v; if (r.sc !== 'NO_DATA' && !/NO_DATA$/.test(r.sc)) { const l = DOCTYPE_TH[r.sc] || r.sn; sm[l] = (sm[l] || 0) + r.v; } }
const it = Object.entries(sm).sort((x, y) => y[1] - x[1]).slice(0, 12).map(([l, v]) => ({ l, v, sub: tot ? Math.round(v / tot * 100) + '%' : '' }));
const ct = d.content_type_th, std = ct === 'มาตรฐาน', book = ct === 'หนังสือ';
const ttl = std ? 'ประเภทเอกสารที่ถูกใช้ (ครั้ง)' : book ? 'หมวดหนังสือที่ถูกใช้มากที่สุด (ครั้ง)' : 'สาขาของวารสารที่ถูกใช้มากที่สุด (ครั้ง)';
const sub = std ? `${periodShort(e)} ${yy(f)} · % ของการใช้ทั้งฐาน · ฐานมาตรฐานรายงานตามประเภทเอกสาร (ฉบับปัจจุบัน ฉบับเก่า ฯลฯ) ไม่ได้แยกตามสาขาวิชา`
: `${periodShort(e)} ${yy(f)} · % ของการใช้ทั้งฐาน · ${book ? 'หมวดของหนังสือที่ถูกอ่าน' : 'สาขาของวารสารที่ถูกเปิด'} ไม่ใช่สาขาของผู้อ่าน`;
left = card(head(ttl, sub) + (it.length ? hbars(it, { unit: ' ครั้ง', lw: std ? '58%' : '44%' }) : `ผู้ให้บริการไม่ได้ส่งข้อมูล${std ? 'ประเภทเอกสาร' : 'สาขา'}มา
หรือยังไม่ได้จัด${std ? 'ประเภท' : 'สาขา'}ให้ฐานนี้
`));
}
const who = card(head('ใครใช้ (คน)', `${esc(pn || '')} · ${periodShort(e)} ${yy(f)} · กลุ่มที่น้อยกว่า ${D.minG} คนไม่แสดงตัวเลข`) +
(p ? `ตำแหน่ง
${whoBar(f, e, p, 'TYPE')}นักศึกษาแยกระดับ
${whoBar(f, e, p, 'DEGREE')}` : 'ฐานนี้ไม่ได้เข้าผ่าน OpenAthens
') +
(CFG.internal && typeof facList === 'function' ? facList(f, e, p) : `ชุดภายในแยกคณะ — แสดงเมื่อสำนักงานทะเบียนยืนยันชื่อคณะ
`));
const yrs = D.fys.filter(y => y >= D.firstFY);
const hasSea = si.show;
const html = `
${esc(d.resource_name)}${acqPill(resAcq(rc, f))}
${esc(d.content_type_th)} · ${esc(d.vendor || '')}${d.include_in_pa === 'Y' ? ' · รายงาน PA' : ''}
${kp}
${left}${who}
${card(head('ทุกปีงบ', `ทั้งปีงบ · * ${D.curFY} ถึง ${MTH[D.compM]} · แต่ละกราฟมีแกนของตัวเอง`) +
`
` +
(hasSea ? `
${isSc ? 'การค้นหา' : 'การค้น/เข้าดู'} (ครั้ง)
` : `
${ICON.info}${esc(si.why)}
`) +
(notes.length ? `
${notes.map(n => '• ' + esc(n.note)).join('
')}
` : `
${periodShort(e)} ${yy(f)}: ${fmt(a)} ครั้ง ${pctSpan(chg(a, b))}
`))}
`;
return { html, after: () => {
const cats = yrs.map(y => ({ k: y, lab: y === D.curFY ? y + '*' : String(y) }));
mount($('#c_dy'), el => lines(el, { cats, lines: [{ name: 'การใช้', color: '#FA4616', vals: yrs.map(y => sumUse(y, 12, one)), labels: true }], xTitle: hasSea ? '' : 'ปีงบประมาณ (พ.ศ.)', unit: ' ครั้ง', labelFmt: fmtC, h: hasSea ? 150 : 250 }));
if (hasSea) mount($('#c_ds'), el => lines(el, { cats, lines: [{ name: isSc ? 'การค้นหา' : 'การค้น/เข้าดู', color: '#1F2124', vals: yrs.map(y => sumUse(y, 12, one, 'sea')), labels: true }], xTitle: 'ปีงบประมาณ (พ.ศ.)', unit: ' ครั้ง', labelFmt: fmtC, h: 150 }));
} };
}
// ---------------------------------------------------------------- value
// ---------------------------------------------------------------- cost per use by period
// public = pub_cost_period (every subscribed resource with a price, PA method) · internal = int_cost_period (presentation method: main group, Scopus apart, PA / ad-hoc)
const CPER = { C3: ['3 เดือน', 'ต.ค.–ธ.ค.'], C6: ['6 เดือน', 'ต.ค.–มี.ค.'], C9: ['9 เดือน', 'ต.ค.–มิ.ย.'], C12: ['12 เดือน', 'ทั้งปีงบ'],
Q1: ['ไตรมาส 1', 'ต.ค.–ธ.ค.'], Q2: ['ไตรมาส 2', 'ม.ค.–มี.ค.'], Q3: ['ไตรมาส 3', 'เม.ย.–มิ.ย.'], Q4: ['ไตรมาส 4', 'ก.ค.–ก.ย.'], FYP: ['ทั้งปี (คาดการณ์)', ''] };
const cpAuto = () => { const m = D.compM, q = Math.floor(m / 3); return m >= 12 ? 'C12' : q ? 'C' + q * 3 : 'FYP'; }; // latest complete quarter
const cpCode = () => (S.cper === 'AUTO' ? cpAuto() : S.cper);
const cpLab = c => (CPER[c] ? CPER[c][0] + (CPER[c][1] ? ` (${CPER[c][1]})` : '') : c);
const useInt = () => !!(CFG.internal && D.icp && D.icp.length);
const cpMeth = () => (useInt() ? S.cmeth : 'PA');
// value of one fiscal year for a period code · 'FYP' falls back to the 12 months of a finished year
function cpAt(y, code, meth) {
const get = c => (useInt() ? icpAgg(y, c, meth || cpMeth()) : (D.cp[y] || {})[c] || null);
return get(code) || (code === 'FYP' ? get('C12') : null);
}
const cpYears = () => [...new Set(useInt() ? D.icp.map(r => r.fy) : Object.keys(D.cp).map(Number))].sort();
function cpFormula(code, meth, n) {
const q = code[0] === 'Q', fyp = code === 'FYP';
if (meth === 'ADHOC') return `แต่ละฐาน: ${fyp ? 'ราคาทั้งปี ÷ การใช้ที่คาดการณ์ทั้งปี (การใช้ × 12 ÷ เดือนที่มีข้อมูล)' : 'ราคาทั้งปี ÷ 12 × เดือนที่มีข้อมูล ÷ การใช้ของเดือนเดียวกัน'} · แล้วเฉลี่ย ${n} ฐาน (น้ำหนักเท่ากัน) · เดือนที่ผู้ให้บริการแจ้งข้อมูลหายไม่นำมาคิดทั้งค่าใช้จ่ายและการใช้ — วิธีเดียวกับรายงานเฉพาะกิจ Cost per Use`;
return `ค่าใช้จ่ายของช่วงเวลา = ${fyp ? 'ราคาทั้งปี' : q ? 'ราคาทั้งปี ÷ 4' : 'ราคาทั้งปี × จำนวนเดือน ÷ 12'} · ต้นทุนต่อการใช้ = ค่าใช้จ่ายรวม ÷ ${fyp ? 'การใช้ที่คาดการณ์ทั้งปี (การใช้ถึงเดือนล่าสุด × 12 ÷ จำนวนเดือน)' : 'การใช้รวมของเดือนเดียวกัน'} ของ ${n} ฐานชุดเดียวกัน — วิธีเดียวกับรายงาน PA`;
}
function pgValue() {
const f = fy(), code = cpCode(), meth = cpMeth(), I = useInt();
const ys = cpYears().filter(y => cpAt(y, code, meth));
const cur = cpAt(f, code, meth), prev = cpAt(f - 1, code, meth);
const n = cur ? cur.n : (cpAt(ys[ys.length - 1], code, meth) || {}).n || '';
const cc = D.cost[f] || D.cost[D.curFY];
const unpriced = Object.values(D.res).filter(r => resAcq(r.resource_code, D.curFY) === 'KMUTT_SUB_NOPRICE' && D.use.some(u => u.rc === r.resource_code)).map(r => r.resource_name);
const unc = Object.values(D.res).filter(r => r.acq_base === 'KMUTT_UNCONF').map(r => r.resource_name);
const perp = Object.values(D.res).filter(r => r.acq_base === 'KMUTT_PERP').map(r => r.resource_name);
const nM = Object.values(D.res).filter(r => r.funding_source === 'MHESI' && r.status === 'Active').length;
const row = (tag, cls, t, s) => ``;
const has = c => !!cpAt(f, c, meth);
const b = (c, lab) => ``;
const ctl = `ช่วงเวลา
${b('AUTO', `อัตโนมัติ · ${CPER[cpAuto()][0]}`)}${['C3', 'C6', 'C9', 'C12'].map(c => b(c, CPER[c][0])).join('')}${f === D.curFY && D.compM < 12 ? b('FYP', 'ทั้งปี (คาดการณ์)') : ''}
${['Q1', 'Q2', 'Q3', 'Q4'].map(c => b(c, CPER[c][0])).join('')}
${I ? `
วิธีคำนวณ` : ''}
`;
const scope = I ? `${n} ฐานบอกรับรายปีที่มีราคา ไม่รวม Scopus (นับการใช้คนละประเภท แสดงแยก)` : `รวมทุกฐานที่บอกรับรายปีและมีราคา (${n} ฐาน)`;
const tip = c => { const x = cpAt(c.k, code, meth); return `ปีงบ ${c.k} · ${cpLab(x.code)}${x.proj ? ' (คาดการณ์)' : ''}
ค่าใช้จ่ายของช่วง: ${fmt(x.cost / 1e6, 2)} ล้านบาท
${x.proj ? `การใช้คาดการณ์ทั้งปี: ${fmt(x.puse)} ครั้ง` : `การใช้: ${fmt(x.use)} ครั้ง`}
ต้นทุนต่อการใช้: ฿${fmt(x.cpu, 2)}
${esc(x.list)}`; };
// internal: per-resource table as in the report (resource, price, months counted, cost of the period, use, baht per use)
let tbl = '';
if (I && cur) {
const avg = cur.cpu, fyp = cur.proj;
const tr = (r, bold) => { const cpu = meth === 'ADHOC' ? r.cpuad : r.cpupa, cst = meth === 'ADHOC' ? r.cad : r.cpa;
return `| ${bold ? '' : ''}${esc((D.res[r.rc] || {}).resource_name || r.rc)}${r.grp === 'SEPARATE' ? ' (แสดงแยก)' : ''}${bold ? '' : ''} | ${fmt(r.price, 2)} |
${meth === 'ADHOC' ? r.md : r.n}${r.md < r.n ? ` ขาด ${r.n - r.md}` : ''} |
${fmt(cst, 2)} | ${fmt(fyp ? (meth === 'ADHOC' ? r.pad : r.ppa) : r.use)} |
${fmt(cpu, 2)} |
`; };
tbl = card(head(`ต้นทุนต่อการใช้รายฐาน · ${cpLab(cur.code)} ปีงบ ${f}`, `${meth === 'ADHOC' ? 'เฉลี่ยรายฐาน' : 'แบบรายงาน PA'} · สีแดง = สูงกว่าค่า${meth === 'ADHOC' ? 'เฉลี่ย' : ''} ${cur.n} ฐาน · ภายในเท่านั้น (ราคารายฐาน)`) +
`| ฐานข้อมูล | ราคาปีงบ ${f} (บาท) | ${meth === 'ADHOC' ? 'เดือนที่มีข้อมูล' : 'จำนวนเดือน'} | ${fyp ? 'ค่าใช้จ่ายทั้งปี' : 'ค่าใช้จ่ายของช่วง'} (บาท) | ${fyp ? 'การใช้คาดการณ์ทั้งปี' : 'การใช้'} (ครั้ง) | บาท/ครั้ง |
` +
cur.main.slice().sort((a, b2) => ((meth === 'ADHOC' ? b2.cpuad : b2.cpupa) || 0) - ((meth === 'ADHOC' ? a.cpuad : a.cpupa) || 0)).map(r => tr(r)).join('') +
`| ${meth === 'ADHOC' ? `ค่าเฉลี่ย ${cur.n} ฐาน` : `รวม ${cur.n} ฐาน`} | ${fmt(cur.main.reduce((s, r) => s + r.price, 0), 2)} | | ${fmt(cur.cost, 2)} | ${fmt(fyp ? cur.puse : cur.use)} | ${fmt(avg, 2)} |
` +
cur.sep.map(r => tr(r)).join('') + `
` +
`${cur.sep.length ? 'Scopus นับ Informetrics use activity จึงไม่นำมาเฉลี่ยรวม · ' : ''}${meth === 'PA' ? 'แบบรายงาน PA คิดค่าใช้จ่ายตามจำนวนเดือนในช่วงเสมอ (รวมเดือนที่ข้อมูลขาด)' : 'เฉลี่ยรายฐานไม่คิดเดือนที่ข้อมูลขาด'}
`, '', 'margin-top:12px');
}
const sc = I && cur && cur.sep[0], scp = I && prev && prev.sep[0];
const scCpu = x => x && (meth === 'ADHOC' ? x.cpuad : x.cpupa);
const html = `${card(ctl, '', 'padding:10px 14px;margin-bottom:12px')}
${card(head(`ค่าใช้จ่าย (ล้านบาท) และต้นทุนต่อการใช้ (บาท/ครั้ง) · ${cpLab(code)}`, `${scope} · ทุกปีเทียบช่วงเดียวกัน · ไม่ขึ้นกับตัวกรอง`, `
ค่าใช้จ่ายของช่วง${meth === 'ADHOC' ? 'เฉลี่ยรายฐาน' : 'ต้นทุน/ครั้ง'}
`) +
(ys.length ? `
` : `
ยังไม่มีข้อมูลครบช่วงนี้
`) +
`
${cpFormula(code, meth, n)}${ys.some(y => (cpAt(y, code, meth) || {}).proj) ? ' · * คาดการณ์ (ปีงบที่ยังไม่จบ)' : ''}${Object.values(D.cost).some(c => c.note) ? ' · ' + Object.values(D.cost).filter(c => c.note).map(c => c.fy).join(', ') + ': ' + esc(Object.values(D.cost).find(c => c.note).note) : ''}
`)}
${card(`
ค่าใช้จ่าย ${n} ฐาน · ${cur ? CPER[cur.code][0] : CPER[code][0]} ปีงบ ${f}
฿${cur ? fmt(cur.cost / 1e6, 2) : '—'}ล้าน
${cur && prev ? pctSpan(chg(cur.cost, prev.cost), true) + ' จากช่วงเดียวกันปีก่อน' : ''}
`)}
${card(`
ต้นทุนต่อการใช้ · ${cur ? CPER[cur.code][0] : CPER[code][0]} ปีงบ ${f}${meth === 'ADHOC' ? ' (เฉลี่ยรายฐาน)' : ''}
฿${cur ? fmt(cur.cpu, 2) : '—'}บาท/ครั้ง${cur && cur.proj ? '*' : ''}
${cur && prev ? pctSpan(chg(cur.cpu, prev.cpu), true) + ` จาก ${f - 1} (฿${fmt(prev.cpu, 2)})` : ''}
`)}
${sc ? card(`
Scopus (แสดงแยก) · ${CPER[cur.code][0]} ปีงบ ${f}
฿${fmt(scCpu(sc), 2)}บาท/ครั้ง
ต่อ Informetrics 1 ครั้ง${scp ? ' · ' + pctSpan(chg(scCpu(sc), scCpu(scp)), true) + ` จาก ${f - 1} (฿${fmt(scCpu(scp), 2)})` : ''}
`) : ''}
${card(head('ฐานไหนถูกนำมาคิดต้นทุน', 'ตัวตั้งและตัวหารมาจากฐานชุดเดียวกันเสมอ') +
row('นำมาคิด', 'up', cur ? cur.list : (cc ? cc.list : ''), 'บอกรับรายปี มีราคาในระบบ') +
(I && cur && cur.sep.length ? row('แสดงแยก', 'wr', cur.sep.map(r => (D.res[r.rc] || {}).resource_name || r.rc).join(' · '), 'นับการใช้คนละประเภท (Informetrics) จึงไม่นำมาเฉลี่ยรวม — ตามรายงาน PA') : '') +
(unpriced.length ? row('ยังไม่นำมาคิด', 'wr', unpriced.join(' · '), 'บอกรับรายปี แต่ยังไม่มีราคาในระบบ — เพิ่มเมื่อได้ราคา') : '') +
(unc.length ? row('ยังไม่นำมาคิด', 'wr', unc.join(' · '), 'รอยืนยันว่าเป็นบอกรับรายปีหรือซื้อขาด') : '') +
row('ไม่นำมาคิด', 'dn', perp.join(' · '), 'ซื้อขาด ไม่มีค่าบำรุงรายปี') +
row('ไม่นำมาคิด', 'dn', `ฐานจาก สป.อว. (${nM} ฐาน)`, 'มจธ. ไม่ได้จ่าย'))}
${CFG.internal ? `
ภายในราคาและต้นทุนรายฐานทั้งปี — ดูหน้า
` : '
ยังไม่เปิดเผยต้นทุนรายฐาน — ไม่เผยแพร่ราคารายฐานเพื่อรักษาเงื่อนไขกับผู้ให้บริการและหน่วยงานอื่น
'}
${tbl}`;
return { html, after: () => {
$$('[data-cper]').forEach(x => x.onclick = () => { S.cper = x.dataset.cper; render(); });
$$('[data-cmeth]').forEach(x => x.onclick = () => { S.cmeth = x.dataset.cmeth; render(); });
if (!ys.length) return;
mount($('#c_combo'), el => combo(el, { cats: ys.map(y => ({ k: y, lab: String(y), proj: !!cpAt(y, code, meth).proj })), bars: { vals: ys.map(y => cpAt(y, code, meth).cost / 1e6), color: '#E9DDB8' },
line: { vals: ys.map(y => cpAt(y, code, meth).cpu), color: '#1F2124' }, xTitle: 'ปีงบประมาณ (พ.ศ.)', y1: 'ค่าใช้จ่ายของช่วง (ล้านบาท)', y2: 'ต้นทุนต่อการใช้ (บาท/ครั้ง)', tip }));
} };
}
// ---------------------------------------------------------------- users
function pgUsers() {
const f = fy(), e = endM(f), plat = userPlat() || 'ALL';
const pn = plat === 'ALL' ? 'ทุกแพลตฟอร์ม' : (D.plat[plat] || {}).platform_name;
const tot = U(f, 'YTD', e, plat);
const uf = Object.keys(D.usersMaxYTD).map(Number).sort();
const deg = D.group.filter(g => g.breakdown === 'DEGREE').sort((a, b) => a.sort_order - b.sort_order).map(g => { const r = U(f, 'YTD', e, plat, 'DEGREE', g.group_code); return `${g.group_name_th} ${r && r.u != null ? fmt(r.u) : r && r.sup ? '<10' : '—'}`; }).join(' · ');
const pl = Object.keys(D.plat).filter(k => k !== 'ALL').map(k => { const r = U(f, 'YTD', e, k); return { k, l: D.plat[k].platform_name, v: r && r.u }; }).filter(x => x.v).sort((a, b) => b.v - a.v).slice(0, 12);
const html = `
${card(head('ผู้ใช้ไม่ซ้ำรายปีงบ (คน)', `${esc(pn)} · * ${D.curFY} ถึง ${MTH[D.usersMaxYTD[D.curFY]]} · เส้นประ = เปลี่ยนวิธีนับ (เม.ย. 68)`) + `
`)}
${card(head(`ผู้ใช้รายไตรมาส (คน)`, `${esc(pn)} · ${f} เทียบ ${f - 1} · นับไม่ซ้ำภายในไตรมาส`, `
${f}${f - 1}
`) + `
`)}
${card(head(`ใครใช้ · ${periodLabel(f, e)} (คน)`, `${esc(pn)} · รวม ${tot && tot.u != null ? fmt(tot.u) : '—'} คน · กลุ่มที่น้อยกว่า ${D.minG} คนไม่แสดงตัวเลข`) + whoBar(f, e, plat, 'TYPE') + `
นักศึกษาแยกระดับ: ${deg}
`)}
${card(head('แพลตฟอร์มที่มีผู้ใช้มากที่สุด (คน)', `${periodLabel(f, e)} · นับผ่าน OpenAthens · แต่ละแพลตฟอร์มนับแยกกัน (คนเดียวอาจใช้หลายแพลตฟอร์ม)`) + hbars(pl.map(x => ({ l: x.l, v: x.v, sub: 'คน' })), { unit: ' คน' }))}
รอข้อมูล% ของนักศึกษา/บุคลากรที่ใช้รอจำนวนนักศึกษาและบุคลากรทั้งหมดจากหน่วยงานที่เกี่ยวข้อง
`;
return { html, after: () => {
mount($('#c_uy'), el => {
columns(el, { cats: uf.map(y => ({ k: y, lab: y === D.curFY ? y + '*' : String(y), note: y === D.curFY ? 'ถึง ' + MTH[D.usersMaxYTD[y]] : 'ทั้งปีงบ' })), series: [{ k: 'u', name: 'ผู้ใช้ไม่ซ้ำ', color: '#1F2124' }], val: c => { const r = U(c.k, 'YTD', D.usersMaxYTD[c.k], plat); return r && r.u; }, totals: true, yAxis: false, xTitle: 'ปีงบประมาณ (พ.ศ.) · ผู้ใช้ (คน)', unit: ' คน', fade: c => c.k === D.curFY, active: c => c.k === f });
const i = uf.indexOf(2568); if (i > 0) { const svg = $('svg', el), w = +svg.getAttribute('width'), pw = w - 16, x = 8 + pw * i / uf.length; svg.insertAdjacentHTML('beforeend', `เม.ย. 68 เปลี่ยนวิธีนับ`); }
});
mount($('#c_uq'), el => {
const cats = [1, 2, 3, 4].map(q => ({ k: q, lab: 'ไตรมาส ' + q }));
const w = Math.max(260, el.clientWidth), h = 210, L = 50, R = 8, T = 16, B = 26;
const va = cats.map(c => { const r = U(f, 'Q', c.k, plat); return c.k <= Math.ceil(e / 3) && r ? r.u : null; }), vb = cats.map(c => { const r = U(f - 1, 'Q', c.k, plat); return r ? r.u : null; });
const { max, step } = niceMax(Math.max(...va, ...vb, 1) * 1.1); const pw = w - L - R, ph = h - T - B, bw = Math.min(40, pw / 4 * 0.34);
let s = axesSVG({ w, h, L, R, T, B, max, step });
cats.forEach((c, i) => { const cx = L + pw * (i + .5) / 4;
[[vb[i], '#C4C7CC', -1, f - 1], [va[i], '#FA4616', 1, f]].forEach(([v, col, side, yr]) => { if (v == null) return; const bh = v / max * ph; s += `${fmt(v)}`; });
s += `${c.lab}`; });
el.innerHTML = svgWrap(w, h, s);
});
} };
}
// ---------------------------------------------------------------- subjects
const PAL = ['#FA4616', '#1F2124', '#CFA537', '#9AA0A6', '#F59A76', '#5C6068', '#E6CC7A', '#A12C08', '#C4C7CC', '#8A6D12'];
function pgSubjects() {
const f = fy(), e = endM(f);
const m = {}, rt = {}; let unc = 0, cls = 0; const noSubj = new Set();
for (const r of D.subj) if (r.fy === f && r.fm <= e && passRes(r.rc) && passAcq(resAcq(r.rc, f))) {
if (!r.kf || r.sc === 'NO_DATA' || /NO_DATA$/.test(r.sc)) { unc += r.v; continue; }
cls += r.v; (m[r.kfn] || (m[r.kfn] = {}))[r.rc] = (m[r.kfn][r.rc] || 0) + r.v; rt[r.rc] = (rt[r.rc] || 0) + r.v;
}
const rcs = Object.keys(rt).sort((a, b) => rt[b] - rt[a]); const col = {}; rcs.forEach((rc, i) => col[rc] = PAL[i % PAL.length]);
const items = Object.entries(m).map(([l, o]) => ({ l, v: Object.values(o).reduce((s, x) => s + x, 0), segs: rcs.filter(rc => o[rc]).map(rc => ({ v: o[rc], c: col[rc], n: D.res[rc].resource_name })) })).sort((a, b) => b.v - a.v);
const useAll = sumUse(f, e, r => pass(r)) || 0;
const withSubj = new Set(D.subj.map(r => r.rc));
const top = lookupRows(f, e).sort((a, b) => b.use - a.use).filter(r => !withSubj.has(r.rc)).slice(0, 5).map(r => r.name.split(' (')[0]);
const html = `
${card(head(`การใช้เนื้อหา (ครั้ง) แยกตามสาขาของวารสาร/หนังสือที่ถูกเปิด${filterNote()}`, `${periodLabel(f, e)} · ${rcs.length} ฐานที่จัดสาขาได้ · 1 แท่ง = 1 สาขา มจธ. (16 กลุ่ม) · สีแยกตามฐาน`,
`
${rcs.map(rc => `${esc(D.res[rc].resource_name.split(' (')[0])}`).join('')}
`) +
(items.length ? hbars(items, { unit: ' ครั้ง' }) : '
ไม่มีข้อมูลสาขาในฐานที่เลือก
'))}
${card(head('3 มุมมอง แยกกันชัด', '') +
`
① การใช้เนื้อหา (ครั้ง) ตามสาขาของเนื้อหา
นับการเปิดเนื้อหาเต็ม แล้วจัดตามสาขาของวารสาร/หนังสือ — ไม่ใช่สาขาของผู้อ่าน
สาธารณะ
② ผู้ใช้ (คน) ตามคณะของผู้ใช้
คนที่เข้าผ่าน OpenAthens จัดตามคณะจากรหัสนักศึกษา
ภายใน · รอยืนยันชื่อคณะ
③ การเข้าใช้ (ครั้ง) ตามคณะของผู้ใช้
จำนวนครั้งที่เข้าผ่าน OpenAthens (ไม่ใช่การดาวน์โหลด)
ภายใน · รอยืนยันชื่อคณะ`)}
${card(`
${fmt(cls)}
ครั้งที่จัดสาขาได้
${fmt(Math.max(useAll - cls, 0))}
ครั้งที่ยังไม่มีข้อมูลสาขา
ฐานใหญ่ที่ผู้ให้บริการยังไม่ส่งข้อมูลสาขา: ${esc(top.join(' · ') || '—')}
`)}
`;
return { html, after: () => {} };
}
// ---------------------------------------------------------------- method
function pgMethod() {
const f = fy(), e = endM(f);
const rcs = [...new Set(D.cov.filter(c => c.fy === f && c.st !== 'NOT_SUBSCRIBED' && c.st !== 'NOT_DUE').map(c => c.rc))].filter(rc => D.res[rc] && D.res[rc].status !== 'Cancelled' && passRes(rc))
.sort((a, b) => (D.res[a].funding_source === D.res[b].funding_source ? D.res[a].resource_name.localeCompare(D.res[b].resource_name) : D.res[a].funding_source === 'KMUTT' ? -1 : 1));
const cm = {}; for (const c of D.cov) if (c.fy === f) cm[c.rc + '|' + c.fm] = c;
const SYM = { NOT_RECEIVED: '×', PENDING: '…', VENDOR_GAP: '!', PARTIAL: '!', MANUAL: 'M', ZERO_USE: '0', NO_SOURCE: '–', SYSTEM_CHANGE: '!', NOT_SUBSCRIBED: '/' };
let hm = `${MTH.slice(1).map(m => `
${m}
`).join('')}`;
for (const rc of rcs) {
hm += `
${esc(D.res[rc].resource_name.split(' (')[0])}
`;
for (let m = 1; m <= 12; m++) { const c = cm[rc + '|' + m]; const st = c ? c.st : (m > e ? 'NOT_DUE' : 'NONE');
hm += `
${SYM[st] || ''}
`; }
}
hm += '
';
const LG = [['RECEIVED', 'ได้รับแล้ว', ''], ['LEGACY', 'ค่าที่ล็อก', ''], ['ZERO_USE', 'มีรายงาน ใช้ 0', '0'], ['NOT_RECEIVED', 'ยังไม่ได้รับ', '×'], ['PENDING', 'ยังไม่มีช่องทาง', '…'], ['VENDOR_GAP', 'ผู้ให้บริการแจ้งข้อมูลหาย / เปลี่ยนระบบ', '!'], ['NO_SOURCE', 'ต้นทางไม่มีข้อมูล', '–'], ['NOT_SUBSCRIBED', 'ไม่ได้บอกรับ', '/'], ['MANUAL', 'ค่าปรับปรุง', 'M']];
const lg = `${LG.map(l => `${l[2]}${l[1]}`).join('')}
`;
const DEF = [['การใช้ (ครั้ง)', 'เปิดเนื้อหาเต็ม (COUNTER R5 Total_Item_Requests) · Hibrary/2eLibrary = ยืม(+อ่าน) · Scopus = Informetrics'], ['การค้น/เข้าดู (ครั้ง)', 'COUNTER = เข้าดูรายการ (Total_Item_Investigations) · Scopus = การค้น · บางฐานไม่มี'],
['ผู้ใช้ (คน)', 'บัญชีที่เข้าผ่าน OpenAthens อย่างน้อย 1 ครั้งในช่วง · ไม่รวมการเข้าผ่าน IP ในมหาวิทยาลัย · นับแยกแต่ละแพลตฟอร์ม'], ['สาขาวิชา', 'การใช้เนื้อหาจัดตามสาขาของวารสาร/หนังสือ ไม่ใช่สาขาของผู้อ่าน'],
['เทียบปีก่อน', 'ช่วงเดือนเดียวกัน เฉพาะเดือนที่ข้อมูลครบ'], ['ต้นทุนต่อการใช้', 'ค่าใช้จ่ายของช่วง (ราคาทั้งปี × เดือน ÷ 12 · รายไตรมาส = ราคา ÷ 4) ÷ การใช้ช่วงเดียวกัน ของฐานที่บอกรับรายปีและมีราคาชุดเดียวกัน · ค่าเริ่มต้น = ไตรมาสล่าสุดที่ข้อมูลครบ · ทั้งปี (คาดการณ์) = ราคาทั้งปี ÷ การใช้ × 12 ÷ จำนวนเดือน'],
['ตัวเลขเบื้องต้น', 'เดือนที่ยังไม่ล็อก ผู้ให้บริการอาจแก้ย้อนหลัง · ช่วงที่รายงาน PA แล้วถูกล็อก'], ['กลุ่มเล็ก', `กลุ่มที่มีน้อยกว่า ${D.minG} คน ไม่แสดงตัวเลข`], ['รูปแบบการได้มา', 'บอกรับรายปี · ซื้อขาด · รอยืนยัน · สป.อว. จัดให้ — แยกกันเสมอ ไม่นำการใช้ของกลุ่มอื่นมาเฉลี่ยต้นทุน']];
const DL = [['pub_usage_month', 'การใช้รายเดือนรายฐาน'], ['pub_users_period', 'ผู้ใช้ไม่ซ้ำรายช่วง'], ['pub_subject_month', 'การใช้ตามสาขา'], ['pub_cost_fy', 'งบและต้นทุนต่อการใช้'], ['pub_coverage', 'สถานะข้อมูลรายเดือน'], ['pub_dim_resource', 'รายชื่อฐาน']];
const rs = D.meta.data_note ? `ปรับโครงสร้างข้อมูล (${esc(D.meta.restated_on || '')})${esc(D.meta.data_note)}
` : '';
const html = rs + `
${card(head(`สถานะข้อมูลรายเดือน ปีงบ ${f}`, `แกนนอน = เดือน (${periodLabel(f, 12)}) · แกนตั้ง = ฐานข้อมูล · 1 ช่อง = ฐาน × เดือน`) + lg + (f === D.curFY ? `
ช่องจาง = เดือนหลัง ${MTH[e]} ยังอยู่ระหว่างรวบรวม ยังไม่นับในตัวเลข
` : '') + `
${hm}
`)}
${card(head('นิยามตัวชี้วัด', 'ใช้คำเดียวกันทุกหน้า') + `
${DEF.map(d => `- ${d[0]}
- ${esc(d[1])}
`).join('')}
`)}
${card(head('แหล่งที่มา', '') + `
รายงาน COUNTER R5 จากผู้ให้บริการ · ThaiLIS/UniNet (ฐาน สป.อว.) · รายงานของ Hibrary, 2eLibrary, Scopus · OpenAthens (สรุปจำนวนคน ไม่มีข้อมูลรายบุคคล)
ชุดข้อมูล ${esc(D.meta.dataset_version || '')} · อ้างอิง ${esc(D.meta.reference_version || '')}
`)}
${card(head('ดาวน์โหลดและติดต่อ', '') + `
${DL.map(d => ``).join('')}
ติดต่อ: ${esc(D.meta.contact || '')}
`)}
`;
return { html, after: () => { $$('[data-dl]').forEach(b => b.onclick = () => download(b.dataset.dl + '.csv', RAW[b.dataset.dl] || '')); } };
}
const PAGES = { overview: pgOverview, trend: pgTrend, lookup: pgLookup, deep: pgDeep, value: pgValue, users: pgUsers, subjects: pgSubjects, method: pgMethod };
/* ===== shell v5: brand bar, tab bar, page head with labelled filters, per-page filter scope ===== */
'use strict';
// which filters each page listens to: res = 'multi' | 'single' (deep dive: one database) | 'off'
const SCOPE = {
overview: { t: 'ภาพรวม', d: 'การใช้ ความคุ้มค่า และผู้ใช้ ของปีงบที่เลือก เทียบช่วงเดียวกันปีก่อน', res: 'multi', acq: true },
trend: { t: 'แนวโน้ม', d: 'การใช้ช่วงเดือนเดียวกันของทุกปีงบ แยกตามรูปแบบการได้มา', res: 'multi', acq: true },
lookup: { t: 'ตัวเลขรายฐาน', d: 'ตัวเลขทุกฐานในช่วงที่เลือก · คลิกชื่อฐานเพื่อเจาะลึก', res: 'multi', acq: true },
deep: { t: 'เจาะลึกรายฐาน', d: 'ดูทีละฐาน: การใช้ ผู้ใช้ และสาขาของเนื้อหา', res: 'single', acq: false, note: 'หน้านี้ดูทีละฐาน — เลือกฐานจากช่อง "ฐานข้อมูล"' },
value: { t: 'ความคุ้มค่า', d: 'งบประมาณและต้นทุนต่อการใช้ของฐานที่รายงาน PA', res: 'off', acq: false, note: () => (useInt() ? 'หน้านี้คิดแบบรายงานที่นำเสนอ: 5 ฐานบอกรับรายปีที่มีราคา และแสดง Scopus แยก' : 'หน้านี้คิดรวมทุกฐานที่บอกรับรายปีและมีราคาทั้งชุดเสมอ') + ' — ตัวกรองฐานข้อมูลและรูปแบบไม่มีผลกับหน้านี้' },
users: { t: 'ผู้ใช้', d: 'ผู้ใช้ไม่ซ้ำที่เข้าผ่าน OpenAthens', res: 'multi', acq: false, note: 'ผู้ใช้นับได้ทีละแพลตฟอร์ม และไม่แยกตามรูปแบบการได้มา' },
subjects: { t: 'สาขาวิชา', d: 'การใช้เนื้อหาจัดตามสาขาของวารสาร/หนังสือที่ถูกเปิด', res: 'multi', acq: true },
method: { t: 'ที่มาและนิยาม', d: 'สถานะข้อมูลรายเดือน นิยามตัวชี้วัด และแหล่งที่มา', res: 'multi', acq: false },
renew: { t: 'ราคารายฐาน', d: 'ภายใน · ราคาและต้นทุนต่อการใช้รายฐาน', res: 'multi', acq: false },
faculty: { t: 'ผู้ใช้ตามคณะ', d: 'ภายใน · ผู้ใช้ไม่ซ้ำตามคณะของผู้ใช้', res: 'multi', acq: false, note: 'ผู้ใช้นับได้ทีละแพลตฟอร์ม' },
health: { t: 'Health Check', d: 'ภายใน · คุณภาพและความครบของข้อมูล', res: 'multi', acq: false },
};
const ICON = {
chev: '',
lock: '',
info: '',
book: '',
};
function setTab(t) { S.tab = t; if (location.hash !== '#' + t) history.replaceState(null, '', '#' + t); render(); window.scrollTo(0, 0); }
function setFY(f) { S.fy = f === D.curFY ? null : f; render(); }
function shell() {
const tabs = TABS.map(([k, l]) => ``).join('');
return `${CFG.internal ? 'ภายใน · ห้ามเผยแพร่ · ห้ามส่งต่อ — ข้อมูลสรุปสำหรับผู้บริหารและทีมงาน
' : ''}
`;
}
// ---------- page head + filters ----------
function field(id, label, value, state, tip) { // state: 'on' | '' | 'off'
return ` `;
}
function renderFilters() {
const f = fy(), sc = SCOPE[S.tab] || SCOPE.overview;
let rv, rstate;
if (sc.res === 'single') { rv = esc((D.res[S.deep] || {}).resource_name || 'เลือกฐาน'); rstate = 'on'; }
else if (sc.res === 'off') { rv = 'ไม่ใช้ในหน้านี้'; rstate = 'off'; }
else { rv = S.res.size === 0 ? 'ทุกฐาน' : S.res.size === 1 ? esc(D.res[[...S.res][0]].resource_name) : `เลือก ${S.res.size} ฐาน`; rstate = S.res.size ? 'on' : ''; }
const av = !sc.acq ? 'ไม่ใช้ในหน้านี้' : S.acq.size === 0 ? 'ทุกรูปแบบ' : S.acq.size === 1 ? esc(ACQPILL[[...S.acq][0]][1]) : `เลือก ${S.acq.size} รูปแบบ`;
const fyo = [...D.fys].reverse().map(y => ``).join('');
$('#phead').innerHTML = `
${field('dd_res', 'ฐานข้อมูล', rv, rstate, 'หน้านี้คิดจากฐานชุดคงที่')}${field('dd_acq', 'รูปแบบการได้มา', av, !sc.acq ? 'off' : S.acq.size ? 'on' : '', 'ไม่มีผลกับหน้านี้')}${field('dd_fy', 'ปีงบประมาณ', 'ปีงบ ' + f + (f === D.curFY ? ' (ล่าสุด)' : ''), S.fy ? 'on' : '')}
${(S.res.size || S.acq.size || S.fy) ? '' : ''}
`;
const chips = (sc.res === 'multi' && S.res.size > 1 ? [...S.res].map(rc => `${esc(D.res[rc].resource_name)}`) : [])
.concat(sc.acq && S.acq.size > 1 ? [...S.acq].map(a => `${esc(ACQLG[a])}`) : []);
const kept = (sc.res === 'off' && S.res.size) || (!sc.acq && S.acq.size) ? ' · ค่าที่เลือกไว้ยังใช้กับหน้าอื่น' : '';
$('#scope').innerHTML = (sc.note ? `${ICON.info}${typeof sc.note === 'function' ? sc.note() : sc.note}${kept}` : '') + chips.join('') + `${periodLabel(f)} · เทียบช่วงเดียวกันปีก่อน${endM(f) < 12 ? ` · ปีงบ ${f} ข้อมูลยังไม่ครบปี (มีถึง ${MTH[endM(f)]})` : ''}`;
if ($('#freset')) $('#freset').onclick = () => { S.res.clear(); S.acq.clear(); S.fy = null; render(); };
$$('[data-rmres]').forEach(b => b.onclick = () => { S.res.delete(b.dataset.rmres); render(); });
$$('[data-rmacq]').forEach(b => b.onclick = () => { S.acq.delete(b.dataset.rmacq); render(); });
setupDD('dd_fy', panel => { panel.style.width = '220px'; panel.innerHTML = `${fyo}
`; $$('[data-fy]', panel).forEach(b => b.onclick = () => { closeDD(); setFY(+b.dataset.fy); }); });
if (sc.res !== 'off') setupDD('dd_res', panel => {
const f2 = fy(), single = sc.res === 'single';
const used = new Set(D.use.filter(r => r.fy >= f2 - 1).map(r => r.rc));
const groups = ACQ_ORDER.map(g => ({ g, items: Object.values(D.res).filter(r => used.has(r.resource_code) && resAcq(r.resource_code, f2) === g).sort((a, b) => a.resource_name.localeCompare(b.resource_name)) })).filter(x => x.items.length);
panel.innerHTML = `${groups.map(x => `
${ACQLG[x.g]}
` + x.items.map(r => `
`).join('') + '
').join('')}
` +
(single ? '' : ``);
const s = $('.srch', panel); s.oninput = () => { const q = s.value.toLowerCase(); $$('label', panel).forEach(l => l.style.display = l.dataset.n.includes(q) ? '' : 'none'); $$('.gw', panel).forEach(g => g.style.display = $$('label', g).some(l => l.style.display !== 'none') ? '' : 'none'); }; setTimeout(() => s.focus(), 0);
$$('input[name=rsel]', panel).forEach(c => c.onchange = () => {
if (single) { S.deep = c.value; closeDD(); render(); return; }
c.checked ? S.res.add(c.value) : S.res.delete(c.value); refresh();
});
if (!single) $('[data-all]', panel).onclick = () => { S.res.clear(); closeDD(); render(); };
});
if (sc.acq) setupDD('dd_acq', panel => {
panel.innerHTML = `${ACQ_ORDER.filter(g => g !== 'KMUTT_UNCONF' || hasUnc()).map(g => ``).join('')}
`;
$$('input[type=checkbox]', panel).forEach(c => c.onchange = () => { c.checked ? S.acq.add(c.value) : S.acq.delete(c.value); refresh(); });
$('[data-all]', panel).onclick = () => { S.acq.clear(); closeDD(); render(); };
});
}
let openDD = null, dirty = false;
function refresh() { dirty = true; if (S.res.size === 1) S.deep = [...S.res][0]; renderMain(); }
function closeDD() { if (openDD) { $('.ddp', openDD).classList.remove('open'); openDD = null; } if (dirty) { dirty = false; renderFilters(); } }
function setupDD(id, fill) {
const dd = $('#' + id); if (!dd) return; const btn = $('.ddb', dd), panel = $('.ddp', dd);
btn.onclick = ev => { ev.stopPropagation(); const was = openDD === dd; closeDD(); if (was) return; fill(panel); panel.classList.add('open'); openDD = dd; const c = $('[data-close]', panel); if (c) c.onclick = () => closeDD(); };
panel.onclick = ev => ev.stopPropagation();
}
document.addEventListener('click', () => closeDD());
document.addEventListener('keydown', ev => { if (ev.key === 'Escape') closeDD(); });
function renderMain() {
for (const el of DRAW.keys()) { if (RO) RO.unobserve(el); } DRAW.clear();
const pg = (PAGES[S.tab] || PAGES.overview)();
$('#main').innerHTML = pg.html;
$$('[data-go]', $('#main')).forEach(b => b.onclick = () => setTab(b.dataset.go));
pg.after && pg.after();
}
function render() {
$$('.tab').forEach(t => t.setAttribute('aria-selected', t.dataset.tab === S.tab));
if (S.tab === 'deep' && typeof ensureDeep === 'function') ensureDeep();
renderFilters(); renderMain();
}
function start() {
const ok = buildData(CFG.tables);
if (!ok) { document.body.innerHTML = 'นี่คือไฟล์แม่แบบ
ยังไม่มีข้อมูล — ให้สร้างไฟล์ด้วยปุ่ม Publish ใน ER_Pipeline (ขั้นตอนสร้าง Dashboard)
'; return; }
if (CFG.internal && typeof buildInternal === 'function') buildInternal();
const h = location.hash.slice(1); if (PAGES[h]) S.tab = h;
document.getElementById('app').innerHTML = shell();
$$('.tab').forEach(t => t.onclick = () => setTab(t.dataset.tab));
window.addEventListener('hashchange', () => { const k = location.hash.slice(1); if (PAGES[k] && k !== S.tab) { S.tab = k; render(); } });
initTip(); render();
}
(document.fonts && document.fonts.ready ? document.fonts.ready : Promise.resolve()).then(start);