Rafael Lopez

jhola

Showing 53 changed files with 11770 additions and 858 deletions
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
4 let { 4 let {
5 data = [], 5 data = [],
6 hoveredYear = $bindable(null), 6 hoveredYear = $bindable(null),
7 + lockedYear = $bindable(null),
7 height = 220, 8 height = 220,
8 fill = false, 9 fill = false,
9 marginTop = 20, 10 marginTop = 20,
...@@ -12,6 +13,17 @@ ...@@ -12,6 +13,17 @@
12 marginLeft = 50 13 marginLeft = 50
13 } = $props(); 14 } = $props();
14 15
16 + // El año activo es el fijado o el hover
17 + let activeYear = $derived(lockedYear ?? hoveredYear);
18 +
19 + function handleBarClick(año) {
20 + if (lockedYear === año) {
21 + lockedYear = null; // desfijar
22 + } else {
23 + lockedYear = año; // fijar
24 + }
25 + }
26 +
15 // Medir altura real del contenedor cuando fill=true 27 // Medir altura real del contenedor cuando fill=true
16 let wrapperEl = $state(null); 28 let wrapperEl = $state(null);
17 let measuredHeight = $state(height); 29 let measuredHeight = $state(height);
...@@ -97,24 +109,41 @@ ...@@ -97,24 +109,41 @@
97 {/each} 109 {/each}
98 110
99 <!-- Barras --> 111 <!-- Barras -->
100 - <div class="bars" onmouseleave={() => hoveredYear = null} role="group"> 112 + <div class="bars" class:has-active={!!lockedYear} onmouseleave={() => { if (!lockedYear) hoveredYear = null; }} role="group">
101 {#each data as d} 113 {#each data as d}
102 {@const safeVal = typeof d.perCapita === 'number' && isFinite(d.perCapita) ? d.perCapita : 0} 114 {@const safeVal = typeof d.perCapita === 'number' && isFinite(d.perCapita) ? d.perCapita : 0}
103 {@const barPct = chartHeight > 0 ? (yScale(safeVal) / chartHeight) * 100 : 0} 115 {@const barPct = chartHeight > 0 ? (yScale(safeVal) / chartHeight) * 100 : 0}
104 <div 116 <div
105 class="bar-container" 117 class="bar-container"
106 - onmouseenter={() => hoveredYear = d.año} 118 + onmouseenter={() => { if (!lockedYear) hoveredYear = d.año; }}
119 + onclick={() => handleBarClick(d.año)}
107 role="button" 120 role="button"
108 tabindex="0" 121 tabindex="0"
109 > 122 >
110 <div 123 <div
111 class="bar" 124 class="bar"
112 - class:hovered={hoveredYear === d.año} 125 + class:hovered={activeYear === d.año}
126 + class:locked={lockedYear === d.año}
113 style="height: {barPct}%; {safeVal > 0 ? 'min-height: 3px;' : ''}" 127 style="height: {barPct}%; {safeVal > 0 ? 'min-height: 3px;' : ''}"
114 ></div> 128 ></div>
115 </div> 129 </div>
116 {/each} 130 {/each}
117 </div> 131 </div>
132 +
133 + <!-- Hint -->
134 + <div class="chart-hint">
135 + {#if lockedYear}
136 + <span class="hint-locked">
137 + {lockedYear} fijado
138 + <button class="hint-unlock" onclick={() => lockedYear = null}>soltar</button>
139 + </span>
140 + {:else if activeYear}
141 + <span class="hint-hover hint-desktop-only">Click para fijar {activeYear}</span>
142 + {:else}
143 + <span class="hint-idle hint-desktop-only">Pasa el cursor sobre las barras</span>
144 + <span class="hint-idle hint-mobile-only">Toca una barra para explorar</span>
145 + {/if}
146 + </div>
118 </div> 147 </div>
119 148
120 <!-- Eje X --> 149 <!-- Eje X -->
...@@ -122,7 +151,7 @@ ...@@ -122,7 +151,7 @@
122 {#each data as d, i} 151 {#each data as d, i}
123 <span 152 <span
124 class="x-label visible" 153 class="x-label visible"
125 - class:hovered={hoveredYear === d.año} 154 + class:hovered={activeYear === d.año}
126 > 155 >
127 {String(d.año).slice(-2)} 156 {String(d.año).slice(-2)}
128 </span> 157 </span>
...@@ -183,7 +212,7 @@ ...@@ -183,7 +212,7 @@
183 display: flex; 212 display: flex;
184 align-items: flex-end; 213 align-items: flex-end;
185 justify-content: center; 214 justify-content: center;
186 - cursor: default; 215 + cursor: pointer;
187 } 216 }
188 217
189 .bar { 218 .bar {
...@@ -198,14 +227,61 @@ ...@@ -198,14 +227,61 @@
198 background: rgba(107, 159, 212, 0.85); 227 background: rgba(107, 159, 212, 0.85);
199 } 228 }
200 229
230 + /* Fade en hover */
201 .bars:hover .bar { 231 .bars:hover .bar {
202 opacity: 0.35; 232 opacity: 0.35;
203 } 233 }
204 -
205 .bars:hover .bar.hovered { 234 .bars:hover .bar.hovered {
206 opacity: 1; 235 opacity: 1;
207 } 236 }
208 237
238 + /* Fade permanente cuando hay año fijado */
239 + .bars.has-active .bar {
240 + opacity: 0.25;
241 + }
242 + .bars.has-active .bar.locked {
243 + opacity: 1;
244 + box-shadow: 0 0 0 2px var(--theme-accent, #C9A751);
245 + }
246 +
247 + /* Chart hint */
248 + .chart-hint {
249 + position: absolute;
250 + top: -2px;
251 + right: 0;
252 + font-family: 'Qanelas', var(--font-sans);
253 + font-size: 0.625rem;
254 + color: var(--theme-texto);
255 + opacity: 0.5;
256 + }
257 + .hint-locked {
258 + display: inline-flex;
259 + align-items: center;
260 + gap: 6px;
261 + color: var(--theme-accent, #C9A751);
262 + opacity: 1;
263 + }
264 + .hint-unlock {
265 + background: none;
266 + border: none;
267 + border-bottom: 1px dotted currentColor;
268 + color: inherit;
269 + font: inherit;
270 + cursor: pointer;
271 + padding: 0;
272 + opacity: 0.7;
273 + transition: opacity 0.15s;
274 + }
275 + .hint-unlock:hover {
276 + opacity: 1;
277 + }
278 + .hint-mobile-only { display: none; }
279 + .hint-desktop-only { display: inline; }
280 + @media (max-width: 768px) {
281 + .hint-mobile-only { display: inline; }
282 + .hint-desktop-only { display: none; }
283 + }
284 +
209 .x-axis { 285 .x-axis {
210 position: absolute; 286 position: absolute;
211 bottom: 0; 287 bottom: 0;
......
...@@ -78,7 +78,6 @@ ...@@ -78,7 +78,6 @@
78 class:active={selectedEntity?.entidad === entity.entidad} 78 class:active={selectedEntity?.entidad === entity.entidad}
79 onclick={() => selectEntity(entity)} 79 onclick={() => selectEntity(entity)}
80 > 80 >
81 - <span class="item-code">{entity.entidad}</span>
82 <span class="item-name">{entity.entidad_desc}</span> 81 <span class="item-name">{entity.entidad_desc}</span>
83 </button> 82 </button>
84 {/each} 83 {/each}
......
...@@ -5,6 +5,8 @@ ...@@ -5,6 +5,8 @@
5 5
6 let isDark = $state(false); 6 let isDark = $state(false);
7 let isMac = $state(false); 7 let isMac = $state(false);
8 + let canGoBack = $state(false);
9 + let canGoForward = $state(false);
8 10
9 function toggleTheme() { 11 function toggleTheme() {
10 isDark = !isDark; 12 isDark = !isDark;
...@@ -12,14 +14,39 @@ ...@@ -12,14 +14,39 @@
12 localStorage.setItem('theme', isDark ? 'dark' : 'light'); 14 localStorage.setItem('theme', isDark ? 'dark' : 'light');
13 } 15 }
14 16
17 + function goBack() { history.back(); }
18 + function goForward() { history.forward(); }
19 +
20 + function updateNavState() {
21 + // history.length > 1 means there's something to go back to
22 + // We track forward availability via sessionStorage
23 + canGoBack = history.length > 1 && sessionStorage.getItem('nav_depth') > 0;
24 + canGoForward = sessionStorage.getItem('nav_forward') === 'true';
25 + }
26 +
15 onMount(() => { 27 onMount(() => {
16 - // Detect Mac for keyboard shortcut display
17 isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform); 28 isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
18 -
19 - // Read actual state from document (set by app.html)
20 isDark = document.documentElement.classList.contains('dark'); 29 isDark = document.documentElement.classList.contains('dark');
21 30
22 - // Listen for theme changes (e.g., from drawer on mobile) 31 + // Track navigation depth for back/forward state
32 + let depth = parseInt(sessionStorage.getItem('nav_depth') || '0');
33 + // On first visit, depth is 0
34 + // We increment on each navigation
35 + depth++;
36 + sessionStorage.setItem('nav_depth', depth.toString());
37 + canGoBack = depth > 1;
38 +
39 + // Listen for popstate (back/forward browser actions) to enable forward
40 + const handlePopstate = () => {
41 + sessionStorage.setItem('nav_forward', 'true');
42 + canGoForward = true;
43 + const d = parseInt(sessionStorage.getItem('nav_depth') || '1');
44 + sessionStorage.setItem('nav_depth', Math.max(0, d - 1).toString());
45 + canGoBack = d - 1 > 0;
46 + };
47 + window.addEventListener('popstate', handlePopstate);
48 +
49 + // Listen for theme changes
23 const observer = new MutationObserver(() => { 50 const observer = new MutationObserver(() => {
24 isDark = document.documentElement.classList.contains('dark'); 51 isDark = document.documentElement.classList.contains('dark');
25 }); 52 });
...@@ -28,12 +55,27 @@ ...@@ -28,12 +55,27 @@
28 attributeFilter: ['class'] 55 attributeFilter: ['class']
29 }); 56 });
30 57
31 - return () => observer.disconnect(); 58 + return () => {
59 + observer.disconnect();
60 + window.removeEventListener('popstate', handlePopstate);
61 + };
32 }); 62 });
33 </script> 63 </script>
34 64
35 -<!-- MÓVIL: Home + Búsqueda + Theme + hamburguesa arriba a la derecha --> 65 +<!-- MÓVIL: Nav + Home + Búsqueda + Theme + hamburguesa -->
36 <div class="navbar-mobile"> 66 <div class="navbar-mobile">
67 + <div class="nav-arrows">
68 + <button onclick={goBack} class="nav-arrow-btn" class:nav-arrow-disabled={!canGoBack} aria-label="Atrás" disabled={!canGoBack}>
69 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
70 + <path d="M19 12H5M12 19l-7-7 7-7"/>
71 + </svg>
72 + </button>
73 + <button onclick={goForward} class="nav-arrow-btn" class:nav-arrow-disabled={!canGoForward} aria-label="Adelante" disabled={!canGoForward}>
74 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
75 + <path d="M5 12h14M12 5l7 7-7 7"/>
76 + </svg>
77 + </button>
78 + </div>
37 <a href="/" class="nav-home-link" aria-label="Ir al inicio"> 79 <a href="/" class="nav-home-link" aria-label="Ir al inicio">
38 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 80 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
39 <path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/> 81 <path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
...@@ -76,10 +118,24 @@ ...@@ -76,10 +118,24 @@
76 </button> 118 </button>
77 </div> 119 </div>
78 120
79 -<!-- DESKTOP: Barra completa con Home + Search + Theme + Menu --> 121 +<!-- DESKTOP: Barra completa con Nav + Home + Search + Theme + Menu -->
80 <div class="navbar-desktop"> 122 <div class="navbar-desktop">
81 <div class="navbar-desktop-inner"> 123 <div class="navbar-desktop-inner">
82 124
125 + <!-- FLECHAS NAVEGACIÓN -->
126 + <div class="nav-arrows">
127 + <button onclick={goBack} class="nav-arrow-btn" class:nav-arrow-disabled={!canGoBack} aria-label="Atrás" disabled={!canGoBack}>
128 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
129 + <path d="M19 12H5M12 19l-7-7 7-7"/>
130 + </svg>
131 + </button>
132 + <button onclick={goForward} class="nav-arrow-btn" class:nav-arrow-disabled={!canGoForward} aria-label="Adelante" disabled={!canGoForward}>
133 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
134 + <path d="M5 12h14M12 5l7 7-7 7"/>
135 + </svg>
136 + </button>
137 + </div>
138 +
83 <!-- BOTÓN HOME --> 139 <!-- BOTÓN HOME -->
84 <div class="relative group/home"> 140 <div class="relative group/home">
85 <a href="/" class="nav-btn-icon" aria-label="Ir al inicio"> 141 <a href="/" class="nav-btn-icon" aria-label="Ir al inicio">
...@@ -390,4 +446,45 @@ ...@@ -390,4 +446,45 @@
390 background: rgba(0, 0, 0, 0.12); 446 background: rgba(0, 0, 0, 0.12);
391 color: #1A1A18; 447 color: #1A1A18;
392 } 448 }
449 +
450 + /* Navigation arrows */
451 + .nav-arrows {
452 + display: flex;
453 + align-items: center;
454 + gap: 2px;
455 + margin-right: 2px;
456 + }
457 +
458 + .nav-arrow-btn {
459 + display: flex;
460 + align-items: center;
461 + justify-content: center;
462 + padding: 6px;
463 + background: transparent;
464 + border: none;
465 + border-radius: 8px;
466 + color: #B8B5AD;
467 + cursor: pointer;
468 + transition: all 0.2s ease;
469 + }
470 +
471 + .nav-arrow-btn:hover {
472 + background: rgba(255, 255, 255, 0.1);
473 + color: #F5F0E8;
474 + }
475 +
476 + .nav-arrow-disabled {
477 + opacity: 0.2;
478 + cursor: default;
479 + pointer-events: none;
480 + }
481 +
482 + :global(html:not(.dark)) .nav-arrow-btn {
483 + color: #888;
484 + }
485 +
486 + :global(html:not(.dark)) .nav-arrow-btn:hover {
487 + background: rgba(0, 0, 0, 0.06);
488 + color: #1A1A18;
489 + }
393 </style> 490 </style>
......
...@@ -25,7 +25,6 @@ ...@@ -25,7 +25,6 @@
25 { id: 'sectores', label: '¿En qué sectores?', tecnico: 'Sectores económicos' }, 25 { id: 'sectores', label: '¿En qué sectores?', tecnico: 'Sectores económicos' },
26 { id: 'rubro', label: '¿Con qué recursos?', tecnico: 'Rubros' }, 26 { id: 'rubro', label: '¿Con qué recursos?', tecnico: 'Rubros' },
27 { id: 'organismo', label: '¿Quién financia?', tecnico: 'Organismos' }, 27 { id: 'organismo', label: '¿Quién financia?', tecnico: 'Organismos' },
28 - { id: 'fuente', label: 'Origen del ingreso', tecnico: 'Fuentes' },
29 ]; 28 ];
30 29
31 function toggleClassifier(id) { 30 function toggleClassifier(id) {
...@@ -40,7 +39,7 @@ ...@@ -40,7 +39,7 @@
40 } 39 }
41 40
42 // Clasificadores seleccionados → class_ param para Typesense 41 // Clasificadores seleccionados → class_ param para Typesense
43 - const CLASS_API_MAP = { geografico: 'ubigeo', sectores: 'acteco' }; 42 + const CLASS_API_MAP = { geografico: 'ubigeo', sectores: 'acteco', finfun: 'finalidad' };
44 43
45 function getActiveClasses() { 44 function getActiveClasses() {
46 if (selectedClassifiers.length === 0) return []; 45 if (selectedClassifiers.length === 0) return [];
...@@ -102,6 +101,16 @@ ...@@ -102,6 +101,16 @@
102 } else if (isClass && cls === 'ubigeo') { 101 } else if (isClass && cls === 'ubigeo') {
103 codigo = String(meta.municipio_ubigeo || ''); 102 codigo = String(meta.municipio_ubigeo || '');
104 tipo = 'ubigeo'; 103 tipo = 'ubigeo';
104 + } else if (isClass && cls === 'acteco') {
105 + const s = meta.acteco_sector != null ? String(Math.round(meta.acteco_sector)) : '';
106 + if (s) {
107 + codigo = s;
108 + if (meta.acteco_subsector != null) {
109 + codigo += '.' + Math.round(meta.acteco_subsector);
110 + if (meta.acteco_actividad != null) codigo += '.' + Math.round(meta.acteco_actividad);
111 + }
112 + }
113 + tipo = 'acteco';
105 } else if (!isClass) { 114 } else if (!isClass) {
106 // Programa/proyecto 115 // Programa/proyecto
107 tipo = 'programa'; 116 tipo = 'programa';
...@@ -200,9 +209,9 @@ ...@@ -200,9 +209,9 @@
200 entidad: '/entidad/', 209 entidad: '/entidad/',
201 objeto_gasto: '/objeto/', 210 objeto_gasto: '/objeto/',
202 finfun: '/finfun/', 211 finfun: '/finfun/',
212 + acteco: '/acteco/',
203 rubro: '/rubro/', 213 rubro: '/rubro/',
204 organismo: '/organismo/', 214 organismo: '/organismo/',
205 - fuente: '/fuente/',
206 ubigeo: '/ubicacion/', 215 ubigeo: '/ubicacion/',
207 programa: '/proyecto/' 216 programa: '/proyecto/'
208 }; 217 };
...@@ -279,7 +288,7 @@ ...@@ -279,7 +288,7 @@
279 bind:this={searchInput} 288 bind:this={searchInput}
280 type="text" 289 type="text"
281 class="search-modal-input" 290 class="search-modal-input"
282 - placeholder={searchMode === 'programas' ? 'Buscar programas y proyectos...' : selectedClassifiers.length > 0 ? 'Buscar en clasificadores seleccionados...' : 'Selecciona al menos un clasificador...'} 291 + placeholder={searchMode === 'todo' ? 'Buscar en todo...' : searchMode === 'programas' ? 'Buscar programas y proyectos...' : selectedClassifiers.length > 0 ? 'Buscar en clasificadores seleccionados...' : 'Selecciona al menos un clasificador...'}
283 value={searchVal} 292 value={searchVal}
284 oninput={handleInput} 293 oninput={handleInput}
285 /> 294 />
...@@ -291,8 +300,11 @@ ...@@ -291,8 +300,11 @@
291 <!-- Mode toggle + classifier pills --> 300 <!-- Mode toggle + classifier pills -->
292 <div class="search-modal-filters"> 301 <div class="search-modal-filters">
293 <div class="mode-toggle"> 302 <div class="mode-toggle">
303 + <button class="mode-btn" class:active={searchMode === 'todo'} onclick={() => { searchMode = 'todo'; selectedClassifiers = []; landingSearchMode.set('todo'); landingSelectedClassifiers.set([]); if (searchVal.length >= 2) doSearch(searchVal); }}>
304 + Todo
305 + </button>
294 <button class="mode-btn" class:active={searchMode === 'programas'} onclick={() => { searchMode = 'programas'; selectedClassifiers = []; landingSearchMode.set('programas'); landingSelectedClassifiers.set([]); if (searchVal.length >= 2) doSearch(searchVal); }}> 306 <button class="mode-btn" class:active={searchMode === 'programas'} onclick={() => { searchMode = 'programas'; selectedClassifiers = []; landingSearchMode.set('programas'); landingSelectedClassifiers.set([]); if (searchVal.length >= 2) doSearch(searchVal); }}>
295 - Programas 307 + Programas y Proyectos
296 </button> 308 </button>
297 <button class="mode-btn" class:active={searchMode === 'clasificadores'} onclick={() => { searchMode = 'clasificadores'; landingSearchMode.set('clasificadores'); if (searchVal.length >= 2) doSearch(searchVal); }}> 309 <button class="mode-btn" class:active={searchMode === 'clasificadores'} onclick={() => { searchMode = 'clasificadores'; landingSearchMode.set('clasificadores'); if (searchVal.length >= 2) doSearch(searchVal); }}>
298 Clasificadores 310 Clasificadores
......
...@@ -10,6 +10,12 @@ ...@@ -10,6 +10,12 @@
10 localStorage.setItem('theme', isDark ? 'dark' : 'light'); 10 localStorage.setItem('theme', isDark ? 'dark' : 'light');
11 } 11 }
12 12
13 + // Navegación back/forward
14 + let canGoBack = false;
15 + let canGoForward = false;
16 + function goBack() { history.back(); }
17 + function goForward() { history.forward(); }
18 +
13 // Estado base 19 // Estado base
14 let mounted = false; 20 let mounted = false;
15 let heroInView = true; 21 let heroInView = true;
...@@ -87,10 +93,7 @@ ...@@ -87,10 +93,7 @@
87 return true; 93 return true;
88 }); 94 });
89 95
90 - // En modo todo/mixto: mantener orden de la API (relevancia) 96 + // Ordenar por criterio del usuario
91 - if (isFlatMode) return hits;
92 -
93 - // En otros modos: ordenar por criterio del usuario
94 return [...hits].sort((a, b) => { 97 return [...hits].sort((a, b) => {
95 const dir = sortOrder === 'desc' ? 1 : -1; 98 const dir = sortOrder === 'desc' ? 1 : -1;
96 if (sortBy === 'alpha') return dir * (a.document.texto || '').localeCompare(b.document.texto || ''); 99 if (sortBy === 'alpha') return dir * (a.document.texto || '').localeCompare(b.document.texto || '');
...@@ -105,10 +108,13 @@ ...@@ -105,10 +108,13 @@
105 // Filtrar hits según clasificadores seleccionados (la API puede devolver tipos extra) 108 // Filtrar hits según clasificadores seleccionados (la API puede devolver tipos extra)
106 $: filteredHits = (() => { 109 $: filteredHits = (() => {
107 if (searchMode !== 'clasificadores' || selectedClassifiers.length === 0) return allHits; 110 if (searchMode !== 'clasificadores' || selectedClassifiers.length === 0) return allHits;
108 - const allowedClasses = new Set(selectedClassifiers.map(c => { 111 + // Permitir tanto el id interno como el nombre que devuelve la API
109 - const map = { geografico: 'ubigeo', sectores: 'acteco', finfun: 'finalidad' }; 112 + const allowedClasses = new Set();
110 - return map[c] || c; 113 + selectedClassifiers.forEach(c => {
111 - })); 114 + allowedClasses.add(c); // id interno: finfun, geografico, sectores
115 + const apiName = { geografico: 'ubigeo', sectores: 'acteco', finfun: 'finalidad' }[c];
116 + if (apiName) allowedClasses.add(apiName); // nombre API: ubigeo, acteco, finalidad
117 + });
112 return allHits.filter(h => allowedClasses.has(h.document.class_)); 118 return allHits.filter(h => allowedClasses.has(h.document.class_));
113 })(); 119 })();
114 120
...@@ -350,7 +356,6 @@ ...@@ -350,7 +356,6 @@
350 { id: 'finfun', label: '¿Para qué se gasta?', icon: '🎯', tecnico: 'Finalidad y función', desc: 'El propósito del gasto según su función social: salud, educación, defensa, justicia, infraestructura, etc.' }, 356 { id: 'finfun', label: '¿Para qué se gasta?', icon: '🎯', tecnico: 'Finalidad y función', desc: 'El propósito del gasto según su función social: salud, educación, defensa, justicia, infraestructura, etc.' },
351 { id: 'rubro', label: '¿Con qué recursos?', icon: '💰', tecnico: 'Rubros de ingreso', desc: 'Tipos de recursos que financian el gasto: impuestos, tasas, regalías, transferencias, donaciones, créditos, etc.' }, 357 { id: 'rubro', label: '¿Con qué recursos?', icon: '💰', tecnico: 'Rubros de ingreso', desc: 'Tipos de recursos que financian el gasto: impuestos, tasas, regalías, transferencias, donaciones, créditos, etc.' },
352 { id: 'organismo', label: '¿Quién financia?', icon: '🏦', tecnico: 'Organismos financiadores', desc: 'Origen institucional del financiamiento: Tesoro General, gobiernos subnacionales, organismos internacionales, etc.' }, 358 { id: 'organismo', label: '¿Quién financia?', icon: '🏦', tecnico: 'Organismos financiadores', desc: 'Origen institucional del financiamiento: Tesoro General, gobiernos subnacionales, organismos internacionales, etc.' },
353 - { id: 'fuente', label: 'Origen del ingreso', icon: '💵', tecnico: 'Fuentes de financiamiento', desc: 'Clasificación por tipo de fuente: recursos propios, transferencias, crédito interno/externo, donaciones.' },
354 ]; 359 ];
355 360
356 // Modal explicativo contextual 361 // Modal explicativo contextual
...@@ -426,18 +431,36 @@ ...@@ -426,18 +431,36 @@
426 // Placeholder dinámico 431 // Placeholder dinámico
427 $: placeholderText = getPlaceholderText(searchMode, selectedClassifiers); 432 $: placeholderText = getPlaceholderText(searchMode, selectedClassifiers);
428 433
434 + const PLACEHOLDER_EXAMPLES = {
435 + entidad: ['Hospital La Paz', 'Ministerio de Salud', 'UMSA', 'Gobierno Municipal de Cochabamba'],
436 + objeto: ['Sueldos', 'Viáticos', 'Consultorías', 'Alquileres'],
437 + finfun: ['Edad avanzada', 'Cambio climático', 'Salud', 'Justicia'],
438 + geografico: ['Filadelfia', 'Tipuani', 'Sucre', 'Oruro'],
439 + sectores: ['Energía eléctrica', 'Vías urbanas', 'Construcción', 'Transporte'],
440 + rubro: ['Impuestos', 'Regalías', 'Ventas', 'Transferencias'],
441 + organismo: ['Unión Europea', 'JICA', 'BID', 'Banco Mundial'],
442 + };
443 +
444 + function pickExamples(classifiers) {
445 + if (classifiers.length === 1) {
446 + const examples = PLACEHOLDER_EXAMPLES[classifiers[0]] || [];
447 + return [examples[0], examples[1]].filter(Boolean);
448 + }
449 + return classifiers.map((id, i) => {
450 + const examples = PLACEHOLDER_EXAMPLES[id] || [];
451 + return examples[i % examples.length] || '';
452 + }).filter(Boolean);
453 + }
454 +
429 function getPlaceholderText(mode, classifiers) { 455 function getPlaceholderText(mode, classifiers) {
430 if (mode === 'programas') { 456 if (mode === 'programas') {
431 return 'Buscar en programas y proyectos...'; 457 return 'Buscar en programas y proyectos...';
432 } 458 }
433 if (mode === 'clasificadores' && classifiers.length > 0) { 459 if (mode === 'clasificadores' && classifiers.length > 0) {
434 - const nombres = classifiers.map(id => { 460 + const picked = pickExamples(classifiers);
435 - const clf = CLASIFICADORES.find(c => c.id === id); 461 + return picked.join(', ') + '...';
436 - return clf?.label?.toLowerCase() || id;
437 - }).join(', ');
438 - return `Buscar en ${nombres}...`;
439 } 462 }
440 - return 'Sueldos, Hospital La Paz, Ministerio de Salud...'; 463 + return 'Hospital La Paz, Viáticos, Educación, Regalías, Tipuani...';
441 } 464 }
442 465
443 // Debounce 466 // Debounce
...@@ -561,6 +584,23 @@ ...@@ -561,6 +584,23 @@
561 let sectionObserver; 584 let sectionObserver;
562 onMount(() => { 585 onMount(() => {
563 isDark = document.documentElement.classList.contains('dark'); 586 isDark = document.documentElement.classList.contains('dark');
587 +
588 + // Nav depth tracking
589 + let depth = parseInt(sessionStorage.getItem('nav_depth') || '0');
590 + depth++;
591 + sessionStorage.setItem('nav_depth', depth.toString());
592 + canGoBack = depth > 1;
593 + canGoForward = sessionStorage.getItem('nav_forward') === 'true';
594 +
595 + const handlePopstate = () => {
596 + sessionStorage.setItem('nav_forward', 'true');
597 + canGoForward = true;
598 + const d = parseInt(sessionStorage.getItem('nav_depth') || '1');
599 + sessionStorage.setItem('nav_depth', Math.max(0, d - 1).toString());
600 + canGoBack = d - 1 > 0;
601 + };
602 + window.addEventListener('popstate', handlePopstate);
603 +
564 setTimeout(() => { mounted = true; }, 80); 604 setTimeout(() => { mounted = true; }, 80);
565 fetchDaily(); 605 fetchDaily();
566 606
...@@ -753,6 +793,17 @@ ...@@ -753,6 +793,17 @@
753 url = `/objeto/${getObjetoCodigo(meta)}`; 793 url = `/objeto/${getObjetoCodigo(meta)}`;
754 } else if (isFinfunClass) { 794 } else if (isFinfunClass) {
755 url = `/finfun/${getFinfunCodigo(meta)}`; 795 url = `/finfun/${getFinfunCodigo(meta)}`;
796 + } else if (hit.document.class_ === 'acteco') {
797 + const s = String(meta.acteco_sector ? Math.round(meta.acteco_sector) : '');
798 + let code = s;
799 + if (meta.acteco_actividad !== undefined && meta.acteco_subsector !== undefined) code = `${s}.${Math.round(meta.acteco_subsector)}.${Math.round(meta.acteco_actividad)}`;
800 + else if (meta.acteco_subsector !== undefined) code = `${s}.${Math.round(meta.acteco_subsector)}`;
801 + url = `/acteco/${code}`;
802 + } else if (hit.document.class_ === 'rubro') {
803 + const code = meta.rubro_sub_cuenta || meta.rubro_cuenta || meta.rubro_clase || meta.rubro_tipo || '';
804 + url = `/rubro/${code}`;
805 + } else if (hit.document.class_ === 'organismo') {
806 + url = `/organismo/${meta.organismo || ''}`;
756 } else if (isClassResult) { 807 } else if (isClassResult) {
757 url = `/clasificador/${hit.document.class_}/${hit.document.id}`; 808 url = `/clasificador/${hit.document.class_}/${hit.document.id}`;
758 } else { 809 } else {
...@@ -811,6 +862,9 @@ ...@@ -811,6 +862,9 @@
811 hits.forEach(h => { 862 hits.forEach(h => {
812 const m = parseMetadatos(h.document.metadatos); 863 const m = parseMetadatos(h.document.metadatos);
813 if (m.entidad) entidadesSet.add(m.entidad); 864 if (m.entidad) entidadesSet.add(m.entidad);
865 + if (m.entidades && Array.isArray(m.entidades)) {
866 + m.entidades.forEach(e => { if (e.entidad) entidadesSet.add(e.entidad); });
867 + }
814 if (m.gestion) { 868 if (m.gestion) {
815 const years = m.gestion.split(',').map(y => parseInt(y.trim())).filter(y => !isNaN(y)); 869 const years = m.gestion.split(',').map(y => parseInt(y.trim())).filter(y => !isNaN(y));
816 allYears.push(...years); 870 allYears.push(...years);
...@@ -1409,8 +1463,13 @@ ...@@ -1409,8 +1463,13 @@
1409 {@const isObjetoClass = hit.document.class_ === 'objeto'} 1463 {@const isObjetoClass = hit.document.class_ === 'objeto'}
1410 {@const isFinfunClass = hit.document.class_ === 'finfun' || hit.document.class_ === 'finalidad'} 1464 {@const isFinfunClass = hit.document.class_ === 'finfun' || hit.document.class_ === 'finalidad'}
1411 {@const isUbigeoClass = hit.document.class_ === 'ubigeo'} 1465 {@const isUbigeoClass = hit.document.class_ === 'ubigeo'}
1466 + {@const isActecoClass = hit.document.class_ === 'acteco'}
1467 + {@const isRubroClass = hit.document.class_ === 'rubro'}
1468 + {@const isOrganismoClass = hit.document.class_ === 'organismo'}
1412 {@const isClassResult = hit.document.is_class === true} 1469 {@const isClassResult = hit.document.is_class === true}
1413 {@const hitType = CLASS_REVERSE_MAP[hit.document.class_] || hit.document.class_} 1470 {@const hitType = CLASS_REVERSE_MAP[hit.document.class_] || hit.document.class_}
1471 + {@const actecoCode = isActecoClass ? (() => { const s = String(meta.acteco_sector ? Math.round(meta.acteco_sector) : ''); if (meta.acteco_actividad !== undefined && meta.acteco_subsector !== undefined) return `${s}.${Math.round(meta.acteco_subsector)}.${Math.round(meta.acteco_actividad)}`; if (meta.acteco_subsector !== undefined) return `${s}.${Math.round(meta.acteco_subsector)}`; return s; })() : ''}
1472 + {@const rubroCode = isRubroClass ? (meta.rubro_sub_cuenta || meta.rubro_cuenta || meta.rubro_clase || meta.rubro_tipo || '') : ''}
1414 <a 1473 <a
1415 href={isEntidadClass 1474 href={isEntidadClass
1416 ? `/entidad/${meta.da ? `${meta.entidad}.${meta.da}` : meta.entidad}` 1475 ? `/entidad/${meta.da ? `${meta.entidad}.${meta.da}` : meta.entidad}`
...@@ -1420,6 +1479,12 @@ ...@@ -1420,6 +1479,12 @@
1420 ? `/objeto/${getObjetoCodigo(meta)}` 1479 ? `/objeto/${getObjetoCodigo(meta)}`
1421 : isFinfunClass 1480 : isFinfunClass
1422 ? `/finfun/${getFinfunCodigo(meta)}` 1481 ? `/finfun/${getFinfunCodigo(meta)}`
1482 + : isActecoClass
1483 + ? `/acteco/${actecoCode}`
1484 + : isRubroClass
1485 + ? `/rubro/${rubroCode}`
1486 + : isOrganismoClass
1487 + ? `/organismo/${meta.organismo || ''}`
1423 : (isClassResult ? `/clasificador/${hit.document.class_}/${hit.document.id}` : `/proyecto/${generateCodigo(hit)}`)} 1488 : (isClassResult ? `/clasificador/${hit.document.class_}/${hit.document.id}` : `/proyecto/${generateCodigo(hit)}`)}
1424 class="result-card" 1489 class="result-card"
1425 class:result-card-selected={selectedResultIndex === i} 1490 class:result-card-selected={selectedResultIndex === i}
...@@ -2199,12 +2264,12 @@ ...@@ -2199,12 +2264,12 @@
2199 2264
2200 .clf-chip{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border-radius:8px;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);cursor:pointer;transition:all 0.2s;font-family:var(--sans);font-size:12px;color:#A8A49C} 2265 .clf-chip{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border-radius:8px;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);cursor:pointer;transition:all 0.2s;font-family:var(--sans);font-size:12px;color:#A8A49C}
2201 .clf-chip:hover{background:rgba(255,255,255,0.08);border-color:rgba(255,255,255,0.15);color:#D0CCC4} 2266 .clf-chip:hover{background:rgba(255,255,255,0.08);border-color:rgba(255,255,255,0.15);color:#D0CCC4}
2202 - .clf-chip-active{background:rgba(90,175,138,0.15);border-color:rgba(90,175,138,0.4);color:#6BC99E} 2267 + .clf-chip-active{background:rgba(240,193,77,0.12);border-color:rgba(240,193,77,0.4);color:#F0C14D}
2203 .clf-chip-disabled{opacity:0.35;cursor:not-allowed} 2268 .clf-chip-disabled{opacity:0.35;cursor:not-allowed}
2204 .clf-chip-disabled:hover{background:rgba(255,255,255,0.04);border-color:rgba(255,255,255,0.08)} 2269 .clf-chip-disabled:hover{background:rgba(255,255,255,0.04);border-color:rgba(255,255,255,0.08)}
2205 .clf-dot{width:5px;height:5px;border-radius:50%;background:rgba(255,255,255,0.15);flex-shrink:0;transition:all 0.3s cubic-bezier(0.16,1,0.3,1)} 2270 .clf-dot{width:5px;height:5px;border-radius:50%;background:rgba(255,255,255,0.15);flex-shrink:0;transition:all 0.3s cubic-bezier(0.16,1,0.3,1)}
2206 .clf-chip:hover .clf-dot{background:rgba(255,255,255,0.3)} 2271 .clf-chip:hover .clf-dot{background:rgba(255,255,255,0.3)}
2207 - .clf-chip-active .clf-dot{background:#4A8B6E;box-shadow:0 0 6px rgba(74,139,110,0.3)} 2272 + .clf-chip-active .clf-dot{background:#F0C14D;box-shadow:0 0 8px rgba(240,193,77,0.5)}
2208 .clf-label{line-height:1} 2273 .clf-label{line-height:1}
2209 2274
2210 .mode-card-pills{display:flex;flex-wrap:wrap;gap:5px;margin-top:6px;align-items:center} 2275 .mode-card-pills{display:flex;flex-wrap:wrap;gap:5px;margin-top:6px;align-items:center}
...@@ -2353,6 +2418,12 @@ ...@@ -2353,6 +2418,12 @@
2353 .nav-menu-btn:hover{background:rgba(255,255,255,0.1);color:#F5F0E8} 2418 .nav-menu-btn:hover{background:rgba(255,255,255,0.1);color:#F5F0E8}
2354 .nav-theme-btn{background:rgba(255,255,255,0.06);border:none;border-radius:10px;width:44px;height:44px;display:flex;align-items:center;justify-content:center;cursor:pointer;color:#B8B5AD;transition:all 0.3s ease} 2419 .nav-theme-btn{background:rgba(255,255,255,0.06);border:none;border-radius:10px;width:44px;height:44px;display:flex;align-items:center;justify-content:center;cursor:pointer;color:#B8B5AD;transition:all 0.3s ease}
2355 .nav-theme-btn:hover{background:rgba(255,255,255,0.1);color:#F5F0E8} 2420 .nav-theme-btn:hover{background:rgba(255,255,255,0.1);color:#F5F0E8}
2421 + .nav-arrows{display:flex;align-items:center;gap:2px}
2422 + .nav-arrow-btn{display:flex;align-items:center;justify-content:center;padding:6px;background:transparent;border:none;border-radius:8px;color:#B8B5AD;cursor:pointer;transition:all 0.2s}
2423 + .nav-arrow-btn:hover{background:rgba(255,255,255,0.1);color:#F5F0E8}
2424 + .nav-arrow-disabled{opacity:0.2;cursor:default;pointer-events:none}
2425 + :global(html:not(.dark)) .nav-arrow-btn{color:#888}
2426 + :global(html:not(.dark)) .nav-arrow-btn:hover{background:rgba(0,0,0,0.06);color:#1d1d1f}
2356 .nav-hidden{opacity:0;pointer-events:none;transform:translateY(-10px)} 2427 .nav-hidden{opacity:0;pointer-events:none;transform:translateY(-10px)}
2357 2428
2358 /* ── DIRECTORY ── */ 2429 /* ── DIRECTORY ── */
......
1 +import { error } from '@sveltejs/kit';
2 +
3 +const API_BASE = 'http://136.112.29.74/api/acteco';
4 +
5 +// Jerarquía acteco: códigos con punto "1" → "1.1" → "1.1.1"
6 +// Todos tienen nivel "actividad" en la API, derivamos la profundidad del código
7 +
8 +function getDepth(codigo) {
9 + return String(codigo).split('.').length;
10 +}
11 +
12 +function getParentCode(codigo) {
13 + const parts = String(codigo).split('.');
14 + if (parts.length <= 1) return null;
15 + return parts.slice(0, -1).join('.');
16 +}
17 +
18 +export async function load({ params }) {
19 + const { codigo } = params;
20 +
21 + const res = await fetch(`${API_BASE}/${codigo}`);
22 + if (!res.ok) throw error(404, 'Sector económico no encontrado');
23 + const acteco = await res.json();
24 +
25 + let padres = [];
26 + let hijos = [];
27 +
28 + try {
29 + const clasRes = await fetch(`${API_BASE}/clasificador`);
30 + if (clasRes.ok) {
31 + const clasificador = await clasRes.json();
32 + const depth = getDepth(codigo);
33 +
34 + // Padres: todos los ancestros
35 + if (depth > 1) {
36 + const parts = String(codigo).split('.');
37 + const parentCodes = [];
38 + for (let i = 1; i < parts.length; i++) {
39 + parentCodes.push(parts.slice(0, i).join('.'));
40 + }
41 + padres = clasificador.filter(c => parentCodes.includes(c.acteco));
42 + }
43 +
44 + // Hijos directos: un nivel más de profundidad, mismo prefijo
45 + const prefix = codigo + '.';
46 + const childDepth = depth + 1;
47 + hijos = clasificador.filter(c =>
48 + c.acteco.startsWith(prefix) && getDepth(c.acteco) === childDepth
49 + );
50 +
51 + padres.sort((a, b) => a.acteco.localeCompare(b.acteco));
52 + hijos.sort((a, b) => a.acteco.localeCompare(b.acteco));
53 + }
54 + } catch { /* clasificador optional */ }
55 +
56 + return {
57 + acteco,
58 + padres,
59 + hijos
60 + };
61 +}
1 +<script>
2 + import { onMount } from 'svelte';
3 + import { tweened } from 'svelte/motion';
4 + import { cubicOut } from 'svelte/easing';
5 + import { goto } from '$app/navigation';
6 + import { page } from '$app/stores';
7 + import Spinner from '$lib/components/ui/Spinner.svelte';
8 + import VistaToggle from '$lib/components/objeto/VistaToggle.svelte';
9 + import BarChart from '$lib/components/objeto/BarChart.svelte';
10 + import EntitySelector from '$lib/components/objeto/EntitySelector.svelte';
11 + import { initIndex } from '$lib/stores/searchStore';
12 +
13 + let { data } = $props();
14 + let actecoData = $derived(data.acteco);
15 + let padres = $derived(data.padres);
16 + let hijos = $derived(data.hijos);
17 +
18 + let mounted = $state(false);
19 + let loading = $state(false);
20 + let hoveredYear = $state(null);
21 + let lockedYear = $state(null);
22 + let activeYear = $derived(lockedYear ?? hoveredYear);
23 + let showDefinicionesModal = $state(false);
24 + let showMobileSidebar = $state(false);
25 + let metrica = $state('percapita'); // 'percapita' | 'monto'
26 + let vista = $state('agregado'); // 'agregado' | 'comparar'
27 + let linkCopied = $state(false);
28 +
29 + // Inline search state
30 + let searchQuery = $state('');
31 + let searchResults = $state([]);
32 + let searchLoading = $state(false);
33 + let searchTimeout = null;
34 +
35 + async function doSearch(q) {
36 + if (!q || q.length < 2) { searchResults = []; return; }
37 + searchLoading = true;
38 + try {
39 + const res = await fetch(`/api/search?q=${encodeURIComponent(q)}&per_page=20&is_class=true&class_=acteco`);
40 + const data = await res.json();
41 + searchResults = (data.hits || []).map(hit => {
42 + const doc = hit.document || {};
43 + let meta = {};
44 + try { meta = typeof doc.metadatos === 'string' ? JSON.parse(doc.metadatos) : (doc.metadatos || {}); } catch {}
45 + const s = meta.acteco_sector != null ? String(Math.round(meta.acteco_sector)) : '';
46 + if (!s) return null;
47 + let code = s;
48 + if (meta.acteco_subsector != null) {
49 + code += '.' + Math.round(meta.acteco_subsector);
50 + if (meta.acteco_actividad != null) {
51 + code += '.' + Math.round(meta.acteco_actividad);
52 + }
53 + }
54 + return { code, label: doc.texto || '' };
55 + }).filter(Boolean);
56 + } catch {
57 + searchResults = [];
58 + }
59 + searchLoading = false;
60 + }
61 +
62 + function onSearchInput(e) {
63 + const q = e.target.value;
64 + searchQuery = q;
65 + clearTimeout(searchTimeout);
66 + searchTimeout = setTimeout(() => doSearch(q), 300);
67 + }
68 +
69 + // Parsear descripciones - la API devuelve array directo con { gestion, descripcion }
70 + function getMaxYear(str) {
71 + if (!str) return 0;
72 + const years = str.match(/\d{4}/g);
73 + return years ? Math.max(...years.map(Number)) : 0;
74 + }
75 +
76 + let descripciones = $derived.by(() => {
77 + const descs = actecoData.descripciones;
78 + if (!descs) return [];
79 + let arr;
80 + if (typeof descs === 'string') {
81 + try { arr = JSON.parse(descs); } catch { return []; }
82 + } else {
83 + arr = Array.isArray(descs) ? descs : [];
84 + }
85 + // Ordenar por año más reciente primero
86 + return [...arr].sort((a, b) => getMaxYear(b.gestion || b.rangos) - getMaxYear(a.gestion || a.rangos));
87 + });
88 + let variaciones = $derived(descripciones.map(d => ({ rango: d.gestion || d.rangos || '', texto: d.descripcion })));
89 +
90 + // Estado selector entidad
91 + let entidades = $state([]);
92 + let selectedEntity = $state(null);
93 + let nEntidades = $derived(entidades.length || data.nEntidades || 0);
94 +
95 + // Datos — inicializar desde estados del server
96 + function initEstados(obj) {
97 + const estadosRaw = obj?.estados || [];
98 + const seen = new Set();
99 + const estados = estadosRaw.filter(d => { if (seen.has(d.gestion)) return false; seen.add(d.gestion); return true; });
100 + const hist = estados.find(d => d.gestion === 0) || null;
101 + const anuales = estados.filter(d => d.gestion >= 2016 && d.gestion > 0);
102 + const topPorAño = {};
103 + anuales.forEach(d => {
104 + topPorAño[d.gestion] = [
105 + { nombre: d.top1_entidad_desc, porcentaje: d.top1_pct, entidad: d.top1_entidad },
106 + { nombre: d.top2_entidad_desc, porcentaje: d.top2_pct, entidad: d.top2_entidad },
107 + { nombre: d.top3_entidad_desc, porcentaje: d.top3_pct, entidad: d.top3_entidad },
108 + ].filter(e => e.nombre);
109 + });
110 + return { hist, anuales, topPorAño };
111 + }
112 +
113 + let { hist: initHist, anuales: initAnuales, topPorAño: initTop } = initEstados(actecoData);
114 + let datosAnuales = $state(initAnuales);
115 + let datosHistorico = $state(initHist);
116 + let topEntidadesPorAño = $state(initTop);
117 +
118 + const POBLACION = 12000000;
119 + let actecoCodigo = $derived(actecoData.acteco);
120 + let actecoNivel = $derived(actecoData.nivel);
121 +
122 + let gastoPerCapita = $derived(
123 + datosAnuales.map(d => {
124 + const monto = Number(selectedEntity ? (d.monto ?? d.devengado ?? 0) : (d.total ?? 0)) || 0;
125 + const perCapita = Number(d.per_capita ?? (d.devengado ? d.devengado / POBLACION : 0)) || 0;
126 + return {
127 + año: d.gestion,
128 + monto,
129 + perCapita: metrica === 'percapita' ? perCapita : monto
130 + };
131 + })
132 + );
133 +
134 + let primerAño = $derived(gastoPerCapita.length > 0 ? gastoPerCapita[0].año : 2005);
135 + let ultimoAño = $derived(gastoPerCapita.length > 0 ? gastoPerCapita[gastoPerCapita.length - 1].año : 2025);
136 +
137 + let totalHistorico = $derived(datosHistorico ? (selectedEntity ? (datosHistorico.monto ?? 0) : (datosHistorico.total ?? 0)) : 0);
138 + let promedioPerCapita = $derived(datosHistorico?.per_capita ?? 0);
139 + let totalPorcentaje = $derived(datosHistorico?.prop ?? 0);
140 + let ranking = $derived(datosHistorico?.ranking ?? 0);
141 + let nPares = $derived(datosHistorico?.n_pares ?? 0);
142 + let totalGestion = $derived(datosHistorico?.total_gestion ?? 0);
143 +
144 + let currentData = $derived(
145 + activeYear ? gastoPerCapita.find(g => g.año === activeYear) : null
146 + );
147 +
148 + // Datos del año activo (fijado o hover) desde datosAnuales
149 + let currentAnual = $derived(activeYear ? datosAnuales.find(d => d.gestion === activeYear) : null);
150 +
151 + // KPI 1: Monto
152 + let displayMonto = $derived.by(() => {
153 + if (selectedEntity) {
154 + return (currentAnual ? currentAnual.monto : totalHistorico) ?? 0;
155 + }
156 + return (currentAnual ? currentAnual.total_gestion : totalGestion) ?? 0;
157 + });
158 + let displayMontoLabel = $derived.by(() => {
159 + if (selectedEntity) {
160 + return currentAnual ? `gastados en ${currentAnual.gestion}` : `gastados ${primerAño}-${ultimoAño}`;
161 + }
162 + return currentAnual ? `gasto total del Estado en ${currentAnual.gestion}` : `gasto total ${primerAño}-${ultimoAño}`;
163 + });
164 +
165 + // KPI 2: Per cápita
166 + let displayPerCapita = $derived((currentAnual?.per_capita ?? promedioPerCapita) ?? 0);
167 + let displayPerCapitaLabel = $derived(currentAnual ? `por cada boliviano en ${currentAnual.gestion}` : 'por cada boliviano (promedio)');
168 +
169 + // KPI 3: Proporción (prop)
170 + let displayPorcentaje = $derived((currentAnual?.prop ?? totalPorcentaje) ?? 0);
171 + let displayPorcentajeLabel = $derived.by(() => {
172 + if (selectedEntity) {
173 + return currentAnual ? `del total de esta actividad en ${currentAnual.gestion}` : 'del total de esta actividad';
174 + }
175 + return currentAnual ? `del gasto en ${currentAnual.gestion}` : 'del gasto total';
176 + });
177 +
178 + let displayPeriodo = $derived(currentData ? currentData.año : `${primerAño}-${ultimoAño}`);
179 + let displayRanking = $derived(`${currentAnual ? currentAnual.ranking : ranking} de ${selectedEntity ? (datosHistorico?.n_entidades || nEntidades) : (currentAnual ? currentAnual.n_pares : nPares)}`);
180 +
181 + let nivelKey = $derived(actecoData.nivel?.toLowerCase());
182 + let nivelPlural = $derived({ actividad: 'actividades' }[nivelKey] || 'actividades');
183 + let nivelLabel = $derived({ actividad: 'Actividad' }[nivelKey] || actecoData.nivel || 'Actividad');
184 + let displayRankingLabel = $derived.by(() => {
185 + if (selectedEntity) {
186 + return currentAnual ? `entre las que más gastan en ${currentAnual.gestion}` : 'entre las que más gastan';
187 + }
188 + return currentData ? `${nivelPlural} más gastadas en ${currentData.año}` : `${nivelPlural} más gastadas`;
189 + });
190 +
191 + // Top entidades
192 + let topEntidadesTotal = $derived(
193 + datosHistorico && !selectedEntity ? [
194 + { nombre: datosHistorico.top1_entidad_desc, porcentaje: datosHistorico.top1_pct, entidad: datosHistorico.top1_entidad },
195 + { nombre: datosHistorico.top2_entidad_desc, porcentaje: datosHistorico.top2_pct, entidad: datosHistorico.top2_entidad },
196 + { nombre: datosHistorico.top3_entidad_desc, porcentaje: datosHistorico.top3_pct, entidad: datosHistorico.top3_entidad },
197 + ].filter(e => e.nombre) : []
198 + );
199 + let displayTopEntidades = $derived(
200 + activeYear && topEntidadesPorAño[activeYear] ? topEntidadesPorAño[activeYear] : topEntidadesTotal
201 + );
202 +
203 + // Tweens
204 + const tw = { duration: 300, easing: cubicOut };
205 + const twMonto = tweened(0, tw);
206 + const twPerCapita = tweened(0, tw);
207 + const twPorcentaje = tweened(0, tw);
208 + const twTop1 = tweened(0, tw);
209 + const twTop2 = tweened(0, tw);
210 + const twTop3 = tweened(0, tw);
211 +
212 + $effect(() => { twMonto.set(typeof displayMonto === 'number' ? displayMonto : 0); });
213 + $effect(() => { twPerCapita.set(typeof displayPerCapita === 'number' ? displayPerCapita : 0); });
214 + $effect(() => { twPorcentaje.set(typeof displayPorcentaje === 'number' ? displayPorcentaje : 0); });
215 + $effect(() => { if (displayTopEntidades[0]) twTop1.set(displayTopEntidades[0].porcentaje); });
216 + $effect(() => { if (displayTopEntidades[1]) twTop2.set(displayTopEntidades[1].porcentaje); });
217 + $effect(() => { if (displayTopEntidades[2]) twTop3.set(displayTopEntidades[2].porcentaje); });
218 +
219 + function formatMontoLargo(n) {
220 + if (n >= 1e9) return `${(n / 1e9).toFixed(1)} mil mill.`;
221 + if (n >= 1e6) return `${(n / 1e6).toFixed(1)} mill.`;
222 + if (n >= 1e3) return `${(n / 1e3).toFixed(0)} mil`;
223 + return n.toLocaleString('es-BO');
224 + }
225 +
226 + function formatPerCapita(n) {
227 + return n.toLocaleString('es-BO', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
228 + }
229 +
230 + async function loadData() {
231 + if (!selectedEntity) return;
232 + lockedYear = null;
233 + loading = true;
234 + try {
235 + const res = await fetch(`/api/acteco-data?codigo=${actecoCodigo}&tipo=entidad&entidad=${selectedEntity.entidad}`);
236 + const raw = await res.json();
237 + console.log('[loadData] entidad:', selectedEntity.entidad, 'raw rows:', raw?.length);
238 + if (Array.isArray(raw)) {
239 + const seen = new Set();
240 + const data = raw.filter(d => { if (seen.has(d.gestion)) return false; seen.add(d.gestion); return true; });
241 + datosHistorico = data.find(d => d.gestion === 0) || null;
242 + datosAnuales = data.filter(d => d.gestion >= 2016);
243 + topEntidadesPorAño = {};
244 + console.log('[loadData] historico:', datosHistorico ? { monto: datosHistorico.monto, per_capita: datosHistorico.per_capita, prop: datosHistorico.prop } : null);
245 + // Forzar tweens directamente
246 + if (datosHistorico) {
247 + twMonto.set(datosHistorico.monto || 0);
248 + twPerCapita.set(datosHistorico.per_capita || 0);
249 + twPorcentaje.set(datosHistorico.prop || 0);
250 + }
251 + }
252 + } catch (err) {
253 + console.error('[loadData] Error:', err);
254 + }
255 + loading = false;
256 + }
257 +
258 + async function loadEntidades() {
259 + try {
260 + const añosConDatos = datosAnuales.map(d => d.gestion).sort((a, b) => b - a);
261 + const año = añosConDatos[0] || 2025;
262 +
263 + const [entRes, clasRes] = await Promise.all([
264 + fetch(`/api/acteco-data?codigo=${actecoCodigo}&tipo=entidades-año&gestion=${año}`),
265 + fetch('/api/entidad-clasificador')
266 + ]);
267 + const entData = await entRes.json();
268 + const clasData = await clasRes.json();
269 +
270 + const nombreMap = new Map();
271 + if (Array.isArray(clasData)) {
272 + clasData.forEach(e => nombreMap.set(e.entidad, e.desc_entidad));
273 + }
274 +
275 + if (Array.isArray(entData)) {
276 + entidades = entData
277 + .filter(d => d.monto > 0 && d.entidad !== 0)
278 + .map(d => ({ entidad: d.entidad, entidad_desc: nombreMap.get(d.entidad) || `Entidad ${d.entidad}` }))
279 + .sort((a, b) => (a.entidad_desc || '').localeCompare(b.entidad_desc || ''));
280 + }
281 + } catch {
282 + entidades = [];
283 + }
284 + }
285 +
286 + // ══════════════════════════════════════════════════════════════
287 + // COMPARACIÓN
288 + // ══════════════════════════════════════════════════════════════
289 + const twMontoA = tweened(0, { duration: 300, easing: cubicOut });
290 + const twMontoB = tweened(0, { duration: 300, easing: cubicOut });
291 + const twPerCapitaA = tweened(0, { duration: 300, easing: cubicOut });
292 + const twPerCapitaB = tweened(0, { duration: 300, easing: cubicOut });
293 +
294 + let hoveredYearComparar = $state(null);
295 + let loadingComparacion = $state(false);
296 + let isDefaultSelection = $state(false);
297 + let alertDefaultDismissed = $state(false);
298 + let alertRangosDismissed = $state(false);
299 +
300 + let entidadCompararA = $state(null);
301 + let entidadCompararB = $state(null);
302 + let datosCompararA = $state({ anual: [], historico: null });
303 + let datosCompararB = $state({ anual: [], historico: null });
304 +
305 + let dropdownAOpen = $state(false);
306 + let dropdownBOpen = $state(false);
307 + let searchA = $state('');
308 + let searchB = $state('');
309 + let entityLimitA = $state(30);
310 + let entityLimitB = $state(30);
311 +
312 + let filteredEntidadesA = $derived.by(() => {
313 + if (!searchA || searchA.length < 2) return entidades.slice(0, entityLimitA);
314 + const q = searchA.toLowerCase();
315 + return entidades.filter(e => e.entidad_desc?.toLowerCase().includes(q) || e.entidad?.toString().includes(q)).slice(0, entityLimitA);
316 + });
317 +
318 + let filteredEntidadesB = $derived.by(() => {
319 + if (!searchB || searchB.length < 2) return entidades.slice(0, entityLimitB);
320 + const q = searchB.toLowerCase();
321 + return entidades.filter(e => e.entidad_desc?.toLowerCase().includes(q) || e.entidad?.toString().includes(q)).slice(0, entityLimitB);
322 + });
323 +
324 + async function loadDatosEntidad(entidad) {
325 + try {
326 + const res = await fetch(`/api/acteco-data?codigo=${actecoCodigo}&tipo=entidad&entidad=${entidad.entidad}`);
327 + const raw = await res.json();
328 + if (Array.isArray(raw)) {
329 + const seen = new Set();
330 + const data = raw.filter(d => { if (seen.has(d.gestion)) return false; seen.add(d.gestion); return true; });
331 + return { anual: data.filter(d => d.gestion >= 2016), historico: data.find(d => d.gestion === 0) || null };
332 + }
333 + } catch {}
334 + return { anual: [], historico: null };
335 + }
336 +
337 + async function initComparacion() {
338 + if (entidadCompararA || entidadCompararB) return;
339 + if (selectedEntity) {
340 + entidadCompararA = selectedEntity;
341 + loadingComparacion = true;
342 + datosCompararA = await loadDatosEntidad(selectedEntity);
343 + loadingComparacion = false;
344 + isDefaultSelection = false;
345 + } else if (topEntidadesTotal.length >= 2) {
346 + const top1 = entidades.find(e => e.entidad_desc === topEntidadesTotal[0].nombre);
347 + const top2 = entidades.find(e => e.entidad_desc === topEntidadesTotal[1].nombre);
348 + if (top1 && top2) {
349 + isDefaultSelection = true;
350 + loadingComparacion = true;
351 + entidadCompararA = top1;
352 + datosCompararA = await loadDatosEntidad(top1);
353 + entidadCompararB = top2;
354 + datosCompararB = await loadDatosEntidad(top2);
355 + loadingComparacion = false;
356 + }
357 + }
358 + }
359 +
360 + async function selectEntidadA(entidad) {
361 + entidadCompararA = entidad;
362 + dropdownAOpen = false;
363 + searchA = '';
364 + entityLimitA = 30;
365 + isDefaultSelection = false;
366 + loadingComparacion = true;
367 + datosCompararA = await loadDatosEntidad(entidad);
368 + loadingComparacion = false;
369 + updateCompararUrl();
370 + }
371 +
372 + async function selectEntidadB(entidad) {
373 + entidadCompararB = entidad;
374 + dropdownBOpen = false;
375 + searchB = '';
376 + entityLimitB = 30;
377 + isDefaultSelection = false;
378 + loadingComparacion = true;
379 + datosCompararB = await loadDatosEntidad(entidad);
380 + loadingComparacion = false;
381 + updateCompararUrl();
382 + }
383 +
384 + function handleEntityScroll(event, type) {
385 + const el = event.target;
386 + if (el.scrollHeight - el.scrollTop - el.clientHeight < 50) {
387 + if (type === 'A' && entityLimitA < entidades.length) entityLimitA += 30;
388 + if (type === 'B' && entityLimitB < entidades.length) entityLimitB += 30;
389 + }
390 + }
391 +
392 + let rangoAños = $state('todo');
393 +
394 + let añosComunesTotales = $derived.by(() => {
395 + const añosA = datosCompararA.anual.map(d => d.gestion);
396 + const añosB = datosCompararB.anual.map(d => d.gestion);
397 + if (añosA.length && añosB.length) {
398 + const setB = new Set(añosB);
399 + return añosA.filter(a => setB.has(a)).sort((a, b) => a - b);
400 + }
401 + if (añosA.length) return [...añosA].sort((a, b) => a - b);
402 + if (añosB.length) return [...añosB].sort((a, b) => a - b);
403 + return [];
404 + });
405 +
406 + let soloUnaEntidad = $derived((entidadCompararA && !entidadCompararB) || (!entidadCompararA && entidadCompararB));
407 +
408 + let rangoInfoA = $derived.by(() => {
409 + if (!datosCompararA.anual.length) return null;
410 + const años = datosCompararA.anual.map(d => d.gestion).sort((a, b) => a - b);
411 + return { desde: años[0], hasta: años[años.length - 1], total: años.length };
412 + });
413 +
414 + let rangoInfoB = $derived.by(() => {
415 + if (!datosCompararB.anual.length) return null;
416 + const años = datosCompararB.anual.map(d => d.gestion).sort((a, b) => a - b);
417 + return { desde: años[0], hasta: años[años.length - 1], total: años.length };
418 + });
419 +
420 + let tienenRangosDiferentes = $derived.by(() => {
421 + if (!rangoInfoA || !rangoInfoB) return false;
422 + return rangoInfoA.desde !== rangoInfoB.desde || rangoInfoA.hasta !== rangoInfoB.hasta;
423 + });
424 +
425 + let añosComparacion = $derived.by(() => {
426 + if (rangoAños === 'todo') return añosComunesTotales;
427 + return añosComunesTotales.slice(-parseInt(rangoAños));
428 + });
429 +
430 + let datosGraficoA = $derived.by(() => {
431 + const map = new Map(datosCompararA.anual.map(d => [d.gestion, d]));
432 + return añosComparacion.map(año => map.get(año) || { per_capita: 0, monto: 0 });
433 + });
434 +
435 + let datosGraficoB = $derived.by(() => {
436 + const map = new Map(datosCompararB.anual.map(d => [d.gestion, d]));
437 + return añosComparacion.map(año => map.get(año) || { per_capita: 0, monto: 0 });
438 + });
439 +
440 + // Valor según métrica seleccionada
441 + function valComparar(d) {
442 + return metrica === 'percapita' ? (d?.per_capita || 0) : (d?.monto || 0);
443 + }
444 +
445 + let maxComparacion = $derived.by(() => {
446 + const all = [...datosGraficoA.map(d => valComparar(d)), ...datosGraficoB.map(d => valComparar(d))];
447 + return Math.max(...all, 1) * 1.1;
448 + });
449 +
450 + let displayPeriodoComparar = $derived.by(() => {
451 + if (hoveredYearComparar) return hoveredYearComparar;
452 + if (añosComparacion.length > 0) return `${añosComparacion[0]}-${añosComparacion[añosComparacion.length - 1]}`;
453 + return 'Selecciona entidades';
454 + });
455 +
456 + let displayPropA = $derived.by(() => {
457 + if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoA[idx]?.prop || 0; }
458 + return datosCompararA.historico?.prop || 0;
459 + });
460 + let displayPropB = $derived.by(() => {
461 + if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoB[idx]?.prop || 0; }
462 + return datosCompararB.historico?.prop || 0;
463 + });
464 + let displayRankingA = $derived.by(() => {
465 + if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoA[idx]?.ranking || '-'; }
466 + return datosCompararA.historico?.ranking || '-';
467 + });
468 + let displayRankingB = $derived.by(() => {
469 + if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoB[idx]?.ranking || '-'; }
470 + return datosCompararB.historico?.ranking || '-';
471 + });
472 + let displayNEntidadesA = $derived.by(() => {
473 + if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoA[idx]?.n_entidades || '-'; }
474 + return datosCompararA.historico?.n_entidades || '-';
475 + });
476 + let displayNEntidadesB = $derived.by(() => {
477 + if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoB[idx]?.n_entidades || '-'; }
478 + return datosCompararB.historico?.n_entidades || '-';
479 + });
480 +
481 + let isDarkMode = $state(false);
482 + $effect(() => {
483 + if (typeof document !== 'undefined') {
484 + const checkDark = () => { isDarkMode = document.documentElement.classList.contains('dark'); };
485 + checkDark();
486 + const observer = new MutationObserver(checkDark);
487 + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
488 + return () => observer.disconnect();
489 + }
490 + });
491 +
492 + let colorA = $derived(isDarkMode ? '#7EB8DA' : '#6B8299');
493 + let colorB = $derived(isDarkMode ? '#D4A574' : '#C4897D');
494 +
495 + function toNumber(v) { return typeof v === 'number' ? v : 0; }
496 +
497 + $effect(() => {
498 + const year = hoveredYearComparar;
499 + const idx = year ? añosComparacion.indexOf(year) : -1;
500 + twMontoA.set(idx >= 0 ? toNumber(datosGraficoA[idx]?.monto) : toNumber(datosCompararA.historico?.monto));
501 + twMontoB.set(idx >= 0 ? toNumber(datosGraficoB[idx]?.monto) : toNumber(datosCompararB.historico?.monto));
502 + twPerCapitaA.set(idx >= 0 ? toNumber(datosGraficoA[idx]?.per_capita) : toNumber(datosCompararA.historico?.per_capita));
503 + twPerCapitaB.set(idx >= 0 ? toNumber(datosGraficoB[idx]?.per_capita) : toNumber(datosCompararB.historico?.per_capita));
504 + });
505 +
506 + // Actualizar URL para vista comparar
507 + function updateCompararUrl() {
508 + if (!mounted) return;
509 + const url = new URL($page.url);
510 + url.searchParams.set('vista', 'comparar');
511 + url.searchParams.delete('entidad');
512 + if (entidadCompararA) url.searchParams.set('entidadA', entidadCompararA.entidad);
513 + else url.searchParams.delete('entidadA');
514 + if (entidadCompararB) url.searchParams.set('entidadB', entidadCompararB.entidad);
515 + else url.searchParams.delete('entidadB');
516 + goto(url.toString(), { replaceState: true, noScroll: true });
517 + }
518 +
519 + // Watch vista changes
520 + let previousVista = 'agregado';
521 + let skipPopstate = false;
522 + $effect(() => {
523 + if (mounted && vista !== previousVista) {
524 + previousVista = vista;
525 + if (!skipPopstate) {
526 + const url = new URL(window.location.href);
527 + if (vista === 'comparar') {
528 + url.searchParams.set('vista', 'comparar');
529 + url.searchParams.delete('entidad');
530 + } else {
531 + url.searchParams.delete('vista');
532 + url.searchParams.delete('entidadA');
533 + url.searchParams.delete('entidadB');
534 + }
535 + history.pushState({ vista }, '', url.toString());
536 + }
537 + skipPopstate = false;
538 + if (vista === 'comparar') initComparacion();
539 + }
540 + });
541 +
542 + function handlePopstate(e) {
543 + const params = new URL(window.location.href).searchParams;
544 + const urlVista = params.get('vista') || 'agregado';
545 + if (urlVista !== vista) {
546 + skipPopstate = true;
547 + vista = urlVista;
548 + }
549 + }
550 +
551 + function formatMonto(m) {
552 + if (m >= 1e9) return (m / 1e9).toLocaleString('es-BO', { maximumFractionDigits: 1 }) + ' MM';
553 + if (m >= 1e6) return (m / 1e6).toLocaleString('es-BO', { maximumFractionDigits: 1 }) + ' M';
554 + if (m >= 1e3) return Math.round(m / 1e3).toLocaleString('es-BO') + ' K';
555 + return m?.toLocaleString('es-BO') || '0';
556 + }
557 +
558 + // ══════════════════════════════════════════════════════════════
559 +
560 + onMount(() => {
561 + mounted = true;
562 + initIndex();
563 + loadEntidades().then(async () => {
564 + // Read URL params after entidades are loaded
565 + const urlVista = $page.url.searchParams.get('vista');
566 + const urlEntidad = $page.url.searchParams.get('entidad');
567 + const urlEntidadA = $page.url.searchParams.get('entidadA');
568 + const urlEntidadB = $page.url.searchParams.get('entidadB');
569 +
570 + if (urlVista === 'comparar') {
571 + vista = 'comparar';
572 + // Precargar entidades de comparación desde URL
573 + if (urlEntidadA) {
574 + const entA = entidades.find(e => e.entidad === parseInt(urlEntidadA));
575 + if (entA) {
576 + entidadCompararA = entA;
577 + loadingComparacion = true;
578 + datosCompararA = await loadDatosEntidad(entA);
579 + }
580 + }
581 + if (urlEntidadB) {
582 + const entB = entidades.find(e => e.entidad === parseInt(urlEntidadB));
583 + if (entB) {
584 + entidadCompararB = entB;
585 + datosCompararB = await loadDatosEntidad(entB);
586 + }
587 + }
588 + loadingComparacion = false;
589 + if (!urlEntidadA && !urlEntidadB) initComparacion();
590 + } else if (urlEntidad && entidades.length > 0) {
591 + const ent = entidades.find(e => e.entidad === parseInt(urlEntidad));
592 + if (ent) selectedEntity = ent;
593 + }
594 + });
595 + });
596 +
597 + // Recargar cuando cambia el acteco (navegación entre actividades)
598 + let prevCodigo = null;
599 + $effect(() => {
600 + const code = actecoCodigo;
601 + if (prevCodigo !== null && code !== prevCodigo) {
602 + // Reiniciar comparación
603 + entidadCompararA = null;
604 + entidadCompararB = null;
605 + datosCompararA = { anual: [], historico: null };
606 + datosCompararB = { anual: [], historico: null };
607 +
608 + // Cargar datos del estado desde actecoData.estados
609 + const { hist, anuales, topPorAño } = initEstados(actecoData);
610 + datosHistorico = hist;
611 + datosAnuales = anuales;
612 + topEntidadesPorAño = topPorAño;
613 + loading = false;
614 +
615 + // Recargar entidades y mantener la seleccionada si existe en la nueva actividad
616 + const prevEntity = selectedEntity;
617 + loadEntidades().then(() => {
618 + if (prevEntity) {
619 + const match = entidades.find(e => e.entidad === prevEntity.entidad);
620 + if (match) {
621 + selectedEntity = match;
622 + loadData();
623 + } else {
624 + selectedEntity = null;
625 + }
626 + }
627 + });
628 + }
629 + prevCodigo = code;
630 + });
631 +
632 + // Recargar cuando cambia la entidad seleccionada
633 + let prevEntity = null;
634 + $effect(() => {
635 + const ent = selectedEntity;
636 + if (ent !== prevEntity) {
637 + prevEntity = ent;
638 + if (ent) {
639 + loadData();
640 + } else if (prevCodigo) {
641 + const { hist, anuales, topPorAño } = initEstados(actecoData);
642 + datosHistorico = hist;
643 + datosAnuales = anuales;
644 + topEntidadesPorAño = topPorAño;
645 + loading = false;
646 + }
647 + // Actualizar URL
648 + if (mounted) {
649 + const url = new URL($page.url);
650 + if (ent) {
651 + url.searchParams.set('entidad', ent.entidad);
652 + } else {
653 + url.searchParams.delete('entidad');
654 + }
655 + goto(url.toString(), { replaceState: true, noScroll: true });
656 + }
657 + }
658 + });
659 +</script>
660 +
661 +<svelte:window on:popstate={handlePopstate} />
662 +
663 +<svelte:head>
664 + <title>{actecoData.desc_acteco} | Presupuesto Abierto</title>
665 + <link rel="preconnect" href="https://fonts.googleapis.com" />
666 + <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" />
667 +</svelte:head>
668 +
669 +<div class="page" class:mounted>
670 +
671 + {#if vista === 'agregado'}
672 + <div class="dashboard-layout">
673 + <!-- Sidebar -->
674 + <aside class="dashboard-context" class:mobile-open={showMobileSidebar}>
675 + <!-- Mobile toggle -->
676 + <button class="mobile-sidebar-toggle" onclick={() => showMobileSidebar = !showMobileSidebar}>
677 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
678 + <circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
679 + </svg>
680 + <span>{showMobileSidebar ? 'Ocultar herramientas' : 'Herramientas'}</span>
681 + </button>
682 +
683 + <div class="context-card">
684 + <h2 class="context-main-title">Herramientas</h2>
685 +
686 + <!-- Buscar -->
687 + <div class="context-section">
688 + <h3 class="context-subtitle">Buscar</h3>
689 + <div class="inline-search">
690 + <input
691 + type="text"
692 + class="inline-search-input"
693 + placeholder="Buscar sector económico..."
694 + value={searchQuery}
695 + oninput={onSearchInput}
696 + />
697 + {#if searchLoading}
698 + <div class="inline-search-loading">Buscando...</div>
699 + {/if}
700 + {#if searchResults.length > 0}
701 + <div class="inline-search-results">
702 + {#each searchResults as result}
703 + <a href="/acteco/{result.code}" class="inline-search-item" onclick={() => { searchQuery = ''; searchResults = []; }}>
704 + <span class="inline-search-label">{result.label}</span>
705 + </a>
706 + {/each}
707 + </div>
708 + {/if}
709 + </div>
710 + </div>
711 +
712 + <div class="context-separator"></div>
713 +
714 + <!-- Navegación -->
715 + {#if padres.length > 0 || hijos.length > 0}
716 + <div class="context-section">
717 + <h3 class="context-subtitle">Navegación</h3>
718 + <p class="context-hint">Actividades relacionadas. Haz click para explorar.</p>
719 + <div class="tree">
720 + {#each padres as padre, i}
721 + <a href="/acteco/{padre.acteco}" class="tree-node has-tooltip" style="--indent: {i}" data-tooltip="{padre.desc_acteco}">
722 + <span class="tree-dot"></span>
723 + <span class="tree-code">{padre.acteco}</span>
724 + <span class="tree-name">{padre.desc_acteco}</span>
725 + </a>
726 + {/each}
727 +
728 + <div class="tree-node tree-node-current has-tooltip" style="--indent: {padres.length}" data-tooltip="{actecoData.desc_acteco}">
729 + <span class="tree-dot"></span>
730 + <span class="tree-code">{actecoData.acteco}</span>
731 + <span class="tree-name">{actecoData.desc_acteco}</span>
732 + </div>
733 +
734 + {#if hijos.length > 0}
735 + <div class="tree-children" style="--indent: {padres.length + 1}">
736 + {#each hijos as hijo}
737 + <a href="/acteco/{hijo.acteco}" class="tree-node tree-node-child has-tooltip" data-tooltip="{hijo.desc_acteco}">
738 + <span class="tree-dot"></span>
739 + <span class="tree-code">{hijo.acteco}</span>
740 + <span class="tree-name">{hijo.desc_acteco}</span>
741 + </a>
742 + {/each}
743 + </div>
744 + {/if}
745 + </div>
746 + </div>
747 +
748 + <div class="context-separator"></div>
749 + {/if}
750 +
751 + <!-- Explorar -->
752 + <div class="context-section">
753 + <div class="explore-header">
754 + <h3 class="context-subtitle">Explorar visualizaciones</h3>
755 + <span class="explore-count">7 →</span>
756 + </div>
757 + <div class="explore-scroll">
758 + <div class="explore-card" onclick={() => vista = 'comparar'} role="button" tabindex="0">
759 + <span class="explore-title">Comparar dos entidades</span>
760 + <span class="explore-desc">¿Quién gasta más en {actecoData.desc_acteco?.toLowerCase()}?</span>
761 + </div>
762 + <a href="/acteco/{actecoData.acteco}/entidades" class="explore-card">
763 + <span class="explore-title">Ranking</span>
764 + <span class="explore-desc">{nEntidades} entidades que gastan en {actecoData.desc_acteco?.toLowerCase()}</span>
765 + </a>
766 + <a href="/ubicacion?clas=acteco&codigo={actecoData.acteco}" class="explore-card">
767 + <span class="explore-title">Mapa</span>
768 + <span class="explore-desc">Dónde se concentra el gasto en {actecoData.desc_acteco?.toLowerCase()}</span>
769 + </a>
770 + <a href="/clasificadores/sectores?modo=mapa" class="explore-card">
771 + <span class="explore-title">Peso relativo</span>
772 + <span class="explore-desc">Cuánto pesa {actecoData.desc_acteco?.toLowerCase()} respecto a otros gastos</span>
773 + </a>
774 + <a href="/clasificadores/sectores?modo=comparar{selectedEntity ? `&entidad=${selectedEntity.entidad}` : ''}{activeYear ? `&gestion=${activeYear}` : ''}" class="explore-card">
775 + <span class="explore-title">Comparar actividades</span>
776 + <span class="explore-desc">Todas las actividades entre dos momentos o entidades</span>
777 + </a>
778 + <a href="/clasificadores/sectores" class="explore-card">
779 + <span class="explore-title">Clasificador</span>
780 + <span class="explore-desc">Ver todos los sectores económicos y navegar entre ellos</span>
781 + </a>
782 + </div>
783 + </div>
784 +
785 + <div class="context-separator"></div>
786 +
787 + <!-- Comparabilidad -->
788 + <div class="context-section">
789 + <h3 class="context-subtitle">Comparabilidad</h3>
790 + <div class="vigencia-compact">
791 + <div class="vigencia-status-icon vigencia-full">
792 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
793 + <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
794 + <polyline points="22 4 12 14.01 9 11.01"/>
795 + </svg>
796 + </div>
797 + <div class="vigencia-text">
798 + <span class="vigencia-main">Comparable en todo el período</span>
799 + </div>
800 + </div>
801 + </div>
802 +
803 + <div class="context-separator"></div>
804 +
805 + <!-- Compartir -->
806 + <div class="context-section">
807 + <h3 class="context-subtitle">Compartir</h3>
808 + <button class="copy-link-btn" onclick={() => {
809 + navigator.clipboard.writeText(window.location.href);
810 + linkCopied = true;
811 + setTimeout(() => linkCopied = false, 2000);
812 + }}>
813 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
814 + {#if linkCopied}
815 + <path d="M20 6L9 17l-5-5"/>
816 + {:else}
817 + <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
818 + <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
819 + {/if}
820 + </svg>
821 + <span>{linkCopied ? 'Link copiado' : 'Copiar link para compartir'}</span>
822 + </button>
823 + </div>
824 + </div>
825 + </aside>
826 +
827 + <!-- Main -->
828 + <main class="dashboard-main">
829 + <div class="chart-protagonista">
830 + <!-- Header -->
831 + <div class="chart-top-row">
832 + <div class="main-title-pills">
833 + <span class="pill pill-nivel">{nivelLabel}</span>
834 + <span class="pill pill-codigo">{actecoData.acteco}</span>
835 + </div>
836 + </div>
837 +
838 + <h2 class="main-title-name">{actecoData.desc_acteco}</h2>
839 +
840 + <div class="chart-controls">
841 + <EntitySelector {entidades} bind:selectedEntity {nEntidades} />
842 + {#if selectedEntity}
843 + <button class="volver-agregado" onclick={() => { selectedEntity = null; }}>
844 + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
845 + <path d="M19 12H5M12 19l-7-7 7-7"/>
846 + </svg>
847 + Todo el Estado
848 + </button>
849 + {/if}
850 + </div>
851 +
852 + <div class="chart-header">
853 + <span class="chart-title">
854 + <button class="metrica-toggle" onclick={() => metrica = metrica === 'percapita' ? 'monto' : 'percapita'}>
855 + {metrica === 'percapita' ? 'Bs/habitante' : 'Bs (monto total)'}
856 + <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 16V4m0 0L3 8m4-4l4 4M17 8v12m0 0l4-4m-4 4l-4-4"/></svg>
857 + </button>
858 + <span class="chart-period">· {displayPeriodo}</span>
859 + </span>
860 + </div>
861 +
862 + {#if loading}
863 + <div class="chart-loading" style="flex: 1; min-height: 100px;">
864 + <span>Cargando...</span>
865 + </div>
866 + {:else if gastoPerCapita.length === 0 && selectedEntity}
867 + <div class="chart-no-data">
868 + <p>Esta entidad no tiene registros en {actecoData.desc_acteco?.toLowerCase()} desde 2016</p>
869 + <button class="volver-agregado" onclick={() => { selectedEntity = null; }}>
870 + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
871 + <path d="M19 12H5M12 19l-7-7 7-7"/>
872 + </svg>
873 + Volver a Todo el Estado
874 + </button>
875 + </div>
876 + {:else if gastoPerCapita.length === 0 && !selectedEntity && hijos.length > 0}
877 + <div class="chart-no-data">
878 + <p>Este es un sector agregado. Explora sus subsectores:</p>
879 + <div style="display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px;">
880 + {#each hijos as hijo}
881 + <a href="/acteco/{hijo.acteco}" class="pill pill-nivel" style="text-decoration: none;">
882 + {hijo.desc_acteco}
883 + </a>
884 + {/each}
885 + </div>
886 + </div>
887 + {:else if gastoPerCapita.length === 0}
888 + <div class="chart-no-data">
889 + <p>Sin datos disponibles para este sector</p>
890 + </div>
891 + {:else}
892 + <div class="chart-flex-wrapper">
893 + <BarChart data={gastoPerCapita} bind:hoveredYear bind:lockedYear height={200} fill={true} />
894 + </div>
895 + {/if}
896 +
897 + <!-- KPIs -->
898 + <div class="kpis">
899 + <div class="kpi">
900 + <span class="kpi-value">{formatMontoLargo(Math.round($twMonto))}</span>
901 + <span class="kpi-label">{displayMontoLabel}</span>
902 + </div>
903 + <div class="kpi">
904 + <span class="kpi-value">{formatPerCapita($twPerCapita)} Bs.</span>
905 + <span class="kpi-label">{displayPerCapitaLabel}</span>
906 + </div>
907 + <div class="kpi">
908 + <span class="kpi-value">{$twPorcentaje.toLocaleString('es-BO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</span>
909 + <span class="kpi-label">{displayPorcentajeLabel}</span>
910 + </div>
911 + <div class="kpi">
912 + <span class="kpi-value">{displayRanking}</span>
913 + <span class="kpi-label">{displayRankingLabel}</span>
914 + </div>
915 + </div>
916 + </div>
917 +
918 + {#if !selectedEntity && displayTopEntidades.length > 0}
919 + <div class="ranking-section">
920 + <div class="ranking-header">
921 + <h3 class="ranking-title">Concentración del gasto</h3>
922 + <div class="ranking-summary">
923 + <span class="ranking-summary-pct">{($twTop1 + $twTop2 + $twTop3).toFixed(1)}%</span>
924 + <span class="ranking-summary-text">de {actecoData.desc_acteco?.toLowerCase()} lo gastan estas 3 entidades{activeYear ? ` en ${activeYear}` : ''}</span>
925 + </div>
926 + </div>
927 + <div class="ranking-cards">
928 + {#each displayTopEntidades as ent, i}
929 + {@const pct = i === 0 ? $twTop1 : i === 1 ? $twTop2 : $twTop3}
930 + <button class="ranking-card ranking-card-link" onclick={() => {
931 + const match = entidades.find(e => e.entidad === ent.entidad);
932 + selectedEntity = match || { entidad: ent.entidad, entidad_desc: ent.nombre };
933 + }}>
934 + <span class="ranking-position">#{i + 1}</span>
935 + <span class="ranking-name">{ent.nombre}</span>
936 + <span class="ranking-pct">{pct.toFixed(1)}%</span>
937 + <svg class="ranking-arrow" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
938 + </button>
939 + {/each}
940 + </div>
941 + </div>
942 + {/if}
943 + </main>
944 + </div>
945 +
946 +
947 + {:else}
948 + <!-- VISTA COMPARAR -->
949 + <main class="main-comparar">
950 + <div class="chart-card-comparar">
951 + <div class="chart-top-row">
952 + <div class="main-title-pills">
953 + <span class="pill pill-nivel">{nivelLabel}</span>
954 + <span class="pill pill-codigo">{actecoData.acteco}</span>
955 + </div>
956 + <VistaToggle bind:vista />
957 + </div>
958 +
959 + <h2 class="main-title-name">{actecoData.desc_acteco}</h2>
960 +
961 + <div class="comparador-selectors">
962 + <div class="selector-group comparador-dropdown-container">
963 + <span class="selector-dot" style="background: {colorA}"></span>
964 + <div class="relative" style="flex: 1;">
965 + <button class="selector-btn comparador-btn" onclick={() => { dropdownAOpen = !dropdownAOpen; dropdownBOpen = false; }}>
966 + <span class="truncate">{entidadCompararA ? entidadCompararA.entidad_desc : 'Seleccionar entidad'}</span>
967 + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
968 + </button>
969 + {#if dropdownAOpen}
970 + <div class="dropdown-panel comparador-panel">
971 + <div class="dropdown-search"><input type="text" bind:value={searchA} placeholder="Buscar entidad..." class="search-input" /></div>
972 + <div class="dropdown-list" onscroll={(e) => handleEntityScroll(e, 'A')}>
973 + {#each filteredEntidadesA as entity}
974 + <button class="dropdown-item" class:active={entidadCompararA?.entidad === entity.entidad} disabled={entidadCompararB?.entidad === entity.entidad} onclick={() => selectEntidadA(entity)}>
975 + <span class="truncate">{entity.entidad_desc}</span>
976 + </button>
977 + {/each}
978 + </div>
979 + </div>
980 + {/if}
981 + </div>
982 + </div>
983 +
984 + <span class="vs">vs</span>
985 +
986 + <div class="selector-group comparador-dropdown-container">
987 + <span class="selector-dot" style="background: {colorB}"></span>
988 + <div class="relative" style="flex: 1;">
989 + <button class="selector-btn comparador-btn" onclick={() => { dropdownBOpen = !dropdownBOpen; dropdownAOpen = false; }}>
990 + <span class="truncate">{entidadCompararB ? entidadCompararB.entidad_desc : 'Seleccionar entidad'}</span>
991 + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
992 + </button>
993 + {#if dropdownBOpen}
994 + <div class="dropdown-panel comparador-panel">
995 + <div class="dropdown-search"><input type="text" bind:value={searchB} placeholder="Buscar entidad..." class="search-input" /></div>
996 + <div class="dropdown-list" onscroll={(e) => handleEntityScroll(e, 'B')}>
997 + {#each filteredEntidadesB as entity}
998 + <button class="dropdown-item" class:active={entidadCompararB?.entidad === entity.entidad} disabled={entidadCompararA?.entidad === entity.entidad} onclick={() => selectEntidadB(entity)}>
999 + <span class="truncate">{entity.entidad_desc}</span>
1000 + </button>
1001 + {/each}
1002 + </div>
1003 + </div>
1004 + {/if}
1005 + </div>
1006 + </div>
1007 + </div>
1008 +
1009 + {#if isDefaultSelection && entidadCompararA && entidadCompararB && !alertDefaultDismissed}
1010 + <div class="comparador-hint">
1011 + <span>Mostrando las dos entidades con mayor gasto. Puedes cambiar la selección arriba.</span>
1012 + <button class="alert-close" onclick={() => alertDefaultDismissed = true}>✕</button>
1013 + </div>
1014 + {/if}
1015 +
1016 + {#if tienenRangosDiferentes && entidadCompararA && entidadCompararB && !loadingComparacion && !alertRangosDismissed}
1017 + <div class="comparador-warning">
1018 + <span>Rangos diferentes. Solo se comparan {añosComunesTotales.length} años en común.</span>
1019 + <button class="alert-close" onclick={() => alertRangosDismissed = true}>✕</button>
1020 + </div>
1021 + {/if}
1022 +
1023 + {#if loadingComparacion}
1024 + <div class="comparador-placeholder"><Spinner size={48} color="var(--theme-texto)" /></div>
1025 + {:else if !entidadCompararA && !entidadCompararB}
1026 + <div class="comparador-placeholder"><p>Selecciona dos entidades para comparar</p></div>
1027 + {:else if añosComparacion.length === 0}
1028 + <div class="comparador-placeholder"><p>No hay datos disponibles para comparar</p></div>
1029 + {:else}
1030 + <div class="chart-header">
1031 + <span class="chart-title">
1032 + <button class="metrica-toggle" onclick={() => metrica = metrica === 'percapita' ? 'monto' : 'percapita'}>
1033 + {metrica === 'percapita' ? 'Bs/habitante' : 'Bs (monto total)'}
1034 + <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 16V4m0 0L3 8m4-4l4 4M17 8v12m0 0l4-4m-4 4l-4-4"/></svg>
1035 + </button>
1036 + <span class="chart-period">· {displayPeriodoComparar}</span>
1037 + </span>
1038 + <button class="copy-link-btn" onclick={() => {
1039 + navigator.clipboard.writeText(window.location.href);
1040 + linkCopied = true;
1041 + setTimeout(() => linkCopied = false, 2000);
1042 + }}>
1043 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1044 + {#if linkCopied}
1045 + <path d="M20 6L9 17l-5-5"/>
1046 + {:else}
1047 + <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
1048 + <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
1049 + {/if}
1050 + </svg>
1051 + <span>{linkCopied ? 'Copiado' : 'Compartir'}</span>
1052 + </button>
1053 + </div>
1054 +
1055 + <div class="chart-comparar-wrapper">
1056 + <div class="chart-comparar-inner">
1057 + <span class="y-label-comparar" style="bottom: 100%">Bs {formatMonto(Math.round(maxComparacion))}</span>
1058 + <span class="y-label-comparar" style="bottom: 50%">Bs {formatMonto(Math.round(maxComparacion / 2))}</span>
1059 + <span class="y-label-comparar" style="bottom: 0%">Bs 0</span>
1060 + <div class="grid-line-comparar" style="bottom: 100%"></div>
1061 + <div class="grid-line-comparar" style="bottom: 50%"></div>
1062 + <div class="grid-line-comparar" style="bottom: 0%"></div>
1063 + <div class="bars-comparar" onmouseleave={() => hoveredYearComparar = null} role="group">
1064 + {#each añosComparacion as año, i}
1065 + <div class="bar-group-comparar" class:active={hoveredYearComparar === año} onmouseenter={() => hoveredYearComparar = año} role="button" tabindex="0">
1066 + <div class="bars-pair">
1067 + <div class="bar-comparar" style="height: {(valComparar(datosGraficoA[i]) / maxComparacion) * 100}%; background: {colorA}"></div>
1068 + <div class="bar-comparar" style="height: {(valComparar(datosGraficoB[i]) / maxComparacion) * 100}%; background: {colorB}"></div>
1069 + </div>
1070 + </div>
1071 + {/each}
1072 + </div>
1073 + </div>
1074 + <div class="x-axis-comparar">
1075 + {#each añosComparacion as año, i}
1076 + {@const show = i === 0 || i === añosComparacion.length - 1 || hoveredYearComparar === año}
1077 + <span class="x-label-comparar" class:visible={show}>{año}</span>
1078 + {/each}
1079 + </div>
1080 + </div>
1081 +
1082 + <div class="chart-legend">
1083 + {#if entidadCompararA}<span class="legend-item"><span class="legend-dot" style="background: {colorA}"></span>{entidadCompararA.entidad_desc}</span>{/if}
1084 + {#if entidadCompararB}<span class="legend-item"><span class="legend-dot" style="background: {colorB}"></span>{entidadCompararB.entidad_desc}</span>{/if}
1085 + </div>
1086 + {/if}
1087 + </div>
1088 +
1089 + {#if soloUnaEntidad}
1090 + <div class="comparador-hint comparador-hint-select">
1091 + <span>Selecciona la segunda entidad para ver la comparación completa.</span>
1092 + </div>
1093 + {/if}
1094 +
1095 + {#if entidadCompararA && entidadCompararB}
1096 + <div class="kpis-card-comparar">
1097 + <div class="tabla-header">
1098 + <span class="tabla-label"></span>
1099 + <span class="tabla-val tabla-entidad" style="color: {colorA}">{entidadCompararA.entidad_desc}</span>
1100 + <span class="tabla-val tabla-entidad" style="color: {colorB}">{entidadCompararB.entidad_desc}</span>
1101 + </div>
1102 + <div class="tabla-row">
1103 + <span class="tabla-label">Gasto</span>
1104 + <span class="tabla-val">Bs {formatMontoLargo(Math.round($twMontoA))}</span>
1105 + <span class="tabla-val">Bs {formatMontoLargo(Math.round($twMontoB))}</span>
1106 + </div>
1107 + <div class="tabla-row">
1108 + <span class="tabla-label">Per cápita</span>
1109 + <span class="tabla-val">Bs {formatPerCapita($twPerCapitaA)}</span>
1110 + <span class="tabla-val">Bs {formatPerCapita($twPerCapitaB)}</span>
1111 + </div>
1112 + <div class="tabla-row">
1113 + <span class="tabla-label">Peso</span>
1114 + <span class="tabla-val">{displayPropA.toLocaleString('es-BO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</span>
1115 + <span class="tabla-val">{displayPropB.toLocaleString('es-BO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</span>
1116 + </div>
1117 + <div class="tabla-row">
1118 + <span class="tabla-label">Ranking</span>
1119 + <span class="tabla-val">#{displayRankingA} de {displayNEntidadesA}</span>
1120 + <span class="tabla-val">#{displayRankingB} de {displayNEntidadesB}</span>
1121 + </div>
1122 + </div>
1123 + {/if}
1124 + </main>
1125 + {/if}
1126 +</div>
1127 +
1128 +<style>
1129 + /* ═══════════════════════════════════════════════════════════════
1130 + PAGE
1131 + ═══════════════════════════════════════════════════════════════ */
1132 + .page {
1133 + height: 100dvh;
1134 + width: 100%;
1135 + max-width: 100%;
1136 + overflow: hidden;
1137 + background: var(--theme-body);
1138 + padding-top: 60px;
1139 + box-sizing: border-box;
1140 + color: var(--theme-titulo);
1141 + font-family: var(--font-sans), -apple-system, sans-serif;
1142 + opacity: 0;
1143 + transition: opacity 0.4s ease;
1144 + }
1145 + .page.mounted { opacity: 1; }
1146 +
1147 + :global(html:not(.dark)) .page {
1148 + background: #f5f5f7;
1149 + }
1150 +
1151 + /* ═══════════════════════════════════════════════════════════════
1152 + NAV
1153 + ═══════════════════════════════════════════════════════════════ */
1154 + .page-nav {
1155 + padding: 2rem 2rem 0.5rem;
1156 + max-width: 1400px;
1157 + margin: 0 auto;
1158 + }
1159 +
1160 + .back-link {
1161 + display: inline-flex;
1162 + align-items: center;
1163 + gap: 0.375rem;
1164 + font-family: 'DM Mono', monospace;
1165 + font-size: 0.6875rem;
1166 + color: var(--theme-texto);
1167 + text-decoration: none;
1168 + transition: color 0.2s;
1169 + }
1170 + .back-link:hover { color: var(--theme-accent); }
1171 +
1172 + /* ═══════════════════════════════════════════════════════════════
1173 + LAYOUT
1174 + ═══════════════════════════════════════════════════════════════ */
1175 + .dashboard-layout {
1176 + display: grid;
1177 + grid-template-columns: minmax(200px, 30%) 1fr;
1178 + gap: 1.5rem;
1179 + max-width: 1400px;
1180 + margin: 0 auto;
1181 + padding: 1rem 2rem 2rem;
1182 + height: 100%;
1183 + overflow: hidden;
1184 + }
1185 +
1186 + /* ═══════════════════════════════════════════════════════════════
1187 + SIDEBAR
1188 + ═══════════════════════════════════════════════════════════════ */
1189 + .dashboard-context {
1190 + display: flex;
1191 + flex-direction: column;
1192 + min-height: 0;
1193 + }
1194 +
1195 + .context-card {
1196 + background: var(--theme-surface);
1197 + border-radius: 12px;
1198 + padding: 0.75rem;
1199 + display: flex;
1200 + flex-direction: column;
1201 + gap: 8px;
1202 + flex: 1;
1203 + min-height: 0;
1204 + overflow-y: auto;
1205 + scrollbar-width: thin;
1206 + }
1207 +
1208 + .context-card::-webkit-scrollbar { width: 4px; }
1209 + .context-card::-webkit-scrollbar-thumb {
1210 + background: rgba(128, 128, 128, 0.3);
1211 + border-radius: 2px;
1212 + }
1213 +
1214 + .context-main-title {
1215 + font-size: 1rem;
1216 + font-weight: 600;
1217 + color: var(--theme-titulo);
1218 + margin: 0;
1219 + line-height: 1.2;
1220 + text-transform: uppercase;
1221 + letter-spacing: 0.05em;
1222 + opacity: 0.5;
1223 + }
1224 +
1225 + .context-subtitle {
1226 + font-family: 'Qanelas', var(--font-sans);
1227 + font-size: 0.875rem;
1228 + font-weight: 700;
1229 + color: var(--theme-titulo);
1230 + margin: 0;
1231 + }
1232 +
1233 + .context-hint {
1234 + font-size: 0.75rem;
1235 + color: var(--theme-texto);
1236 + opacity: 0.7;
1237 + margin: 0;
1238 + line-height: 1.4;
1239 + }
1240 +
1241 + .context-section {
1242 + display: flex;
1243 + flex-direction: column;
1244 + gap: 0.5rem;
1245 + }
1246 +
1247 + .context-separator {
1248 + border-top: 1px solid var(--theme-borde);
1249 + margin: 0.25rem 0;
1250 + }
1251 +
1252 + /* ═══════════════════════════════════════════════════════════════
1253 + INLINE SEARCH
1254 + ═══════════════════════════════════════════════════════════════ */
1255 + .inline-search {
1256 + position: relative;
1257 + }
1258 +
1259 + .inline-search-input {
1260 + width: 100%;
1261 + padding: 0.5rem 0.75rem;
1262 + border: 1px solid var(--theme-borde);
1263 + border-radius: 8px;
1264 + background: var(--theme-body);
1265 + color: var(--theme-titulo);
1266 + font-size: 0.8125rem;
1267 + outline: none;
1268 + font-family: inherit;
1269 + box-sizing: border-box;
1270 + }
1271 + .inline-search-input:focus {
1272 + border-color: var(--theme-accent);
1273 + }
1274 + .inline-search-input::placeholder {
1275 + color: var(--theme-texto);
1276 + opacity: 0.5;
1277 + }
1278 +
1279 + .inline-search-loading {
1280 + font-size: 0.6875rem;
1281 + color: var(--theme-texto);
1282 + opacity: 0.5;
1283 + padding: 0.25rem 0;
1284 + }
1285 +
1286 + .inline-search-results {
1287 + position: absolute;
1288 + top: calc(100% + 4px);
1289 + left: 0;
1290 + right: 0;
1291 + background: var(--theme-surface);
1292 + border: 1px solid var(--theme-borde);
1293 + border-radius: 8px;
1294 + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
1295 + max-height: 240px;
1296 + overflow-y: auto;
1297 + z-index: 100;
1298 + }
1299 +
1300 + .inline-search-item {
1301 + display: flex;
1302 + align-items: center;
1303 + gap: 0.5rem;
1304 + padding: 0.5rem 0.75rem;
1305 + text-decoration: none;
1306 + color: var(--theme-titulo);
1307 + font-size: 0.8125rem;
1308 + transition: background 0.15s;
1309 + }
1310 + .inline-search-item:hover {
1311 + background: var(--theme-surface-hover, rgba(201, 167, 81, 0.08));
1312 + }
1313 +
1314 + .inline-search-code {
1315 + font-family: 'DM Mono', monospace;
1316 + font-size: 0.6875rem;
1317 + color: var(--theme-texto);
1318 + opacity: 0.6;
1319 + min-width: 2.5rem;
1320 + }
1321 +
1322 + .inline-search-label {
1323 + overflow: hidden;
1324 + text-overflow: ellipsis;
1325 + white-space: nowrap;
1326 + }
1327 +
1328 + /* ═══════════════════════════════════════════════════════════════
1329 + TREE
1330 + ═══════════════════════════════════════════════════════════════ */
1331 + .tree {
1332 + font-size: 0.75rem;
1333 + line-height: 1.6;
1334 + }
1335 +
1336 + .tree-node {
1337 + display: flex;
1338 + align-items: center;
1339 + gap: 0.375rem;
1340 + padding: 0.375rem 0.5rem;
1341 + margin-left: calc(var(--indent, 0) * 1rem);
1342 + border-left: 2px solid transparent;
1343 + border-radius: 0 6px 6px 0;
1344 + text-decoration: none;
1345 + color: var(--theme-texto);
1346 + transition: all 0.15s;
1347 + }
1348 + .tree-node:hover { color: var(--theme-accent); }
1349 +
1350 + .tree-node-current {
1351 + color: var(--theme-titulo);
1352 + font-weight: 600;
1353 + border-left-color: var(--theme-accent);
1354 + background: rgba(201, 167, 81, 0.1);
1355 + }
1356 +
1357 + .tree-children {
1358 + margin-left: calc(var(--indent, 0) * 1rem);
1359 + }
1360 +
1361 + .tree-node-child {
1362 + padding-left: 0.75rem;
1363 + }
1364 +
1365 + .tree-dot {
1366 + width: 4px;
1367 + height: 4px;
1368 + border-radius: 50%;
1369 + background: currentColor;
1370 + flex-shrink: 0;
1371 + opacity: 0.4;
1372 + }
1373 +
1374 + .tree-code {
1375 + font-family: 'DM Mono', monospace;
1376 + font-size: 0.6875rem;
1377 + min-width: 2.5rem;
1378 + opacity: 0.6;
1379 + }
1380 +
1381 + .tree-name {
1382 + font-size: 0.6875rem;
1383 + white-space: nowrap;
1384 + overflow: hidden;
1385 + text-overflow: ellipsis;
1386 + }
1387 +
1388 + /* ═══════════════════════════════════════════════════════════════
1389 + EXPLORE
1390 + ═══════════════════════════════════════════════════════════════ */
1391 + .explore-header {
1392 + display: flex;
1393 + align-items: center;
1394 + justify-content: space-between;
1395 + }
1396 +
1397 + .explore-count {
1398 + font-family: 'DM Mono', monospace;
1399 + font-size: 0.625rem;
1400 + color: var(--theme-accent);
1401 + opacity: 0.7;
1402 + animation: nudgeRight 1.5s ease-in-out infinite;
1403 + }
1404 +
1405 + @keyframes nudgeRight {
1406 + 0%, 100% { transform: translateX(0); }
1407 + 50% { transform: translateX(3px); }
1408 + }
1409 +
1410 + .explore-scroll {
1411 + display: flex;
1412 + gap: 8px;
1413 + overflow-x: auto;
1414 + scrollbar-width: none;
1415 + padding-bottom: 2px;
1416 + }
1417 + .explore-scroll::-webkit-scrollbar { display: none; }
1418 +
1419 + .explore-scroll {
1420 + -webkit-mask-image: linear-gradient(to right, black 85%, transparent 100%);
1421 + mask-image: linear-gradient(to right, black 85%, transparent 100%);
1422 + }
1423 +
1424 + .explore-card {
1425 + position: relative;
1426 + display: flex;
1427 + flex-direction: column;
1428 + justify-content: flex-start;
1429 + padding: 0.375rem 0.5rem;
1430 + width: 120px;
1431 + height: 95px;
1432 + border-radius: 8px;
1433 + background: var(--theme-borde);
1434 + text-decoration: none;
1435 + color: var(--theme-texto);
1436 + cursor: pointer;
1437 + transition: all 0.15s;
1438 + flex-shrink: 0;
1439 + overflow: hidden;
1440 + }
1441 + .explore-card:hover {
1442 + background: rgba(201, 167, 81, 0.15);
1443 + color: var(--theme-accent);
1444 + }
1445 + .explore-card:hover .explore-watermark {
1446 + opacity: 0.18;
1447 + color: var(--theme-accent);
1448 + }
1449 + .explore-card:hover .explore-title {
1450 + color: var(--theme-accent);
1451 + }
1452 +
1453 + .explore-watermark {
1454 + position: absolute;
1455 + bottom: -4px;
1456 + right: -4px;
1457 + width: 44px;
1458 + height: 44px;
1459 + opacity: 0.15;
1460 + pointer-events: none;
1461 + color: var(--theme-accent, #C9A751);
1462 + }
1463 +
1464 + .explore-title {
1465 + position: relative;
1466 + font-size: 0.75rem;
1467 + font-weight: 600;
1468 + line-height: 1.3;
1469 + color: var(--theme-titulo);
1470 + z-index: 1;
1471 + }
1472 +
1473 + .explore-desc {
1474 + position: relative;
1475 + font-size: 0.625rem;
1476 + line-height: 1.35;
1477 + opacity: 0.6;
1478 + margin-top: 3px;
1479 + z-index: 1;
1480 + }
1481 +
1482 + .explore-card-disabled {
1483 + opacity: 0.3;
1484 + pointer-events: none;
1485 + }
1486 +
1487 + /* ═══════════════════════════════════════════════════════════════
1488 + VIGENCIA
1489 + ═══════════════════════════════════════════════════════════════ */
1490 + .vigencia-compact {
1491 + display: flex;
1492 + align-items: center;
1493 + gap: 0.75rem;
1494 + }
1495 +
1496 + .vigencia-status-icon {
1497 + width: 32px;
1498 + height: 32px;
1499 + border-radius: 8px;
1500 + display: flex;
1501 + align-items: center;
1502 + justify-content: center;
1503 + flex-shrink: 0;
1504 + }
1505 +
1506 + .vigencia-status-icon.vigencia-full {
1507 + background: rgba(74, 158, 107, 0.15);
1508 + color: #4A9E6B;
1509 + }
1510 +
1511 + .vigencia-text {
1512 + display: flex;
1513 + flex-direction: column;
1514 + gap: 0.125rem;
1515 + }
1516 +
1517 + .vigencia-main {
1518 + font-size: 0.75rem;
1519 + font-weight: 500;
1520 + color: var(--theme-titulo);
1521 + }
1522 +
1523 + .vigencia-sub {
1524 + font-size: 0.625rem;
1525 + color: var(--theme-texto);
1526 + opacity: 0.5;
1527 + }
1528 +
1529 + /* ═══════════════════════════════════════════════════════════════
1530 + DEFINICIÓN
1531 + ═══════════════════════════════════════════════════════════════ */
1532 + .def-link {
1533 + display: flex;
1534 + align-items: center;
1535 + gap: 0.375rem;
1536 + font-size: 0.75rem;
1537 + color: var(--theme-accent, #C9A751);
1538 + background: none;
1539 + border: none;
1540 + padding: 0;
1541 + cursor: pointer;
1542 + transition: opacity 0.15s;
1543 + }
1544 + .def-link:hover { opacity: 0.7; }
1545 + .def-link svg { flex-shrink: 0; opacity: 0.8; }
1546 +
1547 + .chart-no-data {
1548 + flex: 1;
1549 + display: flex;
1550 + flex-direction: column;
1551 + align-items: center;
1552 + justify-content: center;
1553 + gap: 0.75rem;
1554 + color: var(--theme-texto);
1555 + opacity: 0.7;
1556 + font-size: 0.8125rem;
1557 + text-align: center;
1558 + }
1559 +
1560 + .chart-no-data p {
1561 + margin: 0;
1562 + }
1563 +
1564 + .volver-agregado {
1565 + display: inline-flex;
1566 + align-items: center;
1567 + gap: 0.25rem;
1568 + font-size: 0.6875rem;
1569 + color: var(--theme-accent);
1570 + background: none;
1571 + border: none;
1572 + padding: 0;
1573 + cursor: pointer;
1574 + transition: opacity 0.15s;
1575 + }
1576 + .volver-agregado:hover { opacity: 0.7; }
1577 +
1578 + .copy-link-btn {
1579 + display: flex;
1580 + align-items: center;
1581 + gap: 0.375rem;
1582 + font-size: 0.75rem;
1583 + color: var(--theme-texto);
1584 + opacity: 0.6;
1585 + background: none;
1586 + border: none;
1587 + padding: 0;
1588 + cursor: pointer;
1589 + transition: opacity 0.15s;
1590 + }
1591 + .copy-link-btn:hover { opacity: 1; }
1592 + .copy-link-btn svg { flex-shrink: 0; }
1593 +
1594 + /* ═══════════════════════════════════════════════════════════════
1595 + MAIN
1596 + ═══════════════════════════════════════════════════════════════ */
1597 + .dashboard-main {
1598 + display: flex;
1599 + flex-direction: column;
1600 + gap: 1.5rem;
1601 + min-height: 0;
1602 + overflow: hidden;
1603 + }
1604 +
1605 + .chart-protagonista {
1606 + background: var(--theme-surface);
1607 + border-radius: 12px;
1608 + padding: 0.75rem;
1609 + display: flex;
1610 + flex-direction: column;
1611 + min-height: 0;
1612 + flex: 2;
1613 + overflow: hidden;
1614 + }
1615 +
1616 + .ranking-section {
1617 + background: var(--theme-surface);
1618 + border-radius: 12px;
1619 + padding: 0.75rem;
1620 + flex: 1;
1621 + display: flex;
1622 + flex-direction: column;
1623 + min-height: 0;
1624 + }
1625 +
1626 + /* ═══════════════════════════════════════════════════════════════
1627 + CHART & KPIs
1628 + ═══════════════════════════════════════════════════════════════ */
1629 + .chart-top-row {
1630 + display: flex;
1631 + justify-content: space-between;
1632 + align-items: center;
1633 + margin-bottom: 0.375rem;
1634 + }
1635 +
1636 + .main-title-pills {
1637 + display: flex;
1638 + gap: 0.375rem;
1639 + }
1640 +
1641 + .pill {
1642 + font-family: 'DM Mono', monospace;
1643 + font-size: 0.625rem;
1644 + padding: 0.125rem 0.5rem;
1645 + border-radius: 4px;
1646 + letter-spacing: 0.03em;
1647 + }
1648 +
1649 + .pill-nivel {
1650 + background: rgba(201, 167, 81, 0.15);
1651 + color: var(--theme-accent);
1652 + }
1653 +
1654 + .pill-codigo {
1655 + background: var(--theme-borde);
1656 + color: var(--theme-texto);
1657 + }
1658 +
1659 + .main-title-name {
1660 + font-family: 'DM Serif Display', serif;
1661 + font-size: 2rem;
1662 + font-weight: 400;
1663 + margin: 0 0 0.5rem;
1664 + line-height: 1.15;
1665 + color: var(--theme-titulo);
1666 + }
1667 +
1668 + .chart-controls {
1669 + margin-bottom: 0.5rem;
1670 + }
1671 +
1672 + .chart-header {
1673 + display: flex;
1674 + align-items: center;
1675 + justify-content: space-between;
1676 + margin-bottom: 0.25rem;
1677 + }
1678 +
1679 + .chart-title {
1680 + font-family: 'DM Mono', monospace;
1681 + font-size: 0.6875rem;
1682 + color: var(--theme-texto);
1683 + opacity: 0.7;
1684 + }
1685 +
1686 + .chart-period {
1687 + opacity: 0.5;
1688 + }
1689 +
1690 + .metrica-toggle {
1691 + font-family: 'DM Mono', monospace;
1692 + font-size: 0.6875rem;
1693 + color: var(--theme-texto);
1694 + background: rgba(128, 128, 128, 0.08);
1695 + border: 1px solid rgba(128, 128, 128, 0.15);
1696 + padding: 0.125rem 0.375rem;
1697 + border-radius: 4px;
1698 + cursor: pointer;
1699 + transition: all 0.15s;
1700 + display: inline-flex;
1701 + align-items: center;
1702 + gap: 0.25rem;
1703 + }
1704 +
1705 + .metrica-toggle:hover {
1706 + background: rgba(201, 167, 81, 0.12);
1707 + border-color: rgba(201, 167, 81, 0.3);
1708 + color: var(--theme-accent);
1709 + }
1710 +
1711 + .chart-loading {
1712 + display: flex;
1713 + align-items: center;
1714 + justify-content: center;
1715 + color: var(--theme-texto);
1716 + opacity: 0.5;
1717 + }
1718 +
1719 + .chart-flex-wrapper {
1720 + flex: 1;
1721 + min-height: 0;
1722 + }
1723 +
1724 + .kpis {
1725 + display: grid;
1726 + grid-template-columns: repeat(4, 1fr);
1727 + gap: 0.5rem;
1728 + margin-top: 0.5rem;
1729 + padding-top: 0.75rem;
1730 + border-top: 1px solid var(--theme-borde);
1731 + }
1732 +
1733 + .kpi {
1734 + display: flex;
1735 + flex-direction: column;
1736 + align-items: center;
1737 + gap: 0.125rem;
1738 + text-align: center;
1739 + }
1740 +
1741 + .kpi-value {
1742 + font-family: 'Qanelas', var(--font-sans);
1743 + font-size: 1rem;
1744 + font-weight: 600;
1745 + color: var(--theme-titulo);
1746 + }
1747 +
1748 + .kpi-label {
1749 + font-size: 0.625rem;
1750 + color: var(--theme-texto);
1751 + opacity: 0.5;
1752 + line-height: 1.35;
1753 + }
1754 +
1755 + /* ═══════════════════════════════════════════════════════════════
1756 + RANKING
1757 + ═══════════════════════════════════════════════════════════════ */
1758 + .ranking-title {
1759 + font-size: 1rem;
1760 + font-weight: 600;
1761 + color: var(--theme-titulo);
1762 + margin: 0 0 0.25rem;
1763 + }
1764 +
1765 + .ranking-desc {
1766 + font-size: 0.6875rem;
1767 + color: var(--theme-texto);
1768 + opacity: 0.5;
1769 + margin: 0 0 0.5rem;
1770 + }
1771 +
1772 + .ranking-cards {
1773 + display: flex;
1774 + flex-direction: column;
1775 + gap: 0.25rem;
1776 + }
1777 +
1778 + .ranking-card {
1779 + display: flex;
1780 + align-items: center;
1781 + gap: 0.375rem;
1782 + padding: 0.5rem 0.625rem;
1783 + border-radius: 8px;
1784 + background: transparent;
1785 + text-align: left;
1786 + }
1787 +
1788 + .ranking-card-link {
1789 + text-decoration: none;
1790 + cursor: pointer;
1791 + transition: background 0.15s;
1792 + }
1793 +
1794 + .ranking-card-link:hover {
1795 + background: rgba(201, 167, 81, 0.05);
1796 + }
1797 +
1798 + .ranking-card-link:hover .ranking-name {
1799 + color: var(--theme-accent);
1800 + border-bottom-style: solid;
1801 + }
1802 +
1803 + .ranking-arrow {
1804 + opacity: 0;
1805 + color: var(--theme-accent);
1806 + transition: opacity 0.15s, transform 0.15s;
1807 + flex-shrink: 0;
1808 + }
1809 +
1810 + .ranking-card-link:hover .ranking-arrow {
1811 + opacity: 1;
1812 + transform: translateX(2px);
1813 + }
1814 +
1815 + .ranking-header {
1816 + margin-bottom: 0.5rem;
1817 + }
1818 +
1819 + .ranking-summary {
1820 + display: flex;
1821 + align-items: baseline;
1822 + gap: 0.375rem;
1823 + margin-top: 0.125rem;
1824 + }
1825 +
1826 + .ranking-summary-pct {
1827 + font-family: 'DM Mono', monospace;
1828 + font-size: 1.25rem;
1829 + font-weight: 700;
1830 + color: var(--theme-titulo);
1831 + }
1832 +
1833 + .ranking-summary-text {
1834 + font-size: 0.75rem;
1835 + color: var(--theme-texto);
1836 + opacity: 0.7;
1837 + }
1838 +
1839 + .ranking-position {
1840 + font-family: 'DM Mono', monospace;
1841 + font-size: 0.6875rem;
1842 + color: var(--theme-accent);
1843 + font-weight: 600;
1844 + min-width: 1.5rem;
1845 + }
1846 +
1847 + .ranking-name {
1848 + font-size: 0.8125rem;
1849 + color: var(--theme-titulo);
1850 + border-bottom: 1px dotted rgba(128, 128, 128, 0.3);
1851 + }
1852 +
1853 + .ranking-pct {
1854 + font-family: 'DM Mono', monospace;
1855 + font-size: 0.8125rem;
1856 + font-weight: 600;
1857 + color: var(--theme-titulo);
1858 + opacity: 0.7;
1859 + }
1860 +
1861 + /* ═══════════════════════════════════════════════════════════════
1862 + MODAL
1863 + ═══════════════════════════════════════════════════════════════ */
1864 + .modal-overlay {
1865 + position: fixed;
1866 + inset: 0;
1867 + background: rgba(0, 0, 0, 0.6);
1868 + backdrop-filter: blur(4px);
1869 + display: flex;
1870 + align-items: center;
1871 + justify-content: center;
1872 + z-index: 1000;
1873 + padding: 1rem;
1874 + }
1875 +
1876 + .modal-content {
1877 + background: var(--theme-surface);
1878 + border-radius: 16px;
1879 + max-width: 560px;
1880 + width: 100%;
1881 + max-height: 80vh;
1882 + overflow: hidden;
1883 + display: flex;
1884 + flex-direction: column;
1885 + box-shadow: 0 24px 48px rgba(0, 0, 0, 0.2);
1886 + }
1887 +
1888 + .modal-header {
1889 + display: flex;
1890 + align-items: center;
1891 + justify-content: space-between;
1892 + padding: 1.25rem 1.5rem;
1893 + border-bottom: 1px solid var(--theme-borde);
1894 + }
1895 +
1896 + .modal-header h3 {
1897 + font-size: 1rem;
1898 + font-weight: 600;
1899 + color: var(--theme-titulo);
1900 + margin: 0;
1901 + }
1902 +
1903 + .modal-close {
1904 + color: var(--theme-texto);
1905 + background: transparent;
1906 + border: none;
1907 + padding: 0.25rem;
1908 + cursor: pointer;
1909 + border-radius: 6px;
1910 + transition: background 0.2s;
1911 + }
1912 +
1913 + .modal-close:hover {
1914 + background: var(--theme-borde);
1915 + }
1916 +
1917 + .modal-body {
1918 + padding: 1.5rem;
1919 + overflow-y: auto;
1920 + display: flex;
1921 + flex-direction: column;
1922 + gap: 1rem;
1923 + }
1924 +
1925 + .def-item {
1926 + padding: 1rem;
1927 + background: var(--theme-body);
1928 + border-radius: 10px;
1929 + border-left: 3px solid var(--theme-borde);
1930 + }
1931 +
1932 + .def-current {
1933 + border-left-color: #6B9FD4;
1934 + }
1935 +
1936 + .def-header {
1937 + display: flex;
1938 + align-items: center;
1939 + gap: 0.5rem;
1940 + margin-bottom: 0.5rem;
1941 + }
1942 +
1943 + .def-period {
1944 + font-family: 'DM Mono', monospace;
1945 + font-size: 0.75rem;
1946 + font-weight: 500;
1947 + color: #6B9FD4;
1948 + }
1949 +
1950 + .def-badge {
1951 + font-size: 0.625rem;
1952 + font-weight: 600;
1953 + text-transform: uppercase;
1954 + letter-spacing: 0.05em;
1955 + color: white;
1956 + background: #4A9E6B;
1957 + padding: 0.125rem 0.5rem;
1958 + border-radius: 4px;
1959 + }
1960 +
1961 + .def-text {
1962 + font-size: 0.875rem;
1963 + color: var(--theme-texto);
1964 + line-height: 1.6;
1965 + margin: 0;
1966 + }
1967 +
1968 + :global(html.dark) .def-current {
1969 + border-left-color: var(--theme-accent);
1970 + }
1971 +
1972 + :global(html.dark) .def-period {
1973 + color: var(--theme-accent);
1974 + }
1975 +
1976 + /* ═══════════════════════════════════════════════════════════════
1977 + MOBILE SIDEBAR
1978 + ═══════════════════════════════════════════════════════════════ */
1979 + .mobile-sidebar-toggle {
1980 + display: none;
1981 + }
1982 +
1983 + @media (max-width: 900px) {
1984 + .dashboard-layout {
1985 + grid-template-columns: 1fr;
1986 + gap: 0.5rem;
1987 + height: auto;
1988 + overflow: visible;
1989 + padding: 1.5rem 1rem 2rem;
1990 + align-content: center;
1991 + min-height: calc(100vh - 4rem);
1992 + }
1993 +
1994 + .dashboard-main {
1995 + overflow: visible;
1996 + }
1997 +
1998 + .chart-protagonista {
1999 + flex: none;
2000 + }
2001 +
2002 + .ranking-section {
2003 + flex: none;
2004 + }
2005 +
2006 + .mobile-sidebar-toggle {
2007 + display: flex;
2008 + align-items: center;
2009 + gap: 0.5rem;
2010 + padding: 0.625rem 1rem;
2011 + background: var(--theme-surface);
2012 + border: 1px solid var(--theme-borde);
2013 + border-radius: 10px;
2014 + color: var(--theme-titulo);
2015 + font-family: 'Qanelas', var(--font-sans);
2016 + font-size: 0.8125rem;
2017 + font-weight: 500;
2018 + cursor: pointer;
2019 + transition: all 0.2s;
2020 + margin-bottom: 0.25rem;
2021 + width: 100%;
2022 + }
2023 +
2024 + .mobile-sidebar-toggle:hover {
2025 + background: var(--theme-surface-hover);
2026 + }
2027 +
2028 + .mobile-sidebar-toggle svg {
2029 + color: var(--theme-texto);
2030 + opacity: 0.6;
2031 + }
2032 +
2033 + .dashboard-context .context-card {
2034 + display: none;
2035 + }
2036 +
2037 + .dashboard-context.mobile-open .context-card {
2038 + display: flex;
2039 + animation: slideDown 0.25s ease;
2040 + }
2041 +
2042 + @keyframes slideDown {
2043 + from { opacity: 0; transform: translateY(-8px); }
2044 + to { opacity: 1; transform: translateY(0); }
2045 + }
2046 +
2047 + .context-card {
2048 + height: auto;
2049 + overflow-y: visible;
2050 + }
2051 +
2052 + .page-nav {
2053 + padding: 0.75rem 1rem;
2054 + }
2055 +
2056 + .kpis {
2057 + grid-template-columns: repeat(2, 1fr);
2058 + }
2059 + }
2060 +
2061 + @media (max-width: 480px) {
2062 + .dashboard-layout {
2063 + padding: 0.5rem;
2064 + }
2065 +
2066 + .page-nav {
2067 + padding: 0.5rem;
2068 + }
2069 +
2070 + .chart-protagonista,
2071 + .ranking-section,
2072 + .context-card {
2073 + padding: 0.5rem;
2074 + border-radius: 8px;
2075 + }
2076 +
2077 + .main-title-name {
2078 + font-size: 1.375rem;
2079 + }
2080 +
2081 + .chart-header {
2082 + flex-direction: column;
2083 + align-items: flex-start;
2084 + gap: 0.25rem;
2085 + }
2086 +
2087 + .kpis {
2088 + grid-template-columns: repeat(2, 1fr);
2089 + gap: 0.375rem;
2090 + }
2091 +
2092 + .kpi-value {
2093 + font-size: 0.8125rem;
2094 + }
2095 +
2096 + .kpi-label {
2097 + font-size: 0.5625rem;
2098 + }
2099 +
2100 + .explore-scroll {
2101 + gap: 6px;
2102 + }
2103 +
2104 + .explore-card {
2105 + width: 100px;
2106 + height: 80px;
2107 + }
2108 +
2109 + .explore-title {
2110 + font-size: 0.625rem;
2111 + }
2112 +
2113 + .explore-desc {
2114 + font-size: 0.5rem;
2115 + }
2116 +
2117 + .ranking-title {
2118 + font-size: 0.875rem;
2119 + }
2120 +
2121 + .ranking-name {
2122 + font-size: 0.6875rem;
2123 + }
2124 +
2125 + .ranking-pct {
2126 + font-size: 0.75rem;
2127 + }
2128 +
2129 + .modal-content {
2130 + margin: 0.5rem;
2131 + max-height: 90vh;
2132 + }
2133 +
2134 + .tree-name {
2135 + max-width: 150px;
2136 + }
2137 + }
2138 +
2139 + /* ═══════════════════════════════════════════════════════════════
2140 + DARK MODE
2141 + ═══════════════════════════════════════════════════════════════ */
2142 + :global(html.dark) .context-hint {
2143 + color: rgba(255, 255, 255, 0.5);
2144 + }
2145 +
2146 + :global(html.dark) .def-link {
2147 + color: var(--theme-accent, #C9A751);
2148 + }
2149 +
2150 + /* ═══════════════════════════════════════════════════════════════
2151 + COMPARAR VIEW
2152 + ═══════════════════════════════════════════════════════════════ */
2153 + .main-comparar {
2154 + display: flex;
2155 + flex-direction: column;
2156 + gap: 0.75rem;
2157 + padding: 1rem 2rem 2rem;
2158 + width: 100%;
2159 + max-width: 1400px;
2160 + margin: 0 auto;
2161 + height: 100%;
2162 + overflow: hidden;
2163 + }
2164 +
2165 + .chart-card-comparar {
2166 + background: var(--theme-surface);
2167 + border-radius: 12px;
2168 + padding: 1rem 1.5rem;
2169 + display: flex;
2170 + flex-direction: column;
2171 + flex: 1;
2172 + min-height: 0;
2173 + }
2174 +
2175 + .comparador-selectors {
2176 + display: flex;
2177 + align-items: center;
2178 + gap: 1rem;
2179 + margin-bottom: 1rem;
2180 + }
2181 +
2182 + .selector-group { flex: 1; display: flex; align-items: center; gap: 0.5rem; }
2183 + .selector-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
2184 + .vs { font-family: 'DM Mono', monospace; font-size: 0.75rem; color: var(--theme-texto); flex-shrink: 0; }
2185 + .relative { position: relative; }
2186 + .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2187 +
2188 + .selector-btn {
2189 + display: flex;
2190 + align-items: center;
2191 + gap: 0.5rem;
2192 + padding: 0.625rem 1rem;
2193 + background: var(--theme-body);
2194 + border: 1px solid var(--theme-borde);
2195 + border-radius: 8px;
2196 + font-size: 0.8125rem;
2197 + color: var(--theme-titulo);
2198 + cursor: pointer;
2199 + transition: border-color 0.2s;
2200 + }
2201 + .selector-btn:hover { border-color: var(--theme-accent); }
2202 + .comparador-btn { width: 100%; justify-content: space-between; }
2203 + .comparador-dropdown-container { position: relative; }
2204 +
2205 + .dropdown-panel.comparador-panel {
2206 + position: absolute;
2207 + top: calc(100% + 4px);
2208 + left: 0;
2209 + width: 100%;
2210 + min-width: 280px;
2211 + background: var(--theme-surface);
2212 + border: 1px solid var(--theme-borde);
2213 + border-radius: 10px;
2214 + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
2215 + z-index: 100;
2216 + overflow: hidden;
2217 + }
2218 +
2219 + .dropdown-search { padding: 0.5rem; border-bottom: 1px solid var(--theme-borde); }
2220 + .search-input {
2221 + width: 100%;
2222 + padding: 0.5rem 0.75rem;
2223 + border: 1px solid var(--theme-borde);
2224 + border-radius: 6px;
2225 + background: var(--theme-body);
2226 + color: var(--theme-titulo);
2227 + font-size: 0.8125rem;
2228 + outline: none;
2229 + }
2230 + .search-input:focus { border-color: var(--theme-accent); }
2231 +
2232 + .dropdown-item {
2233 + display: flex;
2234 + align-items: center;
2235 + gap: 0.5rem;
2236 + width: 100%;
2237 + padding: 0.625rem 0.75rem;
2238 + border: none;
2239 + background: transparent;
2240 + color: var(--theme-titulo);
2241 + font-size: 0.8125rem;
2242 + text-align: left;
2243 + cursor: pointer;
2244 + transition: background 0.15s;
2245 + }
2246 + .dropdown-item:hover { background: var(--theme-surface-hover); }
2247 + .dropdown-item.active { background: rgba(201, 167, 81, 0.1); color: var(--theme-accent); }
2248 + .dropdown-item:disabled { opacity: 0.4; cursor: not-allowed; }
2249 + .dropdown-list { max-height: 240px; overflow-y: auto; }
2250 + .entity-code { font-family: 'DM Mono', monospace; font-size: 0.6875rem; color: var(--theme-texto); min-width: 2rem; }
2251 +
2252 + .comparador-placeholder {
2253 + display: flex;
2254 + align-items: center;
2255 + justify-content: center;
2256 + min-height: 200px;
2257 + border: 1px dashed var(--theme-borde);
2258 + border-radius: 12px;
2259 + padding: 2rem;
2260 + text-align: center;
2261 + }
2262 + .comparador-placeholder p { color: var(--theme-texto); font-size: 0.875rem; margin: 0; }
2263 +
2264 + .comparador-hint {
2265 + display: flex;
2266 + align-items: center;
2267 + gap: 0.5rem;
2268 + padding: 0.75rem 1rem;
2269 + background: rgba(107, 159, 212, 0.1);
2270 + border-radius: 8px;
2271 + font-size: 0.8125rem;
2272 + color: #6B9FD4;
2273 + }
2274 + .comparador-hint span { flex: 1; }
2275 + .comparador-hint-select { margin-top: 0.75rem; }
2276 +
2277 + .comparador-warning {
2278 + display: flex;
2279 + align-items: center;
2280 + gap: 0.5rem;
2281 + padding: 0.75rem 1rem;
2282 + background: rgba(230, 176, 75, 0.1);
2283 + border-radius: 8px;
2284 + font-size: 0.8125rem;
2285 + color: #C9A751;
2286 + }
2287 + .comparador-warning span { flex: 1; }
2288 +
2289 + .alert-close {
2290 + flex-shrink: 0;
2291 + padding: 0.25rem;
2292 + border: none;
2293 + background: transparent;
2294 + color: inherit;
2295 + opacity: 0.6;
2296 + cursor: pointer;
2297 + border-radius: 4px;
2298 + }
2299 + .alert-close:hover { opacity: 1; }
2300 +
2301 + .chart-comparar-wrapper {
2302 + position: relative;
2303 + width: 100%;
2304 + flex: 1;
2305 + min-height: 180px;
2306 + }
2307 +
2308 + .chart-comparar-inner {
2309 + position: absolute;
2310 + top: 10px;
2311 + bottom: 30px;
2312 + left: 50px;
2313 + right: 10px;
2314 + }
2315 +
2316 + .y-label-comparar {
2317 + position: absolute;
2318 + left: -50px;
2319 + width: 45px;
2320 + text-align: right;
2321 + transform: translateY(50%);
2322 + font-family: 'DM Mono', monospace;
2323 + font-size: 0.6875rem;
2324 + color: var(--theme-texto);
2325 + white-space: nowrap;
2326 + }
2327 +
2328 + .grid-line-comparar {
2329 + position: absolute;
2330 + left: 0;
2331 + right: 0;
2332 + border-top: 0.5px dotted var(--theme-texto);
2333 + opacity: 0.15;
2334 + }
2335 +
2336 + .bars-comparar {
2337 + position: absolute;
2338 + inset: 0;
2339 + display: flex;
2340 + align-items: flex-end;
2341 + gap: 3px;
2342 + }
2343 +
2344 + .bar-group-comparar {
2345 + flex: 1;
2346 + height: 100%;
2347 + display: flex;
2348 + align-items: flex-end;
2349 + justify-content: center;
2350 + cursor: pointer;
2351 + }
2352 +
2353 + .bars-pair {
2354 + display: flex;
2355 + align-items: flex-end;
2356 + gap: 2px;
2357 + height: 100%;
2358 + width: 100%;
2359 + justify-content: center;
2360 + }
2361 +
2362 + .bar-comparar {
2363 + flex: 1;
2364 + max-width: 24px;
2365 + border-radius: 3px 3px 0 0;
2366 + min-height: 2px;
2367 + transition: opacity 0.15s;
2368 + }
2369 +
2370 + .bars-comparar:hover .bar-comparar { opacity: 0.35; }
2371 + .bar-group-comparar.active .bar-comparar { opacity: 1; }
2372 +
2373 + .x-axis-comparar {
2374 + position: absolute;
2375 + bottom: 0;
2376 + left: 50px;
2377 + right: 10px;
2378 + height: 30px;
2379 + display: flex;
2380 + align-items: center;
2381 + }
2382 +
2383 + .x-label-comparar {
2384 + flex: 1;
2385 + text-align: center;
2386 + font-family: 'DM Mono', monospace;
2387 + font-size: 0.6875rem;
2388 + color: var(--theme-texto);
2389 + opacity: 0;
2390 + transition: opacity 0.2s;
2391 + }
2392 + .x-label-comparar.visible { opacity: 1; }
2393 +
2394 + .chart-legend {
2395 + display: flex;
2396 + justify-content: center;
2397 + gap: 2rem;
2398 + margin-top: 0.5rem;
2399 + margin-bottom: 0.75rem;
2400 + }
2401 +
2402 + .legend-item {
2403 + display: flex;
2404 + align-items: center;
2405 + gap: 0.5rem;
2406 + font-size: 0.75rem;
2407 + color: var(--theme-texto);
2408 + }
2409 +
2410 + .legend-dot { width: 10px; height: 10px; border-radius: 3px; }
2411 +
2412 + .kpis-card-comparar {
2413 + background: var(--theme-surface);
2414 + border-radius: 12px;
2415 + padding: 0.75rem 1.25rem;
2416 + }
2417 +
2418 + .tabla-header {
2419 + display: grid;
2420 + grid-template-columns: 1fr 1.2fr 1.2fr;
2421 + gap: 0.75rem;
2422 + padding-bottom: 0.375rem;
2423 + border-bottom: 1px solid var(--theme-borde);
2424 + margin-bottom: 0.25rem;
2425 + }
2426 +
2427 + .tabla-header .tabla-val {
2428 + font-size: 0.625rem;
2429 + font-weight: 500;
2430 + text-transform: uppercase;
2431 + letter-spacing: 0.05em;
2432 + text-align: center;
2433 + }
2434 +
2435 + .tabla-entidad {
2436 + font-size: 0.6875rem;
2437 + line-height: 1.3;
2438 + word-break: break-word;
2439 + }
2440 +
2441 + .tabla-row {
2442 + display: grid;
2443 + grid-template-columns: 1fr 1.2fr 1.2fr;
2444 + gap: 0.75rem;
2445 + padding: 0.1875rem 0;
2446 + }
2447 +
2448 + .tabla-label {
2449 + font-size: 0.6875rem;
2450 + font-weight: 500;
2451 + color: var(--theme-texto);
2452 + text-transform: uppercase;
2453 + letter-spacing: 0.05em;
2454 + }
2455 +
2456 + .tabla-val {
2457 + font-size: 0.875rem;
2458 + font-weight: 500;
2459 + color: var(--theme-titulo);
2460 + text-align: center;
2461 + font-variant-numeric: tabular-nums;
2462 + }
2463 +
2464 + @media (max-width: 640px) {
2465 + .main-comparar { padding: 0.75rem; }
2466 + .chart-card-comparar, .kpis-card-comparar { padding: 1rem; }
2467 + .comparador-selectors { flex-direction: column; gap: 0.75rem; }
2468 + .vs { display: none; }
2469 + }
2470 +</style>
1 +<script>
2 + import { page } from '$app/stores';
3 + import { goto } from '$app/navigation';
4 + import { onMount } from 'svelte';
5 + import Spinner from '$lib/components/ui/Spinner.svelte';
6 + import * as d3 from 'd3';
7 +
8 + let codigo = $derived($page.params.codigo);
9 +
10 + // Estado
11 + let loading = $state(true);
12 + let mounted = $state(false);
13 + let actecoInfo = $state(null);
14 + let entidadesData = $state([]);
15 + let selectedYear = $state(null);
16 + let linkCopied = $state(false);
17 + let hoveredNode = $state(null);
18 + let totalDevengado = $state(0);
19 + let entidadClasificador = $state({}); // Map entidad → { desc_subarea, sigla_subarea, desc_area, ... }
20 + let subareas = $state([]); // Lista única de subareas con color
21 + let sliderMin = $state(0);
22 + let sliderMax = $state(100);
23 +
24 + // Filtrar entidades por rango de % acumulado
25 + let filteredEntidades = $derived.by(() => {
26 + if (sliderMin === 0 && sliderMax === 100) return entidadesData;
27 + let acum = 0;
28 + return entidadesData.filter(d => {
29 + const pctBefore = (acum / totalDevengado) * 100;
30 + acum += d.monto;
31 + const pctAfter = (acum / totalDevengado) * 100;
32 + return pctAfter > sliderMin && pctBefore < sliderMax;
33 + });
34 + });
35 +
36 + let filteredTotal = $derived(filteredEntidades.reduce((s, d) => s + d.monto, 0));
37 + let filteredCount = $derived(filteredEntidades.length);
38 +
39 + // Treemap
40 + let treemapContainer = $state(null);
41 + let treemapWidth = $state(0);
42 + let treemapHeight = $state(0);
43 + let treemapNodes = $state([]);
44 + let treemapGroups = $state([]); // Nodos padre (subareas)
45 +
46 + // Años disponibles desde gestiones del acteco
47 + let availableYears = $derived.by(() => {
48 + if (!actecoInfo?.gestiones) return [];
49 + if (actecoInfo.estados?.length > 0) {
50 + return actecoInfo.estados.map(e => e.gestion).filter(y => y >= 2016 && y > 0).sort((a, b) => b - a);
51 + }
52 + return actecoInfo.gestiones.split(',').map(Number).filter(y => y >= 2016 && y <= new Date().getFullYear()).sort((a, b) => b - a);
53 + });
54 +
55 + // Formatters
56 + function formatMonto(v) {
57 + if (v >= 1e9) return `${(v / 1e9).toFixed(1)} mil mill.`;
58 + if (v >= 1e6) return `${(v / 1e6).toFixed(1)} mill.`;
59 + if (v >= 1e3) return `${(v / 1e3).toFixed(0)} mil`;
60 + return v?.toLocaleString('es-BO') || '0';
61 + }
62 +
63 + function formatCompact(v) {
64 + if (v >= 1e9) return `${(v / 1e9).toFixed(1)}MM`;
65 + if (v >= 1e6) return `${(v / 1e6).toFixed(1)}M`;
66 + if (v >= 1e3) return `${(v / 1e3).toFixed(0)}K`;
67 + return v?.toFixed(0) || '0';
68 + }
69 +
70 + // Colores por subarea
71 + let isDark = $state(false);
72 + const SUBAREA_COLORS = [
73 + '#4E79A7', '#F28E2B', '#E15759', '#76B7B2', '#59A14F',
74 + '#EDC948', '#B07AA1', '#FF9DA7', '#9C755F', '#BAB0AC',
75 + '#4E79A7', '#F28E2B', '#E15759', '#76B7B2', '#59A14F'
76 + ];
77 +
78 + function getSubareaColor(subarea) {
79 + const idx = subareas.indexOf(subarea);
80 + return SUBAREA_COLORS[idx % SUBAREA_COLORS.length];
81 + }
82 +
83 + // Contraste de texto según luminosidad del fondo (WCAG)
84 + function getTextColorForBg(bgColor) {
85 + const color = d3.color(bgColor);
86 + if (!color) return 'rgba(255,255,255,0.95)';
87 + const luminance = 0.299 * (color.r / 255) + 0.587 * (color.g / 255) + 0.114 * (color.b / 255);
88 + return luminance > 0.5 ? 'rgba(0,0,0,0.85)' : 'rgba(255,255,255,0.95)';
89 + }
90 +
91 + function getTextColorSecondary(bgColor) {
92 + const color = d3.color(bgColor);
93 + if (!color) return 'rgba(255,255,255,0.88)';
94 + const luminance = 0.299 * (color.r / 255) + 0.587 * (color.g / 255) + 0.114 * (color.b / 255);
95 + return luminance > 0.5 ? 'rgba(0,0,0,0.6)' : 'rgba(255,255,255,0.7)';
96 + }
97 +
98 + // Cargar clasificador de entidades
99 + async function loadClasificador() {
100 + try {
101 + const res = await fetch('/api/entidad-clasificador');
102 + const data = await res.json();
103 + if (Array.isArray(data)) {
104 + const map = {};
105 + data.forEach(d => { map[d.entidad] = d; });
106 + entidadClasificador = map;
107 + }
108 + } catch {}
109 + }
110 +
111 + // Cargar info del acteco desde API
112 + async function loadActeco() {
113 + try {
114 + const res = await fetch(`/api/acteco-data?codigo=${codigo}&tipo=clasificador`);
115 + const data = await res.json();
116 + if (data && !data.error) actecoInfo = data;
117 + } catch {}
118 + }
119 +
120 + // Cargar entidades desde API
121 + async function loadEntidades() {
122 + if (!selectedYear || !codigo) return;
123 + loading = true;
124 + try {
125 + const res = await fetch(`/api/acteco-data?codigo=${codigo}&tipo=entidades-año&gestion=${selectedYear}`);
126 + const raw = await res.json();
127 + if (Array.isArray(raw)) {
128 + // Deduplicar por entidad
129 + const map = new Map();
130 + raw.forEach(d => {
131 + if (!map.has(d.entidad) || d.monto > map.get(d.entidad).monto) {
132 + map.set(d.entidad, d);
133 + }
134 + });
135 + entidadesData = [...map.values()].filter(d => d.monto > 0).sort((a, b) => b.monto - a.monto);
136 + totalDevengado = entidadesData.reduce((s, d) => s + d.monto, 0);
137 +
138 + // Names from entidad clasificador (acteco entidades endpoint does NOT return desc_entidad)
139 + entidadesData = entidadesData.map(d => ({
140 + ...d,
141 + desc_entidad: entidadClasificador[d.entidad]?.desc_entidad || d.desc_entidad || `Entidad ${d.entidad}`,
142 + desc_subarea: entidadClasificador[d.entidad]?.desc_subarea || 'Otros'
143 + }));
144 +
145 + // Extraer subareas únicas ordenadas por monto total
146 + const subareaMontos = {};
147 + entidadesData.forEach(d => {
148 + subareaMontos[d.desc_subarea] = (subareaMontos[d.desc_subarea] || 0) + d.monto;
149 + });
150 + subareas = Object.entries(subareaMontos).sort((a, b) => b[1] - a[1]).map(([k]) => k);
151 +
152 + buildTreemap();
153 + }
154 + } catch (err) {
155 + console.error('[Entidades] Error:', err);
156 + }
157 + loading = false;
158 + }
159 +
160 + function buildTreemap() {
161 + if (!filteredEntidades.length || treemapWidth <= 0 || treemapHeight <= 0) {
162 + treemapNodes = [];
163 + treemapGroups = [];
164 + return;
165 + }
166 +
167 + // Agrupar por subarea
168 + const groups = {};
169 + filteredEntidades.forEach(d => {
170 + const sub = d.desc_subarea || 'Otros';
171 + if (!groups[sub]) groups[sub] = [];
172 + groups[sub].push({
173 + id: d.entidad,
174 + name: d.desc_entidad || `Entidad ${d.entidad}`,
175 + value: d.monto,
176 + ranking: d.ranking,
177 + per_capita: d.per_capita,
178 + prop: d.prop,
179 + subarea: sub
180 + });
181 + });
182 +
183 + const hierarchy = d3.hierarchy({
184 + name: 'root',
185 + children: Object.entries(groups).map(([subarea, children]) => ({
186 + name: subarea,
187 + children
188 + }))
189 + }).sum(d => d.value || 0)
190 + .sort((a, b) => b.value - a.value);
191 +
192 + d3.treemap()
193 + .size([treemapWidth, treemapHeight])
194 + .paddingOuter(3)
195 + .paddingInner(1)
196 + .paddingTop(18)
197 + .round(true)(hierarchy);
198 +
199 + treemapNodes = hierarchy.leaves() || [];
200 + treemapGroups = hierarchy.children || [];
201 + }
202 +
203 + function changeYear(year) {
204 + selectedYear = year;
205 + goto(`?gestion=${year}`, { replaceState: true, noScroll: true });
206 + loadEntidades();
207 + }
208 +
209 + $effect(() => {
210 + if (treemapContainer) {
211 + const measure = () => {
212 + const w = treemapContainer.clientWidth;
213 + const h = treemapContainer.clientHeight;
214 + if (w > 0 && h > 0) {
215 + treemapWidth = w;
216 + treemapHeight = h;
217 + buildTreemap();
218 + }
219 + };
220 + measure();
221 + const observer = new ResizeObserver(measure);
222 + observer.observe(treemapContainer);
223 + return () => observer.disconnect();
224 + }
225 + });
226 +
227 +
228 + onMount(async () => {
229 + mounted = true;
230 + isDark = document.documentElement.classList.contains('dark');
231 + const observer = new MutationObserver(() => {
232 + isDark = document.documentElement.classList.contains('dark');
233 + });
234 + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
235 +
236 + await Promise.all([loadActeco(), loadClasificador()]);
237 +
238 + const yearParam = $page.url.searchParams.get('gestion');
239 + if (yearParam) {
240 + const y = parseInt(yearParam);
241 + selectedYear = availableYears.includes(y) ? y : availableYears[0] || 2025;
242 + } else {
243 + selectedYear = availableYears[0] || 2025;
244 + }
245 +
246 + if (treemapContainer) {
247 + treemapWidth = treemapContainer.clientWidth;
248 + treemapHeight = treemapContainer.clientHeight;
249 + }
250 + await loadEntidades();
251 + });
252 +</script>
253 +
254 +<svelte:head>
255 + <title>{actecoInfo?.desc_acteco || `Sector económico ${codigo}`} - Ranking de Entidades</title>
256 + <link rel="preconnect" href="https://fonts.googleapis.com" />
257 + <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" />
258 +</svelte:head>
259 +
260 +<div class="page" class:mounted>
261 + <div class="layout">
262 + <!-- Header -->
263 + <header class="header">
264 + <div class="header-top">
265 + <div class="pills">
266 + <span class="pill pill-nivel">{actecoInfo?.nivel || 'sector económico'}</span>
267 + <span class="pill pill-codigo">{codigo}</span>
268 + </div>
269 + <div class="header-actions">
270 + <button class="copy-link-btn" onclick={() => {
271 + navigator.clipboard.writeText(window.location.href);
272 + linkCopied = true;
273 + setTimeout(() => linkCopied = false, 2000);
274 + }}>
275 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
276 + {#if linkCopied}
277 + <path d="M20 6L9 17l-5-5"/>
278 + {:else}
279 + <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
280 + <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
281 + {/if}
282 + </svg>
283 + <span>{linkCopied ? 'Copiado' : 'Compartir'}</span>
284 + </button>
285 + </div>
286 + </div>
287 +
288 + <h1 class="title">{actecoInfo?.desc_acteco || 'Cargando...'}</h1>
289 + <p class="subtitle">¿Qué entidades gastan más en esto?</p>
290 +
291 + <!-- Año + Stats -->
292 + <div class="meta-row">
293 + <div class="year-selector">
294 + {#each availableYears as year}
295 + <button class="year-btn" class:active={selectedYear === year} onclick={() => changeYear(year)}>{year}</button>
296 + {/each}
297 + </div>
298 + {#if !loading && entidadesData.length > 0}
299 + <div class="stats">
300 + <span class="stat"><strong>{entidadesData.length}</strong> entidades en total</span>
301 + </div>
302 + {/if}
303 + </div>
304 +
305 + <!-- Slider de rango -->
306 + {#if !loading && entidadesData.length > 0}
307 + <div class="slider-row">
308 + <span class="slider-label">Rango: {sliderMin}% – {sliderMax}%</span>
309 + <span class="slider-info">{filteredCount} entidades · {((filteredTotal / totalDevengado) * 100).toFixed(1)}% del gasto</span>
310 + </div>
311 + <div class="range-slider">
312 + <input type="range" min="0" max="100" step="1" bind:value={sliderMin} oninput={() => { if (sliderMin >= sliderMax) sliderMin = sliderMax - 1; buildTreemap(); }} />
313 + <input type="range" min="0" max="100" step="1" bind:value={sliderMax} oninput={() => { if (sliderMax <= sliderMin) sliderMax = sliderMin + 1; buildTreemap(); }} />
314 + </div>
315 + {/if}
316 + </header>
317 +
318 + <!-- Treemap -->
319 + <div class="treemap-wrapper" bind:this={treemapContainer}>
320 + {#if loading}
321 + <div class="loading"><Spinner size={48} color="var(--theme-texto)" /></div>
322 + {:else if treemapNodes.length > 0}
323 + <svg width={treemapWidth} height={treemapHeight}>
324 + <!-- Group labels (subareas) -->
325 + {#each treemapGroups as group}
326 + {@const gw = group.x1 - group.x0}
327 + {@const color = getSubareaColor(group.data.name)}
328 + {@const label = gw > 150 ? group.data.name : group.data.name.slice(0, Math.floor(gw / 7))}
329 + {#if gw > 50}
330 + <rect
331 + x={group.x0 + 3}
332 + y={group.y0 + 2}
333 + width={Math.min(label.length * 6.5 + 10, gw - 6)}
334 + height="14"
335 + rx="3"
336 + fill={isDark ? 'rgba(20, 20, 18, 0.85)' : 'rgba(255, 255, 255, 0.9)'}
337 + />
338 + <text
339 + x={group.x0 + 8}
340 + y={group.y0 + 13}
341 + fill={isDark ? '#F5F0E8' : '#1a1a1a'}
342 + font-size="9"
343 + font-family="'Qanelas', var(--font-sans)"
344 + font-weight="600"
345 + >
346 + {label}
347 + </text>
348 + {/if}
349 + {/each}
350 +
351 + <!-- Entity nodes -->
352 + {#each treemapNodes as node, i}
353 + {@const w = node.x1 - node.x0}
354 + {@const h = node.y1 - node.y0}
355 + {@const pct = (node.value / totalDevengado) * 100}
356 + {@const isHovered = hoveredNode === node}
357 + {@const area = Math.sqrt(w * h)}
358 + {@const pctSize = Math.min(20, Math.max(9, area / 8))}
359 + {@const nameSize = Math.min(12, Math.max(7, area / 12))}
360 + {@const color = getSubareaColor(node.data.subarea)}
361 +
362 + <g
363 + transform="translate({node.x0}, {node.y0})"
364 + onmouseenter={() => hoveredNode = node}
365 + onmouseleave={() => hoveredNode = null}
366 + style="cursor: pointer;"
367 + onclick={() => goto(`/acteco/${codigo}?entidad=${node.data.id}`)}
368 + >
369 + <rect
370 + width={w} height={h}
371 + fill={color}
372 + opacity={hoveredNode ? (isHovered ? 1 : hoveredNode.data.subarea === node.data.subarea ? 0.6 : 0.2) : 0.85}
373 + stroke={isHovered ? 'var(--theme-titulo)' : 'rgba(0,0,0,0.15)'}
374 + stroke-width={isHovered ? 2 : 0.5}
375 + rx="2"
376 + />
377 + {#if w > 35 && h > 22}
378 + {@const textColor = getTextColorForBg(color)}
379 + {@const textColorSec = getTextColorSecondary(color)}
380 + <foreignObject x="4" y="3" width={w - 8} height={h - 6}>
381 + <div class="node-text">
382 + <span class="node-pct" style="font-size:{pctSize}px; color:{textColor}">{pct >= 1 ? pct.toFixed(0) : pct.toFixed(1)}%</span>
383 + {#if h > 40 && w > 55}
384 + <span class="node-name" style="font-size:{nameSize}px; color:{textColorSec}">{w > 100 ? node.data.name : node.data.name.slice(0, Math.floor(w / 7))}</span>
385 + {/if}
386 + </div>
387 + </foreignObject>
388 + {/if}
389 + </g>
390 + {/each}
391 + </svg>
392 +
393 + {#if hoveredNode}
394 + {@const pct = (hoveredNode.value / totalDevengado) * 100}
395 + <div class="tooltip" style="left:{Math.min(hoveredNode.x0 + 10, treemapWidth - 250)}px; top:{Math.min(hoveredNode.y0 + 10, treemapHeight - 90)}px;">
396 + <span class="tooltip-rank">#{treemapNodes.indexOf(hoveredNode) + 1} de {treemapNodes.length}</span>
397 + <span class="tooltip-subarea" style="color: {getSubareaColor(hoveredNode.data.subarea)}">{hoveredNode.data.subarea}</span>
398 + <span class="tooltip-name">{hoveredNode.data.name}</span>
399 + <span class="tooltip-val">{formatMonto(hoveredNode.value)} Bs · {pct.toFixed(1)}%</span>
400 + </div>
401 + {/if}
402 + {:else}
403 + <div class="loading">No hay datos para {selectedYear}</div>
404 + {/if}
405 + </div>
406 +
407 + </div>
408 +</div>
409 +
410 +<style>
411 + .page {
412 + min-height: 100vh;
413 + width: 100%;
414 + max-width: 100%;
415 + overflow-x: hidden;
416 + background: var(--theme-body);
417 + color: var(--theme-titulo);
418 + font-family: var(--font-sans), -apple-system, sans-serif;
419 + opacity: 0;
420 + transition: opacity 0.4s;
421 + }
422 + .page.mounted { opacity: 1; }
423 +
424 + :global(html:not(.dark)) .page { background: #f5f5f7; }
425 +
426 + .layout {
427 + max-width: 1400px;
428 + margin: 0 auto;
429 + padding: 80px 2rem 2rem;
430 + height: 100vh;
431 + display: flex;
432 + flex-direction: column;
433 + overflow: hidden;
434 + }
435 +
436 + /* Header */
437 + .header { flex-shrink: 0; margin-bottom: 1rem; }
438 +
439 + .header-top {
440 + display: flex;
441 + justify-content: space-between;
442 + align-items: center;
443 + margin-bottom: 0.375rem;
444 + }
445 +
446 + .pills { display: flex; gap: 0.375rem; }
447 +
448 + .pill {
449 + font-family: 'DM Mono', monospace;
450 + font-size: 0.625rem;
451 + padding: 0.125rem 0.5rem;
452 + border-radius: 4px;
453 + letter-spacing: 0.03em;
454 + }
455 + .pill-nivel { background: rgba(201, 167, 81, 0.15); color: var(--theme-accent); }
456 + .pill-codigo { background: var(--theme-borde); color: var(--theme-texto); }
457 +
458 + .header-actions { display: flex; align-items: center; gap: 0.75rem; }
459 +
460 + .copy-link-btn {
461 + display: flex;
462 + align-items: center;
463 + gap: 0.375rem;
464 + font-size: 0.75rem;
465 + color: var(--theme-texto);
466 + opacity: 0.6;
467 + background: none;
468 + border: none;
469 + padding: 0;
470 + cursor: pointer;
471 + transition: opacity 0.15s;
472 + }
473 + .copy-link-btn:hover { opacity: 1; }
474 +
475 + .back-link {
476 + display: inline-flex;
477 + align-items: center;
478 + gap: 0.25rem;
479 + font-family: 'DM Mono', monospace;
480 + font-size: 0.6875rem;
481 + color: var(--theme-accent);
482 + text-decoration: none;
483 + transition: opacity 0.15s;
484 + }
485 + .back-link:hover { opacity: 0.7; }
486 +
487 + .title {
488 + font-family: 'DM Serif Display', serif;
489 + font-size: 2rem;
490 + font-weight: 400;
491 + margin: 0 0 0.25rem;
492 + line-height: 1.15;
493 + }
494 +
495 + .subtitle {
496 + font-size: 0.8125rem;
497 + color: var(--theme-texto);
498 + opacity: 0.6;
499 + margin: 0 0 0.75rem;
500 + }
501 +
502 + .meta-row {
503 + display: flex;
504 + align-items: center;
505 + justify-content: space-between;
506 + gap: 1rem;
507 + }
508 +
509 + .year-selector { display: flex; gap: 0.25rem; overflow-x: auto; scrollbar-width: none; }
510 + .year-selector::-webkit-scrollbar { display: none; }
511 +
512 + .year-btn {
513 + font-family: 'DM Mono', monospace;
514 + font-size: 0.6875rem;
515 + padding: 0.25rem 0.625rem;
516 + border: 1px solid var(--theme-borde);
517 + border-radius: 6px;
518 + background: transparent;
519 + color: var(--theme-texto);
520 + cursor: pointer;
521 + transition: all 0.15s;
522 + }
523 + .year-btn:hover { border-color: var(--theme-accent); color: var(--theme-titulo); }
524 + .year-btn.active {
525 + background: var(--theme-accent);
526 + border-color: var(--theme-accent);
527 + color: white;
528 + font-weight: 600;
529 + }
530 +
531 + .stats {
532 + display: flex;
533 + align-items: center;
534 + gap: 0.375rem;
535 + font-size: 0.75rem;
536 + color: var(--theme-texto);
537 + opacity: 0.7;
538 + }
539 + .stats strong { color: var(--theme-titulo); font-weight: 600; }
540 + .stat-sep { opacity: 0.3; }
541 +
542 + /* Slider */
543 + .slider-row {
544 + display: flex;
545 + justify-content: space-between;
546 + align-items: center;
547 + margin-top: 0.5rem;
548 + }
549 +
550 + .slider-label {
551 + font-family: 'DM Mono', monospace;
552 + font-size: 0.6875rem;
553 + color: var(--theme-titulo);
554 + font-weight: 600;
555 + }
556 +
557 + .slider-info {
558 + font-size: 0.6875rem;
559 + color: var(--theme-texto);
560 + opacity: 0.6;
561 + }
562 +
563 + .range-slider {
564 + position: relative;
565 + height: 20px;
566 + margin-bottom: 0.5rem;
567 + }
568 +
569 + .range-slider input[type="range"] {
570 + position: absolute;
571 + width: 100%;
572 + height: 4px;
573 + top: 8px;
574 + pointer-events: none;
575 + -webkit-appearance: none;
576 + appearance: none;
577 + background: transparent;
578 + }
579 +
580 + .range-slider input[type="range"]:first-child {
581 + background: var(--theme-borde);
582 + border-radius: 2px;
583 + }
584 +
585 + .range-slider input[type="range"]::-webkit-slider-thumb {
586 + -webkit-appearance: none;
587 + appearance: none;
588 + width: 14px;
589 + height: 14px;
590 + border-radius: 50%;
591 + background: var(--theme-accent);
592 + border: 2px solid var(--theme-surface);
593 + cursor: pointer;
594 + pointer-events: all;
595 + box-shadow: 0 1px 3px rgba(0,0,0,0.2);
596 + }
597 +
598 + .range-slider input[type="range"]::-moz-range-thumb {
599 + width: 14px;
600 + height: 14px;
601 + border-radius: 50%;
602 + background: var(--theme-accent);
603 + border: 2px solid var(--theme-surface);
604 + cursor: pointer;
605 + pointer-events: all;
606 + box-shadow: 0 1px 3px rgba(0,0,0,0.2);
607 + }
608 +
609 + /* Treemap */
610 + .treemap-wrapper {
611 + flex: 1;
612 + min-height: 200px;
613 + position: relative;
614 + background: var(--theme-surface);
615 + border-radius: 12px;
616 + overflow: hidden;
617 + }
618 +
619 + .treemap-wrapper svg { display: block; width: 100%; height: 100%; }
620 +
621 + .loading {
622 + position: absolute;
623 + inset: 0;
624 + display: flex;
625 + align-items: center;
626 + justify-content: center;
627 + color: var(--theme-texto);
628 + opacity: 0.5;
629 + font-size: 0.875rem;
630 + }
631 +
632 + .node-text {
633 + display: flex;
634 + flex-direction: column;
635 + overflow: hidden;
636 + }
637 +
638 + .node-pct { font-weight: 700; line-height: 1; }
639 + .node-name { line-height: 1.2; margin-top: 1px; }
640 +
641 + /* Tooltip */
642 + .tooltip {
643 + position: absolute;
644 + pointer-events: none;
645 + padding: 0.5rem 0.75rem;
646 + border-radius: 8px;
647 + z-index: 10;
648 + max-width: 250px;
649 + background: rgba(30, 30, 30, 0.9);
650 + backdrop-filter: blur(12px);
651 + border: 1px solid rgba(255, 255, 255, 0.1);
652 + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
653 + color: #f5f5f5;
654 + display: flex;
655 + flex-direction: column;
656 + gap: 2px;
657 + }
658 +
659 + :global(html:not(.dark)) .tooltip {
660 + background: rgba(255, 255, 255, 0.9);
661 + border-color: rgba(0, 0, 0, 0.1);
662 + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
663 + color: #1a1a1a;
664 + }
665 +
666 + .tooltip-rank { font-size: 0.6rem; opacity: 0.5; text-transform: uppercase; letter-spacing: 0.05em; }
667 + .tooltip-subarea { font-size: 0.6875rem; font-weight: 600; }
668 + .tooltip-name { font-size: 0.8125rem; font-weight: 600; }
669 + .tooltip-val { font-family: 'DM Mono', monospace; font-size: 0.75rem; opacity: 0.7; }
670 +
671 + /* Leyenda */
672 + .legend {
673 + display: flex;
674 + flex-wrap: wrap;
675 + gap: 0.5rem 1rem;
676 + padding-top: 0.5rem;
677 + flex-shrink: 0;
678 + }
679 +
680 + .legend-item {
681 + display: flex;
682 + align-items: center;
683 + gap: 0.25rem;
684 + font-size: 0.625rem;
685 + color: var(--theme-texto);
686 + opacity: 0.7;
687 + }
688 +
689 + .legend-dot {
690 + width: 8px;
691 + height: 8px;
692 + border-radius: 2px;
693 + flex-shrink: 0;
694 + }
695 +
696 + /* Responsive */
697 + @media (max-width: 900px) {
698 + .layout {
699 + padding: 70px 1rem 1rem;
700 + height: auto;
701 + min-height: 100vh;
702 + overflow: visible;
703 + }
704 +
705 + .treemap-wrapper {
706 + min-height: 350px;
707 + height: 50vh;
708 + flex: none;
709 + }
710 + }
711 +
712 + @media (max-width: 640px) {
713 + .layout { padding: 60px 0.75rem 1rem; }
714 + .title { font-size: 1.375rem; }
715 + .subtitle { font-size: 0.75rem; }
716 + .meta-row { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
717 + .header-top { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
718 +
719 + .year-selector {
720 + flex-wrap: wrap;
721 + gap: 0.25rem;
722 + }
723 +
724 + .year-btn {
725 + font-size: 0.625rem;
726 + padding: 0.2rem 0.5rem;
727 + }
728 +
729 + .slider-row {
730 + flex-direction: column;
731 + align-items: flex-start;
732 + gap: 0.125rem;
733 + }
734 +
735 + .stats { font-size: 0.6875rem; }
736 +
737 + .treemap-wrapper {
738 + min-height: 300px;
739 + height: 45vh;
740 + }
741 +
742 + .tooltip {
743 + max-width: 200px;
744 + font-size: 0.75rem;
745 + }
746 +
747 + .header-actions {
748 + width: 100%;
749 + justify-content: space-between;
750 + }
751 + }
752 +
753 + @media (max-width: 380px) {
754 + .layout { padding: 55px 0.5rem 0.75rem; }
755 + .title { font-size: 1.125rem; }
756 +
757 + .treemap-wrapper {
758 + min-height: 250px;
759 + height: 40vh;
760 + }
761 +
762 + .year-btn {
763 + font-size: 0.5625rem;
764 + padding: 0.15rem 0.375rem;
765 + }
766 + }
767 +</style>
1 +const API_BASE = 'http://136.112.29.74/api/acteco/clasificador';
2 +
3 +export async function GET() {
4 + try {
5 + const response = await fetch(API_BASE);
6 + if (!response.ok) {
7 + return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
8 + status: response.status, headers: { 'Content-Type': 'application/json' }
9 + });
10 + }
11 + const data = await response.json();
12 + return new Response(JSON.stringify(data), {
13 + headers: { 'Content-Type': 'application/json' }
14 + });
15 + } catch {
16 + return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
17 + status: 500, headers: { 'Content-Type': 'application/json' }
18 + });
19 + }
20 +}
1 +const API_BASE = 'http://136.112.29.74/api/acteco';
2 +
3 +export async function GET({ url }) {
4 + const codigo = url.searchParams.get('codigo') || '';
5 + const tipo = url.searchParams.get('tipo') || 'clasificador';
6 + const entidad = url.searchParams.get('entidad') || '';
7 + const gestion = url.searchParams.get('gestion') || '';
8 +
9 + if (!codigo) {
10 + return new Response(JSON.stringify({ error: 'Missing codigo' }), {
11 + status: 400, headers: { 'Content-Type': 'application/json' }
12 + });
13 + }
14 +
15 + let apiUrl;
16 + if (tipo === 'clasificador') {
17 + apiUrl = `${API_BASE}/${codigo}`;
18 + } else if (tipo === 'entidades-lista') {
19 + apiUrl = `${API_BASE}/${codigo}/entidades`;
20 + } else if (tipo === 'entidad' && entidad) {
21 + apiUrl = `${API_BASE}/${codigo}/entidades/${entidad}`;
22 + } else if (tipo === 'entidades-año' && gestion) {
23 + apiUrl = `${API_BASE}/${codigo}/entidades?gestion=${gestion}`;
24 + } else {
25 + return new Response(JSON.stringify({ error: 'Invalid tipo' }), {
26 + status: 400, headers: { 'Content-Type': 'application/json' }
27 + });
28 + }
29 +
30 + try {
31 + const response = await fetch(apiUrl);
32 + if (!response.ok) {
33 + return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
34 + status: response.status, headers: { 'Content-Type': 'application/json' }
35 + });
36 + }
37 + const data = await response.json();
38 + return new Response(JSON.stringify(data), {
39 + headers: { 'Content-Type': 'application/json' }
40 + });
41 + } catch (err) {
42 + return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
43 + status: 500, headers: { 'Content-Type': 'application/json' }
44 + });
45 + }
46 +}
1 +const API_BASE = 'http://136.112.29.74/api/acteco';
2 +
3 +let cachedClasificador = null;
4 +let cachedEstados = null;
5 +let cachedTimestamp = 0;
6 +const CACHE_TTL = 5 * 60 * 1000;
7 +
8 +function getParent(acteco) {
9 + const idx = acteco.lastIndexOf('.');
10 + if (idx === -1) return null;
11 + return acteco.substring(0, idx);
12 +}
13 +
14 +async function loadAndCacheAll() {
15 + const now = Date.now();
16 + if (cachedEstados && (now - cachedTimestamp) < CACHE_TTL) return;
17 +
18 + const clasRes = await fetch(`${API_BASE}/clasificador`);
19 + if (!clasRes.ok) throw new Error('Failed to fetch clasificador');
20 + cachedClasificador = await clasRes.json();
21 +
22 + const estados = new Map();
23 + const batchSize = 80;
24 +
25 + for (let i = 0; i < cachedClasificador.length; i += batchSize) {
26 + const batch = cachedClasificador.slice(i, i + batchSize);
27 + const results = await Promise.all(batch.map(async (item) => {
28 + try {
29 + const res = await fetch(`${API_BASE}/${item.acteco}`);
30 + if (!res.ok) return null;
31 + const data = await res.json();
32 + return { acteco: item.acteco, estados: data.estados || [] };
33 + } catch { return null; }
34 + }));
35 + results.filter(Boolean).forEach(r => estados.set(r.acteco, r.estados));
36 + }
37 +
38 + cachedEstados = estados;
39 + cachedTimestamp = now;
40 +}
41 +
42 +export async function GET({ url }) {
43 + const gestion = parseInt(url.searchParams.get('gestion') || '2025');
44 + const entidad = url.searchParams.get('entidad') || '0';
45 +
46 + try {
47 + if (entidad === '0') {
48 + await loadAndCacheAll();
49 +
50 + const results = [];
51 + for (const item of cachedClasificador) {
52 + const estados = cachedEstados.get(item.acteco);
53 + if (!estados) continue;
54 + const yearData = estados.find(d => d.gestion === gestion);
55 + if (!yearData || !yearData.total) continue;
56 + results.push({
57 + gestion,
58 + nivel: item.nivel || 'actividad',
59 + acteco: item.acteco,
60 + desc_acteco: item.desc_acteco,
61 + parent: getParent(item.acteco),
62 + devengado: yearData.total
63 + });
64 + }
65 +
66 + // Agregar nodos padres faltantes (sectores sin estados propios)
67 + const ids = new Set(results.map(r => r.acteco));
68 + const missingParents = new Set();
69 + results.forEach(r => {
70 + if (r.parent && !ids.has(r.parent)) missingParents.add(r.parent);
71 + });
72 + for (const parentCode of missingParents) {
73 + const item = cachedClasificador.find(c => c.acteco === parentCode);
74 + results.push({
75 + gestion,
76 + nivel: item?.nivel || 'actividad',
77 + acteco: parentCode,
78 + desc_acteco: item?.desc_acteco || parentCode,
79 + parent: getParent(parentCode),
80 + devengado: 0
81 + });
82 + }
83 +
84 + return new Response(JSON.stringify(results), {
85 + headers: { 'Content-Type': 'application/json' }
86 + });
87 + } else {
88 + if (!cachedClasificador) {
89 + const clasRes = await fetch(`${API_BASE}/clasificador`);
90 + cachedClasificador = await clasRes.json();
91 + }
92 +
93 + const results = [];
94 + const batchSize = 80;
95 +
96 + for (let i = 0; i < cachedClasificador.length; i += batchSize) {
97 + const batch = cachedClasificador.slice(i, i + batchSize);
98 + const batchResults = await Promise.all(batch.map(async (item) => {
99 + try {
100 + const res = await fetch(`${API_BASE}/${item.acteco}/entidades/${entidad}`);
101 + if (!res.ok) return null;
102 + const data = await res.json();
103 + const yearData = data.find(d => d.gestion === gestion);
104 + if (!yearData || !yearData.monto) return null;
105 + return {
106 + gestion,
107 + nivel: item.nivel || 'actividad',
108 + acteco: item.acteco,
109 + desc_acteco: item.desc_acteco,
110 + parent: getParent(item.acteco),
111 + devengado: yearData.monto
112 + };
113 + } catch { return null; }
114 + }));
115 + results.push(...batchResults.filter(Boolean));
116 + }
117 +
118 + // Agregar nodos padres faltantes
119 + const ids2 = new Set(results.map(r => r.acteco));
120 + const missing2 = new Set();
121 + results.forEach(r => {
122 + if (r.parent && !ids2.has(r.parent)) missing2.add(r.parent);
123 + });
124 + for (const parentCode of missing2) {
125 + const item = cachedClasificador.find(c => c.acteco === parentCode);
126 + results.push({
127 + gestion,
128 + nivel: item?.nivel || 'actividad',
129 + acteco: parentCode,
130 + desc_acteco: item?.desc_acteco || parentCode,
131 + parent: getParent(parentCode),
132 + devengado: 0
133 + });
134 + }
135 +
136 + return new Response(JSON.stringify(results), {
137 + headers: { 'Content-Type': 'application/json' }
138 + });
139 + }
140 + } catch (err) {
141 + return new Response(JSON.stringify({ error: 'Failed to build treemap' }), {
142 + status: 500, headers: { 'Content-Type': 'application/json' }
143 + });
144 + }
145 +}
...@@ -6,8 +6,7 @@ export async function GET({ url }) { ...@@ -6,8 +6,7 @@ export async function GET({ url }) {
6 6
7 if (!codigo) { 7 if (!codigo) {
8 return new Response(JSON.stringify({ error: 'Missing codigo' }), { 8 return new Response(JSON.stringify({ error: 'Missing codigo' }), {
9 - status: 400, 9 + status: 400, headers: { 'Content-Type': 'application/json' }
10 - headers: { 'Content-Type': 'application/json' }
11 }); 10 });
12 } 11 }
13 12
...@@ -18,8 +17,7 @@ export async function GET({ url }) { ...@@ -18,8 +17,7 @@ export async function GET({ url }) {
18 const response = await fetch(apiUrl.toString()); 17 const response = await fetch(apiUrl.toString());
19 if (!response.ok) { 18 if (!response.ok) {
20 return new Response(JSON.stringify({ error: `API error: ${response.status}` }), { 19 return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
21 - status: response.status, 20 + status: response.status, headers: { 'Content-Type': 'application/json' }
22 - headers: { 'Content-Type': 'application/json' }
23 }); 21 });
24 } 22 }
25 const data = await response.json(); 23 const data = await response.json();
...@@ -28,8 +26,7 @@ export async function GET({ url }) { ...@@ -28,8 +26,7 @@ export async function GET({ url }) {
28 }); 26 });
29 } catch (err) { 27 } catch (err) {
30 return new Response(JSON.stringify({ error: 'Failed to fetch' }), { 28 return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
31 - status: 500, 29 + status: 500, headers: { 'Content-Type': 'application/json' }
32 - headers: { 'Content-Type': 'application/json' }
33 }); 30 });
34 } 31 }
35 } 32 }
......
1 +const API_BASE = 'http://136.112.29.74/api/entidad';
2 +
3 +export async function GET({ url }) {
4 + const codigo = url.searchParams.get('codigo') || '';
5 + const tipo = url.searchParams.get('tipo') || 'detalle';
6 +
7 + if (!codigo) {
8 + return new Response(JSON.stringify({ error: 'Missing codigo' }), {
9 + status: 400, headers: { 'Content-Type': 'application/json' }
10 + });
11 + }
12 +
13 + // For DAs (codigo contains dot like "1201.1"), extract the entidad number
14 + const entidadNum = codigo.includes('.') ? codigo.split('.')[0] : codigo;
15 +
16 + let apiUrl;
17 + if (tipo === 'detalle') {
18 + apiUrl = `${API_BASE}/${entidadNum}`;
19 + } else if (tipo === 'objetos') {
20 + apiUrl = `${API_BASE}/${entidadNum}/objetos`;
21 + } else if (tipo === 'finfuns') {
22 + apiUrl = `${API_BASE}/${entidadNum}/finfuns`;
23 + } else if (tipo === 'actecos') {
24 + apiUrl = `${API_BASE}/${entidadNum}/actecos`;
25 + } else if (tipo === 'rubros') {
26 + apiUrl = `${API_BASE}/${entidadNum}/rubros`;
27 + } else if (tipo === 'organismos') {
28 + apiUrl = `${API_BASE}/${entidadNum}/organismos`;
29 + } else {
30 + return new Response(JSON.stringify({ error: 'Invalid tipo' }), {
31 + status: 400, headers: { 'Content-Type': 'application/json' }
32 + });
33 + }
34 +
35 + try {
36 + const response = await fetch(apiUrl);
37 + if (!response.ok) {
38 + return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
39 + status: response.status, headers: { 'Content-Type': 'application/json' }
40 + });
41 + }
42 + const data = await response.json();
43 + return new Response(JSON.stringify(data), {
44 + headers: { 'Content-Type': 'application/json' }
45 + });
46 + } catch (err) {
47 + return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
48 + status: 500, headers: { 'Content-Type': 'application/json' }
49 + });
50 + }
51 +}
1 +const API_BASE = 'http://136.112.29.74/api/finfun/clasificador';
2 +
3 +export async function GET() {
4 + try {
5 + const response = await fetch(API_BASE);
6 + if (!response.ok) {
7 + return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
8 + status: response.status, headers: { 'Content-Type': 'application/json' }
9 + });
10 + }
11 + const data = await response.json();
12 + return new Response(JSON.stringify(data), {
13 + headers: { 'Content-Type': 'application/json' }
14 + });
15 + } catch {
16 + return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
17 + status: 500, headers: { 'Content-Type': 'application/json' }
18 + });
19 + }
20 +}
1 +const API_BASE = 'http://136.112.29.74/api/finfun';
2 +
3 +export async function GET({ url }) {
4 + const codigo = url.searchParams.get('codigo') || '';
5 + const tipo = url.searchParams.get('tipo') || 'clasificador';
6 + const entidad = url.searchParams.get('entidad') || '';
7 + const gestion = url.searchParams.get('gestion') || '';
8 +
9 + if (!codigo) {
10 + return new Response(JSON.stringify({ error: 'Missing codigo' }), {
11 + status: 400, headers: { 'Content-Type': 'application/json' }
12 + });
13 + }
14 +
15 + let apiUrl;
16 + if (tipo === 'clasificador') {
17 + apiUrl = `${API_BASE}/${codigo}`;
18 + } else if (tipo === 'entidades-lista') {
19 + apiUrl = `${API_BASE}/${codigo}/entidades`;
20 + } else if (tipo === 'entidad' && entidad) {
21 + apiUrl = `${API_BASE}/${codigo}/entidades/${entidad}`;
22 + } else if (tipo === 'entidades-año' && gestion) {
23 + apiUrl = `${API_BASE}/${codigo}/entidades?gestion=${gestion}`;
24 + } else {
25 + return new Response(JSON.stringify({ error: 'Invalid tipo' }), {
26 + status: 400, headers: { 'Content-Type': 'application/json' }
27 + });
28 + }
29 +
30 + try {
31 + const response = await fetch(apiUrl);
32 + if (!response.ok) {
33 + return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
34 + status: response.status, headers: { 'Content-Type': 'application/json' }
35 + });
36 + }
37 + const data = await response.json();
38 + return new Response(JSON.stringify(data), {
39 + headers: { 'Content-Type': 'application/json' }
40 + });
41 + } catch (err) {
42 + return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
43 + status: 500, headers: { 'Content-Type': 'application/json' }
44 + });
45 + }
46 +}
1 +const API_BASE = 'http://136.112.29.74/api/finfun';
2 +
3 +let cachedClasificador = null;
4 +let cachedEstados = null;
5 +let cachedTimestamp = 0;
6 +const CACHE_TTL = 5 * 60 * 1000;
7 +
8 +function getParent(finfun) {
9 + const idx = finfun.lastIndexOf('.');
10 + if (idx === -1) return null;
11 + return finfun.substring(0, idx);
12 +}
13 +
14 +async function loadAndCacheAll() {
15 + const now = Date.now();
16 + if (cachedEstados && (now - cachedTimestamp) < CACHE_TTL) return;
17 +
18 + const clasRes = await fetch(`${API_BASE}/clasificador`);
19 + if (!clasRes.ok) throw new Error('Failed to fetch clasificador');
20 + cachedClasificador = await clasRes.json();
21 +
22 + const estados = new Map();
23 + const batchSize = 80;
24 +
25 + for (let i = 0; i < cachedClasificador.length; i += batchSize) {
26 + const batch = cachedClasificador.slice(i, i + batchSize);
27 + const results = await Promise.all(batch.map(async (item) => {
28 + try {
29 + const res = await fetch(`${API_BASE}/${item.finfun}`);
30 + if (!res.ok) return null;
31 + const data = await res.json();
32 + return { finfun: item.finfun, estados: data.estados || [] };
33 + } catch { return null; }
34 + }));
35 + results.filter(Boolean).forEach(r => estados.set(r.finfun, r.estados));
36 + }
37 +
38 + cachedEstados = estados;
39 + cachedTimestamp = now;
40 +}
41 +
42 +export async function GET({ url }) {
43 + const gestion = parseInt(url.searchParams.get('gestion') || '2025');
44 + const entidad = url.searchParams.get('entidad') || '0';
45 +
46 + try {
47 + if (entidad === '0') {
48 + await loadAndCacheAll();
49 +
50 + const results = [];
51 + for (const item of cachedClasificador) {
52 + const estados = cachedEstados.get(item.finfun);
53 + if (!estados) continue;
54 + const yearData = estados.find(d => d.gestion === gestion);
55 + if (!yearData || !yearData.total) continue;
56 + results.push({
57 + gestion,
58 + nivel: item.nivel,
59 + finfun: item.finfun,
60 + desc_finfun: item.desc_finfun,
61 + parent: getParent(item.finfun),
62 + devengado: yearData.total
63 + });
64 + }
65 +
66 + return new Response(JSON.stringify(results), {
67 + headers: { 'Content-Type': 'application/json' }
68 + });
69 + } else {
70 + if (!cachedClasificador) {
71 + const clasRes = await fetch(`${API_BASE}/clasificador`);
72 + cachedClasificador = await clasRes.json();
73 + }
74 +
75 + const results = [];
76 + const batchSize = 80;
77 +
78 + for (let i = 0; i < cachedClasificador.length; i += batchSize) {
79 + const batch = cachedClasificador.slice(i, i + batchSize);
80 + const batchResults = await Promise.all(batch.map(async (item) => {
81 + try {
82 + const res = await fetch(`${API_BASE}/${item.finfun}/entidades/${entidad}`);
83 + if (!res.ok) return null;
84 + const data = await res.json();
85 + const yearData = data.find(d => d.gestion === gestion);
86 + if (!yearData || !yearData.monto) return null;
87 + return {
88 + gestion,
89 + nivel: item.nivel,
90 + finfun: item.finfun,
91 + desc_finfun: item.desc_finfun,
92 + parent: getParent(item.finfun),
93 + devengado: yearData.monto
94 + };
95 + } catch { return null; }
96 + }));
97 + results.push(...batchResults.filter(Boolean));
98 + }
99 +
100 + return new Response(JSON.stringify(results), {
101 + headers: { 'Content-Type': 'application/json' }
102 + });
103 + }
104 + } catch (err) {
105 + return new Response(JSON.stringify({ error: 'Failed to build treemap' }), {
106 + status: 500, headers: { 'Content-Type': 'application/json' }
107 + });
108 + }
109 +}
...@@ -6,8 +6,7 @@ export async function GET({ url }) { ...@@ -6,8 +6,7 @@ export async function GET({ url }) {
6 6
7 if (!codigo) { 7 if (!codigo) {
8 return new Response(JSON.stringify({ error: 'Missing codigo' }), { 8 return new Response(JSON.stringify({ error: 'Missing codigo' }), {
9 - status: 400, 9 + status: 400, headers: { 'Content-Type': 'application/json' }
10 - headers: { 'Content-Type': 'application/json' }
11 }); 10 });
12 } 11 }
13 12
...@@ -18,8 +17,7 @@ export async function GET({ url }) { ...@@ -18,8 +17,7 @@ export async function GET({ url }) {
18 const response = await fetch(apiUrl.toString()); 17 const response = await fetch(apiUrl.toString());
19 if (!response.ok) { 18 if (!response.ok) {
20 return new Response(JSON.stringify({ error: `API error: ${response.status}` }), { 19 return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
21 - status: response.status, 20 + status: response.status, headers: { 'Content-Type': 'application/json' }
22 - headers: { 'Content-Type': 'application/json' }
23 }); 21 });
24 } 22 }
25 const data = await response.json(); 23 const data = await response.json();
...@@ -28,8 +26,7 @@ export async function GET({ url }) { ...@@ -28,8 +26,7 @@ export async function GET({ url }) {
28 }); 26 });
29 } catch (err) { 27 } catch (err) {
30 return new Response(JSON.stringify({ error: 'Failed to fetch' }), { 28 return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
31 - status: 500, 29 + status: 500, headers: { 'Content-Type': 'application/json' }
32 - headers: { 'Content-Type': 'application/json' }
33 }); 30 });
34 } 31 }
35 } 32 }
......
...@@ -6,10 +6,13 @@ export async function GET() { ...@@ -6,10 +6,13 @@ export async function GET() {
6 if (!res.ok) throw new Error(); 6 if (!res.ok) throw new Error();
7 const data = await res.json(); 7 const data = await res.json();
8 // Mapear al formato que espera el treemap 8 // Mapear al formato que espera el treemap
9 - const mapped = data.map(d => ({ 9 + const mapped = data
10 + .filter(d => d.entidad !== 0)
11 + .map(d => ({
10 entidad: d.entidad, 12 entidad: d.entidad,
11 desc_entidad: d.desc_entidad, 13 desc_entidad: d.desc_entidad,
12 - sigla_entidad: d.sigla_entidad || '' 14 + sigla_entidad: d.sigla_entidad || '',
15 + gestiones: d.gestiones || ''
13 })); 16 }));
14 return new Response(JSON.stringify(mapped), { 17 return new Response(JSON.stringify(mapped), {
15 headers: { 'Content-Type': 'application/json' } 18 headers: { 'Content-Type': 'application/json' }
......
1 +const API_BASE = 'http://136.112.29.74/api/organismo/clasificador';
2 +
3 +export async function GET() {
4 + try {
5 + const response = await fetch(API_BASE);
6 + if (!response.ok) {
7 + return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
8 + status: response.status, headers: { 'Content-Type': 'application/json' }
9 + });
10 + }
11 + const data = await response.json();
12 + return new Response(JSON.stringify(data), {
13 + headers: { 'Content-Type': 'application/json' }
14 + });
15 + } catch {
16 + return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
17 + status: 500, headers: { 'Content-Type': 'application/json' }
18 + });
19 + }
20 +}
1 +const API_BASE = 'http://136.112.29.74/api/organismo';
2 +
3 +export async function GET({ url }) {
4 + const codigo = url.searchParams.get('codigo') || '';
5 + const tipo = url.searchParams.get('tipo') || 'clasificador';
6 + const entidad = url.searchParams.get('entidad') || '';
7 + const gestion = url.searchParams.get('gestion') || '';
8 +
9 + if (!codigo) {
10 + return new Response(JSON.stringify({ error: 'Missing codigo' }), {
11 + status: 400, headers: { 'Content-Type': 'application/json' }
12 + });
13 + }
14 +
15 + let apiUrl;
16 + if (tipo === 'clasificador') {
17 + apiUrl = `${API_BASE}/${codigo}`;
18 + } else if (tipo === 'entidades-lista') {
19 + apiUrl = `${API_BASE}/${codigo}/entidades`;
20 + } else if (tipo === 'entidad' && entidad) {
21 + apiUrl = `${API_BASE}/${codigo}/entidades/${entidad}`;
22 + } else if (tipo === 'entidades-año' && gestion) {
23 + apiUrl = `${API_BASE}/${codigo}/entidades?gestion=${gestion}`;
24 + } else {
25 + return new Response(JSON.stringify({ error: 'Invalid tipo' }), {
26 + status: 400, headers: { 'Content-Type': 'application/json' }
27 + });
28 + }
29 +
30 + try {
31 + const response = await fetch(apiUrl);
32 + if (!response.ok) {
33 + return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
34 + status: response.status, headers: { 'Content-Type': 'application/json' }
35 + });
36 + }
37 + const data = await response.json();
38 + return new Response(JSON.stringify(data), {
39 + headers: { 'Content-Type': 'application/json' }
40 + });
41 + } catch (err) {
42 + return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
43 + status: 500, headers: { 'Content-Type': 'application/json' }
44 + });
45 + }
46 +}
1 +const API_BASE = 'http://136.112.29.74/api/organismo';
2 +
3 +let cachedClasificador = null;
4 +let cachedEstados = null;
5 +let cachedTimestamp = 0;
6 +const CACHE_TTL = 5 * 60 * 1000;
7 +
8 +// Organismo hierarchy: grupo → subgrupo → organismo
9 +// Parent is derived from clasificador fields, not from code pattern
10 +
11 +async function loadAndCacheAll() {
12 + const now = Date.now();
13 + if (cachedEstados && (now - cachedTimestamp) < CACHE_TTL) return;
14 +
15 + const clasRes = await fetch(`${API_BASE}/clasificador`);
16 + if (!clasRes.ok) throw new Error('Failed to fetch clasificador');
17 + cachedClasificador = await clasRes.json();
18 +
19 + const estados = new Map();
20 + const batchSize = 80;
21 +
22 + for (let i = 0; i < cachedClasificador.length; i += batchSize) {
23 + const batch = cachedClasificador.slice(i, i + batchSize);
24 + const results = await Promise.all(batch.map(async (item) => {
25 + try {
26 + const code = item.organismo;
27 + const res = await fetch(`${API_BASE}/${code}`);
28 + if (!res.ok) return null;
29 + const data = await res.json();
30 + return { code: String(code), estados: data.estados || [] };
31 + } catch { return null; }
32 + }));
33 + results.filter(Boolean).forEach(r => estados.set(r.code, r.estados));
34 + }
35 +
36 + cachedEstados = estados;
37 + cachedTimestamp = now;
38 +}
39 +
40 +export async function GET({ url }) {
41 + const gestion = parseInt(url.searchParams.get('gestion') || '2025');
42 + const entidad = url.searchParams.get('entidad') || '0';
43 +
44 + try {
45 + if (entidad === '0') {
46 + await loadAndCacheAll();
47 +
48 + const results = [];
49 + const grupoSet = new Set();
50 + const subgrupoSet = new Set();
51 +
52 + for (const item of cachedClasificador) {
53 + const code = String(item.organismo);
54 + const estados = cachedEstados.get(code);
55 + if (!estados) continue;
56 + const yearData = estados.find(d => d.gestion === gestion);
57 + if (!yearData || !yearData.total) continue;
58 +
59 + const grupoKey = `g_${item.organismo_grupo}`;
60 + const subgrupoKey = `sg_${item.organismo_grupo}_${item.organismo_subgrupo}`;
61 +
62 + // Add grupo node if not yet added
63 + if (!grupoSet.has(grupoKey)) {
64 + grupoSet.add(grupoKey);
65 + results.push({
66 + gestion,
67 + nivel: 'grupo',
68 + organismo: grupoKey,
69 + desc_organismo: item.desc_organismo_grupo || `Grupo ${item.organismo_grupo}`,
70 + parent: null,
71 + devengado: 0
72 + });
73 + }
74 +
75 + // Add subgrupo node if not yet added
76 + if (!subgrupoSet.has(subgrupoKey)) {
77 + subgrupoSet.add(subgrupoKey);
78 + results.push({
79 + gestion,
80 + nivel: 'subgrupo',
81 + organismo: subgrupoKey,
82 + desc_organismo: item.desc_organismo_subgrupo || `Subgrupo ${item.organismo_subgrupo}`,
83 + parent: grupoKey,
84 + devengado: 0
85 + });
86 + }
87 +
88 + results.push({
89 + gestion,
90 + nivel: 'organismo',
91 + organismo: code,
92 + desc_organismo: item.desc_organismo || '',
93 + parent: subgrupoKey,
94 + devengado: yearData.total
95 + });
96 + }
97 +
98 + return new Response(JSON.stringify(results), {
99 + headers: { 'Content-Type': 'application/json' }
100 + });
101 + } else {
102 + if (!cachedClasificador) {
103 + const clasRes = await fetch(`${API_BASE}/clasificador`);
104 + cachedClasificador = await clasRes.json();
105 + }
106 +
107 + const results = [];
108 + const grupoSet2 = new Set();
109 + const subgrupoSet2 = new Set();
110 + const batchSize = 80;
111 +
112 + for (let i = 0; i < cachedClasificador.length; i += batchSize) {
113 + const batch = cachedClasificador.slice(i, i + batchSize);
114 + const batchResults = await Promise.all(batch.map(async (item) => {
115 + try {
116 + const code = String(item.organismo);
117 + const res = await fetch(`${API_BASE}/${code}/entidades/${entidad}`);
118 + if (!res.ok) return null;
119 + const data = await res.json();
120 + const yearData = data.find(d => d.gestion === gestion);
121 + if (!yearData || !yearData.monto) return null;
122 + return { item, code, monto: yearData.monto };
123 + } catch { return null; }
124 + }));
125 +
126 + batchResults.filter(Boolean).forEach(({ item, code, monto }) => {
127 + const grupoKey = `g_${item.organismo_grupo}`;
128 + const subgrupoKey = `sg_${item.organismo_grupo}_${item.organismo_subgrupo}`;
129 +
130 + if (!grupoSet2.has(grupoKey)) {
131 + grupoSet2.add(grupoKey);
132 + results.push({ gestion, nivel: 'grupo', organismo: grupoKey, desc_organismo: item.desc_organismo_grupo || '', parent: null, devengado: 0 });
133 + }
134 + if (!subgrupoSet2.has(subgrupoKey)) {
135 + subgrupoSet2.add(subgrupoKey);
136 + results.push({ gestion, nivel: 'subgrupo', organismo: subgrupoKey, desc_organismo: item.desc_organismo_subgrupo || '', parent: grupoKey, devengado: 0 });
137 + }
138 + results.push({ gestion, nivel: 'organismo', organismo: code, desc_organismo: item.desc_organismo || '', parent: subgrupoKey, devengado: monto });
139 + });
140 + }
141 +
142 + return new Response(JSON.stringify(results), {
143 + headers: { 'Content-Type': 'application/json' }
144 + });
145 + }
146 + } catch (err) {
147 + return new Response(JSON.stringify({ error: 'Failed to build treemap' }), {
148 + status: 500, headers: { 'Content-Type': 'application/json' }
149 + });
150 + }
151 +}
1 +const API_BASE = 'http://136.112.29.74/api/rubro/clasificador';
2 +
3 +export async function GET() {
4 + try {
5 + const response = await fetch(API_BASE);
6 + if (!response.ok) {
7 + return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
8 + status: response.status, headers: { 'Content-Type': 'application/json' }
9 + });
10 + }
11 + const data = await response.json();
12 + return new Response(JSON.stringify(data), {
13 + headers: { 'Content-Type': 'application/json' }
14 + });
15 + } catch {
16 + return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
17 + status: 500, headers: { 'Content-Type': 'application/json' }
18 + });
19 + }
20 +}
1 +const API_BASE = 'http://136.112.29.74/api/rubro';
2 +
3 +export async function GET({ url }) {
4 + const codigo = url.searchParams.get('codigo') || '';
5 + const tipo = url.searchParams.get('tipo') || 'clasificador';
6 + const entidad = url.searchParams.get('entidad') || '';
7 + const gestion = url.searchParams.get('gestion') || '';
8 +
9 + if (!codigo) {
10 + return new Response(JSON.stringify({ error: 'Missing codigo' }), {
11 + status: 400, headers: { 'Content-Type': 'application/json' }
12 + });
13 + }
14 +
15 + let apiUrl;
16 + if (tipo === 'clasificador') {
17 + apiUrl = `${API_BASE}/${codigo}`;
18 + } else if (tipo === 'entidades-lista') {
19 + apiUrl = `${API_BASE}/${codigo}/entidades`;
20 + } else if (tipo === 'entidad' && entidad) {
21 + apiUrl = `${API_BASE}/${codigo}/entidades/${entidad}`;
22 + } else if (tipo === 'entidades-año' && gestion) {
23 + apiUrl = `${API_BASE}/${codigo}/entidades?gestion=${gestion}`;
24 + } else {
25 + return new Response(JSON.stringify({ error: 'Invalid tipo' }), {
26 + status: 400, headers: { 'Content-Type': 'application/json' }
27 + });
28 + }
29 +
30 + try {
31 + const response = await fetch(apiUrl);
32 + if (!response.ok) {
33 + return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
34 + status: response.status, headers: { 'Content-Type': 'application/json' }
35 + });
36 + }
37 + const data = await response.json();
38 + return new Response(JSON.stringify(data), {
39 + headers: { 'Content-Type': 'application/json' }
40 + });
41 + } catch (err) {
42 + return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
43 + status: 500, headers: { 'Content-Type': 'application/json' }
44 + });
45 + }
46 +}
1 +const API_BASE = 'http://136.112.29.74/api/rubro';
2 +
3 +let cachedClasificador = null;
4 +let cachedEstados = null;
5 +let cachedTimestamp = 0;
6 +const CACHE_TTL = 5 * 60 * 1000;
7 +
8 +function getParent(code, nivel) {
9 + const s = String(code).padStart(5, '0');
10 + if (nivel === 'tipo') return null;
11 + // clase (ABC00) → tipo (AB000)
12 + if (nivel === 'clase') return s.substring(0, 2) + '000';
13 + // cuenta (ABCD0) → clase (ABC00)
14 + if (nivel === 'cuenta') return s.substring(0, 3) + '00';
15 + // sub_cuenta (ABCDE) → cuenta (ABCD0)
16 + if (nivel === 'sub_cuenta') return s.substring(0, 4) + '0';
17 + return null;
18 +}
19 +
20 +async function loadAndCacheAll() {
21 + const now = Date.now();
22 + if (cachedEstados && (now - cachedTimestamp) < CACHE_TTL) return;
23 +
24 + const clasRes = await fetch(`${API_BASE}/clasificador`);
25 + if (!clasRes.ok) throw new Error('Failed to fetch clasificador');
26 + cachedClasificador = await clasRes.json();
27 +
28 + const estados = new Map();
29 + const batchSize = 80;
30 +
31 + for (let i = 0; i < cachedClasificador.length; i += batchSize) {
32 + const batch = cachedClasificador.slice(i, i + batchSize);
33 + const results = await Promise.all(batch.map(async (item) => {
34 + try {
35 + const code = item.rubro;
36 + const res = await fetch(`${API_BASE}/${code}`);
37 + if (!res.ok) return null;
38 + const data = await res.json();
39 + return { code: String(code), estados: data.estados || [] };
40 + } catch { return null; }
41 + }));
42 + results.filter(Boolean).forEach(r => estados.set(r.code, r.estados));
43 + }
44 +
45 + cachedEstados = estados;
46 + cachedTimestamp = now;
47 +}
48 +
49 +export async function GET({ url }) {
50 + const gestion = parseInt(url.searchParams.get('gestion') || '2025');
51 + const entidad = url.searchParams.get('entidad') || '0';
52 +
53 + try {
54 + if (entidad === '0') {
55 + await loadAndCacheAll();
56 +
57 + const results = [];
58 + for (const item of cachedClasificador) {
59 + const code = String(item.rubro);
60 + const estados = cachedEstados.get(code);
61 + if (!estados) continue;
62 + const yearData = estados.find(d => d.gestion === gestion);
63 + if (!yearData || !yearData.total) continue;
64 + results.push({
65 + gestion,
66 + nivel: item.nivel || yearData.nivel || '',
67 + rubro: code,
68 + desc_rubro: item.desc_rubro || '',
69 + parent: getParent(code, item.nivel || yearData.nivel),
70 + devengado: yearData.total
71 + });
72 + }
73 +
74 + // Add missing parent nodes (tipos without estados)
75 + const ids = new Set(results.map(r => r.rubro));
76 + const missingParents = new Set();
77 + results.forEach(r => {
78 + if (r.parent && !ids.has(r.parent)) missingParents.add(r.parent);
79 + });
80 + for (const parentCode of missingParents) {
81 + const item = cachedClasificador.find(c => String(c.rubro) === parentCode);
82 + results.push({
83 + gestion,
84 + nivel: item?.nivel || 'tipo',
85 + rubro: parentCode,
86 + desc_rubro: item?.desc_rubro || parentCode,
87 + parent: getParent(parentCode, item?.nivel || 'tipo'),
88 + devengado: 0
89 + });
90 + }
91 +
92 + return new Response(JSON.stringify(results), {
93 + headers: { 'Content-Type': 'application/json' }
94 + });
95 + } else {
96 + if (!cachedClasificador) {
97 + const clasRes = await fetch(`${API_BASE}/clasificador`);
98 + cachedClasificador = await clasRes.json();
99 + }
100 +
101 + const results = [];
102 + const batchSize = 80;
103 +
104 + for (let i = 0; i < cachedClasificador.length; i += batchSize) {
105 + const batch = cachedClasificador.slice(i, i + batchSize);
106 + const batchResults = await Promise.all(batch.map(async (item) => {
107 + try {
108 + const code = String(item.rubro);
109 + const res = await fetch(`${API_BASE}/${code}/entidades/${entidad}`);
110 + if (!res.ok) return null;
111 + const data = await res.json();
112 + const yearData = data.find(d => d.gestion === gestion);
113 + if (!yearData || !yearData.monto) return null;
114 + return {
115 + gestion,
116 + nivel: item.nivel || '',
117 + rubro: code,
118 + desc_rubro: item.desc_rubro || '',
119 + parent: getParent(code, item.nivel),
120 + devengado: yearData.monto
121 + };
122 + } catch { return null; }
123 + }));
124 + results.push(...batchResults.filter(Boolean));
125 + }
126 +
127 + return new Response(JSON.stringify(results), {
128 + headers: { 'Content-Type': 'application/json' }
129 + });
130 + }
131 + } catch (err) {
132 + return new Response(JSON.stringify({ error: 'Failed to build treemap' }), {
133 + status: 500, headers: { 'Content-Type': 'application/json' }
134 + });
135 + }
136 +}
1 +const API_BASE = 'http://136.112.29.74/api/rubro';
2 +
3 +export async function GET({ url }) {
4 + const codigo = url.searchParams.get('codigo') || '';
5 + const gestion = url.searchParams.get('gestion') || '';
6 +
7 + if (!codigo) {
8 + return new Response(JSON.stringify({ error: 'Missing codigo' }), {
9 + status: 400, headers: { 'Content-Type': 'application/json' }
10 + });
11 + }
12 +
13 + const apiUrl = new URL(`${API_BASE}/${codigo}/ubigeos`);
14 + if (gestion) apiUrl.searchParams.set('gestion', gestion);
15 +
16 + try {
17 + const response = await fetch(apiUrl.toString());
18 + if (!response.ok) {
19 + return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
20 + status: response.status, headers: { 'Content-Type': 'application/json' }
21 + });
22 + }
23 + const data = await response.json();
24 + return new Response(JSON.stringify(data), {
25 + headers: { 'Content-Type': 'application/json' }
26 + });
27 + } catch (err) {
28 + return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
29 + status: 500, headers: { 'Content-Type': 'application/json' }
30 + });
31 + }
32 +}
1 +const API_BASE = 'http://136.112.29.74/api/ubigeo';
2 +
3 +export async function GET({ url }) {
4 + const codigo = url.searchParams.get('codigo') || '';
5 + const tipo = url.searchParams.get('tipo') || 'detalle';
6 +
7 + if (!codigo) {
8 + return new Response(JSON.stringify({ error: 'Missing codigo' }), {
9 + status: 400, headers: { 'Content-Type': 'application/json' }
10 + });
11 + }
12 +
13 + let apiUrl;
14 + if (tipo === 'detalle') {
15 + apiUrl = `${API_BASE}/${codigo}`;
16 + } else if (tipo === 'objetos') {
17 + apiUrl = `${API_BASE}/${codigo}/objetos`;
18 + } else if (tipo === 'finfuns') {
19 + apiUrl = `${API_BASE}/${codigo}/finfuns`;
20 + } else if (tipo === 'actecos') {
21 + apiUrl = `${API_BASE}/${codigo}/actecos`;
22 + } else if (tipo === 'clasificador') {
23 + apiUrl = `${API_BASE}/clasificador`;
24 + } else {
25 + return new Response(JSON.stringify({ error: 'Invalid tipo' }), {
26 + status: 400, headers: { 'Content-Type': 'application/json' }
27 + });
28 + }
29 +
30 + try {
31 + const response = await fetch(apiUrl);
32 + if (!response.ok) {
33 + return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
34 + status: response.status, headers: { 'Content-Type': 'application/json' }
35 + });
36 + }
37 + const data = await response.json();
38 + return new Response(JSON.stringify(data), {
39 + headers: { 'Content-Type': 'application/json' }
40 + });
41 + } catch (err) {
42 + return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
43 + status: 500, headers: { 'Content-Type': 'application/json' }
44 + });
45 + }
46 +}
This diff could not be displayed because it is too large.
...@@ -230,8 +230,13 @@ ...@@ -230,8 +230,13 @@
230 const data = await res.json(); 230 const data = await res.json();
231 231
232 if (Array.isArray(data)) { 232 if (Array.isArray(data)) {
233 - entities = data.sort((a, b) => (a.desc_entidad || '').localeCompare(b.desc_entidad || '')); 233 + entities = data
234 - console.log('Entities loaded:', entities.length); 234 + .filter(e => {
235 + if (!e.gestiones) return false;
236 + const años = e.gestiones.split(',').map(Number);
237 + return años.includes(year);
238 + })
239 + .sort((a, b) => (a.desc_entidad || '').localeCompare(b.desc_entidad || ''));
235 } 240 }
236 241
237 if (selectedEntity) { 242 if (selectedEntity) {
...@@ -787,7 +792,7 @@ ...@@ -787,7 +792,7 @@
787 792
788 // Años desde gestiones del primer grupo 793 // Años desde gestiones del primer grupo
789 if (data[0]?.gestiones) { 794 if (data[0]?.gestiones) {
790 - availableYears = data[0].gestiones.split(',').map(Number).filter(y => y >= 2016).sort((a, b) => b - a); 795 + availableYears = data[0].gestiones.split(',').map(Number).filter(y => y >= 2016 && y <= 2025).sort((a, b) => b - a);
791 selectedYear = availableYears[0]; 796 selectedYear = availableYears[0];
792 } else { 797 } else {
793 availableYears = Array.from({ length: 10 }, (_, i) => 2025 - i); 798 availableYears = Array.from({ length: 10 }, (_, i) => 2025 - i);
...@@ -1468,12 +1473,12 @@ ...@@ -1468,12 +1473,12 @@
1468 1473
1469 <svelte:window onclick={handleClickOutsideEntity} /> 1474 <svelte:window onclick={handleClickOutsideEntity} />
1470 1475
1471 -<div class="min-h-screen" style="font-family: var(--font-sans); background-color: var(--theme-body); color: var(--theme-titulo);"> 1476 +<div class="min-h-screen" style="font-family: var(--font-sans); background-color: var(--theme-body); color: var(--theme-titulo); padding-top: 60px;">
1472 <!-- Header pedagógico --> 1477 <!-- Header pedagógico -->
1473 <header class="border-b" style="border-color: var(--theme-borde); background-color: var(--theme-body);"> 1478 <header class="border-b" style="border-color: var(--theme-borde); background-color: var(--theme-body);">
1474 - <div class="max-w-screen-xl mx-auto px-4 sm:px-6 {viewMode === 'mapa' || viewMode === 'comparar' ? 'py-2' : 'py-6'}"> 1479 + <div class="max-w-screen-xl mx-auto px-4 sm:px-6 py-2">
1475 <!-- Breadcrumb: responsive --> 1480 <!-- Breadcrumb: responsive -->
1476 - <nav class="{viewMode === 'comparar' ? 'mb-1' : 'mb-4'}" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums; font-size: 0.75rem;"> 1481 + <nav class="mb-1" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums; font-size: 0.75rem;">
1477 <!-- Móvil: solo padre --> 1482 <!-- Móvil: solo padre -->
1478 <a href="/clasificadores" class="sm:hidden transition-colors" style="color: var(--theme-texto);"> 1483 <a href="/clasificadores" class="sm:hidden transition-colors" style="color: var(--theme-texto);">
1479 ← Clasificadores 1484 ← Clasificadores
...@@ -1487,25 +1492,10 @@ ...@@ -1487,25 +1492,10 @@
1487 </nav> 1492 </nav>
1488 1493
1489 <div class="{viewMode === 'mapa' || viewMode === 'comparar' ? 'flex-1' : 'max-w-5xl'}"> 1494 <div class="{viewMode === 'mapa' || viewMode === 'comparar' ? 'flex-1' : 'max-w-5xl'}">
1490 - <div class="flex items-center gap-3 {viewMode === 'mapa' || viewMode === 'comparar' ? 'mb-1' : 'mb-3'}"> 1495 + <h1 style="font-family: 'DM Serif Display', serif; font-weight: 400; font-size: 2rem; margin-bottom: 0.25rem; color: var(--theme-titulo);">
1491 - <h1 class="{viewMode === 'mapa' || viewMode === 'comparar' ? 'text-xl' : 'text-3xl'}" style="font-family: var(--font-display); color: var(--theme-titulo);">
1492 ¿En qué se gasta? 1496 ¿En qué se gasta?
1493 </h1> 1497 </h1>
1494 - {#if viewMode === 'mapa'} 1498 + <div class="flex items-center gap-3">
1495 - <button
1496 - onclick={() => showTreemapHelp = true}
1497 - class="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs transition-colors"
1498 - style="color: var(--theme-texto); background-color: var(--theme-fill);"
1499 - title="Cómo leer el gráfico"
1500 - >
1501 - <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1502 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
1503 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
1504 - </svg>
1505 - <span class="hidden sm:inline">Cómo leer</span>
1506 - </button>
1507 - {/if}
1508 - <div style="margin-left: auto; display: flex; align-items: center; gap: 0.75rem;">
1509 <button class="share-btn-clas" onclick={() => { 1499 <button class="share-btn-clas" onclick={() => {
1510 navigator.clipboard.writeText(window.location.href); 1500 navigator.clipboard.writeText(window.location.href);
1511 clasLinkCopied = true; 1501 clasLinkCopied = true;
...@@ -1521,13 +1511,6 @@ ...@@ -1521,13 +1511,6 @@
1521 </svg> 1511 </svg>
1522 {clasLinkCopied ? 'Copiado' : 'Compartir'} 1512 {clasLinkCopied ? 'Copiado' : 'Compartir'}
1523 </button> 1513 </button>
1524 - <a href="/" class="share-btn-clas" style="text-decoration: none;">
1525 - <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1526 - <path d="M19 12H5M12 19l-7-7 7-7"/>
1527 - </svg>
1528 - Volver
1529 - </a>
1530 - </div>
1531 </div> 1514 </div>
1532 {#if viewMode === 'mapa'} 1515 {#if viewMode === 'mapa'}
1533 <div class="title-selectors"> 1516 <div class="title-selectors">
...@@ -1565,7 +1548,6 @@ ...@@ -1565,7 +1548,6 @@
1565 class:active={selectedEntity?.entidad === entity.entidad} 1548 class:active={selectedEntity?.entidad === entity.entidad}
1566 onclick={() => { selectEntity(entity); titleEntityDropdownOpen = false; }} 1549 onclick={() => { selectEntity(entity); titleEntityDropdownOpen = false; }}
1567 > 1550 >
1568 - <span class="option-code">{entity.entidad}</span>
1569 <span class="option-name">{entity.desc_entidad}</span> 1551 <span class="option-name">{entity.desc_entidad}</span>
1570 </button> 1552 </button>
1571 {/each} 1553 {/each}
...@@ -1616,7 +1598,7 @@ ...@@ -1616,7 +1598,7 @@
1616 </div> 1598 </div>
1617 1599
1618 <!-- Controles de visualización (fuera del max-w-3xl para usar todo el ancho) --> 1600 <!-- Controles de visualización (fuera del max-w-3xl para usar todo el ancho) -->
1619 - <div class="{viewMode === 'mapa' ? 'mt-3' : 'mt-8'}"> 1601 + <div class="mt-2">
1620 <!-- Fila principal: Toggle + Guía + Filtros --> 1602 <!-- Fila principal: Toggle + Guía + Filtros -->
1621 <div class="flex flex-col md:flex-row gap-4 md:items-center md:justify-between"> 1603 <div class="flex flex-col md:flex-row gap-4 md:items-center md:justify-between">
1622 <!-- Selector de modo + Guía inline (visible en sm+) --> 1604 <!-- Selector de modo + Guía inline (visible en sm+) -->
...@@ -1770,7 +1752,7 @@ ...@@ -1770,7 +1752,7 @@
1770 {layoutMounted ? 'sidebar-mounted' : ''} 1752 {layoutMounted ? 'sidebar-mounted' : ''}
1771 " 1753 "
1772 > 1754 >
1773 - <div class="h-full lg:h-screen lg:sticky lg:top-0 overflow-y-auto py-6 px-4 lg:px-0 lg:pr-6"> 1755 + <div class="h-full lg:sticky overflow-y-auto py-4 px-4 lg:px-0 lg:pr-6" style="top: 70px; max-height: calc(100vh - 70px);">
1774 <!-- Cerrar en móvil --> 1756 <!-- Cerrar en móvil -->
1775 <div class="flex justify-between items-center mb-4 lg:hidden"> 1757 <div class="flex justify-between items-center mb-4 lg:hidden">
1776 <span class="text-sm font-medium" style="color: var(--theme-titulo);">Grupos de gasto</span> 1758 <span class="text-sm font-medium" style="color: var(--theme-titulo);">Grupos de gasto</span>
...@@ -2040,7 +2022,7 @@ ...@@ -2040,7 +2022,7 @@
2040 2022
2041 <!-- Sidebar derecha: Navegación rápida --> 2023 <!-- Sidebar derecha: Navegación rápida -->
2042 <aside class="w-56 flex-shrink-0 hidden xl:block"> 2024 <aside class="w-56 flex-shrink-0 hidden xl:block">
2043 - <div class="sticky top-0 h-screen overflow-y-auto py-8 pl-6 border-l" style="border-color: var(--theme-borde);"> 2025 + <div class="sticky overflow-y-auto py-4 pl-6 border-l" style="top: 70px; max-height: calc(100vh - 70px); border-color: var(--theme-borde);">
2044 <p class="text-sm uppercase tracking-wide mb-4 font-medium" style="color: var(--theme-texto);">Navegación</p> 2026 <p class="text-sm uppercase tracking-wide mb-4 font-medium" style="color: var(--theme-texto);">Navegación</p>
2045 {#if selectedGrupo} 2027 {#if selectedGrupo}
2046 <nav class="space-y-3"> 2028 <nav class="space-y-3">
...@@ -2286,7 +2268,7 @@ ...@@ -2286,7 +2268,7 @@
2286 class="treemap-node" 2268 class="treemap-node"
2287 transform="translate({node.x0}, {node.y0})" 2269 transform="translate({node.x0}, {node.y0})"
2288 onmouseenter={() => { if (!pinnedNode) hoveredNode = node; }} 2270 onmouseenter={() => { if (!pinnedNode) hoveredNode = node; }}
2289 - onmouseleave={() => { if (!pinnedNode && !tooltipHovered) hoveredNode = null; }} 2271 + onmouseleave={() => { if (!pinnedNode) setTimeout(() => { if (!tooltipHovered) hoveredNode = null; }, 30); }}
2290 onclick={(e) => handleFlatNodeClick(node, e)} 2272 onclick={(e) => handleFlatNodeClick(node, e)}
2291 style="cursor: pointer;" 2273 style="cursor: pointer;"
2292 > 2274 >
...@@ -2606,7 +2588,6 @@ ...@@ -2606,7 +2588,6 @@
2606 <button class="inline-dropdown-option" class:active={!compareA.entity} onclick={() => clearCompareEntity('A')}>Todo el Estado</button> 2588 <button class="inline-dropdown-option" class:active={!compareA.entity} onclick={() => clearCompareEntity('A')}>Todo el Estado</button>
2607 {#each filteredEntitiesA() as entity} 2589 {#each filteredEntitiesA() as entity}
2608 <button class="inline-dropdown-option" class:active={compareA.entity?.entidad === entity.entidad} onclick={() => selectCompareEntity('A', entity)}> 2590 <button class="inline-dropdown-option" class:active={compareA.entity?.entidad === entity.entidad} onclick={() => selectCompareEntity('A', entity)}>
2609 - <span class="font-mono text-xs opacity-50">{entity.entidad}</span>
2610 <span class="truncate">{entity.desc_entidad}</span> 2591 <span class="truncate">{entity.desc_entidad}</span>
2611 </button> 2592 </button>
2612 {/each} 2593 {/each}
...@@ -2768,7 +2749,6 @@ ...@@ -2768,7 +2749,6 @@
2768 <button class="inline-dropdown-option" class:active={!compareB.entity} onclick={() => clearCompareEntity('B')}>Todo el Estado</button> 2749 <button class="inline-dropdown-option" class:active={!compareB.entity} onclick={() => clearCompareEntity('B')}>Todo el Estado</button>
2769 {#each filteredEntitiesB() as entity} 2750 {#each filteredEntitiesB() as entity}
2770 <button class="inline-dropdown-option" class:active={compareB.entity?.entidad === entity.entidad} onclick={() => selectCompareEntity('B', entity)}> 2751 <button class="inline-dropdown-option" class:active={compareB.entity?.entidad === entity.entidad} onclick={() => selectCompareEntity('B', entity)}>
2771 - <span class="font-mono text-xs opacity-50">{entity.entidad}</span>
2772 <span class="truncate">{entity.desc_entidad}</span> 2752 <span class="truncate">{entity.desc_entidad}</span>
2773 </button> 2753 </button>
2774 {/each} 2754 {/each}
......
This diff could not be displayed because it is too large.
This diff could not be displayed because it is too large.
This diff could not be displayed because it is too large.
1 -import { supabase } from '$lib/supabase';
2 import { error } from '@sveltejs/kit'; 1 import { error } from '@sveltejs/kit';
3 2
4 export async function load({ params, fetch }) { 3 export async function load({ params, fetch }) {
...@@ -11,25 +10,17 @@ export async function load({ params, fetch }) { ...@@ -11,25 +10,17 @@ export async function load({ params, fetch }) {
11 throw error(400, 'Código de entidad inválido'); 10 throw error(400, 'Código de entidad inválido');
12 } 11 }
13 12
14 - // Cargar metadata, resumen y población en paralelo 13 + // Cargar metadata desde proxy local + población en paralelo
15 - const [entidadRes, resumenRes, pobRes] = await Promise.all([ 14 + const [entidadRes, pobRes] = await Promise.all([
16 - supabase 15 + fetch(`/api/entidad-data?codigo=${entidadNum}&tipo=detalle`).then(r => r.ok ? r.json() : null),
17 - .schema('ppto')
18 - .from('clas_institucional')
19 - .select('*')
20 - .eq('entidad', entidadNum)
21 - .single(),
22 -
23 - supabase
24 - .schema('ppto')
25 - .from('entidad_resumen')
26 - .select('tipo, tipo_codigo, codigo, desc, desc_padre, gestion, devengado, ranking')
27 - .eq('codigo', codigo),
28 -
29 fetch('/poblacion.csv').then(r => r.text()) 16 fetch('/poblacion.csv').then(r => r.text())
30 ]); 17 ]);
31 18
32 - // Buscar población: primero por entidad, si no suma nacional 19 + if (!entidadRes) {
20 + throw error(404, 'Entidad no encontrada');
21 + }
22 +
23 + // Población
33 const poblacionEntidad = {}; 24 const poblacionEntidad = {};
34 const poblacionNacional = {}; 25 const poblacionNacional = {};
35 pobRes.split('\n').slice(1).forEach(line => { 26 pobRes.split('\n').slice(1).forEach(line => {
...@@ -37,51 +28,79 @@ export async function load({ params, fetch }) { ...@@ -37,51 +28,79 @@ export async function load({ params, fetch }) {
37 const g = parseInt(gestion); 28 const g = parseInt(gestion);
38 const p = parseInt(pob); 29 const p = parseInt(pob);
39 if (!g || !p) return; 30 if (!g || !p) return;
40 - // Suma nacional
41 poblacionNacional[g] = (poblacionNacional[g] || 0) + p; 31 poblacionNacional[g] = (poblacionNacional[g] || 0) + p;
42 - // Por entidad (código sin DA)
43 if (ent === entidadCode) { 32 if (ent === entidadCode) {
44 poblacionEntidad[g] = (poblacionEntidad[g] || 0) + p; 33 poblacionEntidad[g] = (poblacionEntidad[g] || 0) + p;
45 } 34 }
46 }); 35 });
47 36
48 - // Usar población de la entidad si existe, si no la nacional
49 const tienePobEntidad = Object.keys(poblacionEntidad).length > 0; 37 const tienePobEntidad = Object.keys(poblacionEntidad).length > 0;
50 const poblacionMap = tienePobEntidad ? poblacionEntidad : poblacionNacional; 38 const poblacionMap = tienePobEntidad ? poblacionEntidad : poblacionNacional;
51 39
52 - if (entidadRes.error || !entidadRes.data) { 40 + // gastos_ingresos → resumenData (compatible con el formato que espera la página)
53 - throw error(404, 'Entidad no encontrada'); 41 + const gastosIngresos = entidadRes.gastos_ingresos || [];
54 - } 42 + const resumenData = gastosIngresos.map(d => ({
43 + tipo: d.tipo,
44 + gestion: d.gestion,
45 + devengado: d.devengado,
46 + ranking: d.ranking,
47 + codigo: codigo,
48 + desc: entidadRes.desc_entidad
49 + }));
55 50
56 - // Determinar última gestión disponible 51 + // Determinar última gestión
57 - const gestiones = [...new Set((resumenRes.data || []).map(d => d.gestion))].sort((a, b) => b - a); 52 + const gestiones = [...new Set(gastosIngresos.map(d => d.gestion))].filter(g => g <= 2025).sort((a, b) => b - a);
58 const ultimaGestion = gestiones[0] || 2025; 53 const ultimaGestion = gestiones[0] || 2025;
59 54
60 - // Cargar distribuciones solo de la última gestión 55 + // Cargar distribuciones de la última gestión (objetos, finfuns, etc.)
61 - const distRes = await supabase 56 + const distEndpoints = [
62 - .schema('ppto') 57 + { api: 'objetos', dim: 'objeto' },
63 - .from('entidad_distribuciones') 58 + { api: 'finfuns', dim: 'finfun' },
64 - .select('tipo, dimension, gestion, padre, desc_padre, hijo, desc_hijo, devengado') 59 + { api: 'actecos', dim: 'acteco' },
65 - .eq('codigo', codigo) 60 + { api: 'rubros', dim: 'rubro' },
66 - .eq('gestion', ultimaGestion); 61 + { api: 'organismos', dim: 'organismo' }
62 + ];
63 + const distResults = await Promise.all(
64 + distEndpoints.map(async ({ api, dim }) => {
65 + try {
66 + const res = await fetch(`/api/entidad-data?codigo=${entidadNum}&tipo=${api}`);
67 + if (!res.ok) return [];
68 + const data = await res.json();
69 + if (!Array.isArray(data)) return [];
70 + return data
71 + .filter(d => d.gestion === ultimaGestion)
72 + .map(d => ({
73 + tipo: d.tipo || 'gastos',
74 + dimension: dim,
75 + gestion: d.gestion,
76 + padre: d.padre,
77 + desc_padre: d.desc_padre,
78 + hijo: d.hijo,
79 + desc_hijo: d.desc_hijo,
80 + devengado: d.devengado
81 + }));
82 + } catch { return []; }
83 + })
84 + );
85 +
86 + const distribucionesData = distResults.flat();
67 87
68 - // Resolver nombre de DA desde el resumen 88 + // DA info
69 let nombreDA = null; 89 let nombreDA = null;
70 let nombreEntidadMadre = null; 90 let nombreEntidadMadre = null;
71 - if (isDA && resumenRes.data?.length > 0) { 91 + if (isDA) {
72 - const firstRow = resumenRes.data[0]; 92 + nombreDA = entidadRes.desc_entidad || null;
73 - nombreDA = firstRow.desc || null; 93 + nombreEntidadMadre = entidadRes.desc_entidad || null;
74 - nombreEntidadMadre = firstRow.desc_padre || null;
75 } 94 }
76 95
77 return { 96 return {
78 - entidad: entidadRes.data, 97 + entidad: entidadRes,
79 isDA, 98 isDA,
80 nombreDA, 99 nombreDA,
81 nombreEntidadMadre, 100 nombreEntidadMadre,
82 codigoEntidadPadre: isDA ? entidadCode : null, 101 codigoEntidadPadre: isDA ? entidadCode : null,
83 - resumenData: resumenRes.data || [], 102 + resumenData,
84 - distribucionesData: distRes.data || [], 103 + distribucionesData,
85 gestionInicial: ultimaGestion, 104 gestionInicial: ultimaGestion,
86 poblacionMap, 105 poblacionMap,
87 tienePobEntidad 106 tienePobEntidad
......
...@@ -2,7 +2,6 @@ ...@@ -2,7 +2,6 @@
2 import { onMount, tick } from 'svelte'; 2 import { onMount, tick } from 'svelte';
3 import { page } from '$app/stores'; 3 import { page } from '$app/stores';
4 import * as d3 from 'd3'; 4 import * as d3 from 'd3';
5 - import { supabase } from '$lib/supabase';
6 5
7 let { data } = $props(); 6 let { data } = $props();
8 let entidad = $derived(data.entidad); 7 let entidad = $derived(data.entidad);
...@@ -37,15 +36,43 @@ ...@@ -37,15 +36,43 @@
37 return; 36 return;
38 } 37 }
39 cargandoDist = true; 38 cargandoDist = true;
40 - const { data: rows } = await supabase 39 + const entidadNum = codigoSeleccionado.includes('.') ? codigoSeleccionado.split('.')[0] : codigoSeleccionado;
41 - .schema('ppto') 40 + const dims = [
42 - .from('entidad_distribuciones') 41 + { api: 'objetos', dim: 'objeto' },
43 - .select('tipo, dimension, gestion, padre, desc_padre, hijo, desc_hijo, devengado') 42 + { api: 'finfuns', dim: 'finfun' },
44 - .eq('codigo', codigoSeleccionado) 43 + { api: 'actecos', dim: 'acteco' },
45 - .eq('gestion', gestion); 44 + { api: 'rubros', dim: 'rubro' },
46 - const result = rows || []; 45 + { api: 'organismos', dim: 'organismo' }
46 + ];
47 + try {
48 + const results = await Promise.all(
49 + dims.map(async ({ api, dim }) => {
50 + try {
51 + const res = await fetch(`/api/entidad-data?codigo=${entidadNum}&tipo=${api}`);
52 + if (!res.ok) return [];
53 + const data = await res.json();
54 + if (!Array.isArray(data)) return [];
55 + return data
56 + .filter(d => d.gestion === gestion)
57 + .map(d => ({
58 + tipo: d.tipo || 'gastos',
59 + dimension: dim,
60 + gestion: d.gestion,
61 + padre: d.padre,
62 + desc_padre: d.desc_padre,
63 + hijo: d.hijo,
64 + desc_hijo: d.desc_hijo,
65 + devengado: d.devengado
66 + }));
67 + } catch { return []; }
68 + })
69 + );
70 + const result = results.flat();
47 distCache[gestion] = result; 71 distCache[gestion] = result;
48 distribucionesData = result; 72 distribucionesData = result;
73 + } catch {
74 + distribucionesData = [];
75 + }
49 cargandoDist = false; 76 cargandoDist = false;
50 } 77 }
51 78
......
1 -import { supabase } from '$lib/supabase';
2 import { error } from '@sveltejs/kit'; 1 import { error } from '@sveltejs/kit';
3 2
4 -// Funciones para derivar jerarquía del código finfun 3 +const API_BASE = 'http://136.112.29.74/api/finfun';
5 -// Jerarquía: Finalidad (1-9 o 10) → Grupo Función (2-3 dígitos) → Función (3+ dígitos)
6 -// Nota: Finalidad 10 es especial - tiene 2 dígitos, sus hijos empiezan con "10"
7 4
8 -function getFinalidadCode(finfun) { 5 +// Jerarquía finfun: Finalidad → Grupo Función → Función
9 - const code = String(finfun); 6 +// Códigos con punto: "1" (finalidad), "1.1" (grpfuncion), "1.1.1" (función)
10 - // Si empieza con "10", la finalidad es "10" 7 +
11 - if (code.startsWith('10')) { 8 +function getNivel(codigo) {
12 - return '10'; 9 + const parts = String(codigo).split('.');
13 - } 10 + if (parts.length === 1) return 'finalidad';
14 - // Si no, la finalidad es el primer dígito 11 + if (parts.length === 2) return 'grpfuncion';
15 - return code.charAt(0); 12 + return 'funcion';
16 } 13 }
17 14
18 -function getGrpFuncionCode(finfun) { 15 +function getFinalidadCode(codigo) {
19 - const code = String(finfun); 16 + return String(codigo).split('.')[0];
20 - // Si empieza con "10", el grupo función son los primeros 3 dígitos (ej: "101", "102")
21 - if (code.startsWith('10')) {
22 - return code.substring(0, 3);
23 - }
24 - // Si no, son los primeros 2 dígitos (ej: "11", "21", "93")
25 - return code.substring(0, 2);
26 } 17 }
27 18
28 -function getNivel(finfun) { 19 +function getGrpFuncionCode(codigo) {
29 - const code = String(finfun); 20 + const parts = String(codigo).split('.');
30 - // Finalidades 1-9 tienen longitud 1, finalidad 10 tiene longitud 2 21 + return parts.slice(0, 2).join('.');
31 - if (code.length === 1) return 'finalidad';
32 - if (code === '10') return 'finalidad';
33 - // GrpFuncion: 2 dígitos para 1-9, 3 dígitos para 10
34 - if (code.startsWith('10')) {
35 - if (code.length === 3) return 'grpfuncion';
36 - return 'funcion';
37 - }
38 - if (code.length === 2) return 'grpfuncion';
39 - return 'funcion';
40 } 22 }
41 23
42 export async function load({ params }) { 24 export async function load({ params }) {
43 - console.time('[SERVER] Total load finfun');
44 const { codigo } = params; 25 const { codigo } = params;
45 26
46 - console.time('[SERVER] Query clas_finfun'); 27 + // Cargar datos del finfun desde la API
47 - const { data, error: dbError } = await supabase 28 + const res = await fetch(`${API_BASE}/${codigo}`);
48 - .schema('ppto') 29 + if (!res.ok) throw error(404, 'Finalidad/Función no encontrada');
49 - .from('clas_finfun') 30 + const finfun = await res.json();
50 - .select('*')
51 - .eq('finfun', codigo);
52 - console.timeEnd('[SERVER] Query clas_finfun');
53 -
54 - if (dbError || !data || data.length === 0) {
55 - throw error(404, 'Finalidad/Función no encontrada');
56 - }
57 31
58 - // Tomar el primer resultado
59 - const finfun = data[0];
60 const nivel = finfun.nivel || getNivel(codigo); 32 const nivel = finfun.nivel || getNivel(codigo);
61 33
62 - // Obtener jerarquía (padres e hijos) 34 + // Cargar clasificador completo para derivar padres e hijos
63 let padres = []; 35 let padres = [];
64 let hijos = []; 36 let hijos = [];
65 37
66 - console.time('[SERVER] Query padres'); 38 + try {
39 + const clasRes = await fetch(`${API_BASE}/clasificador`);
40 + if (clasRes.ok) {
41 + const clasificador = await clasRes.json();
67 42
68 - // Buscar padres según nivel 43 + // Padres
69 if (nivel === 'funcion') { 44 if (nivel === 'funcion') {
70 - // Padres: finalidad y grupo función
71 const finalidadCode = getFinalidadCode(codigo); 45 const finalidadCode = getFinalidadCode(codigo);
72 - const grpFuncionCode = getGrpFuncionCode(codigo); 46 + const grpCode = getGrpFuncionCode(codigo);
73 - 47 + padres = clasificador.filter(c => c.finfun === finalidadCode || c.finfun === grpCode);
74 - const { data: padresData } = await supabase
75 - .schema('ppto')
76 - .from('clas_finfun')
77 - .select('*')
78 - .in('finfun', [finalidadCode, grpFuncionCode])
79 - .order('finfun');
80 -
81 - if (padresData) padres = padresData;
82 } else if (nivel === 'grpfuncion') { 48 } else if (nivel === 'grpfuncion') {
83 - // Padre: finalidad
84 const finalidadCode = getFinalidadCode(codigo); 49 const finalidadCode = getFinalidadCode(codigo);
85 - 50 + padres = clasificador.filter(c => c.finfun === finalidadCode);
86 - const { data: padresData } = await supabase
87 - .schema('ppto')
88 - .from('clas_finfun')
89 - .select('*')
90 - .eq('finfun', finalidadCode);
91 -
92 - if (padresData) padres = padresData;
93 } 51 }
94 - // finalidad no tiene padres
95 52
96 - console.timeEnd('[SERVER] Query padres'); 53 + // Hijos
97 -
98 - console.time('[SERVER] Query hijos');
99 -
100 - // Buscar hijos según nivel
101 if (nivel === 'finalidad') { 54 if (nivel === 'finalidad') {
102 - // Hijos: grupos de función de esta finalidad 55 + hijos = clasificador.filter(c =>
103 - const { data: hijosData } = await supabase 56 + c.nivel === 'grpfuncion' && getFinalidadCode(c.finfun) === codigo && c.finfun !== codigo
104 - .schema('ppto') 57 + );
105 - .from('clas_finfun')
106 - .select('*')
107 - .eq('nivel', 'grpfuncion')
108 - .order('finfun');
109 -
110 - // Filtrar solo los hijos directos de esta finalidad
111 - // Para finalidad "1": hijos son "11", "12", ..., "19" (NO "10x")
112 - // Para finalidad "10": hijos son "101", "102", ..., "109"
113 - if (hijosData) {
114 - hijos = hijosData.filter(h => {
115 - const hijoPadre = getFinalidadCode(h.finfun);
116 - return hijoPadre === codigo;
117 - });
118 - }
119 } else if (nivel === 'grpfuncion') { 58 } else if (nivel === 'grpfuncion') {
120 - // Hijos: funciones de este grupo 59 + hijos = clasificador.filter(c =>
121 - const grpCode = getGrpFuncionCode(codigo); 60 + c.nivel === 'funcion' && getGrpFuncionCode(c.finfun) === codigo
122 - const { data: hijosData } = await supabase 61 + );
123 - .schema('ppto')
124 - .from('clas_finfun')
125 - .select('*')
126 - .eq('nivel', 'funcion')
127 - .order('finfun');
128 -
129 - // Filtrar solo los hijos directos de este grupo
130 - if (hijosData) {
131 - hijos = hijosData.filter(h => {
132 - const hijoGrp = getGrpFuncionCode(h.finfun);
133 - return hijoGrp === grpCode;
134 - });
135 } 62 }
136 - }
137 - // funcion no tiene hijos
138 63
139 - console.timeEnd('[SERVER] Query hijos'); 64 + padres.sort((a, b) => a.finfun.localeCompare(b.finfun));
65 + hijos.sort((a, b) => a.finfun.localeCompare(b.finfun));
66 + }
67 + } catch { /* clasificador optional */ }
140 68
141 - console.timeEnd('[SERVER] Total load finfun');
142 return { 69 return {
143 finfun, 70 finfun,
144 padres, 71 padres,
......
This diff could not be displayed because it is too large.
1 +<script>
2 + import { page } from '$app/stores';
3 + import { goto } from '$app/navigation';
4 + import { onMount } from 'svelte';
5 + import Spinner from '$lib/components/ui/Spinner.svelte';
6 + import * as d3 from 'd3';
7 +
8 + let codigo = $derived($page.params.codigo);
9 +
10 + // Estado
11 + let loading = $state(true);
12 + let mounted = $state(false);
13 + let finfunInfo = $state(null);
14 + let entidadesData = $state([]);
15 + let selectedYear = $state(null);
16 + let linkCopied = $state(false);
17 + let hoveredNode = $state(null);
18 + let totalDevengado = $state(0);
19 + let entidadClasificador = $state({}); // Map entidad → { desc_subarea, sigla_subarea, desc_area, ... }
20 + let subareas = $state([]); // Lista única de subareas con color
21 + let sliderMin = $state(0);
22 + let sliderMax = $state(100);
23 +
24 + // Filtrar entidades por rango de % acumulado
25 + let filteredEntidades = $derived.by(() => {
26 + if (sliderMin === 0 && sliderMax === 100) return entidadesData;
27 + let acum = 0;
28 + return entidadesData.filter(d => {
29 + const pctBefore = (acum / totalDevengado) * 100;
30 + acum += d.monto;
31 + const pctAfter = (acum / totalDevengado) * 100;
32 + return pctAfter > sliderMin && pctBefore < sliderMax;
33 + });
34 + });
35 +
36 + let filteredTotal = $derived(filteredEntidades.reduce((s, d) => s + d.monto, 0));
37 + let filteredCount = $derived(filteredEntidades.length);
38 +
39 + // Treemap
40 + let treemapContainer = $state(null);
41 + let treemapWidth = $state(0);
42 + let treemapHeight = $state(0);
43 + let treemapNodes = $state([]);
44 + let treemapGroups = $state([]); // Nodos padre (subareas)
45 +
46 + // Años disponibles desde gestiones del finfun
47 + let availableYears = $derived.by(() => {
48 + if (!finfunInfo?.gestiones) return [];
49 + if (finfunInfo.estados?.length > 0) {
50 + return finfunInfo.estados.map(e => e.gestion).filter(y => y >= 2016 && y > 0).sort((a, b) => b - a);
51 + }
52 + return finfunInfo.gestiones.split(',').map(Number).filter(y => y >= 2016 && y <= new Date().getFullYear()).sort((a, b) => b - a);
53 + });
54 +
55 + // Formatters
56 + function formatMonto(v) {
57 + if (v >= 1e9) return `${(v / 1e9).toFixed(1)} mil mill.`;
58 + if (v >= 1e6) return `${(v / 1e6).toFixed(1)} mill.`;
59 + if (v >= 1e3) return `${(v / 1e3).toFixed(0)} mil`;
60 + return v?.toLocaleString('es-BO') || '0';
61 + }
62 +
63 + function formatCompact(v) {
64 + if (v >= 1e9) return `${(v / 1e9).toFixed(1)}MM`;
65 + if (v >= 1e6) return `${(v / 1e6).toFixed(1)}M`;
66 + if (v >= 1e3) return `${(v / 1e3).toFixed(0)}K`;
67 + return v?.toFixed(0) || '0';
68 + }
69 +
70 + // Colores por subarea
71 + let isDark = $state(false);
72 + const SUBAREA_COLORS = [
73 + '#4E79A7', '#F28E2B', '#E15759', '#76B7B2', '#59A14F',
74 + '#EDC948', '#B07AA1', '#FF9DA7', '#9C755F', '#BAB0AC',
75 + '#4E79A7', '#F28E2B', '#E15759', '#76B7B2', '#59A14F'
76 + ];
77 +
78 + function getSubareaColor(subarea) {
79 + const idx = subareas.indexOf(subarea);
80 + return SUBAREA_COLORS[idx % SUBAREA_COLORS.length];
81 + }
82 +
83 + // Contraste de texto según luminosidad del fondo (WCAG)
84 + function getTextColorForBg(bgColor) {
85 + const color = d3.color(bgColor);
86 + if (!color) return 'rgba(255,255,255,0.95)';
87 + const luminance = 0.299 * (color.r / 255) + 0.587 * (color.g / 255) + 0.114 * (color.b / 255);
88 + return luminance > 0.5 ? 'rgba(0,0,0,0.85)' : 'rgba(255,255,255,0.95)';
89 + }
90 +
91 + function getTextColorSecondary(bgColor) {
92 + const color = d3.color(bgColor);
93 + if (!color) return 'rgba(255,255,255,0.88)';
94 + const luminance = 0.299 * (color.r / 255) + 0.587 * (color.g / 255) + 0.114 * (color.b / 255);
95 + return luminance > 0.5 ? 'rgba(0,0,0,0.6)' : 'rgba(255,255,255,0.7)';
96 + }
97 +
98 + // Cargar clasificador de entidades
99 + async function loadClasificador() {
100 + try {
101 + const res = await fetch('/api/entidad-clasificador');
102 + const data = await res.json();
103 + if (Array.isArray(data)) {
104 + const map = {};
105 + data.forEach(d => { map[d.entidad] = d; });
106 + entidadClasificador = map;
107 + }
108 + } catch {}
109 + }
110 +
111 + // Cargar info del finfun desde API
112 + async function loadFinfun() {
113 + try {
114 + const res = await fetch(`/api/finfun-data?codigo=${codigo}&tipo=clasificador`);
115 + const data = await res.json();
116 + if (data && !data.error) finfunInfo = data;
117 + } catch {}
118 + }
119 +
120 + // Cargar entidades desde API
121 + async function loadEntidades() {
122 + if (!selectedYear || !codigo) return;
123 + loading = true;
124 + try {
125 + const res = await fetch(`/api/finfun-data?codigo=${codigo}&tipo=entidades-año&gestion=${selectedYear}`);
126 + const raw = await res.json();
127 + if (Array.isArray(raw)) {
128 + // Deduplicar por entidad
129 + const map = new Map();
130 + raw.forEach(d => {
131 + if (!map.has(d.entidad) || d.monto > map.get(d.entidad).monto) {
132 + map.set(d.entidad, d);
133 + }
134 + });
135 + entidadesData = [...map.values()].filter(d => d.monto > 0).sort((a, b) => b.monto - a.monto);
136 + totalDevengado = entidadesData.reduce((s, d) => s + d.monto, 0);
137 +
138 + // Resolve entidad names from the clasificador map
139 + entidadesData = entidadesData.map(d => ({
140 + ...d,
141 + desc_entidad: entidadClasificador[d.entidad]?.desc_entidad || d.desc_entidad || `Entidad ${d.entidad}`,
142 + desc_subarea: entidadClasificador[d.entidad]?.desc_subarea || 'Otros'
143 + }));
144 +
145 + // Extraer subareas únicas ordenadas por monto total
146 + const subareaMontos = {};
147 + entidadesData.forEach(d => {
148 + subareaMontos[d.desc_subarea] = (subareaMontos[d.desc_subarea] || 0) + d.monto;
149 + });
150 + subareas = Object.entries(subareaMontos).sort((a, b) => b[1] - a[1]).map(([k]) => k);
151 +
152 + buildTreemap();
153 + }
154 + } catch (err) {
155 + console.error('[Entidades] Error:', err);
156 + }
157 + loading = false;
158 + }
159 +
160 + function buildTreemap() {
161 + if (!filteredEntidades.length || treemapWidth <= 0 || treemapHeight <= 0) {
162 + treemapNodes = [];
163 + treemapGroups = [];
164 + return;
165 + }
166 +
167 + // Agrupar por subarea
168 + const groups = {};
169 + filteredEntidades.forEach(d => {
170 + const sub = d.desc_subarea || 'Otros';
171 + if (!groups[sub]) groups[sub] = [];
172 + groups[sub].push({
173 + id: d.entidad,
174 + name: d.desc_entidad || `Entidad ${d.entidad}`,
175 + value: d.monto,
176 + ranking: d.ranking,
177 + per_capita: d.per_capita,
178 + prop: d.prop,
179 + subarea: sub
180 + });
181 + });
182 +
183 + const hierarchy = d3.hierarchy({
184 + name: 'root',
185 + children: Object.entries(groups).map(([subarea, children]) => ({
186 + name: subarea,
187 + children
188 + }))
189 + }).sum(d => d.value || 0)
190 + .sort((a, b) => b.value - a.value);
191 +
192 + d3.treemap()
193 + .size([treemapWidth, treemapHeight])
194 + .paddingOuter(3)
195 + .paddingInner(1)
196 + .paddingTop(18)
197 + .round(true)(hierarchy);
198 +
199 + treemapNodes = hierarchy.leaves() || [];
200 + treemapGroups = hierarchy.children || [];
201 + }
202 +
203 + function changeYear(year) {
204 + selectedYear = year;
205 + goto(`?gestion=${year}`, { replaceState: true, noScroll: true });
206 + loadEntidades();
207 + }
208 +
209 + $effect(() => {
210 + if (treemapContainer) {
211 + const measure = () => {
212 + const w = treemapContainer.clientWidth;
213 + const h = treemapContainer.clientHeight;
214 + if (w > 0 && h > 0) {
215 + treemapWidth = w;
216 + treemapHeight = h;
217 + buildTreemap();
218 + }
219 + };
220 + measure();
221 + const observer = new ResizeObserver(measure);
222 + observer.observe(treemapContainer);
223 + return () => observer.disconnect();
224 + }
225 + });
226 +
227 +
228 + onMount(async () => {
229 + mounted = true;
230 + isDark = document.documentElement.classList.contains('dark');
231 + const observer = new MutationObserver(() => {
232 + isDark = document.documentElement.classList.contains('dark');
233 + });
234 + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
235 +
236 + await Promise.all([loadFinfun(), loadClasificador()]);
237 +
238 + const yearParam = $page.url.searchParams.get('gestion');
239 + if (yearParam) {
240 + const y = parseInt(yearParam);
241 + selectedYear = availableYears.includes(y) ? y : availableYears[0] || 2025;
242 + } else {
243 + selectedYear = availableYears[0] || 2025;
244 + }
245 +
246 + if (treemapContainer) {
247 + treemapWidth = treemapContainer.clientWidth;
248 + treemapHeight = treemapContainer.clientHeight;
249 + }
250 + await loadEntidades();
251 + });
252 +</script>
253 +
254 +<svelte:head>
255 + <title>{finfunInfo?.desc_finfun || `Finalidad y función ${codigo}`} - Ranking de Entidades</title>
256 + <link rel="preconnect" href="https://fonts.googleapis.com" />
257 + <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" />
258 +</svelte:head>
259 +
260 +<div class="page" class:mounted>
261 + <div class="layout">
262 + <!-- Header -->
263 + <header class="header">
264 + <div class="header-top">
265 + <div class="pills">
266 + <span class="pill pill-nivel">{finfunInfo?.nivel || 'finalidad y función'}</span>
267 + <span class="pill pill-codigo">{codigo}</span>
268 + </div>
269 + <div class="header-actions">
270 + <button class="copy-link-btn" onclick={() => {
271 + navigator.clipboard.writeText(window.location.href);
272 + linkCopied = true;
273 + setTimeout(() => linkCopied = false, 2000);
274 + }}>
275 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
276 + {#if linkCopied}
277 + <path d="M20 6L9 17l-5-5"/>
278 + {:else}
279 + <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
280 + <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
281 + {/if}
282 + </svg>
283 + <span>{linkCopied ? 'Copiado' : 'Compartir'}</span>
284 + </button>
285 + </div>
286 + </div>
287 +
288 + <h1 class="title">{finfunInfo?.desc_finfun || 'Cargando...'}</h1>
289 + <p class="subtitle">¿Qué entidades gastan más en esto?</p>
290 +
291 + <!-- Año + Stats -->
292 + <div class="meta-row">
293 + <div class="year-selector">
294 + {#each availableYears as year}
295 + <button class="year-btn" class:active={selectedYear === year} onclick={() => changeYear(year)}>{year}</button>
296 + {/each}
297 + </div>
298 + {#if !loading && entidadesData.length > 0}
299 + <div class="stats">
300 + <span class="stat"><strong>{entidadesData.length}</strong> entidades en total</span>
301 + </div>
302 + {/if}
303 + </div>
304 +
305 + <!-- Slider de rango -->
306 + {#if !loading && entidadesData.length > 0}
307 + <div class="slider-row">
308 + <span class="slider-label">Rango: {sliderMin}% – {sliderMax}%</span>
309 + <span class="slider-info">{filteredCount} entidades · {((filteredTotal / totalDevengado) * 100).toFixed(1)}% del gasto</span>
310 + </div>
311 + <div class="range-slider">
312 + <input type="range" min="0" max="100" step="1" bind:value={sliderMin} oninput={() => { if (sliderMin >= sliderMax) sliderMin = sliderMax - 1; buildTreemap(); }} />
313 + <input type="range" min="0" max="100" step="1" bind:value={sliderMax} oninput={() => { if (sliderMax <= sliderMin) sliderMax = sliderMin + 1; buildTreemap(); }} />
314 + </div>
315 + {/if}
316 + </header>
317 +
318 + <!-- Treemap -->
319 + <div class="treemap-wrapper" bind:this={treemapContainer}>
320 + {#if loading}
321 + <div class="loading"><Spinner size={48} color="var(--theme-texto)" /></div>
322 + {:else if treemapNodes.length > 0}
323 + <svg width={treemapWidth} height={treemapHeight}>
324 + <!-- Group labels (subareas) -->
325 + {#each treemapGroups as group}
326 + {@const gw = group.x1 - group.x0}
327 + {@const color = getSubareaColor(group.data.name)}
328 + {@const label = gw > 150 ? group.data.name : group.data.name.slice(0, Math.floor(gw / 7))}
329 + {#if gw > 50}
330 + <rect
331 + x={group.x0 + 3}
332 + y={group.y0 + 2}
333 + width={Math.min(label.length * 6.5 + 10, gw - 6)}
334 + height="14"
335 + rx="3"
336 + fill={isDark ? 'rgba(20, 20, 18, 0.85)' : 'rgba(255, 255, 255, 0.9)'}
337 + />
338 + <text
339 + x={group.x0 + 8}
340 + y={group.y0 + 13}
341 + fill={isDark ? '#F5F0E8' : '#1a1a1a'}
342 + font-size="9"
343 + font-family="'Qanelas', var(--font-sans)"
344 + font-weight="600"
345 + >
346 + {label}
347 + </text>
348 + {/if}
349 + {/each}
350 +
351 + <!-- Entity nodes -->
352 + {#each treemapNodes as node, i}
353 + {@const w = node.x1 - node.x0}
354 + {@const h = node.y1 - node.y0}
355 + {@const pct = (node.value / totalDevengado) * 100}
356 + {@const isHovered = hoveredNode === node}
357 + {@const area = Math.sqrt(w * h)}
358 + {@const pctSize = Math.min(20, Math.max(9, area / 8))}
359 + {@const nameSize = Math.min(12, Math.max(7, area / 12))}
360 + {@const color = getSubareaColor(node.data.subarea)}
361 +
362 + <g
363 + transform="translate({node.x0}, {node.y0})"
364 + onmouseenter={() => hoveredNode = node}
365 + onmouseleave={() => hoveredNode = null}
366 + style="cursor: pointer;"
367 + onclick={() => goto(`/finfun/${codigo}?entidad=${node.data.id}`)}
368 + >
369 + <rect
370 + width={w} height={h}
371 + fill={color}
372 + opacity={hoveredNode ? (isHovered ? 1 : hoveredNode.data.subarea === node.data.subarea ? 0.6 : 0.2) : 0.85}
373 + stroke={isHovered ? 'var(--theme-titulo)' : 'rgba(0,0,0,0.15)'}
374 + stroke-width={isHovered ? 2 : 0.5}
375 + rx="2"
376 + />
377 + {#if w > 35 && h > 22}
378 + {@const textColor = getTextColorForBg(color)}
379 + {@const textColorSec = getTextColorSecondary(color)}
380 + <foreignObject x="4" y="3" width={w - 8} height={h - 6}>
381 + <div class="node-text">
382 + <span class="node-pct" style="font-size:{pctSize}px; color:{textColor}">{pct >= 1 ? pct.toFixed(0) : pct.toFixed(1)}%</span>
383 + {#if h > 40 && w > 55}
384 + <span class="node-name" style="font-size:{nameSize}px; color:{textColorSec}">{w > 100 ? node.data.name : node.data.name.slice(0, Math.floor(w / 7))}</span>
385 + {/if}
386 + </div>
387 + </foreignObject>
388 + {/if}
389 + </g>
390 + {/each}
391 + </svg>
392 +
393 + {#if hoveredNode}
394 + {@const pct = (hoveredNode.value / totalDevengado) * 100}
395 + <div class="tooltip" style="left:{Math.min(hoveredNode.x0 + 10, treemapWidth - 250)}px; top:{Math.min(hoveredNode.y0 + 10, treemapHeight - 90)}px;">
396 + <span class="tooltip-rank">#{treemapNodes.indexOf(hoveredNode) + 1} de {treemapNodes.length}</span>
397 + <span class="tooltip-subarea" style="color: {getSubareaColor(hoveredNode.data.subarea)}">{hoveredNode.data.subarea}</span>
398 + <span class="tooltip-name">{hoveredNode.data.name}</span>
399 + <span class="tooltip-val">{formatMonto(hoveredNode.value)} Bs · {pct.toFixed(1)}%</span>
400 + </div>
401 + {/if}
402 + {:else}
403 + <div class="loading">No hay datos para {selectedYear}</div>
404 + {/if}
405 + </div>
406 +
407 + </div>
408 +</div>
409 +
410 +<style>
411 + .page {
412 + min-height: 100vh;
413 + width: 100%;
414 + max-width: 100%;
415 + overflow-x: hidden;
416 + background: var(--theme-body);
417 + color: var(--theme-titulo);
418 + font-family: var(--font-sans), -apple-system, sans-serif;
419 + opacity: 0;
420 + transition: opacity 0.4s;
421 + }
422 + .page.mounted { opacity: 1; }
423 +
424 + :global(html:not(.dark)) .page { background: #f5f5f7; }
425 +
426 + .layout {
427 + max-width: 1400px;
428 + margin: 0 auto;
429 + padding: 80px 2rem 2rem;
430 + height: 100vh;
431 + display: flex;
432 + flex-direction: column;
433 + overflow: hidden;
434 + }
435 +
436 + /* Header */
437 + .header { flex-shrink: 0; margin-bottom: 1rem; }
438 +
439 + .header-top {
440 + display: flex;
441 + justify-content: space-between;
442 + align-items: center;
443 + margin-bottom: 0.375rem;
444 + }
445 +
446 + .pills { display: flex; gap: 0.375rem; }
447 +
448 + .pill {
449 + font-family: 'DM Mono', monospace;
450 + font-size: 0.625rem;
451 + padding: 0.125rem 0.5rem;
452 + border-radius: 4px;
453 + letter-spacing: 0.03em;
454 + }
455 + .pill-nivel { background: rgba(201, 167, 81, 0.15); color: var(--theme-accent); }
456 + .pill-codigo { background: var(--theme-borde); color: var(--theme-texto); }
457 +
458 + .header-actions { display: flex; align-items: center; gap: 0.75rem; }
459 +
460 + .copy-link-btn {
461 + display: flex;
462 + align-items: center;
463 + gap: 0.375rem;
464 + font-size: 0.75rem;
465 + color: var(--theme-texto);
466 + opacity: 0.6;
467 + background: none;
468 + border: none;
469 + padding: 0;
470 + cursor: pointer;
471 + transition: opacity 0.15s;
472 + }
473 + .copy-link-btn:hover { opacity: 1; }
474 +
475 + .back-link {
476 + display: inline-flex;
477 + align-items: center;
478 + gap: 0.25rem;
479 + font-family: 'DM Mono', monospace;
480 + font-size: 0.6875rem;
481 + color: var(--theme-accent);
482 + text-decoration: none;
483 + transition: opacity 0.15s;
484 + }
485 + .back-link:hover { opacity: 0.7; }
486 +
487 + .title {
488 + font-family: 'DM Serif Display', serif;
489 + font-size: 2rem;
490 + font-weight: 400;
491 + margin: 0 0 0.25rem;
492 + line-height: 1.15;
493 + }
494 +
495 + .subtitle {
496 + font-size: 0.8125rem;
497 + color: var(--theme-texto);
498 + opacity: 0.6;
499 + margin: 0 0 0.75rem;
500 + }
501 +
502 + .meta-row {
503 + display: flex;
504 + align-items: center;
505 + justify-content: space-between;
506 + gap: 1rem;
507 + }
508 +
509 + .year-selector { display: flex; gap: 0.25rem; overflow-x: auto; scrollbar-width: none; }
510 + .year-selector::-webkit-scrollbar { display: none; }
511 +
512 + .year-btn {
513 + font-family: 'DM Mono', monospace;
514 + font-size: 0.6875rem;
515 + padding: 0.25rem 0.625rem;
516 + border: 1px solid var(--theme-borde);
517 + border-radius: 6px;
518 + background: transparent;
519 + color: var(--theme-texto);
520 + cursor: pointer;
521 + transition: all 0.15s;
522 + }
523 + .year-btn:hover { border-color: var(--theme-accent); color: var(--theme-titulo); }
524 + .year-btn.active {
525 + background: var(--theme-accent);
526 + border-color: var(--theme-accent);
527 + color: white;
528 + font-weight: 600;
529 + }
530 +
531 + .stats {
532 + display: flex;
533 + align-items: center;
534 + gap: 0.375rem;
535 + font-size: 0.75rem;
536 + color: var(--theme-texto);
537 + opacity: 0.7;
538 + }
539 + .stats strong { color: var(--theme-titulo); font-weight: 600; }
540 + .stat-sep { opacity: 0.3; }
541 +
542 + /* Slider */
543 + .slider-row {
544 + display: flex;
545 + justify-content: space-between;
546 + align-items: center;
547 + margin-top: 0.5rem;
548 + }
549 +
550 + .slider-label {
551 + font-family: 'DM Mono', monospace;
552 + font-size: 0.6875rem;
553 + color: var(--theme-titulo);
554 + font-weight: 600;
555 + }
556 +
557 + .slider-info {
558 + font-size: 0.6875rem;
559 + color: var(--theme-texto);
560 + opacity: 0.6;
561 + }
562 +
563 + .range-slider {
564 + position: relative;
565 + height: 20px;
566 + margin-bottom: 0.5rem;
567 + }
568 +
569 + .range-slider input[type="range"] {
570 + position: absolute;
571 + width: 100%;
572 + height: 4px;
573 + top: 8px;
574 + pointer-events: none;
575 + -webkit-appearance: none;
576 + appearance: none;
577 + background: transparent;
578 + }
579 +
580 + .range-slider input[type="range"]:first-child {
581 + background: var(--theme-borde);
582 + border-radius: 2px;
583 + }
584 +
585 + .range-slider input[type="range"]::-webkit-slider-thumb {
586 + -webkit-appearance: none;
587 + appearance: none;
588 + width: 14px;
589 + height: 14px;
590 + border-radius: 50%;
591 + background: var(--theme-accent);
592 + border: 2px solid var(--theme-surface);
593 + cursor: pointer;
594 + pointer-events: all;
595 + box-shadow: 0 1px 3px rgba(0,0,0,0.2);
596 + }
597 +
598 + .range-slider input[type="range"]::-moz-range-thumb {
599 + width: 14px;
600 + height: 14px;
601 + border-radius: 50%;
602 + background: var(--theme-accent);
603 + border: 2px solid var(--theme-surface);
604 + cursor: pointer;
605 + pointer-events: all;
606 + box-shadow: 0 1px 3px rgba(0,0,0,0.2);
607 + }
608 +
609 + /* Treemap */
610 + .treemap-wrapper {
611 + flex: 1;
612 + min-height: 200px;
613 + position: relative;
614 + background: var(--theme-surface);
615 + border-radius: 12px;
616 + overflow: hidden;
617 + }
618 +
619 + .treemap-wrapper svg { display: block; width: 100%; height: 100%; }
620 +
621 + .loading {
622 + position: absolute;
623 + inset: 0;
624 + display: flex;
625 + align-items: center;
626 + justify-content: center;
627 + color: var(--theme-texto);
628 + opacity: 0.5;
629 + font-size: 0.875rem;
630 + }
631 +
632 + .node-text {
633 + display: flex;
634 + flex-direction: column;
635 + overflow: hidden;
636 + }
637 +
638 + .node-pct { font-weight: 700; line-height: 1; }
639 + .node-name { line-height: 1.2; margin-top: 1px; }
640 +
641 + /* Tooltip */
642 + .tooltip {
643 + position: absolute;
644 + pointer-events: none;
645 + padding: 0.5rem 0.75rem;
646 + border-radius: 8px;
647 + z-index: 10;
648 + max-width: 250px;
649 + background: rgba(30, 30, 30, 0.9);
650 + backdrop-filter: blur(12px);
651 + border: 1px solid rgba(255, 255, 255, 0.1);
652 + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
653 + color: #f5f5f5;
654 + display: flex;
655 + flex-direction: column;
656 + gap: 2px;
657 + }
658 +
659 + :global(html:not(.dark)) .tooltip {
660 + background: rgba(255, 255, 255, 0.9);
661 + border-color: rgba(0, 0, 0, 0.1);
662 + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
663 + color: #1a1a1a;
664 + }
665 +
666 + .tooltip-rank { font-size: 0.6rem; opacity: 0.5; text-transform: uppercase; letter-spacing: 0.05em; }
667 + .tooltip-subarea { font-size: 0.6875rem; font-weight: 600; }
668 + .tooltip-name { font-size: 0.8125rem; font-weight: 600; }
669 + .tooltip-val { font-family: 'DM Mono', monospace; font-size: 0.75rem; opacity: 0.7; }
670 +
671 + /* Leyenda */
672 + .legend {
673 + display: flex;
674 + flex-wrap: wrap;
675 + gap: 0.5rem 1rem;
676 + padding-top: 0.5rem;
677 + flex-shrink: 0;
678 + }
679 +
680 + .legend-item {
681 + display: flex;
682 + align-items: center;
683 + gap: 0.25rem;
684 + font-size: 0.625rem;
685 + color: var(--theme-texto);
686 + opacity: 0.7;
687 + }
688 +
689 + .legend-dot {
690 + width: 8px;
691 + height: 8px;
692 + border-radius: 2px;
693 + flex-shrink: 0;
694 + }
695 +
696 + /* Responsive */
697 + @media (max-width: 900px) {
698 + .layout {
699 + padding: 70px 1rem 1rem;
700 + height: auto;
701 + min-height: 100vh;
702 + overflow: visible;
703 + }
704 +
705 + .treemap-wrapper {
706 + min-height: 350px;
707 + height: 50vh;
708 + flex: none;
709 + }
710 + }
711 +
712 + @media (max-width: 640px) {
713 + .layout { padding: 60px 0.75rem 1rem; }
714 + .title { font-size: 1.375rem; }
715 + .subtitle { font-size: 0.75rem; }
716 + .meta-row { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
717 + .header-top { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
718 +
719 + .year-selector {
720 + flex-wrap: wrap;
721 + gap: 0.25rem;
722 + }
723 +
724 + .year-btn {
725 + font-size: 0.625rem;
726 + padding: 0.2rem 0.5rem;
727 + }
728 +
729 + .slider-row {
730 + flex-direction: column;
731 + align-items: flex-start;
732 + gap: 0.125rem;
733 + }
734 +
735 + .stats { font-size: 0.6875rem; }
736 +
737 + .treemap-wrapper {
738 + min-height: 300px;
739 + height: 45vh;
740 + }
741 +
742 + .tooltip {
743 + max-width: 200px;
744 + font-size: 0.75rem;
745 + }
746 +
747 + .header-actions {
748 + width: 100%;
749 + justify-content: space-between;
750 + }
751 + }
752 +
753 + @media (max-width: 380px) {
754 + .layout { padding: 55px 0.5rem 0.75rem; }
755 + .title { font-size: 1.125rem; }
756 +
757 + .treemap-wrapper {
758 + min-height: 250px;
759 + height: 40vh;
760 + }
761 +
762 + .year-btn {
763 + font-size: 0.5625rem;
764 + padding: 0.15rem 0.375rem;
765 + }
766 + }
767 +</style>
...@@ -19,6 +19,8 @@ ...@@ -19,6 +19,8 @@
19 let mounted = $state(false); 19 let mounted = $state(false);
20 let loading = $state(false); 20 let loading = $state(false);
21 let hoveredYear = $state(null); 21 let hoveredYear = $state(null);
22 + let lockedYear = $state(null);
23 + let activeYear = $derived(lockedYear ?? hoveredYear);
22 let showDefinicionesModal = $state(false); 24 let showDefinicionesModal = $state(false);
23 let showMobileSidebar = $state(false); 25 let showMobileSidebar = $state(false);
24 let metrica = $state('percapita'); // 'percapita' | 'monto' 26 let metrica = $state('percapita'); // 'percapita' | 'monto'
...@@ -101,11 +103,11 @@ ...@@ -101,11 +103,11 @@
101 let totalGestion = $derived(datosHistorico?.total_gestion ?? 0); 103 let totalGestion = $derived(datosHistorico?.total_gestion ?? 0);
102 104
103 let currentData = $derived( 105 let currentData = $derived(
104 - hoveredYear ? gastoPerCapita.find(g => g.año === hoveredYear) : null 106 + activeYear ? gastoPerCapita.find(g => g.año === activeYear) : null
105 ); 107 );
106 108
107 - // Datos del año hovereado desde datosAnuales (tiene todos los campos de la API) 109 + // Datos del año activo (fijado o hover) desde datosAnuales
108 - let currentAnual = $derived(hoveredYear ? datosAnuales.find(d => d.gestion === hoveredYear) : null); 110 + let currentAnual = $derived(activeYear ? datosAnuales.find(d => d.gestion === activeYear) : null);
109 111
110 // KPI 1: Monto 112 // KPI 1: Monto
111 let displayMonto = $derived.by(() => { 113 let displayMonto = $derived.by(() => {
...@@ -156,7 +158,7 @@ ...@@ -156,7 +158,7 @@
156 ].filter(e => e.nombre) : [] 158 ].filter(e => e.nombre) : []
157 ); 159 );
158 let displayTopEntidades = $derived( 160 let displayTopEntidades = $derived(
159 - hoveredYear && topEntidadesPorAño[hoveredYear] ? topEntidadesPorAño[hoveredYear] : topEntidadesTotal 161 + activeYear && topEntidadesPorAño[activeYear] ? topEntidadesPorAño[activeYear] : topEntidadesTotal
160 ); 162 );
161 163
162 // Tweens 164 // Tweens
...@@ -188,6 +190,7 @@ ...@@ -188,6 +190,7 @@
188 190
189 async function loadData() { 191 async function loadData() {
190 if (!selectedEntity) return; 192 if (!selectedEntity) return;
193 + lockedYear = null;
191 loading = true; 194 loading = true;
192 try { 195 try {
193 const res = await fetch(`/api/objeto-data?codigo=${objetoCodigo}&tipo=entidad&entidad=${selectedEntity.entidad}`); 196 const res = await fetch(`/api/objeto-data?codigo=${objetoCodigo}&tipo=entidad&entidad=${selectedEntity.entidad}`);
...@@ -215,12 +218,25 @@ ...@@ -215,12 +218,25 @@
215 218
216 async function loadEntidades() { 219 async function loadEntidades() {
217 try { 220 try {
218 - const res = await fetch(`/api/objeto-data?codigo=${objetoCodigo}&tipo=entidades-lista`); 221 + const añosConDatos = datosAnuales.map(d => d.gestion).sort((a, b) => b - a);
219 - const data = await res.json(); 222 + const año = añosConDatos[0] || 2025;
220 - if (Array.isArray(data)) { 223 +
221 - entidades = data 224 + const [entRes, clasRes] = await Promise.all([
222 - .filter(d => d.gestion === 0) 225 + fetch(`/api/objeto-data?codigo=${objetoCodigo}&tipo=entidades-año&gestion=${año}`),
223 - .map(d => ({ entidad: d.entidad, entidad_desc: d.desc_entidad })) 226 + fetch('/api/entidad-clasificador')
227 + ]);
228 + const entData = await entRes.json();
229 + const clasData = await clasRes.json();
230 +
231 + const nombreMap = new Map();
232 + if (Array.isArray(clasData)) {
233 + clasData.forEach(e => nombreMap.set(e.entidad, e.desc_entidad));
234 + }
235 +
236 + if (Array.isArray(entData)) {
237 + entidades = entData
238 + .filter(d => d.monto > 0 && d.entidad !== 0)
239 + .map(d => ({ entidad: d.entidad, entidad_desc: nombreMap.get(d.entidad) || d.desc_entidad || `Entidad ${d.entidad}` }))
224 .sort((a, b) => (a.entidad_desc || '').localeCompare(b.entidad_desc || '')); 240 .sort((a, b) => (a.entidad_desc || '').localeCompare(b.entidad_desc || ''));
225 } 241 }
226 } catch { 242 } catch {
...@@ -463,16 +479,36 @@ ...@@ -463,16 +479,36 @@
463 479
464 // Watch vista changes 480 // Watch vista changes
465 let previousVista = 'agregado'; 481 let previousVista = 'agregado';
482 + let skipPopstate = false;
466 $effect(() => { 483 $effect(() => {
467 if (mounted && vista !== previousVista) { 484 if (mounted && vista !== previousVista) {
468 previousVista = vista; 485 previousVista = vista;
486 + if (!skipPopstate) {
487 + const url = new URL(window.location.href);
469 if (vista === 'comparar') { 488 if (vista === 'comparar') {
470 - initComparacion(); 489 + url.searchParams.set('vista', 'comparar');
471 - updateCompararUrl(); 490 + url.searchParams.delete('entidad');
491 + } else {
492 + url.searchParams.delete('vista');
493 + url.searchParams.delete('entidadA');
494 + url.searchParams.delete('entidadB');
495 + }
496 + history.pushState({ vista }, '', url.toString());
472 } 497 }
498 + skipPopstate = false;
499 + if (vista === 'comparar') initComparacion();
473 } 500 }
474 }); 501 });
475 502
503 + function handlePopstate(e) {
504 + const params = new URL(window.location.href).searchParams;
505 + const urlVista = params.get('vista') || 'agregado';
506 + if (urlVista !== vista) {
507 + skipPopstate = true;
508 + vista = urlVista;
509 + }
510 + }
511 +
476 function formatMonto(m) { 512 function formatMonto(m) {
477 if (m >= 1e9) return (m / 1e9).toLocaleString('es-BO', { maximumFractionDigits: 1 }) + ' MM'; 513 if (m >= 1e9) return (m / 1e9).toLocaleString('es-BO', { maximumFractionDigits: 1 }) + ' MM';
478 if (m >= 1e6) return (m / 1e6).toLocaleString('es-BO', { maximumFractionDigits: 1 }) + ' M'; 514 if (m >= 1e6) return (m / 1e6).toLocaleString('es-BO', { maximumFractionDigits: 1 }) + ' M';
...@@ -583,6 +619,8 @@ ...@@ -583,6 +619,8 @@
583 }); 619 });
584 </script> 620 </script>
585 621
622 +<svelte:window on:popstate={handlePopstate} />
623 +
586 <svelte:head> 624 <svelte:head>
587 <link rel="preconnect" href="https://fonts.googleapis.com" /> 625 <link rel="preconnect" href="https://fonts.googleapis.com" />
588 <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" /> 626 <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" />
...@@ -658,44 +696,26 @@ ...@@ -658,44 +696,26 @@
658 </div> 696 </div>
659 <div class="explore-scroll"> 697 <div class="explore-scroll">
660 <div class="explore-card" onclick={() => vista = 'comparar'} role="button" tabindex="0"> 698 <div class="explore-card" onclick={() => vista = 'comparar'} role="button" tabindex="0">
661 - <svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
662 - <rect x="4" y="14" width="4" height="6"/><rect x="10" y="8" width="4" height="12"/><rect x="16" y="4" width="4" height="16"/>
663 - </svg>
664 <span class="explore-title">Comparar dos entidades</span> 699 <span class="explore-title">Comparar dos entidades</span>
665 <span class="explore-desc">¿Quién gasta más en {objetoData.desc_objeto?.toLowerCase()}?</span> 700 <span class="explore-desc">¿Quién gasta más en {objetoData.desc_objeto?.toLowerCase()}?</span>
666 </div> 701 </div>
667 <a href="/objeto/{objetoData.objeto}/entidades" class="explore-card"> 702 <a href="/objeto/{objetoData.objeto}/entidades" class="explore-card">
668 - <svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
669 - <path d="M3 20h18M5 17h2v3H5zM9 14h2v6H9zM13 11h2v9h-2zM17 8h2v12h-2z"/>
670 - </svg>
671 <span class="explore-title">Ranking</span> 703 <span class="explore-title">Ranking</span>
672 <span class="explore-desc">{nEntidades} entidades que gastan en {objetoData.desc_objeto?.toLowerCase()}</span> 704 <span class="explore-desc">{nEntidades} entidades que gastan en {objetoData.desc_objeto?.toLowerCase()}</span>
673 </a> 705 </a>
674 <a href="/ubicacion?clas=objeto&codigo={objetoData.objeto}" class="explore-card"> 706 <a href="/ubicacion?clas=objeto&codigo={objetoData.objeto}" class="explore-card">
675 - <svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
676 - <path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/>
677 - </svg>
678 <span class="explore-title">Mapa</span> 707 <span class="explore-title">Mapa</span>
679 <span class="explore-desc">Dónde se concentra el gasto en {objetoData.desc_objeto?.toLowerCase()}</span> 708 <span class="explore-desc">Dónde se concentra el gasto en {objetoData.desc_objeto?.toLowerCase()}</span>
680 </a> 709 </a>
681 <a href="/clasificadores/objeto-gasto?modo=mapa" class="explore-card"> 710 <a href="/clasificadores/objeto-gasto?modo=mapa" class="explore-card">
682 - <svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
683 - <circle cx="12" cy="12" r="10"/><path d="M12 2a15 15 0 0 1 4 10 15 15 0 0 1-4 10 15 15 0 0 1-4-10A15 15 0 0 1 12 2z"/>
684 - </svg>
685 <span class="explore-title">Peso relativo</span> 711 <span class="explore-title">Peso relativo</span>
686 <span class="explore-desc">Cuánto pesa {objetoData.desc_objeto?.toLowerCase()} respecto a otros gastos</span> 712 <span class="explore-desc">Cuánto pesa {objetoData.desc_objeto?.toLowerCase()} respecto a otros gastos</span>
687 </a> 713 </a>
688 - <a href="/clasificadores/objeto-gasto?modo=comparar{selectedEntity ? `&entidad=${selectedEntity.entidad}` : ''}{hoveredYear ? `&gestion=${hoveredYear}` : ''}" class="explore-card"> 714 + <a href="/clasificadores/objeto-gasto?modo=comparar{selectedEntity ? `&entidad=${selectedEntity.entidad}` : ''}{activeYear ? `&gestion=${activeYear}` : ''}" class="explore-card">
689 - <svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
690 - <path d="M16 3h5v5M8 3H3v5M16 21h5v-5M8 21H3v-5"/>
691 - </svg>
692 <span class="explore-title">Comparar partidas</span> 715 <span class="explore-title">Comparar partidas</span>
693 <span class="explore-desc">Todas las partidas entre dos momentos o entidades</span> 716 <span class="explore-desc">Todas las partidas entre dos momentos o entidades</span>
694 </a> 717 </a>
695 <a href="/clasificadores/objeto-gasto" class="explore-card"> 718 <a href="/clasificadores/objeto-gasto" class="explore-card">
696 - <svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
697 - <path d="M4 6h16M8 10h12M12 14h8M8 18h12"/>
698 - </svg>
699 <span class="explore-title">Clasificador</span> 719 <span class="explore-title">Clasificador</span>
700 <span class="explore-desc">Ver todos los objetos de gasto y navegar entre ellos</span> 720 <span class="explore-desc">Ver todos los objetos de gasto y navegar entre ellos</span>
701 </a> 721 </a>
...@@ -716,7 +736,6 @@ ...@@ -716,7 +736,6 @@
716 </div> 736 </div>
717 <div class="vigencia-text"> 737 <div class="vigencia-text">
718 <span class="vigencia-main">Comparable en todo el período</span> 738 <span class="vigencia-main">Comparable en todo el período</span>
719 - <span class="vigencia-sub">2005 – 2025</span>
720 </div> 739 </div>
721 </div> 740 </div>
722 </div> 741 </div>
...@@ -818,7 +837,7 @@ ...@@ -818,7 +837,7 @@
818 </div> 837 </div>
819 {:else} 838 {:else}
820 <div class="chart-flex-wrapper"> 839 <div class="chart-flex-wrapper">
821 - <BarChart data={gastoPerCapita} bind:hoveredYear height={200} fill={true} /> 840 + <BarChart data={gastoPerCapita} bind:hoveredYear bind:lockedYear height={200} fill={true} />
822 </div> 841 </div>
823 {/if} 842 {/if}
824 843
...@@ -849,7 +868,7 @@ ...@@ -849,7 +868,7 @@
849 <h3 class="ranking-title">Concentración del gasto</h3> 868 <h3 class="ranking-title">Concentración del gasto</h3>
850 <div class="ranking-summary"> 869 <div class="ranking-summary">
851 <span class="ranking-summary-pct">{($twTop1 + $twTop2 + $twTop3).toFixed(1)}%</span> 870 <span class="ranking-summary-pct">{($twTop1 + $twTop2 + $twTop3).toFixed(1)}%</span>
852 - <span class="ranking-summary-text">de {objetoData.desc_objeto?.toLowerCase()} lo gastan estas 3 entidades{hoveredYear ? ` en ${hoveredYear}` : ''}</span> 871 + <span class="ranking-summary-text">de {objetoData.desc_objeto?.toLowerCase()} lo gastan estas 3 entidades{activeYear ? ` en ${activeYear}` : ''}</span>
853 </div> 872 </div>
854 </div> 873 </div>
855 <div class="ranking-cards"> 874 <div class="ranking-cards">
...@@ -857,7 +876,7 @@ ...@@ -857,7 +876,7 @@
857 {@const pct = i === 0 ? $twTop1 : i === 1 ? $twTop2 : $twTop3} 876 {@const pct = i === 0 ? $twTop1 : i === 1 ? $twTop2 : $twTop3}
858 <button class="ranking-card ranking-card-link" onclick={() => { 877 <button class="ranking-card ranking-card-link" onclick={() => {
859 const match = entidades.find(e => e.entidad === ent.entidad); 878 const match = entidades.find(e => e.entidad === ent.entidad);
860 - if (match) selectedEntity = match; 879 + selectedEntity = match || { entidad: ent.entidad, entidad_desc: ent.nombre };
861 }}> 880 }}>
862 <span class="ranking-position">#{i + 1}</span> 881 <span class="ranking-position">#{i + 1}</span>
863 <span class="ranking-name">{ent.nombre}</span> 882 <span class="ranking-name">{ent.nombre}</span>
...@@ -928,7 +947,6 @@ ...@@ -928,7 +947,6 @@
928 <div class="dropdown-list" onscroll={(e) => handleEntityScroll(e, 'A')}> 947 <div class="dropdown-list" onscroll={(e) => handleEntityScroll(e, 'A')}>
929 {#each filteredEntidadesA as entity} 948 {#each filteredEntidadesA as entity}
930 <button class="dropdown-item" class:active={entidadCompararA?.entidad === entity.entidad} disabled={entidadCompararB?.entidad === entity.entidad} onclick={() => selectEntidadA(entity)}> 949 <button class="dropdown-item" class:active={entidadCompararA?.entidad === entity.entidad} disabled={entidadCompararB?.entidad === entity.entidad} onclick={() => selectEntidadA(entity)}>
931 - <span class="entity-code">{entity.entidad}</span>
932 <span class="truncate">{entity.entidad_desc}</span> 950 <span class="truncate">{entity.entidad_desc}</span>
933 </button> 951 </button>
934 {/each} 952 {/each}
...@@ -953,7 +971,6 @@ ...@@ -953,7 +971,6 @@
953 <div class="dropdown-list" onscroll={(e) => handleEntityScroll(e, 'B')}> 971 <div class="dropdown-list" onscroll={(e) => handleEntityScroll(e, 'B')}>
954 {#each filteredEntidadesB as entity} 972 {#each filteredEntidadesB as entity}
955 <button class="dropdown-item" class:active={entidadCompararB?.entidad === entity.entidad} disabled={entidadCompararA?.entidad === entity.entidad} onclick={() => selectEntidadB(entity)}> 973 <button class="dropdown-item" class:active={entidadCompararB?.entidad === entity.entidad} disabled={entidadCompararA?.entidad === entity.entidad} onclick={() => selectEntidadB(entity)}>
956 - <span class="entity-code">{entity.entidad}</span>
957 <span class="truncate">{entity.entidad_desc}</span> 974 <span class="truncate">{entity.entidad_desc}</span>
958 </button> 975 </button>
959 {/each} 976 {/each}
...@@ -1088,11 +1105,13 @@ ...@@ -1088,11 +1105,13 @@
1088 PAGE 1105 PAGE
1089 ═══════════════════════════════════════════════════════════════ */ 1106 ═══════════════════════════════════════════════════════════════ */
1090 .page { 1107 .page {
1091 - min-height: 100vh; 1108 + height: 100dvh;
1092 width: 100%; 1109 width: 100%;
1093 max-width: 100%; 1110 max-width: 100%;
1094 - overflow-x: hidden; 1111 + overflow: hidden;
1095 background: var(--theme-body); 1112 background: var(--theme-body);
1113 + padding-top: 60px;
1114 + box-sizing: border-box;
1096 color: var(--theme-titulo); 1115 color: var(--theme-titulo);
1097 font-family: var(--font-sans), -apple-system, sans-serif; 1116 font-family: var(--font-sans), -apple-system, sans-serif;
1098 opacity: 0; 1117 opacity: 0;
...@@ -1134,8 +1153,8 @@ ...@@ -1134,8 +1153,8 @@
1134 gap: 1.5rem; 1153 gap: 1.5rem;
1135 max-width: 1400px; 1154 max-width: 1400px;
1136 margin: 0 auto; 1155 margin: 0 auto;
1137 - padding: 80px 2rem 2.5rem; 1156 + padding: 1rem 2rem 2rem;
1138 - height: 100vh; 1157 + height: 100%;
1139 overflow: hidden; 1158 overflow: hidden;
1140 } 1159 }
1141 1160
...@@ -2034,11 +2053,11 @@ ...@@ -2034,11 +2053,11 @@
2034 display: flex; 2053 display: flex;
2035 flex-direction: column; 2054 flex-direction: column;
2036 gap: 0.75rem; 2055 gap: 0.75rem;
2037 - padding: 80px 2rem 2.5rem; 2056 + padding: 1rem 2rem 2rem;
2038 width: 100%; 2057 width: 100%;
2039 max-width: 1400px; 2058 max-width: 1400px;
2040 margin: 0 auto; 2059 margin: 0 auto;
2041 - height: 100vh; 2060 + height: 100%;
2042 overflow: hidden; 2061 overflow: hidden;
2043 } 2062 }
2044 2063
......
...@@ -46,7 +46,10 @@ ...@@ -46,7 +46,10 @@
46 // Años disponibles desde gestiones del objeto 46 // Años disponibles desde gestiones del objeto
47 let availableYears = $derived.by(() => { 47 let availableYears = $derived.by(() => {
48 if (!objetoInfo?.gestiones) return []; 48 if (!objetoInfo?.gestiones) return [];
49 - return objetoInfo.gestiones.split(',').map(Number).filter(y => y >= 2016).sort((a, b) => b - a); 49 + if (objetoInfo.estados?.length > 0) {
50 + return objetoInfo.estados.map(e => e.gestion).filter(y => y >= 2016 && y > 0).sort((a, b) => b - a);
51 + }
52 + return objetoInfo.gestiones.split(',').map(Number).filter(y => y >= 2016 && y <= new Date().getFullYear()).sort((a, b) => b - a);
50 }); 53 });
51 54
52 // Formatters 55 // Formatters
...@@ -285,12 +288,6 @@ ...@@ -285,12 +288,6 @@
285 </svg> 288 </svg>
286 <span>{linkCopied ? 'Copiado' : 'Compartir'}</span> 289 <span>{linkCopied ? 'Copiado' : 'Compartir'}</span>
287 </button> 290 </button>
288 - <a href="/objeto/{codigo}" class="back-link">
289 - <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
290 - <path d="M19 12H5M12 19l-7-7 7-7"/>
291 - </svg>
292 - Volver
293 - </a>
294 </div> 291 </div>
295 </div> 292 </div>
296 293
......
1 -import { supabase } from '$lib/supabase';
2 import { error } from '@sveltejs/kit'; 1 import { error } from '@sveltejs/kit';
3 2
3 +const API_BASE = 'http://136.112.29.74/api/organismo';
4 +
4 export async function load({ params }) { 5 export async function load({ params }) {
5 - console.time('[SERVER] Total load organismo');
6 const { codigo } = params; 6 const { codigo } = params;
7 7
8 - console.time('[SERVER] Query clas_organismos'); 8 + const res = await fetch(`${API_BASE}/${codigo}`);
9 - const { data, error: dbError } = await supabase 9 + if (!res.ok) throw error(404, 'Organismo no encontrado');
10 - .schema('ppto') 10 + const organismo = await res.json();
11 - .from('clas_organismos') 11 +
12 - .select('*') 12 + let padres = [];
13 - .eq('organismo', codigo); 13 + let hijos = [];
14 - console.timeEnd('[SERVER] Query clas_organismos'); 14 +
15 - 15 + try {
16 - if (dbError || !data || data.length === 0) { 16 + const clasRes = await fetch(`${API_BASE}/clasificador`);
17 - throw error(404, 'Organismo no encontrado'); 17 + if (clasRes.ok) {
18 + const clasificador = await clasRes.json();
19 + const current = clasificador.find(c => c.organismo === parseInt(codigo));
20 + if (current) {
21 + // Padres: grupo y subgrupo (como objetos virtuales para navegación)
22 + padres = [
23 + { organismo: `grupo_${current.organismo_grupo}`, desc_organismo: current.desc_organismo_grupo, nivel: 'grupo', sigla: current.sigla_organismo_grupo },
24 + { organismo: `subgrupo_${current.organismo_grupo}_${current.organismo_subgrupo}`, desc_organismo: current.desc_organismo_subgrupo, nivel: 'subgrupo', sigla: current.sigla_organismo_subgrupo }
25 + ];
26 +
27 + // Hijos: otros organismos del mismo subgrupo
28 + hijos = clasificador.filter(c =>
29 + c.organismo_grupo === current.organismo_grupo &&
30 + c.organismo_subgrupo === current.organismo_subgrupo &&
31 + c.organismo !== current.organismo &&
32 + c.organismo !== 0
33 + ).sort((a, b) => (a.desc_organismo || '').localeCompare(b.desc_organismo || ''));
18 } 34 }
19 -
20 - const organismo = data[0];
21 -
22 - // Obtener hermanos (otros organismos del mismo subgrupo)
23 - let hermanos = [];
24 -
25 - console.time('[SERVER] Query hermanos');
26 - const { data: hermanosData } = await supabase
27 - .schema('ppto')
28 - .from('clas_organismos')
29 - .select('*')
30 - .eq('organismo_grupo', organismo.organismo_grupo)
31 - .eq('organismo_sub_grupo', organismo.organismo_sub_grupo)
32 - .neq('organismo', codigo)
33 - .order('organismo');
34 -
35 - if (hermanosData) {
36 - hermanos = hermanosData;
37 } 35 }
38 - console.timeEnd('[SERVER] Query hermanos'); 36 + } catch { /* optional */ }
39 37
40 - console.timeEnd('[SERVER] Total load organismo'); 38 + return { organismo, padres, hijos };
41 - return {
42 - organismo,
43 - hermanos
44 - };
45 } 39 }
......
1 <script> 1 <script>
2 import { onMount } from 'svelte'; 2 import { onMount } from 'svelte';
3 + import { tweened } from 'svelte/motion';
4 + import { cubicOut } from 'svelte/easing';
5 + import { goto } from '$app/navigation';
6 + import { page } from '$app/stores';
7 + import Spinner from '$lib/components/ui/Spinner.svelte';
8 + import VistaToggle from '$lib/components/objeto/VistaToggle.svelte';
9 + import BarChart from '$lib/components/objeto/BarChart.svelte';
10 + import EntitySelector from '$lib/components/objeto/EntitySelector.svelte';
3 11
4 - // Datos del servidor (reactivos)
5 let { data } = $props(); 12 let { data } = $props();
6 let organismoData = $derived(data.organismo); 13 let organismoData = $derived(data.organismo);
7 - let hermanos = $derived(data.hermanos); 14 + let padres = $derived(data.padres || []);
15 + let hijos = $derived(data.hijos);
8 16
9 - // Estado
10 let mounted = $state(false); 17 let mounted = $state(false);
18 + let loading = $state(false);
19 + let hoveredYear = $state(null);
20 + let lockedYear = $state(null);
21 + let activeYear = $derived(lockedYear ?? hoveredYear);
22 + let showMobileSidebar = $state(false);
23 + let metrica = $state('percapita'); // 'percapita' | 'monto'
24 + let vista = $state('agregado'); // 'agregado' | 'comparar'
25 + let linkCopied = $state(false);
26 +
27 + // Inline search state
28 + let searchVal = $state('');
29 + let searchFocused = $state(false);
30 + let isSearching = $state(false);
31 + let localResults = $state([]);
32 + let selectedIdx = $state(-1);
33 + let debounceTimer;
34 + let searchInputRef = $state(null);
35 +
36 + function parseMetadatos(meta) {
37 + if (!meta) return {};
38 + if (typeof meta === 'object') return meta;
39 + try { return JSON.parse(meta); } catch { return {}; }
40 + }
41 +
42 + async function doSearch(query) {
43 + isSearching = true;
44 + try {
45 + const params = new URLSearchParams({
46 + q: query,
47 + per_page: '20',
48 + is_class: 'true',
49 + class_: 'organismo'
50 + });
51 + const res = await fetch(`/api/search?${params}`);
52 + if (!res.ok) throw new Error();
53 + const data = await res.json();
54 + localResults = (data.hits || []).map(hit => {
55 + const meta = parseMetadatos(hit.document.metadatos);
56 + const codigo = meta.organismo;
57 + const nombre = hit.document.texto;
58 + const highlight = hit.highlights?.[0]?.snippet || nombre;
59 + return { codigo, nombre, highlight };
60 + });
61 + selectedIdx = -1;
62 + } catch {
63 + localResults = [];
64 + }
65 + isSearching = false;
66 + }
67 +
68 + function handleSearchInput(e) {
69 + searchVal = e.target.value;
70 + clearTimeout(debounceTimer);
71 + if (searchVal.length >= 2) {
72 + debounceTimer = setTimeout(() => doSearch(searchVal), 200);
73 + } else {
74 + localResults = [];
75 + }
76 + }
77 +
78 + function handleSearchKeydown(e) {
79 + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
80 + e.preventDefault();
81 + if (e.key === 'ArrowDown') {
82 + selectedIdx = selectedIdx < localResults.length - 1 ? selectedIdx + 1 : 0;
83 + } else {
84 + selectedIdx = selectedIdx > 0 ? selectedIdx - 1 : localResults.length - 1;
85 + }
86 + } else if (e.key === 'Enter' && localResults.length > 0) {
87 + e.preventDefault();
88 + const selected = localResults[selectedIdx >= 0 ? selectedIdx : 0];
89 + if (selected) handleSearchSelect(selected);
90 + } else if (e.key === 'Escape') {
91 + searchVal = '';
92 + localResults = [];
93 + searchInputRef?.blur();
94 + }
95 + }
96 +
97 + function handleSearchSelect(result) {
98 + const url = vista === 'comparar'
99 + ? `/organismo/${result.codigo}?vista=comparar`
100 + : `/organismo/${result.codigo}`;
101 + goto(url);
102 + searchVal = '';
103 + localResults = [];
104 + searchFocused = false;
105 + }
106 +
107 + function handleSearchBlur() {
108 + setTimeout(() => { searchFocused = false; }, 150);
109 + }
110 +
111 + function handleClear() {
112 + localResults = [];
113 + searchVal = '';
114 + }
115 +
116 + // Estado selector entidad
117 + let entidades = $state([]);
118 + let selectedEntity = $state(null);
119 + let nEntidades = $derived(entidades.length || data.nEntidades || 0);
120 +
121 + // Datos — inicializar desde estados del server
122 + function initEstados(obj) {
123 + const estadosRaw = obj?.estados || [];
124 + const seen = new Set();
125 + const estados = estadosRaw.filter(d => { if (seen.has(d.gestion)) return false; seen.add(d.gestion); return true; });
126 + const hist = estados.find(d => d.gestion === 0) || null;
127 + const anuales = estados.filter(d => d.gestion >= 2016 && d.gestion > 0);
128 + const topPorAño = {};
129 + anuales.forEach(d => {
130 + topPorAño[d.gestion] = [
131 + { nombre: d.top1_entidad_desc, porcentaje: d.top1_pct, entidad: d.top1_entidad },
132 + { nombre: d.top2_entidad_desc, porcentaje: d.top2_pct, entidad: d.top2_entidad },
133 + { nombre: d.top3_entidad_desc, porcentaje: d.top3_pct, entidad: d.top3_entidad },
134 + ].filter(e => e.nombre);
135 + });
136 + return { hist, anuales, topPorAño };
137 + }
138 +
139 + let { hist: initHist, anuales: initAnuales, topPorAño: initTop } = initEstados(organismoData);
140 + let datosAnuales = $state(initAnuales);
141 + let datosHistorico = $state(initHist);
142 + let topEntidadesPorAño = $state(initTop);
143 +
144 + const POBLACION = 12000000;
145 + let organismoCodigo = $derived(organismoData.organismo);
146 +
147 + let gastoPerCapita = $derived(
148 + datosAnuales.map(d => {
149 + const monto = Number(selectedEntity ? (d.monto ?? d.devengado ?? 0) : (d.total ?? 0)) || 0;
150 + const perCapita = Number(d.per_capita ?? (d.devengado ? d.devengado / POBLACION : 0)) || 0;
151 + return {
152 + año: d.gestion,
153 + monto,
154 + perCapita: metrica === 'percapita' ? perCapita : monto
155 + };
156 + })
157 + );
158 +
159 + let primerAño = $derived(gastoPerCapita.length > 0 ? gastoPerCapita[0].año : 2005);
160 + let ultimoAño = $derived(gastoPerCapita.length > 0 ? gastoPerCapita[gastoPerCapita.length - 1].año : 2025);
161 +
162 + let totalHistorico = $derived(datosHistorico ? (selectedEntity ? (datosHistorico.monto ?? 0) : (datosHistorico.total ?? 0)) : 0);
163 + let promedioPerCapita = $derived(datosHistorico?.per_capita ?? 0);
164 + let totalPorcentaje = $derived(datosHistorico?.prop ?? 0);
165 + let ranking = $derived(datosHistorico?.ranking ?? 0);
166 + let nPares = $derived(datosHistorico?.n_pares ?? 0);
167 + let totalGestion = $derived(datosHistorico?.total_gestion ?? 0);
168 +
169 + let currentData = $derived(
170 + activeYear ? gastoPerCapita.find(g => g.año === activeYear) : null
171 + );
172 +
173 + let currentAnual = $derived(activeYear ? datosAnuales.find(d => d.gestion === activeYear) : null);
174 +
175 + // KPI 1: Monto
176 + let displayMonto = $derived.by(() => {
177 + if (selectedEntity) {
178 + return (currentAnual ? currentAnual.monto : totalHistorico) ?? 0;
179 + }
180 + return (currentAnual ? currentAnual.total_gestion : totalGestion) ?? 0;
181 + });
182 + let displayMontoLabel = $derived.by(() => {
183 + if (selectedEntity) {
184 + return currentAnual ? `gastados en ${currentAnual.gestion}` : `gastados ${primerAño}-${ultimoAño}`;
185 + }
186 + return currentAnual ? `gasto total del Estado en ${currentAnual.gestion}` : `gasto total ${primerAño}-${ultimoAño}`;
187 + });
188 +
189 + // KPI 2: Per cápita
190 + let displayPerCapita = $derived((currentAnual?.per_capita ?? promedioPerCapita) ?? 0);
191 + let displayPerCapitaLabel = $derived(currentAnual ? `por cada boliviano en ${currentAnual.gestion}` : 'por cada boliviano (promedio)');
192 +
193 + // KPI 3: Proporción (prop)
194 + let displayPorcentaje = $derived((currentAnual?.prop ?? totalPorcentaje) ?? 0);
195 + let displayPorcentajeLabel = $derived.by(() => {
196 + if (selectedEntity) {
197 + return currentAnual ? `del total de este organismo en ${currentAnual.gestion}` : 'del total de este organismo';
198 + }
199 + return currentAnual ? `del gasto en ${currentAnual.gestion}` : 'del gasto total';
200 + });
201 +
202 + let displayPeriodo = $derived(currentData ? currentData.año : `${primerAño}-${ultimoAño}`);
203 + let displayRanking = $derived(`${currentAnual ? currentAnual.ranking : ranking} de ${selectedEntity ? (datosHistorico?.n_entidades || nEntidades) : (currentAnual ? currentAnual.n_pares : nPares)}`);
204 +
205 + let displayRankingLabel = $derived.by(() => {
206 + if (selectedEntity) {
207 + return currentAnual ? `entre las que más gastan en ${currentAnual.gestion}` : 'entre las que más gastan';
208 + }
209 + return currentData ? `organismos más gastados en ${currentData.año}` : `organismos más gastados`;
210 + });
211 +
212 + // Top entidades
213 + let topEntidadesTotal = $derived(
214 + datosHistorico && !selectedEntity ? [
215 + { nombre: datosHistorico.top1_entidad_desc, porcentaje: datosHistorico.top1_pct, entidad: datosHistorico.top1_entidad },
216 + { nombre: datosHistorico.top2_entidad_desc, porcentaje: datosHistorico.top2_pct, entidad: datosHistorico.top2_entidad },
217 + { nombre: datosHistorico.top3_entidad_desc, porcentaje: datosHistorico.top3_pct, entidad: datosHistorico.top3_entidad },
218 + ].filter(e => e.nombre) : []
219 + );
220 + let displayTopEntidades = $derived(
221 + activeYear && topEntidadesPorAño[activeYear] ? topEntidadesPorAño[activeYear] : topEntidadesTotal
222 + );
223 +
224 + // Tweens
225 + const tw = { duration: 300, easing: cubicOut };
226 + const twMonto = tweened(0, tw);
227 + const twPerCapita = tweened(0, tw);
228 + const twPorcentaje = tweened(0, tw);
229 + const twTop1 = tweened(0, tw);
230 + const twTop2 = tweened(0, tw);
231 + const twTop3 = tweened(0, tw);
232 +
233 + $effect(() => { twMonto.set(typeof displayMonto === 'number' ? displayMonto : 0); });
234 + $effect(() => { twPerCapita.set(typeof displayPerCapita === 'number' ? displayPerCapita : 0); });
235 + $effect(() => { twPorcentaje.set(typeof displayPorcentaje === 'number' ? displayPorcentaje : 0); });
236 + $effect(() => { if (displayTopEntidades[0]) twTop1.set(displayTopEntidades[0].porcentaje); });
237 + $effect(() => { if (displayTopEntidades[1]) twTop2.set(displayTopEntidades[1].porcentaje); });
238 + $effect(() => { if (displayTopEntidades[2]) twTop3.set(displayTopEntidades[2].porcentaje); });
239 +
240 + function formatMontoLargo(n) {
241 + if (n >= 1e9) return `${(n / 1e9).toFixed(1)} mil mill.`;
242 + if (n >= 1e6) return `${(n / 1e6).toFixed(1)} mill.`;
243 + if (n >= 1e3) return `${(n / 1e3).toFixed(0)} mil`;
244 + return n.toLocaleString('es-BO');
245 + }
246 +
247 + function formatPerCapita(n) {
248 + return n.toLocaleString('es-BO', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
249 + }
250 +
251 + async function loadData() {
252 + if (!selectedEntity) return;
253 + lockedYear = null;
254 + loading = true;
255 + try {
256 + const res = await fetch(`/api/organismo-data?codigo=${organismoCodigo}&tipo=entidad&entidad=${selectedEntity.entidad}`);
257 + const raw = await res.json();
258 + if (Array.isArray(raw)) {
259 + const seen = new Set();
260 + const data = raw.filter(d => { if (seen.has(d.gestion)) return false; seen.add(d.gestion); return true; });
261 + datosHistorico = data.find(d => d.gestion === 0) || null;
262 + datosAnuales = data.filter(d => d.gestion >= 2016);
263 + topEntidadesPorAño = {};
264 + if (datosHistorico) {
265 + twMonto.set(datosHistorico.monto || 0);
266 + twPerCapita.set(datosHistorico.per_capita || 0);
267 + twPorcentaje.set(datosHistorico.prop || 0);
268 + }
269 + }
270 + } catch (err) {
271 + console.error('[loadData] Error:', err);
272 + }
273 + loading = false;
274 + }
275 +
276 + async function loadEntidades() {
277 + try {
278 + const añosConDatos = datosAnuales.map(d => d.gestion).sort((a, b) => b - a);
279 + const año = añosConDatos[0] || 2025;
280 +
281 + const entRes = await fetch(`/api/organismo-data?codigo=${organismoCodigo}&tipo=entidades-año&gestion=${año}`);
282 + const entData = await entRes.json();
283 +
284 + if (Array.isArray(entData)) {
285 + entidades = entData
286 + .filter(d => d.monto > 0 && d.entidad !== 0)
287 + .map(d => ({ entidad: d.entidad, entidad_desc: d.entidad_desc || d.desc_entidad || `Entidad ${d.entidad}` }))
288 + .sort((a, b) => (a.entidad_desc || '').localeCompare(b.entidad_desc || ''));
289 + }
290 + } catch {
291 + entidades = [];
292 + }
293 + }
294 +
295 + // ══════════════════════════════════════════════════════════════
296 + // COMPARACION
297 + // ══════════════════════════════════════════════════════════════
298 + const twMontoA = tweened(0, { duration: 300, easing: cubicOut });
299 + const twMontoB = tweened(0, { duration: 300, easing: cubicOut });
300 + const twPerCapitaA = tweened(0, { duration: 300, easing: cubicOut });
301 + const twPerCapitaB = tweened(0, { duration: 300, easing: cubicOut });
302 +
303 + let hoveredYearComparar = $state(null);
304 + let loadingComparacion = $state(false);
305 + let isDefaultSelection = $state(false);
306 + let alertDefaultDismissed = $state(false);
307 + let alertRangosDismissed = $state(false);
308 +
309 + let entidadCompararA = $state(null);
310 + let entidadCompararB = $state(null);
311 + let datosCompararA = $state({ anual: [], historico: null });
312 + let datosCompararB = $state({ anual: [], historico: null });
313 +
314 + let dropdownAOpen = $state(false);
315 + let dropdownBOpen = $state(false);
316 + let searchA = $state('');
317 + let searchB = $state('');
318 + let entityLimitA = $state(30);
319 + let entityLimitB = $state(30);
320 +
321 + let filteredEntidadesA = $derived.by(() => {
322 + if (!searchA || searchA.length < 2) return entidades.slice(0, entityLimitA);
323 + const q = searchA.toLowerCase();
324 + return entidades.filter(e => e.entidad_desc?.toLowerCase().includes(q)).slice(0, entityLimitA);
325 + });
326 +
327 + let filteredEntidadesB = $derived.by(() => {
328 + if (!searchB || searchB.length < 2) return entidades.slice(0, entityLimitB);
329 + const q = searchB.toLowerCase();
330 + return entidades.filter(e => e.entidad_desc?.toLowerCase().includes(q)).slice(0, entityLimitB);
331 + });
332 +
333 + async function loadDatosEntidad(entidad) {
334 + try {
335 + const res = await fetch(`/api/organismo-data?codigo=${organismoCodigo}&tipo=entidad&entidad=${entidad.entidad}`);
336 + const raw = await res.json();
337 + if (Array.isArray(raw)) {
338 + const seen = new Set();
339 + const data = raw.filter(d => { if (seen.has(d.gestion)) return false; seen.add(d.gestion); return true; });
340 + return { anual: data.filter(d => d.gestion >= 2016), historico: data.find(d => d.gestion === 0) || null };
341 + }
342 + } catch {}
343 + return { anual: [], historico: null };
344 + }
345 +
346 + async function initComparacion() {
347 + if (entidadCompararA || entidadCompararB) return;
348 + if (selectedEntity) {
349 + entidadCompararA = selectedEntity;
350 + loadingComparacion = true;
351 + datosCompararA = await loadDatosEntidad(selectedEntity);
352 + loadingComparacion = false;
353 + isDefaultSelection = false;
354 + } else if (topEntidadesTotal.length >= 2) {
355 + const top1 = entidades.find(e => e.entidad_desc === topEntidadesTotal[0].nombre);
356 + const top2 = entidades.find(e => e.entidad_desc === topEntidadesTotal[1].nombre);
357 + if (top1 && top2) {
358 + isDefaultSelection = true;
359 + loadingComparacion = true;
360 + entidadCompararA = top1;
361 + datosCompararA = await loadDatosEntidad(top1);
362 + entidadCompararB = top2;
363 + datosCompararB = await loadDatosEntidad(top2);
364 + loadingComparacion = false;
365 + }
366 + }
367 + }
368 +
369 + async function selectEntidadA(entidad) {
370 + entidadCompararA = entidad;
371 + dropdownAOpen = false;
372 + searchA = '';
373 + entityLimitA = 30;
374 + isDefaultSelection = false;
375 + loadingComparacion = true;
376 + datosCompararA = await loadDatosEntidad(entidad);
377 + loadingComparacion = false;
378 + updateCompararUrl();
379 + }
380 +
381 + async function selectEntidadB(entidad) {
382 + entidadCompararB = entidad;
383 + dropdownBOpen = false;
384 + searchB = '';
385 + entityLimitB = 30;
386 + isDefaultSelection = false;
387 + loadingComparacion = true;
388 + datosCompararB = await loadDatosEntidad(entidad);
389 + loadingComparacion = false;
390 + updateCompararUrl();
391 + }
392 +
393 + function handleEntityScroll(event, type) {
394 + const el = event.target;
395 + if (el.scrollHeight - el.scrollTop - el.clientHeight < 50) {
396 + if (type === 'A' && entityLimitA < entidades.length) entityLimitA += 30;
397 + if (type === 'B' && entityLimitB < entidades.length) entityLimitB += 30;
398 + }
399 + }
400 +
401 + let rangoAños = $state('todo');
402 +
403 + let añosComunesTotales = $derived.by(() => {
404 + const añosA = datosCompararA.anual.map(d => d.gestion);
405 + const añosB = datosCompararB.anual.map(d => d.gestion);
406 + if (añosA.length && añosB.length) {
407 + const setB = new Set(añosB);
408 + return añosA.filter(a => setB.has(a)).sort((a, b) => a - b);
409 + }
410 + if (añosA.length) return [...añosA].sort((a, b) => a - b);
411 + if (añosB.length) return [...añosB].sort((a, b) => a - b);
412 + return [];
413 + });
414 +
415 + let soloUnaEntidad = $derived((entidadCompararA && !entidadCompararB) || (!entidadCompararA && entidadCompararB));
416 +
417 + let rangoInfoA = $derived.by(() => {
418 + if (!datosCompararA.anual.length) return null;
419 + const años = datosCompararA.anual.map(d => d.gestion).sort((a, b) => a - b);
420 + return { desde: años[0], hasta: años[años.length - 1], total: años.length };
421 + });
422 +
423 + let rangoInfoB = $derived.by(() => {
424 + if (!datosCompararB.anual.length) return null;
425 + const años = datosCompararB.anual.map(d => d.gestion).sort((a, b) => a - b);
426 + return { desde: años[0], hasta: años[años.length - 1], total: años.length };
427 + });
428 +
429 + let tienenRangosDiferentes = $derived.by(() => {
430 + if (!rangoInfoA || !rangoInfoB) return false;
431 + return rangoInfoA.desde !== rangoInfoB.desde || rangoInfoA.hasta !== rangoInfoB.hasta;
432 + });
433 +
434 + let añosComparacion = $derived.by(() => {
435 + if (rangoAños === 'todo') return añosComunesTotales;
436 + return añosComunesTotales.slice(-parseInt(rangoAños));
437 + });
438 +
439 + let datosGraficoA = $derived.by(() => {
440 + const map = new Map(datosCompararA.anual.map(d => [d.gestion, d]));
441 + return añosComparacion.map(año => map.get(año) || { per_capita: 0, monto: 0 });
442 + });
443 +
444 + let datosGraficoB = $derived.by(() => {
445 + const map = new Map(datosCompararB.anual.map(d => [d.gestion, d]));
446 + return añosComparacion.map(año => map.get(año) || { per_capita: 0, monto: 0 });
447 + });
448 +
449 + function valComparar(d) {
450 + return metrica === 'percapita' ? (d?.per_capita || 0) : (d?.monto || 0);
451 + }
452 +
453 + let maxComparacion = $derived.by(() => {
454 + const all = [...datosGraficoA.map(d => valComparar(d)), ...datosGraficoB.map(d => valComparar(d))];
455 + return Math.max(...all, 1) * 1.1;
456 + });
457 +
458 + let displayPeriodoComparar = $derived.by(() => {
459 + if (hoveredYearComparar) return hoveredYearComparar;
460 + if (añosComparacion.length > 0) return `${añosComparacion[0]}-${añosComparacion[añosComparacion.length - 1]}`;
461 + return 'Selecciona entidades';
462 + });
463 +
464 + let displayPropA = $derived.by(() => {
465 + if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoA[idx]?.prop || 0; }
466 + return datosCompararA.historico?.prop || 0;
467 + });
468 + let displayPropB = $derived.by(() => {
469 + if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoB[idx]?.prop || 0; }
470 + return datosCompararB.historico?.prop || 0;
471 + });
472 + let displayRankingA = $derived.by(() => {
473 + if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoA[idx]?.ranking || '-'; }
474 + return datosCompararA.historico?.ranking || '-';
475 + });
476 + let displayRankingB = $derived.by(() => {
477 + if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoB[idx]?.ranking || '-'; }
478 + return datosCompararB.historico?.ranking || '-';
479 + });
480 + let displayNEntidadesA = $derived.by(() => {
481 + if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoA[idx]?.n_entidades || '-'; }
482 + return datosCompararA.historico?.n_entidades || '-';
483 + });
484 + let displayNEntidadesB = $derived.by(() => {
485 + if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoB[idx]?.n_entidades || '-'; }
486 + return datosCompararB.historico?.n_entidades || '-';
487 + });
488 +
489 + let isDarkMode = $state(false);
490 + $effect(() => {
491 + if (typeof document !== 'undefined') {
492 + const checkDark = () => { isDarkMode = document.documentElement.classList.contains('dark'); };
493 + checkDark();
494 + const observer = new MutationObserver(checkDark);
495 + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
496 + return () => observer.disconnect();
497 + }
498 + });
499 +
500 + let colorA = $derived(isDarkMode ? '#7EB8DA' : '#6B8299');
501 + let colorB = $derived(isDarkMode ? '#D4A574' : '#C4897D');
502 +
503 + function toNumber(v) { return typeof v === 'number' ? v : 0; }
504 +
505 + $effect(() => {
506 + const year = hoveredYearComparar;
507 + const idx = year ? añosComparacion.indexOf(year) : -1;
508 + twMontoA.set(idx >= 0 ? toNumber(datosGraficoA[idx]?.monto) : toNumber(datosCompararA.historico?.monto));
509 + twMontoB.set(idx >= 0 ? toNumber(datosGraficoB[idx]?.monto) : toNumber(datosCompararB.historico?.monto));
510 + twPerCapitaA.set(idx >= 0 ? toNumber(datosGraficoA[idx]?.per_capita) : toNumber(datosCompararA.historico?.per_capita));
511 + twPerCapitaB.set(idx >= 0 ? toNumber(datosGraficoB[idx]?.per_capita) : toNumber(datosCompararB.historico?.per_capita));
512 + });
513 +
514 + function updateCompararUrl() {
515 + if (!mounted) return;
516 + const url = new URL($page.url);
517 + url.searchParams.set('vista', 'comparar');
518 + url.searchParams.delete('entidad');
519 + if (entidadCompararA) url.searchParams.set('entidadA', entidadCompararA.entidad);
520 + else url.searchParams.delete('entidadA');
521 + if (entidadCompararB) url.searchParams.set('entidadB', entidadCompararB.entidad);
522 + else url.searchParams.delete('entidadB');
523 + goto(url.toString(), { replaceState: true, noScroll: true });
524 + }
525 +
526 + let previousVista = 'agregado';
527 + let skipPopstate = false;
528 + $effect(() => {
529 + if (mounted && vista !== previousVista) {
530 + previousVista = vista;
531 + if (!skipPopstate) {
532 + const url = new URL(window.location.href);
533 + if (vista === 'comparar') {
534 + url.searchParams.set('vista', 'comparar');
535 + url.searchParams.delete('entidad');
536 + } else {
537 + url.searchParams.delete('vista');
538 + url.searchParams.delete('entidadA');
539 + url.searchParams.delete('entidadB');
540 + }
541 + history.pushState({ vista }, '', url.toString());
542 + }
543 + skipPopstate = false;
544 + if (vista === 'comparar') initComparacion();
545 + }
546 + });
547 +
548 + function handlePopstate(e) {
549 + const params = new URL(window.location.href).searchParams;
550 + const urlVista = params.get('vista') || 'agregado';
551 + if (urlVista !== vista) {
552 + skipPopstate = true;
553 + vista = urlVista;
554 + }
555 + }
556 +
557 + function formatMonto(m) {
558 + if (m >= 1e9) return (m / 1e9).toLocaleString('es-BO', { maximumFractionDigits: 1 }) + ' MM';
559 + if (m >= 1e6) return (m / 1e6).toLocaleString('es-BO', { maximumFractionDigits: 1 }) + ' M';
560 + if (m >= 1e3) return Math.round(m / 1e3).toLocaleString('es-BO') + ' K';
561 + return m?.toLocaleString('es-BO') || '0';
562 + }
563 +
564 + // ══════════════════════════════════════════════════════════════
11 565
12 onMount(() => { 566 onMount(() => {
13 - setTimeout(() => { mounted = true; }, 50); 567 + mounted = true;
568 + loadEntidades().then(async () => {
569 + const urlVista = $page.url.searchParams.get('vista');
570 + const urlEntidad = $page.url.searchParams.get('entidad');
571 + const urlEntidadA = $page.url.searchParams.get('entidadA');
572 + const urlEntidadB = $page.url.searchParams.get('entidadB');
573 +
574 + if (urlVista === 'comparar') {
575 + vista = 'comparar';
576 + if (urlEntidadA) {
577 + const entA = entidades.find(e => e.entidad === parseInt(urlEntidadA));
578 + if (entA) {
579 + entidadCompararA = entA;
580 + loadingComparacion = true;
581 + datosCompararA = await loadDatosEntidad(entA);
582 + }
583 + }
584 + if (urlEntidadB) {
585 + const entB = entidades.find(e => e.entidad === parseInt(urlEntidadB));
586 + if (entB) {
587 + entidadCompararB = entB;
588 + datosCompararB = await loadDatosEntidad(entB);
589 + }
590 + }
591 + loadingComparacion = false;
592 + if (!urlEntidadA && !urlEntidadB) initComparacion();
593 + } else if (urlEntidad && entidades.length > 0) {
594 + const ent = entidades.find(e => e.entidad === parseInt(urlEntidad));
595 + if (ent) selectedEntity = ent;
596 + }
597 + });
598 + });
599 +
600 + // Recargar cuando cambia el organismo (navegacion entre organismos)
601 + let prevCodigo = null;
602 + $effect(() => {
603 + const code = organismoCodigo;
604 + if (prevCodigo !== null && code !== prevCodigo) {
605 + entidadCompararA = null;
606 + entidadCompararB = null;
607 + datosCompararA = { anual: [], historico: null };
608 + datosCompararB = { anual: [], historico: null };
609 +
610 + const { hist, anuales, topPorAño } = initEstados(organismoData);
611 + datosHistorico = hist;
612 + datosAnuales = anuales;
613 + topEntidadesPorAño = topPorAño;
614 + loading = false;
615 +
616 + const prevEntity = selectedEntity;
617 + loadEntidades().then(() => {
618 + if (prevEntity) {
619 + const match = entidades.find(e => e.entidad === prevEntity.entidad);
620 + if (match) {
621 + selectedEntity = match;
622 + loadData();
623 + } else {
624 + selectedEntity = null;
625 + }
626 + }
627 + });
628 + }
629 + prevCodigo = code;
630 + });
631 +
632 + // Recargar cuando cambia la entidad seleccionada
633 + let prevEntity = null;
634 + $effect(() => {
635 + const ent = selectedEntity;
636 + if (ent !== prevEntity) {
637 + prevEntity = ent;
638 + if (ent) {
639 + loadData();
640 + } else if (prevCodigo) {
641 + const { hist, anuales, topPorAño } = initEstados(organismoData);
642 + datosHistorico = hist;
643 + datosAnuales = anuales;
644 + topEntidadesPorAño = topPorAño;
645 + loading = false;
646 + }
647 + if (mounted) {
648 + const url = new URL($page.url);
649 + if (ent) {
650 + url.searchParams.set('entidad', ent.entidad);
651 + } else {
652 + url.searchParams.delete('entidad');
653 + }
654 + goto(url.toString(), { replaceState: true, noScroll: true });
655 + }
656 + }
14 }); 657 });
15 </script> 658 </script>
16 659
660 +<svelte:window on:popstate={handlePopstate} />
661 +
17 <svelte:head> 662 <svelte:head>
18 - <title>{organismoData.organismo_desc_organismo || organismoData.organismo} | Clasificador de Organismos</title> 663 + <title>{organismoData?.desc_organismo || `Organismo ${organismoData?.organismo}`} | Presupuesto Abierto</title>
19 <link rel="preconnect" href="https://fonts.googleapis.com" /> 664 <link rel="preconnect" href="https://fonts.googleapis.com" />
20 <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" /> 665 <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" />
21 </svelte:head> 666 </svelte:head>
22 667
23 <div class="page" class:mounted> 668 <div class="page" class:mounted>
24 - <!-- Link a clasificadores -->
25 - <nav class="page-nav">
26 - <a href="/clasificadores/organismos" class="back-link">
27 - <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
28 - <path d="M19 12H5M12 19l-7-7 7-7"/>
29 - </svg>
30 - Clasificador
31 - </a>
32 - </nav>
33 669
670 + {#if vista === 'agregado'}
34 <div class="dashboard-layout"> 671 <div class="dashboard-layout">
35 - <!-- CONTEXTO IZQUIERDO --> 672 + <!-- Sidebar -->
36 - <aside class="dashboard-context"> 673 + <aside class="dashboard-context" class:mobile-open={showMobileSidebar}>
674 + <!-- Mobile toggle -->
675 + <button class="mobile-sidebar-toggle" onclick={() => showMobileSidebar = !showMobileSidebar}>
676 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
677 + <circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
678 + </svg>
679 + <span>{showMobileSidebar ? 'Ocultar herramientas' : 'Herramientas'}</span>
680 + </button>
681 +
37 <div class="context-card"> 682 <div class="context-card">
38 - <h2 class="context-main-title">Contexto</h2> 683 + <h2 class="context-main-title">Herramientas</h2>
684 +
685 + <!-- Buscar -->
686 + <div class="context-section">
687 + <h3 class="context-subtitle">Buscar</h3>
688 + <div class="inline-search" class:focused={searchFocused}>
689 + <svg class="inline-search-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
690 + <circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>
691 + </svg>
692 + <input
693 + bind:this={searchInputRef}
694 + type="text"
695 + class="inline-search-input"
696 + placeholder="Buscar organismo..."
697 + value={searchVal}
698 + oninput={handleSearchInput}
699 + onfocus={() => searchFocused = true}
700 + onblur={handleSearchBlur}
701 + onkeydown={handleSearchKeydown}
702 + />
703 + {#if searchVal}
704 + <button class="inline-search-clear" onclick={handleClear}>
705 + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
706 + <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
707 + </svg>
708 + </button>
709 + {/if}
710 + {#if localResults.length > 0 && searchFocused}
711 + <div class="inline-search-results">
712 + {#each localResults as result, i}
713 + <button class="inline-search-item" class:selected={selectedIdx === i} onclick={() => handleSearchSelect(result)}>
714 + <span class="inline-search-code">{result.codigo}</span>
715 + <span class="inline-search-name">{@html result.highlight}</span>
716 + </button>
717 + {/each}
718 + </div>
719 + {/if}
720 + </div>
721 + </div>
722 +
723 + <div class="context-separator"></div>
39 724
40 - <!-- Info del grupo y subgrupo --> 725 + <!-- Navegación jerárquica: grupo → subgrupo → organismo -->
726 + {#if padres.length > 0}
41 <div class="context-section"> 727 <div class="context-section">
42 - <h3 class="context-subtitle">Ubicación</h3> 728 + <h3 class="context-subtitle">Navegación</h3>
43 - <div class="ubicacion-info"> 729 + <div class="tree">
44 - <div class="ubicacion-item"> 730 + {#each padres as padre, i}
45 - <span class="ubicacion-label">Grupo {organismoData.organismo_grupo}</span> 731 + <div class="tree-node has-tooltip" style="--indent: {i}" data-tooltip="{padre.desc_organismo}">
46 - <span class="ubicacion-value">{organismoData.organismo_desc_grupo}</span> 732 + <span class="tree-dot"></span>
733 + <span class="tree-name">{padre.desc_organismo}</span>
47 </div> 734 </div>
48 - <div class="ubicacion-item"> 735 + {/each}
49 - <span class="ubicacion-label">Subgrupo {organismoData.organismo_sub_grupo}</span> 736 +
50 - <span class="ubicacion-value">{organismoData.organismo_desc_sub_grupo}</span> 737 + <div class="tree-node tree-node-current has-tooltip" style="--indent: {padres.length}" data-tooltip="{organismoData.desc_organismo}">
738 + <span class="tree-dot"></span>
739 + <span class="tree-name">{organismoData.desc_organismo}</span>
51 </div> 740 </div>
52 </div> 741 </div>
53 </div> 742 </div>
54 743
55 - <!-- Hermanos -->
56 - {#if hermanos.length > 0}
57 <div class="context-separator"></div> 744 <div class="context-separator"></div>
745 + {/if}
746 +
747 + <!-- Explorar -->
58 <div class="context-section"> 748 <div class="context-section">
59 - <h3 class="context-subtitle">Mismo subgrupo ({hermanos.length})</h3> 749 + <div class="explore-header">
60 - <p class="context-hint">Otros organismos del mismo subgrupo.</p> 750 + <h3 class="context-subtitle">Explorar visualizaciones</h3>
61 - <div class="hermanos-list"> 751 + <span class="explore-count">5 →</span>
62 - {#each hermanos.slice(0, 10) as hermano} 752 + </div>
63 - <a href="/organismo/{hermano.organismo}" class="hermano-link"> 753 + <div class="explore-scroll">
64 - <span class="hermano-code">{hermano.organismo}</span> 754 + <div class="explore-card" onclick={() => vista = 'comparar'} role="button" tabindex="0">
65 - <span class="hermano-name">{hermano.organismo_desc_organismo}</span> 755 + <span class="explore-title">Comparar dos entidades</span>
756 + <span class="explore-desc">Quien gasta mas con financiamiento de {organismoData.desc_organismo?.toLowerCase()}?</span>
757 + </div>
758 + <a href="/organismo/{organismoData.organismo}/entidades" class="explore-card">
759 + <span class="explore-title">Ranking</span>
760 + <span class="explore-desc">{nEntidades} entidades financiadas por {organismoData.desc_organismo?.toLowerCase()}</span>
761 + </a>
762 + <a href="/clasificadores/organismos" class="explore-card">
763 + <span class="explore-title">Peso relativo</span>
764 + <span class="explore-desc">Cuanto pesa {organismoData.desc_organismo?.toLowerCase()} respecto a otros organismos</span>
765 + </a>
766 + <a href="/clasificadores/organismos?modo=comparar{selectedEntity ? `&entidad=${selectedEntity.entidad}` : ''}{activeYear ? `&gestion=${activeYear}` : ''}" class="explore-card">
767 + <span class="explore-title">Comparar organismos</span>
768 + <span class="explore-desc">Todos los organismos entre dos momentos o entidades</span>
769 + </a>
770 + <a href="/clasificadores/organismos" class="explore-card">
771 + <span class="explore-title">Clasificador</span>
772 + <span class="explore-desc">Ver todos los organismos financiadores y navegar entre ellos</span>
66 </a> 773 </a>
774 + </div>
775 + </div>
776 +
777 + <div class="context-separator"></div>
778 +
779 + <!-- Comparabilidad -->
780 + <div class="context-section">
781 + <h3 class="context-subtitle">Comparabilidad</h3>
782 + <div class="vigencia-compact">
783 + <div class="vigencia-status-icon vigencia-full">
784 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
785 + <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
786 + <polyline points="22 4 12 14.01 9 11.01"/>
787 + </svg>
788 + </div>
789 + <div class="vigencia-text">
790 + <span class="vigencia-main">Comparable en todo el periodo</span>
791 + </div>
792 + </div>
793 + </div>
794 +
795 + <div class="context-separator"></div>
796 +
797 + <!-- Compartir -->
798 + <div class="context-section">
799 + <h3 class="context-subtitle">Compartir</h3>
800 + <button class="copy-link-btn" onclick={() => {
801 + navigator.clipboard.writeText(window.location.href);
802 + linkCopied = true;
803 + setTimeout(() => linkCopied = false, 2000);
804 + }}>
805 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
806 + {#if linkCopied}
807 + <path d="M20 6L9 17l-5-5"/>
808 + {:else}
809 + <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
810 + <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
811 + {/if}
812 + </svg>
813 + <span>{linkCopied ? 'Link copiado' : 'Copiar link para compartir'}</span>
814 + </button>
815 + </div>
816 + </div>
817 + </aside>
818 +
819 + <!-- Main -->
820 + <main class="dashboard-main">
821 + <div class="chart-protagonista">
822 + <!-- Header -->
823 + <div class="chart-top-row">
824 + <div class="main-title-pills">
825 + <span class="pill pill-nivel">Organismo</span>
826 + <span class="pill pill-codigo">{organismoData.organismo}</span>
827 + </div>
828 + </div>
829 +
830 + <h2 class="main-title-name">{organismoData.desc_organismo}</h2>
831 +
832 + <div class="chart-controls">
833 + <EntitySelector {entidades} bind:selectedEntity {nEntidades} />
834 + {#if selectedEntity}
835 + <button class="volver-agregado" onclick={() => { selectedEntity = null; }}>
836 + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
837 + <path d="M19 12H5M12 19l-7-7 7-7"/>
838 + </svg>
839 + Todo el Estado
840 + </button>
841 + {/if}
842 + </div>
843 +
844 + <div class="chart-header">
845 + <span class="chart-title">
846 + <button class="metrica-toggle" onclick={() => metrica = metrica === 'percapita' ? 'monto' : 'percapita'}>
847 + {metrica === 'percapita' ? 'Bs/habitante' : 'Bs (monto total)'}
848 + <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 16V4m0 0L3 8m4-4l4 4M17 8v12m0 0l4-4m-4 4l-4-4"/></svg>
849 + </button>
850 + <span class="chart-period">· {displayPeriodo}</span>
851 + </span>
852 + </div>
853 +
854 + {#if loading}
855 + <div class="chart-loading" style="flex: 1; min-height: 100px;">
856 + <span>Cargando...</span>
857 + </div>
858 + {:else if gastoPerCapita.length === 0 && selectedEntity}
859 + <div class="chart-no-data">
860 + <p>Esta entidad no tiene registros en {organismoData.desc_organismo?.toLowerCase()} desde 2016</p>
861 + <button class="volver-agregado" onclick={() => { selectedEntity = null; }}>
862 + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
863 + <path d="M19 12H5M12 19l-7-7 7-7"/>
864 + </svg>
865 + Volver a Todo el Estado
866 + </button>
867 + </div>
868 + {:else}
869 + <div class="chart-flex-wrapper">
870 + <BarChart data={gastoPerCapita} bind:hoveredYear bind:lockedYear height={200} fill={true} />
871 + </div>
872 + {/if}
873 +
874 + <!-- KPIs -->
875 + <div class="kpis">
876 + <div class="kpi">
877 + <span class="kpi-value">{formatMontoLargo(Math.round($twMonto))}</span>
878 + <span class="kpi-label">{displayMontoLabel}</span>
879 + </div>
880 + <div class="kpi">
881 + <span class="kpi-value">{formatPerCapita($twPerCapita)} Bs.</span>
882 + <span class="kpi-label">{displayPerCapitaLabel}</span>
883 + </div>
884 + <div class="kpi">
885 + <span class="kpi-value">{$twPorcentaje.toLocaleString('es-BO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</span>
886 + <span class="kpi-label">{displayPorcentajeLabel}</span>
887 + </div>
888 + <div class="kpi">
889 + <span class="kpi-value">{displayRanking}</span>
890 + <span class="kpi-label">{displayRankingLabel}</span>
891 + </div>
892 + </div>
893 + </div>
894 +
895 + {#if !selectedEntity && displayTopEntidades.length > 0}
896 + <div class="ranking-section">
897 + <div class="ranking-header">
898 + <h3 class="ranking-title">Concentracion del gasto</h3>
899 + <div class="ranking-summary">
900 + <span class="ranking-summary-pct">{($twTop1 + $twTop2 + $twTop3).toFixed(1)}%</span>
901 + <span class="ranking-summary-text">de {organismoData.desc_organismo?.toLowerCase()} lo gastan estas 3 entidades{activeYear ? ` en ${activeYear}` : ''}</span>
902 + </div>
903 + </div>
904 + <div class="ranking-cards">
905 + {#each displayTopEntidades as ent, i}
906 + {@const pct = i === 0 ? $twTop1 : i === 1 ? $twTop2 : $twTop3}
907 + <button class="ranking-card ranking-card-link" onclick={() => {
908 + const match = entidades.find(e => e.entidad === ent.entidad);
909 + selectedEntity = match || { entidad: ent.entidad, entidad_desc: ent.nombre };
910 + }}>
911 + <span class="ranking-position">#{i + 1}</span>
912 + <span class="ranking-name">{ent.nombre}</span>
913 + <span class="ranking-pct">{pct.toFixed(1)}%</span>
914 + <svg class="ranking-arrow" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
915 + </button>
67 {/each} 916 {/each}
68 - {#if hermanos.length > 10} 917 + </div>
69 - <span class="hermanos-more">+{hermanos.length - 10} más</span> 918 + </div>
70 {/if} 919 {/if}
920 + </main>
921 + </div>
922 +
923 + {:else}
924 + <!-- VISTA COMPARAR -->
925 + <main class="main-comparar">
926 + <div class="chart-card-comparar">
927 + <div class="chart-top-row">
928 + <div class="main-title-pills">
929 + <span class="pill pill-nivel">Organismo</span>
930 + <span class="pill pill-codigo">{organismoData.organismo}</span>
931 + </div>
932 + <VistaToggle bind:vista />
933 + </div>
934 +
935 + <h2 class="main-title-name">{organismoData.desc_organismo}</h2>
936 +
937 + <div class="comparador-selectors">
938 + <div class="selector-group comparador-dropdown-container">
939 + <span class="selector-dot" style="background: {colorA}"></span>
940 + <div class="relative" style="flex: 1;">
941 + <button class="selector-btn comparador-btn" onclick={() => { dropdownAOpen = !dropdownAOpen; dropdownBOpen = false; }}>
942 + <span class="truncate">{entidadCompararA ? entidadCompararA.entidad_desc : 'Seleccionar entidad'}</span>
943 + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
944 + </button>
945 + {#if dropdownAOpen}
946 + <div class="dropdown-panel comparador-panel">
947 + <div class="dropdown-search"><input type="text" bind:value={searchA} placeholder="Buscar entidad..." class="search-input" /></div>
948 + <div class="dropdown-list" onscroll={(e) => handleEntityScroll(e, 'A')}>
949 + {#each filteredEntidadesA as entity}
950 + <button class="dropdown-item" class:active={entidadCompararA?.entidad === entity.entidad} disabled={entidadCompararB?.entidad === entity.entidad} onclick={() => selectEntidadA(entity)}>
951 + <span class="truncate">{entity.entidad_desc}</span>
952 + </button>
953 + {/each}
71 </div> 954 </div>
72 </div> 955 </div>
73 {/if} 956 {/if}
74 </div> 957 </div>
75 - </aside> 958 + </div>
959 +
960 + <span class="vs">vs</span>
961 +
962 + <div class="selector-group comparador-dropdown-container">
963 + <span class="selector-dot" style="background: {colorB}"></span>
964 + <div class="relative" style="flex: 1;">
965 + <button class="selector-btn comparador-btn" onclick={() => { dropdownBOpen = !dropdownBOpen; dropdownAOpen = false; }}>
966 + <span class="truncate">{entidadCompararB ? entidadCompararB.entidad_desc : 'Seleccionar entidad'}</span>
967 + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
968 + </button>
969 + {#if dropdownBOpen}
970 + <div class="dropdown-panel comparador-panel">
971 + <div class="dropdown-search"><input type="text" bind:value={searchB} placeholder="Buscar entidad..." class="search-input" /></div>
972 + <div class="dropdown-list" onscroll={(e) => handleEntityScroll(e, 'B')}>
973 + {#each filteredEntidadesB as entity}
974 + <button class="dropdown-item" class:active={entidadCompararB?.entidad === entity.entidad} disabled={entidadCompararA?.entidad === entity.entidad} onclick={() => selectEntidadB(entity)}>
975 + <span class="truncate">{entity.entidad_desc}</span>
976 + </button>
977 + {/each}
978 + </div>
979 + </div>
980 + {/if}
981 + </div>
982 + </div>
983 + </div>
984 +
985 + {#if isDefaultSelection && entidadCompararA && entidadCompararB && !alertDefaultDismissed}
986 + <div class="comparador-hint">
987 + <span>Mostrando las dos entidades con mayor gasto. Puedes cambiar la seleccion arriba.</span>
988 + <button class="alert-close" onclick={() => alertDefaultDismissed = true}>x</button>
989 + </div>
990 + {/if}
991 +
992 + {#if tienenRangosDiferentes && entidadCompararA && entidadCompararB && !loadingComparacion && !alertRangosDismissed}
993 + <div class="comparador-warning">
994 + <span>Rangos diferentes. Solo se comparan {añosComunesTotales.length} anos en comun.</span>
995 + <button class="alert-close" onclick={() => alertRangosDismissed = true}>x</button>
996 + </div>
997 + {/if}
998 +
999 + {#if loadingComparacion}
1000 + <div class="comparador-placeholder"><Spinner size={48} color="var(--theme-texto)" /></div>
1001 + {:else if !entidadCompararA && !entidadCompararB}
1002 + <div class="comparador-placeholder"><p>Selecciona dos entidades para comparar</p></div>
1003 + {:else if añosComparacion.length === 0}
1004 + <div class="comparador-placeholder"><p>No hay datos disponibles para comparar</p></div>
1005 + {:else}
1006 + <div class="chart-header">
1007 + <span class="chart-title">
1008 + <button class="metrica-toggle" onclick={() => metrica = metrica === 'percapita' ? 'monto' : 'percapita'}>
1009 + {metrica === 'percapita' ? 'Bs/habitante' : 'Bs (monto total)'}
1010 + <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 16V4m0 0L3 8m4-4l4 4M17 8v12m0 0l4-4m-4 4l-4-4"/></svg>
1011 + </button>
1012 + <span class="chart-period">· {displayPeriodoComparar}</span>
1013 + </span>
1014 + <button class="copy-link-btn" onclick={() => {
1015 + navigator.clipboard.writeText(window.location.href);
1016 + linkCopied = true;
1017 + setTimeout(() => linkCopied = false, 2000);
1018 + }}>
1019 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1020 + {#if linkCopied}
1021 + <path d="M20 6L9 17l-5-5"/>
1022 + {:else}
1023 + <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
1024 + <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
1025 + {/if}
1026 + </svg>
1027 + <span>{linkCopied ? 'Copiado' : 'Compartir'}</span>
1028 + </button>
1029 + </div>
1030 +
1031 + <div class="chart-comparar-wrapper">
1032 + <div class="chart-comparar-inner">
1033 + <span class="y-label-comparar" style="bottom: 100%">Bs {formatMonto(Math.round(maxComparacion))}</span>
1034 + <span class="y-label-comparar" style="bottom: 50%">Bs {formatMonto(Math.round(maxComparacion / 2))}</span>
1035 + <span class="y-label-comparar" style="bottom: 0%">Bs 0</span>
1036 + <div class="grid-line-comparar" style="bottom: 100%"></div>
1037 + <div class="grid-line-comparar" style="bottom: 50%"></div>
1038 + <div class="grid-line-comparar" style="bottom: 0%"></div>
1039 + <div class="bars-comparar" onmouseleave={() => hoveredYearComparar = null} role="group">
1040 + {#each añosComparacion as año, i}
1041 + <div class="bar-group-comparar" class:active={hoveredYearComparar === año} onmouseenter={() => hoveredYearComparar = año} role="button" tabindex="0">
1042 + <div class="bars-pair">
1043 + <div class="bar-comparar" style="height: {(valComparar(datosGraficoA[i]) / maxComparacion) * 100}%; background: {colorA}"></div>
1044 + <div class="bar-comparar" style="height: {(valComparar(datosGraficoB[i]) / maxComparacion) * 100}%; background: {colorB}"></div>
1045 + </div>
1046 + </div>
1047 + {/each}
1048 + </div>
1049 + </div>
1050 + <div class="x-axis-comparar">
1051 + {#each añosComparacion as año, i}
1052 + {@const show = i === 0 || i === añosComparacion.length - 1 || hoveredYearComparar === año}
1053 + <span class="x-label-comparar" class:visible={show}>{año}</span>
1054 + {/each}
1055 + </div>
1056 + </div>
1057 +
1058 + <div class="chart-legend">
1059 + {#if entidadCompararA}<span class="legend-item"><span class="legend-dot" style="background: {colorA}"></span>{entidadCompararA.entidad_desc}</span>{/if}
1060 + {#if entidadCompararB}<span class="legend-item"><span class="legend-dot" style="background: {colorB}"></span>{entidadCompararB.entidad_desc}</span>{/if}
1061 + </div>
1062 + {/if}
1063 + </div>
1064 +
1065 + {#if soloUnaEntidad}
1066 + <div class="comparador-hint comparador-hint-select">
1067 + <span>Selecciona la segunda entidad para ver la comparacion completa.</span>
1068 + </div>
1069 + {/if}
1070 +
1071 + {#if entidadCompararA && entidadCompararB}
1072 + <div class="kpis-card-comparar">
1073 + <div class="tabla-header">
1074 + <span class="tabla-label"></span>
1075 + <span class="tabla-val tabla-entidad" style="color: {colorA}">{entidadCompararA.entidad_desc}</span>
1076 + <span class="tabla-val tabla-entidad" style="color: {colorB}">{entidadCompararB.entidad_desc}</span>
1077 + </div>
1078 + <div class="tabla-row">
1079 + <span class="tabla-label">Gasto</span>
1080 + <span class="tabla-val">Bs {formatMontoLargo(Math.round($twMontoA))}</span>
1081 + <span class="tabla-val">Bs {formatMontoLargo(Math.round($twMontoB))}</span>
1082 + </div>
1083 + <div class="tabla-row">
1084 + <span class="tabla-label">Per capita</span>
1085 + <span class="tabla-val">Bs {formatPerCapita($twPerCapitaA)}</span>
1086 + <span class="tabla-val">Bs {formatPerCapita($twPerCapitaB)}</span>
1087 + </div>
1088 + <div class="tabla-row">
1089 + <span class="tabla-label">Peso</span>
1090 + <span class="tabla-val">{displayPropA.toLocaleString('es-BO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</span>
1091 + <span class="tabla-val">{displayPropB.toLocaleString('es-BO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</span>
1092 + </div>
1093 + <div class="tabla-row">
1094 + <span class="tabla-label">Ranking</span>
1095 + <span class="tabla-val">#{displayRankingA} de {displayNEntidadesA}</span>
1096 + <span class="tabla-val">#{displayRankingB} de {displayNEntidadesB}</span>
1097 + </div>
1098 + </div>
1099 + {/if}
1100 + </main>
1101 + {/if}
1102 +</div>
1103 +
1104 +<style>
1105 + /* ═══════════════════════════════════════════════════════════════
1106 + PAGE
1107 + ═══════════════════════════════════════════════════════════════ */
1108 + .page {
1109 + height: 100dvh;
1110 + width: 100%;
1111 + max-width: 100%;
1112 + overflow: hidden;
1113 + background: var(--theme-body);
1114 + padding-top: 60px;
1115 + box-sizing: border-box;
1116 + color: var(--theme-titulo);
1117 + font-family: var(--font-sans), -apple-system, sans-serif;
1118 + opacity: 0;
1119 + transition: opacity 0.4s ease;
1120 + }
1121 + .page.mounted { opacity: 1; }
1122 +
1123 + :global(html:not(.dark)) .page {
1124 + background: #f5f5f7;
1125 + }
1126 +
1127 + /* ═══════════════════════════════════════════════════════════════
1128 + LAYOUT
1129 + ═══════════════════════════════════════════════════════════════ */
1130 + .dashboard-layout {
1131 + display: grid;
1132 + grid-template-columns: minmax(200px, 30%) 1fr;
1133 + gap: 1.5rem;
1134 + max-width: 1400px;
1135 + margin: 0 auto;
1136 + padding: 1rem 2rem 2rem;
1137 + height: 100%;
1138 + overflow: hidden;
1139 + }
1140 +
1141 + /* ═══════════════════════════════════════════════════════════════
1142 + SIDEBAR
1143 + ═══════════════════════════════════════════════════════════════ */
1144 + .dashboard-context {
1145 + display: flex;
1146 + flex-direction: column;
1147 + min-height: 0;
1148 + }
1149 +
1150 + .context-card {
1151 + background: var(--theme-surface);
1152 + border-radius: 12px;
1153 + padding: 0.75rem;
1154 + display: flex;
1155 + flex-direction: column;
1156 + gap: 8px;
1157 + flex: 1;
1158 + min-height: 0;
1159 + overflow-y: auto;
1160 + scrollbar-width: thin;
1161 + }
1162 +
1163 + .context-card::-webkit-scrollbar { width: 4px; }
1164 + .context-card::-webkit-scrollbar-thumb {
1165 + background: rgba(128, 128, 128, 0.3);
1166 + border-radius: 2px;
1167 + }
1168 +
1169 + .context-main-title {
1170 + font-size: 1rem;
1171 + font-weight: 600;
1172 + color: var(--theme-titulo);
1173 + margin: 0;
1174 + line-height: 1.2;
1175 + text-transform: uppercase;
1176 + letter-spacing: 0.05em;
1177 + opacity: 0.5;
1178 + }
1179 +
1180 + .context-subtitle {
1181 + font-family: 'Qanelas', var(--font-sans);
1182 + font-size: 0.875rem;
1183 + font-weight: 700;
1184 + color: var(--theme-titulo);
1185 + margin: 0;
1186 + }
1187 +
1188 + .context-hint {
1189 + font-size: 0.75rem;
1190 + color: var(--theme-texto);
1191 + opacity: 0.7;
1192 + margin: 0;
1193 + line-height: 1.4;
1194 + }
1195 +
1196 + .context-section {
1197 + display: flex;
1198 + flex-direction: column;
1199 + gap: 0.5rem;
1200 + }
1201 +
1202 + .context-separator {
1203 + border-top: 1px solid var(--theme-borde);
1204 + margin: 0.25rem 0;
1205 + }
1206 +
1207 + /* ═══════════════════════════════════════════════════════════════
1208 + INLINE SEARCH
1209 + ═══════════════════════════════════════════════════════════════ */
1210 + .inline-search {
1211 + position: relative;
1212 + display: flex;
1213 + align-items: center;
1214 + gap: 0.375rem;
1215 + background: var(--theme-body);
1216 + border: 1px solid var(--theme-borde);
1217 + border-radius: 8px;
1218 + padding: 0.375rem 0.625rem;
1219 + transition: border-color 0.2s;
1220 + }
1221 + .inline-search.focused { border-color: var(--theme-accent); }
1222 +
1223 + .inline-search-icon {
1224 + flex-shrink: 0;
1225 + opacity: 0.4;
1226 + color: var(--theme-texto);
1227 + }
1228 +
1229 + .inline-search-input {
1230 + flex: 1;
1231 + border: none;
1232 + background: transparent;
1233 + color: var(--theme-titulo);
1234 + font-size: 0.8125rem;
1235 + outline: none;
1236 + min-width: 0;
1237 + }
1238 + .inline-search-input::placeholder {
1239 + color: var(--theme-texto);
1240 + opacity: 0.4;
1241 + }
1242 +
1243 + .inline-search-clear {
1244 + flex-shrink: 0;
1245 + padding: 2px;
1246 + background: none;
1247 + border: none;
1248 + color: var(--theme-texto);
1249 + opacity: 0.5;
1250 + cursor: pointer;
1251 + display: flex;
1252 + align-items: center;
1253 + }
1254 + .inline-search-clear:hover { opacity: 1; }
1255 +
1256 + .inline-search-results {
1257 + position: absolute;
1258 + top: calc(100% + 4px);
1259 + left: 0;
1260 + right: 0;
1261 + background: var(--theme-surface);
1262 + border: 1px solid var(--theme-borde);
1263 + border-radius: 8px;
1264 + box-shadow: 0 8px 24px rgba(0,0,0,0.15);
1265 + max-height: 240px;
1266 + overflow-y: auto;
1267 + z-index: 100;
1268 + }
1269 +
1270 + .inline-search-item {
1271 + display: flex;
1272 + align-items: center;
1273 + gap: 0.5rem;
1274 + width: 100%;
1275 + padding: 0.5rem 0.75rem;
1276 + border: none;
1277 + background: transparent;
1278 + color: var(--theme-titulo);
1279 + font-size: 0.8125rem;
1280 + text-align: left;
1281 + cursor: pointer;
1282 + transition: background 0.15s;
1283 + }
1284 + .inline-search-item:hover,
1285 + .inline-search-item.selected {
1286 + background: rgba(201, 167, 81, 0.1);
1287 + }
1288 +
1289 + .inline-search-code {
1290 + font-family: 'DM Mono', monospace;
1291 + font-size: 0.6875rem;
1292 + color: var(--theme-texto);
1293 + opacity: 0.6;
1294 + min-width: 2rem;
1295 + flex-shrink: 0;
1296 + }
1297 +
1298 + .inline-search-name {
1299 + white-space: nowrap;
1300 + overflow: hidden;
1301 + text-overflow: ellipsis;
1302 + }
1303 +
1304 + /* ═══════════════════════════════════════════════════════════════
1305 + UBICACION INFO (grupo/subgrupo)
1306 + ═══════════════════════════════════════════════════════════════ */
1307 + .ubicacion-info {
1308 + display: flex;
1309 + flex-direction: column;
1310 + gap: 0.5rem;
1311 + }
1312 +
1313 + .ubicacion-item {
1314 + display: flex;
1315 + flex-direction: column;
1316 + gap: 0.125rem;
1317 + padding: 0.5rem 0.625rem;
1318 + background: var(--theme-borde);
1319 + border-radius: 6px;
1320 + }
1321 +
1322 + .ubicacion-label {
1323 + font-family: 'DM Mono', monospace;
1324 + font-size: 0.625rem;
1325 + color: var(--theme-texto);
1326 + opacity: 0.6;
1327 + text-transform: uppercase;
1328 + }
1329 +
1330 + .ubicacion-value {
1331 + font-size: 0.75rem;
1332 + color: var(--theme-titulo);
1333 + line-height: 1.3;
1334 + }
1335 +
1336 + /* ═══════════════════════════════════════════════════════════════
1337 + TREE
1338 + ═══════════════════════════════════════════════════════════════ */
1339 + .tree {
1340 + font-size: 0.75rem;
1341 + line-height: 1.6;
1342 + }
1343 +
1344 + .tree-node {
1345 + display: flex;
1346 + align-items: center;
1347 + gap: 0.375rem;
1348 + padding: 0.375rem 0.5rem;
1349 + margin-left: calc(var(--indent, 0) * 1rem);
1350 + border-left: 2px solid transparent;
1351 + border-radius: 0 6px 6px 0;
1352 + text-decoration: none;
1353 + color: var(--theme-texto);
1354 + transition: all 0.15s;
1355 + }
1356 + .tree-node:hover { color: var(--theme-accent); }
1357 +
1358 + .tree-node-current {
1359 + color: var(--theme-titulo);
1360 + font-weight: 600;
1361 + border-left-color: var(--theme-accent);
1362 + background: rgba(201, 167, 81, 0.1);
1363 + }
1364 +
1365 + .tree-children {
1366 + margin-left: calc(var(--indent, 0) * 1rem);
1367 + }
1368 +
1369 + .tree-node-child {
1370 + padding-left: 0.75rem;
1371 + }
1372 +
1373 + .tree-dot {
1374 + width: 4px;
1375 + height: 4px;
1376 + border-radius: 50%;
1377 + background: currentColor;
1378 + flex-shrink: 0;
1379 + opacity: 0.4;
1380 + }
1381 +
1382 + .tree-code {
1383 + font-family: 'DM Mono', monospace;
1384 + font-size: 0.6875rem;
1385 + min-width: 2.5rem;
1386 + opacity: 0.6;
1387 + }
1388 +
1389 + .tree-name {
1390 + font-size: 0.6875rem;
1391 + white-space: nowrap;
1392 + overflow: hidden;
1393 + text-overflow: ellipsis;
1394 + }
1395 +
1396 + /* ═══════════════════════════════════════════════════════════════
1397 + EXPLORE
1398 + ═══════════════════════════════════════════════════════════════ */
1399 + .explore-header {
1400 + display: flex;
1401 + align-items: center;
1402 + justify-content: space-between;
1403 + }
1404 +
1405 + .explore-count {
1406 + font-family: 'DM Mono', monospace;
1407 + font-size: 0.625rem;
1408 + color: var(--theme-accent);
1409 + opacity: 0.7;
1410 + animation: nudgeRight 1.5s ease-in-out infinite;
1411 + }
1412 +
1413 + @keyframes nudgeRight {
1414 + 0%, 100% { transform: translateX(0); }
1415 + 50% { transform: translateX(3px); }
1416 + }
1417 +
1418 + .explore-scroll {
1419 + display: flex;
1420 + gap: 8px;
1421 + overflow-x: auto;
1422 + scrollbar-width: none;
1423 + padding-bottom: 2px;
1424 + }
1425 + .explore-scroll::-webkit-scrollbar { display: none; }
1426 +
1427 + .explore-scroll {
1428 + -webkit-mask-image: linear-gradient(to right, black 85%, transparent 100%);
1429 + mask-image: linear-gradient(to right, black 85%, transparent 100%);
1430 + }
1431 +
1432 + .explore-card {
1433 + position: relative;
1434 + display: flex;
1435 + flex-direction: column;
1436 + justify-content: flex-start;
1437 + padding: 0.375rem 0.5rem;
1438 + width: 120px;
1439 + height: 95px;
1440 + border-radius: 8px;
1441 + background: var(--theme-borde);
1442 + text-decoration: none;
1443 + color: var(--theme-texto);
1444 + cursor: pointer;
1445 + transition: all 0.15s;
1446 + flex-shrink: 0;
1447 + overflow: hidden;
1448 + }
1449 + .explore-card:hover {
1450 + background: rgba(201, 167, 81, 0.15);
1451 + color: var(--theme-accent);
1452 + }
1453 + .explore-card:hover .explore-title {
1454 + color: var(--theme-accent);
1455 + }
1456 +
1457 + .explore-title {
1458 + position: relative;
1459 + font-size: 0.75rem;
1460 + font-weight: 600;
1461 + line-height: 1.3;
1462 + color: var(--theme-titulo);
1463 + z-index: 1;
1464 + }
1465 +
1466 + .explore-desc {
1467 + position: relative;
1468 + font-size: 0.625rem;
1469 + line-height: 1.35;
1470 + opacity: 0.6;
1471 + margin-top: 3px;
1472 + z-index: 1;
1473 + }
1474 +
1475 + /* ═══════════════════════════════════════════════════════════════
1476 + VIGENCIA
1477 + ═══════════════════════════════════════════════════════════════ */
1478 + .vigencia-compact {
1479 + display: flex;
1480 + align-items: center;
1481 + gap: 0.75rem;
1482 + }
1483 +
1484 + .vigencia-status-icon {
1485 + width: 32px;
1486 + height: 32px;
1487 + border-radius: 8px;
1488 + display: flex;
1489 + align-items: center;
1490 + justify-content: center;
1491 + flex-shrink: 0;
1492 + }
1493 +
1494 + .vigencia-status-icon.vigencia-full {
1495 + background: rgba(74, 158, 107, 0.15);
1496 + color: #4A9E6B;
1497 + }
1498 +
1499 + .vigencia-text {
1500 + display: flex;
1501 + flex-direction: column;
1502 + gap: 0.125rem;
1503 + }
1504 +
1505 + .vigencia-main {
1506 + font-size: 0.75rem;
1507 + font-weight: 500;
1508 + color: var(--theme-titulo);
1509 + }
1510 +
1511 + .chart-no-data {
1512 + flex: 1;
1513 + display: flex;
1514 + flex-direction: column;
1515 + align-items: center;
1516 + justify-content: center;
1517 + gap: 0.75rem;
1518 + color: var(--theme-texto);
1519 + opacity: 0.7;
1520 + font-size: 0.8125rem;
1521 + text-align: center;
1522 + }
1523 +
1524 + .chart-no-data p {
1525 + margin: 0;
1526 + }
1527 +
1528 + .volver-agregado {
1529 + display: inline-flex;
1530 + align-items: center;
1531 + gap: 0.25rem;
1532 + font-size: 0.6875rem;
1533 + color: var(--theme-accent);
1534 + background: none;
1535 + border: none;
1536 + padding: 0;
1537 + cursor: pointer;
1538 + transition: opacity 0.15s;
1539 + }
1540 + .volver-agregado:hover { opacity: 0.7; }
1541 +
1542 + .copy-link-btn {
1543 + display: flex;
1544 + align-items: center;
1545 + gap: 0.375rem;
1546 + font-size: 0.75rem;
1547 + color: var(--theme-texto);
1548 + opacity: 0.6;
1549 + background: none;
1550 + border: none;
1551 + padding: 0;
1552 + cursor: pointer;
1553 + transition: opacity 0.15s;
1554 + }
1555 + .copy-link-btn:hover { opacity: 1; }
1556 + .copy-link-btn svg { flex-shrink: 0; }
1557 +
1558 + /* ═══════════════════════════════════════════════════════════════
1559 + MAIN
1560 + ═══════════════════════════════════════════════════════════════ */
1561 + .dashboard-main {
1562 + display: flex;
1563 + flex-direction: column;
1564 + gap: 1.5rem;
1565 + min-height: 0;
1566 + overflow: hidden;
1567 + }
1568 +
1569 + .chart-protagonista {
1570 + background: var(--theme-surface);
1571 + border-radius: 12px;
1572 + padding: 0.75rem;
1573 + display: flex;
1574 + flex-direction: column;
1575 + min-height: 0;
1576 + flex: 2;
1577 + overflow: hidden;
1578 + }
1579 +
1580 + .ranking-section {
1581 + background: var(--theme-surface);
1582 + border-radius: 12px;
1583 + padding: 0.75rem;
1584 + flex: 1;
1585 + display: flex;
1586 + flex-direction: column;
1587 + min-height: 0;
1588 + }
1589 +
1590 + /* ═══════════════════════════════════════════════════════════════
1591 + CHART & KPIs
1592 + ═══════════════════════════════════════════════════════════════ */
1593 + .chart-top-row {
1594 + display: flex;
1595 + justify-content: space-between;
1596 + align-items: center;
1597 + margin-bottom: 0.375rem;
1598 + }
1599 +
1600 + .main-title-pills {
1601 + display: flex;
1602 + gap: 0.375rem;
1603 + }
1604 +
1605 + .pill {
1606 + font-family: 'DM Mono', monospace;
1607 + font-size: 0.625rem;
1608 + padding: 0.125rem 0.5rem;
1609 + border-radius: 4px;
1610 + letter-spacing: 0.03em;
1611 + }
1612 +
1613 + .pill-nivel {
1614 + background: rgba(201, 167, 81, 0.15);
1615 + color: var(--theme-accent);
1616 + }
1617 +
1618 + .pill-codigo {
1619 + background: var(--theme-borde);
1620 + color: var(--theme-texto);
1621 + }
1622 +
1623 + .main-title-name {
1624 + font-family: 'DM Serif Display', serif;
1625 + font-size: 2rem;
1626 + font-weight: 400;
1627 + margin: 0 0 0.5rem;
1628 + line-height: 1.15;
1629 + color: var(--theme-titulo);
1630 + }
1631 +
1632 + .chart-controls {
1633 + margin-bottom: 0.5rem;
1634 + }
1635 +
1636 + .chart-header {
1637 + display: flex;
1638 + align-items: center;
1639 + justify-content: space-between;
1640 + margin-bottom: 0.25rem;
1641 + }
1642 +
1643 + .chart-title {
1644 + font-family: 'DM Mono', monospace;
1645 + font-size: 0.6875rem;
1646 + color: var(--theme-texto);
1647 + opacity: 0.7;
1648 + }
1649 +
1650 + .chart-period {
1651 + opacity: 0.5;
1652 + }
1653 +
1654 + .metrica-toggle {
1655 + font-family: 'DM Mono', monospace;
1656 + font-size: 0.6875rem;
1657 + color: var(--theme-texto);
1658 + background: rgba(128, 128, 128, 0.08);
1659 + border: 1px solid rgba(128, 128, 128, 0.15);
1660 + padding: 0.125rem 0.375rem;
1661 + border-radius: 4px;
1662 + cursor: pointer;
1663 + transition: all 0.15s;
1664 + display: inline-flex;
1665 + align-items: center;
1666 + gap: 0.25rem;
1667 + }
1668 +
1669 + .metrica-toggle:hover {
1670 + background: rgba(201, 167, 81, 0.12);
1671 + border-color: rgba(201, 167, 81, 0.3);
1672 + color: var(--theme-accent);
1673 + }
1674 +
1675 + .chart-loading {
1676 + display: flex;
1677 + align-items: center;
1678 + justify-content: center;
1679 + color: var(--theme-texto);
1680 + opacity: 0.5;
1681 + }
1682 +
1683 + .chart-flex-wrapper {
1684 + flex: 1;
1685 + min-height: 0;
1686 + }
1687 +
1688 + .kpis {
1689 + display: grid;
1690 + grid-template-columns: repeat(4, 1fr);
1691 + gap: 0.5rem;
1692 + margin-top: 0.5rem;
1693 + padding-top: 0.75rem;
1694 + border-top: 1px solid var(--theme-borde);
1695 + }
1696 +
1697 + .kpi {
1698 + display: flex;
1699 + flex-direction: column;
1700 + align-items: center;
1701 + gap: 0.125rem;
1702 + text-align: center;
1703 + }
1704 +
1705 + .kpi-value {
1706 + font-family: 'Qanelas', var(--font-sans);
1707 + font-size: 1rem;
1708 + font-weight: 600;
1709 + color: var(--theme-titulo);
1710 + }
1711 +
1712 + .kpi-label {
1713 + font-size: 0.625rem;
1714 + color: var(--theme-texto);
1715 + opacity: 0.5;
1716 + line-height: 1.35;
1717 + }
1718 +
1719 + /* ═══════════════════════════════════════════════════════════════
1720 + RANKING
1721 + ═══════════════════════════════════════════════════════════════ */
1722 + .ranking-title {
1723 + font-size: 1rem;
1724 + font-weight: 600;
1725 + color: var(--theme-titulo);
1726 + margin: 0 0 0.25rem;
1727 + }
1728 +
1729 + .ranking-cards {
1730 + display: flex;
1731 + flex-direction: column;
1732 + gap: 0.25rem;
1733 + }
76 1734
77 - <!-- CONTENIDO PRINCIPAL --> 1735 + .ranking-card {
78 - <main class="dashboard-main"> 1736 + display: flex;
79 - <div class="main-content"> 1737 + align-items: center;
80 - <!-- Header --> 1738 + gap: 0.375rem;
81 - <div class="main-header"> 1739 + padding: 0.5rem 0.625rem;
82 - <div class="main-title-pills"> 1740 + border-radius: 8px;
83 - <span class="pill pill-codigo">{organismoData.organismo}</span> 1741 + background: transparent;
84 - </div> 1742 + text-align: left;
85 - <h1 class="main-title-name">{organismoData.organismo_desc_organismo}</h1> 1743 + }
86 - {#if organismoData.organismo_sigla_organismo && organismoData.organismo_sigla_organismo !== 'NC'}
87 - <p class="main-sigla">{organismoData.organismo_sigla_organismo}</p>
88 - {/if}
89 - </div>
90 1744
91 - <!-- Info cards --> 1745 + .ranking-card-link {
92 - <div class="info-grid"> 1746 + text-decoration: none;
93 - <div class="info-card"> 1747 + cursor: pointer;
94 - <span class="info-label">Grupo</span> 1748 + transition: background 0.15s;
95 - <span class="info-value">{organismoData.organismo_grupo}</span> 1749 + }
96 - <span class="info-desc">{organismoData.organismo_desc_grupo}</span>
97 - </div>
98 1750
99 - <div class="info-card"> 1751 + .ranking-card-link:hover {
100 - <span class="info-label">Subgrupo</span> 1752 + background: rgba(201, 167, 81, 0.05);
101 - <span class="info-value">{organismoData.organismo_sub_grupo}</span> 1753 + }
102 - <span class="info-desc">{organismoData.organismo_desc_sub_grupo}</span>
103 - </div>
104 1754
105 - {#if organismoData.gestiones} 1755 + .ranking-card-link:hover .ranking-name {
106 - <div class="info-card"> 1756 + color: var(--theme-accent);
107 - <span class="info-label">Gestiones</span> 1757 + border-bottom-style: solid;
108 - <span class="info-value">{organismoData.n_gestiones || ''} años</span> 1758 + }
109 - <span class="info-desc">{organismoData.gestiones}</span>
110 - </div>
111 - {/if}
112 - </div>
113 - </div>
114 - </main>
115 - </div>
116 -</div>
117 1759
118 -<style> 1760 + .ranking-arrow {
119 - /* ═══════════════════════════════════════════════════════════════
120 - BASE
121 - ═══════════════════════════════════════════════════════════════ */
122 - .page {
123 - min-height: 100vh;
124 - padding: 1rem;
125 opacity: 0; 1761 opacity: 0;
126 - transform: translateY(10px); 1762 + color: var(--theme-accent);
127 - transition: opacity 0.4s ease, transform 0.4s ease; 1763 + transition: opacity 0.15s, transform 0.15s;
1764 + flex-shrink: 0;
128 } 1765 }
129 1766
130 - .page.mounted { 1767 + .ranking-card-link:hover .ranking-arrow {
131 opacity: 1; 1768 opacity: 1;
132 - transform: translateY(0); 1769 + transform: translateX(2px);
133 } 1770 }
134 1771
135 - /* ═══════════════════════════════════════════════════════════════ 1772 + .ranking-header {
136 - NAV 1773 + margin-bottom: 0.5rem;
137 - ═══════════════════════════════════════════════════════════════ */
138 - .page-nav {
139 - max-width: 1400px;
140 - margin: 0 auto 1.5rem;
141 } 1774 }
142 1775
143 - .back-link { 1776 + .ranking-summary {
144 - display: inline-flex; 1777 + display: flex;
145 - align-items: center; 1778 + align-items: baseline;
146 - gap: 0.5rem; 1779 + gap: 0.375rem;
147 - color: var(--text-secondary, #888); 1780 + margin-top: 0.125rem;
148 - text-decoration: none; 1781 + }
149 - font-size: 0.875rem; 1782 +
150 - transition: color 0.2s; 1783 + .ranking-summary-pct {
1784 + font-family: 'DM Mono', monospace;
1785 + font-size: 1.25rem;
1786 + font-weight: 700;
1787 + color: var(--theme-titulo);
1788 + }
1789 +
1790 + .ranking-summary-text {
1791 + font-size: 0.75rem;
1792 + color: var(--theme-texto);
1793 + opacity: 0.7;
1794 + }
1795 +
1796 + .ranking-position {
1797 + font-family: 'DM Mono', monospace;
1798 + font-size: 0.6875rem;
1799 + color: var(--theme-accent);
1800 + font-weight: 600;
1801 + min-width: 1.5rem;
1802 + }
1803 +
1804 + .ranking-name {
1805 + font-size: 0.8125rem;
1806 + color: var(--theme-titulo);
1807 + border-bottom: 1px dotted rgba(128, 128, 128, 0.3);
151 } 1808 }
152 1809
153 - .back-link:hover { 1810 + .ranking-pct {
154 - color: var(--text-primary, #fff); 1811 + font-family: 'DM Mono', monospace;
1812 + font-size: 0.8125rem;
1813 + font-weight: 600;
1814 + color: var(--theme-titulo);
1815 + opacity: 0.7;
155 } 1816 }
156 1817
157 /* ═══════════════════════════════════════════════════════════════ 1818 /* ═══════════════════════════════════════════════════════════════
158 - LAYOUT 1819 + MOBILE SIDEBAR
159 ═══════════════════════════════════════════════════════════════ */ 1820 ═══════════════════════════════════════════════════════════════ */
160 - .dashboard-layout { 1821 + .mobile-sidebar-toggle {
161 - display: grid; 1822 + display: none;
162 - grid-template-columns: 300px 1fr;
163 - gap: 2rem;
164 - max-width: 1400px;
165 - margin: 0 auto;
166 } 1823 }
167 1824
168 @media (max-width: 900px) { 1825 @media (max-width: 900px) {
169 .dashboard-layout { 1826 .dashboard-layout {
170 grid-template-columns: 1fr; 1827 grid-template-columns: 1fr;
1828 + gap: 0.5rem;
1829 + height: auto;
1830 + overflow: visible;
1831 + padding: 1.5rem 1rem 2rem;
1832 + align-content: center;
1833 + min-height: calc(100vh - 4rem);
171 } 1834 }
172 1835
173 - .dashboard-context { 1836 + .dashboard-main {
174 - order: 2; 1837 + overflow: visible;
175 } 1838 }
1839 +
1840 + .chart-protagonista {
1841 + flex: none;
176 } 1842 }
177 1843
178 - /* ═══════════════════════════════════════════════════════════════ 1844 + .ranking-section {
179 - SIDEBAR 1845 + flex: none;
180 - ═══════════════════════════════════════════════════════════════ */ 1846 + }
181 - .dashboard-context { 1847 +
182 - position: sticky; 1848 + .mobile-sidebar-toggle {
183 - top: 5rem; 1849 + display: flex;
184 - height: fit-content; 1850 + align-items: center;
1851 + gap: 0.5rem;
1852 + padding: 0.625rem 1rem;
1853 + background: var(--theme-surface);
1854 + border: 1px solid var(--theme-borde);
1855 + border-radius: 10px;
1856 + color: var(--theme-titulo);
1857 + font-family: 'Qanelas', var(--font-sans);
1858 + font-size: 0.8125rem;
1859 + font-weight: 500;
1860 + cursor: pointer;
1861 + transition: all 0.2s;
1862 + margin-bottom: 0.25rem;
1863 + width: 100%;
1864 + }
1865 +
1866 + .mobile-sidebar-toggle:hover {
1867 + background: var(--theme-surface-hover);
1868 + }
1869 +
1870 + .mobile-sidebar-toggle svg {
1871 + color: var(--theme-texto);
1872 + opacity: 0.6;
1873 + }
1874 +
1875 + .dashboard-context .context-card {
1876 + display: none;
1877 + }
1878 +
1879 + .dashboard-context.mobile-open .context-card {
1880 + display: flex;
1881 + animation: slideDown 0.25s ease;
1882 + }
1883 +
1884 + @keyframes slideDown {
1885 + from { opacity: 0; transform: translateY(-8px); }
1886 + to { opacity: 1; transform: translateY(0); }
185 } 1887 }
186 1888
187 .context-card { 1889 .context-card {
188 - background: var(--card-bg, rgba(255, 255, 255, 0.03)); 1890 + height: auto;
189 - border: 1px solid var(--border-color, rgba(255, 255, 255, 0.08)); 1891 + overflow-y: visible;
190 - border-radius: 16px;
191 - padding: 1.5rem;
192 } 1892 }
193 1893
194 - .context-main-title { 1894 + .kpis {
195 - font-family: 'Instrument Sans', sans-serif; 1895 + grid-template-columns: repeat(2, 1fr);
196 - font-size: 0.75rem; 1896 + }
197 - font-weight: 600;
198 - text-transform: uppercase;
199 - letter-spacing: 0.05em;
200 - color: var(--text-tertiary, #666);
201 - margin-bottom: 1.5rem;
202 } 1897 }
203 1898
204 - .context-section { 1899 + @media (max-width: 480px) {
205 - margin-bottom: 1.5rem; 1900 + .dashboard-layout {
1901 + padding: 0.5rem;
206 } 1902 }
207 1903
208 - .context-subtitle { 1904 + .chart-protagonista,
209 - font-family: 'Instrument Sans', sans-serif; 1905 + .ranking-section,
210 - font-size: 0.8rem; 1906 + .context-card {
211 - font-weight: 500; 1907 + padding: 0.5rem;
212 - color: var(--text-secondary, #aaa); 1908 + border-radius: 8px;
213 - margin-bottom: 0.75rem;
214 } 1909 }
215 1910
216 - .context-hint { 1911 + .main-title-name {
1912 + font-size: 1.375rem;
1913 + }
1914 +
1915 + .chart-header {
1916 + flex-direction: column;
1917 + align-items: flex-start;
1918 + gap: 0.25rem;
1919 + }
1920 +
1921 + .kpis {
1922 + grid-template-columns: repeat(2, 1fr);
1923 + gap: 0.375rem;
1924 + }
1925 +
1926 + .kpi-value {
1927 + font-size: 0.8125rem;
1928 + }
1929 +
1930 + .kpi-label {
1931 + font-size: 0.5625rem;
1932 + }
1933 +
1934 + .explore-scroll {
1935 + gap: 6px;
1936 + }
1937 +
1938 + .explore-card {
1939 + width: 100px;
1940 + height: 80px;
1941 + }
1942 +
1943 + .explore-title {
1944 + font-size: 0.625rem;
1945 + }
1946 +
1947 + .explore-desc {
1948 + font-size: 0.5rem;
1949 + }
1950 +
1951 + .ranking-title {
1952 + font-size: 0.875rem;
1953 + }
1954 +
1955 + .ranking-name {
1956 + font-size: 0.6875rem;
1957 + }
1958 +
1959 + .ranking-pct {
217 font-size: 0.75rem; 1960 font-size: 0.75rem;
218 - color: var(--text-tertiary, #666);
219 - margin-bottom: 1rem;
220 } 1961 }
221 1962
222 - .context-separator { 1963 + .tree-name {
223 - height: 1px; 1964 + max-width: 150px;
224 - background: var(--border-color, rgba(255, 255, 255, 0.08)); 1965 + }
225 - margin: 1.5rem 0;
226 } 1966 }
227 1967
228 /* ═══════════════════════════════════════════════════════════════ 1968 /* ═══════════════════════════════════════════════════════════════
229 - UBICACION INFO 1969 + DARK MODE
230 ═══════════════════════════════════════════════════════════════ */ 1970 ═══════════════════════════════════════════════════════════════ */
231 - .ubicacion-info { 1971 + :global(html.dark) .context-hint {
1972 + color: rgba(255, 255, 255, 0.5);
1973 + }
1974 +
1975 + /* ═══════════════════════════════════════════════════════════════
1976 + COMPARAR VIEW
1977 + ═══════════════════════════════════════════════════════════════ */
1978 + .main-comparar {
232 display: flex; 1979 display: flex;
233 flex-direction: column; 1980 flex-direction: column;
234 gap: 0.75rem; 1981 gap: 0.75rem;
1982 + padding: 1rem 2rem 2rem;
1983 + width: 100%;
1984 + max-width: 1400px;
1985 + margin: 0 auto;
1986 + height: 100%;
1987 + overflow: hidden;
235 } 1988 }
236 1989
237 - .ubicacion-item { 1990 + .chart-card-comparar {
1991 + background: var(--theme-surface);
1992 + border-radius: 12px;
1993 + padding: 1rem 1.5rem;
238 display: flex; 1994 display: flex;
239 flex-direction: column; 1995 flex-direction: column;
240 - gap: 0.25rem; 1996 + flex: 1;
241 - padding: 0.75rem; 1997 + min-height: 0;
242 - background: var(--hover-bg, rgba(255, 255, 255, 0.03)); 1998 + }
1999 +
2000 + .comparador-selectors {
2001 + display: flex;
2002 + align-items: center;
2003 + gap: 1rem;
2004 + margin-bottom: 1rem;
2005 + }
2006 +
2007 + .selector-group { flex: 1; display: flex; align-items: center; gap: 0.5rem; }
2008 + .selector-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
2009 + .vs { font-family: 'DM Mono', monospace; font-size: 0.75rem; color: var(--theme-texto); flex-shrink: 0; }
2010 + .relative { position: relative; }
2011 + .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2012 +
2013 + .selector-btn {
2014 + display: flex;
2015 + align-items: center;
2016 + gap: 0.5rem;
2017 + padding: 0.625rem 1rem;
2018 + background: var(--theme-body);
2019 + border: 1px solid var(--theme-borde);
243 border-radius: 8px; 2020 border-radius: 8px;
2021 + font-size: 0.8125rem;
2022 + color: var(--theme-titulo);
2023 + cursor: pointer;
2024 + transition: border-color 0.2s;
244 } 2025 }
2026 + .selector-btn:hover { border-color: var(--theme-accent); }
2027 + .comparador-btn { width: 100%; justify-content: space-between; }
2028 + .comparador-dropdown-container { position: relative; }
245 2029
246 - .ubicacion-label { 2030 + .dropdown-panel.comparador-panel {
247 - font-family: 'DM Mono', monospace; 2031 + position: absolute;
248 - font-size: 0.7rem; 2032 + top: calc(100% + 4px);
249 - color: var(--text-tertiary, #666); 2033 + left: 0;
250 - text-transform: uppercase; 2034 + width: 100%;
2035 + min-width: 280px;
2036 + background: var(--theme-surface);
2037 + border: 1px solid var(--theme-borde);
2038 + border-radius: 10px;
2039 + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
2040 + z-index: 100;
2041 + overflow: hidden;
251 } 2042 }
252 2043
253 - .ubicacion-value { 2044 + .dropdown-search { padding: 0.5rem; border-bottom: 1px solid var(--theme-borde); }
254 - font-size: 0.85rem; 2045 + .search-input {
255 - color: var(--text-primary, #fff); 2046 + width: 100%;
2047 + padding: 0.5rem 0.75rem;
2048 + border: 1px solid var(--theme-borde);
2049 + border-radius: 6px;
2050 + background: var(--theme-body);
2051 + color: var(--theme-titulo);
2052 + font-size: 0.8125rem;
2053 + outline: none;
256 } 2054 }
2055 + .search-input:focus { border-color: var(--theme-accent); }
257 2056
258 - /* ═══════════════════════════════════════════════════════════════ 2057 + .dropdown-item {
259 - HERMANOS LIST
260 - ═══════════════════════════════════════════════════════════════ */
261 - .hermanos-list {
262 display: flex; 2058 display: flex;
263 - flex-direction: column; 2059 + align-items: center;
264 - gap: 0.25rem; 2060 + gap: 0.5rem;
2061 + width: 100%;
2062 + padding: 0.625rem 0.75rem;
2063 + border: none;
2064 + background: transparent;
2065 + color: var(--theme-titulo);
2066 + font-size: 0.8125rem;
2067 + text-align: left;
2068 + cursor: pointer;
2069 + transition: background 0.15s;
2070 + }
2071 + .dropdown-item:hover { background: var(--theme-surface-hover); }
2072 + .dropdown-item.active { background: rgba(201, 167, 81, 0.1); color: var(--theme-accent); }
2073 + .dropdown-item:disabled { opacity: 0.4; cursor: not-allowed; }
2074 + .dropdown-list { max-height: 240px; overflow-y: auto; }
2075 +
2076 + .comparador-placeholder {
2077 + display: flex;
2078 + align-items: center;
2079 + justify-content: center;
2080 + min-height: 200px;
2081 + border: 1px dashed var(--theme-borde);
2082 + border-radius: 12px;
2083 + padding: 2rem;
2084 + text-align: center;
265 } 2085 }
2086 + .comparador-placeholder p { color: var(--theme-texto); font-size: 0.875rem; margin: 0; }
266 2087
267 - .hermano-link { 2088 + .comparador-hint {
268 display: flex; 2089 display: flex;
269 align-items: center; 2090 align-items: center;
270 gap: 0.5rem; 2091 gap: 0.5rem;
271 - padding: 0.5rem 0.75rem; 2092 + padding: 0.75rem 1rem;
272 - border-radius: 6px; 2093 + background: rgba(107, 159, 212, 0.1);
273 - text-decoration: none; 2094 + border-radius: 8px;
274 - color: var(--text-secondary, #aaa); 2095 + font-size: 0.8125rem;
275 - font-size: 0.8rem; 2096 + color: #6B9FD4;
276 - transition: all 0.2s ease;
277 } 2097 }
2098 + .comparador-hint span { flex: 1; }
2099 + .comparador-hint-select { margin-top: 0.75rem; }
278 2100
279 - .hermano-link:hover { 2101 + .comparador-warning {
280 - background: var(--hover-bg, rgba(255, 255, 255, 0.05)); 2102 + display: flex;
281 - color: var(--text-primary, #fff); 2103 + align-items: center;
2104 + gap: 0.5rem;
2105 + padding: 0.75rem 1rem;
2106 + background: rgba(230, 176, 75, 0.1);
2107 + border-radius: 8px;
2108 + font-size: 0.8125rem;
2109 + color: #C9A751;
282 } 2110 }
2111 + .comparador-warning span { flex: 1; }
283 2112
284 - .hermano-code { 2113 + .alert-close {
285 - font-family: 'DM Mono', monospace;
286 - font-size: 0.7rem;
287 - opacity: 0.6;
288 flex-shrink: 0; 2114 flex-shrink: 0;
289 - min-width: 2.5rem; 2115 + padding: 0.25rem;
2116 + border: none;
2117 + background: transparent;
2118 + color: inherit;
2119 + opacity: 0.6;
2120 + cursor: pointer;
2121 + border-radius: 4px;
290 } 2122 }
2123 + .alert-close:hover { opacity: 1; }
291 2124
292 - .hermano-name { 2125 + .chart-comparar-wrapper {
293 - white-space: nowrap; 2126 + position: relative;
294 - overflow: hidden; 2127 + width: 100%;
295 - text-overflow: ellipsis; 2128 + flex: 1;
2129 + min-height: 180px;
296 } 2130 }
297 2131
298 - .hermanos-more { 2132 + .chart-comparar-inner {
299 - font-size: 0.75rem; 2133 + position: absolute;
300 - color: var(--text-tertiary, #666); 2134 + top: 10px;
301 - padding: 0.5rem 0.75rem; 2135 + bottom: 30px;
2136 + left: 50px;
2137 + right: 10px;
302 } 2138 }
303 2139
304 - /* ═══════════════════════════════════════════════════════════════ 2140 + .y-label-comparar {
305 - MAIN CONTENT 2141 + position: absolute;
306 - ═══════════════════════════════════════════════════════════════ */ 2142 + left: -50px;
307 - .dashboard-main { 2143 + width: 45px;
308 - min-width: 0; 2144 + text-align: right;
2145 + transform: translateY(50%);
2146 + font-family: 'DM Mono', monospace;
2147 + font-size: 0.6875rem;
2148 + color: var(--theme-texto);
2149 + white-space: nowrap;
309 } 2150 }
310 2151
311 - .main-content { 2152 + .grid-line-comparar {
312 - background: var(--card-bg, rgba(255, 255, 255, 0.03)); 2153 + position: absolute;
313 - border: 1px solid var(--border-color, rgba(255, 255, 255, 0.08)); 2154 + left: 0;
314 - border-radius: 16px; 2155 + right: 0;
315 - padding: 2rem; 2156 + border-top: 0.5px dotted var(--theme-texto);
2157 + opacity: 0.15;
316 } 2158 }
317 2159
318 - .main-header { 2160 + .bars-comparar {
319 - margin-bottom: 2rem; 2161 + position: absolute;
2162 + inset: 0;
2163 + display: flex;
2164 + align-items: flex-end;
2165 + gap: 3px;
320 } 2166 }
321 2167
322 - .main-title-pills { 2168 + .bar-group-comparar {
2169 + flex: 1;
2170 + height: 100%;
323 display: flex; 2171 display: flex;
324 - gap: 0.5rem; 2172 + align-items: flex-end;
325 - margin-bottom: 0.75rem; 2173 + justify-content: center;
2174 + cursor: pointer;
326 } 2175 }
327 2176
328 - .pill { 2177 + .bars-pair {
329 - padding: 0.25rem 0.75rem; 2178 + display: flex;
330 - border-radius: 999px; 2179 + align-items: flex-end;
331 - font-size: 0.75rem; 2180 + gap: 2px;
332 - font-weight: 500; 2181 + height: 100%;
2182 + width: 100%;
2183 + justify-content: center;
333 } 2184 }
334 2185
335 - .pill-codigo { 2186 + .bar-comparar {
336 - background: var(--hover-bg, rgba(255, 255, 255, 0.05)); 2187 + flex: 1;
337 - color: var(--text-secondary, #aaa); 2188 + max-width: 24px;
338 - font-family: 'DM Mono', monospace; 2189 + border-radius: 3px 3px 0 0;
2190 + min-height: 2px;
2191 + transition: opacity 0.15s;
339 } 2192 }
340 2193
341 - .main-title-name { 2194 + .bars-comparar:hover .bar-comparar { opacity: 0.35; }
342 - font-family: 'DM Serif Display', serif; 2195 + .bar-group-comparar.active .bar-comparar { opacity: 1; }
343 - font-size: 2rem; 2196 +
344 - font-weight: 400; 2197 + .x-axis-comparar {
345 - color: var(--text-primary, #fff); 2198 + position: absolute;
346 - line-height: 1.2; 2199 + bottom: 0;
347 - margin: 0; 2200 + left: 50px;
2201 + right: 10px;
2202 + height: 30px;
2203 + display: flex;
2204 + align-items: center;
348 } 2205 }
349 2206
350 - .main-sigla { 2207 + .x-label-comparar {
2208 + flex: 1;
2209 + text-align: center;
351 font-family: 'DM Mono', monospace; 2210 font-family: 'DM Mono', monospace;
352 - font-size: 0.9rem; 2211 + font-size: 0.6875rem;
353 - color: var(--text-tertiary, #666); 2212 + color: var(--theme-texto);
354 - margin-top: 0.5rem; 2213 + opacity: 0;
2214 + transition: opacity 0.2s;
355 } 2215 }
2216 + .x-label-comparar.visible { opacity: 1; }
356 2217
357 - /* ═══════════════════════════════════════════════════════════════ 2218 + .chart-legend {
358 - INFO GRID 2219 + display: flex;
359 - ═══════════════════════════════════════════════════════════════ */ 2220 + justify-content: center;
360 - .info-grid { 2221 + gap: 2rem;
361 - display: grid; 2222 + margin-top: 0.5rem;
362 - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); 2223 + margin-bottom: 0.75rem;
363 - gap: 1rem;
364 } 2224 }
365 2225
366 - .info-card { 2226 + .legend-item {
367 display: flex; 2227 display: flex;
368 - flex-direction: column; 2228 + align-items: center;
369 - gap: 0.25rem; 2229 + gap: 0.5rem;
370 - padding: 1rem; 2230 + font-size: 0.75rem;
371 - background: var(--hover-bg, rgba(255, 255, 255, 0.03)); 2231 + color: var(--theme-texto);
2232 + }
2233 +
2234 + .legend-dot { width: 10px; height: 10px; border-radius: 3px; }
2235 +
2236 + .kpis-card-comparar {
2237 + background: var(--theme-surface);
372 border-radius: 12px; 2238 border-radius: 12px;
373 - border: 1px solid var(--border-color, rgba(255, 255, 255, 0.05)); 2239 + padding: 0.75rem 1.25rem;
374 } 2240 }
375 2241
376 - .info-label { 2242 + .tabla-header {
377 - font-size: 0.75rem; 2243 + display: grid;
2244 + grid-template-columns: 1fr 1.2fr 1.2fr;
2245 + gap: 0.75rem;
2246 + padding-bottom: 0.375rem;
2247 + border-bottom: 1px solid var(--theme-borde);
2248 + margin-bottom: 0.25rem;
2249 + }
2250 +
2251 + .tabla-header .tabla-val {
2252 + font-size: 0.625rem;
2253 + font-weight: 500;
378 text-transform: uppercase; 2254 text-transform: uppercase;
379 letter-spacing: 0.05em; 2255 letter-spacing: 0.05em;
380 - color: var(--text-tertiary, #666); 2256 + text-align: center;
381 } 2257 }
382 2258
383 - .info-value { 2259 + .tabla-entidad {
384 - font-family: 'DM Mono', monospace; 2260 + font-size: 0.6875rem;
385 - font-size: 1.25rem; 2261 + line-height: 1.3;
386 - color: var(--text-primary, #fff); 2262 + word-break: break-word;
387 } 2263 }
388 2264
389 - .info-desc { 2265 + .tabla-row {
390 - font-size: 0.8rem; 2266 + display: grid;
391 - color: var(--text-secondary, #aaa); 2267 + grid-template-columns: 1fr 1.2fr 1.2fr;
392 - line-height: 1.4; 2268 + gap: 0.75rem;
2269 + padding: 0.1875rem 0;
2270 + }
2271 +
2272 + .tabla-label {
2273 + font-size: 0.6875rem;
2274 + font-weight: 500;
2275 + color: var(--theme-texto);
2276 + text-transform: uppercase;
2277 + letter-spacing: 0.05em;
2278 + }
2279 +
2280 + .tabla-val {
2281 + font-size: 0.875rem;
2282 + font-weight: 500;
2283 + color: var(--theme-titulo);
2284 + text-align: center;
2285 + font-variant-numeric: tabular-nums;
2286 + }
2287 +
2288 + @media (max-width: 640px) {
2289 + .main-comparar { padding: 0.75rem; }
2290 + .chart-card-comparar, .kpis-card-comparar { padding: 1rem; }
2291 + .comparador-selectors { flex-direction: column; gap: 0.75rem; }
2292 + .vs { display: none; }
393 } 2293 }
394 </style> 2294 </style>
......
1 +<script>
2 + import { page } from '$app/stores';
3 + import { goto } from '$app/navigation';
4 + import { onMount } from 'svelte';
5 + import Spinner from '$lib/components/ui/Spinner.svelte';
6 + import * as d3 from 'd3';
7 +
8 + let codigo = $derived($page.params.codigo);
9 +
10 + // Estado
11 + let loading = $state(true);
12 + let mounted = $state(false);
13 + let organismoInfo = $state(null);
14 + let entidadesData = $state([]);
15 + let selectedYear = $state(null);
16 + let linkCopied = $state(false);
17 + let hoveredNode = $state(null);
18 + let totalDevengado = $state(0);
19 + let entidadClasificador = $state({}); // Map entidad -> { desc_subarea, sigla_subarea, desc_area, ... }
20 + let subareas = $state([]); // Lista unica de subareas con color
21 + let sliderMin = $state(0);
22 + let sliderMax = $state(100);
23 +
24 + // Filtrar entidades por rango de % acumulado
25 + let filteredEntidades = $derived.by(() => {
26 + if (sliderMin === 0 && sliderMax === 100) return entidadesData;
27 + let acum = 0;
28 + return entidadesData.filter(d => {
29 + const pctBefore = (acum / totalDevengado) * 100;
30 + acum += d.monto;
31 + const pctAfter = (acum / totalDevengado) * 100;
32 + return pctAfter > sliderMin && pctBefore < sliderMax;
33 + });
34 + });
35 +
36 + let filteredTotal = $derived(filteredEntidades.reduce((s, d) => s + d.monto, 0));
37 + let filteredCount = $derived(filteredEntidades.length);
38 +
39 + // Treemap
40 + let treemapContainer = $state(null);
41 + let treemapWidth = $state(0);
42 + let treemapHeight = $state(0);
43 + let treemapNodes = $state([]);
44 + let treemapGroups = $state([]); // Nodos padre (subareas)
45 +
46 + // Anos disponibles desde gestiones del organismo
47 + let availableYears = $derived.by(() => {
48 + if (!organismoInfo?.gestiones) return [];
49 + if (organismoInfo.estados?.length > 0) {
50 + return organismoInfo.estados.map(e => e.gestion).filter(y => y >= 2016 && y > 0).sort((a, b) => b - a);
51 + }
52 + return organismoInfo.gestiones.split(',').map(Number).filter(y => y >= 2016 && y <= new Date().getFullYear()).sort((a, b) => b - a);
53 + });
54 +
55 + // Formatters
56 + function formatMonto(v) {
57 + if (v >= 1e9) return `${(v / 1e9).toFixed(1)} mil mill.`;
58 + if (v >= 1e6) return `${(v / 1e6).toFixed(1)} mill.`;
59 + if (v >= 1e3) return `${(v / 1e3).toFixed(0)} mil`;
60 + return v?.toLocaleString('es-BO') || '0';
61 + }
62 +
63 + function formatCompact(v) {
64 + if (v >= 1e9) return `${(v / 1e9).toFixed(1)}MM`;
65 + if (v >= 1e6) return `${(v / 1e6).toFixed(1)}M`;
66 + if (v >= 1e3) return `${(v / 1e3).toFixed(0)}K`;
67 + return v?.toFixed(0) || '0';
68 + }
69 +
70 + // Colores por subarea
71 + let isDark = $state(false);
72 + const SUBAREA_COLORS = [
73 + '#4E79A7', '#F28E2B', '#E15759', '#76B7B2', '#59A14F',
74 + '#EDC948', '#B07AA1', '#FF9DA7', '#9C755F', '#BAB0AC',
75 + '#4E79A7', '#F28E2B', '#E15759', '#76B7B2', '#59A14F'
76 + ];
77 +
78 + function getSubareaColor(subarea) {
79 + const idx = subareas.indexOf(subarea);
80 + return SUBAREA_COLORS[idx % SUBAREA_COLORS.length];
81 + }
82 +
83 + // Contraste de texto segun luminosidad del fondo (WCAG)
84 + function getTextColorForBg(bgColor) {
85 + const color = d3.color(bgColor);
86 + if (!color) return 'rgba(255,255,255,0.95)';
87 + const luminance = 0.299 * (color.r / 255) + 0.587 * (color.g / 255) + 0.114 * (color.b / 255);
88 + return luminance > 0.5 ? 'rgba(0,0,0,0.85)' : 'rgba(255,255,255,0.95)';
89 + }
90 +
91 + function getTextColorSecondary(bgColor) {
92 + const color = d3.color(bgColor);
93 + if (!color) return 'rgba(255,255,255,0.88)';
94 + const luminance = 0.299 * (color.r / 255) + 0.587 * (color.g / 255) + 0.114 * (color.b / 255);
95 + return luminance > 0.5 ? 'rgba(0,0,0,0.6)' : 'rgba(255,255,255,0.7)';
96 + }
97 +
98 + // Cargar clasificador de entidades
99 + async function loadClasificador() {
100 + try {
101 + const res = await fetch('/api/entidad-clasificador');
102 + const data = await res.json();
103 + if (Array.isArray(data)) {
104 + const map = {};
105 + data.forEach(d => { map[d.entidad] = d; });
106 + entidadClasificador = map;
107 + }
108 + } catch {}
109 + }
110 +
111 + // Cargar info del organismo desde API
112 + async function loadOrganismo() {
113 + try {
114 + const res = await fetch(`/api/organismo-data?codigo=${codigo}&tipo=clasificador`);
115 + const data = await res.json();
116 + if (data && !data.error) organismoInfo = data;
117 + } catch {}
118 + }
119 +
120 + // Cargar entidades desde API
121 + async function loadEntidades() {
122 + if (!selectedYear || !codigo) return;
123 + loading = true;
124 + try {
125 + const res = await fetch(`/api/organismo-data?codigo=${codigo}&tipo=entidades-año&gestion=${selectedYear}`);
126 + const raw = await res.json();
127 + if (Array.isArray(raw)) {
128 + // Deduplicar por entidad
129 + const map = new Map();
130 + raw.forEach(d => {
131 + if (!map.has(d.entidad) || d.monto > map.get(d.entidad).monto) {
132 + map.set(d.entidad, d);
133 + }
134 + });
135 + entidadesData = [...map.values()].filter(d => d.monto > 0).sort((a, b) => b.monto - a.monto);
136 + totalDevengado = entidadesData.reduce((s, d) => s + d.monto, 0);
137 +
138 + // Organismo entidades endpoint returns entidad_desc directly
139 + entidadesData = entidadesData.map(d => ({
140 + ...d,
141 + desc_entidad: d.entidad_desc || d.desc_entidad || `Entidad ${d.entidad}`,
142 + desc_subarea: entidadClasificador[d.entidad]?.desc_subarea || 'Otros'
143 + }));
144 +
145 + // Extraer subareas unicas ordenadas por monto total
146 + const subareaMontos = {};
147 + entidadesData.forEach(d => {
148 + subareaMontos[d.desc_subarea] = (subareaMontos[d.desc_subarea] || 0) + d.monto;
149 + });
150 + subareas = Object.entries(subareaMontos).sort((a, b) => b[1] - a[1]).map(([k]) => k);
151 +
152 + buildTreemap();
153 + }
154 + } catch (err) {
155 + console.error('[Entidades] Error:', err);
156 + }
157 + loading = false;
158 + }
159 +
160 + function buildTreemap() {
161 + if (!filteredEntidades.length || treemapWidth <= 0 || treemapHeight <= 0) {
162 + treemapNodes = [];
163 + treemapGroups = [];
164 + return;
165 + }
166 +
167 + // Agrupar por subarea
168 + const groups = {};
169 + filteredEntidades.forEach(d => {
170 + const sub = d.desc_subarea || 'Otros';
171 + if (!groups[sub]) groups[sub] = [];
172 + groups[sub].push({
173 + id: d.entidad,
174 + name: d.desc_entidad || `Entidad ${d.entidad}`,
175 + value: d.monto,
176 + ranking: d.ranking,
177 + per_capita: d.per_capita,
178 + prop: d.prop,
179 + subarea: sub
180 + });
181 + });
182 +
183 + const hierarchy = d3.hierarchy({
184 + name: 'root',
185 + children: Object.entries(groups).map(([subarea, children]) => ({
186 + name: subarea,
187 + children
188 + }))
189 + }).sum(d => d.value || 0)
190 + .sort((a, b) => b.value - a.value);
191 +
192 + d3.treemap()
193 + .size([treemapWidth, treemapHeight])
194 + .paddingOuter(3)
195 + .paddingInner(1)
196 + .paddingTop(18)
197 + .round(true)(hierarchy);
198 +
199 + treemapNodes = hierarchy.leaves() || [];
200 + treemapGroups = hierarchy.children || [];
201 + }
202 +
203 + function changeYear(year) {
204 + selectedYear = year;
205 + goto(`?gestion=${year}`, { replaceState: true, noScroll: true });
206 + loadEntidades();
207 + }
208 +
209 + $effect(() => {
210 + if (treemapContainer) {
211 + const measure = () => {
212 + const w = treemapContainer.clientWidth;
213 + const h = treemapContainer.clientHeight;
214 + if (w > 0 && h > 0) {
215 + treemapWidth = w;
216 + treemapHeight = h;
217 + buildTreemap();
218 + }
219 + };
220 + measure();
221 + const observer = new ResizeObserver(measure);
222 + observer.observe(treemapContainer);
223 + return () => observer.disconnect();
224 + }
225 + });
226 +
227 +
228 + onMount(async () => {
229 + mounted = true;
230 + isDark = document.documentElement.classList.contains('dark');
231 + const observer = new MutationObserver(() => {
232 + isDark = document.documentElement.classList.contains('dark');
233 + });
234 + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
235 +
236 + await Promise.all([loadOrganismo(), loadClasificador()]);
237 +
238 + const yearParam = $page.url.searchParams.get('gestion');
239 + if (yearParam) {
240 + const y = parseInt(yearParam);
241 + selectedYear = availableYears.includes(y) ? y : availableYears[0] || 2025;
242 + } else {
243 + selectedYear = availableYears[0] || 2025;
244 + }
245 +
246 + if (treemapContainer) {
247 + treemapWidth = treemapContainer.clientWidth;
248 + treemapHeight = treemapContainer.clientHeight;
249 + }
250 + await loadEntidades();
251 + });
252 +</script>
253 +
254 +<svelte:head>
255 + <title>{organismoInfo?.desc_organismo || `Organismo ${codigo}`} - Ranking de Entidades</title>
256 + <link rel="preconnect" href="https://fonts.googleapis.com" />
257 + <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" />
258 +</svelte:head>
259 +
260 +<div class="page" class:mounted>
261 + <div class="layout">
262 + <!-- Header -->
263 + <header class="header">
264 + <div class="header-top">
265 + <div class="pills">
266 + <span class="pill pill-nivel">organismo</span>
267 + <span class="pill pill-codigo">{codigo}</span>
268 + </div>
269 + <div class="header-actions">
270 + <button class="copy-link-btn" onclick={() => {
271 + navigator.clipboard.writeText(window.location.href);
272 + linkCopied = true;
273 + setTimeout(() => linkCopied = false, 2000);
274 + }}>
275 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
276 + {#if linkCopied}
277 + <path d="M20 6L9 17l-5-5"/>
278 + {:else}
279 + <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
280 + <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
281 + {/if}
282 + </svg>
283 + <span>{linkCopied ? 'Copiado' : 'Compartir'}</span>
284 + </button>
285 + </div>
286 + </div>
287 +
288 + <h1 class="title">{organismoInfo?.desc_organismo || 'Cargando...'}</h1>
289 + <p class="subtitle">Que entidades gastan mas con financiamiento de este organismo?</p>
290 +
291 + <!-- Ano + Stats -->
292 + <div class="meta-row">
293 + <div class="year-selector">
294 + {#each availableYears as year}
295 + <button class="year-btn" class:active={selectedYear === year} onclick={() => changeYear(year)}>{year}</button>
296 + {/each}
297 + </div>
298 + {#if !loading && entidadesData.length > 0}
299 + <div class="stats">
300 + <span class="stat"><strong>{entidadesData.length}</strong> entidades en total</span>
301 + </div>
302 + {/if}
303 + </div>
304 +
305 + <!-- Slider de rango -->
306 + {#if !loading && entidadesData.length > 0}
307 + <div class="slider-row">
308 + <span class="slider-label">Rango: {sliderMin}% - {sliderMax}%</span>
309 + <span class="slider-info">{filteredCount} entidades · {((filteredTotal / totalDevengado) * 100).toFixed(1)}% del gasto</span>
310 + </div>
311 + <div class="range-slider">
312 + <input type="range" min="0" max="100" step="1" bind:value={sliderMin} oninput={() => { if (sliderMin >= sliderMax) sliderMin = sliderMax - 1; buildTreemap(); }} />
313 + <input type="range" min="0" max="100" step="1" bind:value={sliderMax} oninput={() => { if (sliderMax <= sliderMin) sliderMax = sliderMin + 1; buildTreemap(); }} />
314 + </div>
315 + {/if}
316 + </header>
317 +
318 + <!-- Treemap -->
319 + <div class="treemap-wrapper" bind:this={treemapContainer}>
320 + {#if loading}
321 + <div class="loading"><Spinner size={48} color="var(--theme-texto)" /></div>
322 + {:else if treemapNodes.length > 0}
323 + <svg width={treemapWidth} height={treemapHeight}>
324 + <!-- Group labels (subareas) -->
325 + {#each treemapGroups as group}
326 + {@const gw = group.x1 - group.x0}
327 + {@const color = getSubareaColor(group.data.name)}
328 + {@const label = gw > 150 ? group.data.name : group.data.name.slice(0, Math.floor(gw / 7))}
329 + {#if gw > 50}
330 + <rect
331 + x={group.x0 + 3}
332 + y={group.y0 + 2}
333 + width={Math.min(label.length * 6.5 + 10, gw - 6)}
334 + height="14"
335 + rx="3"
336 + fill={isDark ? 'rgba(20, 20, 18, 0.85)' : 'rgba(255, 255, 255, 0.9)'}
337 + />
338 + <text
339 + x={group.x0 + 8}
340 + y={group.y0 + 13}
341 + fill={isDark ? '#F5F0E8' : '#1a1a1a'}
342 + font-size="9"
343 + font-family="'Qanelas', var(--font-sans)"
344 + font-weight="600"
345 + >
346 + {label}
347 + </text>
348 + {/if}
349 + {/each}
350 +
351 + <!-- Entity nodes -->
352 + {#each treemapNodes as node, i}
353 + {@const w = node.x1 - node.x0}
354 + {@const h = node.y1 - node.y0}
355 + {@const pct = (node.value / totalDevengado) * 100}
356 + {@const isHovered = hoveredNode === node}
357 + {@const area = Math.sqrt(w * h)}
358 + {@const pctSize = Math.min(20, Math.max(9, area / 8))}
359 + {@const nameSize = Math.min(12, Math.max(7, area / 12))}
360 + {@const color = getSubareaColor(node.data.subarea)}
361 +
362 + <g
363 + transform="translate({node.x0}, {node.y0})"
364 + onmouseenter={() => hoveredNode = node}
365 + onmouseleave={() => hoveredNode = null}
366 + style="cursor: pointer;"
367 + onclick={() => goto(`/organismo/${codigo}?entidad=${node.data.id}`)}
368 + >
369 + <rect
370 + width={w} height={h}
371 + fill={color}
372 + opacity={hoveredNode ? (isHovered ? 1 : hoveredNode.data.subarea === node.data.subarea ? 0.6 : 0.2) : 0.85}
373 + stroke={isHovered ? 'var(--theme-titulo)' : 'rgba(0,0,0,0.15)'}
374 + stroke-width={isHovered ? 2 : 0.5}
375 + rx="2"
376 + />
377 + {#if w > 35 && h > 22}
378 + {@const textColor = getTextColorForBg(color)}
379 + {@const textColorSec = getTextColorSecondary(color)}
380 + <foreignObject x="4" y="3" width={w - 8} height={h - 6}>
381 + <div class="node-text">
382 + <span class="node-pct" style="font-size:{pctSize}px; color:{textColor}">{pct >= 1 ? pct.toFixed(0) : pct.toFixed(1)}%</span>
383 + {#if h > 40 && w > 55}
384 + <span class="node-name" style="font-size:{nameSize}px; color:{textColorSec}">{w > 100 ? node.data.name : node.data.name.slice(0, Math.floor(w / 7))}</span>
385 + {/if}
386 + </div>
387 + </foreignObject>
388 + {/if}
389 + </g>
390 + {/each}
391 + </svg>
392 +
393 + {#if hoveredNode}
394 + {@const pct = (hoveredNode.value / totalDevengado) * 100}
395 + <div class="tooltip" style="left:{Math.min(hoveredNode.x0 + 10, treemapWidth - 250)}px; top:{Math.min(hoveredNode.y0 + 10, treemapHeight - 90)}px;">
396 + <span class="tooltip-rank">#{treemapNodes.indexOf(hoveredNode) + 1} de {treemapNodes.length}</span>
397 + <span class="tooltip-subarea" style="color: {getSubareaColor(hoveredNode.data.subarea)}">{hoveredNode.data.subarea}</span>
398 + <span class="tooltip-name">{hoveredNode.data.name}</span>
399 + <span class="tooltip-val">{formatMonto(hoveredNode.value)} Bs · {pct.toFixed(1)}%</span>
400 + </div>
401 + {/if}
402 + {:else}
403 + <div class="loading">No hay datos para {selectedYear}</div>
404 + {/if}
405 + </div>
406 +
407 + </div>
408 +</div>
409 +
410 +<style>
411 + .page {
412 + min-height: 100vh;
413 + width: 100%;
414 + max-width: 100%;
415 + overflow-x: hidden;
416 + background: var(--theme-body);
417 + color: var(--theme-titulo);
418 + font-family: var(--font-sans), -apple-system, sans-serif;
419 + opacity: 0;
420 + transition: opacity 0.4s;
421 + }
422 + .page.mounted { opacity: 1; }
423 +
424 + :global(html:not(.dark)) .page { background: #f5f5f7; }
425 +
426 + .layout {
427 + max-width: 1400px;
428 + margin: 0 auto;
429 + padding: 80px 2rem 2rem;
430 + height: 100vh;
431 + display: flex;
432 + flex-direction: column;
433 + overflow: hidden;
434 + }
435 +
436 + /* Header */
437 + .header { flex-shrink: 0; margin-bottom: 1rem; }
438 +
439 + .header-top {
440 + display: flex;
441 + justify-content: space-between;
442 + align-items: center;
443 + margin-bottom: 0.375rem;
444 + }
445 +
446 + .pills { display: flex; gap: 0.375rem; }
447 +
448 + .pill {
449 + font-family: 'DM Mono', monospace;
450 + font-size: 0.625rem;
451 + padding: 0.125rem 0.5rem;
452 + border-radius: 4px;
453 + letter-spacing: 0.03em;
454 + }
455 + .pill-nivel { background: rgba(201, 167, 81, 0.15); color: var(--theme-accent); }
456 + .pill-codigo { background: var(--theme-borde); color: var(--theme-texto); }
457 +
458 + .header-actions { display: flex; align-items: center; gap: 0.75rem; }
459 +
460 + .copy-link-btn {
461 + display: flex;
462 + align-items: center;
463 + gap: 0.375rem;
464 + font-size: 0.75rem;
465 + color: var(--theme-texto);
466 + opacity: 0.6;
467 + background: none;
468 + border: none;
469 + padding: 0;
470 + cursor: pointer;
471 + transition: opacity 0.15s;
472 + }
473 + .copy-link-btn:hover { opacity: 1; }
474 +
475 + .title {
476 + font-family: 'DM Serif Display', serif;
477 + font-size: 2rem;
478 + font-weight: 400;
479 + margin: 0 0 0.25rem;
480 + line-height: 1.15;
481 + }
482 +
483 + .subtitle {
484 + font-size: 0.8125rem;
485 + color: var(--theme-texto);
486 + opacity: 0.6;
487 + margin: 0 0 0.75rem;
488 + }
489 +
490 + .meta-row {
491 + display: flex;
492 + align-items: center;
493 + justify-content: space-between;
494 + gap: 1rem;
495 + }
496 +
497 + .year-selector { display: flex; gap: 0.25rem; overflow-x: auto; scrollbar-width: none; }
498 + .year-selector::-webkit-scrollbar { display: none; }
499 +
500 + .year-btn {
501 + font-family: 'DM Mono', monospace;
502 + font-size: 0.6875rem;
503 + padding: 0.25rem 0.625rem;
504 + border: 1px solid var(--theme-borde);
505 + border-radius: 6px;
506 + background: transparent;
507 + color: var(--theme-texto);
508 + cursor: pointer;
509 + transition: all 0.15s;
510 + }
511 + .year-btn:hover { border-color: var(--theme-accent); color: var(--theme-titulo); }
512 + .year-btn.active {
513 + background: var(--theme-accent);
514 + border-color: var(--theme-accent);
515 + color: white;
516 + font-weight: 600;
517 + }
518 +
519 + .stats {
520 + display: flex;
521 + align-items: center;
522 + gap: 0.375rem;
523 + font-size: 0.75rem;
524 + color: var(--theme-texto);
525 + opacity: 0.7;
526 + }
527 + .stats strong { color: var(--theme-titulo); font-weight: 600; }
528 +
529 + /* Slider */
530 + .slider-row {
531 + display: flex;
532 + justify-content: space-between;
533 + align-items: center;
534 + margin-top: 0.5rem;
535 + }
536 +
537 + .slider-label {
538 + font-family: 'DM Mono', monospace;
539 + font-size: 0.6875rem;
540 + color: var(--theme-titulo);
541 + font-weight: 600;
542 + }
543 +
544 + .slider-info {
545 + font-size: 0.6875rem;
546 + color: var(--theme-texto);
547 + opacity: 0.6;
548 + }
549 +
550 + .range-slider {
551 + position: relative;
552 + height: 20px;
553 + margin-bottom: 0.5rem;
554 + }
555 +
556 + .range-slider input[type="range"] {
557 + position: absolute;
558 + width: 100%;
559 + height: 4px;
560 + top: 8px;
561 + pointer-events: none;
562 + -webkit-appearance: none;
563 + appearance: none;
564 + background: transparent;
565 + }
566 +
567 + .range-slider input[type="range"]:first-child {
568 + background: var(--theme-borde);
569 + border-radius: 2px;
570 + }
571 +
572 + .range-slider input[type="range"]::-webkit-slider-thumb {
573 + -webkit-appearance: none;
574 + appearance: none;
575 + width: 14px;
576 + height: 14px;
577 + border-radius: 50%;
578 + background: var(--theme-accent);
579 + border: 2px solid var(--theme-surface);
580 + cursor: pointer;
581 + pointer-events: all;
582 + box-shadow: 0 1px 3px rgba(0,0,0,0.2);
583 + }
584 +
585 + .range-slider input[type="range"]::-moz-range-thumb {
586 + width: 14px;
587 + height: 14px;
588 + border-radius: 50%;
589 + background: var(--theme-accent);
590 + border: 2px solid var(--theme-surface);
591 + cursor: pointer;
592 + pointer-events: all;
593 + box-shadow: 0 1px 3px rgba(0,0,0,0.2);
594 + }
595 +
596 + /* Treemap */
597 + .treemap-wrapper {
598 + flex: 1;
599 + min-height: 200px;
600 + position: relative;
601 + background: var(--theme-surface);
602 + border-radius: 12px;
603 + overflow: hidden;
604 + }
605 +
606 + .treemap-wrapper svg { display: block; width: 100%; height: 100%; }
607 +
608 + .loading {
609 + position: absolute;
610 + inset: 0;
611 + display: flex;
612 + align-items: center;
613 + justify-content: center;
614 + color: var(--theme-texto);
615 + opacity: 0.5;
616 + font-size: 0.875rem;
617 + }
618 +
619 + .node-text {
620 + display: flex;
621 + flex-direction: column;
622 + overflow: hidden;
623 + }
624 +
625 + .node-pct { font-weight: 700; line-height: 1; }
626 + .node-name { line-height: 1.2; margin-top: 1px; }
627 +
628 + /* Tooltip */
629 + .tooltip {
630 + position: absolute;
631 + pointer-events: none;
632 + padding: 0.5rem 0.75rem;
633 + border-radius: 8px;
634 + z-index: 10;
635 + max-width: 250px;
636 + background: rgba(30, 30, 30, 0.9);
637 + backdrop-filter: blur(12px);
638 + border: 1px solid rgba(255, 255, 255, 0.1);
639 + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
640 + color: #f5f5f5;
641 + display: flex;
642 + flex-direction: column;
643 + gap: 2px;
644 + }
645 +
646 + :global(html:not(.dark)) .tooltip {
647 + background: rgba(255, 255, 255, 0.9);
648 + border-color: rgba(0, 0, 0, 0.1);
649 + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
650 + color: #1a1a1a;
651 + }
652 +
653 + .tooltip-rank { font-size: 0.6rem; opacity: 0.5; text-transform: uppercase; letter-spacing: 0.05em; }
654 + .tooltip-subarea { font-size: 0.6875rem; font-weight: 600; }
655 + .tooltip-name { font-size: 0.8125rem; font-weight: 600; }
656 + .tooltip-val { font-family: 'DM Mono', monospace; font-size: 0.75rem; opacity: 0.7; }
657 +
658 + /* Responsive */
659 + @media (max-width: 900px) {
660 + .layout {
661 + padding: 70px 1rem 1rem;
662 + height: auto;
663 + min-height: 100vh;
664 + overflow: visible;
665 + }
666 +
667 + .treemap-wrapper {
668 + min-height: 350px;
669 + height: 50vh;
670 + flex: none;
671 + }
672 + }
673 +
674 + @media (max-width: 640px) {
675 + .layout { padding: 60px 0.75rem 1rem; }
676 + .title { font-size: 1.375rem; }
677 + .subtitle { font-size: 0.75rem; }
678 + .meta-row { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
679 + .header-top { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
680 +
681 + .year-selector {
682 + flex-wrap: wrap;
683 + gap: 0.25rem;
684 + }
685 +
686 + .year-btn {
687 + font-size: 0.625rem;
688 + padding: 0.2rem 0.5rem;
689 + }
690 +
691 + .slider-row {
692 + flex-direction: column;
693 + align-items: flex-start;
694 + gap: 0.125rem;
695 + }
696 +
697 + .stats { font-size: 0.6875rem; }
698 +
699 + .treemap-wrapper {
700 + min-height: 300px;
701 + height: 45vh;
702 + }
703 +
704 + .tooltip {
705 + max-width: 200px;
706 + font-size: 0.75rem;
707 + }
708 +
709 + .header-actions {
710 + width: 100%;
711 + justify-content: space-between;
712 + }
713 + }
714 +
715 + @media (max-width: 380px) {
716 + .layout { padding: 55px 0.5rem 0.75rem; }
717 + .title { font-size: 1.125rem; }
718 +
719 + .treemap-wrapper {
720 + min-height: 250px;
721 + height: 40vh;
722 + }
723 +
724 + .year-btn {
725 + font-size: 0.5625rem;
726 + padding: 0.15rem 0.375rem;
727 + }
728 + }
729 +</style>
1 -import { supabase } from '$lib/supabase';
2 import { error } from '@sveltejs/kit'; 1 import { error } from '@sveltejs/kit';
3 2
4 -// Funciones para derivar jerarquía del código rubro 3 +const API_BASE = 'http://136.112.29.74/api/rubro';
5 -// Jerarquía: Tipo (XX000) → Clase (XXX00) → Cuenta (XXXX0) → Subcuenta (XXXXX)
6 -
7 -function getTipoCode(rubro) {
8 - // Primeros 2 dígitos + 000 → ej: 11000, 12000
9 - return rubro.substring(0, 2) + '000';
10 -}
11 -
12 -function getClaseCode(rubro) {
13 - // Primeros 3 dígitos + 00 → ej: 11100, 11200
14 - return rubro.substring(0, 3) + '00';
15 -}
16 -
17 -function getCuentaCode(rubro) {
18 - // Primeros 4 dígitos + 0 → ej: 11110, 11120
19 - return rubro.substring(0, 4) + '0';
20 -}
21 -
22 -function getNivelFromCode(rubro) {
23 - // Determinar nivel basado en el patrón de ceros
24 - if (rubro.endsWith('000')) return 'tipo'; // XX000
25 - if (rubro.endsWith('00')) return 'clase'; // XXX00
26 - if (rubro.endsWith('0')) return 'cuenta'; // XXXX0
27 - return 'sub_cuenta'; // XXXXX
28 -}
29 -
30 -function getNextNivel(nivel) {
31 - const niveles = { tipo: 'clase', clase: 'cuenta', cuenta: 'sub_cuenta' };
32 - return niveles[nivel] || null;
33 -}
34 4
35 export async function load({ params }) { 5 export async function load({ params }) {
36 const { codigo } = params; 6 const { codigo } = params;
37 7
38 - const { data, error: dbError } = await supabase 8 + const res = await fetch(`${API_BASE}/${codigo}`);
39 - .schema('ppto') 9 + if (!res.ok) throw error(404, 'Rubro no encontrado');
40 - .from('clas_rubros') 10 + const rubro = await res.json();
41 - .select('*')
42 - .eq('rubro', codigo);
43 11
44 - if (dbError || !data || data.length === 0) { 12 + const nivel = rubro.nivel;
45 - throw error(404, 'Rubro no encontrado');
46 - }
47 -
48 - const rubro = data[0];
49 - const nivel = rubro.nivel || getNivelFromCode(codigo);
50 -
51 - // Obtener jerarquía (padres e hijos)
52 let padres = []; 13 let padres = [];
53 let hijos = []; 14 let hijos = [];
54 15
55 - // Buscar padres según nivel 16 + try {
56 - if (nivel === 'sub_cuenta') { 17 + const clasRes = await fetch(`${API_BASE}/clasificador`);
57 - // Padres: tipo, clase, cuenta 18 + if (clasRes.ok) {
58 - const tipoCode = getTipoCode(codigo); 19 + const clasificador = await clasRes.json();
59 - const claseCode = getClaseCode(codigo); 20 + const s = String(codigo).padStart(5, '0');
60 - const cuentaCode = getCuentaCode(codigo); 21 +
61 - 22 + // Rubro codes are 5 digits: ABCDE
62 - const { data: padresData } = await supabase 23 + // tipo = AB000, clase = ABC00, cuenta = ABCD0, sub_cuenta = ABCDE
63 - .schema('ppto') 24 + const tipoCode = s.substring(0, 2) + '000';
64 - .from('clas_rubros') 25 + const claseCode = s.substring(0, 3) + '00';
65 - .select('*') 26 + const cuentaCode = s.substring(0, 4) + '0';
66 - .in('rubro', [tipoCode, claseCode, cuentaCode]) 27 +
67 - .order('rubro'); 28 + // Padres según nivel
68 - 29 + const parentCodes = [];
69 - if (padresData) padres = padresData; 30 + if (nivel === 'sub_cuenta') parentCodes.push(cuentaCode, claseCode, tipoCode);
70 - } else if (nivel === 'cuenta') { 31 + else if (nivel === 'cuenta') parentCodes.push(claseCode, tipoCode);
71 - // Padres: tipo, clase 32 + else if (nivel === 'clase') parentCodes.push(tipoCode);
72 - const tipoCode = getTipoCode(codigo); 33 +
73 - const claseCode = getClaseCode(codigo); 34 + padres = clasificador.filter(c => {
74 - 35 + const rc = String(c.rubro).padStart(5, '0');
75 - const { data: padresData } = await supabase 36 + return parentCodes.includes(rc) && rc !== s;
76 - .schema('ppto') 37 + });
77 - .from('clas_rubros')
78 - .select('*')
79 - .in('rubro', [tipoCode, claseCode])
80 - .order('rubro');
81 -
82 - if (padresData) padres = padresData;
83 - } else if (nivel === 'clase') {
84 - // Padre: tipo
85 - const tipoCode = getTipoCode(codigo);
86 -
87 - const { data: padresData } = await supabase
88 - .schema('ppto')
89 - .from('clas_rubros')
90 - .select('*')
91 - .eq('rubro', tipoCode);
92 -
93 - if (padresData) padres = padresData;
94 - }
95 - // tipo no tiene padres
96 -
97 - // Buscar hijos según nivel
98 - const nextNivel = getNextNivel(nivel);
99 - if (nextNivel) {
100 - const { data: hijosData } = await supabase
101 - .schema('ppto')
102 - .from('clas_rubros')
103 - .select('*')
104 - .eq('nivel', nextNivel)
105 - .like('rubro', `${codigo.replace(/0+$/, '')}%`)
106 - .order('rubro');
107 38
108 - if (hijosData) { 39 + // Hijos directos
109 - // Filtrar solo hijos directos (siguiente nivel)
110 - hijos = hijosData.filter(h => {
111 if (nivel === 'tipo') { 40 if (nivel === 'tipo') {
112 - // Hijos de tipo son clase: XXX00 donde XX = tipo 41 + hijos = clasificador.filter(c => c.nivel === 'clase' && String(c.rubro).padStart(5, '0').substring(0, 2) === s.substring(0, 2));
113 - return h.rubro.startsWith(codigo.substring(0, 2)) && h.nivel === 'clase';
114 } else if (nivel === 'clase') { 42 } else if (nivel === 'clase') {
115 - // Hijos de clase son cuenta: XXXX0 donde XXX = clase 43 + hijos = clasificador.filter(c => (c.nivel === 'cuenta') && String(c.rubro).padStart(5, '0').substring(0, 3) === s.substring(0, 3));
116 - return h.rubro.startsWith(codigo.substring(0, 3)) && h.nivel === 'cuenta';
117 } else if (nivel === 'cuenta') { 44 } else if (nivel === 'cuenta') {
118 - // Hijos de cuenta son sub_cuenta: XXXXX donde XXXX = cuenta 45 + hijos = clasificador.filter(c => (c.nivel === 'sub_cuenta') && String(c.rubro).padStart(5, '0').substring(0, 4) === s.substring(0, 4));
119 - return h.rubro.startsWith(codigo.substring(0, 4)) && h.nivel === 'sub_cuenta';
120 - }
121 - return false;
122 - });
123 } 46 }
47 +
48 + padres.sort((a, b) => String(a.rubro).localeCompare(String(b.rubro)));
49 + hijos.sort((a, b) => String(a.rubro).localeCompare(String(b.rubro)));
124 } 50 }
51 + } catch { /* optional */ }
125 52
126 - return { 53 + return { rubro, padres, hijos };
127 - rubro,
128 - padres,
129 - hijos
130 - };
131 } 54 }
......
1 <script> 1 <script>
2 + import { onMount } from 'svelte';
3 + import { tweened } from 'svelte/motion';
4 + import { cubicOut } from 'svelte/easing';
5 + import { goto } from '$app/navigation';
6 + import { page } from '$app/stores';
7 + import Spinner from '$lib/components/ui/Spinner.svelte';
8 + import VistaToggle from '$lib/components/objeto/VistaToggle.svelte';
9 + import BarChart from '$lib/components/objeto/BarChart.svelte';
10 + import EntitySelector from '$lib/components/objeto/EntitySelector.svelte';
11 +
2 let { data } = $props(); 12 let { data } = $props();
13 + let rubroData = $derived(data.rubro);
14 + let padres = $derived(data.padres);
15 + let hijos = $derived(data.hijos);
16 +
17 + let mounted = $state(false);
18 + let loading = $state(false);
19 + let hoveredYear = $state(null);
20 + let lockedYear = $state(null);
21 + let activeYear = $derived(lockedYear ?? hoveredYear);
22 + let showDefinicionesModal = $state(false);
23 + let showMobileSidebar = $state(false);
24 + let metrica = $state('percapita');
25 + let vista = $state('agregado');
26 + let linkCopied = $state(false);
3 27
4 - const rubro = data.rubro; 28 + // Inline search state
5 - const padres = data.padres; 29 + let searchInputRef = $state(null);
6 - const hijos = data.hijos; 30 + let searchVal = $state('');
31 + let searchFocused = $state(false);
32 + let isSearching = $state(false);
33 + let localResults = $state([]);
34 + let selectedIdx = $state(-1);
35 + let debounceTimer;
36 +
37 + function parseMetadatos(meta) {
38 + if (!meta) return {};
39 + if (typeof meta === 'object') return meta;
40 + try { return JSON.parse(meta); } catch { return {}; }
41 + }
7 42
8 - function parseDescripciones(descripcionesStr) { 43 + async function doSearch(query) {
9 - if (!descripcionesStr) return []; 44 + isSearching = true;
10 try { 45 try {
11 - const parsed = JSON.parse(descripcionesStr); 46 + const params = new URLSearchParams({
12 - return parsed.sort((a, b) => { 47 + q: query,
13 - const maxYearA = getMaxYear(a.rangos); 48 + per_page: '20',
14 - const maxYearB = getMaxYear(b.rangos); 49 + is_class: 'true',
15 - return maxYearB - maxYearA; 50 + class_: 'rubro'
51 + });
52 + const res = await fetch(`/api/search?${params}`);
53 + if (!res.ok) throw new Error();
54 + const data = await res.json();
55 + localResults = (data.hits || []).map(hit => {
56 + const meta = parseMetadatos(hit.document.metadatos);
57 + const codigo = meta.rubro_sub_cuenta || meta.rubro_cuenta || meta.rubro_clase || meta.rubro_tipo || '';
58 + const nombre = hit.document.texto;
59 + const highlight = hit.highlights?.[0]?.snippet || nombre;
60 + return { codigo, nombre, highlight };
61 + });
62 + selectedIdx = -1;
63 + } catch {
64 + localResults = [];
65 + }
66 + isSearching = false;
67 + }
68 +
69 + function handleSearchInput(e) {
70 + searchVal = e.target.value;
71 + clearTimeout(debounceTimer);
72 + if (searchVal.length >= 2) {
73 + debounceTimer = setTimeout(() => doSearch(searchVal), 200);
74 + } else {
75 + localResults = [];
76 + }
77 + }
78 +
79 + function handleSearchKeydown(e) {
80 + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
81 + e.preventDefault();
82 + if (e.key === 'ArrowDown') {
83 + selectedIdx = selectedIdx < localResults.length - 1 ? selectedIdx + 1 : 0;
84 + } else {
85 + selectedIdx = selectedIdx > 0 ? selectedIdx - 1 : localResults.length - 1;
86 + }
87 + } else if (e.key === 'Enter' && localResults.length > 0) {
88 + e.preventDefault();
89 + const selected = localResults[selectedIdx >= 0 ? selectedIdx : 0];
90 + if (selected) handleSearchSelect(selected);
91 + } else if (e.key === 'Escape') {
92 + searchVal = '';
93 + localResults = [];
94 + searchInputRef?.blur();
95 + }
96 + }
97 +
98 + function handleSearchSelect(result) {
99 + const url = vista === 'comparar'
100 + ? `/rubro/${result.codigo}?vista=comparar`
101 + : `/rubro/${result.codigo}`;
102 + goto(url);
103 + searchVal = '';
104 + localResults = [];
105 + searchFocused = false;
106 + }
107 +
108 + function handleSearchBlur() {
109 + setTimeout(() => { searchFocused = false; }, 150);
110 + }
111 +
112 + function handleClear() {
113 + localResults = [];
114 + searchVal = '';
115 + }
116 +
117 + // Parsear descripciones
118 + function getMaxYear(str) {
119 + if (!str) return 0;
120 + const years = str.match(/\d{4}/g);
121 + return years ? Math.max(...years.map(Number)) : 0;
122 + }
123 +
124 + let descripciones = $derived.by(() => {
125 + const descs = rubroData.descripciones;
126 + if (!descs) return [];
127 + let arr;
128 + if (typeof descs === 'string') {
129 + try { arr = JSON.parse(descs); } catch { return []; }
130 + } else {
131 + arr = Array.isArray(descs) ? descs : [];
132 + }
133 + return [...arr].sort((a, b) => getMaxYear(b.gestion || b.rangos) - getMaxYear(a.gestion || a.rangos));
134 + });
135 + let variaciones = $derived(descripciones.map(d => ({ rango: d.gestion || d.rangos || '', texto: d.descripcion })));
136 +
137 + // Estado selector entidad
138 + let entidades = $state([]);
139 + let selectedEntity = $state(null);
140 + let nEntidades = $derived(entidades.length || data.nEntidades || 0);
141 +
142 + // Datos
143 + function initEstados(obj) {
144 + const estadosRaw = obj?.estados || [];
145 + const seen = new Set();
146 + const estados = estadosRaw.filter(d => { if (seen.has(d.gestion)) return false; seen.add(d.gestion); return true; });
147 + const hist = estados.find(d => d.gestion === 0) || null;
148 + const anuales = estados.filter(d => d.gestion >= 2016 && d.gestion > 0);
149 + const topPorAño = {};
150 + anuales.forEach(d => {
151 + topPorAño[d.gestion] = [
152 + { nombre: d.top1_entidad_desc, porcentaje: d.top1_pct, entidad: d.top1_entidad },
153 + { nombre: d.top2_entidad_desc, porcentaje: d.top2_pct, entidad: d.top2_entidad },
154 + { nombre: d.top3_entidad_desc, porcentaje: d.top3_pct, entidad: d.top3_entidad },
155 + ].filter(e => e.nombre);
156 + });
157 + return { hist, anuales, topPorAño };
158 + }
159 +
160 + let { hist: initHist, anuales: initAnuales, topPorAño: initTop } = initEstados(rubroData);
161 + let datosAnuales = $state(initAnuales);
162 + let datosHistorico = $state(initHist);
163 + let topEntidadesPorAño = $state(initTop);
164 +
165 + const POBLACION = 12000000;
166 + let rubroCodigo = $derived(rubroData.rubro);
167 + let rubroNivel = $derived(rubroData.nivel);
168 +
169 + let gastoPerCapita = $derived(
170 + datosAnuales.map(d => {
171 + const monto = Number(selectedEntity ? (d.monto ?? d.devengado ?? 0) : (d.total ?? 0)) || 0;
172 + const perCapita = Number(d.per_capita ?? (d.devengado ? d.devengado / POBLACION : 0)) || 0;
173 + return {
174 + año: d.gestion,
175 + monto,
176 + perCapita: metrica === 'percapita' ? perCapita : monto
177 + };
178 + })
179 + );
180 +
181 + let primerAño = $derived(gastoPerCapita.length > 0 ? gastoPerCapita[0].año : 2005);
182 + let ultimoAño = $derived(gastoPerCapita.length > 0 ? gastoPerCapita[gastoPerCapita.length - 1].año : 2025);
183 +
184 + let totalHistorico = $derived(datosHistorico ? (selectedEntity ? (datosHistorico.monto ?? 0) : (datosHistorico.total ?? 0)) : 0);
185 + let promedioPerCapita = $derived(datosHistorico?.per_capita ?? 0);
186 + let totalPorcentaje = $derived(datosHistorico?.prop ?? 0);
187 + let ranking = $derived(datosHistorico?.ranking ?? 0);
188 + let nPares = $derived(datosHistorico?.n_pares ?? 0);
189 + let totalGestion = $derived(datosHistorico?.total_gestion ?? 0);
190 +
191 + let currentData = $derived(
192 + activeYear ? gastoPerCapita.find(g => g.año === activeYear) : null
193 + );
194 +
195 + let currentAnual = $derived(activeYear ? datosAnuales.find(d => d.gestion === activeYear) : null);
196 +
197 + // KPI 1: Monto
198 + let displayMonto = $derived.by(() => {
199 + if (selectedEntity) {
200 + return (currentAnual ? currentAnual.monto : totalHistorico) ?? 0;
201 + }
202 + return (currentAnual ? currentAnual.total_gestion : totalGestion) ?? 0;
203 + });
204 + let displayMontoLabel = $derived.by(() => {
205 + if (selectedEntity) {
206 + return currentAnual ? `recaudados en ${currentAnual.gestion}` : `recaudados ${primerAño}-${ultimoAño}`;
207 + }
208 + return currentAnual ? `ingreso total del Estado en ${currentAnual.gestion}` : `ingreso total ${primerAño}-${ultimoAño}`;
209 + });
210 +
211 + // KPI 2: Per capita
212 + let displayPerCapita = $derived((currentAnual?.per_capita ?? promedioPerCapita) ?? 0);
213 + let displayPerCapitaLabel = $derived(currentAnual ? `por cada boliviano en ${currentAnual.gestion}` : 'por cada boliviano (promedio)');
214 +
215 + // KPI 3: Proporcion
216 + let displayPorcentaje = $derived((currentAnual?.prop ?? totalPorcentaje) ?? 0);
217 + let displayPorcentajeLabel = $derived.by(() => {
218 + if (selectedEntity) {
219 + return currentAnual ? `del total de este rubro en ${currentAnual.gestion}` : 'del total de este rubro';
220 + }
221 + return currentAnual ? `del ingreso en ${currentAnual.gestion}` : 'del ingreso total';
222 + });
223 +
224 + let displayPeriodo = $derived(currentData ? currentData.año : `${primerAño}-${ultimoAño}`);
225 + let displayRanking = $derived(`${currentAnual ? currentAnual.ranking : ranking} de ${selectedEntity ? (datosHistorico?.n_entidades || nEntidades) : (currentAnual ? currentAnual.n_pares : nPares)}`);
226 +
227 + let nivelKey = $derived(rubroData.nivel?.toLowerCase());
228 + let nivelPlural = $derived({ tipo: 'tipos', clase: 'clases', cuenta: 'cuentas', sub_cuenta: 'sub cuentas' }[nivelKey] || 'rubros');
229 + let nivelLabel = $derived({ tipo: 'Tipo', clase: 'Clase', cuenta: 'Cuenta', sub_cuenta: 'Sub cuenta' }[nivelKey] || rubroData.nivel);
230 + let displayRankingLabel = $derived.by(() => {
231 + if (selectedEntity) {
232 + return currentAnual ? `entre las que más reciben en ${currentAnual.gestion}` : 'entre las que más reciben';
233 + }
234 + return currentData ? `${nivelPlural} más recaudados en ${currentData.año}` : `${nivelPlural} más recaudados`;
16 }); 235 });
236 +
237 + // Top entidades
238 + let topEntidadesTotal = $derived(
239 + datosHistorico && !selectedEntity ? [
240 + { nombre: datosHistorico.top1_entidad_desc, porcentaje: datosHistorico.top1_pct, entidad: datosHistorico.top1_entidad },
241 + { nombre: datosHistorico.top2_entidad_desc, porcentaje: datosHistorico.top2_pct, entidad: datosHistorico.top2_entidad },
242 + { nombre: datosHistorico.top3_entidad_desc, porcentaje: datosHistorico.top3_pct, entidad: datosHistorico.top3_entidad },
243 + ].filter(e => e.nombre) : []
244 + );
245 + let displayTopEntidades = $derived(
246 + activeYear && topEntidadesPorAño[activeYear] ? topEntidadesPorAño[activeYear] : topEntidadesTotal
247 + );
248 +
249 + // Tweens
250 + const tw = { duration: 300, easing: cubicOut };
251 + const twMonto = tweened(0, tw);
252 + const twPerCapita = tweened(0, tw);
253 + const twPorcentaje = tweened(0, tw);
254 + const twTop1 = tweened(0, tw);
255 + const twTop2 = tweened(0, tw);
256 + const twTop3 = tweened(0, tw);
257 +
258 + $effect(() => { twMonto.set(typeof displayMonto === 'number' ? displayMonto : 0); });
259 + $effect(() => { twPerCapita.set(typeof displayPerCapita === 'number' ? displayPerCapita : 0); });
260 + $effect(() => { twPorcentaje.set(typeof displayPorcentaje === 'number' ? displayPorcentaje : 0); });
261 + $effect(() => { if (displayTopEntidades[0]) twTop1.set(displayTopEntidades[0].porcentaje); });
262 + $effect(() => { if (displayTopEntidades[1]) twTop2.set(displayTopEntidades[1].porcentaje); });
263 + $effect(() => { if (displayTopEntidades[2]) twTop3.set(displayTopEntidades[2].porcentaje); });
264 +
265 + function formatMontoLargo(n) {
266 + if (n >= 1e9) return `${(n / 1e9).toFixed(1)} mil mill.`;
267 + if (n >= 1e6) return `${(n / 1e6).toFixed(1)} mill.`;
268 + if (n >= 1e3) return `${(n / 1e3).toFixed(0)} mil`;
269 + return n.toLocaleString('es-BO');
270 + }
271 +
272 + function formatPerCapita(n) {
273 + return n.toLocaleString('es-BO', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
274 + }
275 +
276 + async function loadData() {
277 + if (!selectedEntity) return;
278 + lockedYear = null;
279 + loading = true;
280 + try {
281 + const res = await fetch(`/api/rubro-data?codigo=${rubroCodigo}&tipo=entidad&entidad=${selectedEntity.entidad}`);
282 + const raw = await res.json();
283 + if (Array.isArray(raw)) {
284 + const seen = new Set();
285 + const data = raw.filter(d => { if (seen.has(d.gestion)) return false; seen.add(d.gestion); return true; });
286 + datosHistorico = data.find(d => d.gestion === 0) || null;
287 + datosAnuales = data.filter(d => d.gestion >= 2016);
288 + topEntidadesPorAño = {};
289 + if (datosHistorico) {
290 + twMonto.set(datosHistorico.monto || 0);
291 + twPerCapita.set(datosHistorico.per_capita || 0);
292 + twPorcentaje.set(datosHistorico.prop || 0);
293 + }
294 + }
295 + } catch (err) {
296 + console.error('[loadData] Error:', err);
297 + }
298 + loading = false;
299 + }
300 +
301 + async function loadEntidades() {
302 + try {
303 + // Usar el último año con datos reales (de estados), no de gestiones
304 + const añosConDatos = datosAnuales.map(d => d.gestion).sort((a, b) => b - a);
305 + const año = añosConDatos[0] || 2025;
306 +
307 + const entRes = await fetch(`/api/rubro-data?codigo=${rubroCodigo}&tipo=entidades-año&gestion=${año}`);
308 + const entData = await entRes.json();
309 +
310 + if (Array.isArray(entData)) {
311 + entidades = entData
312 + .filter(d => d.monto > 0 && d.entidad !== 0)
313 + .map(d => ({ entidad: d.entidad, entidad_desc: d.entidad_desc || d.desc_entidad || `Entidad ${d.entidad}` }))
314 + .sort((a, b) => (a.entidad_desc || '').localeCompare(b.entidad_desc || ''));
315 + }
17 } catch { 316 } catch {
317 + entidades = [];
318 + }
319 + }
320 +
321 + // ══════════════════════════════════════════════════════════════
322 + // COMPARACION
323 + // ══════════════════════════════════════════════════════════════
324 + const twMontoA = tweened(0, { duration: 300, easing: cubicOut });
325 + const twMontoB = tweened(0, { duration: 300, easing: cubicOut });
326 + const twPerCapitaA = tweened(0, { duration: 300, easing: cubicOut });
327 + const twPerCapitaB = tweened(0, { duration: 300, easing: cubicOut });
328 +
329 + let hoveredYearComparar = $state(null);
330 + let loadingComparacion = $state(false);
331 + let isDefaultSelection = $state(false);
332 + let alertDefaultDismissed = $state(false);
333 + let alertRangosDismissed = $state(false);
334 +
335 + let entidadCompararA = $state(null);
336 + let entidadCompararB = $state(null);
337 + let datosCompararA = $state({ anual: [], historico: null });
338 + let datosCompararB = $state({ anual: [], historico: null });
339 +
340 + let dropdownAOpen = $state(false);
341 + let dropdownBOpen = $state(false);
342 + let searchA = $state('');
343 + let searchB = $state('');
344 + let entityLimitA = $state(30);
345 + let entityLimitB = $state(30);
346 +
347 + let filteredEntidadesA = $derived.by(() => {
348 + if (!searchA || searchA.length < 2) return entidades.slice(0, entityLimitA);
349 + const q = searchA.toLowerCase();
350 + return entidades.filter(e => e.entidad_desc?.toLowerCase().includes(q)).slice(0, entityLimitA);
351 + });
352 +
353 + let filteredEntidadesB = $derived.by(() => {
354 + if (!searchB || searchB.length < 2) return entidades.slice(0, entityLimitB);
355 + const q = searchB.toLowerCase();
356 + return entidades.filter(e => e.entidad_desc?.toLowerCase().includes(q)).slice(0, entityLimitB);
357 + });
358 +
359 + async function loadDatosEntidad(entidad) {
360 + try {
361 + const res = await fetch(`/api/rubro-data?codigo=${rubroCodigo}&tipo=entidad&entidad=${entidad.entidad}`);
362 + const raw = await res.json();
363 + if (Array.isArray(raw)) {
364 + const seen = new Set();
365 + const data = raw.filter(d => { if (seen.has(d.gestion)) return false; seen.add(d.gestion); return true; });
366 + return { anual: data.filter(d => d.gestion >= 2016), historico: data.find(d => d.gestion === 0) || null };
367 + }
368 + } catch {}
369 + return { anual: [], historico: null };
370 + }
371 +
372 + async function initComparacion() {
373 + if (entidadCompararA || entidadCompararB) return;
374 + if (selectedEntity) {
375 + entidadCompararA = selectedEntity;
376 + loadingComparacion = true;
377 + datosCompararA = await loadDatosEntidad(selectedEntity);
378 + loadingComparacion = false;
379 + isDefaultSelection = false;
380 + } else if (topEntidadesTotal.length >= 2) {
381 + const top1 = entidades.find(e => e.entidad_desc === topEntidadesTotal[0].nombre);
382 + const top2 = entidades.find(e => e.entidad_desc === topEntidadesTotal[1].nombre);
383 + if (top1 && top2) {
384 + isDefaultSelection = true;
385 + loadingComparacion = true;
386 + entidadCompararA = top1;
387 + datosCompararA = await loadDatosEntidad(top1);
388 + entidadCompararB = top2;
389 + datosCompararB = await loadDatosEntidad(top2);
390 + loadingComparacion = false;
391 + }
392 + }
393 + }
394 +
395 + async function selectEntidadA(entidad) {
396 + entidadCompararA = entidad;
397 + dropdownAOpen = false;
398 + searchA = '';
399 + entityLimitA = 30;
400 + isDefaultSelection = false;
401 + loadingComparacion = true;
402 + datosCompararA = await loadDatosEntidad(entidad);
403 + loadingComparacion = false;
404 + updateCompararUrl();
405 + }
406 +
407 + async function selectEntidadB(entidad) {
408 + entidadCompararB = entidad;
409 + dropdownBOpen = false;
410 + searchB = '';
411 + entityLimitB = 30;
412 + isDefaultSelection = false;
413 + loadingComparacion = true;
414 + datosCompararB = await loadDatosEntidad(entidad);
415 + loadingComparacion = false;
416 + updateCompararUrl();
417 + }
418 +
419 + function handleEntityScroll(event, type) {
420 + const el = event.target;
421 + if (el.scrollHeight - el.scrollTop - el.clientHeight < 50) {
422 + if (type === 'A' && entityLimitA < entidades.length) entityLimitA += 30;
423 + if (type === 'B' && entityLimitB < entidades.length) entityLimitB += 30;
424 + }
425 + }
426 +
427 + let rangoAños = $state('todo');
428 +
429 + let añosComunesTotales = $derived.by(() => {
430 + const añosA = datosCompararA.anual.map(d => d.gestion);
431 + const añosB = datosCompararB.anual.map(d => d.gestion);
432 + if (añosA.length && añosB.length) {
433 + const setB = new Set(añosB);
434 + return añosA.filter(a => setB.has(a)).sort((a, b) => a - b);
435 + }
436 + if (añosA.length) return [...añosA].sort((a, b) => a - b);
437 + if (añosB.length) return [...añosB].sort((a, b) => a - b);
18 return []; 438 return [];
439 + });
440 +
441 + let soloUnaEntidad = $derived((entidadCompararA && !entidadCompararB) || (!entidadCompararA && entidadCompararB));
442 +
443 + let rangoInfoA = $derived.by(() => {
444 + if (!datosCompararA.anual.length) return null;
445 + const años = datosCompararA.anual.map(d => d.gestion).sort((a, b) => a - b);
446 + return { desde: años[0], hasta: años[años.length - 1], total: años.length };
447 + });
448 +
449 + let rangoInfoB = $derived.by(() => {
450 + if (!datosCompararB.anual.length) return null;
451 + const años = datosCompararB.anual.map(d => d.gestion).sort((a, b) => a - b);
452 + return { desde: años[0], hasta: años[años.length - 1], total: años.length };
453 + });
454 +
455 + let tienenRangosDiferentes = $derived.by(() => {
456 + if (!rangoInfoA || !rangoInfoB) return false;
457 + return rangoInfoA.desde !== rangoInfoB.desde || rangoInfoA.hasta !== rangoInfoB.hasta;
458 + });
459 +
460 + let añosComparacion = $derived.by(() => {
461 + if (rangoAños === 'todo') return añosComunesTotales;
462 + return añosComunesTotales.slice(-parseInt(rangoAños));
463 + });
464 +
465 + let datosGraficoA = $derived.by(() => {
466 + const map = new Map(datosCompararA.anual.map(d => [d.gestion, d]));
467 + return añosComparacion.map(año => map.get(año) || { per_capita: 0, monto: 0 });
468 + });
469 +
470 + let datosGraficoB = $derived.by(() => {
471 + const map = new Map(datosCompararB.anual.map(d => [d.gestion, d]));
472 + return añosComparacion.map(año => map.get(año) || { per_capita: 0, monto: 0 });
473 + });
474 +
475 + function valComparar(d) {
476 + return metrica === 'percapita' ? (d?.per_capita || 0) : (d?.monto || 0);
477 + }
478 +
479 + let maxComparacion = $derived.by(() => {
480 + const all = [...datosGraficoA.map(d => valComparar(d)), ...datosGraficoB.map(d => valComparar(d))];
481 + return Math.max(...all, 1) * 1.1;
482 + });
483 +
484 + let displayPeriodoComparar = $derived.by(() => {
485 + if (hoveredYearComparar) return hoveredYearComparar;
486 + if (añosComparacion.length > 0) return `${añosComparacion[0]}-${añosComparacion[añosComparacion.length - 1]}`;
487 + return 'Selecciona entidades';
488 + });
489 +
490 + let displayPropA = $derived.by(() => {
491 + if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoA[idx]?.prop || 0; }
492 + return datosCompararA.historico?.prop || 0;
493 + });
494 + let displayPropB = $derived.by(() => {
495 + if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoB[idx]?.prop || 0; }
496 + return datosCompararB.historico?.prop || 0;
497 + });
498 + let displayRankingA = $derived.by(() => {
499 + if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoA[idx]?.ranking || '-'; }
500 + return datosCompararA.historico?.ranking || '-';
501 + });
502 + let displayRankingB = $derived.by(() => {
503 + if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoB[idx]?.ranking || '-'; }
504 + return datosCompararB.historico?.ranking || '-';
505 + });
506 + let displayNEntidadesA = $derived.by(() => {
507 + if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoA[idx]?.n_entidades || '-'; }
508 + return datosCompararA.historico?.n_entidades || '-';
509 + });
510 + let displayNEntidadesB = $derived.by(() => {
511 + if (hoveredYearComparar) { const idx = añosComparacion.indexOf(hoveredYearComparar); if (idx >= 0) return datosGraficoB[idx]?.n_entidades || '-'; }
512 + return datosCompararB.historico?.n_entidades || '-';
513 + });
514 +
515 + let isDarkMode = $state(false);
516 + $effect(() => {
517 + if (typeof document !== 'undefined') {
518 + const checkDark = () => { isDarkMode = document.documentElement.classList.contains('dark'); };
519 + checkDark();
520 + const observer = new MutationObserver(checkDark);
521 + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
522 + return () => observer.disconnect();
523 + }
524 + });
525 +
526 + let colorA = $derived(isDarkMode ? '#7EB8DA' : '#6B8299');
527 + let colorB = $derived(isDarkMode ? '#D4A574' : '#C4897D');
528 +
529 + function toNumber(v) { return typeof v === 'number' ? v : 0; }
530 +
531 + $effect(() => {
532 + const year = hoveredYearComparar;
533 + const idx = year ? añosComparacion.indexOf(year) : -1;
534 + twMontoA.set(idx >= 0 ? toNumber(datosGraficoA[idx]?.monto) : toNumber(datosCompararA.historico?.monto));
535 + twMontoB.set(idx >= 0 ? toNumber(datosGraficoB[idx]?.monto) : toNumber(datosCompararB.historico?.monto));
536 + twPerCapitaA.set(idx >= 0 ? toNumber(datosGraficoA[idx]?.per_capita) : toNumber(datosCompararA.historico?.per_capita));
537 + twPerCapitaB.set(idx >= 0 ? toNumber(datosGraficoB[idx]?.per_capita) : toNumber(datosCompararB.historico?.per_capita));
538 + });
539 +
540 + function updateCompararUrl() {
541 + if (!mounted) return;
542 + const url = new URL($page.url);
543 + url.searchParams.set('vista', 'comparar');
544 + url.searchParams.delete('entidad');
545 + if (entidadCompararA) url.searchParams.set('entidadA', entidadCompararA.entidad);
546 + else url.searchParams.delete('entidadA');
547 + if (entidadCompararB) url.searchParams.set('entidadB', entidadCompararB.entidad);
548 + else url.searchParams.delete('entidadB');
549 + goto(url.toString(), { replaceState: true, noScroll: true });
550 + }
551 +
552 + let previousVista = 'agregado';
553 + let skipPopstate = false;
554 + $effect(() => {
555 + if (mounted && vista !== previousVista) {
556 + previousVista = vista;
557 + if (!skipPopstate) {
558 + const url = new URL(window.location.href);
559 + if (vista === 'comparar') {
560 + url.searchParams.set('vista', 'comparar');
561 + url.searchParams.delete('entidad');
562 + } else {
563 + url.searchParams.delete('vista');
564 + url.searchParams.delete('entidadA');
565 + url.searchParams.delete('entidadB');
566 + }
567 + history.pushState({ vista }, '', url.toString());
19 } 568 }
569 + skipPopstate = false;
570 + if (vista === 'comparar') initComparacion();
20 } 571 }
572 + });
21 573
22 - function getMaxYear(rangos) { 574 + // Restaurar vista al navegar atrás/adelante
23 - if (!rangos) return 0; 575 + function handlePopstate(e) {
24 - const years = rangos.match(/\d{4}/g); 576 + const params = new URL(window.location.href).searchParams;
25 - if (!years) return 0; 577 + const urlVista = params.get('vista') || 'agregado';
26 - return Math.max(...years.map(y => parseInt(y))); 578 + if (urlVista !== vista) {
579 + skipPopstate = true;
580 + vista = urlVista;
581 + }
27 } 582 }
28 583
29 - function getNivelLabel(nivel) { 584 + function formatMonto(m) {
30 - const labels = { tipo: 'Tipo', clase: 'Clase', cuenta: 'Cuenta', sub_cuenta: 'Subcuenta' }; 585 + if (m >= 1e9) return (m / 1e9).toLocaleString('es-BO', { maximumFractionDigits: 1 }) + ' MM';
31 - return labels[nivel] || nivel; 586 + if (m >= 1e6) return (m / 1e6).toLocaleString('es-BO', { maximumFractionDigits: 1 }) + ' M';
587 + if (m >= 1e3) return Math.round(m / 1e3).toLocaleString('es-BO') + ' K';
588 + return m?.toLocaleString('es-BO') || '0';
32 } 589 }
33 590
34 - // Obtener la descripción correcta según el nivel 591 + // ══════════════════════════════════════════════════════════════
35 - function getDescripcion(item) { 592 +
36 - if (item.nivel === 'tipo') return item.desc_tipo; 593 + onMount(() => {
37 - if (item.nivel === 'clase') return item.desc_clase; 594 + mounted = true;
38 - if (item.nivel === 'cuenta') return item.desc_cuenta; 595 + loadEntidades().then(async () => {
39 - if (item.nivel === 'sub_cuenta') return item.desc_sub_cuenta; 596 + const urlVista = $page.url.searchParams.get('vista');
40 - return item.desc_rubros; 597 + const urlEntidad = $page.url.searchParams.get('entidad');
598 + const urlEntidadA = $page.url.searchParams.get('entidadA');
599 + const urlEntidadB = $page.url.searchParams.get('entidadB');
600 +
601 + if (urlVista === 'comparar') {
602 + vista = 'comparar';
603 + if (urlEntidadA) {
604 + const entA = entidades.find(e => e.entidad === parseInt(urlEntidadA));
605 + if (entA) {
606 + entidadCompararA = entA;
607 + loadingComparacion = true;
608 + datosCompararA = await loadDatosEntidad(entA);
609 + }
610 + }
611 + if (urlEntidadB) {
612 + const entB = entidades.find(e => e.entidad === parseInt(urlEntidadB));
613 + if (entB) {
614 + entidadCompararB = entB;
615 + datosCompararB = await loadDatosEntidad(entB);
41 } 616 }
617 + }
618 + loadingComparacion = false;
619 + if (!urlEntidadA && !urlEntidadB) initComparacion();
620 + } else if (urlEntidad && entidades.length > 0) {
621 + const ent = entidades.find(e => e.entidad === parseInt(urlEntidad));
622 + if (ent) selectedEntity = ent;
623 + }
624 + });
625 + });
42 626
43 - function getHijosLabel(nivel) { 627 + // Recargar cuando cambia el rubro
44 - const labels = { tipo: 'Clases', clase: 'Cuentas', cuenta: 'Subcuentas' }; 628 + let prevCodigo = null;
45 - return labels[nivel] || 'Hijos'; 629 + $effect(() => {
630 + const code = rubroCodigo;
631 + if (prevCodigo !== null && code !== prevCodigo) {
632 + entidadCompararA = null;
633 + entidadCompararB = null;
634 + datosCompararA = { anual: [], historico: null };
635 + datosCompararB = { anual: [], historico: null };
636 +
637 + const { hist, anuales, topPorAño } = initEstados(rubroData);
638 + datosHistorico = hist;
639 + datosAnuales = anuales;
640 + topEntidadesPorAño = topPorAño;
641 + loading = false;
642 +
643 + const prevEntity = selectedEntity;
644 + loadEntidades().then(() => {
645 + if (prevEntity) {
646 + const match = entidades.find(e => e.entidad === prevEntity.entidad);
647 + if (match) {
648 + selectedEntity = match;
649 + loadData();
650 + } else {
651 + selectedEntity = null;
652 + }
653 + }
654 + });
46 } 655 }
656 + prevCodigo = code;
657 + });
47 658
48 - const descripciones = parseDescripciones(rubro.descripciones); 659 + // Recargar cuando cambia la entidad seleccionada
660 + let prevEntity = null;
661 + $effect(() => {
662 + const ent = selectedEntity;
663 + if (ent !== prevEntity) {
664 + prevEntity = ent;
665 + if (ent) {
666 + loadData();
667 + } else if (prevCodigo) {
668 + const { hist, anuales, topPorAño } = initEstados(rubroData);
669 + datosHistorico = hist;
670 + datosAnuales = anuales;
671 + topEntidadesPorAño = topPorAño;
672 + loading = false;
673 + }
674 + if (mounted) {
675 + const url = new URL($page.url);
676 + if (ent) {
677 + url.searchParams.set('entidad', ent.entidad);
678 + } else {
679 + url.searchParams.delete('entidad');
680 + }
681 + goto(url.toString(), { replaceState: true, noScroll: true });
682 + }
683 + }
684 + });
49 </script> 685 </script>
50 686
687 +<svelte:window on:popstate={handlePopstate} />
688 +
51 <svelte:head> 689 <svelte:head>
52 - <title>{getDescripcion(rubro)} | Presupuesto Público</title> 690 + <title>{rubroData?.desc_rubro || `Rubro ${rubroCodigo}`} | Presupuesto Abierto</title>
691 + <link rel="preconnect" href="https://fonts.googleapis.com" />
692 + <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" />
53 </svelte:head> 693 </svelte:head>
54 694
55 -<div class="min-h-screen bg-white"> 695 +<div class="page" class:mounted>
56 - <header class="border-b bg-slate-50"> 696 +
57 - <div class="max-w-4xl mx-auto px-6 py-4"> 697 + {#if vista === 'agregado'}
58 - <nav class="text-sm text-slate-500 mb-2 flex flex-wrap items-center gap-1"> 698 + <div class="dashboard-layout">
59 - <a href="/" class="hover:text-slate-700">Inicio</a> 699 + <!-- Sidebar -->
60 - <span>/</span> 700 + <aside class="dashboard-context" class:mobile-open={showMobileSidebar}>
61 - <a href="/clasificadores/rubros" class="hover:text-slate-700">Rubros</a> 701 + <!-- Mobile toggle -->
62 - {#each padres as padre} 702 + <button class="mobile-sidebar-toggle" onclick={() => showMobileSidebar = !showMobileSidebar}>
63 - <span>/</span> 703 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
64 - <a href="/rubro/{padre.rubro}" class="hover:text-slate-700">{getDescripcion(padre)}</a> 704 + <circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
705 + </svg>
706 + <span>{showMobileSidebar ? 'Ocultar herramientas' : 'Herramientas'}</span>
707 + </button>
708 +
709 + <div class="context-card">
710 + <h2 class="context-main-title">Herramientas</h2>
711 +
712 + <!-- Buscar (inline) -->
713 + <div class="context-section">
714 + <h3 class="context-subtitle">Buscar</h3>
715 + <div class="inline-search-wrapper">
716 + <div class="inline-search-input-wrap">
717 + <input
718 + bind:this={searchInputRef}
719 + type="text"
720 + value={searchVal}
721 + oninput={handleSearchInput}
722 + onkeydown={handleSearchKeydown}
723 + onfocus={() => searchFocused = true}
724 + onblur={handleSearchBlur}
725 + placeholder="Buscar rubro de ingreso..."
726 + class="inline-search-input"
727 + />
728 + {#if searchVal}
729 + <button class="inline-search-clear" onclick={handleClear}>
730 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
731 + </button>
732 + {/if}
733 + </div>
734 + {#if searchFocused && localResults.length > 0}
735 + <div class="inline-search-results">
736 + {#each localResults as result, i}
737 + <button
738 + class="inline-search-item"
739 + class:selected={i === selectedIdx}
740 + onmousedown={() => handleSearchSelect(result)}
741 + >
742 + <span class="inline-search-code">{result.codigo}</span>
743 + <span class="inline-search-name">{@html result.highlight}</span>
744 + </button>
745 + {/each}
746 + </div>
747 + {/if}
748 + </div>
749 + </div>
750 +
751 + <div class="context-separator"></div>
752 +
753 + <!-- Navegacion -->
754 + {#if padres.length > 0 || hijos.length > 0}
755 + <div class="context-section">
756 + <h3 class="context-subtitle">Navegacion</h3>
757 + <p class="context-hint">Rubros relacionados. Haz click para explorar.</p>
758 + <div class="tree">
759 + {#each padres as padre, i}
760 + <a href="/rubro/{padre.rubro}" class="tree-node has-tooltip" style="--indent: {i}" data-tooltip="{padre.desc_rubro}">
761 + <span class="tree-dot"></span>
762 + <span class="tree-code">{padre.rubro}</span>
763 + <span class="tree-name">{padre.desc_rubro}</span>
764 + </a>
765 + {/each}
766 +
767 + <div class="tree-node tree-node-current has-tooltip" style="--indent: {padres.length}" data-tooltip="{rubroData.desc_rubro}">
768 + <span class="tree-dot"></span>
769 + <span class="tree-code">{rubroData.rubro}</span>
770 + <span class="tree-name">{rubroData.desc_rubro}</span>
771 + </div>
772 +
773 + {#if hijos.length > 0}
774 + <div class="tree-children" style="--indent: {padres.length + 1}">
775 + {#each hijos as hijo}
776 + <a href="/rubro/{hijo.rubro}" class="tree-node tree-node-child has-tooltip" data-tooltip="{hijo.desc_rubro}">
777 + <span class="tree-dot"></span>
778 + <span class="tree-code">{hijo.rubro}</span>
779 + <span class="tree-name">{hijo.desc_rubro}</span>
780 + </a>
65 {/each} 781 {/each}
66 - <span>/</span>
67 - <span class="text-slate-900">{getDescripcion(rubro)}</span>
68 - </nav>
69 </div> 782 </div>
70 - </header> 783 + {/if}
784 + </div>
785 + </div>
786 +
787 + <div class="context-separator"></div>
788 + {/if}
789 +
790 + <!-- Explorar -->
791 + <div class="context-section">
792 + <div class="explore-header">
793 + <h3 class="context-subtitle">Explorar visualizaciones</h3>
794 + <span class="explore-count">7 &rarr;</span>
795 + </div>
796 + <div class="explore-scroll">
797 + <div class="explore-card" onclick={() => vista = 'comparar'} role="button" tabindex="0">
798 + <span class="explore-title">Comparar dos entidades</span>
799 + <span class="explore-desc">Quien recauda mas en {rubroData.desc_rubro?.toLowerCase()}?</span>
800 + </div>
801 + <a href="/rubro/{rubroData.rubro}/entidades" class="explore-card">
802 + <span class="explore-title">Ranking</span>
803 + <span class="explore-desc">{nEntidades} entidades que reciben en {rubroData.desc_rubro?.toLowerCase()}</span>
804 + </a>
805 + <a href="/clasificadores/rubros?modo=mapa" class="explore-card">
806 + <span class="explore-title">Peso relativo</span>
807 + <span class="explore-desc">Cuanto pesa {rubroData.desc_rubro?.toLowerCase()} respecto a otros ingresos</span>
808 + </a>
809 + <a href="/clasificadores/rubros?modo=comparar{selectedEntity ? `&entidad=${selectedEntity.entidad}` : ''}{activeYear ? `&gestion=${activeYear}` : ''}" class="explore-card">
810 + <span class="explore-title">Comparar rubros</span>
811 + <span class="explore-desc">Todos los rubros entre dos momentos o entidades</span>
812 + </a>
813 + <a href="/clasificadores/rubros" class="explore-card">
814 + <span class="explore-title">Clasificador</span>
815 + <span class="explore-desc">Ver todos los rubros de ingreso y navegar entre ellos</span>
816 + </a>
817 + </div>
818 + </div>
71 819
72 - <main class="max-w-4xl mx-auto px-6 py-8"> 820 + <div class="context-separator"></div>
73 - <!-- Encabezado --> 821 +
74 - <div class="mb-8"> 822 + <!-- Comparabilidad -->
75 - <p class="text-xs text-slate-500 uppercase tracking-wide mb-1">{getNivelLabel(rubro.nivel)}</p> 823 + <div class="context-section">
76 - <div class="flex items-baseline gap-4"> 824 + <h3 class="context-subtitle">Comparabilidad</h3>
77 - <span class="font-mono text-2xl text-slate-400">{rubro.rubro}</span> 825 + <div class="vigencia-compact">
78 - <h1 class="text-2xl font-medium text-slate-900">{getDescripcion(rubro)}</h1> 826 + <div class="vigencia-status-icon vigencia-full">
827 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
828 + <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
829 + <polyline points="22 4 12 14.01 9 11.01"/>
830 + </svg>
831 + </div>
832 + <div class="vigencia-text">
833 + <span class="vigencia-main">Comparable en todo el periodo</span>
79 </div> 834 </div>
80 </div> 835 </div>
836 + </div>
837 +
838 + <div class="context-separator"></div>
81 839
82 - <!-- Descripciones --> 840 + <!-- Definicion -->
83 - <section class="mb-10"> 841 + <div class="context-section">
84 - <h2 class="text-sm font-medium text-slate-700 mb-4"> 842 + <h3 class="context-subtitle">Definicion</h3>
85 - {#if descripciones.length > 1} 843 + {#if variaciones.length <= 1}
86 - Descripciones ({descripciones.length} variaciones) 844 + <p class="context-hint">Definicion estable en todo el periodo</p>
87 {:else} 845 {:else}
88 - Descripción 846 + <p class="context-hint">El significado de este ingreso ha cambiado {variaciones.length} veces en el periodo</p>
89 {/if} 847 {/if}
90 - </h2> 848 + <button class="def-link" onclick={() => showDefinicionesModal = true}>
849 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
850 + <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>
851 + </svg>
852 + {#if variaciones.length <= 1}
853 + Ver definicion
854 + {:else}
855 + Ver variaciones
856 + {/if}
857 + </button>
858 + </div>
859 +
860 + <div class="context-separator"></div>
91 861
92 - <div class="space-y-4"> 862 + <!-- Compartir -->
93 - {#each descripciones as desc, i} 863 + <div class="context-section">
94 - <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"> 864 + <h3 class="context-subtitle">Compartir</h3>
95 - <p class="text-xs text-slate-500 mb-2"> 865 + <button class="copy-link-btn" onclick={() => {
96 - {#if i === 0 && descripciones.length > 1} 866 + navigator.clipboard.writeText(window.location.href);
97 - <span class="text-blue-600 font-medium">Vigente</span> 867 + linkCopied = true;
98 - <span class="mx-1">·</span> 868 + setTimeout(() => linkCopied = false, 2000);
869 + }}>
870 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
871 + {#if linkCopied}
872 + <path d="M20 6L9 17l-5-5"/>
873 + {:else}
874 + <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
875 + <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
99 {/if} 876 {/if}
100 - {desc.rangos} 877 + </svg>
101 - </p> 878 + <span>{linkCopied ? 'Link copiado' : 'Copiar link para compartir'}</span>
102 - <p class="text-slate-700 leading-relaxed">{desc.descripcion}</p> 879 + </button>
103 </div> 880 </div>
104 - {/each}
105 </div> 881 </div>
106 - </section> 882 + </aside>
107 883
108 - <!-- Jerarquía --> 884 + <!-- Main -->
109 - {#if padres.length > 0} 885 + <main class="dashboard-main">
110 - <section class="mb-10"> 886 + <div class="chart-protagonista">
111 - <h2 class="text-sm font-medium text-slate-700 mb-4">Jerarquía</h2> 887 + <!-- Header -->
112 - <div class="space-y-2"> 888 + <div class="chart-top-row">
113 - {#each padres as padre, i} 889 + <div class="main-title-pills">
114 - <div class="flex items-center gap-2" style="margin-left: {i * 1.5}rem"> 890 + <span class="pill pill-nivel">{nivelLabel}</span>
115 - <span class="text-slate-300">└</span> 891 + <span class="pill pill-codigo">{rubroData.rubro}</span>
116 - <a
117 - href="/rubro/{padre.rubro}"
118 - class="text-sm text-slate-600 hover:text-blue-600"
119 - >
120 - <span class="font-mono text-xs text-slate-400">{padre.rubro}</span>
121 - {getDescripcion(padre)}
122 - </a>
123 </div> 892 </div>
124 - {/each} 893 + </div>
125 - <div class="flex items-center gap-2" style="margin-left: {padres.length * 1.5}rem"> 894 +
126 - <span class="text-blue-500">└</span> 895 + <h2 class="main-title-name">{rubroData.desc_rubro}</h2>
127 - <span class="text-sm font-medium text-blue-600"> 896 +
128 - <span class="font-mono text-xs">{rubro.rubro}</span> 897 + <div class="chart-controls">
129 - {getDescripcion(rubro)} 898 + <EntitySelector {entidades} bind:selectedEntity {nEntidades} />
899 + {#if selectedEntity}
900 + <button class="volver-agregado" onclick={() => { selectedEntity = null; }}>
901 + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
902 + <path d="M19 12H5M12 19l-7-7 7-7"/>
903 + </svg>
904 + Todo el Estado
905 + </button>
906 + {/if}
907 + </div>
908 +
909 + <div class="chart-header">
910 + <span class="chart-title">
911 + <button class="metrica-toggle" onclick={() => metrica = metrica === 'percapita' ? 'monto' : 'percapita'}>
912 + {metrica === 'percapita' ? 'Bs/habitante' : 'Bs (monto total)'}
913 + <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 16V4m0 0L3 8m4-4l4 4M17 8v12m0 0l4-4m-4 4l-4-4"/></svg>
914 + </button>
915 + <span class="chart-period">. {displayPeriodo}</span>
130 </span> 916 </span>
131 </div> 917 </div>
918 +
919 + {#if loading}
920 + <div class="chart-loading" style="flex: 1; min-height: 100px;">
921 + <span>Cargando...</span>
922 + </div>
923 + {:else if gastoPerCapita.length === 0 && selectedEntity}
924 + <div class="chart-no-data">
925 + <p>Esta entidad no tiene registros en {rubroData.desc_rubro?.toLowerCase()} desde 2016</p>
926 + <button class="volver-agregado" onclick={() => { selectedEntity = null; }}>
927 + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
928 + <path d="M19 12H5M12 19l-7-7 7-7"/>
929 + </svg>
930 + Volver a Todo el Estado
931 + </button>
932 + </div>
933 + {:else}
934 + <div class="chart-flex-wrapper">
935 + <BarChart data={gastoPerCapita} bind:hoveredYear bind:lockedYear height={200} fill={true} />
132 </div> 936 </div>
133 - </section>
134 {/if} 937 {/if}
135 938
136 - <!-- Hijos --> 939 + <!-- KPIs -->
137 - {#if hijos.length > 0} 940 + <div class="kpis">
138 - <section class="mb-10"> 941 + <div class="kpi">
139 - <h2 class="text-sm font-medium text-slate-700 mb-4"> 942 + <span class="kpi-value">{formatMontoLargo(Math.round($twMonto))}</span>
140 - {getHijosLabel(rubro.nivel)} ({hijos.length}) 943 + <span class="kpi-label">{displayMontoLabel}</span>
141 - </h2> 944 + </div>
945 + <div class="kpi">
946 + <span class="kpi-value">{formatPerCapita($twPerCapita)} Bs.</span>
947 + <span class="kpi-label">{displayPerCapitaLabel}</span>
948 + </div>
949 + <div class="kpi">
950 + <span class="kpi-value">{$twPorcentaje.toLocaleString('es-BO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</span>
951 + <span class="kpi-label">{displayPorcentajeLabel}</span>
952 + </div>
953 + <div class="kpi">
954 + <span class="kpi-value">{displayRanking}</span>
955 + <span class="kpi-label">{displayRankingLabel}</span>
956 + </div>
957 + </div>
958 + </div>
142 959
143 - <div class="border rounded-lg divide-y"> 960 + {#if !selectedEntity && displayTopEntidades.length > 0}
144 - {#each hijos as hijo} 961 + <div class="ranking-section">
145 - {@const hijoDescs = parseDescripciones(hijo.descripciones)} 962 + <div class="ranking-header">
146 - <a 963 + <h3 class="ranking-title">Concentracion del ingreso</h3>
147 - href="/rubro/{hijo.rubro}" 964 + <div class="ranking-summary">
148 - class="block px-4 py-3 hover:bg-slate-50 transition-colors" 965 + <span class="ranking-summary-pct">{($twTop1 + $twTop2 + $twTop3).toFixed(1)}%</span>
149 - > 966 + <span class="ranking-summary-text">de {rubroData.desc_rubro?.toLowerCase()} lo reciben estas 3 entidades{activeYear ? ` en ${activeYear}` : ''}</span>
150 - <div class="flex items-baseline gap-3"> 967 + </div>
151 - <span class="font-mono text-sm text-slate-400">{hijo.rubro}</span> 968 + </div>
152 - <span class="text-slate-900">{getDescripcion(hijo)}</span> 969 + <div class="ranking-cards">
153 - {#if hijo.n_variaciones > 1} 970 + {#each displayTopEntidades as ent, i}
154 - <span class="text-xs text-orange-500">({hijo.n_variaciones} var.)</span> 971 + {@const pct = i === 0 ? $twTop1 : i === 1 ? $twTop2 : $twTop3}
972 + <button class="ranking-card ranking-card-link" onclick={() => {
973 + const match = entidades.find(e => e.entidad === ent.entidad);
974 + selectedEntity = match || { entidad: ent.entidad, entidad_desc: ent.nombre };
975 + }}>
976 + <span class="ranking-position">#{i + 1}</span>
977 + <span class="ranking-name">{ent.nombre}</span>
978 + <span class="ranking-pct">{pct.toFixed(1)}%</span>
979 + <svg class="ranking-arrow" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
980 + </button>
981 + {/each}
982 + </div>
983 + </div>
155 {/if} 984 {/if}
985 + </main>
986 + </div>
987 +
988 + <!-- Modal de definiciones -->
989 + {#if showDefinicionesModal}
990 + <div class="modal-overlay" onclick={() => showDefinicionesModal = false}>
991 + <div class="modal-content" onclick={(e) => e.stopPropagation()}>
992 + <div class="modal-header">
993 + <h3>Definiciones de "{rubroData.desc_rubro}"</h3>
994 + <button class="modal-close" onclick={() => showDefinicionesModal = false}>
995 + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
996 + <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
997 + </svg>
998 + </button>
156 </div> 999 </div>
157 - {#if hijoDescs.length > 0} 1000 + <div class="modal-body">
158 - <p class="text-sm text-slate-500 mt-1 ml-16">{hijoDescs[0].descripcion}</p> 1001 + {#each variaciones as v, i}
1002 + <div class="def-item" class:def-current={i === 0}>
1003 + <div class="def-header">
1004 + <span class="def-period">{v.rango}</span>
1005 + {#if i === 0}
1006 + <span class="def-badge">Vigente</span>
159 {/if} 1007 {/if}
160 - </a> 1008 + </div>
1009 + <p class="def-text">{v.texto}</p>
1010 + </div>
161 {/each} 1011 {/each}
162 </div> 1012 </div>
163 - </section> 1013 + </div>
1014 + </div>
164 {/if} 1015 {/if}
165 1016
166 - <!-- Volver --> 1017 + {:else}
167 - <div class="pt-6 border-t"> 1018 + <!-- VISTA COMPARAR -->
168 - <a 1019 + <main class="main-comparar">
169 - href="/clasificadores/rubros" 1020 + <div class="chart-card-comparar">
170 - class="text-sm text-blue-600 hover:underline" 1021 + <div class="chart-top-row">
171 - > 1022 + <div class="main-title-pills">
172 - Volver al clasificador 1023 + <span class="pill pill-nivel">{nivelLabel}</span>
173 - </a> 1024 + <span class="pill pill-codigo">{rubroData.rubro}</span>
174 </div> 1025 </div>
175 - </main> 1026 + <VistaToggle bind:vista />
176 -</div> 1027 + </div>
1028 +
1029 + <h2 class="main-title-name">{rubroData.desc_rubro}</h2>
1030 +
1031 + <div class="comparador-selectors">
1032 + <div class="selector-group comparador-dropdown-container">
1033 + <span class="selector-dot" style="background: {colorA}"></span>
1034 + <div class="relative" style="flex: 1;">
1035 + <button class="selector-btn comparador-btn" onclick={() => { dropdownAOpen = !dropdownAOpen; dropdownBOpen = false; }}>
1036 + <span class="truncate">{entidadCompararA ? entidadCompararA.entidad_desc : 'Seleccionar entidad'}</span>
1037 + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
1038 + </button>
1039 + {#if dropdownAOpen}
1040 + <div class="dropdown-panel comparador-panel">
1041 + <div class="dropdown-search"><input type="text" bind:value={searchA} placeholder="Buscar entidad..." class="search-input" /></div>
1042 + <div class="dropdown-list" onscroll={(e) => handleEntityScroll(e, 'A')}>
1043 + {#each filteredEntidadesA as entity}
1044 + <button class="dropdown-item" class:active={entidadCompararA?.entidad === entity.entidad} disabled={entidadCompararB?.entidad === entity.entidad} onclick={() => selectEntidadA(entity)}>
1045 + <span class="truncate">{entity.entidad_desc}</span>
1046 + </button>
1047 + {/each}
1048 + </div>
1049 + </div>
1050 + {/if}
1051 + </div>
1052 + </div>
1053 +
1054 + <span class="vs">vs</span>
1055 +
1056 + <div class="selector-group comparador-dropdown-container">
1057 + <span class="selector-dot" style="background: {colorB}"></span>
1058 + <div class="relative" style="flex: 1;">
1059 + <button class="selector-btn comparador-btn" onclick={() => { dropdownBOpen = !dropdownBOpen; dropdownAOpen = false; }}>
1060 + <span class="truncate">{entidadCompararB ? entidadCompararB.entidad_desc : 'Seleccionar entidad'}</span>
1061 + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
1062 + </button>
1063 + {#if dropdownBOpen}
1064 + <div class="dropdown-panel comparador-panel">
1065 + <div class="dropdown-search"><input type="text" bind:value={searchB} placeholder="Buscar entidad..." class="search-input" /></div>
1066 + <div class="dropdown-list" onscroll={(e) => handleEntityScroll(e, 'B')}>
1067 + {#each filteredEntidadesB as entity}
1068 + <button class="dropdown-item" class:active={entidadCompararB?.entidad === entity.entidad} disabled={entidadCompararA?.entidad === entity.entidad} onclick={() => selectEntidadB(entity)}>
1069 + <span class="truncate">{entity.entidad_desc}</span>
1070 + </button>
1071 + {/each}
1072 + </div>
1073 + </div>
1074 + {/if}
1075 + </div>
1076 + </div>
1077 + </div>
1078 +
1079 + {#if isDefaultSelection && entidadCompararA && entidadCompararB && !alertDefaultDismissed}
1080 + <div class="comparador-hint">
1081 + <span>Mostrando las dos entidades con mayor ingreso. Puedes cambiar la seleccion arriba.</span>
1082 + <button class="alert-close" onclick={() => alertDefaultDismissed = true}>&#x2715;</button>
1083 + </div>
1084 + {/if}
1085 +
1086 + {#if tienenRangosDiferentes && entidadCompararA && entidadCompararB && !loadingComparacion && !alertRangosDismissed}
1087 + <div class="comparador-warning">
1088 + <span>Rangos diferentes. Solo se comparan {añosComunesTotales.length} anos en comun.</span>
1089 + <button class="alert-close" onclick={() => alertRangosDismissed = true}>&#x2715;</button>
1090 + </div>
1091 + {/if}
1092 +
1093 + {#if loadingComparacion}
1094 + <div class="comparador-placeholder"><Spinner size={48} color="var(--theme-texto)" /></div>
1095 + {:else if !entidadCompararA && !entidadCompararB}
1096 + <div class="comparador-placeholder"><p>Selecciona dos entidades para comparar</p></div>
1097 + {:else if añosComparacion.length === 0}
1098 + <div class="comparador-placeholder"><p>No hay datos disponibles para comparar</p></div>
1099 + {:else}
1100 + <div class="chart-header">
1101 + <span class="chart-title">
1102 + <button class="metrica-toggle" onclick={() => metrica = metrica === 'percapita' ? 'monto' : 'percapita'}>
1103 + {metrica === 'percapita' ? 'Bs/habitante' : 'Bs (monto total)'}
1104 + <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 16V4m0 0L3 8m4-4l4 4M17 8v12m0 0l4-4m-4 4l-4-4"/></svg>
1105 + </button>
1106 + <span class="chart-period">. {displayPeriodoComparar}</span>
1107 + </span>
1108 + <button class="copy-link-btn" onclick={() => {
1109 + navigator.clipboard.writeText(window.location.href);
1110 + linkCopied = true;
1111 + setTimeout(() => linkCopied = false, 2000);
1112 + }}>
1113 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1114 + {#if linkCopied}
1115 + <path d="M20 6L9 17l-5-5"/>
1116 + {:else}
1117 + <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
1118 + <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
1119 + {/if}
1120 + </svg>
1121 + <span>{linkCopied ? 'Copiado' : 'Compartir'}</span>
1122 + </button>
1123 + </div>
1124 +
1125 + <div class="chart-comparar-wrapper">
1126 + <div class="chart-comparar-inner">
1127 + <span class="y-label-comparar" style="bottom: 100%">Bs {formatMonto(Math.round(maxComparacion))}</span>
1128 + <span class="y-label-comparar" style="bottom: 50%">Bs {formatMonto(Math.round(maxComparacion / 2))}</span>
1129 + <span class="y-label-comparar" style="bottom: 0%">Bs 0</span>
1130 + <div class="grid-line-comparar" style="bottom: 100%"></div>
1131 + <div class="grid-line-comparar" style="bottom: 50%"></div>
1132 + <div class="grid-line-comparar" style="bottom: 0%"></div>
1133 + <div class="bars-comparar" onmouseleave={() => hoveredYearComparar = null} role="group">
1134 + {#each añosComparacion as año, i}
1135 + <div class="bar-group-comparar" class:active={hoveredYearComparar === año} onmouseenter={() => hoveredYearComparar = año} role="button" tabindex="0">
1136 + <div class="bars-pair">
1137 + <div class="bar-comparar" style="height: {(valComparar(datosGraficoA[i]) / maxComparacion) * 100}%; background: {colorA}"></div>
1138 + <div class="bar-comparar" style="height: {(valComparar(datosGraficoB[i]) / maxComparacion) * 100}%; background: {colorB}"></div>
1139 + </div>
1140 + </div>
1141 + {/each}
1142 + </div>
1143 + </div>
1144 + <div class="x-axis-comparar">
1145 + {#each añosComparacion as año, i}
1146 + {@const show = i === 0 || i === añosComparacion.length - 1 || hoveredYearComparar === año}
1147 + <span class="x-label-comparar" class:visible={show}>{año}</span>
1148 + {/each}
1149 + </div>
1150 + </div>
1151 +
1152 + <div class="chart-legend">
1153 + {#if entidadCompararA}<span class="legend-item"><span class="legend-dot" style="background: {colorA}"></span>{entidadCompararA.entidad_desc}</span>{/if}
1154 + {#if entidadCompararB}<span class="legend-item"><span class="legend-dot" style="background: {colorB}"></span>{entidadCompararB.entidad_desc}</span>{/if}
1155 + </div>
1156 + {/if}
1157 + </div>
1158 +
1159 + {#if soloUnaEntidad}
1160 + <div class="comparador-hint comparador-hint-select">
1161 + <span>Selecciona la segunda entidad para ver la comparacion completa.</span>
1162 + </div>
1163 + {/if}
1164 +
1165 + {#if entidadCompararA && entidadCompararB}
1166 + <div class="kpis-card-comparar">
1167 + <div class="tabla-header">
1168 + <span class="tabla-label"></span>
1169 + <span class="tabla-val tabla-entidad" style="color: {colorA}">{entidadCompararA.entidad_desc}</span>
1170 + <span class="tabla-val tabla-entidad" style="color: {colorB}">{entidadCompararB.entidad_desc}</span>
1171 + </div>
1172 + <div class="tabla-row">
1173 + <span class="tabla-label">Ingreso</span>
1174 + <span class="tabla-val">Bs {formatMontoLargo(Math.round($twMontoA))}</span>
1175 + <span class="tabla-val">Bs {formatMontoLargo(Math.round($twMontoB))}</span>
1176 + </div>
1177 + <div class="tabla-row">
1178 + <span class="tabla-label">Per capita</span>
1179 + <span class="tabla-val">Bs {formatPerCapita($twPerCapitaA)}</span>
1180 + <span class="tabla-val">Bs {formatPerCapita($twPerCapitaB)}</span>
1181 + </div>
1182 + <div class="tabla-row">
1183 + <span class="tabla-label">Peso</span>
1184 + <span class="tabla-val">{displayPropA.toLocaleString('es-BO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</span>
1185 + <span class="tabla-val">{displayPropB.toLocaleString('es-BO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</span>
1186 + </div>
1187 + <div class="tabla-row">
1188 + <span class="tabla-label">Ranking</span>
1189 + <span class="tabla-val">#{displayRankingA} de {displayNEntidadesA}</span>
1190 + <span class="tabla-val">#{displayRankingB} de {displayNEntidadesB}</span>
1191 + </div>
1192 + </div>
1193 + {/if}
1194 + </main>
1195 + {/if}
1196 +</div>
1197 +
1198 +<style>
1199 + /* ═══════════════════════════════════════════════════════════════
1200 + PAGE
1201 + ═══════════════════════════════════════════════════════════════ */
1202 + .page {
1203 + height: 100dvh;
1204 + width: 100%;
1205 + max-width: 100%;
1206 + overflow: hidden;
1207 + background: var(--theme-body);
1208 + padding-top: 60px;
1209 + box-sizing: border-box;
1210 + color: var(--theme-titulo);
1211 + font-family: var(--font-sans), -apple-system, sans-serif;
1212 + opacity: 0;
1213 + transition: opacity 0.4s ease;
1214 + }
1215 + .page.mounted { opacity: 1; }
1216 +
1217 + :global(html:not(.dark)) .page {
1218 + background: #f5f5f7;
1219 + }
1220 +
1221 + /* ═══════════════════════════════════════════════════════════════
1222 + LAYOUT
1223 + ═══════════════════════════════════════════════════════════════ */
1224 + .dashboard-layout {
1225 + display: grid;
1226 + grid-template-columns: minmax(200px, 30%) 1fr;
1227 + gap: 1.5rem;
1228 + max-width: 1400px;
1229 + margin: 0 auto;
1230 + padding: 1rem 2rem 2rem;
1231 + height: 100%;
1232 + overflow: hidden;
1233 + }
1234 +
1235 + /* ═══════════════════════════════════════════════════════════════
1236 + SIDEBAR
1237 + ═══════════════════════════════════════════════════════════════ */
1238 + .dashboard-context {
1239 + display: flex;
1240 + flex-direction: column;
1241 + min-height: 0;
1242 + }
1243 +
1244 + .context-card {
1245 + background: var(--theme-surface);
1246 + border-radius: 12px;
1247 + padding: 0.75rem;
1248 + display: flex;
1249 + flex-direction: column;
1250 + gap: 8px;
1251 + flex: 1;
1252 + min-height: 0;
1253 + overflow-y: auto;
1254 + scrollbar-width: thin;
1255 + }
1256 +
1257 + .context-card::-webkit-scrollbar { width: 4px; }
1258 + .context-card::-webkit-scrollbar-thumb {
1259 + background: rgba(128, 128, 128, 0.3);
1260 + border-radius: 2px;
1261 + }
1262 +
1263 + .context-main-title {
1264 + font-size: 1rem;
1265 + font-weight: 600;
1266 + color: var(--theme-titulo);
1267 + margin: 0;
1268 + line-height: 1.2;
1269 + text-transform: uppercase;
1270 + letter-spacing: 0.05em;
1271 + opacity: 0.5;
1272 + }
1273 +
1274 + .context-subtitle {
1275 + font-family: 'Qanelas', var(--font-sans);
1276 + font-size: 0.875rem;
1277 + font-weight: 700;
1278 + color: var(--theme-titulo);
1279 + margin: 0;
1280 + }
1281 +
1282 + .context-hint {
1283 + font-size: 0.75rem;
1284 + color: var(--theme-texto);
1285 + opacity: 0.7;
1286 + margin: 0;
1287 + line-height: 1.4;
1288 + }
1289 +
1290 + .context-section {
1291 + display: flex;
1292 + flex-direction: column;
1293 + gap: 0.5rem;
1294 + }
1295 +
1296 + .context-separator {
1297 + border-top: 1px solid var(--theme-borde);
1298 + margin: 0.25rem 0;
1299 + }
1300 +
1301 + /* ═══════════════════════════════════════════════════════════════
1302 + INLINE SEARCH
1303 + ═══════════════════════════════════════════════════════════════ */
1304 + .inline-search-wrapper {
1305 + position: relative;
1306 + }
1307 +
1308 + .inline-search-input-wrap {
1309 + position: relative;
1310 + }
1311 +
1312 + .inline-search-input {
1313 + width: 100%;
1314 + padding: 0.5rem 2rem 0.5rem 0.75rem;
1315 + border: 1px solid var(--theme-borde);
1316 + border-radius: 8px;
1317 + background: var(--theme-body);
1318 + color: var(--theme-titulo);
1319 + font-size: 0.8125rem;
1320 + outline: none;
1321 + box-sizing: border-box;
1322 + }
1323 + .inline-search-input:focus { border-color: var(--theme-accent); }
1324 +
1325 + .inline-search-clear {
1326 + position: absolute;
1327 + right: 0.5rem;
1328 + top: 50%;
1329 + transform: translateY(-50%);
1330 + background: none;
1331 + border: none;
1332 + color: var(--theme-texto);
1333 + opacity: 0.5;
1334 + cursor: pointer;
1335 + padding: 0;
1336 + line-height: 1;
1337 + }
1338 + .inline-search-clear:hover { opacity: 1; }
1339 +
1340 + .inline-search-results {
1341 + position: absolute;
1342 + top: calc(100% + 4px);
1343 + left: 0;
1344 + width: 100%;
1345 + max-height: 240px;
1346 + overflow-y: auto;
1347 + background: var(--theme-surface);
1348 + border: 1px solid var(--theme-borde);
1349 + border-radius: 8px;
1350 + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
1351 + z-index: 100;
1352 + }
1353 +
1354 + .inline-search-item {
1355 + display: flex;
1356 + align-items: center;
1357 + gap: 0.5rem;
1358 + width: 100%;
1359 + padding: 0.5rem 0.75rem;
1360 + border: none;
1361 + background: transparent;
1362 + color: var(--theme-titulo);
1363 + font-size: 0.8125rem;
1364 + text-align: left;
1365 + cursor: pointer;
1366 + transition: background 0.15s;
1367 + }
1368 + .inline-search-item:hover,
1369 + .inline-search-item.selected { background: rgba(201, 167, 81, 0.1); }
1370 +
1371 + .inline-search-code {
1372 + font-family: 'DM Mono', monospace;
1373 + font-size: 0.6875rem;
1374 + color: var(--theme-texto);
1375 + opacity: 0.6;
1376 + min-width: 2.5rem;
1377 + flex-shrink: 0;
1378 + }
1379 +
1380 + .inline-search-name {
1381 + overflow: hidden;
1382 + text-overflow: ellipsis;
1383 + white-space: nowrap;
1384 + }
1385 +
1386 + /* ═══════════════════════════════════════════════════════════════
1387 + TREE
1388 + ═══════════════════════════════════════════════════════════════ */
1389 + .tree {
1390 + font-size: 0.75rem;
1391 + line-height: 1.6;
1392 + }
1393 +
1394 + .tree-node {
1395 + display: flex;
1396 + align-items: center;
1397 + gap: 0.375rem;
1398 + padding: 0.375rem 0.5rem;
1399 + margin-left: calc(var(--indent, 0) * 1rem);
1400 + border-left: 2px solid transparent;
1401 + border-radius: 0 6px 6px 0;
1402 + text-decoration: none;
1403 + color: var(--theme-texto);
1404 + transition: all 0.15s;
1405 + }
1406 + .tree-node:hover { color: var(--theme-accent); }
1407 +
1408 + .tree-node-current {
1409 + color: var(--theme-titulo);
1410 + font-weight: 600;
1411 + border-left-color: var(--theme-accent);
1412 + background: rgba(201, 167, 81, 0.1);
1413 + }
1414 +
1415 + .tree-children {
1416 + margin-left: calc(var(--indent, 0) * 1rem);
1417 + }
1418 +
1419 + .tree-node-child {
1420 + padding-left: 0.75rem;
1421 + }
1422 +
1423 + .tree-dot {
1424 + width: 4px;
1425 + height: 4px;
1426 + border-radius: 50%;
1427 + background: currentColor;
1428 + flex-shrink: 0;
1429 + opacity: 0.4;
1430 + }
1431 +
1432 + .tree-code {
1433 + font-family: 'DM Mono', monospace;
1434 + font-size: 0.6875rem;
1435 + min-width: 2.5rem;
1436 + opacity: 0.6;
1437 + }
1438 +
1439 + .tree-name {
1440 + font-size: 0.6875rem;
1441 + white-space: nowrap;
1442 + overflow: hidden;
1443 + text-overflow: ellipsis;
1444 + }
1445 +
1446 + /* ═══════════════════════════════════════════════════════════════
1447 + EXPLORE
1448 + ═══════════════════════════════════════════════════════════════ */
1449 + .explore-header {
1450 + display: flex;
1451 + align-items: center;
1452 + justify-content: space-between;
1453 + }
1454 +
1455 + .explore-count {
1456 + font-family: 'DM Mono', monospace;
1457 + font-size: 0.625rem;
1458 + color: var(--theme-accent);
1459 + opacity: 0.7;
1460 + animation: nudgeRight 1.5s ease-in-out infinite;
1461 + }
1462 +
1463 + @keyframes nudgeRight {
1464 + 0%, 100% { transform: translateX(0); }
1465 + 50% { transform: translateX(3px); }
1466 + }
1467 +
1468 + .explore-scroll {
1469 + display: flex;
1470 + gap: 8px;
1471 + overflow-x: auto;
1472 + scrollbar-width: none;
1473 + padding-bottom: 2px;
1474 + }
1475 + .explore-scroll::-webkit-scrollbar { display: none; }
1476 +
1477 + .explore-scroll {
1478 + -webkit-mask-image: linear-gradient(to right, black 85%, transparent 100%);
1479 + mask-image: linear-gradient(to right, black 85%, transparent 100%);
1480 + }
1481 +
1482 + .explore-card {
1483 + position: relative;
1484 + display: flex;
1485 + flex-direction: column;
1486 + justify-content: flex-start;
1487 + padding: 0.375rem 0.5rem;
1488 + width: 120px;
1489 + height: 95px;
1490 + border-radius: 8px;
1491 + background: var(--theme-borde);
1492 + text-decoration: none;
1493 + color: var(--theme-texto);
1494 + cursor: pointer;
1495 + transition: all 0.15s;
1496 + flex-shrink: 0;
1497 + overflow: hidden;
1498 + }
1499 + .explore-card:hover {
1500 + background: rgba(201, 167, 81, 0.15);
1501 + color: var(--theme-accent);
1502 + }
1503 + .explore-card:hover .explore-title {
1504 + color: var(--theme-accent);
1505 + }
1506 +
1507 + .explore-title {
1508 + position: relative;
1509 + font-size: 0.75rem;
1510 + font-weight: 600;
1511 + line-height: 1.3;
1512 + color: var(--theme-titulo);
1513 + z-index: 1;
1514 + }
1515 +
1516 + .explore-desc {
1517 + position: relative;
1518 + font-size: 0.625rem;
1519 + line-height: 1.35;
1520 + opacity: 0.6;
1521 + margin-top: 3px;
1522 + z-index: 1;
1523 + }
1524 +
1525 + /* ═══════════════════════════════════════════════════════════════
1526 + VIGENCIA
1527 + ═══════════════════════════════════════════════════════════════ */
1528 + .vigencia-compact {
1529 + display: flex;
1530 + align-items: center;
1531 + gap: 0.75rem;
1532 + }
1533 +
1534 + .vigencia-status-icon {
1535 + width: 32px;
1536 + height: 32px;
1537 + border-radius: 8px;
1538 + display: flex;
1539 + align-items: center;
1540 + justify-content: center;
1541 + flex-shrink: 0;
1542 + }
1543 +
1544 + .vigencia-status-icon.vigencia-full {
1545 + background: rgba(74, 158, 107, 0.15);
1546 + color: #4A9E6B;
1547 + }
1548 +
1549 + .vigencia-text {
1550 + display: flex;
1551 + flex-direction: column;
1552 + gap: 0.125rem;
1553 + }
1554 +
1555 + .vigencia-main {
1556 + font-size: 0.75rem;
1557 + font-weight: 500;
1558 + color: var(--theme-titulo);
1559 + }
1560 +
1561 + /* ═══════════════════════════════════════════════════════════════
1562 + DEFINICION
1563 + ═══════════════════════════════════════════════════════════════ */
1564 + .def-link {
1565 + display: flex;
1566 + align-items: center;
1567 + gap: 0.375rem;
1568 + font-size: 0.75rem;
1569 + color: var(--theme-accent, #C9A751);
1570 + background: none;
1571 + border: none;
1572 + padding: 0;
1573 + cursor: pointer;
1574 + transition: opacity 0.15s;
1575 + }
1576 + .def-link:hover { opacity: 0.7; }
1577 + .def-link svg { flex-shrink: 0; opacity: 0.8; }
1578 +
1579 + .chart-no-data {
1580 + flex: 1;
1581 + display: flex;
1582 + flex-direction: column;
1583 + align-items: center;
1584 + justify-content: center;
1585 + gap: 0.75rem;
1586 + color: var(--theme-texto);
1587 + opacity: 0.7;
1588 + font-size: 0.8125rem;
1589 + text-align: center;
1590 + }
1591 +
1592 + .chart-no-data p {
1593 + margin: 0;
1594 + }
1595 +
1596 + .volver-agregado {
1597 + display: inline-flex;
1598 + align-items: center;
1599 + gap: 0.25rem;
1600 + font-size: 0.6875rem;
1601 + color: var(--theme-accent);
1602 + background: none;
1603 + border: none;
1604 + padding: 0;
1605 + cursor: pointer;
1606 + transition: opacity 0.15s;
1607 + }
1608 + .volver-agregado:hover { opacity: 0.7; }
1609 +
1610 + .copy-link-btn {
1611 + display: flex;
1612 + align-items: center;
1613 + gap: 0.375rem;
1614 + font-size: 0.75rem;
1615 + color: var(--theme-texto);
1616 + opacity: 0.6;
1617 + background: none;
1618 + border: none;
1619 + padding: 0;
1620 + cursor: pointer;
1621 + transition: opacity 0.15s;
1622 + }
1623 + .copy-link-btn:hover { opacity: 1; }
1624 + .copy-link-btn svg { flex-shrink: 0; }
1625 +
1626 + /* ═══════════════════════════════════════════════════════════════
1627 + MAIN
1628 + ═══════════════════════════════════════════════════════════════ */
1629 + .dashboard-main {
1630 + display: flex;
1631 + flex-direction: column;
1632 + gap: 1.5rem;
1633 + min-height: 0;
1634 + overflow: hidden;
1635 + }
1636 +
1637 + .chart-protagonista {
1638 + background: var(--theme-surface);
1639 + border-radius: 12px;
1640 + padding: 0.75rem;
1641 + display: flex;
1642 + flex-direction: column;
1643 + min-height: 0;
1644 + flex: 2;
1645 + overflow: hidden;
1646 + }
1647 +
1648 + .ranking-section {
1649 + background: var(--theme-surface);
1650 + border-radius: 12px;
1651 + padding: 0.75rem;
1652 + flex: 1;
1653 + display: flex;
1654 + flex-direction: column;
1655 + min-height: 0;
1656 + }
1657 +
1658 + /* ═══════════════════════════════════════════════════════════════
1659 + CHART & KPIs
1660 + ═══════════════════════════════════════════════════════════════ */
1661 + .chart-top-row {
1662 + display: flex;
1663 + justify-content: space-between;
1664 + align-items: center;
1665 + margin-bottom: 0.375rem;
1666 + }
1667 +
1668 + .main-title-pills {
1669 + display: flex;
1670 + gap: 0.375rem;
1671 + }
1672 +
1673 + .pill {
1674 + font-family: 'DM Mono', monospace;
1675 + font-size: 0.625rem;
1676 + padding: 0.125rem 0.5rem;
1677 + border-radius: 4px;
1678 + letter-spacing: 0.03em;
1679 + }
1680 +
1681 + .pill-nivel {
1682 + background: rgba(201, 167, 81, 0.15);
1683 + color: var(--theme-accent);
1684 + }
1685 +
1686 + .pill-codigo {
1687 + background: var(--theme-borde);
1688 + color: var(--theme-texto);
1689 + }
1690 +
1691 + .main-title-name {
1692 + font-family: 'DM Serif Display', serif;
1693 + font-size: 2rem;
1694 + font-weight: 400;
1695 + margin: 0 0 0.5rem;
1696 + line-height: 1.15;
1697 + color: var(--theme-titulo);
1698 + }
1699 +
1700 + .chart-controls {
1701 + margin-bottom: 0.5rem;
1702 + }
1703 +
1704 + .chart-header {
1705 + display: flex;
1706 + align-items: center;
1707 + justify-content: space-between;
1708 + margin-bottom: 0.25rem;
1709 + }
1710 +
1711 + .chart-title {
1712 + font-family: 'DM Mono', monospace;
1713 + font-size: 0.6875rem;
1714 + color: var(--theme-texto);
1715 + opacity: 0.7;
1716 + }
1717 +
1718 + .chart-period {
1719 + opacity: 0.5;
1720 + }
1721 +
1722 + .metrica-toggle {
1723 + font-family: 'DM Mono', monospace;
1724 + font-size: 0.6875rem;
1725 + color: var(--theme-texto);
1726 + background: rgba(128, 128, 128, 0.08);
1727 + border: 1px solid rgba(128, 128, 128, 0.15);
1728 + padding: 0.125rem 0.375rem;
1729 + border-radius: 4px;
1730 + cursor: pointer;
1731 + transition: all 0.15s;
1732 + display: inline-flex;
1733 + align-items: center;
1734 + gap: 0.25rem;
1735 + }
1736 +
1737 + .metrica-toggle:hover {
1738 + background: rgba(201, 167, 81, 0.12);
1739 + border-color: rgba(201, 167, 81, 0.3);
1740 + color: var(--theme-accent);
1741 + }
1742 +
1743 + .chart-loading {
1744 + display: flex;
1745 + align-items: center;
1746 + justify-content: center;
1747 + color: var(--theme-texto);
1748 + opacity: 0.5;
1749 + }
1750 +
1751 + .chart-flex-wrapper {
1752 + flex: 1;
1753 + min-height: 0;
1754 + }
1755 +
1756 + .kpis {
1757 + display: grid;
1758 + grid-template-columns: repeat(4, 1fr);
1759 + gap: 0.5rem;
1760 + margin-top: 0.5rem;
1761 + padding-top: 0.75rem;
1762 + border-top: 1px solid var(--theme-borde);
1763 + }
1764 +
1765 + .kpi {
1766 + display: flex;
1767 + flex-direction: column;
1768 + align-items: center;
1769 + gap: 0.125rem;
1770 + text-align: center;
1771 + }
1772 +
1773 + .kpi-value {
1774 + font-family: 'Qanelas', var(--font-sans);
1775 + font-size: 1rem;
1776 + font-weight: 600;
1777 + color: var(--theme-titulo);
1778 + }
1779 +
1780 + .kpi-label {
1781 + font-size: 0.625rem;
1782 + color: var(--theme-texto);
1783 + opacity: 0.5;
1784 + line-height: 1.35;
1785 + }
1786 +
1787 + /* ═══════════════════════════════════════════════════════════════
1788 + RANKING
1789 + ═══════════════════════════════════════════════════════════════ */
1790 + .ranking-title {
1791 + font-size: 1rem;
1792 + font-weight: 600;
1793 + color: var(--theme-titulo);
1794 + margin: 0 0 0.25rem;
1795 + }
1796 +
1797 + .ranking-cards {
1798 + display: flex;
1799 + flex-direction: column;
1800 + gap: 0.25rem;
1801 + }
1802 +
1803 + .ranking-card {
1804 + display: flex;
1805 + align-items: center;
1806 + gap: 0.375rem;
1807 + padding: 0.5rem 0.625rem;
1808 + border-radius: 8px;
1809 + background: transparent;
1810 + text-align: left;
1811 + }
1812 +
1813 + .ranking-card-link {
1814 + text-decoration: none;
1815 + cursor: pointer;
1816 + transition: background 0.15s;
1817 + }
1818 +
1819 + .ranking-card-link:hover {
1820 + background: rgba(201, 167, 81, 0.05);
1821 + }
1822 +
1823 + .ranking-card-link:hover .ranking-name {
1824 + color: var(--theme-accent);
1825 + border-bottom-style: solid;
1826 + }
1827 +
1828 + .ranking-arrow {
1829 + opacity: 0;
1830 + color: var(--theme-accent);
1831 + transition: opacity 0.15s, transform 0.15s;
1832 + flex-shrink: 0;
1833 + }
1834 +
1835 + .ranking-card-link:hover .ranking-arrow {
1836 + opacity: 1;
1837 + transform: translateX(2px);
1838 + }
1839 +
1840 + .ranking-header {
1841 + margin-bottom: 0.5rem;
1842 + }
1843 +
1844 + .ranking-summary {
1845 + display: flex;
1846 + align-items: baseline;
1847 + gap: 0.375rem;
1848 + margin-top: 0.125rem;
1849 + }
1850 +
1851 + .ranking-summary-pct {
1852 + font-family: 'DM Mono', monospace;
1853 + font-size: 1.25rem;
1854 + font-weight: 700;
1855 + color: var(--theme-titulo);
1856 + }
1857 +
1858 + .ranking-summary-text {
1859 + font-size: 0.75rem;
1860 + color: var(--theme-texto);
1861 + opacity: 0.7;
1862 + }
1863 +
1864 + .ranking-position {
1865 + font-family: 'DM Mono', monospace;
1866 + font-size: 0.6875rem;
1867 + color: var(--theme-accent);
1868 + font-weight: 600;
1869 + min-width: 1.5rem;
1870 + }
1871 +
1872 + .ranking-name {
1873 + font-size: 0.8125rem;
1874 + color: var(--theme-titulo);
1875 + border-bottom: 1px dotted rgba(128, 128, 128, 0.3);
1876 + }
1877 +
1878 + .ranking-pct {
1879 + font-family: 'DM Mono', monospace;
1880 + font-size: 0.8125rem;
1881 + font-weight: 600;
1882 + color: var(--theme-titulo);
1883 + opacity: 0.7;
1884 + }
1885 +
1886 + /* ═══════════════════════════════════════════════════════════════
1887 + MODAL
1888 + ═══════════════════════════════════════════════════════════════ */
1889 + .modal-overlay {
1890 + position: fixed;
1891 + inset: 0;
1892 + background: rgba(0, 0, 0, 0.6);
1893 + backdrop-filter: blur(4px);
1894 + display: flex;
1895 + align-items: center;
1896 + justify-content: center;
1897 + z-index: 1000;
1898 + padding: 1rem;
1899 + }
1900 +
1901 + .modal-content {
1902 + background: var(--theme-surface);
1903 + border-radius: 16px;
1904 + max-width: 560px;
1905 + width: 100%;
1906 + max-height: 80vh;
1907 + overflow: hidden;
1908 + display: flex;
1909 + flex-direction: column;
1910 + box-shadow: 0 24px 48px rgba(0, 0, 0, 0.2);
1911 + }
1912 +
1913 + .modal-header {
1914 + display: flex;
1915 + align-items: center;
1916 + justify-content: space-between;
1917 + padding: 1.25rem 1.5rem;
1918 + border-bottom: 1px solid var(--theme-borde);
1919 + }
1920 +
1921 + .modal-header h3 {
1922 + font-size: 1rem;
1923 + font-weight: 600;
1924 + color: var(--theme-titulo);
1925 + margin: 0;
1926 + }
1927 +
1928 + .modal-close {
1929 + color: var(--theme-texto);
1930 + background: transparent;
1931 + border: none;
1932 + padding: 0.25rem;
1933 + cursor: pointer;
1934 + border-radius: 6px;
1935 + transition: background 0.2s;
1936 + }
1937 +
1938 + .modal-close:hover {
1939 + background: var(--theme-borde);
1940 + }
1941 +
1942 + .modal-body {
1943 + padding: 1.5rem;
1944 + overflow-y: auto;
1945 + display: flex;
1946 + flex-direction: column;
1947 + gap: 1rem;
1948 + }
1949 +
1950 + .def-item {
1951 + padding: 1rem;
1952 + background: var(--theme-body);
1953 + border-radius: 10px;
1954 + border-left: 3px solid var(--theme-borde);
1955 + }
1956 +
1957 + .def-current {
1958 + border-left-color: #6B9FD4;
1959 + }
1960 +
1961 + .def-header {
1962 + display: flex;
1963 + align-items: center;
1964 + gap: 0.5rem;
1965 + margin-bottom: 0.5rem;
1966 + }
1967 +
1968 + .def-period {
1969 + font-family: 'DM Mono', monospace;
1970 + font-size: 0.75rem;
1971 + font-weight: 500;
1972 + color: #6B9FD4;
1973 + }
1974 +
1975 + .def-badge {
1976 + font-size: 0.625rem;
1977 + font-weight: 600;
1978 + text-transform: uppercase;
1979 + letter-spacing: 0.05em;
1980 + color: white;
1981 + background: #4A9E6B;
1982 + padding: 0.125rem 0.5rem;
1983 + border-radius: 4px;
1984 + }
1985 +
1986 + .def-text {
1987 + font-size: 0.875rem;
1988 + color: var(--theme-texto);
1989 + line-height: 1.6;
1990 + margin: 0;
1991 + }
1992 +
1993 + :global(html.dark) .def-current {
1994 + border-left-color: var(--theme-accent);
1995 + }
1996 +
1997 + :global(html.dark) .def-period {
1998 + color: var(--theme-accent);
1999 + }
2000 +
2001 + /* ═══════════════════════════════════════════════════════════════
2002 + MOBILE SIDEBAR
2003 + ═══════════════════════════════════════════════════════════════ */
2004 + .mobile-sidebar-toggle {
2005 + display: none;
2006 + }
2007 +
2008 + @media (max-width: 900px) {
2009 + .dashboard-layout {
2010 + grid-template-columns: 1fr;
2011 + gap: 0.5rem;
2012 + height: auto;
2013 + overflow: visible;
2014 + padding: 1.5rem 1rem 2rem;
2015 + align-content: center;
2016 + min-height: calc(100vh - 4rem);
2017 + }
2018 +
2019 + .dashboard-main {
2020 + overflow: visible;
2021 + }
2022 +
2023 + .chart-protagonista {
2024 + flex: none;
2025 + }
2026 +
2027 + .ranking-section {
2028 + flex: none;
2029 + }
2030 +
2031 + .mobile-sidebar-toggle {
2032 + display: flex;
2033 + align-items: center;
2034 + gap: 0.5rem;
2035 + padding: 0.625rem 1rem;
2036 + background: var(--theme-surface);
2037 + border: 1px solid var(--theme-borde);
2038 + border-radius: 10px;
2039 + color: var(--theme-titulo);
2040 + font-family: 'Qanelas', var(--font-sans);
2041 + font-size: 0.8125rem;
2042 + font-weight: 500;
2043 + cursor: pointer;
2044 + transition: all 0.2s;
2045 + margin-bottom: 0.25rem;
2046 + width: 100%;
2047 + }
2048 +
2049 + .mobile-sidebar-toggle:hover {
2050 + background: var(--theme-surface-hover);
2051 + }
2052 +
2053 + .mobile-sidebar-toggle svg {
2054 + color: var(--theme-texto);
2055 + opacity: 0.6;
2056 + }
2057 +
2058 + .dashboard-context .context-card {
2059 + display: none;
2060 + }
2061 +
2062 + .dashboard-context.mobile-open .context-card {
2063 + display: flex;
2064 + animation: slideDown 0.25s ease;
2065 + }
2066 +
2067 + @keyframes slideDown {
2068 + from { opacity: 0; transform: translateY(-8px); }
2069 + to { opacity: 1; transform: translateY(0); }
2070 + }
2071 +
2072 + .context-card {
2073 + height: auto;
2074 + overflow-y: visible;
2075 + }
2076 +
2077 + .kpis {
2078 + grid-template-columns: repeat(2, 1fr);
2079 + }
2080 + }
2081 +
2082 + @media (max-width: 480px) {
2083 + .dashboard-layout {
2084 + padding: 0.5rem;
2085 + }
2086 +
2087 + .chart-protagonista,
2088 + .ranking-section,
2089 + .context-card {
2090 + padding: 0.5rem;
2091 + border-radius: 8px;
2092 + }
2093 +
2094 + .main-title-name {
2095 + font-size: 1.375rem;
2096 + }
2097 +
2098 + .chart-header {
2099 + flex-direction: column;
2100 + align-items: flex-start;
2101 + gap: 0.25rem;
2102 + }
2103 +
2104 + .kpis {
2105 + grid-template-columns: repeat(2, 1fr);
2106 + gap: 0.375rem;
2107 + }
2108 +
2109 + .kpi-value {
2110 + font-size: 0.8125rem;
2111 + }
2112 +
2113 + .kpi-label {
2114 + font-size: 0.5625rem;
2115 + }
2116 +
2117 + .explore-scroll {
2118 + gap: 6px;
2119 + }
2120 +
2121 + .explore-card {
2122 + width: 100px;
2123 + height: 80px;
2124 + }
2125 +
2126 + .explore-title {
2127 + font-size: 0.625rem;
2128 + }
2129 +
2130 + .explore-desc {
2131 + font-size: 0.5rem;
2132 + }
2133 +
2134 + .ranking-title {
2135 + font-size: 0.875rem;
2136 + }
2137 +
2138 + .ranking-name {
2139 + font-size: 0.6875rem;
2140 + }
2141 +
2142 + .ranking-pct {
2143 + font-size: 0.75rem;
2144 + }
2145 +
2146 + .modal-content {
2147 + margin: 0.5rem;
2148 + max-height: 90vh;
2149 + }
2150 +
2151 + .tree-name {
2152 + max-width: 150px;
2153 + }
2154 + }
2155 +
2156 + /* ═══════════════════════════════════════════════════════════════
2157 + DARK MODE
2158 + ═══════════════════════════════════════════════════════════════ */
2159 + :global(html.dark) .context-hint {
2160 + color: rgba(255, 255, 255, 0.5);
2161 + }
2162 +
2163 + :global(html.dark) .def-link {
2164 + color: var(--theme-accent, #C9A751);
2165 + }
2166 +
2167 + /* ═══════════════════════════════════════════════════════════════
2168 + COMPARAR VIEW
2169 + ═══════════════════════════════════════════════════════════════ */
2170 + .main-comparar {
2171 + display: flex;
2172 + flex-direction: column;
2173 + gap: 0.75rem;
2174 + padding: 1rem 2rem 2rem;
2175 + width: 100%;
2176 + max-width: 1400px;
2177 + margin: 0 auto;
2178 + height: 100%;
2179 + overflow: hidden;
2180 + }
2181 +
2182 + .chart-card-comparar {
2183 + background: var(--theme-surface);
2184 + border-radius: 12px;
2185 + padding: 1rem 1.5rem;
2186 + display: flex;
2187 + flex-direction: column;
2188 + flex: 1;
2189 + min-height: 0;
2190 + }
2191 +
2192 + .comparador-selectors {
2193 + display: flex;
2194 + align-items: center;
2195 + gap: 1rem;
2196 + margin-bottom: 1rem;
2197 + }
2198 +
2199 + .selector-group { flex: 1; display: flex; align-items: center; gap: 0.5rem; }
2200 + .selector-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
2201 + .vs { font-family: 'DM Mono', monospace; font-size: 0.75rem; color: var(--theme-texto); flex-shrink: 0; }
2202 + .relative { position: relative; }
2203 + .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2204 +
2205 + .selector-btn {
2206 + display: flex;
2207 + align-items: center;
2208 + gap: 0.5rem;
2209 + padding: 0.625rem 1rem;
2210 + background: var(--theme-body);
2211 + border: 1px solid var(--theme-borde);
2212 + border-radius: 8px;
2213 + font-size: 0.8125rem;
2214 + color: var(--theme-titulo);
2215 + cursor: pointer;
2216 + transition: border-color 0.2s;
2217 + }
2218 + .selector-btn:hover { border-color: var(--theme-accent); }
2219 + .comparador-btn { width: 100%; justify-content: space-between; }
2220 + .comparador-dropdown-container { position: relative; }
2221 +
2222 + .dropdown-panel.comparador-panel {
2223 + position: absolute;
2224 + top: calc(100% + 4px);
2225 + left: 0;
2226 + width: 100%;
2227 + min-width: 280px;
2228 + background: var(--theme-surface);
2229 + border: 1px solid var(--theme-borde);
2230 + border-radius: 10px;
2231 + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
2232 + z-index: 100;
2233 + overflow: hidden;
2234 + }
2235 +
2236 + .dropdown-search { padding: 0.5rem; border-bottom: 1px solid var(--theme-borde); }
2237 + .search-input {
2238 + width: 100%;
2239 + padding: 0.5rem 0.75rem;
2240 + border: 1px solid var(--theme-borde);
2241 + border-radius: 6px;
2242 + background: var(--theme-body);
2243 + color: var(--theme-titulo);
2244 + font-size: 0.8125rem;
2245 + outline: none;
2246 + }
2247 + .search-input:focus { border-color: var(--theme-accent); }
2248 +
2249 + .dropdown-item {
2250 + display: flex;
2251 + align-items: center;
2252 + gap: 0.5rem;
2253 + width: 100%;
2254 + padding: 0.625rem 0.75rem;
2255 + border: none;
2256 + background: transparent;
2257 + color: var(--theme-titulo);
2258 + font-size: 0.8125rem;
2259 + text-align: left;
2260 + cursor: pointer;
2261 + transition: background 0.15s;
2262 + }
2263 + .dropdown-item:hover { background: var(--theme-surface-hover); }
2264 + .dropdown-item.active { background: rgba(201, 167, 81, 0.1); color: var(--theme-accent); }
2265 + .dropdown-item:disabled { opacity: 0.4; cursor: not-allowed; }
2266 + .dropdown-list { max-height: 240px; overflow-y: auto; }
2267 +
2268 + .comparador-placeholder {
2269 + display: flex;
2270 + align-items: center;
2271 + justify-content: center;
2272 + min-height: 200px;
2273 + border: 1px dashed var(--theme-borde);
2274 + border-radius: 12px;
2275 + padding: 2rem;
2276 + text-align: center;
2277 + }
2278 + .comparador-placeholder p { color: var(--theme-texto); font-size: 0.875rem; margin: 0; }
2279 +
2280 + .comparador-hint {
2281 + display: flex;
2282 + align-items: center;
2283 + gap: 0.5rem;
2284 + padding: 0.75rem 1rem;
2285 + background: rgba(107, 159, 212, 0.1);
2286 + border-radius: 8px;
2287 + font-size: 0.8125rem;
2288 + color: #6B9FD4;
2289 + }
2290 + .comparador-hint span { flex: 1; }
2291 + .comparador-hint-select { margin-top: 0.75rem; }
2292 +
2293 + .comparador-warning {
2294 + display: flex;
2295 + align-items: center;
2296 + gap: 0.5rem;
2297 + padding: 0.75rem 1rem;
2298 + background: rgba(230, 176, 75, 0.1);
2299 + border-radius: 8px;
2300 + font-size: 0.8125rem;
2301 + color: #C9A751;
2302 + }
2303 + .comparador-warning span { flex: 1; }
2304 +
2305 + .alert-close {
2306 + flex-shrink: 0;
2307 + padding: 0.25rem;
2308 + border: none;
2309 + background: transparent;
2310 + color: inherit;
2311 + opacity: 0.6;
2312 + cursor: pointer;
2313 + border-radius: 4px;
2314 + }
2315 + .alert-close:hover { opacity: 1; }
2316 +
2317 + .chart-comparar-wrapper {
2318 + position: relative;
2319 + width: 100%;
2320 + flex: 1;
2321 + min-height: 180px;
2322 + }
2323 +
2324 + .chart-comparar-inner {
2325 + position: absolute;
2326 + top: 10px;
2327 + bottom: 30px;
2328 + left: 50px;
2329 + right: 10px;
2330 + }
2331 +
2332 + .y-label-comparar {
2333 + position: absolute;
2334 + left: -50px;
2335 + width: 45px;
2336 + text-align: right;
2337 + transform: translateY(50%);
2338 + font-family: 'DM Mono', monospace;
2339 + font-size: 0.6875rem;
2340 + color: var(--theme-texto);
2341 + white-space: nowrap;
2342 + }
2343 +
2344 + .grid-line-comparar {
2345 + position: absolute;
2346 + left: 0;
2347 + right: 0;
2348 + border-top: 0.5px dotted var(--theme-texto);
2349 + opacity: 0.15;
2350 + }
2351 +
2352 + .bars-comparar {
2353 + position: absolute;
2354 + inset: 0;
2355 + display: flex;
2356 + align-items: flex-end;
2357 + gap: 3px;
2358 + }
2359 +
2360 + .bar-group-comparar {
2361 + flex: 1;
2362 + height: 100%;
2363 + display: flex;
2364 + align-items: flex-end;
2365 + justify-content: center;
2366 + cursor: pointer;
2367 + }
2368 +
2369 + .bars-pair {
2370 + display: flex;
2371 + align-items: flex-end;
2372 + gap: 2px;
2373 + height: 100%;
2374 + width: 100%;
2375 + justify-content: center;
2376 + }
2377 +
2378 + .bar-comparar {
2379 + flex: 1;
2380 + max-width: 24px;
2381 + border-radius: 3px 3px 0 0;
2382 + min-height: 2px;
2383 + transition: opacity 0.15s;
2384 + }
2385 +
2386 + .bars-comparar:hover .bar-comparar { opacity: 0.35; }
2387 + .bar-group-comparar.active .bar-comparar { opacity: 1; }
2388 +
2389 + .x-axis-comparar {
2390 + position: absolute;
2391 + bottom: 0;
2392 + left: 50px;
2393 + right: 10px;
2394 + height: 30px;
2395 + display: flex;
2396 + align-items: center;
2397 + }
2398 +
2399 + .x-label-comparar {
2400 + flex: 1;
2401 + text-align: center;
2402 + font-family: 'DM Mono', monospace;
2403 + font-size: 0.6875rem;
2404 + color: var(--theme-texto);
2405 + opacity: 0;
2406 + transition: opacity 0.2s;
2407 + }
2408 + .x-label-comparar.visible { opacity: 1; }
2409 +
2410 + .chart-legend {
2411 + display: flex;
2412 + justify-content: center;
2413 + gap: 2rem;
2414 + margin-top: 0.5rem;
2415 + margin-bottom: 0.75rem;
2416 + }
2417 +
2418 + .legend-item {
2419 + display: flex;
2420 + align-items: center;
2421 + gap: 0.5rem;
2422 + font-size: 0.75rem;
2423 + color: var(--theme-texto);
2424 + }
2425 +
2426 + .legend-dot { width: 10px; height: 10px; border-radius: 3px; }
2427 +
2428 + .kpis-card-comparar {
2429 + background: var(--theme-surface);
2430 + border-radius: 12px;
2431 + padding: 0.75rem 1.25rem;
2432 + }
2433 +
2434 + .tabla-header {
2435 + display: grid;
2436 + grid-template-columns: 1fr 1.2fr 1.2fr;
2437 + gap: 0.75rem;
2438 + padding-bottom: 0.375rem;
2439 + border-bottom: 1px solid var(--theme-borde);
2440 + margin-bottom: 0.25rem;
2441 + }
2442 +
2443 + .tabla-header .tabla-val {
2444 + font-size: 0.625rem;
2445 + font-weight: 500;
2446 + text-transform: uppercase;
2447 + letter-spacing: 0.05em;
2448 + text-align: center;
2449 + }
2450 +
2451 + .tabla-entidad {
2452 + font-size: 0.6875rem;
2453 + line-height: 1.3;
2454 + word-break: break-word;
2455 + }
2456 +
2457 + .tabla-row {
2458 + display: grid;
2459 + grid-template-columns: 1fr 1.2fr 1.2fr;
2460 + gap: 0.75rem;
2461 + padding: 0.1875rem 0;
2462 + }
2463 +
2464 + .tabla-label {
2465 + font-size: 0.6875rem;
2466 + font-weight: 500;
2467 + color: var(--theme-texto);
2468 + text-transform: uppercase;
2469 + letter-spacing: 0.05em;
2470 + }
2471 +
2472 + .tabla-val {
2473 + font-size: 0.875rem;
2474 + font-weight: 500;
2475 + color: var(--theme-titulo);
2476 + text-align: center;
2477 + font-variant-numeric: tabular-nums;
2478 + }
2479 +
2480 + @media (max-width: 640px) {
2481 + .main-comparar { padding: 0.75rem; }
2482 + .chart-card-comparar, .kpis-card-comparar { padding: 1rem; }
2483 + .comparador-selectors { flex-direction: column; gap: 0.75rem; }
2484 + .vs { display: none; }
2485 + }
2486 +</style>
......
1 +<script>
2 + import { page } from '$app/stores';
3 + import { goto } from '$app/navigation';
4 + import { onMount } from 'svelte';
5 + import Spinner from '$lib/components/ui/Spinner.svelte';
6 + import * as d3 from 'd3';
7 +
8 + let codigo = $derived($page.params.codigo);
9 +
10 + // Estado
11 + let loading = $state(true);
12 + let mounted = $state(false);
13 + let rubroInfo = $state(null);
14 + let entidadesData = $state([]);
15 + let selectedYear = $state(null);
16 + let linkCopied = $state(false);
17 + let hoveredNode = $state(null);
18 + let totalDevengado = $state(0);
19 + let entidadClasificador = $state({});
20 + let subareas = $state([]);
21 + let sliderMin = $state(0);
22 + let sliderMax = $state(100);
23 +
24 + // Filtrar entidades por rango de % acumulado
25 + let filteredEntidades = $derived.by(() => {
26 + if (sliderMin === 0 && sliderMax === 100) return entidadesData;
27 + let acum = 0;
28 + return entidadesData.filter(d => {
29 + const pctBefore = (acum / totalDevengado) * 100;
30 + acum += d.monto;
31 + const pctAfter = (acum / totalDevengado) * 100;
32 + return pctAfter > sliderMin && pctBefore < sliderMax;
33 + });
34 + });
35 +
36 + let filteredTotal = $derived(filteredEntidades.reduce((s, d) => s + d.monto, 0));
37 + let filteredCount = $derived(filteredEntidades.length);
38 +
39 + // Treemap
40 + let treemapContainer = $state(null);
41 + let treemapWidth = $state(0);
42 + let treemapHeight = $state(0);
43 + let treemapNodes = $state([]);
44 + let treemapGroups = $state([]);
45 +
46 + // Anos disponibles desde gestiones del rubro
47 + let availableYears = $derived.by(() => {
48 + if (!rubroInfo?.gestiones) return [];
49 + // Filtrar años con datos reales: usar los estados del rubro
50 + if (rubroInfo.estados && rubroInfo.estados.length > 0) {
51 + return rubroInfo.estados.map(e => e.gestion).filter(y => y >= 2016 && y > 0).sort((a, b) => b - a);
52 + }
53 + return rubroInfo.gestiones.split(',').map(Number).filter(y => y >= 2016 && y <= new Date().getFullYear()).sort((a, b) => b - a);
54 + });
55 +
56 + // Formatters
57 + function formatMonto(v) {
58 + if (v >= 1e9) return `${(v / 1e9).toFixed(1)} mil mill.`;
59 + if (v >= 1e6) return `${(v / 1e6).toFixed(1)} mill.`;
60 + if (v >= 1e3) return `${(v / 1e3).toFixed(0)} mil`;
61 + return v?.toLocaleString('es-BO') || '0';
62 + }
63 +
64 + function formatCompact(v) {
65 + if (v >= 1e9) return `${(v / 1e9).toFixed(1)}MM`;
66 + if (v >= 1e6) return `${(v / 1e6).toFixed(1)}M`;
67 + if (v >= 1e3) return `${(v / 1e3).toFixed(0)}K`;
68 + return v?.toFixed(0) || '0';
69 + }
70 +
71 + // Colores por subarea
72 + let isDark = $state(false);
73 + const SUBAREA_COLORS = [
74 + '#4E79A7', '#F28E2B', '#E15759', '#76B7B2', '#59A14F',
75 + '#EDC948', '#B07AA1', '#FF9DA7', '#9C755F', '#BAB0AC',
76 + '#4E79A7', '#F28E2B', '#E15759', '#76B7B2', '#59A14F'
77 + ];
78 +
79 + function getSubareaColor(subarea) {
80 + const idx = subareas.indexOf(subarea);
81 + return SUBAREA_COLORS[idx % SUBAREA_COLORS.length];
82 + }
83 +
84 + function getTextColorForBg(bgColor) {
85 + const color = d3.color(bgColor);
86 + if (!color) return 'rgba(255,255,255,0.95)';
87 + const luminance = 0.299 * (color.r / 255) + 0.587 * (color.g / 255) + 0.114 * (color.b / 255);
88 + return luminance > 0.5 ? 'rgba(0,0,0,0.85)' : 'rgba(255,255,255,0.95)';
89 + }
90 +
91 + function getTextColorSecondary(bgColor) {
92 + const color = d3.color(bgColor);
93 + if (!color) return 'rgba(255,255,255,0.88)';
94 + const luminance = 0.299 * (color.r / 255) + 0.587 * (color.g / 255) + 0.114 * (color.b / 255);
95 + return luminance > 0.5 ? 'rgba(0,0,0,0.6)' : 'rgba(255,255,255,0.7)';
96 + }
97 +
98 + // Cargar clasificador de entidades
99 + async function loadClasificador() {
100 + try {
101 + const res = await fetch('/api/entidad-clasificador');
102 + const data = await res.json();
103 + if (Array.isArray(data)) {
104 + const map = {};
105 + data.forEach(d => { map[d.entidad] = d; });
106 + entidadClasificador = map;
107 + }
108 + } catch {}
109 + }
110 +
111 + // Cargar info del rubro desde API
112 + async function loadRubro() {
113 + try {
114 + const res = await fetch(`/api/rubro-data?codigo=${codigo}&tipo=clasificador`);
115 + const data = await res.json();
116 + if (data && !data.error) rubroInfo = data;
117 + } catch {}
118 + }
119 +
120 + // Cargar entidades desde API
121 + async function loadEntidades() {
122 + if (!selectedYear || !codigo) return;
123 + loading = true;
124 + try {
125 + const res = await fetch(`/api/rubro-data?codigo=${codigo}&tipo=entidades-año&gestion=${selectedYear}`);
126 + const raw = await res.json();
127 + if (Array.isArray(raw)) {
128 + // Deduplicar por entidad
129 + const map = new Map();
130 + raw.forEach(d => {
131 + if (!map.has(d.entidad) || d.monto > map.get(d.entidad).monto) {
132 + map.set(d.entidad, d);
133 + }
134 + });
135 + entidadesData = [...map.values()].filter(d => d.monto > 0).sort((a, b) => b.monto - a.monto);
136 + totalDevengado = entidadesData.reduce((s, d) => s + d.monto, 0);
137 +
138 + // Rubro entidades endpoint returns entidad_desc directly
139 + entidadesData = entidadesData.map(d => ({
140 + ...d,
141 + desc_entidad: d.entidad_desc || d.desc_entidad || `Entidad ${d.entidad}`,
142 + desc_subarea: entidadClasificador[d.entidad]?.desc_subarea || 'Otros'
143 + }));
144 +
145 + // Extraer subareas unicas ordenadas por monto total
146 + const subareaMontos = {};
147 + entidadesData.forEach(d => {
148 + subareaMontos[d.desc_subarea] = (subareaMontos[d.desc_subarea] || 0) + d.monto;
149 + });
150 + subareas = Object.entries(subareaMontos).sort((a, b) => b[1] - a[1]).map(([k]) => k);
151 +
152 + buildTreemap();
153 + }
154 + } catch (err) {
155 + console.error('[Entidades] Error:', err);
156 + }
157 + loading = false;
158 + }
159 +
160 + function buildTreemap() {
161 + if (!filteredEntidades.length || treemapWidth <= 0 || treemapHeight <= 0) {
162 + treemapNodes = [];
163 + treemapGroups = [];
164 + return;
165 + }
166 +
167 + // Agrupar por subarea
168 + const groups = {};
169 + filteredEntidades.forEach(d => {
170 + const sub = d.desc_subarea || 'Otros';
171 + if (!groups[sub]) groups[sub] = [];
172 + groups[sub].push({
173 + id: d.entidad,
174 + name: d.desc_entidad || `Entidad ${d.entidad}`,
175 + value: d.monto,
176 + ranking: d.ranking,
177 + per_capita: d.per_capita,
178 + prop: d.prop,
179 + subarea: sub
180 + });
181 + });
182 +
183 + const hierarchy = d3.hierarchy({
184 + name: 'root',
185 + children: Object.entries(groups).map(([subarea, children]) => ({
186 + name: subarea,
187 + children
188 + }))
189 + }).sum(d => d.value || 0)
190 + .sort((a, b) => b.value - a.value);
191 +
192 + d3.treemap()
193 + .size([treemapWidth, treemapHeight])
194 + .paddingOuter(3)
195 + .paddingInner(1)
196 + .paddingTop(18)
197 + .round(true)(hierarchy);
198 +
199 + treemapNodes = hierarchy.leaves() || [];
200 + treemapGroups = hierarchy.children || [];
201 + }
202 +
203 + function changeYear(year) {
204 + selectedYear = year;
205 + goto(`?gestion=${year}`, { replaceState: true, noScroll: true });
206 + loadEntidades();
207 + }
208 +
209 + $effect(() => {
210 + if (treemapContainer) {
211 + const measure = () => {
212 + const w = treemapContainer.clientWidth;
213 + const h = treemapContainer.clientHeight;
214 + if (w > 0 && h > 0) {
215 + treemapWidth = w;
216 + treemapHeight = h;
217 + buildTreemap();
218 + }
219 + };
220 + measure();
221 + const observer = new ResizeObserver(measure);
222 + observer.observe(treemapContainer);
223 + return () => observer.disconnect();
224 + }
225 + });
226 +
227 +
228 + onMount(async () => {
229 + mounted = true;
230 + isDark = document.documentElement.classList.contains('dark');
231 + const observer = new MutationObserver(() => {
232 + isDark = document.documentElement.classList.contains('dark');
233 + });
234 + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
235 +
236 + await Promise.all([loadRubro(), loadClasificador()]);
237 +
238 + const yearParam = $page.url.searchParams.get('gestion');
239 + if (yearParam) {
240 + const y = parseInt(yearParam);
241 + selectedYear = availableYears.includes(y) ? y : availableYears[0] || 2025;
242 + } else {
243 + selectedYear = availableYears[0] || 2025;
244 + }
245 +
246 + if (treemapContainer) {
247 + treemapWidth = treemapContainer.clientWidth;
248 + treemapHeight = treemapContainer.clientHeight;
249 + }
250 + await loadEntidades();
251 + });
252 +</script>
253 +
254 +<svelte:head>
255 + <title>{rubroInfo?.desc_rubro || `Rubro ${codigo}`} - Ranking de Entidades</title>
256 + <link rel="preconnect" href="https://fonts.googleapis.com" />
257 + <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" />
258 +</svelte:head>
259 +
260 +<div class="page" class:mounted>
261 + <div class="layout">
262 + <!-- Header -->
263 + <header class="header">
264 + <div class="header-top">
265 + <div class="pills">
266 + <span class="pill pill-nivel">{rubroInfo?.nivel || 'rubro'}</span>
267 + <span class="pill pill-codigo">{codigo}</span>
268 + </div>
269 + <div class="header-actions">
270 + <button class="copy-link-btn" onclick={() => {
271 + navigator.clipboard.writeText(window.location.href);
272 + linkCopied = true;
273 + setTimeout(() => linkCopied = false, 2000);
274 + }}>
275 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
276 + {#if linkCopied}
277 + <path d="M20 6L9 17l-5-5"/>
278 + {:else}
279 + <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
280 + <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
281 + {/if}
282 + </svg>
283 + <span>{linkCopied ? 'Copiado' : 'Compartir'}</span>
284 + </button>
285 + </div>
286 + </div>
287 +
288 + <h1 class="title">{rubroInfo?.desc_rubro || 'Cargando...'}</h1>
289 + <p class="subtitle">Que entidades recaudan mas en este rubro de ingreso?</p>
290 +
291 + <!-- Ano + Stats -->
292 + <div class="meta-row">
293 + <div class="year-selector">
294 + {#each availableYears as year}
295 + <button class="year-btn" class:active={selectedYear === year} onclick={() => changeYear(year)}>{year}</button>
296 + {/each}
297 + </div>
298 + {#if !loading && entidadesData.length > 0}
299 + <div class="stats">
300 + <span class="stat"><strong>{entidadesData.length}</strong> entidades en total</span>
301 + </div>
302 + {/if}
303 + </div>
304 +
305 + <!-- Slider de rango -->
306 + {#if !loading && entidadesData.length > 0}
307 + <div class="slider-row">
308 + <span class="slider-label">Rango: {sliderMin}% - {sliderMax}%</span>
309 + <span class="slider-info">{filteredCount} entidades . {((filteredTotal / totalDevengado) * 100).toFixed(1)}% del ingreso</span>
310 + </div>
311 + <div class="range-slider">
312 + <input type="range" min="0" max="100" step="1" bind:value={sliderMin} oninput={() => { if (sliderMin >= sliderMax) sliderMin = sliderMax - 1; buildTreemap(); }} />
313 + <input type="range" min="0" max="100" step="1" bind:value={sliderMax} oninput={() => { if (sliderMax <= sliderMin) sliderMax = sliderMin + 1; buildTreemap(); }} />
314 + </div>
315 + {/if}
316 + </header>
317 +
318 + <!-- Treemap -->
319 + <div class="treemap-wrapper" bind:this={treemapContainer}>
320 + {#if loading}
321 + <div class="loading"><Spinner size={48} color="var(--theme-texto)" /></div>
322 + {:else if treemapNodes.length > 0}
323 + <svg width={treemapWidth} height={treemapHeight}>
324 + <!-- Group labels (subareas) -->
325 + {#each treemapGroups as group}
326 + {@const gw = group.x1 - group.x0}
327 + {@const color = getSubareaColor(group.data.name)}
328 + {@const label = gw > 150 ? group.data.name : group.data.name.slice(0, Math.floor(gw / 7))}
329 + {#if gw > 50}
330 + <rect
331 + x={group.x0 + 3}
332 + y={group.y0 + 2}
333 + width={Math.min(label.length * 6.5 + 10, gw - 6)}
334 + height="14"
335 + rx="3"
336 + fill={isDark ? 'rgba(20, 20, 18, 0.85)' : 'rgba(255, 255, 255, 0.9)'}
337 + />
338 + <text
339 + x={group.x0 + 8}
340 + y={group.y0 + 13}
341 + fill={isDark ? '#F5F0E8' : '#1a1a1a'}
342 + font-size="9"
343 + font-family="'Qanelas', var(--font-sans)"
344 + font-weight="600"
345 + >
346 + {label}
347 + </text>
348 + {/if}
349 + {/each}
350 +
351 + <!-- Entity nodes -->
352 + {#each treemapNodes as node, i}
353 + {@const w = node.x1 - node.x0}
354 + {@const h = node.y1 - node.y0}
355 + {@const pct = (node.value / totalDevengado) * 100}
356 + {@const isHovered = hoveredNode === node}
357 + {@const area = Math.sqrt(w * h)}
358 + {@const pctSize = Math.min(20, Math.max(9, area / 8))}
359 + {@const nameSize = Math.min(12, Math.max(7, area / 12))}
360 + {@const color = getSubareaColor(node.data.subarea)}
361 +
362 + <g
363 + transform="translate({node.x0}, {node.y0})"
364 + onmouseenter={() => hoveredNode = node}
365 + onmouseleave={() => hoveredNode = null}
366 + style="cursor: pointer;"
367 + onclick={() => goto(`/rubro/${codigo}?entidad=${node.data.id}`)}
368 + >
369 + <rect
370 + width={w} height={h}
371 + fill={color}
372 + opacity={hoveredNode ? (isHovered ? 1 : hoveredNode.data.subarea === node.data.subarea ? 0.6 : 0.2) : 0.85}
373 + stroke={isHovered ? 'var(--theme-titulo)' : 'rgba(0,0,0,0.15)'}
374 + stroke-width={isHovered ? 2 : 0.5}
375 + rx="2"
376 + />
377 + {#if w > 35 && h > 22}
378 + {@const textColor = getTextColorForBg(color)}
379 + {@const textColorSec = getTextColorSecondary(color)}
380 + <foreignObject x="4" y="3" width={w - 8} height={h - 6}>
381 + <div class="node-text">
382 + <span class="node-pct" style="font-size:{pctSize}px; color:{textColor}">{pct >= 1 ? pct.toFixed(0) : pct.toFixed(1)}%</span>
383 + {#if h > 40 && w > 55}
384 + <span class="node-name" style="font-size:{nameSize}px; color:{textColorSec}">{w > 100 ? node.data.name : node.data.name.slice(0, Math.floor(w / 7))}</span>
385 + {/if}
386 + </div>
387 + </foreignObject>
388 + {/if}
389 + </g>
390 + {/each}
391 + </svg>
392 +
393 + {#if hoveredNode}
394 + {@const pct = (hoveredNode.value / totalDevengado) * 100}
395 + <div class="tooltip" style="left:{Math.min(hoveredNode.x0 + 10, treemapWidth - 250)}px; top:{Math.min(hoveredNode.y0 + 10, treemapHeight - 90)}px;">
396 + <span class="tooltip-rank">#{treemapNodes.indexOf(hoveredNode) + 1} de {treemapNodes.length}</span>
397 + <span class="tooltip-subarea" style="color: {getSubareaColor(hoveredNode.data.subarea)}">{hoveredNode.data.subarea}</span>
398 + <span class="tooltip-name">{hoveredNode.data.name}</span>
399 + <span class="tooltip-val">{formatMonto(hoveredNode.value)} Bs . {pct.toFixed(1)}%</span>
400 + </div>
401 + {/if}
402 + {:else}
403 + <div class="loading">No hay datos para {selectedYear}</div>
404 + {/if}
405 + </div>
406 +
407 + </div>
408 +</div>
409 +
410 +<style>
411 + .page {
412 + min-height: 100vh;
413 + width: 100%;
414 + max-width: 100%;
415 + overflow-x: hidden;
416 + background: var(--theme-body);
417 + color: var(--theme-titulo);
418 + font-family: var(--font-sans), -apple-system, sans-serif;
419 + opacity: 0;
420 + transition: opacity 0.4s;
421 + }
422 + .page.mounted { opacity: 1; }
423 +
424 + :global(html:not(.dark)) .page { background: #f5f5f7; }
425 +
426 + .layout {
427 + max-width: 1400px;
428 + margin: 0 auto;
429 + padding: 80px 2rem 2rem;
430 + height: 100vh;
431 + display: flex;
432 + flex-direction: column;
433 + overflow: hidden;
434 + }
435 +
436 + /* Header */
437 + .header { flex-shrink: 0; margin-bottom: 1rem; }
438 +
439 + .header-top {
440 + display: flex;
441 + justify-content: space-between;
442 + align-items: center;
443 + margin-bottom: 0.375rem;
444 + }
445 +
446 + .pills { display: flex; gap: 0.375rem; }
447 +
448 + .pill {
449 + font-family: 'DM Mono', monospace;
450 + font-size: 0.625rem;
451 + padding: 0.125rem 0.5rem;
452 + border-radius: 4px;
453 + letter-spacing: 0.03em;
454 + }
455 + .pill-nivel { background: rgba(201, 167, 81, 0.15); color: var(--theme-accent); }
456 + .pill-codigo { background: var(--theme-borde); color: var(--theme-texto); }
457 +
458 + .header-actions { display: flex; align-items: center; gap: 0.75rem; }
459 +
460 + .copy-link-btn {
461 + display: flex;
462 + align-items: center;
463 + gap: 0.375rem;
464 + font-size: 0.75rem;
465 + color: var(--theme-texto);
466 + opacity: 0.6;
467 + background: none;
468 + border: none;
469 + padding: 0;
470 + cursor: pointer;
471 + transition: opacity 0.15s;
472 + }
473 + .copy-link-btn:hover { opacity: 1; }
474 +
475 + .title {
476 + font-family: 'DM Serif Display', serif;
477 + font-size: 2rem;
478 + font-weight: 400;
479 + margin: 0 0 0.25rem;
480 + line-height: 1.15;
481 + }
482 +
483 + .subtitle {
484 + font-size: 0.8125rem;
485 + color: var(--theme-texto);
486 + opacity: 0.6;
487 + margin: 0 0 0.75rem;
488 + }
489 +
490 + .meta-row {
491 + display: flex;
492 + align-items: center;
493 + justify-content: space-between;
494 + gap: 1rem;
495 + }
496 +
497 + .year-selector { display: flex; gap: 0.25rem; overflow-x: auto; scrollbar-width: none; }
498 + .year-selector::-webkit-scrollbar { display: none; }
499 +
500 + .year-btn {
501 + font-family: 'DM Mono', monospace;
502 + font-size: 0.6875rem;
503 + padding: 0.25rem 0.625rem;
504 + border: 1px solid var(--theme-borde);
505 + border-radius: 6px;
506 + background: transparent;
507 + color: var(--theme-texto);
508 + cursor: pointer;
509 + transition: all 0.15s;
510 + }
511 + .year-btn:hover { border-color: var(--theme-accent); color: var(--theme-titulo); }
512 + .year-btn.active {
513 + background: var(--theme-accent);
514 + border-color: var(--theme-accent);
515 + color: white;
516 + font-weight: 600;
517 + }
518 +
519 + .stats {
520 + display: flex;
521 + align-items: center;
522 + gap: 0.375rem;
523 + font-size: 0.75rem;
524 + color: var(--theme-texto);
525 + opacity: 0.7;
526 + }
527 + .stats strong { color: var(--theme-titulo); font-weight: 600; }
528 +
529 + /* Slider */
530 + .slider-row {
531 + display: flex;
532 + justify-content: space-between;
533 + align-items: center;
534 + margin-top: 0.5rem;
535 + }
536 +
537 + .slider-label {
538 + font-family: 'DM Mono', monospace;
539 + font-size: 0.6875rem;
540 + color: var(--theme-titulo);
541 + font-weight: 600;
542 + }
543 +
544 + .slider-info {
545 + font-size: 0.6875rem;
546 + color: var(--theme-texto);
547 + opacity: 0.6;
548 + }
549 +
550 + .range-slider {
551 + position: relative;
552 + height: 20px;
553 + margin-bottom: 0.5rem;
554 + }
555 +
556 + .range-slider input[type="range"] {
557 + position: absolute;
558 + width: 100%;
559 + height: 4px;
560 + top: 8px;
561 + pointer-events: none;
562 + -webkit-appearance: none;
563 + appearance: none;
564 + background: transparent;
565 + }
566 +
567 + .range-slider input[type="range"]:first-child {
568 + background: var(--theme-borde);
569 + border-radius: 2px;
570 + }
571 +
572 + .range-slider input[type="range"]::-webkit-slider-thumb {
573 + -webkit-appearance: none;
574 + appearance: none;
575 + width: 14px;
576 + height: 14px;
577 + border-radius: 50%;
578 + background: var(--theme-accent);
579 + border: 2px solid var(--theme-surface);
580 + cursor: pointer;
581 + pointer-events: all;
582 + box-shadow: 0 1px 3px rgba(0,0,0,0.2);
583 + }
584 +
585 + .range-slider input[type="range"]::-moz-range-thumb {
586 + width: 14px;
587 + height: 14px;
588 + border-radius: 50%;
589 + background: var(--theme-accent);
590 + border: 2px solid var(--theme-surface);
591 + cursor: pointer;
592 + pointer-events: all;
593 + box-shadow: 0 1px 3px rgba(0,0,0,0.2);
594 + }
595 +
596 + /* Treemap */
597 + .treemap-wrapper {
598 + flex: 1;
599 + min-height: 200px;
600 + position: relative;
601 + background: var(--theme-surface);
602 + border-radius: 12px;
603 + overflow: hidden;
604 + }
605 +
606 + .treemap-wrapper svg { display: block; width: 100%; height: 100%; }
607 +
608 + .loading {
609 + position: absolute;
610 + inset: 0;
611 + display: flex;
612 + align-items: center;
613 + justify-content: center;
614 + color: var(--theme-texto);
615 + opacity: 0.5;
616 + font-size: 0.875rem;
617 + }
618 +
619 + .node-text {
620 + display: flex;
621 + flex-direction: column;
622 + overflow: hidden;
623 + }
624 +
625 + .node-pct { font-weight: 700; line-height: 1; }
626 + .node-name { line-height: 1.2; margin-top: 1px; }
627 +
628 + /* Tooltip */
629 + .tooltip {
630 + position: absolute;
631 + pointer-events: none;
632 + padding: 0.5rem 0.75rem;
633 + border-radius: 8px;
634 + z-index: 10;
635 + max-width: 250px;
636 + background: rgba(30, 30, 30, 0.9);
637 + backdrop-filter: blur(12px);
638 + border: 1px solid rgba(255, 255, 255, 0.1);
639 + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
640 + color: #f5f5f5;
641 + display: flex;
642 + flex-direction: column;
643 + gap: 2px;
644 + }
645 +
646 + :global(html:not(.dark)) .tooltip {
647 + background: rgba(255, 255, 255, 0.9);
648 + border-color: rgba(0, 0, 0, 0.1);
649 + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
650 + color: #1a1a1a;
651 + }
652 +
653 + .tooltip-rank { font-size: 0.6rem; opacity: 0.5; text-transform: uppercase; letter-spacing: 0.05em; }
654 + .tooltip-subarea { font-size: 0.6875rem; font-weight: 600; }
655 + .tooltip-name { font-size: 0.8125rem; font-weight: 600; }
656 + .tooltip-val { font-family: 'DM Mono', monospace; font-size: 0.75rem; opacity: 0.7; }
657 +
658 + /* Responsive */
659 + @media (max-width: 900px) {
660 + .layout {
661 + padding: 70px 1rem 1rem;
662 + height: auto;
663 + min-height: 100vh;
664 + overflow: visible;
665 + }
666 +
667 + .treemap-wrapper {
668 + min-height: 350px;
669 + height: 50vh;
670 + flex: none;
671 + }
672 + }
673 +
674 + @media (max-width: 640px) {
675 + .layout { padding: 60px 0.75rem 1rem; }
676 + .title { font-size: 1.375rem; }
677 + .subtitle { font-size: 0.75rem; }
678 + .meta-row { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
679 + .header-top { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
680 +
681 + .year-selector {
682 + flex-wrap: wrap;
683 + gap: 0.25rem;
684 + }
685 +
686 + .year-btn {
687 + font-size: 0.625rem;
688 + padding: 0.2rem 0.5rem;
689 + }
690 +
691 + .slider-row {
692 + flex-direction: column;
693 + align-items: flex-start;
694 + gap: 0.125rem;
695 + }
696 +
697 + .stats { font-size: 0.6875rem; }
698 +
699 + .treemap-wrapper {
700 + min-height: 300px;
701 + height: 45vh;
702 + }
703 +
704 + .tooltip {
705 + max-width: 200px;
706 + font-size: 0.75rem;
707 + }
708 +
709 + .header-actions {
710 + width: 100%;
711 + justify-content: space-between;
712 + }
713 + }
714 +
715 + @media (max-width: 380px) {
716 + .layout { padding: 55px 0.5rem 0.75rem; }
717 + .title { font-size: 1.125rem; }
718 +
719 + .treemap-wrapper {
720 + min-height: 250px;
721 + height: 40vh;
722 + }
723 +
724 + .year-btn {
725 + font-size: 0.5625rem;
726 + padding: 0.15rem 0.375rem;
727 + }
728 + }
729 +</style>
...@@ -44,7 +44,7 @@ ...@@ -44,7 +44,7 @@
44 44
45 const CLAS_SEARCH_MAP = { 45 const CLAS_SEARCH_MAP = {
46 objeto: { class_: 'objeto', codigoFn: (meta) => meta.objeto_subpartida || meta.objeto_partida || meta.objeto_subgrupo || meta.objeto_grupo || '' }, 46 objeto: { class_: 'objeto', codigoFn: (meta) => meta.objeto_subpartida || meta.objeto_partida || meta.objeto_subgrupo || meta.objeto_grupo || '' },
47 - finfun: { class_: 'finfun', codigoFn: (meta) => { 47 + finfun: { class_: 'finalidad', codigoFn: (meta) => {
48 const fin = String(meta.finfun_finalidad || ''); 48 const fin = String(meta.finfun_finalidad || '');
49 if (meta.finfun_funcion !== undefined && meta.finfun_grpfuncion !== undefined) return `${fin}.${meta.finfun_grpfuncion}.${meta.finfun_funcion}`; 49 if (meta.finfun_funcion !== undefined && meta.finfun_grpfuncion !== undefined) return `${fin}.${meta.finfun_grpfuncion}.${meta.finfun_funcion}`;
50 if (meta.finfun_grpfuncion !== undefined) return `${fin}.${meta.finfun_grpfuncion}`; 50 if (meta.finfun_grpfuncion !== undefined) return `${fin}.${meta.finfun_grpfuncion}`;
...@@ -123,13 +123,14 @@ ...@@ -123,13 +123,14 @@
123 resumen = clasUbigeoCache[key]; 123 resumen = clasUbigeoCache[key];
124 return; 124 return;
125 } 125 }
126 +
126 const params = new URLSearchParams({ codigo }); 127 const params = new URLSearchParams({ codigo });
127 if (gestion) params.set('gestion', gestion); 128 if (gestion) params.set('gestion', gestion);
128 try { 129 try {
129 const res = await fetch(`${apiEndpoint}?${params}`); 130 const res = await fetch(`${apiEndpoint}?${params}`);
130 const data = await res.json(); 131 const data = await res.json();
131 const mapped = (data || []) 132 const mapped = (data || [])
132 - .filter(d => d.ubigeo !== '0.0.0' && !/multimunicipal/i.test(d.desc_ubigeo)) 133 + .filter(d => d.ubigeo !== '0.0.0' && !d.ubigeo.endsWith('.0') && !/multimunicipal/i.test(d.desc_ubigeo) && !/multiprovincial/i.test(d.desc_ubigeo) && !/desconocido/i.test(d.desc_ubigeo))
133 .map(d => ({ 134 .map(d => ({
134 codigo: d.ubigeo, 135 codigo: d.ubigeo,
135 desc: d.desc_ubigeo, 136 desc: d.desc_ubigeo,
...@@ -403,6 +404,18 @@ ...@@ -403,6 +404,18 @@
403 const data = await res.json(); 404 const data = await res.json();
404 if (data?.desc_objeto) return data.desc_objeto; 405 if (data?.desc_objeto) return data.desc_objeto;
405 } 406 }
407 + } else if (clas === 'finfun') {
408 + const res = await fetch(`/api/finfun-data?codigo=${codigo}&tipo=clasificador`);
409 + if (res.ok) {
410 + const data = await res.json();
411 + if (data?.desc_finfun) return data.desc_finfun;
412 + }
413 + } else if (clas === 'acteco') {
414 + const res = await fetch(`/api/acteco-data?codigo=${codigo}&tipo=clasificador`);
415 + if (res.ok) {
416 + const data = await res.json();
417 + if (data?.desc_acteco) return data.desc_acteco;
418 + }
406 } 419 }
407 // Fallback: buscar en Typesense 420 // Fallback: buscar en Typesense
408 const cfg = CLAS_SEARCH_MAP[clas]; 421 const cfg = CLAS_SEARCH_MAP[clas];
...@@ -482,6 +495,7 @@ ...@@ -482,6 +495,7 @@
482 <title>Gasto por geografía | Presupuesto Público</title> 495 <title>Gasto por geografía | Presupuesto Público</title>
483 </svelte:head> 496 </svelte:head>
484 497
498 +{#if mapaData}
485 <div class="dashboard"> 499 <div class="dashboard">
486 <div class="mapa-fullscreen"> 500 <div class="mapa-fullscreen">
487 <div class="mapa-top-controls"> 501 <div class="mapa-top-controls">
...@@ -496,6 +510,11 @@ ...@@ -496,6 +510,11 @@
496 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg> 510 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
497 Volver a {clasSeleccionado.nombre} 511 Volver a {clasSeleccionado.nombre}
498 </a> 512 </a>
513 + {:else if clasSeleccionado && clasificadorSeleccionado === 'acteco'}
514 + <a href="/acteco/{clasSeleccionado.codigo}" class="back-link-mapa">
515 + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
516 + Volver a {clasSeleccionado.nombre}
517 + </a>
499 {:else} 518 {:else}
500 <span class="mapa-location-label">Ubicación geográfica</span> 519 <span class="mapa-location-label">Ubicación geográfica</span>
501 {/if} 520 {/if}
...@@ -698,7 +717,7 @@ ...@@ -698,7 +717,7 @@
698 <span class="tooltip-rank">#{pos}/{allBarras.length}</span> 717 <span class="tooltip-rank">#{pos}/{allBarras.length}</span>
699 {/if} 718 {/if}
700 </div> 719 </div>
701 - <span class="mapa-muni-count">{barrasRankeadas.length} municipios</span> 720 + <span class="mapa-muni-count">{barrasRankeadas.length} municipios/TIOCs</span>
702 </div> 721 </div>
703 </div> 722 </div>
704 723
...@@ -721,7 +740,7 @@ ...@@ -721,7 +740,7 @@
721 <!-- Drawer lateral de ranking --> 740 <!-- Drawer lateral de ranking -->
722 <div class="ranking-drawer" class:open={drawerOpen}> 741 <div class="ranking-drawer" class:open={drawerOpen}>
723 <div class="drawer-header"> 742 <div class="drawer-header">
724 - <span class="drawer-titulo">{barrasRankeadas.length} municipios</span> 743 + <span class="drawer-titulo">{barrasRankeadas.length} municipios/TIOCs</span>
725 <button class="drawer-close" onclick={() => { drawerOpen = false; }}> 744 <button class="drawer-close" onclick={() => { drawerOpen = false; }}>
726 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 745 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
727 <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/> 746 <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
...@@ -769,8 +788,33 @@ ...@@ -769,8 +788,33 @@
769 </div> 788 </div>
770 </div> 789 </div>
771 </div> 790 </div>
791 +{:else}
792 +<div class="mapa-loading">
793 + <div class="mapa-loading-inner">
794 + <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="animation: spin 1.5s linear infinite; opacity: 0.4;">
795 + <path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>
796 + </svg>
797 + <span style="font-family: var(--font-sans); font-size: 13px; color: var(--theme-texto); opacity: 0.5;">Cargando mapa...</span>
798 + </div>
799 +</div>
800 +{/if}
772 801
773 <style> 802 <style>
803 + .mapa-loading {
804 + height: 100vh;
805 + display: flex;
806 + align-items: center;
807 + justify-content: center;
808 + background: var(--theme-body);
809 + }
810 + .mapa-loading-inner {
811 + display: flex;
812 + flex-direction: column;
813 + align-items: center;
814 + gap: 12px;
815 + }
816 + @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
817 +
774 .dashboard { 818 .dashboard {
775 min-height: 100vh; 819 min-height: 100vh;
776 background: var(--theme-body); 820 background: var(--theme-body);
......
1 -import { supabase } from '$lib/supabase';
2 import { error } from '@sveltejs/kit'; 1 import { error } from '@sveltejs/kit';
3 2
4 export async function load({ params, fetch }) { 3 export async function load({ params, fetch }) {
5 - const codigo = params.ubigeo; 4 + const municipioUbigeo = params.ubigeo;
6 5
7 - // Cargar resumen y población en paralelo 6 + // Primero obtener el clasificador para mapear municipio_ubigeo → ubigeo con puntos
8 - const [resumenRes, pobRes] = await Promise.all([ 7 + const clasRes = await fetch('/api/ubigeo-data?codigo=0&tipo=clasificador');
9 - supabase 8 + if (!clasRes.ok) throw error(500, 'Error cargando clasificador');
10 - .schema('ppto') 9 + const clasificador = await clasRes.json();
11 - .from('entidad_resumen')
12 - .select('tipo, tipo_codigo, codigo, desc, desc_padre, gestion, devengado, ranking')
13 - .eq('codigo', codigo),
14 10
11 + const match = clasificador.find(c => String(c.municipio_ubigeo) === municipioUbigeo);
12 + if (!match) throw error(404, 'Ubicación no encontrada');
13 +
14 + const ubigeoCode = match.ubigeo; // formato "2.7.7"
15 +
16 + // Cargar detalle del ubigeo + población en paralelo
17 + const [ubigeoRes, pobRes] = await Promise.all([
18 + fetch(`/api/ubigeo-data?codigo=${ubigeoCode}&tipo=detalle`).then(r => r.ok ? r.json() : null),
15 fetch('/poblacion.csv').then(r => r.text()) 19 fetch('/poblacion.csv').then(r => r.text())
16 ]); 20 ]);
17 21
18 - if (resumenRes.error || !resumenRes.data?.length) { 22 + if (!ubigeoRes) throw error(404, 'Ubicación no encontrada');
19 - throw error(404, 'Ubicación no encontrada');
20 - }
21 23
22 - // Parsear población para este código (codigo_ine) 24 + // Población
23 const poblacionMap = {}; 25 const poblacionMap = {};
24 pobRes.split('\n').slice(1).forEach(line => { 26 pobRes.split('\n').slice(1).forEach(line => {
25 const [cod, , gestion, pob] = line.split(','); 27 const [cod, , gestion, pob] = line.split(',');
26 - if (cod === codigo) { 28 + if (cod === municipioUbigeo) {
27 poblacionMap[parseInt(gestion)] = parseInt(pob); 29 poblacionMap[parseInt(gestion)] = parseInt(pob);
28 } 30 }
29 }); 31 });
30 32
31 - // Determinar última gestión 33 + // gastos_ingresos ��� resumenData
32 - const gestiones = [...new Set(resumenRes.data.map(d => d.gestion))].sort((a, b) => b - a); 34 + const gastosIngresos = ubigeoRes.gastos_ingresos || [];
33 - const ultimaGestion = gestiones[0] || 2025; 35 + const resumenData = gastosIngresos.map(d => ({
36 + tipo: d.tipo,
37 + gestion: d.gestion,
38 + devengado: d.devengado,
39 + ranking: d.ranking,
40 + codigo: municipioUbigeo,
41 + desc: ubigeoRes.desc_ubigeo
42 + }));
34 43
35 - // Cargar distribuciones de la última gestión 44 + // Última gestión
36 - const distRes = await supabase 45 + const gestiones = [...new Set(gastosIngresos.map(d => d.gestion))].filter(g => g <= 2025).sort((a, b) => b - a);
37 - .schema('ppto') 46 + const ultimaGestion = gestiones[0] || 2025;
38 - .from('entidad_distribuciones')
39 - .select('tipo, dimension, gestion, padre, desc_padre, hijo, desc_hijo, devengado')
40 - .eq('codigo', codigo)
41 - .eq('gestion', ultimaGestion);
42 47
43 - const firstRow = resumenRes.data[0]; 48 + // Distribuciones de la última gestión
49 + const distEndpoints = [
50 + { api: 'objetos', dim: 'objeto' },
51 + { api: 'finfuns', dim: 'finfun' },
52 + { api: 'actecos', dim: 'acteco' }
53 + ];
54 + const distResults = await Promise.all(
55 + distEndpoints.map(async ({ api, dim }) => {
56 + try {
57 + const res = await fetch(`/api/ubigeo-data?codigo=${ubigeoCode}&tipo=${api}`);
58 + if (!res.ok) return [];
59 + const data = await res.json();
60 + if (!Array.isArray(data)) return [];
61 + return data
62 + .filter(d => d.gestion === ultimaGestion)
63 + .map(d => ({
64 + tipo: d.tipo || 'gastos',
65 + dimension: dim,
66 + gestion: d.gestion,
67 + padre: d.padre,
68 + desc_padre: d.desc_padre,
69 + hijo: d.hijo,
70 + desc_hijo: d.desc_hijo,
71 + devengado: d.devengado
72 + }));
73 + } catch { return []; }
74 + })
75 + );
44 76
45 return { 77 return {
46 - nombre: firstRow.desc || `Ubicación ${codigo}`, 78 + nombre: ubigeoRes.desc_ubigeo || `Ubicación ${municipioUbigeo}`,
47 - nombrePadre: firstRow.desc_padre || null, 79 + nombrePadre: ubigeoRes.desc_departamento || null,
48 - codigo, 80 + codigo: municipioUbigeo,
49 - resumenData: resumenRes.data || [], 81 + ubigeoCode,
50 - distribucionesData: distRes.data || [], 82 + resumenData,
83 + distribucionesData: distResults.flat(),
51 gestionInicial: ultimaGestion, 84 gestionInicial: ultimaGestion,
52 poblacionMap 85 poblacionMap
53 }; 86 };
......
...@@ -3,7 +3,6 @@ ...@@ -3,7 +3,6 @@
3 import { page } from '$app/stores'; 3 import { page } from '$app/stores';
4 import { get } from 'svelte/store'; 4 import { get } from 'svelte/store';
5 import * as d3 from 'd3'; 5 import * as d3 from 'd3';
6 - import { supabase } from '$lib/supabase';
7 import { mapaCache } from '$lib/stores/mapaCache'; 6 import { mapaCache } from '$lib/stores/mapaCache';
8 7
9 let { data } = $props(); 8 let { data } = $props();
...@@ -107,15 +106,41 @@ ...@@ -107,15 +106,41 @@
107 return; 106 return;
108 } 107 }
109 cargandoDist = true; 108 cargandoDist = true;
110 - const { data: rows } = await supabase 109 + const ubigeoCode = data.ubigeoCode;
111 - .schema('ppto') 110 + const dims = [
112 - .from('entidad_distribuciones') 111 + { api: 'objetos', dim: 'objeto' },
113 - .select('tipo, dimension, gestion, padre, desc_padre, hijo, desc_hijo, devengado') 112 + { api: 'finfuns', dim: 'finfun' },
114 - .eq('codigo', codigoSeleccionado) 113 + { api: 'actecos', dim: 'acteco' }
115 - .eq('gestion', gestion); 114 + ];
116 - const result = rows || []; 115 + try {
116 + const results = await Promise.all(
117 + dims.map(async ({ api, dim }) => {
118 + try {
119 + const res = await fetch(`/api/ubigeo-data?codigo=${ubigeoCode}&tipo=${api}`);
120 + if (!res.ok) return [];
121 + const rows = await res.json();
122 + if (!Array.isArray(rows)) return [];
123 + return rows
124 + .filter(d => d.gestion === gestion)
125 + .map(d => ({
126 + tipo: d.tipo || 'gastos',
127 + dimension: dim,
128 + gestion: d.gestion,
129 + padre: d.padre,
130 + desc_padre: d.desc_padre,
131 + hijo: d.hijo,
132 + desc_hijo: d.desc_hijo,
133 + devengado: d.devengado
134 + }));
135 + } catch { return []; }
136 + })
137 + );
138 + const result = results.flat();
117 distCache[gestion] = result; 139 distCache[gestion] = result;
118 distribucionesData = result; 140 distribucionesData = result;
141 + } catch {
142 + distribucionesData = [];
143 + }
119 cargandoDist = false; 144 cargandoDist = false;
120 } 145 }
121 146
......
This diff could not be displayed because it is too large.