humanmap-vas 1.0.21 → 1.0.23

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,18 @@
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; }
9
+ .hm-canvas-wrap { position:relative; width:100%; height:600px; margin:auto; aspect-ratio: 2/3; background:#fff; }
10
10
  svg.hm-svg { position:absolute; inset:0; width:100%; height:100%; }
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); }
15
21
  `;
16
22
 
17
23
  // ───────────────────────────────────────────────────────────────────────────
@@ -117,6 +123,11 @@
117
123
  this._view=this.getAttribute('view') || 'head_right';
118
124
  this._zones=ZONES;
119
125
  this._selected=new Set();
126
+ this._readOnly = this.hasAttribute('read-only') && this.getAttribute('read-only') !== 'false';
127
+
128
+ this._upgradeProperty('selectedIds');
129
+ this._upgradeProperty('selectedZones');
130
+
120
131
 
121
132
  // Detectar automáticamente la ruta base del script (compatible con todos los entornos)
122
133
  let scriptBase = '';
@@ -148,8 +159,26 @@
148
159
  };
149
160
  }
150
161
 
151
- connectedCallback(){this._renderShell();this._renderCanvas();}
152
- static get observedAttributes() { return ['view', 'img-root']; }
162
+ // Captura valores asignados *antes* de que el custom element se registre
163
+ _upgradeProperty(prop) {
164
+ if (this.hasOwnProperty(prop)) {
165
+ const value = this[prop];
166
+ delete this[prop]; // elimina la propiedad “propia” del elemento “no mejorado”
167
+ this[prop] = value; // re-ejecuta el setter ya del elemento mejorado
168
+ }
169
+ }
170
+
171
+ connectedCallback(){
172
+ this._renderShell();
173
+ this._renderCanvas();
174
+ this.dispatchEvent(new CustomEvent('human-map-vas:ready'));
175
+ this.dispatchEvent(new CustomEvent('human-map-vas:readonly-change', {
176
+ detail: { readOnly: this._readOnly }
177
+ }));
178
+ }
179
+
180
+ static get observedAttributes() { return ['view', 'img-root', 'read-only']; }
181
+
153
182
  attributeChangedCallback (name, oldValue, newValue) {
154
183
  if (oldValue === newValue) return;
155
184
 
@@ -172,6 +201,76 @@
172
201
  if (this._root) this._renderCanvas();
173
202
  return;
174
203
  }
204
+
205
+ if (name === 'read-only') {
206
+ this._readOnly = newValue === 'true' || newValue === true;
207
+
208
+ if (this._root) {
209
+ // Refrescar la vista completamente según el modo
210
+ if (this._view === 'all') {
211
+ this._renderAllViews();
212
+ } else {
213
+ this._renderCanvas();
214
+ }
215
+
216
+ // Actualizar la visibilidad de toolbar y botón de impresión
217
+ const toolbar = this._root.querySelector('.hm-toolbar');
218
+ const printBtn = this.shadowRoot.getElementById('printBtn');
219
+
220
+ if (this._view === 'all' && this._readOnly) {
221
+ if (toolbar) toolbar.style.display = 'none';
222
+ if (printBtn) printBtn.style.display = 'block';
223
+ } else {
224
+ if (toolbar) toolbar.style.display = '';
225
+ if (printBtn) printBtn.style.display = 'none';
226
+ }
227
+ }
228
+
229
+ return;
230
+ }
231
+
232
+ }
233
+
234
+ // Devuelve solo IDs seleccionados
235
+ get selectedIds() {
236
+ return Array.from(this._selected);
237
+ }
238
+
239
+ // Asigna selección por IDs (array de strings)
240
+ set selectedIds(ids) {
241
+ if (!Array.isArray(ids)) return;
242
+ this._selected = new Set(ids);
243
+
244
+ if (this._root) {
245
+ // Si está en modo global, renderizamos todas las vistas
246
+ if (this._view === 'all') this._renderAllViews();
247
+ else this._renderZones();
248
+ this._emit();
249
+ }
250
+ }
251
+
252
+ // Devuelve objetos completos (id, code, label, view)
253
+ get selectedZones() {
254
+ const map = new Map(this._zones.map(z => [z.id, z]));
255
+ return this.selectedIds.map(id => {
256
+ const z = map.get(id);
257
+ return z ? { id: z.id, code: z.code, label: z.label, view: z.view } : { id };
258
+ });
259
+ }
260
+
261
+ // Asigna selección pasando objetos (tomamos los IDs)
262
+ set selectedZones(zones) {
263
+ if (!Array.isArray(zones)) return;
264
+ const ids = zones.map(z => z && z.id).filter(Boolean);
265
+ this.selectedIds = ids; // reutiliza el setter de IDs para redibujar y emitir
266
+ }
267
+
268
+ get selectedCodes() {
269
+ const map = new Map(this._zones.map(z => [z.id, z]));
270
+ return this.selectedIds.map(id => {
271
+ const z = map.get(id);
272
+ return z ? z.code : { id };
273
+ });
175
274
  }
176
275
 
177
276
  getSelected(){
@@ -191,7 +290,11 @@
191
290
  this._root=document.createElement('div');
192
291
  this._root.className='hm';
193
292
 
194
- const opts=VIEWS.map(v=>`<option value="${v.id}">${v.label}</option>`).join('');
293
+ const opts = [
294
+ `<option value="all">Todas las vistas</option>`,
295
+ ...VIEWS.map(v => `<option value="${v.id}">${v.label}</option>`)
296
+ ].join('');
297
+
195
298
  this._root.innerHTML=`
