This repository has been archived on 2026-10-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Ananta-ps/Ananta.Server/Ananta.App/Admin/MetroCardParts.cs
T

661 lines
40 KiB
C#
Raw Normal View History

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);
""";
}