Greasy Fork is available in English.
一键高亮云崽并展示相关数据
// ==UserScript==
// @name 云崽高亮器
// @namespace https://github.com/hinotoyk/contrail_progeny
// @version 3.2.0
// @description 一键高亮云崽并展示相关数据
// @author hinotoyk
// @license CC BY-NC-SA 4.0
// @match *://*/*
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @connect docs.google.com
// @connect googleusercontent.com
// ==/UserScript==
(function () {
'use strict';
if (window.top !== window.self) {
return;
}
/**
* Module: Constants
* 常量定义
*/
const Constants = {
// 主数据源:Google Sheets(按年份管理的 sheet)
MAIN_SHEET_ID: '1lUlndcCVPly7dV13LswGZKlaMu145XBVGxl4hXIkfus',
MAIN_SHEETS: [
{ gid: '35201753', source: '2023', label: '2023年生(2025年2岁)' },
{ gid: '2033113937', source: '2024', label: '2024年生(2026年2岁)' }
],
// 辅助数据源:赛绩 & 登录信息
SHEET_URL: `https://docs.google.com/spreadsheets/d/1PPasJnqqBQy_cbhXLDJ0V11CTUDJs6UBtRwe-nsCNfc/export?format=csv&gid=0`,
RACE_SHEET_URL: `https://docs.google.com/spreadsheets/d/1PPasJnqqBQy_cbhXLDJ0V11CTUDJs6UBtRwe-nsCNfc/export?format=csv&gid=1454271910`,
CACHE_KEY: 'contrail_progeny_data',
SHEET_CACHE_KEY: 'sheet_csv_cache',
RACE_SHEET_CACHE_KEY: 'sheet_race_cache',
CACHE_EXPIRY: 30 * 60 * 1000, // 30分钟(主数据来自 Google Sheets,缩短缓存时间)
SHEET_CACHE_EXPIRY: 10 * 60 * 1000, // 10分钟
// 列名别名映射(兼容不同 sheet 的列名差异)
COLUMN_ALIASES: {
'血统评价': '血统分析'
},
ALPINE_URL: 'https://unpkg.com/[email protected]/dist/cdn.min.js',
GRADE_ORDER: ['GI', 'JpnI', 'GII', 'JpnII', 'GIII', 'JpnIII', 'L', 'OP', ''],
SITE_STORAGE_KEY: 'contrail_progeny_sites',
SITE_STORAGE_VERSION: 1,
DEFAULT_SITES: [
{ id: 'jra', label: 'JRA 官方网站', pattern: 'https://www.jra.go.jp/*', enabled: true, origin: 'default' },
{ id: 'jbis', label: 'JBIS 官方数据库', pattern: 'https://www.jbis.or.jp/*', enabled: true, origin: 'default' },
{ id: 'netkeiba', label: 'netkeiba', pattern: 'https://*.netkeiba.com/*', enabled: true, origin: 'default' },
{ id: 'keibanomiryoku', label: '競馬の魅力', pattern: 'https://www.keibanomiryoku.com/*', enabled: true, origin: 'default' }
]
};
/**
* Module: Utils
* 工具函数
*/
const Utils = {
escapeHTML(str) {
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
},
escapeRegExp(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
},
matchWildcard(url, pattern) {
if (!pattern) return false;
const escaped = this.escapeRegExp(pattern).replace(/\\\*/g, '.*');
const regex = new RegExp(`^${escaped}$`, 'i');
return regex.test(url);
},
onDocumentReady(cb) {
if (document.readyState === 'loading') {
const once = () => {
document.removeEventListener('DOMContentLoaded', once);
cb();
};
document.addEventListener('DOMContentLoaded', once);
} else {
cb();
}
},
/**
* 円 → 日式金额显示(万円 / 億)
* @param {number|string} amount
* @returns {string}
*/
formatJPY(amount) {
if (amount === null || amount === undefined || amount === '') return '';
// 移除逗号
const cleanAmount = String(amount).replace(/,/g, '');
const num = Number(cleanAmount);
if (Number.isNaN(num)) return '';
const YEN_PER_MAN = 10000;
const YEN_PER_OKU = 100000000;
// 小于 1 亿
if (num < YEN_PER_OKU) {
const man = num / YEN_PER_MAN;
// 保留 1 位小数,去掉多余 0
const manStr = man.toFixed(1).replace(/\.0$/, '');
return `${manStr}万円`;
}
// 大于等于 1 亿
const oku = Math.floor(num / YEN_PER_OKU);
const rest = num % YEN_PER_OKU;
const man = Math.floor(rest / YEN_PER_MAN);
if (man > 0) {
return `${oku}億${man}万円`;
} else {
return `${oku}億`;
}
},
nl2br(str) {
return this.escapeHTML(str).replace(/\n/g, '<br>');
},
formatDate(dateStr) {
if (!dateStr) return null;
const d = new Date(dateStr);
if (isNaN(d.getTime())) return dateStr;
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
},
loadAlpine(cb) {
if (window.Alpine) return cb();
const script = document.createElement('script');
script.src = Constants.ALPINE_URL;
script.defer = true;
script.onload = cb;
document.head.appendChild(script);
},
calculateStats(races) {
if (!races || !races.length) return null;
let total = 0;
let first = 0, second = 0, third = 0, fourth = 0, fifth = 0, unplaced = 0;
races.forEach(r => {
// 解析着顺,可能是数字字符串,也可能是 "1(降)" 等
const resultStr = String(r.result).trim();
const rank = parseInt(resultStr, 10);
// 只要有结果,就计入总场数(排除取消、中止等非数字情况)
if (!isNaN(rank)) {
total++;
if (rank === 1) first++;
else if (rank === 2) second++;
else if (rank === 3) third++;
else if (rank === 4) fourth++;
else if (rank === 5) fifth++;
else unplaced++;
}
});
const formatRate = (num, den) => {
if (den === 0) return '0%';
return Math.round((num / den) * 100) + '%';
};
const topFive = first + second + third + fourth + fifth;
return {
total,
wins: first,
first,
second,
third,
unplaced,
winRate: formatRate(first, total),
quinellaRate: formatRate(first + second, total),
placeRate: formatRate(first + second + third, total),
boardRate: formatRate(topFive, total) // 进板率 = 前5名 / 总数
};
},
calculateWins(races) {
if (!races || !races.length) return [];
// 筛选出所有获胜比赛(着顺为1)
const wins = races.filter(r => String(r.result).trim() === '1');
if (!wins.length) return [];
// 排序逻辑:格高者优先,同格日期新者优先
wins.sort((a, b) => {
const gradeA = (a.grade || '').trim();
const gradeB = (b.grade || '').trim();
const idxA = Constants.GRADE_ORDER.indexOf(gradeA);
const idxB = Constants.GRADE_ORDER.indexOf(gradeB);
// 如果都不在列表中(未知等级),按字符串排序(或者都视为最低级)
// 这里假设不在列表中的等级排在列表之后
const rankA = idxA === -1 ? 999 : idxA;
const rankB = idxB === -1 ? 999 : idxB;
if (rankA !== rankB) {
return rankA - rankB;
}
// 同等级,按日期倒序
return new Date(b.date) - new Date(a.date);
});
// 过滤逻辑:
// 如果所有胜鞍都是空白格(普通赛事),只保留最新的3个
// 如果有分级赛胜鞍,全部展示
const hasOpOrHigherWin = wins.some(w => {
const g = (w.grade || '').trim();
// OP is index 7 in GRADE_ORDER
return g && Constants.GRADE_ORDER.indexOf(g) !== -1 && Constants.GRADE_ORDER.indexOf(g) <= 7;
});
if (hasOpOrHigherWin) {
// 如果有 OP 以上的胜鞍,过滤掉空白格的胜鞍
return wins.filter(w => {
const g = (w.grade || '').trim();
return g && Constants.GRADE_ORDER.indexOf(g) !== -1 && Constants.GRADE_ORDER.indexOf(g) <= 7;
});
}
// 否则(只有空白格胜鞍),只保留最新的3个
return wins.slice(0, 3);
},
getLatestRace(races) {
if (!races || !races.length) return null;
// 假设 races 已经按日期倒序排列
return races[0];
}
};
/**
* Module: SiteManager
* 支持站点动态管理
*/
const SiteManager = {
sites: [],
onSiteEnabled: null,
init(options = {}) {
this.onSiteEnabled = options.onSiteEnabled || null;
this.sites = this.loadSites();
this.renderMenu();
},
loadSites() {
try {
const stored = GM_getValue(Constants.SITE_STORAGE_KEY, null);
if (stored && stored.version === Constants.SITE_STORAGE_VERSION && Array.isArray(stored.sites)) {
return this.mergeDefaults(this.normalizeSites(stored.sites));
}
} catch (err) {
console.warn('Failed to load site configuration', err);
}
const defaults = this.normalizeSites(Constants.DEFAULT_SITES);
this.saveSites(defaults);
return defaults;
},
mergeDefaults(currentSites) {
const patternSet = new Set(currentSites.map(site => site.pattern));
Constants.DEFAULT_SITES.forEach(def => {
if (!patternSet.has(def.pattern)) {
currentSites.push({ ...def });
}
});
return this.normalizeSites(currentSites);
},
normalizeSites(list) {
const usedIds = new Set();
return list.reduce((acc, site) => {
const pattern = (site.pattern || '').trim();
if (!pattern) return acc;
let id = site.id && !usedIds.has(site.id)
? site.id
: this.makeIdFromPattern(pattern, usedIds);
usedIds.add(id);
acc.push({
id,
label: (site.label || pattern).trim(),
pattern,
enabled: site.enabled !== false,
origin: site.origin || 'user'
});
return acc;
}, []);
},
makeIdFromPattern(pattern, usedIds) {
const base = pattern
.replace(/[^a-z0-9]+/gi, '_')
.replace(/^_+|_+$/g, '')
.toLowerCase() || 'site';
let candidate = base;
let idx = 1;
while (usedIds.has(candidate)) {
candidate = `${base}_${idx++}`;
}
usedIds.add(candidate);
return candidate;
},
saveSites(sites) {
GM_setValue(Constants.SITE_STORAGE_KEY, {
version: Constants.SITE_STORAGE_VERSION,
sites: this.normalizeSites(sites)
});
},
persist() {
this.sites = this.normalizeSites(this.sites);
this.saveSites(this.sites);
},
renderMenu() {
if (typeof GM_registerMenuCommand !== 'function') return;
GM_registerMenuCommand('Contrail · 添加当前站点', () => this.openSiteModal('add'));
GM_registerMenuCommand('Contrail · 管理站点列表', () => this.openSiteModal('manage'));
},
getActiveSites() {
return this.sites.filter(site => site.enabled);
},
isCurrentSiteEnabled() {
const href = window.location.href;
return this.getActiveSites().some(site => Utils.matchWildcard(href, site.pattern));
},
getTopLevelLocation() {
try {
const topLocation = window.top?.location;
if (topLocation) {
return {
origin: topLocation.origin,
href: topLocation.href,
hostname: topLocation.hostname
};
}
} catch (err) {
// ignore cross-origin errors
}
return {
origin: window.location.origin,
href: window.location.href,
hostname: window.location.hostname
};
},
promptAddCurrentSite() {
const topLoc = this.getTopLevelLocation();
const defaultPattern = `${topLoc.origin}/*`;
return this.openSiteModal('add', {
pattern: defaultPattern,
label: document.title || topLoc.hostname || defaultPattern
});
},
openManagePrompt() {
this.openSiteModal('manage');
},
toggleSite(index) {
const site = this.sites[index];
if (!site) return;
this.sites[index] = { ...site, enabled: !site.enabled };
this.persist();
if (this.isCurrentSiteEnabled() && this.sites[index].enabled && this.onSiteEnabled) {
this.onSiteEnabled();
}
},
deleteSite(index) {
const site = this.sites[index];
if (!site) return;
if (site.origin === 'default') {
if (typeof alert === 'function') alert('默认站点不可删除,可通过切换禁用');
return;
}
this.sites.splice(index, 1);
this.persist();
if (this.isCurrentSiteEnabled() && this.onSiteEnabled) {
this.onSiteEnabled();
}
},
renderInactiveBanner() {},
removeInactiveBanner() {},
openSiteModal(mode, options = {}) {
const topLoc = this.getTopLevelLocation();
const overlay = document.createElement('div');
overlay.className = 'contrail-modal-overlay';
const modal = document.createElement('div');
modal.className = 'contrail-modal';
const closeModal = () => {
overlay.remove();
};
overlay.addEventListener('click', (event) => {
if (event.target === overlay) {
closeModal();
}
});
const renderAdd = () => {
const patternValue = options.pattern || `${topLoc.origin}/*`;
const labelValue = options.label || document.title || topLoc.hostname || patternValue;
modal.innerHTML = `
<div class="contrail-modal__header">
<div class="contrail-modal__title">添加支持站点</div>
<button class="contrail-modal__close" aria-label="关闭">×</button>
</div>
<div class="contrail-modal__body">
<label class="contrail-modal__field">
<span>地址匹配规则 <small>(支持 * 通配符)</small></span>
<input type="text" class="contrail-modal__input" data-field="pattern" value="${patternValue}">
</label>
<label class="contrail-modal__field">
<span>站点名称</span>
<input type="text" class="contrail-modal__input" data-field="label" value="${labelValue}">
</label>
</div>
<div class="contrail-modal__footer">
<button class="contrail-btn contrail-btn--secondary" data-action="cancel">取消</button>
<button class="contrail-btn contrail-btn--primary" data-action="submit">保存并启用</button>
</div>
`;
modal.querySelector('[data-action="submit"]').addEventListener('click', () => {
const pattern = modal.querySelector('[data-field="pattern"]').value.trim();
const label = modal.querySelector('[data-field="label"]').value.trim() || pattern;
if (!pattern) {
alert('地址匹配规则不能为空');
return;
}
const existingIndex = this.sites.findIndex(site => site.pattern === pattern);
if (existingIndex !== -1) {
this.sites[existingIndex] = {
...this.sites[existingIndex],
label,
pattern,
enabled: true
};
} else {
this.sites.push({
id: null,
label,
pattern,
enabled: true,
origin: 'user'
});
}
this.persist();
closeModal();
if (this.onSiteEnabled && this.isCurrentSiteEnabled()) {
this.onSiteEnabled();
}
alert('站点已保存并启用');
});
};
const renderManage = () => {
if (!this.sites.length) {
modal.innerHTML = `
<div class="contrail-modal__header">
<div class="contrail-modal__title">管理支持站点</div>
<button class="contrail-modal__close" aria-label="关闭">×</button>
</div>
<div class="contrail-modal__body">
<div class="contrail-empty">尚未配置任何站点</div>
</div>
<div class="contrail-modal__footer">
<button class="contrail-btn contrail-btn--secondary" data-action="close">关闭</button>
</div>
`;
return;
}
const listHtml = this.sites.map((site, idx) => {
const statusClass = site.enabled ? 'is-enabled' : 'is-disabled';
const toggleLabel = site.enabled ? '已启用' : '已禁用';
const badge = site.origin === 'default' ? '<span class="contrail-tag">默认</span>' : '';
const deleteDisabled = site.origin === 'default' ? 'disabled' : '';
return `
<div class="contrail-site-item ${statusClass}" data-index="${idx}">
<div class="contrail-site-item__info">
<div class="contrail-site-item__title">${Utils.escapeHTML(site.label)} ${badge}</div>
<div class="contrail-site-item__url">${Utils.escapeHTML(site.pattern)}</div>
</div>
<div class="contrail-site-item__buttons">
<button class="contrail-btn contrail-btn--ghost" data-action="toggle">${toggleLabel}</button>
<button class="contrail-btn contrail-btn--danger" data-action="delete" ${deleteDisabled}>删除</button>
</div>
</div>
`;
}).join('');
modal.innerHTML = `
<div class="contrail-modal__header">
<div class="contrail-modal__title">管理支持站点</div>
<button class="contrail-modal__close" aria-label="关闭">×</button>
</div>
<div class="contrail-modal__body">
<div class="contrail-site-list">${listHtml}</div>
</div>
<div class="contrail-modal__footer">
<button class="contrail-btn contrail-btn--secondary" data-action="close">关闭</button>
</div>
`;
modal.querySelectorAll('.contrail-site-item').forEach(item => {
const index = Number(item.getAttribute('data-index'));
const toggleBtn = item.querySelector('[data-action="toggle"]');
const deleteBtn = item.querySelector('[data-action="delete"]');
toggleBtn.addEventListener('click', () => {
this.toggleSite(index);
closeModal();
this.openSiteModal('manage');
});
if (deleteBtn && !deleteBtn.disabled) {
deleteBtn.addEventListener('click', () => {
if (!confirm('确定删除该站点吗?')) return;
this.deleteSite(index);
closeModal();
this.openSiteModal('manage');
});
}
});
};
if (mode === 'add') {
renderAdd();
} else {
renderManage();
}
modal.querySelectorAll('.contrail-modal__close, [data-action="cancel"], [data-action="close"]').forEach(btn => {
btn.addEventListener('click', closeModal);
});
overlay.appendChild(modal);
document.body.appendChild(overlay);
return true;
}
};
/**
* Module: CSVUtils
* Google Sheet CSV 解析工具
*/
const CSVUtils = {
parse(text) {
// 去除 BOM 字符
if (text.charCodeAt(0) === 0xFEFF) {
text = text.slice(1);
}
const rows = [];
let row = [];
let cell = '';
let inQuotes = false;
for (let i = 0; i < text.length; i++) {
const char = text[i];
const next = text[i + 1];
if (char === '"' && inQuotes && next === '"') {
cell += '"';
i++;
} else if (char === '"') {
inQuotes = !inQuotes;
} else if (char === ',' && !inQuotes) {
row.push(cell);
cell = '';
} else if ((char === '\n' || char === '\r') && !inQuotes) {
if (cell || row.length) {
row.push(cell);
rows.push(row);
row = [];
cell = '';
}
} else {
cell += char;
}
}
if (cell || row.length) {
row.push(cell);
rows.push(row);
}
// trim 所有表头(第一行),避免不可见字符干扰字段匹配
if (rows.length > 0) {
rows[0] = rows[0].map(h => h.trim());
}
return rows;
},
loadSheetCsvData({ url, fields, cacheKey, cacheTTL }) {
return new Promise((resolve, reject) => {
const cache = GM_getValue(cacheKey, null);
const cacheTime = GM_getValue(cacheKey + '_time', 0);
// 命中缓存
if (cache && Date.now() - cacheTime < cacheTTL) {
console.log('Using cached sheet data');
resolve(cache);
return;
}
console.log('Fetching sheet data from:', url);
GM_xmlhttpRequest({
method: 'GET',
url,
onload: res => {
try {
console.log('Sheet data response length:', res.responseText.length);
const rows = this.parse(res.responseText);
console.log('Parsed CSV rows:', rows.length);
const headers = rows[0];
console.log('CSV Headers:', headers);
const result = rows.slice(1).map(cols => {
const rowObj = {};
headers.forEach((h, i) => {
rowObj[h] = cols[i] ?? null;
});
// 字段映射
const mapped = {};
Object.entries(fields).forEach(([outKey, header]) => {
mapped[outKey] = rowObj[header] ?? null;
});
return mapped;
});
GM_setValue(cacheKey, result);
GM_setValue(cacheKey + '_time', Date.now());
resolve(result);
} catch (e) {
if (cache) resolve(cache);
else reject(e);
}
},
onerror: err => {
if (cache) resolve(cache);
else reject(err);
}
});
});
}
};
/**
* Module: DataManager
* 数据管理与缓存
* 主数据源:Google Sheets(按年份管理的多个 sheet)
* 辅助数据源:赛绩 & 登录信息 Google Sheets
*/
const DataManager = {
/**
* 构建指定 sheet 的 CSV 导出 URL
*/
buildSheetUrl(gid) {
return `https://docs.google.com/spreadsheets/d/${Constants.MAIN_SHEET_ID}/export?format=csv&gid=${gid}`;
},
/**
* 从 Google Sheets CSV 获取单个 sheet 的数据
* @param {{ gid: string, source: string, label: string }} sheetConfig
* @returns {Promise<Array<Object>>}
*/
async fetchSheetData(sheetConfig) {
const url = this.buildSheetUrl(sheetConfig.gid);
const cacheKey = `main_sheet_${sheetConfig.source}`;
return new Promise((resolve, reject) => {
const cache = GM_getValue(cacheKey, null);
const cacheTime = GM_getValue(cacheKey + '_time', 0);
if (cache && Date.now() - cacheTime < Constants.CACHE_EXPIRY) {
console.log(`[主数据] 使用缓存: ${sheetConfig.label}`);
resolve(cache);
return;
}
console.log(`[主数据] 正在获取: ${sheetConfig.label}`);
GM_xmlhttpRequest({
method: 'GET',
url,
onload: res => {
try {
const rows = CSVUtils.parse(res.responseText);
if (!rows.length) {
resolve(cache || []);
return;
}
const headers = rows[0];
const records = rows.slice(1)
.filter(cols => cols.some(c => c && c.trim())) // 跳过空行
.map(cols => {
const record = {};
headers.forEach((h, i) => {
if (!h) return;
// 应用列名别名映射
const normalizedKey = Constants.COLUMN_ALIASES[h] || h;
record[normalizedKey] = cols[i] ?? '';
});
// 标记数据来源(年份)
record['_source'] = sheetConfig.source;
return record;
})
.filter(r => r['馬名'] && r['馬名'].trim()); // 必须有马名
GM_setValue(cacheKey, records);
GM_setValue(cacheKey + '_time', Date.now());
console.log(`[主数据] ${sheetConfig.label}: ${records.length} 条记录`);
resolve(records);
} catch (e) {
console.error(`[主数据] 解析失败: ${sheetConfig.label}`, e);
if (cache) resolve(cache);
else reject(e);
}
},
onerror: err => {
console.error(`[主数据] 请求失败: ${sheetConfig.label}`, err);
if (cache) resolve(cache);
else reject(err);
}
});
});
},
/**
* 并行获取所有年份 sheet 的主数据,合并为一个数组
*/
async fetchAllMainData() {
const promises = Constants.MAIN_SHEETS.map(cfg => this.fetchSheetData(cfg));
const results = await Promise.allSettled(promises);
const allRecords = [];
results.forEach((result, i) => {
if (result.status === 'fulfilled') {
allRecords.push(...result.value);
} else {
console.error(`[主数据] Sheet ${Constants.MAIN_SHEETS[i].label} 加载失败:`, result.reason);
}
});
return allRecords;
},
getCache() {
const cached = GM_getValue(Constants.CACHE_KEY);
if (!cached) return null;
try {
if (Date.now() - cached.timestamp < Constants.CACHE_EXPIRY) {
console.log('[主数据] 使用聚合缓存');
return cached.data;
}
} catch (e) {
console.error('Cache parse error', e);
}
return null;
},
saveCache(data) {
GM_setValue(Constants.CACHE_KEY, {
timestamp: Date.now(),
data: data
});
},
async getData() {
try {
// 并行获取主数据和辅助 Sheet 数据
const [mainData, sheetData, raceData] = await Promise.all([
this.getMainData(),
this.getSheetData(),
this.getRaceSheetData()
]);
// 合并数据
const merged = this.mergeData(mainData, sheetData, raceData);
this.saveCache(merged);
return merged;
} catch (err) {
console.error('Data loading error:', err);
// 降级:尝试返回聚合缓存
const cached = this.getCache();
if (cached) return cached;
// 最终降级:只返回主数据
return await this.getMainData();
}
},
async getMainData() {
return this.fetchAllMainData();
},
async getSheetData() {
return CSVUtils.loadSheetCsvData({
url: Constants.SHEET_URL,
fields: {
horseName: '馬名',
debutDate: '初出走',
winDate: '初勝利',
registerDate: '登録日',
retireDate: '抹消日',
prizeMoney: '獲得賞金(円)',
earnings: '収得賞金(円)'
},
cacheKey: Constants.SHEET_CACHE_KEY,
cacheTTL: Constants.SHEET_CACHE_EXPIRY
});
},
async getRaceSheetData() {
const rawData = await CSVUtils.loadSheetCsvData({
url: Constants.RACE_SHEET_URL,
fields: {
horseName: '出走馬名',
date: '日付',
raceName: '競走名',
grade: '格',
result: '結果'
},
cacheKey: Constants.RACE_SHEET_CACHE_KEY,
cacheTTL: Constants.SHEET_CACHE_EXPIRY
});
console.log('[赛绩] rawData 条数:', rawData.length);
if (rawData.length > 0) {
console.log('[赛绩] 第一条数据样本:', JSON.stringify(rawData[0]));
const nullCount = rawData.filter(d => !d.horseName).length;
if (nullCount > 0) {
console.warn(`[赛绩] ${nullCount}/${rawData.length} 条 horseName 为空,可能是列名不匹配`);
}
}
// 按马名分组
const grouped = new Map();
rawData.forEach(item => {
if (!item.horseName) return;
// 清理马名中的空白字符
const cleanName = item.horseName.trim();
if (!grouped.has(cleanName)) {
grouped.set(cleanName, []);
}
grouped.get(cleanName).push(item);
});
console.log('[赛绩] 分组后马匹数:', grouped.size,
'样本马名:', [...grouped.keys()].slice(0, 5));
// 按日期倒序排序
grouped.forEach(races => {
races.sort((a, b) => new Date(b.date) - new Date(a.date));
});
return grouped;
},
mergeData(mainList, sheetList, raceMap) {
const sheetMap = new Map();
if (sheetList) {
sheetList.forEach(item => {
if (item.horseName) {
sheetMap.set(item.horseName, item);
}
});
}
let raceMatchCount = 0;
const merged = mainList.map(horse => {
const cleanName = horse['馬名'] ? horse['馬名'].trim() : '';
const sheetInfo = sheetMap.get(cleanName);
const races = raceMap ? raceMap.get(cleanName) : null;
if (races) raceMatchCount++;
let combined = horse;
if (sheetInfo) {
combined = { ...combined, ...sheetInfo };
}
if (races) {
combined = { ...combined, races };
}
return combined;
});
console.log(`[合并] 主数据: ${mainList.length}, 辅助匹配: ${sheetMap.size}, 赛绩匹配: ${raceMatchCount}/${mainList.length}`);
if (raceMap && raceMatchCount === 0 && raceMap.size > 0) {
const mainNames = mainList.slice(0, 3).map(h => h['馬名']);
const raceNames = [...raceMap.keys()].slice(0, 3);
console.warn('[合并] 赛绩0匹配! 主数据马名样本:', mainNames, '赛绩马名样本:', raceNames);
}
return merged;
}
};
/**
* Module: Styles
* 样式定义
*/
const Styles = {
CSS: `
:root {
--contrail-font-family: 'Source Han Sans', '思源黑体', 'Source Han Sans SC', 'Noto Sans CJK SC', 'Microsoft YaHei', sans-serif;
/* Light Mode Variables */
--contrail-bg: #faf7f2;
--contrail-text: #333;
--contrail-text-secondary: #888;
--contrail-border: #e8e3dc;
--contrail-link: #318cfa;
--contrail-highlight-bg: #ffd6d6;
--contrail-highlight-text: #ff88a6;
--contrail-shadow: rgba(0,0,0,.25);
--contrail-accent: #ff88a6;
--contrail-accent-light: rgba(255, 136, 166, 0.1);
--contrail-accent-lighter: rgba(255, 136, 166, 0.04);
--contrail-th-bg: #f5f0ea;
}
@media (prefers-color-scheme: dark) {
:root {
--contrail-bg: #2d2d2d;
--contrail-text: #e0e0e0;
--contrail-text-secondary: #a0a0a0;
--contrail-border: #444;
--contrail-link: #5ca1ff;
--contrail-highlight-bg: #4a2c2c;
--contrail-highlight-text: #ff88a6;
--contrail-shadow: rgba(0,0,0,.5);
--contrail-accent: #ff88a6;
--contrail-accent-light: rgba(255, 136, 166, 0.1);
--contrail-accent-lighter: rgba(255, 136, 166, 0.04);
--contrail-th-bg: #3a3a3a;
}
}
.contrail-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
backdrop-filter: blur(2px);
display: flex;
align-items: center;
justify-content: center;
z-index: 2147483646;
padding: 24px;
}
.contrail-modal {
width: min(480px, calc(100vw - 48px));
max-height: min(620px, calc(100vh - 48px));
background: var(--contrail-bg);
color: var(--contrail-text);
border-radius: 16px;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.25);
display: flex;
flex-direction: column;
overflow: hidden;
font-family: var(--contrail-font-family);
border: 1px solid var(--contrail-border);
animation: contrail-modal-in 0.18s ease-out;
}
@keyframes contrail-modal-in {
from { transform: translateY(16px) scale(0.98); opacity: 0; }
to { transform: translateY(0) scale(1); opacity: 1; }
}
.contrail-modal__header,
.contrail-modal__footer {
padding: 16px 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: rgba(0,0,0,0.02);
}
.contrail-modal__header {
border-bottom: 1px solid var(--contrail-border);
}
.contrail-modal__footer {
border-top: 1px solid var(--contrail-border);
justify-content: flex-end;
}
.contrail-modal__body {
padding: 20px;
overflow-y: auto;
max-height: 100%;
display: flex;
flex-direction: column;
gap: 16px;
}
.contrail-modal__title {
font-size: 18px;
font-weight: 600;
color: var(--contrail-highlight-text);
}
.contrail-modal__close {
border: none;
background: transparent;
color: var(--contrail-text-secondary);
font-size: 20px;
cursor: pointer;
transition: color 0.2s ease;
}
.contrail-modal__close:hover {
color: var(--contrail-highlight-text);
}
.contrail-modal__field {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 14px;
color: var(--contrail-text-secondary);
}
.contrail-modal__field span {
display: flex;
align-items: baseline;
justify-content: space-between;
font-weight: 600;
color: var(--contrail-text);
}
.contrail-modal__field small {
font-weight: normal;
color: var(--contrail-text-secondary);
font-size: 12px;
}
.contrail-modal__input {
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--contrail-border);
background: rgba(255,255,255,0.9);
color: var(--contrail-text);
font-size: 14px;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.contrail-modal__input:focus {
outline: none;
border-color: var(--contrail-highlight-text);
box-shadow: 0 0 0 2px rgba(255,136,166,0.25);
}
.contrail-btn {
border: none;
border-radius: 999px;
padding: 8px 18px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease, opacity 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
color: inherit;
}
.contrail-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
box-shadow: none;
transform: none;
}
.contrail-btn:not(:disabled):hover {
transform: translateY(-1px);
box-shadow: 0 6px 12px rgba(0,0,0,0.12);
}
.contrail-btn--primary {
background: rgba(255, 136, 166, 0.15);
color: #ff88a6;
border: 1px solid rgba(255, 136, 166, 0.4);
}
.contrail-btn--secondary {
background: rgba(0,0,0,0.05);
border: 1px solid var(--contrail-border);
color: var(--contrail-text);
}
.contrail-btn--ghost {
background: transparent;
border: 1px solid var(--contrail-border);
color: var(--contrail-text);
}
.contrail-btn--danger {
background: rgba(255, 82, 82, 0.12);
border: 1px solid rgba(255, 82, 82, 0.4);
color: #d32f2f;
}
.contrail-site-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.contrail-site-item {
border: 1px solid var(--contrail-border);
border-radius: 12px;
padding: 12px 16px;
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
background: rgba(0,0,0,0.02);
transition: border-color 0.2s ease, background 0.2s ease;
}
.contrail-site-item.is-enabled {
border-color: rgba(255,136,166,0.45);
background: rgba(255,136,166,0.08);
}
.contrail-site-item__info {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.contrail-site-item__title {
font-weight: 600;
font-size: 15px;
color: var(--contrail-text);
}
.contrail-site-item__url {
font-size: 13px;
color: var(--contrail-text-secondary);
word-break: break-all;
}
.contrail-site-item__buttons {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.contrail-tag {
display: inline-block;
padding: 2px 8px;
margin-left: 8px;
border-radius: 999px;
background: rgba(0,0,0,0.08);
color: var(--contrail-text-secondary);
font-size: 12px;
font-weight: 600;
}
.contrail-empty {
text-align: center;
color: var(--contrail-text-secondary);
padding: 24px 0;
font-size: 14px;
}
.horse-highlight,
.horse-tooltip,
.horse-tooltip * {
font-family: var(--contrail-font-family);
box-sizing: border-box;
}
.horse-highlight {
background: none !important;
color: var(--contrail-highlight-text);
font-weight: 600;
cursor: pointer;
position: relative;
text-decoration: none !important;
}
.horse-tooltip {
position: fixed;
top: 0;
left: 0;
width: 520px;
max-height: 80vh;
overflow-y: auto;
background: var(--contrail-bg);
color: var(--contrail-text);
border-radius: 14px;
box-shadow: 0 20px 60px rgba(0,0,0,0.15), 0 0 0 1px rgba(0,0,0,0.05);
z-index: 2147483647;
font-size: 14px;
line-height: 1.6;
pointer-events: auto;
text-align: left;
overscroll-behavior: contain;
overflow: hidden;
/* Animation Initial State */
opacity: 0;
transform: scale(0.95);
transform-origin: center;
pointer-events: none;
transition: opacity 0.2s cubic-bezier(0.2, 0, 0, 1), transform 0.2s cubic-bezier(0.2, 0, 0, 1);
}
.horse-tooltip.horse-tooltip--visible {
opacity: 1;
transform: scale(1);
pointer-events: auto;
overflow-y: auto;
}
/* Header */
.tt-header {
padding: 16px 20px 12px;
border-bottom: 2px solid var(--contrail-accent);
background: linear-gradient(135deg, var(--contrail-accent-lighter), transparent);
}
.tt-name {
font-size: 20px;
font-weight: 700;
color: var(--contrail-accent);
letter-spacing: 0.3px;
}
.tt-sub {
font-size: 12px;
color: var(--contrail-text-secondary);
margin-top: 2px;
}
/* Info Table */
.tt-info-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.tt-info-table th,
.tt-info-table td {
padding: 7px 14px;
border-bottom: 1px solid var(--contrail-border);
vertical-align: middle;
}
.tt-info-table th {
background: var(--contrail-th-bg);
color: var(--contrail-text-secondary);
font-weight: 500;
font-size: 12px;
white-space: nowrap;
width: 60px;
text-align: left;
}
.tt-info-table td {
color: var(--contrail-text);
font-weight: 600;
font-size: 13px;
}
.tt-info-table tr:last-child th,
.tt-info-table tr:last-child td {
border-bottom: none;
}
/* Content Section */
.tt-section {
padding: 0 20px;
}
.tt-block {
margin-top: 14px;
}
.tt-block-title {
font-size: 13px;
font-weight: 700;
margin-bottom: 4px;
padding-bottom: 4px;
border-bottom: 1px dashed var(--contrail-border);
color: var(--contrail-text);
}
.tt-block-body {
font-size: 13px;
color: var(--contrail-text);
line-height: 1.7;
}
/* Collapse */
.tt-collapse {
margin-top: 10px;
}
.tt-collapse:last-child {
margin-bottom: 16px;
}
.tt-collapse-title {
cursor: pointer;
font-size: 13px;
font-weight: 700;
color: var(--contrail-accent);
display: flex;
align-items: center;
gap: 4px;
padding: 6px 0;
user-select: none;
transition: opacity 0.15s;
}
.tt-collapse-title:hover {
opacity: 0.75;
}
.tt-collapse-icon {
display: inline-block;
width: 14px;
font-size: 10px;
text-align: center;
transition: transform 0.2s ease;
}
.tt-collapse.open .tt-collapse-icon {
transform: rotate(0deg);
}
.tt-collapse:not(.open) .tt-collapse-icon {
transform: rotate(-90deg);
}
.tt-collapse-label {
margin-left: 0;
}
.tt-collapse-body {
font-size: 13px;
color: var(--contrail-text);
line-height: 1.7;
padding: 4px 0 2px 18px;
border-left: 2px solid var(--contrail-border);
margin-left: 6px;
}
/* Stats Card */
.tt-stats-card {
margin: 12px 16px 0;
background: var(--contrail-accent-light);
border-radius: 10px;
padding: 14px 16px;
border: 1px solid rgba(255, 136, 166, 0.2);
}
.tt-stats-header {
display: flex;
align-items: baseline;
gap: 6px;
margin-bottom: 10px;
}
.tt-stats-title {
font-size: 17px;
font-weight: 700;
color: var(--contrail-accent);
}
.tt-stats-record {
font-size: 13px;
color: var(--contrail-text-secondary);
font-weight: 400;
}
.tt-stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 6px;
margin-bottom: 10px;
}
.tt-stat-item {
display: flex;
flex-direction: column;
align-items: center;
background: rgba(255,255,255,0.7);
border-radius: 8px;
padding: 6px 4px;
}
.tt-stat-label {
font-size: 10px;
color: var(--contrail-text-secondary);
margin-bottom: 2px;
font-weight: 500;
}
.tt-stat-value {
font-size: 16px;
font-weight: 700;
color: var(--contrail-text);
}
.tt-detail-rows {
display: flex;
flex-direction: column;
gap: 4px;
}
.tt-detail-row {
display: flex;
font-size: 13px;
line-height: 1.5;
}
.tt-detail-label {
color: var(--contrail-text-secondary);
width: 56px;
flex-shrink: 0;
font-weight: 500;
}
.tt-detail-value {
color: var(--contrail-text);
font-weight: 500;
}
.tt-footer-space {
height: 6px;
}
`,
inject() {
GM_addStyle(this.CSS);
}
};
/**
* Module: Components
* UI 组件渲染
*/
const Components = {
cell(label, val) {
const value = val ? Utils.escapeHTML(val) : '<span style="color:#ccc">/</span>';
return `<th>${label}</th><td>${value}</td>`;
},
block(label, val) {
if (!val) return '';
return `
<div class="tt-block">
<div class="tt-block-title">${label}</div>
<div class="tt-block-body">${Utils.nl2br(val)}</div>
</div>`;
},
collapse(label, val) {
if (!val) return '';
return `
<div class="tt-collapse open">
<div class="tt-collapse-title">
<span class="tt-collapse-icon">▼</span>
<span class="tt-collapse-label">${Utils.escapeHTML(label)}</span>
</div>
<div class="tt-collapse-body">
${Utils.nl2br(val)}
</div>
</div>`;
},
raceStats(races) {
const stats = Utils.calculateStats(races);
if (!stats) return '';
const recordStr = `[${stats.first}-${stats.second}-${stats.third}-${stats.unplaced}]`;
// 主胜鞍
const majorWins = Utils.calculateWins(races);
const majorWinsStr = majorWins.length > 0
? majorWins.map(w => {
const name = Utils.escapeHTML(w.raceName);
const grade = (w.grade || '').trim();
// 判断是否为 OP 及以上
const isOpOrHigher = grade && Constants.GRADE_ORDER.indexOf(grade) !== -1 && Constants.GRADE_ORDER.indexOf(grade) <= 7;
let displayName;
if (isOpOrHigher) {
// OP及以上:25'xxx(G1)
const yy = w.date ? String(new Date(w.date).getFullYear()).slice(-2) : '';
const prefix = yy ? `${yy}'` : '';
displayName = `${prefix}${name}(${grade})`;
} else {
// 条件赛:xxx
displayName = name;
}
if (grade === 'GI' || grade === 'JpnI') {
return `<b style="color:#d32f2f">${displayName}</b>`; // G1 wins highlighted red
}
return displayName;
}).join('、')
: '-';
// 前走
const latest = Utils.getLatestRace(races);
let latestStr = '-';
if (latest) {
const name = Utils.escapeHTML(latest.raceName);
const grade = (latest.grade || '').trim();
const isOpOrHigher = grade && Constants.GRADE_ORDER.indexOf(grade) !== -1 && Constants.GRADE_ORDER.indexOf(grade) <= 7;
const displayName = isOpOrHigher ? `${name}(${grade})` : name;
latestStr = `${displayName} <span style="font-weight:bold; color:${latest.result == 1 ? '#d32f2f' : 'inherit'}">(${Utils.escapeHTML(latest.result)})</span>`;
}
return `
<div class="tt-stats-card">
<div class="tt-stats-header">
<span class="tt-stats-title">${stats.total}战${stats.wins}胜</span>
<span class="tt-stats-record">${recordStr}</span>
</div>
<div class="tt-stats-grid">
<div class="tt-stat-item">
<span class="tt-stat-label">胜率</span>
<span class="tt-stat-value">${stats.winRate}</span>
</div>
<div class="tt-stat-item">
<span class="tt-stat-label">连对率</span>
<span class="tt-stat-value">${stats.quinellaRate}</span>
</div>
<div class="tt-stat-item">
<span class="tt-stat-label">复胜率</span>
<span class="tt-stat-value">${stats.placeRate}</span>
</div>
<div class="tt-stat-item">
<span class="tt-stat-label">进板率</span>
<span class="tt-stat-value">${stats.boardRate}</span>
</div>
</div>
<div class="tt-detail-rows">
<div class="tt-detail-row">
<div class="tt-detail-label">主胜鞍</div>
<div class="tt-detail-value">${majorWinsStr}</div>
</div>
<div class="tt-detail-row">
<div class="tt-detail-label">前走</div>
<div class="tt-detail-value">${latestStr}</div>
</div>
</div>
</div>`;
},
renderTooltip(horse) {
const translation = horse['港译'] || horse['译名'];
const displayName = translation
? `${Utils.escapeHTML(horse['馬名'])}【${Utils.escapeHTML(translation)}】`
: Utils.escapeHTML(horse['馬名']);
const fallbackTranslation = translation ? '' : '<div class="tt-sub">暂无译名</div>';
const html = `
<div class="horse-tooltip">
<div class="tt-header">
<div class="tt-name">${displayName}</div>
${fallbackTranslation}
</div>
<table class="tt-info-table">
<tr>${this.cell('性别', horse['性別'])}${this.cell('毛色', horse['毛色'])}</tr>
<tr>${this.cell('调教师', horse['管理調教師'])}${this.cell('生产牧场', horse['生产牧场'])}</tr>
<tr>${this.cell('母父', horse['母父名'])}${this.cell('母马', horse['母名'])}</tr>
<tr><th>马主</th><td colspan="3">${horse['馬主'] ? Utils.escapeHTML(horse['馬主']) : '<span style="color:#ccc">/</span>'}</td></tr>
<tr>${this.cell('注册日', Utils.formatDate(horse['registerDate']))}${this.cell('抹消日', Utils.formatDate(horse['retireDate']))}</tr>
<tr>${this.cell('初出走', Utils.formatDate(horse['debutDate']))}${this.cell('初胜利', Utils.formatDate(horse['winDate']))}</tr>
<tr>${this.cell('赏金', Utils.formatJPY(horse['prizeMoney']))}${this.cell('收得', Utils.formatJPY(horse['earnings']))}</tr>
</table>
${this.raceStats(horse.races)}
<div class="tt-section">
${this.block('备注', horse['备考'])}
${this.collapse('近况 / 牧场评价', horse['近况更新/近走/牧场评价'])}
${this.collapse('血统分析', horse['血统分析'])}
</div>
<div class="tt-footer-space"></div>
</div>`;
const template = document.createElement('template');
template.innerHTML = html.trim();
const tooltip = template.content.firstElementChild;
// 交互逻辑绑定
// 阻止滚动冒泡到父页面
const stopScrollPropagation = (e) => {
const el = e.currentTarget;
// 只有当元素实际可滚动时才处理
if (el.scrollHeight <= el.clientHeight) return;
const delta = e.deltaY;
const scrollTop = el.scrollTop;
const scrollHeight = el.scrollHeight;
const height = el.clientHeight;
// 滚动到底部且继续向下滚,或者滚动到顶部且继续向上滚
if ((delta > 0 && scrollTop + height >= scrollHeight - 1) ||
(delta < 0 && scrollTop <= 1)) {
e.preventDefault();
e.stopPropagation();
}
};
// 注意:passive: false 是必须的,否则 preventDefault 无效
tooltip.addEventListener('wheel', stopScrollPropagation, { passive: false });
tooltip.querySelectorAll('.tt-collapse').forEach(section => {
const title = section.querySelector('.tt-collapse-title');
const icon = section.querySelector('.tt-collapse-icon');
const body = section.querySelector('.tt-collapse-body');
const setState = open => {
section.classList.toggle('open', open);
if (icon) icon.textContent = open ? '▼' : '▶';
if (body) body.style.display = open ? '' : 'none';
};
setState(section.classList.contains('open'));
if (title) {
title.addEventListener('click', () => {
const next = !section.classList.contains('open');
setState(next);
});
}
});
return tooltip;
}
};
/**
* Module: App
* 主程序逻辑
*/
const App = {
loading: false,
dataCache: null,
mapCache: null,
observer: null,
init() {
Styles.inject();
SiteManager.init({
onSiteEnabled: () => this.enableForCurrentSite()
});
if (SiteManager.isCurrentSiteEnabled()) {
this.enableForCurrentSite();
}
},
enableForCurrentSite() {
if (this.loading) return;
if (this.dataCache) {
this.highlight(this.dataCache);
return;
}
this.loading = true;
Utils.loadAlpine(async () => {
try {
const data = await DataManager.getData();
this.dataCache = data;
this.highlight(data);
} catch (err) {
console.error('Failed to load horse data:', err);
} finally {
this.loading = false;
}
});
},
setupMutationObserver() {
if (this.observer) return;
const initObserver = () => {
if (this.observer || !document.body) return;
this.observer = new MutationObserver((mutations) => {
let shouldProcess = false;
for (const mutation of mutations) {
if (mutation.addedNodes.length > 0) {
shouldProcess = true;
break;
}
}
if (shouldProcess) {
this.processNodes(document.body);
}
});
this.observer.observe(document.body, {
childList: true,
subtree: true
});
};
if (document.body) {
initObserver();
} else {
Utils.onDocumentReady(initObserver);
}
},
processNodes(rootNode) {
const map = this.mapCache;
if (!map || !map.size || !rootNode) return;
const walker = document.createTreeWalker(
rootNode,
NodeFilter.SHOW_TEXT,
{
acceptNode: (node) => {
// Skip already highlighted nodes or tooltips
if (node.parentElement && (
node.parentElement.classList.contains('horse-highlight') ||
node.parentElement.closest('.horse-tooltip')
)) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
}
}
);
const nodes = [];
while (walker.nextNode()) nodes.push(walker.currentNode);
nodes.forEach(node => {
const text = node.nodeValue;
if (!text || !text.trim()) return;
// Simple check before iterating map
let found = false;
// Convert Map keys to array for iteration to break early
for (const [name, horse] of map.entries()) {
const index = text.indexOf(name);
if (index !== -1) {
// Split text node to isolate the matching part
const matchNode = node.splitText(index);
matchNode.splitText(name.length);
// 高亮文本生成
const translation = horse['港译'] || horse['译名'];
const highlightedName = translation
? `${Utils.escapeHTML(name)}【${Utils.escapeHTML(translation)}】`
: Utils.escapeHTML(name);
const span = document.createElement('span');
span.className = 'horse-highlight';
span.innerHTML = highlightedName;
// 生成 Tooltip
const tooltip = Components.renderTooltip(horse);
document.body.appendChild(tooltip);
// tooltip.classList.add('horse-tooltip--floating'); // Removed, handled by base class + visible class
// Tooltip 交互逻辑
this.attachTooltipEvents(span, tooltip);
if (matchNode.parentNode) {
matchNode.parentNode.replaceChild(span, matchNode);
}
found = true;
break; // Only highlight first match per text node to avoid complexity
}
}
});
},
highlight(horses) {
if (!SiteManager.isCurrentSiteEnabled()) {
return;
}
if (!Array.isArray(horses) || !horses.length) return;
const map = new Map();
horses.forEach(h => h && h['馬名'] && map.set(h['馬名'], h));
this.mapCache = map;
const process = () => {
if (!document.body) return;
this.processNodes(document.body);
this.setupMutationObserver();
};
if (document.body) {
process();
} else {
Utils.onDocumentReady(process);
}
},
attachTooltipEvents(targetSpan, tooltip) {
let hideTimer = null;
let listenersAttached = false;
const margin = 12;
const order = ['bottom', 'top', 'right', 'left'];
const positionTooltip = () => {
const tooltipRect = tooltip.getBoundingClientRect();
const targetRect = targetSpan.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const spaces = {
bottom: viewportHeight - targetRect.bottom - margin,
top: targetRect.top - margin,
right: viewportWidth - targetRect.right - margin,
left: targetRect.left - margin
};
const fits = order.find(dir => {
if (dir === 'bottom' || dir === 'top') {
return spaces[dir] >= tooltipRect.height;
}
return spaces[dir] >= tooltipRect.width;
});
const placement = fits || order.reduce((best, dir) => spaces[dir] > spaces[best] ? dir : best, order[0]);
let top, left;
switch (placement) {
case 'bottom':
top = targetRect.bottom + margin;
left = targetRect.left + (targetRect.width - tooltipRect.width) / 2;
break;
case 'top':
top = targetRect.top - tooltipRect.height - margin;
left = targetRect.left + (targetRect.width - tooltipRect.width) / 2;
break;
case 'right':
top = targetRect.top + (targetRect.height - tooltipRect.height) / 2;
left = targetRect.right + margin;
break;
default:
top = targetRect.top + (targetRect.height - tooltipRect.height) / 2;
left = targetRect.left - tooltipRect.width - margin;
break;
}
const maxLeft = viewportWidth - tooltipRect.width - margin;
const maxTop = viewportHeight - tooltipRect.height - margin;
left = Math.min(Math.max(left, margin), Math.max(maxLeft, margin));
top = Math.min(Math.max(top, margin), Math.max(maxTop, margin));
tooltip.style.top = `${top}px`;
tooltip.style.left = `${left}px`;
};
const reposition = () => {
if (!tooltip.classList.contains('horse-tooltip--visible')) return;
positionTooltip();
};
const attachListeners = () => {
if (listenersAttached) return;
window.addEventListener('scroll', reposition, true);
window.addEventListener('resize', reposition);
listenersAttached = true;
};
const detachListeners = () => {
if (!listenersAttached) return;
window.removeEventListener('scroll', reposition, true);
window.removeEventListener('resize', reposition);
listenersAttached = false;
};
const showTooltip = () => {
clearTimeout(hideTimer);
tooltip.classList.add('horse-tooltip--visible');
positionTooltip();
attachListeners();
};
const hideTooltip = () => {
hideTimer = setTimeout(() => {
tooltip.classList.remove('horse-tooltip--visible');
detachListeners();
}, 100);
};
targetSpan.addEventListener('mouseenter', showTooltip);
targetSpan.addEventListener('mouseleave', hideTooltip);
tooltip.addEventListener('mouseenter', showTooltip);
tooltip.addEventListener('mouseleave', hideTooltip);
}
};
// 启动应用
App.init();
})();