196
299
  <div class="hm-toolbar">
197
300
  <button id="prev">◀</button>
@@ -208,6 +311,7 @@
208
311
  <g id="bg"></g>
209
312
  <g id="zones"></g>
210
313
  </svg>
314
+ <button id="printBtn" title="Imprimir vista" class="hm-print-btn">🖨️</button>
211
315
  </div>`;
212
316
  this.shadowRoot.append(style,this._root);
213
317
 
@@ -226,6 +330,15 @@
226
330
  this._els.prev.addEventListener('click',()=>this._cycle(-1));
227
331
  this._els.next.addEventListener('click',()=>this._cycle(1));
228
332
  this._els.reset.addEventListener('click',()=>this.clear());
333
+ this._els.printBtn = this.shadowRoot.getElementById('printBtn');
334
+ this._els.printBtn.addEventListener('click', () => {
335
+ this._els.printBtn.style.transform = 'scale(0.94)';
336
+ setTimeout(() => {
337
+ this._els.printBtn.style.transform = '';
338
+ this._printCanvasOnly(); // ⬅️ imprime solo el área actual
339
+ }, 120);
340
+ });
341
+
229
342
  }
230
343
 
231
344
  _cycle(dir){
@@ -235,8 +348,55 @@
235
348
  }
236
349
 
237
350
  _renderCanvas(){
238
- const layout=VIEW_LAYOUTS[this._view];
239
- const v=VIEWS.find(v=>v.id===this._view);
351
+ // Ocultar toolbar si está en modo global y solo lectura
352
+ if (this._els && this._root) {
353
+ const toolbar = this._root.querySelector('.hm-toolbar');
354
+ if (this._view === 'all' && this._readOnly) {
355
+ toolbar.style.display = 'none';
356
+ } else {
357
+ toolbar.style.display = '';
358
+ }
359
+ }
360
+
361
+ // Mostrar botón de impresión solo en modo global + solo lectura
362
+ const printBtn = this.shadowRoot.getElementById('printBtn');
363
+ if (printBtn) {
364
+ if (this._view === 'all' && this._readOnly) {
365
+ printBtn.style.display = 'block';
366
+ } else {
367
+ printBtn.style.display = 'none';
368
+ }
369
+ }
370
+
371
+ // Limpia cualquier render anterior (modo all o vista única)
372
+ if (this._els && this._els.svg) {
373
+ this._els.svg.innerHTML = `
374
+ <defs id="defs"></defs>
375
+ <g id="bg"></g>
376
+ <g id="zones"></g>
377
+ `;
378
+ this._els.bg = this._els.svg.querySelector('#bg');
379
+ this._els.zones = this._els.svg.querySelector('#zones');
380
+ }
381
+
382
+ // 🆕 Detección de modo global
383
+ if (this._view === 'all') {
384
+ this._renderAllViews();
385
+ this._els.cur.textContent = 'Todas las vistas';
386
+ this._els.picker.value = 'all';
387
+ this._els.prev.disabled = true;
388
+ this._els.next.disabled = true;
389
+ this._els.reset.disabled = this._readOnly;
390
+ return;
391
+ } else {
392
+ this._els.prev.disabled = false;
393
+ this._els.next.disabled = false;
394
+ this._els.reset.disabled = false;
395
+ }
396
+
397
+ // --- Render normal ---
398
+ const layout = VIEW_LAYOUTS[this._view];
399
+ const v = VIEWS.find(v => v.id === this._view);
240
400
  if(!layout||!v)return;
241
401
  this._els.cur.textContent=v.label;
242
402
  this._els.picker.value=v.id;
@@ -247,6 +407,112 @@
247
407
  this._renderZones();
248
408
  }
249
409
 
410
+ _renderAllViews() {
411
+ const svg = this._els.svg;
412
+ svg.innerHTML = ''; // limpiar
413
+
414
+ // Creamos un grupo por cada vista
415
+ const cols = 2, gap = 40;
416
+ const cellW = 480, cellH = 720;
417
+ const gridW = cols * (cellW + gap);
418
+ const gridH = Math.ceil(VIEWS.length / cols) * (cellH + gap);
419
+
420
+ svg.setAttribute('viewBox', `0 0 ${gridW} ${gridH}`);
421
+ const gRoot = document.createElementNS('http://www.w3.org/2000/svg', 'g');
422
+ svg.appendChild(gRoot);
423
+
424
+ VIEWS.forEach((v, i) => {
425
+ const layout = VIEW_LAYOUTS[v.id] || { vb: [0, 0, 1024, 1536], y: 0, h: 1536, rotate: 0 };
426
+ const row = Math.floor(i / cols);
427
+ const col = i % cols;
428
+ const gx = col * (cellW + gap);
429
+ const gy = row * (cellH + gap);
430
+
431
+ const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
432
+ g.setAttribute('transform', `translate(${gx}, ${gy}) scale(0.45)`);
433
+
434
+ // Fondo (imagen)
435
+ const img = document.createElementNS('http://www.w3.org/2000/svg', 'image');
436
+ img.setAttribute('href', this._bg[v.id]);
437
+ img.setAttribute('x', '0');
438
+ img.setAttribute('y', layout?.y || 0);
439
+ img.setAttribute('width', layout?.vb[2] || 1024);
440
+ img.setAttribute('height', layout?.h || 1536);
441
+ img.setAttribute('preserveAspectRatio', 'xMidYMid meet');
442
+ g.appendChild(img);
443
+
444
+ // Grupo para las zonas (permite rotar sin afectar la imagen)
445
+ const gZones = document.createElementNS('http://www.w3.org/2000/svg', 'g');
446
+
447
+ // Rotar solo las zonas del cuello
448
+ let rotation = 0;
449
+ if (v.id === 'neck_right') rotation = 12;
450
+ if (v.id === 'neck_left') rotation = -12;
451
+ if (rotation !== 0) {
452
+ gZones.setAttribute(
453
+ 'transform',
454
+ `rotate(${rotation}, ${layout.vb[2]*0.55}, ${layout.vb[3]*0.45})`
455
+ );
456
+ }
457
+
458
+ // --- Dibujar zonas ---
459
+ const zones = this._zones.filter(z => z.view === v.id);
460
+ zones.forEach(z => {
461
+ const { x, y, w, h } = z.shape;
462
+
463
+ // Zona rectangular
464
+ const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
465
+ rect.setAttribute('x', x * layout.vb[2]);
466
+ rect.setAttribute('y', y * layout.vb[3]);
467
+ rect.setAttribute('width', w * layout.vb[2]);
468
+ rect.setAttribute('height', h * layout.vb[3]);
469
+ rect.setAttribute('rx', Math.min(w, h) * layout.vb[2] * 0.1);
470
+ rect.classList.add('zone');
471
+ if (this._selected.has(z.id)) rect.classList.add('selected');
472
+ if (this._readOnly) {
473
+ rect.classList.add('readonly');
474
+ } else {
475
+ rect.addEventListener('click', e => {
476
+ e.stopPropagation();
477
+ if (this._selected.has(z.id)) this._selected.delete(z.id);
478
+ else this._selected.add(z.id);
479
+ this._renderAllViews(); // refrescar vista global
480
+ this._emit();
481
+ });
482
+ }
483
+ gZones.appendChild(rect);
484
+
485
+ // Label centrado
486
+ const t = document.createElementNS('http://www.w3.org/2000/svg', 'text');
487
+ t.textContent = z.code;
488
+ t.setAttribute('x', (x + w / 2) * layout.vb[2]);
489
+ t.setAttribute('y', (y + h / 2) * layout.vb[3]);
490
+ t.setAttribute('fill', '#0a0a0a');
491
+ t.setAttribute('font-size', '32');
492
+ t.setAttribute('font-weight', '700');
493
+ t.setAttribute('text-anchor', 'middle');
494
+ t.setAttribute('dominant-baseline', 'middle');
495
+ t.setAttribute('pointer-events', 'none');
496
+ gZones.appendChild(t);
497
+ });
498
+
499
+ // Añadir grupo de zonas dentro del grupo principal
500
+ g.appendChild(gZones);
501
+
502
+
503
+ // Label de la vista
504
+ const t = document.createElementNS('http://www.w3.org/2000/svg', 'text');
505
+ t.textContent = v.label;
506
+ t.setAttribute('x', layout.vb[2] / 2);
507
+ t.setAttribute('y', 80);
508
+ t.setAttribute('class', 'hm-all-label');
509
+ g.appendChild(t);
510
+
511
+
512
+ gRoot.appendChild(g);
513
+ });
514
+ }
515
+
250
516
  _renderZones(){
251
517
  const g=this._els.zones;g.innerHTML='';
252
518
  const layout=VIEW_LAYOUTS[this._view];
@@ -258,19 +524,34 @@
258
524
  Z.forEach(z=>{
259
525
  const{x,y,w,h}=z.shape;
260
526
  const rect=document.createElementNS('http://www.w3.org/2000/svg','rect');
527
+
261
528
  rect.setAttribute('x',x*vw);rect.setAttribute('y',y*vh);
262
529
  rect.setAttribute('width',w*vw);rect.setAttribute('height',h*vh);
263
530
  rect.setAttribute('rx',Math.min(w*vw,h*vh)*0.1);
531
+
264
532
  rect.classList.add('zone');
265
- if(this._selected.has(z.id))rect.classList.add('selected');
266
- rect.addEventListener('click',e=>{
267
- e.stopPropagation();
268
- if(this._selected.has(z.id))this._selected.delete(z.id);
269
- else this._selected.add(z.id);
270
- this._renderZones();this._emit();
271
- });
533
+
534
+ if (this._selected.has(z.id)) rect.classList.add('selected');
535
+
536
+ // Si está en modo lectura, añadir clase visual
537
+ if (this._readOnly) {
538
+ rect.classList.add('readonly');
539
+ } else {
540
+ rect.classList.remove('readonly');
541
+ rect.addEventListener('click', e => {
542
+ e.stopPropagation();
543
+ if (this._selected.has(z.id)) this._selected.delete(z.id);
544
+ else this._selected.add(z.id);
545
+ this._renderZones();
546
+ this._emit();
547
+ });
548
+ }
549
+
550
+
272
551
  g.appendChild(rect);
552
+
273
553
  const t=document.createElementNS('http://www.w3.org/2000/svg','text');
554
+
274
555
  t.setAttribute('x',(x+w/2)*vw);
275
556
  t.setAttribute('y',(y+h/2)*vh);
276
557
  t.textContent=z.code;
@@ -279,6 +560,112 @@
279
560
  });
280
561
  }
281
562
 
563
+ // Imprime solo el área visible (hm-canvas-wrap) en una nueva ventana
564
+ _printCanvasOnly() {
565
+ if (!this._els || !this._els.svg) return;
566
+
567
+ // Clonar el SVG actual completo (vistas individuales o "all")
568
+ const clone = this._els.svg.cloneNode(true);
569
+
570
+ // Asegurar rutas absolutas en <image> (importante para que se muestren)
571
+ clone.querySelectorAll('image').forEach(img => {
572
+ const href = img.getAttribute('href') || img.getAttributeNS('http://www.w3.org/1999/xlink', 'href');
573
+ if (href) {
574
+ const a = document.createElement('a');
575
+ a.href = href;
576
+ const abs = a.href;
577
+ img.setAttribute('href', abs);
578
+ img.setAttributeNS('http://www.w3.org/1999/xlink', 'href', abs);
579
+ }
580
+ });
581
+
582
+ // Inyectar los estilos básicos directamente en el SVG (para conservar colores y transparencias)
583
+ const style = document.createElementNS('http://www.w3.org/2000/svg', 'style');
584
+ style.textContent = `
585
+ .zone { fill: rgba(31,41,55,0); cursor: pointer; transition: fill 120ms ease; }
586
+ .zone:hover { fill: rgba(31,41,55,0.22); }
587
+ .zone.selected { fill: rgba(31,41,55,0.36); }
588
+ .label { fill: #0a0a0a; font-size: 36px; font-weight: 800;
589
+ text-anchor: middle; dominant-baseline: middle; pointer-events: none; user-select: none; }
590
+ `;
591
+ clone.insertBefore(style, clone.firstChild);
592
+
593
+ const serializer = new XMLSerializer();
594
+ const svgMarkup = serializer.serializeToString(clone);
595
+
596
+ const isGlobal = this._view === 'all';
597
+
598
+ // HTML limpio con estilos mínimos
599
+ const html = `
600
+ <!DOCTYPE html>
601
+ <html lang="es">
602
+ <head>
603
+ <meta charset="utf-8">
604
+ <title>Impresión del mapa anatómico</title>
605
+ <style>
606
+ html, body {
607
+ margin: 0;
608
+ padding: 0mm;
609
+ background: #fff;
610
+ text-align: center;
611
+ }
612
+ svg {
613
+ width: 100%;
614
+ height: auto;
615
+ display: block;
616
+ }
617
+ .hm-all-label {
618
+ font-family: system-ui, sans-serif;
619
+ font-size: 48px;
620
+ font-weight: 800;
621
+ fill: #111827;
622
+ text-anchor: middle;
623
+ dominant-baseline: middle;
624
+ }
625
+ @page {
626
+ size: ${isGlobal ? 'A4 landscape' : 'A4 portrait'};
627
+ margin: 5mm;
628
+ }
629
+ </style>
630
+ </head>
631
+ <body>
632
+ ${svgMarkup}
633
+ </body>
634
+ </html>
635
+ `;
636
+
637
+ // Abrir ventana y escribir el HTML
638
+ const printWin = window.open('', '_blank', 'width=1024,height=768');
639
+ if (!printWin) {
640
+ alert('El navegador bloqueó la ventana de impresión. Permite popups para continuar.');
641
+ return;
642
+ }
643
+
644
+ printWin.document.open();
645
+ printWin.document.write(html);
646
+ printWin.document.close();
647
+
648
+ // Esperar hasta que las imágenes del SVG estén listas
649
+ const waitForImages = () => {
650
+ const imgs = printWin.document.querySelectorAll('image');
651
+ const promises = Array.from(imgs).map(img => {
652
+ return new Promise(resolve => {
653
+ const test = new Image();
654
+ test.onload = test.onerror = resolve;
655
+ test.src = img.getAttribute('href') || img.getAttributeNS('http://www.w3.org/1999/xlink', 'href');
656
+ });
657
+ });
658
+ return Promise.all(promises);
659
+ };
660
+
661
+ waitForImages().then(() => {
662
+ printWin.focus();
663
+ printWin.print();
664
+ setTimeout(() => printWin.close(), 1000);
665
+ });
666
+ }
667
+
668
+
282
669
  _emit(){this.dispatchEvent(new CustomEvent('human-map-vas:select',{detail:{selected:this.getSelected()}}));}
283
670
  }
284
671
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "humanmap-vas",
3
- "version": "1.0.21",
3
+ "version": "1.0.23",
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": [