Your network blocks the Lichess assets!

lichess.org
Donate

Shared a Tampermonkey script to display Masters and Lichess databases simultaneously

Hi everyone,

I wanted to share a small project I’ve been working on with the help of Claude. I’ve developed a script that allows both the Masters database and the Lichess community database to be displayed at the same time on the Analysis board.

I use this via the Tampermonkey extension on Chrome. Since I use a vertical monitor, I’ve optimized the layout for narrow windows, but I thought it might be useful for others as well.

Currently, I’m still refining the code with Claude to make sure it responds correctly to move clicks and UI updates. I’m sharing it here in case anyone finds it helpful or wants to use it as a reference for their own customizations!

https://i.imgur.com/Vufoy5t.jpg

// ==UserScript==
// @name Lichess Dual Opening Explorer
// @namespace https://adjva4.dpdns.org
// @version 6.0.0
// @description Masters と Lichess のオープニングエクスプローラーを上下同時表示
// @author Custom
// @match https://adjva4.dpdns.org/*
// @grant none
// @run-at document-start
// ==/UserScript==

(function () {
'use strict';

// 状態管理
let lastFen = null;
let lastPlay = null;
let lastPrimaryDb = null;
let lastLichessParams = {};
let lastData = null;
let lastDb = null;
let renderTimer = null;
let observer = null;

// Fetch インターセプト
const origFetch = window.fetch;
window.fetch = async function (...args) {
const req = args[0];
const urlStr = typeof req === 'string' ? req : (req?.url ?? '');

if (urlStr.includes('explorer.adjva4.dpdns.org')) {
try {
const u = new URL(urlStr);
const fen = u.searchParams.get('fen');
const play = u.searchParams.get('play') || '';
const pathIsMasters = u.pathname.includes('/masters');
const pathIsLichess = u.pathname.includes('/lichess');

if (fen && (pathIsMasters || pathIsLichess)) {
const db = pathIsMasters ? 'masters' : 'lichess';
if (pathIsLichess) {
lastLichessParams = {
variant: u.searchParams.get('variant') || 'standard',
speeds: u.searchParams.get('speeds') || 'blitz,rapid,classical',
ratings: u.searchParams.get('ratings') || '2000,2200,2500',
since: u.searchParams.get('since') || '',
};
}
if (fen !== lastFen || play !== lastPlay || db !== lastPrimaryDb) {
lastFen = fen;
lastPlay = play;
lastPrimaryDb = db;
scheduleSecondary(fen, play, db);
}
}
} catch (_) {}
}
return origFetch.apply(this, args);
};

// セカンダリ取得
function scheduleSecondary(fen, play, primaryDb) {
clearTimeout(renderTimer);
renderTimer = setTimeout(() => fetchSecondary(fen, play, primaryDb), 200);
}

async function fetchSecondary(fen, play, primaryDb) {
const secondDb = primaryDb === 'masters' ? 'lichess' : 'masters';
let url;

if (secondDb === 'masters') {
url = https://explorer.adjva4.dpdns.org/masters?fen=${encodeURIComponent(fen)}
+ (play ? &play=${encodeURIComponent(play)} : '')
+ &moves=12&topGames=4;
} else {
const { variant, speeds, ratings, since } = lastLichessParams;
url = https://explorer.adjva4.dpdns.org/lichess?fen=${encodeURIComponent(fen)}
+ (play ? &play=${encodeURIComponent(play)} : '')
+ &variant=${variant || 'standard'}
+ &speeds=${speeds || 'blitz,rapid,classical'}
+ &ratings=${ratings || '2000,2200,2500'}
+ (since ? &since=${since} : '')
+ &moves=12&topGames=0&source=analysis;
}

try {
const res = await origFetch(url, { credentials: 'include' });
const data = await res.json();
lastData = data;
lastDb = secondDb;
ensurePanel(data, secondDb);
} catch (e) {
console.warn('[DualExplorer] fetch error:', e);
}
}

// MutationObserver:パネルが消えたら再挿入
function startObserver() {
if (observer) return;
observer = new MutationObserver(() => {
if (!document.getElementById('dual-explorer-panel') && lastData && lastDb) {
ensurePanel(lastData, lastDb);
}
});
observer.observe(document.body, { childList: true, subtree: true });
}

if (document.readyState !== 'loading') {
startObserver();
} else {
document.addEventListener('DOMContentLoaded', startObserver, { once: true });
}

// パネル挿入:ネイティブ explorer の直後
function ensurePanel(data, db) {
// 章一覧の直前(= エクスプローラー・棋譜エリアの直後)に挿入
const chapters = document.querySelector('.study__chapters');
if (!chapters) return;

let panel = document.getElementById('dual-explorer-panel');
if (!panel) {
panel = document.createElement('section');
panel.id = 'dual-explorer-panel';
panel.className = 'explorer-box sub-box';
}

if (panel !== chapters.previousSibling) {
chapters.before(panel);
}

renderPanel(panel, data, db);
}

// パネル描画
function renderPanel(panel, data, db) {
const total = (data.white || 0) + (data.draws || 0) + (data.black || 0);
const isM = db === 'masters';
const label = isM ? ' Masters DB' : ' Lichess DB';
const color = isM ? '#c5a028' : '#4a9cc9';

if (total === 0) {
panel.innerHTML = <div class="overlay"></div> <div class="data"> <div style="padding:8px 10px;color:#555;font-size:11px"> ${label} — このポジションのデータなし </div> </div>;
addEvents(panel);
return;
}

const opening = data.opening
? <div class="title" title="${data.opening.eco} ${data.opening.name}"> <span style="color:#888;margin-right:4px">${data.opening.eco}</span>${data.opening.name} </div>
: '';

let tbody = '';
(data.moves || []).slice(0, 12).forEach(mv => {
const mt = (mv.white || 0) + (mv.draws || 0) + (mv.black || 0);
if (!mt) return;
const mw = pct(mv.white, mt);
const md = pct(mv.draws, mt);
const mb = pct(mv.black, mt);
const rat = mv.averageRating ? title="平均レーティング: ${mv.averageRating}" : '';

// バーの高さ: 13.5px(9px × 1.5)
// 黒セグメント: #1a1a1a(暗い黒に戻す)
// バー内テキスト: 10%以下は非表示
const barH = 13;
const txtSz = '11.7px';

const wTxt = mw > 10 ? <span style="font-size:${txtSz};color:#333;font-weight:600;line-height:${barH}px">${mw}%</span> : '';
const dTxt = md > 10 ? <span style="font-size:${txtSz};color:#eee;font-weight:600;line-height:${barH}px">${md}%</span> : '';
const bTxt = mb > 10 ? <span style="font-size:${txtSz};color:#ccc;font-weight:600;line-height:${barH}px">${mb}%</span> : '';

tbody += <tr data-uci="${mv.uci}" style="cursor:pointer"> <td>${mv.san}</td> <td>${pct(mt, total)}%</td> <td ${rat}>${fmt(mt)}</td> <td> <div style="display:flex;height:${barH}px;border-radius:1px;overflow:hidden"> <span style="background:rgba(255,255,255,.88);width:${mw}%; display:flex;align-items:center;justify-content:center; overflow:hidden;flex-shrink:0">${wTxt}</span> <span style="background:#696969;width:${md}%; display:flex;align-items:center;justify-content:center; overflow:hidden;flex-shrink:0">${dTxt}</span> <span style="background:#1a1a1a;width:${mb}%; display:flex;align-items:center;justify-content:center; overflow:hidden;flex-shrink:0;border:1px solid #444;box-sizing:border-box">${bTxt}</span> </div> </td> </tr>;
});

// トップゲーム(Masters のみ)
let topGames = '';
if (isM && (data.topGames || []).length) {
let rows = '';
data.topGames.slice(0, 4).forEach(g => {
const res = g.winner === 'white' ? '1-0' : g.winner === 'black' ? '0-1' : '½-½';
rows += <tr> <td>${g.white?.name ?? '?'}</td> <td>${g.white?.rating ?? ''}</td> <td style="color:#c8a028;font-weight:700;text-align:center">${res}</td> <td>${g.black?.rating ?? ''}</td> <td>${g.black?.name ?? '?'}</td> <td style="color:#666">${g.year ?? ''}</td> </tr>;
});
topGames = <table class="games">${rows}</table>;
}

panel.innerHTML = <div class="overlay"></div> <div class="data"> <div class="explorer-title" style="display:flex;align-items:center;justify-content:space-between;padding:3px 8px 0"> <span style="color:${color};font-weight:700;font-size:12px">${label}</span> <span style="color:#666;font-size:10.5px">${fmt(total)} 局</span> </div> ${opening} <table class="moves"> <thead> <tr><th>手</th><th colspan="2">局</th><th>白勝 / ドロー / 黒勝</th></tr> </thead> <tbody>${tbody}</tbody> </table> ${topGames} </div>;

addEvents(panel);
}

// イベント登録
function addEvents(panel) {
const tbody = panel.querySelector('tbody');
if (!tbody || tbody._dexBound) return;
tbody._dexBound = true;

tbody.addEventListener('mouseenter', e => {
const tr = e.target.closest('tr[data-uci]');
if (tr) onHover(tr.dataset.uci, true);
}, true);

tbody.addEventListener('mouseleave', e => {
const tr = e.target.closest('tr[data-uci]');
if (tr) onHover(tr.dataset.uci, false);
}, true);

tbody.addEventListener('click', e => {
const tr = e.target.closest('tr[data-uci]');
if (tr) onClickMove(tr.dataset.uci);
});
}

// ホバー矢印
function onHover(uci, entering) {
const native = document.querySelector(section.explorer-box.sub-box tr[data-uci="${uci}"]);
if (native) {
native.dispatchEvent(new MouseEvent(entering ? 'mouseenter' : 'mouseleave', { bubbles: true }));
}
if (entering) drawArrow(uci);
else clearArrows();
}

// クリック着手
// ネイティブ explorer の tbody に一時的な隠し TR を注入し、
// Lichess 自身のクリックハンドラを経由して着手させる
function onClickMove(uci) {
if (!uci || uci.length < 4) return;

const nativeTbody = document.querySelector('section.explorer-box.sub-box:not(#dual-explorer-panel) tbody');
if (!nativeTbody) return;

// 同じ手がすでにある場合はそのまま click
const existing = nativeTbody.querySelector(tr[data-uci="${uci}"]);
if (existing) { existing.click(); return; }

// ハンドラが $(n.target).parents("tr").attr("data-uci") を使うため
// TR の中に TD を入れて TD をクリックする必要がある
const tmp = document.createElement('tr');
tmp.dataset.uci = uci;
tmp.style.cssText = 'visibility:hidden;height:0;line-height:0;font-size:0;position:absolute';
const td = document.createElement('td');
tmp.appendChild(td);
nativeTbody.appendChild(tmp);
td.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
tmp.remove();
}

// SVG 矢印
function isFlipped() {
// cg-wrap の orientation-black クラス、またはボードの transform で判定
const wrap = document.querySelector('.cg-wrap');
if (!wrap) return false;
if (wrap.classList.contains('orientation-black')) return true;
const board = wrap.querySelector('cg-board');
if (board?.style.transform?.includes('rotate')) return true;
// 白キングの位置で判定(最終手段)
const wk = wrap.querySelector('piece.king.white');
if (wk) {
const style = wk.style.transform || '';
const match = style.match(/translate((\d+)%,\s*(\d+)%)/);
if (match) return parseInt(match[2]) < 400; // 上半分にいたら黒視点
}
return false;
}

function squareXY(name, sq, flipped) {
const file = name.charCodeAt(0) - 97;
const rank = parseInt(name[1]) - 1;
const col = flipped ? 7 - file : file;
const row = flipped ? rank : 7 - rank;
return { x: col * sq + sq / 2, y: row * sq + sq / 2 };
}

function drawArrow(uci) {
if (!uci || uci.length < 4) return;
clearArrows();
const wrap = document.querySelector('.cg-wrap');
if (!wrap) return;

const rect = wrap.getBoundingClientRect();
const sq = rect.width / 8;
const flipped = isFlipped();

const f = squareXY(uci.slice(0, 2), sq, flipped);
const t = squareXY(uci.slice(2, 4), sq, flipped);

const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.classList.add('dual-arrows');
svg.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:9';
svg.setAttribute('viewBox', 0 0 ${rect.width} ${rect.width});
svg.innerHTML = <defs> <marker id="dex-head" markerWidth="3" markerHeight="3" refX="2" refY="1.5" orient="auto"> <polygon points="0 0, 3 1.5, 0 3" fill="#003088" fill-opacity="0.85"/> </marker> </defs> <line x1="${f.x}" y1="${f.y}" x2="${t.x}" y2="${t.y}" stroke="#003088" stroke-width="${sq * 0.1}" stroke-opacity="0.8" marker-end="url(#dex-head)"/>;
wrap.appendChild(svg);
}

function clearArrows() {
document.querySelectorAll('svg.dual-arrows').forEach(s => s.remove());
}

// Utilities
function pct(n, total) {
return total ? Math.round(((n || 0) / total) * 100) : 0;
}
function fmt(n) {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
if (n >= 10_000) return Math.round(n / 10_000) + '万';
if (n >= 1_000) return (n / 1000).toFixed(1) + 'k';
return String(n);
}

})();

