humanmap-vas 1.0.22 → 1.0.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,12 +6,31 @@
6
6
  .hm-toolbar { display:grid; grid-template-columns: auto 1fr auto; align-items:center; gap:8px; padding:8px 10px; border-bottom:1px solid #eef2f7; background:#fafafa; }
7
7
  .hm-center { text-align:center; font-weight:600; color:#1f2937; }
8
8
  .hm-toolbar select, .hm-toolbar button { appearance:none; border:1px solid #d1d5db; border-radius:10px; padding:6px 10px; background:#fff; cursor:pointer; font-weight:500; }
9
- .hm-canvas-wrap { position:relative; width:100%; height:512px; margin:auto; aspect-ratio: 2/3; background:#fff; }
10
- svg.hm-svg { position:absolute; inset:0; width:100%; height:100%; }
9
+ .hm-canvas-wrap { position:relative; width:100%; height:600px; margin:auto; aspect-ratio: 2/3; background:#fff; }
10
+ svg.hm-svg { position:absolute; inset:0; width:100%; height:100%; margin: auto; }
11
11
  .zone { fill: rgba(31,41,55,0); transition: fill 120ms ease; cursor: pointer; }
12
12
  .zone:hover { fill: rgba(31,41,55,0.22); }
13
+ .zone.readonly { cursor: default; }
14
+ .zone.readonly:not(.selected):hover { fill: rgba(31,41,55,0); }
13
15
  .zone.selected { fill: rgba(31,41,55,0.36); }
16
+ .hm-all-label { fill: #111827; font-weight: 700; font-size: 36px; text-anchor: middle; dominant-baseline: middle; }
14
17
  .label { fill:#0a0a0a; font-size:36px; pointer-events: none; user-select: none; text-anchor: middle; dominant-baseline: middle; font-weight:800; }
18
+ .hm-print-btn { position: absolute; top: 10px; right: 10px; background: rgba(17,24,39,0.85); color: #f9fafb; border: none; border-radius: 8px; cursor: pointer; font-size: 18px; padding: 6px 10px; box-shadow: 0 2px 4px rgba(0,0,0,0.3); transition: opacity 0.25s ease, background 0.2s ease; opacity: 0; pointer-events: none; }
19
+ .hm-canvas-wrap:hover .hm-print-btn { opacity: 1; pointer-events: auto; }
20
+ .hm-print-btn:hover { background: rgba(37,99,235,0.9); }
21
+ .hm-zoom-float { position: absolute; bottom: 10px; right: 10px; background: rgba(31,41,55,0.85); color: #fff; border: none; border-radius: 50%; width: 42px; height: 42px; font-size: 20px; line-height: 1; cursor: pointer; box-shadow: 0 3px 8px rgba(0,0,0,0.3); transition: transform 0.2s ease, background 0.2s ease; z-index: 20; }
22
+ .hm-zoom-float:hover { transform: scale(1.08); background: rgba(31,41,55,1); }
23
+ .hm-zoom-modal { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.85); display: flex; align-items: center; justify-content: center; z-index: 9999; opacity: 0; pointer-events: none; transition: opacity 0.3s ease; }
24
+ .hm-zoom-modal.active { opacity: 1; pointer-events: auto; }
25
+ .hm-zoom-inner { position: relative; width: 95vw; height: 90vh; background: #fff; border-radius: 10px; overflow: hidden; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; }
26
+ .hm-zoom-content { position: relative; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background: #fff; overflow: auto; }
27
+ .hm-zoom-close { position: absolute; top: 12px; right: 12px; border: none; background: rgba(0, 0, 0, 0.75); color: #fff; border-radius: 50%; width: 42px; height: 42px; font-size: 24px; cursor: pointer; z-index: 10000; line-height: 1; }
28
+ .hm-zoom-close:hover { background: rgba(0, 0, 0, 0.9); }
29
+ .hm-zoom-modal svg { width: auto; height: auto; max-width: 100%; max-height: 100%; display: block; transition: transform 0.25s ease; }
30
+ .hm-zoom-inner { transform: scale(0.95); opacity: 0; transition: transform 0.3s ease, opacity 0.3s ease; }
31
+ .hm-zoom-modal.active .hm-zoom-inner { transform: scale(1); opacity: 1; }
32
+ .hm-zoom-hint { position: absolute; bottom: 10px; right: 20px; color: rgba(0,0,0,0.4); font-size: 14px; font-family: system-ui, sans-serif; background: rgba(255,255,255,0.7); padding: 4px 10px; border-radius: 6px; pointer-events: none; user-select: none; transition: opacity 1s ease 2s; opacity: 1; }
33
+
15
34
  `;
16
35
 
17
36
  // ───────────────────────────────────────────────────────────────────────────
@@ -117,6 +136,7 @@
117
136
  this._view=this.getAttribute('view') || 'head_right';
118
137
  this._zones=ZONES;
119
138
  this._selected=new Set();
139
+ this._readOnly = this.hasAttribute('read-only') && this.getAttribute('read-only') !== 'false';
120
140
 
121
141
  this._upgradeProperty('selectedIds');
122
142
  this._upgradeProperty('selectedZones');
@@ -161,9 +181,23 @@
161
181
  }
162
182
  }
163
183
 
164
- connectedCallback(){this._renderShell();this._renderCanvas();this.dispatchEvent(new CustomEvent('human-map-vas:ready'));}
184
+ connectedCallback(){
185
+ this._renderShell();
186
+ this._renderCanvas();
187
+ this.dispatchEvent(new CustomEvent('human-map-vas:ready'));
188
+ this.dispatchEvent(new CustomEvent('human-map-vas:readonly-change', {
189
+ detail: { readOnly: this._readOnly }
190
+ }));
191
+
192
+ window.addEventListener('resize', () => {
193
+ if (this._view === 'all') {
194
+ clearTimeout(this._resizeTimer);
195
+ this._resizeTimer = setTimeout(() => this._renderAllViews(), 150);
196
+ }
197
+ });
198
+ }
165
199
 
166
- static get observedAttributes() { return ['view', 'img-root']; }
200
+ static get observedAttributes() { return ['view', 'img-root', 'read-only']; }
167
201
 
168
202
  attributeChangedCallback (name, oldValue, newValue) {
169
203
  if (oldValue === newValue) return;
@@ -171,6 +205,11 @@
171
205
  if (name === 'view') {
172
206
  this._view = newValue;
173
207
  if (this._root) this._renderCanvas();
208
+
209
+ this.dispatchEvent(new CustomEvent('human-map-vas:view-changed', {
210
+ detail: { view: newValue }
211
+ }));
212
+
174
213
  return;
175
214
  }
176
215
 
@@ -187,6 +226,34 @@
187
226
  if (this._root) this._renderCanvas();
188
227
  return;
189
228
  }
229
+
230
+ if (name === 'read-only') {
231
+ this._readOnly = newValue === 'true' || newValue === true;
232
+
233
+ if (this._root) {
234
+ // Refrescar la vista completamente según el modo
235
+ if (this._view === 'all') {
236
+ this._renderAllViews();
237
+ } else {
238
+ this._renderCanvas();
239
+ }
240
+
241
+ // Actualizar la visibilidad de toolbar y botón de impresión
242
+ const toolbar = this._root.querySelector('.hm-toolbar');
243
+ const printBtn = this.shadowRoot.getElementById('printBtn');
244
+
245
+ if (this._view === 'all' && this._readOnly) {
246
+ if (toolbar) toolbar.style.display = 'none';
247
+ if (printBtn) printBtn.style.display = 'block';
248
+ } else {
249
+ if (toolbar) toolbar.style.display = '';
250
+ if (printBtn) printBtn.style.display = 'none';
251
+ }
252
+ }
253
+
254
+ return;
255
+ }
256
+
190
257
  }
191
258
 
192
259
  // Devuelve solo IDs seleccionados
@@ -198,7 +265,13 @@
198
265
  set selectedIds(ids) {
199
266
  if (!Array.isArray(ids)) return;
200
267
  this._selected = new Set(ids);
201
- if (this._root) { this._renderZones(); this._emit(); }
268
+
269
+ if (this._root) {
270
+ // Si está en modo global, renderizamos todas las vistas
271
+ if (this._view === 'all') this._renderAllViews();
272
+ else this._renderZones();
273
+ this._emit();
274
+ }
202
275
  }
203
276
 
204
277
  // Devuelve objetos completos (id, code, label, view)
@@ -242,7 +315,11 @@
242
315
  this._root=document.createElement('div');
243
316
  this._root.className='hm';
244
317
 
245
- const opts=VIEWS.map(v=>`<option value="${v.id}">${v.label}</option>`).join('');
318
+ const opts = [
319
+ `<option value="all">Todas las vistas</option>`,
320
+ ...VIEWS.map(v => `<option value="${v.id}">${v.label}</option>`)
321
+ ].join('');
322
+
246
323
  this._root.innerHTML=`
247
324
  <div class="hm-toolbar">
248
325
  <button id="prev">◀</button>
@@ -259,6 +336,8 @@
259
336
  <g id="bg"></g>
260
337
  <g id="zones"></g>
261
338
  </svg>
339
+ <button id="printBtn" title="Imprimir vista" class="hm-print-btn">🖨️</button>
340
+ <button id="zoom-float" class="hm-zoom-float" title="Ampliar vista">⤢</button>
262
341
  </div>`;
263
342
  this.shadowRoot.append(style,this._root);
264
343
 
@@ -277,6 +356,17 @@
277
356
  this._els.prev.addEventListener('click',()=>this._cycle(-1));
278
357
  this._els.next.addEventListener('click',()=>this._cycle(1));
279
358
  this._els.reset.addEventListener('click',()=>this.clear());
359
+ this._els.printBtn = this.shadowRoot.getElementById('printBtn');
360
+ this._els.printBtn.addEventListener('click', () => {
361
+ this._els.printBtn.style.transform = 'scale(0.94)';
362
+ setTimeout(() => {
363
+ this._els.printBtn.style.transform = '';
364
+ this._printCanvasOnly(); // ⬅️ imprime solo el área actual
365
+ }, 120);
366
+ });
367
+ this._els.zoomFloat = this.shadowRoot.getElementById('zoom-float');
368
+ this._els.zoomFloat.addEventListener('click', () => this._openPreviewModal());
369
+
280
370
  }
281
371
 
282
372
  _cycle(dir){
@@ -286,8 +376,59 @@
286
376
  }
287
377
 
288
378
  _renderCanvas(){
289
- const layout=VIEW_LAYOUTS[this._view];
290
- const v=VIEWS.find(v=>v.id===this._view);
379
+ // Ocultar toolbar si está en modo global y solo lectura
380
+ if (this._els && this._root) {
381
+ const toolbar = this._root.querySelector('.hm-toolbar');
382
+ if (this._view === 'all' && this._readOnly) {
383
+ toolbar.style.display = 'none';
384
+ } else {
385
+ toolbar.style.display = '';
386
+ }
387
+ }
388
+
389
+ // Mostrar botón de impresión solo en modo global + solo lectura
390
+ const printBtn = this.shadowRoot.getElementById('printBtn');
391
+ if (printBtn) {
392
+ if (this._view === 'all' && this._readOnly) {
393
+ printBtn.style.display = 'block';
394
+ } else {
395
+ printBtn.style.display = 'none';
396
+ }
397
+ }
398
+
399
+ if (this._els.zoomFloat) {
400
+ this._els.zoomFloat.style.display = this._readOnly ? 'block' : 'none';
401
+ }
402
+
403
+ // Limpia cualquier render anterior (modo all o vista única)
404
+ if (this._els && this._els.svg) {
405
+ this._els.svg.innerHTML = `
406
+ <defs id="defs"></defs>
407
+ <g id="bg"></g>
408
+ <g id="zones"></g>
409
+ `;
410
+ this._els.bg = this._els.svg.querySelector('#bg');
411
+ this._els.zones = this._els.svg.querySelector('#zones');
412
+ }
413
+
414
+ // 🆕 Detección de modo global
415
+ if (this._view === 'all') {
416
+ this._renderAllViews();
417
+ this._els.cur.textContent = 'Todas las vistas';
418
+ this._els.picker.value = 'all';
419
+ this._els.prev.disabled = true;
420
+ this._els.next.disabled = true;
421
+ this._els.reset.disabled = this._readOnly;
422
+ return;
423
+ } else {
424
+ this._els.prev.disabled = false;
425
+ this._els.next.disabled = false;
426
+ this._els.reset.disabled = false;
427
+ }
428
+
429
+ // --- Render normal ---
430
+ const layout = VIEW_LAYOUTS[this._view];
431
+ const v = VIEWS.find(v => v.id === this._view);
291
432
  if(!layout||!v)return;
292
433
  this._els.cur.textContent=v.label;
293
434
  this._els.picker.value=v.id;
@@ -298,6 +439,124 @@
298
439
  this._renderZones();
299
440
  }
300
441
 
442
+ _renderAllViews() {
443
+ const svg = this._els.svg;
444
+ svg.innerHTML = ''; // limpiar
445
+
446
+ // Permitir configurar columnas dinámicas (por atributo) o calcular automáticamente
447
+ const defaultCols = 4;
448
+ const attrCols = parseInt(this.getAttribute('columns'), 10);
449
+ let cols = !isNaN(attrCols) && attrCols > 0 ? attrCols : defaultCols;
450
+
451
+ const gap = 80;
452
+ const cellW = 560, cellH = 720;
453
+
454
+ const gridW = cols * (cellW + gap);
455
+ const gridH = Math.ceil(VIEWS.length / cols) * (cellH + gap);
456
+
457
+ svg.setAttribute('viewBox', `0 0 ${gridW} ${gridH}`);
458
+ const gRoot = document.createElementNS('http://www.w3.org/2000/svg', 'g');
459
+ svg.appendChild(gRoot);
460
+
461
+ VIEWS.forEach((v, i) => {
462
+ const layout = VIEW_LAYOUTS[v.id] || { vb: [0, 0, 1024, 1536], y: 0, h: 1536, rotate: 0 };
463
+ const row = Math.floor(i / cols);
464
+ const col = i % cols;
465
+ const gx = col * (cellW + gap);
466
+ const gy = row * (cellH + gap);
467
+
468
+ const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
469
+ // Escala dinámica: reduce más si hay muchas columnas
470
+ const scale = Math.max(0.3, 0.9 - cols * 0.1);
471
+ g.setAttribute('transform', `translate(${gx}, ${gy}) scale(${scale})`);
472
+
473
+ // Fondo (imagen)
474
+ const img = document.createElementNS('http://www.w3.org/2000/svg', 'image');
475
+ img.setAttribute('href', this._bg[v.id]);
476
+ img.setAttribute('x', '0');
477
+ img.setAttribute('y', layout?.y || 0);
478
+ img.setAttribute('width', layout?.vb[2] || 1024);
479
+ img.setAttribute('height', layout?.h || 1536);
480
+ img.setAttribute('preserveAspectRatio', 'xMidYMid meet');
481
+ g.appendChild(img);
482
+
483
+ // Grupo para las zonas (permite rotar sin afectar la imagen)
484
+ const gZones = document.createElementNS('http://www.w3.org/2000/svg', 'g');
485
+
486
+ // Rotar solo las zonas del cuello
487
+ let rotation = 0;
488
+ if (v.id === 'neck_right') rotation = 12;
489
+ if (v.id === 'neck_left') rotation = -12;
490
+ if (rotation !== 0) {
491
+ gZones.setAttribute(
492
+ 'transform',
493
+ `rotate(${rotation}, ${layout.vb[2]*0.55}, ${layout.vb[3]*0.45})`
494
+ );
495
+ }
496
+
497
+ // --- Dibujar zonas ---
498
+ const zones = this._zones.filter(z => z.view === v.id);
499
+ zones.forEach(z => {
500
+ const { x, y, w, h } = z.shape;
501
+
502
+ // Zona rectangular
503
+ const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
504
+ rect.setAttribute('x', x * layout.vb[2]);
505
+ rect.setAttribute('y', y * layout.vb[3]);
506
+ rect.setAttribute('width', w * layout.vb[2]);
507
+ rect.setAttribute('height', h * layout.vb[3]);
508
+ rect.setAttribute('rx', Math.min(w, h) * layout.vb[2] * 0.1);
509
+ rect.classList.add('zone');
510
+ if (this._selected.has(z.id)) rect.classList.add('selected');
511
+ if (this._readOnly) {
512
+ rect.classList.add('readonly');
513
+ } else {
514
+ rect.addEventListener('click', e => {
515
+ e.stopPropagation();
516
+ if (this._selected.has(z.id)) this._selected.delete(z.id);
517
+ else this._selected.add(z.id);
518
+ this._renderAllViews(); // refrescar vista global
519
+ this._emit();
520
+ });
521
+ }
522
+ gZones.appendChild(rect);
523
+
524
+ // Label centrado
525
+ const t = document.createElementNS('http://www.w3.org/2000/svg', 'text');
526
+ t.textContent = z.code;
527
+ t.setAttribute('x', (x + w / 2) * layout.vb[2]);
528
+ t.setAttribute('y', (y + h / 2) * layout.vb[3]);
529
+ t.setAttribute('fill', '#0a0a0a');
530
+ t.setAttribute('font-size', '32');
531
+ t.setAttribute('font-weight', '700');
532
+ t.setAttribute('text-anchor', 'middle');
533
+ t.setAttribute('dominant-baseline', 'middle');
534
+ t.setAttribute('pointer-events', 'none');
535
+ gZones.appendChild(t);
536
+ });
537
+
538
+ // Añadir grupo de zonas dentro del grupo principal
539
+ g.appendChild(gZones);
540
+
541
+
542
+ // Label de la vista
543
+ const t = document.createElementNS('http://www.w3.org/2000/svg', 'text');
544
+ t.textContent = v.label;
545
+ t.setAttribute('x', layout.vb[2] / 2);
546
+ t.setAttribute('y', 80);
547
+ t.setAttribute('class', 'hm-all-label');
548
+ g.appendChild(t);
549
+
550
+
551
+ gRoot.appendChild(g);
552
+ });
553
+
554
+ if (this._els.zoomFloat) {
555
+ this._els.zoomFloat.style.display = this._readOnly ? 'block' : 'none';
556
+ }
557
+
558
+ }
559
+
301
560
  _renderZones(){
302
561
  const g=this._els.zones;g.innerHTML='';
303
562
  const layout=VIEW_LAYOUTS[this._view];
@@ -309,19 +568,34 @@
309
568
  Z.forEach(z=>{
310
569
  const{x,y,w,h}=z.shape;
311
570
  const rect=document.createElementNS('http://www.w3.org/2000/svg','rect');
571
+
312
572
  rect.setAttribute('x',x*vw);rect.setAttribute('y',y*vh);
313
573
  rect.setAttribute('width',w*vw);rect.setAttribute('height',h*vh);
314
574
  rect.setAttribute('rx',Math.min(w*vw,h*vh)*0.1);
575
+
315
576
  rect.classList.add('zone');
316
- if(this._selected.has(z.id))rect.classList.add('selected');
317
- rect.addEventListener('click',e=>{
318
- e.stopPropagation();
319
- if(this._selected.has(z.id))this._selected.delete(z.id);
320
- else this._selected.add(z.id);
321
- this._renderZones();this._emit();
322
- });
577
+
578
+ if (this._selected.has(z.id)) rect.classList.add('selected');
579
+
580
+ // Si está en modo lectura, añadir clase visual
581
+ if (this._readOnly) {
582
+ rect.classList.add('readonly');
583
+ } else {
584
+ rect.classList.remove('readonly');
585
+ rect.addEventListener('click', e => {
586
+ e.stopPropagation();
587
+ if (this._selected.has(z.id)) this._selected.delete(z.id);
588
+ else this._selected.add(z.id);
589
+ this._renderZones();
590
+ this._emit();
591
+ });
592
+ }
593
+
594
+
323
595
  g.appendChild(rect);
596
+
324
597
  const t=document.createElementNS('http://www.w3.org/2000/svg','text');
598
+
325
599
  t.setAttribute('x',(x+w/2)*vw);
326
600
  t.setAttribute('y',(y+h/2)*vh);
327
601
  t.textContent=z.code;
@@ -330,6 +604,262 @@
330
604
  });
331
605
  }
332
606
 
607
+ // Imprime solo el área visible (hm-canvas-wrap) en una nueva ventana
608
+ _printCanvasOnly() {
609
+ if (!this._els || !this._els.svg) return;
610
+
611
+ // Clonar el SVG actual completo (vistas individuales o "all")
612
+ const clone = this._els.svg.cloneNode(true);
613
+
614
+ // Asegurar rutas absolutas en <image> (importante para que se muestren)
615
+ clone.querySelectorAll('image').forEach(img => {
616
+ const href = img.getAttribute('href') || img.getAttributeNS('http://www.w3.org/1999/xlink', 'href');
617
+ if (href) {
618
+ const a = document.createElement('a');
619
+ a.href = href;
620
+ const abs = a.href;
621
+ img.setAttribute('href', abs);
622
+ img.setAttributeNS('http://www.w3.org/1999/xlink', 'href', abs);
623
+ }
624
+ });
625
+
626
+ // Inyectar los estilos básicos directamente en el SVG (para conservar colores y transparencias)
627
+ const style = document.createElementNS('http://www.w3.org/2000/svg', 'style');
628
+ style.textContent = `
629
+ .zone { fill: rgba(31,41,55,0); cursor: pointer; transition: fill 120ms ease; }
630
+ .zone:hover { fill: rgba(31,41,55,0.22); }
631
+ .zone.selected { fill: rgba(31,41,55,0.36); }
632
+ .label { fill: #0a0a0a; font-size: 36px; font-weight: 800;
633
+ text-anchor: middle; dominant-baseline: middle; pointer-events: none; user-select: none; }
634
+ `;
635
+ clone.insertBefore(style, clone.firstChild);
636
+
637
+ const serializer = new XMLSerializer();
638
+ const svgMarkup = serializer.serializeToString(clone);
639
+
640
+ const isGlobal = this._view === 'all';
641
+
642
+ // HTML limpio con estilos mínimos
643
+ const html = `
644
+ <!DOCTYPE html>
645
+ <html lang="es">
646
+ <head>
647
+ <meta charset="utf-8">
648
+ <title>Impresión del mapa anatómico</title>
649
+ <style>
650
+ html, body {
651
+ margin: 0;
652
+ padding: 0mm;
653
+ background: #fff;
654
+ text-align: center;
655
+ }
656
+ svg {
657
+ width: 100%;
658
+ height: auto;
659
+ display: block;
660
+ }
661
+ .hm-all-label {
662
+ font-family: system-ui, sans-serif;
663
+ font-size: 48px;
664
+ font-weight: 800;
665
+ fill: #111827;
666
+ text-anchor: middle;
667
+ dominant-baseline: middle;
668
+ }
669
+ @page {
670
+ size: ${isGlobal ? 'A4 landscape' : 'A4 portrait'};
671
+ margin: 5mm;
672
+ }
673
+ </style>
674
+ </head>
675
+ <body>
676
+ ${svgMarkup}
677
+ </body>
678
+ </html>
679
+ `;
680
+
681
+ // Abrir ventana y escribir el HTML
682
+ const printWin = window.open('', '_blank', 'width=1024,height=768');
683
+ if (!printWin) {
684
+ alert('El navegador bloqueó la ventana de impresión. Permite popups para continuar.');
685
+ return;
686
+ }
687
+
688
+ printWin.document.open();
689
+ printWin.document.write(html);
690
+ printWin.document.close();
691
+
692
+ // Esperar hasta que las imágenes del SVG estén listas
693
+ const waitForImages = () => {
694
+ const imgs = printWin.document.querySelectorAll('image');
695
+ const promises = Array.from(imgs).map(img => {
696
+ return new Promise(resolve => {
697
+ const test = new Image();
698
+ test.onload = test.onerror = resolve;
699
+ test.src = img.getAttribute('href') || img.getAttributeNS('http://www.w3.org/1999/xlink', 'href');
700
+ });
701
+ });
702
+ return Promise.all(promises);
703
+ };
704
+
705
+ waitForImages().then(() => {
706
+ printWin.focus();
707
+ printWin.print();
708
+ setTimeout(() => printWin.close(), 1000);
709
+ });
710
+ }
711
+
712
+ _openPreviewModal() {
713
+ if (!this._els || !this._els.svg) return;
714
+
715
+ // Clonar el SVG actual
716
+ const clone = this._els.svg.cloneNode(true);
717
+
718
+ // Asegurar que las imágenes se vean correctamente (usar rutas absolutas)
719
+ clone.querySelectorAll('image').forEach(img => {
720
+ const href = img.getAttribute('href') || img.getAttributeNS('http://www.w3.org/1999/xlink', 'href');
721
+ if (href) {
722
+ const a = document.createElement('a');
723
+ a.href = href;
724
+ const abs = a.href;
725
+ img.setAttribute('href', abs);
726
+ img.setAttributeNS('http://www.w3.org/1999/xlink', 'href', abs);
727
+ }
728
+ });
729
+
730
+ // Ajustar estilos dentro del SVG
731
+ const style = document.createElementNS('http://www.w3.org/2000/svg', 'style');
732
+ style.textContent = `
733
+ .zone { fill: rgba(31,41,55,0); cursor: default; transition: fill 120ms ease; }
734
+ .zone.selected { fill: rgba(31,41,55,0.36); }
735
+ .label { fill: #0a0a0a; font-size: 42px; font-weight: 800;
736
+ text-anchor: middle; dominant-baseline: middle;
737
+ pointer-events: none; user-select: none; }
738
+ .hm-all-label {
739
+ font-family: system-ui, sans-serif;
740
+ font-size: 48px;
741
+ font-weight: 800;
742
+ fill: #111827;
743
+ text-anchor: middle;
744
+ dominant-baseline: middle;
745
+ }
746
+ `;
747
+ clone.insertBefore(style, clone.firstChild);
748
+
749
+ // Crear el modal
750
+ const modal = document.createElement('div');
751
+ modal.className = 'hm-zoom-modal';
752
+ modal.innerHTML = `
753
+ <div class="hm-zoom-inner">
754
+ <button class="hm-zoom-close" title="Cerrar">×</button>
755
+ <div class="hm-zoom-content"></div>
756
+ <div class="hm-zoom-hint">🖱️ Usa la rueda para hacer zoom y arrastra para mover. Presiona <strong>Esc</strong> para cerrar</div>
757
+ </div>
758
+ `;
759
+
760
+ // Insertar el SVG clonado dentro del modal
761
+ modal.querySelector('.hm-zoom-content').appendChild(clone);
762
+ this.shadowRoot.appendChild(modal);
763
+
764
+ // Forzar render y animación
765
+ requestAnimationFrame(() => modal.classList.add('active'));
766
+ // 🔒 Bloquear scroll del fondo
767
+ document.body.style.overflow = 'hidden';
768
+
769
+ // Cerrar modal
770
+ const close = () => {
771
+ modal.classList.remove('active');
772
+
773
+ // 🔓 Restaurar scroll del fondo
774
+ document.body.style.overflow = '';
775
+
776
+ setTimeout(() => modal.remove(), 300);
777
+
778
+ // Quitar listener del teclado al cerrar
779
+ document.removeEventListener('keydown', onKey);
780
+ };
781
+
782
+ modal.querySelector('.hm-zoom-close').addEventListener('click', close);
783
+ modal.addEventListener('click', e => {
784
+ if (e.target === modal) close();
785
+ });
786
+
787
+ // 🔑 Cerrar con tecla Escape
788
+ const onKey = e => {
789
+ if (e.key === 'Escape') close();
790
+ };
791
+ document.addEventListener('keydown', onKey);
792
+
793
+ // ───────────────────────────────
794
+ // 🔍 Zoom + Pan interactivo
795
+ // ───────────────────────────────
796
+ const content = modal.querySelector('.hm-zoom-content');
797
+ let scale = 1;
798
+ let translateX = 0, translateY = 0;
799
+ let isPanning = false;
800
+ let startX = 0, startY = 0;
801
+
802
+ content.addEventListener('wheel', e => {
803
+ e.preventDefault();
804
+
805
+ const rect = clone.getBoundingClientRect();
806
+ const offsetX = e.clientX - rect.left;
807
+ const offsetY = e.clientY - rect.top;
808
+
809
+ const delta = e.deltaY < 0 ? 0.1 : -0.1;
810
+ const newScale = Math.min(Math.max(scale + delta, 0.5), 3);
811
+
812
+ // Mantener el punto bajo el puntero "anclado"
813
+ const dx = offsetX - (offsetX / scale) * newScale;
814
+ const dy = offsetY - (offsetY / scale) * newScale;
815
+ translateX += dx;
816
+ translateY += dy;
817
+
818
+ clone.style.transform = `translate(${translateX}px, ${translateY}px) scale(${newScale})`;
819
+ clone.style.transformOrigin = '0 0';
820
+ scale = newScale;
821
+ });
822
+
823
+ // ───────────────────────────────
824
+ // 🖱️ Arrastrar (Pan)
825
+ // ───────────────────────────────
826
+ content.addEventListener('mousedown', e => {
827
+ e.preventDefault();
828
+ isPanning = true;
829
+ startX = e.clientX - translateX;
830
+ startY = e.clientY - translateY;
831
+ content.style.cursor = 'grabbing';
832
+ });
833
+
834
+ content.addEventListener('mousemove', e => {
835
+ if (!isPanning) return;
836
+ translateX = e.clientX - startX;
837
+ translateY = e.clientY - startY;
838
+ clone.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`;
839
+ });
840
+
841
+ content.addEventListener('mouseup', () => {
842
+ isPanning = false;
843
+ content.style.cursor = 'default';
844
+ });
845
+
846
+ content.addEventListener('mouseleave', () => {
847
+ isPanning = false;
848
+ content.style.cursor = 'default';
849
+ });
850
+
851
+ // ───────────────────────────────
852
+ // 🔄 Doble clic para resetear vista
853
+ // ───────────────────────────────
854
+ content.addEventListener('dblclick', () => {
855
+ scale = 1;
856
+ translateX = 0;
857
+ translateY = 0;
858
+ clone.style.transform = '';
859
+ });
860
+
861
+ }
862
+
333
863
  _emit(){this.dispatchEvent(new CustomEvent('human-map-vas:select',{detail:{selected:this.getSelected()}}));}
334
864
  }
335
865
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "humanmap-vas",
3
- "version": "1.0.22",
3
+ "version": "1.0.24",
4
4
  "description": "**HumanMap VAS** es una librería web que permite graficar el cuerpo humano con vistas anatómicas interactivas para identificar zonas según el sistema VAS. Desarrollada como *Web Component standalone*, puede integrarse fácilmente en proyectos **HTML**, **Django**, o **Vue.js**.",
5
5
  "main": "humanmap-vas-standalone.js",
6
6
  "files": [