|
@@ -118,230 +118,171 @@ function selectSample(sample) {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
class SampleCanvas {
|
|
class SampleCanvas {
|
|
|
- constructor(canvasId, wrapperId) {
|
|
|
|
|
- this.canvas = document.getElementById(canvasId);
|
|
|
|
|
|
|
+ constructor(containerId, wrapperId) {
|
|
|
|
|
+ this.container = document.getElementById(containerId);
|
|
|
this.wrapper = document.getElementById(wrapperId);
|
|
this.wrapper = document.getElementById(wrapperId);
|
|
|
- this.ctx = this.canvas.getContext('2d');
|
|
|
|
|
this.samples = [];
|
|
this.samples = [];
|
|
|
this.pans = [];
|
|
this.pans = [];
|
|
|
this.tilts = [];
|
|
this.tilts = [];
|
|
|
this.sampleMap = new Map();
|
|
this.sampleMap = new Map();
|
|
|
- this.images = new Map();
|
|
|
|
|
this.cellW = 160;
|
|
this.cellW = 160;
|
|
|
this.cellH = 120;
|
|
this.cellH = 120;
|
|
|
this.captionH = 20;
|
|
this.captionH = 20;
|
|
|
this.scale = 1;
|
|
this.scale = 1;
|
|
|
- this.offsetX = 0;
|
|
|
|
|
- this.offsetY = 0;
|
|
|
|
|
this.selectedPan = null;
|
|
this.selectedPan = null;
|
|
|
this.selectedTilt = null;
|
|
this.selectedTilt = null;
|
|
|
- this.isDragging = false;
|
|
|
|
|
- this.dragStart = { x: 0, y: 0, ox: 0, oy: 0 };
|
|
|
|
|
- this.pendingDraw = false;
|
|
|
|
|
|
|
+ this.selectedNodeId = null;
|
|
|
|
|
+ this.graph = null;
|
|
|
|
|
|
|
|
- this.resize();
|
|
|
|
|
|
|
+ this.initGraph();
|
|
|
window.addEventListener('resize', () => this.resize());
|
|
window.addEventListener('resize', () => this.resize());
|
|
|
- this.setupEvents();
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- resize() {
|
|
|
|
|
- const rect = this.wrapper.getBoundingClientRect();
|
|
|
|
|
- this.canvas.width = rect.width;
|
|
|
|
|
- this.canvas.height = rect.height;
|
|
|
|
|
- this.draw();
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ initGraph() {
|
|
|
|
|
+ const G6 = window.G6;
|
|
|
|
|
+ if (!G6) {
|
|
|
|
|
+ log('G6 库未加载,扫描矩阵不可用');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- setSamples(samples) {
|
|
|
|
|
- this.samples = samples || [];
|
|
|
|
|
- this.pans = Array.from(new Set(this.samples.map(s => s.pan))).sort((a, b) => a - b);
|
|
|
|
|
- this.tilts = Array.from(new Set(this.samples.map(s => s.tilt))).sort((a, b) => b - a);
|
|
|
|
|
- this.sampleMap = new Map();
|
|
|
|
|
- this.samples.forEach(s => this.sampleMap.set(`${s.pan},${s.tilt}`, s));
|
|
|
|
|
- this.images = new Map();
|
|
|
|
|
- this.samples.forEach(s => {
|
|
|
|
|
- const img = new Image();
|
|
|
|
|
- img.crossOrigin = 'anonymous';
|
|
|
|
|
- img.src = `/api/sample-image?path=${encodeURIComponent(s.thumbnail)}`;
|
|
|
|
|
- img.onload = () => this.draw();
|
|
|
|
|
- this.images.set(`${s.pan},${s.tilt}`, img);
|
|
|
|
|
|
|
+ const rect = this.wrapper.getBoundingClientRect();
|
|
|
|
|
+ const width = rect.width || this.wrapper.clientWidth || 800;
|
|
|
|
|
+ const height = rect.height || this.wrapper.clientHeight || 600;
|
|
|
|
|
+ this.graph = new G6.Graph({
|
|
|
|
|
+ container: this.container,
|
|
|
|
|
+ width,
|
|
|
|
|
+ height,
|
|
|
|
|
+ animation: false,
|
|
|
|
|
+ zoomRange: [0.1, 5],
|
|
|
|
|
+ data: { nodes: [], edges: [] },
|
|
|
|
|
+ node: {
|
|
|
|
|
+ type: 'image',
|
|
|
|
|
+ style: {
|
|
|
|
|
+ size: [this.cellW, this.cellH],
|
|
|
|
|
+ cursor: 'pointer',
|
|
|
|
|
+ labelFill: '#cbd5e1',
|
|
|
|
|
+ labelFontSize: 11,
|
|
|
|
|
+ labelPlacement: 'bottom',
|
|
|
|
|
+ labelOffsetY: 4,
|
|
|
|
|
+ radius: 4,
|
|
|
|
|
+ },
|
|
|
|
|
+ state: {
|
|
|
|
|
+ selected: {
|
|
|
|
|
+ halo: true,
|
|
|
|
|
+ haloStroke: '#4ade80',
|
|
|
|
|
+ haloLineWidth: 6,
|
|
|
|
|
+ haloOpacity: 1,
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ layout: {
|
|
|
|
|
+ type: 'grid',
|
|
|
|
|
+ cols: 1,
|
|
|
|
|
+ nodeSize: [this.cellW, this.cellH + this.captionH],
|
|
|
|
|
+ preventOverlap: true,
|
|
|
|
|
+ },
|
|
|
|
|
+ behaviors: ['drag-canvas', 'zoom-canvas'],
|
|
|
});
|
|
});
|
|
|
- this.fitToView();
|
|
|
|
|
- this.draw();
|
|
|
|
|
- }
|
|
|
|
|
|
|
|
|
|
- contentWidth() {
|
|
|
|
|
- return this.pans.length * this.cellW;
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ this.graph.on('node:click', (e) => {
|
|
|
|
|
+ const target = e.item || e.target;
|
|
|
|
|
+ const id = target?.id;
|
|
|
|
|
+ if (!id) return;
|
|
|
|
|
+ const nodeData = this.graph.getNodeData(id);
|
|
|
|
|
+ if (nodeData?.data?.sample) {
|
|
|
|
|
+ selectSample(nodeData.data.sample);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
|
|
|
- contentHeight() {
|
|
|
|
|
- return this.tilts.length * (this.cellH + this.captionH);
|
|
|
|
|
|
|
+ this.graph.on('aftertransform', () => {
|
|
|
|
|
+ if (!this.graph) return;
|
|
|
|
|
+ this.scale = this.graph.getZoom();
|
|
|
|
|
+ this.updateZoomLabel();
|
|
|
|
|
+ });
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- fitToView() {
|
|
|
|
|
- const pw = this.canvas.width / this.contentWidth();
|
|
|
|
|
- const ph = this.canvas.height / this.contentHeight();
|
|
|
|
|
- this.scale = Math.min(pw, ph, 1);
|
|
|
|
|
- this.offsetX = (this.canvas.width - this.contentWidth() * this.scale) / 2;
|
|
|
|
|
- this.offsetY = (this.canvas.height - this.contentHeight() * this.scale) / 2;
|
|
|
|
|
|
|
+ async resize() {
|
|
|
|
|
+ if (!this.graph) return;
|
|
|
|
|
+ const rect = this.wrapper.getBoundingClientRect();
|
|
|
|
|
+ this.graph.setSize(rect.width, rect.height);
|
|
|
|
|
+ await this.graph.fitView();
|
|
|
|
|
+ this.scale = this.graph.getZoom();
|
|
|
this.updateZoomLabel();
|
|
this.updateZoomLabel();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- resetView() {
|
|
|
|
|
- this.fitToView();
|
|
|
|
|
- this.draw();
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- updateZoomLabel() {
|
|
|
|
|
- const label = document.getElementById('zoom-level');
|
|
|
|
|
- if (label) label.textContent = `${Math.round(this.scale * 100)}%`;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- setupEvents() {
|
|
|
|
|
- this.canvas.addEventListener('wheel', (e) => {
|
|
|
|
|
- e.preventDefault();
|
|
|
|
|
- const rect = this.canvas.getBoundingClientRect();
|
|
|
|
|
- const mx = e.clientX - rect.left;
|
|
|
|
|
- const my = e.clientY - rect.top;
|
|
|
|
|
- const factor = e.deltaY < 0 ? 1.1 : 0.9;
|
|
|
|
|
- const newScale = Math.max(0.1, Math.min(5.0, this.scale * factor));
|
|
|
|
|
- this.offsetX = mx - (mx - this.offsetX) * (newScale / this.scale);
|
|
|
|
|
- this.offsetY = my - (my - this.offsetY) * (newScale / this.scale);
|
|
|
|
|
- this.scale = newScale;
|
|
|
|
|
- this.updateZoomLabel();
|
|
|
|
|
- this.draw();
|
|
|
|
|
- }, { passive: false });
|
|
|
|
|
-
|
|
|
|
|
- this.canvas.addEventListener('mousedown', (e) => {
|
|
|
|
|
- if (e.button !== 0) return;
|
|
|
|
|
- this.isDragging = false;
|
|
|
|
|
- this.dragMoved = false;
|
|
|
|
|
- this.dragStart = { x: e.clientX, y: e.clientY, ox: this.offsetX, oy: this.offsetY };
|
|
|
|
|
- this.wrapper.style.cursor = 'grabbing';
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- window.addEventListener('mousemove', (e) => {
|
|
|
|
|
- if (this.dragStart == null) return;
|
|
|
|
|
- const dx = e.clientX - this.dragStart.x;
|
|
|
|
|
- const dy = e.clientY - this.dragStart.y;
|
|
|
|
|
- if (!this.isDragging && (Math.abs(dx) > 3 || Math.abs(dy) > 3)) {
|
|
|
|
|
- this.isDragging = true;
|
|
|
|
|
- this.dragMoved = true;
|
|
|
|
|
- }
|
|
|
|
|
- if (this.isDragging) {
|
|
|
|
|
- this.offsetX = this.dragStart.ox + dx;
|
|
|
|
|
- this.offsetY = this.dragStart.oy + dy;
|
|
|
|
|
- this.draw();
|
|
|
|
|
- }
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ async setSamples(samples) {
|
|
|
|
|
+ this.samples = samples || [];
|
|
|
|
|
+ this.pans = Array.from(new Set(this.samples.map(s => s.pan))).sort((a, b) => a - b);
|
|
|
|
|
+ this.tilts = Array.from(new Set(this.samples.map(s => s.tilt))).sort((a, b) => b - a);
|
|
|
|
|
+ this.sampleMap = new Map();
|
|
|
|
|
+ this.samples.forEach(s => this.sampleMap.set(`${s.pan},${s.tilt}`, s));
|
|
|
|
|
+ if (!this.graph) return;
|
|
|
|
|
|
|
|
- window.addEventListener('mouseup', () => {
|
|
|
|
|
- this.dragStart = null;
|
|
|
|
|
- if (this.isDragging) {
|
|
|
|
|
- this.isDragging = false;
|
|
|
|
|
- this.wrapper.style.cursor = 'grab';
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ const nodes = [];
|
|
|
|
|
+ this.tilts.forEach(tilt => {
|
|
|
|
|
+ this.pans.forEach(pan => {
|
|
|
|
|
+ const s = this.sampleMap.get(`${pan},${tilt}`);
|
|
|
|
|
+ if (!s) return;
|
|
|
|
|
+ const id = `p${pan.toFixed(1)}_t${tilt.toFixed(1)}`;
|
|
|
|
|
+ nodes.push({
|
|
|
|
|
+ id,
|
|
|
|
|
+ data: { sample: s },
|
|
|
|
|
+ style: {
|
|
|
|
|
+ src: `/api/sample-image?path=${encodeURIComponent(s.thumbnail)}`,
|
|
|
|
|
+ labelText: `P:${pan.toFixed(0)} T:${tilt.toFixed(0)}`,
|
|
|
|
|
+ },
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- this.canvas.addEventListener('click', (e) => {
|
|
|
|
|
- if (this.isDragging || this.dragMoved) return;
|
|
|
|
|
- const rect = this.canvas.getBoundingClientRect();
|
|
|
|
|
- const mx = e.clientX - rect.left;
|
|
|
|
|
- const my = e.clientY - rect.top;
|
|
|
|
|
- const worldX = (mx - this.offsetX) / this.scale;
|
|
|
|
|
- const worldY = (my - this.offsetY) / this.scale;
|
|
|
|
|
- const rowH = this.cellH + this.captionH;
|
|
|
|
|
- // 使用最近单元格,避免边界处 floor 导致点不到
|
|
|
|
|
- const col = Math.round((worldX - this.cellW / 2) / this.cellW);
|
|
|
|
|
- const row = Math.round((worldY - rowH / 2) / rowH);
|
|
|
|
|
- if (col < 0 || col >= this.pans.length || row < 0 || row >= this.tilts.length) return;
|
|
|
|
|
- const pan = this.pans[col];
|
|
|
|
|
- const tilt = this.tilts[row];
|
|
|
|
|
- const s = this.sampleMap.get(`${pan},${tilt}`);
|
|
|
|
|
- if (s) {
|
|
|
|
|
- selectSample(s);
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ this.graph.setLayout({
|
|
|
|
|
+ type: 'grid',
|
|
|
|
|
+ cols: this.pans.length || 1,
|
|
|
|
|
+ nodeSize: [this.cellW, this.cellH + this.captionH],
|
|
|
|
|
+ preventOverlap: true,
|
|
|
});
|
|
});
|
|
|
|
|
+ this.graph.setData({ nodes, edges: [] });
|
|
|
|
|
+ await this.graph.render();
|
|
|
|
|
+ if (nodes.length > 0) await this.graph.fitView();
|
|
|
|
|
+ this.selectedNodeId = null;
|
|
|
|
|
+ this.selectedPan = null;
|
|
|
|
|
+ this.selectedTilt = null;
|
|
|
|
|
+ this.scale = this.graph.getZoom();
|
|
|
|
|
+ this.updateZoomLabel();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
setSelected(pan, tilt) {
|
|
setSelected(pan, tilt) {
|
|
|
|
|
+ if (!this.graph) return;
|
|
|
|
|
+ const prevId = this.selectedNodeId;
|
|
|
this.selectedPan = pan;
|
|
this.selectedPan = pan;
|
|
|
this.selectedTilt = tilt;
|
|
this.selectedTilt = tilt;
|
|
|
- this.draw();
|
|
|
|
|
|
|
+ this.selectedNodeId = pan !== null && tilt !== null
|
|
|
|
|
+ ? `p${pan.toFixed(1)}_t${tilt.toFixed(1)}`
|
|
|
|
|
+ : null;
|
|
|
|
|
+
|
|
|
|
|
+ const states = {};
|
|
|
|
|
+ if (prevId && prevId !== this.selectedNodeId) states[prevId] = [];
|
|
|
|
|
+ if (this.selectedNodeId) states[this.selectedNodeId] = ['selected'];
|
|
|
|
|
+ if (Object.keys(states).length > 0) {
|
|
|
|
|
+ this.graph.setElementState(states, false);
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- draw() {
|
|
|
|
|
- if (this.pendingDraw) return;
|
|
|
|
|
- this.pendingDraw = true;
|
|
|
|
|
- requestAnimationFrame(() => {
|
|
|
|
|
- this.pendingDraw = false;
|
|
|
|
|
- this._draw();
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ async resetView() {
|
|
|
|
|
+ if (!this.graph) return;
|
|
|
|
|
+ await this.graph.fitView();
|
|
|
|
|
+ this.scale = this.graph.getZoom();
|
|
|
|
|
+ this.updateZoomLabel();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- _draw() {
|
|
|
|
|
- const ctx = this.ctx;
|
|
|
|
|
- const w = this.canvas.width;
|
|
|
|
|
- const h = this.canvas.height;
|
|
|
|
|
- ctx.clearRect(0, 0, w, h);
|
|
|
|
|
-
|
|
|
|
|
- if (this.samples.length === 0) {
|
|
|
|
|
- ctx.fillStyle = '#94a3b8';
|
|
|
|
|
- ctx.font = '14px sans-serif';
|
|
|
|
|
- ctx.fillText('暂无扫描样本,请先执行 360° 扫描', 20, 30);
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const rowH = this.cellH + this.captionH;
|
|
|
|
|
- const startCol = Math.floor((-this.offsetX / this.scale) / this.cellW);
|
|
|
|
|
- const endCol = Math.ceil((w - this.offsetX) / this.scale / this.cellW);
|
|
|
|
|
- const startRow = Math.floor((-this.offsetY / this.scale) / rowH);
|
|
|
|
|
- const endRow = Math.ceil((h - this.offsetY) / this.scale / rowH);
|
|
|
|
|
-
|
|
|
|
|
- ctx.save();
|
|
|
|
|
- ctx.translate(this.offsetX, this.offsetY);
|
|
|
|
|
- ctx.scale(this.scale, this.scale);
|
|
|
|
|
-
|
|
|
|
|
- for (let r = Math.max(0, startRow); r <= Math.min(this.tilts.length - 1, endRow); r++) {
|
|
|
|
|
- for (let c = Math.max(0, startCol); c <= Math.min(this.pans.length - 1, endCol); c++) {
|
|
|
|
|
- const pan = this.pans[c];
|
|
|
|
|
- const tilt = this.tilts[r];
|
|
|
|
|
- const x = c * this.cellW;
|
|
|
|
|
- const y = r * rowH;
|
|
|
|
|
- const s = this.sampleMap.get(`${pan},${tilt}`);
|
|
|
|
|
-
|
|
|
|
|
- ctx.fillStyle = '#0f172a';
|
|
|
|
|
- ctx.fillRect(x, y, this.cellW, rowH);
|
|
|
|
|
-
|
|
|
|
|
- const img = this.images.get(`${pan},${tilt}`);
|
|
|
|
|
- if (img && img.complete && img.naturalWidth) {
|
|
|
|
|
- const sx = 0, sy = 0, sw = img.naturalWidth, sh = img.naturalHeight;
|
|
|
|
|
- const dw = this.cellW - 4;
|
|
|
|
|
- const dh = this.cellH - 4;
|
|
|
|
|
- const scale = Math.min(dw / sw, dh / sh);
|
|
|
|
|
- const iw = sw * scale;
|
|
|
|
|
- const ih = sh * scale;
|
|
|
|
|
- const ix = x + 2 + (dw - iw) / 2;
|
|
|
|
|
- const iy = y + 2 + (dh - ih) / 2;
|
|
|
|
|
- ctx.drawImage(img, ix, iy, iw, ih);
|
|
|
|
|
- } else {
|
|
|
|
|
- ctx.fillStyle = '#1e293b';
|
|
|
|
|
- ctx.fillRect(x + 2, y + 2, this.cellW - 4, this.cellH - 4);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- ctx.fillStyle = '#cbd5e1';
|
|
|
|
|
- ctx.font = '11px sans-serif';
|
|
|
|
|
- ctx.textAlign = 'center';
|
|
|
|
|
- ctx.fillText(`P:${pan.toFixed(0)} T:${tilt.toFixed(0)}`, x + this.cellW / 2, y + this.cellH + 14);
|
|
|
|
|
-
|
|
|
|
|
- if (this.selectedPan === pan && this.selectedTilt === tilt) {
|
|
|
|
|
- ctx.strokeStyle = '#22c55e';
|
|
|
|
|
- ctx.lineWidth = 2;
|
|
|
|
|
- ctx.strokeRect(x + 1, y + 1, this.cellW - 2, rowH - 2);
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ updateZoomLabel() {
|
|
|
|
|
+ const label = document.getElementById('zoom-level');
|
|
|
|
|
+ if (label) label.textContent = `${Math.round(this.scale * 100)}%`;
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- ctx.restore();
|
|
|
|
|
|
|
+ draw() {
|
|
|
|
|
+ if (!this.graph) return;
|
|
|
|
|
+ this.graph.zoomTo(this.scale);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -353,7 +294,7 @@ async function loadSamples(groupId) {
|
|
|
if (!sampleCanvas) {
|
|
if (!sampleCanvas) {
|
|
|
sampleCanvas = new SampleCanvas('sample-canvas', 'sample-grid-wrapper');
|
|
sampleCanvas = new SampleCanvas('sample-canvas', 'sample-grid-wrapper');
|
|
|
}
|
|
}
|
|
|
- sampleCanvas.setSamples(data.samples || []);
|
|
|
|
|
|
|
+ await sampleCanvas.setSamples(data.samples || []);
|
|
|
} catch (e) {
|
|
} catch (e) {
|
|
|
log(`加载扫描样本失败: ${e.message}`);
|
|
log(`加载扫描样本失败: ${e.message}`);
|
|
|
}
|
|
}
|