logo NodeSeekbeta

【油猴脚本】给ping.pe的ping结果加上更直观的地图展示

因为itdog目前不太好用,所以目前重新用上ping.pe了,但是由于节点众多且无分类,所以用vibe了一个脚本出来方便展示,地图支持缩放拖拽,可以直接点击国家,右边会显示该国家的节点,点击节点可以跳转到对应节点并自动展开mtr结果

效果如图

image

脚本如下

// ==UserScript==
// @name         Looking Glass Map for ping.pe
// @namespace    https://ping.pe/
// @version      1.1.0
// @description  将 ping.pe 的全球 Ping/MTR 结果可视化为可缩放区域地图,并支持点击城市跳转 MTR 明细。小地区(香港、新加坡等)额外渲染散点标记。
// @author       SmileQWQ
// @match        https://ping.pe/*
// @match        https://ping6.ping.pe/*
// @match        https://chart.ping.pe/*
// @match        https://chart6.ping.pe/*
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/echarts.min.js
// @resource     worldGeoJson https://cdn.jsdelivr.net/npm/[email protected]/map/json/world.json
// @grant        GM_addStyle
// @grant        GM_getResourceText
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  const PANEL_ID = 'pingpe-global-region-map';
  const UPDATE_INTERVAL_MS = 4000;
  const WORLD_MAP = 'pingpe-world';

  const BUCKETS = [
    { key: 'timeout', label: '超时', color: '#e51b1b' },
    { key: 'gt250', label: '>250ms', color: '#ff9829' },
    { key: '201-250', label: '201ms-250ms', color: '#f1e62b' },
    { key: '101-200', label: '101ms-200ms', color: '#b8f052' },
    { key: '51-100', label: '51ms-100ms', color: '#35d144' },
    { key: 'le50', label: '<=50ms', color: '#18a81f' },
    { key: 'pending', label: '等待中', color: '#6b7280' },
  ];

  const COUNTRY_ALIAS = {
    USA: 'United States',
    UK: 'United Kingdom',
    UAE: 'United Arab Emirates',
    Kyiv: 'Ukraine',
    Kosovo: 'Kosovo',
    Taiwan: 'Taiwan',
    'Hong Kong': 'Hong Kong',
  };

  // 小地区坐标表:地图面积太小、难以点中的地区
  // 坐标为 [经度, 纬度]
  const SMALL_REGION_COORDS = {
    'Hong Kong':          [114.17,  22.32],
    'Singapore':          [103.82,   1.35],
    'Taiwan':             [120.97,  23.70],
    'Macau':              [113.55,  22.20],
    'Luxembourg':         [  6.13,  49.61],
    'Bahrain':            [ 50.55,  26.07],
    'Maldives':           [ 73.22,   3.20],
    'Malta':              [ 14.51,  35.90],
    'Liechtenstein':      [  9.52,  47.14],
    'Monaco':             [  7.42,  43.73],
    'San Marino':         [ 12.46,  43.94],
    'Andorra':            [  1.52,  42.51],
    'Vatican City':       [ 12.45,  41.90],
    'Cyprus':             [ 33.43,  35.13],
    'Iceland':            [-19.02,  64.96],
    'Trinidad and Tobago':[-61.22,  10.65],
    'Jamaica':            [-77.30,  18.11],
    'Mauritius':          [ 57.55, -20.28],
    'Reunion':            [ 55.54, -21.13],
    'Djibouti':           [ 43.14,  11.83],
  };

  // 需要用散点标记的地区集合(面积小 or 地图上看不清楚)
  const SMALL_REGION_SET = new Set(Object.keys(SMALL_REGION_COORDS));

  let chart = null;
  let panelEl = null;
  let chartEl = null;
  let lastMetric = 'avg';
  let lastSignature = '';
  let renderQueued = false;
  let currentZoom = 1.18;
  let currentCenter = null;
  let lastGroups = new Map();

  function boot() {
    if (document.getElementById(PANEL_ID)) return;
    const table = document.querySelector('table.pingtable');
    if (!table || !document.querySelector('tr.ping-result-row')) {
      window.setTimeout(boot, 500);
      return;
    }
    init(table);
  }

  function init(table) {
    if (typeof echarts === 'undefined') {
      insertError(table, 'ECharts 加载失败:请检查 Tampermonkey 是否允许访问 cdn.jsdelivr.net。');
      return;
    }

    try {
      echarts.registerMap(WORLD_MAP, JSON.parse(GM_getResourceText('worldGeoJson')));
    } catch (error) {
      insertError(table, `世界地图 GeoJSON 加载失败:${error && error.message ? error.message : error}`);
      return;
    }

    GM_addStyle(`
      #${PANEL_ID} {
        margin: 14px 0 18px;
        border: 1px solid #155015;
        border-radius: 10px;
        overflow: hidden;
        background: #050805;
        box-shadow: 0 0 0 1px rgba(48,224,80,.12), 0 12px 30px rgba(0,0,0,.35);
      }
      #${PANEL_ID} .pgr-toolbar {
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: 12px;
        padding: 9px 11px;
        color: #e6ffe6;
        background: linear-gradient(90deg, #001c00, #071407);
        border-bottom: 1px solid #155015;
        flex-wrap: wrap;
      }
      #${PANEL_ID} .pgr-title { color: #30e050; font-weight: 700; }
      #${PANEL_ID} .pgr-status { color: #cdeccd; opacity: .9; }
      #${PANEL_ID} .pgr-controls {
        display: flex;
        align-items: center;
        gap: 8px;
        flex-wrap: wrap;
      }
      #${PANEL_ID} select,
      #${PANEL_ID} button {
        color: #e6ffe6;
        background: #002000;
        border: 1px solid #238023;
        border-radius: 5px;
        padding: 4px 8px;
        font: inherit;
        cursor: pointer;
      }
      #${PANEL_ID} .pgr-layout {
        display: grid;
        grid-template-columns: minmax(0, 1fr) 330px;
        background: #050805;
      }
      #${PANEL_ID} .pgr-chart {
        height: min(68vh, 650px);
        min-height: 430px;
        background: #f7f7f2;
        border-right: 1px solid #155015;
      }
      #${PANEL_ID} .pgr-detail {
        height: min(68vh, 650px);
        min-height: 430px;
        overflow: auto;
        color: #e6ffe6;
        background: #020802;
      }
      #${PANEL_ID} .pgr-detail-head {
        position: sticky;
        top: 0;
        z-index: 1;
        padding: 10px 12px;
        background: #002000;
        border-bottom: 1px solid #155015;
      }
      #${PANEL_ID} .pgr-detail-title {
        color: #30e050;
        font-weight: 700;
        margin-bottom: 4px;
      }
      #${PANEL_ID} .pgr-detail-table {
        width: 100%;
        border-collapse: collapse;
        font-size: 12px;
        line-height: 1.45;
      }
      #${PANEL_ID} .pgr-detail-table th,
      #${PANEL_ID} .pgr-detail-table td {
        padding: 5px 7px;
        border-bottom: 1px solid rgba(48,224,80,.13);
        text-align: left;
        white-space: nowrap;
      }
      #${PANEL_ID} .pgr-detail-table tbody tr {
        cursor: pointer;
      }
      #${PANEL_ID} .pgr-detail-table tbody tr:hover {
        background: rgba(48,224,80,.16);
      }
      #${PANEL_ID} .pgr-detail-table th {
        color: #001800;
        background: #30e050;
        position: sticky;
        top: 59px;
        z-index: 1;
      }
      #${PANEL_ID} .pgr-detail-empty {
        padding: 14px 12px;
        color: #bcdabc;
      }
      #${PANEL_ID}.pgr-fullscreen {
        position: fixed;
        z-index: 999999;
        inset: 10px;
        margin: 0;
      }
      #${PANEL_ID}.pgr-fullscreen .pgr-chart,
      #${PANEL_ID}.pgr-fullscreen .pgr-detail {
        height: calc(100vh - 78px);
        max-height: none;
      }
      #${PANEL_ID} .pgr-legend {
        display: flex;
        gap: 10px 14px;
        align-items: center;
        flex-wrap: wrap;
        padding: 8px 11px;
        color: #d7ead7;
        background: #020702;
        border-top: 1px solid #155015;
      }
      #${PANEL_ID} .pgr-legend-item {
        display: inline-flex;
        align-items: center;
        gap: 5px;
        white-space: nowrap;
      }
      #${PANEL_ID} .pgr-swatch {
        width: 14px;
        height: 14px;
        border-radius: 4px;
        display: inline-block;
      }
      .pgr-tooltip {
        min-width: 220px;
        max-width: 360px;
        color: #fff;
        background: rgba(38, 48, 38, .86);
        border-radius: 4px;
        box-shadow: 0 12px 28px rgba(0,0,0,.35);
        overflow: hidden;
        font-family: Roboto Mono, Consolas, "Courier New", monospace;
      }
      .pgr-tooltip-title {
        padding: 8px 12px;
        text-align: center;
        background: #4384f5;
        color: #fff;
        font-size: 16px;
        line-height: 1.2;
      }
      .pgr-tooltip-body { padding: 12px 18px 14px; font-size: 14px; line-height: 1.65; }
      .pgr-tooltip-line { display: flex; align-items: baseline; gap: 4px; white-space: nowrap; }
      .pgr-tooltip-isp { color: #7de354; }
      .pgr-tooltip-value { margin-left: auto; }
      .pgr-tooltip-hint { margin-top: 3px; text-align: center; color: #fff; opacity: .92; }
      tr.pgr-jump-highlight > td {
        outline: 1px solid #30e050;
        box-shadow: inset 0 0 0 9999px rgba(48,224,80,.12);
      }
      @media (max-width: 980px) {
        #${PANEL_ID} .pgr-layout { grid-template-columns: 1fr; }
        #${PANEL_ID} .pgr-chart { border-right: 0; border-bottom: 1px solid #155015; }
        #${PANEL_ID} .pgr-detail { min-height: 260px; height: 330px; }
      }
    `);

    panelEl = document.createElement('section');
    panelEl.id = PANEL_ID;
    panelEl.innerHTML = `
      <div class="pgr-toolbar">
        <div>
          <span class="pgr-title">Looking Glass Map</span>
          <span class="pgr-status" data-role="status">waiting...</span>
        </div>
        <div class="pgr-controls">
          <label>指标
            <select data-role="metric">
              <option value="avg" selected>Avg</option>
              <option value="last">Last</option>
              <option value="best">Best</option>
              <option value="worst">Worst</option>
            </select>
          </label>
          <button type="button" data-role="zoom-in">+</button>
          <button type="button" data-role="zoom-out">-</button>
          <button type="button" data-role="reset">重置视图</button>
          <button type="button" data-role="fullscreen">全屏</button>
        </div>
      </div>
      <div class="pgr-layout">
        <div class="pgr-chart" data-role="chart"></div>
        <aside class="pgr-detail" data-role="detail"></aside>
      </div>
      <div class="pgr-legend">
        ${BUCKETS.map(b => `<span class="pgr-legend-item"><i class="pgr-swatch" style="background:${b.color}"></i>${b.label}</span>`).join('')}
        <span class="pgr-legend-item"><i class="pgr-swatch" style="background:#fff;border:2px solid #30e050;border-radius:50%;box-sizing:border-box"></i>小地区标记(可点击)</span>
      </div>
    `;

    table.parentNode.insertBefore(panelEl, table);
    chartEl = panelEl.querySelector('[data-role="chart"]');
    chart = echarts.init(chartEl, null, { renderer: 'canvas', useDirtyRect: true });

    panelEl.querySelector('[data-role="metric"]').addEventListener('change', ev => {
      lastMetric = ev.target.value;
      lastSignature = '';
      scheduleRender(true);
    });
    panelEl.querySelector('[data-role="zoom-in"]').addEventListener('click', () => zoomBy(1.28));
    panelEl.querySelector('[data-role="zoom-out"]').addEventListener('click', () => zoomBy(0.78));
    panelEl.querySelector('[data-role="reset"]').addEventListener('click', resetView);
    panelEl.querySelector('[data-role="fullscreen"]').addEventListener('click', () => {
      panelEl.classList.toggle('pgr-fullscreen');
      window.setTimeout(() => chart.resize(), 120);
    });
    panelEl.querySelector('[data-role="detail"]').addEventListener('click', ev => {
      const tr = ev.target.closest('tr[data-pinger-id]');
      if (!tr) return;
      jumpToMtr(tr.getAttribute('data-pinger-id'));
    });

    chartEl.addEventListener('wheel', ev => ev.preventDefault(), { passive: false });
    window.addEventListener('resize', () => chart && chart.resize());

    chart.on('georoam', () => {
      const option = chart.getOption();
      // geo 组件和 map series 都会触发 georoam,优先从 geo 读,回退到 map series
      const geoComp = option.geo && option.geo[0];
      const mapSeries = option.series && option.series[0];
      const src = geoComp || mapSeries;
      if (src) {
        currentZoom = Array.isArray(src.zoom) ? src.zoom[0] : (src.zoom || currentZoom);
        const rawCenter = Array.isArray(src.center) ? src.center : null;
        if (rawCenter) currentCenter = Array.isArray(rawCenter[0]) ? rawCenter[0] : rawCenter;
      }
    });

    // geo 区域点击(国家/地区)
    chart.on('click', { geoIndex: 0 }, params => {
      if (params && params.name) {
        renderDetail(params.name);
      }
    });

    // 散点标记点击(小地区圆点)
    chart.on('click', { seriesId: 'pingpe-dot-series' }, params => {
      if (params && params.data && params.data.country) {
        renderDetail(params.data.country);
      }
    });

    const observer = new MutationObserver(() => scheduleRender(false));
    observer.observe(table, {
      subtree: true,
      attributes: true,
      attributeFilter: ['data-last', 'data-avg', 'data-best', 'data-worst', 'data-loss', 'data-sent'],
    });

    scheduleRender(true);
    window.setInterval(() => scheduleRender(false), UPDATE_INTERVAL_MS);
  }

  function insertError(table, message) {
    const div = document.createElement('div');
    div.id = PANEL_ID;
    div.style.cssText = 'margin:12px 0;padding:10px;border:1px solid #803030;background:#300;color:#fdd;border-radius:6px';
    div.textContent = message;
    table.parentNode.insertBefore(div, table);
  }

  function scheduleRender(force) {
    if (renderQueued) return;
    renderQueued = true;
    window.requestAnimationFrame(() => {
      renderQueued = false;
      render(force);
    });
  }

  function render(force) {
    if (!chart) return;

    const rows = Array.from(document.querySelectorAll('tr.ping-result-row'));
    const groups = collectCountryGroups(rows, lastMetric);
    const signature = buildSignature(groups, rows.length);
    if (!force && signature === lastSignature) return;
    lastSignature = signature;
    lastGroups = groups;

    // ── 散点标记数据(仅有数据的小地区)──
    const dotData = [];
    for (const [country, group] of groups) {
      if (!SMALL_REGION_SET.has(country)) continue;
      const coords = SMALL_REGION_COORDS[country];
      if (!coords) continue;
      dotData.push({
        name: country,
        value: [...coords, group.value],   // [lng, lat, metricValue]
        country,
        group,
        itemStyle: {
          color: group.bucket.color,
          borderColor: '#ffffff',
          borderWidth: 1.5,
          shadowBlur: 6,
          shadowColor: 'rgba(0,0,0,0.5)',
        },
        emphasis: {
          itemStyle: {
            color: group.bucket.color,
            borderColor: '#30e050',
            borderWidth: 2.5,
            shadowBlur: 14,
            shadowColor: group.bucket.color,
          },
        },
      });
    }

    const mappedRows = Array.from(groups.values()).reduce((sum, group) => sum + group.rows.length, 0);
    const timeoutRows = rows.filter(row => {
      const value = parseLatency(row.dataset[lastMetric]);
      const sent = parseNumber(row.dataset.sent);
      const loss = parseNumber(row.dataset.loss);
      return classify(value, sent, loss).key === 'timeout';
    }).length;
    updateStatus(rows.length, mappedRows, rows.length - mappedRows, timeoutRows);

    const tooltipFormatter = params => {
      const g = params.data && params.data.group;
      return buildTooltip(g, params.name);
    };

    // geo regions:把每个国家的颜色注入到 geo 组件的 regions 数组
    // 只渲染一次地图,scatter 也挂在同一个 geo 上,彻底避免双重渲染
    const geoRegions = Array.from(groups.values()).map(group => ({
      name: group.country,
      itemStyle: { areaColor: group.bucket.color },
      emphasis: {
        itemStyle: {
          areaColor: group.bucket.color,
          borderColor: '#ffffff',
          borderWidth: 1.5,
        },
        label: { show: true, color: '#111', fontWeight: 'bold' },
      },
      // 附上 group 以供 tooltip/click 使用
      _group: group,
    }));

    chart.setOption({
      animation: false,
      backgroundColor: '#f7f7f2',
      tooltip: {
        trigger: 'item',
        borderWidth: 0,
        padding: 0,
        transitionDuration: 0,
        confine: true,
        extraCssText: 'box-shadow:none;background:transparent;',
        formatter: params => {
          // geo 区域点击时 params.data 是 region 对象(含 _group),scatter 时是 dotData 项
          const g = (params.data && params.data._group)
            || (params.data && params.data.group)
            || null;
          return buildTooltip(g, params.name);
        },
      },
      geo: [{
        id: 'pingpe-geo',
        map: WORLD_MAP,
        roam: true,
        zoom: currentZoom,
        center: currentCenter || undefined,
        scaleLimit: { min: 0.9, max: 30 },
        selectedMode: false,
        nameProperty: 'name',
        itemStyle: {
          areaColor: '#e9e7df',
          borderColor: '#cfcfc6',
          borderWidth: 0.6,
        },
        emphasis: {
          itemStyle: { borderColor: '#ffffff', borderWidth: 1 },
          label: { show: true, color: '#111', fontWeight: 'bold' },
        },
        label: { show: false },
        regions: geoRegions,
      }],
      series: [{
        id: 'pingpe-dot-series',
        type: 'scatter',
        coordinateSystem: 'geo',
        geoIndex: 0,
        data: dotData,
        symbol: 'circle',
        symbolSize: 10,
        zlevel: 2,
        label: {
          show: true,
          position: 'right',
          formatter: params => params.name,
          fontSize: 10,
          color: '#1a1a1a',
          textBorderColor: '#fff',
          textBorderWidth: 2,
        },
        emphasis: {
          label: {
            show: true,
            fontSize: 11,
            fontWeight: 'bold',
            color: '#001800',
            textBorderColor: '#30e050',
            textBorderWidth: 2,
          },
          scale: 1.5,
        },
      }],
    }, {
      notMerge: false,
      lazyUpdate: true,
      replaceMerge: ['series'],
    });

    const selected = panelEl?.querySelector('[data-role="detail"]')?.dataset.country;
    if (selected && groups.has(selected)) {
      renderDetail(selected);
    } else {
      renderDetail(pickDefaultCountry(groups));
    }
  }

  function collectCountryGroups(rows, metric) {
    const groups = new Map();
    for (const row of rows) {
      const location = clean(row.dataset.location || row.querySelector('.td-location')?.textContent || '');
      if (!location) continue;

      const country = toMapCountry(location);
      if (!country) continue;

      const item = {
        id: row.dataset.pingerId || '',
        location,
        city: shortLocationTitle(location),
        provider: clean(row.dataset.provider || row.querySelector('.td-provider')?.textContent || ''),
        value: parseLatency(row.dataset[metric]),
        last: parseLatency(row.dataset.last),
        avg: parseLatency(row.dataset.avg),
        best: parseLatency(row.dataset.best),
        worst: parseLatency(row.dataset.worst),
        loss: parseNumber(row.dataset.loss),
        sent: parseNumber(row.dataset.sent),
      };
      item.bucket = classify(item.value, item.sent, item.loss);

      if (!groups.has(country)) {
        groups.set(country, { country, rows: [], value: NaN, bucket: BUCKETS[6] });
      }

      const group = groups.get(country);
      group.rows.push(item);
      if (normalizedLatency(item.value) < normalizedLatency(group.value)) {
        group.value = item.value;
        group.bucket = item.bucket;
      } else if (!Number.isFinite(group.value) && item.bucket.key === 'timeout') {
        group.bucket = item.bucket;
      }
    }
    return groups;
  }

  function renderDetail(country) {
    const detail = panelEl && panelEl.querySelector('[data-role="detail"]');
    if (!detail) return;
    const group = lastGroups.get(country);
    if (!group) {
      detail.dataset.country = '';
      detail.innerHTML = `<div class="pgr-detail-empty">点击地图上的国家/地区查看城市节点明细。</div>`;
      return;
    }

    detail.dataset.country = country;
    const rows = group.rows.slice().sort((a, b) => normalizedLatency(a.value) - normalizedLatency(b.value));
    const fastest = rows.find(item => Number.isFinite(item.value));
    detail.innerHTML = `
      <div class="pgr-detail-head">
        <div class="pgr-detail-title">${escapeHtml(country)}</div>
        <div>最快:${fastest ? escapeHtml(formatValue(fastest.value)) : '等待中/超时'} · 节点:${rows.length} · 指标:${escapeHtml(lastMetric.toUpperCase())}</div>
      </div>
      <table class="pgr-detail-table">
        <thead>
          <tr>
            <th>城市</th>
            <th>ISP</th>
            <th>${escapeHtml(lastMetric.toUpperCase())}</th>
            <th>Loss</th>
            <th>Sent</th>
          </tr>
        </thead>
        <tbody>
          ${rows.map(item => `
            <tr data-pinger-id="${escapeHtml(item.id)}" title="点击展开并跳转到该节点 MTR 结果">
              <td>${escapeHtml(item.city)}</td>
              <td title="${escapeHtml(item.provider)}">${escapeHtml(shortProvider(item.provider || item.id))}</td>
              <td style="background:${classify(item.value, item.sent, item.loss).color};color:#001800">${escapeHtml(formatValue(item.value))}</td>
              <td>${Number.isFinite(item.loss) ? `${item.loss}%` : '-'}</td>
              <td>${Number.isFinite(item.sent) ? item.sent : '-'}</td>
            </tr>
          `).join('')}
        </tbody>
      </table>
    `;
  }

  function jumpToMtr(pingerId) {
    if (!pingerId) return;

    const pingRow = document.getElementById(`ping-${pingerId}-tr`);
    const showButton = document.getElementById(`td-${pingerId}-mtr-report-show`)
      || document.querySelector(`.mtr-toggle[data-pinger-id="${cssAttrEscape(pingerId)}"]`);

    if (pingRow && pingRow.getAttribute('data-mtr-visible') !== '1' && showButton) {
      showButton.click();
    }

    window.setTimeout(() => {
      const target = document.getElementById(`tr-${pingerId}-mtr-report`)
        || document.getElementById(`ping-${pingerId}-tr`);
      if (!target) return;

      target.scrollIntoView({ behavior: 'smooth', block: 'center' });
      highlightRows(pingerId);
    }, 180);
  }

  function highlightRows(pingerId) {
    const rows = [
      document.getElementById(`ping-${pingerId}-tr`),
      document.getElementById(`tr-${pingerId}-mtr-report`),
    ].filter(Boolean);

    rows.forEach(row => row.classList.add('pgr-jump-highlight'));
    window.setTimeout(() => rows.forEach(row => row.classList.remove('pgr-jump-highlight')), 2200);
  }

  function buildTooltip(group, fallbackName) {
    if (!group) {
      return `
        <div class="pgr-tooltip">
          <div class="pgr-tooltip-title">${escapeHtml(fallbackName || '-')}</div>
          <div class="pgr-tooltip-body">暂无 ping.pe 节点数据<br>点击其它染色区域查看明细</div>
        </div>
      `;
    }

    const rows = group.rows.slice().sort((a, b) => normalizedLatency(a.value) - normalizedLatency(b.value));
    const fastest = rows.find(item => Number.isFinite(item.value));
    const maxLines = 8;

    return `
      <div class="pgr-tooltip">
        <div class="pgr-tooltip-title">${escapeHtml(group.country)}</div>
        <div class="pgr-tooltip-body">
          <div>最快响应:${fastest ? escapeHtml(formatValue(fastest.value)) : '等待中/超时'}</div>
          <div>节点数量:${rows.length}</div>
          ${rows.slice(0, maxLines).map(item => `
            <div class="pgr-tooltip-line">
              <span class="pgr-tooltip-isp">[${escapeHtml(shortProvider(item.provider || item.id))}]</span>
              <span>${escapeHtml(item.city)}</span>
              <span class="pgr-tooltip-value">${escapeHtml(formatValue(item.value))}</span>
            </div>
          `).join('')}
          ${rows.length > maxLines ? `<div>还有 ${rows.length - maxLines} 条线路,点击国家看完整明细...</div>` : ''}
          <div class="pgr-tooltip-hint">--- 滚轮缩放,拖动平移,点击看城市 ---</div>
        </div>
      </div>
    `;
  }

  function zoomBy(factor) {
    currentZoom = Math.max(0.9, Math.min(30, currentZoom * factor));
    chart.setOption({
      geo: [{ id: 'pingpe-geo', zoom: currentZoom, center: currentCenter || undefined }],
    }, { lazyUpdate: true });
  }

  function resetView() {
    currentZoom = 1.18;
    currentCenter = null;
    chart.setOption({
      geo: [{ id: 'pingpe-geo', zoom: currentZoom, center: undefined }],
    }, { lazyUpdate: true });
  }

  function buildSignature(groups, totalRows) {
    const parts = [lastMetric, totalRows];
    Array.from(groups.keys()).sort().forEach(country => {
      const group = groups.get(country);
      parts.push(country, Math.round((Number.isFinite(group.value) ? group.value : -1) * 100), group.rows.length, group.bucket.key);
    });
    return parts.join('|');
  }

  function updateStatus(total, mapped, skipped, timeoutRows) {
    const status = document.querySelector(`#${PANEL_ID} [data-role="status"]`);
    if (!status) return;
    const fastRows = Array.from(document.querySelectorAll('tr.ping-result-row')).filter(row => {
      const value = parseLatency(row.dataset[lastMetric]);
      return Number.isFinite(value) && value <= 50;
    }).length;
    status.textContent = ` · mapped ${mapped}/${total}, skipped ${skipped}, timeout ${timeoutRows}, <=50ms ${fastRows}`;
  }

  function pickDefaultCountry(groups) {
    if (groups.has('China')) return 'China';
    if (groups.has('United States')) return 'United States';
    return groups.keys().next().value || '';
  }

  function toMapCountry(location) {
    const first = clean(location).split(',')[0].trim();
    return COUNTRY_ALIAS[first] || first;
  }

  function classify(value, sent, loss) {
    if ((!Number.isFinite(value) && sent > 0) || loss >= 100) return BUCKETS[0];
    if (!Number.isFinite(value)) return BUCKETS[6];
    if (value > 250) return BUCKETS[1];
    if (value > 200) return BUCKETS[2];
    if (value > 100) return BUCKETS[3];
    if (value > 50) return BUCKETS[4];
    return BUCKETS[5];
  }

  function parseLatency(value) {
    if (value == null) return NaN;
    const text = String(value).replace(/&ndash;|–|—/g, '').replace(/[^\d.-]/g, '').trim();
    if (!text) return NaN;
    const number = Number.parseFloat(text);
    return Number.isFinite(number) ? number : NaN;
  }

  function parseNumber(value) {
    const number = parseLatency(value);
    return Number.isFinite(number) ? number : NaN;
  }

  function normalizedLatency(value) {
    return Number.isFinite(value) ? value : Number.POSITIVE_INFINITY;
  }

  function formatValue(value) {
    return Number.isFinite(value) ? `${Math.round(value * 100) / 100} ms` : 'timeout/pending';
  }

  function shortLocationTitle(location) {
    const parts = String(location).split(',').map(s => s.trim()).filter(Boolean);
    if (!parts.length) return location;
    if ((parts[0] === 'USA' || parts[0] === 'Canada') && parts.length >= 3) return `${parts[1]} ${parts[2]}`;
    return parts[parts.length - 1] || parts[0];
  }

  function shortProvider(provider) {
    const text = String(provider || '').trim();
    if (text.length <= 14) return text;
    return `${text.slice(0, 13)}…`;
  }

  function clean(value) {
    return String(value || '').replace(/\s+/g, ' ').trim();
  }

  function cssAttrEscape(value) {
    if (window.CSS && typeof window.CSS.escape === 'function') {
      return window.CSS.escape(String(value));
    }
    return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
  }

  function escapeHtml(value) {
    return String(value)
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;')
      .replace(/'/g, '&#39;');
  }

  boot();
})();

更新

1.0.0版本发现香港台湾等地区无法点击,现在加上了小圆点便于点击了

  • 牛的很 xhj016

  • 好东西,直观很多 xhj006

  • 鸡腿奉上!

  • 很好 给个鸡腿 xhj003

  • Cool

  • 感谢,加鸡腿

  • 1.0.0版本发现香港台湾等地区无法点击,现在加上了小圆点便于点击了,需要的重新复制脚本就好了哦

你好啊,陌生人!

我的朋友,看起来你是新来的,如果想参与到讨论中,点击下面的按钮!

📈用户数目📈

目前论坛共有60792位seeker

🎉欢迎新用户🎉