- Blueprint with API endpoints: list, detail, SVG serve, vehicle-linked diagrams - Interactive SVG viewer with zoom/pan (mouse wheel, drag, touch, keyboard) - Clickable hotspots that highlight on hover and show part detail panel - Parts sidebar listing all callout numbers with catalog search integration - 3 placeholder SVG diagrams: braking system, suspension, engine components - Seeded diagrams, hotspots, and vehicle_diagrams in DB - Added to sidebar nav, i18n (ES/EN), and "Ver diagramas" link in catalog Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
514 lines
20 KiB
JavaScript
514 lines
20 KiB
JavaScript
// /home/Autopartes/pos/static/js/diagrams.js
|
|
// Interactive exploded-view diagrams with zoom/pan and clickable hotspots.
|
|
|
|
(function () {
|
|
'use strict';
|
|
|
|
var API = '/pos/api/diagrams';
|
|
var CATALOG_API = '/pos/api/catalog';
|
|
var token = localStorage.getItem('pos_token');
|
|
if (!token) { window.location.href = '/pos/login'; return; }
|
|
|
|
var headers = { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
|
|
|
|
// ---- State ----
|
|
var state = {
|
|
view: 'list', // 'list' | 'viewer'
|
|
diagrams: [],
|
|
categories: [], // grouped by category
|
|
currentDiagram: null,
|
|
hotspots: [],
|
|
selectedHotspot: null,
|
|
// zoom/pan
|
|
scale: 1,
|
|
panX: 0,
|
|
panY: 0,
|
|
isPanning: false,
|
|
lastPointer: null,
|
|
};
|
|
|
|
// ---- DOM refs ----
|
|
var diagramList = document.getElementById('diagramList');
|
|
var diagramViewer = document.getElementById('diagramViewer');
|
|
var svgContainer = document.getElementById('svgContainer');
|
|
var svgWrapper = document.getElementById('svgWrapper');
|
|
var backBtn = document.getElementById('backBtn');
|
|
var zoomInBtn = document.getElementById('zoomInBtn');
|
|
var zoomOutBtn = document.getElementById('zoomOutBtn');
|
|
var zoomResetBtn = document.getElementById('zoomResetBtn');
|
|
var diagramTitle = document.getElementById('diagramTitle');
|
|
var hotspotPanel = document.getElementById('hotspotPanel');
|
|
var hotspotBody = document.getElementById('hotspotBody');
|
|
var hotspotClose = document.getElementById('hotspotClose');
|
|
var partsListEl = document.getElementById('partsList');
|
|
var loadingEl = document.getElementById('loading');
|
|
var emptyEl = document.getElementById('emptyState');
|
|
var filterInput = document.getElementById('diagramFilter');
|
|
var profileAvatar = document.getElementById('profileAvatar');
|
|
var profileName = document.getElementById('profileName');
|
|
var profileRole = document.getElementById('profileRole');
|
|
|
|
// ---- Init ----
|
|
function init() {
|
|
loadProfile();
|
|
loadAllDiagrams();
|
|
bindEvents();
|
|
}
|
|
|
|
function loadProfile() {
|
|
try {
|
|
var u = JSON.parse(localStorage.getItem('pos_user') || '{}');
|
|
if (profileName) profileName.textContent = u.name || '--';
|
|
if (profileRole) profileRole.textContent = u.role || '--';
|
|
if (profileAvatar) {
|
|
var n = (u.name || '--');
|
|
profileAvatar.textContent = n.split(' ').map(function(w){return w[0]||'';}).join('').substring(0,2).toUpperCase();
|
|
}
|
|
} catch(e) {}
|
|
}
|
|
|
|
function bindEvents() {
|
|
if (backBtn) backBtn.addEventListener('click', showList);
|
|
if (zoomInBtn) zoomInBtn.addEventListener('click', function() { zoom(1.25); });
|
|
if (zoomOutBtn) zoomOutBtn.addEventListener('click', function() { zoom(0.8); });
|
|
if (zoomResetBtn) zoomResetBtn.addEventListener('click', resetZoom);
|
|
if (hotspotClose) hotspotClose.addEventListener('click', closeHotspotPanel);
|
|
if (filterInput) filterInput.addEventListener('input', renderList);
|
|
|
|
// Zoom with mouse wheel on SVG container
|
|
if (svgContainer) {
|
|
svgContainer.addEventListener('wheel', function(e) {
|
|
e.preventDefault();
|
|
var factor = e.deltaY < 0 ? 1.1 : 0.9;
|
|
zoom(factor);
|
|
}, { passive: false });
|
|
|
|
// Pan with mouse drag
|
|
svgContainer.addEventListener('mousedown', startPan);
|
|
svgContainer.addEventListener('mousemove', doPan);
|
|
svgContainer.addEventListener('mouseup', endPan);
|
|
svgContainer.addEventListener('mouseleave', endPan);
|
|
|
|
// Touch support
|
|
svgContainer.addEventListener('touchstart', function(e) {
|
|
if (e.touches.length === 1) {
|
|
startPan({ clientX: e.touches[0].clientX, clientY: e.touches[0].clientY, preventDefault: function(){} });
|
|
}
|
|
});
|
|
svgContainer.addEventListener('touchmove', function(e) {
|
|
if (e.touches.length === 1) {
|
|
e.preventDefault();
|
|
doPan({ clientX: e.touches[0].clientX, clientY: e.touches[0].clientY });
|
|
}
|
|
}, { passive: false });
|
|
svgContainer.addEventListener('touchend', endPan);
|
|
}
|
|
|
|
// Keyboard shortcuts
|
|
document.addEventListener('keydown', function(e) {
|
|
if (state.view !== 'viewer') return;
|
|
if (e.key === 'Escape') {
|
|
if (state.selectedHotspot) closeHotspotPanel();
|
|
else showList();
|
|
}
|
|
if (e.key === '+' || e.key === '=') zoom(1.15);
|
|
if (e.key === '-') zoom(0.87);
|
|
if (e.key === '0') resetZoom();
|
|
});
|
|
}
|
|
|
|
// ---- API calls ----
|
|
function apiFetch(url, cb) {
|
|
loadingEl.style.display = 'flex';
|
|
emptyEl.style.display = 'none';
|
|
fetch(url, { headers: headers })
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
loadingEl.style.display = 'none';
|
|
cb(null, data);
|
|
})
|
|
.catch(function(err) {
|
|
loadingEl.style.display = 'none';
|
|
cb(err);
|
|
});
|
|
}
|
|
|
|
function loadAllDiagrams() {
|
|
apiFetch(API + '/', function(err, data) {
|
|
if (err || !data || !data.data) {
|
|
emptyEl.style.display = 'flex';
|
|
return;
|
|
}
|
|
state.diagrams = data.data;
|
|
// Group by category
|
|
var cats = {};
|
|
data.data.forEach(function(d) {
|
|
var key = d.category_name || 'Other';
|
|
if (!cats[key]) cats[key] = { name: key, name_es: d.category_name_es || key, diagrams: [] };
|
|
cats[key].diagrams.push(d);
|
|
});
|
|
state.categories = Object.values(cats);
|
|
renderList();
|
|
});
|
|
}
|
|
|
|
function loadDiagram(id) {
|
|
apiFetch(API + '/' + id, function(err, data) {
|
|
if (err || !data || data.error) return;
|
|
state.currentDiagram = data;
|
|
state.hotspots = data.hotspots || [];
|
|
showViewer();
|
|
});
|
|
}
|
|
|
|
// ---- Render list view ----
|
|
function renderList() {
|
|
state.view = 'list';
|
|
diagramViewer.style.display = 'none';
|
|
diagramList.style.display = 'block';
|
|
|
|
var filter = (filterInput && filterInput.value || '').toLowerCase();
|
|
var html = '';
|
|
|
|
state.categories.forEach(function(cat) {
|
|
var filtered = cat.diagrams.filter(function(d) {
|
|
if (!filter) return true;
|
|
return (d.name_es || d.name || '').toLowerCase().indexOf(filter) !== -1 ||
|
|
(d.category_name_es || '').toLowerCase().indexOf(filter) !== -1 ||
|
|
(d.group_name_es || '').toLowerCase().indexOf(filter) !== -1;
|
|
});
|
|
if (filtered.length === 0) return;
|
|
|
|
html += '<div class="diagram-category">';
|
|
html += '<h3 class="category-title">' + esc(cat.name_es || cat.name) + '</h3>';
|
|
html += '<div class="diagram-grid">';
|
|
|
|
filtered.forEach(function(d) {
|
|
html += '<div class="diagram-card" data-id="' + d.id_diagram + '" onclick="DiagramsApp.openDiagram(' + d.id_diagram + ')">';
|
|
html += '<div class="diagram-card__preview">';
|
|
html += '<img src="/pos/static/' + esc(d.image_path) + '" alt="' + esc(d.name_es || d.name) + '" loading="lazy" />';
|
|
html += '</div>';
|
|
html += '<div class="diagram-card__info">';
|
|
html += '<div class="diagram-card__name">' + esc(d.name_es || d.name) + '</div>';
|
|
html += '<div class="diagram-card__group">' + esc(d.group_name_es || d.group_name || '') + '</div>';
|
|
html += '</div>';
|
|
html += '</div>';
|
|
});
|
|
|
|
html += '</div></div>';
|
|
});
|
|
|
|
if (!html) {
|
|
emptyEl.style.display = 'flex';
|
|
} else {
|
|
emptyEl.style.display = 'none';
|
|
}
|
|
|
|
diagramList.innerHTML = html;
|
|
}
|
|
|
|
// ---- Viewer ----
|
|
function showViewer() {
|
|
state.view = 'viewer';
|
|
diagramList.style.display = 'none';
|
|
diagramViewer.style.display = 'flex';
|
|
closeHotspotPanel();
|
|
|
|
var d = state.currentDiagram;
|
|
if (diagramTitle) diagramTitle.textContent = d.name_es || d.name;
|
|
|
|
// Load SVG inline for interactivity
|
|
resetZoom();
|
|
loadSVGInline(d.id_diagram);
|
|
renderPartsList();
|
|
}
|
|
|
|
function showList() {
|
|
state.view = 'list';
|
|
state.currentDiagram = null;
|
|
state.selectedHotspot = null;
|
|
diagramViewer.style.display = 'none';
|
|
diagramList.style.display = 'block';
|
|
closeHotspotPanel();
|
|
}
|
|
|
|
function loadSVGInline(diagramId) {
|
|
svgWrapper.innerHTML = '<div class="svg-loading">Cargando diagrama...</div>';
|
|
fetch(API + '/' + diagramId + '/svg', { headers: headers })
|
|
.then(function(r) { return r.text(); })
|
|
.then(function(svgText) {
|
|
svgWrapper.innerHTML = svgText;
|
|
var svg = svgWrapper.querySelector('svg');
|
|
if (svg) {
|
|
svg.style.width = '100%';
|
|
svg.style.height = '100%';
|
|
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
|
attachHotspotListeners(svg);
|
|
}
|
|
})
|
|
.catch(function() {
|
|
svgWrapper.innerHTML = '<div class="svg-error">Error al cargar el diagrama</div>';
|
|
});
|
|
}
|
|
|
|
function attachHotspotListeners(svg) {
|
|
var areas = svg.querySelectorAll('[data-hotspot]');
|
|
areas.forEach(function(area) {
|
|
var callout = parseInt(area.getAttribute('data-hotspot'));
|
|
area.style.cursor = 'pointer';
|
|
|
|
area.addEventListener('click', function(e) {
|
|
e.stopPropagation();
|
|
selectHotspot(callout);
|
|
});
|
|
|
|
area.addEventListener('mouseenter', function() {
|
|
highlightHotspot(callout, true);
|
|
});
|
|
area.addEventListener('mouseleave', function() {
|
|
highlightHotspot(callout, false);
|
|
});
|
|
});
|
|
}
|
|
|
|
function highlightHotspot(callout, on) {
|
|
// Highlight in parts list
|
|
var items = partsListEl.querySelectorAll('.part-item');
|
|
items.forEach(function(item) {
|
|
if (parseInt(item.dataset.callout) === callout) {
|
|
item.classList.toggle('is-highlighted', on);
|
|
}
|
|
});
|
|
}
|
|
|
|
function selectHotspot(callout) {
|
|
var hotspot = state.hotspots.find(function(h) { return h.callout_number === callout; });
|
|
if (!hotspot) return;
|
|
|
|
state.selectedHotspot = hotspot;
|
|
|
|
// Highlight in SVG
|
|
var svg = svgWrapper.querySelector('svg');
|
|
if (svg) {
|
|
svg.querySelectorAll('[data-hotspot]').forEach(function(area) {
|
|
var num = parseInt(area.getAttribute('data-hotspot'));
|
|
if (num === callout) {
|
|
area.style.fill = 'rgba(245, 166, 35, 0.25)';
|
|
area.style.stroke = '#F5A623';
|
|
area.style.strokeWidth = '3';
|
|
} else {
|
|
area.style.fill = 'transparent';
|
|
area.style.stroke = 'none';
|
|
}
|
|
});
|
|
}
|
|
|
|
// Highlight in parts list
|
|
var items = partsListEl.querySelectorAll('.part-item');
|
|
items.forEach(function(item) {
|
|
item.classList.toggle('is-active', parseInt(item.dataset.callout) === callout);
|
|
});
|
|
|
|
// Show hotspot detail panel
|
|
showHotspotDetail(hotspot);
|
|
}
|
|
|
|
function showHotspotDetail(hotspot) {
|
|
hotspotPanel.classList.add('is-open');
|
|
|
|
var partName = hotspot.part_name_es || hotspot.part_name || 'Parte #' + hotspot.callout_number;
|
|
var partNum = hotspot.part_number || '';
|
|
var desc = hotspot.description_es || hotspot.description || '';
|
|
|
|
// Determine search term for catalog link
|
|
var searchTerm = partNum || partName;
|
|
|
|
// Map callout to friendly part names for placeholder diagrams
|
|
var placeholderNames = getPlaceholderPartInfo(hotspot.callout_number);
|
|
|
|
var displayName = hotspot.part_name_es || hotspot.part_name || placeholderNames.name;
|
|
var displayDesc = desc || placeholderNames.desc;
|
|
|
|
var html = '';
|
|
html += '<div class="hotspot-detail">';
|
|
html += '<div class="hotspot-callout">' + hotspot.callout_number + '</div>';
|
|
html += '<h4 class="hotspot-name">' + esc(displayName) + '</h4>';
|
|
if (partNum) {
|
|
html += '<div class="hotspot-partnumber">No. Parte: ' + esc(partNum) + '</div>';
|
|
}
|
|
if (displayDesc) {
|
|
html += '<p class="hotspot-desc">' + esc(displayDesc) + '</p>';
|
|
}
|
|
|
|
// Action buttons
|
|
html += '<div class="hotspot-actions">';
|
|
if (hotspot.part_id) {
|
|
html += '<button class="btn btn-primary btn-sm" onclick="DiagramsApp.viewPart(' + hotspot.part_id + ')">Ver detalle</button>';
|
|
html += '<button class="btn btn-accent btn-sm" onclick="DiagramsApp.addToCart(' + hotspot.part_id + ')"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 002 1.61h9.72a2 2 0 002-1.61L23 6H6"/></svg> Agregar</button>';
|
|
} else {
|
|
html += '<button class="btn btn-primary btn-sm" onclick="DiagramsApp.searchPart(\'' + esc(displayName) + '\')">Buscar en catalogo</button>';
|
|
}
|
|
html += '</div>';
|
|
html += '</div>';
|
|
|
|
hotspotBody.innerHTML = html;
|
|
}
|
|
|
|
function getPlaceholderPartInfo(callout) {
|
|
// For built-in placeholder diagrams, provide friendly names
|
|
var d = state.currentDiagram;
|
|
if (!d) return { name: 'Parte ' + callout, desc: '' };
|
|
|
|
var brakeNames = {
|
|
1: { name: 'Disco de freno', desc: 'Disco ventilado de freno delantero. Se recomienda cambiar en pares.' },
|
|
2: { name: 'Caliper de freno', desc: 'Caliper con pistones, incluye purga. Verificar compatibilidad con tipo de pastilla.' },
|
|
3: { name: 'Pastillas de freno', desc: 'Juego de pastillas con indicador de desgaste. Material ceramico o semi-metalico.' },
|
|
4: { name: 'Manguera de freno', desc: 'Manguera flexible de alta presion. Revisar por grietas cada 40,000 km.' },
|
|
5: { name: 'Cilindro maestro', desc: 'Cilindro maestro con deposito de liquido de frenos. Incluye empaques.' },
|
|
};
|
|
var suspNames = {
|
|
1: { name: 'Amortiguador', desc: 'Amortiguador delantero de gas. Se recomienda cambiar en pares.' },
|
|
2: { name: 'Resorte helicoidal', desc: 'Resorte de suspension delantera. Verificar altura libre.' },
|
|
3: { name: 'Brazo de control', desc: 'Brazo inferior de control con bujes. Incluye herraje de montaje.' },
|
|
4: { name: 'Rotula', desc: 'Rotula inferior de suspension. Incluye guardapolvo y seguros.' },
|
|
5: { name: 'Barra de acoplamiento', desc: 'Barra de acoplamiento de direccion con terminales. Requiere alineacion.' },
|
|
};
|
|
var engineNames = {
|
|
1: { name: 'Filtro de aire', desc: 'Filtro de aire del motor. Cambiar cada 15,000-20,000 km.' },
|
|
2: { name: 'Bujias', desc: 'Juego de bujias. Verificar tipo (platino, iridio) segun especificacion del motor.' },
|
|
3: { name: 'Banda serpentina', desc: 'Banda de accesorios. Revisar tension y desgaste. Incluye alternador, A/C y direccion.' },
|
|
4: { name: 'Junta de culata', desc: 'Junta de cabeza de cilindros. Material MLS multicapa. Requiere torque especifico.' },
|
|
5: { name: 'Filtro de aceite', desc: 'Filtro de aceite del motor. Cambiar en cada servicio de aceite.' },
|
|
};
|
|
|
|
var name = (d.name || '').toLowerCase();
|
|
if (name.indexOf('brak') !== -1 || name.indexOf('freno') !== -1) return brakeNames[callout] || { name: 'Parte ' + callout, desc: '' };
|
|
if (name.indexOf('susp') !== -1) return suspNames[callout] || { name: 'Parte ' + callout, desc: '' };
|
|
if (name.indexOf('engine') !== -1 || name.indexOf('motor') !== -1) return engineNames[callout] || { name: 'Parte ' + callout, desc: '' };
|
|
return { name: 'Parte ' + callout, desc: '' };
|
|
}
|
|
|
|
function closeHotspotPanel() {
|
|
hotspotPanel.classList.remove('is-open');
|
|
state.selectedHotspot = null;
|
|
// Clear SVG highlights
|
|
var svg = svgWrapper.querySelector('svg');
|
|
if (svg) {
|
|
svg.querySelectorAll('[data-hotspot]').forEach(function(area) {
|
|
area.style.fill = 'transparent';
|
|
area.style.stroke = 'none';
|
|
});
|
|
}
|
|
// Clear list highlights
|
|
if (partsListEl) {
|
|
partsListEl.querySelectorAll('.part-item').forEach(function(item) {
|
|
item.classList.remove('is-active');
|
|
});
|
|
}
|
|
}
|
|
|
|
// ---- Parts list sidebar ----
|
|
function renderPartsList() {
|
|
if (!partsListEl) return;
|
|
var html = '<h4 class="parts-list__title">Partes en diagrama</h4>';
|
|
|
|
state.hotspots.forEach(function(h) {
|
|
var info = getPlaceholderPartInfo(h.callout_number);
|
|
var name = h.part_name_es || h.part_name || info.name;
|
|
var num = h.part_number || '';
|
|
|
|
html += '<div class="part-item" data-callout="' + h.callout_number + '" onclick="DiagramsApp.selectHotspot(' + h.callout_number + ')">';
|
|
html += '<span class="part-item__callout">' + h.callout_number + '</span>';
|
|
html += '<div class="part-item__info">';
|
|
html += '<div class="part-item__name">' + esc(name) + '</div>';
|
|
if (num) html += '<div class="part-item__number">' + esc(num) + '</div>';
|
|
html += '</div>';
|
|
html += '</div>';
|
|
});
|
|
|
|
if (state.hotspots.length === 0) {
|
|
html += '<div class="parts-list__empty">Sin partes definidas</div>';
|
|
}
|
|
|
|
partsListEl.innerHTML = html;
|
|
}
|
|
|
|
// ---- Zoom / Pan ----
|
|
function zoom(factor) {
|
|
state.scale = Math.max(0.3, Math.min(5, state.scale * factor));
|
|
applyTransform();
|
|
}
|
|
|
|
function resetZoom() {
|
|
state.scale = 1;
|
|
state.panX = 0;
|
|
state.panY = 0;
|
|
applyTransform();
|
|
}
|
|
|
|
function applyTransform() {
|
|
if (!svgWrapper) return;
|
|
svgWrapper.style.transform = 'translate(' + state.panX + 'px, ' + state.panY + 'px) scale(' + state.scale + ')';
|
|
}
|
|
|
|
function startPan(e) {
|
|
// Only pan with left button and not on a hotspot
|
|
if (e.button && e.button !== 0) return;
|
|
state.isPanning = true;
|
|
state.lastPointer = { x: e.clientX, y: e.clientY };
|
|
}
|
|
|
|
function doPan(e) {
|
|
if (!state.isPanning || !state.lastPointer) return;
|
|
var dx = e.clientX - state.lastPointer.x;
|
|
var dy = e.clientY - state.lastPointer.y;
|
|
state.panX += dx;
|
|
state.panY += dy;
|
|
state.lastPointer = { x: e.clientX, y: e.clientY };
|
|
applyTransform();
|
|
}
|
|
|
|
function endPan() {
|
|
state.isPanning = false;
|
|
state.lastPointer = null;
|
|
}
|
|
|
|
// ---- Actions ----
|
|
function viewPart(partId) {
|
|
window.open('/pos/catalog?part=' + partId, '_blank');
|
|
}
|
|
|
|
function searchPart(query) {
|
|
window.open('/pos/catalog?search=' + encodeURIComponent(query), '_blank');
|
|
}
|
|
|
|
function addToCart(partId) {
|
|
// Reuse the catalog cart logic if available
|
|
alert('Funcion disponible desde el catalogo. Busca la parte para agregarla al carrito.');
|
|
}
|
|
|
|
// ---- Helpers ----
|
|
function esc(s) {
|
|
if (!s) return '';
|
|
var d = document.createElement('div');
|
|
d.appendChild(document.createTextNode(s));
|
|
return d.innerHTML;
|
|
}
|
|
|
|
// ---- Public API ----
|
|
window.DiagramsApp = {
|
|
openDiagram: loadDiagram,
|
|
selectHotspot: selectHotspot,
|
|
viewPart: viewPart,
|
|
searchPart: searchPart,
|
|
addToCart: addToCart,
|
|
};
|
|
|
|
// Init on DOM ready
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
})();
|