namespace Ananta.Server.App.Admin; /// /// 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 api(path, opts) /// fetch helper, plus the donor table rules in the page stylesheet. /// 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 = """

🚈 地铁 / 轻轨

总开关 —
推送周期 毫秒 (玩家远离轨道时自动降到 3000 ms;列车由客户端自己推进,降频只影响对时粒度)
时间倍率 ×
快捷组合:
说明
一行=一条线路,圆点=停靠点,虚线方块=车库,白圈=换乘站,▼=一列车(上方数字是列车编号)。车在站台上时那一站圆点变大发光、站名变黄;否则车落在两站之间。
「时间倍率」默认 15:客户端会把收到的值 ÷15 才是场景秒。除非客户端改过,别动它。
说明
列车编号 = 线路号 × 100 + 车序(101 = 1 号线第 1 列)。「还有多远」是按时间比例估算的直线距离,不是仪表。
"""; 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 = '' + metroT('线路', 'line') + '' + metroT('名称', 'name') + '' + metroT('类型', 'type') + '' + metroT('列车 / 班次', 'carriages / interval') + '' + metroT('首末班', 'first / last') + '' + metroT('停靠点', 'stops') + '' + metroT('单程时长', 'one-way') + 'Prefab'; const rows = metroLines.map(l => { const stops = (l.stations || []).map(s => metroStationName(s)); const title = stops.slice(0, 24).join(metroEn() ? ' → ' : ' → '); return '' + '' + '' + l.id + '' + '' + (l.name || '—') + '' + '' + (l.isTestPath ? metroT('测试线', 'test path') : (l.ring ? metroT('环线', 'ring') : metroT('往返', 'out-and-back'))) + (l.enabled ? ' ✓' : '') + '' + '' + l.trainCount + ' / ' + l.departureIntervalSeconds + 's' + (l.maxTrainAmount ? ' (' + metroT('上限', 'max') + ' ' + l.maxTrainAmount + ')' : '') + '' + '' + l.firstTrain + '–' + l.lastTrain + '' + '' + l.stopCount + (stops.length ? ' (' + stops[0] + ' …)' : '') + '' + '' + metroClock(l.totalTravelSeconds) + '' + '' + (l.prefab || '—') + '' + ''; }).join(''); box.innerHTML = '' + head + rows + '
'; 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 = '' + metroT('列车', 'carriage') + '' + metroT('线路', 'line') + '' + metroT('状态', 'state') + '' + metroT('当前站', 'at stop') + '' + metroT('下一站', 'next stop') + '' + metroT('还有多久', 'eta') + '' + metroT('还有多远', 'distance') + '' + metroT('线路里程', 'elapsed') + '' + metroT('坐标', 'position') + ''; const rows = trains.map(t => '' + '' + t.id + '' + '' + t.lineId + ' ' + (t.lineName || '') + '' + '' + (t.atStation ? metroT('在站', 'at stop') : metroT('行驶中', 'running')) + '' + '' + metroTrainStation(t, 'stopStationChinese', 'stopStation') + ' #' + t.stopIndex + '' + '' + (t.isLastStop ? metroT('(终点)', '(terminus)') : metroTrainStation(t, 'nextStationChinese', 'nextStation')) + '' + '' + metroClock(t.secondsToNext) + '' + '' + (typeof t.metresToNext === 'number' ? Math.round(t.metresToNext) + 'm' : '—') + '' + '' + metroClock(t.elapsedSeconds) + '' + '' + (typeof t.x === 'number' ? t.x.toFixed(0) + ', ' + t.y.toFixed(0) + ', ' + t.z.toFixed(0) : '—') + '' + '').join(''); box.innerHTML = '' + head + rows + '
'; } 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 = '
' + 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).')) + '
'; 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 '
' + '' + '' + metroMapEsc(label) + '' + '
'; }).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 '
' + '
' + '
' + id + '' + '' + info.dir + '
' + '
' + metroMapEsc(metroEn() ? info.en : info.zh) + '
' + '
' + metroMapEsc(sub) + '
' + '
' + '
' + '' + cells + '' + '
' + '
'; }).join(''); const legend = '
' + metroT('换乘站', 'interchange') + '' + metroT('在站', 'at stop') + '' + metroT('即将到站', 'arriving') + '' + metroT('车库', 'depot') + '' + '  ' + metroT('▼ 一列车,数字是列车编号', '▼ a carriage, the number is its id') + '
'; 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 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 = ''; 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); """; }