Hi everyone, I wanted to share a small project I’ve been working on with the help of Claude. I’ve developed a script that allows both the Masters database and the Lichess community database to be displayed at the same time on the Analysis board. I use this via the Tampermonkey extension on Chrome. Since I use a vertical monitor, I’ve optimized the layout for narrow windows, but I thought it might be useful for others as well. Currently, I’m still refining the code with Claude to make sure it responds correctly to move clicks and UI updates. I’m sharing it here in case anyone finds it helpful or wants to use it as a reference for their own customizations! https://i.imgur.com/Vufoy5t.jpg // ==UserScript== // @name Lichess Dual Opening Explorer // @namespace https://adjva4.dpdns.org // @version 6.0.0 // @description Masters と Lichess のオープニングエクスプローラーを上下同時表示 // @author Custom // @match https://adjva4.dpdns.org/* // @grant none // @run-at document-start // ==/UserScript== (function () { 'use strict'; // 状態管理 let lastFen = null; let lastPlay = null; let lastPrimaryDb = null; let lastLichessParams = {}; let lastData = null; let lastDb = null; let renderTimer = null; let observer = null; // Fetch インターセプト const origFetch = window.fetch; window.fetch = async function (...args) { const req = args[0]; const urlStr = typeof req === 'string' ? req : (req?.url ?? ''); if (urlStr.includes('explorer.adjva4.dpdns.org')) { try { const u = new URL(urlStr); const fen = u.searchParams.get('fen'); const play = u.searchParams.get('play') || ''; const pathIsMasters = u.pathname.includes('/masters'); const pathIsLichess = u.pathname.includes('/lichess'); if (fen && (pathIsMasters || pathIsLichess)) { const db = pathIsMasters ? 'masters' : 'lichess'; if (pathIsLichess) { lastLichessParams = { variant: u.searchParams.get('variant') || 'standard', speeds: u.searchParams.get('speeds') || 'blitz,rapid,classical', ratings: u.searchParams.get('ratings') || '2000,2200,2500', since: u.searchParams.get('since') || '', }; } if (fen !== lastFen || play !== lastPlay || db !== lastPrimaryDb) { lastFen = fen; lastPlay = play; lastPrimaryDb = db; scheduleSecondary(fen, play, db); } } } catch (_) {} } return origFetch.apply(this, args); }; // セカンダリ取得 function scheduleSecondary(fen, play, primaryDb) { clearTimeout(renderTimer); renderTimer = setTimeout(() => fetchSecondary(fen, play, primaryDb), 200); } async function fetchSecondary(fen, play, primaryDb) { const secondDb = primaryDb === 'masters' ? 'lichess' : 'masters'; let url; if (secondDb === 'masters') { url = `https://explorer.adjva4.dpdns.org/masters?fen=${encodeURIComponent(fen)}` + (play ? `&play=${encodeURIComponent(play)}` : '') + `&moves=12&topGames=4`; } else { const { variant, speeds, ratings, since } = lastLichessParams; url = `https://explorer.adjva4.dpdns.org/lichess?fen=${encodeURIComponent(fen)}` + (play ? `&play=${encodeURIComponent(play)}` : '') + `&variant=${variant || 'standard'}` + `&speeds=${speeds || 'blitz,rapid,classical'}` + `&ratings=${ratings || '2000,2200,2500'}` + (since ? `&since=${since}` : '') + `&moves=12&topGames=0&source=analysis`; } try { const res = await origFetch(url, { credentials: 'include' }); const data = await res.json(); lastData = data; lastDb = secondDb; ensurePanel(data, secondDb); } catch (e) { console.warn('[DualExplorer] fetch error:', e); } } // MutationObserver:パネルが消えたら再挿入 function startObserver() { if (observer) return; observer = new MutationObserver(() => { if (!document.getElementById('dual-explorer-panel') && lastData && lastDb) { ensurePanel(lastData, lastDb); } }); observer.observe(document.body, { childList: true, subtree: true }); } if (document.readyState !== 'loading') { startObserver(); } else { document.addEventListener('DOMContentLoaded', startObserver, { once: true }); } // パネル挿入:ネイティブ explorer の直後 function ensurePanel(data, db) { // 章一覧の直前(= エクスプローラー・棋譜エリアの直後)に挿入 const chapters = document.querySelector('.study__chapters'); if (!chapters) return; let panel = document.getElementById('dual-explorer-panel'); if (!panel) { panel = document.createElement('section'); panel.id = 'dual-explorer-panel'; panel.className = 'explorer-box sub-box'; } if (panel !== chapters.previousSibling) { chapters.before(panel); } renderPanel(panel, data, db); } // パネル描画 function renderPanel(panel, data, db) { const total = (data.white || 0) + (data.draws || 0) + (data.black || 0); const isM = db === 'masters'; const label = isM ? ' Masters DB' : ' Lichess DB'; const color = isM ? '#c5a028' : '#4a9cc9'; if (total === 0) { panel.innerHTML = ` <div class="overlay"></div> <div class="data"> <div style="padding:8px 10px;color:#555;font-size:11px"> ${label} — このポジションのデータなし </div> </div>`; addEvents(panel); return; } const opening = data.opening ? `<div class="title" title="${data.opening.eco} ${data.opening.name}"> <span style="color:#888;margin-right:4px">${data.opening.eco}</span>${data.opening.name} </div>` : ''; let tbody = ''; (data.moves || []).slice(0, 12).forEach(mv => { const mt = (mv.white || 0) + (mv.draws || 0) + (mv.black || 0); if (!mt) return; const mw = pct(mv.white, mt); const md = pct(mv.draws, mt); const mb = pct(mv.black, mt); const rat = mv.averageRating ? `title="平均レーティング: ${mv.averageRating}"` : ''; // バーの高さ: 13.5px(9px × 1.5) // 黒セグメント: #1a1a1a(暗い黒に戻す) // バー内テキスト: 10%以下は非表示 const barH = 13; const txtSz = '11.7px'; const wTxt = mw > 10 ? `<span style="font-size:${txtSz};color:#333;font-weight:600;line-height:${barH}px">${mw}%</span>` : ''; const dTxt = md > 10 ? `<span style="font-size:${txtSz};color:#eee;font-weight:600;line-height:${barH}px">${md}%</span>` : ''; const bTxt = mb > 10 ? `<span style="font-size:${txtSz};color:#ccc;font-weight:600;line-height:${barH}px">${mb}%</span>` : ''; tbody += ` <tr data-uci="${mv.uci}" style="cursor:pointer"> <td>${mv.san}</td> <td>${pct(mt, total)}%</td> <td ${rat}>${fmt(mt)}</td> <td> <div style="display:flex;height:${barH}px;border-radius:1px;overflow:hidden"> <span style="background:rgba(255,255,255,.88);width:${mw}%; display:flex;align-items:center;justify-content:center; overflow:hidden;flex-shrink:0">${wTxt}</span> <span style="background:#696969;width:${md}%; display:flex;align-items:center;justify-content:center; overflow:hidden;flex-shrink:0">${dTxt}</span> <span style="background:#1a1a1a;width:${mb}%; display:flex;align-items:center;justify-content:center; overflow:hidden;flex-shrink:0;border:1px solid #444;box-sizing:border-box">${bTxt}</span> </div> </td> </tr>`; }); // トップゲーム(Masters のみ) let topGames = ''; if (isM && (data.topGames || []).length) { let rows = ''; data.topGames.slice(0, 4).forEach(g => { const res = g.winner === 'white' ? '1-0' : g.winner === 'black' ? '0-1' : '½-½'; rows += ` <tr> <td>${g.white?.name ?? '?'}</td> <td>${g.white?.rating ?? ''}</td> <td style="color:#c8a028;font-weight:700;text-align:center">${res}</td> <td>${g.black?.rating ?? ''}</td> <td>${g.black?.name ?? '?'}</td> <td style="color:#666">${g.year ?? ''}</td> </tr>`; }); topGames = `<table class="games">${rows}</table>`; } panel.innerHTML = ` <div class="overlay"></div> <div class="data"> <div class="explorer-title" style="display:flex;align-items:center;justify-content:space-between;padding:3px 8px 0"> <span style="color:${color};font-weight:700;font-size:12px">${label}</span> <span style="color:#666;font-size:10.5px">${fmt(total)} 局</span> </div> ${opening} <table class="moves"> <thead> <tr><th>手</th><th colspan="2">局</th><th>白勝 / ドロー / 黒勝</th></tr> </thead> <tbody>${tbody}</tbody> </table> ${topGames} </div>`; addEvents(panel); } // イベント登録 function addEvents(panel) { const tbody = panel.querySelector('tbody'); if (!tbody || tbody._dexBound) return; tbody._dexBound = true; tbody.addEventListener('mouseenter', e => { const tr = e.target.closest('tr[data-uci]'); if (tr) onHover(tr.dataset.uci, true); }, true); tbody.addEventListener('mouseleave', e => { const tr = e.target.closest('tr[data-uci]'); if (tr) onHover(tr.dataset.uci, false); }, true); tbody.addEventListener('click', e => { const tr = e.target.closest('tr[data-uci]'); if (tr) onClickMove(tr.dataset.uci); }); } // ホバー矢印 function onHover(uci, entering) { const native = document.querySelector(`section.explorer-box.sub-box tr[data-uci="${uci}"]`); if (native) { native.dispatchEvent(new MouseEvent(entering ? 'mouseenter' : 'mouseleave', { bubbles: true })); } if (entering) drawArrow(uci); else clearArrows(); } // クリック着手 // ネイティブ explorer の tbody に一時的な隠し TR を注入し、 // Lichess 自身のクリックハンドラを経由して着手させる function onClickMove(uci) { if (!uci || uci.length < 4) return; const nativeTbody = document.querySelector('section.explorer-box.sub-box:not(#dual-explorer-panel) tbody'); if (!nativeTbody) return; // 同じ手がすでにある場合はそのまま click const existing = nativeTbody.querySelector(`tr[data-uci="${uci}"]`); if (existing) { existing.click(); return; } // ハンドラが $(n.target).parents("tr").attr("data-uci") を使うため // TR の中に TD を入れて TD をクリックする必要がある const tmp = document.createElement('tr'); tmp.dataset.uci = uci; tmp.style.cssText = 'visibility:hidden;height:0;line-height:0;font-size:0;position:absolute'; const td = document.createElement('td'); tmp.appendChild(td); nativeTbody.appendChild(tmp); td.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); tmp.remove(); } // SVG 矢印 function isFlipped() { // cg-wrap の orientation-black クラス、またはボードの transform で判定 const wrap = document.querySelector('.cg-wrap'); if (!wrap) return false; if (wrap.classList.contains('orientation-black')) return true; const board = wrap.querySelector('cg-board'); if (board?.style.transform?.includes('rotate')) return true; // 白キングの位置で判定(最終手段) const wk = wrap.querySelector('piece.king.white'); if (wk) { const style = wk.style.transform || ''; const match = style.match(/translate\((\d+)%,\s*(\d+)%\)/); if (match) return parseInt(match[2]) < 400; // 上半分にいたら黒視点 } return false; } function squareXY(name, sq, flipped) { const file = name.charCodeAt(0) - 97; const rank = parseInt(name[1]) - 1; const col = flipped ? 7 - file : file; const row = flipped ? rank : 7 - rank; return { x: col * sq + sq / 2, y: row * sq + sq / 2 }; } function drawArrow(uci) { if (!uci || uci.length < 4) return; clearArrows(); const wrap = document.querySelector('.cg-wrap'); if (!wrap) return; const rect = wrap.getBoundingClientRect(); const sq = rect.width / 8; const flipped = isFlipped(); const f = squareXY(uci.slice(0, 2), sq, flipped); const t = squareXY(uci.slice(2, 4), sq, flipped); const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.classList.add('dual-arrows'); svg.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:9'; svg.setAttribute('viewBox', `0 0 ${rect.width} ${rect.width}`); svg.innerHTML = ` <defs> <marker id="dex-head" markerWidth="3" markerHeight="3" refX="2" refY="1.5" orient="auto"> <polygon points="0 0, 3 1.5, 0 3" fill="#003088" fill-opacity="0.85"/> </marker> </defs> <line x1="${f.x}" y1="${f.y}" x2="${t.x}" y2="${t.y}" stroke="#003088" stroke-width="${sq * 0.1}" stroke-opacity="0.8" marker-end="url(#dex-head)"/>`; wrap.appendChild(svg); } function clearArrows() { document.querySelectorAll('svg.dual-arrows').forEach(s => s.remove()); } // Utilities function pct(n, total) { return total ? Math.round(((n || 0) / total) * 100) : 0; } function fmt(n) { if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'; if (n >= 10_000) return Math.round(n / 10_000) + '万'; if (n >= 1_000) return (n / 1000).toFixed(1) + 'k'; return String(n); } })();

