Rafael Lopez

ubigeo

...@@ -24,6 +24,10 @@ ...@@ -24,6 +24,10 @@
24 24
25 // Navegación por teclado 25 // Navegación por teclado
26 let selectedResultIndex = -1; 26 let selectedResultIndex = -1;
27 + let expandedGroups = [];
28 + let collapsedGroups = [];
29 + let scrollSentinel;
30 + let scrollObserver;
27 31
28 // Tracking para evitar re-animaciones 32 // Tracking para evitar re-animaciones
29 let seenHitIds = new Set(); 33 let seenHitIds = new Set();
...@@ -103,6 +107,20 @@ ...@@ -103,6 +107,20 @@
103 geografico: 'Ubicación geográfica', 107 geografico: 'Ubicación geográfica',
104 acteco: 'Sectores Económicos', 108 acteco: 'Sectores Económicos',
105 sectores: 'Sectores Económicos', 109 sectores: 'Sectores Económicos',
110 + otros: 'Programas y/o Proyectos',
111 + programa_proyecto: 'Programas y/o Proyectos',
112 + undefined: 'Programas y/o Proyectos',
113 + };
114 +
115 + const CLASS_EASY_LABELS = {
116 + entidad: 'Quién',
117 + objeto: 'En qué se gasta',
118 + finfun: 'Para qué se gasta',
119 + ubigeo: 'Dónde se gasta',
120 + acteco: 'En qué sectores',
121 + rubro: 'Con qué recursos',
122 + organismo: 'Quién financia',
123 + fuente: 'Origen del ingreso',
106 }; 124 };
107 125
108 // Extraer código de finfun desde metadatos (formato con puntos: 1, 1.1, 1.1.3) 126 // Extraer código de finfun desde metadatos (formato con puntos: 1, 1.1, 1.1.3)
...@@ -132,8 +150,8 @@ ...@@ -132,8 +150,8 @@
132 $: groupedHits = (() => { 150 $: groupedHits = (() => {
133 if (filteredHits.length === 0) return null; 151 if (filteredHits.length === 0) return null;
134 152
135 - // Búsqueda mixta: agrupar por tipo de clasificador 153 + // Búsqueda mixta o global: agrupar por tipo
136 - if (isMixedClassSearch) { 154 + if (isMixedClassSearch || searchMode === 'todo') {
137 // Filtrar duplicados de finfun 155 // Filtrar duplicados de finfun
138 const filtered = filteredHits.filter(hit => { 156 const filtered = filteredHits.filter(hit => {
139 if (hit.document.class_ !== 'finfun') return true; 157 if (hit.document.class_ !== 'finfun') return true;
...@@ -160,12 +178,9 @@ ...@@ -160,12 +178,9 @@
160 }); 178 });
161 }); 179 });
162 180
181 + // Menos hits primero (clasificadores arriba, programas abajo)
163 const sortedGroups = Object.values(groups); 182 const sortedGroups = Object.values(groups);
164 - if (sortBy === 'alpha') { 183 + sortedGroups.sort((a, b) => a.hits.length - b.hits.length);
165 - sortedGroups.sort((a, b) => dir * a.name.localeCompare(b.name));
166 - } else {
167 - sortedGroups.sort((a, b) => dir * (b.totalMonto - a.totalMonto));
168 - }
169 return sortedGroups; 184 return sortedGroups;
170 } 185 }
171 186
...@@ -287,9 +302,9 @@ ...@@ -287,9 +302,9 @@
287 const CLASIFICADORES = [ 302 const CLASIFICADORES = [
288 { id: 'entidad', label: '¿Quién gasta?', icon: '🏛️', tecnico: 'Clasificador institucional', desc: 'Ministerios, municipios, universidades, empresas públicas y todas las entidades del sector público que ejecutan presupuesto.' }, 303 { id: 'entidad', label: '¿Quién gasta?', icon: '🏛️', tecnico: 'Clasificador institucional', desc: 'Ministerios, municipios, universidades, empresas públicas y todas las entidades del sector público que ejecutan presupuesto.' },
289 { id: 'objeto', label: '¿En qué se gasta?', icon: '📦', tecnico: 'Objeto de gasto', desc: 'Categorías estandarizadas del destino del gasto: sueldos, viáticos, materiales, servicios básicos, inversiones, deuda, etc.' }, 304 { id: 'objeto', label: '¿En qué se gasta?', icon: '📦', tecnico: 'Objeto de gasto', desc: 'Categorías estandarizadas del destino del gasto: sueldos, viáticos, materiales, servicios básicos, inversiones, deuda, etc.' },
290 - { 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.' },
291 { id: 'geografico', label: '¿Dónde se gasta?', icon: '📍', tecnico: 'Geográfico', desc: 'Distribución territorial del gasto por departamento y municipio.' }, 305 { id: 'geografico', label: '¿Dónde se gasta?', icon: '📍', tecnico: 'Geográfico', desc: 'Distribución territorial del gasto por departamento y municipio.' },
292 { id: 'sectores', label: '¿En qué sectores se gasta?', icon: '🏢', tecnico: 'Sectores económicos', desc: 'Sectores de actividad económica donde se dirige el gasto: agricultura, minería, manufactura, transporte, etc.' }, 306 { id: 'sectores', label: '¿En qué sectores se gasta?', icon: '🏢', tecnico: 'Sectores económicos', desc: 'Sectores de actividad económica donde se dirige el gasto: agricultura, minería, manufactura, transporte, etc.' },
307 + { 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.' },
293 { 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.' }, 308 { 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.' },
294 { id: 'organismo', label: '¿Quién financia?', icon: '🏦', tecnico: 'Organismos financiadores', desc: 'Origen institucional del financiamiento: Tesoro General, gobiernos subnacionales, organismos internacionales, etc.' }, 309 { id: 'organismo', label: '¿Quién financia?', icon: '🏦', tecnico: 'Organismos financiadores', desc: 'Origen institucional del financiamiento: Tesoro General, gobiernos subnacionales, organismos internacionales, etc.' },
295 { 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.' }, 310 { 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.' },
...@@ -369,15 +384,15 @@ ...@@ -369,15 +384,15 @@
369 if (mode === 'programas') { 384 if (mode === 'programas') {
370 return 'Buscar en programas y proyectos...'; 385 return 'Buscar en programas y proyectos...';
371 } 386 }
372 - if (classifiers.length === 0) { 387 + if (mode === 'clasificadores' && classifiers.length > 0) {
373 - return 'Selecciona al menos un clasificador...';
374 - }
375 const nombres = classifiers.map(id => { 388 const nombres = classifiers.map(id => {
376 const clf = CLASIFICADORES.find(c => c.id === id); 389 const clf = CLASIFICADORES.find(c => c.id === id);
377 return clf?.label?.toLowerCase() || id; 390 return clf?.label?.toLowerCase() || id;
378 }).join(', '); 391 }).join(', ');
379 return `Buscar en ${nombres}...`; 392 return `Buscar en ${nombres}...`;
380 } 393 }
394 + return 'Sueldos, Hospital La Paz, Ministerio de Salud...';
395 + }
381 396
382 // Debounce 397 // Debounce
383 let debounceTimer; 398 let debounceTimer;
...@@ -411,9 +426,10 @@ ...@@ -411,9 +426,10 @@
411 ]; 426 ];
412 427
413 const EXAMPLES_CLASIFICADORES = [ 428 const EXAMPLES_CLASIFICADORES = [
414 - { label: 'YPFB', classifier: 'entidad' }, 429 + { label: 'YPFB', href: '/entidad/513' },
415 - { label: 'viáticos', classifier: 'objeto' }, 430 + { label: 'Sueldos', href: '/objeto/11700' },
416 - { label: 'Ministerio de Salud', classifier: 'entidad' }, 431 + { label: 'Uyuni', href: '/ubicacion/51201' },
432 + { label: 'Educación', href: '/finfun/9' },
417 ]; 433 ];
418 434
419 // Función para buscar desde pills 435 // Función para buscar desde pills
...@@ -585,15 +601,16 @@ ...@@ -585,15 +601,16 @@
585 const params = new URLSearchParams({ 601 const params = new URLSearchParams({
586 q: query, 602 q: query,
587 mode: 'gastos', 603 mode: 'gastos',
588 - page: page.toString() 604 + page: page.toString(),
605 + per_page: '30'
589 }); 606 });
590 607
591 // Mapeo de ids internos a class_ de la API 608 // Mapeo de ids internos a class_ de la API
592 - const CLASS_API_MAP = { geografico: 'ubigeo', sectores: 'acteco' }; 609 + const CLASS_API_MAP = { geografico: 'ubigeo', sectores: 'acteco', finfun: 'finalidad' };
593 610
594 if (isClassSearch) { 611 if (isClassSearch) {
595 - params.set('is_class', 'true');
596 params.set('per_page', '250'); 612 params.set('per_page', '250');
613 + params.set('is_class', 'true');
597 params.set('class_', selectedClassifiers.map(c => CLASS_API_MAP[c] || c).join(',')); 614 params.set('class_', selectedClassifiers.map(c => CLASS_API_MAP[c] || c).join(','));
598 } else if (isProgramSearch) { 615 } else if (isProgramSearch) {
599 params.set('is_class', 'false'); 616 params.set('is_class', 'false');
...@@ -609,6 +626,8 @@ ...@@ -609,6 +626,8 @@
609 results = data; 626 results = data;
610 if (page === 1) { 627 if (page === 1) {
611 resetSeenHits(); 628 resetSeenHits();
629 + expandedGroups = [];
630 + collapsedGroups = [];
612 allHits = data.hits; 631 allHits = data.hits;
613 } else { 632 } else {
614 allHits = [...allHits, ...data.hits]; 633 allHits = [...allHits, ...data.hits];
...@@ -627,6 +646,7 @@ ...@@ -627,6 +646,7 @@
627 if (hasMoreResults && !isLoadingMore) performSearch(searchVal, currentPage + 1); 646 if (hasMoreResults && !isLoadingMore) performSearch(searchVal, currentPage + 1);
628 } 647 }
629 648
649 +
630 function handleGlobalKeydown(e) { 650 function handleGlobalKeydown(e) {
631 if (e.key === 'Escape' && (searchVal || allHits.length > 0)) { 651 if (e.key === 'Escape' && (searchVal || allHits.length > 0)) {
632 clearSearch(); 652 clearSearch();
...@@ -803,8 +823,8 @@ ...@@ -803,8 +823,8 @@
803 } 823 }
804 824
805 $: stats = filteredHits.length > 0 ? getStats( 825 $: stats = filteredHits.length > 0 ? getStats(
806 - (isFinfunSearchActive || isMixedClassSearch) ? flatHitsForNav : filteredHits, 826 + (isFinfunSearchActive || isMixedClassSearch || searchMode === 'todo') ? flatHitsForNav : filteredHits,
807 - (isFinfunSearchActive || isMixedClassSearch) ? flatHitsForNav.length : filteredHits.length 827 + results?.found || filteredHits.length
808 ) : null; 828 ) : null;
809 829
810 // Texto contextual para resultados 830 // Texto contextual para resultados
...@@ -1035,6 +1055,9 @@ ...@@ -1035,6 +1055,9 @@
1035 </div> 1055 </div>
1036 <div class="mode-card-pills"> 1056 <div class="mode-card-pills">
1037 {#each EXAMPLES_CLASIFICADORES as ex} 1057 {#each EXAMPLES_CLASIFICADORES as ex}
1058 + {#if ex.href}
1059 + <a href={ex.href} class="mode-pill">{ex.label}</a>
1060 + {:else}
1038 <span 1061 <span
1039 class="mode-pill" 1062 class="mode-pill"
1040 on:click|stopPropagation={() => searchFromPill(ex.label, ex.classifier)} 1063 on:click|stopPropagation={() => searchFromPill(ex.label, ex.classifier)}
...@@ -1044,6 +1067,7 @@ ...@@ -1044,6 +1067,7 @@
1044 > 1067 >
1045 {ex.label} 1068 {ex.label}
1046 </span> 1069 </span>
1070 + {/if}
1047 {/each} 1071 {/each}
1048 </div> 1072 </div>
1049 <div class="mode-card-radio"> 1073 <div class="mode-card-radio">
...@@ -1161,14 +1185,26 @@ ...@@ -1161,14 +1185,26 @@
1161 {#if groupedHits} 1185 {#if groupedHits}
1162 <!-- Vista agrupada para entidades u objetos --> 1186 <!-- Vista agrupada para entidades u objetos -->
1163 {#each groupedHits as group, gi} 1187 {#each groupedHits as group, gi}
1188 + {@const collapsible = isMixedClassSearch && groupedHits.length > 1}
1189 + {@const isCollapsed = collapsedGroups.includes(gi)}
1190 + {@const maxVisible = isCollapsed ? 0 : group.hits.length}
1164 <div class="result-group"> 1191 <div class="result-group">
1165 - <div class="result-group-header"> 1192 + <button class="result-group-header" on:click={() => {
1166 - <span class="result-group-name">{group.name}</span> 1193 + if (isCollapsed) { collapsedGroups = collapsedGroups.filter(x => x !== gi); }
1194 + else { collapsedGroups = [...collapsedGroups, gi]; }
1195 + }}>
1196 + <svg class="group-chevron" class:collapsed={isCollapsed} width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1197 + <path d="M6 9l6 6 6-6"/>
1198 + </svg>
1199 + <span class="result-group-name">
1200 + {CLASS_EASY_LABELS[group.classType] || group.name}
1201 + <span class="result-group-tecnico">{CLASS_LABELS[group.classType] || ''}</span>
1202 + </span>
1167 <span class="result-group-count">{group.hits.length}</span> 1203 <span class="result-group-count">{group.hits.length}</span>
1168 <span class="result-group-monto">{formatMonto(group.totalMonto)}</span> 1204 <span class="result-group-monto">{formatMonto(group.totalMonto)}</span>
1169 - </div> 1205 + </button>
1170 <div class="result-group-items"> 1206 <div class="result-group-items">
1171 - {#each group.hits as hit, i (getHitKey(hit, gi * 1000 + i))} 1207 + {#each group.hits.slice(0, maxVisible) as hit, i (getHitKey(hit, gi * 1000 + i))}
1172 {@const meta = parseMetadatos(hit.document.metadatos)} 1208 {@const meta = parseMetadatos(hit.document.metadatos)}
1173 {@const globalIdx = flatHitsForNav.indexOf(hit)} 1209 {@const globalIdx = flatHitsForNav.indexOf(hit)}
1174 {@const hitClass = hit.document.class_} 1210 {@const hitClass = hit.document.class_}
...@@ -1251,6 +1287,28 @@ ...@@ -1251,6 +1287,28 @@
1251 </div> 1287 </div>
1252 </div> 1288 </div>
1253 </a> 1289 </a>
1290 + {:else}
1291 + <!-- Programa/proyecto -->
1292 + <div class="result-card result-card-grouped"
1293 + class:result-card-selected={selectedResultIndex === globalIdx}
1294 + data-result-index={globalIdx}>
1295 + <div class="result-content">
1296 + <div class="result-text">
1297 + {#if hit.highlights?.[0]?.snippet}
1298 + {@html hit.highlights[0].snippet}
1299 + {:else}
1300 + {hit.document.texto}
1301 + {/if}
1302 + </div>
1303 + <div class="result-meta">
1304 + <span class="result-monto">{formatMonto(hit.document.devengado)}</span>
1305 + {#if meta.entidad_desc_entidad}
1306 + <span class="result-sep">·</span>
1307 + <span class="result-entity">{meta.entidad_desc_entidad}</span>
1308 + {/if}
1309 + </div>
1310 + </div>
1311 + </div>
1254 {/if} 1312 {/if}
1255 {/each} 1313 {/each}
1256 </div> 1314 </div>
...@@ -1333,7 +1391,7 @@ ...@@ -1333,7 +1391,7 @@
1333 {#if isLoadingMore} 1391 {#if isLoadingMore}
1334 <span class="loading-dot"></span> Cargando... 1392 <span class="loading-dot"></span> Cargando...
1335 {:else} 1393 {:else}
1336 - Cargar más ({allHits.length} de {results.found}) 1394 + Cargar más resultados
1337 {/if} 1395 {/if}
1338 </button> 1396 </button>
1339 </div> 1397 </div>
...@@ -2001,7 +2059,7 @@ ...@@ -2001,7 +2059,7 @@
2001 /* Section Group */ 2059 /* Section Group */
2002 .section-group{margin-top:16px;transition:all 0.4s cubic-bezier(0.16,1,0.3,1)} 2060 .section-group{margin-top:16px;transition:all 0.4s cubic-bezier(0.16,1,0.3,1)}
2003 .section-group-hidden{opacity:0;pointer-events:none;position:absolute;z-index:-1} 2061 .section-group-hidden{opacity:0;pointer-events:none;position:absolute;z-index:-1}
2004 - .section-label{display:block;font-family:var(--sans);font-size:10px;font-weight:500;color:#6B6860;text-transform:uppercase;letter-spacing:0.08em;margin-bottom:8px;padding-left:4px} 2062 + .section-label{display:block;font-family:var(--sans);font-size:11px;font-weight:600;color:#D4B76A;text-transform:uppercase;letter-spacing:0.08em;margin-bottom:8px;padding-left:4px}
2005 .section-label-goto{margin-top:12px} 2063 .section-label-goto{margin-top:12px}
2006 2064
2007 /* Mode Cards */ 2065 /* Mode Cards */
...@@ -2129,8 +2187,15 @@ ...@@ -2129,8 +2187,15 @@
2129 /* Result Group (for entidades) */ 2187 /* Result Group (for entidades) */
2130 .result-group{background:transparent;border-radius:0;overflow:hidden;border-bottom:1px solid rgba(255,255,255,0.06);padding-bottom:8px;margin-bottom:8px} 2188 .result-group{background:transparent;border-radius:0;overflow:hidden;border-bottom:1px solid rgba(255,255,255,0.06);padding-bottom:8px;margin-bottom:8px}
2131 .result-group:last-child{border-bottom:none;margin-bottom:0;padding-bottom:0} 2189 .result-group:last-child{border-bottom:none;margin-bottom:0;padding-bottom:0}
2132 - .result-group-header{display:flex;align-items:center;gap:12px;padding:10px 12px;background:rgba(255,255,255,0.04);border-radius:8px;margin-bottom:6px} 2190 + .result-group-header{display:flex;align-items:center;gap:8px;padding:10px 12px;background:rgba(255,255,255,0.04);border-radius:8px;margin-bottom:6px;border:none;width:100%;text-align:left;font-family:var(--sans);color:inherit;cursor:pointer;transition:background 0.15s}
2133 - .result-group-name{font-family:var(--sans);font-size:14px;font-weight:600;color:#F5F0E8;flex:1} 2191 + .result-group-header:hover{background:rgba(255,255,255,0.08)}
2192 + .group-chevron{color:#9B9890;transition:transform 0.2s;flex-shrink:0}
2193 + .group-chevron.collapsed{transform:rotate(-90deg)}
2194 + .group-show-more{display:block;width:100%;padding:8px;background:none;border:none;color:#9B9890;font-size:12px;font-family:var(--sans);cursor:pointer;transition:color 0.15s;text-align:center}
2195 + .group-show-more:hover{color:#F5F0E8}
2196 + .infinite-scroll-sentinel{display:flex;justify-content:center;padding:16px;min-height:1px}
2197 + .result-group-name{font-family:var(--sans);font-size:14px;font-weight:600;color:#F5F0E8;flex:1;display:flex;align-items:baseline;gap:6px}
2198 + .result-group-tecnico{font-size:11px;font-weight:400;color:#9B9890;opacity:0.7}
2134 .result-group-count{font-family:var(--mono);font-size:11px;color:#A5A29A;background:rgba(255,255,255,0.08);padding:3px 10px;border-radius:4px} 2199 .result-group-count{font-family:var(--mono);font-size:11px;color:#A5A29A;background:rgba(255,255,255,0.08);padding:3px 10px;border-radius:4px}
2135 .result-group-monto{font-family:var(--mono);font-size:13px;font-weight:500;color:#5AAF8A} 2200 .result-group-monto{font-family:var(--mono);font-size:13px;font-weight:500;color:#5AAF8A}
2136 .result-group-items{display:flex;flex-direction:column} 2201 .result-group-items{display:flex;flex-direction:column}
......
1 // Proxy para evitar CORS 1 // Proxy para evitar CORS
2 -const API_BASE = 'http://34.171.18.2/api/proyecto/search'; 2 +const API_BASE = 'http://136.112.29.74/api/proyecto/search';
3 3
4 export async function GET({ url }) { 4 export async function GET({ url }) {
5 const q = url.searchParams.get('q') || ''; 5 const q = url.searchParams.get('q') || '';
......
1 +const API_BASE = 'http://136.112.29.74/api/ubigeo/clasificador';
2 +
3 +export async function GET() {
4 + try {
5 + const response = await fetch(API_BASE);
6 + const data = await response.json();
7 + return new Response(JSON.stringify(data), {
8 + headers: { 'Content-Type': 'application/json' }
9 + });
10 + } catch (err) {
11 + return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
12 + status: 500,
13 + headers: { 'Content-Type': 'application/json' }
14 + });
15 + }
16 +}
1 +export async function load({ fetch }) {
2 + const res = await fetch('/api/ubigeo-clasificador');
3 + const data = await res.json();
4 + return { clasificador: data };
5 +}
1 +<script>
2 + let { data } = $props();
3 +
4 + let searchQuery = $state('');
5 + let allMunicipios = data.clasificador.filter(d => d.nivel === 'municipio' && d.municipio_ubigeo);
6 + let highlightedMuni = $state(null);
7 + let sidebarOpen = $state(false);
8 +
9 + function titleCase(str) {
10 + if (!str) return '';
11 + return str.toLowerCase().replace(/\b\w/g, c => c.toUpperCase());
12 + }
13 +
14 + // Agrupar por departamento (calculado una vez)
15 + const deptoMap = {};
16 + allMunicipios.forEach(m => {
17 + const dept = m.desc_departamento;
18 + if (!deptoMap[dept]) {
19 + deptoMap[dept] = { nombre: dept, municipios: [] };
20 + }
21 + deptoMap[dept].municipios.push(m);
22 + });
23 + let departamentos = Object.values(deptoMap).sort((a, b) => a.nombre.localeCompare(b.nombre));
24 + let selectedDepto = $state(departamentos[0] || null);
25 +
26 +
27 + function searchGlobal(query) {
28 + if (!query || query.length < 2) return [];
29 + const q = query.toLowerCase();
30 + return allMunicipios
31 + .filter(m =>
32 + m.desc_ubigeo?.toLowerCase().includes(q) ||
33 + m.desc_departamento?.toLowerCase().includes(q) ||
34 + m.municipio_ubigeo?.toString().includes(q)
35 + )
36 + .slice(0, 20);
37 + }
38 +
39 + function selectDepto(depto) {
40 + selectedDepto = depto;
41 + searchQuery = '';
42 + highlightedMuni = null;
43 + sidebarOpen = false;
44 + }
45 +
46 + function goToSearchResult(item) {
47 + const targetDepto = departamentos.find(d => d.nombre === item.desc_departamento);
48 + if (targetDepto) {
49 + selectedDepto = targetDepto;
50 + highlightedMuni = item.municipio_ubigeo;
51 + searchQuery = '';
52 + sidebarOpen = false;
53 + setTimeout(() => {
54 + const el = document.getElementById(`muni-${item.municipio_ubigeo}`);
55 + el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
56 + }, 100);
57 + }
58 + }
59 +
60 + function formatGestiones(gestiones) {
61 + if (!gestiones) return '';
62 + const years = gestiones.split(',').map(y => parseInt(y.trim())).sort((a, b) => a - b);
63 + if (years.length <= 1) return years[0]?.toString() || '';
64 + const ranges = [];
65 + let start = years[0], end = years[0];
66 + for (let i = 1; i < years.length; i++) {
67 + if (years[i] === end + 1) { end = years[i]; }
68 + else { ranges.push(start === end ? `${start}` : `${start}-${end}`); start = years[i]; end = years[i]; }
69 + }
70 + ranges.push(start === end ? `${start}` : `${start}-${end}`);
71 + return ranges.join(', ');
72 + }
73 +
74 + let searchResults = $derived(searchGlobal(searchQuery));
75 + let isSearching = $derived(searchQuery.length >= 2);
76 + let totalMunicipios = $derived(allMunicipios.length);
77 + let totalDeptos = $derived(departamentos.length);
78 +</script>
79 +
80 +<svelte:head>
81 + <title>Geográfico | Presupuesto Público</title>
82 +</svelte:head>
83 +
84 +<div class="min-h-screen" style="font-family: var(--font-sans); background-color: var(--theme-body); color: var(--theme-titulo);">
85 + <!-- Header -->
86 + <header class="border-b" style="border-color: var(--theme-borde);">
87 + <div class="max-w-screen-xl mx-auto px-4 sm:px-6 py-6">
88 + <nav class="mb-4" style="font-size: 0.75rem; color: var(--theme-texto);">
89 + <div class="flex items-center gap-2">
90 + <a href="/" class="hover:opacity-80">Inicio</a>
91 + <span style="opacity: 0.5;">/</span>
92 + <a href="/clasificadores" class="hover:opacity-80">Clasificadores</a>
93 + </div>
94 + </nav>
95 +
96 + <div class="max-w-3xl">
97 + <p class="text-xs uppercase tracking-widest mb-2" style="color: var(--theme-texto);">Clasificador geográfico</p>
98 + <h1 class="text-3xl mb-3" style="font-weight: 700;">¿Dónde se gasta?</h1>
99 + <p class="leading-relaxed mb-3" style="color: var(--theme-texto);">
100 + Este clasificador organiza el gasto público por <strong style="color: var(--theme-titulo);">ubicación geográfica</strong>:
101 + departamentos y municipios donde se ejecuta el presupuesto, independientemente de la institución que lo ejecuta.
102 + </p>
103 +
104 + {#if totalMunicipios > 0}
105 + <p class="text-sm mb-5" style="color: var(--theme-texto);">
106 + <span style="color: var(--theme-titulo); font-weight: 500;">{totalMunicipios}</span> municipios en
107 + <span style="color: var(--theme-titulo); font-weight: 500;">{totalDeptos}</span> departamentos
108 + </p>
109 + {/if}
110 +
111 + <div class="flex flex-wrap items-center gap-2 text-xs mb-6">
112 + <span class="px-2.5 py-1 rounded-full font-medium" style="background-color: var(--theme-accent); color: var(--theme-body);">
113 + Departamento
114 + </span>
115 + <span style="color: var(--theme-texto);">→</span>
116 + <span class="px-2.5 py-1 rounded-full" style="border: 1px solid var(--theme-borde); color: var(--theme-titulo);">
117 + Municipio
118 + </span>
119 + </div>
120 + </div>
121 + </div>
122 + </header>
123 +
124 + <!-- Botón móvil sidebar -->
125 + <div class="lg:hidden px-4 py-3 border-b" style="border-color: var(--theme-borde); background-color: var(--theme-fill);">
126 + <button
127 + onclick={() => sidebarOpen = !sidebarOpen}
128 + class="flex items-center gap-2 text-sm"
129 + style="color: var(--theme-texto);"
130 + >
131 + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
132 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
133 + </svg>
134 + <span class="font-medium">{selectedDepto ? titleCase(selectedDepto.nombre) : 'Departamentos'}</span>
135 + <svg class="w-4 h-4 ml-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
136 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
137 + </svg>
138 + </button>
139 + </div>
140 +
141 + <div class="clasificador-layout max-w-screen-xl mx-auto flex px-4" style="display: flex !important; flex-direction: row;">
142 + <!-- Sidebar: Departamentos -->
143 + {#if sidebarOpen}
144 + <div class="fixed inset-0 bg-black/30 z-40 lg:hidden" onclick={() => sidebarOpen = false}></div>
145 + {/if}
146 + <aside class="
147 + {sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
148 + lg:translate-x-0
149 + fixed lg:relative
150 + inset-y-0 left-0
151 + w-72 lg:w-64
152 + z-50 lg:z-auto
153 + transition-transform duration-200 ease-in-out
154 + lg:flex-shrink-0
155 + shadow-xl lg:shadow-none
156 + sidebar-left
157 + ">
158 + <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" style="background-color: var(--theme-body);">
159 + <!-- Cerrar en móvil -->
160 + <div class="flex justify-between items-center mb-4 lg:hidden">
161 + <span class="text-sm font-medium" style="color: var(--theme-titulo);">Departamentos</span>
162 + <button onclick={() => sidebarOpen = false} style="color: var(--theme-texto);">
163 + <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
164 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
165 + </svg>
166 + </button>
167 + </div>
168 +
169 + <!-- Buscador -->
170 + <div class="mb-6">
171 + <input
172 + type="text"
173 + bind:value={searchQuery}
174 + placeholder="Buscar municipio..."
175 + class="w-full px-3 py-2 text-sm rounded-md focus:outline-none focus:ring-2 focus:ring-offset-0"
176 + style="border: 1px solid var(--theme-borde); background-color: var(--theme-surface); color: var(--theme-titulo);"
177 + />
178 + </div>
179 +
180 + <!-- Resultados de búsqueda -->
181 + {#if isSearching}
182 + <div class="mb-4">
183 + <p class="text-xs uppercase tracking-wide mb-2" style="color: var(--theme-texto);">
184 + {searchResults.length} resultados
185 + </p>
186 + <div class="space-y-1">
187 + {#each searchResults as result}
188 + <button
189 + class="w-full text-left px-2 py-2 text-sm rounded transition-all"
190 + style="color: var(--theme-titulo);"
191 + onclick={() => goToSearchResult(result)}
192 + >
193 + <span class="text-xs block" style="color: var(--theme-texto);">{titleCase(result.desc_departamento)}</span>
194 + <span>{titleCase(result.desc_ubigeo)}</span>
195 + </button>
196 + {/each}
197 + {#if searchResults.length === 0}
198 + <p class="text-sm py-4 text-center" style="color: var(--theme-texto);">Sin resultados</p>
199 + {/if}
200 + </div>
201 + </div>
202 + {:else}
203 + <!-- Lista de departamentos -->
204 + <div class="space-y-0.5">
205 + {#each departamentos as depto}
206 + <button
207 + class="w-full text-left px-3 py-2.5 text-sm rounded-lg transition-all flex items-center justify-between"
208 + style="{selectedDepto?.nombre === depto.nombre
209 + ? `background-color: var(--theme-accent); color: var(--theme-body);`
210 + : `color: var(--theme-titulo);`}"
211 + onclick={() => selectDepto(depto)}
212 + >
213 + <span class="font-medium">{titleCase(depto.nombre)}</span>
214 + <span class="text-xs" style="opacity: 0.6;">{depto.municipios.length}</span>
215 + </button>
216 + {/each}
217 + </div>
218 + {/if}
219 + </div>
220 + </aside>
221 +
222 + <!-- Contenido: Municipios -->
223 + <main class="flex-1 min-w-0 py-6 lg:pl-6 lg:border-l" style="border-color: var(--theme-borde);">
224 + {#if selectedDepto}
225 + <div class="mb-6">
226 + <h2 class="text-xl font-bold mb-1" style="color: var(--theme-titulo);">{titleCase(selectedDepto.nombre)}</h2>
227 + <p class="text-sm" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">
228 + {selectedDepto.municipios.length} municipios
229 + </p>
230 + </div>
231 +
232 + <div class="space-y-2">
233 + {#each [...selectedDepto.municipios].sort((a, b) => a.desc_ubigeo.localeCompare(b.desc_ubigeo)) as muni}
234 + <a
235 + href="/ubicacion/{muni.municipio_ubigeo}"
236 + id="muni-{muni.municipio_ubigeo}"
237 + class="block p-4 rounded-xl border transition-all hover:shadow-sm"
238 + style="border-color: {highlightedMuni === muni.municipio_ubigeo ? 'var(--theme-accent)' : 'var(--theme-borde)'}; background-color: var(--theme-surface);"
239 + >
240 + <div class="flex items-center justify-between">
241 + <div>
242 + <span class="font-medium" style="color: var(--theme-titulo);">{titleCase(muni.desc_ubigeo)}</span>
243 + <span class="text-xs ml-2" style="color: var(--theme-texto); opacity: 0.5;">{muni.municipio_ubigeo}</span>
244 + </div>
245 + <div class="text-xs" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">
246 + {muni.n_gestiones} años
247 + </div>
248 + </div>
249 + <div class="text-xs mt-1" style="font-family: 'DM Mono', monospace; color: var(--theme-texto); opacity: 0.6;">
250 + {formatGestiones(muni.gestiones)}
251 + </div>
252 + </a>
253 + {/each}
254 + </div>
255 + {/if}
256 + </main>
257 + </div>
258 +</div>
1 import { supabase } from '$lib/supabase'; 1 import { supabase } from '$lib/supabase';
2 import { error } from '@sveltejs/kit'; 2 import { error } from '@sveltejs/kit';
3 3
4 -export async function load({ params }) { 4 +export async function load({ params, fetch }) {
5 const codigo = params.codigo; 5 const codigo = params.codigo;
6 const isDA = codigo.includes('.'); 6 const isDA = codigo.includes('.');
7 const entidadCode = isDA ? codigo.split('.')[0] : codigo; 7 const entidadCode = isDA ? codigo.split('.')[0] : codigo;
...@@ -11,8 +11,8 @@ export async function load({ params }) { ...@@ -11,8 +11,8 @@ export async function load({ params }) {
11 throw error(400, 'Código de entidad inválido'); 11 throw error(400, 'Código de entidad inválido');
12 } 12 }
13 13
14 - // Cargar metadata y resumen en paralelo 14 + // Cargar metadata, resumen y población en paralelo
15 - const [entidadRes, resumenRes] = await Promise.all([ 15 + const [entidadRes, resumenRes, pobRes] = await Promise.all([
16 supabase 16 supabase
17 .schema('ppto') 17 .schema('ppto')
18 .from('clas_institucional') 18 .from('clas_institucional')
...@@ -24,9 +24,31 @@ export async function load({ params }) { ...@@ -24,9 +24,31 @@ export async function load({ params }) {
24 .schema('ppto') 24 .schema('ppto')
25 .from('entidad_resumen') 25 .from('entidad_resumen')
26 .select('tipo, tipo_codigo, codigo, desc, desc_padre, gestion, devengado, ranking') 26 .select('tipo, tipo_codigo, codigo, desc, desc_padre, gestion, devengado, ranking')
27 - .eq('codigo', codigo) 27 + .eq('codigo', codigo),
28 +
29 + fetch('/poblacion.csv').then(r => r.text())
28 ]); 30 ]);
29 31
32 + // Buscar población: primero por entidad, si no suma nacional
33 + const poblacionEntidad = {};
34 + const poblacionNacional = {};
35 + pobRes.split('\n').slice(1).forEach(line => {
36 + const [, ent, gestion, pob] = line.split(',');
37 + const g = parseInt(gestion);
38 + const p = parseInt(pob);
39 + if (!g || !p) return;
40 + // Suma nacional
41 + poblacionNacional[g] = (poblacionNacional[g] || 0) + p;
42 + // Por entidad (código sin DA)
43 + if (ent === entidadCode) {
44 + poblacionEntidad[g] = (poblacionEntidad[g] || 0) + p;
45 + }
46 + });
47 +
48 + // Usar población de la entidad si existe, si no la nacional
49 + const tienePobEntidad = Object.keys(poblacionEntidad).length > 0;
50 + const poblacionMap = tienePobEntidad ? poblacionEntidad : poblacionNacional;
51 +
30 if (entidadRes.error || !entidadRes.data) { 52 if (entidadRes.error || !entidadRes.data) {
31 throw error(404, 'Entidad no encontrada'); 53 throw error(404, 'Entidad no encontrada');
32 } 54 }
...@@ -60,6 +82,8 @@ export async function load({ params }) { ...@@ -60,6 +82,8 @@ export async function load({ params }) {
60 codigoEntidadPadre: isDA ? entidadCode : null, 82 codigoEntidadPadre: isDA ? entidadCode : null,
61 resumenData: resumenRes.data || [], 83 resumenData: resumenRes.data || [],
62 distribucionesData: distRes.data || [], 84 distribucionesData: distRes.data || [],
63 - gestionInicial: ultimaGestion 85 + gestionInicial: ultimaGestion,
86 + poblacionMap,
87 + tienePobEntidad
64 }; 88 };
65 } 89 }
......
...@@ -10,6 +10,9 @@ ...@@ -10,6 +10,9 @@
10 let nombreDA = $derived(data.nombreDA); 10 let nombreDA = $derived(data.nombreDA);
11 let nombreEntidadMadre = $derived(data.nombreEntidadMadre); 11 let nombreEntidadMadre = $derived(data.nombreEntidadMadre);
12 let codigoEntidadPadre = $derived(data.codigoEntidadPadre); 12 let codigoEntidadPadre = $derived(data.codigoEntidadPadre);
13 + let poblacionMap = $derived(data.poblacionMap);
14 + let tienePobEntidad = $derived(data.tienePobEntidad);
15 + let modoPerCapita = $state(false);
13 16
14 // Datos desde el loader (Supabase) 17 // Datos desde el loader (Supabase)
15 let codigoSeleccionado = $derived($page.params.codigo); 18 let codigoSeleccionado = $derived($page.params.codigo);
...@@ -137,22 +140,34 @@ ...@@ -137,22 +140,34 @@
137 } 140 }
138 141
139 // Datos derivados (ya filtrados por codigo desde el loader) 142 // Datos derivados (ya filtrados por codigo desde el loader)
143 + function aplicarPerCapita(rows) {
144 + if (!modoPerCapita) return rows;
145 + return rows.map(d => {
146 + const pob = poblacionMap[d.gestion];
147 + return { ...d, devengado: pob ? d.devengado / pob : 0 };
148 + });
149 + }
150 +
140 let historiaGastos = $derived( 151 let historiaGastos = $derived(
152 + aplicarPerCapita(
141 resumenData 153 resumenData
142 - .filter(d => d.tipo === 'gastos') 154 + .filter(d => d.tipo === 'gastos' && d.gestion >= 2016)
143 .sort((a, b) => a.gestion - b.gestion) 155 .sort((a, b) => a.gestion - b.gestion)
156 + )
144 ); 157 );
145 158
146 let historiaIngresos = $derived( 159 let historiaIngresos = $derived(
160 + aplicarPerCapita(
147 resumenData 161 resumenData
148 - .filter(d => d.tipo === 'ingresos') 162 + .filter(d => d.tipo === 'ingresos' && d.gestion >= 2016)
149 .sort((a, b) => a.gestion - b.gestion) 163 .sort((a, b) => a.gestion - b.gestion)
164 + )
150 ); 165 );
151 166
152 let tieneIngresos = $derived(historiaIngresos.length > 0); 167 let tieneIngresos = $derived(historiaIngresos.length > 0);
153 168
154 let gestiones = $derived(() => { 169 let gestiones = $derived(() => {
155 - const years = [...new Set(resumenData.map(d => d.gestion))].sort(); 170 + const years = [...new Set(resumenData.map(d => d.gestion))].filter(g => g >= 2016).sort((a, b) => b - a);
156 return years; 171 return years;
157 }); 172 });
158 173
...@@ -176,11 +191,12 @@ ...@@ -176,11 +191,12 @@
176 let distFiltradas = $derived(distribucionesData); 191 let distFiltradas = $derived(distribucionesData);
177 192
178 function prepararSegmentos(data) { 193 function prepararSegmentos(data) {
194 + const pob = modoPerCapita ? poblacionMap[gestionSeleccionada] : null;
179 return data 195 return data
180 .map(d => ({ 196 .map(d => ({
181 codigo: d.hijo, 197 codigo: d.hijo,
182 nombre: d.desc_hijo, 198 nombre: d.desc_hijo,
183 - monto: d.devengado, 199 + monto: pob ? d.devengado / pob : d.devengado,
184 padre: d.desc_padre 200 padre: d.desc_padre
185 })) 201 }))
186 .filter(d => d.monto > 0) 202 .filter(d => d.monto > 0)
...@@ -231,6 +247,18 @@ ...@@ -231,6 +247,18 @@
231 return valor.toFixed(0); 247 return valor.toFixed(0);
232 } 248 }
233 249
250 + function formatearPerCapita(valor) {
251 + if (valor >= 1e6) return `${(valor / 1e6).toFixed(1)} millones`;
252 + if (valor >= 1e4) return `${(valor / 1e3).toFixed(1)} mil`;
253 + return Math.round(valor).toLocaleString('es-BO');
254 + }
255 +
256 + function fmt(valor) {
257 + return modoPerCapita ? formatearPerCapita(valor) : formatearMonto(valor);
258 + }
259 +
260 + let unidadMonto = $derived(modoPerCapita ? 'Bs por persona al año' : 'de Bolivianos');
261 +
234 function formatearMontoCorto(valor) { 262 function formatearMontoCorto(valor) {
235 if (valor >= 1e9) return `${(valor / 1e9).toFixed(1)}B`; 263 if (valor >= 1e9) return `${(valor / 1e9).toFixed(1)}B`;
236 if (valor >= 1e6) return `${(valor / 1e6).toFixed(0)}M`; 264 if (valor >= 1e6) return `${(valor / 1e6).toFixed(0)}M`;
...@@ -551,7 +579,13 @@ ...@@ -551,7 +579,13 @@
551 579
552 <!-- Historia temporal (todos los años) --> 580 <!-- Historia temporal (todos los años) -->
553 <section class="seccion"> 581 <section class="seccion">
582 + <div class="seccion-titulo-row">
554 <h2 class="seccion-titulo">Historia</h2> 583 <h2 class="seccion-titulo">Historia</h2>
584 + <div class="percapita-toggle">
585 + <button class="percapita-btn" class:active={!modoPerCapita} onclick={() => { modoPerCapita = false; }}>Total</button>
586 + <button class="percapita-btn" class:active={modoPerCapita} onclick={() => { modoPerCapita = true; }}>Per cápita</button>
587 + </div>
588 + </div>
555 <div class="historia-grid" class:single={!tieneIngresos}> 589 <div class="historia-grid" class:single={!tieneIngresos}>
556 {#if tieneIngresos} 590 {#if tieneIngresos}
557 <div class="historia-card" bind:this={chartRefA}> 591 <div class="historia-card" bind:this={chartRefA}>
...@@ -560,9 +594,9 @@ ...@@ -560,9 +594,9 @@
560 {#if historiaIngresos.length > 0} 594 {#if historiaIngresos.length > 0}
561 <span class="historia-monto ingresos"> 595 <span class="historia-monto ingresos">
562 {#if hoveredIngresos} 596 {#if hoveredIngresos}
563 - {hoveredIngresos.gestion} · {formatearMonto(hoveredIngresos.devengado)} de Bolivianos 597 + {hoveredIngresos.gestion} · {fmt(hoveredIngresos.devengado)} {unidadMonto}
564 {:else} 598 {:else}
565 - {formatearMonto(historiaIngresos.reduce((s, d) => s + d.devengado, 0))} de Bolivianos 599 + {fmt(historiaIngresos.reduce((s, d) => s + d.devengado, 0))} {unidadMonto}
566 {/if} 600 {/if}
567 </span> 601 </span>
568 {/if} 602 {/if}
...@@ -610,9 +644,9 @@ ...@@ -610,9 +644,9 @@
610 {#if historiaGastos.length > 0} 644 {#if historiaGastos.length > 0}
611 <span class="historia-monto gastos"> 645 <span class="historia-monto gastos">
612 {#if hoveredGastos} 646 {#if hoveredGastos}
613 - {hoveredGastos.gestion} · {formatearMonto(hoveredGastos.devengado)} de Bolivianos 647 + {hoveredGastos.gestion} · {fmt(hoveredGastos.devengado)} {unidadMonto}
614 {:else} 648 {:else}
615 - {formatearMonto(historiaGastos.reduce((s, d) => s + d.devengado, 0))} de Bolivianos 649 + {fmt(historiaGastos.reduce((s, d) => s + d.devengado, 0))} {unidadMonto}
616 {/if} 650 {/if}
617 </span> 651 </span>
618 {/if} 652 {/if}
...@@ -712,7 +746,7 @@ ...@@ -712,7 +746,7 @@
712 <div class="ranking-card"> 746 <div class="ranking-card">
713 <div class="ranking-headline"> 747 <div class="ranking-headline">
714 <span class="ranking-pos-big">#{r.posicion}</span> 748 <span class="ranking-pos-big">#{r.posicion}</span>
715 - <span class="ranking-pos-context">de <span class="ranking-total-num">{total}</span> instituciones · {r.posicion <= total / 2 ? 'entre las que más ingresan' : 'entre las que menos ingresan'}</span> 749 + <span class="ranking-pos-context">de <span class="ranking-total-num">{total}</span> instituciones · {r.posicion <= total / 2 ? 'entre las que más ingreso reciben/generan' : 'entre las que menos ingreso reciben/generan'}</span>
716 </div> 750 </div>
717 <div class="ranking-bars"> 751 <div class="ranking-bars">
718 {#each Array(80) as _, i} 752 {#each Array(80) as _, i}
...@@ -745,7 +779,7 @@ ...@@ -745,7 +779,7 @@
745 <span class="clasificador-nombre">{displayItem?.nombre || ''}</span> 779 <span class="clasificador-nombre">{displayItem?.nombre || ''}</span>
746 </div> 780 </div>
747 <div class="clasificador-valores"> 781 <div class="clasificador-valores">
748 - <span class="clasificador-monto">{formatearMonto(displayItem?.monto || 0)} de Bolivianos</span> 782 + <span class="clasificador-monto">{fmt(displayItem?.monto || 0)} {unidadMonto}</span>
749 <span class="clasificador-pct">{pct}%</span> 783 <span class="clasificador-pct">{pct}%</span>
750 </div> 784 </div>
751 </div> 785 </div>
...@@ -775,7 +809,7 @@ ...@@ -775,7 +809,7 @@
775 <span class="clasificador-nombre">{displayItem?.nombre || ''}</span> 809 <span class="clasificador-nombre">{displayItem?.nombre || ''}</span>
776 </div> 810 </div>
777 <div class="clasificador-valores"> 811 <div class="clasificador-valores">
778 - <span class="clasificador-monto">{formatearMonto(displayItem?.monto || 0)} de Bolivianos</span> 812 + <span class="clasificador-monto">{fmt(displayItem?.monto || 0)} {unidadMonto}</span>
779 <span class="clasificador-pct">{pct}%</span> 813 <span class="clasificador-pct">{pct}%</span>
780 </div> 814 </div>
781 </div> 815 </div>
...@@ -802,6 +836,12 @@ ...@@ -802,6 +836,12 @@
802 font-family: var(--font-sans); 836 font-family: var(--font-sans);
803 } 837 }
804 838
839 + .dashboard > :not(.nav-spacer) {
840 + max-width: 1200px;
841 + margin-left: auto;
842 + margin-right: auto;
843 + }
844 +
805 :global(html:not(.dark)) .dashboard { 845 :global(html:not(.dark)) .dashboard {
806 background: #f5f5f7; 846 background: #f5f5f7;
807 } 847 }
...@@ -1119,6 +1159,45 @@ ...@@ -1119,6 +1159,45 @@
1119 margin-bottom: 2.5rem; 1159 margin-bottom: 2.5rem;
1120 } 1160 }
1121 1161
1162 + .seccion-titulo-row {
1163 + display: flex;
1164 + align-items: center;
1165 + justify-content: space-between;
1166 + gap: 1rem;
1167 + margin-bottom: 1rem;
1168 + }
1169 +
1170 + .seccion-titulo-row .seccion-titulo {
1171 + margin-bottom: 0;
1172 + }
1173 +
1174 + .percapita-toggle {
1175 + display: flex;
1176 + gap: 2px;
1177 + background: rgba(255, 255, 255, 0.06);
1178 + border-radius: 8px;
1179 + padding: 3px;
1180 + flex-shrink: 0;
1181 + }
1182 + :global(html:not(.dark)) .percapita-toggle { background: rgba(0, 0, 0, 0.05); }
1183 +
1184 + .percapita-btn {
1185 + padding: 5px 12px;
1186 + font-size: 0.75rem;
1187 + font-weight: 600;
1188 + font-family: var(--font-sans);
1189 + border: none;
1190 + border-radius: 6px;
1191 + cursor: pointer;
1192 + background: transparent;
1193 + color: var(--theme-texto);
1194 + opacity: 0.6;
1195 + transition: all 0.2s;
1196 + }
1197 + .percapita-btn:hover { opacity: 0.8; }
1198 + .percapita-btn.active { opacity: 1; background: rgba(255, 255, 255, 0.12); color: var(--theme-titulo); }
1199 + :global(html:not(.dark)) .percapita-btn.active { background: rgba(255, 255, 255, 0.8); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); }
1200 +
1122 .seccion-titulo { 1201 .seccion-titulo {
1123 font-size: 1.1rem; 1202 font-size: 1.1rem;
1124 font-weight: 600; 1203 font-weight: 600;
......
...@@ -1530,7 +1530,7 @@ ...@@ -1530,7 +1530,7 @@
1530 display: grid; 1530 display: grid;
1531 grid-template-columns: 320px minmax(auto, 900px); 1531 grid-template-columns: 320px minmax(auto, 900px);
1532 gap: 16px; 1532 gap: 16px;
1533 - padding: 1rem 2rem 2rem; 1533 + padding: 3.5rem 2rem 2rem;
1534 max-width: 1600px; 1534 max-width: 1600px;
1535 margin: 0 auto; 1535 margin: 0 auto;
1536 justify-content: center; 1536 justify-content: center;
......
...@@ -1076,6 +1076,55 @@ ...@@ -1076,6 +1076,55 @@
1076 <!-- Separador --> 1076 <!-- Separador -->
1077 <div class="context-separator"></div> 1077 <div class="context-separator"></div>
1078 1078
1079 + <!-- Explorar -->
1080 + <div class="context-section">
1081 + <h3 class="context-subtitle">Explorar visualizaciones</h3>
1082 + <div class="explore-scroll">
1083 + <a href="/objeto/{objetoCodigo}?vista=comparar" class="explore-card" onclick={(e) => { e.preventDefault(); vista = 'comparar'; }}>
1084 + <svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
1085 + <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"/>
1086 + </svg>
1087 + <span class="explore-title">Comparar dos entidades</span>
1088 + <span class="explore-desc">¿Quién gasta más en {objetoData.desc_objeto?.toLowerCase()}?</span>
1089 + </a>
1090 +
1091 + <a href="/clasificadores/objeto-gasto" class="explore-card">
1092 + <svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
1093 + <path d="M3 20h18M5 17h2v3H5zM9 14h2v6H9zM13 11h2v9h-2zM17 8h2v12h-2z"/>
1094 + </svg>
1095 + <span class="explore-title">Ranking</span>
1096 + <span class="explore-desc">{nEntidades} entidades que gastan en esta partida</span>
1097 + </a>
1098 +
1099 + <a href="/ubicacion" class="explore-card explore-card-disabled">
1100 + <svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
1101 + <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"/>
1102 + </svg>
1103 + <span class="explore-title">Mapa</span>
1104 + <span class="explore-desc">Dónde se concentra el gasto en esta partida</span>
1105 + </a>
1106 +
1107 + <a href="/clasificadores/objeto-gasto?modo=mapa" class="explore-card">
1108 + <svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
1109 + <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"/>
1110 + </svg>
1111 + <span class="explore-title">Peso relativo</span>
1112 + <span class="explore-desc">Cuánto pesa {objetoData.desc_objeto?.toLowerCase()} respecto a otros gastos</span>
1113 + </a>
1114 +
1115 + <a href="/clasificadores/objeto-gasto" class="explore-card">
1116 + <svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
1117 + <path d="M4 6h16M8 10h12M12 14h8M8 18h12"/>
1118 + </svg>
1119 + <span class="explore-title">Clasificador</span>
1120 + <span class="explore-desc">Ver todas las partidas de gasto y navegar entre ellas</span>
1121 + </a>
1122 + </div>
1123 + </div>
1124 +
1125 + <!-- Separador -->
1126 + <div class="context-separator"></div>
1127 +
1079 <!-- Contexto: Vigencia temporal --> 1128 <!-- Contexto: Vigencia temporal -->
1080 <div class="context-section"> 1129 <div class="context-section">
1081 <h3 class="context-subtitle">Comparabilidad</h3> 1130 <h3 class="context-subtitle">Comparabilidad</h3>
...@@ -1113,18 +1162,18 @@ ...@@ -1113,18 +1162,18 @@
1113 <!-- Contexto: Definición oficial --> 1162 <!-- Contexto: Definición oficial -->
1114 <div class="context-section"> 1163 <div class="context-section">
1115 <h3 class="context-subtitle">Definición</h3> 1164 <h3 class="context-subtitle">Definición</h3>
1116 - <p class="context-desc">Descripción oficial {getNivelArticuloContracto(objetoData.nivel)} {objetoData.nivel} según el clasificador presupuestario.</p> 1165 + <button class="def-link" onclick={() => showDefinicionesModal = true}>
1117 - <div class="definicion-compact"> 1166 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1167 + <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"/>
1168 + </svg>
1118 {#if objeto.variaciones.length === 1} 1169 {#if objeto.variaciones.length === 1}
1119 - <span class="def-status">Sin cambios en el período</span> 1170 + Ver definición oficial
1120 {:else} 1171 {:else}
1121 - <span class="def-status def-changed">{objeto.variaciones.length} versiones en el período</span> 1172 + Ver {objeto.variaciones.length} variaciones en la definición
1122 {/if} 1173 {/if}
1123 - <button class="def-btn" onclick={() => showDefinicionesModal = true}>
1124 - Ver
1125 </button> 1174 </button>
1126 </div> 1175 </div>
1127 - </div> 1176 +
1128 </div> 1177 </div>
1129 </aside> 1178 </aside>
1130 1179
...@@ -1154,11 +1203,13 @@ ...@@ -1154,11 +1203,13 @@
1154 <span class="chart-title">Bs/habitante <span class="chart-period">· {displayPeriodo}</span></span> 1203 <span class="chart-title">Bs/habitante <span class="chart-period">· {displayPeriodo}</span></span>
1155 </div> 1204 </div>
1156 {#if loading} 1205 {#if loading}
1157 - <div class="chart-loading" style="height: 280px;"> 1206 + <div class="chart-loading" style="flex: 1; min-height: 100px;">
1158 <span>Cargando...</span> 1207 <span>Cargando...</span>
1159 </div> 1208 </div>
1160 {:else} 1209 {:else}
1161 - <BarChart data={gastoPerCapita} bind:hoveredYear height={280} fill={!!selectedEntity} /> 1210 + <div class="chart-flex-wrapper">
1211 + <BarChart data={gastoPerCapita} bind:hoveredYear height={200} fill={true} />
1212 + </div>
1162 {/if} 1213 {/if}
1163 1214
1164 <!-- KPIs abajo --> 1215 <!-- KPIs abajo -->
...@@ -1200,6 +1251,7 @@ ...@@ -1200,6 +1251,7 @@
1200 </div> 1251 </div>
1201 {/if} 1252 {/if}
1202 </main> 1253 </main>
1254 +
1203 </div> 1255 </div>
1204 1256
1205 <!-- Modal de definiciones (fuera del layout) --> 1257 <!-- Modal de definiciones (fuera del layout) -->
...@@ -1548,12 +1600,13 @@ ...@@ -1548,12 +1600,13 @@
1548 ═══════════════════════════════════════════════════════════════ */ 1600 ═══════════════════════════════════════════════════════════════ */
1549 .dashboard-layout { 1601 .dashboard-layout {
1550 display: grid; 1602 display: grid;
1551 - grid-template-columns: 320px minmax(auto, 900px); 1603 + grid-template-columns: minmax(200px, 30%) 1fr;
1552 - gap: 16px; 1604 + gap: 1.5rem;
1553 - padding: 1rem 2rem 2rem; 1605 + max-width: 1400px;
1554 - max-width: 1600px;
1555 margin: 0 auto; 1606 margin: 0 auto;
1556 - justify-content: center; 1607 + padding: 1.5rem 2rem 2rem;
1608 + height: calc(100vh - 4rem);
1609 + overflow: hidden;
1557 } 1610 }
1558 1611
1559 /* Contexto */ 1612 /* Contexto */
...@@ -1570,13 +1623,24 @@ ...@@ -1570,13 +1623,24 @@
1570 1623
1571 .context-card { 1624 .context-card {
1572 background: var(--theme-surface); 1625 background: var(--theme-surface);
1573 - border-radius: 16px; 1626 + border-radius: 12px;
1574 - padding: 1.25rem 1.5rem; 1627 + padding: 0.75rem;
1575 display: flex; 1628 display: flex;
1576 flex-direction: column; 1629 flex-direction: column;
1577 - gap: 1rem; 1630 + gap: 8px;
1578 flex: 1; 1631 flex: 1;
1579 min-height: 0; 1632 min-height: 0;
1633 + overflow-y: auto;
1634 + scrollbar-width: thin;
1635 + }
1636 +
1637 + .context-card::-webkit-scrollbar {
1638 + width: 4px;
1639 + }
1640 +
1641 + .context-card::-webkit-scrollbar-thumb {
1642 + background: rgba(128, 128, 128, 0.3);
1643 + border-radius: 2px;
1580 } 1644 }
1581 1645
1582 .context-main-title { 1646 .context-main-title {
...@@ -1760,59 +1824,47 @@ ...@@ -1760,59 +1824,47 @@
1760 opacity: 0.7; 1824 opacity: 0.7;
1761 } 1825 }
1762 1826
1763 - /* Definición compacta */ 1827 + /* Definición - link style */
1764 - .definicion-compact { 1828 + .def-link {
1765 display: flex; 1829 display: flex;
1766 align-items: center; 1830 align-items: center;
1767 - justify-content: space-between; 1831 + gap: 0.375rem;
1768 - gap: 0.5rem; 1832 + font-size: 0.75rem;
1769 - } 1833 + color: var(--theme-accent, #C9A751);
1770 - 1834 + background: none;
1771 - .def-status {
1772 - font-size: 0.8125rem;
1773 - color: #555555;
1774 - }
1775 -
1776 - .def-status.def-changed {
1777 - color: #555555;
1778 - }
1779 -
1780 - .def-btn {
1781 - font-size: 0.6875rem;
1782 - font-weight: 500;
1783 - color: #555555;
1784 - background: rgba(0, 0, 0, 0.06);
1785 border: none; 1835 border: none;
1786 - padding: 0.25rem 0.625rem; 1836 + padding: 0;
1787 - border-radius: 4px;
1788 cursor: pointer; 1837 cursor: pointer;
1789 - transition: background 0.2s; 1838 + transition: opacity 0.15s;
1790 } 1839 }
1791 1840
1792 - .def-btn:hover { 1841 + .def-link:hover {
1793 - background: rgba(0, 0, 0, 0.1); 1842 + opacity: 0.7;
1843 + }
1844 +
1845 + .def-link svg {
1846 + flex-shrink: 0;
1847 + opacity: 0.8;
1794 } 1848 }
1795 1849
1796 /* Main content */ 1850 /* Main content */
1797 .dashboard-main { 1851 .dashboard-main {
1798 display: flex; 1852 display: flex;
1799 flex-direction: column; 1853 flex-direction: column;
1800 - gap: 0.75rem; 1854 + gap: 1.5rem;
1801 - min-width: 0; 1855 + min-height: 0;
1802 - width: 100%; 1856 + overflow: hidden;
1803 - align-self: start;
1804 - padding-top: 0;
1805 - padding-bottom: 0;
1806 - transition: all 0.3s ease;
1807 - }
1808 -
1809 - .dashboard-main.expanded {
1810 - align-self: stretch;
1811 } 1857 }
1812 1858
1813 /* Header compacto */ 1859 /* Header compacto */
1814 .page-nav { 1860 .page-nav {
1815 - padding: 1rem 2rem; 1861 + padding: 2rem 2rem 0.5rem;
1862 + max-width: 1400px;
1863 + margin: 0 auto;
1864 + }
1865 +
1866 + .chart-section {
1867 + margin-bottom: 0;
1816 } 1868 }
1817 1869
1818 .back-link { 1870 .back-link {
...@@ -2158,17 +2210,8 @@ ...@@ -2158,17 +2210,8 @@
2158 } 2210 }
2159 2211
2160 /* Dark mode - Definición */ 2212 /* Dark mode - Definición */
2161 - :global(html.dark) .def-status, 2213 + :global(html.dark) .def-link {
2162 - :global(html.dark) .def-btn { 2214 + color: var(--theme-accent, #C9A751);
2163 - color: rgba(255, 255, 255, 0.7);
2164 - }
2165 -
2166 - :global(html.dark) .def-btn {
2167 - background: rgba(255, 255, 255, 0.1);
2168 - }
2169 -
2170 - :global(html.dark) .def-btn:hover {
2171 - background: rgba(255, 255, 255, 0.15);
2172 } 2215 }
2173 2216
2174 :global(html.dark) .tree-children { 2217 :global(html.dark) .tree-children {
...@@ -2488,22 +2531,18 @@ ...@@ -2488,22 +2531,18 @@
2488 2531
2489 /* Chart */ 2532 /* Chart */
2490 .chart-section { 2533 .chart-section {
2491 - margin-bottom: 1rem; 2534 + margin-bottom: 0;
2492 } 2535 }
2493 2536
2494 .chart-protagonista { 2537 .chart-protagonista {
2495 background: var(--theme-surface); 2538 background: var(--theme-surface);
2496 - border-radius: 16px; 2539 + border-radius: 12px;
2497 - padding: 1.25rem 1.5rem; 2540 + padding: 0.75rem;
2498 display: flex; 2541 display: flex;
2499 flex-direction: column; 2542 flex-direction: column;
2500 - min-width: 0; 2543 + min-height: 0;
2501 - width: 100%; 2544 + flex: 2;
2502 - transition: flex 0.3s ease; 2545 + overflow: hidden;
2503 - }
2504 -
2505 - .chart-protagonista.expanded {
2506 - flex: 1;
2507 } 2546 }
2508 2547
2509 .chart-protagonista .chart-wrapper { 2548 .chart-protagonista .chart-wrapper {
...@@ -2526,6 +2565,11 @@ ...@@ -2526,6 +2565,11 @@
2526 padding-bottom: 1.5rem; 2565 padding-bottom: 1.5rem;
2527 } 2566 }
2528 2567
2568 + .chart-flex-wrapper {
2569 + flex: 1;
2570 + min-height: 0;
2571 + }
2572 +
2529 .chart-top-row { 2573 .chart-top-row {
2530 display: flex; 2574 display: flex;
2531 justify-content: space-between; 2575 justify-content: space-between;
...@@ -2536,59 +2580,49 @@ ...@@ -2536,59 +2580,49 @@
2536 2580
2537 .main-title-pills { 2581 .main-title-pills {
2538 display: flex; 2582 display: flex;
2539 - gap: 0.5rem; 2583 + gap: 0.375rem;
2540 } 2584 }
2541 2585
2542 .pill { 2586 .pill {
2543 font-family: 'DM Mono', monospace; 2587 font-family: 'DM Mono', monospace;
2544 - font-size: 0.75rem; 2588 + font-size: 0.625rem;
2545 - font-weight: 500; 2589 + padding: 0.125rem 0.5rem;
2546 - padding: 0.375rem 0.75rem; 2590 + border-radius: 4px;
2547 - border-radius: 6px;
2548 letter-spacing: 0.03em; 2591 letter-spacing: 0.03em;
2549 } 2592 }
2550 2593
2551 .pill-nivel { 2594 .pill-nivel {
2552 - background: rgba(166, 138, 62, 0.12); 2595 + background: rgba(201, 167, 81, 0.15);
2553 - color: #C9A751; 2596 + color: var(--theme-accent);
2554 } 2597 }
2555 2598
2556 .pill-codigo { 2599 .pill-codigo {
2557 - background: rgba(107, 159, 212, 0.12); 2600 + background: var(--theme-borde);
2558 - color: #6B9FD4; 2601 + color: var(--theme-texto);
2559 } 2602 }
2560 2603
2561 .main-title-name { 2604 .main-title-name {
2562 - font-size: 1.375rem; 2605 + font-family: 'DM Serif Display', serif;
2563 - font-weight: 600; 2606 + font-size: 1.75rem;
2607 + font-weight: 400;
2564 color: var(--theme-titulo); 2608 color: var(--theme-titulo);
2565 margin: 0 0 0.5rem 0; 2609 margin: 0 0 0.5rem 0;
2566 line-height: 1.2; 2610 line-height: 1.2;
2567 - flex-shrink: 0;
2568 } 2611 }
2569 2612
2570 .chart-header { 2613 .chart-header {
2571 - margin-bottom: 0.5rem; 2614 + margin-bottom: 0.25rem;
2572 - flex-shrink: 0;
2573 } 2615 }
2574 2616
2575 .chart-title { 2617 .chart-title {
2576 - font-family: 'Qanelas', var(--font-sans); 2618 + font-family: 'DM Mono', monospace;
2577 - font-size: 0.75rem; 2619 + font-size: 0.6875rem;
2578 - font-weight: 500;
2579 color: var(--theme-texto); 2620 color: var(--theme-texto);
2580 - text-transform: uppercase; 2621 + opacity: 0.7;
2581 - letter-spacing: 0.08em;
2582 } 2622 }
2583 2623
2584 .chart-period { 2624 .chart-period {
2585 - font-family: 'Qanelas', var(--font-sans); 2625 + opacity: 0.5;
2586 - font-size: 0.75rem;
2587 - font-weight: 400;
2588 - color: var(--theme-texto);
2589 - opacity: 0.7;
2590 - text-transform: none;
2591 - letter-spacing: 0;
2592 } 2626 }
2593 2627
2594 .chart-loading { 2628 .chart-loading {
...@@ -2734,75 +2768,149 @@ ...@@ -2734,75 +2768,149 @@
2734 display: grid; 2768 display: grid;
2735 grid-template-columns: repeat(4, 1fr); 2769 grid-template-columns: repeat(4, 1fr);
2736 gap: 0.5rem; 2770 gap: 0.5rem;
2737 - margin-top: 1rem; 2771 + margin-top: 0.5rem;
2772 + padding-top: 0.75rem;
2773 + border-top: 1px solid var(--theme-borde);
2738 } 2774 }
2739 2775
2740 .kpi { 2776 .kpi {
2741 - text-align: center; 2777 + display: flex;
2742 - min-width: 0; 2778 + flex-direction: column;
2743 - padding: 0.625rem 0.5rem; 2779 + gap: 0.125rem;
2744 - background: transparent; 2780 + }
2781 +
2782 + .kpi-value {
2783 + font-family: 'DM Mono', monospace;
2784 + font-size: 0.9375rem;
2785 + font-weight: 600;
2786 + color: var(--theme-titulo);
2787 + }
2788 +
2789 + .kpi-label {
2790 + font-size: 0.5625rem;
2791 + color: var(--theme-texto);
2792 + opacity: 0.6;
2793 + line-height: 1.3;
2794 + }
2795 +
2796 + /* Ranking Simple */
2797 + /* Explorar - scroll horizontal */
2798 + .explore-scroll {
2799 + display: flex;
2800 + gap: 8px;
2801 + overflow-x: auto;
2802 + scrollbar-width: none;
2803 + padding-bottom: 2px;
2804 + }
2805 +
2806 + .explore-scroll::-webkit-scrollbar { display: none; }
2807 +
2808 + .explore-card {
2809 + position: relative;
2810 + display: flex;
2811 + flex-direction: column;
2812 + justify-content: flex-start;
2813 + padding: 0.375rem 0.5rem;
2814 + width: 120px;
2815 + height: 95px;
2745 border-radius: 8px; 2816 border-radius: 8px;
2746 - border: 1px solid rgba(0, 0, 0, 0.08); 2817 + background: var(--theme-borde);
2818 + text-decoration: none;
2819 + color: var(--theme-texto);
2820 + transition: all 0.15s;
2821 + flex-shrink: 0;
2822 + overflow: hidden;
2747 } 2823 }
2748 2824
2749 - :global(html.dark) .kpi { 2825 + .explore-card:hover {
2750 - border-color: rgba(255, 255, 255, 0.1); 2826 + background: rgba(201, 167, 81, 0.15);
2827 + color: var(--theme-accent);
2751 } 2828 }
2752 2829
2753 - :global(html.dark) .kpi-value, 2830 + .explore-card:hover .explore-watermark {
2754 - :global(html.dark) .kpi-label { 2831 + opacity: 0.18;
2755 - color: rgba(255, 255, 255, 0.7); 2832 + color: var(--theme-accent);
2756 } 2833 }
2757 2834
2758 - .kpi-value { 2835 + .explore-watermark {
2759 - display: block; 2836 + position: absolute;
2760 - font-family: 'Qanelas', var(--font-sans); 2837 + bottom: -4px;
2761 - font-size: 0.9375rem; 2838 + right: -4px;
2839 + width: 44px;
2840 + height: 44px;
2841 + opacity: 0.15;
2842 + pointer-events: none;
2843 + color: var(--theme-accent, #C9A751);
2844 + }
2845 +
2846 + .explore-title {
2847 + position: relative;
2848 + font-size: 0.75rem;
2762 font-weight: 600; 2849 font-weight: 600;
2763 - color: #666666; 2850 + line-height: 1.3;
2764 - margin-bottom: 0.125rem; 2851 + color: var(--theme-titulo);
2765 - transition: opacity 0.2s ease; 2852 + z-index: 1;
2766 - font-variant-numeric: tabular-nums;
2767 - min-height: auto;
2768 - white-space: nowrap;
2769 - overflow: hidden;
2770 - text-overflow: ellipsis;
2771 } 2853 }
2772 2854
2773 - .kpi-label { 2855 + .explore-desc {
2774 - font-size: 0.5625rem; 2856 + position: relative;
2857 + font-size: 0.625rem;
2858 + line-height: 1.35;
2859 + opacity: 0.6;
2860 + margin-top: 3px;
2861 + z-index: 1;
2862 + }
2863 +
2864 + .explore-card:hover .explore-title {
2865 + color: var(--theme-accent);
2866 + }
2867 +
2868 + .explore-card-disabled {
2869 + opacity: 0.3;
2870 + pointer-events: none;
2871 + }
2872 +
2873 + .tools-title {
2874 + display: block;
2875 + font-size: 0.6rem;
2775 font-weight: 600; 2876 font-weight: 600;
2776 - color: #666666;
2777 text-transform: uppercase; 2877 text-transform: uppercase;
2778 - letter-spacing: 0.03em; 2878 + letter-spacing: 0.08em;
2779 - line-height: 1.3; 2879 + color: var(--theme-texto);
2780 - transition: opacity 0.2s ease; 2880 + opacity: 0.5;
2781 - min-height: auto; 2881 + margin-bottom: 0.75rem;
2882 + }
2883 +
2884 + .tools-list {
2782 display: flex; 2885 display: flex;
2783 - align-items: center; 2886 + flex-direction: column;
2784 - justify-content: center; 2887 + gap: 4px;
2785 } 2888 }
2786 2889
2787 - /* Ranking Simple */ 2890 +
2891 +
2788 .ranking-section { 2892 .ranking-section {
2789 background: var(--theme-surface); 2893 background: var(--theme-surface);
2790 - border-radius: 16px; 2894 + border-radius: 12px;
2791 - padding: 1.25rem 1.5rem; 2895 + padding: 0.75rem;
2896 + flex: 1;
2897 + display: flex;
2898 + flex-direction: column;
2899 + min-height: 0;
2792 } 2900 }
2793 2901
2794 .ranking-title { 2902 .ranking-title {
2795 - font-size: 1.375rem; 2903 + font-size: 1.125rem;
2796 font-weight: 600; 2904 font-weight: 600;
2797 color: var(--theme-titulo); 2905 color: var(--theme-titulo);
2798 - margin: 0 0 0.5rem 0; 2906 + margin: 0 0 0.25rem;
2799 - line-height: 1.2;
2800 } 2907 }
2801 2908
2802 .ranking-desc { 2909 .ranking-desc {
2803 - font-size: 0.8125rem; 2910 + font-size: 0.75rem;
2804 - color: #666666; 2911 + color: var(--theme-texto);
2805 - margin: 0 0 1.25rem 0; 2912 + opacity: 0.6;
2913 + margin: 0 0 0.75rem;
2806 } 2914 }
2807 2915
2808 .ranking-cards { 2916 .ranking-cards {
...@@ -2815,16 +2923,17 @@ ...@@ -2815,16 +2923,17 @@
2815 display: flex; 2923 display: flex;
2816 align-items: center; 2924 align-items: center;
2817 gap: 0.75rem; 2925 gap: 0.75rem;
2818 - padding: 0.25rem 0; 2926 + padding: 0.5rem 0.625rem;
2819 - min-width: 0; 2927 + border-radius: 8px;
2928 + background: var(--theme-borde);
2820 } 2929 }
2821 2930
2822 .ranking-position { 2931 .ranking-position {
2823 - font-family: 'Qanelas', var(--font-sans); 2932 + font-family: 'DM Mono', monospace;
2824 - font-size: 1rem; 2933 + font-size: 0.6875rem;
2934 + color: var(--theme-accent);
2825 font-weight: 600; 2935 font-weight: 600;
2826 - color: #555555; 2936 + min-width: 1.5rem;
2827 - flex-shrink: 0;
2828 } 2937 }
2829 2938
2830 .ranking-list { 2939 .ranking-list {
...@@ -2848,37 +2957,15 @@ ...@@ -2848,37 +2957,15 @@
2848 2957
2849 .ranking-name { 2958 .ranking-name {
2850 flex: 1; 2959 flex: 1;
2851 - font-size: 0.8125rem; 2960 + font-size: 0.75rem;
2852 - font-weight: 400; 2961 + color: var(--theme-titulo);
2853 - color: #666666;
2854 - line-height: 1.3;
2855 - white-space: nowrap;
2856 - overflow: hidden;
2857 - text-overflow: ellipsis;
2858 } 2962 }
2859 2963
2860 .ranking-pct { 2964 .ranking-pct {
2861 - font-family: 'Qanelas', var(--font-sans); 2965 + font-family: 'DM Mono', monospace;
2862 - font-size: 1rem; 2966 + font-size: 0.8125rem;
2863 font-weight: 600; 2967 font-weight: 600;
2864 - color: #555555; 2968 + color: var(--theme-titulo);
2865 - flex-shrink: 0;
2866 - min-width: 3.5rem;
2867 - text-align: right;
2868 - }
2869 -
2870 - /* Dark mode - Ranking texts */
2871 - :global(html.dark) .ranking-desc {
2872 - color: rgba(255, 255, 255, 0.6);
2873 - }
2874 -
2875 - :global(html.dark) .ranking-position,
2876 - :global(html.dark) .ranking-pct {
2877 - color: rgba(255, 255, 255, 0.85);
2878 - }
2879 -
2880 - :global(html.dark) .ranking-name {
2881 - color: rgba(255, 255, 255, 0.7);
2882 } 2969 }
2883 2970
2884 .see-all { 2971 .see-all {
...@@ -2905,7 +2992,7 @@ ...@@ -2905,7 +2992,7 @@
2905 gap: 0.75rem; 2992 gap: 0.75rem;
2906 padding: 0.75rem 2rem 1.5rem; 2993 padding: 0.75rem 2rem 1.5rem;
2907 width: 100%; 2994 width: 100%;
2908 - max-width: 1200px; 2995 + max-width: 1400px;
2909 margin: 0 auto; 2996 margin: 0 auto;
2910 min-height: calc(100vh - 60px); 2997 min-height: calc(100vh - 60px);
2911 } 2998 }
...@@ -3402,12 +3489,26 @@ ...@@ -3402,12 +3489,26 @@
3402 .dashboard-layout { 3489 .dashboard-layout {
3403 grid-template-columns: 1fr; 3490 grid-template-columns: 1fr;
3404 padding: 1rem; 3491 padding: 1rem;
3492 + height: auto;
3493 + overflow: visible;
3405 } 3494 }
3406 3495
3407 .dashboard-context { 3496 .dashboard-context {
3408 height: auto; 3497 height: auto;
3409 } 3498 }
3410 3499
3500 + .dashboard-main {
3501 + overflow: visible;
3502 + }
3503 +
3504 + .chart-protagonista {
3505 + flex: none;
3506 + }
3507 +
3508 + .ranking-section {
3509 + flex: none;
3510 + }
3511 +
3411 /* Mobile sidebar toggle */ 3512 /* Mobile sidebar toggle */
3412 .mobile-sidebar-toggle { 3513 .mobile-sidebar-toggle {
3413 display: flex; 3514 display: flex;
......
1 +import { supabase } from '$lib/supabase';
2 +
3 +// Hardcoded a objeto 11700 (Sueldos) para testing de layout
4 +const CODIGO = '11700';
5 +
6 +function getGrupoCode(objeto) { return objeto.charAt(0) + '0000'; }
7 +function getSubgrupoCode(objeto) { return objeto.substring(0, 2) + '000'; }
8 +function getPartidaCode(objeto) { return objeto.substring(0, 3) + '00'; }
9 +
10 +export async function load() {
11 + const { data } = await supabase.schema('ppto').from('clas_objetos').select('*').eq('objeto', CODIGO);
12 + const objeto = data[0];
13 +
14 + const grupoCode = getGrupoCode(CODIGO);
15 + const subgrupoCode = getSubgrupoCode(CODIGO);
16 + const partidaCode = getPartidaCode(CODIGO);
17 +
18 + const { data: padresData } = await supabase.schema('ppto').from('clas_objetos').select('*').in('objeto', [grupoCode, subgrupoCode, partidaCode]).order('objeto');
19 + const padres = padresData || [];
20 +
21 + // Contar entidades
22 + const nEntidades = objeto.n_entidades || 0;
23 +
24 + return { objeto, padres, hijos: [], nEntidades };
25 +}
1 +<script>
2 + import { onMount } from 'svelte';
3 + import { tweened } from 'svelte/motion';
4 + import { cubicOut } from 'svelte/easing';
5 + import { supabase } from '$lib/supabase';
6 + import ObjetoSearch from '$lib/components/objeto/ObjetoSearch.svelte';
7 + import BarChart from '$lib/components/objeto/BarChart.svelte';
8 + import EntitySelector from '$lib/components/objeto/EntitySelector.svelte';
9 + import { initIndex } from '$lib/stores/searchStore';
10 +
11 + let { data } = $props();
12 + let objetoData = $derived(data.objeto);
13 + let padres = $derived(data.padres);
14 + let hijos = $derived(data.hijos);
15 +
16 + let mounted = $state(false);
17 + let loading = $state(true);
18 + let hoveredYear = $state(null);
19 +
20 + // Estado selector entidad
21 + let entidades = $state([]);
22 + let selectedEntity = $state(null);
23 + let nEntidades = $derived(data.nEntidades);
24 +
25 + // Datos
26 + let datosAnuales = $state([]);
27 + let datosHistorico = $state(null);
28 + let topEntidadesPorAño = $state({});
29 +
30 + const POBLACION = 12000000;
31 + const objetoCodigo = '11700';
32 + const objetoNivel = 'subpartida';
33 +
34 + let gastoPerCapita = $derived(
35 + datosAnuales.map(d => ({
36 + año: d.gestion,
37 + monto: selectedEntity ? (d.monto ?? d.devengado) : d.total,
38 + perCapita: d.per_capita ?? (d.devengado ? d.devengado / POBLACION : 0)
39 + }))
40 + );
41 +
42 + let primerAño = $derived(gastoPerCapita.length > 0 ? gastoPerCapita[0].año : 2005);
43 + let ultimoAño = $derived(gastoPerCapita.length > 0 ? gastoPerCapita[gastoPerCapita.length - 1].año : 2025);
44 +
45 + let totalHistorico = $derived(datosHistorico ? (selectedEntity ? datosHistorico.monto : datosHistorico.total) : 0);
46 + let promedioPerCapita = $derived(datosHistorico?.per_capita || 0);
47 + let totalPorcentaje = $derived(datosHistorico?.prop || 0);
48 + let ranking = $derived(datosHistorico?.ranking || 0);
49 + let nPares = $derived(datosHistorico?.n_pares || 0);
50 +
51 + let currentData = $derived(
52 + hoveredYear ? gastoPerCapita.find(g => g.año === hoveredYear) : null
53 + );
54 +
55 + let displayMonto = $derived(currentData ? currentData.monto : totalHistorico);
56 + let displayMontoLabel = $derived(currentData ? `gastados en ${currentData.año}` : `gastados ${primerAño}-${ultimoAño}`);
57 + let displayPerCapita = $derived(currentData ? currentData.perCapita : promedioPerCapita);
58 + let displayPerCapitaLabel = $derived(currentData ? `por cada boliviano en ${currentData.año}` : 'por cada boliviano');
59 + let displayPorcentaje = $derived(currentData ? (totalHistorico > 0 ? totalPorcentaje * (currentData.monto / (totalHistorico / gastoPerCapita.length)) : 0) : totalPorcentaje);
60 + let displayPeriodo = $derived(currentData ? currentData.año : `${primerAño}-${ultimoAño}`);
61 + let displayRanking = $derived(`${currentData ? (datosAnuales.find(d => d.gestion === hoveredYear)?.ranking || ranking) : ranking} de ${selectedEntity ? (datosHistorico?.n_entidades || nEntidades) : nPares}`);
62 + let displayPorcentajeLabel = $derived(currentData ? `del gasto en ${currentData.año}` : 'del gasto total');
63 + let displayRankingLabel = $derived(currentData ? `partidas más gastadas en ${currentData.año}` : 'partidas más gastadas');
64 +
65 + // Top entidades
66 + let topEntidadesTotal = $derived(
67 + datosHistorico && !selectedEntity ? [
68 + { nombre: datosHistorico.top1_entidad, porcentaje: datosHistorico.top1_pct },
69 + { nombre: datosHistorico.top2_entidad, porcentaje: datosHistorico.top2_pct },
70 + { nombre: datosHistorico.top3_entidad, porcentaje: datosHistorico.top3_pct },
71 + ].filter(e => e.nombre) : []
72 + );
73 + let displayTopEntidades = $derived(
74 + hoveredYear && topEntidadesPorAño[hoveredYear] ? topEntidadesPorAño[hoveredYear] : topEntidadesTotal
75 + );
76 +
77 + // Tweens
78 + const tw = { duration: 300, easing: cubicOut };
79 + const twMonto = tweened(0, tw);
80 + const twPerCapita = tweened(0, tw);
81 + const twPorcentaje = tweened(0, tw);
82 + const twTop1 = tweened(0, tw);
83 + const twTop2 = tweened(0, tw);
84 + const twTop3 = tweened(0, tw);
85 +
86 + $effect(() => { twMonto.set(displayMonto); });
87 + $effect(() => { twPerCapita.set(displayPerCapita); });
88 + $effect(() => { twPorcentaje.set(displayPorcentaje); });
89 + $effect(() => { if (displayTopEntidades[0]) twTop1.set(displayTopEntidades[0].porcentaje); });
90 + $effect(() => { if (displayTopEntidades[1]) twTop2.set(displayTopEntidades[1].porcentaje); });
91 + $effect(() => { if (displayTopEntidades[2]) twTop3.set(displayTopEntidades[2].porcentaje); });
92 +
93 + function formatMontoLargo(n) {
94 + if (n >= 1e9) return `${(n / 1e9).toFixed(1)} mil mill.`;
95 + if (n >= 1e6) return `${(n / 1e6).toFixed(1)} mill.`;
96 + if (n >= 1e3) return `${(n / 1e3).toFixed(0)} mil`;
97 + return n.toLocaleString('es-BO');
98 + }
99 +
100 + function formatPerCapita(n) {
101 + return n.toLocaleString('es-BO', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
102 + }
103 +
104 + async function loadData() {
105 + loading = true;
106 + if (selectedEntity) {
107 + const { data } = await supabase.schema('ppto').from('vista_objeto_entidad').select('*')
108 + .eq('objeto', objetoCodigo).eq('nivel', objetoNivel).eq('entidad', selectedEntity.entidad).order('gestion');
109 + if (data) {
110 + datosHistorico = data.find(d => d.gestion === 0) || null;
111 + datosAnuales = data.filter(d => d.gestion > 0);
112 + topEntidadesPorAño = {};
113 + }
114 + } else {
115 + const { data } = await supabase.schema('ppto').from('vista_objeto_estado').select('*')
116 + .eq('objeto', objetoCodigo).order('gestion');
117 + if (data) {
118 + datosHistorico = data.find(d => d.gestion === 0) || null;
119 + datosAnuales = data.filter(d => d.gestion > 0);
120 + topEntidadesPorAño = {};
121 + datosAnuales.forEach(d => {
122 + topEntidadesPorAño[d.gestion] = [
123 + { nombre: d.top1_entidad, porcentaje: d.top1_pct },
124 + { nombre: d.top2_entidad, porcentaje: d.top2_pct },
125 + { nombre: d.top3_entidad, porcentaje: d.top3_pct },
126 + ].filter(e => e.nombre);
127 + });
128 + }
129 + }
130 + loading = false;
131 + }
132 +
133 + async function loadEntidades() {
134 + const { data } = await supabase.schema('ppto').from('entidades_por_objeto').select('entidad, entidad_desc')
135 + .eq('objeto', objetoCodigo).eq('nivel', objetoNivel).order('entidad_desc');
136 + if (data) entidades = data;
137 + }
138 +
139 + onMount(async () => {
140 + mounted = true;
141 + initIndex();
142 + await Promise.all([loadData(), loadEntidades()]);
143 + });
144 +</script>
145 +
146 +<svelte:head>
147 + <link rel="preconnect" href="https://fonts.googleapis.com" />
148 + <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" />
149 +</svelte:head>
150 +
151 +<div class="page" class:mounted>
152 + <nav class="page-nav">
153 + <a href="/clasificadores/objeto-gasto" class="back-link">
154 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
155 + <path d="M19 12H5M12 19l-7-7 7-7"/>
156 + </svg>
157 + Clasificador
158 + </a>
159 + </nav>
160 +
161 + <div class="dashboard-layout">
162 + <!-- Sidebar -->
163 + <aside class="dashboard-context">
164 + <div class="context-card">
165 + <h2 class="context-main-title">Herramientas</h2>
166 +
167 + <!-- Buscar -->
168 + <div class="context-section">
169 + <h3 class="context-subtitle">Buscar</h3>
170 + <p class="context-hint">Buscar otro grupo, subgrupo, partida o subpartida</p>
171 + <ObjetoSearch />
172 + </div>
173 +
174 + <div class="context-separator"></div>
175 +
176 + <!-- Navegación -->
177 + {#if padres.length > 0 || hijos.length > 0}
178 + <div class="context-section">
179 + <h3 class="context-subtitle">Navegación</h3>
180 + <p class="context-hint">Partidas relacionadas. Haz click para explorar.</p>
181 + <div class="tree">
182 + {#each padres as padre, i}
183 + <a href="/objeto/{padre.objeto}" class="tree-node has-tooltip" style="--indent: {i}" data-tooltip="{padre.desc_objeto}">
184 + <span class="tree-dot"></span>
185 + <span class="tree-code">{padre.objeto}</span>
186 + <span class="tree-name">{padre.desc_objeto}</span>
187 + </a>
188 + {/each}
189 +
190 + <div class="tree-node tree-node-current has-tooltip" style="--indent: {padres.length}" data-tooltip="{objetoData.desc_objeto}">
191 + <span class="tree-dot"></span>
192 + <span class="tree-code">{objetoData.objeto}</span>
193 + <span class="tree-name">{objetoData.desc_objeto}</span>
194 + </div>
195 +
196 + {#if hijos.length > 0}
197 + <div class="tree-children" style="--indent: {padres.length + 1}">
198 + {#each hijos as hijo}
199 + <a href="/objeto/{hijo.objeto}" class="tree-node tree-node-child has-tooltip" data-tooltip="{hijo.desc_objeto}">
200 + <span class="tree-dot"></span>
201 + <span class="tree-code">{hijo.objeto}</span>
202 + <span class="tree-name">{hijo.desc_objeto}</span>
203 + </a>
204 + {/each}
205 + </div>
206 + {/if}
207 + </div>
208 + </div>
209 +
210 + <div class="context-separator"></div>
211 + {/if}
212 +
213 + <!-- Explorar -->
214 + <div class="context-section">
215 + <h3 class="context-subtitle">Explorar visualizaciones</h3>
216 + <div class="explore-scroll">
217 + <a href="/objeto/{objetoData.objeto}?vista=comparar" class="explore-card">
218 + <svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
219 + <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"/>
220 + </svg>
221 + <span class="explore-title">Comparar dos entidades</span>
222 + <span class="explore-desc">¿Quién gasta más en sueldos, el Min. de Salud o Defensa?</span>
223 + </a>
224 + <a href="/clasificadores/objeto-gasto" class="explore-card">
225 + <svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
226 + <path d="M3 20h18M5 17h2v3H5zM9 14h2v6H9zM13 11h2v9h-2zM17 8h2v12h-2z"/>
227 + </svg>
228 + <span class="explore-title">Ranking</span>
229 + <span class="explore-desc">{nEntidades} entidades que gastan en esta partida</span>
230 + </a>
231 + <a href="/ubicacion" class="explore-card explore-card-disabled">
232 + <svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
233 + <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"/>
234 + </svg>
235 + <span class="explore-title">Mapa</span>
236 + <span class="explore-desc">Dónde se concentra el gasto en esta partida</span>
237 + </a>
238 + <a href="/clasificadores/objeto-gasto?modo=mapa" class="explore-card">
239 + <svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
240 + <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"/>
241 + </svg>
242 + <span class="explore-title">Peso relativo</span>
243 + <span class="explore-desc">Cuánto pesan los sueldos respecto a otros gastos</span>
244 + </a>
245 + <a href="/clasificadores/objeto-gasto" class="explore-card">
246 + <svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
247 + <path d="M4 6h16M8 10h12M12 14h8M8 18h12"/>
248 + </svg>
249 + <span class="explore-title">Clasificador</span>
250 + <span class="explore-desc">Ver todas las partidas de gasto y navegar entre ellas</span>
251 + </a>
252 + </div>
253 + </div>
254 +
255 + <div class="context-separator"></div>
256 +
257 + <!-- Comparabilidad -->
258 + <div class="context-section">
259 + <h3 class="context-subtitle">Comparabilidad</h3>
260 + <div class="vigencia-compact">
261 + <div class="vigencia-status-icon vigencia-full">
262 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
263 + <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
264 + <polyline points="22 4 12 14.01 9 11.01"/>
265 + </svg>
266 + </div>
267 + <div class="vigencia-text">
268 + <span class="vigencia-main">Comparable en todo el período</span>
269 + <span class="vigencia-sub">2005 – 2025</span>
270 + </div>
271 + </div>
272 + </div>
273 +
274 + <div class="context-separator"></div>
275 +
276 + <!-- Definición -->
277 + <div class="context-section">
278 + <h3 class="context-subtitle">Definición</h3>
279 + <button class="def-link">
280 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
281 + <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"/>
282 + </svg>
283 + Ver variaciones en la definición
284 + </button>
285 + </div>
286 + </div>
287 + </aside>
288 +
289 + <!-- Main -->
290 + <main class="dashboard-main">
291 + <div class="chart-protagonista">
292 + <!-- Header -->
293 + <div class="chart-top-row">
294 + <div class="main-title-pills">
295 + <span class="pill pill-nivel">Subpartida</span>
296 + <span class="pill pill-codigo">{objetoData.objeto}</span>
297 + </div>
298 + </div>
299 +
300 + <h2 class="main-title-name">{objetoData.desc_objeto}</h2>
301 +
302 + <div class="chart-controls">
303 + <EntitySelector {entidades} bind:selectedEntity {nEntidades} />
304 + </div>
305 +
306 + <div class="chart-header">
307 + <span class="chart-title">Bs/habitante <span class="chart-period">· {displayPeriodo}</span></span>
308 + </div>
309 +
310 + {#if loading}
311 + <div class="chart-loading" style="flex: 1; min-height: 100px;">
312 + <span>Cargando...</span>
313 + </div>
314 + {:else}
315 + <div class="chart-flex-wrapper">
316 + <BarChart data={gastoPerCapita} bind:hoveredYear height={200} fill={true} />
317 + </div>
318 + {/if}
319 +
320 + <!-- KPIs -->
321 + <div class="kpis">
322 + <div class="kpi">
323 + <span class="kpi-value">{formatMontoLargo(Math.round($twMonto))}</span>
324 + <span class="kpi-label">{displayMontoLabel}</span>
325 + </div>
326 + <div class="kpi">
327 + <span class="kpi-value">{formatPerCapita($twPerCapita)} Bs.</span>
328 + <span class="kpi-label">{displayPerCapitaLabel}</span>
329 + </div>
330 + <div class="kpi">
331 + <span class="kpi-value">{$twPorcentaje.toLocaleString('es-BO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</span>
332 + <span class="kpi-label">{displayPorcentajeLabel}</span>
333 + </div>
334 + <div class="kpi">
335 + <span class="kpi-value">{displayRanking}</span>
336 + <span class="kpi-label">{displayRankingLabel}</span>
337 + </div>
338 + </div>
339 + </div>
340 +
341 + {#if !selectedEntity && displayTopEntidades.length > 0}
342 + <div class="ranking-section">
343 + <h3 class="ranking-title">Concentración del gasto</h3>
344 + <p class="ranking-desc">Porcentaje del total gastado en {objetoData.desc_objeto?.toLowerCase()} por todo el Estado {hoveredYear ? `en ${hoveredYear}` : `(${primerAño}-${ultimoAño})`}</p>
345 + <div class="ranking-cards">
346 + {#each displayTopEntidades as ent, i}
347 + {@const pct = i === 0 ? $twTop1 : i === 1 ? $twTop2 : $twTop3}
348 + <div class="ranking-card">
349 + <span class="ranking-position">#{i + 1}</span>
350 + <span class="ranking-name">{ent.nombre}</span>
351 + <span class="ranking-pct">{pct.toFixed(1)}%</span>
352 + </div>
353 + {/each}
354 + </div>
355 + </div>
356 + {/if}
357 + </main>
358 + </div>
359 +</div>
360 +
361 +<style>
362 + /* ═══════════════════════════════════════════════════════════════
363 + PAGE
364 + ═══════════════════════════════════════════════════════════════ */
365 + .page {
366 + min-height: 100vh;
367 + width: 100%;
368 + max-width: 100vw;
369 + overflow-x: hidden;
370 + background: var(--theme-body);
371 + color: var(--theme-titulo);
372 + font-family: var(--font-sans), -apple-system, sans-serif;
373 + opacity: 0;
374 + transition: opacity 0.4s ease;
375 + }
376 + .page.mounted { opacity: 1; }
377 +
378 + :global(html:not(.dark)) .page {
379 + background: #f5f5f7;
380 + }
381 +
382 + /* ═══════════════════════════════════════════════════════════════
383 + NAV
384 + ═══════════════════════════════════════════════════════════════ */
385 + .page-nav {
386 + padding: 2rem 2rem 0.5rem;
387 + max-width: 1400px;
388 + margin: 0 auto;
389 + }
390 +
391 + .back-link {
392 + display: inline-flex;
393 + align-items: center;
394 + gap: 0.375rem;
395 + font-family: 'DM Mono', monospace;
396 + font-size: 0.6875rem;
397 + color: var(--theme-texto);
398 + text-decoration: none;
399 + transition: color 0.2s;
400 + }
401 + .back-link:hover { color: var(--theme-accent); }
402 +
403 + /* ═══════════════════════════════════════════════════════════════
404 + LAYOUT
405 + ═══════════════════════════════════════════════════════════════ */
406 + .dashboard-layout {
407 + display: grid;
408 + grid-template-columns: minmax(200px, 30%) 1fr;
409 + gap: 1.5rem;
410 + max-width: 1400px;
411 + margin: 0 auto;
412 + padding: 1.5rem 2rem 2rem;
413 + height: calc(100vh - 4rem);
414 + overflow: hidden;
415 + }
416 +
417 + /* ═══════════════════════════════════════════════════════════════
418 + SIDEBAR
419 + ═══════════════════════════════════════════════════════════════ */
420 + .dashboard-context {
421 + display: flex;
422 + flex-direction: column;
423 + min-height: 0;
424 + }
425 +
426 + .context-card {
427 + background: var(--theme-surface);
428 + border-radius: 12px;
429 + padding: 0.75rem;
430 + display: flex;
431 + flex-direction: column;
432 + gap: 8px;
433 + flex: 1;
434 + min-height: 0;
435 + overflow-y: auto;
436 + scrollbar-width: thin;
437 + }
438 +
439 + .context-card::-webkit-scrollbar { width: 4px; }
440 + .context-card::-webkit-scrollbar-thumb {
441 + background: rgba(128, 128, 128, 0.3);
442 + border-radius: 2px;
443 + }
444 +
445 + .context-main-title {
446 + font-size: 1.375rem;
447 + font-weight: 600;
448 + color: var(--theme-titulo);
449 + margin: 0;
450 + line-height: 1.2;
451 + }
452 +
453 + .context-subtitle {
454 + font-family: 'Qanelas', var(--font-sans);
455 + font-size: 0.875rem;
456 + font-weight: 600;
457 + color: var(--theme-titulo);
458 + margin: 0;
459 + }
460 +
461 + .context-hint {
462 + font-size: 0.75rem;
463 + color: #666666;
464 + margin: 0;
465 + line-height: 1.4;
466 + }
467 +
468 + .context-section {
469 + display: flex;
470 + flex-direction: column;
471 + gap: 0.5rem;
472 + }
473 +
474 + .context-separator {
475 + border-top: 1px solid var(--theme-borde);
476 + margin: 0.25rem 0;
477 + }
478 +
479 + /* ═══════════════════════════════════════════════════════════════
480 + TREE
481 + ═══════════════════════════════════════════════════════════════ */
482 + .tree {
483 + font-size: 0.75rem;
484 + line-height: 1.6;
485 + }
486 +
487 + .tree-node {
488 + display: flex;
489 + align-items: center;
490 + gap: 0.375rem;
491 + padding: 0.375rem 0.5rem;
492 + margin-left: calc(var(--indent, 0) * 1rem);
493 + border-left: 2px solid transparent;
494 + border-radius: 0 6px 6px 0;
495 + text-decoration: none;
496 + color: var(--theme-texto);
497 + transition: all 0.15s;
498 + }
499 + .tree-node:hover { color: var(--theme-accent); }
500 +
501 + .tree-node-current {
502 + color: var(--theme-titulo);
503 + font-weight: 600;
504 + border-left-color: var(--theme-accent);
505 + }
506 +
507 + .tree-children {
508 + margin-left: calc(var(--indent, 0) * 1rem);
509 + }
510 +
511 + .tree-node-child {
512 + padding-left: 0.75rem;
513 + }
514 +
515 + .tree-dot {
516 + width: 4px;
517 + height: 4px;
518 + border-radius: 50%;
519 + background: currentColor;
520 + flex-shrink: 0;
521 + opacity: 0.4;
522 + }
523 +
524 + .tree-code {
525 + font-family: 'DM Mono', monospace;
526 + font-size: 0.6875rem;
527 + min-width: 2.5rem;
528 + opacity: 0.6;
529 + }
530 +
531 + .tree-name {
532 + font-size: 0.75rem;
533 + white-space: nowrap;
534 + overflow: hidden;
535 + text-overflow: ellipsis;
536 + max-width: 140px;
537 + }
538 +
539 + /* ═══════════════════════════════════════════════════════════════
540 + EXPLORE
541 + ═══════════════════════════════════════════════════════════════ */
542 + .explore-scroll {
543 + display: flex;
544 + gap: 8px;
545 + overflow-x: auto;
546 + scrollbar-width: none;
547 + padding-bottom: 2px;
548 + }
549 + .explore-scroll::-webkit-scrollbar { display: none; }
550 +
551 + .explore-card {
552 + position: relative;
553 + display: flex;
554 + flex-direction: column;
555 + justify-content: flex-start;
556 + padding: 0.375rem 0.5rem;
557 + width: 120px;
558 + height: 95px;
559 + border-radius: 8px;
560 + background: var(--theme-borde);
561 + text-decoration: none;
562 + color: var(--theme-texto);
563 + transition: all 0.15s;
564 + flex-shrink: 0;
565 + overflow: hidden;
566 + }
567 + .explore-card:hover {
568 + background: rgba(201, 167, 81, 0.15);
569 + color: var(--theme-accent);
570 + }
571 + .explore-card:hover .explore-watermark {
572 + opacity: 0.18;
573 + color: var(--theme-accent);
574 + }
575 + .explore-card:hover .explore-title {
576 + color: var(--theme-accent);
577 + }
578 +
579 + .explore-watermark {
580 + position: absolute;
581 + bottom: -4px;
582 + right: -4px;
583 + width: 44px;
584 + height: 44px;
585 + opacity: 0.15;
586 + pointer-events: none;
587 + color: var(--theme-accent, #C9A751);
588 + }
589 +
590 + .explore-title {
591 + position: relative;
592 + font-size: 0.75rem;
593 + font-weight: 600;
594 + line-height: 1.3;
595 + color: var(--theme-titulo);
596 + z-index: 1;
597 + }
598 +
599 + .explore-desc {
600 + position: relative;
601 + font-size: 0.625rem;
602 + line-height: 1.35;
603 + opacity: 0.6;
604 + margin-top: 3px;
605 + z-index: 1;
606 + }
607 +
608 + .explore-card-disabled {
609 + opacity: 0.3;
610 + pointer-events: none;
611 + }
612 +
613 + /* ═══════════════════════════════════════════════════════════════
614 + VIGENCIA
615 + ═══════════════════════════════════════════════════════════════ */
616 + .vigencia-compact {
617 + display: flex;
618 + align-items: center;
619 + gap: 0.75rem;
620 + }
621 +
622 + .vigencia-status-icon {
623 + width: 32px;
624 + height: 32px;
625 + border-radius: 8px;
626 + display: flex;
627 + align-items: center;
628 + justify-content: center;
629 + flex-shrink: 0;
630 + }
631 +
632 + .vigencia-status-icon.vigencia-full {
633 + background: rgba(74, 158, 107, 0.15);
634 + color: #4A9E6B;
635 + }
636 +
637 + .vigencia-text {
638 + display: flex;
639 + flex-direction: column;
640 + gap: 0.125rem;
641 + }
642 +
643 + .vigencia-main {
644 + font-size: 0.875rem;
645 + font-weight: 500;
646 + color: var(--theme-titulo);
647 + }
648 +
649 + .vigencia-sub {
650 + font-size: 0.6875rem;
651 + color: var(--theme-texto);
652 + opacity: 0.7;
653 + }
654 +
655 + /* ═══════════════════════════════════════════════════════════════
656 + DEFINICIÓN
657 + ═══════════════════════════════════════════════════════════════ */
658 + .def-link {
659 + display: flex;
660 + align-items: center;
661 + gap: 0.375rem;
662 + font-size: 0.75rem;
663 + color: var(--theme-accent, #C9A751);
664 + background: none;
665 + border: none;
666 + padding: 0;
667 + cursor: pointer;
668 + transition: opacity 0.15s;
669 + }
670 + .def-link:hover { opacity: 0.7; }
671 + .def-link svg { flex-shrink: 0; opacity: 0.8; }
672 +
673 + /* ═══════════════════════════════════════════════════════════════
674 + MAIN
675 + ═══════════════════════════════════════════════════════════════ */
676 + .dashboard-main {
677 + display: flex;
678 + flex-direction: column;
679 + gap: 1.5rem;
680 + min-height: 0;
681 + overflow: hidden;
682 + }
683 +
684 + .chart-protagonista {
685 + background: var(--theme-surface);
686 + border-radius: 12px;
687 + padding: 0.75rem;
688 + display: flex;
689 + flex-direction: column;
690 + min-height: 0;
691 + flex: 2;
692 + overflow: hidden;
693 + }
694 +
695 + .ranking-section {
696 + background: var(--theme-surface);
697 + border-radius: 12px;
698 + padding: 0.75rem;
699 + flex: 1;
700 + display: flex;
701 + flex-direction: column;
702 + min-height: 0;
703 + }
704 +
705 + /* ═══════════════════════════════════════════════════════════════
706 + CHART & KPIs
707 + ═══════════════════════════════════════════════════════════════ */
708 + .chart-top-row {
709 + display: flex;
710 + justify-content: space-between;
711 + align-items: center;
712 + margin-bottom: 0.375rem;
713 + }
714 +
715 + .main-title-pills {
716 + display: flex;
717 + gap: 0.375rem;
718 + }
719 +
720 + .pill {
721 + font-family: 'DM Mono', monospace;
722 + font-size: 0.625rem;
723 + padding: 0.125rem 0.5rem;
724 + border-radius: 4px;
725 + letter-spacing: 0.03em;
726 + }
727 +
728 + .pill-nivel {
729 + background: rgba(201, 167, 81, 0.15);
730 + color: var(--theme-accent);
731 + }
732 +
733 + .pill-codigo {
734 + background: var(--theme-borde);
735 + color: var(--theme-texto);
736 + }
737 +
738 + .main-title-name {
739 + font-family: 'DM Serif Display', serif;
740 + font-size: 1.75rem;
741 + font-weight: 400;
742 + margin: 0 0 0.5rem;
743 + line-height: 1.2;
744 + color: var(--theme-titulo);
745 + }
746 +
747 + .chart-controls {
748 + margin-bottom: 0.5rem;
749 + }
750 +
751 + .chart-header {
752 + margin-bottom: 0.25rem;
753 + }
754 +
755 + .chart-title {
756 + font-family: 'DM Mono', monospace;
757 + font-size: 0.6875rem;
758 + color: var(--theme-texto);
759 + opacity: 0.7;
760 + }
761 +
762 + .chart-period {
763 + opacity: 0.5;
764 + }
765 +
766 + .chart-loading {
767 + display: flex;
768 + align-items: center;
769 + justify-content: center;
770 + color: var(--theme-texto);
771 + opacity: 0.5;
772 + }
773 +
774 + .chart-flex-wrapper {
775 + flex: 1;
776 + min-height: 0;
777 + }
778 +
779 + .kpis {
780 + display: grid;
781 + grid-template-columns: repeat(4, 1fr);
782 + gap: 0.5rem;
783 + margin-top: 0.5rem;
784 + padding-top: 0.75rem;
785 + border-top: 1px solid var(--theme-borde);
786 + }
787 +
788 + .kpi {
789 + display: flex;
790 + flex-direction: column;
791 + gap: 0.125rem;
792 + }
793 +
794 + .kpi-value {
795 + font-family: 'DM Mono', monospace;
796 + font-size: 0.9375rem;
797 + font-weight: 600;
798 + color: var(--theme-titulo);
799 + }
800 +
801 + .kpi-label {
802 + font-size: 0.5625rem;
803 + color: var(--theme-texto);
804 + opacity: 0.6;
805 + line-height: 1.3;
806 + }
807 +
808 + /* ═══════════════════════════════════════════════════════════════
809 + RANKING
810 + ═══════════════════════════════════════════════════════════════ */
811 + .ranking-title {
812 + font-size: 1.125rem;
813 + font-weight: 600;
814 + color: var(--theme-titulo);
815 + margin: 0 0 0.25rem;
816 + }
817 +
818 + .ranking-desc {
819 + font-size: 0.75rem;
820 + color: var(--theme-texto);
821 + opacity: 0.6;
822 + margin: 0 0 0.75rem;
823 + }
824 +
825 + .ranking-cards {
826 + display: flex;
827 + flex-direction: column;
828 + gap: 0.25rem;
829 + }
830 +
831 + .ranking-card {
832 + display: flex;
833 + align-items: center;
834 + gap: 0.75rem;
835 + padding: 0.5rem 0.625rem;
836 + border-radius: 8px;
837 + background: var(--theme-borde);
838 + }
839 +
840 + .ranking-position {
841 + font-family: 'DM Mono', monospace;
842 + font-size: 0.6875rem;
843 + color: var(--theme-accent);
844 + font-weight: 600;
845 + min-width: 1.5rem;
846 + }
847 +
848 + .ranking-name {
849 + flex: 1;
850 + font-size: 0.75rem;
851 + color: var(--theme-titulo);
852 + }
853 +
854 + .ranking-pct {
855 + font-family: 'DM Mono', monospace;
856 + font-size: 0.8125rem;
857 + font-weight: 600;
858 + color: var(--theme-titulo);
859 + }
860 +
861 + /* ═══════════════════════════════════════════════════════════════
862 + DARK MODE
863 + ═══════════════════════════════════════════════════════════════ */
864 + :global(html.dark) .context-hint {
865 + color: rgba(255, 255, 255, 0.4);
866 + }
867 +
868 + :global(html.dark) .def-link {
869 + color: var(--theme-accent, #C9A751);
870 + }
871 +</style>
...@@ -18,7 +18,7 @@ export async function load({ fetch }) { ...@@ -18,7 +18,7 @@ export async function load({ fetch }) {
18 18
19 const poblacionMap = {}; 19 const poblacionMap = {};
20 pobRes.split('\n').slice(1).forEach(line => { 20 pobRes.split('\n').slice(1).forEach(line => {
21 - const [cod, gestion, pob] = line.split(','); 21 + const [cod, , gestion, pob] = line.split(',');
22 if (parseInt(gestion) === 2025) { 22 if (parseInt(gestion) === 2025) {
23 poblacionMap[cod] = parseInt(pob); 23 poblacionMap[cod] = parseInt(pob);
24 } 24 }
......
...@@ -223,7 +223,7 @@ ...@@ -223,7 +223,7 @@
223 } 223 }
224 const map = {}; 224 const map = {};
225 pobCSV.split('\n').slice(1).forEach(line => { 225 pobCSV.split('\n').slice(1).forEach(line => {
226 - const [cod, g, pob] = line.split(','); 226 + const [cod, , g, pob] = line.split(',');
227 if (parseInt(g) === gestion) { 227 if (parseInt(g) === gestion) {
228 map[cod] = parseInt(pob); 228 map[cod] = parseInt(pob);
229 } 229 }
......
...@@ -19,10 +19,10 @@ export async function load({ params, fetch }) { ...@@ -19,10 +19,10 @@ export async function load({ params, fetch }) {
19 throw error(404, 'Ubicación no encontrada'); 19 throw error(404, 'Ubicación no encontrada');
20 } 20 }
21 21
22 - // Parsear población para este código 22 + // Parsear población para este código (codigo_ine)
23 const poblacionMap = {}; 23 const poblacionMap = {};
24 pobRes.split('\n').slice(1).forEach(line => { 24 pobRes.split('\n').slice(1).forEach(line => {
25 - const [cod, gestion, pob] = line.split(','); 25 + const [cod, , gestion, pob] = line.split(',');
26 if (cod === codigo) { 26 if (cod === codigo) {
27 poblacionMap[parseInt(gestion)] = parseInt(pob); 27 poblacionMap[parseInt(gestion)] = parseInt(pob);
28 } 28 }
......
...@@ -246,7 +246,7 @@ ...@@ -246,7 +246,7 @@
246 let tieneIngresos = $derived(historiaIngresos.length > 0); 246 let tieneIngresos = $derived(historiaIngresos.length > 0);
247 247
248 let gestiones = $derived(() => { 248 let gestiones = $derived(() => {
249 - const years = [...new Set(resumenData.map(d => d.gestion))].filter(g => poblacionMap[g]).sort(); 249 + const years = [...new Set(resumenData.map(d => d.gestion))].filter(g => poblacionMap[g]).sort((a, b) => b - a);
250 return years; 250 return years;
251 }); 251 });
252 252
......
This diff could not be displayed because it is too large.