feat(web): 新增后台线路拓扑图布局编辑器,允许手动拖拽调整所有站点坐标
This commit is contained in:
@@ -16,8 +16,8 @@
|
|||||||
"mtimeMs": 1783602619602.658
|
"mtimeMs": 1783602619602.658
|
||||||
},
|
},
|
||||||
"ticket-order.js": {
|
"ticket-order.js": {
|
||||||
"size": 37721,
|
"size": 38975,
|
||||||
"mtimeMs": 1783947073044.0798
|
"mtimeMs": 1783947975169.5613
|
||||||
},
|
},
|
||||||
"ticket-search.js": {
|
"ticket-search.js": {
|
||||||
"size": 17269,
|
"size": 17269,
|
||||||
@@ -28,8 +28,8 @@
|
|||||||
"mtimeMs": 1783666241687.1482
|
"mtimeMs": 1783666241687.1482
|
||||||
},
|
},
|
||||||
"index.js": {
|
"index.js": {
|
||||||
"size": 82754,
|
"size": 93210,
|
||||||
"mtimeMs": 1783779774480.386
|
"mtimeMs": 1783947920227.9448
|
||||||
},
|
},
|
||||||
"ic-card-admin.js": {
|
"ic-card-admin.js": {
|
||||||
"size": 19095,
|
"size": 19095,
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
"blog.css": 2,
|
"blog.css": 2,
|
||||||
"custom-dialog.js": 11,
|
"custom-dialog.js": 11,
|
||||||
"public-status.js": 14,
|
"public-status.js": 14,
|
||||||
"ticket-order.js": 30,
|
"ticket-order.js": 31,
|
||||||
"ticket-search.js": 12,
|
"ticket-search.js": 12,
|
||||||
"ticket-route.js": 3,
|
"ticket-route.js": 3,
|
||||||
"index.js": 5,
|
"index.js": 6,
|
||||||
"ic-card-admin.js": 3,
|
"ic-card-admin.js": 3,
|
||||||
"ic-card-order.js": 3,
|
"ic-card-order.js": 3,
|
||||||
"ic-card-detail.js": 2,
|
"ic-card-detail.js": 2,
|
||||||
|
|||||||
+63
-18
@@ -74,7 +74,7 @@
|
|||||||
<span class="nav-icon"><i class="fas fa-credit-card"></i></span> IC卡管理
|
<span class="nav-icon"><i class="fas fa-credit-card"></i></span> IC卡管理
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-item" :class="{active: currentView === 'assets'}" @click="currentView = 'assets'">
|
<div class="nav-item" :class="{active: currentView === 'assets'}" @click="currentView = 'assets'">
|
||||||
<span class="nav-icon"><i class="fas fa-route"></i></span> 线路图
|
<span class="nav-icon"><i class="fas fa-route"></i></span> 拓扑图布局
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-item" :class="{active: currentView === 'settings'}" @click="currentView = 'settings'">
|
<div class="nav-item" :class="{active: currentView === 'settings'}" @click="currentView = 'settings'">
|
||||||
<span class="nav-icon"><i class="fas fa-cog"></i></span> 系统设置
|
<span class="nav-icon"><i class="fas fa-cog"></i></span> 系统设置
|
||||||
@@ -794,31 +794,76 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="currentView === 'assets'">
|
<div v-if="currentView === 'assets'" class="assets-editor-container" style="display: flex; flex-direction: column; height: 80vh;">
|
||||||
<div class="card mb-4">
|
<div class="card mb-4" style="flex: 1; display: flex; flex-direction: column;">
|
||||||
<div class="flex between mb-4" style="flex-wrap: wrap; gap: 10px;">
|
<div class="flex between mb-4" style="flex-wrap: wrap; gap: 10px;">
|
||||||
<div class="flex" style="align-items: center; gap: 10px;">
|
<div class="flex" style="align-items: center; gap: 10px;">
|
||||||
<h4>线路图</h4>
|
<h4>线路拓扑图布局编辑器</h4>
|
||||||
<span class="badge" v-if="assetsManifest.routeMap" style="font-family: monospace;">{{ assetsManifest.routeMap }}</span>
|
<span class="badge" style="background: #2563eb; color: #fff;">拖拽站点以自定义位置</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex" style="gap: 8px;">
|
<div class="flex" style="gap: 8px;">
|
||||||
<label class="btn" style="cursor: pointer;">
|
<button class="btn" @click="resetTopologyLayout">
|
||||||
<i class="fas fa-upload"></i> 上传线路图
|
<i class="fas fa-undo"></i> 重置布局
|
||||||
<input type="file" hidden accept=".png,.jpg,.jpeg,.webp,.svg" @change="uploadRouteMap">
|
</button>
|
||||||
</label>
|
<button class="btn primary" @click="saveTopologyLayout">
|
||||||
<a v-if="assetsRouteMapUrl" :href="assetsRouteMapUrl" target="_blank" class="btn">
|
<i class="fas fa-save"></i> 保存坐标
|
||||||
<i class="fas fa-external-link-alt"></i> 打开
|
|
||||||
</a>
|
|
||||||
<button v-if="assetsManifest.routeMap" class="danger" @click="deleteRouteMap">
|
|
||||||
<i class="fas fa-trash"></i> 删除
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="assetsRouteMapUrl" style="border: 1px solid var(--border); border-radius: 10px; overflow: hidden; background: #0b0b0f; padding: 16px;">
|
<div style="flex: 1; border: 1px solid var(--border); border-radius: 10px; overflow: hidden; background: #0f172a; position: relative; user-select: none;"
|
||||||
<img :src="assetsRouteMapUrl" alt="线路图" style="display:block; width: 100%; height: auto;" ref="mapImageRef">
|
ref="topologyCanvas"
|
||||||
|
@mousedown="onTopologyCanvasMouseDown"
|
||||||
|
@mousemove="onTopologyCanvasMouseMove"
|
||||||
|
@mouseup="onTopologyCanvasMouseUp"
|
||||||
|
@mouseleave="onTopologyCanvasMouseUp"
|
||||||
|
@wheel="onTopologyCanvasWheel">
|
||||||
|
<div :style="{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '0', top: '0',
|
||||||
|
width: '100%', height: '100%',
|
||||||
|
transformOrigin: '0 0',
|
||||||
|
transform: `translate(${topologyPan.x}px, ${topologyPan.y}px) scale(${topologyPan.scale})`
|
||||||
|
}">
|
||||||
|
<!-- Grid Background (Optional) -->
|
||||||
|
<svg width="10000" height="10000" style="position: absolute; left: -5000px; top: -5000px; pointer-events: none;">
|
||||||
|
<defs>
|
||||||
|
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
|
||||||
|
<rect width="40" height="40" fill="none" stroke="rgba(255,255,255,0.05)" />
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="10000" height="10000" fill="url(#grid)" />
|
||||||
|
<!-- Lines -->
|
||||||
|
<path v-for="l in topologyLines" :key="'path-'+l.id" :d="l.d" fill="none" :stroke="l.color" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" opacity="0.6" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- Stations -->
|
||||||
|
<div v-for="node in topologyNodes" :key="node.canonical"
|
||||||
|
:style="{
|
||||||
|
position: 'absolute',
|
||||||
|
left: node.x + 'px',
|
||||||
|
top: node.y + 'px',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
cursor: 'grab',
|
||||||
|
zIndex: node.canonical === topologyDraggingNode ? 100 : 10
|
||||||
|
}"
|
||||||
|
@mousedown.stop="onTopologyNodeMouseDown($event, node)">
|
||||||
|
|
||||||
|
<div v-if="node.isTransfer" style="width: 24px; height: 24px; border-radius: 50%; background: #fff; border: 3px solid #1f2937; display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 6px rgba(0,0,0,0.3);">
|
||||||
|
<div style="width: 10px; height: 10px; border-radius: 50%; background: #cbd5e1;"></div>
|
||||||
|
</div>
|
||||||
|
<div v-else :style="{
|
||||||
|
width: '20px', height: '20px', borderRadius: '50%', background: '#fff',
|
||||||
|
border: `4px solid ${node.color || '#94a3b8'}`,
|
||||||
|
boxShadow: '0 2px 4px rgba(0,0,0,0.2)'
|
||||||
|
}"></div>
|
||||||
|
|
||||||
|
<div style="position: absolute; top: 26px; left: 50%; transform: translateX(-50%); white-space: nowrap; font-size: 12px; font-weight: 600; color: #f8fafc; text-shadow: 0 1px 3px rgba(0,0,0,0.8); pointer-events: none;">
|
||||||
|
{{ node.name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="loading">未上传线路图</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -920,7 +965,7 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<script src="/custom-dialog.js?v=11"></script>
|
<script src="/custom-dialog.js?v=11"></script>
|
||||||
<script src="index.js?v=5"></script>
|
<script src="index.js?v=6"></script>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const isDomain = location.hostname.includes('fse-media.group');
|
const isDomain = location.hostname.includes('fse-media.group');
|
||||||
|
|||||||
+239
@@ -121,6 +121,13 @@ createApp({
|
|||||||
const lineFormOriginalId = ref('');
|
const lineFormOriginalId = ref('');
|
||||||
const lineForm = reactive({ id: '', name: '', en_name: '', color: '#3366cc', stations: [] });
|
const lineForm = reactive({ id: '', name: '', en_name: '', color: '#3366cc', stations: [] });
|
||||||
|
|
||||||
|
// Topology Editor State
|
||||||
|
const topologyPan = reactive({ x: 0, y: 0, scale: 1, active: false, startX: 0, startY: 0, initialX: 0, initialY: 0 });
|
||||||
|
const topologyNodes = ref([]);
|
||||||
|
const topologyLines = ref([]);
|
||||||
|
const topologyDraggingNode = ref(null);
|
||||||
|
let topologyNodeDragStart = { x: 0, y: 0, nodeX: 0, nodeY: 0 };
|
||||||
|
|
||||||
// Legacy/Other State
|
// Legacy/Other State
|
||||||
const fareMapSvg = ref('');
|
const fareMapSvg = ref('');
|
||||||
const fareMapScale = ref(1);
|
const fareMapScale = ref(1);
|
||||||
@@ -1073,6 +1080,9 @@ createApp({
|
|||||||
const found = lines.value.find((line) => line.id === selectedLine.value.id);
|
const found = lines.value.find((line) => line.id === selectedLine.value.id);
|
||||||
selectedLine.value = found || null;
|
selectedLine.value = found || null;
|
||||||
}
|
}
|
||||||
|
if (currentView.value === 'assets') {
|
||||||
|
initTopologyLayout();
|
||||||
|
}
|
||||||
coreLoaded = true;
|
coreLoaded = true;
|
||||||
markSynced();
|
markSynced();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1605,6 +1615,227 @@ createApp({
|
|||||||
syncSelectedIcCard().catch(console.error);
|
syncSelectedIcCard().catch(console.error);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Topology Editor Logic
|
||||||
|
const initTopologyLayout = () => {
|
||||||
|
const nodesMap = new Map();
|
||||||
|
const linesArr = lines.value || [];
|
||||||
|
const stationsArr = stations.value || [];
|
||||||
|
|
||||||
|
// Helper to get transfer canonicals
|
||||||
|
const canonicals = {};
|
||||||
|
const isTransfer = {};
|
||||||
|
stationsArr.forEach(s => {
|
||||||
|
const code = String(s.code || '').trim();
|
||||||
|
canonicals[code] = code;
|
||||||
|
isTransfer[code] = false;
|
||||||
|
if (Array.isArray(s.transfer_to) && s.transfer_to.length > 0) {
|
||||||
|
const group = [code, ...s.transfer_to].map(x => String(x).trim()).sort();
|
||||||
|
const canonical = group[0];
|
||||||
|
group.forEach(g => canonicals[g] = canonical);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Count lines per canonical to identify transfer hubs
|
||||||
|
const lineCountByCanonical = {};
|
||||||
|
linesArr.forEach(line => {
|
||||||
|
const stops = Array.isArray(line.stations) ? line.stations : [];
|
||||||
|
stops.forEach(stop => {
|
||||||
|
if (stop === '-') return;
|
||||||
|
const canonical = canonicals[stop] || stop;
|
||||||
|
if (!lineCountByCanonical[canonical]) lineCountByCanonical[canonical] = new Set();
|
||||||
|
lineCountByCanonical[canonical].add(line.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
Object.keys(lineCountByCanonical).forEach(c => {
|
||||||
|
if (lineCountByCanonical[c].size > 1) {
|
||||||
|
isTransfer[c] = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize Nodes
|
||||||
|
stationsArr.forEach((s, idx) => {
|
||||||
|
const code = String(s.code || '').trim();
|
||||||
|
const canonical = canonicals[code] || code;
|
||||||
|
if (nodesMap.has(canonical)) return;
|
||||||
|
|
||||||
|
nodesMap.set(canonical, {
|
||||||
|
canonical,
|
||||||
|
code: code,
|
||||||
|
name: s.name || code,
|
||||||
|
color: null,
|
||||||
|
isTransfer: isTransfer[canonical],
|
||||||
|
x: Number(s.svg_x) || (100 + (idx % 10) * 80),
|
||||||
|
y: Number(s.svg_y) || (100 + Math.floor(idx / 10) * 80),
|
||||||
|
originalX: Number(s.svg_x) || (100 + (idx % 10) * 80),
|
||||||
|
originalY: Number(s.svg_y) || (100 + Math.floor(idx / 10) * 80)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assign colors to nodes based on lines
|
||||||
|
linesArr.forEach(line => {
|
||||||
|
const stops = Array.isArray(line.stations) ? line.stations : [];
|
||||||
|
stops.forEach(stop => {
|
||||||
|
if (stop === '-') return;
|
||||||
|
const canonical = canonicals[stop] || stop;
|
||||||
|
const node = nodesMap.get(canonical);
|
||||||
|
if (node && !node.color) {
|
||||||
|
node.color = line.color || '#93a2b7';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
topologyNodes.value = Array.from(nodesMap.values());
|
||||||
|
updateTopologyLines();
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateTopologyLines = () => {
|
||||||
|
const canonicals = {};
|
||||||
|
stations.value.forEach(s => {
|
||||||
|
const code = String(s.code || '').trim();
|
||||||
|
canonicals[code] = code;
|
||||||
|
if (Array.isArray(s.transfer_to) && s.transfer_to.length > 0) {
|
||||||
|
const group = [code, ...s.transfer_to].map(x => String(x).trim()).sort();
|
||||||
|
const canonical = group[0];
|
||||||
|
group.forEach(g => canonicals[g] = canonical);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const newLines = [];
|
||||||
|
(lines.value || []).forEach(line => {
|
||||||
|
const stops = Array.isArray(line.stations) ? line.stations : [];
|
||||||
|
let d = '';
|
||||||
|
let isFirst = true;
|
||||||
|
|
||||||
|
for (let i = 0; i < stops.length; i++) {
|
||||||
|
const stop = stops[i];
|
||||||
|
if (stop === '-') {
|
||||||
|
isFirst = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const canonical = canonicals[stop] || stop;
|
||||||
|
const node = topologyNodes.value.find(n => n.canonical === canonical);
|
||||||
|
if (!node) continue;
|
||||||
|
|
||||||
|
if (isFirst) {
|
||||||
|
d += `M ${node.x} ${node.y} `;
|
||||||
|
isFirst = false;
|
||||||
|
} else {
|
||||||
|
d += `L ${node.x} ${node.y} `;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (d) {
|
||||||
|
newLines.push({ id: line.id, color: line.color || '#93a2b7', d });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
topologyLines.value = newLines;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTopologyCanvasMouseDown = (e) => {
|
||||||
|
topologyPan.active = true;
|
||||||
|
topologyPan.startX = e.clientX;
|
||||||
|
topologyPan.startY = e.clientY;
|
||||||
|
topologyPan.initialX = topologyPan.x;
|
||||||
|
topologyPan.initialY = topologyPan.y;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTopologyCanvasMouseMove = (e) => {
|
||||||
|
if (topologyPan.active) {
|
||||||
|
topologyPan.x = topologyPan.initialX + (e.clientX - topologyPan.startX);
|
||||||
|
topologyPan.y = topologyPan.initialY + (e.clientY - topologyPan.startY);
|
||||||
|
} else if (topologyDraggingNode.value) {
|
||||||
|
const node = topologyNodes.value.find(n => n.canonical === topologyDraggingNode.value);
|
||||||
|
if (node) {
|
||||||
|
const dx = (e.clientX - topologyNodeDragStart.x) / topologyPan.scale;
|
||||||
|
const dy = (e.clientY - topologyNodeDragStart.y) / topologyPan.scale;
|
||||||
|
// Snap to grid of 20
|
||||||
|
node.x = Math.round((topologyNodeDragStart.nodeX + dx) / 20) * 20;
|
||||||
|
node.y = Math.round((topologyNodeDragStart.nodeY + dy) / 20) * 20;
|
||||||
|
updateTopologyLines();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTopologyCanvasMouseUp = () => {
|
||||||
|
topologyPan.active = false;
|
||||||
|
topologyDraggingNode.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTopologyCanvasWheel = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
||||||
|
topologyPan.scale = Math.max(0.1, Math.min(3, topologyPan.scale * delta));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTopologyNodeMouseDown = (e, node) => {
|
||||||
|
topologyDraggingNode.value = node.canonical;
|
||||||
|
topologyNodeDragStart.x = e.clientX;
|
||||||
|
topologyNodeDragStart.y = e.clientY;
|
||||||
|
topologyNodeDragStart.nodeX = node.x;
|
||||||
|
topologyNodeDragStart.nodeY = node.y;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetTopologyLayout = () => {
|
||||||
|
if (!confirm('确定要放弃当前修改并重置吗?')) return;
|
||||||
|
topologyNodes.value.forEach(node => {
|
||||||
|
node.x = node.originalX;
|
||||||
|
node.y = node.originalY;
|
||||||
|
});
|
||||||
|
topologyPan.x = 0;
|
||||||
|
topologyPan.y = 0;
|
||||||
|
topologyPan.scale = 1;
|
||||||
|
updateTopologyLines();
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveTopologyLayout = async () => {
|
||||||
|
if (!confirm('确定要保存当前站点坐标吗?这会覆盖所有站点的 svg_x 和 svg_y。')) return;
|
||||||
|
|
||||||
|
mutationBusy.value = true;
|
||||||
|
try {
|
||||||
|
// We need to update svg_x and svg_y for all stations that belong to each canonical group
|
||||||
|
const canonicals = {};
|
||||||
|
stations.value.forEach(s => {
|
||||||
|
const code = String(s.code || '').trim();
|
||||||
|
canonicals[code] = code;
|
||||||
|
if (Array.isArray(s.transfer_to) && s.transfer_to.length > 0) {
|
||||||
|
const group = [code, ...s.transfer_to].map(x => String(x).trim()).sort();
|
||||||
|
const canonical = group[0];
|
||||||
|
group.forEach(g => canonicals[g] = canonical);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const updates = [];
|
||||||
|
for (const s of stations.value) {
|
||||||
|
const code = String(s.code || '').trim();
|
||||||
|
const canonical = canonicals[code] || code;
|
||||||
|
const node = topologyNodes.value.find(n => n.canonical === canonical);
|
||||||
|
if (node) {
|
||||||
|
updates.push({
|
||||||
|
...s,
|
||||||
|
svg_x: node.x,
|
||||||
|
svg_y: node.y
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch update one by one
|
||||||
|
for (const update of updates) {
|
||||||
|
await fetch(`/api/admin/stations/${encodeURIComponent(update.code)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(update)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
alert('坐标保存成功!');
|
||||||
|
await fetchData();
|
||||||
|
initTopologyLayout(); // Re-initialize to update originalX/Y
|
||||||
|
} catch (e) {
|
||||||
|
alert('保存失败: ' + e.message);
|
||||||
|
} finally {
|
||||||
|
mutationBusy.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
watch(currentView, (v) => {
|
watch(currentView, (v) => {
|
||||||
sidebarOpen.value = false;
|
sidebarOpen.value = false;
|
||||||
if (v === 'iccards') {
|
if (v === 'iccards') {
|
||||||
@@ -1612,6 +1843,9 @@ createApp({
|
|||||||
} else {
|
} else {
|
||||||
stopIcCardSync();
|
stopIcCardSync();
|
||||||
}
|
}
|
||||||
|
if (v === 'assets') {
|
||||||
|
setTimeout(initTopologyLayout, 300);
|
||||||
|
}
|
||||||
ensureViewData(v).catch(console.error);
|
ensureViewData(v).catch(console.error);
|
||||||
const sp = new URLSearchParams(location.search);
|
const sp = new URLSearchParams(location.search);
|
||||||
if (v === 'dashboard') sp.delete('view');
|
if (v === 'dashboard') sp.delete('view');
|
||||||
@@ -1787,6 +2021,11 @@ createApp({
|
|||||||
|
|
||||||
saveCurrentFare, deleteCurrentFare, closeFareModal,
|
saveCurrentFare, deleteCurrentFare, closeFareModal,
|
||||||
|
|
||||||
|
// Topology Editor
|
||||||
|
topologyPan, topologyNodes, topologyLines, topologyDraggingNode,
|
||||||
|
onTopologyCanvasMouseDown, onTopologyCanvasMouseMove, onTopologyCanvasMouseUp, onTopologyCanvasWheel, onTopologyNodeMouseDown,
|
||||||
|
resetTopologyLayout, saveTopologyLayout,
|
||||||
|
|
||||||
saveConfig, isPushingUpdate, pushLuaUpdate, exportData, exportFareMap,
|
saveConfig, isPushingUpdate, pushLuaUpdate, exportData, exportFareMap,
|
||||||
formatTime, formatLogDetail, getStationName, getStationInfo, loadFareMap, fetchData, refreshData: fetchData,
|
formatTime, formatLogDetail, getStationName, getStationInfo, loadFareMap, fetchData, refreshData: fetchData,
|
||||||
fareMapScale, fareMapLoading, fareMapError, zoomFareMapIn, zoomFareMapOut, zoomFareMapReset,
|
fareMapScale, fareMapLoading, fareMapError, zoomFareMapIn, zoomFareMapOut, zoomFareMapReset,
|
||||||
|
|||||||
+29
-3
@@ -209,11 +209,29 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
stationXByCanonical = {};
|
stationXByCanonical = {};
|
||||||
|
stationYByCanonical = {};
|
||||||
|
const customXByCanonical = {};
|
||||||
|
const customYByCanonical = {};
|
||||||
|
|
||||||
|
for (const s of stationsData) {
|
||||||
|
const code = String(s.code || s.编号 || '').trim();
|
||||||
|
if (!code) continue;
|
||||||
|
const canonical = stationCanonicalByCode[code] || code;
|
||||||
|
if (s.svg_x != null && s.svg_y != null) {
|
||||||
|
customXByCanonical[canonical] = Number(s.svg_x);
|
||||||
|
customYByCanonical[canonical] = Number(s.svg_y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const canonical of Object.keys(occurrences)) {
|
for (const canonical of Object.keys(occurrences)) {
|
||||||
|
if (customXByCanonical[canonical] != null) {
|
||||||
|
stationXByCanonical[canonical] = customXByCanonical[canonical];
|
||||||
|
} else {
|
||||||
const arr = occurrences[canonical];
|
const arr = occurrences[canonical];
|
||||||
const avg = arr.reduce((a,b)=>a+b,0) / Math.max(1, arr.length);
|
const avg = arr.reduce((a,b)=>a+b,0) / Math.max(1, arr.length);
|
||||||
stationXByCanonical[canonical] = startX + Math.round(avg);
|
stationXByCanonical[canonical] = startX + Math.round(avg);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (let pass = 0; pass < 3; pass++) {
|
for (let pass = 0; pass < 3; pass++) {
|
||||||
for (const li of lineStops) {
|
for (const li of lineStops) {
|
||||||
@@ -221,6 +239,10 @@
|
|||||||
for (const code of li.stops) {
|
for (const code of li.stops) {
|
||||||
if (code === '-') continue;
|
if (code === '-') continue;
|
||||||
const canonical = stationCanonicalByCode[code] || code;
|
const canonical = stationCanonicalByCode[code] || code;
|
||||||
|
if (customXByCanonical[canonical] != null) {
|
||||||
|
prevX = customXByCanonical[canonical];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const x = stationXByCanonical[canonical] ?? (prevX + minGapX);
|
const x = stationXByCanonical[canonical] ?? (prevX + minGapX);
|
||||||
const nx = Math.max(x, prevX + minGapX);
|
const nx = Math.max(x, prevX + minGapX);
|
||||||
if (stationXByCanonical[canonical] == null || nx > stationXByCanonical[canonical]) {
|
if (stationXByCanonical[canonical] == null || nx > stationXByCanonical[canonical]) {
|
||||||
@@ -234,18 +256,21 @@
|
|||||||
// 3. Assign Grid Y to canonical stations (Average of their lines)
|
// 3. Assign Grid Y to canonical stations (Average of their lines)
|
||||||
const hubYByCanonical = {};
|
const hubYByCanonical = {};
|
||||||
for (const canonical of Object.keys(lineIdxsByCanonical)) {
|
for (const canonical of Object.keys(lineIdxsByCanonical)) {
|
||||||
|
if (customYByCanonical[canonical] != null) {
|
||||||
|
hubYByCanonical[canonical] = customYByCanonical[canonical];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const idxs = Array.from(lineIdxsByCanonical[canonical] || []).sort((a, b) => a - b);
|
const idxs = Array.from(lineIdxsByCanonical[canonical] || []).sort((a, b) => a - b);
|
||||||
if (!idxs.length) continue;
|
if (!idxs.length) continue;
|
||||||
const ys = idxs.map(idx => baseY + idx * lineGapY);
|
const ys = idxs.map(idx => baseY + idx * lineGapY);
|
||||||
hubYByCanonical[canonical] = ys.reduce((sum, y) => sum + y, 0) / ys.length;
|
hubYByCanonical[canonical] = ys.reduce((sum, y) => sum + y, 0) / ys.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
stationYByCanonical = {};
|
|
||||||
for (const li of lineStops) {
|
for (const li of lineStops) {
|
||||||
const yNode = baseY + li.idx * lineGapY;
|
const yNode = baseY + li.idx * lineGapY;
|
||||||
for (const c of li.stops) {
|
for (const c of li.stops) {
|
||||||
const canonical = stationCanonicalByCode[c] || c;
|
const canonical = stationCanonicalByCode[c] || c;
|
||||||
stationYByCanonical[c] = hubYByCanonical[canonical] ?? yNode;
|
stationYByCanonical[c] = customYByCanonical[canonical] ?? hubYByCanonical[canonical] ?? yNode;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,8 +421,9 @@
|
|||||||
const allCanonicals = Object.keys(stationXByCanonical);
|
const allCanonicals = Object.keys(stationXByCanonical);
|
||||||
const minX = Math.min(...allCanonicals.map(c => stationXByCanonical[c] || 0));
|
const minX = Math.min(...allCanonicals.map(c => stationXByCanonical[c] || 0));
|
||||||
const maxX = Math.max(...allCanonicals.map(c => stationXByCanonical[c] || 0));
|
const maxX = Math.max(...allCanonicals.map(c => stationXByCanonical[c] || 0));
|
||||||
|
const maxY = Math.max(...allCanonicals.map(c => stationYByCanonical[c] || 0));
|
||||||
const width = Math.max(560, maxX + 200);
|
const width = Math.max(560, maxX + 200);
|
||||||
const height = Math.max(260, baseY + currentLineIdx * lineGapY + 120);
|
const height = Math.max(260, maxY + 120, baseY + lineStops.length * lineGapY + 120);
|
||||||
mapContainer.innerHTML = `<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${svgContent}</svg>`;
|
mapContainer.innerHTML = `<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${svgContent}</svg>`;
|
||||||
|
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
|
|||||||
Reference in New Issue
Block a user