Added interactivity to the moves table. Clicking a move in the list will now trigger the corresponding move on the Lichess board.

// ==UserScript==
// @name Lichess Dual Opening Explorer
// @namespace https://adjva4.dpdns.org
// @version 6.0.0
// @description Masters と Lichess のオープニングエクスプローラーを上下同時表示
// @author Custom
// @match https://adjva4.dpdns.org/*
// @grant none
// @run-at document-start
// ==/UserScript==

(function () {
'use strict';

// 状態管理
let lastFen = null;
let lastPlay = null;
let lastPrimaryDb = null;
let lastLichessParams = {};
let lastMastersParams = {};
let lastData = null;
let lastDb = null;
let renderTimer = null;
let observer = null;

// Fetch インターセプト
const origFetch = window.fetch;
window.fetch = async function (...args) {
const req = args[0];
const urlStr = typeof req === 'string' ? req : (req?.url ?? '');

if (urlStr.includes('explorer.adjva4.dpdns.org')) {
try {
const u = new URL(urlStr);
const fen = u.searchParams.get('fen');
const play = u.searchParams.get('play') || '';
const pathIsMasters = u.pathname.includes('/masters');
const pathIsLichess = u.pathname.includes('/lichess');

if (fen && (pathIsMasters || pathIsLichess)) {
const db = pathIsMasters ? 'masters' : 'lichess';
if (pathIsLichess) {
lastLichessParams = {
variant: u.searchParams.get('variant') || 'standard',
speeds: u.searchParams.get('speeds') || 'blitz,rapid,classical',
ratings: u.searchParams.get('ratings') || '2000,2200,2500',
since: u.searchParams.get('since') || '',
};
}
if (pathIsMasters) {
lastMastersParams = {
since: u.searchParams.get('since') || '',
until: u.searchParams.get('until') || '',
};
}
if (fen !== lastFen || play !== lastPlay || db !== lastPrimaryDb) {
lastFen = fen;
lastPlay = play;
lastPrimaryDb = db;
scheduleSecondary(fen, play, db);
}
}
} catch (_) {}
}
return origFetch.apply(this, args);
};

// セカンダリ取得
function scheduleSecondary(fen, play, primaryDb) {
clearTimeout(renderTimer);
renderTimer = setTimeout(() => fetchSecondary(fen, play, primaryDb), 200);
}

async function fetchSecondary(fen, play, primaryDb) {
const secondDb = primaryDb === 'masters' ? 'lichess' : 'masters';
let url;

if (secondDb === 'masters') {
const { since: mSince, until: mUntil } = lastMastersParams;
url = https://explorer.adjva4.dpdns.org/masters?fen=${encodeURIComponent(fen)}
+ (play ? &play=${encodeURIComponent(play)} : '')
+ (mSince ? &since=${mSince} : '')
+ (mUntil ? &until=${mUntil} : '')
+ &moves=12&topGames=4;
} else {
const { variant, speeds, ratings, since } = lastLichessParams;
url = https://explorer.adjva4.dpdns.org/lichess?fen=${encodeURIComponent(fen)}
+ (play ? &play=${encodeURIComponent(play)} : '')
+ &variant=${variant || 'standard'}
+ &speeds=${speeds || 'blitz,rapid,classical'}
+ &ratings=${ratings || '2000,2200,2500'}
+ (since ? &since=${since} : '')
+ &moves=12&topGames=0&source=analysis;
}

try {
const res = await origFetch(url, { credentials: 'include' });
const data = await res.json();
lastData = data;
lastDb = secondDb;
ensurePanel(data, secondDb);
} catch (e) {
console.warn('[DualExplorer] fetch error:', e);
}
}

// MutationObserver:パネルが消えたら再挿入
function startObserver() {
if (observer) return;
observer = new MutationObserver(() => {
if (!document.getElementById('dual-explorer-panel') && lastData && lastDb) {
ensurePanel(lastData, lastDb);
}
});
observer.observe(document.body, { childList: true, subtree: true });
}

if (document.readyState !== 'loading') {
startObserver();
} else {
document.addEventListener('DOMContentLoaded', startObserver, { once: true });
}

// パネル挿入:ネイティブ explorer の直後
function ensurePanel(data, db) {
// 章一覧の直前(= エクスプローラー・棋譜エリアの直後)に挿入
const chapters = document.querySelector('.study__chapters');
if (!chapters) return;

let panel = document.getElementById('dual-explorer-panel');
if (!panel) {
panel = document.createElement('section');
panel.id = 'dual-explorer-panel';
panel.className = 'explorer-box sub-box';
}

if (panel !== chapters.previousSibling) {
chapters.before(panel);
}

renderPanel(panel, data, db);
}

// パネル描画
function renderPanel(panel, data, db) {
const total = (data.white || 0) + (data.draws || 0) + (data.black || 0);
const isM = db === 'masters';
const label = isM ? ' Masters DB' : ' Lichess DB';
const color = isM ? '#c5a028' : '#4a9cc9';

if (total === 0) {
panel.innerHTML = <div class="overlay"></div> <div class="data"> <div style="padding:8px 10px;color:#555;font-size:11px"> ${label} — このポジションのデータなし </div> </div>;
addEvents(panel);
return;
}

const opening = data.opening
? <div class="title" title="${data.opening.eco} ${data.opening.name}"> <span style="color:#888;margin-right:4px">${data.opening.eco}</span>${data.opening.name} </div>
: '';

let tbody = '';
(data.moves || []).slice(0, 12).forEach(mv => {
const mt = (mv.white || 0) + (mv.draws || 0) + (mv.black || 0);
if (!mt) return;
const mw = pct(mv.white, mt);
const md = pct(mv.draws, mt);
const mb = pct(mv.black, mt);
const rat = mv.averageRating ? title="平均レーティング: ${mv.averageRating}" : '';

// バーの高さ: 13.5px(9px × 1.5)
// 黒セグメント: #1a1a1a(暗い黒に戻す)
// バー内テキスト: 10%以下は非表示
const barH = 13;
const txtSz = '11.7px';

const wTxt = mw > 10 ? <span style="font-size:${txtSz};color:#333;font-weight:600;line-height:${barH}px">${mw}%</span> : '';
const dTxt = md > 10 ? <span style="font-size:${txtSz};color:#eee;font-weight:600;line-height:${barH}px">${md}%</span> : '';
const bTxt = mb > 10 ? <span style="font-size:${txtSz};color:#ccc;font-weight:600;line-height:${barH}px">${mb}%</span> : '';

tbody += <tr data-uci="${mv.uci}" style="cursor:pointer"> <td>${mv.san}</td> <td>${pct(mt, total)}%</td> <td ${rat}>${fmt(mt)}</td> <td> <div style="display:flex;height:${barH}px;border-radius:1px;overflow:hidden"> <span style="background:rgba(255,255,255,.88);width:${mw}%; display:flex;align-items:center;justify-content:center; overflow:hidden;flex-shrink:0">${wTxt}</span> <span style="background:#696969;width:${md}%; display:flex;align-items:center;justify-content:center; overflow:hidden;flex-shrink:0">${dTxt}</span> <span style="background:#1a1a1a;width:${mb}%; display:flex;align-items:center;justify-content:center; overflow:hidden;flex-shrink:0;border:1px solid #444;box-sizing:border-box">${bTxt}</span> </div> </td> </tr>;
});

// トップゲーム(Masters のみ)
let topGames = '';
if (isM && (data.topGames || []).length) {
let rows = '';
data.topGames.slice(0, 4).forEach(g => {
const res = g.winner === 'white' ? '1-0' : g.winner === 'black' ? '0-1' : '½-½';
rows += <tr> <td>${g.white?.name ?? '?'}</td> <td>${g.white?.rating ?? ''}</td> <td style="color:#c8a028;font-weight:700;text-align:center">${res}</td> <td>${g.black?.rating ?? ''}</td> <td>${g.black?.name ?? '?'}</td> <td style="color:#666">${g.year ?? ''}</td> </tr>;
});
topGames = <table class="games">${rows}</table>;
}

panel.innerHTML = <div class="overlay"></div> <div class="data"> <div class="explorer-title" style="display:flex;align-items:center;justify-content:space-between;padding:3px 8px 0"> <span style="color:${color};font-weight:700;font-size:12px">${label}</span> <span style="color:#666;font-size:10.5px">${fmt(total)} 局</span> </div> ${opening} <table class="moves"> <thead> <tr><th>手</th><th colspan="2">局</th><th>白勝 / ドロー / 黒勝</th></tr> </thead> <tbody>${tbody}</tbody> </table> ${topGames} </div>;

addEvents(panel);
}

// イベント登録
function addEvents(panel) {
const tbody = panel.querySelector('tbody');
if (!tbody || tbody._dexBound) return;
tbody._dexBound = true;

tbody.addEventListener('mouseenter', e => {
const tr = e.target.closest('tr[data-uci]');
if (tr) onHover(tr.dataset.uci, true);
}, true);

tbody.addEventListener('mouseleave', e => {
const tr = e.target.closest('tr[data-uci]');
if (tr) onHover(tr.dataset.uci, false);
}, true);

tbody.addEventListener('click', e => {
const tr = e.target.closest('tr[data-uci]');
if (tr) onClickMove(tr.dataset.uci);
});
}

// ホバー矢印
function onHover(uci, entering) {
const native = document.querySelector(section.explorer-box.sub-box tr[data-uci="${uci}"]);
if (native) {
native.dispatchEvent(new MouseEvent(entering ? 'mouseenter' : 'mouseleave', { bubbles: true }));
}
if (entering) drawArrow(uci);
else clearArrows();
}

// クリック着手
// ネイティブ explorer の tbody に一時的な隠し TR を注入し、
// Lichess 自身のクリックハンドラを経由して着手させる
function onClickMove(uci) {
if (!uci || uci.length < 4) return;

const nativeTbody = document.querySelector('section.explorer-box.sub-box:not(#dual-explorer-panel) tbody');
if (!nativeTbody) return;

// jQueryハンドラが $(n.target).parents("tr") を使うため
// 常に TR の中の TD をクリックする必要がある(TR自身をクリックしても親に辿れない)
const existing = nativeTbody.querySelector(tr[data-uci="${uci}"]);
const tr = existing || (() => {
const t = document.createElement('tr');
t.dataset.uci = uci;
t.style.cssText = 'visibility:hidden;height:0;line-height:0;font-size:0;position:absolute';
nativeTbody.appendChild(t);
return t;
})();

let td = tr.querySelector('td');
if (!td) {
td = document.createElement('td');
tr.appendChild(td);
}
td.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
if (!existing) tr.remove();
}

// SVG 矢印
function isFlipped() {
// cg-wrap の orientation-black クラス、またはボードの transform で判定
const wrap = document.querySelector('.cg-wrap');
if (!wrap) return false;
if (wrap.classList.contains('orientation-black')) return true;
const board = wrap.querySelector('cg-board');
if (board?.style.transform?.includes('rotate')) return true;
// 白キングの位置で判定(最終手段)
const wk = wrap.querySelector('piece.king.white');
if (wk) {
const style = wk.style.transform || '';
const match = style.match(/translate((\d+)%,\s*(\d+)%)/);
if (match) return parseInt(match[2]) < 400; // 上半分にいたら黒視点
}
return false;
}

function squareXY(name, sq, flipped) {
const file = name.charCodeAt(0) - 97;
const rank = parseInt(name[1]) - 1;
const col = flipped ? 7 - file : file;
const row = flipped ? rank : 7 - rank;
return { x: col * sq + sq / 2, y: row * sq + sq / 2 };
}

function drawArrow(uci) {
if (!uci || uci.length < 4) return;
clearArrows();
const wrap = document.querySelector('.cg-wrap');
if (!wrap) return;

const rect = wrap.getBoundingClientRect();
const sq = rect.width / 8;
const flipped = isFlipped();

const f = squareXY(uci.slice(0, 2), sq, flipped);
const t = squareXY(uci.slice(2, 4), sq, flipped);

const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.classList.add('dual-arrows');
svg.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:9';
svg.setAttribute('viewBox', 0 0 ${rect.width} ${rect.width});
svg.innerHTML = <defs> <marker id="dex-head" markerWidth="3" markerHeight="3" refX="2" refY="1.5" orient="auto"> <polygon points="0 0, 3 1.5, 0 3" fill="#003088" fill-opacity="0.85"/> </marker> </defs> <line x1="${f.x}" y1="${f.y}" x2="${t.x}" y2="${t.y}" stroke="#003088" stroke-width="${sq * 0.1}" stroke-opacity="0.8" marker-end="url(#dex-head)"/>;
wrap.appendChild(svg);
}

function clearArrows() {
document.querySelectorAll('svg.dual-arrows').forEach(s => s.remove());
}

// Utilities
function pct(n, total) {
return total ? Math.round(((n || 0) / total) * 100) : 0;
}
function fmt(n) {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
if (n >= 10_000) return Math.round(n / 10_000) + '万';
if (n >= 1_000) return (n / 1000).toFixed(1) + 'k';
return String(n);
}

})();

