mirror of
https://github.com/deepmodeling/Uni-Lab-OS
synced 2026-04-01 18:43:05 +00:00
新增 labware_manager 模块: - Web UI 支持耗材 CRUD、SVG 俯视图/侧面图实时预览 - SVG 支持触控板双指缩放(pinch-to-zoom)和平移 - 网格排列自动居中按钮(autoCenter) - 表单参数标签中英文双语显示 - 从已有代码/YAML 导入、Python/YAML 代码生成 更新 CLAUDE.md:补充 labware manager、decorator 注册模式、CI 说明 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
359 lines
13 KiB
JavaScript
359 lines
13 KiB
JavaScript
/**
|
|
* labware_viz.js — PRCXI 耗材 SVG 2D 可视化渲染引擎
|
|
*
|
|
* renderTopDown(container, itemData) — 俯视图
|
|
* renderSideProfile(container, itemData) — 侧面截面图
|
|
*/
|
|
|
|
const TYPE_COLORS = {
|
|
plate: '#3b82f6',
|
|
tip_rack: '#10b981',
|
|
tube_rack: '#f59e0b',
|
|
trash: '#ef4444',
|
|
plate_adapter: '#8b5cf6',
|
|
};
|
|
|
|
function _svgNS() { return 'http://www.w3.org/2000/svg'; }
|
|
|
|
function _makeSVG(w, h) {
|
|
const svg = document.createElementNS(_svgNS(), 'svg');
|
|
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
|
|
svg.setAttribute('width', '100%');
|
|
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
|
svg.style.background = '#fff';
|
|
return svg;
|
|
}
|
|
|
|
function _rect(svg, x, y, w, h, fill, stroke, rx) {
|
|
const r = document.createElementNS(_svgNS(), 'rect');
|
|
r.setAttribute('x', x); r.setAttribute('y', y);
|
|
r.setAttribute('width', w); r.setAttribute('height', h);
|
|
r.setAttribute('fill', fill || 'none');
|
|
r.setAttribute('stroke', stroke || '#333');
|
|
r.setAttribute('stroke-width', '0.5');
|
|
if (rx) r.setAttribute('rx', rx);
|
|
svg.appendChild(r);
|
|
return r;
|
|
}
|
|
|
|
function _circle(svg, cx, cy, r, fill, stroke) {
|
|
const c = document.createElementNS(_svgNS(), 'circle');
|
|
c.setAttribute('cx', cx); c.setAttribute('cy', cy);
|
|
c.setAttribute('r', r);
|
|
c.setAttribute('fill', fill || 'none');
|
|
c.setAttribute('stroke', stroke || '#333');
|
|
c.setAttribute('stroke-width', '0.4');
|
|
svg.appendChild(c);
|
|
return c;
|
|
}
|
|
|
|
function _text(svg, x, y, txt, size, anchor, fill) {
|
|
const t = document.createElementNS(_svgNS(), 'text');
|
|
t.setAttribute('x', x); t.setAttribute('y', y);
|
|
t.setAttribute('font-size', size || '3');
|
|
t.setAttribute('text-anchor', anchor || 'middle');
|
|
t.setAttribute('fill', fill || '#666');
|
|
t.setAttribute('font-family', 'sans-serif');
|
|
t.textContent = txt;
|
|
svg.appendChild(t);
|
|
return t;
|
|
}
|
|
|
|
function _line(svg, x1, y1, x2, y2, stroke, dash) {
|
|
const l = document.createElementNS(_svgNS(), 'line');
|
|
l.setAttribute('x1', x1); l.setAttribute('y1', y1);
|
|
l.setAttribute('x2', x2); l.setAttribute('y2', y2);
|
|
l.setAttribute('stroke', stroke || '#999');
|
|
l.setAttribute('stroke-width', '0.3');
|
|
if (dash) l.setAttribute('stroke-dasharray', dash);
|
|
svg.appendChild(l);
|
|
return l;
|
|
}
|
|
|
|
function _title(el, txt) {
|
|
const t = document.createElementNS(_svgNS(), 'title');
|
|
t.textContent = txt;
|
|
el.appendChild(t);
|
|
}
|
|
|
|
// ==================== 俯视图 ====================
|
|
function renderTopDown(container, data) {
|
|
container.innerHTML = '';
|
|
if (!data) return;
|
|
|
|
const pad = 18;
|
|
const sx = data.size_x || 127;
|
|
const sy = data.size_y || 85;
|
|
const w = sx + pad * 2;
|
|
const h = sy + pad * 2;
|
|
const svg = _makeSVG(w, h);
|
|
|
|
const color = TYPE_COLORS[data.type] || '#3b82f6';
|
|
const lightColor = color + '22';
|
|
|
|
// 板子外轮廓
|
|
_rect(svg, pad, pad, sx, sy, lightColor, color, 3);
|
|
|
|
// 尺寸标注
|
|
_text(svg, pad + sx / 2, pad - 4, `${sx} mm`, '3.5', 'middle', '#333');
|
|
// Y 尺寸 (竖直)
|
|
const yt = document.createElementNS(_svgNS(), 'text');
|
|
yt.setAttribute('x', pad - 5);
|
|
yt.setAttribute('y', pad + sy / 2);
|
|
yt.setAttribute('font-size', '3.5');
|
|
yt.setAttribute('text-anchor', 'middle');
|
|
yt.setAttribute('fill', '#333');
|
|
yt.setAttribute('font-family', 'sans-serif');
|
|
yt.setAttribute('transform', `rotate(-90, ${pad - 5}, ${pad + sy / 2})`);
|
|
yt.textContent = `${sy} mm`;
|
|
svg.appendChild(yt);
|
|
|
|
const grid = data.grid;
|
|
const well = data.well;
|
|
const tip = data.tip;
|
|
const tube = data.tube;
|
|
|
|
if (grid && (well || tip || tube)) {
|
|
const nx = grid.num_items_x || 1;
|
|
const ny = grid.num_items_y || 1;
|
|
const dx = grid.dx || 0;
|
|
const dy = grid.dy || 0;
|
|
const idx = grid.item_dx || 9;
|
|
const idy = grid.item_dy || 9;
|
|
|
|
const child = well || tip || tube;
|
|
const csx = child.size_x || child.spot_size_x || 8;
|
|
const csy = child.size_y || child.spot_size_y || 8;
|
|
|
|
const isCircle = well ? (well.cross_section_type === 'CIRCLE') : (!!tip);
|
|
|
|
// 行列标签
|
|
for (let col = 0; col < nx; col++) {
|
|
const cx = pad + dx + csx / 2 + col * idx;
|
|
_text(svg, cx, pad + sy + 5, String(col + 1), '2.5', 'middle', '#999');
|
|
}
|
|
const rowLabels = 'ABCDEFGHIJKLMNOP';
|
|
for (let row = 0; row < ny; row++) {
|
|
const cy = pad + dy + csy / 2 + row * idy;
|
|
_text(svg, pad - 4, cy + 1, rowLabels[row] || String(row), '2.5', 'middle', '#999');
|
|
}
|
|
|
|
// 绘制孔位
|
|
for (let col = 0; col < nx; col++) {
|
|
for (let row = 0; row < ny; row++) {
|
|
const cx = pad + dx + csx / 2 + col * idx;
|
|
const cy = pad + dy + csy / 2 + row * idy;
|
|
|
|
let el;
|
|
if (isCircle) {
|
|
const r = Math.min(csx, csy) / 2;
|
|
el = _circle(svg, cx, cy, r, '#fff', color);
|
|
} else {
|
|
el = _rect(svg, cx - csx / 2, cy - csy / 2, csx, csy, '#fff', color);
|
|
}
|
|
|
|
const label = (rowLabels[row] || '') + String(col + 1);
|
|
_title(el, `${label}: ${csx}x${csy} mm`);
|
|
|
|
// hover 效果
|
|
el.style.cursor = 'pointer';
|
|
el.addEventListener('mouseenter', () => el.setAttribute('fill', color + '44'));
|
|
el.addEventListener('mouseleave', () => el.setAttribute('fill', '#fff'));
|
|
}
|
|
}
|
|
} else if (data.type === 'plate_adapter' && data.adapter) {
|
|
// 绘制适配器凹槽
|
|
const adp = data.adapter;
|
|
const ahx = adp.adapter_hole_size_x || 127;
|
|
const ahy = adp.adapter_hole_size_y || 85;
|
|
const adx = adp.dx != null ? adp.dx : (sx - ahx) / 2;
|
|
const ady = adp.dy != null ? adp.dy : (sy - ahy) / 2;
|
|
_rect(svg, pad + adx, pad + ady, ahx, ahy, '#f0f0ff', '#8b5cf6', 2);
|
|
_text(svg, pad + adx + ahx / 2, pad + ady + ahy / 2, `${ahx}x${ahy}`, '4', 'middle', '#8b5cf6');
|
|
} else if (data.type === 'trash') {
|
|
// 简单标记
|
|
_text(svg, pad + sx / 2, pad + sy / 2, 'TRASH', '8', 'middle', '#ef4444');
|
|
}
|
|
|
|
container.appendChild(svg);
|
|
_enableZoomPan(svg, `0 0 ${w} ${h}`);
|
|
}
|
|
|
|
// ==================== 侧面截面图 ====================
|
|
function renderSideProfile(container, data) {
|
|
container.innerHTML = '';
|
|
if (!data) return;
|
|
|
|
const pad = 20;
|
|
const sx = data.size_x || 127;
|
|
const sz = data.size_z || 20;
|
|
|
|
// 按比例缩放,侧面以 X-Z 面
|
|
const scaleH = Math.max(1, sz / 60); // 让较矮的板子不会太小
|
|
const drawW = sx;
|
|
const drawH = sz;
|
|
const w = drawW + pad * 2 + 30; // 额外空间给标注
|
|
const h = drawH + pad * 2 + 10;
|
|
const svg = _makeSVG(w, h);
|
|
|
|
const color = TYPE_COLORS[data.type] || '#3b82f6';
|
|
const baseY = pad + drawH; // 底部 Y
|
|
|
|
// 板壳矩形
|
|
_rect(svg, pad, pad, drawW, drawH, color + '15', color);
|
|
|
|
// 尺寸标注
|
|
// X 方向
|
|
_line(svg, pad, baseY + 5, pad + drawW, baseY + 5, '#333');
|
|
_text(svg, pad + drawW / 2, baseY + 12, `${sx} mm`, '3.5', 'middle', '#333');
|
|
|
|
// Z 方向
|
|
_line(svg, pad + drawW + 5, pad, pad + drawW + 5, baseY, '#333');
|
|
const zt = document.createElementNS(_svgNS(), 'text');
|
|
zt.setAttribute('x', pad + drawW + 12);
|
|
zt.setAttribute('y', pad + drawH / 2);
|
|
zt.setAttribute('font-size', '3.5');
|
|
zt.setAttribute('text-anchor', 'middle');
|
|
zt.setAttribute('fill', '#333');
|
|
zt.setAttribute('font-family', 'sans-serif');
|
|
zt.setAttribute('transform', `rotate(-90, ${pad + drawW + 12}, ${pad + drawH / 2})`);
|
|
zt.textContent = `${sz} mm`;
|
|
svg.appendChild(zt);
|
|
|
|
const grid = data.grid;
|
|
const well = data.well;
|
|
const tip = data.tip;
|
|
const tube = data.tube;
|
|
|
|
if (grid && (well || tip || tube)) {
|
|
const dx = grid.dx || 0;
|
|
const dz = grid.dz || 0;
|
|
const idx = grid.item_dx || 9;
|
|
const nx = Math.min(grid.num_items_x || 1, 24); // 最多画24列
|
|
|
|
const child = well || tube;
|
|
const childTip = tip;
|
|
|
|
if (child) {
|
|
const csx = child.size_x || 8;
|
|
const csz = child.size_z || 10;
|
|
const bt = well ? (well.bottom_type || 'FLAT') : 'FLAT';
|
|
|
|
// 画几个代表性的孔截面
|
|
const nDraw = Math.min(nx, 12);
|
|
for (let i = 0; i < nDraw; i++) {
|
|
const cx = pad + dx + csx / 2 + i * idx;
|
|
const topZ = baseY - dz - csz;
|
|
const botZ = baseY - dz;
|
|
|
|
// 孔壁
|
|
_rect(svg, cx - csx / 2, topZ, csx, csz, '#e0e8ff', color, 0.5);
|
|
|
|
// 底部形状
|
|
if (bt === 'V') {
|
|
// V 底 三角
|
|
const triH = Math.min(csx / 2, csz * 0.3);
|
|
const p = document.createElementNS(_svgNS(), 'polygon');
|
|
p.setAttribute('points',
|
|
`${cx - csx / 2},${botZ - triH} ${cx},${botZ} ${cx + csx / 2},${botZ - triH}`);
|
|
p.setAttribute('fill', color + '33');
|
|
p.setAttribute('stroke', color);
|
|
p.setAttribute('stroke-width', '0.3');
|
|
svg.appendChild(p);
|
|
} else if (bt === 'U') {
|
|
// U 底 圆弧
|
|
const arcR = csx / 2;
|
|
const p = document.createElementNS(_svgNS(), 'path');
|
|
p.setAttribute('d', `M ${cx - csx / 2} ${botZ - arcR} A ${arcR} ${arcR} 0 0 0 ${cx + csx / 2} ${botZ - arcR}`);
|
|
p.setAttribute('fill', color + '33');
|
|
p.setAttribute('stroke', color);
|
|
p.setAttribute('stroke-width', '0.3');
|
|
svg.appendChild(p);
|
|
}
|
|
}
|
|
|
|
// dz 标注
|
|
if (dz > 0) {
|
|
const lx = pad + dx + 0.5 * idx * nDraw + csx / 2 + 5;
|
|
_line(svg, lx, baseY, lx, baseY - dz, '#999', '1,1');
|
|
_text(svg, lx + 6, baseY - dz / 2, `dz=${dz}`, '2.5', 'start', '#999');
|
|
}
|
|
}
|
|
|
|
if (childTip) {
|
|
// 枪头截面
|
|
const nDraw = Math.min(nx, 12);
|
|
for (let i = 0; i < nDraw; i++) {
|
|
const cx = pad + dx + 3.5 + i * idx;
|
|
const topZ = pad + dz;
|
|
const tipLen = childTip.tip_length || 50;
|
|
const drawLen = Math.min(tipLen, sz - dz);
|
|
|
|
// 枪头轮廓 (梯形)
|
|
const topW = 4;
|
|
const botW = 1.5;
|
|
const p = document.createElementNS(_svgNS(), 'polygon');
|
|
p.setAttribute('points',
|
|
`${cx - topW / 2},${topZ} ${cx + topW / 2},${topZ} ${cx + botW / 2},${topZ + drawLen} ${cx - botW / 2},${topZ + drawLen}`);
|
|
p.setAttribute('fill', '#10b98133');
|
|
p.setAttribute('stroke', '#10b981');
|
|
p.setAttribute('stroke-width', '0.3');
|
|
svg.appendChild(p);
|
|
}
|
|
}
|
|
} else if (data.type === 'plate_adapter' && data.adapter) {
|
|
const adp = data.adapter;
|
|
const ahz = adp.adapter_hole_size_z || 10;
|
|
const adz = adp.dz || 0;
|
|
const adx_val = adp.dx != null ? adp.dx : (sx - (adp.adapter_hole_size_x || 127)) / 2;
|
|
const ahx = adp.adapter_hole_size_x || 127;
|
|
|
|
// 凹槽截面
|
|
_rect(svg, pad + adx_val, pad + adz, ahx, ahz, '#ede9fe', '#8b5cf6');
|
|
_text(svg, pad + adx_val + ahx / 2, pad + adz + ahz / 2 + 1, `hole: ${ahz}mm deep`, '3', 'middle', '#8b5cf6');
|
|
} else if (data.type === 'trash') {
|
|
_text(svg, pad + drawW / 2, pad + drawH / 2, 'TRASH', '8', 'middle', '#ef4444');
|
|
}
|
|
|
|
container.appendChild(svg);
|
|
_enableZoomPan(svg, `0 0 ${w} ${h}`);
|
|
}
|
|
|
|
// ==================== 缩放 & 平移 ====================
|
|
function _enableZoomPan(svgEl, origViewBox) {
|
|
const parts = origViewBox.split(' ').map(Number);
|
|
let vx = parts[0], vy = parts[1], vw = parts[2], vh = parts[3];
|
|
const origW = vw, origH = vh;
|
|
const MIN_SCALE = 0.5, MAX_SCALE = 5;
|
|
|
|
function applyViewBox() {
|
|
svgEl.setAttribute('viewBox', `${vx} ${vy} ${vw} ${vh}`);
|
|
}
|
|
|
|
svgEl.addEventListener('wheel', function (e) {
|
|
e.preventDefault();
|
|
if (e.ctrlKey) {
|
|
// pinch / ctrl+scroll → 缩放
|
|
const factor = e.deltaY > 0 ? 1.08 : 1 / 1.08;
|
|
const newW = vw * factor;
|
|
const newH = vh * factor;
|
|
// 限制缩放范围
|
|
if (newW < origW / MAX_SCALE || newW > origW * (1 / MIN_SCALE)) return;
|
|
// 以鼠标位置为缩放中心
|
|
const rect = svgEl.getBoundingClientRect();
|
|
const mx = (e.clientX - rect.left) / rect.width;
|
|
const my = (e.clientY - rect.top) / rect.height;
|
|
vx += (vw - newW) * mx;
|
|
vy += (vh - newH) * my;
|
|
vw = newW;
|
|
vh = newH;
|
|
} else {
|
|
// 普通滚轮 → 平移
|
|
const panSpeed = vw * 0.002;
|
|
vx += e.deltaX * panSpeed;
|
|
vy += e.deltaY * panSpeed;
|
|
}
|
|
applyViewBox();
|
|
}, { passive: false });
|
|
}
|