Rafael Lopez

site

1 +<script>
2 + let { size = 40, color = 'currentColor' } = $props();
3 +</script>
4 +
5 +<svg
6 + fill={color}
7 + viewBox="0 -10 40 28"
8 + xmlns="http://www.w3.org/2000/svg"
9 + width={size}
10 + height={size * 0.7}
11 + class="spinner"
12 +>
13 + <circle cx="8" cy="12" r="3" class="first"/>
14 + <circle cx="16" cy="12" r="3"/>
15 + <circle cx="24" cy="12" r="3"/>
16 + <circle cx="32" cy="12" r="3" class="second"/>
17 +</svg>
18 +
19 +<style>
20 + @keyframes swing {
21 + 0% {
22 + transform: rotate(0deg);
23 + animation-timing-function: ease-out;
24 + }
25 + 25% {
26 + transform: rotate(50deg);
27 + animation-timing-function: ease-in;
28 + }
29 + 50% {
30 + transform: rotate(0deg);
31 + animation-timing-function: linear;
32 + }
33 + }
34 +
35 + @keyframes swing2 {
36 + 0% {
37 + transform: rotate(0deg);
38 + animation-timing-function: linear;
39 + }
40 + 50% {
41 + transform: rotate(0deg);
42 + animation-timing-function: ease-out;
43 + }
44 + 75% {
45 + transform: rotate(-50deg);
46 + animation-timing-function: ease-in;
47 + }
48 + }
49 +
50 + .spinner .first {
51 + animation: swing 1.2s linear infinite;
52 + transform-origin: center top;
53 + }
54 +
55 + .spinner .second {
56 + animation: swing2 1.2s linear infinite;
57 + transform-origin: center top;
58 + }
59 +</style>
1 +/**
2 + * Cache store for clasificador data
3 + * Persists data in memory to avoid reloading on navigation
4 + */
5 +import { writable } from 'svelte/store';
6 +
7 +// Cache structure
8 +const createClasificadorCache = () => {
9 + const { subscribe, set, update } = writable({
10 + objetoGasto: {
11 + allItems: null,
12 + grupos: null,
13 + entities: null,
14 + availableYears: null,
15 + lastFetched: null
16 + }
17 + });
18 +
19 + return {
20 + subscribe,
21 +
22 + // Set all clasificador objeto data
23 + setObjetoGasto: (data) => {
24 + update(cache => ({
25 + ...cache,
26 + objetoGasto: {
27 + ...data,
28 + lastFetched: Date.now()
29 + }
30 + }));
31 + },
32 +
33 + // Get cached data if valid (less than 5 minutes old)
34 + getObjetoGasto: (currentCache) => {
35 + const cache = currentCache.objetoGasto;
36 + if (!cache.lastFetched) return null;
37 +
38 + // Cache valid for 5 minutes
39 + const isValid = Date.now() - cache.lastFetched < 5 * 60 * 1000;
40 + if (!isValid) return null;
41 +
42 + return cache;
43 + },
44 +
45 + // Clear cache
46 + clear: () => {
47 + set({
48 + objetoGasto: {
49 + allItems: null,
50 + grupos: null,
51 + entities: null,
52 + availableYears: null,
53 + lastFetched: null
54 + }
55 + });
56 + }
57 + };
58 +};
59 +
60 +export const clasificadorCache = createClasificadorCache();
...@@ -4,6 +4,8 @@ ...@@ -4,6 +4,8 @@
4 import { page } from '$app/stores'; 4 import { page } from '$app/stores';
5 import { goto } from '$app/navigation'; 5 import { goto } from '$app/navigation';
6 import * as d3 from 'd3'; 6 import * as d3 from 'd3';
7 + import { clasificadorCache } from '$lib/stores/clasificadorCache';
8 + import Spinner from '$lib/components/ui/Spinner.svelte';
7 9
8 let loading = $state(true); 10 let loading = $state(true);
9 let searchQuery = $state(''); 11 let searchQuery = $state('');
...@@ -37,6 +39,11 @@ ...@@ -37,6 +39,11 @@
37 let levelDropdownOpen = $state(false); 39 let levelDropdownOpen = $state(false);
38 let yearDropdownOpen = $state(false); 40 let yearDropdownOpen = $state(false);
39 41
42 + // Límites dinámicos para scroll infinito en dropdowns
43 + let entityLimit = $state(30);
44 + let compareALimit = $state(30);
45 + let compareBLimit = $state(30);
46 +
40 // Estado para modo comparar 47 // Estado para modo comparar
41 let compareA = $state({ year: null, entity: null }); 48 let compareA = $state({ year: null, entity: null });
42 let compareB = $state({ year: null, entity: null }); 49 let compareB = $state({ year: null, entity: null });
...@@ -57,6 +64,7 @@ ...@@ -57,6 +64,7 @@
57 let treemapHeight = $state(500); 64 let treemapHeight = $state(500);
58 let treemapContainer = $state(null); 65 let treemapContainer = $state(null);
59 let hoveredNode = $state(null); 66 let hoveredNode = $state(null);
67 + let showTreemapHelp = $state(false);
60 68
61 // Selector de nivel para vista aplanada 69 // Selector de nivel para vista aplanada
62 let treemapViewLevel = $state('partida'); // 'jerarquico' | 'subgrupo' | 'partida' | 'subpartida' 70 let treemapViewLevel = $state('partida'); // 'jerarquico' | 'subgrupo' | 'partida' | 'subpartida'
...@@ -513,6 +521,30 @@ ...@@ -513,6 +521,30 @@
513 }); 521 });
514 522
515 onMount(async () => { 523 onMount(async () => {
524 + // Intentar usar cache primero
525 + let cacheValue;
526 + const unsubscribe = clasificadorCache.subscribe(value => { cacheValue = value; });
527 + unsubscribe();
528 +
529 + const cachedData = clasificadorCache.getObjetoGasto(cacheValue);
530 +
531 + if (cachedData && cachedData.allItems && cachedData.grupos && cachedData.entities && cachedData.availableYears) {
532 + // Usar datos del cache
533 + allItems = cachedData.allItems;
534 + grupos = cachedData.grupos;
535 + entities = cachedData.entities;
536 + availableYears = cachedData.availableYears;
537 + selectedYear = availableYears[0];
538 +
539 + if (grupos.length > 0) {
540 + selectedGrupo = grupos[0];
541 + }
542 +
543 + loading = false;
544 + return;
545 + }
546 +
547 + // Si no hay cache, cargar datos
516 // Cargar clasificador de objetos 548 // Cargar clasificador de objetos
517 const { data, error } = await supabase 549 const { data, error } = await supabase
518 .schema('ppto') 550 .schema('ppto')
...@@ -565,13 +597,21 @@ ...@@ -565,13 +597,21 @@
565 entities = entidadesData; 597 entities = entidadesData;
566 } 598 }
567 599
600 + // Guardar en cache
601 + clasificadorCache.setObjetoGasto({
602 + allItems,
603 + grupos,
604 + entities,
605 + availableYears
606 + });
607 +
568 loading = false; 608 loading = false;
569 }); 609 });
570 610
571 // Filtrar entidades por búsqueda 611 // Filtrar entidades por búsqueda
572 let filteredEntities = $derived(() => { 612 let filteredEntities = $derived(() => {
573 if (!entitySearchQuery || entitySearchQuery.length < 2) { 613 if (!entitySearchQuery || entitySearchQuery.length < 2) {
574 - return entities.slice(0, 20); // Mostrar primeras 20 si no hay búsqueda 614 + return entities.slice(0, entityLimit);
575 } 615 }
576 const q = entitySearchQuery.toLowerCase(); 616 const q = entitySearchQuery.toLowerCase();
577 return entities 617 return entities
...@@ -580,25 +620,42 @@ ...@@ -580,25 +620,42 @@
580 e.sigla_entidad?.toLowerCase().includes(q) || 620 e.sigla_entidad?.toLowerCase().includes(q) ||
581 e.entidad?.toString().includes(q) 621 e.entidad?.toString().includes(q)
582 ) 622 )
583 - .slice(0, 20); 623 + .slice(0, entityLimit);
584 }); 624 });
585 625
586 function selectEntity(entity) { 626 function selectEntity(entity) {
587 selectedEntity = entity; 627 selectedEntity = entity;
588 entityDropdownOpen = false; 628 entityDropdownOpen = false;
589 entitySearchQuery = ''; 629 entitySearchQuery = '';
630 + entityLimit = 30; // Reset limit
590 } 631 }
591 632
592 function clearEntity() { 633 function clearEntity() {
593 selectedEntity = null; 634 selectedEntity = null;
594 entityDropdownOpen = false; 635 entityDropdownOpen = false;
595 entitySearchQuery = ''; 636 entitySearchQuery = '';
637 + entityLimit = 30;
638 + }
639 +
640 + // Handler para cargar más entidades al hacer scroll
641 + function handleEntityScroll(event, type) {
642 + const el = event.target;
643 + const threshold = 50; // pixels from bottom
644 + if (el.scrollHeight - el.scrollTop - el.clientHeight < threshold) {
645 + if (type === 'main' && entityLimit < entities.length) {
646 + entityLimit = Math.min(entityLimit + 30, entities.length);
647 + } else if (type === 'A' && compareALimit < entities.length) {
648 + compareALimit = Math.min(compareALimit + 30, entities.length);
649 + } else if (type === 'B' && compareBLimit < entities.length) {
650 + compareBLimit = Math.min(compareBLimit + 30, entities.length);
651 + }
652 + }
596 } 653 }
597 654
598 // Filtrar entidades para comparación A 655 // Filtrar entidades para comparación A
599 let filteredEntitiesA = $derived(() => { 656 let filteredEntitiesA = $derived(() => {
600 if (!compareASearchQuery || compareASearchQuery.length < 2) { 657 if (!compareASearchQuery || compareASearchQuery.length < 2) {
601 - return entities.slice(0, 20); 658 + return entities.slice(0, compareALimit);
602 } 659 }
603 const q = compareASearchQuery.toLowerCase(); 660 const q = compareASearchQuery.toLowerCase();
604 return entities 661 return entities
...@@ -607,13 +664,13 @@ ...@@ -607,13 +664,13 @@
607 e.sigla_entidad?.toLowerCase().includes(q) || 664 e.sigla_entidad?.toLowerCase().includes(q) ||
608 e.entidad?.toString().includes(q) 665 e.entidad?.toString().includes(q)
609 ) 666 )
610 - .slice(0, 20); 667 + .slice(0, compareALimit);
611 }); 668 });
612 669
613 // Filtrar entidades para comparación B 670 // Filtrar entidades para comparación B
614 let filteredEntitiesB = $derived(() => { 671 let filteredEntitiesB = $derived(() => {
615 if (!compareBSearchQuery || compareBSearchQuery.length < 2) { 672 if (!compareBSearchQuery || compareBSearchQuery.length < 2) {
616 - return entities.slice(0, 20); 673 + return entities.slice(0, compareBLimit);
617 } 674 }
618 const q = compareBSearchQuery.toLowerCase(); 675 const q = compareBSearchQuery.toLowerCase();
619 return entities 676 return entities
...@@ -622,7 +679,7 @@ ...@@ -622,7 +679,7 @@
622 e.sigla_entidad?.toLowerCase().includes(q) || 679 e.sigla_entidad?.toLowerCase().includes(q) ||
623 e.entidad?.toString().includes(q) 680 e.entidad?.toString().includes(q)
624 ) 681 )
625 - .slice(0, 20); 682 + .slice(0, compareBLimit);
626 }); 683 });
627 684
628 // Inicializar modo comparar con valores inteligentes 685 // Inicializar modo comparar con valores inteligentes
...@@ -704,6 +761,7 @@ ...@@ -704,6 +761,7 @@
704 .sort((a, b) => a.objeto.localeCompare(b.objeto)); 761 .sort((a, b) => a.objeto.localeCompare(b.objeto));
705 762
706 const subgrupos = subgruposUnicos.map(sg => { 763 const subgrupos = subgruposUnicos.map(sg => {
764 + // Obtener partidas existentes
707 const partidasUnicas = allItems 765 const partidasUnicas = allItems
708 .filter(item => item.nivel === 'partida' && item.grupo == grupoNum && item.subgrupo == sg.subgrupo) 766 .filter(item => item.nivel === 'partida' && item.grupo == grupoNum && item.subgrupo == sg.subgrupo)
709 .reduce((acc, item) => { 767 .reduce((acc, item) => {
...@@ -714,21 +772,62 @@ ...@@ -714,21 +772,62 @@
714 }, []) 772 }, [])
715 .sort((a, b) => a.objeto.localeCompare(b.objeto)); 773 .sort((a, b) => a.objeto.localeCompare(b.objeto));
716 774
775 + // Obtener todas las subpartidas del subgrupo
776 + const todasSubpartidas = allItems
777 + .filter(item => item.nivel === 'subpartida' && item.grupo == grupoNum && item.subgrupo == sg.subgrupo)
778 + .reduce((acc, item) => {
779 + if (!acc.find(sp => sp.objeto === item.objeto)) {
780 + acc.push(item);
781 + }
782 + return acc;
783 + }, [])
784 + .sort((a, b) => a.objeto.localeCompare(b.objeto));
785 +
786 + // Set de números de partida que existen
787 + const partidasExistentes = new Set(partidasUnicas.map(p => p.partida));
788 +
789 + // Encontrar subpartidas huérfanas (cuya partida no existe)
790 + const subpartidasHuerfanas = todasSubpartidas.filter(sp => !partidasExistentes.has(sp.partida));
791 +
792 + // Agrupar huérfanas por número de partida para crear partidas sintéticas
793 + const huerfanasPorPartida = {};
794 + subpartidasHuerfanas.forEach(sp => {
795 + if (!huerfanasPorPartida[sp.partida]) {
796 + huerfanasPorPartida[sp.partida] = [];
797 + }
798 + huerfanasPorPartida[sp.partida].push(sp);
799 + });
800 +
801 + // Crear partidas sintéticas para las huérfanas
802 + const partidasSinteticas = Object.entries(huerfanasPorPartida).map(([partidaNum, subpartidas]) => {
803 + // Generar código de partida (ej: grupo=3, subgrupo=4, partida=1 -> 34100)
804 + const codigoPartida = `${grupoNum}${sg.subgrupo}${partidaNum}00`;
805 + return {
806 + objeto: codigoPartida,
807 + desc_objeto: subpartidas[0]?.desc_objeto?.split(',')[0] || `Partida ${codigoPartida}`,
808 + nivel: 'partida',
809 + grupo: parseInt(grupoNum),
810 + subgrupo: sg.subgrupo,
811 + partida: parseInt(partidaNum),
812 + _sintetica: true, // Marcar como sintética
813 + subpartidas
814 + };
815 + });
816 +
817 + // Asignar subpartidas a partidas existentes
717 const partidas = partidasUnicas.map(p => { 818 const partidas = partidasUnicas.map(p => {
718 - const subpartidas = allItems 819 + const subpartidas = todasSubpartidas
719 - .filter(item => item.nivel === 'subpartida' && item.grupo == grupoNum && item.subgrupo == sg.subgrupo && item.partida == p.partida) 820 + .filter(item => item.partida == p.partida)
720 - .reduce((acc, item) => {
721 - if (!acc.find(sp => sp.objeto === item.objeto)) {
722 - acc.push(item);
723 - }
724 - return acc;
725 - }, [])
726 .sort((a, b) => a.objeto.localeCompare(b.objeto)); 821 .sort((a, b) => a.objeto.localeCompare(b.objeto));
727 822
728 return { ...p, subpartidas }; 823 return { ...p, subpartidas };
729 }); 824 });
730 825
731 - return { ...sg, partidas }; 826 + // Combinar partidas existentes con sintéticas y ordenar
827 + const todasPartidas = [...partidas, ...partidasSinteticas]
828 + .sort((a, b) => a.objeto.localeCompare(b.objeto));
829 +
830 + return { ...sg, partidas: todasPartidas };
732 }); 831 });
733 832
734 return { subgrupos }; 833 return { subgrupos };
...@@ -794,23 +893,36 @@ ...@@ -794,23 +893,36 @@
794 } 893 }
795 894
796 function goToSearchResult(item) { 895 function goToSearchResult(item) {
797 - // Encontrar el grupo correspondiente 896 + // Si es un grupo, simplemente seleccionarlo
897 + if (item.nivel === 'grupo') {
898 + selectGrupo(item);
899 + return;
900 + }
901 +
902 + // Para otros niveles, encontrar y seleccionar el grupo padre
798 const grupoNum = item.grupo; 903 const grupoNum = item.grupo;
799 - const targetGrupo = grupos.find(g => g.objeto.startsWith(grupoNum)); 904 + const targetGrupo = grupos.find(g => g.objeto === grupoNum + '0000');
800 905
801 if (targetGrupo) { 906 if (targetGrupo) {
907 + // Seleccionar el grupo primero
802 selectedGrupo = targetGrupo; 908 selectedGrupo = targetGrupo;
803 highlightedItem = item.objeto; 909 highlightedItem = item.objeto;
804 searchQuery = ''; 910 searchQuery = '';
911 + sidebarOpen = false;
805 912
806 - // Scroll al elemento después de un breve delay 913 + // Esperar a que el contenido se renderice y luego hacer scroll
807 setTimeout(() => { 914 setTimeout(() => {
808 const prefix = item.nivel === 'subgrupo' ? 'sg' : item.nivel === 'partida' ? 'p' : 'sp'; 915 const prefix = item.nivel === 'subgrupo' ? 'sg' : item.nivel === 'partida' ? 'p' : 'sp';
809 const element = document.getElementById(`${prefix}-${item.objeto}`); 916 const element = document.getElementById(`${prefix}-${item.objeto}`);
810 if (element) { 917 if (element) {
811 element.scrollIntoView({ behavior: 'smooth', block: 'center' }); 918 element.scrollIntoView({ behavior: 'smooth', block: 'center' });
812 } 919 }
813 - }, 100); 920 +
921 + // Quitar el highlight después de unos segundos
922 + setTimeout(() => {
923 + highlightedItem = null;
924 + }, 3000);
925 + }, 150);
814 } 926 }
815 } 927 }
816 928
...@@ -1072,7 +1184,7 @@ ...@@ -1072,7 +1184,7 @@
1072 <span class="font-medium">Todo el Estado</span> 1184 <span class="font-medium">Todo el Estado</span>
1073 <span class="text-xs ml-2" style="color: var(--theme-texto);">(agregado nacional)</span> 1185 <span class="text-xs ml-2" style="color: var(--theme-texto);">(agregado nacional)</span>
1074 </button> 1186 </button>
1075 - <div class="overflow-y-auto max-h-48"> 1187 + <div class="overflow-y-auto max-h-64" onscroll={(e) => handleEntityScroll(e, 'main')}>
1076 {#each filteredEntities() as entity} 1188 {#each filteredEntities() as entity}
1077 <button 1189 <button
1078 class="w-full text-left px-3 py-2 text-sm" 1190 class="w-full text-left px-3 py-2 text-sm"
...@@ -1088,6 +1200,11 @@ ...@@ -1088,6 +1200,11 @@
1088 </div> 1200 </div>
1089 </button> 1201 </button>
1090 {/each} 1202 {/each}
1203 + {#if entityLimit < entities.length}
1204 + <div class="text-center py-2 text-xs" style="color: var(--theme-texto);">
1205 + Scroll para ver más ({entities.length - entityLimit} restantes)
1206 + </div>
1207 + {/if}
1091 </div> 1208 </div>
1092 </div> 1209 </div>
1093 {/if} 1210 {/if}
...@@ -1133,7 +1250,7 @@ ...@@ -1133,7 +1250,7 @@
1133 1250
1134 {#if loading} 1251 {#if loading}
1135 <div class="flex items-center justify-center py-20"> 1252 <div class="flex items-center justify-center py-20">
1136 - <p style="color: var(--theme-texto);">Cargando clasificador...</p> 1253 + <Spinner size={48} color="var(--theme-texto)" />
1137 </div> 1254 </div>
1138 {:else} 1255 {:else}
1139 {#if viewMode === 'lista'} 1256 {#if viewMode === 'lista'}
...@@ -1207,13 +1324,17 @@ ...@@ -1207,13 +1324,17 @@
1207 <div class="space-y-1"> 1324 <div class="space-y-1">
1208 {#each searchResults as result} 1325 {#each searchResults as result}
1209 <button 1326 <button
1210 - class="w-full text-left px-2 py-2 text-sm rounded transition-all" 1327 + class="search-result-item w-full text-left px-3 py-3 text-sm rounded-lg transition-all cursor-pointer"
1211 - style="color: var(--theme-titulo);"
1212 onclick={() => goToSearchResult(result)} 1328 onclick={() => goToSearchResult(result)}
1213 > 1329 >
1214 - <span class="text-xs block" style="color: var(--theme-texto);">{getNivelLabel(result.nivel)}</span> 1330 + <span class="text-xs block mb-0.5" style="color: var(--theme-texto);">{getNivelLabel(result.nivel)}</span>
1215 - <span class="font-mono text-xs font-medium" style="color: var(--theme-accent);">{result.objeto}</span> 1331 + <div class="flex items-baseline gap-2">
1216 - <span class="ml-1">{result.desc_objeto}</span> 1332 + <span class="font-mono text-xs font-medium" style="color: var(--theme-accent);">{result.objeto}</span>
1333 + <span class="flex-1" style="color: var(--theme-titulo);">{result.desc_objeto}</span>
1334 + <svg class="w-4 h-4 flex-shrink-0 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1335 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
1336 + </svg>
1337 + </div>
1217 </button> 1338 </button>
1218 {/each} 1339 {/each}
1219 {#if searchResults.length === 0} 1340 {#if searchResults.length === 0}
...@@ -1333,10 +1454,14 @@ ...@@ -1333,10 +1454,14 @@
1333 style="{highlightedItem === partida.objeto ? `box-shadow: 0 0 0 2px color-mix(in srgb, var(--theme-accent) 30%, transparent); border-radius: 0.5rem; padding: 0.5rem; margin-left: -0.5rem;` : ''}" 1454 style="{highlightedItem === partida.objeto ? `box-shadow: 0 0 0 2px color-mix(in srgb, var(--theme-accent) 30%, transparent); border-radius: 0.5rem; padding: 0.5rem; margin-left: -0.5rem;` : ''}"
1334 > 1455 >
1335 <div class="flex items-start gap-2 sm:gap-3"> 1456 <div class="flex items-start gap-2 sm:gap-3">
1336 - <span class="font-mono text-xs pt-0.5 shrink-0" style="color: var(--theme-texto);">{partida.objeto}</span> 1457 + <span class="font-mono text-xs pt-0.5 shrink-0" style="color: var(--theme-texto); {partida._sintetica ? 'opacity: 0.5;' : ''}">{partida.objeto}</span>
1337 <div class="flex-1 min-w-0"> 1458 <div class="flex-1 min-w-0">
1338 <h4 class="text-sm font-medium flex items-center gap-2 flex-wrap" style="color: var(--theme-titulo);"> 1459 <h4 class="text-sm font-medium flex items-center gap-2 flex-wrap" style="color: var(--theme-titulo);">
1339 - <span class="text-left">{partida.desc_objeto}</span> 1460 + {#if partida._sintetica}
1461 + <span class="text-left opacity-60 italic">(Partida inferida)</span>
1462 + {:else}
1463 + <span class="text-left">{partida.desc_objeto}</span>
1464 + {/if}
1340 {#if partida.n_variaciones > 1} 1465 {#if partida.n_variaciones > 1}
1341 <button 1466 <button
1342 class="text-xs font-normal text-orange-500 hover:text-orange-700 transition-colors underline decoration-dotted decoration-orange-300 hover:decoration-orange-500 cursor-pointer" 1467 class="text-xs font-normal text-orange-500 hover:text-orange-700 transition-colors underline decoration-dotted decoration-orange-300 hover:decoration-orange-500 cursor-pointer"
...@@ -1346,18 +1471,20 @@ ...@@ -1346,18 +1471,20 @@
1346 {partida.n_variaciones} var. 1471 {partida.n_variaciones} var.
1347 </button> 1472 </button>
1348 {/if} 1473 {/if}
1349 - <a 1474 + {#if !partida._sintetica}
1350 - href="/objeto/{partida.objeto}" 1475 + <a
1351 - class="transition-colors hover:text-[var(--theme-accent)]" 1476 + href="/objeto/{partida.objeto}"
1352 - style="color: var(--theme-texto);" 1477 + class="transition-colors hover:text-[var(--theme-accent)]"
1353 - title="Ver página de detalle" 1478 + style="color: var(--theme-texto);"
1354 - > 1479 + title="Ver página de detalle"
1355 - <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 1480 + >
1356 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /> 1481 + <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1357 - </svg> 1482 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
1358 - </a> 1483 + </svg>
1484 + </a>
1485 + {/if}
1359 </h4> 1486 </h4>
1360 - {#if partidaDescs.length > 0} 1487 + {#if partidaDescs.length > 0 && !partida._sintetica}
1361 <p class="text-sm mt-1 leading-relaxed" style="color: var(--theme-texto);">{partidaDescs[0].descripcion}</p> 1488 <p class="text-sm mt-1 leading-relaxed" style="color: var(--theme-texto);">{partidaDescs[0].descripcion}</p>
1362 {/if} 1489 {/if}
1363 </div> 1490 </div>
...@@ -1466,35 +1593,53 @@ ...@@ -1466,35 +1593,53 @@
1466 1593
1467 <div class="max-w-screen-xl mx-auto px-4 sm:px-6 pt-3 pb-4"> 1594 <div class="max-w-screen-xl mx-auto px-4 sm:px-6 pt-3 pb-4">
1468 1595
1469 - <!-- Breadcrumb de navegación (solo en modo jerárquico) --> 1596 + <!-- Header con breadcrumb y botón de ayuda -->
1470 - {#if treemapViewLevel === 'jerarquico' && treemapBreadcrumb.length > 0} 1597 + <div class="flex items-center justify-between mb-2">
1471 - <nav class="flex items-center gap-2 mb-2 flex-wrap" style="font-family: 'DM Mono', monospace; font-size: 0.75rem;"> 1598 + <!-- Breadcrumb de navegación (solo en modo jerárquico) -->
1472 - {#each treemapBreadcrumb as crumb, i} 1599 + {#if treemapViewLevel === 'jerarquico' && treemapBreadcrumb.length > 0}
1473 - {#if i > 0} 1600 + <nav class="flex items-center gap-2 flex-wrap" style="font-family: 'DM Mono', monospace; font-size: 0.75rem;">
1474 - <span style="color: var(--theme-texto); opacity: 0.5;">›</span> 1601 + {#each treemapBreadcrumb as crumb, i}
1475 - {/if} 1602 + {#if i > 0}
1476 - <button 1603 + <span style="color: var(--theme-texto); opacity: 0.5;">›</span>
1477 - onclick={() => drillUp(i)} 1604 + {/if}
1478 - class="px-2 py-1 rounded transition-colors" 1605 + <button
1479 - style="color: {i === treemapBreadcrumb.length - 1 ? 'var(--theme-titulo)' : 'var(--theme-texto)'}; background-color: {i === treemapBreadcrumb.length - 1 ? 'var(--theme-fill)' : 'transparent'}; cursor: pointer;" 1606 + onclick={() => drillUp(i)}
1480 - onmouseenter={(e) => e.currentTarget.style.backgroundColor = 'var(--theme-fill)'} 1607 + class="px-2 py-1 rounded transition-colors"
1481 - onmouseleave={(e) => e.currentTarget.style.backgroundColor = i === treemapBreadcrumb.length - 1 ? 'var(--theme-fill)' : 'transparent'} 1608 + style="color: {i === treemapBreadcrumb.length - 1 ? 'var(--theme-titulo)' : 'var(--theme-texto)'}; background-color: {i === treemapBreadcrumb.length - 1 ? 'var(--theme-fill)' : 'transparent'}; cursor: pointer;"
1482 - > 1609 + onmouseenter={(e) => e.currentTarget.style.backgroundColor = 'var(--theme-fill)'}
1483 - {crumb.name} 1610 + onmouseleave={(e) => e.currentTarget.style.backgroundColor = i === treemapBreadcrumb.length - 1 ? 'var(--theme-fill)' : 'transparent'}
1484 - </button> 1611 + >
1485 - {/each} 1612 + {crumb.name}
1486 - </nav> 1613 + </button>
1487 - {:else if treemapViewLevel !== 'jerarquico'} 1614 + {/each}
1488 - <!-- Info de nivel aplanado --> 1615 + </nav>
1489 - <div class="mb-2 flex items-center gap-3" style="font-family: 'DM Mono', monospace; font-size: 0.75rem;"> 1616 + {:else if treemapViewLevel !== 'jerarquico'}
1490 - <span class="px-2 py-1 rounded" style="background-color: var(--theme-fill); color: var(--theme-titulo);"> 1617 + <!-- Info de nivel aplanado -->
1491 - {NIVEL_OPTIONS.find(o => o.value === treemapViewLevel)?.label || treemapViewLevel} 1618 + <div class="flex items-center gap-3" style="font-family: 'DM Mono', monospace; font-size: 0.75rem;">
1492 - </span> 1619 + <span class="px-2 py-1 rounded" style="background-color: var(--theme-fill); color: var(--theme-titulo);">
1493 - <span style="color: var(--theme-texto);"> 1620 + {NIVEL_OPTIONS.find(o => o.value === treemapViewLevel)?.label || treemapViewLevel}
1494 - {treemapNodes.filter(n => n.type === 'item').length} categorías en {treemapNodes.filter(n => n.type === 'grupo').length} grupos 1621 + </span>
1495 - </span> 1622 + <span style="color: var(--theme-texto);">
1496 - </div> 1623 + {treemapNodes.filter(n => n.type === 'item').length} categorías en {treemapNodes.filter(n => n.type === 'grupo').length} grupos
1497 - {/if} 1624 + </span>
1625 + </div>
1626 + {:else}
1627 + <div></div>
1628 + {/if}
1629 +
1630 + <!-- Botón de ayuda -->
1631 + <button
1632 + onclick={() => showTreemapHelp = true}
1633 + class="flex items-center gap-1.5 px-2 py-1 rounded-lg text-xs transition-colors"
1634 + style="color: var(--theme-texto); background-color: var(--theme-fill);"
1635 + title="Cómo leer este gráfico"
1636 + >
1637 + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1638 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
1639 + </svg>
1640 + <span class="hidden sm:inline">Cómo leer</span>
1641 + </button>
1642 + </div>
1498 1643
1499 <!-- Contenedor del Treemap --> 1644 <!-- Contenedor del Treemap -->
1500 <div 1645 <div
...@@ -1782,7 +1927,7 @@ ...@@ -1782,7 +1927,7 @@
1782 > 1927 >
1783 <span class="font-medium">Todo el Estado</span> 1928 <span class="font-medium">Todo el Estado</span>
1784 </button> 1929 </button>
1785 - <div class="overflow-y-auto max-h-48"> 1930 + <div class="overflow-y-auto max-h-64" onscroll={(e) => handleEntityScroll(e, 'A')}>
1786 {#each filteredEntitiesA() as entity} 1931 {#each filteredEntitiesA() as entity}
1787 <button 1932 <button
1788 class="w-full text-left px-3 py-2 text-sm" 1933 class="w-full text-left px-3 py-2 text-sm"
...@@ -1795,6 +1940,11 @@ ...@@ -1795,6 +1940,11 @@
1795 </div> 1940 </div>
1796 </button> 1941 </button>
1797 {/each} 1942 {/each}
1943 + {#if compareALimit < entities.length}
1944 + <div class="text-center py-2 text-xs" style="color: var(--theme-texto);">
1945 + Scroll para ver más
1946 + </div>
1947 + {/if}
1798 </div> 1948 </div>
1799 </div> 1949 </div>
1800 {/if} 1950 {/if}
...@@ -1880,7 +2030,7 @@ ...@@ -1880,7 +2030,7 @@
1880 > 2030 >
1881 <span class="font-medium">Todo el Estado</span> 2031 <span class="font-medium">Todo el Estado</span>
1882 </button> 2032 </button>
1883 - <div class="overflow-y-auto max-h-48"> 2033 + <div class="overflow-y-auto max-h-64" onscroll={(e) => handleEntityScroll(e, 'B')}>
1884 {#each filteredEntitiesB() as entity} 2034 {#each filteredEntitiesB() as entity}
1885 <button 2035 <button
1886 class="w-full text-left px-3 py-2 text-sm" 2036 class="w-full text-left px-3 py-2 text-sm"
...@@ -1893,6 +2043,11 @@ ...@@ -1893,6 +2043,11 @@
1893 </div> 2043 </div>
1894 </button> 2044 </button>
1895 {/each} 2045 {/each}
2046 + {#if compareBLimit < entities.length}
2047 + <div class="text-center py-2 text-xs" style="color: var(--theme-texto);">
2048 + Scroll para ver más
2049 + </div>
2050 + {/if}
1896 </div> 2051 </div>
1897 </div> 2052 </div>
1898 {/if} 2053 {/if}
...@@ -1992,6 +2147,109 @@ ...@@ -1992,6 +2147,109 @@
1992 </div> 2147 </div>
1993 {/if} 2148 {/if}
1994 2149
2150 +<!-- Modal de ayuda para el Treemap -->
2151 +{#if showTreemapHelp}
2152 + <div
2153 + class="fixed inset-0 z-50 flex items-center justify-center p-4"
2154 + onclick={() => showTreemapHelp = false}
2155 + role="dialog"
2156 + aria-modal="true"
2157 + >
2158 + <!-- Overlay -->
2159 + <div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
2160 +
2161 + <!-- Modal content -->
2162 + <div
2163 + class="relative w-full max-w-lg rounded-2xl shadow-2xl overflow-hidden"
2164 + style="background-color: var(--theme-body);"
2165 + onclick={(e) => e.stopPropagation()}
2166 + >
2167 + <!-- Header -->
2168 + <div class="flex items-center justify-between px-6 py-4 border-b" style="border-color: var(--theme-borde);">
2169 + <div class="flex items-center gap-3">
2170 + <div class="w-10 h-10 rounded-xl flex items-center justify-center" style="background-color: var(--theme-fill);">
2171 + <svg class="w-5 h-5" style="color: var(--theme-accent);" fill="none" stroke="currentColor" viewBox="0 0 24 24">
2172 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
2173 + </svg>
2174 + </div>
2175 + <h3 class="text-lg font-semibold" style="color: var(--theme-titulo);">Cómo leer este gráfico</h3>
2176 + </div>
2177 + <button
2178 + onclick={() => showTreemapHelp = false}
2179 + class="p-2 rounded-lg transition-colors"
2180 + style="color: var(--theme-texto);"
2181 + >
2182 + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
2183 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
2184 + </svg>
2185 + </button>
2186 + </div>
2187 +
2188 + <!-- Body -->
2189 + <div class="px-6 py-5 space-y-4" style="color: var(--theme-texto);">
2190 + <div class="flex gap-4">
2191 + <div class="w-12 h-12 rounded-lg flex-shrink-0 flex items-center justify-center" style="background-color: color-mix(in srgb, var(--theme-accent) 15%, transparent);">
2192 + <svg class="w-6 h-6" style="color: var(--theme-accent);" fill="none" stroke="currentColor" viewBox="0 0 24 24">
2193 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
2194 + </svg>
2195 + </div>
2196 + <div>
2197 + <h4 class="font-medium mb-1" style="color: var(--theme-titulo);">Tamaño = Proporción del gasto</h4>
2198 + <p class="text-sm leading-relaxed">Cada rectángulo representa una categoría de gasto. Cuanto más grande es el rectángulo, mayor es el monto gastado en esa categoría.</p>
2199 + </div>
2200 + </div>
2201 +
2202 + <div class="flex gap-4">
2203 + <div class="w-12 h-12 rounded-lg flex-shrink-0 flex items-center justify-center" style="background-color: color-mix(in srgb, var(--theme-accent) 15%, transparent);">
2204 + <svg class="w-6 h-6" style="color: var(--theme-accent);" fill="none" stroke="currentColor" viewBox="0 0 24 24">
2205 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
2206 + </svg>
2207 + </div>
2208 + <div>
2209 + <h4 class="font-medium mb-1" style="color: var(--theme-titulo);">Colores por grupo</h4>
2210 + <p class="text-sm leading-relaxed">Cada color representa un grupo principal del clasificador. Las categorías del mismo grupo comparten el mismo color.</p>
2211 + </div>
2212 + </div>
2213 +
2214 + <div class="flex gap-4">
2215 + <div class="w-12 h-12 rounded-lg flex-shrink-0 flex items-center justify-center" style="background-color: color-mix(in srgb, var(--theme-accent) 15%, transparent);">
2216 + <svg class="w-6 h-6" style="color: var(--theme-accent);" fill="none" stroke="currentColor" viewBox="0 0 24 24">
2217 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122" />
2218 + </svg>
2219 + </div>
2220 + <div>
2221 + <h4 class="font-medium mb-1" style="color: var(--theme-titulo);">Hover para ver detalle</h4>
2222 + <p class="text-sm leading-relaxed">Pasa el cursor sobre cualquier rectángulo para ver el nombre de la categoría, el monto devengado y su porcentaje respecto al total.</p>
2223 + </div>
2224 + </div>
2225 +
2226 + <div class="flex gap-4">
2227 + <div class="w-12 h-12 rounded-lg flex-shrink-0 flex items-center justify-center" style="background-color: color-mix(in srgb, var(--theme-accent) 15%, transparent);">
2228 + <svg class="w-6 h-6" style="color: var(--theme-accent);" fill="none" stroke="currentColor" viewBox="0 0 24 24">
2229 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7" />
2230 + </svg>
2231 + </div>
2232 + <div>
2233 + <h4 class="font-medium mb-1" style="color: var(--theme-titulo);">Explorar (modo jerárquico)</h4>
2234 + <p class="text-sm leading-relaxed">Haz clic en <span class="inline-flex items-center justify-center w-5 h-5 rounded-full text-xs font-bold mx-1" style="background-color: var(--theme-fill); border: 1px solid var(--theme-borde);">+</span> para entrar en un grupo y ver sus subcategorías en detalle.</p>
2235 + </div>
2236 + </div>
2237 + </div>
2238 +
2239 + <!-- Footer -->
2240 + <div class="px-6 py-4 border-t" style="border-color: var(--theme-borde); background-color: var(--theme-fill);">
2241 + <button
2242 + onclick={() => showTreemapHelp = false}
2243 + class="w-full py-2.5 rounded-lg font-medium transition-colors"
2244 + style="background-color: var(--theme-accent); color: white;"
2245 + >
2246 + Entendido
2247 + </button>
2248 + </div>
2249 + </div>
2250 + </div>
2251 +{/if}
2252 +
1995 <style> 2253 <style>
1996 /* Dropdown controls - tema oscuro por defecto */ 2254 /* Dropdown controls - tema oscuro por defecto */
1997 .dropdown-control { 2255 .dropdown-control {
...@@ -2051,4 +2309,33 @@ ...@@ -2051,4 +2309,33 @@
2051 opacity: 0; 2309 opacity: 0;
2052 transform: scale(0.95); 2310 transform: scale(0.95);
2053 } 2311 }
2312 +
2313 + /* Search result items - más clickeables */
2314 + .search-result-item {
2315 + background: var(--theme-surface);
2316 + border: 1px solid var(--theme-borde);
2317 + color: var(--theme-titulo);
2318 + }
2319 + .search-result-item:hover {
2320 + background: var(--theme-fill);
2321 + border-color: var(--theme-accent);
2322 + transform: translateX(4px);
2323 + }
2324 + .search-result-item:active {
2325 + transform: translateX(2px);
2326 + opacity: 0.9;
2327 + }
2328 +
2329 + /* Tema claro */
2330 + :global(.light) .search-result-item,
2331 + :global(html:not(.dark)) .search-result-item {
2332 + background: transparent;
2333 + border: none;
2334 + box-shadow: inset 0 0 0 1px rgba(0,0,0,0.06);
2335 + }
2336 + :global(.light) .search-result-item:hover,
2337 + :global(html:not(.dark)) .search-result-item:hover {
2338 + background: rgba(0,0,0,0.03);
2339 + box-shadow: inset 0 0 0 1px var(--theme-accent);
2340 + }
2054 </style> 2341 </style>
......
1 <script> 1 <script>
2 + import { onMount } from 'svelte';
3 + import { tweened } from 'svelte/motion';
4 + import { cubicOut } from 'svelte/easing';
5 + import { supabase } from '$lib/supabase';
6 + import Spinner from '$lib/components/ui/Spinner.svelte';
7 +
8 + // Datos del servidor (reactivos)
2 let { data } = $props(); 9 let { data } = $props();
10 + let objetoData = $derived(data.objeto);
11 + let padres = $derived(data.padres);
12 + let hijos = $derived(data.hijos);
13 +
14 + // Código y nivel del objeto desde datos del servidor (reactivos)
15 + let objetoCodigo = $derived(objetoData.objeto);
16 + let objetoNivel = $derived(objetoData.nivel);
3 17
4 - const objeto = data.objeto; 18 + // ══════════════════════════════════════════════════════════════
5 - const padres = data.padres; 19 + // ESTADO GENERAL
6 - const hijos = data.hijos; 20 + // ══════════════════════════════════════════════════════════════
21 + let mounted = $state(false);
22 + let loading = $state(true);
23 + let hoveredYear = $state(null);
24 + let showInfo = $state(false);
25 + let vista = $state('agregado'); // 'agregado' | 'comparar'
26 + let isDark = $state(true);
7 27
28 + // Parsear descripciones del objeto
8 function parseDescripciones(descripcionesStr) { 29 function parseDescripciones(descripcionesStr) {
9 if (!descripcionesStr) return []; 30 if (!descripcionesStr) return [];
10 try { 31 try {
11 const parsed = JSON.parse(descripcionesStr); 32 const parsed = JSON.parse(descripcionesStr);
12 - // Ordenar por año más reciente (extraer el máximo año de cada rango)
13 return parsed.sort((a, b) => { 33 return parsed.sort((a, b) => {
14 const maxYearA = getMaxYear(a.rangos); 34 const maxYearA = getMaxYear(a.rangos);
15 const maxYearB = getMaxYear(b.rangos); 35 const maxYearB = getMaxYear(b.rangos);
16 - return maxYearB - maxYearA; // Descendente, más reciente primero 36 + return maxYearB - maxYearA;
17 }); 37 });
18 } catch { 38 } catch {
19 return []; 39 return [];
...@@ -32,138 +52,2430 @@ ...@@ -32,138 +52,2430 @@
32 return labels[nivel] || nivel; 52 return labels[nivel] || nivel;
33 } 53 }
34 54
35 - const descripciones = parseDescripciones(objeto.descripciones); 55 + let descripciones = $derived(parseDescripciones(objetoData.descripciones));
56 +
57 + // Info del objeto (reactivo)
58 + let objeto = $derived({
59 + codigo: objetoData.objeto,
60 + nombre: objetoData.desc_objeto,
61 + nivel: getNivelLabel(objetoData.nivel),
62 + años: objetoData.gestiones || '',
63 + descripcion: descripciones.length > 0 ? descripciones[0].descripcion : '',
64 + variaciones: descripciones.map(d => ({ rango: d.rangos, texto: d.descripcion }))
65 + });
66 +
67 + // ══════════════════════════════════════════════════════════════
68 + // ESTADO SELECTOR DE ENTIDAD
69 + // ══════════════════════════════════════════════════════════════
70 + let entidades = $state([]); // Lista de entidades disponibles
71 + let selectedEntity = $state(null); // null = Todo el Estado
72 + let entitySearchQuery = $state('');
73 + let entityDropdownOpen = $state(false);
74 +
75 + // Límites dinámicos para scroll infinito
76 + let entityLimit = $state(30);
77 + let entityLimitA = $state(30);
78 + let entityLimitB = $state(30);
79 +
80 + // Filtrar entidades por búsqueda
81 + let filteredEntities = $derived.by(() => {
82 + if (!entitySearchQuery || entitySearchQuery.length < 2) {
83 + return entidades.slice(0, entityLimit);
84 + }
85 + const q = entitySearchQuery.toLowerCase();
86 + return entidades
87 + .filter(e =>
88 + e.entidad_desc?.toLowerCase().includes(q) ||
89 + e.entidad?.toString().includes(q)
90 + )
91 + .slice(0, entityLimit);
92 + });
93 +
94 + // Handler para cargar más entidades al hacer scroll
95 + function handleEntityScroll(event, type) {
96 + const el = event.target;
97 + const threshold = 50;
98 + if (el.scrollHeight - el.scrollTop - el.clientHeight < threshold) {
99 + if (type === 'main' && entityLimit < entidades.length) {
100 + entityLimit = Math.min(entityLimit + 30, entidades.length);
101 + } else if (type === 'A' && entityLimitA < entidades.length) {
102 + entityLimitA = Math.min(entityLimitA + 30, entidades.length);
103 + } else if (type === 'B' && entityLimitB < entidades.length) {
104 + entityLimitB = Math.min(entityLimitB + 30, entidades.length);
105 + }
106 + }
107 + }
108 +
109 + function selectEntity(entity) {
110 + selectedEntity = entity;
111 + entityDropdownOpen = false;
112 + entitySearchQuery = '';
113 + entityLimit = 30;
114 + loadData();
115 + }
116 +
117 + function clearEntity() {
118 + selectedEntity = null;
119 + entityDropdownOpen = false;
120 + entitySearchQuery = '';
121 + entityLimit = 30;
122 + loadData();
123 + }
124 +
125 + // ══════════════════════════════════════════════════════════════
126 + // DATOS DE SUPABASE
127 + // ══════════════════════════════════════════════════════════════
128 + let datosAnuales = $state([]); // Datos por año
129 + let datosHistorico = $state(null); // Fila con gestion=0
130 + let topEntidadesPorAño = $state({}); // Top 3 por año (solo para Todo el Estado)
131 +
132 + // Cargar datos según selección
133 + async function loadData() {
134 + loading = true;
135 +
136 + if (selectedEntity) {
137 + // Cargar desde vista_objeto_entidad
138 + const { data, error } = await supabase
139 + .schema('ppto')
140 + .from('vista_objeto_entidad')
141 + .select('*')
142 + .eq('objeto', objetoCodigo)
143 + .eq('nivel', objetoNivel)
144 + .eq('entidad', selectedEntity.entidad)
145 + .order('gestion');
146 +
147 + if (data) {
148 + datosHistorico = data.find(d => d.gestion === 0) || null;
149 + datosAnuales = data.filter(d => d.gestion > 0);
150 + topEntidadesPorAño = {}; // No hay top entidades cuando se filtra por entidad
151 + }
152 + } else {
153 + // Cargar desde vista_objeto_estado (Todo el Estado)
154 + const { data, error } = await supabase
155 + .schema('ppto')
156 + .from('vista_objeto_estado')
157 + .select('*')
158 + .eq('objeto', objetoCodigo)
159 + .order('gestion');
160 +
161 + if (data) {
162 + datosHistorico = data.find(d => d.gestion === 0) || null;
163 + datosAnuales = data.filter(d => d.gestion > 0);
164 +
165 + // Construir top entidades por año
166 + topEntidadesPorAño = {};
167 + datosAnuales.forEach(d => {
168 + topEntidadesPorAño[d.gestion] = [
169 + { nombre: d.top1_entidad, monto: d.top1_monto, porcentaje: d.top1_pct },
170 + { nombre: d.top2_entidad, monto: d.top2_monto, porcentaje: d.top2_pct },
171 + { nombre: d.top3_entidad, monto: d.top3_monto, porcentaje: d.top3_pct },
172 + ].filter(e => e.nombre);
173 + });
174 + }
175 + }
176 +
177 + loading = false;
178 + }
179 +
180 + // Cargar lista de entidades disponibles para este objeto
181 + async function loadEntidades() {
182 + const { data, error } = await supabase
183 + .schema('ppto')
184 + .from('entidades_por_objeto')
185 + .select('entidad, entidad_desc')
186 + .eq('objeto', objetoCodigo)
187 + .eq('nivel', objetoNivel)
188 + .order('entidad_desc');
189 +
190 + if (data) {
191 + entidades = data;
192 + }
193 + }
194 +
195 + // ══════════════════════════════════════════════════════════════
196 + // VALORES DERIVADOS
197 + // ══════════════════════════════════════════════════════════════
198 +
199 + // Datos para el gráfico (monto o per_capita según preferencia)
200 + let gastoPerCapita = $derived(
201 + datosAnuales.map(d => ({
202 + año: d.gestion,
203 + monto: selectedEntity ? d.monto : d.total,
204 + perCapita: d.per_capita
205 + }))
206 + );
207 +
208 + let maxPerCapita = $derived(
209 + gastoPerCapita.length > 0 ? Math.max(...gastoPerCapita.map(g => g.perCapita)) : 1
210 + );
211 +
212 + // Histórico
213 + let totalHistorico = $derived(datosHistorico ? (selectedEntity ? datosHistorico.monto : datosHistorico.total) : 0);
214 + let promedioPerCapita = $derived(datosHistorico?.per_capita || 0);
215 + let totalPorcentaje = $derived(datosHistorico?.prop || 0);
216 + let ranking = $derived(datosHistorico?.ranking || 0);
217 + let nPares = $derived(datosHistorico?.n_pares || 0);
218 + let nEntidades = $derived(datosHistorico?.n_entidades || 0);
219 + // Número de entidades - cambia según el año seleccionado y la entidad seleccionada
220 + let totalEntidadesHistorico = $derived.by(() => {
221 + if (selectedEntity && datosAnuales.length > 0) {
222 + // Para entidad seleccionada: promedio de n_entidades de los años que tiene esta entidad
223 + // Esto refleja cuántas entidades participaron durante el período de vida de esta entidad
224 + const suma = datosAnuales.reduce((acc, d) => acc + (d.n_entidades || 0), 0);
225 + return Math.round(suma / datosAnuales.length);
226 + }
227 + // Para "Todo el Estado": usar la lista del dropdown o el histórico
228 + return entidades.length || nEntidades;
229 + });
230 +
231 + let totalEntidades = $derived.by(() => {
232 + if (hoveredYear) {
233 + // Buscar n_entidades del año específico
234 + const añoData = datosAnuales.find(d => d.gestion === hoveredYear);
235 + return añoData?.n_entidades || totalEntidadesHistorico;
236 + }
237 + return totalEntidadesHistorico;
238 + });
239 +
240 + // Para el ranking: valores reactivos según hover
241 + let displayRankingValue = $derived.by(() => {
242 + if (hoveredYear) {
243 + const añoData = datosAnuales.find(d => d.gestion === hoveredYear);
244 + return añoData?.ranking || ranking;
245 + }
246 + return ranking;
247 + });
248 +
249 + let rankingDenominador = $derived.by(() => {
250 + if (selectedEntity) {
251 + // Para entidad: usar n_entidades del año específico o del histórico
252 + if (hoveredYear) {
253 + const añoData = datosAnuales.find(d => d.gestion === hoveredYear);
254 + return añoData?.n_entidades || nEntidades;
255 + }
256 + // Sin hover: usar el n_entidades del histórico (total de entidades únicas del período)
257 + return nEntidades;
258 + }
259 + // Para "Todo el Estado": comparación entre objetos del mismo nivel
260 + return nPares;
261 + });
262 +
263 + // Top entidades histórico (solo para Todo el Estado)
264 + let topEntidadesTotal = $derived(
265 + datosHistorico && !selectedEntity ? [
266 + { nombre: datosHistorico.top1_entidad, monto: datosHistorico.top1_monto, porcentaje: datosHistorico.top1_pct },
267 + { nombre: datosHistorico.top2_entidad, monto: datosHistorico.top2_monto, porcentaje: datosHistorico.top2_pct },
268 + { nombre: datosHistorico.top3_entidad, monto: datosHistorico.top3_monto, porcentaje: datosHistorico.top3_pct },
269 + ].filter(e => e.nombre) : []
270 + );
271 +
272 + // Años disponibles
273 + let primerAño = $derived(gastoPerCapita.length > 0 ? gastoPerCapita[0].año : 2005);
274 + let ultimoAño = $derived(gastoPerCapita.length > 0 ? gastoPerCapita[gastoPerCapita.length - 1].año : 2025);
275 +
276 + // Current view data (changes on hover)
277 + let currentData = $derived(
278 + hoveredYear ? gastoPerCapita.find(g => g.año === hoveredYear) : null
279 + );
280 +
281 + let displayMonto = $derived(currentData ? currentData.monto : totalHistorico);
282 + let displayMontoLabel = $derived(currentData ? `gasto en ${currentData.año}` : `gasto ${primerAño}-${ultimoAño}`);
283 + let displayPerCapita = $derived(currentData ? currentData.perCapita : promedioPerCapita);
284 + let displayPerCapitaLabel = $derived(currentData ? `por persona en ${currentData.año}` : 'por persona (promedio)');
285 + let displayPorcentaje = $derived(
286 + currentData && totalHistorico > 0
287 + ? (totalPorcentaje * (currentData.monto / (totalHistorico / gastoPerCapita.length))).toFixed(1)
288 + : totalPorcentaje
289 + );
290 + let displayPorcentajeLabel = $derived(currentData ? `del gasto ${currentData.año}` : 'del gasto total');
291 + let displayPeriodo = $derived(currentData ? currentData.año : `${primerAño}-${ultimoAño}`);
292 + let displayRanking = $derived(`${displayRankingValue} de ${rankingDenominador}`);
293 + let displayRankingLabel = $derived(
294 + selectedEntity
295 + ? (currentData ? `entre las que más gastan en ${currentData.año}` : 'entre las que más gastan')
296 + : (currentData ? `ranking en ${currentData.año}` : 'ranking entre grupos')
297 + );
298 +
299 + // Top entidades reactivo según hover
300 + let displayTopEntidades = $derived(
301 + hoveredYear && topEntidadesPorAño[hoveredYear]
302 + ? topEntidadesPorAño[hoveredYear]
303 + : topEntidadesTotal
304 + );
305 + let displayTopLabel = $derived(hoveredYear ? `en ${hoveredYear}` : `${primerAño}-${ultimoAño}`);
306 +
307 + // ══════════════════════════════════════════════════════════════
308 + // TWEENED VALUES
309 + // ══════════════════════════════════════════════════════════════
310 + const tweenOptions = { duration: 300, easing: cubicOut };
311 +
312 + const twMonto = tweened(0, tweenOptions);
313 + const twPerCapita = tweened(0, tweenOptions);
314 + const twPorcentaje = tweened(0, tweenOptions);
315 + const twTop1 = tweened(0, tweenOptions);
316 + const twTop2 = tweened(0, tweenOptions);
317 + const twTop3 = tweened(0, tweenOptions);
318 +
319 + // ══════════════════════════════════════════════════════════════
320 + // COMPARACIÓN - Datos reales de Supabase
321 + // ══════════════════════════════════════════════════════════════
322 + const twMontoA = tweened(0, tweenOptions);
323 + const twMontoB = tweened(0, tweenOptions);
324 + const twPerCapitaA = tweened(0, tweenOptions);
325 + const twPerCapitaB = tweened(0, tweenOptions);
326 +
327 + let hoveredYearComparar = $state(null);
328 + let loadingComparacion = $state(false);
329 +
330 + // Entidades seleccionadas para comparar
331 + let entidadCompararA = $state(null);
332 + let entidadCompararB = $state(null);
333 +
334 + // Datos cargados de cada entidad
335 + let datosCompararA = $state({ anual: [], historico: null });
336 + let datosCompararB = $state({ anual: [], historico: null });
337 +
338 + // Dropdown states para comparación
339 + let dropdownAOpen = $state(false);
340 + let dropdownBOpen = $state(false);
341 + let searchA = $state('');
342 + let searchB = $state('');
343 +
344 + // Filtrar entidades para cada dropdown
345 + let filteredEntidadesA = $derived.by(() => {
346 + if (!searchA || searchA.length < 2) return entidades.slice(0, entityLimitA);
347 + const q = searchA.toLowerCase();
348 + return entidades.filter(e =>
349 + e.entidad_desc?.toLowerCase().includes(q) ||
350 + e.entidad?.toString().includes(q)
351 + ).slice(0, entityLimitA);
352 + });
353 +
354 + let filteredEntidadesB = $derived.by(() => {
355 + if (!searchB || searchB.length < 2) return entidades.slice(0, entityLimitB);
356 + const q = searchB.toLowerCase();
357 + return entidades.filter(e =>
358 + e.entidad_desc?.toLowerCase().includes(q) ||
359 + e.entidad?.toString().includes(q)
360 + ).slice(0, entityLimitB);
361 + });
362 +
363 + // Cargar datos de una entidad para comparación
364 + async function loadDatosEntidad(entidad) {
365 + const { data, error } = await supabase
366 + .schema('ppto')
367 + .from('vista_objeto_entidad')
368 + .select('*')
369 + .eq('objeto', objetoCodigo)
370 + .eq('nivel', objetoNivel)
371 + .eq('entidad', entidad.entidad)
372 + .order('gestion');
373 +
374 + if (data) {
375 + return {
376 + anual: data.filter(d => d.gestion > 0),
377 + historico: data.find(d => d.gestion === 0) || null
378 + };
379 + }
380 + return { anual: [], historico: null };
381 + }
382 +
383 + // Seleccionar entidad A
384 + async function selectEntidadA(entidad) {
385 + entidadCompararA = entidad;
386 + dropdownAOpen = false;
387 + searchA = '';
388 + entityLimitA = 30;
389 + loadingComparacion = true;
390 + datosCompararA = await loadDatosEntidad(entidad);
391 + loadingComparacion = false;
392 + }
393 +
394 + // Seleccionar entidad B
395 + async function selectEntidadB(entidad) {
396 + entidadCompararB = entidad;
397 + dropdownBOpen = false;
398 + searchB = '';
399 + entityLimitB = 30;
400 + loadingComparacion = true;
401 + datosCompararB = await loadDatosEntidad(entidad);
402 + loadingComparacion = false;
403 + }
404 +
405 + // Selector de rango de años
406 + let rangoAños = $state('10'); // '5', '10', '15', 'todo'
407 +
408 + // Años comunes entre ambas entidades
409 + let añosComunesTotales = $derived.by(() => {
410 + if (!datosCompararA.anual.length || !datosCompararB.anual.length) return [];
411 + const añosA = new Set(datosCompararA.anual.map(d => d.gestion));
412 + const añosB = new Set(datosCompararB.anual.map(d => d.gestion));
413 + return [...añosA].filter(a => añosB.has(a)).sort((a, b) => a - b);
414 + });
415 +
416 + // Años filtrados según el rango seleccionado
417 + let añosComparacion = $derived.by(() => {
418 + if (rangoAños === 'todo') return añosComunesTotales;
419 + const n = parseInt(rangoAños);
420 + return añosComunesTotales.slice(-n);
421 + });
422 +
423 + // Datos formateados para el gráfico
424 + let datosGraficoA = $derived.by(() => {
425 + const map = new Map(datosCompararA.anual.map(d => [d.gestion, d]));
426 + return añosComparacion.map(año => map.get(año) || { per_capita: 0, monto: 0 });
427 + });
428 +
429 + let datosGraficoB = $derived.by(() => {
430 + const map = new Map(datosCompararB.anual.map(d => [d.gestion, d]));
431 + return añosComparacion.map(año => map.get(año) || { per_capita: 0, monto: 0 });
432 + });
433 +
434 + // Máximo para escala del gráfico
435 + let maxComparacion = $derived.by(() => {
436 + const allPerCapita = [
437 + ...datosGraficoA.map(d => d.per_capita || 0),
438 + ...datosGraficoB.map(d => d.per_capita || 0)
439 + ];
440 + return Math.max(...allPerCapita, 1) * 1.1;
441 + });
442 +
443 + // Período para mostrar
444 + let displayPeriodoComparar = $derived.by(() => {
445 + if (hoveredYearComparar) return hoveredYearComparar;
446 + if (añosComparacion.length > 0) {
447 + return `${añosComparacion[0]}-${añosComparacion[añosComparacion.length - 1]}`;
448 + }
449 + return 'Selecciona entidades';
450 + });
451 +
452 + // Valores reactivos para comparación (peso y ranking)
453 + let displayPropA = $derived.by(() => {
454 + if (hoveredYearComparar) {
455 + const idx = añosComparacion.indexOf(hoveredYearComparar);
456 + if (idx >= 0) return datosGraficoA[idx]?.prop || 0;
457 + }
458 + return datosCompararA.historico?.prop || 0;
459 + });
460 +
461 + let displayPropB = $derived.by(() => {
462 + if (hoveredYearComparar) {
463 + const idx = añosComparacion.indexOf(hoveredYearComparar);
464 + if (idx >= 0) return datosGraficoB[idx]?.prop || 0;
465 + }
466 + return datosCompararB.historico?.prop || 0;
467 + });
468 +
469 + let displayRankingA = $derived.by(() => {
470 + if (hoveredYearComparar) {
471 + const idx = añosComparacion.indexOf(hoveredYearComparar);
472 + if (idx >= 0) return datosGraficoA[idx]?.ranking || '-';
473 + }
474 + return datosCompararA.historico?.ranking || '-';
475 + });
476 +
477 + let displayRankingB = $derived.by(() => {
478 + if (hoveredYearComparar) {
479 + const idx = añosComparacion.indexOf(hoveredYearComparar);
480 + if (idx >= 0) return datosGraficoB[idx]?.ranking || '-';
481 + }
482 + return datosCompararB.historico?.ranking || '-';
483 + });
484 +
485 + let displayNEntidadesA = $derived.by(() => {
486 + if (hoveredYearComparar) {
487 + const idx = añosComparacion.indexOf(hoveredYearComparar);
488 + if (idx >= 0) return datosGraficoA[idx]?.n_entidades || '-';
489 + }
490 + return datosCompararA.historico?.n_entidades || '-';
491 + });
492 +
493 + let displayNEntidadesB = $derived.by(() => {
494 + if (hoveredYearComparar) {
495 + const idx = añosComparacion.indexOf(hoveredYearComparar);
496 + if (idx >= 0) return datosGraficoB[idx]?.n_entidades || '-';
497 + }
498 + return datosCompararB.historico?.n_entidades || '-';
499 + });
500 +
501 + // Colores
502 + let colorA = '#2a9d8f';
503 + let colorB = '#f4a261';
504 +
505 + // Actualizar tweened de comparación
506 + $effect(() => {
507 + const year = hoveredYearComparar;
508 + const idx = year ? añosComparacion.indexOf(year) : -1;
509 +
510 + let montoA, montoB, perCapitaA, perCapitaB;
511 +
512 + if (idx >= 0) {
513 + montoA = toNumber(datosGraficoA[idx]?.monto);
514 + montoB = toNumber(datosGraficoB[idx]?.monto);
515 + perCapitaA = toNumber(datosGraficoA[idx]?.per_capita);
516 + perCapitaB = toNumber(datosGraficoB[idx]?.per_capita);
517 + } else {
518 + // Histórico o suma
519 + montoA = toNumber(datosCompararA.historico?.monto);
520 + montoB = toNumber(datosCompararB.historico?.monto);
521 + perCapitaA = toNumber(datosCompararA.historico?.per_capita);
522 + perCapitaB = toNumber(datosCompararB.historico?.per_capita);
523 + }
524 +
525 + twMontoA.set(montoA);
526 + twMontoB.set(montoB);
527 + twPerCapitaA.set(perCapitaA);
528 + twPerCapitaB.set(perCapitaB);
529 + });
530 +
531 + // Helper para asegurar que siempre tengamos un número
532 + function toNumber(val) {
533 + const n = parseFloat(val);
534 + return isNaN(n) ? 0 : n;
535 + }
536 +
537 + // Actualizar tweened cuando cambian los valores
538 + // Leemos TODAS las dependencias explícitamente para que el effect se re-ejecute
539 + $effect(() => {
540 + // Leer todas las dependencias primero (para que Svelte las trackee)
541 + const year = hoveredYear;
542 + const datos = gastoPerCapita; // Leer siempre
543 + const histTotal = toNumber(totalHistorico);
544 + const histPerCapita = toNumber(promedioPerCapita);
545 + const histPorcentaje = toNumber(totalPorcentaje);
546 +
547 + // Calcular valores
548 + const data = year ? datos.find(g => g.año === year) : null;
549 + const monto = toNumber(data ? data.monto : histTotal);
550 + const perCapita = toNumber(data ? data.perCapita : histPerCapita);
551 + const porcentaje = data && histTotal > 0
552 + ? toNumber((histPorcentaje * (data.monto / (histTotal / datos.length))).toFixed(1))
553 + : histPorcentaje;
554 +
555 + twMonto.set(monto);
556 + twPerCapita.set(perCapita);
557 + twPorcentaje.set(porcentaje);
558 + });
559 +
560 + $effect(() => {
561 + // Leer todas las dependencias primero
562 + const year = hoveredYear;
563 + const topsPorAño = topEntidadesPorAño;
564 + const topsTotal = topEntidadesTotal;
565 +
566 + const tops = year && topsPorAño[year] ? topsPorAño[year] : topsTotal;
567 +
568 + twTop1.set(toNumber(tops?.[0]?.porcentaje));
569 + twTop2.set(toNumber(tops?.[1]?.porcentaje));
570 + twTop3.set(toNumber(tops?.[2]?.porcentaje));
571 + });
572 +
573 + // ══════════════════════════════════════════════════════════════
574 + // MOUNT
575 + // ══════════════════════════════════════════════════════════════
576 + onMount(async () => {
577 + setTimeout(() => { mounted = true; }, 50);
578 +
579 + // Leer estado del tema
580 + isDark = document.documentElement.classList.contains('dark');
581 +
582 + // Observar cambios de tema
583 + const observer = new MutationObserver(() => {
584 + isDark = document.documentElement.classList.contains('dark');
585 + });
586 + observer.observe(document.documentElement, {
587 + attributes: true,
588 + attributeFilter: ['class']
589 + });
590 +
591 + // Cargar datos
592 + await Promise.all([loadData(), loadEntidades()]);
593 +
594 + // Cerrar dropdown al hacer click fuera
595 + const handleClickOutside = (e) => {
596 + if (entityDropdownOpen && !e.target.closest('.entity-dropdown-container')) {
597 + entityDropdownOpen = false;
598 + }
599 + // Dropdowns de comparación
600 + if (dropdownAOpen && !e.target.closest('.comparador-dropdown-container')) {
601 + dropdownAOpen = false;
602 + }
603 + if (dropdownBOpen && !e.target.closest('.comparador-dropdown-container')) {
604 + dropdownBOpen = false;
605 + }
606 + };
607 + document.addEventListener('click', handleClickOutside);
608 +
609 + return () => {
610 + observer.disconnect();
611 + document.removeEventListener('click', handleClickOutside);
612 + };
613 + });
614 +
615 + // Recargar datos cuando cambia el objeto (navegación interna)
616 + let previousCodigo = objetoCodigo;
617 + $effect(() => {
618 + if (mounted && objetoCodigo !== previousCodigo) {
619 + previousCodigo = objetoCodigo;
620 + // Resetear estado
621 + loading = true;
622 + selectedEntity = null;
623 + entidadCompararA = null;
624 + entidadCompararB = null;
625 + datosCompararA = { anual: [], historico: null };
626 + datosCompararB = { anual: [], historico: null };
627 + vista = 'agregado';
628 + // Recargar datos
629 + loadData();
630 + }
631 + });
632 +
633 + // ══════════════════════════════════════════════════════════════
634 + // HELPERS
635 + // ══════════════════════════════════════════════════════════════
636 + function formatMonto(m) {
637 + if (m >= 1e9) return (m / 1e9).toFixed(1) + 'MM';
638 + if (m >= 1e6) return (m / 1e6).toFixed(1) + 'M';
639 + if (m >= 1e3) return Math.round(m / 1e3) + 'K';
640 + return m?.toLocaleString() || '0';
641 + }
642 +
643 + function formatMontoLargo(m) {
644 + if (m >= 1e9) return Math.round(m / 1e9).toLocaleString('es-BO') + ' mil Mill.';
645 + if (m >= 1e6) return (m / 1e6).toLocaleString('es-BO', { minimumFractionDigits: 1, maximumFractionDigits: 1 }) + ' Mill.';
646 + return m?.toLocaleString('es-BO', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) || '0';
647 + }
648 +
649 + function formatPerCapita(n) {
650 + if (n >= 1000) return n.toLocaleString('es-BO', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
651 + if (n >= 10) return n.toLocaleString('es-BO', { minimumFractionDigits: 1, maximumFractionDigits: 1 });
652 + if (n < 1) return n.toLocaleString('es-BO', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
653 + return n.toLocaleString('es-BO', { minimumFractionDigits: 1, maximumFractionDigits: 1 });
654 + }
36 </script> 655 </script>
37 656
38 <svelte:head> 657 <svelte:head>
39 - <title>{objeto.desc_objeto} | Presupuesto Público</title> 658 + <title>{objeto.nombre} | Presupuesto Público</title>
659 + <link rel="preconnect" href="https://fonts.googleapis.com" />
660 + <link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=DM+Mono:wght@400;500&family=Instrument+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
40 </svelte:head> 661 </svelte:head>
41 662
42 -<div class="min-h-screen bg-white"> 663 +<div class="page" class:mounted class:tema-claro={!isDark}>
43 - <header class="border-b bg-slate-50">
44 - <div class="max-w-4xl mx-auto px-6 py-4">
45 - <nav class="text-sm text-slate-500 mb-2 flex flex-wrap items-center gap-1">
46 - <a href="/" class="hover:text-slate-700">Inicio</a>
47 - <span>/</span>
48 - <a href="/clasificadores/objeto-gasto" class="hover:text-slate-700">Objeto del Gasto</a>
49 - {#each padres as padre}
50 - <span>/</span>
51 - <a href="/objeto/{padre.objeto}" class="hover:text-slate-700">{padre.desc_objeto}</a>
52 - {/each}
53 - <span>/</span>
54 - <span class="text-slate-900">{objeto.desc_objeto}</span>
55 - </nav>
56 - </div>
57 - </header>
58 664
59 - <main class="max-w-4xl mx-auto px-6 py-8"> 665 + <!-- Header compacto -->
60 - <!-- Encabezado --> 666 + <header>
61 - <div class="mb-8"> 667 + <nav class="breadcrumb">
62 - <p class="text-xs text-slate-500 uppercase tracking-wide mb-1">{getNivelLabel(objeto.nivel)}</p> 668 + <a href="/clasificadores/objeto-gasto">← Objeto del Gasto</a>
63 - <div class="flex items-baseline gap-4"> 669 + {#each padres as padre}
64 - <span class="font-mono text-2xl text-slate-400">{objeto.objeto}</span> 670 + <span class="breadcrumb-sep">/</span>
65 - <h1 class="text-2xl font-medium text-slate-900">{objeto.desc_objeto}</h1> 671 + <a href="/objeto/{padre.objeto}" class="breadcrumb-link">{padre.desc_objeto}</a>
672 + {/each}
673 + </nav>
674 +
675 + <div class="title-row">
676 + <div class="title-main">
677 + <span class="codigo">{objeto.codigo}</span>
678 + <h1>
679 + {objeto.nombre}
680 + <button class="help-icon" onclick={() => showInfo = !showInfo} class:active={showInfo}>
681 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
682 + <circle cx="12" cy="12" r="10"/>
683 + <path d="M9.5 9a3 3 0 1 1 3.5 2.95c-.5.17-1 .62-1 1.3V15"/>
684 + <circle cx="12" cy="18" r="0.5" fill="currentColor"/>
685 + </svg>
686 + </button>
687 + </h1>
66 </div> 688 </div>
67 </div> 689 </div>
68 690
69 - <!-- Descripciones --> 691 + <div class="meta">
70 - <section class="mb-10"> 692 + <span class="badge">{objeto.nivel}</span>
71 - <h2 class="text-sm font-medium text-slate-700 mb-4"> 693 + <span class="years">{objeto.años}</span>
72 - {#if descripciones.length > 1} 694 + </div>
73 - Descripciones ({descripciones.length} variaciones) 695 + </header>
74 - {:else} 696 +
75 - Descripción 697 + <!-- Panel de info (colapsable) -->
698 + {#if showInfo}
699 + <div class="info-panel">
700 + <div class="info-content">
701 + <h4>Definición vigente</h4>
702 + <p>{objeto.descripcion}</p>
703 +
704 + {#if objeto.variaciones.length > 1}
705 + <h4>Variaciones históricas</h4>
706 + {#each objeto.variaciones as v}
707 + <div class="variacion">
708 + <span class="variacion-rango">{v.rango}</span>
709 + <p>{v.texto}</p>
710 + </div>
711 + {/each}
76 {/if} 712 {/if}
77 - </h2> 713 +
78 - 714 + {#if hijos.length > 0}
79 - <div class="space-y-4"> 715 + <h4>
80 - {#each descripciones as desc, i} 716 + {#if objetoNivel === 'grupo'}
81 - <div class="border-l-2 {i === 0 ? 'border-blue-500 bg-blue-50/50' : 'border-slate-200 bg-slate-50/50'} pl-4 py-3 rounded-r"> 717 + Subgrupos ({hijos.length})
82 - <p class="text-xs text-slate-500 mb-2"> 718 + {:else if objetoNivel === 'subgrupo'}
83 - {#if i === 0 && descripciones.length > 1} 719 + Partidas ({hijos.length})
84 - <span class="text-blue-600 font-medium">Vigente</span> 720 + {:else if objetoNivel === 'partida'}
85 - <span class="mx-1">·</span> 721 + Subpartidas ({hijos.length})
86 - {/if} 722 + {/if}
87 - {desc.rangos} 723 + </h4>
88 - </p> 724 + <div class="hijos-list">
89 - <p class="text-slate-700 leading-relaxed">{desc.descripcion}</p> 725 + {#each hijos as hijo}
726 + <a href="/objeto/{hijo.objeto}" class="hijo-link">
727 + <span class="hijo-codigo">{hijo.objeto}</span>
728 + <span class="hijo-nombre">{hijo.desc_objeto}</span>
729 + </a>
730 + {/each}
90 </div> 731 </div>
91 - {/each} 732 + {/if}
92 </div> 733 </div>
93 - </section> 734 + </div>
94 - 735 + {/if}
95 - <!-- Jerarquía --> 736 +
96 - {#if padres.length > 0} 737 + <!-- Dashboard principal -->
97 - <section class="mb-10"> 738 + <main>
98 - <h2 class="text-sm font-medium text-slate-700 mb-4">Jerarquía</h2> 739 +
99 - <div class="space-y-2"> 740 + <!-- Toggle de vista -->
100 - {#each padres as padre, i} 741 + <div class="vista-toggle">
101 - <div class="flex items-center gap-2" style="margin-left: {i * 1.5}rem"> 742 + <button
102 - <span class="text-slate-300">└</span> 743 + class="toggle-btn"
103 - <a 744 + class:active={vista === 'agregado'}
104 - href="/objeto/{padre.objeto}" 745 + onclick={() => vista = 'agregado'}
105 - class="text-sm text-slate-600 hover:text-blue-600" 746 + >
747 + Agregado
748 + </button>
749 + <button
750 + class="toggle-btn"
751 + class:active={vista === 'comparar'}
752 + onclick={() => vista = 'comparar'}
753 + >
754 + Comparar
755 + </button>
756 + </div>
757 +
758 + <!-- Selector de entidad (solo en vista agregado) -->
759 + {#if vista === 'agregado'}
760 + <div class="entity-selector entity-dropdown-container">
761 + <span class="selector-label">Viendo</span>
762 + <div class="relative">
763 + <button
764 + class="selector-btn"
765 + onclick={() => entityDropdownOpen = !entityDropdownOpen}
766 + >
767 + <span class="truncate">
768 + {#if selectedEntity}
769 + {selectedEntity.entidad_desc?.slice(0, 30)}{selectedEntity.entidad_desc?.length > 30 ? '...' : ''}
770 + {:else}
771 + Todo el sector público
772 + {/if}
773 + </span>
774 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
775 + <path d="M6 9l6 6 6-6"/>
776 + </svg>
777 + </button>
778 +
779 + {#if entityDropdownOpen}
780 + <div class="dropdown-panel">
781 + <div class="dropdown-search">
782 + <input
783 + type="text"
784 + bind:value={entitySearchQuery}
785 + placeholder="Buscar entidad..."
786 + class="search-input"
787 + />
788 + </div>
789 + <button
790 + class="dropdown-item"
791 + class:active={!selectedEntity}
792 + onclick={() => clearEntity()}
106 > 793 >
107 - <span class="font-mono text-xs text-slate-400">{padre.objeto}</span> 794 + <span class="font-medium">Todo el sector público</span>
108 - {padre.desc_objeto} 795 + <span class="text-xs opacity-60 ml-2">({nEntidades} entidades)</span>
109 - </a> 796 + </button>
797 + <div class="dropdown-list" onscroll={(e) => handleEntityScroll(e, 'main')}>
798 + {#each filteredEntities as entity}
799 + <button
800 + class="dropdown-item"
801 + class:active={selectedEntity?.entidad === entity.entidad}
802 + onclick={() => selectEntity(entity)}
803 + >
804 + <span class="entity-code">{entity.entidad}</span>
805 + <span class="truncate">{entity.entidad_desc}</span>
806 + </button>
807 + {/each}
808 + {#if entityLimit < entidades.length}
809 + <div class="text-center py-2 text-xs opacity-60">
810 + Scroll para ver más ({entidades.length - entityLimit} restantes)
811 + </div>
812 + {/if}
813 + </div>
110 </div> 814 </div>
111 - {/each} 815 + {/if}
112 - <div class="flex items-center gap-2" style="margin-left: {padres.length * 1.5}rem">
113 - <span class="text-blue-500">└</span>
114 - <span class="text-sm font-medium text-blue-600">
115 - <span class="font-mono text-xs">{objeto.objeto}</span>
116 - {objeto.desc_objeto}
117 - </span>
118 - </div>
119 </div> 816 </div>
120 - </section> 817 + <span class="interaction-hint desktop-hint">Pasa el cursor sobre las barras para ver valores por año</span>
818 + <span class="interaction-hint mobile-hint">Toca las barras para ver valores por año</span>
819 + </div>
121 {/if} 820 {/if}
122 821
123 - <!-- Hijos --> 822 + {#if vista === 'agregado'}
124 - {#if hijos.length > 0} 823 + <!-- VISTA AGREGADO -->
125 - <section class="mb-10"> 824 +
126 - <h2 class="text-sm font-medium text-slate-700 mb-4"> 825 + <!-- KPIs arriba -->
127 - {#if objeto.nivel === 'grupo'} 826 + <div class="kpis">
128 - Subgrupos ({hijos.length}) 827 + <div class="kpi">
129 - {:else if objeto.nivel === 'subgrupo'} 828 + <span class="kpi-value">Bs {formatMontoLargo(Math.round($twMonto))}</span>
130 - Partidas ({hijos.length}) 829 + <span class="kpi-label">{displayMontoLabel}</span>
131 - {:else if objeto.nivel === 'partida'} 830 + </div>
132 - Subpartidas ({hijos.length}) 831 +
133 - {/if} 832 + <div class="kpi">
134 - </h2> 833 + <span class="kpi-value">Bs {formatPerCapita($twPerCapita)}</span>
135 - 834 + <span class="kpi-label">{displayPerCapitaLabel}</span>
136 - <div class="border rounded-lg divide-y"> 835 + </div>
137 - {#each hijos as hijo} 836 +
138 - {@const hijoDescs = parseDescripciones(hijo.descripciones)} 837 + <div class="kpi">
139 - <a 838 + <span class="kpi-value">{$twPorcentaje.toLocaleString('es-BO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</span>
140 - href="/objeto/{hijo.objeto}" 839 + <span class="kpi-label">{displayPorcentajeLabel}</span>
141 - class="block px-4 py-3 hover:bg-slate-50 transition-colors" 840 + </div>
142 - > 841 +
143 - <div class="flex items-baseline gap-3"> 842 + <div class="kpi">
144 - <span class="font-mono text-sm text-slate-400">{hijo.objeto}</span> 843 + <span class="kpi-value">{displayRanking}</span>
145 - <span class="text-slate-900">{hijo.desc_objeto}</span> 844 + <span class="kpi-label">{displayRankingLabel}</span>
146 - {#if hijo.n_variaciones > 1} 845 + </div>
147 - <span class="text-xs text-orange-500">({hijo.n_variaciones} var.)</span> 846 + </div>
148 - {/if} 847 +
848 + <!-- Gráfico de barras (per cápita) - protagonista al medio -->
849 + <div class="chart-section chart-protagonista">
850 + <div class="chart-header">
851 + <span class="chart-title">Gasto por persona</span>
852 + <span class="chart-period">{displayPeriodo}</span>
853 + </div>
854 +
855 + <div class="chart-wrapper">
856 + <!-- Y-axis labels -->
857 + <div class="y-axis">
858 + <span class="y-label">Bs {formatMonto(maxPerCapita)}</span>
859 + <span class="y-label">Bs {formatMonto(Math.round(maxPerCapita / 2))}</span>
860 + <span class="y-label">Bs 0</span>
861 + </div>
862 +
863 + <div
864 + class="chart"
865 + onmouseleave={() => hoveredYear = null}
866 + role="group"
867 + >
868 + {#each gastoPerCapita as g}
869 + <div
870 + class="bar-container"
871 + onmouseenter={() => hoveredYear = g.año}
872 + role="button"
873 + tabindex="0"
874 + >
875 + <div
876 + class="bar"
877 + class:hovered={hoveredYear === g.año}
878 + style="height: {(g.perCapita / maxPerCapita) * 100}%"
879 + ></div>
880 + <span class="bar-label" class:visible={hoveredYear === g.año || g.año % 5 === 0}>
881 + {g.año.toString().slice(-2)}
882 + </span>
149 </div> 883 </div>
150 - {#if hijoDescs.length > 0} 884 + {/each}
151 - <p class="text-sm text-slate-500 mt-1 ml-16">{hijoDescs[0].descripcion}</p> 885 + </div>
152 - {/if} 886 + </div>
153 - </a> 887 + </div>
154 - {/each} 888 +
889 + <!-- Top entidades (solo visible cuando es "Todo el Estado") -->
890 + {#if !selectedEntity}
891 + <div class="top-section">
892 + <div class="top-header">
893 + <h3>Mayores ejecutores</h3>
894 + <span class="top-context">{totalEntidades} entidades · {displayTopLabel}</span>
895 + </div>
896 +
897 + <div class="top-list">
898 + {#each displayTopEntidades as ent, i (ent.nombre)}
899 + <div class="top-item">
900 + <span class="top-rank">#{i + 1}</span>
901 + <div class="top-info">
902 + <span class="top-name">{ent.nombre}</span>
903 + <div class="top-bar-bg">
904 + <div class="top-bar" style="width: {((i === 0 ? $twTop1 : i === 1 ? $twTop2 : $twTop3) / $twTop1) * 100}%"></div>
905 + </div>
906 + </div>
907 + <span class="top-pct">{(i === 0 ? $twTop1 : i === 1 ? $twTop2 : $twTop3).toFixed(1)}%</span>
908 + </div>
909 + {/each}
910 + </div>
911 + </div>
912 + {/if}
913 +
914 + {:else}
915 + <!-- VISTA COMPARAR -->
916 +
917 + <!-- Selectores de entidades con búsqueda -->
918 + <div class="comparador-selectors">
919 + <div class="selector-group comparador-dropdown-container">
920 + <span class="selector-dot" style="background: {colorA}"></span>
921 + <div class="relative" style="flex: 1;">
922 + <button class="selector-btn comparador-btn" onclick={() => { dropdownAOpen = !dropdownAOpen; dropdownBOpen = false; }}>
923 + <span class="truncate">
924 + {entidadCompararA ? entidadCompararA.entidad_desc : 'Seleccionar entidad'}
925 + </span>
926 + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
927 + <path d="M6 9l6 6 6-6"/>
928 + </svg>
929 + </button>
930 + {#if dropdownAOpen}
931 + <div class="dropdown-panel comparador-panel">
932 + <div class="dropdown-search">
933 + <input type="text" bind:value={searchA} placeholder="Buscar entidad..." class="search-input" />
934 + </div>
935 + <div class="dropdown-list" onscroll={(e) => handleEntityScroll(e, 'A')}>
936 + {#each filteredEntidadesA as entity}
937 + <button
938 + class="dropdown-item"
939 + class:active={entidadCompararA?.entidad === entity.entidad}
940 + disabled={entidadCompararB?.entidad === entity.entidad}
941 + onclick={() => selectEntidadA(entity)}
942 + >
943 + <span class="entity-code">{entity.entidad}</span>
944 + <span class="truncate">{entity.entidad_desc}</span>
945 + </button>
946 + {/each}
947 + {#if entityLimitA < entidades.length}
948 + <div class="text-center py-2 text-xs opacity-60">
949 + Scroll para ver más
950 + </div>
951 + {/if}
952 + </div>
953 + </div>
954 + {/if}
955 + </div>
956 + </div>
957 +
958 + <span class="vs">vs</span>
959 +
960 + <div class="selector-group comparador-dropdown-container">
961 + <span class="selector-dot" style="background: {colorB}"></span>
962 + <div class="relative" style="flex: 1;">
963 + <button class="selector-btn comparador-btn" onclick={() => { dropdownBOpen = !dropdownBOpen; dropdownAOpen = false; }}>
964 + <span class="truncate">
965 + {entidadCompararB ? entidadCompararB.entidad_desc : 'Seleccionar entidad'}
966 + </span>
967 + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
968 + <path d="M6 9l6 6 6-6"/>
969 + </svg>
970 + </button>
971 + {#if dropdownBOpen}
972 + <div class="dropdown-panel comparador-panel">
973 + <div class="dropdown-search">
974 + <input type="text" bind:value={searchB} placeholder="Buscar entidad..." class="search-input" />
975 + </div>
976 + <div class="dropdown-list" onscroll={(e) => handleEntityScroll(e, 'B')}>
977 + {#each filteredEntidadesB as entity}
978 + <button
979 + class="dropdown-item"
980 + class:active={entidadCompararB?.entidad === entity.entidad}
981 + disabled={entidadCompararA?.entidad === entity.entidad}
982 + onclick={() => selectEntidadB(entity)}
983 + >
984 + <span class="entity-code">{entity.entidad}</span>
985 + <span class="truncate">{entity.entidad_desc}</span>
986 + </button>
987 + {/each}
988 + {#if entityLimitB < entidades.length}
989 + <div class="text-center py-2 text-xs opacity-60">
990 + Scroll para ver más
991 + </div>
992 + {/if}
993 + </div>
994 + </div>
995 + {/if}
996 + </div>
997 + </div>
998 + </div>
999 +
1000 + <!-- Mensaje si no hay entidades seleccionadas -->
1001 + {#if !entidadCompararA || !entidadCompararB}
1002 + <div class="comparador-placeholder">
1003 + <p>Selecciona dos entidades para comparar su gasto en <strong>{objeto.nombre}</strong></p>
1004 + </div>
1005 + {:else if loadingComparacion}
1006 + <div class="comparador-placeholder">
1007 + <Spinner size={48} color="var(--theme-texto, #5A5650)" />
1008 + </div>
1009 + {:else if añosComparacion.length === 0}
1010 + <div class="comparador-placeholder">
1011 + <p>Las entidades seleccionadas no tienen años en común para este objeto.</p>
1012 + </div>
1013 + {:else}
1014 + <!-- Selector de rango de años -->
1015 + {#if añosComunesTotales.length > 10}
1016 + <div class="rango-selector">
1017 + <span class="rango-label">Mostrar:</span>
1018 + <div class="rango-options">
1019 + <button class="rango-btn" class:active={rangoAños === '5'} onclick={() => rangoAños = '5'}>5 años</button>
1020 + <button class="rango-btn" class:active={rangoAños === '10'} onclick={() => rangoAños = '10'}>10 años</button>
1021 + <button class="rango-btn" class:active={rangoAños === '15'} onclick={() => rangoAños = '15'}>15 años</button>
1022 + <button class="rango-btn" class:active={rangoAños === 'todo'} onclick={() => rangoAños = 'todo'}>Todo ({añosComunesTotales.length})</button>
1023 + </div>
1024 + </div>
1025 + {/if}
1026 +
1027 + <!-- Gráfico comparativo -->
1028 + <div class="chart-section">
1029 + <div class="chart-header">
1030 + <span class="chart-title">Gasto por persona</span>
1031 + <span class="chart-period">{displayPeriodoComparar}</span>
1032 + </div>
1033 +
1034 + <div class="chart-wrapper chart-wrapper-scroll">
1035 + <div class="y-axis y-axis-sticky">
1036 + <span class="y-label">Bs {formatMonto(Math.round(maxComparacion))}</span>
1037 + <span class="y-label">Bs {formatMonto(Math.round(maxComparacion / 2))}</span>
1038 + <span class="y-label">Bs 0</span>
1039 + </div>
1040 +
1041 + <div
1042 + class="chart chart-comparar"
1043 + class:chart-muchos-años={añosComparacion.length > 15}
1044 + onmouseleave={() => hoveredYearComparar = null}
1045 + role="group"
1046 + >
1047 + {#each añosComparacion as año, i}
1048 + <div
1049 + class="bar-group"
1050 + class:active={hoveredYearComparar === año}
1051 + onmouseenter={() => hoveredYearComparar = año}
1052 + role="button"
1053 + tabindex="0"
1054 + >
1055 + <div class="bars-pair">
1056 + <div
1057 + class="bar bar-a"
1058 + style="height: {((datosGraficoA[i]?.per_capita || 0) / maxComparacion) * 100}%; --bar-color: {colorA}"
1059 + ></div>
1060 + <div
1061 + class="bar bar-b"
1062 + style="height: {((datosGraficoB[i]?.per_capita || 0) / maxComparacion) * 100}%; --bar-color: {colorB}"
1063 + ></div>
1064 + </div>
1065 + <span class="bar-label visible" class:highlight={hoveredYearComparar === año}>{año}</span>
1066 + </div>
1067 + {/each}
1068 + </div>
1069 + </div>
1070 +
1071 + <div class="chart-legend">
1072 + <span class="legend-item"><span class="legend-dot" style="background: {colorA}"></span>{entidadCompararA.entidad_desc}</span>
1073 + <span class="legend-item"><span class="legend-dot" style="background: {colorB}"></span>{entidadCompararB.entidad_desc}</span>
1074 + </div>
1075 + </div>
1076 +
1077 + <!-- KPIs comparativos - tabla -->
1078 + <div class="kpis-tabla">
1079 + <!-- Header -->
1080 + <div class="tabla-header">
1081 + <span class="tabla-label"></span>
1082 + <span class="tabla-val" style="color: {colorA}">{entidadCompararA.entidad_desc?.slice(0, 20)}{entidadCompararA.entidad_desc?.length > 20 ? '...' : ''}</span>
1083 + <span class="tabla-val" style="color: {colorB}">{entidadCompararB.entidad_desc?.slice(0, 20)}{entidadCompararB.entidad_desc?.length > 20 ? '...' : ''}</span>
1084 + </div>
1085 + <!-- Gasto -->
1086 + <div class="tabla-row">
1087 + <span class="tabla-label">{hoveredYearComparar ? `Gasto ${hoveredYearComparar}` : `Gasto ${añosComparacion[0]}-${añosComparacion[añosComparacion.length-1]}`}</span>
1088 + <span class="tabla-val">Bs {formatMontoLargo(Math.round($twMontoA))}</span>
1089 + <span class="tabla-val">Bs {formatMontoLargo(Math.round($twMontoB))}</span>
1090 + </div>
1091 + <!-- Per cápita -->
1092 + <div class="tabla-row">
1093 + <span class="tabla-label">{hoveredYearComparar ? `Por persona ${hoveredYearComparar}` : 'Por persona (prom.)'}</span>
1094 + <span class="tabla-val">Bs {formatPerCapita($twPerCapitaA)}</span>
1095 + <span class="tabla-val">Bs {formatPerCapita($twPerCapitaB)}</span>
1096 + </div>
1097 + <!-- Porcentaje -->
1098 + <div class="tabla-row">
1099 + <span class="tabla-label">{hoveredYearComparar ? `Peso en ${hoveredYearComparar}` : 'Peso sobre total'}</span>
1100 + <span class="tabla-val">{displayPropA.toLocaleString('es-BO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</span>
1101 + <span class="tabla-val">{displayPropB.toLocaleString('es-BO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</span>
1102 + </div>
1103 + <!-- Ranking -->
1104 + <div class="tabla-row">
1105 + <span class="tabla-label">{hoveredYearComparar ? `Ranking ${hoveredYearComparar}` : 'Ranking'}</span>
1106 + <span class="tabla-val">#{displayRankingA} de {displayNEntidadesA}</span>
1107 + <span class="tabla-val">#{displayRankingB} de {displayNEntidadesB}</span>
1108 + </div>
155 </div> 1109 </div>
156 - </section> 1110 + {/if}
1111 +
157 {/if} 1112 {/if}
158 1113
159 - <!-- Volver -->
160 - <div class="pt-6 border-t">
161 - <a
162 - href="/clasificadores/objeto-gasto"
163 - class="text-sm text-blue-600 hover:underline"
164 - >
165 - Volver al clasificador
166 - </a>
167 - </div>
168 </main> 1114 </main>
169 </div> 1115 </div>
1116 +
1117 +
1118 +
1119 +<style>
1120 + .page {
1121 + min-height: 100vh;
1122 + background: #0A0A0A;
1123 + color: #F5F0E8;
1124 + font-family: 'Instrument Sans', -apple-system, sans-serif;
1125 + opacity: 0;
1126 + transition: opacity 0.4s ease;
1127 + }
1128 + .mounted { opacity: 1; }
1129 +
1130 + /* Header */
1131 + header {
1132 + padding: 1rem 2rem;
1133 + border-bottom: 1px solid #1E1E1C;
1134 + }
1135 +
1136 + .breadcrumb {
1137 + display: flex;
1138 + align-items: center;
1139 + gap: 0.5rem;
1140 + flex-wrap: wrap;
1141 + }
1142 +
1143 + .breadcrumb a {
1144 + font-family: 'DM Mono', monospace;
1145 + font-size: 0.75rem;
1146 + color: #5A5650;
1147 + text-decoration: none;
1148 + transition: color 0.2s;
1149 + }
1150 + .breadcrumb a:hover { color: #F5F0E8; }
1151 +
1152 + .breadcrumb-sep {
1153 + color: #3A3A38;
1154 + font-size: 0.75rem;
1155 + }
1156 +
1157 + .breadcrumb-link {
1158 + max-width: 150px;
1159 + overflow: hidden;
1160 + text-overflow: ellipsis;
1161 + white-space: nowrap;
1162 + }
1163 +
1164 + .title-row {
1165 + display: flex;
1166 + align-items: flex-start;
1167 + justify-content: space-between;
1168 + margin-top: 0.5rem;
1169 + gap: 1rem;
1170 + }
1171 +
1172 + .title-main {
1173 + display: flex;
1174 + align-items: baseline;
1175 + gap: 1rem;
1176 + flex-wrap: wrap;
1177 + }
1178 +
1179 + .codigo {
1180 + font-family: 'DM Mono', monospace;
1181 + font-size: 1.5rem;
1182 + color: #5A5650;
1183 + font-weight: 400;
1184 + }
1185 +
1186 + h1 {
1187 + font-family: 'DM Serif Display', Georgia, serif;
1188 + font-size: 1.5rem;
1189 + font-weight: 400;
1190 + color: #F5F0E8;
1191 + margin: 0;
1192 + display: inline-flex;
1193 + align-items: center;
1194 + gap: 0.5rem;
1195 + }
1196 +
1197 + .help-icon {
1198 + background: transparent;
1199 + border: none;
1200 + padding: 0;
1201 + color: #5A5650;
1202 + cursor: pointer;
1203 + transition: all 0.2s;
1204 + display: inline-flex;
1205 + align-items: center;
1206 + justify-content: center;
1207 + vertical-align: middle;
1208 + }
1209 + .help-icon:hover, .help-icon.active {
1210 + color: #E8C547;
1211 + }
1212 +
1213 + .meta {
1214 + display: flex;
1215 + align-items: center;
1216 + gap: 0.75rem;
1217 + margin-top: 0.5rem;
1218 + }
1219 +
1220 + .badge {
1221 + font-family: 'DM Mono', monospace;
1222 + font-size: 0.625rem;
1223 + letter-spacing: 0.1em;
1224 + text-transform: uppercase;
1225 + padding: 0.25rem 0.5rem;
1226 + background: #1A1A18;
1227 + border: 1px solid #2E2E2C;
1228 + border-radius: 4px;
1229 + color: #8A8578;
1230 + }
1231 +
1232 + .years {
1233 + font-family: 'DM Mono', monospace;
1234 + font-size: 0.75rem;
1235 + color: #5A5650;
1236 + }
1237 +
1238 + /* Info panel */
1239 + .info-panel {
1240 + background: #111110;
1241 + border-bottom: 1px solid #1E1E1C;
1242 + padding: 1.5rem 2rem;
1243 + }
1244 +
1245 + .info-content h4 {
1246 + font-family: 'DM Mono', monospace;
1247 + font-size: 0.625rem;
1248 + letter-spacing: 0.15em;
1249 + text-transform: uppercase;
1250 + color: #5A5650;
1251 + margin: 0 0 0.5rem 0;
1252 + }
1253 +
1254 + .info-content p {
1255 + font-size: 0.875rem;
1256 + line-height: 1.6;
1257 + color: #8A8578;
1258 + margin: 0 0 1.25rem 0;
1259 + }
1260 +
1261 + .variacion {
1262 + padding-left: 1rem;
1263 + border-left: 2px solid #2E2E2C;
1264 + margin-bottom: 1rem;
1265 + }
1266 +
1267 + .variacion-rango {
1268 + font-family: 'DM Mono', monospace;
1269 + font-size: 0.688rem;
1270 + color: #5A5650;
1271 + }
1272 +
1273 + .variacion p {
1274 + margin-top: 0.25rem;
1275 + font-size: 0.813rem;
1276 + }
1277 +
1278 + /* Hijos list in info panel */
1279 + .hijos-list {
1280 + display: flex;
1281 + flex-direction: column;
1282 + gap: 0.25rem;
1283 + margin-bottom: 1rem;
1284 + }
1285 +
1286 + .hijo-link {
1287 + display: flex;
1288 + align-items: baseline;
1289 + gap: 0.75rem;
1290 + padding: 0.375rem 0.5rem;
1291 + border-radius: 4px;
1292 + text-decoration: none;
1293 + transition: background 0.2s;
1294 + }
1295 +
1296 + .hijo-link:hover {
1297 + background: rgba(255, 255, 255, 0.05);
1298 + }
1299 +
1300 + .hijo-codigo {
1301 + font-family: 'DM Mono', monospace;
1302 + font-size: 0.688rem;
1303 + color: #5A5650;
1304 + min-width: 3rem;
1305 + }
1306 +
1307 + .hijo-nombre {
1308 + font-size: 0.813rem;
1309 + color: #B0A89C;
1310 + }
1311 +
1312 + .hijo-link:hover .hijo-nombre {
1313 + color: #F5F0E8;
1314 + }
1315 +
1316 + /* Main */
1317 + main {
1318 + padding: 1rem 2rem;
1319 + max-width: 900px;
1320 + margin: 0 auto;
1321 + }
1322 +
1323 + /* Entity selector */
1324 + .entity-selector {
1325 + display: flex;
1326 + align-items: center;
1327 + gap: 0.75rem;
1328 + margin-bottom: 0.75rem;
1329 + flex-wrap: wrap;
1330 + }
1331 +
1332 + .interaction-hint {
1333 + font-size: 0.688rem;
1334 + color: #5A5650;
1335 + font-style: italic;
1336 + margin-left: auto;
1337 + }
1338 +
1339 + .desktop-hint {
1340 + display: inline;
1341 + }
1342 +
1343 + .mobile-hint {
1344 + display: none;
1345 + }
1346 +
1347 + @media (max-width: 640px) {
1348 + .desktop-hint {
1349 + display: none;
1350 + }
1351 + .mobile-hint {
1352 + display: inline;
1353 + }
1354 + }
1355 +
1356 + .selector-label {
1357 + font-size: 0.813rem;
1358 + color: #5A5650;
1359 + }
1360 +
1361 + .selector-btn {
1362 + display: flex;
1363 + align-items: center;
1364 + gap: 0.5rem;
1365 + padding: 0.5rem 1rem;
1366 + background: transparent;
1367 + border: none;
1368 + box-shadow: inset 0 0 0 1px rgba(255,255,255,0.08);
1369 + border-radius: 8px;
1370 + color: #F5F0E8;
1371 + font-size: 0.875rem;
1372 + cursor: pointer;
1373 + transition: all 0.2s;
1374 + }
1375 + .selector-btn:hover {
1376 + box-shadow: inset 0 0 0 1px rgba(255,255,255,0.15);
1377 + }
1378 + .selector-btn svg {
1379 + color: #5A5650;
1380 + }
1381 +
1382 + .relative {
1383 + position: relative;
1384 + }
1385 +
1386 + .dropdown-panel {
1387 + position: absolute;
1388 + top: 100%;
1389 + left: 0;
1390 + margin-top: 0.25rem;
1391 + width: min(500px, 90vw);
1392 + max-height: 360px;
1393 + overflow: hidden;
1394 + background: #1A1A18;
1395 + border: 1px solid #2E2E2C;
1396 + border-radius: 8px;
1397 + box-shadow: 0 10px 40px rgba(0,0,0,0.4);
1398 + z-index: 50;
1399 + }
1400 +
1401 + .dropdown-search {
1402 + padding: 0.5rem;
1403 + border-bottom: 1px solid #2E2E2C;
1404 + }
1405 +
1406 + .search-input {
1407 + width: 100%;
1408 + padding: 0.5rem 0.75rem;
1409 + background: #111110;
1410 + border: 1px solid #2E2E2C;
1411 + border-radius: 6px;
1412 + color: #F5F0E8;
1413 + font-size: 0.875rem;
1414 + }
1415 + .search-input:focus {
1416 + outline: none;
1417 + border-color: #E8C547;
1418 + }
1419 + .search-input::placeholder {
1420 + color: #5A5650;
1421 + }
1422 +
1423 + .dropdown-item {
1424 + display: flex;
1425 + align-items: center;
1426 + gap: 0.5rem;
1427 + width: 100%;
1428 + padding: 0.625rem 0.75rem;
1429 + background: transparent;
1430 + border: none;
1431 + color: #F5F0E8;
1432 + font-size: 0.813rem;
1433 + text-align: left;
1434 + cursor: pointer;
1435 + transition: background 0.15s;
1436 + }
1437 + .dropdown-item:hover {
1438 + background: rgba(255,255,255,0.05);
1439 + }
1440 + .dropdown-item.active {
1441 + background: rgba(232,197,71,0.15);
1442 + }
1443 +
1444 + .dropdown-list {
1445 + max-height: 240px;
1446 + overflow-y: auto;
1447 + }
1448 +
1449 + .entity-code {
1450 + font-family: 'DM Mono', monospace;
1451 + font-size: 0.688rem;
1452 + color: #5A5650;
1453 + min-width: 2.5rem;
1454 + }
1455 +
1456 + .truncate {
1457 + overflow: hidden;
1458 + text-overflow: ellipsis;
1459 + white-space: nowrap;
1460 + }
1461 +
1462 + /* Chart */
1463 + .chart-section {
1464 + margin-bottom: 1rem;
1465 + }
1466 +
1467 + .chart-protagonista {
1468 + margin-bottom: 1.25rem;
1469 + }
1470 +
1471 + .chart-protagonista .chart {
1472 + height: calc(100vh - 480px);
1473 + min-height: 120px;
1474 + max-height: 200px;
1475 + }
1476 +
1477 + .chart-protagonista .y-axis {
1478 + height: calc(100vh - 480px);
1479 + min-height: 120px;
1480 + max-height: 200px;
1481 + }
1482 +
1483 + .chart-section:has(.chart-comparar) {
1484 + margin-bottom: 0;
1485 + }
1486 +
1487 + .chart-section:has(.chart-comparar) .y-axis {
1488 + height: calc(100vh - 380px);
1489 + min-height: 140px;
1490 + max-height: 280px;
1491 + }
1492 +
1493 + .chart-header {
1494 + display: flex;
1495 + justify-content: space-between;
1496 + align-items: baseline;
1497 + margin-bottom: 0.5rem;
1498 + }
1499 +
1500 + .chart-title {
1501 + font-size: 0.75rem;
1502 + color: #5A5650;
1503 + text-transform: uppercase;
1504 + letter-spacing: 0.1em;
1505 + }
1506 +
1507 + .chart-period {
1508 + font-family: 'DM Mono', monospace;
1509 + font-size: 0.875rem;
1510 + color: #B0A89C;
1511 + font-variant-numeric: tabular-nums;
1512 + min-width: 5rem;
1513 + text-align: right;
1514 + }
1515 +
1516 + .chart-wrapper {
1517 + display: flex;
1518 + gap: 0.75rem;
1519 + }
1520 +
1521 + .chart-wrapper-scroll {
1522 + overflow-x: auto;
1523 + overflow-y: visible;
1524 + scrollbar-width: thin;
1525 + scrollbar-color: #2E2E2C transparent;
1526 + }
1527 +
1528 + .chart-wrapper-scroll::-webkit-scrollbar {
1529 + height: 6px;
1530 + }
1531 +
1532 + .chart-wrapper-scroll::-webkit-scrollbar-track {
1533 + background: transparent;
1534 + }
1535 +
1536 + .chart-wrapper-scroll::-webkit-scrollbar-thumb {
1537 + background: #2E2E2C;
1538 + border-radius: 3px;
1539 + }
1540 +
1541 + .y-axis-sticky {
1542 + position: sticky;
1543 + left: 0;
1544 + z-index: 10;
1545 + background: #0A0A0A;
1546 + padding-right: 0.5rem;
1547 + margin-right: -0.5rem;
1548 + }
1549 +
1550 + .chart-muchos-años {
1551 + min-width: max-content;
1552 + }
1553 +
1554 + .chart-muchos-años .bar-group {
1555 + min-width: 40px;
1556 + }
1557 +
1558 + .y-axis {
1559 + display: flex;
1560 + flex-direction: column;
1561 + justify-content: space-between;
1562 + align-items: flex-end;
1563 + height: 160px;
1564 + padding-bottom: 1.5rem;
1565 + }
1566 +
1567 + .y-label {
1568 + font-family: 'DM Mono', monospace;
1569 + font-size: 0.625rem;
1570 + color: #5A5650;
1571 + }
1572 +
1573 + .chart {
1574 + flex: 1;
1575 + display: flex;
1576 + align-items: flex-end;
1577 + gap: 4px;
1578 + height: 160px;
1579 + padding: 0 0.5rem;
1580 + border-left: 1px solid #2E2E2C;
1581 + }
1582 +
1583 + .bar-container {
1584 + flex: 1;
1585 + display: flex;
1586 + flex-direction: column;
1587 + align-items: center;
1588 + justify-content: flex-end;
1589 + height: 100%;
1590 + cursor: pointer;
1591 + }
1592 +
1593 + .bar {
1594 + width: 100%;
1595 + background: #2a9d8f;
1596 + border-radius: 4px 4px 0 0;
1597 + transition: background 0.3s cubic-bezier(0.4, 0, 0.2, 1), transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
1598 + min-height: 4px;
1599 + }
1600 +
1601 + .bar.hovered {
1602 + background: #E8C547;
1603 + transform: scaleX(1.1);
1604 + }
1605 +
1606 + .bar-label {
1607 + font-family: 'DM Mono', monospace;
1608 + font-size: 0.625rem;
1609 + color: #B0A89C;
1610 + margin-top: 0.5rem;
1611 + opacity: 0;
1612 + transition: opacity 0.25s cubic-bezier(0.4, 0, 0.2, 1), color 0.25s ease;
1613 + }
1614 + .bar-label.visible { opacity: 1; }
1615 +
1616 + /* KPIs */
1617 + .kpis {
1618 + display: grid;
1619 + grid-template-columns: repeat(4, 1fr);
1620 + gap: 0.5rem;
1621 + margin-bottom: 1rem;
1622 + }
1623 +
1624 + .kpi {
1625 + background: transparent;
1626 + box-shadow: inset 0 0 0 1px rgba(255,255,255,0.08);
1627 + padding: 0.75rem 0.75rem;
1628 + text-align: center;
1629 + border-radius: 8px;
1630 + transition: box-shadow 0.3s ease;
1631 + }
1632 +
1633 + .kpi-value {
1634 + display: block;
1635 + font-family: 'DM Mono', monospace;
1636 + font-size: 1.125rem;
1637 + font-weight: 500;
1638 + color: #F5F0E8;
1639 + margin-bottom: 0;
1640 + transition: opacity 0.2s ease;
1641 + font-variant-numeric: tabular-nums;
1642 + min-height: 1.5rem;
1643 + }
1644 +
1645 + .kpi-label {
1646 + font-size: 0.5625rem;
1647 + color: #5A5650;
1648 + text-transform: uppercase;
1649 + letter-spacing: 0.03em;
1650 + line-height: 1.3;
1651 + transition: opacity 0.2s ease;
1652 + min-height: 1.25rem;
1653 + display: flex;
1654 + align-items: center;
1655 + justify-content: center;
1656 + }
1657 +
1658 + /* Top entidades */
1659 + .top-header {
1660 + display: flex;
1661 + justify-content: space-between;
1662 + align-items: baseline;
1663 + margin-bottom: 0.625rem;
1664 + }
1665 +
1666 + .top-section h3 {
1667 + font-family: 'DM Mono', monospace;
1668 + font-size: 0.75rem;
1669 + color: #5A5650;
1670 + text-transform: uppercase;
1671 + letter-spacing: 0.1em;
1672 + margin: 0;
1673 + }
1674 +
1675 + .top-context {
1676 + font-family: 'DM Mono', monospace;
1677 + font-size: 0.688rem;
1678 + color: #5A5650;
1679 + font-variant-numeric: tabular-nums;
1680 + }
1681 +
1682 + .top-list {
1683 + display: flex;
1684 + flex-direction: column;
1685 + gap: 0.5rem;
1686 + }
1687 +
1688 + .top-item {
1689 + display: flex;
1690 + align-items: center;
1691 + gap: 0.75rem;
1692 + padding: 0.625rem 0.875rem;
1693 + background: transparent;
1694 + box-shadow: inset 0 0 0 1px rgba(255,255,255,0.08);
1695 + border-radius: 8px;
1696 + transition: box-shadow 0.25s ease;
1697 + }
1698 +
1699 + .top-item:hover {
1700 + box-shadow: inset 0 0 0 1px rgba(201, 167, 81, 0.4);
1701 + }
1702 +
1703 + .top-rank {
1704 + font-family: 'DM Mono', monospace;
1705 + font-size: 0.875rem;
1706 + color: #5A5650;
1707 + width: 24px;
1708 + }
1709 +
1710 + .top-info {
1711 + flex: 1;
1712 + }
1713 +
1714 + .top-name {
1715 + display: block;
1716 + font-size: 0.8125rem;
1717 + color: #F5F0E8;
1718 + margin-bottom: 0.375rem;
1719 + }
1720 +
1721 + .top-bar-bg {
1722 + height: 4px;
1723 + background: #1E1E1C;
1724 + border-radius: 2px;
1725 + overflow: hidden;
1726 + }
1727 +
1728 + .top-bar {
1729 + height: 100%;
1730 + background: #C9A751;
1731 + border-radius: 2px;
1732 + transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1), background 0.25s ease;
1733 + }
1734 +
1735 + .top-item:hover .top-bar {
1736 + background: #E8C547;
1737 + }
1738 +
1739 + .top-pct {
1740 + font-family: 'DM Mono', monospace;
1741 + font-size: 0.875rem;
1742 + color: #B0A89C;
1743 + min-width: 52px;
1744 + width: 52px;
1745 + text-align: right;
1746 + transition: opacity 0.2s ease;
1747 + font-variant-numeric: tabular-nums;
1748 + }
1749 +
1750 + .see-all {
1751 + background: transparent;
1752 + border: 1px solid #2E2E2C;
1753 + border-radius: 8px;
1754 + padding: 0.75rem 1rem;
1755 + color: #8A8578;
1756 + font-size: 0.813rem;
1757 + cursor: pointer;
1758 + transition: all 0.2s;
1759 + width: 100%;
1760 + margin-top: 1rem;
1761 + }
1762 + .see-all:hover {
1763 + border-color: #E8C547;
1764 + color: #E8C547;
1765 + }
1766 +
1767 + /* Vista Toggle */
1768 + .vista-toggle {
1769 + display: inline-flex;
1770 + background: transparent;
1771 + box-shadow: inset 0 0 0 1px rgba(255,255,255,0.08);
1772 + border-radius: 10px;
1773 + padding: 4px;
1774 + margin-bottom: 0.75rem;
1775 + }
1776 +
1777 + .toggle-btn {
1778 + padding: 0.5rem 1.25rem;
1779 + font-size: 0.813rem;
1780 + font-weight: 500;
1781 + color: #5A5650;
1782 + background: transparent;
1783 + border: none;
1784 + border-radius: 7px;
1785 + cursor: pointer;
1786 + transition: all 0.2s;
1787 + }
1788 +
1789 + .toggle-btn:hover {
1790 + color: #8A8578;
1791 + }
1792 +
1793 + .toggle-btn.active {
1794 + background: #F5F0E8;
1795 + color: #0A0A0A;
1796 + }
1797 +
1798 + /* Comparador inline - ajustado para 100vh */
1799 + .comparador-selectors {
1800 + display: flex;
1801 + align-items: flex-start;
1802 + gap: 1rem;
1803 + margin-bottom: 0.75rem;
1804 + }
1805 +
1806 + .comparador-dropdown-container {
1807 + position: relative;
1808 + }
1809 +
1810 + .comparador-btn {
1811 + width: 100%;
1812 + justify-content: space-between;
1813 + }
1814 +
1815 + .comparador-panel {
1816 + width: 100%;
1817 + min-width: 280px;
1818 + }
1819 +
1820 + .comparador-placeholder {
1821 + display: flex;
1822 + align-items: center;
1823 + justify-content: center;
1824 + min-height: 200px;
1825 + background: rgba(255,255,255,0.02);
1826 + border: 1px dashed #2E2E2C;
1827 + border-radius: 12px;
1828 + padding: 2rem;
1829 + text-align: center;
1830 + }
1831 +
1832 + .comparador-placeholder p {
1833 + color: #5A5650;
1834 + font-size: 0.875rem;
1835 + margin: 0;
1836 + }
1837 +
1838 + .comparador-placeholder strong {
1839 + color: #8A8578;
1840 + }
1841 +
1842 + .rango-selector {
1843 + display: flex;
1844 + align-items: center;
1845 + gap: 0.75rem;
1846 + margin-bottom: 0.75rem;
1847 + }
1848 +
1849 + .rango-label {
1850 + font-size: 0.75rem;
1851 + color: #5A5650;
1852 + }
1853 +
1854 + .rango-options {
1855 + display: flex;
1856 + gap: 0.25rem;
1857 + }
1858 +
1859 + .rango-btn {
1860 + padding: 0.375rem 0.75rem;
1861 + font-size: 0.6875rem;
1862 + font-family: 'DM Mono', monospace;
1863 + color: #5A5650;
1864 + background: transparent;
1865 + border: none;
1866 + border-radius: 6px;
1867 + cursor: pointer;
1868 + transition: all 0.2s;
1869 + }
1870 +
1871 + .rango-btn:hover {
1872 + color: #8A8578;
1873 + background: rgba(255,255,255,0.05);
1874 + }
1875 +
1876 + .rango-btn.active {
1877 + color: #F5F0E8;
1878 + background: rgba(255,255,255,0.1);
1879 + }
1880 +
1881 + .dropdown-item:disabled {
1882 + opacity: 0.4;
1883 + cursor: not-allowed;
1884 + }
1885 +
1886 + .selector-group {
1887 + flex: 1;
1888 + display: flex;
1889 + align-items: center;
1890 + gap: 0.5rem;
1891 + }
1892 +
1893 + .selector-dot {
1894 + width: 10px;
1895 + height: 10px;
1896 + border-radius: 50%;
1897 + flex-shrink: 0;
1898 + }
1899 +
1900 + .selector-group select {
1901 + flex: 1;
1902 + background: transparent;
1903 + border: none;
1904 + box-shadow: inset 0 0 0 1px rgba(255,255,255,0.08);
1905 + border-radius: 8px;
1906 + padding: 0.5rem 0.75rem;
1907 + color: #F5F0E8;
1908 + font-size: 0.813rem;
1909 + font-family: 'Instrument Sans', sans-serif;
1910 + cursor: pointer;
1911 + }
1912 + .selector-group select:focus {
1913 + outline: none;
1914 + border-color: #E8C547;
1915 + }
1916 +
1917 + .vs {
1918 + font-family: 'DM Mono', monospace;
1919 + font-size: 0.75rem;
1920 + color: #5A5650;
1921 + flex-shrink: 0;
1922 + }
1923 +
1924 + .chart-comparar {
1925 + gap: 1.5rem;
1926 + justify-content: center;
1927 + height: calc(100vh - 380px);
1928 + min-height: 140px;
1929 + max-height: 280px;
1930 + }
1931 +
1932 + .chart-comparar + .chart-legend + .kpis-comparar {
1933 + margin-top: 0;
1934 + }
1935 +
1936 + .bar-group {
1937 + display: flex;
1938 + flex-direction: column;
1939 + align-items: center;
1940 + height: 100%;
1941 + }
1942 +
1943 + .bars-pair {
1944 + display: flex;
1945 + align-items: flex-end;
1946 + gap: 4px;
1947 + flex: 1;
1948 + }
1949 +
1950 + .bar-a, .bar-b {
1951 + width: 20px;
1952 + border-radius: 4px 4px 0 0;
1953 + min-height: 4px;
1954 + background: var(--bar-color);
1955 + transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), filter 0.25s ease;
1956 + }
1957 +
1958 + .bar-group.active .bar-a,
1959 + .bar-group.active .bar-b {
1960 + filter: brightness(1.15);
1961 + }
1962 +
1963 + .bar-group {
1964 + cursor: pointer;
1965 + padding: 0.5rem 0.35rem;
1966 + margin: -0.5rem -0.35rem;
1967 + border-radius: 6px;
1968 + transition: background 0.3s cubic-bezier(0.4, 0, 0.2, 1);
1969 + }
1970 +
1971 + .bar-group.active {
1972 + background: rgba(42, 157, 143, 0.15);
1973 + }
1974 +
1975 + .bar-label.highlight {
1976 + color: #F5F0E8;
1977 + font-weight: 500;
1978 + transition: color 0.25s ease;
1979 + }
1980 +
1981 + .chart-legend {
1982 + display: flex;
1983 + justify-content: center;
1984 + gap: 2rem;
1985 + margin-top: 0.5rem;
1986 + margin-bottom: 0.75rem;
1987 + }
1988 +
1989 + .legend-item {
1990 + display: flex;
1991 + align-items: center;
1992 + gap: 0.5rem;
1993 + font-size: 0.75rem;
1994 + color: #8A8578;
1995 + }
1996 +
1997 + .legend-dot {
1998 + width: 10px;
1999 + height: 10px;
2000 + border-radius: 3px;
2001 + }
2002 +
2003 + /* KPIs comparativos - tabla */
2004 + .kpis-tabla {
2005 + background: transparent;
2006 + box-shadow: inset 0 0 0 1px rgba(255,255,255,0.08);
2007 + border-radius: 10px;
2008 + padding: 0.5rem 1rem;
2009 + }
2010 +
2011 + .tabla-header {
2012 + display: grid;
2013 + grid-template-columns: 1fr 1fr 1fr;
2014 + gap: 1rem;
2015 + padding-bottom: 0.375rem;
2016 + border-bottom: 1px solid #2E2E2C;
2017 + margin-bottom: 0.375rem;
2018 + }
2019 +
2020 + .tabla-header .tabla-val {
2021 + font-family: 'DM Mono', monospace;
2022 + font-size: 0.625rem;
2023 + letter-spacing: 0.05em;
2024 + text-transform: uppercase;
2025 + text-align: center;
2026 + }
2027 +
2028 + .tabla-row {
2029 + display: grid;
2030 + grid-template-columns: 1fr 1fr 1fr;
2031 + gap: 1rem;
2032 + padding: 0.25rem 0;
2033 + }
2034 +
2035 + .tabla-label {
2036 + font-family: 'DM Mono', monospace;
2037 + font-size: 0.75rem;
2038 + font-weight: 500;
2039 + color: #8A8578;
2040 + display: flex;
2041 + align-items: center;
2042 + text-transform: uppercase;
2043 + letter-spacing: 0.03em;
2044 + }
2045 +
2046 + .tabla-val {
2047 + font-family: 'DM Mono', monospace;
2048 + font-size: 0.9375rem;
2049 + font-weight: 500;
2050 + color: #F5F0E8;
2051 + text-align: center;
2052 + font-variant-numeric: tabular-nums;
2053 + transition: opacity 0.2s ease;
2054 + }
2055 +
2056 + /* Insight comparador */
2057 + .comparador-insight {
2058 + text-align: center;
2059 + padding: 1rem 1.5rem;
2060 + background: linear-gradient(90deg, rgba(232,197,71,0.05), rgba(74,139,110,0.05));
2061 + border-radius: 10px;
2062 + border: 1px solid #2E2E2C;
2063 + font-size: 0.875rem;
2064 + color: #8A8578;
2065 + line-height: 1.6;
2066 + }
2067 +
2068 + .comparador-insight strong {
2069 + color: #F5F0E8;
2070 + }
2071 +
2072 + .insight-year {
2073 + font-family: 'DM Mono', monospace;
2074 + font-size: 0.813rem;
2075 + color: #E8C547;
2076 + }
2077 +
2078 + /* Responsive */
2079 + @media (max-width: 640px) {
2080 + header, main {
2081 + padding: 1.25rem 1rem;
2082 + }
2083 +
2084 + .title-main {
2085 + flex-direction: column;
2086 + gap: 0.25rem;
2087 + }
2088 +
2089 + .codigo {
2090 + font-size: 1.25rem;
2091 + }
2092 +
2093 + h1 {
2094 + font-size: 1.375rem;
2095 + }
2096 +
2097 + .y-axis {
2098 + display: none;
2099 + }
2100 +
2101 + .chart {
2102 + border-left: none;
2103 + height: 120px;
2104 + }
2105 +
2106 + .kpis {
2107 + grid-template-columns: repeat(2, 1fr);
2108 + }
2109 +
2110 + .kpi-value {
2111 + font-size: 1.125rem;
2112 + }
2113 +
2114 + .top-summary {
2115 + flex-direction: column;
2116 + gap: 0.25rem;
2117 + }
2118 +
2119 + .comparador-modal {
2120 + padding: 1.5rem;
2121 + }
2122 +
2123 + .comparador-selectors {
2124 + flex-direction: column;
2125 + gap: 0.75rem;
2126 + }
2127 +
2128 + .vs {
2129 + display: none;
2130 + }
2131 +
2132 + .kpi-values {
2133 + gap: 1rem;
2134 + }
2135 +
2136 + .kpi-val {
2137 + font-size: 0.75rem;
2138 + min-width: 60px;
2139 + }
2140 +
2141 + .comp-bar {
2142 + width: 12px;
2143 + }
2144 +
2145 + .grouped-chart {
2146 + gap: 0.5rem;
2147 + }
2148 + }
2149 +
2150 + /* ========== TEMA CLARO ========== */
2151 + .tema-claro {
2152 + background: #FAFAF8;
2153 + color: #1A1A18;
2154 + }
2155 +
2156 + .tema-claro header {
2157 + border-bottom-color: #E5E5E0;
2158 + }
2159 +
2160 + .tema-claro .breadcrumb a {
2161 + color: #8A8578;
2162 + }
2163 + .tema-claro .breadcrumb a:hover {
2164 + color: #1A1A18;
2165 + }
2166 +
2167 + .tema-claro .breadcrumb-sep {
2168 + color: #C5C5C0;
2169 + }
2170 +
2171 + .tema-claro .codigo {
2172 + color: #8A8578;
2173 + }
2174 +
2175 + .tema-claro h1 {
2176 + color: #1A1A18;
2177 + }
2178 +
2179 + .tema-claro .help-icon {
2180 + color: #8A8578;
2181 + }
2182 + .tema-claro .help-icon:hover,
2183 + .tema-claro .help-icon.active {
2184 + color: #C9A227;
2185 + }
2186 +
2187 + .tema-claro .badge {
2188 + background: #F0F0EC;
2189 + border-color: #E5E5E0;
2190 + color: #5A5650;
2191 + }
2192 +
2193 + .tema-claro .years {
2194 + color: #8A8578;
2195 + }
2196 +
2197 + .tema-claro .info-panel {
2198 + background: #F5F5F3;
2199 + border-bottom-color: #E5E5E0;
2200 + }
2201 +
2202 + .tema-claro .info-content h4 {
2203 + color: #8A8578;
2204 + }
2205 +
2206 + .tema-claro .info-content p {
2207 + color: #5A5650;
2208 + }
2209 +
2210 + .tema-claro .variacion {
2211 + border-left-color: #D5D5D0;
2212 + }
2213 +
2214 + .tema-claro .variacion-rango {
2215 + color: #8A8578;
2216 + }
2217 +
2218 + .tema-claro .hijo-link:hover {
2219 + background: rgba(0, 0, 0, 0.03);
2220 + }
2221 +
2222 + .tema-claro .hijo-codigo {
2223 + color: #8A8578;
2224 + }
2225 +
2226 + .tema-claro .hijo-nombre {
2227 + color: #5A5650;
2228 + }
2229 +
2230 + .tema-claro .hijo-link:hover .hijo-nombre {
2231 + color: #1A1A18;
2232 + }
2233 +
2234 + .tema-claro .vista-toggle {
2235 + background: transparent;
2236 + box-shadow: inset 0 0 0 1px rgba(0,0,0,0.08);
2237 + }
2238 +
2239 + .tema-claro .toggle-btn {
2240 + color: #8A8578;
2241 + }
2242 + .tema-claro .toggle-btn:hover {
2243 + color: #5A5650;
2244 + }
2245 + .tema-claro .toggle-btn.active {
2246 + background: #1A1A18;
2247 + color: #FAFAF8;
2248 + }
2249 +
2250 + .tema-claro .entity-selector .selector-label {
2251 + color: #8A8578;
2252 + }
2253 +
2254 + .tema-claro .interaction-hint {
2255 + color: #8A8578;
2256 + }
2257 +
2258 + .tema-claro .selector-btn {
2259 + background: transparent;
2260 + border: none;
2261 + box-shadow: inset 0 0 0 1px rgba(0,0,0,0.08);
2262 + color: #1A1A18;
2263 + }
2264 + .tema-claro .selector-btn:hover {
2265 + box-shadow: inset 0 0 0 1px rgba(0,0,0,0.15);
2266 + }
2267 + .tema-claro .selector-btn svg {
2268 + color: #8A8578;
2269 + }
2270 +
2271 + .tema-claro .dropdown-panel {
2272 + background: #FAFAF8;
2273 + border-color: #E5E5E0;
2274 + }
2275 +
2276 + .tema-claro .dropdown-search {
2277 + border-bottom-color: #E5E5E0;
2278 + }
2279 +
2280 + .tema-claro .search-input {
2281 + background: #F5F5F3;
2282 + border-color: #E5E5E0;
2283 + color: #1A1A18;
2284 + }
2285 + .tema-claro .search-input:focus {
2286 + border-color: #C9A227;
2287 + }
2288 + .tema-claro .search-input::placeholder {
2289 + color: #8A8578;
2290 + }
2291 +
2292 + .tema-claro .dropdown-item {
2293 + color: #1A1A18;
2294 + }
2295 + .tema-claro .dropdown-item:hover {
2296 + background: rgba(0,0,0,0.03);
2297 + }
2298 + .tema-claro .dropdown-item.active {
2299 + background: rgba(201,162,39,0.12);
2300 + }
2301 +
2302 + .tema-claro .entity-code {
2303 + color: #8A8578;
2304 + }
2305 +
2306 + .tema-claro .chart-title {
2307 + color: #8A8578;
2308 + }
2309 +
2310 + .tema-claro .chart-period {
2311 + color: #333333;
2312 + }
2313 +
2314 + .tema-claro .y-label {
2315 + color: #8A8578;
2316 + }
2317 +
2318 + .tema-claro .y-axis-sticky {
2319 + background: #FAFAF8;
2320 + }
2321 +
2322 + .tema-claro .chart-wrapper-scroll::-webkit-scrollbar-thumb {
2323 + background: #D5D5D0;
2324 + }
2325 +
2326 + .tema-claro .chart {
2327 + border-left-color: #E5E5E0;
2328 + }
2329 +
2330 + .tema-claro .bar-container .bar {
2331 + background: #2a9d8f;
2332 + }
2333 +
2334 + .tema-claro .bar-container .bar.hovered {
2335 + background: #C9A751;
2336 + }
2337 +
2338 + /* Las barras de comparación mantienen su --bar-color */
2339 + .tema-claro .bar-a,
2340 + .tema-claro .bar-b {
2341 + background: var(--bar-color);
2342 + }
2343 +
2344 + .tema-claro .bar-label {
2345 + color: #333333;
2346 + }
2347 +
2348 + .tema-claro .kpis {
2349 + background: transparent;
2350 + }
2351 +
2352 + .tema-claro .kpi {
2353 + background: transparent;
2354 + box-shadow: inset 0 0 0 1px rgba(0,0,0,0.08);
2355 + }
2356 +
2357 + .tema-claro .kpi-value {
2358 + color: #1A1A18;
2359 + }
2360 +
2361 + .tema-claro .kpi-label {
2362 + color: #8A8578;
2363 + }
2364 +
2365 + .tema-claro .top-section h3 {
2366 + color: #8A8578;
2367 + }
2368 +
2369 + .tema-claro .top-context {
2370 + color: #8A8578;
2371 + }
2372 +
2373 + .tema-claro .top-item {
2374 + background: transparent;
2375 + box-shadow: inset 0 0 0 1px rgba(0,0,0,0.08);
2376 + }
2377 +
2378 + .tema-claro .top-rank {
2379 + color: #8A8578;
2380 + }
2381 +
2382 + .tema-claro .top-name {
2383 + color: #1A1A18;
2384 + }
2385 +
2386 + .tema-claro .top-bar-bg {
2387 + background: #F0F0EC;
2388 + }
2389 +
2390 + .tema-claro .top-bar {
2391 + background: #C9A751;
2392 + }
2393 +
2394 + .tema-claro .top-item:hover {
2395 + box-shadow: inset 0 0 0 1px rgba(201, 167, 81, 0.5);
2396 + }
2397 +
2398 + .tema-claro .top-item:hover .top-bar {
2399 + background: #B8963F;
2400 + }
2401 +
2402 + .tema-claro .top-pct {
2403 + color: #333333;
2404 + }
2405 +
2406 + /* Comparador - tema claro */
2407 + .tema-claro .comparador-selectors .selector-group select {
2408 + background: transparent;
2409 + border: none;
2410 + box-shadow: inset 0 0 0 1px rgba(0,0,0,0.08);
2411 + color: #1A1A18;
2412 + }
2413 + .tema-claro .comparador-selectors .selector-group select:focus {
2414 + box-shadow: inset 0 0 0 1px rgba(0,0,0,0.15);
2415 + outline: none;
2416 + }
2417 +
2418 + .tema-claro .comparador-placeholder {
2419 + background: rgba(0,0,0,0.02);
2420 + border-color: #E5E5E0;
2421 + }
2422 +
2423 + .tema-claro .comparador-placeholder p {
2424 + color: #8A8578;
2425 + }
2426 +
2427 + .tema-claro .comparador-placeholder strong {
2428 + color: #5A5650;
2429 + }
2430 +
2431 + .tema-claro .rango-label {
2432 + color: #8A8578;
2433 + }
2434 +
2435 + .tema-claro .rango-btn {
2436 + color: #8A8578;
2437 + }
2438 +
2439 + .tema-claro .rango-btn:hover {
2440 + color: #5A5650;
2441 + background: rgba(0,0,0,0.05);
2442 + }
2443 +
2444 + .tema-claro .rango-btn.active {
2445 + color: #1A1A18;
2446 + background: rgba(0,0,0,0.08);
2447 + }
2448 +
2449 + .tema-claro .vs {
2450 + color: #8A8578;
2451 + }
2452 +
2453 + .tema-claro .chart-legend .legend-item {
2454 + color: #5A5650;
2455 + }
2456 +
2457 + .tema-claro .kpis-tabla {
2458 + background: transparent;
2459 + box-shadow: inset 0 0 0 1px rgba(0,0,0,0.08);
2460 + }
2461 +
2462 + .tema-claro .tabla-header {
2463 + border-bottom-color: #E5E5E0;
2464 + }
2465 +
2466 + .tema-claro .tabla-label {
2467 + color: #5A5650;
2468 + }
2469 +
2470 + .tema-claro .tabla-val {
2471 + color: #1A1A18;
2472 + }
2473 +
2474 + .tema-claro .bar-group.active {
2475 + background: rgba(42, 157, 143, 0.12);
2476 + }
2477 +
2478 + .tema-claro .bar-label.highlight {
2479 + color: #1A1A18;
2480 + }
2481 +</style>
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
3 import { tweened } from 'svelte/motion'; 3 import { tweened } from 'svelte/motion';
4 import { cubicOut } from 'svelte/easing'; 4 import { cubicOut } from 'svelte/easing';
5 import { supabase } from '$lib/supabase'; 5 import { supabase } from '$lib/supabase';
6 + import Spinner from '$lib/components/ui/Spinner.svelte';
6 7
7 // ══════════════════════════════════════════════════════════════ 8 // ══════════════════════════════════════════════════════════════
8 // ESTADO GENERAL 9 // ESTADO GENERAL
...@@ -886,7 +887,7 @@ ...@@ -886,7 +887,7 @@
886 </div> 887 </div>
887 {:else if loadingComparacion} 888 {:else if loadingComparacion}
888 <div class="comparador-placeholder"> 889 <div class="comparador-placeholder">
889 - <p>Cargando datos...</p> 890 + <Spinner size={48} color="var(--theme-texto, #5A5650)" />
890 </div> 891 </div>
891 {:else if añosComparacion.length === 0} 892 {:else if añosComparacion.length === 0}
892 <div class="comparador-placeholder"> 893 <div class="comparador-placeholder">
......