Added interactivity to the moves table. Clicking a move in the list will now trigger the corresponding move on the Lichess board. // ==UserScript== // @name Lichess Dual Opening Explorer // @namespace https://adjva4.dpdns.org // @version 6.0.0 // @description Masters と Lichess のオープニングエクスプローラーを上下同時表示 // @author Custom // @match https://adjva4.dpdns.org/* // @grant none // @run-at document-start // ==/UserScript== (function () { 'use strict'; // 状態管理 let lastFen = null; let lastPlay = null; let lastPrimaryDb = null; let lastLichessParams = {}; let lastMastersParams = {}; let lastData = null; let lastDb = null; let renderTimer = null; let observer = null; // Fetch インターセプト const origFetch = window.fetch; window.fetch = async function (...args) { const req = args[0]; const urlStr = typeof req === 'string' ? req : (req?.url ?? ''); if (urlStr.includes('explorer.adjva4.dpdns.org')) { try { const u = new URL(urlStr); const fen = u.searchParams.get('fen'); const play = u.searchParams.get('play') || ''; const pathIsMasters = u.pathname.includes('/masters'); const pathIsLichess = u.pathname.includes('/lichess'); if (fen && (pathIsMasters || pathIsLichess)) { const db = pathIsMasters ? 'masters' : 'lichess'; if (pathIsLichess) { lastLichessParams = { variant: u.searchParams.get('variant') || 'standard', speeds: u.searchParams.get('speeds') || 'blitz,rapid,classical', ratings: u.searchParams.get('ratings') || '2000,2200,2500', since: u.searchParams.get('since') || '', }; } if (pathIsMasters) { lastMastersParams = { since: u.searchParams.get('since') || '', until: u.searchParams.get('until') || '', }; } if (fen !== lastFen || play !== lastPlay || db !== lastPrimaryDb) { lastFen = fen; lastPlay = play; lastPrimaryDb = db; scheduleSecondary(fen, play, db); } } } catch (_) {} } return origFetch.apply(this, args); }; // セカンダリ取得 function scheduleSecondary(fen, play, primaryDb) { clearTimeout(renderTimer); renderTimer = setTimeout(() => fetchSecondary(fen, play, primaryDb), 200); } async function fetchSecondary(fen, play, primaryDb) { const secondDb = primaryDb === 'masters' ? 'lichess' : 'masters'; let url; if (secondDb === 'masters') { const { since: mSince, until: mUntil } = lastMastersParams; url = `https://explorer.adjva4.dpdns.org/masters?fen=${encodeURIComponent(fen)}` + (play ? `&play=${encodeURIComponent(play)}` : '') + (mSince ? `&since=${mSince}` : '') + (mUntil ? `&until=${mUntil}` : '') + `&moves=12&topGames=4`; } else { const { variant, speeds, ratings, since } = lastLichessParams; url = `https://explorer.adjva4.dpdns.org/lichess?fen=${encodeURIComponent(fen)}` + (play ? `&play=${encodeURIComponent(play)}` : '') + `&variant=${variant || 'standard'}` + `&speeds=${speeds || 'blitz,rapid,classical'}` + `&ratings=${ratings || '2000,2200,2500'}` + (since ? `&since=${since}` : '') + `&moves=12&topGames=0&source=analysis`; } try { const res = await origFetch(url, { credentials: 'include' }); const data = await res.json(); lastData = data; lastDb = secondDb; ensurePanel(data, secondDb); } catch (e) { console.warn('[DualExplorer] fetch error:', e); } } // MutationObserver:パネルが消えたら再挿入 function startObserver() { if (observer) return; observer = new MutationObserver(() => { if (!document.getElementById('dual-explorer-panel') && lastData && lastDb) { ensurePanel(lastData, lastDb); } }); observer.observe(document.body, { childList: true, subtree: true }); } if (document.readyState !== 'loading') { startObserver(); } else { document.addEventListener('DOMContentLoaded', startObserver, { once: true }); } // パネル挿入:ネイティブ explorer の直後 function ensurePanel(data, db) { // 章一覧の直前(= エクスプローラー・棋譜エリアの直後)に挿入 const chapters = document.querySelector('.study__chapters'); if (!chapters) return; let panel = document.getElementById('dual-explorer-panel'); if (!panel) { panel = document.createElement('section'); panel.id = 'dual-explorer-panel'; panel.className = 'explorer-box sub-box'; } if (panel !== chapters.previousSibling) { chapters.before(panel); } renderPanel(panel, data, db); } // パネル描画 function renderPanel(panel, data, db) { const total = (data.white || 0) + (data.draws || 0) + (data.black || 0); const isM = db === 'masters'; const label = isM ? ' Masters DB' : ' Lichess DB'; const color = isM ? '#c5a028' : '#4a9cc9'; if (total === 0) { panel.innerHTML = ` <div class="overlay"></div> <div class="data"> <div style="padding:8px 10px;color:#555;font-size:11px"> ${label} — このポジションのデータなし </div> </div>`; addEvents(panel); return; } const opening = data.opening ? `<div class="title" title="${data.opening.eco} ${data.opening.name}"> <span style="color:#888;margin-right:4px">${data.opening.eco}</span>${data.opening.name} </div>` : ''; let tbody = ''; (data.moves || []).slice(0, 12).forEach(mv => { const mt = (mv.white || 0) + (mv.draws || 0) + (mv.black || 0); if (!mt) return; const mw = pct(mv.white, mt); const md = pct(mv.draws, mt); const mb = pct(mv.black, mt); const rat = mv.averageRating ? `title="平均レーティング: ${mv.averageRating}"` : ''; // バーの高さ: 13.5px(9px × 1.5) // 黒セグメント: #1a1a1a(暗い黒に戻す) // バー内テキスト: 10%以下は非表示 const barH = 13; const txtSz = '11.7px'; const wTxt = mw > 10 ? `<span style="font-size:${txtSz};color:#333;font-weight:600;line-height:${barH}px">${mw}%</span>` : ''; const dTxt = md > 10 ? `<span style="font-size:${txtSz};color:#eee;font-weight:600;line-height:${barH}px">${md}%</span>` : ''; const bTxt = mb > 10 ? `<span style="font-size:${txtSz};color:#ccc;font-weight:600;line-height:${barH}px">${mb}%</span>` : ''; tbody += ` <tr data-uci="${mv.uci}" style="cursor:pointer"> <td>${mv.san}</td> <td>${pct(mt, total)}%</td> <td ${rat}>${fmt(mt)}</td> <td> <div style="display:flex;height:${barH}px;border-radius:1px;overflow:hidden"> <span style="background:rgba(255,255,255,.88);width:${mw}%; display:flex;align-items:center;justify-content:center; overflow:hidden;flex-shrink:0">${wTxt}</span> <span style="background:#696969;width:${md}%; display:flex;align-items:center;justify-content:center; overflow:hidden;flex-shrink:0">${dTxt}</span> <span style="background:#1a1a1a;width:${mb}%; display:flex;align-items:center;justify-content:center; overflow:hidden;flex-shrink:0;border:1px solid #444;box-sizing:border-box">${bTxt}</span> </div> </td> </tr>`; }); // トップゲーム(Masters のみ) let topGames = ''; if (isM && (data.topGames || []).length) { let rows = ''; data.topGames.slice(0, 4).forEach(g => { const res = g.winner === 'white' ? '1-0' : g.winner === 'black' ? '0-1' : '½-½'; rows += ` <tr> <td>${g.white?.name ?? '?'}</td> <td>${g.white?.rating ?? ''}</td> <td style="color:#c8a028;font-weight:700;text-align:center">${res}</td> <td>${g.black?.rating ?? ''}</td> <td>${g.black?.name ?? '?'}</td> <td style="color:#666">${g.year ?? ''}</td> </tr>`; }); topGames = `<table class="games">${rows}</table>`; } panel.innerHTML = ` <div class="overlay"></div> <div class="data"> <div class="explorer-title" style="display:flex;align-items:center;justify-content:space-between;padding:3px 8px 0"> <span style="color:${color};font-weight:700;font-size:12px">${label}</span> <span style="color:#666;font-size:10.5px">${fmt(total)} 局</span> </div> ${opening} <table class="moves"> <thead> <tr><th>手</th><th colspan="2">局</th><th>白勝 / ドロー / 黒勝</th></tr> </thead> <tbody>${tbody}</tbody> </table> ${topGames} </div>`; addEvents(panel); } // イベント登録 function addEvents(panel) { const tbody = panel.querySelector('tbody'); if (!tbody || tbody._dexBound) return; tbody._dexBound = true; tbody.addEventListener('mouseenter', e => { const tr = e.target.closest('tr[data-uci]'); if (tr) onHover(tr.dataset.uci, true); }, true); tbody.addEventListener('mouseleave', e => { const tr = e.target.closest('tr[data-uci]'); if (tr) onHover(tr.dataset.uci, false); }, true); tbody.addEventListener('click', e => { const tr = e.target.closest('tr[data-uci]'); if (tr) onClickMove(tr.dataset.uci); }); } // ホバー矢印 function onHover(uci, entering) { const native = document.querySelector(`section.explorer-box.sub-box tr[data-uci="${uci}"]`); if (native) { native.dispatchEvent(new MouseEvent(entering ? 'mouseenter' : 'mouseleave', { bubbles: true })); } if (entering) drawArrow(uci); else clearArrows(); } // クリック着手 // ネイティブ explorer の tbody に一時的な隠し TR を注入し、 // Lichess 自身のクリックハンドラを経由して着手させる function onClickMove(uci) { if (!uci || uci.length < 4) return; const nativeTbody = document.querySelector('section.explorer-box.sub-box:not(#dual-explorer-panel) tbody'); if (!nativeTbody) return; // jQueryハンドラが $(n.target).parents("tr") を使うため // 常に TR の中の TD をクリックする必要がある(TR自身をクリックしても親に辿れない) const existing = nativeTbody.querySelector(`tr[data-uci="${uci}"]`); const tr = existing || (() => { const t = document.createElement('tr'); t.dataset.uci = uci; t.style.cssText = 'visibility:hidden;height:0;line-height:0;font-size:0;position:absolute'; nativeTbody.appendChild(t); return t; })(); let td = tr.querySelector('td'); if (!td) { td = document.createElement('td'); tr.appendChild(td); } td.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); if (!existing) tr.remove(); } // SVG 矢印 function isFlipped() { // cg-wrap の orientation-black クラス、またはボードの transform で判定 const wrap = document.querySelector('.cg-wrap'); if (!wrap) return false; if (wrap.classList.contains('orientation-black')) return true; const board = wrap.querySelector('cg-board'); if (board?.style.transform?.includes('rotate')) return true; // 白キングの位置で判定(最終手段) const wk = wrap.querySelector('piece.king.white'); if (wk) { const style = wk.style.transform || ''; const match = style.match(/translate\((\d+)%,\s*(\d+)%\)/); if (match) return parseInt(match[2]) < 400; // 上半分にいたら黒視点 } return false; } function squareXY(name, sq, flipped) { const file = name.charCodeAt(0) - 97; const rank = parseInt(name[1]) - 1; const col = flipped ? 7 - file : file; const row = flipped ? rank : 7 - rank; return { x: col * sq + sq / 2, y: row * sq + sq / 2 }; } function drawArrow(uci) { if (!uci || uci.length < 4) return; clearArrows(); const wrap = document.querySelector('.cg-wrap'); if (!wrap) return; const rect = wrap.getBoundingClientRect(); const sq = rect.width / 8; const flipped = isFlipped(); const f = squareXY(uci.slice(0, 2), sq, flipped); const t = squareXY(uci.slice(2, 4), sq, flipped); const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.classList.add('dual-arrows'); svg.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:9'; svg.setAttribute('viewBox', `0 0 ${rect.width} ${rect.width}`); svg.innerHTML = ` <defs> <marker id="dex-head" markerWidth="3" markerHeight="3" refX="2" refY="1.5" orient="auto"> <polygon points="0 0, 3 1.5, 0 3" fill="#003088" fill-opacity="0.85"/> </marker> </defs> <line x1="${f.x}" y1="${f.y}" x2="${t.x}" y2="${t.y}" stroke="#003088" stroke-width="${sq * 0.1}" stroke-opacity="0.8" marker-end="url(#dex-head)"/>`; wrap.appendChild(svg); } function clearArrows() { document.querySelectorAll('svg.dual-arrows').forEach(s => s.remove()); } // Utilities function pct(n, total) { return total ? Math.round(((n || 0) / total) * 100) : 0; } function fmt(n) { if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'; if (n >= 10_000) return Math.round(n / 10_000) + '万'; if (n >= 1_000) return (n / 1000).toFixed(1) + 'k'; return String(n); } })();

