662 lines
40 KiB
C#
662 lines
40 KiB
C#
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
|
||
/// <style>, the card into the body, the script before </script>. 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, '"') + '">' + 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 === '&' ? '&' : m === '<' ? '<' : m === '>' ? '>' : '"';
|
||
});
|
||
}
|
||
|
||
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>'
|
||
+ ' ' + 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);
|
||
""";
|
||
|
||
}
|