matsim_crs_verified_exporter.html 解説書(統合版)

2026年2月13日

以下は,前述の内容を統合した matsim_crs_verified_exporter.html 解説書(統合版) である.そのまま README,論文補助資料,あるいはツール内 Help セクションとして利用可能である.


matsim_crs_verified_exporter.html 解説書(統合版)


1. はじめに

matsim_crs_verified_exporter.html( \\wsl$\Ubuntu\home\tomoi\matsim-example-project\scenarios\equil3\output)は,MATSim の出力ファイルからエージェント(person)の移動履歴を抽出し,

  • 平面直角座標(x,y)を保持した TSV
  • 緯度・経度に変換した GeoCSV

を生成するブラウザ完結型ツールである.

<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8" />
  <title>MATSim Drag&Drop → TSV + GeoCSV (auto zone w/ hint)</title>
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <style>
    body { font-family: system-ui, sans-serif; margin: 0; padding: 14px; }
    #drop { border: 2px dashed #777; border-radius: 12px; padding: 18px; }
    #drop.drag { background: #f2f2f2; }
    .row { display: flex; gap: 10px; flex-wrap: wrap; align-items: end; margin-top: 12px; }
    label { font-size: 12px; color: #444; display: block; }
    input, select { padding: 6px; width: 420px; max-width: 95vw; }
    select { width: 320px; }
    button { padding: 8px 12px; }
    pre { background: #fafafa; border: 1px solid #ddd; padding: 10px; height: 40vh; overflow: auto; margin-top: 10px; }
    .meta { margin-top: 10px; font-size: 12px; color: #333; white-space: pre-wrap; }
    code { background: #eee; padding: 0 4px; border-radius: 4px; }
    .small { font-size: 12px; color: #555; }
  </style>
</head>
<body>
  <h2>MATSim:network+events(ドラッグ)→ TSV(平面)+ GeoCSV(経緯度)</h2>

  <div id="drop">
    ここに <b>output_network.xml.gz</b> と <b>output_events.xml.gz</b> をドラッグ&ドロップ
    <div style="margin-top:8px" class="small">
      TSV列:<code>time person_id x y type link</code><br>
      GeoCSV列:<code>Source,Longitude,Latitude,Timestamp,Distance,Speed,Transport</code><br>
      本ツールは「町田(広袴)など、想定位置に近いゾーン」を自動選択します。network記載CRSは参考表示に留めます(誤記の可能性があるため)。
    </div>
  </div>

  <div class="row">
    <div>
      <label>対象イベント type(空なら「link+personが解決できたもの全て」)</label>
      <input id="types" value="entered link,left link,departure,arrival,vehicle enters traffic,actstart,actend" />
    </div>
    <div>
      <label>TSV出力ファイル名</label>
      <input id="outnameTsv" value="traj_all.tsv" />
    </div>
  </div>

  <div class="row">
    <div>
      <label>GeoCSV出力ファイル名</label>
      <input id="outnameCsv" value="traj_all_geo.csv" />
    </div>
    <div>
      <label>Source(person_id)のゼロ埋め桁数</label>
      <input id="srcWidth" value="6" style="width:120px" />
    </div>
    <div>
      <label>Timestamp日付(固定)</label>
      <input id="baseDate" value="2026-01-01" style="width:140px" />
    </div>
  </div>

  <div class="row">
    <div>
      <label>地域ヒント(経度,緯度)※ゾーン自動選択に使用</label>
      <input id="hintLonLat" value="139.50,35.60" style="width:240px" />
    </div>
    <div class="small" style="max-width:900px">
      町田(広袴)想定ならこのままで良いです。別地域なら変更してください(例:福岡なら 130.40,33.60)。
    </div>
  </div>

  <div class="row">
    <div>
      <label>ゾーン(自動選択結果/手動上書き可)</label>
      <select id="zoneSelect" disabled></select>
    </div>
    <div style="max-width:980px">
      <div class="small">
        「ヒント座標に近いゾーン」を自動採用します。意図した地域と合わない場合はここで手動変更してください。<br>
        network記載CRS(EPSG:xxxx)も表示しますが、誤記の可能性があるため自動採用には使いません。
      </div>
      <div class="meta" id="zoneCandidates"></div>
    </div>
  </div>

  <div class="row">
    <button id="run" disabled>生成してダウンロード(TSV+CSV)</button>
  </div>

  <div class="meta" id="status">未読み込み</div>
  <pre id="preview"></pre>

<script>
let networkFile = null;
let eventsFile  = null;

const drop     = document.getElementById("drop");
const runBtn   = document.getElementById("run");
const statusEl = document.getElementById("status");
const previewEl= document.getElementById("preview");

const zoneSelect = document.getElementById("zoneSelect");
const zoneCandidatesEl = document.getElementById("zoneCandidates");

function setStatus(msg){ statusEl.textContent = msg; }
function setPreview(msg){ previewEl.textContent = msg; }

drop.addEventListener("dragover", (e)=>{ e.preventDefault(); drop.classList.add("drag"); });
drop.addEventListener("dragleave", ()=> drop.classList.remove("drag"));

drop.addEventListener("drop", (e)=>{
  e.preventDefault();
  drop.classList.remove("drag");

  networkFile = null;
  eventsFile  = null;

  for (const f of e.dataTransfer.files) {
    const n = f.name.toLowerCase();
    if (n.includes("network") && n.endsWith(".gz")) networkFile = f;
    if (n.includes("events")  && n.endsWith(".gz")) eventsFile  = f;
  }

  zoneSelect.disabled = true;
  zoneSelect.innerHTML = "";
  zoneCandidatesEl.textContent = "";

  if (networkFile && eventsFile) {
    runBtn.disabled = false;
    runBtn.textContent = "生成してダウンロード(TSV+CSV)";
    setStatus(`準備OK\nnetwork: ${networkFile.name}\nevents : ${eventsFile.name}\n「生成」を押してください`);
  } else {
    runBtn.disabled = true;
    setStatus("network/events の両方が必要です(ファイル名に network / events を含む .gz を想定)");
  }
});

// ---------- gzip streaming decode ----------
async function* ungzipTextChunks(file) {
  const ds = new DecompressionStream("gzip");
  const textStream = file.stream().pipeThrough(ds).pipeThrough(new TextDecoderStream("utf-8"));
  const reader = textStream.getReader();
  try {
    while (true) {
      const {value, done} = await reader.read();
      if (done) break;
      if (value) yield value;
    }
  } finally {
    reader.releaseLock();
  }
}

// ---------- attribute parse ----------
function parseAttrs(tagText) {
  const attrs = {};
  const re = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
  let m;
  while ((m = re.exec(tagText)) !== null) {
    attrs[m[1]] = (m[2] !== undefined) ? m[2] : m[3];
  }
  return attrs;
}

// ---------- JGD2011 Japan Plane Rectangular (zones 1..19) params ----------
const JPRCS = {
  1:  {lat0:33, lon0:129},
  2:  {lat0:33, lon0:131},
  3:  {lat0:36, lon0:132.1666666667},
  4:  {lat0:33, lon0:133.5},
  5:  {lat0:36, lon0:134.3333333333},
  6:  {lat0:36, lon0:136},
  7:  {lat0:36, lon0:137.1666666667},
  8:  {lat0:36, lon0:138.5},
  9:  {lat0:36, lon0:139.8333333333},
  10: {lat0:40, lon0:140.8333333333},
  11: {lat0:44, lon0:140.25},
  12: {lat0:44, lon0:142.25},
  13: {lat0:44, lon0:144.25},
  14: {lat0:26, lon0:142},
  15: {lat0:26, lon0:127.5},
  16: {lat0:26, lon0:124},
  17: {lat0:26, lon0:131},
  18: {lat0:20, lon0:136},
  19: {lat0:26, lon0:154},
};

function tmParamsFromZone(zone) {
  const z = JPRCS[zone];
  if (!z) return null;
  const epsg = 6669 + zone; // 6670..6688
  return {
    epsg,
    zone,
    a: 6378137.0,                 // GRS80
    f: 1.0 / 298.257222101,       // GRS80
    k0: 0.9999,
    lat0: z.lat0 * Math.PI/180,
    lon0: z.lon0 * Math.PI/180,
    fe: 0.0,
    fn: 0.0,
    lat0deg: z.lat0,
    lon0deg: z.lon0,
  };
}

// ---------- inverse Transverse Mercator (verification-grade) ----------
function invTM(x, y, p) {
  const a = p.a, f = p.f, k0 = p.k0;
  const e2 = 2*f - f*f;
  const ep2 = e2 / (1 - e2);

  const x1 = (x - p.fe) / k0;
  const y1 = (y - p.fn) / k0;

  const A0 = 1 - e2/4 - 3*e2*e2/64 - 5*e2*e2*e2/256;
  const A2 = 3*e2/8 + 3*e2*e2/32 + 45*e2*e2*e2/1024;
  const A4 = 15*e2*e2/256 + 45*e2*e2*e2/1024;
  const A6 = 35*e2*e2*e2/3072;

  function meridional(phi) {
    return a*(A0*phi - A2*Math.sin(2*phi) + A4*Math.sin(4*phi) - A6*Math.sin(6*phi));
  }
  const M0 = meridional(p.lat0);
  const M  = M0 + y1;
  const mu = M / (a*A0);

  const e1 = (1 - Math.sqrt(1-e2)) / (1 + Math.sqrt(1-e2));
  const J1 = 3*e1/2 - 27*Math.pow(e1,3)/32;
  const J2 = 21*e1*e1/16 - 55*Math.pow(e1,4)/32;
  const J3 = 151*Math.pow(e1,3)/96;
  const J4 = 1097*Math.pow(e1,4)/512;

  const fp = mu + J1*Math.sin(2*mu) + J2*Math.sin(4*mu) + J3*Math.sin(6*mu) + J4*Math.sin(8*mu);

  const sinfp = Math.sin(fp), cosfp = Math.cos(fp), tanfp = Math.tan(fp);

  const N1 = a / Math.sqrt(1 - e2*sinfp*sinfp);
  const R1 = a*(1-e2) / Math.pow(1 - e2*sinfp*sinfp, 1.5);
  const T1 = tanfp*tanfp;
  const C1 = ep2*cosfp*cosfp;
  const D  = x1 / N1;

  const Q1 = N1*tanfp / R1;
  const Q2 = (D*D)/2;
  const Q3 = (5 + 3*T1 + 10*C1 - 4*C1*C1 - 9*ep2) * Math.pow(D,4)/24;
  const Q4 = (61 + 90*T1 + 298*C1 + 45*T1*T1 - 252*ep2 - 3*C1*C1) * Math.pow(D,6)/720;

  const lat = fp - Q1*(Q2 - Q3 + Q4);

  const Q5 = D;
  const Q6 = (1 + 2*T1 + C1) * Math.pow(D,3)/6;
  const Q7 = (5 - 2*C1 + 28*T1 - 3*C1*C1 + 8*ep2 + 24*T1*T1) * Math.pow(D,5)/120;

  const lon = p.lon0 + (Q5 - Q6 + Q7) / cosfp;

  return {lat, lon}; // radians
}

function median(arr) {
  if (!arr.length) return NaN;
  const a = [...arr].sort((x,y)=>x-y);
  const mid = Math.floor(a.length/2);
  return (a.length % 2) ? a[mid] : (a[mid-1] + a[mid]) / 2;
}

function padLeft(s, w) {
  s = String(s);
  if (s.length >= w) return s;
  return "0".repeat(w - s.length) + s;
}

function secToTimestamp(secStr, baseDate) {
  const sec = Math.floor(parseFloat(secStr || "0"));
  const hh = Math.floor(sec / 3600);
  const mm = Math.floor((sec % 3600) / 60);
  const ss = sec % 60;
  const HH = String(hh).padStart(2, "0");
  const MM = String(mm).padStart(2, "0");
  const SS = String(ss).padStart(2, "0");
  return `${baseDate} ${HH}:${MM}:${SS}`;
}

// ---------- network parse + CRS detect + sample nodes ----------
async function parseNetworkStream(networkFile) {
  const nodes = new Map(); // id -> [x,y]
  const links = new Map(); // id -> [from,to]
  let crsText = "";

  const sampleXY = [];
  const SAMPLE_MAX = 250;

  let buf = "";
  let nodeCount = 0;
  let linkCount = 0;

  const keepMax = 2_000_000;
  let headerChecked = false;

  for await (const chunk of ungzipTextChunks(networkFile)) {
    if (!headerChecked) {
      const head = (buf + chunk).slice(0, 200000);
      const m = /coordinateReferenceSystem[^>]*>([^<]+)</i.exec(head);
      if (m) crsText = m[1].trim();
      headerChecked = true;
    }

    buf += chunk;

    while (true) {
      const iNode = buf.indexOf("<node ");
      const iLink = buf.indexOf("<link ");

      let i = -1, kind = "";
      if (iNode === -1 && iLink === -1) break;
      if (iNode !== -1 && (iLink === -1 || iNode < iLink)) { i = iNode; kind = "node"; }
      else { i = iLink; kind = "link"; }

      const j = buf.indexOf(">", i);
      if (j === -1) break;

      const tagText = buf.slice(i, j+1);
      buf = buf.slice(j+1);

      const a = parseAttrs(tagText);

      if (kind === "node") {
        const id = a.id, x = a.x, y = a.y;
        if (id != null && x != null && y != null) {
          const xx = parseFloat(x), yy = parseFloat(y);
          nodes.set(id, [xx, yy]);
          nodeCount++;
          if (sampleXY.length < SAMPLE_MAX) sampleXY.push([xx, yy]);
        }
      } else {
        const id = a.id, fr = a.from, to = a.to;
        if (id != null && fr != null && to != null) {
          links.set(id, [fr, to]);
          linkCount++;
        }
      }

      if ((nodeCount + linkCount) % 20000 === 0 && (nodeCount + linkCount) > 0) {
        setStatus(`network解析中… nodes=${nodeCount}, links=${linkCount}`);
        await new Promise(r => setTimeout(r, 0));
      }
    }

    if (buf.length > keepMax) buf = buf.slice(-keepMax);
  }

  setStatus(`network解析完了 nodes=${nodeCount}, links=${linkCount}\nnetwork記載CRS=${crsText || "(unknown)"}\nゾーン選択中…`);
  return {nodes, links, crsText, sampleXY};
}

function linkMidXY(linkId, nodes, links) {
  const lt = links.get(linkId);
  if (!lt) return null;
  const p1 = nodes.get(lt[0]);
  const p2 = nodes.get(lt[1]);
  if (!p1 || !p2) return null;
  return [(p1[0] + p2[0]) / 2.0, (p1[1] + p2[1]) / 2.0];
}

// ---------- zone choose by lon/lat hint ----------
function parseHint() {
  const s = (document.getElementById("hintLonLat").value || "").trim();
  const m = s.split(",").map(x => x.trim()).filter(Boolean);
  if (m.length !== 2) return {hintLon: 139.5, hintLat: 35.6};
  const hintLon = parseFloat(m[0]);
  const hintLat = parseFloat(m[1]);
  if (!Number.isFinite(hintLon) || !Number.isFinite(hintLat)) return {hintLon: 139.5, hintLat: 35.6};
  return {hintLon, hintLat};
}

function inferZonesByHint(sampleXY, hintLon, hintLat) {
  const results = [];
  for (let zone = 1; zone <= 19; zone++) {
    const p = tmParamsFromZone(zone);
    const lons = [];
    const lats = [];
    for (const [x,y] of sampleXY) {
      const ll = invTM(x, y, p);
      const lon = ll.lon * 180/Math.PI;
      const lat = ll.lat * 180/Math.PI;
      if (Number.isFinite(lon) && Number.isFinite(lat)) {
        lons.push(lon);
        lats.push(lat);
      }
    }
    const mlon = median(lons);
    const mlat = median(lats);

    // ヒントからの距離(度)…経度は緯度でスケール(ざっくり)
    const dLon = (mlon - hintLon) * Math.cos((hintLat*Math.PI)/180);
    const dLat = (mlat - hintLat);
    const dist = Math.sqrt(dLon*dLon + dLat*dLat);

    const inJapan = (mlon >= 120 && mlon <= 155 && mlat >= 20 && mlat <= 47);
    const score = dist + (inJapan ? 0 : 999);

    results.push({
      zone,
      epsg: p.epsg,
      mlon,
      mlat,
      score,
      dist,
      lon0: p.lon0deg,
      lat0: p.lat0deg,
      inJapan
    });
  }
  results.sort((a,b)=>a.score - b.score);
  return results;
}

function renderZoneUI(results, defaultZone, crsText, hintLon, hintLat) {
  zoneSelect.innerHTML = "";
  for (let z=1; z<=19; z++) {
    const opt = document.createElement("option");
    opt.value = String(z);
    opt.textContent = `第${z}系(zone ${z} / EPSG:${6669+z})`;
    if (z === defaultZone) opt.selected = true;
    zoneSelect.appendChild(opt);
  }
  zoneSelect.disabled = false;

  const top = results.slice(0, 6);
  let msg = `地域ヒント: (${hintLon.toFixed(4)}, ${hintLat.toFixed(4)})\n`;
  msg += "ゾーン候補(ヒント近傍・上位):\n";
  for (const r of top) {
    msg += `  zone${r.zone} EPSG:${r.epsg}  median(lon,lat)=(${r.mlon.toFixed(6)}, ${r.mlat.toFixed(6)})  dist≈${r.dist.toFixed(3)}deg\n`;
  }
  msg += "\n";
  msg += `network記載CRS: ${crsText || "(unknown)"}\n`;
  msg += "※networkのCRSは誤記の可能性があるため、自動採用は行いません。\n";
  zoneCandidatesEl.textContent = msg;
}

// ---------- events parse + build TSV+GeoCSV ----------
async function buildOutputs(eventsFile, nodes, links, wantTypes, outTsvName, outCsvName, zone, srcWidth, baseDate) {
  const tmParams = tmParamsFromZone(zone);

  const tsvHeader = "time\tperson_id\tx\ty\ttype\tlink\n";
  const csvHeader = "Source,Longitude,Latitude,Timestamp,Distance,Speed,Transport\n";

  const tsvParts = [tsvHeader];
  const csvParts = [csvHeader];

  let carry = "";
  let outLinesTsv = 1;
  let outLinesCsv = 1;

  const veh2person = new Map();

  let matched = 0;
  let skippedType = 0, skippedNoLink = 0, skippedNoXY = 0, skippedNoPersonResolved = 0;

  const previewLines = [];
  const previewMax = 80;
  let lastUi = performance.now();

  for await (const chunk of ungzipTextChunks(eventsFile)) {
    let text = carry + chunk;
    carry = "";

    while (true) {
      const i = text.indexOf("<event ");
      if (i === -1) { carry = text; break; }

      const j = text.indexOf("/>", i);
      if (j === -1) { carry = text.slice(i); break; }

      const tagText = text.slice(i, j+2);
      text = text.slice(j+2);

      const a = parseAttrs(tagText);

      const type = a.type || "";
      if (wantTypes && wantTypes.size && !wantTypes.has(type)) { skippedType++; continue; }

      if (type === "PersonEntersVehicle") {
        const p = a.person, v = a.vehicle;
        if (p && v) veh2person.set(v, p);
        continue;
      }

      const link = a.link;
      if (!link) { skippedNoLink++; continue; }

      let pid = a.person || "";
      if (!pid) {
        const v = a.vehicle || "";
        if (v && veh2person.has(v)) pid = veh2person.get(v);
      }
      if (!pid) { skippedNoPersonResolved++; continue; }

      const lt = links.get(link);
      if (!lt) { skippedNoXY++; continue; }
      const p1 = nodes.get(lt[0]);
      const p2 = nodes.get(lt[1]);
      if (!p1 || !p2) { skippedNoXY++; continue; }
      const x = (p1[0] + p2[0]) / 2.0;
      const y = (p1[1] + p2[1]) / 2.0;

      const t = a.time ?? "0";

      // TSV
      const tsvLine = `${t}\t${pid}\t${x}\t${y}\t${type}\t${link}\n`;
      tsvParts.push(tsvLine);
      outLinesTsv++;

      // GeoCSV
      const ll = invTM(x, y, tmParams);
      const lon = (ll.lon * 180/Math.PI).toFixed(6);
      const lat = (ll.lat * 180/Math.PI).toFixed(6);

      const src = padLeft(pid, srcWidth);
      const ts  = secToTimestamp(t, baseDate);

      const csvLine = `${src},${lon},${lat},${ts},,,${type}\n`;
      csvParts.push(csvLine);
      outLinesCsv++;

      matched++;
      if (previewLines.length < previewMax) previewLines.push(csvLine);

      const now = performance.now();
      if (now - lastUi > 250) {
        setStatus(
          `events処理中… TSV行=${outLinesTsv}, CSV行=${outLinesCsv}(マッチ=${matched})\n` +
          `使用ゾーン: zone${zone} EPSG:${tmParams.epsg}(lon0=${tmParams.lon0deg})\n` +
          `skip: type=${skippedType}, noLink=${skippedNoLink}, noXY=${skippedNoXY}, noPersonResolved=${skippedNoPersonResolved}\n` +
          `veh2person=${veh2person.size}`
        );
        setPreview(csvHeader + previewLines.join("") + (matched > previewMax ? "…(CSVプレビュー省略)\n" : ""));
        lastUi = now;
        await new Promise(r => setTimeout(r, 0));
      }
    }
  }

  setStatus(
    `完了\nTSV行=${outLinesTsv} CSV行=${outLinesCsv}(マッチ=${matched})\n` +
    `使用ゾーン: zone${zone} EPSG:${tmParams.epsg}\n` +
    `skip: type=${skippedType}, noLink=${skippedNoLink}, noXY=${skippedNoXY}, noPersonResolved=${skippedNoPersonResolved}`
  );

  function download(name, parts, mime) {
    const blob = new Blob(parts, {type: mime});
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = name;
    a.click();
    URL.revokeObjectURL(url);
  }

  download(outTsvName || "traj_all.tsv", tsvParts, "text/tab-separated-values;charset=utf-8");
  download(outCsvName || "traj_all_geo.csv", csvParts, "text/csv;charset=utf-8");
}

// ---------- main ----------
runBtn.addEventListener("click", async ()=>{
  try {
    runBtn.disabled = true;
    setPreview("");

    const typeStr   = document.getElementById("types").value.trim();
    const outTsv    = document.getElementById("outnameTsv").value.trim() || "traj_all.tsv";
    const outCsv    = document.getElementById("outnameCsv").value.trim() || "traj_all_geo.csv";
    const srcWidth  = Math.max(1, parseInt(document.getElementById("srcWidth").value.trim() || "6", 10));
    const baseDate  = document.getElementById("baseDate").value.trim() || "2026-01-01";
    const {hintLon, hintLat} = parseHint();

    const wantTypes = new Set(typeStr ? typeStr.split(",").map(s=>s.trim()).filter(Boolean) : []);

    setStatus("network解析開始…");
    const {nodes, links, crsText, sampleXY} = await parseNetworkStream(networkFile);

    const inferred = inferZonesByHint(sampleXY, hintLon, hintLat);
    const defaultZone = inferred[0]?.zone || 9;

    renderZoneUI(inferred, defaultZone, crsText, hintLon, hintLat);

    setStatus(
      `ゾーン自動選択完了\n` +
      `採用デフォルト: zone${defaultZone}(手動変更可)\n` +
      `events解析を開始します…`
    );

    const zone = parseInt(zoneSelect.value, 10) || defaultZone;

    setStatus(
      `events解析開始…(TSV+CSV同時生成)\n` +
      `使用ゾーン: zone${zone} (EPSG:${6669+zone})`
    );

    await buildOutputs(eventsFile, nodes, links, wantTypes, outTsv, outCsv, zone, srcWidth, baseDate);

    runBtn.disabled = false;
    setStatus(statusEl.textContent + "\nダウンロード完了");

  } catch (e) {
    runBtn.disabled = false;
    setStatus("エラー: " + (e?.message || e));
  }
});
</script>
</body>
</html>

サーバは不要であり,ローカルPC上で動作する.入力データは外部送信されない.

本ツールの設計思想は,

座標系(CRS)をメタデータに依存せず,地理的位置整合性で検証して決定する

ことにある.

これは,MATSimネットワークの CRS 記述が誤っている場合でも,実際の地理的位置に基づいて適切な平面直角系(ゾーン)を選択するための安全設計である.


2. 使い方(基本操作)

Step 1:ファイルを準備する

MATSim 実行後の出力ディレクトリから,以下の2つのファイルを用意する.

output_network.xml.gz
output_events.xml.gz

Step 2:ドラッグ&ドロップ

ブラウザで

matsim_crs_verified_exporter.html

を開き,画面中央の枠内に

  • output_network.xml.gz
  • output_events.xml.gz

2ファイルを同時にドラッグ&ドロップする.

両方が認識されると「生成してダウンロード」ボタンが有効になる.


Step 3:設定を確認

  • 対象イベント type(例:actend, departure 等)
  • 出力ファイル名
  • Source のゼロ埋め桁数
  • Timestamp 固定日付
  • 地域ヒント(例:町田なら 139.50,35.60)

特に重要なのが 地域ヒント である.
この値を基準にゾーンが自動決定される.


Step 4:生成

「生成してダウンロード」を押すと,

  • traj_all.tsv(平面座標)
  • traj_all_geo.csv(緯度経度)

が生成される.


3. 入力ファイルの説明

3.1 output_network.xml.gz とは

MATSim のネットワーク定義ファイルである.

主な内容:

<node id="..." x="..." y="..."/>
<link id="..." from="..." to="..."/>
<coordinateReferenceSystem>...</coordinateReferenceSystem>

役割

  • link の両端 node 座標(x,y)を提供
  • link id → 平面座標 への変換基盤

このファイルが無いと,events の link を地理座標へ変換できない.


3.2 output_events.xml.gz とは

MATSim シミュレーション中に発生したイベントの時系列記録である.

例:

<event time="21600.0" type="actend" person="2" link="10"/>

主なイベント:

  • actstart / actend
  • departure / arrival
  • entered link / left link
  • vehicle enters traffic
  • PersonEntersVehicle

役割

  • person_id
  • event time(秒)
  • link id

を取得する.

本ツールでは,events 内の link id を network の座標に結び付けることで地理情報を復元する.


4. 内部処理の概要

4.1 network の解析

  • gzip をブラウザで展開
  • <node><link> をストリーム解析
  • id → 座標マッピングを構築
  • ゾーン推定用に座標をサンプリング

4.2 ゾーン決定(CRS Verified の核心)

平面直角座標系(JGD2011の1〜19系)を総当たりし,

  1. 各 zone で逆TM変換
  2. 緯度経度中央値を計算
  3. 地域ヒントとの距離を算出
  4. 最も近い zone を採用

する.

これにより,

  • CRS誤記
  • 変換時の系ズレ
  • 推定ミス

を回避する.


4.3 events の解析

  • 指定 type のみ抽出
  • link を持つイベントのみ処理
  • vehicle → person 解決を試行
  • link の両端 node の中点を座標とする
  • TSV と GeoCSV を同時生成

5. 出力仕様

5.1 TSV(平面座標)

time	person_id	x	y	type	link

5.2 GeoCSV(緯度経度)

Source,Longitude,Latitude,Timestamp,Distance,Speed,Transport
  • Timestamp = 固定日付 + event秒変換
  • Distance / Speed は空欄(現仕様)

6. 使用条件と制約

6.1 前提条件

  • 日本国内の平面直角座標(JGD2011)
  • link を含む events
  • gzip 対応ブラウザ

6.2 地域依存性

初期値:

139.50,35.60(町田想定)

別地域ではヒント変更が必要.
日本国外は現仕様では非対応.


7. 設計思想の転換点

旧方式:

  • CRSメタデータ依存

現方式:

  • 地理整合性検証ベース

これは

静的メタ情報依存 → 実データ整合判定

への設計変更である.


8. 既知の制限

  • 座標は link 中点
  • 実軌跡再構成ではない
  • Distance / Speed 未算出
  • Excel表示崩れの可能性あり(データ自体は正常)

9. 改造・拡張方向

9.1 汎用化

  • CRS二段検証方式導入
  • 日本国外投影法対応

9.2 軌跡精度向上

  • イベント種別別位置推定
  • 連続軌跡復元

9.3 解析機能追加

  • 距離・速度算出
  • 滞在時間算出

9.4 可視化統合

  • Leaflet表示
  • 時間スライダ再生

10. まとめ

matsim_crs_verified_exporter.html は,

  • MATSim events を地理的に検証可能な形へ変換する
  • CRS誤認識による座標崩壊を防ぐ
  • 地域ヒント付きゾーン自動選択を備える

研究検証用途向けの安全設計ツールである.


必要であれば,

  • 英語版併記
  • HTML内Help埋め込み版
  • バージョン履歴セクション追加

にも再構成できる.

=====

これによって作成されたtraj_all_geo.csvを使って、pm_proxy_single_socket_traj_all_geo.goserver22-1_v3.go を使うことで、WebにMATSimで作ったエージェントを動かしてみせることができる。

[pm_proxy_single_socket_traj_all_geo.go] (I:\home\ebata\hakata\video3)

// pm_proxy_single_socket_traj_all_geo.go

package main

import (
	"encoding/csv"
	"flag"
	"fmt"
	"log"
	"math"
	"net/url"
	"os"
	"strconv"
	"sync"
	"time"

	"github.com/gorilla/websocket"
)

type GetLoc struct {
	ID        int     `json:"id"`
	Lat       float64 `json:"lat"`
	Lng       float64 `json:"lng"`
	Type      int     `json:"type"`
	Timestamp string  `json:"timestamp"` // 懿「蕕SVのTimestampをそのまま使う
}

type unmTbl struct {
	uniName string
	objType int
	simNum  int
	pmNum   int
	lon     float64
	lat     float64
}

var (
	list  = make([]unmTbl, 0)
	addr  = flag.String("addr", "0.0.0.0:8080", "http service address")
	mutex sync.Mutex
)

func connectWebSocket() (*websocket.Conn, error) {
	u := url.URL{Scheme: "ws", Host: *addr, Path: "/echo2"}

	for i := 0; i < 5; i++ {
		c, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
		if err == nil {
			log.Println("Connected to WebSocket.")
			return c, nil
		}
		log.Println("WebSocket connection failed:", err)
	}
	return nil, fmt.Errorf("failed to connect WebSocket")
}

func main() {
	flag.Parse()
	log.SetFlags(0)

	file, err := os.Open("traj_all_geo.csv")
	if err != nil {
		log.Fatal(err)
	}
	defer file.Close()

	reader := csv.NewReader(file)
	reader.FieldsPerRecord = -1 // 999.9 行など列数揺れ対策

	c, err := connectWebSocket()
	if err != nil {
		log.Fatal(err)
	}
	defer c.Close()

	for {
		line, err := reader.Read()
		if err != nil {
			break
		}

		fmt.Println("--->1")
		time.Sleep(100 * time.Millisecond)

		// CSV:
		// Source,Longitude,Latitude,Timestamp,Distance,Speed,Transport,Type
		if len(line) < 8 {
			continue
		}
		if line[0] == "Source" {
			continue
		}

		uniName := line[0]

		lon, err1 := strconv.ParseFloat(line[1], 64)
		lat, err2 := strconv.ParseFloat(line[2], 64)
		if err1 != nil || err2 != nil {
			continue
		}

		ts := line[3] // 懿「蜒fータの Timestamp をそのまま使う

		typ, err := strconv.Atoi(line[7])
		if err != nil || typ < 1 || typ > 4 {
			typ = 1
		}

		found := false

		for i := range list {
			if list[i].uniName == uniName {

				fmt.Println("--->2")
				time.Sleep(100 * time.Millisecond)

				found = true

				oldLat := list[i].lat
				oldLon := list[i].lon

				list[i].lat = lat
				list[i].lon = lon
				list[i].objType = typ

				if math.Abs(oldLat-lat) > 1e-12 || math.Abs(oldLon-lon) > 1e-12 {
					gl := GetLoc{
						ID:        list[i].pmNum,
						Lat:       lat,
						Lng:       lon,
						Type:      typ,
						Timestamp: ts,
					}

					mutex.Lock()
					err = c.WriteJSON(gl)
					if err != nil {
						mutex.Unlock()
						c.Close()
						c, err = connectWebSocket()
						if err != nil {
							log.Println("WebSocket reconnect failed")
							return
						}
						continue
					}

					glAck := new(GetLoc)
					err = c.ReadJSON(glAck)
					mutex.Unlock()
					if err != nil {
						c.Close()
						c, err = connectWebSocket()
						if err != nil {
							log.Println("WebSocket reconnect failed")
							return
						}
						continue
					}

					// 終端(999.9,999.9)で削除
					if lat > 999.0 || lon > 999.0 {
						list = append(list[:i], list[i+1:]...)
					}
				}
				break
			}
		}

		if !found {
			ut := unmTbl{
				uniName: uniName,
				objType: typ,
				simNum:  len(list),
				lat:     lat,
				lon:     lon,
			}

			gl := GetLoc{
				ID:        0,
				Lat:       lat,
				Lng:       lon,
				Type:      typ,
				Timestamp: ts,
			}

			mutex.Lock()
			err := c.WriteJSON(gl)
			if err != nil {
				mutex.Unlock()
				c.Close()
				c, err = connectWebSocket()
				if err != nil {
					log.Println("WebSocket reconnect failed")
					return
				}
				continue
			}

			glAck := new(GetLoc)
			err = c.ReadJSON(glAck)
			mutex.Unlock()
			if err != nil {
				c.Close()
				c, err = connectWebSocket()
				if err != nil {
					log.Println("WebSocket reconnect failed")
					return
				}
				continue
			}

			ut.pmNum = glAck.ID
			list = append(list, ut)
		}
	}
}

[server22-1_v3.go] (I:\home\ebata\hakata\video3)

// server22-1_v3.go
// server22-1_v3.go
package main

import (
	"flag"
	"html/template"
	"log"
	"net/http"
	"sync"

	"github.com/gorilla/websocket"
)

type GetLoc struct {
	ID        int     `json:"id"`
	Lat       float64 `json:"lat"`
	Lng       float64 `json:"lng"`
	Type      int     `json:"type"`
	Timestamp string  `json:"timestamp"` // 懿「蜥ヌ加:pm_proxy側から送られてくるTimestamp
}

var addr = flag.String("addr", "0.0.0.0:8080", "http service address")
var upgrader = websocket.Upgrader{}
var chan2_1 = make(chan GetLoc)
var maxid = 0
var mutex sync.Mutex

func echo2(w http.ResponseWriter, r *http.Request) {
	c, err := upgrader.Upgrade(w, r, nil)
	if err != nil {
		log.Print("upgrade:", err)
		return
	}
	defer c.Close()

	for {
		gl := new(GetLoc)
		err := c.ReadJSON(&gl)

		mutex.Lock()

		if gl.ID == 0 && gl.Lat < 0.01 && gl.Lng < 0.01 {
			mutex.Unlock()
			break
		} else if gl.ID < -1 {
			gl.ID = -1
			gl.Lat = -999
			gl.Lng = -999
			_ = c.WriteJSON(gl)
			mutex.Unlock()
			continue
		} else {
			if err != nil {
				log.Println("read:", err)
				mutex.Unlock()
				break
			}

			// 懿「蜥ヌ加:pm_proxyが読み取ったTimestampをサーバ側で表示
			//log.Printf("Timestamp=%s id=%d type=%d lat=%f lng=%f", gl.Timestamp, gl.ID, gl.Type, gl.Lat, gl.Lng)

			chan2_1 <- *gl
			gl2 := <-chan2_1
			maxid = gl2.ID
			if err2 := c.WriteJSON(gl2); err2 != nil {
				log.Println("write2:", err2)
				mutex.Unlock()
				break
			}
		}

		mutex.Unlock()
	}
}

func echo(w http.ResponseWriter, r *http.Request) {
	c, err := upgrader.Upgrade(w, r, nil)
	if err != nil {
		log.Print("upgrade:", err)
		return
	}
	defer c.Close()

	for {
		gl := <-chan2_1
		if err = c.WriteJSON(gl); err != nil {
			log.Println("WriteJSON1:", err)
		}
		gl2 := new(GetLoc)
		if err2 := c.ReadJSON(&gl2); err2 != nil {
			log.Println("ReadJSON:", err2)
		}
		chan2_1 <- *gl2
	}
}

func home(w http.ResponseWriter, r *http.Request) {
	homeTemplate.Execute(w, "ws://"+r.Host+"/echo")
}

func smartphone(w http.ResponseWriter, r *http.Request) {
	smartphoneTemplate.Execute(w, "ws://"+r.Host+"/echo2")
}

func main() {
	flag.Parse()
	log.SetFlags(0)

	http.HandleFunc("/echo2", echo2)
	http.HandleFunc("/echo", echo)
	http.HandleFunc("/", home)
	http.HandleFunc("/smartphone", smartphone)
	log.Fatal(http.ListenAndServe(*addr, nil))
}

var smartphoneTemplate = template.Must(template.New("").Parse(`
<!DOCTYPE html>
<head>
<meta charset="utf-8">
<script> 

function obj(id, lat, lng, type){
	this.id = id;
	this.lat = lat;
	this.lng = lng;
	this.type = type;
	// timestamp はスマホ側では送らない(必要なら追加可)
}

function random(min, max){
	return  Math.random()*(max-min) + min;
}

var lat = 35.654543;
var lng = 139.795534;  
var myType = 1;

window.addEventListener("load", function(evt) {
    var output = document.getElementById("output");
    var ws;
    var print = function(message) {
        var d = document.createElement("div");
        d.textContent = message;
        output.appendChild(d);
	};

	var personal_id = 0;

	document.getElementById("open").removeAttribute("disabled");
	document.getElementById("open").style.color = "black";

	document.getElementById("close").setAttribute("disabled", true);
	document.getElementById("close").style.color = "White";	

	document.getElementById("send").setAttribute("disabled", true);
	document.getElementById("send").style.color = "White";	

	document.getElementById("open").onclick = function(evt) {
		document.getElementById("open").setAttribute("disabled", true);
		document.getElementById("open").style.color = "White";		

		document.getElementById("send").removeAttribute("disabled");
		document.getElementById("send").style.color = "black";	

		document.getElementById("close").removeAttribute("disabled");
		document.getElementById("close").style.color = "black";	

        if (ws) { return false; }

		ws = new WebSocket("{{.}}");
		
        ws.onopen = function(evt) {
			print("OPEN");
			var send_obj = new obj(0, 35.654543,139.795534, myType);
			ws.send(JSON.stringify(send_obj));
		}
		
        ws.onclose = function(evt) {
			print("CLOSE");
            ws = null;
        }

		ws.onmessage = function(evt) {
			print("RESPONSE: " + evt.data);
			var o = JSON.parse(evt.data);
			personal_id = o.id;
			if ((Math.abs(o.lat) > 90.0) || (Math.abs(o.lng) > 180.0)){
				ws.close();
			}
		}
		
        ws.onerror = function(evt) {
            print("ERROR: " + evt.data);
        }
        return false;
    };
	
	document.getElementById("send").onclick = function(evt) {
		if (!ws) { return false; }

		lat += random(0.5, -0.5) * 0.00001 * 10 * 5;
		lng += random(0.5, -0.5) * 0.00002 * 10 * 5

		var send_obj = new obj(personal_id, lat, lng, myType);
		ws.send(JSON.stringify(send_obj));		
		return false;	
    };

	document.getElementById("close").onclick = function(evt) {
		document.getElementById("open").removeAttribute("disabled");
		document.getElementById("open").style.color = "black";

		document.getElementById("close").setAttribute("disabled", true);
		document.getElementById("close").style.color = "White";	

		document.getElementById("send").setAttribute("disabled", true);
		document.getElementById("send").style.color = "White";			

        if (!ws) { return false; }
	
		var send_obj = new obj(personal_id, 999.9, 999.9, myType);
		ws.send(JSON.stringify(send_obj));
        return false;
    };
});
</script>
</head>
<body>
<table>
<tr><td valign="top" width="50%">
<form>
<button id="open">Open</button>
<button id="send">Send</button>
<button id="close">Close</button>
</form>
</td><td valign="top" width="50%">
<div id="output"></div>
</td></tr></table>
</body>
</html>
`))

var homeTemplate = template.Must(template.New("").Parse(`
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <title>PruneMobile</title>

	<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.0.0-beta.2.rc.2/leaflet.css"/>
	<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.0.0-beta.2.rc.2/leaflet.js"></script>

	<script src="http://kobore.net/PruneCluster.js"></script>
	<link rel="stylesheet" href="http://kobore.net/examples.css"/>

</head>

<style>
  .legend {
    position: absolute;
    top: 10px;
    right: 10px;
    z-index: 9999;
    background: rgba(255,255,255,0.95);
    padding: 10px 12px;
    border-radius: 8px;
    box-shadow: 0 2px 8px rgba(0,0,0,0.25);
    font-family: sans-serif;
    font-size: 13px;
    line-height: 1.4;
  }
  .legend .row {
    display: flex;
    align-items: center;
    gap: 8px;
    margin: 4px 0;
    white-space: nowrap;
  }
  .legend .dot {
    width: 12px;
    height: 12px;
    border-radius: 50%;
    border: 2px solid #ffffff;
    box-shadow: 0 0 0 1px rgba(0,0,0,0.25);
    flex: 0 0 auto;
  }
  .legend .ts {
    margin-top: 8px;
    padding-top: 6px;
    border-top: 1px solid #ddd;
    font-weight: 600;
  }
</style>

<body>
<div id="map"></div>

<div class="legend">
  <div class="row"><span class="dot" style="background:#e74c3c"></span>Type1: 赤  (金のある)社会人</div>
  <div class="row"><span class="dot" style="background:#3498db"></span>Type2: 青  (金のある)社会人 + (金のある)ハウスキーパー</div>
  <div class="row"><span class="dot" style="background:#2ecc71"></span>Type3-1: 緑  (金はないが時間はある)学生</div>
  <div class="row"><span class="dot" style="background:#f1c40f"></span>Type3-2: 黄  (金はないが時間がある)シニア </div>

  <div class="ts">Timestamp: <span id="tstext">(none)</span></div>
</div>

<script>
	ws = new WebSocket("{{.}}");

    var map = L.map("map", {
        attributionControl: false,
        zoomControl: false
	}).setView(new L.LatLng(33.58973407765046, 130.41048227121925), 16); // 中州

    L.tileLayer('http://{s}.tile.osm.org/{z}/{x}/{y}.png', {
        detectRetina: true,
        maxNativeZoom: 18
    }).addTo(map);

    var leafletView = new PruneClusterForLeaflet(1,1);

	// 懿「蚯ype別アイコン(SVGをdata URI化)
	function svgIcon(fill) {
		var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28">' +
		          '<circle cx="14" cy="14" r="10" fill="' + fill + '" stroke="white" stroke-width="2"/></svg>';
		return L.icon({
			iconUrl: 'data:image/svg+xml;charset=UTF-8,' + encodeURIComponent(svg),
			iconSize: [28,28],
			iconAnchor: [14,14]
		});
	}

	var typeIcons = {
		1: svgIcon('#e74c3c'), // 赤
		2: svgIcon('#3498db'), // 青
		3: svgIcon('#2ecc71'), // 緑
		4: svgIcon('#f1c40f')  // 黄
	};

	// 懿「蕷runeCluster懼Leaflet markerへアイコン適用
	leafletView.PrepareLeafletMarker = function(leafletMarker, data) {
		if (data && data.icon) {
			leafletMarker.setIcon(data.icon);
		}
	};

	var markers = [];

	ws.onmessage = function (event) {
		var obj = JSON.parse(event.data);

		// 懿「蜥ヌ加:Timestamp 表示(来ていれば)
		if (obj.timestamp) {
			document.getElementById("tstext").textContent = obj.timestamp;
		}

		var t = obj.type || 1;
		if (!typeIcons[t]) { t = 1; }

		if (obj.id == 0){
			// 懿「蜊・ャ時にtypeをmarker.dataへ
			var marker = new PruneCluster.Marker(obj.lat, obj.lng, {
				type: t,
				icon: typeIcons[t]
			});

			markers.push(marker);
			leafletView.RegisterMarker(marker);

			obj.id = marker.hashCode;
			ws.send(JSON.stringify(obj));
		} else if ((Math.abs(obj.lat) > 90.0) || (Math.abs(obj.lng) > 180.0)){
			for (let i = 0; i < markers.length; ++i) {
				if (obj.id == markers[i].hashCode){
					var deleteList = markers.splice(i, 1);
					leafletView.RemoveMarkers(deleteList);
					break;
				}
			}
			obj.lat = 91.0;
			obj.lng = 181.0;
			ws.send(JSON.stringify(obj));
		} else {
			for (let i = 0; i < markers.length; ++i) {
				if (obj.id == markers[i].hashCode){
					var ll = markers[i].position;
					ll.lat = obj.lat;
					ll.lng = obj.lng;

					// 懿「蚯ypeが来た場合は色も追従(固定なら実質変わらない)
					markers[i].data.type = t;
					markers[i].data.icon = typeIcons[t];
					break;
				}
			}
			ws.send(JSON.stringify(obj));
		}
	}

    window.setInterval(function () {
        leafletView.ProcessView();
	}, 30); ////////////////////

	ws.onclose = function(event) { ws = null; }

    map.addLayer(leafletView);
</script>
</body>
</html>
`))

2026年2月13日未分類

Posted by ebata