Thanks, but what does it do? What's the moves table?

Thanks, but what does it do? What's the moves table?

@TotalNoob69

WS000000.JPG

It allows you to view both the Lichess and Masters databases at the same time. This saves the trouble of manually switching between them and makes it much easier to compare moves.

@TotalNoob69 ![WS000000.JPG](https://image.lichess1.org/display?op=noop&path=Pdr9LQi3OvT0.jpg&sig=e2181df9a0bfa1ac0a613adfb9c05c52adbe6022) It allows you to view both the Lichess and Masters databases at the same time. This saves the trouble of manually switching between them and makes it much easier to compare moves.

Thanks, @TarouChess ! I will see if I can make it part of LT. The major problem I see here is that it takes a lot of space and that's why I never implemented it. But if you made the effort of writing a script, maybe it's worth it.

Will look into it.

Thanks, @TarouChess ! I will see if I can make it part of LT. The major problem I see here is that it takes a lot of space and that's why I never implemented it. But if you made the effort of writing a script, maybe it's worth it. Will look into it.

I've streamlined the code and made the move table more compact.

// ==UserScript==
// @name Lichess Dual Opening Explorer
// @namespace https://adjva4.dpdns.org
// @version 7.0.0
// @description Masters と Lichess のオープニングエクスプローラーを上下同時表示
// @author Custom
// @match https://adjva4.dpdns.org/*
// @grant none
// @run-at document-start
// ==/UserScript==

(function () {
'use strict';

const SEL_NATIVE = 'section.explorer-box.sub-box:not(#dual-explorer-panel)';
const PANEL_ID = 'dual-explorer-panel';
const API = 'https://explorer.adjva4.dpdns.org';
const BAR_H = 13;
const TXT_SZ = '11.7px';

// 状態
const state = {
fen: null, play: null, primaryDb: null,
lichessParams: {}, mastersParams: {},
data: null, db: null,
timer: null,
};

// Fetch インターセプト
const origFetch = window.fetch;
window.fetch = async function (...args) {
const url = typeof args[0] === 'string' ? args[0] : (args[0]?.url ?? '');
if (url.includes('explorer.adjva4.dpdns.org')) {
try { onExplorerRequest(new URL(url)); } catch (_) {}
}
return origFetch.apply(this, args);
};

function onExplorerRequest(u) {
const fen = u.searchParams.get('fen');
const play = u.searchParams.get('play') || '';
const isMasters = u.pathname.includes('/masters');
const isLichess = u.pathname.includes('/lichess');
if (!fen || (!isMasters && !isLichess)) return;

const db = isMasters ? 'masters' : 'lichess';

if (isMasters) {
state.mastersParams = {
since: u.searchParams.get('since') || '',
until: u.searchParams.get('until') || '',
};
} else {
state.lichessParams = {
variant: u.searchParams.get('variant') || 'standard',
speeds: u.searchParams.get('speeds') || 'blitz,rapid,classical',
ratings: u.searchParams.get('ratings') || '2000,2200,2500',
since: u.searchParams.get('since') || '',
};
}

if (fen !== state.fen || play !== state.play || db !== state.primaryDb) {
Object.assign(state, { fen, play, primaryDb: db });
clearTimeout(state.timer);
state.timer = setTimeout(() => fetchSecondary(fen, play, db), 200);
}
}

// セカンダリ取得
async function fetchSecondary(fen, play, primaryDb) {
const db = primaryDb === 'masters' ? 'lichess' : 'masters';
const p = play ? &play=${encodeURIComponent(play)} : '';
const f = fen=${encodeURIComponent(fen)};

const url = db === 'masters'
? ${API}/masters?${f}${p}
+ (state.mastersParams.since ? &since=${state.mastersParams.since} : '')
+ (state.mastersParams.until ? &until=${state.mastersParams.until} : '')
+ &moves=6&topGames=0
: ${API}/lichess?${f}${p}
+ &variant=${state.lichessParams.variant || 'standard'}
+ &speeds=${state.lichessParams.speeds || 'blitz,rapid,classical'}
+ &ratings=${state.lichessParams.ratings || '2000,2200,2500'}
+ (state.lichessParams.since ? &since=${state.lichessParams.since} : '')
+ &moves=6&topGames=0&source=analysis;

try {
const data = await origFetch(url, { credentials: 'include' }).then(r => r.json());
Object.assign(state, { data, db });
ensurePanel(data, db);
} catch (e) {
console.warn('[DualExplorer]', e);
}
}

// パネル永続化
function startObserver() {
new MutationObserver(() => {
if (!document.getElementById(PANEL_ID) && state.data) {
ensurePanel(state.data, state.db);
}
}).observe(document.body, { childList: true, subtree: true });
}

document.readyState === 'loading'
? document.addEventListener('DOMContentLoaded', startObserver, { once: true })
: startObserver();

// パネル挿入
function ensurePanel(data, db) {
const anchor = document.querySelector('.study__chapters');
if (!anchor) return;

let panel = document.getElementById(PANEL_ID);
if (!panel) {
panel = Object.assign(document.createElement('section'), {
id: PANEL_ID, className: 'explorer-box sub-box',
});
}
if (panel !== anchor.previousSibling) anchor.before(panel);
renderPanel(panel, data, db);
}

// パネル描画
function renderPanel(panel, data, db) {
const total = (data.white || 0) + (data.draws || 0) + (data.black || 0);
const isM = db === 'masters';
const label = isM ? ' Masters DB' : ' Lichess DB';
const color = isM ? '#c5a028' : '#4a9cc9';

if (!total) {
panel.innerHTML = <div class="overlay"></div> <div class="data" style="padding:8px 10px;color:#555;font-size:11px"> ${label} — このポジションのデータなし</div>;
bindEvents(panel);
return;
}

const rows = (data.moves || []).slice(0, 6).map(mv => {
const mt = (mv.white || 0) + (mv.draws || 0) + (mv.black || 0);
if (!mt) return '';
const [mw, md, mb] = [pct(mv.white, mt), pct(mv.draws, mt), pct(mv.black, mt)];
const rat = mv.averageRating ? title="平均レーティング: ${mv.averageRating}" : '';
const seg = (bg, w, txt, border = '') =>
<span style="background:${bg};width:${w}%;display:flex;align-items:center; justify-content:center;overflow:hidden;flex-shrink:0${border}"> ${w > 10 ?<span style="font-size:${TXT_SZ};font-weight:600;line-height:${BAR_H}px">${txt}</span>: ''} </span>;

return <tr data-uci="${mv.uci}" style="cursor:pointer"> <td>${mv.san}</td> <td>${pct(mt, total)}%</td> <td ${rat}>${fmt(mt)}</td> <td><div style="display:flex;height:${BAR_H}px;border-radius:1px;overflow:hidden"> ${seg('rgba(255,255,255,.88)', mw,<span style="color:#333">${mw}%</span>)} ${seg('#696969', md,<span style="color:#eee">${md}%</span>)} ${seg('#1a1a1a', mb,<span style="color:#ccc">${mb}%</span>, ';border:1px solid #444;box-sizing:border-box')} </div></td> </tr>;
}).join('');

panel.innerHTML = <div class="overlay"></div> <div class="data"> <div class="explorer-title" style="display:flex;align-items:center;justify-content:space-between;padding:3px 8px 0"> <span style="color:${color};font-weight:700;font-size:12px">${label}</span> <span style="color:#666;font-size:10.5px">${fmt(total)} 局</span> </div> <table class="moves"> <thead><tr><th>手</th><th colspan="2">局</th><th>白勝 / ドロー / 黒勝</th></tr></thead> <tbody>${rows}</tbody> </table> </div>;

bindEvents(panel);
}

// イベント
function bindEvents(panel) {
const tbody = panel.querySelector('tbody');
if (!tbody || tbody._dex) return;
tbody._dex = true;

const uciOf = e => e.target.closest('tr[data-uci]')?.dataset.uci;

tbody.addEventListener('mouseenter', e => { const u = uciOf(e); if (u) hover(u, true); }, true);
tbody.addEventListener('mouseleave', e => { const u = uciOf(e); if (u) hover(u, false); }, true);
tbody.addEventListener('click', e => { const u = uciOf(e); if (u) clickMove(u); });
}

function hover(uci, on) {
const native = document.querySelector(${SEL_NATIVE} tr[data-uci="${uci}"]);
native?.dispatchEvent(new MouseEvent(on ? 'mouseenter' : 'mouseleave', { bubbles: true }));
on ? drawArrow(uci) : clearArrows();
}

function clickMove(uci) {
if (!uci || uci.length < 4) return;
const tbody = document.querySelector(${SEL_NATIVE} tbody);
if (!tbody) return;

const existing = tbody.querySelector(tr[data-uci="${uci}"]);
const tr = existing ?? (() => {
const t = Object.assign(document.createElement('tr'), { dataset: { uci } });
t.style.cssText = 'visibility:hidden;height:0;line-height:0;font-size:0;position:absolute';
tbody.appendChild(t);
return t;
})();

const td = tr.querySelector('td') ?? tr.appendChild(document.createElement('td'));
td.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
if (!existing) tr.remove();
}

// SVG 矢印
function isFlipped() {
const wrap = document.querySelector('.cg-wrap');
if (!wrap) return false;
if (wrap.classList.contains('orientation-black')) return true;
if (wrap.querySelector('cg-board')?.style.transform?.includes('rotate')) return true;
const wk = wrap.querySelector('piece.king.white');
if (wk) {
const m = (wk.style.transform || '').match(/translate((\d+)%,\s*(\d+)%)/);
if (m) return parseInt(m[2]) < 400;
}
return false;
}

function sqXY(name, sq, flip) {
const f = name.charCodeAt(0) - 97, r = name[1] - 1;
return { x: (flip ? 7 - f : f) * sq + sq / 2, y: (flip ? r : 7 - r) * sq + sq / 2 };
}

function drawArrow(uci) {
clearArrows();
const wrap = document.querySelector('.cg-wrap');
if (!wrap || uci.length < 4) return;
const rect = wrap.getBoundingClientRect();
const sq = rect.width / 8;
const flip = isFlipped();
const f = sqXY(uci.slice(0, 2), sq, flip);
const t = sqXY(uci.slice(2, 4), sq, flip);
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.classList.add('dual-arrows');
svg.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:9';
svg.setAttribute('viewBox', 0 0 ${rect.width} ${rect.width});
svg.innerHTML = <defs><marker id="dex-head" markerWidth="3" markerHeight="3" refX="2" refY="1.5" orient="auto"> <polygon points="0 0,3 1.5,0 3" fill="#003088" fill-opacity=".85"/></marker></defs> <line x1="${f.x}" y1="${f.y}" x2="${t.x}" y2="${t.y}" stroke="#003088" stroke-width="${sq * 0.1}" stroke-opacity=".8" marker-end="url(#dex-head)"/>;
wrap.appendChild(svg);
}

function clearArrows() {
document.querySelectorAll('svg.dual-arrows').forEach(s => s.remove());
}

// ユーティリティ
const pct = (n, t) => t ? Math.round((n || 0) / t * 100) : 0;
const fmt = n => n >= 1e6 ? (n/1e6).toFixed(1)+'M' : n >= 1e4 ? Math.round(n/1e4)+'万' : n >= 1e3 ? (n/1e3).toFixed(1)+'k' : String(n);

})();

I've streamlined the code and made the move table more compact. // ==UserScript== // @name Lichess Dual Opening Explorer // @namespace https://adjva4.dpdns.org // @version 7.0.0 // @description Masters と Lichess のオープニングエクスプローラーを上下同時表示 // @author Custom // @match https://adjva4.dpdns.org/* // @grant none // @run-at document-start // ==/UserScript== (function () { 'use strict'; const SEL_NATIVE = 'section.explorer-box.sub-box:not(#dual-explorer-panel)'; const PANEL_ID = 'dual-explorer-panel'; const API = 'https://explorer.adjva4.dpdns.org'; const BAR_H = 13; const TXT_SZ = '11.7px'; // 状態 const state = { fen: null, play: null, primaryDb: null, lichessParams: {}, mastersParams: {}, data: null, db: null, timer: null, }; // Fetch インターセプト const origFetch = window.fetch; window.fetch = async function (...args) { const url = typeof args[0] === 'string' ? args[0] : (args[0]?.url ?? ''); if (url.includes('explorer.adjva4.dpdns.org')) { try { onExplorerRequest(new URL(url)); } catch (_) {} } return origFetch.apply(this, args); }; function onExplorerRequest(u) { const fen = u.searchParams.get('fen'); const play = u.searchParams.get('play') || ''; const isMasters = u.pathname.includes('/masters'); const isLichess = u.pathname.includes('/lichess'); if (!fen || (!isMasters && !isLichess)) return; const db = isMasters ? 'masters' : 'lichess'; if (isMasters) { state.mastersParams = { since: u.searchParams.get('since') || '', until: u.searchParams.get('until') || '', }; } else { state.lichessParams = { variant: u.searchParams.get('variant') || 'standard', speeds: u.searchParams.get('speeds') || 'blitz,rapid,classical', ratings: u.searchParams.get('ratings') || '2000,2200,2500', since: u.searchParams.get('since') || '', }; } if (fen !== state.fen || play !== state.play || db !== state.primaryDb) { Object.assign(state, { fen, play, primaryDb: db }); clearTimeout(state.timer); state.timer = setTimeout(() => fetchSecondary(fen, play, db), 200); } } // セカンダリ取得 async function fetchSecondary(fen, play, primaryDb) { const db = primaryDb === 'masters' ? 'lichess' : 'masters'; const p = play ? `&play=${encodeURIComponent(play)}` : ''; const f = `fen=${encodeURIComponent(fen)}`; const url = db === 'masters' ? `${API}/masters?${f}${p}` + (state.mastersParams.since ? `&since=${state.mastersParams.since}` : '') + (state.mastersParams.until ? `&until=${state.mastersParams.until}` : '') + `&moves=6&topGames=0` : `${API}/lichess?${f}${p}` + `&variant=${state.lichessParams.variant || 'standard'}` + `&speeds=${state.lichessParams.speeds || 'blitz,rapid,classical'}` + `&ratings=${state.lichessParams.ratings || '2000,2200,2500'}` + (state.lichessParams.since ? `&since=${state.lichessParams.since}` : '') + `&moves=6&topGames=0&source=analysis`; try { const data = await origFetch(url, { credentials: 'include' }).then(r => r.json()); Object.assign(state, { data, db }); ensurePanel(data, db); } catch (e) { console.warn('[DualExplorer]', e); } } // パネル永続化 function startObserver() { new MutationObserver(() => { if (!document.getElementById(PANEL_ID) && state.data) { ensurePanel(state.data, state.db); } }).observe(document.body, { childList: true, subtree: true }); } document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', startObserver, { once: true }) : startObserver(); // パネル挿入 function ensurePanel(data, db) { const anchor = document.querySelector('.study__chapters'); if (!anchor) return; let panel = document.getElementById(PANEL_ID); if (!panel) { panel = Object.assign(document.createElement('section'), { id: PANEL_ID, className: 'explorer-box sub-box', }); } if (panel !== anchor.previousSibling) anchor.before(panel); renderPanel(panel, data, db); } // パネル描画 function renderPanel(panel, data, db) { const total = (data.white || 0) + (data.draws || 0) + (data.black || 0); const isM = db === 'masters'; const label = isM ? ' Masters DB' : ' Lichess DB'; const color = isM ? '#c5a028' : '#4a9cc9'; if (!total) { panel.innerHTML = `<div class="overlay"></div> <div class="data" style="padding:8px 10px;color:#555;font-size:11px"> ${label} — このポジションのデータなし</div>`; bindEvents(panel); return; } const rows = (data.moves || []).slice(0, 6).map(mv => { const mt = (mv.white || 0) + (mv.draws || 0) + (mv.black || 0); if (!mt) return ''; const [mw, md, mb] = [pct(mv.white, mt), pct(mv.draws, mt), pct(mv.black, mt)]; const rat = mv.averageRating ? `title="平均レーティング: ${mv.averageRating}"` : ''; const seg = (bg, w, txt, border = '') => `<span style="background:${bg};width:${w}%;display:flex;align-items:center; justify-content:center;overflow:hidden;flex-shrink:0${border}"> ${w > 10 ? `<span style="font-size:${TXT_SZ};font-weight:600;line-height:${BAR_H}px">${txt}</span>` : ''} </span>`; return `<tr data-uci="${mv.uci}" style="cursor:pointer"> <td>${mv.san}</td> <td>${pct(mt, total)}%</td> <td ${rat}>${fmt(mt)}</td> <td><div style="display:flex;height:${BAR_H}px;border-radius:1px;overflow:hidden"> ${seg('rgba(255,255,255,.88)', mw, `<span style="color:#333">${mw}%</span>`)} ${seg('#696969', md, `<span style="color:#eee">${md}%</span>`)} ${seg('#1a1a1a', mb, `<span style="color:#ccc">${mb}%</span>`, ';border:1px solid #444;box-sizing:border-box')} </div></td> </tr>`; }).join(''); panel.innerHTML = `<div class="overlay"></div> <div class="data"> <div class="explorer-title" style="display:flex;align-items:center;justify-content:space-between;padding:3px 8px 0"> <span style="color:${color};font-weight:700;font-size:12px">${label}</span> <span style="color:#666;font-size:10.5px">${fmt(total)} 局</span> </div> <table class="moves"> <thead><tr><th>手</th><th colspan="2">局</th><th>白勝 / ドロー / 黒勝</th></tr></thead> <tbody>${rows}</tbody> </table> </div>`; bindEvents(panel); } // イベント function bindEvents(panel) { const tbody = panel.querySelector('tbody'); if (!tbody || tbody._dex) return; tbody._dex = true; const uciOf = e => e.target.closest('tr[data-uci]')?.dataset.uci; tbody.addEventListener('mouseenter', e => { const u = uciOf(e); if (u) hover(u, true); }, true); tbody.addEventListener('mouseleave', e => { const u = uciOf(e); if (u) hover(u, false); }, true); tbody.addEventListener('click', e => { const u = uciOf(e); if (u) clickMove(u); }); } function hover(uci, on) { const native = document.querySelector(`${SEL_NATIVE} tr[data-uci="${uci}"]`); native?.dispatchEvent(new MouseEvent(on ? 'mouseenter' : 'mouseleave', { bubbles: true })); on ? drawArrow(uci) : clearArrows(); } function clickMove(uci) { if (!uci || uci.length < 4) return; const tbody = document.querySelector(`${SEL_NATIVE} tbody`); if (!tbody) return; const existing = tbody.querySelector(`tr[data-uci="${uci}"]`); const tr = existing ?? (() => { const t = Object.assign(document.createElement('tr'), { dataset: { uci } }); t.style.cssText = 'visibility:hidden;height:0;line-height:0;font-size:0;position:absolute'; tbody.appendChild(t); return t; })(); const td = tr.querySelector('td') ?? tr.appendChild(document.createElement('td')); td.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); if (!existing) tr.remove(); } // SVG 矢印 function isFlipped() { const wrap = document.querySelector('.cg-wrap'); if (!wrap) return false; if (wrap.classList.contains('orientation-black')) return true; if (wrap.querySelector('cg-board')?.style.transform?.includes('rotate')) return true; const wk = wrap.querySelector('piece.king.white'); if (wk) { const m = (wk.style.transform || '').match(/translate\((\d+)%,\s*(\d+)%\)/); if (m) return parseInt(m[2]) < 400; } return false; } function sqXY(name, sq, flip) { const f = name.charCodeAt(0) - 97, r = name[1] - 1; return { x: (flip ? 7 - f : f) * sq + sq / 2, y: (flip ? r : 7 - r) * sq + sq / 2 }; } function drawArrow(uci) { clearArrows(); const wrap = document.querySelector('.cg-wrap'); if (!wrap || uci.length < 4) return; const rect = wrap.getBoundingClientRect(); const sq = rect.width / 8; const flip = isFlipped(); const f = sqXY(uci.slice(0, 2), sq, flip); const t = sqXY(uci.slice(2, 4), sq, flip); const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.classList.add('dual-arrows'); svg.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:9'; svg.setAttribute('viewBox', `0 0 ${rect.width} ${rect.width}`); svg.innerHTML = `<defs><marker id="dex-head" markerWidth="3" markerHeight="3" refX="2" refY="1.5" orient="auto"> <polygon points="0 0,3 1.5,0 3" fill="#003088" fill-opacity=".85"/></marker></defs> <line x1="${f.x}" y1="${f.y}" x2="${t.x}" y2="${t.y}" stroke="#003088" stroke-width="${sq * 0.1}" stroke-opacity=".8" marker-end="url(#dex-head)"/>`; wrap.appendChild(svg); } function clearArrows() { document.querySelectorAll('svg.dual-arrows').forEach(s => s.remove()); } // ユーティリティ const pct = (n, t) => t ? Math.round((n || 0) / t * 100) : 0; const fmt = n => n >= 1e6 ? (n/1e6).toFixed(1)+'M' : n >= 1e4 ? Math.round(n/1e4)+'万' : n >= 1e3 ? (n/1e3).toFixed(1)+'k' : String(n); })();

Join the L1Chess Tools Users Team team, to post in this forum