设计坐标和实测坐标匹配程序

设计坐标
未加载 CSV
实测坐标
未加载 CSV
对比结果
三栏逐行对应,蓝色小、黄色中等、红色大
0设计总点
0匹配点
0%完成率
0丢点
0错位
0重复
0线号完成
选择导出列
按线号统计完成情况
`; } function crc32(bytes) { let crc = -1; for (const b of bytes) { crc ^= b; for (let i = 0; i < 8; i++) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); } return (crc ^ -1) >>> 0; } function dosDateTime(date = new Date()) { const time = (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2); const day = ((date.getFullYear() - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(); return { time, day }; } function push16(arr, n) { arr.push(n & 255, (n >>> 8) & 255); } function push32(arr, n) { arr.push(n & 255, (n >>> 8) & 255, (n >>> 16) & 255, (n >>> 24) & 255); } function makeZip(files) { const encoder = new TextEncoder(); const chunks = []; const central = []; let offset = 0; const dt = dosDateTime(); for (const file of files) { const nameBytes = encoder.encode(file.name); const data = typeof file.content === "string" ? encoder.encode(file.content) : file.content; const crc = crc32(data); const local = []; push32(local, 0x04034b50); push16(local, 20); push16(local, 0); push16(local, 0); push16(local, dt.time); push16(local, dt.day); push32(local, crc); push32(local, data.length); push32(local, data.length); push16(local, nameBytes.length); push16(local, 0); local.push(...nameBytes); chunks.push(new Uint8Array(local), data); const cd = []; push32(cd, 0x02014b50); push16(cd, 20); push16(cd, 20); push16(cd, 0); push16(cd, 0); push16(cd, dt.time); push16(cd, dt.day); push32(cd, crc); push32(cd, data.length); push32(cd, data.length); push16(cd, nameBytes.length); push16(cd, 0); push16(cd, 0); push16(cd, 0); push16(cd, 0); push32(cd, 0); push32(cd, offset); cd.push(...nameBytes); central.push(new Uint8Array(cd)); offset += local.length + data.length; } const centralOffset = offset; let centralSize = 0; central.forEach(part => { chunks.push(part); centralSize += part.length; }); const end = []; push32(end, 0x06054b50); push16(end, 0); push16(end, 0); push16(end, files.length); push16(end, files.length); push32(end, centralSize); push32(end, centralOffset); push16(end, 0); chunks.push(new Uint8Array(end)); return new Blob(chunks, { type: "application/zip" }); } function colName(index) { let name = ""; index += 1; while (index) { const mod = (index - 1) % 26; name = String.fromCharCode(65 + mod) + name; index = Math.floor((index - mod) / 26); } return name; } function sheetCell(value, rowIndex, colIndex) { const ref = colName(colIndex) + rowIndex; const text = escapeXml(value); const num = Number(value); if (value !== "" && value != null && Number.isFinite(num) && String(value).trim() !== "") { return `${num}`; } return `${text}`; } function toXlsxBlob(rows) { const sheetRows = rows.map((row, r) => `${row.map((cell, c) => sheetCell(cell, r + 1, c)).join("")}`).join(""); const maxCol = Math.max(1, ...rows.map(row => row.length)); const dimension = `A1:${colName(maxCol - 1)}${Math.max(1, rows.length)}`; const files = [ { name: "[Content_Types].xml", content: `` }, { name: "_rels/.rels", content: `` }, { name: "xl/workbook.xml", content: `` }, { name: "xl/_rels/workbook.xml.rels", content: `` }, { name: "xl/worksheets/sheet1.xml", content: `${sheetRows}` } ]; return makeZip(files); } function escapeXml(value) { return String(value ?? "").replace(/[<>&'"]/g, ch => ({ "<": "<", ">": ">", "&": "&", "'": "'", '"': """ })[ch]); } function parseDelimitedText(text) { const firstLine = String(text).replace(/^\ufeff/, "").split(/\r?\n/).find(line => line.trim()) || ""; if (firstLine.includes("\t") && !firstLine.includes(",")) { return text.replace(/^\ufeff/, "").split(/\r?\n/) .map(line => line.split("\t").map(cell => cell.trim())) .filter(row => row.some(v => v !== "")); } return parseCsv(text); } function parseXlsLike(text) { const clean = String(text || "").replace(/^\ufeff/, ""); if (/]/i.test(clean)) return parseHtmlTable(clean); return parseDelimitedText(clean); } function isOleFile(buffer) { const b = new Uint8Array(buffer, 0, Math.min(8, buffer.byteLength)); return b[0] === 0xd0 && b[1] === 0xcf && b[2] === 0x11 && b[3] === 0xe0 && b[4] === 0xa1 && b[5] === 0xb1 && b[6] === 0x1a && b[7] === 0xe1; } function u16(bytes, offset) { return bytes[offset] | (bytes[offset + 1] << 8); } function u32(bytes, offset) { return (bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16) | (bytes[offset + 3] << 24)) >>> 0; } function i16(bytes, offset) { const n = u16(bytes, offset); return n & 0x8000 ? n - 0x10000 : n; } function readUtf16(bytes, offset, length) { const slice = bytes.slice(offset, offset + length); return new TextDecoder("utf-16le").decode(slice).replace(/\0+$/, ""); } function readAnsi(bytes, offset, length) { const slice = bytes.slice(offset, offset + length); try { return new TextDecoder("gb18030").decode(slice).replace(/\0+$/, ""); } catch { return new TextDecoder("windows-1252").decode(slice).replace(/\0+$/, ""); } } function oleSectorOffset(sector, sectorSize) { return (sector + 1) * sectorSize; } function readOleChain(bytes, fat, startSector, sectorSize, maxBytes = Infinity) { const out = []; let sector = startSector >>> 0; let guard = 0; while (sector !== 0xfffffffe && sector !== 0xffffffff && sector < fat.length && guard < 10000 && out.length < maxBytes) { const offset = oleSectorOffset(sector, sectorSize); const take = Math.min(sectorSize, maxBytes - out.length); for (let i = 0; i < take && offset + i < bytes.length; i++) out.push(bytes[offset + i]); sector = fat[sector] >>> 0; guard++; } return new Uint8Array(out); } function getOleStream(buffer, streamNames) { const bytes = new Uint8Array(buffer); const sectorSize = 1 << u16(bytes, 30); const miniSectorSize = 1 << u16(bytes, 32); const fatSectorCount = u32(bytes, 44); const firstDirSector = u32(bytes, 48); const miniCutoff = u32(bytes, 56); const firstMiniFatSector = u32(bytes, 60); const miniFatSectorCount = u32(bytes, 64); const difat = []; for (let i = 0; i < 109 && difat.length < fatSectorCount; i++) { const v = u32(bytes, 76 + i * 4); if (v !== 0xffffffff) difat.push(v); } const fat = []; for (const sector of difat) { const offset = oleSectorOffset(sector, sectorSize); for (let i = 0; i < sectorSize / 4; i++) fat.push(u32(bytes, offset + i * 4)); } const dirBytes = readOleChain(bytes, fat, firstDirSector, sectorSize); const entries = []; for (let offset = 0; offset + 128 <= dirBytes.length; offset += 128) { const nameLength = u16(dirBytes, offset + 64); if (nameLength < 2) continue; const name = readUtf16(dirBytes, offset, nameLength - 2); entries.push({ name, type: dirBytes[offset + 66], start: u32(dirBytes, offset + 116), size: u32(dirBytes, offset + 120) }); } const root = entries.find(e => e.type === 5); const wanted = entries.find(e => streamNames.includes(e.name)); if (!wanted) throw new Error("未找到 Workbook 工作簿流。"); if (wanted.size < miniCutoff && root && root.start !== 0xffffffff && miniFatSectorCount) { const miniFatBytes = readOleChain(bytes, fat, firstMiniFatSector, sectorSize, miniFatSectorCount * sectorSize); const miniFat = []; for (let i = 0; i + 4 <= miniFatBytes.length; i += 4) miniFat.push(u32(miniFatBytes, i)); const miniStream = readOleChain(bytes, fat, root.start, sectorSize, root.size); const out = []; let sector = wanted.start; let guard = 0; while (sector !== 0xfffffffe && sector !== 0xffffffff && sector < miniFat.length && guard < 10000 && out.length < wanted.size) { const offset = sector * miniSectorSize; const take = Math.min(miniSectorSize, wanted.size - out.length); for (let i = 0; i < take && offset + i < miniStream.length; i++) out.push(miniStream[offset + i]); sector = miniFat[sector] >>> 0; guard++; } return new Uint8Array(out); } return readOleChain(bytes, fat, wanted.start, sectorSize, wanted.size).slice(0, wanted.size); } function decodeBiffString(bytes, offset, charCount) { if (charCount <= 0) return ""; const flags = bytes[offset] || 0; const isUnicode = flags & 0x01; let pos = offset + 1; if (flags & 0x08) pos += 2; if (flags & 0x04) pos += 4; if (isUnicode) return readUtf16(bytes, pos, charCount * 2); return readAnsi(bytes, pos, charCount); } function parseBiffWorkbook(workbook) { const rows = []; const sst = []; const addCell = (r, c, v) => { if (!rows[r]) rows[r] = []; rows[r][c] = v == null ? "" : String(v); }; let pos = 0; while (pos + 4 <= workbook.length) { const id = u16(workbook, pos); const len = u16(workbook, pos + 2); const start = pos + 4; if (start + len > workbook.length) break; if (id === 0x00fc) { let p = start + 8; while (p < start + len) { const count = u16(workbook, p); p += 2; const value = decodeBiffString(workbook, p, count); const flags = workbook[p] || 0; let bytesUsed = 1 + (flags & 1 ? count * 2 : count); if (flags & 0x08) bytesUsed += 2; if (flags & 0x04) bytesUsed += 4; sst.push(value); p += bytesUsed; } } else if (id === 0x00fd && len >= 10) { const r = u16(workbook, start); const c = u16(workbook, start + 2); const idx = u32(workbook, start + 6); addCell(r, c, sst[idx] ?? ""); } else if (id === 0x0203 && len >= 14) { const r = u16(workbook, start); const c = u16(workbook, start + 2); addCell(r, c, new DataView(workbook.buffer, workbook.byteOffset + start + 6, 8).getFloat64(0, true)); } else if (id === 0x027e && len >= 10) { const r = u16(workbook, start); const c = u16(workbook, start + 2); const raw = u32(workbook, start + 6); let value; if ((raw & 0x03) === 0x02) value = raw >> 2; else value = new DataView(workbook.buffer, workbook.byteOffset + start + 6, 8).getFloat64(0, true); addCell(r, c, value); } else if (id === 0x0204 && len >= 8) { const r = u16(workbook, start); const c = u16(workbook, start + 2); const count = u16(workbook, start + 6); addCell(r, c, readAnsi(workbook, start + 8, count)); } else if (id === 0x00d6 && len >= 8) { const r = u16(workbook, start); const c = u16(workbook, start + 2); const count = u16(workbook, start + 6); addCell(r, c, decodeBiffString(workbook, start + 8, count)); } else if (id === 0x0006 && len >= 20) { const r = u16(workbook, start); const c = u16(workbook, start + 2); addCell(r, c, new DataView(workbook.buffer, workbook.byteOffset + start + 6, 8).getFloat64(0, true)); } pos = start + len; } return rows.map(row => row || []).filter(row => row.some(v => String(v ?? "").trim() !== "")); } function parseBinaryXls(buffer) { const workbook = getOleStream(buffer, ["Workbook", "Book"]); return parseBiffWorkbook(workbook); } function parseHtmlTable(html) { const doc = new DOMParser().parseFromString(html, "text/html"); const table = doc.querySelector("table"); if (!table) return []; return [...table.querySelectorAll("tr")].map(tr => { return [...tr.children].filter(cell => /^(td|th)$/i.test(cell.tagName)).map(cell => cell.textContent.trim()); }).filter(row => row.some(v => v !== "")); } function columnNameToIndex(name) { let n = 0; for (const ch of name) n = n * 26 + ch.charCodeAt(0) - 64; return n - 1; } function readZipEntries(buffer) { const bytes = new Uint8Array(buffer); const entries = new Map(); for (let i = 0; i < bytes.length - 46; i++) { if (bytes[i] !== 0x50 || bytes[i + 1] !== 0x4b || bytes[i + 2] !== 0x01 || bytes[i + 3] !== 0x02) continue; const method = bytes[i + 10] | (bytes[i + 11] << 8); const compressedSize = bytes[i + 20] | (bytes[i + 21] << 8) | (bytes[i + 22] << 16) | (bytes[i + 23] << 24); const nameLength = bytes[i + 28] | (bytes[i + 29] << 8); const extraLength = bytes[i + 30] | (bytes[i + 31] << 8); const commentLength = bytes[i + 32] | (bytes[i + 33] << 8); const localOffset = bytes[i + 42] | (bytes[i + 43] << 8) | (bytes[i + 44] << 16) | (bytes[i + 45] << 24); const name = new TextDecoder().decode(bytes.slice(i + 46, i + 46 + nameLength)); const localNameLength = bytes[localOffset + 26] | (bytes[localOffset + 27] << 8); const localExtraLength = bytes[localOffset + 28] | (bytes[localOffset + 29] << 8); const dataStart = localOffset + 30 + localNameLength + localExtraLength; const data = bytes.slice(dataStart, dataStart + compressedSize); entries.set(name, { method, data }); i += 46 + nameLength + extraLength + commentLength - 1; } return entries; } async function unzipEntry(entry) { if (!entry) return ""; if (entry.method === 0) return new TextDecoder().decode(entry.data); if (entry.method === 8 && "DecompressionStream" in window) { const stream = new Blob([entry.data]).stream().pipeThrough(new DecompressionStream("deflate-raw")); return await new Response(stream).text(); } throw new Error("当前浏览器不支持解压该 XLSX 文件,请使用新版 Chrome/Edge,或另存为 CSV/DAT 后导入。"); } async function parseXlsx(buffer) { const entries = readZipEntries(buffer); const workbookXml = await unzipEntry(entries.get("xl/workbook.xml")); const relsXml = await unzipEntry(entries.get("xl/_rels/workbook.xml.rels")); const sharedXml = await unzipEntry(entries.get("xl/sharedStrings.xml")); const parser = new DOMParser(); const workbook = parser.parseFromString(workbookXml, "application/xml"); const rels = parser.parseFromString(relsXml, "application/xml"); const firstSheet = workbook.querySelector("sheet"); if (!firstSheet) return []; const relId = firstSheet.getAttribute("r:id"); const rel = [...rels.querySelectorAll("Relationship")].find(r => r.getAttribute("Id") === relId); const target = rel ? rel.getAttribute("Target") : "worksheets/sheet1.xml"; const sheetPath = "xl/" + target.replace(/^\/?xl\//, ""); const sheetXml = await unzipEntry(entries.get(sheetPath)); const shared = []; if (sharedXml) { const sharedDoc = parser.parseFromString(sharedXml, "application/xml"); sharedDoc.querySelectorAll("si").forEach(si => { shared.push([...si.querySelectorAll("t")].map(t => t.textContent).join("")); }); } const sheet = parser.parseFromString(sheetXml, "application/xml"); const rows = []; sheet.querySelectorAll("sheetData row").forEach(rowNode => { const row = []; rowNode.querySelectorAll("c").forEach(cell => { const ref = cell.getAttribute("r") || ""; const colLetters = (ref.match(/[A-Z]+/) || ["A"])[0]; const colIndex = columnNameToIndex(colLetters); const type = cell.getAttribute("t"); const valueNode = cell.querySelector("v"); const inlineNode = cell.querySelector("is t"); let value = ""; if (type === "s") value = shared[Number(valueNode?.textContent || 0)] || ""; else if (type === "inlineStr") value = inlineNode?.textContent || ""; else value = valueNode?.textContent || ""; row[colIndex] = value; }); rows.push(row.map(v => v ?? "")); }); return rows.filter(row => row.some(v => String(v).trim() !== "")); } function isHeaderRow(row) { const vals = row.map(c => String(c || "").trim().toLowerCase()); let count = 0; for (const kw of headerKeywords) { if (vals.some(v => v.includes(kw))) count++; } return count >= 2; } function normalizeHeader(value) { return String(value || "").trim().toLowerCase().replace(/[\s_\-()()]/g, ""); } function guessMapping(headers) { const normalized = headers.map(h => normalizeHeader(h)); const aliases = { id: ["id", "编号", "点id", "点名", "名称", "name"], line: ["line", "线号", "测线", "线", "lineid", "surveyline"], station: ["station", "点号", "桩号", "站号", "点", "stn", "pt", "stationid"], x: ["x", "北坐标", "北", "north", "northing", "纵坐标", "n"], y: ["y", "东坐标", "东", "east", "easting", "横坐标", "e"], h: ["h", "高程", "height", "elevation", "z", "海拔", "高度"] }; const map = {}; for (const [key] of fields) { const idx = normalized.findIndex(h => aliases[key].includes(h)); map[key] = idx >= 0 ? idx : ""; } return map; } function defaultMapping(width) { const map = {}; fields.forEach(([key], index) => { map[key] = index < width ? index : ""; }); return map; } async function parseFileToRows(file) { const ext = file.name.split(".").pop().toLowerCase(); const data = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = () => reject(reader.error || new Error("读取文件失败")); if (ext === "xlsx" || ext === "xls") reader.readAsArrayBuffer(file); else reader.readAsText(file, "utf-8"); }); if (ext === "xlsx") return await parseXlsx(data); if (ext === "xls") { if (isOleFile(data)) return parseBinaryXls(data); return parseXlsLike(new TextDecoder("utf-8").decode(data)); } return parseDelimitedText(String(data || "")); } function datasetFromRows(parsed, file) { if (!parsed.length) throw new Error("文件为空或没有可识别的数据。"); const first = parsed[0].map(c => String(c).trim()); const hasHeader = isHeaderRow(first); const headers = hasHeader ? first : first.map((_, index) => "第" + (index + 1) + "列"); const body = hasHeader ? parsed.slice(1) : parsed; const width = headers.length; const rows = body.map(r => Array.from({ length: width }, (_, i) => r[i] ?? "")); return { name: file.name, headers, rows, map: hasHeader ? guessMapping(headers) : defaultMapping(width), records: [], hasHeader }; } async function loadFile(kind, file) { try { const parsed = await parseFileToRows(file); state[kind] = datasetFromRows(parsed, file); document.getElementById(kind === "design" ? "designName" : "measuredName").textContent = file.name + (state[kind].hasHeader ? "" : "(无表头)"); state.hasResultView = false; renderMappingStrip(kind); renderSourceTable(kind); clearResults(false); } catch (error) { alert("文件解析失败:" + error.message); } } async function loadMeasuredFiles(files) { const loaded = []; for (const file of files) { try { const parsed = await parseFileToRows(file); loaded.push(datasetFromRows(parsed, file)); } catch (error) { alert(file.name + " 解析失败:" + error.message); } } if (!loaded.length) return; state.measuredFiles = loaded; state.activeMeasuredIndex = 0; state.measured = state.measuredFiles[0]; updateMeasuredFileSelect(); state.hasResultView = false; renderMappingStrip("measured"); renderSourceTable("measured"); clearResults(false); } function updateMeasuredFileSelect() { if (!state.measuredFiles.length) { el.measuredFileSelect.innerHTML = ""; el.measuredName.textContent = "未加载文件"; return; } el.measuredFileSelect.innerHTML = state.measuredFiles.map((dataset, index) => { return ``; }).join(""); const active = state.measuredFiles[state.activeMeasuredIndex]; el.measuredName.textContent = state.measuredFiles.length + " 个文件;当前:" + active.name + (active.hasHeader ? "" : "(无表头)"); } function switchMeasuredFile(index) { if (!state.measuredFiles[index]) return; state.activeMeasuredIndex = index; state.measured = state.measuredFiles[index]; updateMeasuredFileSelect(); renderMappingStrip("measured"); renderSourceTable("measured"); clearResults(false); } function renderMappingStrip(kind) { const dataset = state[kind]; const strip = kind === "design" ? el.designMapping : el.measuredMapping; if (!dataset.headers.length) { strip.innerHTML = `
请先打开 CSV 文件。
`; return; } const cells = dataset.headers.map((h, i) => { const selected = Object.entries(dataset.map).find(([, idx]) => String(idx) === String(i))?.[0] || ""; const options = [''].concat(fields.map(([key, label]) => { return ``; })).join(""); return `${escapeHtml(h)}`; }).join(""); strip.innerHTML = `${cells}
`; strip.querySelectorAll("select").forEach(select => { select.addEventListener("change", event => { const target = event.target; const index = Number(target.dataset.index); const value = target.value; Object.keys(dataset.map).forEach(key => { if (Number(dataset.map[key]) === index) dataset.map[key] = ""; }); if (value) dataset.map[value] = index; state.hasResultView = false; renderMappingStrip(kind); renderSourceTable(kind); clearResults(false); }); }); } function renderSourceTable(kind) { const dataset = state[kind]; const table = kind === "design" ? el.designTable : el.measuredTable; const flags = kind === "measured" ? state.measuredFlags : new Map(); if (!dataset.headers.length) { table.innerHTML = ""; return; } const head = dataset.headers.map(h => `${escapeHtml(h)}`).join(""); const rows = dataset.rows.slice(0, 1000).map((row, rowIndex) => { return "" + row.map((cell, colIndex) => { const mappedKey = Object.entries(dataset.map).find(([, idx]) => Number(idx) === colIndex)?.[0]; const activeFileIndex = kind === "measured" && state.measuredFiles.length ? state.activeMeasuredIndex : null; const flagKey = activeFileIndex == null ? rowIndex : activeFileIndex + ":" + rowIndex; const flag = flags.get(flagKey); let cls = ""; if (mappedKey === "id" && flag === "misaligned") cls = "id-misaligned"; if (mappedKey === "id" && flag === "duplicate") cls = "id-duplicate"; return `${escapeHtml(cell)}`; }).join("") + ""; }).join(""); table.innerHTML = `${head}${rows}`; } function requiredKeysForMode() { return el.matchMode.value === "nearest" ? ["x", "y"] : ["line", "station", "x", "y"]; } function buildRecordsFromDataset(dataset, requiredKeys, label, fileIndex = null) { const missing = fields .filter(([key]) => requiredKeys.includes(key) && (dataset.map[key] === "" || dataset.map[key] == null)) .map(([, label]) => label); if (missing.length) { throw new Error(label + "缺少列映射:" + missing.join(",")); } const has = key => dataset.map[key] !== "" && dataset.map[key] != null; dataset.records = dataset.rows.map((row, index) => { const get = key => has(key) ? (row[Number(dataset.map[key])] ?? "") : ""; return { sourceIndex: index, globalIndex: fileIndex == null ? index : fileIndex + ":" + index, fileIndex, fileName: dataset.name || "", raw: row, id: String(get("id")).trim(), line: String(get("line")).trim(), station: String(get("station")).trim(), x: parseNumber(get("x")), y: parseNumber(get("y")), h: parseNumber(get("h")) }; }); const bad = dataset.records.find(r => !Number.isFinite(r.x) || !Number.isFinite(r.y)); if (bad) throw new Error(label + "存在无法识别的 X/Y 数值,数据行:" + (bad.sourceIndex + 1)); return dataset.records; } function buildRecords(kind, requiredKeys) { return buildRecordsFromDataset(state[kind], requiredKeys, kind === "design" ? "设计坐标" : "实测坐标"); } function buildAllMeasuredRecords(requiredKeys) { const datasets = state.measuredFiles.length ? state.measuredFiles : (state.measured.headers.length ? [state.measured] : []); return datasets.flatMap((dataset, index) => buildRecordsFromDataset(dataset, requiredKeys, "实测坐标[" + (index + 1) + " " + (dataset.name || "") + "]", index)); } function parseNumber(value) { const n = Number(String(value).trim().replace(/,/g, "")); return Number.isFinite(n) ? n : NaN; } function keyOf(row) { return row.line + "\u0001" + row.station; } function hasLineStation(row) { return row && row.line !== "" && row.station !== ""; } function displayKey(row) { return hasLineStation(row) ? row.line + "/" + row.station : ""; } function stationSortValue(value) { const n = Number(String(value).replace(/[^\d.+-]/g, "")); return Number.isFinite(n) ? n : String(value); } function compareLineStation(a, b) { const lineA = stationSortValue(a.line); const lineB = stationSortValue(b.line); if (lineA !== lineB) { return typeof lineA === "number" && typeof lineB === "number" ? lineA - lineB : String(lineA).localeCompare(String(lineB), "zh-Hans-CN"); } const stA = stationSortValue(a.station); const stB = stationSortValue(b.station); return typeof stA === "number" && typeof stB === "number" ? stA - stB : String(stA).localeCompare(String(stB), "zh-Hans-CN"); } function distance(a, b) { return Math.hypot(a.x - b.x, a.y - b.y); } function pointToSegmentDistance(p, a, b) { const dx = b.x - a.x; const dy = b.y - a.y; const len2 = dx * dx + dy * dy; if (len2 === 0) return distance(p, a); const t = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2)); return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy)); } function adjacentPoint(designByLine, designPoint) { const linePoints = designByLine.get(designPoint.line) || []; const idx = linePoints.findIndex(p => p === designPoint); if (idx < 0) return null; return linePoints[idx + 1] || linePoints[idx - 1] || null; } function nearestDesignPoint(designRecords, measuredPoint) { let best = null; let bestDistance = Infinity; for (const d of designRecords) { const dist = distance(d, measuredPoint); if (dist < bestDistance) { best = d; bestDistance = dist; } } return { point: best, distance: bestDistance }; } function analyze() { try { const mode = el.matchMode.value; const requiredKeys = requiredKeysForMode(); const design = buildRecords("design", requiredKeys); const measured = buildAllMeasuredRecords(requiredKeys); if (!design.length || !measured.length) throw new Error("设计坐标和实测坐标都需要至少一行数据。"); const designByKey = new Map(); design.forEach(d => { if (!hasLineStation(d)) return; if (!designByKey.has(keyOf(d))) designByKey.set(keyOf(d), []); designByKey.get(keyOf(d)).push(d); }); const measuredByKey = new Map(); measured.forEach(m => { if (!hasLineStation(m)) return; if (!measuredByKey.has(keyOf(m))) measuredByKey.set(keyOf(m), []); measuredByKey.get(keyOf(m)).push(m); }); const designByLine = new Map(); design.forEach(d => { if (!d.line) return; if (!designByLine.has(d.line)) designByLine.set(d.line, []); designByLine.get(d.line).push(d); }); designByLine.forEach(points => points.sort(compareLineStation)); const duplicateThreshold = Number(el.duplicateThreshold.value) || 0; const misalignThreshold = Number(el.misalignThreshold.value) || 0; const flags = new Map(); const duplicateRows = new Set(); measuredByKey.forEach(group => { if (mode !== "lineStation") return; for (let i = 0; i < group.length; i++) { for (let j = i + 1; j < group.length; j++) { if (distance(group[i], group[j]) <= duplicateThreshold) { duplicateRows.add(group[i].globalIndex); duplicateRows.add(group[j].globalIndex); } } } }); duplicateRows.forEach(i => flags.set(i, "duplicate")); const results = []; if (mode === "lineStation") { const coveredDesignRows = new Set(); measured.forEach(m => { const group = designByKey.get(keyOf(m)) || []; const matchedDesign = group[0] || null; const near = nearestDesignPoint(design, m); const matchedOffset = matchedDesign ? distance(matchedDesign, m) : Infinity; const nearestIsDifferentPoint = matchedDesign && near.point && keyOf(near.point) !== keyOf(matchedDesign); const isMisaligned = near.point && near.distance <= misalignThreshold && ( !matchedDesign || (nearestIsDifferentPoint && near.distance < matchedOffset) ); if (isMisaligned) flags.set(m.globalIndex, "misaligned"); const d = isMisaligned ? near.point : matchedDesign; if (d) coveredDesignRows.add(d.sourceIndex); results.push(makeResult(d, m, designByLine, isMisaligned ? "疑似错位" : (matchedDesign ? "已匹配" : "无对应设计点"), isMisaligned)); }); design.forEach(d => { if (!coveredDesignRows.has(d.sourceIndex)) results.push(makeResult(d, null, designByLine, "丢点", false)); }); } else { const usedDesign = new Set(); measured.forEach(m => { const near = nearestDesignPoint(design, m); const isMisaligned = near.point && hasLineStation(near.point) && hasLineStation(m) && keyOf(near.point) !== keyOf(m) && near.distance <= misalignThreshold; if (isMisaligned) flags.set(m.globalIndex, "misaligned"); if (near.point) usedDesign.add(near.point.sourceIndex); results.push(makeResult(near.point, m, designByLine, isMisaligned ? "疑似错位" : "坐标最近匹配", isMisaligned)); }); design.forEach(d => { const sameKeyMeasured = hasLineStation(d) && measuredByKey.has(keyOf(d)); if (!usedDesign.has(d.sourceIndex) && !sameKeyMeasured) { results.push(makeResult(d, null, designByLine, "丢点", false)); } }); } state.measuredFlags = flags; state.results = results; state.compareRows = buildCompareRows(results); state.lineStats = buildLineStats(design, results); state.hasResultView = true; applyColorScale(results); renderCompareTables(); updateMetrics(); el.exportBtn.disabled = !results.length; el.saveAsBtn.disabled = !results.length; } catch (error) { alert(error.message); } } function makeResult(designPoint, measuredPoint, designByLine, status, misaligned) { let offset = null; let lineDistance = null; let adjacent = null; if (designPoint && measuredPoint) { offset = distance(designPoint, measuredPoint); adjacent = adjacentPoint(designByLine, designPoint); lineDistance = adjacent ? pointToSegmentDistance(measuredPoint, designPoint, adjacent) : null; } return { design: designPoint, measured: measuredPoint, adjacent, offset, lineDistance, status, misaligned }; } function buildCompareRows(results) { return results.slice().sort((a, b) => { const aa = a.design || a.measured || { line: "", station: "" }; const bb = b.design || b.measured || { line: "", station: "" }; const c = compareLineStation(aa, bb); if (c !== 0) return c; return (a.measured?.sourceIndex ?? -1) - (b.measured?.sourceIndex ?? -1); }); } function designPointKey(point) { return point.line + "\u0001" + (point.station !== "" ? point.station : point.sourceIndex); } function buildLineStats(designRecords, results) { const designMap = new Map(); const matchedDesignKeys = new Set(); const addDesign = point => { if (!point.line) return; if (!designMap.has(point.line)) designMap.set(point.line, []); designMap.get(point.line).push(point); }; designRecords.forEach(addDesign); results.forEach(result => { if (!result.design || !result.measured || !result.design.line) return; matchedDesignKeys.add(designPointKey(result.design)); }); return [...designMap.entries()] .sort(([a], [b]) => String(a).localeCompare(String(b), "zh-Hans-CN", { numeric: true })) .map(([line, points]) => { const sorted = points.slice().sort(compareLineStation); let designLength = 0; let matchedLength = 0; let matchedPoints = 0; const pointMatches = new Map(); sorted.forEach(p => { const k = designPointKey(p); pointMatches.set(k, matchedDesignKeys.has(k)); }); for (let i = 0; i < sorted.length - 1; i++) { const a = sorted[i]; const b = sorted[i + 1]; const segLen = distance(a, b); designLength += segLen; if (pointMatches.get(designPointKey(a)) && pointMatches.get(designPointKey(b))) { matchedLength += segLen; } } sorted.forEach(p => { if (pointMatches.get(designPointKey(p))) matchedPoints++; }); return { line, total: sorted.length, done: matchedPoints, missing: Math.max(0, sorted.length - matchedPoints), rate: sorted.length ? matchedPoints / sorted.length : 0, designLength, matchedLength }; }); } function applyColorScale(results) { const values = results.flatMap(r => [r.offset, r.lineDistance]).filter(v => Number.isFinite(v)); const min = values.length ? Math.min(...values) : 0; const max = values.length ? Math.max(...values) : 0; results.forEach(r => { r.offsetColor = colorFor(r.offset, min, max); r.lineDistanceColor = colorFor(r.lineDistance, min, max); }); } function colorFor(value, min, max) { if (!Number.isFinite(value)) return ""; if (max === min) return "rgb(11, 99, 206)"; const t = (value - min) / (max - min); if (t <= .5) return mix([11, 99, 206], [230, 190, 0], t / .5); return mix([230, 190, 0], [217, 35, 50], (t - .5) / .5); } function mix(a, b, t) { const rgb = a.map((v, i) => Math.round(v + (b[i] - v) * t)); return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`; } function fmt(value) { return Number.isFinite(value) ? Number(value).toFixed(4) : ""; } function showValue(value) { return Number.isFinite(value) ? value : (value ?? ""); } function renderCompareTables() { renderMappingStrip("design"); renderMappingStrip("measured"); renderDesignCompareTable(); renderMeasuredCompareTable(); renderResultTable(); syncScrollTops(0); } function rowClass(r) { if (r.status === "丢点") return "row-missing"; if (r.misaligned) return "row-warn"; return ""; } function renderDesignCompareTable() { const headers = ["ID", "Line", "Station", "X", "Y", "H"]; const body = state.compareRows.map(r => { const d = r.design; return ` ${escapeHtml(d?.id)} ${escapeHtml(d?.line)} ${escapeHtml(d?.station)} ${escapeHtml(d?.x ?? "")} ${escapeHtml(d?.y ?? "")} ${escapeHtml(showValue(d?.h))} `; }).join(""); el.designTable.innerHTML = `${headers.map(h => `${h}`).join("")}${body}`; } function renderMeasuredCompareTable() { const headers = ["文件", "ID", "Line", "Station", "X", "Y", "H"]; const body = state.compareRows.map(r => { const m = r.measured; const flag = m ? state.measuredFlags.get(m.globalIndex) : ""; const idCls = flag === "misaligned" ? "id-misaligned" : (flag === "duplicate" ? "id-duplicate" : ""); return ` ${escapeHtml(m?.fileName)} ${escapeHtml(m?.id)} ${escapeHtml(m?.line)} ${escapeHtml(m?.station)} ${escapeHtml(m?.x ?? "")} ${escapeHtml(m?.y ?? "")} ${escapeHtml(showValue(m?.h))} `; }).join(""); el.measuredTable.innerHTML = `${headers.map(h => `${h}`).join("")}${body}`; } function renderResultTable() { const headers = ["状态", "设计点", "实测点", "点偏移", "线偏移", "相邻点"]; const body = state.compareRows.map(r => { const statusClass = r.status === "丢点" ? "status-lost" : (r.misaligned ? "status-warn" : "status-ok"); return ` ${escapeHtml(r.status)} ${escapeHtml(displayKey(r.design))} ${escapeHtml(displayKey(r.measured))} ${fmt(r.offset)} ${fmt(r.lineDistance)} ${r.adjacent ? escapeHtml(displayKey(r.adjacent)) : ""} `; }).join(""); el.resultTable.innerHTML = `${headers.map(h => `${h}`).join("")}${body}`; } function updateMetrics() { const designTotal = state.design.records?.length || state.results.filter(r => r.design).length; const matchedTotal = state.results.filter(r => r.measured && r.design).length; el.designTotalCount.textContent = designTotal; el.matchedCount.textContent = matchedTotal; el.completionPercent.textContent = designTotal ? ((matchedTotal / designTotal) * 100).toFixed(1) + "%" : "0%"; el.lostCount.textContent = state.results.filter(r => r.status === "丢点").length; el.misalignCount.textContent = [...state.measuredFlags.values()].filter(v => v === "misaligned").length; el.duplicateCount.textContent = [...state.measuredFlags.values()].filter(v => v === "duplicate").length; const totalDone = state.lineStats.reduce((sum, item) => sum + item.done, 0); const totalDesign = state.lineStats.reduce((sum, item) => sum + item.total, 0); const detail = state.lineStats.map(item => `Line ${item.line}: ${item.done}/${item.total}`).join(";"); el.lineCompleteCount.textContent = state.lineStats.length ? `${state.lineStats.length}线` : "0线"; el.lineCompleteMetric.title = detail ? `总计 ${totalDone}/${totalDesign};${detail}` : "暂无线号完成统计"; } function openLineStatsDialog() { if (!state.lineStats.length) { alert("暂无线号完成统计,请先完成计算。"); return; } const headers = ["Line", "设计点数", "完成点数", "未完成", "完成率", "测线长度", "匹配长度"]; const body = state.lineStats.map(item => { return ` ${escapeHtml(item.line)} ${item.total} ${item.done} ${item.missing} ${(item.rate * 100).toFixed(1)}% ${item.designLength.toFixed(2)} ${item.matchedLength.toFixed(2)} `; }).join(""); el.lineStatsTable.innerHTML = `${headers.map(h => `${h}`).join("")}${body}`; el.lineStatsDialog.showModal(); } function clearResults(renderRaw = true) { state.results = []; state.compareRows = []; state.measuredFlags = new Map(); state.lineStats = []; state.hasResultView = false; el.resultTable.innerHTML = ""; el.exportBtn.disabled = true; el.saveAsBtn.disabled = true; updateMetrics(); if (renderRaw) { if (state.design.headers.length) { renderMappingStrip("design"); renderSourceTable("design"); } if (state.measured.headers.length) { renderMappingStrip("measured"); renderSourceTable("measured"); } } } function openExportDialog() { state.exportColumns = [ ...fields.map(([key, label]) => ({ key: "design." + key, label: "设计-" + label, getter: r => r.design?.[key] ?? "" })), ...fields.map(([key, label]) => ({ key: "measured." + key, label: "实测-" + label, getter: r => r.measured?.[key] ?? "" })), { key: "result.status", label: "结果-状态", getter: r => r.status }, { key: "result.offset", label: "结果-两点偏移距离", getter: r => fmt(r.offset) }, { key: "result.lineDistance", label: "结果-实测点到设计测线距离", getter: r => fmt(r.lineDistance) }, { key: "result.adjacent", label: "结果-相邻设计点", getter: r => r.adjacent ? displayKey(r.adjacent) : "" } ]; el.exportColumns.innerHTML = state.exportColumns.map(col => { return ``; }).join(""); el.exportDialog.showModal(); } function downloadExport() { const selected = new Set([...el.exportColumns.querySelectorAll("input:checked")].map(i => i.value)); const cols = state.exportColumns.filter(c => selected.has(c.key)); if (!cols.length) { alert("请至少选择一列。"); return; } const rows = [cols.map(c => c.label)]; state.compareRows.forEach(r => rows.push(cols.map(c => c.getter(r)))); const format = el.exportFormat.value; let blob; let filename; if (format === "xlsx") { blob = toXlsxBlob(rows); filename = "设计实测坐标匹配结果.xlsx"; } else if (format === "xls") { blob = new Blob(["\ufeff" + toExcelHtml(rows)], { type: "application/vnd.ms-excel;charset=utf-8" }); filename = "设计实测坐标匹配结果.xls"; } else if (format === "dat") { blob = new Blob(["\ufeff" + toCsv(rows)], { type: "text/plain;charset=utf-8" }); filename = "设计实测坐标匹配结果.dat"; } else if (format === "txt") { blob = new Blob(["\ufeff" + toCsv(rows)], { type: "text/plain;charset=utf-8" }); filename = "设计实测坐标匹配结果.txt"; } else { blob = new Blob(["\ufeff" + toCsv(rows)], { type: "text/csv;charset=utf-8" }); filename = "设计实测坐标匹配结果.csv"; } const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); el.exportDialog.close(); } function escapeHtml(value) { return String(value ?? "").replace(/[&<>"']/g, ch => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ch]); } function syncScrollTops(top) { document.querySelectorAll(".sync-scroll").forEach(wrap => { wrap.scrollTop = top; }); } function initSyncScroll() { const wraps = [...document.querySelectorAll(".sync-scroll")]; let locking = false; wraps.forEach(wrap => { wrap.addEventListener("scroll", () => { if (locking) return; locking = true; const top = wrap.scrollTop; wraps.forEach(other => { if (other !== wrap) other.scrollTop = top; }); requestAnimationFrame(() => { locking = false; }); }); }); const xWraps = [...document.querySelectorAll(".sync-x")]; let xLocking = false; xWraps.forEach(wrap => { wrap.addEventListener("scroll", () => { if (xLocking) return; xLocking = true; const left = wrap.scrollLeft; xWraps.forEach(other => { if (other !== wrap) other.scrollLeft = left; }); requestAnimationFrame(() => { xLocking = false; }); }); }); } el.designOpen.addEventListener("click", () => el.designFile.click()); el.measuredOpen.addEventListener("click", () => el.measuredFile.click()); el.designFile.addEventListener("change", e => e.target.files[0] && loadFile("design", e.target.files[0])); el.measuredFile.addEventListener("change", e => e.target.files.length && loadMeasuredFiles([...e.target.files])); el.measuredFileSelect.addEventListener("change", e => switchMeasuredFile(Number(e.target.value))); el.runBtn.addEventListener("click", analyze); el.clearBtn.addEventListener("click", () => clearResults(true)); el.exportBtn.addEventListener("click", openExportDialog); el.saveAsBtn.addEventListener("click", openExportDialog); el.closeExport.addEventListener("click", () => el.exportDialog.close()); el.lineCompleteMetric.addEventListener("click", openLineStatsDialog); el.closeLineStats.addEventListener("click", () => el.lineStatsDialog.close()); el.downloadExport.addEventListener("click", downloadExport); el.selectAllExport.addEventListener("click", () => el.exportColumns.querySelectorAll("input").forEach(i => i.checked = true)); el.selectNoneExport.addEventListener("click", () => el.exportColumns.querySelectorAll("input").forEach(i => i.checked = false)); renderMappingStrip("design"); renderMappingStrip("measured"); initSyncScroll();