Files

662 lines
40 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
namespace Ananta.Server.App.Admin;
/// <summary>
/// Metro / light-rail admin card, ported from the donor build panel
/// (Ananta.App/DebugPanel/index.html) so the two stay diffable.
///
/// Three fragments because each one goes into a different place of the page: CSS into
/// &lt;style&gt;, the card into the body, the script before &lt;/script&gt;. The JS drives itself - it
/// installs its own setTimeout/setInterval at the bottom - so nothing here has to be wired up.
///
/// Deliberately NOT carried over from the donor file:
/// - the global boot block that followed the card (loadFleet(), panelBadge(), the #timeslider
/// handlers, ...): it belongs to the donor layout and would throw here;
/// - the language-toggle hook: that panel has an i18n pass and a #langBtn, this one does not,
/// so metroEn() is always false and only the Chinese strings ever render.
///
/// Dependency provided by the hosting page rather than by this file: an <c>api(path, opts)</c>
/// fetch helper, plus the donor table rules in the page stylesheet.
/// </summary>
internal static class MetroCardParts
{
internal const string Css = """
/* ---- round 58: metro run diagram. One row per service, a dot per stop, a triangle per carriage.
The station colours come in as CSS variables (--c / --cd / --g) set on .mm-track, so the state
classes below (.dp depot, .tr interchange, .near arriving, .at standing) can win over them. ---- */
.mm-row { display: flex; align-items: stretch; gap: 10px; margin-bottom: 8px; padding: 6px 10px 4px 10px;
background: #101215; border: 1px solid #2c3138; border-radius: 8px; }
.mm-head { flex: 0 0 166px; width: 166px; border-left: 4px solid #555; padding-left: 8px; }
.mm-badge { display: inline-block; min-width: 15px; text-align: center; padding: 1px 5px; border-radius: 4px;
color: #10161c; font-weight: bold; font-size: 11px; }
.mm-dir { margin-left: 5px; font-size: 13px; color: #8b96a2; }
.mm-name { margin-top: 4px; font-size: 13px; color: #d7dce2; }
.mm-sub { margin-top: 2px; font-size: 11px; color: #8b96a2; line-height: 1.35; }
.mm-scroll { flex: 1 1 auto; min-width: 0; overflow-x: auto; overflow-y: hidden; }
.mm-track { position: relative; height: 104px; }
.mm-line { position: absolute; left: 8px; right: 8px; top: 30px; height: 5px; border-radius: 3px;
background: var(--c, #90a4ae); opacity: .9; }
.mm-stop { position: absolute; top: 0; height: 104px; }
.mm-dot { position: absolute; left: 0; top: 32.5px; width: 11px; height: 11px; margin: -5.5px 0 0 -5.5px;
box-sizing: border-box; border-radius: 50%; background: #0e1116; border: 2px solid var(--cd, #6b7684);
transition: border-color .15s, background .15s; }
.mm-dot.dp { border-radius: 2px; border-style: dashed; border-color: #6b7684; }
.mm-dot.tr { border-color: #cfd8dc; box-shadow: 0 0 0 2px rgba(207, 216, 220, .22); }
.mm-dot.near { border-color: #ffd166; }
.mm-dot.at { width: 16px; height: 16px; margin: -8px 0 0 -8px; border-color: var(--c, #90a4ae);
background: var(--c, #90a4ae); box-shadow: 0 0 0 5px var(--g, rgba(144, 164, 174, .3)); }
.mm-lbl { position: absolute; left: 1px; top: 44px; white-space: nowrap; font-size: 10px; color: #9fb3c8;
transform: rotate(45deg); transform-origin: left top; }
.mm-lbl.dp { color: #79838e; font-style: italic; }
.mm-lbl.tr { color: #dfe7ef; font-weight: bold; }
.mm-lbl.at { color: #ffe082; font-weight: bold; }
.mm-trains { position: absolute; left: 0; right: 0; top: 0; bottom: 0; }
/* left is the only moving part; 1s linear matches the panel's 1s poll so a carriage glides
between two readings instead of teleporting. .mm-snap is set for one frame when the
position jumps (ring wrap-around, first placement) so it does not slide backwards. */
.mm-train { position: absolute; top: 0; height: 104px; transition: left 1s linear; }
.mm-train.mm-snap { transition: none; }
.mm-arrow { position: absolute; left: 0; top: 18px; margin-left: -6px; width: 0; height: 0;
border-left: 6px solid transparent; border-right: 6px solid transparent;
border-top: 11px solid var(--c, #90a4ae); }
.mm-tid { position: absolute; left: 0; top: 3px; transform: translateX(-50%); font-size: 9px; color: #b9c2cc; }
.mm-train.at .mm-arrow { border-top-color: #ffe082; }
.mm-tid.at { color: #ffe082; font-weight: bold; }
.mm-legend { margin-bottom: 8px; font-size: 11px; color: #8b96a2; }
.mm-legend i { display: inline-block; width: 11px; height: 11px; margin: 0 3px 0 10px; box-sizing: border-box;
vertical-align: -1px; border-radius: 50%; border: 2px solid #6b7684; }
.mm-legend i:first-child { margin-left: 0; }
.mm-legend i.mm-k.tr { border-color: #cfd8dc; box-shadow: 0 0 0 2px rgba(207, 216, 220, .22); }
.mm-legend i.mm-k.at { border-color: #ffe082; background: #ffe082; box-shadow: 0 0 0 4px rgba(255, 224, 130, .22); }
.mm-legend i.mm-k.near { border-color: #ffd166; }
.mm-legend i.mm-k.dp { border-radius: 2px; border-style: dashed; }
""";
internal const string Card = """
<div class="card xl" id="card-metro" style="border-left: 4px solid #4dd0e1;">
<h2><i class="ci" data-ic="bus"></i>🚈 地铁 / 轻轨</h2>
<div class="row btns">
<span class="mbadge">总开关 <b id="metroSwitchText" data-nol10n style="color:#f44336;">—</b></span>
<button onclick="metroToggle(true)" style="background:#0288d1; border-color:#01579b; font-weight:bold;">▶ 启动轻轨</button>
<button class="ghost" onclick="metroToggle(false)">⏸ 停止推送</button>
</div>
<div class="row">
<span>推送周期 <input id="metroPushMs" type="number" min="50" max="10000" step="50" value="1000" style="width:80px"> 毫秒</span>
<span class="hint" style="margin:0">(玩家远离轨道时自动降到 3000 ms;列车由客户端自己推进,降频只影响对时粒度)</span>
</div>
<div class="row">
<span>时间倍率 <input id="metroTimeFactor" type="number" min="0.0001" max="1000" step="any" value="15" style="width:90px"> ×</span>
<button class="ghost" onclick="metroSetFactor(15)">15</button>
<button class="ghost" onclick="metroSetFactor(1)">1</button>
<button class="ghost" onclick="metroSetFactor(0.0666667)">0.0666667</button>
</div>
<div class="row btns">
<button onclick="metroApplyTuning()">🔧 只应用参数(不动线路)</button>
<button onclick="metroApplyLines()" style="background:#2e7d32; border-color:#1b5e20;">✅ 应用勾选的线路</button>
</div>
<div class="row">
<span class="hint" style="margin:0">快捷组合:</span>
<button class="ghost" onclick="metroPreset('auto')">跟随当前地区</button>
<button class="ghost" onclick="metroPreset('main')">主城 4 条环线</button>
<button class="ghost" onclick="metroPreset('all')">全部线路</button>
<button class="ghost" onclick="metroPreset('chongxiao')">重霄 1/2/3 号线</button>
<button class="ghost" onclick="metroPreset('cruise')">画舫(船)</button>
<button class="ghost" onclick="metroPreset('none')">全部关掉</button>
</div>
<div id="metroStatus" data-nol10n style="font-size:12px; color:#aaa; margin-bottom:8px;"></div>
<details class="help" data-nol10n><summary>说明</summary>
<div style="font-size:12px; color:#aaa; margin-bottom:6px;">
一行=一条线路,圆点=停靠点,虚线方块=车库,白圈=换乘站,▼=一列车(上方数字是列车编号)。车在站台上时那一站圆点变大发光、站名变黄;否则车落在两站之间。
</div>
<div style="font-size:12px; color:#aaa; margin-bottom:6px;">
「时间倍率」默认 15:客户端会把收到的值 ÷15 才是场景秒。除非客户端改过,别动它。
</div>
</details>
<div id="metroMap" data-nol10n style="font-size:12px; margin-bottom:10px;"></div>
<div id="metroLines" data-nol10n style="font-size:12px; max-height:260px; overflow:auto;"></div>
<div style="margin-top:14px; padding-top:10px; border-top:1px dashed #333;">
<details class="help" data-nol10n><summary>说明</summary>
<div style="font-size:12px; color:#aaa; margin-bottom:6px;">
列车编号 = 线路号 × 100 + 车序(101 = 1 号线第 1 列)。「还有多远」是按时间比例估算的直线距离,不是仪表。
</div>
</details>
<div id="metroTrains" data-nol10n style="font-size:12px; max-height:150px; overflow:auto;"></div>
</div>
<div id="metroResult" data-nol10n style="margin-top:8px; font-size:12px; color:#4caf50;"></div>
</div>
""";
internal const string Script = """
// ---------------------------------------------------------------- metro / light rail (round 54)
// The card's own strings are bilingual here rather than through the DOM i18n pass: most of them carry
// live numbers, and the pass only rewrites text it can look up verbatim.
function metroEn() { return !!(window.PanelI18n && window.PanelI18n.lang === 'en'); }
function metroT(zh, en) { return metroEn() ? en : zh; }
var metroLines = []; // catalog the server reported last
var metroPicked = null; // Set of ticked line ids; null until the first status arrives
var metroLineSig = ''; // rebuild the line table only when this changes, so ticks survive a refresh
function metroClock(sec) {
if (typeof sec !== 'number' || !isFinite(sec)) return '—';
const s = Math.max(0, Math.round(sec));
return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
}
function metroStationName(stop) {
if (!stop) return '—';
const zh = stop.chinese || stop.name || '';
const en = stop.name || zh;
return metroEn() ? en : zh;
}
function metroTrainStation(row, chineseKey, englishKey) {
const zh = row[chineseKey] || row[englishKey] || '';
const en = row[englishKey] || zh;
return (metroEn() ? en : zh) || '—';
}
async function refreshMetro() {
let d;
try {
d = await api('/api/metro/status');
} catch (e) {
return;
}
if (!d) return;
const swEl = document.getElementById('metroSwitchText');
const stEl = document.getElementById('metroStatus');
const trEl = document.getElementById('metroTrains');
const resEl = document.getElementById('metroResult');
if (!stEl) return;
if (!d.online) {
if (swEl) { swEl.textContent = metroT('离线', 'offline'); swEl.style.color = '#f44336'; }
stEl.textContent = metroT('客户端未进入游戏(当前没有在线会话)', 'the client is not in the game (no online session)');
stEl.style.color = '#f44336';
if (trEl) trEl.innerHTML = '';
metroMapSync([]); // no session: keep the lines as they are, only drop the carriages
return;
}
if (!d.started) {
if (swEl) { swEl.textContent = metroT('未启动', 'not started'); swEl.style.color = '#f44336'; }
stEl.textContent = metroT(
'场景还没有请求过地铁列表(AskGetAllMetroInfos)。进入游戏世界并等到场景加载完,服务端收到请求后这一页才会有数据。',
'the scene has not asked for the metro list yet (AskGetAllMetroInfos). Enter the game world and let the scene finish loading; this page fills in once the request arrives.');
stEl.style.color = '#ff9800';
if (trEl) trEl.innerHTML = '';
// The catalog is readable before the scene asks for anything, so still show the lines - that is
// where you look if you want to know whether the server found the Rail*Config files at all.
metroLines = Array.isArray(d.lines) ? d.lines : [];
if (metroPicked === null) metroPicked = new Set();
if (metroLineSig !== 'idle') { metroLineSig = 'idle'; metroRenderLines(); }
metroMapSync([]); // the diagram is readable straight from the catalog, before the scene asks for it
return;
}
const pushEl = document.getElementById('metroPushMs');
if (pushEl && document.activeElement !== pushEl && typeof d.pushMs === 'number') pushEl.value = d.pushMs;
const factorEl = document.getElementById('metroTimeFactor');
if (factorEl && document.activeElement !== factorEl && typeof d.timeFactor === 'number') factorEl.value = String(d.timeFactor);
const enabled = !!(d.enabled && d.running);
if (swEl) {
swEl.textContent = d.enabled
? (d.running ? metroT('已开启', 'running') : metroT('已开启(循环待启动)', 'enabled (loop pending)'))
: metroT('已关闭', 'off');
swEl.style.color = d.enabled ? '#00e676' : '#f44336';
}
const parts = [
metroT('场景已请求', 'scene requested') + ': ' + (d.requested ? (metroEn() ? 'yes' : '是') : (metroEn() ? 'no' : '否')),
metroT('推送', 'pushes') + ' ' + (d.pushes || 0) + ' ' + metroT('次 / 列车', 'x / carriages') + ' ' + (d.trainsSent || 0),
d.lastPushUtc ? metroT('最近', 'last') + ' ' + d.lastPushUtc : null,
metroT('地区', 'raid') + ' ' + (d.raidId || 0)
+ (d.autoRaidLines ? (metroEn() ? ' · auto' : ' · 自动选线') : (metroEn() ? ' · manual' : ' · 手动选线')),
metroT('服务时间', 'service time') + ' ' + (d.serviceSeconds || 0) + 's',
metroT('周期', 'period') + ' ' + (d.pushMs || 0) + 'ms',
metroT('时间倍率', 'time factor') + ' ×' + (typeof d.timeFactor === 'number' ? d.timeFactor : '?'),
metroT('心跳', 'ticks') + ' ' + (d.ticks || 0),
d.lastError ? '⚠ ' + d.lastError : null,
].filter(Boolean);
stEl.textContent = parts.join(' · ');
stEl.style.color = d.lastError ? '#f44336' : '#4dd0e1';
if (resEl && d.lastError) resEl.textContent = '⚠ ' + d.lastError;
metroLines = Array.isArray(d.lines) ? d.lines : [];
if (metroPicked === null) metroPicked = new Set(metroLines.filter(l => l.enabled).map(l => l.id));
const sig = metroLines.map(l => l.id + (l.enabled ? '1' : '0')).join(',');
if (sig !== metroLineSig) {
metroLineSig = sig;
metroPicked = new Set(metroLines.filter(l => l.enabled).map(l => l.id));
metroRenderLines();
}
const trainList = Array.isArray(d.trains) ? d.trains : [];
metroRenderTrains(trainList);
metroMapSync(trainList);
}
function metroRenderLines() {
const box = document.getElementById('metroLines');
if (!box) return;
if (!metroLines.length) { box.textContent = metroT('服务端没有读到 RailLineConfig(配置没复制过去?)', 'the server read no RailLineConfig (was the config copied over?)'); return; }
const head = '<tr><th></th><th>' + metroT('线路', 'line') + '</th><th>' + metroT('名称', 'name') + '</th><th>'
+ metroT('类型', 'type') + '</th><th>' + metroT('列车 / 班次', 'carriages / interval') + '</th><th>'
+ metroT('首末班', 'first / last') + '</th><th>' + metroT('停靠点', 'stops') + '</th><th>'
+ metroT('单程时长', 'one-way') + '</th><th>Prefab</th></tr>';
const rows = metroLines.map(l => {
const stops = (l.stations || []).map(s => metroStationName(s));
const title = stops.slice(0, 24).join(metroEn() ? ' → ' : ' → ');
return '<tr>'
+ '<td><input type="checkbox" data-metro-line="' + l.id + '"' + (metroPicked && metroPicked.has(l.id) ? ' checked' : '') + '></td>'
+ '<td><b>' + l.id + '</b></td>'
+ '<td>' + (l.name || '—') + '</td>'
+ '<td>' + (l.isTestPath ? metroT('测试线', 'test path') : (l.ring ? metroT('环线', 'ring') : metroT('往返', 'out-and-back')))
+ (l.enabled ? ' <span style="color:#00e676">✓</span>' : '') + '</td>'
+ '<td>' + l.trainCount + ' / ' + l.departureIntervalSeconds + 's' + (l.maxTrainAmount ? ' (' + metroT('上限', 'max') + ' ' + l.maxTrainAmount + ')' : '') + '</td>'
+ '<td>' + l.firstTrain + '–' + l.lastTrain + '</td>'
+ '<td title="' + title.replace(/"/g, '&quot;') + '">' + l.stopCount + (stops.length ? ' (' + stops[0] + ' …)' : '') + '</td>'
+ '<td>' + metroClock(l.totalTravelSeconds) + '</td>'
+ '<td style="color:#777">' + (l.prefab || '—') + '</td>'
+ '</tr>';
}).join('');
box.innerHTML = '<table>' + head + rows + '</table>';
box.onchange = function (ev) {
const id = parseInt(ev.target.getAttribute('data-metro-line') || '0', 10);
if (!id) return;
if (!metroPicked) metroPicked = new Set();
if (ev.target.checked) metroPicked.add(id); else metroPicked.delete(id);
};
}
function metroRenderTrains(trains) {
const box = document.getElementById('metroTrains');
if (!box) return;
if (!trains.length) {
box.textContent = metroT('当前没有列车(勾选的线路都是空的,或者总开关关着)', 'no carriages right now (the ticked lines are empty, or the master switch is off)');
return;
}
const head = '<tr><th>' + metroT('列车', 'carriage') + '</th><th>' + metroT('线路', 'line') + '</th><th>'
+ metroT('状态', 'state') + '</th><th>' + metroT('当前站', 'at stop') + '</th><th>' + metroT('下一站', 'next stop') + '</th><th>'
+ metroT('还有多久', 'eta') + '</th><th>' + metroT('还有多远', 'distance') + '</th><th>' + metroT('线路里程', 'elapsed') + '</th><th>'
+ metroT('坐标', 'position') + '</th></tr>';
const rows = trains.map(t => '<tr>'
+ '<td><b>' + t.id + '</b></td>'
+ '<td>' + t.lineId + ' <span style="color:#777">' + (t.lineName || '') + '</span></td>'
+ '<td' + (t.atStation ? ' style="color:#ffb300"' : '') + '>' + (t.atStation ? metroT('在站', 'at stop') : metroT('行驶中', 'running')) + '</td>'
+ '<td>' + metroTrainStation(t, 'stopStationChinese', 'stopStation') + ' <span style="color:#777">#' + t.stopIndex + '</span></td>'
+ '<td>' + (t.isLastStop ? metroT('(终点)', '(terminus)') : metroTrainStation(t, 'nextStationChinese', 'nextStation')) + '</td>'
+ '<td>' + metroClock(t.secondsToNext) + '</td>'
+ '<td>' + (typeof t.metresToNext === 'number' ? Math.round(t.metresToNext) + 'm' : '—') + '</td>'
+ '<td>' + metroClock(t.elapsedSeconds) + '</td>'
+ '<td style="color:#777">' + (typeof t.x === 'number'
? t.x.toFixed(0) + ', ' + t.y.toFixed(0) + ', ' + t.z.toFixed(0) : '—') + '</td>'
+ '</tr>').join('');
box.innerHTML = '<table>' + head + rows + '</table>';
}
async function metroPost(path, body) {
const resEl = document.getElementById('metroResult');
try {
const d = await api(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body || {}),
});
if (resEl) resEl.textContent = d && d.ok === false
? '❌ ' + (d.error || metroT('失败', 'failed'))
: '✅ ' + metroT('已下发', 'sent');
metroLineSig = '';
await refreshMetro();
} catch (e) {
if (resEl) resEl.textContent = '❌ ' + e;
}
}
function metroToggle(on) {
return metroPost('/api/metro/toggle', { enabled: !!on });
}
function metroPushMs() {
const el = document.getElementById('metroPushMs');
const v = parseInt((el && el.value) || '250', 10);
return Number.isFinite(v) ? Math.min(2000, Math.max(50, v)) : 250;
}
// The multiplier between the timetable second and the ElapsedTime the client expects: the client divides
// what we send by 15 (SERVER_TIME_FACTOR), the schedule is in scene seconds, so 15 is the correct value.
// Kept editable so a future client change can be absorbed without a rebuild.
function metroTimeFactor() {
const el = document.getElementById('metroTimeFactor');
const v = parseFloat((el && el.value) || '15');
return Number.isFinite(v) && v > 0 ? Math.min(1000, Math.max(0.0001, v)) : 15;
}
function metroSetFactor(v) {
const el = document.getElementById('metroTimeFactor');
if (el) el.value = String(v);
return metroApplyTuning();
}
// Retune the push period / time factor without touching the ticked lines (the router keeps the selection
// when the body has no "lines" key).
function metroApplyTuning() {
return metroPost('/api/metro/lines', { pushMs: metroPushMs(), timeFactor: metroTimeFactor() });
}
function metroApplyLines() {
return metroPost('/api/metro/lines', { lines: Array.from(metroPicked || []), pushMs: metroPushMs(), timeFactor: metroTimeFactor() });
}
async function metroPreset(name) {
if (name === 'auto') {
// 交回「跟随当前地区」:不带 lines,只带 auto:true —— 服务端按当前 raid 的
// RaidConfig.LightRailLineIds 重选一次(新启 1-4 / 凌云 9-14+15)。
// 结果由 metroPost 的 refresh 回读,勾选框会跟着变。
return metroPost('/api/metro/lines', { auto: true, pushMs: metroPushMs(), timeFactor: metroTimeFactor() });
}
let ids = [];
if (name === 'main') ids = [1, 2, 3, 4];
else if (name === 'chongxiao') ids = [9, 10, 11, 12, 13, 14];
else if (name === 'cruise') ids = [15];
else if (name === 'all') ids = metroLines.map(l => l.id);
else if (name === 'none') ids = [];
metroPicked = new Set(ids);
const boxes = document.querySelectorAll('#metroLines input[data-metro-line]');
for (let i = 0; i < boxes.length; i++) boxes[i].checked = metroPicked.has(parseInt(boxes[i].getAttribute('data-metro-line'), 10));
await metroPost('/api/metro/lines', { lines: ids, pushMs: metroPushMs(), timeFactor: metroTimeFactor() });
}
// ---------------------------------------------------------------- round 58: metro run diagram
// The diagram is rebuilt by hand (its container is marked data-nol10n) because the arrows carry live
// numbers; it speaks both languages through metroT, so the DOM translation pass never touches it.
var METRO_MAP_LINES = [1, 2, 3, 4]; // the four services of the main city; the table below lists all 15
var METRO_MAP_MIRROR = { 2: true }; // the outer ring is the same track run the other way round, so its row
// prints the inner ring's stop order and its carriages travel right to left
var METRO_MAP_COLORS = {
1: { c: '#4dd0e1', cd: '#4dd0e166', g: 'rgba(77, 208, 225, .32)' },
2: { c: '#7e57c2', cd: '#7e57c266', g: 'rgba(126, 87, 194, .32)' },
3: { c: '#66bb6a', cd: '#66bb6a66', g: 'rgba(102, 187, 106, .32)' },
4: { c: '#ffa726', cd: '#ffa72666', g: 'rgba(255, 167, 38, .32)' },
};
var METRO_MAP_NAMES = {
1: { zh: '湾岸线 · 内环', en: 'Bayshore Line (Inner Ring)', dir: '→', subZh: '双向 · 往返', subEn: 'two-way · out-and-back' },
2: { zh: '湾岸线 · 外环', en: 'Bayshore Line (Outer Ring)', dir: '←', subZh: '双向 · 往返', subEn: 'two-way · out-and-back' },
3: { zh: '中央线', en: 'Central Line', dir: '→', subZh: '单向 · 环线', subEn: 'one-way · ring' },
4: { zh: '东城线', en: 'East Line', dir: '→', subZh: '单向 · 环线', subEn: 'one-way · ring' },
};
// The two Bayshore directions are one service, so only crossing to another line makes an interchange -
// that keeps 白帆湾 / 狂悖街区 / 晴海(湾岸)from being marked as one.
var METRO_MAP_GROUP = { 1: 'bayshore', 2: 'bayshore', 3: 'central', 4: 'east' };
var METRO_MAP_GROUP_ZH = { bayshore: '湾岸线', central: '中央线', east: '东城线' };
var METRO_MAP_GROUP_EN = { bayshore: 'Bayshore', central: 'Central', east: 'East' };
var METRO_MAP_DEPOT_EN = 'Ivory Cove Depot'; // this stop has no RailStationConfig row, so the server echoes
// the Chinese name in both name and chinese
var METRO_MAP_PITCH = 92; // px between two neighbouring stops
var METRO_MAP_STOP_WINDOW = 0.03; // 3% of a leg counts as standing at the platform (see metroMapUpdate)
var metroMapSig = '';
var metroMapGeom = {};
function metroMapColor(id) {
return METRO_MAP_COLORS[id] || { c: '#90a4ae', cd: '#90a4ae66', g: 'rgba(144, 164, 174, .32)' };
}
function metroMapEsc(s) {
return String(s === null || s === undefined ? '' : s).replace(/[&<>"]/g, function (m) {
return m === '&' ? '&amp;' : m === '<' ? '&lt;' : m === '>' ? '&gt;' : '&quot;';
});
}
function metroMapBuild() {
const box = document.getElementById('metroMap');
if (!box) return;
const drawn = METRO_MAP_LINES.map(function (id) {
return metroLines.find(function (l) { return l.id === id; });
}).filter(function (l) { return !!(l && Array.isArray(l.stations) && l.stations.length >= 2); });
const sig = (metroEn() ? 'en' : 'zh') + '|' + drawn.map(function (l) {
return l.id + ':' + l.stations.length + ':' + (l.enabled ? '1' : '0');
}).join(',');
if (sig === metroMapSig) return; // static layout: only the language or the line set rebuilds it
metroMapSig = sig;
metroMapGeom = {};
if (!drawn.length) {
box.innerHTML = '<div class="mm-legend">' + metroMapEsc(metroT(
'还没有线路数据:服务端没读到 RailLineConfig,或者场景还没请求过地铁列表(进入游戏世界并等场景加载完)。',
'no line data yet: the server read no RailLineConfig, or the scene has not asked for the metro list (enter the game world and let the scene finish loading).')) + '</div>';
return;
}
// Interchange = one station id shared by two different services; a name that owns two different station
// ids (晴海 sits on the Bayshore line and again on the Central line) needs a suffix to tell them apart.
const idsByName = {}, groupsById = {};
drawn.forEach(function (line) {
const grp = METRO_MAP_GROUP[line.id] || ('line' + line.id);
line.stations.forEach(function (s) {
if (!s.stationId) return;
const key = s.chinese || s.name || '';
(idsByName[key] = idsByName[key] || {})[s.stationId] = 1;
(groupsById[s.stationId] = groupsById[s.stationId] || {})[grp] = 1;
});
});
const dupName = {};
Object.keys(idsByName).forEach(function (k) {
if (Object.keys(idsByName[k]).length > 1) dupName[k] = 1;
});
const rows = drawn.map(function (line) {
const id = line.id, col = metroMapColor(id);
const info = METRO_MAP_NAMES[id] || {
zh: line.name || ('线路 ' + id), en: line.name || ('Line ' + id), dir: '→', subZh: '', subEn: '',
};
const n = line.stations.length;
const mirror = !!METRO_MAP_MIRROR[id];
const stops = mirror ? line.stations.slice().reverse() : line.stations;
const arrivals = line.stations.map(function (s) {
return typeof s.arrivalSeconds === 'number' && isFinite(s.arrivalSeconds) ? s.arrivalSeconds : 0;
});
const total = typeof line.totalTravelSeconds === 'number' && line.totalTravelSeconds > 0
? line.totalTravelSeconds : arrivals[n - 1];
const geom = { n: n, mirror: mirror, arrivals: arrivals, total: total, dots: [], lbls: [], arrows: null, nodes: {} };
metroMapGeom[id] = geom;
const cells = stops.map(function (s, slot) {
const depot = !s.stationId;
const grp = METRO_MAP_GROUP[id] || ('line' + id);
const transfer = !depot && Object.keys(groupsById[s.stationId] || {}).length >= 2;
const dotCls = 'mm-dot' + (depot ? ' dp' : (transfer ? ' tr' : ''));
const lblCls = 'mm-lbl' + (depot ? ' dp' : (transfer ? ' tr' : ''));
const zh = s.chinese || s.name || '';
let label;
if (depot) {
label = metroEn() ? METRO_MAP_DEPOT_EN : (zh || '车库');
} else {
label = metroEn() ? (s.name || zh) : (zh || s.name);
if (dupName[zh]) label += ' · ' + (metroEn() ? (METRO_MAP_GROUP_EN[grp] || grp) : (METRO_MAP_GROUP_ZH[grp] || grp));
}
// "Into the run" always counts from the left end of the row, so the mirrored row flips its clock.
const arr = arrivals[mirror ? (n - 1 - slot) : slot];
let tip = label + ' · ' + metroT('行程', 'into the run') + ' '
+ metroClock(mirror ? Math.max(0, total - arr) : arr);
tip += ' · ' + (typeof s.dwellSeconds === 'number' && s.dwellSeconds > 0
? metroT('停站', 'dwells') + ' ' + Math.round(s.dwellSeconds) + 's'
: metroT('不停站', 'no dwell'));
if (transfer) tip += ' · ' + metroT('换乘站', 'interchange');
if (depot) tip += ' · ' + metroT('车库(客户端里没有这个停靠点的站台数据)', 'depot (the client has no platform row for this stop)');
const t = metroMapEsc(tip);
return '<div class="mm-stop" style="left:' + (slot / (n - 1) * 100).toFixed(4) + '%">'
+ '<span class="' + dotCls + '" data-mm-cls="' + dotCls + '" title="' + t + '"></span>'
+ '<span class="' + lblCls + '" data-mm-cls="' + lblCls + '" title="' + t + '">' + metroMapEsc(label) + '</span>'
+ '</div>';
}).join('');
const sub = [
metroEn() ? info.subEn : info.subZh,
n + metroT(' 停靠点', ' stops'),
metroClock(total),
(typeof line.trainCount === 'number' ? line.trainCount : 0) + metroT(' 列车', ' carriages'),
line.enabled ? metroT('已推送', 'pushing') : metroT('未推送', 'idle'),
].filter(Boolean).join(' · ');
return '<div class="mm-row" data-mm-rid="' + id + '"' + (line.enabled ? '' : ' style="opacity:.62"') + '>'
+ '<div class="mm-head" style="border-left-color:' + col.c + '">'
+ '<div><span class="mm-badge" style="background:' + col.c + '">' + id + '</span>'
+ '<span class="mm-dir">' + info.dir + '</span></div>'
+ '<div class="mm-name">' + metroMapEsc(metroEn() ? info.en : info.zh) + '</div>'
+ '<div class="mm-sub">' + metroMapEsc(sub) + '</div>'
+ '</div>'
+ '<div class="mm-scroll"><div class="mm-track" style="--c:' + col.c + ';--cd:' + col.cd + ';--g:' + col.g
+ ';min-width:' + Math.max(560, (n - 1) * METRO_MAP_PITCH + 70) + 'px;width:100%">'
+ '<span class="mm-line"></span>' + cells + '<span class="mm-trains"></span>'
+ '</div></div>'
+ '</div>';
}).join('');
const legend = '<div class="mm-legend">'
+ metroT('换乘站', 'interchange') + '<i class="mm-k tr"></i>'
+ metroT('在站', 'at stop') + '<i class="mm-k at"></i>'
+ metroT('即将到站', 'arriving') + '<i class="mm-k near"></i>'
+ metroT('车库', 'depot') + '<i class="mm-k dp"></i>'
+ '&nbsp;&nbsp;' + metroT('▼ 一列车,数字是列车编号', '▼ a carriage, the number is its id')
+ '</div>';
box.innerHTML = legend + rows;
drawn.forEach(function (line) {
const geom = metroMapGeom[line.id];
const row = box.querySelector('[data-mm-rid="' + line.id + '"]');
if (!geom || !row) return;
geom.dots = Array.prototype.slice.call(row.querySelectorAll('.mm-dot'));
geom.lbls = Array.prototype.slice.call(row.querySelectorAll('.mm-lbl'));
geom.arrows = row.querySelector('.mm-trains');
geom.nodes = {}; // the row markup was just rebuilt, so every carriage node is gone
});
}
// Round 59: the carriage nodes are REUSED, not rebuilt.
//
// Until now every poll replaced the whole .mm-trains innerHTML. That kills any chance of a
// smooth ride: a brand new element starts at its final position, so a CSS transition can
// never run, and the diagram could only ever move in one-second jumps (blink-teleport-blink).
// Now one <span> is created per carriage, kept in geom.nodes and only re-positioned, so the
// 1s linear transition on .mm-train has something to animate.
//
// The one case a transition must NOT cover is a jump: a ring service that wraps from the
// last stop back to the first would otherwise slide the whole row backwards. Anything that
// moves more than a quarter of the row in one poll is placed with the transition suppressed
// for that single frame (.mm-snap + forced reflow).
function metroMapUpdate(trains) {
METRO_MAP_LINES.forEach(function (id) {
const geom = metroMapGeom[id];
if (!geom || !geom.arrows) return;
geom.dots.forEach(function (d) { d.className = d.getAttribute('data-mm-cls') || 'mm-dot'; });
geom.lbls.forEach(function (l) { l.className = l.getAttribute('data-mm-cls') || 'mm-lbl'; });
const list = (trains || []).filter(function (t) { return t && t.lineId === id; });
const n = geom.n;
const seen = {};
list.forEach(function (t) {
const i = t.stopIndex - 1;
if (!(i >= 0 && i < n)) return;
let target = (typeof t.nextStopIndex === 'number' && t.nextStopIndex > 0) ? t.nextStopIndex - 1 : -1;
const secs = (typeof t.secondsToNext === 'number' && isFinite(t.secondsToNext)) ? t.secondsToNext : 0;
let leg = 0;
if (target >= 0) {
leg = geom.arrivals[target] - geom.arrivals[i];
if (leg <= 0.001) { leg = 0; target = -1; } // ring wrap-around or a stale row: keep it on the dot
}
let frac = 0;
if (leg > 0) {
frac = 1 - secs / leg;
if (!(frac >= 0)) frac = 0;
if (frac > 1) frac = 1;
// "standing" comes from the server, so pin the carriage to its dot whatever the clock says.
if (t.atStation) frac = 0;
}
// Most stops have no dwell at all in the config (0 s), far too short to catch on a 1 s poll, so a
// carriage within the last few percent of a leg already counts as standing at the platform.
const at = !!t.atStation || target < 0 || (leg > 0 && frac <= METRO_MAP_STOP_WINDOW);
const near = !at && target >= 0 && leg > 0 && frac >= 1 - METRO_MAP_STOP_WINDOW;
const slot = geom.mirror ? (n - 1 - i) : i;
const pos = geom.mirror ? (slot - frac) : (slot + frac);
const left = (n > 1 ? pos / (n - 1) : 0) * 100;
const fill = at ? ' at' : (near ? ' near' : '');
if (fill) {
// "at" marks the stop the carriage stands on, "arriving" the one it is closing in on.
const fs = at ? slot : (geom.mirror ? n - 1 - target : target);
if (fs >= 0 && fs < n) {
const d = geom.dots[fs], l = geom.lbls[fs];
if (d) d.className = (d.getAttribute('data-mm-cls') || 'mm-dot') + fill;
if (l) l.className = (l.getAttribute('data-mm-cls') || 'mm-lbl') + fill;
}
}
const num = (typeof t.id === 'number' ? t.id : (id * 100 + (t.index || 0)));
const key = 't' + num;
seen[key] = 1;
const tip = metroMapEsc(metroT('列车 ', 'carriage ') + num + ' · ' + (t.stopName || '—')
+ (at ? metroT('(在站)', ' (at the stop)') : '')
+ (target >= 0 ? ' → ' + (t.nextStopName || '—') + ' · ' + metroT('还有 ', 'in ') + metroClock(secs) : '')
+ (typeof t.metresToNext === 'number' && t.metresToNext > 0 ? ' · ' + Math.round(t.metresToNext) + 'm' : ''));
let node = geom.nodes[key];
if (!node) {
node = document.createElement('span');
node.className = 'mm-train mm-snap';
node.innerHTML = '<i class="mm-arrow"></i><b class="mm-tid"></b>';
node.style.left = left.toFixed(4) + '%';
geom.arrows.appendChild(node);
geom.nodes[key] = node;
node.setAttribute('data-mm-left', String(left));
void node.offsetWidth; // flush the first placement without a slide
node.className = 'mm-train';
} else {
const prev = parseFloat(node.getAttribute('data-mm-left'));
const jump = !isFinite(prev) || Math.abs(left - prev) > 25;
if (jump) node.classList.add('mm-snap');
node.style.left = left.toFixed(4) + '%';
if (jump) {
void node.offsetWidth; // commit the jump, then hand control back to the transition
node.classList.remove('mm-snap');
}
node.setAttribute('data-mm-left', String(left));
}
const arrow = node.firstElementChild, tid = node.lastElementChild;
if (arrow) arrow.setAttribute('title', tip);
if (tid) {
tid.textContent = num;
tid.className = 'mm-tid' + fill.replace(' near', '');
}
node.className = 'mm-train' + fill.replace(' near', '');
});
Object.keys(geom.nodes).forEach(function (key) {
if (seen[key]) return;
const node = geom.nodes[key];
if (node && node.parentNode) node.parentNode.removeChild(node);
delete geom.nodes[key];
});
});
}
function metroMapSync(trains) {
metroMapBuild();
metroMapUpdate(trains || []);
}
setTimeout(refreshMetro, 0);
setInterval(refreshMetro, 1000);
""";
}